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
MervinPraison 847890dd18 Release v4.6.79 2026-06-25 11:19:27 +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
MervinPraison 9aff899360 Merge branch 'main' of https://github.com/MervinPraison/PraisonAI 2026-06-25 09:29:31 +01:00
MervinPraison 661b6ae105 chore: update version numbers and add new dependencies
- Bump praisonai-platform version to 0.1.9 in pyproject.toml.
- Update praisonai TypeScript version to 1.7.3 in package.json.
- Add email-validator and dnspython packages to uv.lock with their respective versions and dependencies.
2026-06-25 09:29:26 +01:00
MervinPraison ad68e2e1d5 docs: auto-update feature parity trackers [skip ci] 2026-06-25 08:21:56 +00:00
MervinPraison 393de39408 Release v4.6.78 2026-06-25 09:21:27 +01: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
MervinPraison f47a4f97ec refactor: optimize sandbox environment handling and improve isolation
- Set clean_env=True in sandlock to prevent host environment leakage into the sandbox.
- Adjusted environment handling to ensure explicit empty dicts are honored.
- Added regression tests to verify the new clean environment behavior.

This change enhances security by isolating the sandboxed process from the host's environment, aligning with best practices for sandbox execution.
2026-06-25 08:43:11 +01:00
MervinPraisonandCursor 66dcfb632c fix: poll PyPI version-specific JSON when waiting for publish
Project info.version lags after upload (4.6.77 uploaded but latest
still showed 4.6.75). Check /pypi/{pkg}/{version}/json instead.

Co-authored-by: Cursor <[email protected]>
2026-06-25 07:40:36 +01:00
MervinPraisonandCursor 69dd1e0183 fix: wait for praisonai PyPI propagation before verify step
Upload succeeded but immediate verify read stale latest (4.6.75).
Poll PyPI for wrapper version like agents step already does.

Co-authored-by: Cursor <[email protected]>
2026-06-25 07:40:05 +01:00
MervinPraison 06953f7a2a Release v4.6.77 2026-06-25 06:39:24 +00:00
MervinPraison 8ad0c5ce59 Bump praisonaiagents to 1.6.77 2026-06-25 06:39:10 +00:00
MervinPraisonandCursor 8ca929b56c fix: pass agent version env vars to PyPI publish step in CI
Bash does not expand {CURRENT} in heredocs; Python f-strings then raised
NameError. Use quoted heredoc with CURRENT/NEW_VERSION from env instead.

Co-authored-by: Cursor <[email protected]>
2026-06-25 07:37:24 +01:00
0816d39042 fix: prevent praisonai doctor UnicodeEncodeError on Windows cp1252 (#2271)
* fix: prevent praisonai doctor UnicodeEncodeError on Windows cp1252 (fixes #2270)

The doctor TextFormatter already used ASCII fallbacks for status symbols,
but the header/summary divider ("━" * 70) had no fallback and the report
was written directly to a strict cp1252 sys.stdout, crashing with
'charmap' codec can't encode characters on default Windows consoles.

- Add ASCII fallback ("-") for the divider when the console can't encode
  Unicode, and include the divider char in the encoding-capability probe.
- Add a defensive _safe_write() on the base formatter (used by both text
  and JSON formatters) that retries with an encoding-safe representation
  on UnicodeEncodeError, so doctor output never crashes.
- Add unit tests for divider fallback, encoding detection and safe write;
  make the pre-existing encoding tests robust by controlling
  _unicode_supported instead of patching readonly sys.stdout.encoding.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address reviewer feedback on doctor encoding fix

- Remove redundant JsonFormatter.write() override (now inherits BaseFormatter.write)
- Open doctor report output file with explicit UTF-8 encoding so Unicode
  dividers/symbols survive on Windows default-encoding filesystems
- Move JSON cp1252 test into TestJsonFormatter and rewrite it to verify
  ASCII-safe JSON output instead of a fallback path that never triggers
- Add a direct _safe_write fallback test using raw unencodable Unicode

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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-25 07:34:54 +01:00
praisonai-triage-agent[bot]andMervin Praison 8cda6a7702 fix: per-command authorization on Slack/Discord for privileged commands (#2269)
* fix: per-command authorization on Slack/Discord for privileged commands (fixes #2268)

Lift the Telegram-only CommandAccessPolicy to a shared bots-level gate so
privileged, state-changing commands like /learn can be restricted per-command
on every channel. Slack and Discord now build the policy from config and call
can_run(user_id, command) before dispatching any command. /learn is tagged
privileged and defaults to admin-only whenever a policy is configured, while
remaining permissive when no policy is set (no breaking change).

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

* fix: address reviewer feedback on per-command bot authorization

- Make PRIVILEGED_COMMANDS/ALWAYS_ALLOWED frozenset (RUF012)
- Treat explicitly empty user_allowed_commands ("") as a configured allow-list
- Lowercase Slack extracted command name so /LEARN is gated consistently
- Gate registered Slack @app.command slash handlers with can_run
- Migrate Telegram to shared build_command_access_policy (remove duplicate parsing)
- Add tests for admin+allow-list combo and empty allow-list

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 01:19:55 +01:00
MervinPraison 6c322a5340 Release v4.6.76 2026-06-25 00:51:16 +01:00
MervinPraison 939421b06d docs: auto-update api.md [skip ci] 2026-06-24 23:50:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 6151342b4b feat: one-command learn-a-skill-from-sources (#2267)
* feat: add one-command learn-a-skill-from-sources (fixes #2266)

Add build_learn_prompt() (core skills subsystem) and Agent.learn()
(execution mixin) to distil one grounded SKILL.md from a codebase, docs,
PDFs, configs or the current chat via existing tools (skill_manage,
file/PDF read). Register a /learn slash command in the wrapper.

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

* fix: wire /learn dispatch, fix format-string crash, rename to learn_skill

- learn.py: use str.replace instead of .format so curly braces in user
  requests no longer raise KeyError (P1, Greptile)
- execution_mixin: rename Agent.learn -> learn_skill (avoid clash with
  memory learn= param) keeping learn/alearn as backward-compat aliases;
  add async alearn_skill parity; robust tool dedup for dict/JSON specs
- bots: dispatch /learn in telegram, slack, discord (Critical, CodeRabbit/Greptile)
- cli: add `praisonai skills learn` surface under existing skills group

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 00:49:43 +01:00
Wu Shuwen 5eee2c609a docs: add ARCHITECTURE.md — reliability, orchestration, and observability roadmap (#2249) 2026-06-25 00:09:40 +01:00
MervinPraison 4cb05c0cde chore: move repo tests/ into praisonai unit tests
Relocate test_wrapper_layer_regression.py to src/praisonai/tests/unit/,
fix praisonai import paths, and update stale assertions for current wrapper layout.
2026-06-25 00:07:30 +01:00
MervinPraisonandCursor 42f43b1cc0 feat: add manual PyPI Release GitHub Actions workflow
Add workflow_dispatch pypi-release.yml for patch/minor/major releases of
praisonaiagents and praisonai with environment approval, uv publish, and
bump_and_release integration. Disable legacy python-publish.yml release
trigger to avoid duplicate wrapper publishes. Dedupe merge-conflict Claude
triggers on main push bursts.

Co-authored-by: Cursor <[email protected]>
2026-06-25 00:03:35 +01:00
MervinPraison 9c495e168e Release v4.6.75 2026-06-24 21:56:57 +01:00
55dc751cc6 fix(sandbox): correct sandlock integration semantics and fail loud (#1367)
* fix(sandbox): correct sandlock integration semantics and fail loud

The SandlockSandbox wrapper had several latent correctness issues that
could cause it to silently run with weaker isolation than intended, or
drop resource limits on the floor.  Written against the sandlock 0.8.1
API, which replaces the standalone Policy object with direct
Sandbox(**kwargs) configuration and collapses the network knobs into a
single net_allow endpoint allowlist.

Fixes:

* Configuration uses the current sandlock API.  sandlock 0.8.1 dropped
  the Policy object; config is passed straight to Sandbox(**kwargs).
  _build_sandbox_kwargs() assembles that dict.

* Network policy intent is now explicit via net_allow.  An empty
  allowlist denies all outbound (the default); network-enabled=True
  opens all TCP plus UDP DNS (["*:*", "udp://*:53"]) so the sandboxed
  process can actually resolve hostnames and connect.

* stdout/stderr are now str, not bytes.  sandlock returns bytes from
  Sandbox.run(); PraisonAI's SandboxResult is typed as str.  Added a
  _decode() helper with errors="replace" so downstream consumers never
  see binary artefacts or crash on .lower() / .split().

* max_cpu is now actually passed to the sandbox.  Previously
  limits.cpu_percent was silently ignored.

* env variables are injected via the Sandbox env field (set/overridden
  in the child), which the current API supports natively.

* execute_file() passes the script by path, not via `python3 -c <code>`.
  Large scripts no longer hit ARG_MAX, and the script's parent
  directory is added to the Landlock read allowlist via the new
  extra_readable parameter on _build_sandbox_kwargs.

* Timeout detection is authoritative: we inspect result.error for
  "timed out" rather than heuristically comparing wall-clock duration
  against limits.timeout_seconds.  (Refined in the following commit to
  use sandlock's exit_code == -1 sentinel.)

* Sandbox handles are now managed via `with ... as sb:` so cleanup
  runs on exception.

* fs_readable is filtered to paths that actually exist.  Landlock
  fails at spawn time if any allowlisted path is missing, so the
  hardcoded list (which included /usr/local/lib/python3) caused
  sandbox creation failures on most hosts.  Now we filter with
  os.path.isdir before constructing the sandbox.

Breaking change — silent fallback removed:

  SandlockSandbox used to fall back to SubprocessSandbox whenever
  Landlock support was too low, logging only a warning.  This violates
  the caller's explicit choice of kernel-level isolation: a
  SandlockSandbox that isn't actually using Landlock is a security
  footgun, and a warning in the logs is not a consent mechanism.

  __init__ now raises RuntimeError if Landlock support is missing —
  i.e. landlock_abi_version() < min_landlock_abi() (currently ABI 6,
  Linux 6.7+).  Callers who want graceful degradation should catch
  ImportError / RuntimeError and construct SubprocessSandbox
  explicitly, e.g.:

      try:
          sb = SandlockSandbox(cfg)
      except (ImportError, RuntimeError):
          sb = SubprocessSandbox(cfg)

  The equivalent fallback branches in execute(), run_command(), and
  execute_file() are removed.

Tests updated:

  - test_raises_when_landlock_unavailable replaces the two fallback
    tests and asserts RuntimeError is raised at construction time.
  - test_sandbox_kwargs_with_minimal_limits / _network_enabled check
    max_cpu and the net_allow deny-all / allow-TCP+DNS semantics.
  - test_sandlock_execution_timeout mocks result.error to assert the
    timeout path.
  - test_sandlock_execution_failure sets result.error=None explicitly.

All unit tests pass, including the real-sandlock integration test.

* fix(sandbox): use sandlock's exit_code == -1 sentinel for timeout

Sandbox.run() does not populate result.error on timeout — only the
Pipeline path does.  String-matching on result.error was therefore
unreliable for the common single-sandbox case: a real timeout from
Sandbox.run() returns success=False, exit_code=-1, empty stderr, and
error=None, which my previous logic mis-classified as FAILED.

Switch to the structural signal: sandlock's ExitStatus::Timeout is
exposed as exit_code == -1 (see sandlock's _sdk.py around line 1475).
This matches how sandlock itself detects pipeline timeouts and works
uniformly across Sandbox.run() and any future execution paths.

Verified end-to-end with a real forced timeout against real sandlock:
  status: SandboxStatus.TIMEOUT
  exit: -1
  error: Execution timed out after 1s

Test updated to match: mock_timeout_result.exit_code = -1 and
error = None (reflecting actual Sandbox.run() behavior).

* fix(sandbox): address review feedback on execute_file and ABI message

Code-review fixes from PR #1367:

* execute_file no longer allowlists the script's entire parent
  directory for Landlock read.  Only the script file itself is granted
  read access, so sibling files on the host are never exposed (gemini
  flagged this as high severity).  Verified end-to-end against real
  sandlock: a script that tries to read a sibling file is denied.

* _build_sandbox_kwargs filters extra_readable with os.path.exists
  instead of os.path.isdir, so individual files (not just directories)
  can be allowlisted — required for the file-only execute_file path.

* Corrected the Landlock kernel version in the fail-loud error message.
  The wrapper now requires ABI >= min_landlock_abi() (currently v6),
  which shipped in Linux 6.12 — not 6.7.  The message is phrased around
  the ABI version so it stays correct as the SDK's minimum changes.

* test_sandlock_execution_success now uses bytes for stdout/stderr (to
  exercise the _decode() byte path, matching real sandlock) and sets
  result.success = True explicitly.

Note: the suggestion to also add temp_dir/working_dir to fs_readable
was not applied — sandlock's fs_writable already grants read access
("fs_readable ... in addition to writable paths"), confirmed by test.

* fix(sandbox): address reviewer feedback on Landlock read access, test skip, and error logging

- Mirror writable dirs into the Landlock read allowlist. Landlock does not
  grant read from write, so the sandboxed process could not read its own
  working/temp directory (Gemini MEDIUM).
- Fix test_real_sandlock_integration to skip below sandlock.min_landlock_abi()
  instead of ABI < 1, so it cleanly skips on intermediate-ABI kernels rather
  than hard-failing in the constructor (Greptile).
- Log the full traceback in the broad except around sandlock execution so the
  underlying cause is not lost (CodeRabbit BLE001).

* fix(sandbox): remove subprocess fallback to honor fail-loud guarantee

execute() and run_command() still carried subprocess-fallback branches
(inherited from main during the rebase) while execute_file() had none.
Since __init__ now raises RuntimeError when Landlock is unavailable, the
object can't be constructed without kernel isolation, making those
branches dead code — and an inconsistency: if is_available ever returned
False, execute()/run_command() would silently degrade to subprocess
isolation while execute_file() propagated the error.

Remove the fallback blocks and the now-orphaned _tag_subprocess_fallback
helper so all three execution methods behave uniformly and fail loud.
The _used_subprocess_fallback / metadata fields are retained: they now
report a constant "not fallen back / kernel-isolated", keeping main's
metadata schema intact and truthful.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

* fix(sandbox): isolate host environment from sandlock child (clean_env=True)

The sandlock child inherited the parent process's FULL environment on
every run: _build_sandbox_kwargs never set clean_env, so sandlock used
its default (clean_env=False = inherit everything), leaking host secrets
(SECRET_KEY, DATABASE_URL, ...) into untrusted sandboxed code. This also
diverged from SubprocessSandbox, which deliberately builds a minimal,
policy-driven env instead of copying the host's.

The reviewer-reported symptom (env={} skipped by `if env:`) was real but
its proposed fix was insufficient: with clean_env=False, setting env={}
overrides nothing and the full host environment still leaks. The actual
lever is clean_env.

- Set clean_env=True so the child starts from a minimal baseline
  (PATH/HOME/USER/TERM/LANG); per-call env is layered on top.
- Change the guard to `if env is not None:` so an explicit empty dict is
  honoured rather than skipped.
- Add a regression test asserting clean_env=True and that env={} /
  populated / None are all handled without host-env inheritance.

Verified behaviorally: with SECRET_KEY set in the parent, the sandboxed
process sees it as absent for env=None and env={}, while an explicit
override still applies.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>

---------

Co-authored-by: Cong Wang <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-24 21:47:22 +01:00
MORDEHAI BOTBIKA ec1ee648bc fix: add typed payload contract to handoff example to prevent silent data violations (closes #1810) (#1889) 2026-06-24 21:45:59 +01:00
dymuxanddymux a57e6966ac fix(examples): import AgentFlow in simple_workflow.py (#1984)
simple_workflow.py imports Workflow but uses AgentFlow on line 26,
causing NameError at runtime. AgentFlow is the primary class name
(Workflow is a silent alias). Other workflow examples already use
AgentFlow. Aligns with #1750 fix pattern.

Co-authored-by: dymux <[email protected]>
2026-06-24 21:42:57 +01:00
praisonai-triage-agent[bot]andMervin Praison 2225583a17 fix: remove dead deprecated BaseTool._python_type_to_json() (fixes #2262) (#2263)
The private deprecated static method had zero callers and its own
docstring marked it for removal. Type-to-JSON-schema conversion already
goes through annotation_to_json_schema() in tools/schema.py. Also drop
the now-unused Type import.

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-24 21:41:36 +01:00
praisonai-triage-agent[bot]andMervin Praison afed877713 fix: add runtime control surface commands to gateway bots (#2245)
* fix: add runtime control surface commands to gateway bots (fixes #2158)

- Add /model command for runtime LLM model switching
- Add /usage command to show token usage and estimated cost
- Add /compress command for context window compression
- Add /queue command for message queuing

These commands leverage existing core capabilities (context optimization,
hooks, model override) and are exposed as first-class bot commands in
the wrapper layer, properly gated by CommandAccessPolicy.

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

* fix: correct /model override and /queue dispatch in gateway bots

- /model: store per-user override in session manager (fix inverted
  hasattr guard that made it dead code); apply the override per-turn
  inside the agent lock in chat() and restore it afterwards so a shared
  Agent instance never leaks one user's model to other concurrent users.
- /queue: delegate to the canonical SessionRunControl pending slot that
  the gateway run loop actually drains (next_pending/finish_run) instead
  of an orphan _message_queues dict with no consumer; give honest
  feedback when idle or when run control is disabled.
- slack: match /model and /queue on the exact command token so prefixes
  like /models or /queued no longer false-trigger.

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-24 21:41:04 +01:00
8dd2e134e7 fix: cleanup wrapper architecture - framework adapter migration, dead ToolRegistry path, and duplicate storage stack (#2248)
* fix: cleanup wrapper architecture - framework adapter migration, dead ToolRegistry path, and duplicate storage stack

Three architectural gaps in src/praisonai/praisonai:

1. Framework-adapter migration:
   - Remove dead register_builtin_autogen_adapters() call from AgentsGenerator.__init__
   - Remove ToolRegistry wiring that was only used by dead _run_* methods
   - Remove the now-unused ToolRegistry import from agents_generator.py

2. Duplicate persistence stack (storage/ vs persistence/):
   - Mark storage/__init__.py as legacy with docstring pointing to persistence.factory
   - storage/ files retained for backward compatibility, new code should use persistence

3. Dead tool-registry path:
   - Remove all deprecated autogen-specific methods from ToolRegistry:
     register_autogen_adapter, get_autogen_adapter, list_autogen_adapters,
     register_builtin_autogen_adapters
   - Remove _autogen_adapters storage and its references in clear() and __len__()
   - Remove register_builtin_autogen_adapters() call from config/validator.py
   - Remove ToolRegistry import from config/validator.py

Fixes #1652

* fix: update ToolRegistry tests for removed autogen API and clean PEP8 whitespace

- Remove/rewrite test cases that called deleted register_autogen_adapter,
  get_autogen_adapter, list_autogen_adapters (Greptile P1 — tests would
  AttributeError against the post-PR ToolRegistry)
- test_clear/test_len now use register_function instead of autogen adapters
- Collapse redundant blank lines left after method removal (Gemini PEP8)

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: Mervin Praison <[email protected]>
2026-06-24 21:40:01 +01:00
praisonai-triage-agent[bot]andMervin Praison 1ff5134f3c fix: consolidate duplicated _fix_array_schemas helper (#2265)
* fix: consolidate duplicated _fix_array_schemas helper (fixes #2260)

Extract the recursive array-schema normalisation into a shared
praisonaiagents/llm/schema_utils.py:fix_array_schemas helper and have
LLM, OpenAIClient and MCP delegate to it via thin wrappers. Removes a
three-place maintenance/drift risk with no behaviour change; method
names are preserved for backward compatibility.

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

* fix: make schema_utils.fix_array_schemas the single canonical helper

Resolves reviewer feedback (Greptile/CodeRabbit): the new
llm/schema_utils.fix_array_schemas was a strict subset of the existing
mcp/mcp_schema_utils.fix_array_schemas, leaving two divergent helpers.

- Promote llm/schema_utils.fix_array_schemas to the complete superset,
  now also normalising additionalProperties and anyOf/oneOf/allOf.
- mcp/mcp_schema_utils.fix_array_schemas becomes a thin wrapper that
  delegates to the canonical helper (preserved for the MCP transports
  that import it directly).

Strictly improves the LLM/OpenAI paths (no regression for MCP) and
removes the divergence so all code paths produce identical schema shapes.

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: Mervin Praison <[email protected]>
2026-06-24 21:38:59 +01:00
praisonai-triage-agent[bot]andMervin Praison fa6b801240 fix: extract duplicated prompt variable-substitution helper (#2264)
* fix: extract duplicated prompt variable-substitution into helper (fixes #2261)

Consolidate the four near-identical {{var}}/{{previous_output}}/{{input}}
substitution blocks in workflows.py into a single module-level helper
_substitute_action_variables(). Pure refactor with identical behaviour.

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

* fix: rename input param to task_input and add helper unit tests (#2261)

- Rename _substitute_action_variables param `input` -> `task_input` to
  avoid shadowing the Python builtin (Ruff A002 / CodeRabbit / Greptile P2)
- Add direct unit tests for _substitute_action_variables covering the
  {{key}}, explicit {{previous_output}}, implicit context-append, and
  {{input}} branches (CodeRabbit coverage request)

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: Mervin Praison <[email protected]>
2026-06-24 21:38:25 +01:00
praisonai-triage-agent[bot]andMervin Praison cb3a353d77 fix: consolidate duplicated scheduler config-construction logic (#2259)
* fix: consolidate duplicated scheduler config-construction logic (fixes #2252)

Extract the near-identical from_yaml() and from_recipe() construction
logic from AgentScheduler and AsyncAgentScheduler into shared
module-level helpers build_from_yaml() and build_from_recipe() in
scheduler/_base_scheduler.py. The helpers are parameterised by the
scheduler class and (for recipe) the executor-agent wrapper class,
removing ~150 LOC of copy-paste while keeping every public signature
and behaviour unchanged. from_blueprint() (sync-only) is left untouched.

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

* fix: address reviewer findings in scheduler construction helpers

- Validate task before constructing agent in build_from_yaml so a missing
  task raises ValueError before any expensive agent/tool loading (CodeRabbit).
- Only inject timeout_sec into recipe resolve options when an explicit
  timeout_override is given, so a recipe's own runtime_config timeout is no
  longer clobbered by the scheduler default (CodeRabbit, Major).
- Add return type annotations to build_from_yaml/build_from_recipe (Greptile).

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-24 18:08:58 +01:00
praisonai-triage-agent[bot]andMervinPraison bb2d35ba66 fix: deduplicate health() across bot adapters (fixes #2254) (#2258)
Add a shared _default_health() to MessageHookMixin (the common base all
five adapters already inherit) that builds HealthResult from self.probe(),
self._is_running, self._started_at and self._session, parameterised by
self.platform. Telegram, Discord, Slack, Email and AgentMail now delegate
to it. Handles both _histories and active_count() session sources so every
adapter keeps identical output. probe() implementations untouched.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-24 18:08:25 +01:00
praisonai-triage-agent[bot]andMervinPraison 9ff02ede44 fix: deduplicate aiui launcher logic in ui.py default callback (fixes #2253) (#2256)
Replace the inline install-check/app-resolution/subprocess-launch block in
the default ui() callback with a single call to the existing
_launch_aiui_app helper. Remove the now-unused _ensure_default_app helper
and its module constants. Behaviour is preserved: the chat UI still resolves
to ~/.praisonai/ui/app.py from the bundled ui_chat/default_app.py, with the
same install check, custom --app handling, and aiui->python -m praisonaiui.cli
fallback.

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-24 18:07:54 +01:00
praisonai-triage-agent[bot]andMervinPraison 9a9f5b8677 fix: remove dead test.py script and unused resolve_variant() adapter method (fixes #2255) (#2257)
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-24 18:07:14 +01:00
98a4bcf1f8 Agent runtime lacks structured retry/backoff for tool failures (#2250)
* fix: Agent runtime lacks structured retry/backoff for tool failures

* fix: prevent outer/inner retry-loop compounding for tool error dicts

Only escalate tool error dicts to the outer retry/backoff loop when the
inner circuit-breaker retry loop does NOT already retry that error type.
Error types in the policy's retry_on set (timeout/rate_limit/connection_error)
are exhausted by the inner loop before reaching here, so escalating them as
retryable would re-run the entire inner loop per outer attempt (compounding
to inner_max x outer_max). Now those terminal results surface immediately,
while genuinely unhandled errors (e.g. "unknown") still get an outer retry.

Addresses review feedback from Gemini, CodeRabbit, and Greptile.

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: Mervin Praison <[email protected]>
2026-06-24 16:49:42 +01:00
fb8b64f2a3 fix: clear PraisonAIUI auth env in cross-repo integration tests (#2247)
* fix: clear PraisonAIUI auth env in cross-repo integration tests (fixes #2246)

Add autouse conftest fixture for tests/integration that clears
AUTH_ENFORCE, AIUI_URL_TOKEN and GATEWAY_AUTH_TOKEN so unauthenticated
/run test clients don't return 401 when those vars are present from
org/repo secrets or local env. Mirrors PraisonAIUI's own conftest.

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

* docs: clarify autouse auth-env fixture scope per reviewer feedback

Document that the fixture intentionally applies to all integration
subdirectories and guide future contributors to set GATEWAY_AUTH_TOKEN
locally if needed.

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]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-24 16:47:46 +01:00
MervinPraison a1bd5030b1 docs: auto-update api.md [skip ci] 2026-06-24 14:00:25 +00:00
praisonai-triage-agent[bot]andMervin Praison d942e833e4 feat: add runtime MCP server management to Agent (#2243)
* feat: add runtime MCP server management to Agent (fixes #2241)

Add add_mcp_server/remove_mcp_server/refresh_tools/list_mcp_servers to
Agent so a live agent can attach or detach MCP integrations mid-session
without rebuilding. Tools are read live from self.tools each turn, so a
newly attached MCP becomes available on the next turn and flows through
existing tool-execution and approval paths. remove cleanly shuts down the
connection; close()/cleanup also shuts down runtime-attached servers.

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

* fix: clean up runtime MCP servers in aclose() and guard each shutdown

- aclose() now tears down runtime-attached MCP servers (previously leaked
  on async teardown via `async with agent:`)
- extract _shutdown_runtime_mcp_servers() helper guarding each shutdown()
  individually so one failure no longer aborts cleanup of the rest
- add tests for close()/aclose() teardown and per-server failure isolation

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-24 15:00:01 +01:00
praisonai-triage-agent[bot]andMervin Praison 43ba0f09bb fix: implement gateway STEER busy-mode wiring (#2244)
* fix: implement gateway STEER busy-mode wiring (fixes #2242)

Wire SessionRunControl's STEER busy-mode to the live agent's steering
queue instead of silently falling back to QUEUE. The gateway now
registers the running agent and, on a mid-run message in STEER mode,
calls agent.steer(text, priority=30) so the running turn folds in the
new guidance (preserving partial progress). Safe fallback to QUEUE is
retained when no steering-enabled agent is available.

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

* fix: re-register live agent for STEER on pending follow-up runs

finish_run() clears session.agent after every turn, so STEER silently
fell back to QUEUE for any run after the first within a single
chat_with_run_control invocation. Move register_agent into the run loop
so it re-registers on each RUN_NOW/INTERRUPTED iteration. Adds a
regression test covering STEER injection during a pending-message run.

Addresses Greptile and CodeRabbit review feedback on #2244.

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-24 14:59:05 +01:00
MervinPraison 018f50e692 docs: auto-update documentation parity trackers [skip ci] 2026-06-24 13:25:48 +00:00
praisonai-triage-agent[bot]andMervin Praison cd97735bb1 feat: bridge registered tools into code execution (code mode) (#2240)
* feat: bridge registered tools into code execution (code mode) (fixes #2239)

Add an opt-in code-execution-with-tools mode that lets model-generated code
call the agent's registered tools directly, enabling multi-step tool pipelines
in a single turn with intermediate results staying out of the context window.

- ToolProxy + build_tool_namespace (tools/tool_proxy.py): resolves names
  against ToolRegistry, enforces an explicit allow-list, and routes each call
  through the existing require_approval gate. Read-only namespace.
- execute_code_with_tools (tools/python_tools.py): in-process restricted-builtins
  executor that injects tool proxies; imports/dangerous builtins still blocked.
- ExecutionConfig.code_tools + code_tools_allow flags (with to_dict/from_dict).
- Exports from tools/__init__.py.
- Tests covering allow-list, approval gate, positional/keyword args, namespace
  form, and import blocking.

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

* fix: close registry bypass + pass positional args through approval (code mode)

Address reviewer findings on the code-tools bridge:
- ToolProxy now holds registry/allow-list in a closure via __getattribute__,
  so sandboxed code can no longer reach tools._registry/_allowed to bypass
  the allow-list or approval gate (Greptile/Copilot P1 security).
- Positional args are bound to parameter names before approval so they are
  visible to and modifiable by the approval backend (Greptile/CodeRabbit).
- Reject reserved 'tools' name instead of silently shadowing a tool (CodeRabbit).
- Drop unused injected_names param from _execute_code_direct (Greptile P2).
- Lazily import tool_proxy from tools/__init__ to avoid import-time cost (CodeRabbit).
- Add regression tests for the bypass paths and positional approval.

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

* fix: close two P0/P1 code-mode security holes in ToolProxy

- Remove "__init__" from __getattribute__ allowlist so sandboxed code can no
  longer call tools.__init__([...]) to re-bind the proxy to the global registry
  with an attacker-chosen allow-list (P0, Greptile).
- Stop calling mark_approved/honouring is_already_approved in code mode so a
  single approved call no longer silently unlocks every later call to the same
  tool with arbitrary args; each code-mode call is gated independently (P1).
- Add regression tests for both vectors.

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-24 14:25:20 +01:00
MervinPraison 377794ed7a Release v4.6.74 2026-06-24 12:51:25 +01:00
Wu Shuwen e57347bd43 fix: move logger.debug into function body for github_create_branch (#2093)
* fix: move logger.debug into function body for github_create_branch

The debug log line was placed outside the function body (zero
indentation), making it a syntax error. Move it inside the try
block after successful checkout.

Fixes #1348

* fix: add git branch name validation before checkout

Per CodeRabbit review: validate branch_name with
git check-ref-format --branch before passing it to
git checkout -B to prevent misinterpretation as git
options or invalid refs.
2026-06-24 12:49:59 +01:00
praisonai-triage-agent[bot]andMervin Praison ea17a6addf fix: concurrency-safe automatic stale-write protection in built-in file/edit tools (#2224)
* fix: automatic stale-write protection and per-file locking in built-in file/edit tools (fixes #2221)

Add a shared, module-level per-canonical-path lock registry and an
automatic last-read-hash map so the built-in FileTools/EditTools guarantee
two production-robustness properties by default:

- Per-file concurrency safety: read-modify-write on the same path is
  serialised under a keyed RLock, so parallel tools / multi-agent teams
  cannot interleave and corrupt a shared file. Different files still run
  concurrently.
- Automatic stale-write protection: a read records the on-disk hash; a
  later edit/write/apply_patch recomputes it and aborts on mismatch with a
  clear "re-read before editing" error, without the caller threading a
  hash. Overridable via force=True; explicit expected_hash still honoured.

Backward compatible: same signatures/return contract; force is an additive
default-off escape hatch. No new dependencies (stdlib threading/weakref).

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

* fix: CRLF hash consistency, fail-closed writes, delete staleness & bounded hash map

- apply_patch: record on-disk (CRLF-preserved) hash on update so consecutive
  edits to CRLF files are not falsely flagged stale (Greptile P1).
- FileTools: hash on-disk byte form (binary, BOM-stripped) in read_file/write_file
  so the shared staleness registry stays consistent with EditTools (Greptile P1).
- FileTools.write_file: fail closed when the staleness verify re-read raises,
  rather than blindly overwriting (CodeRabbit major).
- apply_patch: guard delete operations against stale reads (CodeRabbit major).
- read_file (both tools): acquire the per-path lock so reads don't observe
  partially-written files (CodeRabbit major).
- _file_locks: bound _read_hashes with LRU eviction to prevent unbounded growth
  in long-running agents (Greptile P2).
- Add CRLF/delete/cross-tool/fail-closed regression tests.

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-24 12:46:17 +01:00
MervinPraison 89a7c2aee4 docs: auto-update api.md [skip ci] 2026-06-24 11:45:29 +00:00
4e6c6af775 feat: blueprint catalogue and consent-first suggestion mechanism (#2169)
* feat: add blueprint catalogue and consent-first suggestion mechanism

Adds two major features to the scheduler:

1. Blueprint Catalogue — Named, parameterized automation templates
   ("morning-brief", "important-mail", "weekly-review") with fillable
   slots (hour, weekdays, focus, interval, keywords) that work across
   CLI, YAML, and Python API surfaces.

2. Consent-First Suggestion Mechanism — The framework may propose
   automations via a capped, deduplicated SuggestionStore backed by
   atomic JSON. Nothing is ever auto-created; suggestions only become
   real jobs when explicitly accepted.

Architecture (per AGENTS.md):
- Thin protocol layer: Blueprint/BlueprintSlot dataclasses,
  Suggestion/SuggestionStore in praisonaiagents
- Heavy implementation: BlueprintCatalogue, SuggestionEngine in praisonai

New CLI commands:
  praisonai schedule blueprint morning-brief --hour 8 --weekdays mon-fri --deliver telegram
  praisonai schedule blueprint-list
  praisonai schedule suggestions
  praisonai schedule suggestion-propose morning-brief --reason "..."
  praisonai schedule suggestion-accept <id>
  praisonai schedule suggestion-dismiss <id>

New Python API:
  AgentScheduler.from_blueprint("morning-brief", slots={...}, deliver="telegram")

Fixes #2159

* Update src/praisonai-agents/praisonaiagents/scheduler/suggestion_store.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/praisonai/praisonai/scheduler/blueprint_catalogue.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/praisonai/praisonai/scheduler/blueprint_catalogue.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/praisonai/praisonai/cli/commands/schedule.py

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update src/praisonai-agents/praisonaiagents/scheduler/suggestion_store.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update src/praisonai/praisonai/scheduler/blueprint_catalogue.py

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* fix: complete review fixes — suggestion-propose params, error handling, format hardening, race fix

* fix: add expired-suggestion guard in suggestion-accept CLI

* fix: cron support in ScheduleParser, SuggestionEngine export, user blueprint label

- Extend ScheduleParser.parse() to handle "cron:..." expressions
  with best-effort interval conversion (daily/hourly/per-minute)
- Fix from_blueprint silently failing for cron-based blueprints
  (morning-brief, weekly-review now resolve to 86400 interval)
- Register SuggestionEngine in scheduler __init__.py lazy loader
- Fix user YAML blueprints mislabeled [builtin]: change custom=False
  to custom=True for ~/.praisonai/blueprints/ directory
- Add 5 cron parser tests (interval, hourly, daily, case-insensitive, fallback)

---------

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-06-24 12:45:08 +01:00
MervinPraisonandCursor 76b1b6917e feat: allow Claude to push fork PRs when maintainer edits enabled
Honour maintainerCanModify in claude.yml and merge-conflict-claude.yml
so external contributors with "Allow edits from maintainers" can receive
automated conflict resolution via GH_TOKEN PAT push to the fork remote.

Co-authored-by: Cursor <[email protected]>
2026-06-24 12:36:17 +01:00
praisonai-triage-agent[bot]andMervin Praison 2f3174dcc0 fix: add outbound send-policy guard for agent-initiated messaging (#2228)
* fix: add outbound send-policy guard for agent-initiated messaging (fixes #2226)

Add a core enforcement seam so operators can constrain where an agent's
model-controlled send_message tool may deliver. Introduces a closed
SendDecision shape, SendPolicyProtocol, and a lightweight allow/deny
SendPolicy in praisonaiagents/gateway, a task-local policy registry in
session/context, and enforcement inside the send_message path before
dispatch. Absent a policy, today's allow-all behaviour is preserved.

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

* fix: harden send-policy guard (fail-closed, frozen decision, real origin)

Address reviewer findings on PR #2228:
- Treat any non-SendDecision return from policy.evaluate() as a denial
  (fail-closed), closing the implicit allow-all gap on a None return.
- Make SendDecision frozen to protect the security-critical decision
  object from post-evaluation mutation.
- Pass the real origin platform to policy.evaluate() instead of the
  literal "origin".
- Log context-enrichment failures instead of silently passing.
- Add a unit test covering the non-SendDecision fail-closed path.

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-24 12:21:24 +01:00
praisonai-triage-agent[bot]andMervin Praison fff7effbbf fix: fail-loud, schema-validated project config (catch typos; align init scaffold with resolver) (#2222)
* fix: fail-loud schema-validated project config (fixes #2219)

- Add difflib-based config validation in resolver (warn-by-default,
  strict via --strict-config / PRAISONAI_STRICT_CONFIG) with
  "did you mean" suggestions naming file/key/section.
- Fix `init` scaffold to emit the nested shape the resolver consumes
  (agent.model / output.format) plus a $schema pointer.
- Ship config.schema.json asset for editor autocomplete/lint.
- Wire `config validate` to the new validator with --strict-config.

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

* fix: address reviewer findings on config validation (PR #2222)

- Tolerate scaffolded `$schema` key in resolver allowlist + JSON schema
- Remove unimplemented `screen_reader` from schema/KNOWN_OUTPUT_KEYS (no silent drop)
- Reject non-mapping agent/rag/output sections with clear message (no deferred crash)
- config validate (no --file) now validates RAW discovered configs so nested
  typos are caught and --strict-config is honoured
- Emit structured JSON for warn case so `config validate --json` is machine-readable
- Add tests for $schema tolerance and non-mapping rejection

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-24 12:18:01 +01:00
MervinPraison 9ae04d1f2f fix(ci): run FINAL Claude review before merge-conflict automation
Narrow @claude dedupe to FINAL/Lead Engineer prompts only; defer conflict
trigger until review is posted; add recovery job after rebase synchronize.
2026-06-24 12:15:13 +01:00
praisonai-triage-agent[bot]andMervin Praison 7a6c245ce5 fix: unified structured connect-rejection envelope with recovery guidance (#2229)
* fix: unified structured connect-rejection envelope with recovery guidance (fixes #2227)

Extend the gateway connect-error contract so every rejection path emits a
single structured envelope clients can branch on for deterministic reconnect.

Core (praisonaiagents/gateway/protocols.py):
- Add ConnectRecoveryStep enum (reauthenticate, repair, upgrade_client,
  wait_then_retry, do_not_retry)
- Add RATE_LIMITED, ORIGIN_NOT_ALLOWED, CONFIGURATION_ERROR codes
- Add HelloError.next_step / retry_after_seconds + to_dict() serializer
  (legacy "next" key preserved for backward compatibility)

Wrapper (praisonai/gateway/server.py):
- Route pre-handshake auth/origin/rate-limit/config rejections through the
  structured envelope (accept + send hello_error frame, then close), with
  retry_after_seconds wired from the limiter for rate limiting
- Update handshake rejection sites to use to_dict() and set next_step

Additive and backward-compatible (new optional fields + new codes).

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

* fix: correct recovery hints and rejection-path robustness in connect envelope

Addresses reviewer findings on PR #2229:
- Add ConnectRecoveryStep.DOWNGRADE_CLIENT and use it for the
  "client protocol too new" path (was incorrectly UPGRADE_CLIENT).
- Catch ValueError from check_origin() so external-bind misconfig routes
  through the structured configuration_error envelope.
- Log (debug) on best-effort send/close failures in _reject_connection.
- Omit legacy "next" key when no recovery hint is set (no explicit null).
- Tests for the new step, next-omission, and downgrade path.

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-24 12:11:46 +01:00
praisonai-triage-agent[bot]andMervin Praison 72937594e5 fix: add priority-ordered per-peer/role/account route bindings to gateway (#2230)
* fix: add priority-ordered per-peer/role/account route bindings to gateway (fixes #2225)

Add a declarative, priority-ordered inbound routing model so multiple agents
behind one gateway can be selected by sender/peer id, role/guild membership,
specific channel id, receiving bot account, or chat type — not just chat type.

Core (praisonaiagents/gateway/protocols.py):
- RouteBinding, RouteFacts, RouteMatch dataclasses and a pure resolve_route()
  contract that evaluates bindings most-specific-first (peer > role/channel >
  account > chat_type), with explicit priority override and deterministic
  tie-breaking by declaration order.

Wrapper (praisonai):
- ChannelConfigSchema accepts an additive `bindings:` block (backward-compatible
  with the flat `routes` map).
- Gateway parses bindings, feeds richer inbound facts (peer/role/channel/account)
  from the Discord/Slack and Telegram handlers, and resolves via the core
  resolver, falling back to chat-type routes then default.

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

* fix: harden route-binding resolution (normalize roles, tolerate bad bindings)

- Normalize role comparison so numeric/typed roles match stringified facts
- _parse_bindings: skip & warn on malformed bindings (e.g. priority: "high")
  instead of aborting channel startup; warn on missing 'agent' key
- Add core + wrapper tests for both behaviors

Addresses CodeRabbit & Greptile review feedback on #2225.

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-24 12:10:41 +01:00
MervinPraison 285da3366c docs: auto-update api.md [skip ci] 2026-06-24 11:10:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 23317ae8ba fix: implement skill mutation approval gate (fixes #2232) (#2236)
* fix: implement skill mutation approval gate (fixes #2232)

Implement the declared SkillMutatorProtocol on SkillManager so agent-written
skills are staged for human approval by default instead of writing straight
to disk.

- Add `propose` param to create/edit/patch/delete/write_file/remove_file
  (safe-by-default; resolved from SKILL_WRITE_APPROVAL env, default True).
- Add a JSON-backed pending store under the skills root plus an append-only
  audit log; implement list_pending()/approve()/reject() and protocol-named
  aliases (create/edit/patch/delete/write_file/remove_file).
- Wire skill_manage tool with pending/approve/reject actions and a propose
  override so the model gets {"status": "pending", "id": ...} by default.
- Fix pending-store + create_skill base dir to honour PRAISONAI_HOME.
- Tests for staging, propose=False direct write, env opt-out, approve/reject,
  and protocol conformance.

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

* fix: harden skill approval gate (apply-before-commit, staged validation, user-dir target)

Address reviewer feedback on PR #2236:
- approve() now applies the mutation before removing the pending record and
  auditing "approved", so a failed apply neither loses the proposal nor logs a
  false approval (audits "approval_failed" instead).
- _stage_pending validates skill name + content/file size up front for all
  actions, so invalid/oversized proposals never enter the store or audit log.
- Added SKILL_MAX_PENDING cap (default 100) to bound pending-store growth.
- _skills_base_dir now always targets the user-owned skills dir (honours
  PRAISONAI_HOME), never an admin/system path like /etc/praison/skills.
- Module-level skill_manage wrapper: name is now optional (mirrors method).
- write_file branch allows intentional empty content (checks is None).
- Added tests for staged validation, size cap, and approve-failure retention.

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

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

* test: use propose=False in lifecycle tests after rebase onto main

main (#2235/#2237) added direct-write lifecycle/provenance tests; this PR
makes staging safe-by-default, so those tests must opt into the direct-write
path with propose=False (consistent with the path-traversal tests).

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-24 12:10:07 +01:00
praisonai-triage-agent[bot]andMervin Praison 381300e3f9 fix: add conditional pre-run gate to scheduler (#2238)
* fix: add conditional pre-run gate to scheduler (fixes #2234)

Adds an optional cost/efficiency gate that decides whether a scheduled
job's (expensive) model turn should happen at all, so unattended
poll-and-react automations only spend LLM tokens when a cheap,
deterministic check says it is worth it.

- core: GateResult + JobConditionProtocol in scheduler/protocols.py;
  pre_run/condition fields on ScheduleJob (data + protocol only)
- wrapper: ShellConditionGate + enforcement hook in the executor before
  agent.chat() — skip => no tokens/no delivery; go => gate output seeds
  the prompt; backward-compatible (gating off unless pre_run is set)
- 3-way surface: Python ScheduleJob, YAML loader, agent-callable
  schedule_add tool, and `schedule add --pre-run` CLI option

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

* fix: harden scheduler pre-run gate (security + async + honesty)

- Run pre-run gate off the event loop via asyncio.to_thread so a slow
  subprocess no longer blocks other ticks/deliveries.
- Drop pre_run/condition from the LLM-callable schedule_add tool to close
  the prompt-injection -> arbitrary host command path; keep them on the
  trusted CLI and Python ScheduleJob surfaces.
- Surface truncated stderr in the gate skip reason for debuggability.
- Remove inert pre_run/condition/message YAML keys (no consumer read them
  and message shadowed the task-derived message).
- Clarify that condition is advisory metadata, not enforced by default.
- Add tests for stderr-in-reason and the schedule_add security contract.

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-24 12:09:49 +01:00
praisonai-triage-agent[bot]andMervin Praison 92abea7c98 fix: add autonomous skill self-improvement loop (self_improve) (#2235)
* fix: add autonomous skill self-improvement loop (fixes #2231)

Adds an opt-in `self_improve` agent parameter that closes the skill loop:
after each task an isolated, guarded review pass restricted to the
`skill_manage` tool asks the agent to capture a reusable technique as a
new/patched skill. Off by default, never runs with the full toolset, and
re-entrancy guarded.

- skills/protocols.py: SkillReviewProtocol + DefaultSkillReviewPolicy
- agent/skill_review.py: SkillReviewMixin with guarded sync/async review pass
- agent/agent.py: self_improve param wired into Agent
- agent/tool_execution.py: trigger review from _trigger_after_agent_hook
- tests: tests/unit/skills/test_self_improve.py

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

* fix: isolate skill-review turn from hooks & chat_history (#2231)

Addresses reviewer findings (Greptile P1, CodeRabbit, Copilot):
- Skip the entire AFTER_AGENT side-effect pipeline during the guarded
  review turn so auto-memory/auto-learning/nudge/external hooks no longer
  double-fire per reviewed task.
- Snapshot+restore chat_history around the review turn so the internal
  review exchange never leaks into reused agents (chatbot/REPL/loop).
- Clamp DefaultSkillReviewPolicy.min_tool_calls to >=1 (0 silently
  reviewed every call).
- Truncate echoed task prompt to MAX_PROMPT_CHARS (token cost + injection
  surface).
- Forward self_improve state through clone_for_channel().
- Enrich swallowed-failure warnings with agent/session context.
- Extract shared gating into _prepare_skill_review (DRY sync/async).
- Add async parity, chat_history isolation, clamp, truncation & clone tests.

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

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-24 11:10:53 +01:00
MervinPraison a83198e514 docs: auto-update api.md [skip ci] 2026-06-24 10:10:36 +00:00
praisonai-triage-agent[bot]andMervin Praison ef9bd49948 fix: skill provenance, usage telemetry & recoverable lifecycle/rollback (#2237)
* fix: add skill provenance, usage telemetry & recoverable lifecycle/rollback (fixes #2233)

Adds protocol-only provenance/usage fields to SkillProperties
(agent_created, created_at, use_count, last_used, patch_count + idle_days
helper) that lifecycle/curator plugins read. SkillManager now tracks usage
on invoke/get_instructions, increments patch_count on edit/patch, persists
telemetry to SKILL.md frontmatter, snapshots content for rollback, and makes
delete recoverable by default (archive/restore) while keeping hard delete via
delete_skill(hard=True).

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

* fix: harden skill telemetry frontmatter split + guard broken-skill use count

- _update_frontmatter_fields now splits on a newline-fenced "\n---\n" closing
  fence instead of the bare "---" token, so frontmatter values containing
  "---" no longer corrupt SKILL.md on telemetry writes (Greptile P1).
- get_instructions no longer records use telemetry when a skill fails to
  activate (instructions is None), aligning it with invoke (Greptile P2).
- Documented rollback_skill as single-step undo (Greptile P2).
- Added regression test for embedded "---" frontmatter values.

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-24 11:10:14 +01:00
praisonai-triage-agent[bot]andMervin Praison 0db2bc99d0 fix: make .praisonai/config.yaml a single source of truth for MCP + permissions (#2223)
* fix: surface mcp and permissions from .praisonai/config.yaml (fixes #2220)

Make the project config a single source of truth by surfacing the existing
mcp schema section and a new permissions section on the resolved config, and
wiring them into the run path. CLI flags (--mcp, --permissions, --allow/--deny)
still override config; existing separate mechanisms keep working.

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

* fix: harden config-driven MCP/permissions wiring per review

- shlex.quote each MCP command token so args with spaces survive
  downstream shlex.split (CodeRabbit)
- skip MCP env values containing commas with a warning instead of
  silently corrupting the comma-delimited env string (Greptile P1)
- warn when multiple enabled local MCP servers are declared, since the
  command-string path only wires the first (Greptile P2)
- guard non-dict mcp section in _resolve_mcp_from_config and validate
  mcp/permissions are mappings in ResolvedConfig.from_dict (CodeRabbit)
- only accept allow/deny/ask in structured permission rules, matching the
  flat-mapping path (CodeRabbit security)
- tests: SimpleNamespace fixtures (RUF012), rename unused var (RUF059),
  add coverage for quoting, comma-skip, invalid-action, non-dict mcp

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-24 10:12:11 +01:00
praisonai-triage-agent[bot]andMervin Praison 50042b9b8d fix: wrapper thread-safety & observability lifecycle gaps (#2217) (#2218)
* fix: wrapper thread-safety & observability lifecycle gaps (fixes #2217)

- tool_resolver: perform all source lookups (incl. cold-start imports and
  nested ToolRegistry lock) OUTSIDE _resolve_cache_lock using a fast-path
  read + do-work-unlocked + re-check-on-insert pattern. Removes cold-start
  serialization of concurrent resolutions and the lock-order inversion with
  ToolRegistry._notify_invalidate. Preserves negative/transient cache semantics.

- observability/hooks: add observability_session() context manager that always
  finalizes with the correct Success/Failure status, plus discover_observability_sinks()
  for entry-point-based third-party sinks. Adapters (praisonai, crewai) now
  finalize observability in a finally block with status derived from exc state,
  so sessions are no longer orphaned "in progress" on error/cancellation.

- _lockmap: guard structural mutations of _buckets/_loop_refs with a
  threading.Lock so the per-loop bucketing is safe across multiple OS threads
  (one event loop per thread), fixing RuntimeError/KeyError races in bot flows.

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

* fix: address reviewer feedback on wrapper thread-safety PR (#2217)

- tool_resolver: add cache epoch to discard stale results when invalidate/
  clear_cache races with an in-flight (unlocked) source lookup (CodeRabbit)
- hooks: discover_observability_sinks skips non-callable entry points (CodeRabbit)
- crewai_adapter: fix 2-space indentation; move lazy imports inside the
  finalize guard so import failures still finalize observability (Greptile/CodeRabbit)
- praisonai_adapter: start finalize guard before lazy imports/runtime startup
  so failures/cancellations there still finalize (init interactive_runtime=None) (CodeRabbit)
- _lockmap: add lock-free fast path for existing buckets; only take struct
  lock on first-touch to avoid per-call contention (Greptile)

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-24 10:10:51 +01:00
praisonai-triage-agent[bot]andMervin Praison 38c6d02ff6 fix: derive CLI routing from single command registry (fixes #2191) (#2192)
* fix: derive CLI routing from single command registry (fixes #2191)

_COMMAND_GROUPS drifted from _LAZY_COMMANDS, causing six advertised Typer
commands (agent, auth, command, models, permissions, validate) to misroute
to the legacy argparse dispatcher via the main entry point. Make
_LAZY_COMMANDS the single source of truth, derive get_command_names() from
it, delete the redundant _COMMAND_GROUPS dict, and add a regression test
guarding against future drift.

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

* test: report all missing drifted commands in single assertion (Greptile)

Replace per-name assertIn loop with a set-difference assertEqual so a
regression reporting multiple missing commands surfaces all of them at
once instead of halting on the first.

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-24 10:08:16 +01:00
praisonai-triage-agent[bot]andMervin Praison 88e691468b fix: compact long-lived bot/gateway sessions instead of hard-truncating (#2205)
* fix: compact long-lived bot/gateway sessions instead of hard-truncating (fixes #2197)

Wire the existing core ContextCompactor into BotSessionManager so that
persistent bot/gateway conversations summarise older turns (keeping a
recent verbatim tail) once history exceeds the configured budget, rather
than permanently dropping them. Truncation remains the default and is
selectable as an explicit opt-out.

- Add SessionCompactionConfigSchema (enabled/strategy/max_messages/
  max_tokens/keep_recent) to SessionConfigSchema.
- BotSessionManager gains a compaction kwarg that lazily builds a
  ContextCompactor; _save_history compacts before persisting, falling
  back to the legacy tail-slice when disabled or on error.
- build_session_manager forwards session.compaction from config.

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

* fix: isolate per-user compaction state + enforce max_history hard cap (PR #2205 review)

Addresses reviewer findings on PR #2205:
- CodeRabbit (Major): shared ContextCompactor leaked per-conversation state
  (iterative summary, anti-thrashing streaks) across users and raced under
  concurrent _save_history calls. Now store only the config and build a fresh
  compactor per save; instance keeps a _compaction_enabled probe flag.
- Greptile (P1): token-estimate (max_messages*80) could let short-message bots
  grow history unbounded while bypassing the max_history safety valve. Added a
  hard cap (max_history*4) in compaction mode so history can never grow without
  bound, with headroom so it doesn't fight the summary/recent-tail.
- Greptile/CodeRabbit (P2): strategy comment now includes llm_summarize.
- Greptile (P2): tests now importorskip praisonaiagents.compaction so they skip
  cleanly in minimal installs; added multi-user isolation + hard-cap tests.

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

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-24 09:59:52 +01:00
praisonai-triage-agent[bot]andMervin Praison 2b7700ebb7 fix: post-edit diagnostics feedback for built-in file-editing tools (#2214)
* fix: post-edit diagnostics feedback for built-in file-editing tools (fixes #2212)

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

* fix: harden post-edit diagnostics (eslint sandbox, ruff syntax-gate, tsc flags, tests)

- eslint: add --no-config-lookup to prevent execution of repo-controlled
  config/plugins (arbitrary code execution risk in untrusted workspaces)
- ruff: restrict to error-class rules (E9,F63,F7,F82) + --no-cache so clean
  but stylistically-imperfect edits no longer flood diagnostics
- tsc: add --skipLibCheck/--allowJs/--target/--moduleResolution for viable
  per-file invocation without a project tsconfig.json
- tests: add behaviour-based invalid-mode test and "on" mode clean-edit
  "no problems found" coverage

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-24 09:59:29 +01:00
praisonai-triage-agent[bot]andMervin Praison e998e67ed7 fix: per-agent permission scoping + named agent presets (#2215)
* fix: per-agent permission scoping + named agent presets (fixes #2211)

Extend CustomAgent definitions with declarative `permission`/`mode`
blocks and ship built-in `build`/`plan`/`review` presets.

- custom_definitions.py: parse `permission`/`mode` from YAML + markdown
  frontmatter; translate to a flat {pattern: action} config via the new
  resolve_permission_config() helper; register zero-config BUILTIN_PRESETS
  (builtin < user < project precedence).
- run.py: wire parsed per-agent permissions onto the existing
  Agent(approval=ApprovalConfig(permissions=...)) path, with invocation
  --allow/--deny flags overriding the definition.
- Tests for mode/permission resolution, definition parsing, builtin
  preset discovery, and override precedence.

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

* fix: align permission namespace, fail-closed validation, preserve ask prompts

Addresses reviewer findings (Greptile P1/P2, CodeRabbit Major) on PR #2215:

- Namespace: MODE_RULES now use bash:* (the canonical PermissionManager tool
  target) instead of shell:*, which never matched the bash tool.
- Fail closed: resolve_permission_config now raises ValueError on unknown mode
  values and invalid actions (typos like mode: readonly no longer silently
  yield an unrestricted agent). Actions are normalised case-insensitively.
- Interactive ask: _run_custom_agent only forces non_interactive when there is
  no promptable path (no explicit --approval and no ask rules), so the review
  preset's "ask before bash" actually prompts.
- Tests: updated namespace assertions, added fail-closed + case-norm tests, and
  a runtime merge regression test for _run_custom_agent (invocation overrides
  agent perms; ask stays interactive; deny-only is non-interactive). Trailing
  newline added.

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

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-24 09:57:38 +01:00
praisonai-triage-agent[bot]andMervin Praison bbe2b53ade fix: machine-readable streaming event protocol for praisonai run (#2216)
* fix: bridge per-step agent events into stream-json for praisonai run (fixes #2213)

Maps the core agent StreamEventEmitter events onto the CLI OutputController
so `--output stream-json` emits a stable, versioned NDJSON event stream
(run.start, agent.message, tool.start, tool.result, tool.error, text.delta,
reasoning.delta, run.result, run.error) instead of only start/result.

The bridge is a no-op outside JSON modes, leaving TTY/human output unchanged.

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

* fix: address reviewer feedback on stream-json event bridge

- Map core stream-level error to run.error (was tool.error; reserve
  tool.error for tool-scoped failures) per CodeRabbit
- Emit canonical run.start alongside legacy start in STREAM_JSON so the
  documented schema's first event exists (Greptile/CodeRabbit)
- Gate bridge.active on STREAM_JSON only (emit_event only flushes in
  STREAM_JSON; avoids no-op callback overhead in json mode) per Greptile
- Guard except typer.Exit: raise before broad handlers so normal early
  exits don't emit spurious run.error per CodeRabbit
- Log swallowed exceptions at debug level instead of silent pass

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-24 09:57:15 +01:00
MervinPraison 26962238ff Release v4.6.72 2026-06-24 09:51:20 +01:00
MervinPraison 408c069685 ci: auto-deploy praison.ai when install scripts change
Trigger praison.ai CI via workflow_dispatch when install.sh or install.ps1 change on main.
2026-06-24 09:41:25 +01:00
MervinPraison be6eaf8a30 fix(ci): use GH_TOKEN PAT so @claude comment triggers claude.yml 2026-06-24 09:02:25 +01:00
MervinPraison d460a206d2 fix(ci): grant pull-requests write for merge-conflict Claude comments 2026-06-24 09:00:38 +01:00
MervinPraison 215f59bb7c ci: auto-trigger Claude on merge conflicts across PRs
Add merge-conflict-claude workflow to post @claude when internal PRs become DIRTY, harden merge_state detection via GraphQL, and extend auto-pr-comment SDK value gate.
2026-06-24 08:58:36 +01:00
praisonai-triage-agent[bot]andMervin Praison 77f276fb84 fix: deduplicate litellm lazy-import helper in llm/ (#2195)
* fix: deduplicate litellm lazy-import helper in llm/ (fixes #2194)

Extract the duplicated _get_litellm() lazy-import-with-cache pattern from
model_capabilities.py and _cost.py into a single shared _litellm_loader.py.
Both modules now resolve litellm through one shared cache. The _cost.py debug
log on missing litellm is preserved via an on_missing callback.

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

* fix: fire on_missing on every litellm-unavailable call + reset shared loader in tests

- _litellm_loader.get_litellm now invokes on_missing whenever litellm is
  unavailable (including cached failures), so _cost.py's debug log is no longer
  silently dropped when model_capabilities loads litellm first (Greptile P1).
- test_cost setup_method now resets the shared _litellm_loader cache state
  instead of dead attributes on _cost (Greptile P1 / CodeRabbit).
- Added regression test for callback ordering.

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-24 07:37:44 +01:00
praisonai-triage-agent[bot]andMervin Praison 8622064287 fix: consolidate duplicated display lazy-loaders across agent mixins (#2196)
* fix: consolidate duplicated display lazy-loaders across agent mixins (fixes #2193)

Move the canonical thread-safe, cached _get_console/_get_live/
_get_display_functions helpers into a shared agent/_lazy_display.py
module and import them from agent.py and the chat/execution/memory
mixins. Removes three uncached duplicate copies, preserving all
display behaviour and circular-import avoidance while extending
caching to the mixin call sites. No public API change.

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

* fix: use per-resource locks in _lazy_display to avoid cross-resource contention

Splits the single shared _lazy_import_lock into _console_lock, _live_lock,
and _main_module_lock so a slow first import of one display resource does not
block initialisation of the unrelated others. Addresses Greptile P2 feedback.

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-24 07:36:58 +01:00
praisonai-triage-agent[bot]andMervin Praison 7c27a13c2f fix: capability-adapt presentations before rendering (#2204)
* fix: capability-adapt presentations before rendering (fixes #2198)

Add a single, channel-agnostic adapt_presentation() pass in core that
normalises any MessagePresentation against the target PresentationLimits
before channel renderers run:

- Priority-aware button truncation (lowest priority dropped first)
- Label/option truncation to limits
- select -> buttons when supports_select is False
- web_app action -> url when supports_web_apps is False

Wrapper Telegram/Slack/Discord renderers now call the adapter first and
drop their divergent hardcoded caps, fixing the bug where Slack kept the
first 5 buttons by list order (dropping higher-priority actions).

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

* fix: correct Discord button cap, bound degraded select callbacks, drop dead Telegram SELECT path

- Discord max_buttons 25 -> 5 (per-row): total_cap now 5*5=25 matching API,
  so priority-aware truncation actually fires (was 125, bypassing it).
- _select_to_buttons emits a bounded, collision-resistant callback (<=64 chars)
  so long action_id/values no longer collapse to the same Telegram prefix.
- Removed unreachable SELECT branch from Telegram renderer (supports_select=False
  means adapt_presentation already degrades SELECT -> BUTTONS).
- Added tests: Discord 25-cap priority truncation, long-id callback uniqueness,
  short-payload raw passthrough.

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-24 07:36:27 +01:00
praisonai-triage-agent[bot]andMervin Praison 50c5adb2aa fix: AGENTS.md walk-up discovery and layered merge (#2206)
* fix: AGENTS.md walk-up discovery and layered merge (fixes #2203)

Mirror the configuration resolver's project-aware discovery in
load_context_files: walk up from cwd to the git root collecting
instruction files, layer a user-global ~/.praisonai/AGENTS.md as the
lowest-precedence source, and concatenate root -> cwd so nearer files
take precedence. Explicit paths stay cwd-only for backward compatibility.

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

* fix: address reviewer feedback on AGENTS.md discovery (PR #2206)

- Apply consistent OSError guard to explicit-paths branch (Greptile P1)
- Skip global ~/.praisonai/AGENTS.md when walk_up=False (Greptile P2)
- Cap walk-up depth when no git root found (Greptile P2)
- De-duplicate by (st_dev, st_ino) for case-insensitive volumes (CodeRabbit)
- Narrow broad except to (ImportError, OSError) (CodeRabbit BLE001)
- Fix unused unpacked vars in tests; add walk_up=False global-skip test

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-24 07:33:17 +01:00
MervinPraison 2b2d770d06 docs: auto-update api.md [skip ci] 2026-06-24 06:32:59 +00:00
praisonai-triage-agent[bot]andMervinPraison d2ab994c4d fix: built-in store-backed cross-platform identity resolver (#2207)
* fix: add built-in store-backed cross-platform identity resolver (fixes #2201)

Add StoreBackedIdentityResolver in the wrapper that maps
(platform, user_id) to a canonical identity using an explicit,
persisted link map plus the gateway pairing store, with a safe
per-platform fallback for unlinked users. Reuses the core
IdentityResolverProtocol/FileIdentityResolver and the existing
Bot/BotOS identity_resolver wiring.

- StoreBackedIdentityResolver.from_env() wires pairing + persistence
- Paired channels sharing a label resolve to one canonical identity
- link_paired() materialises pairing labels as explicit links
- New `praisonai identity link/unlink/list/import` CLI commands
- Exported from praisonai.bots; added unit tests

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

* fix: address reviewer feedback on store-backed identity resolver

- Add is_paired() O(1) short-circuit before O(n) list_paired() scan on resolve() hot path (CodeRabbit/Greptile)

- Batch link_paired() into a single _flush() instead of N atomic disk writes (Greptile)

- Implement 'identity list' all-links path via new all_links() API matching documented behaviour (Greptile)

- Fix misleading 'Fast path' comment; rename ambiguous loop var l -> link to clear Ruff E741 (CodeRabbit)

- Make degraded-path test force the pairing import failure with an explicit path (Greptile)

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-24 07:32:39 +01:00
MervinPraison eca525824e docs: auto-update feature parity trackers [skip ci] 2026-06-24 06:32:12 +00:00
praisonai-triage-agent[bot]andMervin Praison 102b43dba1 fix: command-aware permission matching for shell tool calls (#2208)
* fix: command-aware permission matching for shell tool calls (fixes #2202)

Decompose bash:/shell: permission targets into their constituent
simple-commands (handling &&, ||, ;, |, subshells and command
substitution) plus truncating redirections, and evaluate each
file-mutating operation against the ruleset. A deny rule for rm now
fires regardless of where rm appears in a compound statement.

- New permissions/command_parser.py: dependency-light shlex-based parser.
- PermissionManager.check expands shell targets into per-operation
  sub-targets (deny-wins, then ask, then allow). Single commands defer
  to the legacy flat matcher for exact backward compatibility.
- Lazy-imported parser; zero overhead when not matching shell targets.

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

* fix: parser redirect bugs + legacy-rule aggregation in command-aware perms

Address reviewer feedback (Greptile, CodeRabbit) on PR #2208:
- command_parser: consume input-redirect operand so `< file` is never
  selected as the executable (was a no-op skip_next=False).
- command_parser: skip fd-to-fd redirects like `2>&1` so they no longer
  emit a spurious `write:&1` target that broad `write:*` denies would hit.
- command_parser: mask single-quoted spans in _extract_substitutions so
  `echo '$(rm -rf x)'` is treated as a literal (double quotes still expand).
- manager: fold an explicit rule/approval match on the original compound
  target into deny>ask>allow aggregation so legacy flat rules still fire.
- ruff nits: RUF005 (list unpacking), B007 (unused idx), BLE001 (noqa).
- tests: add regressions for input/fd redirects, quoted substitution,
  ASK precedence, and legacy compound deny.

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-24 07:31:48 +01:00
praisonai-triage-agent[bot]andMervin Praison e2a5053019 fix: run-scoped guardrail for unattended scheduled agent runs (#2209)
* fix: add run-scoped guardrail for unattended scheduled agent runs (fixes #2200)

Introduce RunPolicy in the wrapper scheduler and enforce it in
ScheduledAgentExecutor so unattended runs execute under a declarable policy:
toolset scoping (deny self-scheduling/interactive by default), assembled-prompt
injection scanning, durable output audit, fail-closed delivery, and a
last_status vs last_delivery_error distinction.

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

* fix: address reviewer findings for run-scoped scheduler guardrail

- Scope built-in injection scan to UNTRUSTED content (job message +
  loaded skills/recipes) only; exclude trusted admin-authored
  system_prompt/instructions/backstory to avoid false-positives that
  silently blocked legit scheduled agents (Greptile P1).
- Sanitize job.id in audit filename and use time_ns() to prevent path
  traversal and same-second collisions (Greptile/CodeRabbit).
- _tool_name: resolve name/__name__ before str(tool) fallback so a
  failing custom __str__ cannot break scheduling (CodeRabbit).
- Scanner failures: log exception type only (no prompt leak) and ensure
  falsy custom-scanner results carry a useful block reason (CodeRabbit/Greptile).
- _restore: log tool-restore failures instead of silently swallowing (CodeRabbit).
- tests: use asyncio.run(); cover untrusted-vs-trusted scan, scanner
  fail-closed no-leak, and falsy-reason behaviour.

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-24 07:30:05 +01:00
MervinPraison c9926857c4 docs: auto-update documentation parity trackers [skip ci] 2026-06-24 06:27:51 +00:00
praisonai-triage-agent[bot]andMervin Praison 454564d0bd fix: emit gateway/session/schedule lifecycle hooks from bot runtime (#2210)
* fix: emit gateway/session/schedule lifecycle hooks from bot runtime (fixes #2199)

The gateway runtime only fired the three MESSAGE_* hooks, leaving
observability/guardrail/policy plugins blind to the rest of the gateway
lifecycle. This wires the already-defined HookEvents at their natural
points using the existing HookRunner:

- GATEWAY_START / GATEWAY_STOP in BotOS.start()/stop()
- SESSION_START (once per session) / SESSION_END (on /new reset) in
  BotSessionManager
- SCHEDULE_TRIGGER when a scheduled job fires in BotOS

BEFORE_AGENT / AFTER_AGENT are intentionally NOT re-emitted at the
gateway since agent.chat() already fires them (avoids double-dispatch).

Core adds the three missing input dataclasses (GatewayStartInput,
GatewayStopInput, ScheduleTriggerInput) plus exports. A shared async-safe
_emit() dispatcher schedules runner.execute() when inside a running event
loop (execute_sync raises there) and falls back to execute_sync otherwise.

All emission is best-effort and a no-op when no hooks are registered, so
behaviour is fully backward-compatible with zero overhead.

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

* fix: per-session hook context + sync SESSION_END across all clear paths

Addresses reviewer feedback (Greptile P1/P2, CodeRabbit) on PR #2210:

- Replace shared _last_agent_ref with per-session _session_hook_context so
  reset(userA) can never emit SESSION_END through userB's agent/runner.
- Centralize SESSION_END in _fire_session_end() and call it from every clear
  path (explicit reset, policy auto-reset in _load_history, stale reaping,
  reset_all) so lifecycle hooks no longer go silent after a policy reset.
- Track fire-and-forget hook tasks in _emit() with a strong ref + done
  callback so tasks aren't GC'd mid-flight and coroutine errors are logged.
- Remove dead fire_before_agent/fire_after_agent (never called; would cause
  the double-dispatch the PR explicitly avoids).
- Tests: replace timing-based asyncio.sleep with deterministic task draining;
  add coverage for per-session agent attribution and the auto-reset reopen path.

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

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-24 07:27:28 +01:00
praisonai-triage-agent[bot]andMervin Praison bc8af42826 fix: frictionless environment-isolated CLI install (uv/pipx/venv + completions) (#2176)
* fix: frictionless environment-isolated CLI install (uv/pipx/venv) (fixes #2173)

Enhance scripts/install.sh with an isolation-backend preference order
(uv tool -> pipx -> venv fallback), a non-destructive ~/.local/bin PATH
shim with a clearly-marked rc block, --no-modify-path, PRAISONAI_INSTALL_DIR
and PRAISONAI_BACKEND overrides, and optional shell-completion wiring via
the existing `praisonai completion` subcommand. Keeps the venv fallback,
bot onboarding, and pip install fully backward-compatible.

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

* fix: correct uv/pipx verification, dedupe PATH block, reject invalid backend

Address reviewer feedback on install.sh:
- verify_installation: uv/pipx backends installed into an isolated env, but
  verification used system python3 which always failed the import and aborted
  the script under set -e. Now verifies via the resolved praisonai CLI when no
  venv python is present, while keeping the direct import check for venv.
- venv backend wrote two PraisonAI PATH blocks (venv/bin + ~/.local/bin) on a
  fresh install. The shim covers ~/.local/bin, so only add venv/bin to PATH
  when no shim is created (dry-run or missing binary).
- detect_backend silently fell back to auto-detection for unknown --backend
  values. Now rejects them with a clear error and exits 1.

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-23 13:02:31 +01:00
82e60c2a51 fix: handoff must execute via agent.chat, not bare LLM runtime (#2178)
* fix: handoff must execute via agent.chat, not bare LLM runtime

Turn-time runtime resolution in #2073 bypassed the target agent's full
chat pipeline (instructions, memory, hooks, guardrails) by calling a
fresh LLM wrapper when resolve_runtime succeeded. Restore execution
through agent.chat/achat and add regression tests.

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

* fix: harden async handoff achat guard and strengthen regression tests

- Guard achat with inspect.iscoroutinefunction so sync-only/proxy agents
  fall back to the executor path instead of failing at await
- Widen executor effective_tools type to Optional[List[Any]] to preserve
  the None (unrestricted) vs [] (no tools) tool-policy contract
- Assert resolve_runtime is never called in the async regression test
- Make sub-agent tool test exercise the real chat path instead of mocking
  the method under test

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-23 13:01:36 +01:00
praisonai-triage-agent[bot]andMervin Praison 470a2dc56a fix: add /ready and /live probes to Gateway for zero-downtime deploys (#2181)
* fix: add /ready and /live probes to Gateway for zero-downtime deploys (fixes #2180)

Add dedicated readiness and liveness HTTP probes to the Gateway so
orchestrators and load balancers can safely perform rolling/zero-downtime
deployments:

- GET /ready returns 200 only when started and not draining; 503 during
  startup (startup-pending) and graceful shutdown (draining), and for any
  FAILED channel (channel:<name>). A _draining flag is set at the start of
  stop() before draining begins so LBs stop routing first.
- GET /live returns 200 while the event loop is responsive (measured via
  asyncio scheduling lag), independent of transient channel health.
- /health is unchanged (backward compatible).

Wrapper-layer change only (gateway/server.py); core stays protocol-only.

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

* fix: harden gateway readiness/liveness probes per reviewer feedback

- Reset _draining=False at start() so a stopped-then-restarted instance
  reports ready again (Greptile P1 / CodeRabbit Major)
- Use asyncio.get_running_loop() instead of deprecated get_event_loop()
  (Greptile P2)
- Make readiness use elif so post-shutdown state reports only "draining",
  not the misleading "startup-pending" (Greptile P2)
- Fail closed in probe helpers: log and surface errors instead of silently
  reporting healthy (CodeRabbit Major)
- Fix test sys.path bootstrap to resolve from repo root; add stop/restart
  and draining-state regression tests (CodeRabbit Minor)

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-23 13:00:38 +01:00
praisonai-triage-agent[bot]andMervin Praison dbcdc48f9b fix: memory search/store schema mismatch after backend fallback to SQLite (#2190)
* fix: delegate memory search/store to adapter on sqlite/in_memory fallback (fixes #2189)

When a memory backend (e.g. redis) is unavailable and PraisonAI falls back
to the SqliteMemoryAdapter, legacy search_short_term/search_long_term queried
the never-created short_mem/long_mem tables, raising
"sqlite3.OperationalError: no such table: short_mem".

Delegate search_short_term and search_long_term to the active memory_adapter
when provider is sqlite/in_memory, and route store_long_term through the
adapter (mirroring store_short_term) so storage and search use the same
short_term_memory/long_term_memory schema. Legacy SQLite paths remain as a
graceful fallback if the adapter call fails.

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

* fix: harden memory adapter delegation per reviewer feedback (#2189)

- store_long_term: gate adapter-first write by provider in (sqlite, in_memory)
  so chroma/mem0/mongodb keep their dedicated write paths (fixes Greptile P1
  embedding-model mismatch) and sync ident to adapter result_id.
- store_long_term: raise on adapter failure for sqlite/in_memory instead of
  silently falling back to the non-existent legacy long_mem table.
- search_short_term: apply min_quality/relevance_cutoff filtering, emit search
  event, and return [] on adapter failure instead of legacy short_mem fallthrough.
- search_long_term: return [] on adapter failure instead of legacy long_mem
  fallthrough to avoid re-triggering the schema mismatch.

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-23 12:58:26 +01:00
Chanhyo Jung 576d54c9a8 fix: remove duplicate Valkey client_name argument (#2168) 2026-06-23 12:55:31 +01:00
praisonai-triage-agent[bot]andMervin Praison f4e8904d94 fix: make ChannelDirectory durable and refreshable (#2186)
* fix: make ChannelDirectory durable and refreshable (fixes #2185)

Persist home + observed channels to ~/.praisonai/state/channel_directory.json,
apply a durable hand-editable friendly-alias overlay (channel_aliases.json) on
every load/refresh, and add refresh_from_adapters() to enumerate live adapters
(Discord/Slack list_channels) so reachable targets survive restarts and can be
pre-named before producing traffic. Surfaces observed channels via
describe_targets and adds DeliveryRouter.refresh_directory for the gateway loop.

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

* fix: crash-safe atomic save, prune stale aliases, persist observations

Address reviewer feedback on PR #2186 (Greptile P1/P2, CodeRabbit):
- _save() now writes to a temp file and os.replace()s atomically so an
  interrupted write can never leave a corrupt directory file that _load
  silently discards.
- _apply_alias_overlay() tracks overlay-sourced aliases and prunes those
  removed from channel_aliases.json on the next load/refresh, making the
  overlay the source of truth for the aliases it defines.
- observe_channel() persists new observations immediately (best-effort),
  so channels seen from live traffic survive a restart, consistent with
  set_home_channel() and the class docstring.

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-23 12:36:52 +01:00
praisonai-triage-agent[bot]andMervin Praison 3ec0fc437f fix: cross-session conversation recall via session_search (#2184) (#2187)
* fix: add cross-session conversation recall via session_search (fixes #2184)

Add a searchable session-store contract and wire the built-in session_search
tool to the default store so an agent can search its own past conversations.

- protocols: SearchableSessionStoreProtocol + SessionHit/SessionSummary
- DefaultSessionStore: dependency-free search()/window()/recent()
- tools: session_search now supports discovery/scroll/browse over transcripts
- export the tool via the tools registry (tools=["session_search"])

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

* fix: harden session_search per reviewer feedback (#2184)

- search(): cumulative per-session scoring (was best-single-message) so
  densely-relevant transcripts rank above short single-match sessions
- search()/_session_title(): guard against malformed/non-dict message
  entries so one bad session file cannot abort the whole query
- session_search tool: structured error context (tool/mode/session_id/hint)
- SessionTools.store: thread-safe double-checked lazy init

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-23 12:36:26 +01:00
praisonai-triage-agent[bot]andMervin Praison 4be9d828e1 fix: add agent-facing send_message tool for proactive gateway delivery (#2188)
* fix: add agent-facing send_message tool for proactive gateway delivery (fixes #2183)

Exposes the existing proactive-delivery stack to agents via a lightweight
built-in tool. Adds OutboundMessengerProtocol + DeliveryResult/TargetInfo to
core gateway protocols, a task-local messenger registry in session context,
and a send_message tool (action="send"/"list") that resolves the active
gateway messenger and fails cleanly for CLI/one-shot runs.

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

* fix: correct async loop bridge and media parsing in send_message tool

Addresses reviewer (Greptile) findings on PR #2188:

- P1: _run_async no longer spins up a brand-new event loop in a worker
  thread (which broke messengers with loop-bound async resources and
  raised "Task got Future attached to a different loop"). It now detects
  the gateway loop registered by the running bot and schedules the
  coroutine on it via run_coroutine_threadsafe — the real bot pattern
  where sync tools run in executor threads. Keeps a deadlock-free
  fallback for the on-loop-thread case.
- P2: ContextVar/loop propagation — gateway records its loop process-wide
  (register/get/clear_gateway_loop) so sync tools in worker threads reach
  loop-bound resources correctly.
- P2: _parse_media now strips only the MEDIA: directive via regex instead
  of whitespace-tokenizing, preserving surrounding message text.

Adds tests: loop-bound messenger across threads, and media-path text
preservation.

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-23 12:35:58 +01:00
praisonai-triage-agent[bot]andMervin Praison 5a2d3f05c3 fix: operator scopes/roles for multi-operator Gateway access control (#2182)
* fix: add operator scopes/roles for multi-operator Gateway access control (fixes #2179)

Layer an opt-in operator-scope model on top of the existing Gateway
authentication so multi-operator deployments can enforce least-privilege.

Core (praisonaiagents.gateway):
- Add OperatorScope vocabulary enum (read/write/approvals/pairing/admin).
- Add auth_scopes scope policy to GatewayConfig with resolve_scopes() and
  has_scope_policy; parse from gateway.yaml (auth.tokens or flat auth_scopes).

Wrapper (praisonai/gateway):
- Resolve scopes per request/connection; gate privileged HTTP routes
  (channels pause/resume/reconnect -> admin, approval resolve + allow-list
  mutations -> approvals, pairing approve/revoke -> pairing).
- Gate WebSocket "message" dispatch on the write scope.
- Filter outbound broadcasts by subscriber scope (approval-class events only
  reach approvals-holders).

Backward compatible: when no scope policy is configured an authenticated
client is granted all scopes, identical to the prior binary behaviour.

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

* fix: harden operator-scope resolution (validate scope names, loopback bypass, rate-limit ordering)

Addresses reviewer feedback on PR #2182:
- config.resolve_scopes now validates scope names against OperatorScope,
  dropping unknown names (e.g. YAML typos) with a warning instead of
  silently denying all access.
- Gateway loopback dev bypass now grants all operator scopes (consistent
  with the auth bypass) so local testing with a scope policy works.
- approval_allowlist checks the APPROVALS scope before consuming the
  rate-limit budget so read-only clients can't drain a source IP's budget.

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-23 12:34:40 +01:00
MervinPraison 11736737e8 docs: auto-update api.md [skip ci] 2026-06-23 11:34:17 +00:00
praisonai-triage-agent[bot]andMervin Praison b438cd8cc6 feat: add praisonai init to scaffold the .praisonai/ convention (#2175)
* feat: add `praisonai init` to scaffold the .praisonai/ convention (fixes #2174)

Adds a top-level `init` command that scaffolds the project .praisonai/
structure (config.yaml + starter agent + starter command) matching exactly
what CustomDefinitionsDiscovery parses, so scaffolded definitions are
immediately discoverable and runnable. Supports --global and --force, is
idempotent, and prints next-step commands. Updates the "not found" hints in
agent/command list to point users at `praisonai init`.

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

* fix: graceful IO error handling, utf-8 encoding, drop unused import in init

Addresses reviewer (Greptile) feedback on praisonai init:
- Catch OSError/PermissionError so write failures show a friendly
  message + remediation and exit 1 instead of a raw traceback.
- Pass encoding="utf-8" to write_text for cross-platform consistency.
- Remove unused Optional import.

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-23 12:33:55 +01:00
MervinPraison 6bedd1f26b docs: auto-update documentation parity trackers [skip ci] 2026-06-23 11:33:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 4b58537311 fix: resilient fuzzy file editing + multi-file apply_patch (#2177)
* fix: resilient fuzzy multi-strategy file editing + apply_patch (fixes #2172)

Add a dependency-free fuzzy matching ladder (exact, line-trimmed,
whitespace-normalised, indentation-flexible, similarity-scored
block-anchor) to EditTools.edit_file so edits land even when old_string
differs by whitespace/indentation/line-endings, only applying when the
target is unambiguous. Add an atomic apply_patch tool for multi-file
Add/Update/Delete in one call. Backward-compatible: exact matches behave
as before; fuzzy paths only engage on exact-match failure.

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

* fix: address reviewer findings in fuzzy edit + apply_patch

- Exclude matched line's trailing newline from fuzzy spans so replacements
  no longer merge with the following line (CodeRabbit Major).
- Stop *** Begin/End Patch sentinels from leaking into Add/Update bodies in
  _parse_patch (Greptile P1).
- Preserve BOM and CRLF on apply_patch Update via shared binary read/encode
  helpers, matching edit_file (Greptile/CodeRabbit).
- Make apply_patch Phase 2 truly atomic: stage writes/deletes and roll back
  on any commit-time failure (Greptile/CodeRabbit).
- Continue scanning block-anchor candidates instead of breaking after the
  first anchor pairing (Greptile P2).
- Remove dead _AMBIGUOUS constant; resolve Ruff E731/E741 lint.
- Add regression tests: mid-file no-merge, sentinel body exclusion,
  BOM/CRLF preservation, mid-Phase-2 rollback, anchor multi-candidate scan,
  and fuzzy ambiguity error.

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

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-23 12:32:36 +01:00
praisonai-triage-agent[bot]andMervin Praison af7e652ae8 fix: wire real cost tracking, lazy imports, and live aliases in wrapper (#2171)
* fix: wire real cost tracking, lazy imports, and live aliases in wrapper (fixes #2170)

- scheduler: replace fake $0.0001/run estimate with model-aware cost from
  token usage via cost_tracker.get_pricing; shared _compute_run_cost in
  _base_scheduler keeps sync/async in lockstep and falls back to $0 when no
  usage metadata is available
- agents_generator: defer heavy yaml/rich/framework-adapter-registry imports
  to call sites via lazy wrappers, removing import-time work from every CLI
- llm registry: register built-in aliases (oai/claude/gemini/google_genai)
  by checking the loader map populated at __init__ instead of the empty
  resolved cache

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

* fix: harden run-cost computation against empty model and bad tokens

Addresses reviewer findings on PR #2171:
- Greptile: get_pricing("") substring-matched gpt-4o instead of the
  'default' tier; pass `model or "default"`.
- CodeRabbit: clamp token counts to non-negative ints (budget-bypass guard)
  and isolate pricing exceptions so a successful run is never marked failed.

Adds TestComputeRunCost regression coverage.

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: Mervin Praison <[email protected]>
2026-06-23 12:31:53 +01:00
MervinPraison f882c0cfdb Merge branch 'main' of https://github.com/MervinPraison/PraisonAI 2026-06-23 09:12:51 +01:00
MervinPraison bb98e7988f chore(workflows): update model in claude.yml configuration
Changed the model from 'opus' to 'claude-opus-4-8' to improve performance.
2026-06-23 09:12:37 +01:00
MervinPraison 52ee3aac10 docs: auto-update documentation parity trackers [skip ci] 2026-06-23 08:08:09 +00:00
55bbb3d829 fix: Turn-time runtime resolution for handoffs and sub-agents (#2073)
* feat: implement turn-time runtime resolution for handoffs and sub-agents (fixes #1938)

- Add runtime resolution system with turn-time resolution API
- Replace construction-time runtime pins with dynamic resolution
- Update handoff execution to resolve runtime per-turn using target agent's model
- Add caching mechanism for per-turn resolution with session isolation
- Support both sync and async handoff execution paths
- Provide fallback to agent.chat when runtime resolution fails
- Include comprehensive unit and integration tests

This addresses the issue where handoffs and sub-agents inherit construction-time
runtime pins from parent agents, causing wrong harness execution when delegates
use different model_ref or provider.

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

* fix: address critical bugs in turn-time runtime resolution

- Fixed NameError in _cleanup_expired_cache when cache is empty
- Fixed FallbackRuntime to raise errors instead of returning stub strings
- Added kwargs passthrough to preserve custom configurations (API keys, base URLs)
- Replaced deprecated asyncio.get_event_loop() with get_running_loop()
- Fixed AttributeError risks with safe getattr fallbacks
- Removed dead code in unified_execution_mixin.py
- Cleaned up unused imports
- Updated tests to match new FallbackRuntime behavior

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-23 09:07:41 +01:00
MervinPraison b65ca182df docs: auto-update feature parity trackers [skip ci] 2026-06-23 08:07:01 +00:00
praisonai-triage-agent[bot]andMervin Praison aff66b1630 fix: add memory adapter functions to lazy imports (#2167)
* fix: add register_memory_adapter, get_memory_adapter, list_memory_adapters to _LAZY_IMPORTS (fixes #2166)

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

* fix: add missing memory adapter registry functions to lazy imports

- Added register_memory_factory for factory-based adapters
- Added canonical aliases per naming guide: add_memory_adapter, add_memory_factory
- Added has_memory_adapter for checking adapter existence
- Functions already exist in adapters module, just needed top-level exposure

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-23 09:06:35 +01:00
MervinPraison e908ba8745 docs: auto-update feature parity trackers [skip ci] 2026-06-23 07:23:33 +00:00
Mervin PraisonandCursor e4e70a0a06 Add lazy top-level re-exports for docs B.1 symbols. (#2165)
Expose skills, knowledge, memory, and workflow helpers via praisonaiagents/__init__.py so documentation can use friendly imports without deep module paths.

Co-authored-by: Cursor <[email protected]>
2026-06-23 08:23:12 +01:00
MervinPraison 8b80fa59cf docs: auto-update documentation parity trackers [skip ci] 2026-06-23 00:43:33 +00:00
MervinPraisonandCursor 985b18ff27 fix(parity): resolve Python docs orphan categories
Add doc-category aliases, orphan exclusions, and module-path hints
so the parity tracker no longer flags valid SDK docs as orphans.

Co-authored-by: Cursor <[email protected]>
2026-06-23 01:43:02 +01:00
MervinPraison fff694625c Release v4.6.71 2026-06-23 00:53:51 +01:00
MervinPraison 7bcac63d6d chore: move runtime capability root tests into unit test folders
Relocate PR #2072 runtime tests from repo root into tests/unit/runtime and
tests/unit/agent; remove sys.path hacks and use pytest patterns.
2026-06-23 00:52:53 +01:00
MervinPraison d0a89050b2 docs: auto-update api.md [skip ci] 2026-06-22 23:51:42 +00:00
32d149967b fix: DoctorContractProtocol for runtime config migration (#2075)
* feat: implement DoctorContractProtocol for runtime config migration (fixes #1941)

- Add DoctorContractProtocol in praisonaiagents/runtime/doctor_protocol.py
- Implement built-in CliBackendMigrationRule for cli_backend -> models.default.runtime migration
- Add plugin discovery via praisonai.doctor_contracts entry points
- Wire into 'praisonai doctor runtime --fix' CLI command
- Include comprehensive unit tests
- Support idempotent migrations with backup creation
- Protocol-driven design: core protocols in SDK, heavy implementations in wrapper

* fix: address critical issues found in code review

- Fix @register_check decorator to include required arguments (id, title, description, category)
- Update function signature to accept DoctorConfig parameter
- Fix shallow copy bug by using deepcopy to prevent mutation of original config
- Add type checks for roles dict to prevent AttributeError
- Fix dead validation code in register_rule (isinstance check)
- Remove unnecessary abc.abstractmethod decorators from Protocol
- Prioritize importlib.metadata over deprecated pkg_resources
- Return ERROR status when explicitly specified config file not found
- Serialize YAML before opening file to prevent corruption on errors
- Remove test files from repository root

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

* fix: remove duplicate RUNTIME enum value in CheckCategory

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

* fix: correct import path for DoctorRulesRegistry in tests

- Fixed import to use doctor_registry instead of registry module
- All 14 tests now pass successfully after rebase

* fix: correct runtime __init__.py merge - use lazy loading for doctor protocol

- Removed direct imports of doctor_protocol and doctor_registry to avoid import-time performance regression
- Added doctor protocol components to _LAZY_GROUPS for proper lazy loading
- Maintains all exports in __all__ while following the lazy loading pattern
- Fixes merge issue where both changes were accepted but not properly integrated

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: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 00:51:22 +01:00
MervinPraison b40a93f044 docs: auto-update api.md [skip ci] 2026-06-22 23:49:35 +00:00
praisonai-triage-agent[bot]andMervinPraison 0cd5f16d71 fix: add fail-fast YAML schema validation with actionable errors (#2128)
* fix: add fail-fast YAML schema validation with actionable errors (fixes #2125)

- Add comprehensive Pydantic-based schema validation for agents/tasks/workflow
- Implement fail-fast validation with aggregated error reporting
- Add cross-reference validation for agents, tasks, tools, and handoffs
- Create 'praisonai validate' CLI command for pre-commit/CI validation
- Update agents_generator to fail on validation errors (not just warn)
- Add pydantic dependency for schema validation

Key improvements:
- Required fields are now enforced (role, goal, backstory)
- Type checking for all configuration values
- Cross-references validated (tasks->agents, tools->definitions, handoffs->roles)
- Unknown fields detected with suggestions
- Aggregated error messages show all problems at once
- --strict mode treats warnings as errors

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

* fix: address all 6 validation issues from CodeRabbit review

- Fix JSON output for missing files in validate command
- Fix stream alias not enabling streaming field (use model_validator before)
- Add validation to reject unknown workflow step types
- Fix handoff validation order-dependency (two-pass validation)
- Handle non-dict YAML input gracefully without crashing
- Remove overly permissive endswith('Tool') fallback

These fixes ensure fail-fast validation works correctly with proper error
aggregation and backward compatibility as intended.

* fix: make schema validation compatible with existing YAML formats

- Change llm/function_calling_llm from Optional[str] to Optional[Union[str, Dict[str, Any]]] to support dict format with 'model' key
- Add tasks field to AgentConfig to support nested task definitions
- Add dependencies to known_top_level fields in validator
- Add task normalization logic to convert dict tasks to TaskConfig objects

These changes ensure backward compatibility with all existing YAML configurations while maintaining validation capabilities.

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-06-23 00:49:13 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 39e068b791 feat: add industry templates for faster agent workforce deployment (#2143)
* feat: add industry templates for faster agent workforce deployment (fixes #2142)

- Added 5 industry-specific templates based on SRAO Framework
- Manufacturing: order processing, inventory, scheduling, quality control
- Energy: wind farm monitoring, predictive maintenance, power forecasting
- Healthcare: emergency triage with HIPAA compliance, resource allocation
- Agriculture: precision farming, pest detection, yield prediction
- Transportation: infrastructure monitoring with LiDAR, safety assessment

Each template includes:
- Complete agent definitions with specific roles
- I/O schemas using Pydantic for structured data handling
- SLA requirements for performance guarantees
- Fallback strategies for reliability
- Cross-industry reusable patterns (70% code reuse)

These templates enable rapid deployment of agent workforces with minimal setup time, allowing users to quickly adapt PraisonAI to their specific industry needs.

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address critical issues in industry templates

- Add missing Optional import in agriculture_template.py
- Fix __init__.py to properly import template modules
- Fix None in resources_dispatched list in transportation_template.py
- Add missing affected_area field in spray recommendation
- Fix maintenance_type to use documented values (preventive vs routine)

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-23 00:47:44 +01:00
praisonai-triage-agent[bot]andMervin Praison 98ac919de2 fix: gateway resume can silently lose events - add integrity checks (#2155)
* fix: gateway resume can silently lose events - add integrity checks (fixes #2153)

- Track oldest retained cursor in session buffer
- Add resync_required flag when requested cursor is below oldest retained
- Send authoritative snapshot instead of partial replay when gap detected
- Add top-level seq field to all events for client-side gap detection
- Update wire protocol documentation with new resume fields

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

* fix: address reviewer feedback on gateway integrity checks

- Add input validation for 'since' cursor to prevent type errors
- Include token_stream and tool_call_stream events in seq stamping
- Clarify get_oldest_cursor() semantics with documentation
- Document resume_or_create_session() replay_events caveat

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-23 00:47:00 +01:00
Mervin Praisonandpraisonai-triage-agent[bot] bf97b9556e fix: add protocol validation to framework adapter registry (#2083)
- Add _validate_adapter method to enforce required keyword-only parameters
- Override create() to validate adapters on instantiation
- Handle TypeError in is_available() for invalid adapters
- Ensures all framework adapters implement the correct protocol signature

Fixes #1654

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-23 00:46:40 +01:00
8ccf2a7aec fix: Runtime capability matrix and hook compatibility (#2072)
* feat: implement runtime capability matrix and hook compatibility system

- Add RuntimeCapability enum with 12 capability flags (native_hooks, tool_loop, streaming_deltas, etc.)
- Implement RuntimeCapabilityMatrix for declaring runtime capability sets
- Add AgentRuntimeProtocol requiring capabilities() method for all runtimes
- Create validate_capabilities() with fail-fast validation and CapabilityValidationError
- Add RuntimeConfig to agent configuration system with required_capabilities support
- Integrate runtime parameter in Agent constructor with validation at creation time
- Update CliBackendProtocol to require capability reporting for consistency
- Provide built-in capability matrices for native and plugin harness runtimes
- Add comprehensive unit tests and validation for all functionality
- Enable fail-fast capability validation at config/preflight time instead of mid-turn

Fixes #1936

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

Co-Authored-By: Claude <[email protected]>

* fix: address critical issues from code review

- Fix capability validation to check actual runtime capabilities instead of always-true native matrix
- Remove duplicate incompatible CliBackendProtocol definition
- Fix hard-coded CI paths in test files for portability
- Add proper type safety and error handling
- Add list/set support to resolve_runtime function
- Improve error messages with remediation guidance
- Handle empty required_capabilities safely
- Serialize enum values properly in to_dict()

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

* chore: remove temporary validation test file

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

* fix: update runtime config resolution to support both AgentRuntimeConfig and RuntimeConfig

- Modified _resolve_runtime_config to handle both turn-based (AgentRuntimeConfig) and capability validation (RuntimeConfig) systems
- Added _validate_runtime_capabilities method to perform capability validation
- Ensures compatibility with both main's turn-based runtime and PR's capability system

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

* fix: update runtime capability mapping to handle all documented runtime IDs

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-23 00:43:14 +01:00
0d1b699671 fix: wrapper layer framework adapter issues - protocol-driven dispatch, fail-fast stubs, conditional tool registration (#2086)
- Add resolve_alias() to FrameworkAdapter protocol for version selection
- Add AutoGenFamilyAdapter as router for autogen version dispatch
- Add resolve() override to properly create concrete adapter instances
- Mark unimplemented adapters (v0.4, AG2) with NotImplementedError
- Fix autogen_v2 dispatch by setting correct name attribute
- Move autogen tool registration to be conditional on framework family
- Include autogen_v2 in autogen family tool registration guard

Fixes #1590

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-23 00:27:06 +01:00
MervinPraison 0adde0ea1f chore: move test_artifact_store.py into context unit tests
Relocate root-level artifact store test to tests/unit/context/ and remove
sys.path hack for proper package imports.
2026-06-23 00:12:14 +01:00
praisonai-triage-agent[bot]andMervin Praison a030599171 fix: implement ArtifactStore for retrievable tool output overflow (#2149)
* fix: implement ArtifactStore for retrievable tool output overflow (fixes #2148)

- Add concrete FileSystemArtifactStore implementing ArtifactStoreProtocol
- Wire into tool_execution.py to spill large outputs instead of discarding
- Add artifact retrieval tools (head, tail, grep, chunk, load)
- Add ToolOutputConfig for configurable limits (max_bytes, retention_days)
- Register retrieval tools dynamically on first artifact creation
- Add garbage collection for old artifacts

This preserves full tool outputs that exceed limits in retrievable artifacts instead of silently truncating and discarding the overflow.

* fix: address critical security and architectural issues in artifact store

- Add path traversal protection by validating artifact paths are within store directory
- Replace global artifact store with thread-local storage for multi-agent support
- Preserve artifact references through context manager truncation
- Add missing artifact_load and artifact_list tools to registration
- Fix tool_output_limit being overwritten by tracking custom config
- Propagate tool_output config in clone_for_channel for cloned agents
- Add proper error logging for artifact cleanup failures
- Add validation for ToolOutputConfig parameters
- Fix run_id filter in list_artifacts when agent_id not provided
- Fix cleanup_old_artifacts to handle days=0 correctly
- Fix secret redaction to cover JSON and list content
- Fix artifact_load to use correct mime_type from metadata

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

* fix: reduce Agent parameter bloat by merging artifact config into ToolConfig

- Merged ToolOutputConfig fields into existing ToolConfig class
- Removed tool_output parameter from Agent to avoid bloat
- All tool-related config now consolidated in one place (ToolConfig)
- Fixed NameErrors in artifact_grep and artifact_list tools
- Path traversal protection already in place
- Tests pass successfully

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: Mervin Praison <[email protected]>
2026-06-23 00:09:36 +01: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
364 changed files with 45639 additions and 4681 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,
};
+203 -44
View File
@@ -15,7 +15,10 @@ on:
pull_request_review:
types: [submitted]
pull_request:
types: [opened]
types: [opened, synchronize]
schedule:
- cron: '30 3,9,15,21 * * *'
workflow_dispatch:
env:
MAX_POLL_ATTEMPTS: 20
@@ -143,9 +146,12 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
const alreadyPosted = comments.data.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');
});
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
core.setOutput('comments_count', comments.data.length.toString());
@@ -186,7 +192,8 @@ jobs:
const claudeBody = '@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. ' + reviewSummary + '.\n\n' +
'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).\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});
@@ -209,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
@@ -295,9 +306,12 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
const alreadyPosted = comments.data.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');
});
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Trigger Claude final review
@@ -313,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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. 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)
# ================================================================
@@ -332,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
@@ -399,9 +430,12 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
const alreadyPosted = comments.data.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');
});
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Trigger Claude final review
@@ -417,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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. 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.
@@ -453,9 +500,12 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo
});
const alreadyPosted = comments.data.some(c =>
c.body.includes('@claude') && triggerLogins.includes(c.user.login)
);
const alreadyPosted = comments.data.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');
});
core.setOutput('already_triggered', alreadyPosted ? 'true' : 'false');
- name: Post Claude analysis request (Lead Engineer)
@@ -468,9 +518,127 @@ 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\n\n**Phase 2: FIX Valid Issues**\n4. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n5. Push all code fixes directly to THIS branch (do NOT create a new PR)\n6. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n7. If all issues are resolved, approve the PR / close the Issue\n8. 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'
});
# ================================================================
# Recovery: post FINAL @claude if merge-conflict comment blocked the chain
# (e.g. conflict fired before claude-after-copilot could post review prompt)
# ================================================================
claude-review-recovery:
if: |
github.event_name == 'pull_request' &&
github.event.action == 'synchronize'
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
steps:
- uses: actions/checkout@v4
- name: Re-post FINAL Claude review when stale or missing
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 pr = context.payload.pull_request.number;
const owner = context.repo.owner;
const repo = context.repo.repo;
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 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;
}
const hasReviewerActivity = comments.some(c => {
const login = c.user.login.toLowerCase();
return login.includes('coderabbit') || login.includes('copilot') ||
login.includes('greptile') || login.includes('qodo') || login.includes('gemini');
});
if (!hasReviewerActivity) {
core.info('No bot reviewer activity yet, skipping recovery');
return;
}
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,
});
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.
# Trigger them explicitly. Do NOT trigger Copilot here — it will
@@ -488,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();
}
+100 -10
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]' ||
@@ -161,6 +163,7 @@ jobs:
if (!isPR) {
core.setOutput('is_pr', 'false');
core.setOutput('is_fork', 'false');
core.setOutput('can_push_fork', 'false');
return;
}
@@ -173,20 +176,44 @@ jobs:
pull_number: prNumber
});
let mergeState = '';
try {
const gql = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) { mergeStateStatus }
}
}`,
{ owner: context.repo.owner, repo: context.repo.repo, number: prNumber }
);
mergeState = (gql.repository.pullRequest.mergeStateStatus || '').toUpperCase();
} catch (e) {
core.warning(`GraphQL mergeStateStatus failed: ${e.message}`);
}
if (!mergeState) {
mergeState = (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase();
}
core.setOutput('is_pr', 'true');
core.setOutput('pr_number', String(prNumber));
core.setOutput('pr_branch', pr.data.head.ref);
core.setOutput('head_repo', pr.data.head.repo.full_name);
core.setOutput('merge_state', (pr.data.mergeable_state || pr.data.merge_state_status || '').toUpperCase());
core.setOutput('merge_state', mergeState);
const baseRepo = `${context.repo.owner}/${context.repo.repo}`;
const maintainerCanModify = pr.data.maintainer_can_modify === true;
core.setOutput('maintainer_can_modify', String(maintainerCanModify));
if (pr.data.head.repo && pr.data.head.repo.full_name !== baseRepo) {
core.setOutput('is_fork', 'true');
core.setOutput('can_push_fork', String(maintainerCanModify));
core.setOutput('fork_repo', pr.data.head.repo.full_name);
core.setOutput('clone_url', pr.data.head.repo.clone_url);
core.setOutput('branch', pr.data.head.ref);
return;
}
core.setOutput('is_fork', 'false');
core.setOutput('can_push_fork', 'false');
- name: Checkout repository
uses: actions/checkout@v4
@@ -201,30 +228,35 @@ jobs:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
PR_NUMBER: ${{ steps.check_fork.outputs.pr_number }}
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
CAN_PUSH_FORK: ${{ steps.check_fork.outputs.can_push_fork }}
FORK_REPO: ${{ steps.check_fork.outputs.fork_repo }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
run: |
git fetch origin "pull/${PR_NUMBER}/head:${PR_BRANCH}"
git checkout "${PR_BRANCH}"
git fetch origin main
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
if [ "${IS_FORK}" = "true" ]; then
if [ "${IS_FORK}" = "true" ] && [ "${CAN_PUSH_FORK}" = "true" ]; then
git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${FORK_REPO}.git"
elif [ "${IS_FORK}" = "true" ]; then
# Block push: trick claude-code-action's `git fetch origin <branch>`
git remote set-url origin "file://$(pwd)"
fi
- uses: anthropics/claude-code-action@beta
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ steps.check_fork.outputs.can_push_fork == 'true' && secrets.GH_TOKEN || steps.app-token.outputs.token }}
with:
allowed_bots: 'praisonai-triage-agent[bot]'
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
github_token: ${{ steps.app-token.outputs.token }}
model: opus
model: claude-opus-4-8
trigger_phrase: "@claude"
label_trigger: "claude"
direct_prompt: |
${{ steps.check_fork.outputs.is_fork == 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK. YOU DO NOT HAVE PUSH PERMISSIONS TO THIS REPOSITORY. ONLY READ FILES, VALIDATE CODE, AND PROVIDE YOUR COMMENTS/FEEDBACK DIRECTLY IN THIS PR VIA COMMENTS. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.is_pr == 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n') }}
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, merge_state={4}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.merge_state) || '' }}
${{ steps.check_fork.outputs.is_fork == 'true' && steps.check_fork.outputs.can_push_fork != 'true' && 'CRITICAL: THIS IS A PULL REQUEST FROM A FORK WITHOUT MAINTAINER EDITS. YOU DO NOT HAVE PUSH PERMISSIONS. ONLY READ FILES, VALIDATE CODE, AND PROVIDE COMMENTS/FEEDBACK ON THIS PR. DO NOT ATTEMPT TO PUSH OR CREATE PULL REQUESTS.\n\n' || (steps.check_fork.outputs.can_push_fork == 'true' && format('NOTE: Fork PR with maintainer edits allowed. Push directly to fork {0} branch {1}.\n\n', steps.check_fork.outputs.fork_repo, steps.check_fork.outputs.pr_branch) || (steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && 'NOTE: The branch is under MervinPraison/PraisonAI (not a fork). You are able to make modifications and push directly to this branch.\n\n' || (steps.check_fork.outputs.is_pr != 'true' && 'NOTE: This is an issue (not a PR). Create a new fix branch per STEP 3 below.\n\n' || ''))) }}
${{ steps.check_fork.outputs.is_pr == 'true' && format('CONTEXT: PR #{0} on branch {1}. head.repo={2}, is_fork={3}, can_push_fork={4}, merge_state={5}. NEVER treat cursor/*, claude/* branch names or bot authors as forks — only is_fork=true means fork.\n\n', steps.check_fork.outputs.pr_number, steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.head_repo, steps.check_fork.outputs.is_fork, steps.check_fork.outputs.can_push_fork, steps.check_fork.outputs.merge_state) || '' }}
You are working on the PraisonAI SDK. Follow AGENTS.md strictly.
STEP 0 — SETUP GIT IDENTITY & AUTH (GLOBAL — required for all repos):
@@ -247,14 +279,15 @@ 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.
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
${{ steps.check_fork.outputs.is_pr == 'true' && steps.check_fork.outputs.is_fork != 'true' && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number) || '' }}
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state == 'DIRTY' && format('STEP 3 — RESOLVE MERGE CONFLICTS ON PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git fetch origin main && git rebase origin/main\n- Resolve conflicts: keep this PR intent, merge in main newer logic\n- git add -A && git rebase --continue (repeat until done)\n- git push --force-with-lease origin {0}\n- Run targeted pytest on changed paths\n- Comment files resolved and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
@@ -303,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}`);
@@ -0,0 +1,52 @@
name: Deploy install scripts to praison.ai
# When install.sh or install.ps1 change on main, trigger praison.ai CI which
# syncs scripts at build time and deploys to Azure AKS (existing azure-pipeline.yaml).
on:
push:
branches: [main]
paths:
- 'src/praisonai/scripts/install.sh'
- 'src/praisonai/scripts/install.ps1'
workflow_dispatch:
concurrency:
group: deploy-install-scripts
cancel-in-progress: false
permissions:
contents: read
jobs:
trigger-website-deploy:
runs-on: ubuntu-latest
steps:
- name: Trigger praison.ai CI
env:
GH_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
SOURCE_SHA: ${{ github.sha }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
echo "SECRETS_ADMIN_PAT is not configured in this repository"
exit 1
fi
gh workflow run CI \
--repo MervinPraison/praison.ai \
--ref master
echo "Triggered praison.ai CI for PraisonAI commit ${SOURCE_SHA}"
echo "Monitor: https://github.com/MervinPraison/praison.ai/actions/workflows/azure-pipeline.yaml"
sleep 10
RUN_URL=$(gh run list \
--repo MervinPraison/praison.ai \
--workflow CI \
--limit 1 \
--json url,status,createdAt \
--jq '.[0].url' 2>/dev/null || true)
if [ -n "${RUN_URL}" ]; then
echo "Latest praison.ai CI run: ${RUN_URL}"
fi
+184
View File
@@ -0,0 +1,184 @@
name: Auto Merge Conflict → Claude
# Detects internal PRs with mergeStateStatus=DIRTY and posts @claude to trigger claude.yml.
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
workflow_dispatch:
schedule:
- cron: '0 */6 * * *'
concurrency:
group: merge-conflict-scan-${{ github.repository }}
cancel-in-progress: true
env:
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
GH_TOKEN: ${{ secrets.GH_TOKEN || github.token }}
permissions:
pull-requests: write
issues: write
jobs:
detect-and-trigger:
runs-on: ubuntu-latest
steps:
- name: Detect conflicting PRs and trigger Claude
uses: actions/github-script@v7
with:
github-token: ${{ env.GH_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const baseRepo = `${owner}/${repo}`;
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
const LABEL = 'claude-conflict-pending';
const COMMENT_BODY = [
'@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.',
'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));
async function getMergeState(prNumber) {
const query = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
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,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
}
const pr = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
return {
status: (pr.data.mergeable_state || '').toUpperCase(),
isDraft: pr.data.draft,
headRepo: pr.data.head.repo?.full_name,
maintainerCanModify: pr.data.maintainer_can_modify === true,
};
}
function hasFinalClaudeReviewTrigger(comments) {
return comments.some((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 hasRecentAutoComment(comments) {
const cutoff = Date.now() - COOLDOWN_MS;
return comments.some((c) => {
if (!AUTO_ACTORS.includes(c.user.login)) return false;
const body = (c.body || '').toLowerCase();
if (!body.includes('@claude') || !body.includes('merge conflict')) return false;
return new Date(c.created_at).getTime() > cutoff;
});
}
async function processPR(prNumber) {
const { status, isDraft, headRepo, maintainerCanModify } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}, maintainerCanModify=${maintainerCanModify}`);
const { data: issue } = await github.rest.issues.get({
owner,
repo,
issue_number: prNumber,
});
const labels = issue.labels.map((l) => l.name);
if (status !== 'DIRTY' && labels.includes(LABEL)) {
await github.rest.issues.removeLabel({
owner,
repo,
issue_number: prNumber,
name: LABEL,
});
core.info(`Removed ${LABEL} from PR #${prNumber}`);
}
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
if (isDraft) return { skipped: true, reason: 'draft' };
if (headRepo && headRepo !== baseRepo && !maintainerCanModify) {
return { skipped: true, reason: 'fork without maintainer edits' };
}
if (labels.includes(LABEL)) {
return { skipped: true, reason: 'conflict resolution pending' };
}
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: prNumber,
per_page: 100,
});
if (hasRecentAutoComment(comments)) {
return { skipped: true, reason: 'recent auto comment' };
}
if (!hasFinalClaudeReviewTrigger(comments)) {
return { skipped: true, reason: 'awaiting final claude review trigger' };
}
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [LABEL],
});
await github.rest.issues.createComment({
owner,
repo,
issue_number: prNumber,
body: COMMENT_BODY,
});
return { triggered: true };
}
let prNumbers = [];
if (context.eventName === 'pull_request') {
prNumbers = [context.payload.pull_request.number];
} else {
const prs = await github.paginate(github.rest.pulls.list, {
owner,
repo,
state: 'open',
per_page: 100,
});
prNumbers = prs.map((p) => p.number);
}
const results = [];
for (const num of prNumbers) {
results.push({ pr: num, ...(await processPR(num)) });
}
core.summary.addRaw('```json\n' + JSON.stringify(results, null, 2) + '\n```');
await core.summary.write();
+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
);
+305
View File
@@ -0,0 +1,305 @@
name: PyPI Release
# Requires a GitHub Environment named "pypi" (Settings → Environments → pypi)
# with required reviewers before secrets are exposed to this job.
on:
workflow_dispatch:
inputs:
bump:
description: 'Version bump type for both packages'
required: true
default: patch
type: choice
options:
- patch
- minor
- major
dry_run:
description: 'Preview versions and steps without publishing or pushing'
required: false
default: false
type: boolean
skip_agents:
description: 'Skip agents publish (recovery if already on PyPI)'
required: false
default: false
type: boolean
skip_wrapper:
description: 'Publish agents only'
required: false
default: false
type: boolean
agents_version:
description: 'Override agents version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
wrapper_version:
description: 'Override wrapper version (empty = auto bump from version.py)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
default: manual
type: choice
options:
- manual
- nightly
permissions:
contents: write
concurrency:
group: pypi-release
cancel-in-progress: false
jobs:
release:
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: pypi
env:
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: '3.11'
- name: Set up uv
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
run: |
git config user.name "MervinPraison"
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:
BUMP: ${{ inputs.bump }}
AGENTS_OVERRIDE: ${{ inputs.agents_version }}
WRAPPER_OVERRIDE: ${{ inputs.wrapper_version }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
run: |
python <<'PY'
import os
import re
import sys
from pathlib import Path
SEMVER = re.compile(r"^\d+\.\d+\.\d+$")
root = Path(".")
bump = os.environ["BUMP"]
def bump_version(version: str) -> str:
parts = version.split(".")
if len(parts) != 3:
print(f"Invalid version: {version}", file=sys.stderr)
sys.exit(1)
maj, min_, patch = (int(parts[0]), int(parts[1]), int(parts[2]))
if bump == "major":
return f"{maj + 1}.0.0"
if bump == "minor":
return f"{maj}.{min_ + 1}.0"
return f"{maj}.{min_}.{patch + 1}"
def validate_version(label: str, value: str) -> None:
if value and not SEMVER.match(value):
print(f"Invalid {label} override: {value!r} (expected X.Y.Z)", file=sys.stderr)
sys.exit(1)
agents_override = os.environ.get("AGENTS_OVERRIDE", "").strip()
wrapper_override = os.environ.get("WRAPPER_OVERRIDE", "").strip()
validate_version("agents_version", agents_override)
validate_version("wrapper_version", wrapper_override)
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
agents_content = agents_pyproject.read_text()
agents_match = re.search(r'^version\s*=\s*"([^"]+)"', agents_content, re.MULTILINE)
if not agents_match:
print("Could not read agents version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current_agents = agents_match.group(1)
if not SEMVER.match(current_agents):
print(f"Invalid current agents version: {current_agents}", file=sys.stderr)
sys.exit(1)
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
wrapper_content = wrapper_version_py.read_text()
wrapper_match = re.search(r'__version__ = "([^"]+)"', wrapper_content)
if not wrapper_match:
print("Could not read wrapper version from version.py", file=sys.stderr)
sys.exit(1)
current_wrapper = wrapper_match.group(1)
if not SEMVER.match(current_wrapper):
print(f"Invalid current wrapper version: {current_wrapper}", file=sys.stderr)
sys.exit(1)
skip_agents = os.environ.get("SKIP_AGENTS", "false").lower() == "true"
if agents_override:
agents_version = agents_override
elif skip_agents:
agents_version = current_agents
else:
agents_version = bump_version(current_agents)
if wrapper_override:
wrapper_version = wrapper_override
else:
wrapper_version = bump_version(current_wrapper)
github_output = os.environ["GITHUB_OUTPUT"]
with open(github_output, "a", encoding="utf-8") as fh:
fh.write(f"agents_version={agents_version}\n")
fh.write(f"wrapper_version={wrapper_version}\n")
fh.write(f"current_agents={current_agents}\n")
fh.write(f"current_wrapper={current_wrapper}\n")
print(f"Agents: {current_agents} -> {agents_version}")
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
PY
- name: Dry run summary
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Agents version: ${{ steps.versions.outputs.agents_version }}"
echo "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
echo ""
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
echo "Would commit src/praisonai-agents/pyproject.toml and uv.lock"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }}"
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
fi
- name: Publish praisonaiagents
if: inputs.dry_run != true && inputs.skip_agents != true
working-directory: src/praisonai-agents
env:
NEW_VERSION: ${{ steps.versions.outputs.agents_version }}
CURRENT: ${{ steps.versions.outputs.current_agents }}
run: |
set -euo pipefail
python <<'PY'
import os
from pathlib import Path
path = Path("pyproject.toml")
content = path.read_text()
current = os.environ["CURRENT"]
new = os.environ["NEW_VERSION"]
path.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
PY
rm -rf dist
uv lock
uv build
uv publish
- name: Wait for praisonaiagents on PyPI
if: inputs.dry_run != true && inputs.skip_agents != true
run: |
set -euo pipefail
PACKAGE="praisonaiagents"
VERSION="${{ steps.versions.outputs.agents_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
- name: Commit agents version bump
if: inputs.dry_run != true && inputs.skip_agents != true
run: |
set -euo pipefail
git add src/praisonai-agents/pyproject.toml src/praisonai-agents/uv.lock
git diff --cached --quiet && { echo "No agents files to commit"; exit 0; }
git commit -m "Bump praisonaiagents to ${{ steps.versions.outputs.agents_version }}"
git pull --rebase origin main
git push origin main
- name: Bump and release wrapper
if: inputs.dry_run != true && inputs.skip_wrapper != true
run: |
set -euo pipefail
python src/praisonai/scripts/bump_and_release.py "${{ steps.versions.outputs.wrapper_version }}" \
--agents "${{ steps.versions.outputs.agents_version }}" \
--wait --max-wait 600 --no-add-all
- name: Publish praisonai wrapper to PyPI
if: inputs.dry_run != true && inputs.skip_wrapper != true
working-directory: src/praisonai
run: uv publish
- name: Wait for praisonai on PyPI
if: inputs.dry_run != true && inputs.skip_wrapper != true
run: |
set -euo pipefail
PACKAGE="praisonai"
VERSION="${{ steps.versions.outputs.wrapper_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
- name: Verify PyPI versions
if: inputs.dry_run != true
run: |
set -euo pipefail
AGENTS="${{ steps.versions.outputs.agents_version }}"
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "✅ praisonaiagents==${AGENTS}"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "✅ praisonai==${WRAPPER}"
fi
+5 -2
View File
@@ -8,15 +8,18 @@
name: Upload Python Package
# Superseded by pypi-release.yml (manual workflow_dispatch with uv publish).
# Kept as workflow_dispatch-only fallback; release trigger removed to avoid
# duplicate PyPI publish when bump_and_release creates a GitHub release.
on:
release:
types: [published]
workflow_dispatch:
permissions:
contents: read
jobs:
deploy:
if: false
runs-on: ubuntu-latest
+437
View File
@@ -0,0 +1,437 @@
# PraisonAI Architecture
> **Last updated:** 2026-06-24
>
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
> Covers system design, runtime architecture, data contracts, reliability,
> observability, and the 2-quarter road map.
---
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [System Overview](#2-system-overview)
3. [Layered Architecture](#3-layered-architecture)
4. [Core Data Contracts](#4-core-data-contracts)
5. [Reliability by Design](#5-reliability-by-design)
6. [Observability & Telemetry](#6-observability--telemetry)
7. [Replay & Checkpointing](#7-replay--checkpointing)
8. [Implementation Roadmap](#8-implementation-roadmap)
9. [Success Metrics](#9-success-metrics)
---
## 1. Executive Summary
PraisonAI is a multi-agent AI framework with broad capability surface across
Python, TypeScript, and Rust SDKs. The framework's core strength is its
feature breadth — agent abstractions, integrations, workflows, and active
release cadence.
The strategic bet for the next two quarters is building a **Reliability +
Orchestration + Observability** core to unlock adoption and trust:
| Dimension | Current State | Target |
|-----------|--------------|--------|
| Reliability | Cross-platform mismatches, optional dep fragility | Deterministic gating, adapter abstraction, parity CI |
| Determinism | Flaky tests, global state collisions | Isolated test fixtures, traceable tenant IDs |
| Developer UX | Complex onboarding, implicit config | Golden-path CLI, Doctor Auto-Fix |
| Observability | Minimal runtime tracing | Structured event bus, replay engine, failure classifier |
### Design Principles
- **Deterministic by default**, flexible by opt-in
- **Policy-enforced execution boundaries** at every layer
- **Structured events** for all lifecycle transitions
- **Capability isolation** per agent and per tool
---
## 2. System Overview
```
User/SDK/CLI
→ Workflow Compiler (graph + policy + schemas)
→ Execution Orchestrator (state machine, retries, fallbacks)
→ Tool Runtime (sandbox + approval + capability scope)
→ Model Runtime (provider abstraction + rate/timeout policy)
→ Observability Bus (trace events + metrics + cost)
→ Replay Engine (checkpoint + deterministic re-run)
→ Persistence (session, memory, artifacts, run ledger)
```
### Runtime Components
| Component | Responsibility | Interface |
|-----------|---------------|-----------|
| API/CLI Gateway | Accepts run requests, validates config/profile | `RunRequest`, `RunProfile` |
| Workflow Compiler | Converts YAML/DSL to normalized execution graph | `CompiledGraph` |
| Orchestrator | Executes graph state machine with retries/fallbacks | `ExecutionState`, `StepTransition` |
| Tool Runtime | Runs tools with approval/sandbox policies | `ToolCall`, `ToolResult` |
| Model Runtime | Provider routing, timeout, budget control | `ModelRequest`, `ModelResponse` |
| Observability Bus | Streams structured events + metrics | `RunEvent` schema |
| Replay Engine | Restarts from checkpoint with deterministic inputs | `ReplayRequest` |
### Existing Architecture (Python SDK)
The Python SDK is the primary runtime and is organised as follows:
- `src/praisonai/` — Core runtime and agent abstractions
- `agents/` — Agent types (chat, code, realtime, audio, vision, etc.)
- `llm/` — Model runtime with provider routing, rate limiting, failover
- `tools/` — Tool runtime with sandbox, approval, retry
- `memory/` — Memory runtime (in-memory, SQLite, MongoDB, Mem0 adapters)
- `knowledge/` — Knowledge management (indexing, retrieval, chunking)
- `workflow/` — Workflow engine (YAML/SDK-based orchestration)
- `telemetry/` — Observability, monitoring, token tracking
- `reliability/` — Error classification, circuit breaker, failover
- `skill/` — Plugin/skill system
- `loop_detection/` — Doom loop detection and prevention
- `mcp/` — MCP protocol support
- `a2a/`, `a2ui/` — Agent-to-agent and agent-to-UI protocols
### Multi-SDK Layout
- **Python SDK** — Primary runtime (`src/praisonai/`)
- **TypeScript SDK** — JS/TS runtime (`ts-sdk/`)
- **Rust SDK** — High-performance Rust runtime (`rust-sdk/`)
- **CLI** — Command-line interface (`cli/`)
- **UI** — Web UI (`ui/`)
---
## 3. Layered Architecture
```mermaid
flowchart TB
subgraph UX["Interface Layer"]
CLI[CLI]
SDK[SDK]
API[API Gateway]
end
subgraph Control["Control Plane"]
Compiler[Workflow Compiler]
Policy[Policy Engine]
Orchestrator[Execution Orchestrator]
end
subgraph Runtime["Runtime Plane"]
ModelRT[Model Runtime]
ToolRT[Tool Runtime]
MemoryRT[Memory Runtime]
end
subgraph Observe["Observability Plane"]
EventBus[Run Event Bus]
Metrics[Metrics + Cost]
Replay[Replay Engine]
end
subgraph Data["Data Plane"]
Ledger[Run Ledger]
Checkpoints[Checkpoints]
Artifacts[Artifacts Store]
end
CLI --> API
SDK --> API
API --> Compiler
API --> Policy
Compiler --> Orchestrator
Policy --> Orchestrator
Orchestrator --> ModelRT
Orchestrator --> ToolRT
Orchestrator --> MemoryRT
Orchestrator --> EventBus
EventBus --> Metrics
EventBus --> Replay
Orchestrator --> Ledger
Orchestrator --> Checkpoints
ToolRT --> Artifacts
Replay --> Checkpoints
```
### Interface Layer
- **CLI** — The `praisonai` command-line entry point
- **SDK** — Python library API (`from praisonai import Agent`)
- **API Gateway** — HTTP/WebSocket REST API (`praisonai api` or `a2a`/`a2ui`)
### Control Plane
- **Workflow Compiler** — Converts YAML workflows and SDK-defined graphs into
a normalized `CompiledGraph` with adjacency validation and cycle detection.
- **Policy Engine** — Enforces runtime policies (budget, timeout, approval
gates, capability validation) before and during execution.
- **Execution Orchestrator** — Drives the state machine through graph nodes,
handling retry, fallback, and failure classification.
### Runtime Plane
- **Model Runtime** — Provider abstraction layer. Routes to OpenAI, Anthropic,
Gemini, Ollama, DeepSeek, etc. Handles rate limiting, token tracking, and
automatic failover between providers.
- **Tool Runtime** — Executes tool calls with configurable sandboxing,
approval gates, retry policies, and capability scope validation.
- **Memory Runtime** — Manages session memory, persistent memory stores,
and knowledge retrieval across in-memory, SQLite, and MongoDB backends.
### Observability Plane
- **Run Event Bus** — Structured event streaming for all lifecycle transitions
(START, INPUT, MODEL_CALL, TOOL_CALL, ERROR, RETRY, COMPLETE).
- **Metrics + Cost** — Token counting, cost tracking, and performance metrics.
- **Replay Engine** — Deterministic replay from the last stable checkpoint,
with state hash verification.
### Data Plane
- **Run Ledger** — Persistent record of all runs with state, metrics, and
failure classifications.
- **Checkpoints** — Point-in-time snapshots of run state, memory, and
artifacts for replay and recovery.
- **Artifacts Store** — Tool outputs, generated files, and intermediate
results.
---
## 4. Core Data Contracts
### RunRequest
```text
RunRequest {
run_id: UUID,
workflow_ref: string,
inputs: object,
profile: {safe_mode, budget, timeout, approval_policy},
context: {user_id, workspace_id, env}
}
```
### RunEvent
```text
RunEvent {
ts: ISO8601,
run_id: UUID,
step_id: string,
type: START | INPUT | MODEL_CALL | TOOL_CALL |
ERROR | RETRY | COMPLETE,
payload: object,
cost: {tokens_in, tokens_out, usd},
latency_ms: number
}
```
### Checkpoint
```text
Checkpoint {
run_id: UUID,
step_id: string,
state_hash: string,
memory_snapshot_ref: string,
artifact_refs: string[]
}
```
### Execution Sequence
```text
1) Client submits RunRequest
2) Gateway validates profile + schema
3) Compiler resolves workflow → CompiledGraph
4) Orchestrator starts node execution
5) Node may call Model Runtime or Tool Runtime
6) Every transition emits RunEvent
7) On failure: classifier decides retry/fallback/abort
8) Checkpoint stored after each critical step
9) Final state + artifacts persisted to run ledger
10) Replay can resume from last stable checkpoint
```
---
## 5. Reliability by Design
### Error Taxonomy
All failures are classified into one of five categories for automatic
remediation:
| Category | Description | Default Action |
|----------|-------------|----------------|
| `config` | Invalid configuration or profile | Abort with remediation hint |
| `dependency` | Missing optional module or capability | Degrade / skip optional path |
| `tool` | Tool execution failure | Retry with backoff and policy cap |
| `model` | Model API error or timeout | Fallback to alternate provider/route |
| `infra` | Network or resource exhaustion | Circuit-breaker + replay from checkpoint |
```mermaid
flowchart TD
S[Step Failure] --> T{Failure Type?}
T -->|Config| C1[Abort + config remediation hint]
T -->|Dependency| C2[Degrade capability / skip optional path]
T -->|Tool| C3[Retry with backoff and policy cap]
T -->|Model| C4[Fallback model/provider route]
T -->|Infra| C5[Circuit-breaker + replay from checkpoint]
```
### Hardening Checklist
- **Dependency gates** — Optional modules declared as capabilities;
unavailable capability results in skip/degrade, not crash.
- **Cross-platform adapters** — OS-specific implementations behind a single
interface (e.g., file lock adapter for Windows/Linux/macOS).
- **Idempotent fixtures** — Integration tests use unique tenant/workspace IDs
and guaranteed teardown.
- **Fail-safe output mode** — CLI rendering falls back to ASCII-safe mode on
encoding mismatch.
- **CI parity matrix** — Run smoke workflows across Windows, Linux, and macOS.
### Deterministic Test Pattern
```python
# Integration test pattern — always use unique, traceable IDs
import uuid
def test_agent_workflow():
tenant_id = f"test-{uuid.uuid4().hex[:8]}"
agent = Agent(name=f"agent-{tenant_id}", ...)
result = agent.run("task")
assert result.status == "success"
# teardown is guaranteed via fixture or context manager
```
---
## 6. Observability & Telemetry
### Current Telemetry Stack
PraisonAI already includes:
- **OpenTelemetry integration** — Manual and auto-instrumentation for
traces, metrics, and logs.
- **Token tracking** — Per-call and cumulative token/cost tracking.
- **Performance monitoring** — Real-time dashboards and monitoring views.
- **LangTrace integration** — LangTrace provider for tracing.
- **Run outcomes** — Structured `RunOutcome` objects with status, duration,
token usage, and error classification.
### Planned Enhancements
- **Run Event Bus** — Stream all lifecycle events as structured `RunEvent`
payloads.
- **Failure classifier** — Automatic failure category detection with
remediation hints.
- **Run ledger visualization** — Timeline view of runs with step-by-step
event drill-down.
- **Replay integration** — Checkpoint-based run replay from the
observability dashboard.
---
## 7. Replay & Checkpointing
### Design
```mermaid
sequenceDiagram
participant U as User/Client
participant G as Gateway
participant C as Compiler
participant O as Orchestrator
participant R as Model/Tool Runtime
participant E as Event Bus
participant L as Run Ledger
participant K as Checkpoint Store
U->>G: Submit RunRequest
G->>C: Validate + compile workflow
C-->>O: CompiledGraph
O->>R: Execute next step
R-->>O: Step output / error
O->>E: Emit structured RunEvent
O->>K: Persist checkpoint
O->>L: Persist run state + metrics
O-->>U: Final result / failure summary
```
### Current State
- Basic run outcome persistence exists
- Session persistence for bot/multi-turn conversations
- Token and cost tracking per run
### Roadmap
- Full checkpoint creation at configurable step granularity
- State hash verification for deterministic replay
- Replay API endpoint (`POST /runs/{id}/replay`)
- Checkpoint pruning and retention policy
---
## 8. Implementation Roadmap
### Quarter 1: Trust Foundation (Q3 2026)
| Priority | Project | Description | Status |
|----------|---------|-------------|--------|
| P0 | Reliability Core | Cross-platform hardening, optional-dep gating, deterministic fixtures | In progress |
| P0 | Doctor Auto-Fix | Automated environment diagnosis and repairs | Planned |
| P1 | Golden-Path CLI | Single canonical flow: init → run → test → deploy | Planned |
| P1 | CI Parity Matrix | Cross-platform smoke tests in CI pipeline | Planned |
### Quarter 2: Operational Excellence (Q4 2026)
| Priority | Project | Description | Status |
|----------|---------|-------------|--------|
| P1 | Trace + Replay | Run timeline, checkpoint replay, failure classifier | Planned |
| P1 | Failure Classifier | Automatic failure category detection with remediation | Planned |
| P2 | Graph Studio UX | Visual deterministic orchestration editor/inspector | Planned |
| P2 | Safe Production Profiles | Policy presets: guardrails, approvals, cost/time caps | Planned |
```mermaid
gantt
title PraisonAI Architecture Program (2 Quarters)
dateFormat YYYY-MM-DD
section Quarter 1
Reliability hardening + parity CI :a1, 2026-07-01, 45d
Deterministic test isolation :a2, 2026-07-10, 50d
Doctor Auto-Fix MVP :a3, 2026-07-20, 40d
Golden-path CLI :a4, 2026-08-01, 45d
section Quarter 2
Trace bus + run ledger :b1, 2026-10-01, 45d
Replay engine :b2, 2026-10-15, 45d
Failure classifier :b3, 2026-11-01, 30d
Graph UX beta + Safe profiles :b4, 2026-11-10, 50d
```
---
## 9. Success Metrics
### KPI Targets
| KPI | Target | Primary Workstream |
|-----|--------|-------------------|
| Time-to-first-successful run | 30-40% reduction | Golden-path CLI + Doctor Auto-Fix |
| Cross-platform issue rate | 50% reduction | Reliability Core + adapter layer |
| Incident triage time | 40% reduction | Trace + Replay + failure classifier |
| CI confidence | Flaky test rate <2% | Deterministic tests + fixture isolation |
### Governance
- **Architecture Review Council** — Reviews runtime-contract changes
- **Release Quality Gate** — Platform matrix + deterministic test thresholds
- **Monthly telemetry review** — DX, reliability, adoption trends
---
*This document is a living reference. Update as the architecture evolves.*
+66
View File
@@ -0,0 +1,66 @@
# Security Policy
PraisonAI takes security seriously. We appreciate responsible disclosure from researchers and users.
## Supported packages
| Package | Registry | Source path |
|---------|----------|-------------|
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
| `praisonai-platform` | [PyPI](https://pypi.org/project/praisonai-platform/) | `src/praisonai-platform/` |
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
## In scope
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
- Tool execution, sandbox escape, and code execution paths
- SSRF, injection, and unsafe deserialisation in shipped code
- MCP, gateway, and bot/webhook integrations
- Secrets handling, JWT/session configuration, and default credentials
- Supply-chain issues in published package artefacts
## Out of scope
- Issues in forks or deployments not maintained in [MervinPraison/PraisonAI](https://github.com/MervinPraison/PraisonAI)
- Vulnerabilities requiring physical access or fully compromised operator machines
- Reports against versions no longer published or without a supported upgrade path
- Theoretical issues with no practical impact in documented, supported configurations
- Behaviour covered by an explicit, documented opt-out (e.g. dev-only env vars)
## How to report
**Preferred:** open a **private** security advisory:
[https://github.com/MervinPraison/PraisonAI/security/advisories/new](https://github.com/MervinPraison/PraisonAI/security/advisories/new)
Include:
1. Affected package and version
2. Clear reproduction steps
3. Impact assessment (confidentiality, integrity, availability)
4. Suggested fix (optional)
Please allow reasonable time for triage and a patched release before public disclosure.
## What to expect
1. **Triage** — we validate the report and assign a disposition (`real`, `duplicate`, `wontfix`, etc.)
2. **Fix** — minimal, backward-compatible patch on `main` where appropriate
3. **Release** — patched version published to PyPI/npm **before** the advisory is published
4. **Advisory** — GitHub Security Advisory with reporter credit and CVE where applicable
## Resources
- **Repository:** https://github.com/MervinPraison/PraisonAI
- **Documentation:** https://docs.praison.ai
- **Website:** https://praison.ai
- **Advisories:** https://github.com/MervinPraison/PraisonAI/security/advisories
## Maintainer workflow
Contributors and agents handling audits should follow:
`src/praisonai-agents/.agent/workflows/security-audit.md`
+54 -12
View File
@@ -30,6 +30,7 @@ from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepR
Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
@@ -59,6 +60,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">handoff_to_async</a>(target_agent: 'Agent', prompt: str, context: Optional[Dict[str, Any]] = None, config: Optional['HandoffConfig'] = None) -> 'HandoffResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">is_closed</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">list_mcp_servers</a>() -> List[str]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_instance</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">llm_model</a>() -> Optional[str]</code>
@@ -70,6 +72,8 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rag_query</a>(question: str, **kwargs) -> 'RAGResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
@@ -479,11 +483,16 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate</a>(skill: LoadedSkill) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">approve</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">archive_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create</a>(name: str, content: str, category: Optional[str] = None, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None, agent_created: bool = True, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete</a>(name: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str, hard: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
@@ -492,15 +501,24 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_archived_skills</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_pending</a>() -> List[dict]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">restore_skill</a>(name: str, skill_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">rollback_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">idle_days</a>() -> Optional[float]</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -801,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>
@@ -843,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>
@@ -872,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>
@@ -893,19 +921,18 @@ Methods:
* <code title="cli">praisonai docs serve <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai docs stats <a href="./src/praisonai/praisonai/cli/commands/docs.py">--help</a></code>
* <code title="cli">praisonai doctor cleanup <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor config <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor db <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor doctor-callback <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor docker <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor fix <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor llm-providers <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor memory-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor metadata-store <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor runtime <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
* <code title="cli">praisonai endpoints test <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
@@ -945,6 +972,11 @@ Methods:
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai identity import <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity link <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity list <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai identity unlink <a href="./src/praisonai/praisonai/cli/commands/identity.py">--help</a></code>
* <code title="cli">praisonai init init <a href="./src/praisonai/praisonai/cli/commands/init.py">--help</a></code>
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
@@ -1099,6 +1131,8 @@ Methods:
* <code title="cli">praisonai sandbox sandbox-callback <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai sandbox status <a href="./src/praisonai/praisonai/cli/commands/sandbox.py">--help</a></code>
* <code title="cli">praisonai schedule add <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule blueprint <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule blueprint-list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule delete <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
@@ -1108,6 +1142,10 @@ Methods:
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stop <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-accept <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai serve a2a <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve a2u <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve acp <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
@@ -1143,6 +1181,7 @@ Methods:
* <code title="cli">praisonai skills eligible <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills info <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills install <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills learn <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills list <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills search <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
* <code title="cli">praisonai skills validate <a href="./src/praisonai/praisonai/cli/commands/skills.py">--help</a></code>
@@ -1184,6 +1223,9 @@ Methods:
* <code title="cli">praisonai up logs <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
* <code title="cli">praisonai up status <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
* <code title="cli">praisonai up up-start <a href="./src/praisonai/praisonai/cli/commands/up.py">--help</a></code>
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai version check <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
* <code title="cli">praisonai version show <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
* <code title="cli">praisonai version version-callback <a href="./src/praisonai/praisonai/cli/commands/version.py">--help</a></code>
@@ -1335,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.70" \
"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.70" \
"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.70" \
"praisonai>=4.6.80" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -0,0 +1,216 @@
# Industry Templates for PraisonAI
This directory contains industry-specific agent templates based on the SRAO Framework, providing pre-built workflows for rapid deployment of agent workforces across different industries.
## Overview
These templates demonstrate how PraisonAI can be used to create specialized agent teams for various industries, with **70% code reuse** across different sectors through shared patterns and abstractions.
## Available Templates
### 1. Manufacturing (`manufacturing_template.py`)
**Key Agents:**
- **ParseOrder**: Extracts and structures order information from various formats
- **CheckInventory**: Validates material availability and supply chain status
- **OptimizeSchedule**: Optimizes production scheduling based on constraints
- **DefectDetect**: Quality control and defect detection
**Features:**
- Order processing with multiple input formats
- Real-time inventory management
- Production optimization algorithms
- Quality inspection with defect detection
- Fallback strategies for each critical step
### 2. Energy (`energy_template.py`)
**Key Agents:**
- **SCADAReader**: Processes SCADA telemetry data from wind turbines
- **VibrationAnalyzer**: Analyzes vibration patterns for predictive maintenance
- **PowerForecaster**: Predicts power generation based on weather and history
- **MaintenanceScheduler**: Schedules predictive maintenance
**Features:**
- Real-time wind farm monitoring
- Vibration-based fault detection
- Power generation forecasting
- Grid integration and load balancing
- Predictive maintenance scheduling
### 3. Healthcare (`healthcare_template.py`)
**Key Agents:**
- **VitalSignsCapture**: Processes patient vital signs from monitoring devices
- **EMRRetrieval**: Retrieves electronic medical records with HIPAA compliance
- **TriageRecommendation**: Provides ESI-based triage recommendations
- **ResourceAllocator**: Manages hospital resource allocation
**Features:**
- Emergency triage workflow
- HIPAA-compliant data handling
- Patient safety protocols
- Resource optimization
- Cross-department coordination
### 4. Agriculture (`agriculture_template.py`)
**Key Agents:**
- **MultispectralAnalyzer**: Analyzes multispectral imagery for crop health
- **DiseaseIdentifier**: Identifies crop diseases and pests using AI
- **SprayRecommender**: Recommends targeted spraying strategies
- **YieldPredictor**: Predicts crop yield based on conditions
**Features:**
- Precision agriculture with drone/satellite imagery
- Pest and disease early warning system
- Optimized chemical application
- Yield forecasting
- Sustainable farming patterns
### 5. Transportation (`transportation_template.py`)
**Key Agents:**
- **LiDARFusion**: Processes LiDAR point cloud data for infrastructure
- **DisplacementCalculator**: Calculates structural displacement
- **HeatmapGenerator**: Generates safety visualization heatmaps
- **MaintenancePlanner**: Plans infrastructure maintenance
**Features:**
- Tunnel/bridge safety monitoring
- LiDAR-based structural analysis
- Real-time safety assessment
- Traffic flow optimization
- Predictive maintenance scheduling
## Key Features Across All Templates
### 1. I/O Schemas
Each template includes Pydantic models for structured data handling:
- Input validation
- Type safety
- Clear data contracts between agents
### 2. SLA Requirements
Every agent has defined Service Level Agreements:
- Processing time requirements
- Response time guarantees
- Performance benchmarks
### 3. Fallback Strategies
Robust error handling with fallback mechanisms:
- Graceful degradation
- Manual intervention queues
- Conservative estimates when systems fail
### 4. Cross-Industry Patterns (70% Reuse)
Reusable base patterns that work across industries:
- Data parsing agents
- Resource availability checkers
- Optimization engines
- Inspection/quality control agents
## Usage Example
```python
from manufacturing_template import (
order_parser,
inventory_checker,
schedule_optimizer,
quality_inspector,
manufacturing_workflow
)
# Process a manufacturing order
order_text = "Customer ABC needs 100 units of Product-XYZ by March 15th, high priority"
result = manufacturing_workflow(order_text)
print(result)
```
## Adapting Templates for Your Industry
### Using Base Patterns
Each template includes reusable pattern classes that can be adapted:
```python
from manufacturing_template import IndustryAgentPattern
# Create a custom data parser for any domain
custom_parser = IndustryAgentPattern.create_data_parser(
name="CustomParser",
domain="retail",
sla_seconds=20
)
# Create a resource checker for any resource type
resource_checker = IndustryAgentPattern.create_resource_checker(
name="StaffAvailability",
resource_type="customer_service_staff",
sla_seconds=3
)
```
### Creating Custom Workflows
Combine agents from different templates or create new ones:
```python
from praisonaiagents import Agent, tool
# Define custom tools
@tool
def custom_analysis(data: str) -> Dict:
"""Your custom analysis logic"""
return {"result": "analyzed"}
# Create custom agent
custom_agent = Agent(
name="CustomAnalyzer",
instructions="Your specific instructions",
tools=[custom_analysis]
)
```
## Integration with PraisonAI Core
These templates are built on top of PraisonAI's core Agent framework:
```python
from praisonaiagents import Agent, tool, AgentTeam, Task
# Combine multiple industry agents into a team
team = AgentTeam(
agents=[order_parser, inventory_checker, schedule_optimizer],
tasks=[
Task(name="parse", agent=order_parser),
Task(name="check", agent=inventory_checker),
Task(name="optimize", agent=schedule_optimizer)
]
)
```
## Best Practices
1. **Start with Templates**: Use these as starting points and customize for your specific needs
2. **Maintain SLAs**: Define clear performance requirements for each agent
3. **Implement Fallbacks**: Always have fallback strategies for critical operations
4. **Use Type Safety**: Leverage Pydantic models for data validation
5. **Monitor Performance**: Track agent performance against SLAs
6. **Iterate and Improve**: Continuously refine based on real-world usage
## Contributing
To add a new industry template:
1. Follow the existing template structure
2. Include all key components:
- I/O Schemas (Pydantic models)
- Agent definitions with SLAs
- Tools for agent capabilities
- Complete workflow function
- Fallback strategies
- Cross-industry patterns
3. Add comprehensive documentation
4. Include usage examples
## License
These templates are part of the PraisonAI project and follow the same licensing terms.
## References
Based on the [SRAO Framework](https://github.com/beixuan577/SRAO-Framework) (MIT License)
@@ -0,0 +1,38 @@
"""
Industry Templates for PraisonAI
================================
Pre-built agent templates for rapid deployment across different industries.
Based on SRAO Framework with 70% cross-industry code reuse.
Available templates:
- Manufacturing: Order processing, inventory, scheduling, quality control
- Energy: Wind farm monitoring, predictive maintenance, power forecasting
- Healthcare: Emergency triage, EMR retrieval, resource allocation
- Agriculture: Precision farming, pest detection, yield prediction
- Transportation: Infrastructure monitoring, safety assessment, maintenance
Example usage:
from Industry_Templates.manufacturing_template import manufacturing_workflow
result = manufacturing_workflow("Customer order text")
"""
__version__ = "1.0.0"
__author__ = "PraisonAI Team"
# Import the modules so they're accessible via the package
from . import manufacturing_template
from . import energy_template
from . import healthcare_template
from . import agriculture_template
from . import transportation_template
# Export commonly used items for convenience
__all__ = [
"manufacturing_template",
"energy_template",
"healthcare_template",
"agriculture_template",
"transportation_template"
]
@@ -0,0 +1,551 @@
"""
Agriculture Industry Template
=============================
Precision agriculture and pest management workflow
Based on SRAO Framework with IoT sensor integration
Key agents:
- MultispectralAnalyzer: Analyzes multispectral imagery
- DiseaseIdentifier: Identifies crop diseases and pests
- SprayRecommender: Recommends targeted spraying strategies
- YieldPredictor: Predicts crop yield based on conditions
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Tuple, Optional
from pydantic import BaseModel
from datetime import datetime, timedelta
from enum import Enum
# Crop health levels
class CropHealthLevel(str, Enum):
EXCELLENT = "excellent"
GOOD = "good"
FAIR = "fair"
POOR = "poor"
CRITICAL = "critical"
# Pest/Disease severity
class SeverityLevel(str, Enum):
NONE = "none"
LOW = "low"
MODERATE = "moderate"
HIGH = "high"
SEVERE = "severe"
# I/O Schemas
class MultispectralData(BaseModel):
"""Multispectral imagery analysis data"""
field_id: str
capture_time: str
ndvi_index: float # Normalized Difference Vegetation Index
ndre_index: float # Normalized Difference Red Edge
ccci_index: float # Canopy Chlorophyll Content Index
moisture_level: float # Soil moisture percentage
temperature: float # Celsius
affected_area: float # Hectares
gps_coordinates: Tuple[float, float]
class PestDiseaseReport(BaseModel):
"""Pest and disease identification report"""
report_id: str
field_id: str
detection_time: str
pest_type: Optional[str]
disease_type: Optional[str]
severity: SeverityLevel
affected_crops: List[str]
spread_rate: float # % per day
environmental_factors: Dict[str, Any]
confidence_score: float
class SprayRecommendation(BaseModel):
"""Targeted spraying recommendation"""
recommendation_id: str
field_id: str
treatment_type: str # pesticide, fungicide, herbicide, fertilizer
product_name: str
application_rate: float # L/hectare
target_zones: List[Dict[str, Any]] # GPS zones
optimal_time: str
weather_window: Dict[str, Any]
cost_estimate: float
environmental_impact: str # low, medium, high
class YieldForecast(BaseModel):
"""Crop yield prediction"""
forecast_id: str
field_id: str
crop_type: str
predicted_yield: float # tons/hectare
confidence_interval: Tuple[float, float]
harvest_window: Tuple[str, str]
quality_grade: str # A, B, C
market_price_estimate: float
risk_factors: List[str]
# Agriculture-specific tools
@tool
def analyze_multispectral_imagery(field_id: str, image_data: Dict) -> Dict:
"""Analyze multispectral drone/satellite imagery for crop health"""
# Simulate multispectral analysis
ndvi = 0.75 # Healthy vegetation typically 0.6-0.9
# Determine health based on NDVI
if ndvi > 0.8:
health = CropHealthLevel.EXCELLENT
affected_area = 0.0
elif ndvi > 0.6:
health = CropHealthLevel.GOOD
affected_area = 2.5
elif ndvi > 0.4:
health = CropHealthLevel.FAIR
affected_area = 5.0
else:
health = CropHealthLevel.POOR
affected_area = 10.0
return {
"field_id": field_id,
"capture_time": datetime.now().isoformat(),
"ndvi_index": ndvi,
"ndre_index": 0.68,
"ccci_index": 0.72,
"moisture_level": 35.0,
"temperature": 24.5,
"affected_area": affected_area,
"gps_coordinates": (40.7128, -74.0060),
"health_level": health.value
}
@tool
def identify_pest_disease(spectral_data: Dict, historical_data: List) -> Dict:
"""Identify pests and diseases using AI image recognition"""
# Simulate pest/disease detection
ndvi = spectral_data.get("ndvi_index", 0.75)
affected_area = spectral_data.get("affected_area", 0)
if affected_area > 8.0:
pest_type = "aphids"
disease_type = "powdery_mildew"
severity = SeverityLevel.HIGH
spread_rate = 2.5
elif affected_area > 4.0:
pest_type = "whiteflies"
disease_type = "leaf_spot"
severity = SeverityLevel.MODERATE
spread_rate = 1.5
elif affected_area > 0:
pest_type = None
disease_type = "early_blight"
severity = SeverityLevel.LOW
spread_rate = 0.5
else:
pest_type = None
disease_type = None
severity = SeverityLevel.NONE
spread_rate = 0.0
return {
"report_id": f"PDR-{datetime.now().strftime('%Y%m%d%H%M')}",
"field_id": spectral_data["field_id"],
"detection_time": datetime.now().isoformat(),
"pest_type": pest_type,
"disease_type": disease_type,
"severity": severity.value,
"affected_crops": ["wheat", "corn"],
"spread_rate": spread_rate,
"environmental_factors": {
"humidity": 65,
"temperature": spectral_data.get("temperature", 25),
"rainfall_last_week": 15 # mm
},
"confidence_score": 0.85
}
@tool
def calculate_spray_recommendation(pest_report: Dict, field_data: Dict, weather: Dict) -> Dict:
"""Calculate optimal spraying strategy for pest/disease control"""
severity = pest_report.get("severity", "none")
if severity == "high" or severity == "severe":
treatment = "pesticide"
product = "Imidacloprid"
rate = 0.5
impact = "medium"
elif severity == "moderate":
treatment = "fungicide"
product = "Azoxystrobin"
rate = 0.3
impact = "low"
elif severity == "low":
treatment = "organic_treatment"
product = "Neem Oil"
rate = 0.2
impact = "minimal"
else:
treatment = "fertilizer"
product = "NPK 20-20-20"
rate = 0.4
impact = "positive"
# Calculate target zones based on affected area
affected_area = field_data.get("affected_area", 0)
if affected_area > 0:
target_zones = [
{
"zone_id": "A1",
"gps_boundary": [(40.71, -74.00), (40.72, -74.01)],
"priority": "high"
}
]
else:
target_zones = []
return {
"recommendation_id": f"SPR-{datetime.now().strftime('%Y%m%d%H%M')}",
"field_id": field_data["field_id"],
"treatment_type": treatment,
"product_name": product,
"application_rate": rate,
"affected_area": affected_area,
"target_zones": target_zones,
"optimal_time": (datetime.now() + timedelta(days=1)).isoformat(),
"weather_window": {
"wind_speed": weather.get("wind_speed", 5),
"precipitation": weather.get("precipitation", 0),
"suitable": True
},
"cost_estimate": rate * affected_area * 50, # $/hectare
"environmental_impact": impact
}
@tool
def predict_crop_yield(field_id: str, crop_type: str, current_health: Dict, weather_forecast: Dict) -> Dict:
"""Predict crop yield based on current conditions and forecasts"""
# Simulate yield prediction
health_level = current_health.get("health_level", "good")
base_yield = {
"wheat": 3.5,
"corn": 9.5,
"soybeans": 2.8,
"rice": 6.0
}.get(crop_type, 5.0)
# Adjust based on health
health_multiplier = {
"excellent": 1.2,
"good": 1.0,
"fair": 0.8,
"poor": 0.6,
"critical": 0.4
}.get(health_level, 1.0)
predicted_yield = base_yield * health_multiplier
# Quality grade based on conditions
if health_level in ["excellent", "good"]:
quality = "A"
price_multiplier = 1.2
elif health_level == "fair":
quality = "B"
price_multiplier = 1.0
else:
quality = "C"
price_multiplier = 0.8
return {
"forecast_id": f"YLD-{datetime.now().strftime('%Y%m%d%H%M')}",
"field_id": field_id,
"crop_type": crop_type,
"predicted_yield": predicted_yield,
"confidence_interval": (predicted_yield * 0.9, predicted_yield * 1.1),
"harvest_window": (
(datetime.now() + timedelta(days=60)).isoformat(),
(datetime.now() + timedelta(days=75)).isoformat()
),
"quality_grade": quality,
"market_price_estimate": predicted_yield * 250 * price_multiplier, # $/ton
"risk_factors": ["weather_variability", "pest_pressure", "market_volatility"]
}
# Agriculture agent definitions
multispectral_agent = Agent(
name="MultispectralAnalyzer",
instructions="""You are a remote sensing specialist for precision agriculture.
Analyze multispectral imagery from drones and satellites.
Calculate vegetation indices (NDVI, NDRE, CCCI) to assess crop health.
Identify stress zones and anomalies in field conditions.
SLA: Process imagery within 2 minutes per field.""",
tools=[analyze_multispectral_imagery]
)
disease_agent = Agent(
name="DiseaseIdentifier",
instructions="""You are a plant pathology expert with AI vision capabilities.
Identify crop diseases, pests, and nutrient deficiencies.
Assess severity levels and predict spread patterns.
Consider environmental factors affecting disease development.
SLA: Complete identification within 30 seconds.""",
tools=[identify_pest_disease]
)
spray_agent = Agent(
name="SprayRecommender",
instructions="""You are a precision application specialist.
Recommend targeted spraying strategies for pest and disease control.
Optimize chemical usage to minimize environmental impact.
Consider weather windows and application regulations.
SLA: Generate recommendations within 1 minute.""",
tools=[calculate_spray_recommendation]
)
yield_agent = Agent(
name="YieldPredictor",
instructions="""You are a crop yield forecasting specialist.
Predict harvest yields based on current conditions and historical data.
Assess crop quality grades and market value estimates.
Identify risk factors that could impact final yield.
SLA: Generate forecast within 45 seconds.""",
tools=[predict_crop_yield]
)
# Precision agriculture workflow
def precision_agriculture_workflow(field_ids: List[str], crop_type: str, weather_data: Dict):
"""
Complete precision agriculture workflow from monitoring to treatment
Includes early warning system and sustainable farming practices
"""
workflow_results = {
"timestamp": datetime.now().isoformat(),
"fields_monitored": len(field_ids),
"alerts": [],
"treatments_needed": [],
"yield_forecasts": [],
"sustainability_score": 0.0
}
for field_id in field_ids:
try:
# Step 1: Multispectral analysis
spectral_analysis = multispectral_agent.start(
f"Analyze multispectral imagery for field {field_id}"
)
# Step 2: Disease and pest detection
pest_report = disease_agent.start(
f"Identify pests/diseases from spectral data: {spectral_analysis}"
)
# Check for alerts
if pest_report.get("severity") in ["high", "severe"]:
workflow_results["alerts"].append({
"field_id": field_id,
"type": "pest_disease_alert",
"severity": pest_report["severity"],
"pest": pest_report.get("pest_type"),
"disease": pest_report.get("disease_type"),
"action": "immediate_treatment_required"
})
# Step 3: Generate spray recommendation
spray_recommendation = spray_agent.start(
f"Calculate spray strategy for pest report: {pest_report}, "
f"field: {spectral_analysis}, weather: {weather_data}"
)
workflow_results["treatments_needed"].append(spray_recommendation)
# Step 4: Yield prediction
yield_forecast = yield_agent.start(
f"Predict yield for field {field_id}, crop {crop_type}, "
f"health: {spectral_analysis}, weather: {weather_data}"
)
workflow_results["yield_forecasts"].append(yield_forecast)
except Exception as e:
# Fallback: Schedule manual inspection
workflow_results["alerts"].append({
"field_id": field_id,
"type": "monitoring_failure",
"error": str(e),
"action": "schedule_manual_inspection"
})
# Calculate sustainability score
total_chemical = sum(
t.get("application_rate", 0) * t.get("affected_area", 0)
for t in workflow_results["treatments_needed"]
)
workflow_results["sustainability_score"] = max(0, 100 - (total_chemical * 10))
return workflow_results
# Sustainable farming patterns
class SustainableFarmingPatterns:
"""
Patterns for sustainable and regenerative agriculture
"""
@staticmethod
def integrated_pest_management(pest_level: str) -> Dict:
"""IPM strategy based on pest pressure"""
strategies = {
"low": {
"method": "biological_control",
"actions": ["release_beneficial_insects", "plant_trap_crops"],
"chemical_use": 0
},
"moderate": {
"method": "targeted_intervention",
"actions": ["spot_treatment", "pheromone_traps", "minimal_spray"],
"chemical_use": 30
},
"high": {
"method": "integrated_approach",
"actions": ["systematic_spray", "crop_rotation", "resistant_varieties"],
"chemical_use": 70
}
}
return strategies.get(pest_level, strategies["moderate"])
@staticmethod
def precision_irrigation(moisture_level: float, crop_stage: str) -> Dict:
"""Calculate optimal irrigation based on soil moisture and crop stage"""
# Water requirements by growth stage (mm/day)
stage_requirements = {
"germination": 3.0,
"vegetative": 5.0,
"flowering": 7.0,
"grain_filling": 6.0,
"maturation": 2.0
}
required = stage_requirements.get(crop_stage, 5.0)
deficit = max(0, 40 - moisture_level) # Target 40% moisture
return {
"irrigation_needed": deficit > 10,
"amount_mm": deficit * 2,
"method": "drip" if deficit < 20 else "sprinkler",
"schedule": "night_irrigation" if deficit > 15 else "morning_irrigation",
"water_saved": (100 - deficit) / 100 * 1000 # Liters per hectare
}
@staticmethod
def crop_rotation_optimizer(current_crop: str, soil_data: Dict) -> str:
"""Recommend next crop for rotation based on soil health"""
rotations = {
"wheat": ["soybeans", "canola"], # Nitrogen fixation
"corn": ["soybeans", "alfalfa"], # Break disease cycle
"soybeans": ["wheat", "corn"], # Utilize fixed nitrogen
"rice": ["legumes", "vegetables"], # Soil structure improvement
}
return rotations.get(current_crop, ["cover_crops"])[0]
# IoT sensor integration patterns
class IoTSensorPatterns:
"""
Patterns for integrating IoT sensors in smart farming
"""
@staticmethod
def create_sensor_network_agent(sensor_types: List[str]) -> Agent:
"""Create agent for managing IoT sensor networks"""
return Agent(
name="SensorNetworkManager",
instructions=f"""Manage IoT sensor network with {', '.join(sensor_types)}.
Collect real-time data from field sensors.
Detect sensor anomalies and maintenance needs.
Aggregate data for precision agriculture decisions.
SLA: Data collection every 15 minutes."""
)
@staticmethod
def process_sensor_data(sensor_readings: List[Dict]) -> Dict:
"""Process and aggregate IoT sensor data"""
aggregated = {
"timestamp": datetime.now().isoformat(),
"sensor_count": len(sensor_readings),
"averages": {},
"alerts": []
}
# Calculate averages
for reading in sensor_readings:
for key, value in reading.items():
if isinstance(value, (int, float)):
if key not in aggregated["averages"]:
aggregated["averages"][key] = []
aggregated["averages"][key].append(value)
# Convert to means
for key in aggregated["averages"]:
values = aggregated["averages"][key]
aggregated["averages"][key] = sum(values) / len(values)
# Check for anomalies
if key == "soil_ph" and (aggregated["averages"][key] < 6.0 or aggregated["averages"][key] > 7.5):
aggregated["alerts"].append({
"type": "soil_ph_anomaly",
"value": aggregated["averages"][key],
"action": "adjust_soil_treatment"
})
return aggregated
# Example usage
if __name__ == "__main__":
# Monitor multiple fields
fields = ["FIELD-001", "FIELD-002", "FIELD-003"]
weather = {
"temperature": 25,
"humidity": 60,
"wind_speed": 8,
"precipitation": 0,
"forecast": "partly_cloudy"
}
result = precision_agriculture_workflow(fields, "wheat", weather)
print("Agriculture workflow result:", result)
# Sustainable farming example
ipm_strategy = SustainableFarmingPatterns.integrated_pest_management("moderate")
print("IPM Strategy:", ipm_strategy)
irrigation_plan = SustainableFarmingPatterns.precision_irrigation(25.0, "flowering")
print("Irrigation Plan:", irrigation_plan)
next_crop = SustainableFarmingPatterns.crop_rotation_optimizer("wheat", {})
print("Recommended next crop:", next_crop)
# IoT integration example
sensor_agent = IoTSensorPatterns.create_sensor_network_agent(
["soil_moisture", "temperature", "humidity", "light", "ph"]
)
sensor_data = [
{"sensor_id": "S001", "soil_moisture": 35, "temperature": 24, "soil_ph": 6.8},
{"sensor_id": "S002", "soil_moisture": 32, "temperature": 25, "soil_ph": 7.2},
{"sensor_id": "S003", "soil_moisture": 38, "temperature": 23, "soil_ph": 5.8}
]
processed_data = IoTSensorPatterns.process_sensor_data(sensor_data)
print("Processed sensor data:", processed_data)
@@ -0,0 +1,372 @@
"""
Energy Industry Template
========================
Wind farm monitoring and power optimization workflow
Based on SRAO Framework patterns with predictive maintenance
Key agents:
- SCADAReader: Processes SCADA telemetry data
- VibrationAnalyzer: Analyzes turbine vibration patterns
- PowerForecaster: Predicts power generation
- MaintenanceScheduler: Schedules predictive maintenance
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Optional
from pydantic import BaseModel
from datetime import datetime, timedelta
# I/O Schemas
class TurbineData(BaseModel):
"""Wind turbine telemetry data"""
turbine_id: str
timestamp: str
wind_speed: float # m/s
power_output: float # MW
rotor_speed: float # RPM
blade_pitch: float # degrees
nacelle_temperature: float # Celsius
vibration_level: float # mm/s
operational_status: str # running, stopped, maintenance
class VibrationAnalysis(BaseModel):
"""Vibration analysis results"""
turbine_id: str
analysis_time: str
vibration_rms: float
frequency_peaks: List[float]
anomaly_detected: bool
failure_probability: float
recommended_action: str
class PowerForecast(BaseModel):
"""Power generation forecast"""
forecast_id: str
start_time: str
end_time: str
predicted_output: List[float] # MW per hour
confidence_interval: float
weather_factors: Dict[str, Any]
grid_demand: float
class MaintenancePlan(BaseModel):
"""Maintenance scheduling plan"""
plan_id: str
turbine_id: str
maintenance_type: str # preventive, predictive, corrective
scheduled_date: str
estimated_duration: int # hours
required_parts: List[str]
technician_team: str
production_loss: float # MW
# Tools for energy management
@tool
def read_scada_telemetry(turbine_id: str, time_range: str = "last_hour") -> Dict:
"""Read SCADA system telemetry for wind turbines"""
# Simulate SCADA data reading
return {
"turbine_id": turbine_id,
"timestamp": datetime.now().isoformat(),
"wind_speed": 12.5,
"power_output": 2.3,
"rotor_speed": 16.8,
"blade_pitch": 8.5,
"nacelle_temperature": 45.2,
"vibration_level": 3.2,
"operational_status": "running"
}
@tool
def analyze_vibration_patterns(turbine_data: Dict) -> Dict:
"""Analyze vibration patterns for early fault detection"""
# Simulate vibration analysis
vibration_level = turbine_data.get("vibration_level", 0)
anomaly = vibration_level > 5.0 # Threshold for anomaly
return {
"turbine_id": turbine_data["turbine_id"],
"analysis_time": datetime.now().isoformat(),
"vibration_rms": vibration_level,
"frequency_peaks": [10.5, 25.3, 50.0], # Hz
"anomaly_detected": anomaly,
"failure_probability": min(vibration_level / 10.0, 1.0),
"recommended_action": "immediate_inspection" if anomaly else "continue_monitoring"
}
@tool
def forecast_power_generation(weather_data: Dict, historical_data: List) -> Dict:
"""Forecast power generation based on weather and historical patterns"""
# Simulate power forecasting
base_forecast = [2.0 + (i * 0.1) for i in range(24)] # 24-hour forecast
return {
"forecast_id": f"FCST-{datetime.now().strftime('%Y%m%d%H%M')}",
"start_time": datetime.now().isoformat(),
"end_time": (datetime.now() + timedelta(hours=24)).isoformat(),
"predicted_output": base_forecast,
"confidence_interval": 0.85,
"weather_factors": weather_data,
"grid_demand": sum(base_forecast)
}
@tool
def schedule_maintenance(turbine_id: str, maintenance_type: str, urgency: str = "normal") -> Dict:
"""Schedule maintenance based on condition monitoring"""
# Calculate maintenance window
if urgency == "urgent":
scheduled_date = (datetime.now() + timedelta(days=1)).isoformat()
else:
scheduled_date = (datetime.now() + timedelta(days=7)).isoformat()
return {
"plan_id": f"MNT-{datetime.now().strftime('%Y%m%d%H%M')}",
"turbine_id": turbine_id,
"maintenance_type": maintenance_type,
"scheduled_date": scheduled_date,
"estimated_duration": 4,
"required_parts": ["bearing_set", "lubricant", "filter"],
"technician_team": "Team-A",
"production_loss": 8.0 # MW lost during maintenance
}
# Agent Definitions with SLA requirements
scada_reader = Agent(
name="SCADAReader",
instructions="""You are a SCADA system specialist for wind farms.
Process real-time telemetry data from wind turbines.
Identify operational anomalies and performance deviations.
SLA: Process telemetry within 1 second for real-time monitoring.""",
tools=[read_scada_telemetry]
)
vibration_analyzer = Agent(
name="VibrationAnalyzer",
instructions="""You are a vibration analysis expert for rotating machinery.
Analyze vibration patterns to detect early signs of mechanical failure.
Use FFT analysis and pattern recognition for fault diagnosis.
SLA: Complete analysis within 5 seconds per turbine.""",
tools=[analyze_vibration_patterns]
)
power_forecaster = Agent(
name="PowerForecaster",
instructions="""You are a power generation forecasting specialist.
Predict power output based on weather forecasts and historical patterns.
Consider grid demand and optimize generation schedules.
SLA: Generate 24-hour forecast within 30 seconds.""",
tools=[forecast_power_generation]
)
maintenance_scheduler = Agent(
name="MaintenanceScheduler",
instructions="""You are a predictive maintenance planning expert.
Schedule maintenance based on condition monitoring and failure predictions.
Minimize production loss while ensuring turbine reliability.
SLA: Create maintenance plan within 1 minute.""",
tools=[schedule_maintenance]
)
# Energy workflow with grid integration
def energy_monitoring_workflow(turbine_ids: List[str], weather_forecast: Dict):
"""
Complete energy monitoring and optimization workflow
Includes predictive maintenance and grid integration
"""
results = {
"timestamp": datetime.now().isoformat(),
"turbines_monitored": len(turbine_ids),
"alerts": [],
"maintenance_required": [],
"power_forecast": None
}
# Step 1: Monitor all turbines
for turbine_id in turbine_ids:
try:
# Read SCADA data
scada_data = scada_reader.start(
f"Read telemetry for turbine {turbine_id}"
)
# Analyze vibrations
vibration_result = vibration_analyzer.start(
f"Analyze vibrations for data: {scada_data}"
)
# Check for anomalies
if vibration_result.get("anomaly_detected"):
results["alerts"].append({
"turbine_id": turbine_id,
"type": "vibration_anomaly",
"severity": "high" if vibration_result.get("failure_probability", 0) > 0.7 else "medium"
})
# Schedule maintenance if needed
maintenance_plan = maintenance_scheduler.start(
f"Schedule maintenance for turbine {turbine_id} with urgency based on {vibration_result}"
)
results["maintenance_required"].append(maintenance_plan)
except Exception as e:
# Fallback: Mark turbine for manual inspection
results["alerts"].append({
"turbine_id": turbine_id,
"type": "monitoring_failure",
"error": str(e),
"action": "manual_inspection_required"
})
# Step 2: Power generation forecast
try:
historical_data = [] # Would be fetched from database
power_forecast = power_forecaster.start(
f"Forecast power with weather {weather_forecast} and history {historical_data}"
)
results["power_forecast"] = power_forecast
# Grid integration check
if power_forecast.get("predicted_output"):
total_predicted = sum(power_forecast["predicted_output"])
grid_demand = power_forecast.get("grid_demand", 0)
if total_predicted < grid_demand * 0.9:
results["alerts"].append({
"type": "supply_shortage",
"predicted": total_predicted,
"demand": grid_demand,
"action": "consider_backup_sources"
})
except Exception as e:
# Fallback: Use conservative forecast
results["power_forecast"] = {
"status": "fallback_mode",
"conservative_estimate": len(turbine_ids) * 1.5, # MW
"error": str(e)
}
return results
# Cross-industry pattern adaptation
class EnergyPatternAdapter:
"""
Adapts manufacturing patterns for energy sector
Demonstrates 70% code reuse across industries
"""
@staticmethod
def adapt_for_solar_farm():
"""Adapt wind farm agents for solar farm monitoring"""
return Agent(
name="SolarMonitor",
instructions="""Monitor solar panel efficiency and temperature.
Track irradiance levels and predict power output.
Detect panel degradation and soiling issues.
SLA: Real-time monitoring with 2-second updates."""
)
@staticmethod
def adapt_for_power_grid():
"""Adapt for power grid management"""
return Agent(
name="GridBalancer",
instructions="""Balance power supply and demand across the grid.
Manage load distribution and prevent blackouts.
Coordinate with renewable and traditional sources.
SLA: Sub-second response for grid stability."""
)
@staticmethod
def adapt_for_battery_storage():
"""Adapt for battery energy storage systems"""
return Agent(
name="BatteryOptimizer",
instructions="""Optimize battery charge/discharge cycles.
Predict battery degradation and schedule replacements.
Maximize energy arbitrage opportunities.
SLA: Optimization decision within 10 seconds."""
)
# Fallback strategies for critical operations
class EnergyFallbackStrategies:
"""
Fallback strategies for maintaining grid stability
"""
@staticmethod
def turbine_failure_fallback(failed_turbine_id: str, available_turbines: List[str]):
"""Handle turbine failure by redistributing load"""
return {
"strategy": "load_redistribution",
"failed_turbine": failed_turbine_id,
"compensating_turbines": available_turbines[:3], # Use top 3 turbines
"increase_output_by": 0.3, # MW per turbine
"duration": "until_maintenance_complete"
}
@staticmethod
def forecast_failure_fallback(last_known_forecast: Optional[Dict]):
"""Handle forecasting system failure"""
if last_known_forecast:
return {
"strategy": "use_last_known",
"forecast": last_known_forecast,
"confidence": 0.6,
"validity": "4_hours"
}
else:
return {
"strategy": "seasonal_average",
"estimated_output": 50.0, # MW based on seasonal average
"confidence": 0.4,
"recommendation": "increase_reserves"
}
@staticmethod
def communication_failure_fallback():
"""Handle SCADA communication failure"""
return {
"strategy": "local_control_mode",
"actions": [
"Switch turbines to autonomous mode",
"Use local safety thresholds",
"Log data locally for later sync",
"Alert maintenance teams"
],
"recovery_check_interval": 300 # seconds
}
# Example usage
if __name__ == "__main__":
# Monitor wind farm
turbine_list = [f"WT-{i:03d}" for i in range(1, 11)] # 10 turbines
weather = {"wind_speed": 14.0, "direction": "NW", "temperature": 15.0}
result = energy_monitoring_workflow(turbine_list, weather)
print("Energy monitoring result:", result)
# Demonstrate cross-industry adaptation
solar_monitor = EnergyPatternAdapter.adapt_for_solar_farm()
grid_balancer = EnergyPatternAdapter.adapt_for_power_grid()
battery_optimizer = EnergyPatternAdapter.adapt_for_battery_storage()
# Fallback examples
turbine_fallback = EnergyFallbackStrategies.turbine_failure_fallback(
"WT-003", ["WT-001", "WT-002", "WT-004", "WT-005"]
)
print("Turbine failure fallback:", turbine_fallback)
@@ -0,0 +1,478 @@
"""
Healthcare Industry Template
============================
Emergency triage and patient care coordination workflow
Based on SRAO Framework with HIPAA-compliant patterns
Key agents:
- VitalSignsCapture: Processes patient vital signs
- EMRRetrieval: Retrieves electronic medical records
- TriageRecommendation: Provides triage recommendations
- ResourceAllocator: Manages hospital resource allocation
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Optional
from pydantic import BaseModel
from datetime import datetime
from enum import Enum
# Triage levels following Emergency Severity Index (ESI)
class TriageLevel(str, Enum):
RESUSCITATION = "1_resuscitation" # Immediate life-saving intervention
EMERGENT = "2_emergent" # High risk, severe pain/distress
URGENT = "3_urgent" # Stable but needs multiple resources
LESS_URGENT = "4_less_urgent" # Stable, one resource needed
NON_URGENT = "5_non_urgent" # Stable, no resources needed
# I/O Schemas
class VitalSigns(BaseModel):
"""Patient vital signs data"""
patient_id: str
timestamp: str
heart_rate: int # bpm
blood_pressure_systolic: int # mmHg
blood_pressure_diastolic: int # mmHg
respiratory_rate: int # breaths/min
temperature: float # Celsius
oxygen_saturation: int # percentage
pain_scale: int # 0-10
consciousness_level: str # alert, verbal, pain, unresponsive (AVPU)
class MedicalHistory(BaseModel):
"""Patient medical history from EMR"""
patient_id: str
allergies: List[str]
chronic_conditions: List[str]
current_medications: List[Dict[str, str]]
recent_visits: List[Dict[str, Any]]
emergency_contacts: List[Dict[str, str]]
insurance_status: str
class TriageAssessment(BaseModel):
"""Triage assessment and recommendations"""
assessment_id: str
patient_id: str
triage_level: TriageLevel
chief_complaint: str
recommended_department: str
estimated_wait_time: int # minutes
required_resources: List[str]
clinical_notes: str
red_flags: List[str]
class ResourceAllocation(BaseModel):
"""Hospital resource allocation"""
allocation_id: str
patient_id: str
assigned_bed: Optional[str]
assigned_staff: List[str]
equipment_needed: List[str]
department: str
priority_score: float
estimated_treatment_time: int # minutes
# Healthcare-specific tools
@tool
def capture_vital_signs(patient_id: str) -> Dict:
"""Capture patient vital signs from monitoring devices"""
# Simulate vital signs capture
return {
"patient_id": patient_id,
"timestamp": datetime.now().isoformat(),
"heart_rate": 78,
"blood_pressure_systolic": 130,
"blood_pressure_diastolic": 85,
"respiratory_rate": 16,
"temperature": 37.2,
"oxygen_saturation": 98,
"pain_scale": 6,
"consciousness_level": "alert"
}
@tool
def retrieve_medical_history(patient_id: str, consent_verified: bool = True) -> Dict:
"""Retrieve patient medical history from EMR system"""
if not consent_verified:
return {"error": "consent_required", "message": "Patient consent required for EMR access"}
# Simulate EMR retrieval
return {
"patient_id": patient_id,
"allergies": ["penicillin", "latex"],
"chronic_conditions": ["hypertension", "diabetes_type2"],
"current_medications": [
{"name": "metformin", "dose": "500mg", "frequency": "twice daily"},
{"name": "lisinopril", "dose": "10mg", "frequency": "once daily"}
],
"recent_visits": [
{"date": "2024-02-15", "reason": "routine_checkup", "department": "primary_care"}
],
"emergency_contacts": [
{"name": "Jane Doe", "relationship": "spouse", "phone": "555-0123"}
],
"insurance_status": "active"
}
@tool
def calculate_triage_priority(vital_signs: Dict, medical_history: Dict, chief_complaint: str) -> Dict:
"""Calculate triage priority based on clinical indicators"""
# Simulate triage algorithm
priority_score = 0
red_flags = []
# Check vital signs
if vital_signs["heart_rate"] > 120 or vital_signs["heart_rate"] < 50:
priority_score += 3
red_flags.append("abnormal_heart_rate")
if vital_signs["oxygen_saturation"] < 92:
priority_score += 4
red_flags.append("low_oxygen")
if vital_signs["consciousness_level"] != "alert":
priority_score += 5
red_flags.append("altered_consciousness")
# Determine triage level
if priority_score >= 8:
triage_level = TriageLevel.RESUSCITATION
department = "emergency"
wait_time = 0
elif priority_score >= 5:
triage_level = TriageLevel.EMERGENT
department = "emergency"
wait_time = 10
elif priority_score >= 3:
triage_level = TriageLevel.URGENT
department = "urgent_care"
wait_time = 30
else:
triage_level = TriageLevel.LESS_URGENT
department = "primary_care"
wait_time = 60
return {
"assessment_id": f"TRG-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"patient_id": vital_signs["patient_id"],
"triage_level": triage_level.value,
"chief_complaint": chief_complaint,
"recommended_department": department,
"estimated_wait_time": wait_time,
"required_resources": ["physician", "nurse", "monitoring"],
"clinical_notes": f"Priority score: {priority_score}",
"red_flags": red_flags
}
@tool
def allocate_resources(patient_id: str, triage_level: str, department: str) -> Dict:
"""Allocate hospital resources based on triage assessment"""
# Simulate resource allocation
if "resuscitation" in triage_level:
bed = "ER-01"
staff = ["Dr. Smith", "Nurse Johnson", "Nurse Williams"]
equipment = ["cardiac_monitor", "defibrillator", "crash_cart"]
priority = 10.0
elif "emergent" in triage_level:
bed = "ER-05"
staff = ["Dr. Brown", "Nurse Davis"]
equipment = ["vital_signs_monitor", "IV_pump"]
priority = 8.0
else:
bed = "UC-12"
staff = ["PA Miller", "Nurse Garcia"]
equipment = ["basic_monitor"]
priority = 5.0
return {
"allocation_id": f"RES-{datetime.now().strftime('%Y%m%d%H%M%S')}",
"patient_id": patient_id,
"assigned_bed": bed,
"assigned_staff": staff,
"equipment_needed": equipment,
"department": department,
"priority_score": priority,
"estimated_treatment_time": 45
}
# Healthcare agent definitions with SLA requirements
vital_signs_agent = Agent(
name="VitalSignsCapture",
instructions="""You are a clinical vital signs specialist.
Capture and interpret patient vital signs from various monitoring devices.
Identify abnormal values and trends that require immediate attention.
Follow HIPAA guidelines for patient data handling.
SLA: Capture and process within 30 seconds.""",
tools=[capture_vital_signs]
)
emr_agent = Agent(
name="EMRRetrieval",
instructions="""You are an EMR system specialist with HIPAA compliance expertise.
Retrieve relevant patient medical history while ensuring data privacy.
Verify patient consent and maintain audit trails.
Highlight relevant allergies and contraindications.
SLA: Retrieve records within 5 seconds.""",
tools=[retrieve_medical_history]
)
triage_agent = Agent(
name="TriageRecommendation",
instructions="""You are an emergency triage specialist following ESI guidelines.
Assess patient severity and provide triage recommendations.
Consider vital signs, medical history, and presenting symptoms.
Identify red flags that require immediate intervention.
SLA: Complete assessment within 1 minute.""",
tools=[calculate_triage_priority]
)
resource_agent = Agent(
name="ResourceAllocator",
instructions="""You are a hospital resource management specialist.
Allocate beds, staff, and equipment based on patient needs and priority.
Optimize resource utilization while maintaining quality of care.
Coordinate between departments for efficient patient flow.
SLA: Allocate resources within 30 seconds.""",
tools=[allocate_resources]
)
# Healthcare workflow with safety protocols
def emergency_triage_workflow(patient_id: str, chief_complaint: str):
"""
Complete emergency triage workflow from arrival to resource allocation
Includes safety checks and fallback protocols
"""
workflow_result = {
"patient_id": patient_id,
"timestamp": datetime.now().isoformat(),
"status": "in_progress",
"safety_checks": [],
"clinical_pathway": []
}
# Step 1: Vital signs capture with validation
try:
vital_signs = vital_signs_agent.start(
f"Capture vital signs for patient {patient_id}"
)
workflow_result["clinical_pathway"].append({
"step": "vital_signs",
"status": "completed",
"data": vital_signs
})
# Safety check: Validate vital signs are within possible ranges
if vital_signs.get("heart_rate", 0) < 20 or vital_signs.get("heart_rate", 0) > 300:
workflow_result["safety_checks"].append({
"type": "vital_signs_validation",
"issue": "impossible_heart_rate",
"action": "manual_verification_required"
})
except Exception as e:
# Fallback: Manual vital signs entry
workflow_result["clinical_pathway"].append({
"step": "vital_signs",
"status": "manual_entry_required",
"error": str(e)
})
vital_signs = {"manual_entry": True}
# Step 2: EMR retrieval with consent check
try:
medical_history = emr_agent.start(
f"Retrieve medical history for patient {patient_id} with verified consent"
)
workflow_result["clinical_pathway"].append({
"step": "emr_retrieval",
"status": "completed",
"records_retrieved": True
})
# Safety check: Allergy alerts
if medical_history.get("allergies"):
workflow_result["safety_checks"].append({
"type": "allergy_alert",
"allergies": medical_history["allergies"],
"action": "notify_all_providers"
})
except Exception as e:
# Fallback: Proceed with limited information
medical_history = {
"patient_id": patient_id,
"allergies": [],
"note": "EMR_unavailable_proceed_with_caution"
}
workflow_result["safety_checks"].append({
"type": "emr_unavailable",
"action": "collect_history_manually"
})
# Step 3: Triage assessment
try:
triage_assessment = triage_agent.start(
f"Assess triage for patient with vitals {vital_signs}, "
f"history {medical_history}, complaint: {chief_complaint}"
)
workflow_result["clinical_pathway"].append({
"step": "triage_assessment",
"status": "completed",
"triage_level": triage_assessment.get("triage_level")
})
# Safety check: Red flags requiring immediate action
if triage_assessment.get("red_flags"):
workflow_result["safety_checks"].append({
"type": "clinical_red_flags",
"flags": triage_assessment["red_flags"],
"action": "immediate_physician_notification"
})
except Exception as e:
# Fallback: Conservative triage (assume urgent)
triage_assessment = {
"triage_level": TriageLevel.URGENT.value,
"recommended_department": "emergency",
"fallback": True
}
# Step 4: Resource allocation
try:
resource_allocation = resource_agent.start(
f"Allocate resources for patient {patient_id} "
f"with triage {triage_assessment.get('triage_level')} "
f"to {triage_assessment.get('recommended_department')}"
)
workflow_result["clinical_pathway"].append({
"step": "resource_allocation",
"status": "completed",
"bed_assigned": resource_allocation.get("assigned_bed"),
"staff_assigned": resource_allocation.get("assigned_staff")
})
except Exception as e:
# Fallback: Queue for next available
workflow_result["clinical_pathway"].append({
"step": "resource_allocation",
"status": "queued",
"queue_position": "next_available",
"error": str(e)
})
workflow_result["status"] = "completed"
return workflow_result
# HIPAA-compliant data handling patterns
class HIPAACompliancePatterns:
"""
Reusable patterns for HIPAA-compliant healthcare workflows
"""
@staticmethod
def anonymize_patient_data(data: Dict) -> Dict:
"""Remove PHI for analytics and reporting"""
safe_fields = ["triage_level", "department", "wait_time", "resource_type"]
return {k: v for k, v in data.items() if k in safe_fields}
@staticmethod
def audit_log_access(user_id: str, patient_id: str, action: str, reason: str):
"""Create audit trail for EMR access"""
return {
"timestamp": datetime.now().isoformat(),
"user_id": user_id,
"patient_id": patient_id,
"action": action,
"reason": reason,
"ip_address": "10.0.0.1", # Would be actual IP
"session_id": "SEC-" + datetime.now().strftime('%Y%m%d%H%M%S')
}
@staticmethod
def encrypt_sensitive_data(data: Dict) -> Dict:
"""Encrypt sensitive fields (placeholder for real encryption)"""
sensitive_fields = ["ssn", "dob", "address", "phone"]
encrypted = data.copy()
for field in sensitive_fields:
if field in encrypted:
encrypted[field] = f"ENCRYPTED_{field.upper()}"
return encrypted
# Cross-department coordination patterns
class HealthcareCoordinationPatterns:
"""
Patterns for coordinating across healthcare departments
"""
@staticmethod
def coordinate_lab_orders(patient_id: str, triage_level: str) -> Agent:
"""Create agent for lab order coordination"""
return Agent(
name="LabCoordinator",
instructions=f"""Coordinate laboratory orders for patient {patient_id}.
Priority level: {triage_level}.
Ensure STAT orders for critical patients.
Track specimen collection and result delivery.
SLA: Order placement within 2 minutes."""
)
@staticmethod
def coordinate_radiology(patient_id: str, imaging_type: str) -> Agent:
"""Create agent for radiology coordination"""
return Agent(
name="RadiologyCoordinator",
instructions=f"""Schedule {imaging_type} imaging for patient {patient_id}.
Check for contrast allergies and pregnancy status.
Coordinate with transport for patient movement.
SLA: Schedule within 5 minutes for urgent cases."""
)
@staticmethod
def coordinate_pharmacy(patient_id: str, medications: List[str]) -> Agent:
"""Create agent for pharmacy coordination"""
return Agent(
name="PharmacyCoordinator",
instructions=f"""Verify and dispense medications for patient {patient_id}.
Check for drug interactions and allergies.
Ensure proper dosing based on patient parameters.
SLA: Medication verification within 3 minutes."""
)
# Example usage
if __name__ == "__main__":
# Process emergency patient
patient_id = "PT-2024-00123"
chief_complaint = "severe chest pain radiating to left arm"
result = emergency_triage_workflow(patient_id, chief_complaint)
print("Triage workflow result:", result)
# Create specialized coordinators
lab_coordinator = HealthcareCoordinationPatterns.coordinate_lab_orders(
patient_id, TriageLevel.EMERGENT.value
)
radiology_coordinator = HealthcareCoordinationPatterns.coordinate_radiology(
patient_id, "chest_xray"
)
pharmacy_coordinator = HealthcareCoordinationPatterns.coordinate_pharmacy(
patient_id, ["aspirin", "nitroglycerin"]
)
# HIPAA compliance example
audit_log = HIPAACompliancePatterns.audit_log_access(
"DR-001", patient_id, "EMR_ACCESS", "emergency_triage"
)
print("Audit log created:", audit_log)
@@ -0,0 +1,298 @@
"""
Manufacturing Industry Template
================================
Order scheduling, inventory management, and quality inspection workflow
Based on SRAO Framework patterns with 70% cross-industry reuse
Key agents:
- ParseOrder: Extracts order details from various formats
- CheckInventory: Validates material availability
- OptimizeSchedule: Optimizes production scheduling
- DefectDetect: Quality control and defect detection
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any
from pydantic import BaseModel
# I/O Schemas
class OrderDetails(BaseModel):
"""Structured order information"""
order_id: str
customer_id: str
product_id: str
quantity: int
priority: str = "normal" # normal, high, urgent
due_date: str
requirements: Dict[str, Any] = {}
class InventoryStatus(BaseModel):
"""Material availability status"""
material_id: str
available_quantity: int
reorder_level: int
supplier_lead_time: int
location: str
class ProductionSchedule(BaseModel):
"""Optimized production schedule"""
schedule_id: str
production_line: str
start_time: str
end_time: str
assigned_workers: List[str]
estimated_completion: str
bottleneck_analysis: Dict[str, Any] = {}
class QualityReport(BaseModel):
"""Quality inspection results"""
batch_id: str
inspection_time: str
defect_rate: float
defect_types: List[str]
passed: bool
recommendations: List[str]
# Tools for agents
@tool
def extract_order_details(text: str) -> Dict:
"""Extract structured order information from unstructured text"""
# Simulate order parsing logic
return {
"order_id": "ORD-2024-001",
"customer_id": "CUST-123",
"product_id": "PROD-456",
"quantity": 100,
"priority": "high",
"due_date": "2024-03-15",
"requirements": {"material": "steel", "finish": "polished"}
}
@tool
def check_material_availability(product_id: str, quantity: int) -> Dict:
"""Check if materials are available for production"""
# Simulate inventory check
return {
"material_id": "MAT-789",
"available_quantity": 500,
"reorder_level": 100,
"supplier_lead_time": 7,
"location": "Warehouse-A"
}
@tool
def calculate_optimal_schedule(order: Dict, inventory: Dict) -> Dict:
"""Calculate optimal production schedule based on constraints"""
# Simulate scheduling optimization
return {
"schedule_id": "SCH-2024-001",
"production_line": "Line-3",
"start_time": "2024-03-10T08:00:00",
"end_time": "2024-03-14T17:00:00",
"assigned_workers": ["Worker-001", "Worker-002"],
"estimated_completion": "2024-03-14T16:30:00",
"bottleneck_analysis": {
"critical_path": "Assembly",
"buffer_time": 2.5
}
}
@tool
def perform_quality_inspection(batch_id: str) -> Dict:
"""Perform automated quality inspection on production batch"""
# Simulate quality control
return {
"batch_id": batch_id,
"inspection_time": "2024-03-14T17:00:00",
"defect_rate": 0.02,
"defect_types": ["surface_scratch", "dimension_variance"],
"passed": True,
"recommendations": [
"Adjust machine calibration on Line-3",
"Increase inspection frequency for next batch"
]
}
# Agent Definitions with SLA requirements
order_parser = Agent(
name="ParseOrder",
instructions="""You are an order parsing specialist. Extract and structure order information
from various formats (email, PDF, API). Ensure all required fields are captured and validated.
SLA: Process within 30 seconds per order.""",
tools=[extract_order_details]
)
inventory_checker = Agent(
name="CheckInventory",
instructions="""You are an inventory management specialist. Verify material availability,
check reorder points, and identify potential supply chain issues.
SLA: Real-time response within 5 seconds.""",
tools=[check_material_availability]
)
schedule_optimizer = Agent(
name="OptimizeSchedule",
instructions="""You are a production scheduling expert. Optimize production schedules
based on order priority, resource availability, and constraints. Minimize idle time
and maximize throughput.
SLA: Generate schedule within 2 minutes for complex orders.""",
tools=[calculate_optimal_schedule]
)
quality_inspector = Agent(
name="DefectDetect",
instructions="""You are a quality control specialist. Perform automated inspections,
detect defects using vision analysis, and provide improvement recommendations.
SLA: Complete inspection within 1 minute per batch.""",
tools=[perform_quality_inspection]
)
# Workflow with fallback strategies
def manufacturing_workflow(order_text: str):
"""
Complete manufacturing workflow from order to quality control
Includes fallback strategies for each critical step
"""
# Step 1: Parse order with fallback
try:
order_details = order_parser.start(f"Parse this order: {order_text}")
if not order_details:
# Fallback: Manual review queue
return {"status": "manual_review_required", "reason": "order_parsing_failed"}
except Exception as e:
return {"status": "error", "stage": "order_parsing", "error": str(e)}
# Step 2: Check inventory with fallback
try:
inventory_status = inventory_checker.start(
f"Check materials for order: {order_details}"
)
if not inventory_status:
# Fallback: Alternative supplier check
return {"status": "alternative_supplier_needed", "order": order_details}
except Exception as e:
# Fallback: Conservative estimate with safety margin
inventory_status = {"estimated": True, "safety_margin": 1.5}
# Step 3: Optimize schedule with fallback
try:
schedule = schedule_optimizer.start(
f"Create schedule for order {order_details} with inventory {inventory_status}"
)
if not schedule:
# Fallback: Standard scheduling template
schedule = {"template": "standard", "priority": "normal"}
except Exception as e:
# Fallback: Queue for next available slot
schedule = {"status": "queued", "estimated_start": "next_available"}
# Step 4: Quality inspection with fallback
try:
quality_report = quality_inspector.start(
f"Inspect batch for schedule {schedule}"
)
if not quality_report:
# Fallback: Manual inspection required
quality_report = {"requires_manual": True, "inspector_assigned": "QC-Team-1"}
except Exception as e:
# Fallback: Quarantine for review
quality_report = {"status": "quarantined", "review_required": True}
return {
"status": "completed",
"order": order_details,
"inventory": inventory_status,
"schedule": schedule,
"quality": quality_report
}
# Cross-industry reusable patterns (70% reuse)
class IndustryAgentPattern:
"""
Base pattern reusable across industries
Provides common agent capabilities that can be specialized
"""
@staticmethod
def create_data_parser(name: str, domain: str, sla_seconds: int = 30):
"""Create a data parsing agent for any domain"""
return Agent(
name=name,
instructions=f"""You are a {domain} data parsing specialist.
Extract and structure information from various formats.
Validate all required fields according to {domain} standards.
SLA: Process within {sla_seconds} seconds."""
)
@staticmethod
def create_resource_checker(name: str, resource_type: str, sla_seconds: int = 5):
"""Create a resource availability checker for any resource type"""
return Agent(
name=name,
instructions=f"""You are a {resource_type} availability specialist.
Verify {resource_type} availability and identify constraints.
Provide real-time status and alternative options if needed.
SLA: Response within {sla_seconds} seconds."""
)
@staticmethod
def create_optimizer(name: str, optimization_target: str, sla_minutes: int = 2):
"""Create an optimization agent for any target metric"""
return Agent(
name=name,
instructions=f"""You are an optimization expert for {optimization_target}.
Find optimal solutions considering all constraints and priorities.
Minimize waste and maximize efficiency.
SLA: Generate solution within {sla_minutes} minutes."""
)
@staticmethod
def create_inspector(name: str, inspection_type: str, sla_minutes: int = 1):
"""Create an inspection agent for any quality metric"""
return Agent(
name=name,
instructions=f"""You are a {inspection_type} inspection specialist.
Perform automated analysis and detect anomalies.
Provide actionable recommendations for improvement.
SLA: Complete inspection within {sla_minutes} minute(s)."""
)
# Example usage
if __name__ == "__main__":
# Create a manufacturing workflow instance
order = "Customer ABC needs 100 units of Product-XYZ by March 15th, high priority"
# Option 1: Use specialized agents
result = manufacturing_workflow(order)
print("Manufacturing workflow result:", result)
# Option 2: Use reusable patterns for other industries
# These patterns can be adapted for Energy, Healthcare, Agriculture, Transportation
energy_parser = IndustryAgentPattern.create_data_parser(
"EnergyDataParser", "wind farm telemetry", sla_seconds=10
)
healthcare_checker = IndustryAgentPattern.create_resource_checker(
"BedAvailability", "hospital bed", sla_seconds=3
)
agriculture_optimizer = IndustryAgentPattern.create_optimizer(
"IrrigationOptimizer", "water usage", sla_minutes=5
)
transport_inspector = IndustryAgentPattern.create_inspector(
"TunnelSafety", "structural integrity", sla_minutes=2
)
@@ -0,0 +1,599 @@
"""
Transportation Industry Template
================================
Tunnel safety monitoring and infrastructure management workflow
Based on SRAO Framework with LiDAR and sensor fusion
Key agents:
- LiDARFusion: Processes LiDAR point cloud data
- DisplacementCalculator: Calculates structural displacement
- HeatmapGenerator: Generates safety heatmaps
- MaintenancePlanner: Plans infrastructure maintenance
"""
from praisonaiagents import Agent, tool
from typing import Dict, List, Any, Tuple, Optional
from pydantic import BaseModel
from datetime import datetime, timedelta
from enum import Enum
import math
# Safety levels for infrastructure
class SafetyLevel(str, Enum):
SAFE = "safe"
CAUTION = "caution"
WARNING = "warning"
DANGER = "danger"
CRITICAL = "critical"
# Infrastructure types
class InfrastructureType(str, Enum):
TUNNEL = "tunnel"
BRIDGE = "bridge"
HIGHWAY = "highway"
RAILWAY = "railway"
AIRPORT = "airport"
# I/O Schemas
class LiDARScan(BaseModel):
"""LiDAR scan data for infrastructure"""
scan_id: str
infrastructure_id: str
scan_time: str
point_cloud_size: int # Number of points
scan_resolution: float # Points per square meter
coverage_area: float # Square meters
anomaly_points: List[Tuple[float, float, float]] # XYZ coordinates
reference_baseline: Optional[Dict[str, Any]]
class DisplacementAnalysis(BaseModel):
"""Structural displacement analysis"""
analysis_id: str
infrastructure_id: str
analysis_time: str
max_displacement: float # millimeters
displacement_vector: Tuple[float, float, float] # XYZ displacement
critical_zones: List[Dict[str, Any]]
settlement_rate: float # mm/month
tilt_angle: float # degrees
safety_factor: float
class SafetyHeatmap(BaseModel):
"""Infrastructure safety heatmap"""
heatmap_id: str
infrastructure_id: str
generation_time: str
grid_resolution: float # meters
safety_scores: List[List[float]] # 2D grid of safety scores
critical_areas: List[Dict[str, Any]]
overall_safety: SafetyLevel
risk_trends: Dict[str, Any]
class MaintenanceSchedule(BaseModel):
"""Maintenance planning schedule"""
schedule_id: str
infrastructure_id: str
maintenance_type: str # preventive, corrective, emergency
priority_level: str # low, medium, high, critical
scheduled_date: str
estimated_duration: int # hours
required_resources: List[str]
traffic_impact: str
cost_estimate: float
# Transportation-specific tools
@tool
def process_lidar_scan(infrastructure_id: str, scan_type: str = "full") -> Dict:
"""Process LiDAR point cloud data for structural analysis"""
# Simulate LiDAR processing
return {
"scan_id": f"LIDAR-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": infrastructure_id,
"scan_time": datetime.now().isoformat(),
"point_cloud_size": 5000000, # 5 million points
"scan_resolution": 100.0, # points/m²
"coverage_area": 50000.0, # m²
"anomaly_points": [
(10.5, 20.3, 5.1),
(15.2, 25.8, 5.3),
(20.1, 30.5, 5.5)
],
"reference_baseline": {
"date": "2024-01-01",
"baseline_id": "BSL-001"
}
}
@tool
def calculate_displacement(current_scan: Dict, baseline_scan: Dict) -> Dict:
"""Calculate structural displacement from LiDAR scans"""
# Simulate displacement calculation
anomalies = current_scan.get("anomaly_points", [])
if len(anomalies) > 5:
max_displacement = 15.0
safety_factor = 0.6
safety_level = SafetyLevel.WARNING
elif len(anomalies) > 2:
max_displacement = 8.0
safety_factor = 0.8
safety_level = SafetyLevel.CAUTION
else:
max_displacement = 3.0
safety_factor = 0.95
safety_level = SafetyLevel.SAFE
return {
"analysis_id": f"DISP-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": current_scan["infrastructure_id"],
"analysis_time": datetime.now().isoformat(),
"max_displacement": max_displacement,
"displacement_vector": (2.5, 1.8, max_displacement),
"critical_zones": [
{
"zone_id": "CZ-001",
"location": "tunnel_crown",
"displacement": max_displacement * 0.8,
"risk": "high" if max_displacement > 10 else "medium"
}
],
"settlement_rate": max_displacement / 30, # mm/month
"tilt_angle": math.degrees(math.atan(max_displacement / 1000)),
"safety_factor": safety_factor
}
@tool
def generate_safety_heatmap(displacement_data: Dict, sensor_data: List[Dict]) -> Dict:
"""Generate safety heatmap visualization for infrastructure"""
# Simulate heatmap generation
safety_factor = displacement_data.get("safety_factor", 1.0)
# Create grid (simplified 10x10)
grid_size = 10
safety_grid = []
critical_areas = []
for i in range(grid_size):
row = []
for j in range(grid_size):
# Generate safety score based on position and displacement
base_score = safety_factor
distance_factor = math.sqrt((i-5)**2 + (j-5)**2) / 10
safety_score = min(1.0, base_score + (0.2 * distance_factor))
row.append(safety_score)
if safety_score < 0.7:
critical_areas.append({
"grid_position": (i, j),
"safety_score": safety_score,
"recommended_action": "immediate_inspection"
})
safety_grid.append(row)
# Determine overall safety
avg_safety = sum(sum(row) for row in safety_grid) / (grid_size * grid_size)
if avg_safety > 0.9:
overall = SafetyLevel.SAFE
elif avg_safety > 0.8:
overall = SafetyLevel.CAUTION
elif avg_safety > 0.7:
overall = SafetyLevel.WARNING
else:
overall = SafetyLevel.DANGER
return {
"heatmap_id": f"HEAT-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": displacement_data["infrastructure_id"],
"generation_time": datetime.now().isoformat(),
"grid_resolution": 5.0, # meters
"safety_scores": safety_grid,
"critical_areas": critical_areas,
"overall_safety": overall.value,
"risk_trends": {
"trend": "deteriorating" if avg_safety < 0.8 else "stable",
"rate": -0.02 if avg_safety < 0.8 else 0.0
}
}
@tool
def plan_maintenance(infrastructure_id: str, safety_analysis: Dict, traffic_data: Dict) -> Dict:
"""Plan maintenance based on safety analysis and traffic patterns"""
safety_level = safety_analysis.get("overall_safety", "safe")
# Determine maintenance urgency
if safety_level in ["danger", "critical"]:
maintenance_type = "emergency"
priority = "critical"
days_until = 1
duration = 48
elif safety_level == "warning":
maintenance_type = "corrective"
priority = "high"
days_until = 7
duration = 24
elif safety_level == "caution":
maintenance_type = "preventive"
priority = "medium"
days_until = 30
duration = 12
else:
maintenance_type = "preventive"
priority = "low"
days_until = 90
duration = 8
# Traffic impact assessment
peak_traffic = traffic_data.get("peak_volume", 1000)
if peak_traffic > 5000:
traffic_impact = "severe"
cost_multiplier = 2.0
elif peak_traffic > 2000:
traffic_impact = "moderate"
cost_multiplier = 1.5
else:
traffic_impact = "minimal"
cost_multiplier = 1.0
return {
"schedule_id": f"MAINT-{datetime.now().strftime('%Y%m%d%H%M')}",
"infrastructure_id": infrastructure_id,
"maintenance_type": maintenance_type,
"priority_level": priority,
"scheduled_date": (datetime.now() + timedelta(days=days_until)).isoformat(),
"estimated_duration": duration,
"required_resources": [
"inspection_team",
"repair_crew",
"safety_equipment",
"traffic_control"
],
"traffic_impact": traffic_impact,
"cost_estimate": 50000 * cost_multiplier * (duration / 24)
}
# Transportation agent definitions
lidar_agent = Agent(
name="LiDARFusion",
instructions="""You are a LiDAR data processing specialist for infrastructure.
Process point cloud data from mobile and terrestrial LiDAR scanners.
Perform data fusion from multiple sensor sources.
Identify structural anomalies and deformations.
SLA: Process scan data within 5 minutes.""",
tools=[process_lidar_scan]
)
displacement_agent = Agent(
name="DisplacementCalculator",
instructions="""You are a structural displacement analysis expert.
Calculate precise displacement measurements from temporal scans.
Analyze settlement patterns and structural movements.
Predict future displacement trends using ML models.
SLA: Complete analysis within 2 minutes.""",
tools=[calculate_displacement]
)
heatmap_agent = Agent(
name="HeatmapGenerator",
instructions="""You are a safety visualization specialist.
Generate intuitive safety heatmaps for infrastructure monitoring.
Integrate multiple data sources for comprehensive risk assessment.
Highlight critical areas requiring immediate attention.
SLA: Generate heatmap within 1 minute.""",
tools=[generate_safety_heatmap]
)
maintenance_agent = Agent(
name="MaintenancePlanner",
instructions="""You are an infrastructure maintenance planning expert.
Schedule maintenance based on structural health and traffic patterns.
Optimize maintenance windows to minimize disruption.
Coordinate resources and estimate costs.
SLA: Create maintenance plan within 3 minutes.""",
tools=[plan_maintenance]
)
# Transportation infrastructure monitoring workflow
def infrastructure_monitoring_workflow(
infrastructure_ids: List[str],
infrastructure_type: InfrastructureType,
traffic_data: Dict
):
"""
Complete infrastructure monitoring workflow from scanning to maintenance
Includes safety assessment and predictive maintenance
"""
monitoring_results = {
"timestamp": datetime.now().isoformat(),
"infrastructure_type": infrastructure_type.value,
"structures_monitored": len(infrastructure_ids),
"safety_assessments": [],
"maintenance_required": [],
"emergency_alerts": [],
"network_health_score": 0.0
}
total_safety_score = 0.0
for infra_id in infrastructure_ids:
try:
# Step 1: LiDAR scanning
lidar_scan = lidar_agent.start(
f"Process LiDAR scan for {infrastructure_type.value} {infra_id}"
)
# Step 2: Displacement analysis
# Get baseline (would be from database in real scenario)
baseline = {"scan_id": "BSL-001", "point_cloud": []}
displacement_analysis = displacement_agent.start(
f"Calculate displacement for scan: {lidar_scan} against baseline: {baseline}"
)
# Step 3: Generate safety heatmap
sensor_data = [] # Would include IoT sensor readings
safety_heatmap = heatmap_agent.start(
f"Generate heatmap for displacement: {displacement_analysis} "
f"with sensors: {sensor_data}"
)
monitoring_results["safety_assessments"].append({
"infrastructure_id": infra_id,
"safety_level": safety_heatmap.get("overall_safety"),
"max_displacement": displacement_analysis.get("max_displacement"),
"critical_zones": len(safety_heatmap.get("critical_areas", []))
})
# Check for emergency conditions
if safety_heatmap.get("overall_safety") in ["danger", "critical"]:
monitoring_results["emergency_alerts"].append({
"infrastructure_id": infra_id,
"alert_level": "emergency",
"safety_status": safety_heatmap["overall_safety"],
"immediate_action": "close_to_traffic",
"notification_sent": True
})
# Step 4: Emergency maintenance planning
maintenance_plan = maintenance_agent.start(
f"Plan emergency maintenance for {infra_id} "
f"with safety: {safety_heatmap} and traffic: {traffic_data}"
)
monitoring_results["maintenance_required"].append(maintenance_plan)
elif safety_heatmap.get("overall_safety") in ["warning", "caution"]:
# Regular maintenance planning
maintenance_plan = maintenance_agent.start(
f"Plan maintenance for {infra_id} "
f"with safety: {safety_heatmap} and traffic: {traffic_data}"
)
monitoring_results["maintenance_required"].append(maintenance_plan)
# Calculate safety score contribution
safety_mapping = {
"safe": 1.0, "caution": 0.8, "warning": 0.6,
"danger": 0.3, "critical": 0.1
}
total_safety_score += safety_mapping.get(
safety_heatmap.get("overall_safety", "safe"), 0.5
)
except Exception as e:
# Fallback: Manual inspection
monitoring_results["emergency_alerts"].append({
"infrastructure_id": infra_id,
"alert_level": "system_failure",
"error": str(e),
"action": "dispatch_manual_inspection_team"
})
# Calculate network health score
if infrastructure_ids:
monitoring_results["network_health_score"] = (
total_safety_score / len(infrastructure_ids) * 100
)
return monitoring_results
# Multi-modal transportation patterns
class MultiModalTransportPatterns:
"""
Patterns for different transportation infrastructure types
Demonstrates cross-industry reuse for transportation sector
"""
@staticmethod
def adapt_for_bridge_monitoring() -> Agent:
"""Adapt tunnel monitoring for bridge structures"""
return Agent(
name="BridgeMonitor",
instructions="""Monitor bridge structural health using sensors.
Track deck deflection, cable tension, and vibration patterns.
Detect fatigue cracks and corrosion.
Consider wind and seismic loads.
SLA: Real-time monitoring with 10-second updates."""
)
@staticmethod
def adapt_for_railway_tracks() -> Agent:
"""Adapt for railway track monitoring"""
return Agent(
name="RailwayMonitor",
instructions="""Monitor railway track geometry and condition.
Detect rail defects, gauge variations, and ballast degradation.
Analyze wheel-rail interaction forces.
Predict track maintenance needs.
SLA: Process track inspection data within 3 minutes per km."""
)
@staticmethod
def adapt_for_airport_runways() -> Agent:
"""Adapt for airport runway monitoring"""
return Agent(
name="RunwayMonitor",
instructions="""Monitor airport runway surface conditions.
Detect FOD (Foreign Object Debris) and surface irregularities.
Assess pavement condition and friction levels.
Monitor drainage and lighting systems.
SLA: Complete runway scan within 10 minutes."""
)
# Intelligent traffic management integration
class TrafficManagementPatterns:
"""
Patterns for integrating with intelligent traffic systems
"""
@staticmethod
def optimize_traffic_flow(maintenance_window: Dict, traffic_patterns: Dict) -> Dict:
"""Optimize traffic flow during maintenance"""
peak_hours = traffic_patterns.get("peak_hours", [7, 9, 17, 19])
maintenance_start = maintenance_window.get("start_time", "02:00")
# Calculate optimal diversion routes
return {
"strategy": "dynamic_routing",
"diversion_routes": [
{"route_id": "ALT-1", "capacity": 0.6, "added_time": 15},
{"route_id": "ALT-2", "capacity": 0.3, "added_time": 20}
],
"lane_management": {
"reversible_lanes": True,
"peak_direction": "inbound" if int(maintenance_start[:2]) < 12 else "outbound"
},
"public_transport": {
"increase_frequency": True,
"additional_services": 4
},
"estimated_delay": 10 # minutes
}
@staticmethod
def incident_response_plan(incident_type: str, location: Dict) -> Dict:
"""Generate incident response plan"""
response_times = {
"accident": 5,
"breakdown": 10,
"infrastructure_failure": 2,
"weather_hazard": 15
}
return {
"incident_id": f"INC-{datetime.now().strftime('%Y%m%d%H%M')}",
"response_time": response_times.get(incident_type, 10),
"resources_dispatched": [
r for r in [
"emergency_response_team",
"traffic_control_unit",
"maintenance_crew" if "infrastructure" in incident_type else None
] if r is not None
],
"traffic_management": {
"lane_closures": 2,
"speed_reduction": 30, # km/h
"message_signs": ["Incident ahead", "Reduce speed", "Merge left"]
},
"estimated_clearance": 45 # minutes
}
# Predictive maintenance patterns
class PredictiveMaintenancePatterns:
"""
ML-based predictive maintenance for transportation infrastructure
"""
@staticmethod
def predict_failure_probability(
displacement_trend: List[float],
environmental_factors: Dict
) -> float:
"""Predict probability of structural failure"""
# Simplified prediction model
trend_slope = (displacement_trend[-1] - displacement_trend[0]) / len(displacement_trend)
temperature_factor = abs(environmental_factors.get("temp_variation", 0)) / 50
moisture_factor = environmental_factors.get("humidity", 50) / 100
base_probability = 0.1
trend_contribution = max(0, trend_slope * 10)
env_contribution = (temperature_factor + moisture_factor) * 0.2
return min(1.0, base_probability + trend_contribution + env_contribution)
@staticmethod
def optimize_maintenance_schedule(
infrastructure_conditions: List[Dict],
budget_constraint: float
) -> List[Dict]:
"""Optimize maintenance schedule across infrastructure network"""
# Sort by urgency (safety_factor * cost_efficiency)
prioritized = sorted(
infrastructure_conditions,
key=lambda x: (1 - x.get("safety_factor", 1)) * (1 / x.get("cost", 1)),
reverse=True
)
scheduled = []
total_cost = 0
for infra in prioritized:
if total_cost + infra.get("cost", 0) <= budget_constraint:
scheduled.append({
"infrastructure_id": infra["id"],
"scheduled_date": infra["recommended_date"],
"priority": "high" if infra["safety_factor"] < 0.7 else "medium"
})
total_cost += infra.get("cost", 0)
return scheduled
# Example usage
if __name__ == "__main__":
# Monitor tunnel network
tunnel_ids = [f"TUN-{i:03d}" for i in range(1, 6)]
traffic = {
"peak_volume": 3000,
"off_peak_volume": 800,
"peak_hours": [7, 9, 17, 19]
}
result = infrastructure_monitoring_workflow(
tunnel_ids,
InfrastructureType.TUNNEL,
traffic
)
print("Infrastructure monitoring result:", result)
# Multi-modal adaptation examples
bridge_monitor = MultiModalTransportPatterns.adapt_for_bridge_monitoring()
railway_monitor = MultiModalTransportPatterns.adapt_for_railway_tracks()
runway_monitor = MultiModalTransportPatterns.adapt_for_airport_runways()
# Traffic management example
maintenance = {"start_time": "22:00", "duration": 8}
traffic_plan = TrafficManagementPatterns.optimize_traffic_flow(maintenance, traffic)
print("Traffic optimization plan:", traffic_plan)
# Predictive maintenance example
displacement_history = [2.0, 2.3, 2.8, 3.5, 4.3] # mm over months
environment = {"temp_variation": 30, "humidity": 75}
failure_prob = PredictiveMaintenancePatterns.predict_failure_probability(
displacement_history, environment
)
print(f"Failure probability: {failure_prob:.2%}")
+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)")
+38 -11
View File
@@ -4,43 +4,70 @@ Basic handoff example demonstrating agent-to-agent delegation.
This example shows how agents can hand off tasks to specialized agents.
"""
from pydantic import BaseModel
from typing import TypedDict, Optional
from praisonaiagents import Agent
# Create specialized agents
# Define typed payload contracts for each handoff
class BillingPayload(BaseModel):
account_id: str
invoice_amount: Optional[float] = None
issue_type: str = "billing"
class RefundPayload(TypedDict):
transaction_id: str
amount: float
reason: str
class TechnicalPayload(BaseModel):
error_code: Optional[str] = None
device_info: str
user_agent: str
# Create specialized agents that expect typed payloads from the triage
# (In a real scenario, the agents would validate and use the payload.)
billing_agent = Agent(
name="Billing Agent",
role="Billing Specialist",
goal="Handle all billing-related inquiries and tasks",
backstory="I am an expert in billing systems, payment processing, and invoice management."
goal="Handle all billing-related inquiries and tasks using structured data",
backstory="I am an expert in billing systems, payment processing, and invoice management.",
input_payload_schema=BillingPayload # hypothetical but consistent with design
)
refund_agent = Agent(
name="Refund Agent",
role="Refund Specialist",
goal="Process refund requests and handle refund-related issues",
backstory="I specialize in processing refunds, evaluating refund eligibility, and ensuring customer satisfaction."
goal="Process refund requests and handle refund-related issues with validated data",
backstory="I specialize in processing refunds, evaluating refund eligibility, and ensuring customer satisfaction.",
input_payload_schema=RefundPayload
)
technical_support_agent = Agent(
name="Technical Support",
role="Technical Support Specialist",
goal="Resolve technical issues and provide technical assistance",
backstory="I am skilled in troubleshooting technical problems and providing solutions."
goal="Resolve technical issues and provide technical assistance with structured context",
backstory="I am skilled in troubleshooting technical problems and providing solutions.",
input_payload_schema=TechnicalPayload
)
# Create a triage agent with handoffs to specialized agents
# Create a triage agent with typed handoffs
triage_agent = Agent(
name="Triage Agent",
role="Customer Service Triage",
goal="Understand customer needs and route them to the appropriate specialist",
goal="Understand customer needs and route them to the appropriate specialist with a validated payload",
backstory="I analyze customer requests and direct them to the most suitable specialist for efficient resolution.",
instructions="""Analyze the customer's request and determine which specialist can best help:
- For billing questions, payment issues, or invoices, transfer to the Billing Agent
- For refund requests or refund status inquiries, transfer to the Refund Agent
- For technical problems or product issues, transfer to Technical Support
Always explain why you're transferring the customer before doing so.""",
handoffs=[billing_agent, refund_agent, technical_support_agent]
Always explain why you're transferring the customer before doing so.
When handing off, populate the appropriate payload with extracted data.""",
handoffs=[
(billing_agent, BillingPayload),
(refund_agent, RefundPayload),
(technical_support_agent, TechnicalPayload)
]
)
# Example usage
@@ -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)
@@ -539,7 +539,7 @@ Repeat Phases 12 → 13 → 14 until the mergeability gate in Phase 13 is fully
### When the PR has merge conflicts
If `mergeStateStatus=DIRTY`, ask Claude to rebase rather than resolving conflicts yourself on non-trivial logic:
If `mergeStateStatus=DIRTY`, the **Auto Merge Conflict → Claude** workflow (`.github/workflows/merge-conflict-claude.yml`) posts `@claude` automatically on push to `main` and on PR sync. You can still comment manually:
```bash
gh pr comment $PR --repo $REPO --body "@claude this PR has merge conflicts with \`main\` (PR #<X> was merged, touching the same regions). Please rebase onto latest \`main\`, re-run \`pytest <paths>\`, and force-push."
@@ -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
@@ -755,6 +755,20 @@ Developer Push ─────────────────────
**Result**: `https://praison.ai/install.sh` is automatically updated **only when the source files change**!
### Auto-deploy when install scripts change (PraisonAI → praison.ai)
Pushing `install.sh` or `install.ps1` to PraisonAI **`main`** triggers [`.github/workflows/deploy-install-scripts.yml`](/Users/praison/praisonai-package/.github/workflows/deploy-install-scripts.yml), which dispatches the existing praison.ai **`CI`** workflow (`workflow_dispatch` on `master`). No manual website push or `make upgrade` is required.
```mermaid
flowchart LR
pushMain["PraisonAI push main\ninstall.sh|install.ps1"] --> deployWF["deploy-install-scripts.yml"]
deployWF --> dispatch["gh workflow run CI"]
dispatch --> siteCI["praison.ai azure-pipeline.yaml"]
siteCI --> live["https://praison.ai/install.sh"]
```
Manual re-deploy: `gh workflow run "Deploy install scripts to praison.ai" --repo MervinPraison/PraisonAI`
### How install.sh is Served
The `web/install.sh` file is copied into the Docker image at `/var/www/html/web/install.sh` and served by nginx:
@@ -797,20 +811,20 @@ git push origin master
**Task 2: Update install.sh and deploy**
```bash
# 1. Update install.sh
# 1. Update install.sh / install.ps1
vim /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
# 2. Test install.sh
bash /Users/praison/praisonai-package/src/praisonai/scripts/install.sh --dry-run
bash /Users/praison/praisonai-package/src/praisonai/scripts/test-install-smoke.sh
# 3. Commit and push
git add /Users/praison/praisonai-package/src/praisonai/scripts/install.sh
# 3. Commit and push to PraisonAI main — auto-triggers praison.ai deploy
git add src/praisonai/scripts/install.sh src/praisonai/scripts/install.ps1
git commit -m "fix: update install script"
git push origin main
# deploy-install-scripts.yml → praison.ai CI → https://praison.ai/install.sh
# 4. Deploy website (if needed)
cd /Users/praison/Sites/localhost/praisonai
make upgrade
# Manual re-deploy (optional):
gh workflow run "Deploy install scripts to praison.ai" --repo MervinPraison/PraisonAI
```
**Task 3: Rollback deployment**
@@ -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>"
@@ -257,6 +267,21 @@ find $(python -c "import site; print(site.getsitepackages()[0])") -name "<suspic
---
## Fork PRs — contributor checklist and Claude push
For PRs from external forks (`head.repo != MervinPraison/PraisonAI`), ask the contributor:
1. **Allow edits from maintainers** on the PR (right sidebar).
2. **Keep fork and branch** until the PR is merged or closed.
Optional: private fork → add maintainer as collaborator; fork with Actions → only enable “Allow edits and access to secrets by maintainers” if they accept the risk.
When `maintainerCanModify` is true, `claude.yml` pushes via `GH_TOKEN` to the fork remote and `merge-conflict-claude.yml` may auto-trigger `@claude` for `DIRTY` PRs (after FINAL architecture review).
Manual unblock: `gh pr checkout <N> && git fetch origin main && git rebase origin/main && git push --force-with-lease`
---
## Quick Reference — Common Commands
| Action | Command |
@@ -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
@@ -32,7 +32,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> --jq '{ghsa:
3. For each advisory, validate the vulnerability in source code:
- Read the file(s) mentioned in the advisory description
- Confirm the vulnerability exists (or is already fixed)
- Classify: `real` | `already-fixed` | `not-reproducible`
- Classify using triage labels (see **Managing ongoing audits** below)
- Reference the official repository for latest source: https://github.com/MervinPraison/PraisonAI
## PHASE 2 — VALIDATE & FIX
@@ -99,9 +99,29 @@ pip index versions praisonaiagents | head -1
pip index versions praisonai | head -1
```
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI)
13. Publish **praisonai-platform** (only when the advisory targets platform code):
```bash
cd /Users/praison/praisonai-package/src/praisonai-platform
praisonai publish pypi
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
```
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
13. For each advisory, reopen to draft state and set patched versions + credits:
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
```bash
cd /Users/praison/praisonai-package/src/praisonai-ts
npm version patch && npm publish
```
15. Verify platform / npm when applicable:
```bash
pip index versions praisonai-platform | head -1
npm view praisonai version
```
## PHASE 4 — UPDATE ADVISORIES (only after packages are on PyPI/npm)
16. For each advisory, reopen to draft state and set patched versions + credits:
```bash
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
--method PATCH \
@@ -119,34 +139,86 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
}
EOF
```
- `<PACKAGE_NAME>`: `praisonaiagents` for core SDK, `praisonai` for wrapper
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
- `<LAST_VULNERABLE_VERSION>`: version before the fix
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
- Always include credits for the reporter
14. Publish each advisory:
17. Publish each advisory:
```bash
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
--method PATCH --field state=published
```
15. Request CVE for each published advisory:
18. Request CVE for each published advisory:
```bash
gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID>/cve --method POST
```
GitHub reviews and assigns CVE IDs within 1-3 business days.
16. Verify final state:
19. Verify final state:
```bash
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending") \(.severity) \([.credits[].login] | join(",") ) \(.summary | .[0:50])"'
```
## MANAGING ONGOING AUDITS
Continuous security reports are **expected** for an agent framework. Treat them as a product signal, not a crisis. Use a consistent process so triage stays manageable.
### Triage labels
After validating each advisory in source code, assign **one** label:
| Label | Meaning | Action |
|---|---|---|
| `real` | Reproducible, valid security issue | Fix → publish → advisory |
| `already-fixed` | Fixed in `main` but advisory still open | Set patched version to current release; publish advisory |
| `duplicate` | Same root cause as another GHSA | Close or merge; link to primary advisory |
| `not-reproducible` | Cannot reproduce with steps provided | Request more detail; hold in triage |
| `theoretical` | Requires unlikely preconditions or no practical impact | Document reasoning; close as wontfix or low priority |
| `documented-opt-out` | Behaviour is intentional with documented escape hatch | Close as wontfix; point to docs/env var |
| `out-of-scope` | Wrong package, fork, or deployment the project does not ship | Close with explanation |
### Triage load
- Not every report is valid — filter with the labels above before writing code.
- Batch related GHSAs (same file, same pattern) into one fix where possible.
- Prefer regression tests per GHSA so fixes are not re-opened by the next audit round.
### Version churn and release boundaries
| Package | Version line | Notes |
|---|---|---|
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
### Researcher incentives
- Some reports are bounty-driven or low-quality — still acknowledge and triage promptly.
- Credit valid reporters in the advisory even for small fixes.
- Close invalid reports with a clear label and short rationale (no exploit discussion in public issues).
- Never fix “for the sake of it” — see Phase 2 validate checks.
### When to close as wontfix
Close (or publish advisory with no code change only if already mitigated) when:
- Documented opt-out exists and risk is accepted (e.g. localhost-only dev modes with explicit env vars).
- Issue is theoretical with no realistic attack path in supported deployments.
- Fix would remove existing features or materially bloat the SDK — escalate instead.
- Duplicate of an existing published advisory.
## REFERENCE — Package-to-file mapping
| Package | Canonical Path | Key files |
|---|---|---|
| praisonaiagents | src/praisonai-agents/praisonaiagents/ | tools/python_tools.py, mcp/, memory/, agent/ |
| praisonai | src/praisonai/praisonai/ | ui/sql_alchemy.py, capabilities/, mcp_server/, cli/ |
| Package | Ecosystem | Canonical Path | Key files |
|---|---|---|---|
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
## RESOURCES & DOCUMENTATION
@@ -160,8 +232,10 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
- NEVER expose exploit details in public issues or PRs
- ALWAYS credit the reporter in the advisory
- ALWAYS set patched_versions before publishing advisory
- ALWAYS publish to PyPI BEFORE publishing advisories (so Dependabot can point users to a fix) - to publish `praisonai publish pypi` from praisonai-agents folder, after that
`cd /Users/praison/praisonai-package/src/praisonai && python scripts/bump_and_release.py 4.5.125 --agents 1.5.125 --wait`. 4.5.125 is praisonai version number and 1.5.125 is praisonaiagents version number. Last 2 numbers will be same 4/1.xx.xxx
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
- ALWAYS request CVE AFTER publishing advisory
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
- Fix on main, push directly — no feature branches for security patches
@@ -172,7 +246,6 @@ gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id
## REPORTING SECURITY ISSUES
If you discover a security vulnerability, please report it responsibly:
1. Open a draft security advisory at https://github.com/MervinPraison/PraisonAI/security/advisories/new
2. Include detailed reproduction steps and impact assessment
3. Allow time for the maintainers to address the issue before public disclosure
If you discover a security vulnerability, please report it responsibly. See [SECURITY.md](../../../../SECURITY.md) at the monorepo root for scope, supported packages, and the private advisory link.
Maintainers and agents: use [.cursor/skills/security-audit/SKILL.md](../../.cursor/skills/security-audit/SKILL.md) for Cursor skill entry point.
+48 -71
View File
@@ -1,6 +1,6 @@
# Documentation Parity Tracker (Python)
> **Categories:** 64 | **Documented:** 61 | **Parity:** 95.3%
> **Categories:** 66 | **Documented:** 65 | **Parity:** 98.5%
This report compares **Python SDK feature categories** against **Python documentation** (docs/concepts, docs/features, etc.).
@@ -8,109 +8,86 @@ This report compares **Python SDK feature categories** against **Python document
| Metric | Count |
|--------|-------|
| Feature Categories | 64 |
| **Documented Categories** | **61** |
| **Undocumented Categories** | **3** |
| **Parity** | **95.3%** |
| Feature Categories | 66 |
| **Documented Categories** | **65** |
| **Undocumented Categories** | **1** |
| **Parity** | **98.5%** |
## Documented Categories
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 21 | 34 | 9360 |
| ✅ Agent | 21 | 42 | 11340 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 547 |
| ✅ Approval | 1 | 3 | 1283 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
| ✅ Approval | 1 | 3 | 1344 |
| ✅ Audio | 2 | 10 | 649 |
| ✅ Auto Generation | 4 | 6 | 1724 |
| ✅ Auto Generation | 5 | 6 | 1637 |
| ✅ Autonomy | 3 | 5 | 1919 |
| ✅ Bots | 7 | 14 | 5228 |
| ✅ Bots | 7 | 23 | 7714 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 98 | 24103 |
| ✅ Chunking | 2 | 2 | 285 |
| ✅ CLI | 5 | 108 | 26975 |
| ✅ Chunking | 2 | 2 | 288 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 3 | 13 | 3551 |
| ✅ Conditions | 1 | 3 | 1086 |
| ✅ Configuration | 3 | 7 | 1812 |
| ✅ Context Management | 16 | 31 | 9294 |
| ✅ Database | 1 | 34 | 4339 |
| ✅ Code Execution | 2 | 13 | 3554 |
| ✅ Conditions | 1 | 3 | 1085 |
| ✅ Configuration | 3 | 7 | 2160 |
| ✅ Context Management | 16 | 33 | 10280 |
| ✅ Database | 1 | 40 | 5373 |
| ✅ Deep Research | 8 | 2 | 437 |
| ✅ Display | 6 | 2 | 1175 |
| ✅ Embeddings | 6 | 23 | 1966 |
| ✅ Evaluation | 1 | 6 | 2539 |
| ✅ Events | 1 | 2 | 704 |
| ✅ Execution | 3 | 3 | 1059 |
| ✅ Files | 2 | 6 | 1100 |
| ✅ Events | 1 | 2 | 764 |
| ✅ Execution | 3 | 3 | 1099 |
| ✅ Failover | 2 | 1 | 372 |
| ✅ Files | 2 | 6 | 1632 |
| ✅ Flow | 1 | 2 | 435 |
| ✅ Gateway | 7 | 10 | 3924 |
| ✅ Guardrails | 4 | 4 | 2008 |
| ✅ Handoffs | 11 | 5 | 2155 |
| ✅ Hooks | 2 | 6 | 1916 |
| ✅ Gateway | 7 | 21 | 7477 |
| ✅ Guardrails | 4 | 4 | 1619 |
| ✅ Handoffs | 11 | 6 | 2499 |
| ✅ Hooks | 2 | 8 | 3150 |
| ✅ Image | 1 | 11 | 1096 |
| ✅ Knowledge | 3 | 12 | 3466 |
| ✅ LLM | 3 | 5 | 2251 |
| ✅ Knowledge | 4 | 24 | 4525 |
| ✅ LLM | 3 | 10 | 3674 |
| ✅ Loops | 4 | 3 | 774 |
| ✅ MCP | 2 | 51 | 10960 |
| ✅ Memory | 6 | 16 | 5855 |
| ✅ Observability | 2 | 23 | 2590 |
| ✅ Output | 3 | 4 | 903 |
| ✅ MCP | 1 | 51 | 10939 |
| ✅ Memory | 6 | 16 | 5879 |
| ✅ OCR | 2 | 1 | 226 |
| ✅ Observability | 2 | 23 | 2754 |
| ✅ Optimizer | 1 | 2 | 653 |
| ✅ Output | 3 | 4 | 860 |
| ✅ Parallel Execution | 3 | 2 | 399 |
| ✅ Planning | 6 | 5 | 1519 |
| ✅ Planning | 6 | 5 | 1198 |
| ✅ Plugins | 8 | 3 | 1502 |
| ✅ Prompts | 2 | 7 | 1570 |
| ✅ Providers | 2 | 51 | 5911 |
| ✅ 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 | 469 |
| ✅ Retrieval | 2 | 5 | 944 |
| ✅ Reflection | 3 | 2 | 477 |
| ✅ Retrieval | 2 | 5 | 945 |
| ✅ Routing | 1 | 2 | 597 |
| ✅ Sandbox | 5 | 6 | 2369 |
| ✅ Security | 1 | 3 | 2518 |
| ✅ Sessions | 4 | 9 | 3826 |
| ✅ Skills | 5 | 9 | 3415 |
| ✅ 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 | 113 | 23809 |
| ✅ Tools | 12 | 118 | 25895 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Video | 2 | 6 | 510 |
| ✅ Vision | 2 | 1 | 283 |
| ✅ Web | 4 | 6 | 1297 |
| ✅ Workflows | 4 | 11 | 4302 |
## Stub Documentation (Need Content)
These categories have documentation files but < 50 lines (stubs):
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ⚠️ OCR | 2 | 1 | 49 |
| ✅ Web | 3 | 7 | 1537 |
| ✅ Workflows | 4 | 14 | 5893 |
## Undocumented Categories (Need Documentation)
| Category | Features |
|----------|----------|
| ❌ Failover | 2 |
| ❌ Optimizer | 1 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ AI SDK (2 docs, 527 lines)
- ℹ️ Callbacks (1 docs, 1122 lines)
- ℹ️ Chat (3 docs, 634 lines)
- ℹ️ Deep Research (2 docs, 437 lines)
- ℹ️ Deployment (29 docs, 6013 lines)
- ℹ️ Jobs (2 docs, 961 lines)
- ℹ️ Middleware (1 docs, 60 lines)
- ℹ️ Process (1 docs, 545 lines)
- ℹ️ Scheduler (2 docs, 1084 lines)
- ℹ️ Storage (5 docs, 957 lines)
- ℹ️ Streaming (4 docs, 1163 lines)
- ℹ️ Token Management (4 docs, 1383 lines)
- ℹ️ Vector Store (11 docs, 733 lines)
| ❌ Vector Store | 1 |
---
@@ -178,6 +178,11 @@ _LAZY_IMPORTS = {
'loop': ('praisonaiagents.workflows', 'loop'),
'repeat': ('praisonaiagents.workflows', 'repeat'),
'when': ('praisonaiagents.workflows', 'when'),
'if_': ('praisonaiagents.workflows', 'if_'),
'include': ('praisonaiagents.workflows', 'include'),
'Include': ('praisonaiagents.workflows', 'Include'),
'MAX_NESTING_DEPTH': ('praisonaiagents.workflows', 'MAX_NESTING_DEPTH'),
'YAMLWorkflowParser': ('praisonaiagents.workflows', 'YAMLWorkflowParser'),
# Conditions (Protocol-driven condition evaluation)
'ConditionProtocol': ('praisonaiagents.conditions.protocols', 'ConditionProtocol'),
@@ -230,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'),
@@ -302,6 +313,12 @@ _LAZY_IMPORTS = {
# Knowledge
'Knowledge': ('praisonaiagents.knowledge.knowledge', 'Knowledge'),
'Chunking': ('praisonaiagents.knowledge.chunking', 'Chunking'),
'KnowledgeStoreProtocol': ('praisonaiagents.knowledge.protocols', 'KnowledgeStoreProtocol'),
'ScopeRequiredError': ('praisonaiagents.knowledge.protocols', 'ScopeRequiredError'),
'BackendNotAvailableError': ('praisonaiagents.knowledge.protocols', 'BackendNotAvailableError'),
'FileTracker': ('praisonaiagents.knowledge.indexing', 'FileTracker'),
'IndexResult': ('praisonaiagents.knowledge.indexing', 'IndexResult'),
'CorpusStats': ('praisonaiagents.knowledge.indexing', 'CorpusStats'),
# FastContext
'FastContext': ('praisonaiagents.context.fast', 'FastContext'),
@@ -325,9 +342,24 @@ _LAZY_IMPORTS = {
'SkillProperties': ('praisonaiagents.skills', 'SkillProperties'),
'SkillMetadata': ('praisonaiagents.skills', 'SkillMetadata'),
'SkillLoader': ('praisonaiagents.skills', 'SkillLoader'),
'validate': ('praisonaiagents.skills', 'validate'),
'validate_metadata': ('praisonaiagents.skills', 'validate_metadata'),
'discover_skills': ('praisonaiagents.skills', 'discover_skills'),
'load_skill': ('praisonaiagents.skills', 'load_skill'),
'EnforcementLevel': ('praisonaiagents.skills', 'EnforcementLevel'),
'SkillState': ('praisonaiagents.skills', 'SkillState'),
# Memory
'Memory': ('praisonaiagents.memory.memory', 'Memory'),
'AutoMemory': ('praisonaiagents.memory', 'AutoMemory'),
'ChromaMemory': ('praisonaiagents.memory.adapters.factories', 'create_chroma_memory_adapter'),
'register_memory_adapter': ('praisonaiagents.memory.adapters', 'register_memory_adapter'),
'register_memory_factory': ('praisonaiagents.memory.adapters', 'register_memory_factory'),
'get_memory_adapter': ('praisonaiagents.memory.adapters', 'get_memory_adapter'),
'list_memory_adapters': ('praisonaiagents.memory.adapters', 'list_memory_adapters'),
'add_memory_adapter': ('praisonaiagents.memory.adapters.registry', 'add_memory_adapter'),
'add_memory_factory': ('praisonaiagents.memory.adapters.registry', 'add_memory_factory'),
'has_memory_adapter': ('praisonaiagents.memory.adapters', 'has_memory_adapter'),
# Planning
'Plan': ('praisonaiagents.planning', 'Plan'),
@@ -0,0 +1,71 @@
"""
Shared lazy-loaders for display utilities used across the Agent class and mixins.
These helpers defer importing rich and the heavy ``..main`` display module until
first use, which keeps import time low for silent mode and avoids circular
imports during package initialisation. Results are cached in module globals and
protected by per-resource locks using double-checked locking, so each import is
resolved at most once across threads. Independent resources use separate locks so
that a slow first import of one does not stall initialisation of the others.
"""
import threading
# Lazy-loaded modules (populated on first use, each protected by its own lock)
_console_lock = threading.Lock()
_live_lock = threading.Lock()
_main_module_lock = threading.Lock()
_rich_console = None
_rich_live = None
_main_module = None
def _get_console():
"""Lazy load rich.console.Console (thread-safe)."""
global _rich_console
if _rich_console is None:
with _console_lock:
if _rich_console is None:
from rich.console import Console
_rich_console = Console
return _rich_console
def _get_live():
"""Lazy load rich.live.Live (thread-safe)."""
global _rich_live
if _rich_live is None:
with _live_lock:
if _rich_live is None:
from rich.live import Live
_rich_live = Live
return _rich_live
def _get_display_functions():
"""Lazy load display functions from main module (thread-safe)."""
global _main_module
if _main_module is None:
with _main_module_lock:
if _main_module is None:
from ..main import (
display_error,
display_instruction,
display_interaction,
display_generating,
display_self_reflection,
ReflectionOutput,
adisplay_instruction,
execute_sync_callback
)
_main_module = {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
return _main_module
@@ -24,6 +24,7 @@ from .async_safety import AsyncSafeState
from .unified_execution_mixin import UnifiedExecutionMixin
from .sandbox_mixin import SandboxMixin
from .message_steering import SteeringMixin
from .skill_review import SkillReviewMixin
# Module-level logger for thread safety errors and debugging
logger = get_logger(__name__)
@@ -34,35 +35,15 @@ logger = get_logger(__name__)
# This reduces import time from ~420ms to ~20ms for silent mode
# ============================================================================
# Shared, cached, thread-safe display lazy-loaders (single source of truth)
from ._lazy_display import _get_console, _get_live, _get_display_functions
# Lazy-loaded modules (populated on first use, protected by _lazy_import_lock)
_lazy_import_lock = threading.Lock()
_rich_console = None
_rich_live = None
_llm_module = None
_main_module = None
_hooks_module = None
_stream_emitter_class = None
def _get_console():
"""Lazy load rich.console.Console (thread-safe)."""
global _rich_console
if _rich_console is None:
with _lazy_import_lock:
if _rich_console is None:
from rich.console import Console
_rich_console = Console
return _rich_console
def _get_live():
"""Lazy load rich.live.Live (thread-safe)."""
global _rich_live
if _rich_live is None:
with _lazy_import_lock:
if _rich_live is None:
from rich.live import Live
_rich_live = Live
return _rich_live
def _get_llm_functions():
"""Lazy load LLM functions (thread-safe)."""
global _llm_module
@@ -76,34 +57,6 @@ def _get_llm_functions():
}
return _llm_module
def _get_display_functions():
"""Lazy load display functions from main module (thread-safe)."""
global _main_module
if _main_module is None:
with _lazy_import_lock:
if _main_module is None:
from ..main import (
display_error,
display_instruction,
display_interaction,
display_generating,
display_self_reflection,
ReflectionOutput,
adisplay_instruction,
execute_sync_callback
)
_main_module = {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
return _main_module
def _get_hooks_module():
"""Lazy load hooks module for HookRunner and HookRegistry (thread-safe)."""
global _hooks_module
@@ -255,6 +208,7 @@ if TYPE_CHECKING:
from ..rag.models import RAGResult, ContextPack
from ..eval.results import EvaluationLoopResult
from ..tools.retry import RetryPolicy
from ..skills.protocols import SkillReviewProtocol
# Import structured error from central errors module
from ..errors import BudgetExceededError
@@ -262,7 +216,7 @@ from ..errors import BudgetExceededError
# Import retry configuration
from .retry_utils import RetryBackoffConfig
class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
class Agent(SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
_agent_counter_lock = threading.Lock()
@@ -588,12 +542,13 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
caching: Optional[Union[bool, str, Dict[str, Any], 'CachingConfig']] = None,
hooks: Optional[Union[List[Any], Dict[str, Any], 'HooksConfig']] = None,
skills: Optional[Union[List[str], str, Dict[str, Any], 'SkillsConfig']] = None,
self_improve: Optional[Union[bool, 'SkillReviewProtocol']] = False, # Autonomous skill self-improvement loop (off by default)
approval: Optional[Union[bool, str, Dict[str, Any], 'ApprovalConfig', 'ApprovalProtocol']] = None,
tool_config: Optional[Union[bool, 'ToolConfig']] = None, # Tool execution configuration (timeout, retry, parallel)
learn: Optional[Union[bool, str, Dict[str, Any], 'LearnConfig']] = None, # Continuous learning (peer to memory)
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code") - DEPRECATED
runtime: Optional[Union[str, Dict[str, Any], 'AgentRuntimeConfig']] = None, # Model-scoped runtime configuration
runtime: Optional[Union[bool, str, Dict[str, Any], 'AgentRuntimeConfig', 'RuntimeConfig']] = None, # Model-scoped runtime configuration with capability validation
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
message_steering: Optional[Union[bool, 'MessageSteeringProtocol']] = False, # Real-time message steering during execution
@@ -635,6 +590,14 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
reflection: Self-reflection. Accepts:
- bool: True enables with defaults
- ReflectionConfig: Custom configuration
self_improve: Autonomous skill self-improvement loop (off by default).
After each task, runs a guarded review pass restricted to the
``skill_manage`` tool that asks the agent to capture a reusable
technique as a new/patched skill. Accepts:
- bool: True enables with the default review policy, False disables
- SkillReviewProtocol: Custom review policy
This is distinct from ``reflection`` (which is answer-quality
retry) — it captures durable capability across runs.
guardrails: Output validation. Accepts:
- bool: True enables with defaults
- Callable: Validation function
@@ -699,12 +662,15 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
cli_backend: **DEPRECATED** CLI backend for delegating full turns. Use 'runtime' parameter instead.
This parameter is deprecated in favor of model-scoped runtime configuration.
Will emit deprecation warning when used.
runtime: Model-scoped runtime configuration for turn delegation. Accepts:
- str: Runtime ID ("claude-code", "praisonai")
- Dict[str, Any]: Runtime config {"runtime": "claude-code", "config_overrides": {...}}
- AgentRuntimeConfig: Pre-configured runtime instance
runtime: Model-scoped runtime configuration with capability validation. Accepts:
- bool: False=disabled, True=basic capability validation
- str: Runtime ID ("claude-code", "praisonai", "native", "plugin-harness")
- Dict[str, Any]: Runtime config with required_capabilities, preferred_runtime, config_overrides, etc.
- AgentRuntimeConfig: Pre-configured runtime instance (model-scoped)
- RuntimeConfig: Configuration with capability requirements and preferences
- None: Use model-based resolution (default)
Enables turn delegation with per-model runtime selection and fail-closed behavior.
Combines turn delegation with per-model runtime selection and fail-fast capability
validation at config time to catch incompatibilities early.
tool_search: Progressive tool disclosure configuration. Accepts:
- bool: False=disabled (default), True=auto mode
- str: Mode ("auto", "on", "off")
@@ -1514,6 +1480,9 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
"a dict of ToolSearchConfig fields, or ToolSearchConfig"
)
# Process tool_config and artifact storage (moved from tool_output)
self._artifact_store = None
# ============================================================
# END CONSOLIDATED PARAMS EXTRACTION
# ============================================================
@@ -1562,6 +1531,14 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
tool_config=tool_config
)
# Set up artifact store if enabled in tool_config
if _tool_config and _tool_config.enable_artifacts:
from ..context.artifact_store import FileSystemArtifactStore
self._artifact_store = _tool_config.artifact_store or FileSystemArtifactStore(
retention_days=_tool_config.artifact_retention_days,
redact_secrets=_tool_config.redact_secrets
)
# Gap 2: Store parallel tool calls setting for ToolCallExecutor selection
self.parallel_tool_calls = _tool_config.parallel if _tool_config else parallel_tool_calls
# G2: Store interrupt controller for cooperative cancellation
@@ -1785,7 +1762,8 @@ class Agent(SteeringMixin, SandboxMixin, UnifiedExecutionMixin, ToolExecutionMix
self._init_message_steering(message_steering)
self.verbose = verbose
self._has_explicit_output_config = _has_explicit_output # Track if user set output mode
self.tool_output_limit = tool_output_limit # Configurable tool output limit
# Use tool_config output_limit if configured, otherwise use parameter value
self.tool_output_limit = _tool_config.output_limit if _tool_config else tool_output_limit
self.allow_delegation = allow_delegation
self.step_callback = step_callback
# Token budget guard (zero overhead when _max_budget is None)
@@ -2048,6 +2026,18 @@ Your Goal: {self.goal}
self._skill_manager = None # Lazy loaded
self._skills_initialized = False
# Autonomous skill self-improvement loop (issue #2231). Off by default;
# when enabled, a guarded review pass restricted to skill_manage runs
# after each task. A SkillReviewProtocol instance customises the policy.
if self_improve is True or self_improve is False or self_improve is None:
self._self_improve = bool(self_improve)
self._self_improve_policy = None
else:
# Custom policy object implementing SkillReviewProtocol.
self._self_improve = True
self._self_improve_policy = self_improve
self._in_skill_review = False
# Database persistence (lazy - no imports until used)
self._db = db
self._session_id = session_id
@@ -2109,10 +2099,13 @@ Your Goal: {self.goal}
)
self._cli_backend = self._resolve_cli_backend(cli_backend)
# Runtime Configuration - model-scoped runtime selection
# Runtime Configuration - model-scoped runtime selection with capability validation
self._runtime_config = None
if runtime is not None:
self._runtime_config = self._resolve_runtime_config(runtime)
# Perform capability validation if enabled
if self._runtime_config and hasattr(self._runtime_config, 'validate_on_creation') and self._runtime_config.validate_on_creation:
self._validate_runtime_capabilities()
# Runtime resolver for per-turn resolution
self._runtime_resolver = None # Will be initialized on first use
@@ -2212,8 +2205,13 @@ Your Goal: {self.goal}
'skills': getattr(self, '_skills_config', None),
'approval': getattr(self, '_approval_config', None),
'learn': getattr(self, '_learn_config', None),
# Autonomous skill self-improvement: forward the custom policy when
# set, else the opt-in boolean, so clones keep the same behavior.
'self_improve': (
getattr(self, '_self_improve_policy', None)
or getattr(self, '_self_improve', False)
),
'tool_search': getattr(self, '_tool_search_config', None),
# Tool configuration - use consolidated config when available
'tool_config': getattr(self, '_tool_config', None),
@@ -4065,35 +4063,79 @@ 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.
# -------------------------------------------------------------------------
# Runtime Capability Management
# -------------------------------------------------------------------------
def _resolve_runtime_config(self, runtime):
"""Resolve runtime configuration parameter.
Args:
profile: Agent profile name
context: Additional context
runtime: Runtime parameter (bool, str, dict, or RuntimeConfig)
Returns:
Configured Agent instance
RuntimeConfig instance or None
"""
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,
try:
from ..config import resolve_runtime
return resolve_runtime(runtime)
except ImportError:
raise ImportError(
"Runtime capability features requested but configuration not available. "
"This should not happen in a properly installed praisonaiagents package."
)
else:
# Default subagent
return Agent(
name=f"subagent_{profile}",
instructions=f"You are a {profile} assistant.",
def _validate_runtime_capabilities(self):
"""Validate that current runtime supports required capabilities.
Performs fail-fast validation of runtime capabilities against
agent requirements at config/creation time.
Raises:
CapabilityValidationError: If validation fails
"""
if not self._runtime_config or not self._runtime_config.required_capabilities:
return # No requirements to validate
try:
from ..runtime import RuntimeCapability, validate_capabilities, get_native_runtime_capabilities
# Convert string capability names to enum values
required_set = set()
for cap in self._runtime_config.required_capabilities:
if isinstance(cap, str):
# Convert string to enum value
cap_upper = cap.upper()
if hasattr(RuntimeCapability, cap_upper):
required_set.add(getattr(RuntimeCapability, cap_upper))
else:
raise ValueError(f"Unknown capability: {cap}")
else:
# Assume it's already a RuntimeCapability enum
required_set.add(cap)
# Get the actual runtime capabilities based on the selected runtime
runtime_name = self._runtime_config.preferred_runtime or "native"
# Determine which capability matrix to use based on runtime type
if runtime_name in ["native", "praisonai"]:
runtime_matrix = get_native_runtime_capabilities()
elif runtime_name in ["plugin", "harness", "reduced", "plugin-harness", "claude-code"]:
# Use reduced capabilities for plugin/harness runtimes
from praisonaiagents.runtime.capabilities import get_reduced_harness_capabilities
runtime_matrix = get_reduced_harness_capabilities()
else:
# For unknown runtimes, use reduced capabilities as safe default
from praisonaiagents.runtime.capabilities import get_reduced_harness_capabilities
runtime_matrix = get_reduced_harness_capabilities()
# Perform validation
validate_capabilities(runtime_matrix, required_set, runtime_name)
except ImportError:
raise ImportError(
"Runtime capability validation requested but runtime module not available. "
"This should not happen in a properly installed praisonaiagents package."
)
def _run_verification_hooks(self) -> List[Dict[str, Any]]:
@@ -4151,6 +4193,129 @@ Summary:"""
return filtered_tools
def add_mcp_server(self, name: str, mcp: Any) -> Any:
"""
Attach an MCP server to a live agent at runtime.
Connects the MCP server (discovering its tools), tracks it by name, and
appends it to ``self.tools`` so its tools become available on the next
turn of the same run — no agent rebuild required. New MCP tools flow
through the existing tool-execution and approval paths automatically.
Args:
name: Unique name for this MCP server (used by ``remove_mcp_server``).
mcp: An ``MCP`` instance (e.g. ``MCP("npx -y @notionhq/notion-mcp-server")``).
Returns:
The attached ``MCP`` instance.
Example:
```python
from praisonaiagents.mcp import MCP
agent.add_mcp_server("notion", MCP("npx -y @notionhq/notion-mcp-server"))
# tools live on the next turn
```
"""
if not name:
raise ValueError("add_mcp_server requires a non-empty name")
if not hasattr(self, "_mcp_servers"):
self._mcp_servers = {}
if name in self._mcp_servers:
raise ValueError(
f"MCP server '{name}' is already attached. "
"Call remove_mcp_server() first to replace it."
)
# Ensure self.tools is a mutable list we can append to
if not isinstance(self.tools, list):
self.tools = list(self.tools) if self.tools else []
self._mcp_servers[name] = mcp
self.tools.append(mcp)
self.refresh_tools()
logging.debug(f"Attached MCP server '{name}' with runtime tools")
return mcp
def remove_mcp_server(self, name: str) -> bool:
"""
Disconnect and deregister a previously attached MCP server.
Removes the server's tools from ``self.tools`` and shuts the connection
down cleanly (no leaked background loops). Tools disappear on the next
turn.
Args:
name: The name used in ``add_mcp_server``.
Returns:
True if a server was removed, False if no server by that name existed.
"""
servers = getattr(self, "_mcp_servers", None)
if not servers or name not in servers:
return False
mcp = servers.pop(name)
if isinstance(self.tools, list):
self.tools = [t for t in self.tools if t is not mcp]
# Best-effort clean shutdown of the MCP connection
try:
if hasattr(mcp, "shutdown"):
mcp.shutdown()
except Exception as e:
logging.warning(f"MCP server '{name}' shutdown failed: {e}")
self.refresh_tools()
logging.debug(f"Removed MCP server '{name}'")
return True
def refresh_tools(self) -> List[Any]:
"""
Re-derive the live toolset from ``self.tools``.
Per-turn tool assembly already reads from ``self.tools`` directly, so
tools added/removed via ``add_mcp_server``/``remove_mcp_server`` are
picked up automatically on the next turn. This method clears any cached
tool definitions so callers (or an MCP ``tools/list_changed`` event) can
force an immediate refresh, and returns the current toolset.
Returns:
The current list of tools/MCP instances on the agent.
"""
# Invalidate any cached tool definitions if such a cache exists
for cache_attr in ("_cached_tool_definitions", "_tool_definitions_cache"):
if hasattr(self, cache_attr):
try:
setattr(self, cache_attr, None)
except Exception:
pass
return self.tools
def list_mcp_servers(self) -> List[str]:
"""Return the names of MCP servers currently attached at runtime."""
return list(getattr(self, "_mcp_servers", {}).keys())
def _shutdown_runtime_mcp_servers(self) -> None:
"""Shut down all runtime-attached MCP servers, guarding each individually.
Used by both ``close()`` and ``aclose()`` so a single failing
``shutdown()`` never aborts cleanup of the remaining servers. The
registry is always cleared afterwards.
"""
servers = getattr(self, "_mcp_servers", None)
if not servers:
return
for name, server in list(servers.items()):
try:
if hasattr(server, "shutdown"):
server.shutdown()
except Exception as e:
logger.warning(f"Runtime MCP server '{name}' cleanup failed: {e}")
servers.clear()
def _model_supports_web_search(self) -> bool:
"""
Check if the agent's model supports native web search via LiteLLM.
@@ -5196,39 +5361,122 @@ Answer:"""
raise TypeError(f"Invalid cli_backend type: {type(cli_backend)}. Expected CliBackendProtocol instance or factory callable.")
def _resolve_runtime_config(self, runtime):
"""Resolve runtime parameter to AgentRuntimeConfig instance.
"""Resolve runtime parameter to AgentRuntimeConfig or RuntimeConfig instance.
Args:
runtime: Runtime configuration parameter
Returns:
AgentRuntimeConfig instance
AgentRuntimeConfig or RuntimeConfig instance
Raises:
TypeError: If runtime type is invalid
ValueError: If runtime configuration is invalid
"""
# Try to import AgentRuntimeConfig (from main's turn-based runtime)
AgentRuntimeConfig = None
try:
from ..runtime import AgentRuntimeConfig
except ImportError:
pass
# Try to import RuntimeConfig (from capability validation system)
RuntimeConfig = None
try:
from ..config.feature_configs import RuntimeConfig, resolve_runtime
except ImportError:
pass
if not AgentRuntimeConfig and not RuntimeConfig:
raise ImportError(
"Runtime configuration features requested but not available. "
"This should not happen in a properly installed praisonaiagents package."
)
# If already an AgentRuntimeConfig instance, return as-is
if isinstance(runtime, AgentRuntimeConfig):
if AgentRuntimeConfig and isinstance(runtime, AgentRuntimeConfig):
return runtime
# If string, create config with runtime ID
if isinstance(runtime, str):
return AgentRuntimeConfig.from_runtime_id(runtime)
# If already a RuntimeConfig instance, return as-is
if RuntimeConfig and hasattr(RuntimeConfig, '__name__') and isinstance(runtime, RuntimeConfig):
return runtime
# If dictionary, create config from dict
if isinstance(runtime, dict):
return AgentRuntimeConfig.from_dict(runtime)
# Handle capability validation style (bool, RuntimeConfig)
if RuntimeConfig and resolve_runtime:
try:
result = resolve_runtime(runtime)
if result is not None:
return result
except (TypeError, ValueError):
pass # Try AgentRuntimeConfig path
raise TypeError(f"Invalid runtime type: {type(runtime)}. Expected str, dict, or AgentRuntimeConfig instance.")
# Handle turn-based style (AgentRuntimeConfig)
if AgentRuntimeConfig:
# If string, create config with runtime ID
if isinstance(runtime, str):
if hasattr(AgentRuntimeConfig, 'from_runtime_id'):
return AgentRuntimeConfig.from_runtime_id(runtime)
# If dictionary, create config from dict
if isinstance(runtime, dict):
if hasattr(AgentRuntimeConfig, 'from_dict'):
return AgentRuntimeConfig.from_dict(runtime)
raise TypeError(f"Invalid runtime type: {type(runtime)}. Expected bool, str, dict, RuntimeConfig, or AgentRuntimeConfig instance.")
def _validate_runtime_capabilities(self):
"""Validate runtime capabilities against requirements.
Raises:
CapabilityValidationError: If runtime lacks required capabilities
"""
if not self._runtime_config:
return
# Import capability validation components
try:
from ..runtime.capabilities import (
RuntimeCapability,
validate_capabilities,
get_native_runtime_capabilities,
get_reduced_harness_capabilities
)
except ImportError:
# Capability validation not available, skip
return
# Get required capabilities from config
required_capabilities = getattr(self._runtime_config, 'required_capabilities', None)
if not required_capabilities:
return
# Convert string capabilities to enum
required_set = set()
for cap in required_capabilities:
if isinstance(cap, str):
try:
required_set.add(RuntimeCapability[cap.upper()])
except KeyError:
raise ValueError(f"Unknown capability: {cap}")
elif isinstance(cap, RuntimeCapability):
required_set.add(cap)
else:
raise TypeError(f"Invalid capability type: {type(cap)}")
# Determine runtime name
runtime_name = getattr(self._runtime_config, 'preferred_runtime', 'native')
# Get runtime capabilities based on runtime type
if runtime_name == 'native':
runtime_matrix = get_native_runtime_capabilities()
elif runtime_name in ('plugin-harness', 'harness', 'plugin', 'reduced'):
runtime_matrix = get_reduced_harness_capabilities()
else:
# Unknown runtime, use reduced capabilities as safe default
runtime_matrix = get_reduced_harness_capabilities()
# Validate capabilities
validate_capabilities(runtime_matrix, required_set, runtime_name)
def _get_runtime_resolver(self):
"""Get or create runtime resolver instance (lazy initialization)."""
@@ -5488,6 +5736,9 @@ Answer:"""
except Exception as e:
logger.warning(f"MCP cleanup failed: {e}")
# Runtime-attached MCP servers cleanup (each guarded individually)
self._shutdown_runtime_mcp_servers()
# Server registry cleanup
try:
self._cleanup_server_registrations()
@@ -5536,7 +5787,10 @@ Answer:"""
elif hasattr(client, 'close'):
client.close()
self._mcp_clients.clear()
# Runtime-attached MCP servers cleanup (each guarded individually)
self._shutdown_runtime_mcp_servers()
# Clean up server registrations and tasks
self._cleanup_server_registrations()
@@ -5612,6 +5866,18 @@ Answer:"""
memory = getattr(self, "_memory_instance", None)
if memory and hasattr(memory, 'close_connections'):
memory.close_connections()
# Clean up old artifacts if artifact store is configured
artifact_store = getattr(self, "_artifact_store", None)
if artifact_store and hasattr(artifact_store, 'cleanup_old_artifacts'):
try:
artifact_store.cleanup_old_artifacts()
except Exception as e:
# Log the error for debugging but don't fail cleanup
import logging
logging.debug(
f"Failed to cleanup artifacts for agent {self.name}: {e}"
)
except Exception as exc: # noqa: BLE001 - finalizers must not raise
import contextlib
with contextlib.suppress(Exception):
@@ -13,31 +13,8 @@ import logging
from praisonaiagents._logging import get_logger
from ..errors import BudgetExceededError
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
return Console
def _get_live():
from rich.live import Live
return Live
def _get_display_functions():
from ..main import (
display_error, display_instruction, display_interaction,
display_generating, display_self_reflection, ReflectionOutput,
adisplay_instruction, execute_sync_callback
)
return {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
logger = logging.getLogger(__name__)
@@ -637,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.
@@ -809,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
@@ -835,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)"
)
@@ -929,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.
@@ -1017,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)
@@ -1695,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.
@@ -252,67 +252,28 @@ Follow best practices and coding standards."""
def _execute_python(self, code: str, **kwargs) -> Dict[str, Any]:
"""Execute Python code."""
import subprocess
import time
import tempfile
import os
from praisonaiagents.tools.python_tools import execute_code
start_time = time.time()
# Write code to temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(code)
temp_file = f.name
try:
# Execute in subprocess (basic sandboxing)
env = os.environ.copy()
env.update(self._code_config.environment)
result = subprocess.run(
["python", temp_file],
capture_output=True,
text=True,
timeout=self._code_config.timeout,
cwd=self._code_config.working_directory,
env=env
)
execution_time = time.time() - start_time
stdout = result.stdout[:self._code_config.max_output_length]
stderr = result.stderr[:self._code_config.max_output_length]
mode = "sandbox" if self._code_config.sandbox else "direct"
result = execute_code(
code,
timeout=self._code_config.timeout,
max_output_size=self._code_config.max_output_length,
sandbox_mode=mode,
)
execution_time = time.time() - start_time
if result.get("success"):
self._log(f"Execution completed in {execution_time:.2f}s", "green")
return {
"stdout": stdout,
"stderr": stderr,
"return_code": result.returncode,
"execution_time": execution_time
}
except subprocess.TimeoutExpired:
return {
"stdout": "",
"stderr": f"Execution timed out after {self._code_config.timeout}s",
"return_code": -1,
"execution_time": self._code_config.timeout
}
except Exception as e:
return {
"stdout": "",
"stderr": str(e),
"return_code": -1,
"execution_time": time.time() - start_time
}
finally:
# Clean up temp file
try:
os.unlink(temp_file)
except Exception as e:
# Temp file cleanup failed - not critical
get_logger(__name__).debug(f"Failed to clean up temp file {temp_file}: {e}")
return {
"stdout": (result.get("stdout") or "")[: self._code_config.max_output_length],
"stderr": (result.get("stderr") or "")[: self._code_config.max_output_length],
"return_code": 0 if result.get("success") else 1,
"execution_time": execution_time,
}
async def aexecute(self, code: str, language: str = "python", **kwargs) -> Dict[str, Any]:
"""Execute code asynchronously.
@@ -15,31 +15,8 @@ import asyncio
import threading
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
return Console
def _get_live():
from rich.live import Live
return Live
def _get_display_functions():
from ..main import (
display_error, display_instruction, display_interaction,
display_generating, display_self_reflection, ReflectionOutput,
adisplay_instruction, execute_sync_callback
)
return {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
logger = logging.getLogger(__name__)
@@ -58,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
@@ -941,6 +914,113 @@ Write the complete compiled report:"""
self.verbose = original_verbose
self.markdown = original_markdown
def learn_skill(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Author a grounded, reusable skill from sources you describe.
Point the agent at real source material — a directory of code, API docs,
PDFs/manuals, configs, pasted notes, or "what we just did in this chat" —
and it gathers them with the tools it already has (file read, fetch, PDF
read) and authors **one grounded SKILL.md** via the ``skill_manage`` tool.
House-style rules keep self-written skills trustworthy: only flags, paths,
and APIs that appear verbatim in the source are used (never invented), the
SKILL.md is kept tight (~100-200 lines), and long material is moved to
supporting files referenced by relative path. The result is a permanent,
reusable skill, immediately invocable like any other skill.
This is the **skill authoring** path and is intentionally distinct from
the memory ``learn=`` constructor param (conversation→memory extraction)
and the automatic ``self_improve`` loop. It is a prompt + entry point on
top of existing tools, not a new distillation engine: it ensures
``skill_manage`` is available, then runs a single turn with :meth:`start`.
Args:
request: Natural-language description of the sources to learn from and
the skill to produce, e.g.
``"Read ./my-repo and the docs in ./docs/*.pdf and make a
'deploy-flow' skill"``.
**kwargs: Additional arguments forwarded to :meth:`start`.
Returns:
The agent's response (the authored skill summary), as returned by
:meth:`start`.
Example::
agent = Agent()
agent.learn_skill("Read ./my-repo and make a 'deploy-flow' skill")
"""
from praisonaiagents.skills import build_learn_prompt
self._ensure_skill_management_tools()
return self.start(build_learn_prompt(request), **kwargs)
async def alearn_skill(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Async variant of :meth:`learn_skill` (parity with ``start``/``astart``).
Ensures ``skill_manage`` is available, then awaits a single turn via
:meth:`astart` so async callers never block the event loop.
Args:
request: See :meth:`learn_skill`.
**kwargs: Additional arguments forwarded to :meth:`astart`.
Returns:
The agent's response (the authored skill summary).
"""
from praisonaiagents.skills import build_learn_prompt
self._ensure_skill_management_tools()
return await self.astart(build_learn_prompt(request), **kwargs)
def learn(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Backward-compatible alias for :meth:`learn_skill`.
Retained so existing ``agent.learn("...")`` calls keep working. Prefer
:meth:`learn_skill` to avoid confusion with the memory ``learn=`` param.
"""
return self.learn_skill(request, **kwargs)
async def alearn(self, request: str, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
"""Backward-compatible async alias for :meth:`alearn_skill`."""
return await self.alearn_skill(request, **kwargs)
def _ensure_skill_management_tools(self) -> None:
"""Ensure the agent has the ``skill_manage`` tool for authoring skills.
Adds the skill-management tool (and lightweight file readers) to
``self.tools`` if they are not already present, so :meth:`learn` can
author a skill without the caller wiring tools manually. Idempotent.
"""
if not hasattr(self, 'tools') or self.tools is None:
self.tools = []
def _tool_id(tool: Any) -> str:
# Mirror tool_execution.py: handle callables, dict/JSON specs, and
# anything else via a stable string fallback so dedup never lets a
# dict-form spec slip past the callable form (or vice versa).
name = getattr(tool, '__name__', None)
if isinstance(tool, dict):
name = name or tool.get('name') or (tool.get('function') or {}).get('name')
return name or str(tool)
existing_names = {_tool_id(t) for t in self.tools}
try:
from praisonaiagents.tools.skill_tools import (
skill_manage,
read_skill_file,
list_skill_scripts,
)
except Exception as exc: # pragma: no cover - defensive
logger.debug(f"Could not import skill tools for learn_skill(): {exc}")
return
for tool in (skill_manage, read_skill_file, list_skill_scripts):
if _tool_id(tool) not in existing_names:
self.tools.append(tool)
existing_names.add(_tool_id(tool))
def execute(self, task: Any, context: Optional[Any] = None) -> Optional[str]:
"""Execute a task synchronously - backward compatibility method"""
if hasattr(task, 'description'):
@@ -405,6 +405,59 @@ class Handoff:
# In intersect mode, empty list means no tools allowed - this is the security boundary
return effective_tools
@staticmethod
def _extract_model_ref(agent: 'Agent') -> str:
"""Extract a model reference string from an agent configuration."""
llm = getattr(agent, 'llm', None) or getattr(agent, 'model', None)
if isinstance(llm, str):
return llm
if llm is not None:
for attr in ('model', 'model_name', 'model_ref'):
value = getattr(llm, attr, None)
if isinstance(value, str) and value:
return value
return 'gpt-4o-mini'
def _execute_with_runtime_resolution(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any]
) -> str:
"""Execute handoff through the target agent's full chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
logger.debug(
"Handoff to %s via agent.chat (model=%s, depth=%s)",
getattr(self.agent, 'name', 'unknown'),
target_model_ref,
_get_handoff_depth(),
)
return self.agent.chat(prompt, tools=effective_tools)
async def _execute_with_runtime_resolution_async(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any]
) -> str:
"""Execute handoff through the target agent's full async chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
logger.debug(
"Async handoff to %s via agent chat (model=%s, depth=%s)",
getattr(self.agent, 'name', 'unknown'),
target_model_ref,
_get_handoff_depth(),
)
async_chat = getattr(self.agent, 'achat', None)
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
return await async_chat(prompt, tools=effective_tools)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self.agent.chat(prompt, tools=effective_tools)
)
def _check_safety(self, source_agent: 'Agent') -> None:
"""
Check safety constraints before handoff.
@@ -569,8 +622,10 @@ class Handoff:
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Execute with tool boundary enforcement
response = self.agent.chat(full_prompt, tools=effective_tools)
# Resolve runtime at turn-time instead of using construction-time pin
response = self._execute_with_runtime_resolution(
source_agent, full_prompt, effective_tools, kwargs
)
result = HandoffResult(
success=True,
@@ -675,13 +730,10 @@ class Handoff:
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Execute with tool boundary enforcement - check for async chat method
if hasattr(self.agent, 'achat'):
response = await self.agent.achat(full_prompt, tools=effective_tools)
else:
# Run sync chat in executor with tool constraint
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, lambda: self.agent.chat(full_prompt, tools=effective_tools))
# Resolve runtime at turn-time for async execution
response = await self._execute_with_runtime_resolution_async(
source_agent, full_prompt, effective_tools, kwargs
)
result = HandoffResult(
success=True,
@@ -817,7 +869,10 @@ class Handoff:
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
response = self.agent.chat(prompt, tools=effective_tools)
# Resolve runtime at turn-time for tool function execution
response = self._execute_with_runtime_resolution(
source_agent, prompt, effective_tools, kwargs
)
result = HandoffResult(
success=True,
@@ -10,31 +10,8 @@ import os
import logging
from typing import Optional
# Fallback helpers to avoid circular imports
def _get_console():
from rich.console import Console
return Console
def _get_live():
from rich.live import Live
return Live
def _get_display_functions():
from ..main import (
display_error, display_instruction, display_interaction,
display_generating, display_self_reflection, ReflectionOutput,
adisplay_instruction, execute_sync_callback
)
return {
'display_error': display_error,
'display_instruction': display_instruction,
'display_interaction': display_interaction,
'display_generating': display_generating,
'display_self_reflection': display_self_reflection,
'ReflectionOutput': ReflectionOutput,
'adisplay_instruction': adisplay_instruction,
'execute_sync_callback': execute_sync_callback,
}
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
logger = logging.getLogger(__name__)
@@ -649,10 +626,18 @@ class MemoryMixin:
try:
import os
# Expand user home directory and resolve path
file_path = os.path.expanduser(self._output_file)
file_path = os.path.abspath(file_path)
from praisonaiagents.tools.path_safety import resolve_within_root
project_root = os.environ.get("PRAISONAI_PROJECT_ROOT") or os.getcwd()
file_path = resolve_within_root(self._output_file, project_root)
if file_path is None:
logging.warning(
"Output file %r is outside project root %r; skipping save",
self._output_file,
project_root,
)
print(f"⚠️ Output path outside project root: {self._output_file}")
return False
# Create parent directories if they don't exist
parent_dir = os.path.dirname(file_path)
@@ -0,0 +1,162 @@
"""Autonomous skill self-improvement loop.
Closes the loop on the existing skill subsystem: after a task finishes, an
opt-in *guarded* review pass runs a short, isolated turn restricted to the
``skill_manage`` tool and asks the agent whether the session revealed a
reusable technique worth persisting as a skill.
Design guarantees (see issue #2231):
- Off by default — only runs when ``self_improve`` is enabled.
- Never runs with the full toolset — the review turn is restricted to the
single skill-mutation tool.
- Re-entrancy guarded — the review turn itself cannot trigger another review.
- Best-effort — any failure is swallowed so it never breaks the main task.
This logic lives in its own mixin so ``agent.py`` stays lean.
"""
import logging
logger = logging.getLogger(__name__)
class SkillReviewMixin:
"""Mixin providing the guarded skill-review pass for ``Agent``."""
def _skill_review_policy(self):
"""Return the active review policy, building the default lazily."""
policy = getattr(self, "_self_improve_policy", None)
if policy is not None:
return policy
from ..skills import DefaultSkillReviewPolicy
policy = DefaultSkillReviewPolicy()
self._self_improve_policy = policy
return policy
def _build_skill_review_tool(self):
"""Return the restricted toolset for the review turn (skill_manage only)."""
try:
from ..tools.skill_tools import create_skill_tools
skill_tools = create_skill_tools()
return [skill_tools.skill_manage]
except Exception as e: # pragma: no cover - defensive
logger.debug("Could not build skill review tool: %s", e, exc_info=True)
return []
def _skill_review_log_context(self):
"""Return (agent_name, session_id) for swallowed-failure warnings."""
return (
getattr(self, "name", None),
getattr(self, "_session_id", None),
)
def _prepare_skill_review(self, prompt, response, tools_used=None):
"""Gate + build the review turn. Returns (review_prompt, review_tools) or None.
Shared by the sync and async paths so they stay in lock-step. Applies
the opt-in switch, the re-entrancy guard, the policy decision, and the
restricted-toolset build.
"""
# Opt-in only.
if not getattr(self, "_self_improve", False):
return None
# Re-entrancy guard: never review a review.
if getattr(self, "_in_skill_review", False):
return None
trajectory = {
"prompt": prompt if isinstance(prompt, str) else str(prompt),
"response": response or "",
"tools_used": list(tools_used or []),
}
try:
policy = self._skill_review_policy()
if not policy.should_review(trajectory):
return None
review_prompt = policy.review_prompt(trajectory)
except Exception as e:
name, session_id = self._skill_review_log_context()
logger.warning(
"Skill review policy failed for agent=%s session_id=%s: %s",
name, session_id, e, exc_info=True,
)
return None
review_tools = self._build_skill_review_tool()
if not review_tools:
logger.debug("Skill review skipped: skill_manage tool unavailable")
return None
return review_prompt, review_tools
def _snapshot_chat_history_len(self):
"""Return the current chat_history length, or None if unavailable."""
history = getattr(self, "chat_history", None)
return len(history) if isinstance(history, list) else None
def _restore_chat_history(self, snapshot_len):
"""Trim chat_history back to ``snapshot_len`` so the review turn is isolated.
The review turn is internal: any user/assistant messages it appended to
the shared, persistent chat_history must not leak into subsequent turns
of a reused agent (chatbot, REPL, agentic loop).
"""
if snapshot_len is None:
return
history = getattr(self, "chat_history", None)
if isinstance(history, list) and len(history) > snapshot_len:
del history[snapshot_len:]
def _run_skill_review(self, prompt, response, tools_used=None):
"""Run the guarded skill-review pass.
Args:
prompt: The original user prompt for the finished task.
response: The final response produced for the task.
tools_used: Names of tools used during the task (if known).
"""
prepared = self._prepare_skill_review(prompt, response, tools_used)
if prepared is None:
return
review_prompt, review_tools = prepared
self._in_skill_review = True
history_len = self._snapshot_chat_history_len()
try:
if getattr(self, "verbose", False):
logger.info("Agent %s running guarded skill-review pass", self.name)
# Restricted turn: only skill_manage is exposed via the tools= arg.
self.chat(review_prompt, tools=review_tools)
except Exception as e:
name, session_id = self._skill_review_log_context()
logger.warning(
"Skill review pass failed for agent=%s session_id=%s: %s",
name, session_id, e, exc_info=True,
)
finally:
self._restore_chat_history(history_len)
self._in_skill_review = False
async def _arun_skill_review(self, prompt, response, tools_used=None):
"""Async variant of :meth:`_run_skill_review`."""
prepared = self._prepare_skill_review(prompt, response, tools_used)
if prepared is None:
return
review_prompt, review_tools = prepared
self._in_skill_review = True
history_len = self._snapshot_chat_history_len()
try:
if getattr(self, "verbose", False):
logger.info("Agent %s running guarded skill-review pass", self.name)
await self.achat(review_prompt, tools=review_tools)
except Exception as e:
name, session_id = self._skill_review_log_context()
logger.warning(
"Skill review pass failed for agent=%s session_id=%s: %s",
name, session_id, e, exc_info=True,
)
finally:
self._restore_chat_history(history_len)
self._in_skill_review = False
@@ -0,0 +1,300 @@
"""
Integration tests for handoff runtime resolution.
This test suite validates that handoffs now use turn-time runtime resolution
instead of construction-time pins, addressing issue #1938.
"""
import pytest
import time
from unittest.mock import Mock, patch, MagicMock, AsyncMock
import asyncio
from .handoff import Handoff, HandoffConfig, ContextPolicy, HandoffResult
class MockAgent:
"""Mock agent for testing handoff scenarios."""
def __init__(self, name, model="gpt-4o", agent_id=None, session_id="test"):
self.name = name
self.llm = model
self.model = model
self.agent_id = agent_id or name
self._session_id = session_id
self.chat_history = []
def chat(self, prompt, **kwargs):
"""Mock chat method that returns a simple response."""
response = f"Response from {self.name} using {self.model}: {prompt[:50]}..."
self.chat_history.append({"role": "user", "content": prompt})
self.chat_history.append({"role": "assistant", "content": response})
return response
async def achat(self, prompt, **kwargs):
"""Mock async chat method."""
return self.chat(prompt, **kwargs)
class TestHandoffRuntimeResolution:
"""Test handoff runtime resolution functionality."""
def test_handoff_uses_turn_time_resolution(self):
"""Test that handoff uses turn-time runtime resolution instead of construction-time."""
# Create agents with different models
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "gpt-4o")
# Create handoff
handoff = Handoff(
agent=target_agent,
config=HandoffConfig(context_policy=ContextPolicy.NONE)
)
# Mock the runtime resolution to verify it's called
with patch.object(handoff, '_execute_with_runtime_resolution') as mock_execute:
mock_execute.return_value = "Resolved response"
# Execute programmatic handoff
result = handoff.execute_programmatic(
source_agent=source_agent,
prompt="Test handoff",
context={}
)
# Verify runtime resolution was called instead of direct agent.chat
mock_execute.assert_called_once()
args = mock_execute.call_args[0]
assert args[0] == source_agent # source_agent
assert "Test handoff" in args[1] # full_prompt with context
assert args[3] == {} # context kwargs
@pytest.mark.asyncio
async def test_async_handoff_uses_turn_time_resolution(self):
"""Test that async handoff uses turn-time runtime resolution."""
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "claude-3-sonnet")
handoff = Handoff(
agent=target_agent,
config=HandoffConfig(context_policy=ContextPolicy.NONE)
)
# Mock the async runtime resolution
with patch.object(handoff, '_execute_with_runtime_resolution_async') as mock_execute_async:
mock_execute_async.return_value = "Async resolved response"
# Execute async handoff
result = await handoff.execute_async(
source_agent=source_agent,
prompt="Test async handoff",
context={}
)
# Verify async runtime resolution was called
mock_execute_async.assert_called_once()
args = mock_execute_async.call_args[0]
assert args[0] == source_agent
assert "Test async handoff" in args[1]
def test_handoff_tool_function_uses_runtime_resolution(self):
"""Test that handoff tool function uses runtime resolution."""
source_agent = MockAgent("SourceAgent", "gpt-4")
source_agent.chat_history = [{"role": "user", "content": "Complete this task"}]
target_agent = MockAgent("TargetAgent", "gpt-4o")
handoff = Handoff(
agent=target_agent,
config=HandoffConfig(context_policy=ContextPolicy.SUMMARY)
)
# Get tool function
tool_func = handoff.to_tool_function(source_agent)
# Mock the runtime resolution
with patch.object(handoff, '_execute_with_runtime_resolution') as mock_execute:
mock_execute.return_value = "Tool resolved response"
# Execute tool function
result = tool_func(task="Complete this task")
# Verify runtime resolution was called
mock_execute.assert_called_once()
args = mock_execute.call_args[0]
assert args[0] == source_agent
@patch.object(MockAgent, 'chat')
def test_runtime_resolution_uses_agent_chat(self, mock_chat):
"""Handoff must execute via agent.chat, not a bare LLM runtime."""
mock_chat.return_value = "Agent chat response"
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "gpt-4o")
handoff = Handoff(agent=target_agent)
response = handoff._execute_with_runtime_resolution(
source_agent=source_agent,
prompt="Test prompt",
effective_tools=[],
context={}
)
mock_chat.assert_called_once_with("Test prompt", tools=[])
assert response == "Agent chat response"
@pytest.mark.asyncio
async def test_async_runtime_resolution_uses_agent_achat(self):
"""Async handoff must execute via agent.achat, not a bare LLM runtime."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "claude-3-sonnet")
with patch.object(target_agent, 'achat', new_callable=AsyncMock) as mock_achat:
mock_achat.return_value = "Async agent chat response"
handoff = Handoff(agent=target_agent)
response = await handoff._execute_with_runtime_resolution_async(
source_agent=source_agent,
prompt="Test async prompt",
effective_tools=[],
context={}
)
mock_achat.assert_called_once_with("Test async prompt", tools=[])
assert response == "Async agent chat response"
def test_runtime_resolution_uses_agent_chat_even_when_resolve_available(self):
"""Regression: bare resolve_runtime must not bypass agent instructions."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "gpt-4o")
handoff = Handoff(agent=target_agent)
with patch('praisonaiagents.runtime.resolve.resolve_runtime') as mock_resolve:
mock_runtime = Mock()
mock_runtime.execute.return_value = "Bare runtime response"
mock_resolve.return_value = mock_runtime
with patch.object(target_agent, 'chat', wraps=target_agent.chat) as mock_chat:
response = handoff._execute_with_runtime_resolution(
source_agent=source_agent,
prompt="Test fallback",
effective_tools=[],
context={}
)
mock_resolve.assert_not_called()
mock_runtime.execute.assert_not_called()
mock_chat.assert_called_once()
assert "Response from TargetAgent using gpt-4o" in response
@pytest.mark.asyncio
async def test_async_runtime_resolution_uses_agent_chat(self):
"""Async handoff should route through agent chat, not bare runtime."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "claude-3-haiku")
handoff = Handoff(agent=target_agent)
with patch('praisonaiagents.runtime.resolve.resolve_runtime') as mock_resolve:
mock_resolve.side_effect = Exception("Should not be called")
response = await handoff._execute_with_runtime_resolution_async(
source_agent=source_agent,
prompt="Test async fallback",
effective_tools=[],
context={}
)
mock_resolve.assert_not_called()
assert "Response from TargetAgent using claude-3-haiku" in response
def test_extract_model_ref_from_llm_object(self):
"""Model extraction should handle LLM objects, not pass them to resolvers."""
source_agent = MockAgent("SourceAgent", "gpt-4")
target_agent = MockAgent("TargetAgent", "gpt-4o")
llm_object = Mock()
llm_object.model = "claude-3-opus"
target_agent.llm = llm_object
handoff = Handoff(agent=target_agent)
assert handoff._extract_model_ref(target_agent) == "claude-3-opus"
class TestConstructionVsTurnTimeRegression:
"""
Regression tests for construction-time vs turn-time resolution.
These tests specifically validate that the fix for issue #1938 works correctly.
"""
def test_model_change_after_handoff_creation(self):
"""
Test the core regression case: model change after handoff creation.
Before fix: handoff would use construction-time model
After fix: handoff should use turn-time model
"""
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_agent = MockAgent("TargetAgent", "gpt-4")
# Create handoff with initial target model
handoff = Handoff(agent=target_agent)
# Target agent changes model (router decision, user preference, etc.)
target_agent.llm = "gpt-4o"
target_agent.model = "gpt-4o"
with patch.object(target_agent, 'chat', wraps=target_agent.chat) as mock_chat:
handoff._execute_with_runtime_resolution(
source_agent=source_agent,
prompt="Test with new model",
effective_tools=[],
context={}
)
mock_chat.assert_called_once()
assert handoff._extract_model_ref(target_agent) == "gpt-4o"
def test_sub_agent_tool_respects_current_model(self):
"""Test that as_tool() handoffs route through the target agent chat path."""
parent_agent = MockAgent("ParentAgent", "gpt-4")
parent_agent.chat_history = [{"role": "user", "content": "Complete subtask"}]
sub_agent = MockAgent("SubAgent", "claude-3-sonnet")
handoff = Handoff(agent=sub_agent)
sub_agent.llm = "claude-3-opus"
sub_agent.model = "claude-3-opus"
with patch.object(sub_agent, 'chat', wraps=sub_agent.chat) as mock_chat:
tool_func = handoff.to_tool_function(parent_agent)
result = tool_func(task="Complete subtask")
mock_chat.assert_called_once()
assert "claude-3-opus" in result
assert handoff._extract_model_ref(sub_agent) == "claude-3-opus"
def test_multiple_handoffs_different_models(self):
"""Test multiple handoffs with different models work correctly."""
source_agent = MockAgent("SourceAgent", "gpt-3.5-turbo")
target_a = MockAgent("TargetA", "gpt-4o")
target_b = MockAgent("TargetB", "claude-3-sonnet")
handoff_a = Handoff(agent=target_a)
handoff_b = Handoff(agent=target_b)
response_a = handoff_a._execute_with_runtime_resolution(
source_agent, "Task A", [], {}
)
response_b = handoff_b._execute_with_runtime_resolution(
source_agent, "Task B", [], {}
)
assert "gpt-4o" in response_a
assert "claude-3-sonnet" in response_b
assert handoff_a._extract_model_ref(target_a) == "gpt-4o"
assert handoff_b._extract_model_ref(target_b) == "claude-3-sonnet"
# Run tests
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -51,6 +51,35 @@ class BackoffPolicy:
class ToolExecutionMixin:
"""Mixin providing toolexecution methods for the Agent class."""
def _register_artifact_tools(self):
"""Register artifact retrieval tools when artifacts are first created."""
try:
from ..tools import artifact_tools
# Set the store reference for the tools
artifact_tools.set_artifact_store(self._artifact_store)
# Add the retrieval tools
tools_to_add = [
artifact_tools.artifact_head,
artifact_tools.artifact_tail,
artifact_tools.artifact_grep,
artifact_tools.artifact_chunk,
artifact_tools.artifact_load,
artifact_tools.artifact_list,
]
# Only add if not already present
existing_tool_names = {getattr(t, '__name__', str(t)) for t in self.tools}
for tool in tools_to_add:
tool_name = getattr(tool, '__name__', str(tool))
if tool_name not in existing_tool_names:
self.tools.append(tool)
logging.debug("Registered artifact retrieval tools")
except Exception as e:
logging.warning(f"Failed to register artifact tools: {e}")
def _get_existing_stream_emitter(self):
"""Return an already-initialized stream emitter without creating one."""
@@ -364,10 +393,28 @@ class ToolExecutionMixin:
agent_id=self.name,
is_retryable=True,
)
# For other error dicts, treat as non-retryable unless specified
else:
# Success path - return the result
# For other error dicts: approval/permission denials are legitimate
# non-retryable outcomes; everything else represents a tool failure
# that should engage the outer retry/backoff loop.
elif result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error"):
break
else:
# Avoid compounding with the inner retry loop in
# _execute_tool_with_circuit_breaker: error types it already
# retries (e.g. timeout/rate_limit/connection_error) are
# exhausted by the time they reach here, so escalating them as
# retryable would re-run the entire inner loop from scratch on
# every outer attempt. Only escalate error types the inner loop
# does NOT retry (e.g. "unknown") so they get one outer pass.
error_type = self._classify_error_type(result, None)
inner_policy = self._get_tool_retry_policy(function_name)
is_retryable = error_type not in inner_policy.retry_on
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
else:
# Success path - return the result
break
@@ -462,36 +509,68 @@ class ToolExecutionMixin:
try:
result_str = str(result)
if self.context_manager:
# Use context-aware truncation with configured budget
truncated = self._truncate_tool_output(function_name, result_str, tool_call_id)
else:
# Apply default limit even without context management
# This prevents runaway tool outputs from causing overflow
limit = getattr(self, 'tool_output_limit', 16000)
if len(result_str) > limit:
# Use smart truncation format that judge recognizes as OK
tail_size = min(limit // 5, 2000)
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
# Store full output for later retrieval
# Get configured limit
limit = getattr(self, 'tool_output_limit', 16000)
# Check if we need to spill to artifact store
if len(result_str) > limit:
# Try to use artifact store if available
artifact_ref = None
if hasattr(self, '_artifact_store') and self._artifact_store is not None:
try:
from ..runtime.tool_output_store import get_tool_output_store
store = get_tool_output_store(getattr(self, '_run_id', None))
metadata = store.store(function_name, result_str, call_id=tool_call_id)
if metadata:
# Add reference to stored output in the truncated preview
truncated = store.format_reference(metadata, truncated)
logging.debug(f"Stored full {function_name} output ({len(result_str)} bytes) at {metadata.get('path')}")
except ImportError:
# Fallback if store not available
pass
from ..context.artifacts import ArtifactMetadata
# Create metadata for this artifact
metadata = ArtifactMetadata(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
tool_name=function_name,
turn_id=getattr(self, '_turn_counter', 0),
)
# Store the full output
artifact_ref = self._artifact_store.store(result_str, metadata)
logging.debug(f"Stored {function_name} output ({len(result_str)} bytes) as artifact {artifact_ref.artifact_id}")
# Register artifact retrieval tools if not already registered
if not hasattr(self, '_artifact_tools_registered'):
self._register_artifact_tools()
self._artifact_tools_registered = True
except Exception as e:
logging.debug(f"Failed to store tool output: {e}")
logging.debug(f"Failed to store artifact: {e}")
# Generate truncated preview
tail_size = min(limit // 5, 2000)
head = result_str[:limit - tail_size]
tail = result_str[-tail_size:] if tail_size > 0 else ""
# If we stored an artifact, include reference in the output
if artifact_ref:
truncated = (
f"{head}\n"
f"...[{len(result_str):,} chars total, showing first/last portions]...\n"
f"{tail}\n\n"
f"{artifact_ref.to_inline()}"
)
else:
truncated = result_str
# Fallback to simple truncation
truncated = f"{head}\n...[{len(result_str):,} chars, showing first/last portions]...\n{tail}"
else:
truncated = result_str
if self.context_manager and hasattr(self, '_truncate_tool_output'):
# Use context-aware truncation if available, but preserve artifact reference
if 'artifact_ref' in locals() and artifact_ref:
# Extract the artifact reference from the truncated string
artifact_inline = artifact_ref.to_inline()
# Remove the artifact reference before context truncation
truncated_without_ref = truncated.replace(artifact_inline, "").rstrip()
# Apply context truncation
truncated_without_ref = self._truncate_tool_output(function_name, truncated_without_ref, tool_call_id)
# Re-append the artifact reference
truncated = f"{truncated_without_ref}\n\n{artifact_inline}"
else:
truncated = self._truncate_tool_output(function_name, truncated, tool_call_id)
if len(truncated) < len(result_str):
logging.debug(f"Truncated {function_name} output from {len(result_str)} to {len(truncated)} chars")
@@ -499,6 +578,9 @@ class ToolExecutionMixin:
if isinstance(result, dict):
max_field_chars = getattr(self, 'tool_output_limit', 16000) if not self.context_manager else None
result = self._truncate_dict_fields(result, function_name, max_field_chars, tool_call_id)
# Add artifact reference to dict result if available
if 'artifact_ref' in locals() and artifact_ref:
result["_artifact_ref"] = artifact_ref.to_dict()
else:
result = truncated
except Exception as e:
@@ -612,6 +694,12 @@ class ToolExecutionMixin:
def _trigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
"""Trigger AFTER_AGENT hook and return response."""
# During a guarded skill-review turn, skip the entire after-agent
# side-effect pipeline (hooks, auto-memory, auto-learning, nudge,
# skill-review). The review turn is internal and must not re-fire
# these effects or recurse into another review.
if getattr(self, "_in_skill_review", False):
return response
from ..hooks import HookEvent, AfterAgentInput
after_agent_input = AfterAgentInput(
session_id=getattr(self, '_session_id', 'default'),
@@ -647,10 +735,31 @@ class ToolExecutionMixin:
# Log learning nudge failures for debugging
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
# Runs a guarded review pass restricted to skill_manage. No-op when
# disabled or already inside a review (re-entrancy guarded).
try:
run_review = getattr(self, "_run_skill_review", None)
if run_review is not None:
run_review(prompt, response, tools_used)
except Exception as e:
logger.warning(
"Skill self-improvement review failed for agent=%s session_id=%s; "
"set self_improve=False to disable this best-effort pass. error=%s",
getattr(self, "name", None),
getattr(self, "_session_id", None),
e,
exc_info=True,
)
return response
async def _atrigger_after_agent_hook(self, prompt, response, start_time, tools_used=None):
"""Async version: Trigger AFTER_AGENT hook and return response."""
# During a guarded skill-review turn, skip the entire after-agent
# side-effect pipeline (see sync variant for rationale).
if getattr(self, "_in_skill_review", False):
return response
from ..hooks import HookEvent, AfterAgentInput
after_agent_input = AfterAgentInput(
session_id=getattr(self, '_session_id', 'default'),
@@ -683,6 +792,21 @@ class ToolExecutionMixin:
# Log learning nudge failures for debugging
logger.warning("Learning nudge generation failed: %s", e, exc_info=True)
# Autonomous skill self-improvement loop (opt-in via self_improve=True).
try:
arun_review = getattr(self, "_arun_skill_review", None)
if arun_review is not None:
await arun_review(prompt, response, tools_used)
except Exception as e:
logger.warning(
"Skill self-improvement review failed for agent=%s session_id=%s; "
"set self_improve=False to disable this best-effort pass. error=%s",
getattr(self, "name", None),
getattr(self, "_session_id", None),
e,
exc_info=True,
)
return response
def _calculate_llm_cost(self, prompt_tokens: int, completion_tokens: int, response: any = None) -> float:
@@ -707,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:
@@ -34,7 +34,7 @@ class UnifiedExecutionMixin:
This replaces the duplicated logic between chat/achat and execute_tool/execute_tool_async
with a single async-first implementation plus sync bridge.
"""
async def _unified_chat_impl(
self,
prompt: str,
@@ -108,7 +108,8 @@ class UnifiedExecutionMixin:
tool_choice=tool_choice
)
elif hasattr(self, '_cli_backend') and self._cli_backend is not None:
# Legacy CLI backend with deprecation warning (emitted in Agent.__init__)
# CLI Backend routing - delegate entire turn if configured
# Note: Turn-time resolution placeholder is in place for future backend switching
return await self._chat_via_cli_backend(
prompt=prompt,
temperature=temperature,
@@ -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``.
"""
...
@@ -18,6 +18,8 @@ from __future__ import annotations
import asyncio
import contextvars
import hashlib
import json
import logging
from praisonaiagents._logging import get_logger
import os
@@ -173,15 +175,21 @@ class ApprovalRegistry:
# ── Context helpers ──────────────────────────────────────────────────
def mark_approved(self, tool_name: str) -> None:
@staticmethod
def _approval_cache_key(tool_name: str, arguments: Dict) -> str:
payload = json.dumps(arguments or {}, sort_keys=True, default=str)
digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
return f"{tool_name}:{digest}"
def mark_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> None:
approved = self._approved_context.get(set())
approved.add(tool_name)
approved.add(self._approval_cache_key(tool_name, arguments or {}))
self._approved_context.set(approved)
def is_already_approved(self, tool_name: str) -> bool:
def is_already_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> bool:
if self.get_risk_level(tool_name) == "critical":
return False
return tool_name in self._approved_context.get(set())
return self._approval_cache_key(tool_name, arguments or {}) in self._approved_context.get(set())
def clear_approved(self) -> None:
self._approved_context.set(set())
@@ -222,22 +230,22 @@ class ApprovalRegistry:
return ApprovalDecision(approved=True, reason="No approval required")
# Already approved in this context
if self.is_already_approved(tool_name):
if self.is_already_approved(tool_name, arguments):
return ApprovalDecision(approved=True, reason="Already approved in context")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
# Env auto-approve
if self.is_env_auto_approve():
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
# YAML auto-approve
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
# Delegate to backend
@@ -261,7 +269,7 @@ class ApprovalRegistry:
)
if decision.approved:
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return decision
async def approve_async(
@@ -275,20 +283,20 @@ class ApprovalRegistry:
if not self.is_required(tool_name):
return ApprovalDecision(approved=True, reason="No approval required")
if self.is_already_approved(tool_name):
if self.is_already_approved(tool_name, arguments):
return ApprovalDecision(approved=True, reason="Already approved in context")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
if self.is_env_auto_approve():
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
if self.is_yaml_approved(tool_name):
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
backend = self.get_backend(agent_name)
@@ -308,5 +316,5 @@ class ApprovalRegistry:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
if decision.approved:
self.mark_approved(tool_name)
self.mark_approved(tool_name, arguments)
return decision
@@ -27,6 +27,7 @@ from .protocols import (
EmailInbox,
SupportsPresentation,
PlatformCapabilities,
WebhookVerifierProtocol,
)
from .presentation import (
MessagePresentation,
@@ -38,17 +39,25 @@ from .presentation import (
ActionType,
ButtonStyle,
BlockType,
adapt_presentation,
)
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
@@ -80,14 +89,21 @@ __all__ = [
"ActionType",
"ButtonStyle",
"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
@@ -11,10 +11,15 @@ rendering belongs in the wrapper (praisonai).
from __future__ import annotations
import hashlib
from dataclasses import dataclass, field
from enum import Enum
from typing import Any, Dict, List, Literal, Optional, Union
# Most channels (e.g. Telegram) hard-cap inline callback payloads at 64 bytes.
# Keep degraded select callbacks within this bound while preserving uniqueness.
_MAX_CALLBACK_LEN = 64
class ActionType(str, Enum):
"""Types of actions that can be triggered by interactive elements."""
@@ -23,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):
@@ -87,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:
@@ -219,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."""
@@ -442,7 +502,7 @@ class PresentationLimits:
def discord() -> "PresentationLimits":
"""Get Discord-specific limits."""
return PresentationLimits(
max_buttons=25,
max_buttons=5, # per-row capacity; total cap = max_buttons * max_button_rows = 25
max_button_rows=5,
max_button_label=80,
max_options=25,
@@ -454,3 +514,240 @@ class PresentationLimits:
)
def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> PresentationButton:
"""Return a copy of a button adapted to the given limits.
Truncates the label and, when the channel does not support web apps,
degrades a ``web_app`` action to a plain URL so the button still works.
"""
label = button.label or ""
if limits.max_button_label and len(label) > limits.max_button_label:
label = label[: limits.max_button_label]
action = button.action
url = button.url
if action is not None:
action_type = action.type.value if isinstance(action.type, ActionType) else action.type
# 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
action = PresentationAction(type=ActionType.URL, url=action.web_app_url)
return PresentationButton(
label=label,
action=action,
url=url,
priority=button.priority,
style=button.style,
disabled=button.disabled,
)
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.
The raw ``select:<action_id>:<value>`` form can exceed channel callback
limits (e.g. Telegram's 64-byte cap) and collide when distinct options
share a long prefix. When the raw payload fits within ``_MAX_CALLBACK_LEN``
it is returned unchanged; otherwise the value is replaced with a short,
collision-resistant hash so distinct options stay distinct after truncation.
"""
raw = f"select:{action_id}:{value}"
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}:"
# Reserve room for the digest; trim the action_id prefix if needed.
budget = _MAX_CALLBACK_LEN - len(digest)
if budget < len("select::"):
# action_id itself is too long; hash it too to guarantee the bound.
aid_digest = hashlib.sha1((action_id or "").encode("utf-8")).hexdigest()[:8]
prefix = f"select:{aid_digest}:"
return f"{prefix[:_MAX_CALLBACK_LEN - len(digest)]}{digest}"
def _select_to_buttons(block: PresentationBlock) -> PresentationBlock:
"""Convert a SELECT block into an equivalent BUTTONS block.
Used when a channel does not support native select menus. Each option
becomes a callback button carrying a bounded, channel-safe identifier
derived from ``select:<action_id>:<value>``.
"""
buttons: List[PresentationButton] = []
action_id = block.action_id or ""
for option in (block.options or []):
label = option.label
if option.emoji:
label = f"{option.emoji} {label}"
buttons.append(
PresentationButton(
label=label,
action=PresentationAction(
type=ActionType.CALLBACK,
value=_encode_select_callback(action_id, option.value),
),
)
)
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
def adapt_presentation(
presentation: MessagePresentation,
limits: PresentationLimits,
) -> MessagePresentation:
"""Return a copy of ``presentation`` guaranteed to satisfy ``limits``.
This is the single, channel-agnostic adaptation pass that channel
renderers should run before mapping a presentation to native widgets.
It performs:
1. Priority-aware button truncation: when a buttons block exceeds
``max_buttons`` (or the implied ``max_buttons * max_button_rows``
cap), the lowest-``priority`` buttons are dropped first (highest
survives), preserving original order among kept buttons.
2. Label truncation to ``max_button_label`` / ``max_option_label``.
3. Option truncation to ``max_options``.
4. Capability degradation: ``select`` blocks become button rows when
``supports_select`` is False; ``web_app`` actions become URLs when
``supports_web_apps`` is False.
The input presentation is never mutated.
Args:
presentation: The portable presentation to adapt.
limits: The target channel's capability limits.
Returns:
A new ``MessagePresentation`` that is safe to render natively.
"""
adapted_blocks: List[PresentationBlock] = []
for block in presentation.blocks:
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
if block_type == BlockType.SELECT.value and not limits.supports_select:
# Degrade select -> buttons, then adapt the resulting buttons block
block = _select_to_buttons(block)
block_type = BlockType.BUTTONS.value
if block_type == BlockType.BUTTONS.value and block.buttons:
# Cap total buttons by max_buttons * max_button_rows (interpreting
# max_buttons as per-row capacity), falling back to max_buttons.
rows = limits.max_button_rows if limits.max_button_rows else 1
total_cap = limits.max_buttons * rows if limits.max_buttons else len(block.buttons)
if total_cap <= 0:
total_cap = len(block.buttons)
buttons = list(block.buttons)
if len(buttons) > total_cap:
# Keep highest-priority buttons; preserve original order among kept.
indexed = list(enumerate(buttons))
kept = sorted(
indexed,
key=lambda iv: (iv[1].priority, -iv[0]),
reverse=True,
)[:total_cap]
kept.sort(key=lambda iv: iv[0])
buttons = [b for _, b in kept]
adapted_buttons = [_adapt_button(b, limits) for b in buttons]
adapted_blocks.append(
PresentationBlock(type=BlockType.BUTTONS, buttons=adapted_buttons)
)
continue
if block_type == BlockType.SELECT.value and block.options:
options = block.options
if limits.max_options and len(options) > limits.max_options:
options = options[: limits.max_options]
new_options: List[SelectOption] = []
for option in options:
label = option.label
if limits.max_option_label and len(label) > limits.max_option_label:
label = label[: limits.max_option_label]
new_options.append(
SelectOption(
label=label,
value=option.value,
description=option.description,
emoji=option.emoji,
default=option.default,
)
)
adapted_blocks.append(
PresentationBlock(
type=BlockType.SELECT,
options=new_options,
placeholder=block.placeholder,
action_id=block.action_id,
)
)
continue
if block_type == BlockType.TEXT.value or block_type == BlockType.CONTEXT.value:
text = block.text
if text is not None and limits.max_text_length and len(text) > limits.max_text_length:
text = text[: limits.max_text_length]
adapted_blocks.append(
PresentationBlock(type=block.type, text=text)
)
continue
adapted_blocks.append(block)
return MessagePresentation(
blocks=adapted_blocks,
tone=presentation.tone,
ephemeral=presentation.ephemeral,
replace_message_id=presentation.replace_message_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."""
@@ -92,9 +92,25 @@ class CliBackendProtocol(Protocol):
Any object implementing these methods can serve as an Agent backend.
Follows typing.Protocol pattern from AGENTS.md.
Note: Starting with runtime capability system, all CLI backends must
declare their capabilities via the capabilities() method for compatibility
validation at config/selection time.
"""
config: CliBackendConfig
def capabilities(self) -> "RuntimeCapabilityMatrix": # type: ignore
"""Report capabilities supported by this CLI backend.
Required for integration with the runtime capability validation system.
CLI backends should honestly declare their capabilities to enable
fail-fast validation when agents require specific features.
Returns:
RuntimeCapabilityMatrix with supported capabilities
"""
...
async def execute(
self,
prompt: str,
@@ -41,6 +41,7 @@ __all__ = [
"HooksConfig",
"SkillsConfig",
"ToolSearchConfig",
"RuntimeConfig",
# Type aliases
"MemoryParam",
"KnowledgeParam",
@@ -48,6 +49,7 @@ __all__ = [
"ReflectionParam",
"GuardrailParam",
"WebParam",
"RuntimeParam",
# Resolver
"resolve",
"ArrayMode",
@@ -65,6 +67,7 @@ __all__ = [
"resolve_hooks",
"resolve_skills",
"resolve_routing",
"resolve_runtime",
# Parse utilities
"detect_url_scheme",
"is_path_like",
@@ -126,12 +129,14 @@ _MODULE_MAP = {
"HooksConfig": "feature_configs",
"SkillsConfig": "feature_configs",
"ToolSearchConfig": "feature_configs",
"RuntimeConfig": "feature_configs",
"MemoryParam": "feature_configs",
"KnowledgeParam": "feature_configs",
"PlanningParam": "feature_configs",
"ReflectionParam": "feature_configs",
"GuardrailParam": "feature_configs",
"WebParam": "feature_configs",
"RuntimeParam": "feature_configs",
# param_resolver
"resolve": "param_resolver",
"ArrayMode": "param_resolver",
@@ -148,6 +153,7 @@ _MODULE_MAP = {
"resolve_hooks": "param_resolver",
"resolve_skills": "param_resolver",
"resolve_routing": "param_resolver",
"resolve_runtime": "feature_configs",
# parse_utils
"detect_url_scheme": "parse_utils",
"is_path_like": "parse_utils",
@@ -42,6 +42,7 @@ from ..agent.autonomy import AutonomyConfig
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from ..compaction.strategy import CompactionStrategy
from ..context.artifact_store import FileSystemArtifactStore
class MemoryBackend(str, Enum):
@@ -144,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)
@@ -165,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,
}
@@ -738,6 +745,16 @@ class ExecutionConfig:
# Code execution sandbox mode - "sandbox" (default) uses subprocess isolation
# "direct" runs in current process (legacy, less secure)
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct"
# Code-execution-with-tools (code mode): when True, model-generated code may
# call the agent's registered tools directly via injected proxies, enabling
# multi-step tool pipelines in a single turn (intermediate results stay out
# of context). Opt-in and gated by code_tools_allow + the approval framework.
code_tools: bool = False
# Explicit per-run allow-list of tool names callable from code when
# code_tools is enabled. None/empty = no tools exposed (safe default).
code_tools_allow: Optional[List[str]] = None
# Rate limiter instance (consolidated from standalone rate_limiter param)
rate_limiter: Optional[Any] = None
@@ -832,6 +849,8 @@ class ExecutionConfig:
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"code_sandbox_mode": self.code_sandbox_mode,
"code_tools": self.code_tools,
"code_tools_allow": self.code_tools_allow,
"context_compaction": (
self.context_compaction.to_dict()
if hasattr(self.context_compaction, 'to_dict')
@@ -860,6 +879,8 @@ class ExecutionConfig:
code_execution=data.get("code_execution", False),
code_mode=data.get("code_mode", "safe"),
code_sandbox_mode=data.get("code_sandbox_mode", "docker"),
code_tools=data.get("code_tools", False),
code_tools_allow=data.get("code_tools_allow", None),
context_compaction=context_compaction,
max_context_tokens=data.get("max_context_tokens", None),
max_budget=data.get("max_budget", None),
@@ -935,7 +956,8 @@ class ToolConfig:
"""
Configuration for tool execution behavior.
Configuration for tool execution behavior including timeout, retry policy, and parallel execution.
Configuration for tool execution behavior including timeout, retry policy, parallel execution,
and artifact storage for large outputs.
Usage:
# Simple enable with defaults
@@ -948,8 +970,12 @@ class ToolConfig:
parallel=True,
))
# With timeout only
Agent(tool_config=ToolConfig(timeout=30))
# With artifact storage for large outputs
Agent(tool_config=ToolConfig(
output_limit=32000,
enable_artifacts=True,
artifact_retention_days=14,
))
"""
# Tool execution timeout in seconds
timeout: Optional[int] = None
@@ -960,6 +986,26 @@ class ToolConfig:
# Enable parallel execution of batched LLM tool calls
parallel: bool = False
# Tool output handling and artifact storage
output_limit: int = 16000 # Maximum bytes before spilling to artifact store
output_max_lines: Optional[int] = None # Maximum lines before spilling
output_direction: str = "both" # Truncation direction: "head", "tail", or "both"
enable_artifacts: bool = False # Whether to enable artifact storage (default False for backward compat)
artifact_retention_days: int = 7 # Days to retain artifacts before garbage collection
artifact_store: Optional[Any] = None # Custom artifact store instance
redact_secrets: bool = True # Whether to redact secrets from artifacts
def __post_init__(self) -> None:
"""Validate configuration after initialization."""
if self.output_limit <= 0:
raise ValueError("tool_config.output_limit must be > 0")
if self.output_max_lines is not None and self.output_max_lines <= 0:
raise ValueError("tool_config.output_max_lines must be > 0 when provided")
if self.output_direction not in {"head", "tail", "both"}:
raise ValueError("tool_config.output_direction must be one of: 'head', 'tail', 'both'")
if self.artifact_retention_days < 0:
raise ValueError("tool_config.artifact_retention_days must be >= 0")
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
@@ -970,6 +1016,13 @@ class ToolConfig:
else self.retry_policy
),
"parallel": self.parallel,
"output_limit": self.output_limit,
"output_max_lines": self.output_max_lines,
"output_direction": self.output_direction,
"enable_artifacts": self.enable_artifacts,
"artifact_retention_days": self.artifact_retention_days,
"artifact_store": self.artifact_store,
"redact_secrets": self.redact_secrets,
}
@classmethod
@@ -988,6 +1041,13 @@ class ToolConfig:
timeout=data.get("timeout"),
retry_policy=retry_policy,
parallel=data.get("parallel", False),
output_limit=data.get("output_limit", 16000),
output_max_lines=data.get("output_max_lines"),
output_direction=data.get("output_direction", "both"),
enable_artifacts=data.get("enable_artifacts", False),
artifact_retention_days=data.get("artifact_retention_days", 7),
artifact_store=data.get("artifact_store"),
redact_secrets=data.get("redact_secrets", True),
)
@@ -1513,6 +1573,99 @@ def resolve_tools(value: ToolParam) -> Optional[ToolConfig]:
return value
@dataclass
class RuntimeConfig:
"""
Configuration for agent runtime capabilities and requirements.
Used to declare what capabilities an agent requires and validate
against runtime implementations at config/selection time.
Usage:
# Agent that requires native hooks and streaming
Agent(
instructions="...",
runtime=RuntimeConfig(
required_capabilities={"native_hooks", "streaming_deltas"}
)
)
# Agent with runtime preference
Agent(
runtime=RuntimeConfig(
preferred_runtime="native",
required_capabilities={"tool_loop", "mcp_tools"},
fallback_allowed=True
)
)
"""
# Required capabilities for this agent (capability names or enum values)
required_capabilities: Optional[Union[List[str], FrozenSet[str], List[Any], FrozenSet[Any]]] = None
# Preferred runtime implementation name
preferred_runtime: Optional[str] = None
# Whether to allow fallback to other runtimes if preferred is unavailable
fallback_allowed: bool = True
# Fail fast validation (validate at agent creation vs first execution)
validate_on_creation: bool = True
# Additional runtime metadata/hints
metadata: Optional[Dict[str, Any]] = field(default_factory=dict)
def __post_init__(self) -> None:
"""Normalize required_capabilities to a list."""
if self.required_capabilities is not None:
if isinstance(self.required_capabilities, str):
self.required_capabilities = [self.required_capabilities]
else:
try:
self.required_capabilities = list(self.required_capabilities)
except TypeError:
self.required_capabilities = [self.required_capabilities]
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
req_caps = None
if self.required_capabilities:
req_caps = [
cap.name.lower() if hasattr(cap, "name") else str(cap)
for cap in self.required_capabilities
]
return {
"required_capabilities": req_caps,
"preferred_runtime": self.preferred_runtime,
"fallback_allowed": self.fallback_allowed,
"validate_on_creation": self.validate_on_creation,
"metadata": self.metadata,
}
# Type aliases for runtime parameters
RuntimeParam = Union[bool, str, Dict[str, Any], RuntimeConfig, None]
def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
"""Resolve runtime= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return RuntimeConfig()
if isinstance(value, str):
return RuntimeConfig(preferred_runtime=value)
if isinstance(value, (list, set, tuple, frozenset)):
return RuntimeConfig(required_capabilities=list(value))
if isinstance(value, dict):
return RuntimeConfig(**value)
if isinstance(value, RuntimeConfig):
return value
raise TypeError(
f"Invalid runtime parameter type: {type(value).__name__}. "
"Expected None/False, True, str, list, set, dict, or RuntimeConfig."
)
__all__ = [
# Enums
"MemoryBackend",
@@ -1540,6 +1693,7 @@ __all__ = [
"SkillsConfig",
"AutonomyConfig",
"ToolSearchConfig",
"RuntimeConfig",
# Config classes (Multi-Agent)
"MultiAgentHooksConfig",
"MultiAgentOutputConfig",
@@ -1562,6 +1716,7 @@ __all__ = [
"AutonomyParam",
"ToolSearchParam",
"ToolParam",
"RuntimeParam",
# Precedence ladder resolvers
"resolve_memory",
"resolve_knowledge",
@@ -1573,4 +1728,5 @@ __all__ = [
"resolve_autonomy",
"resolve_tool_search",
"resolve_tools",
"resolve_runtime",
]
@@ -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)
@@ -180,6 +180,7 @@ __all__ = [
"TerminalLoggerProtocol",
"compute_checksum",
"generate_summary",
"FileSystemArtifactStore",
# Context Compaction Policy
"ContextCompactionPolicy",
"ContextCompactionPolicyProtocol",
@@ -295,6 +296,11 @@ def __getattr__(name: str):
from . import artifacts
return getattr(artifacts, name)
# FileSystemArtifactStore
if name == "FileSystemArtifactStore":
from .artifact_store import FileSystemArtifactStore
return FileSystemArtifactStore
# Session Context Tracking (Agno pattern)
if name in ("SessionContextTracker", "SessionState"):
from . import session_tracker
@@ -0,0 +1,446 @@
"""
Concrete implementation of ArtifactStoreProtocol for filesystem storage.
This module provides a filesystem-based artifact store that persists
tool outputs to disk when they exceed configured size limits.
"""
import os
import re
import json
import time
import uuid
import hashlib
import logging
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
from .artifacts import (
ArtifactRef,
ArtifactMetadata,
ArtifactStoreProtocol,
GrepMatch,
compute_checksum,
generate_summary,
)
class FileSystemArtifactStore:
"""
Filesystem-based artifact storage implementation.
Stores artifacts under ~/.praisonai/artifacts/{agent_id}/{run_id}/
with content-addressed naming using SHA256 hashes.
"""
def __init__(
self,
base_dir: Optional[str] = None,
retention_days: int = 7,
redact_secrets: bool = True,
):
"""
Initialize the filesystem artifact store.
Args:
base_dir: Base directory for storage (default: ~/.praisonai/artifacts)
retention_days: Days to retain artifacts before GC
redact_secrets: Whether to redact secrets from stored content
"""
if base_dir:
self.base_dir = Path(base_dir)
else:
# Use standard PraisonAI data directory
home = Path.home()
self.base_dir = home / ".praisonai" / "artifacts"
self.retention_days = retention_days
self.redact_secrets = redact_secrets
self._secret_patterns = [
r'(?i)(api[_-]?key|apikey)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'(?i)(secret|password|passwd|pwd)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'(?i)(token|bearer)["\']?\s*[:=]\s*["\']?[\w\-]+',
r'sk-[a-zA-Z0-9]{20,}', # OpenAI keys
r'ghp_[a-zA-Z0-9]{36}', # GitHub tokens
]
# Ensure base directory exists
self.base_dir.mkdir(parents=True, exist_ok=True)
def store(
self,
content: Any,
metadata: ArtifactMetadata,
) -> ArtifactRef:
"""
Store content as an artifact.
Args:
content: The content to store (will be serialized)
metadata: Metadata about the artifact
Returns:
ArtifactRef pointing to the stored artifact
"""
# Serialize content and prepare for redaction
if isinstance(content, (str, bytes)):
content_str = content if isinstance(content, str) else None
content_bytes = content.encode("utf-8") if isinstance(content, str) else content
mime_type = "text/plain"
else:
# JSON serialize structured data
content_str = json.dumps(content, indent=2, default=str)
content_bytes = content_str.encode("utf-8")
mime_type = "application/json"
# Redact secrets if enabled (for text and JSON content)
if self.redact_secrets and content_str:
for pattern in self._secret_patterns:
content_str = re.sub(pattern, "[REDACTED]", content_str)
content_bytes = content_str.encode("utf-8")
# Compute checksum
checksum = compute_checksum(content_bytes)
# Generate artifact ID
artifact_id = f"{checksum[:12]}_{uuid.uuid4().hex[:8]}"
# Create directory structure
artifact_dir = self.base_dir / metadata.agent_id / metadata.run_id
artifact_dir.mkdir(parents=True, exist_ok=True)
# Write content to file
artifact_path = artifact_dir / f"{artifact_id}.artifact"
artifact_path.write_bytes(content_bytes)
# Write metadata
meta_path = artifact_dir / f"{artifact_id}.meta.json"
meta_data = metadata.to_dict()
meta_data["checksum"] = checksum
meta_data["size_bytes"] = len(content_bytes)
meta_data["mime_type"] = mime_type
meta_data["created_at"] = time.time()
meta_path.write_text(json.dumps(meta_data, indent=2))
# Generate summary (use redacted content if available)
summary_source = content_str if content_str else str(content)
summary = generate_summary(summary_source, max_chars=200)
# Create and return reference
ref = ArtifactRef(
path=str(artifact_path),
summary=summary,
size_bytes=len(content_bytes),
mime_type=mime_type,
checksum=checksum,
created_at=meta_data["created_at"],
artifact_id=artifact_id,
agent_id=metadata.agent_id,
run_id=metadata.run_id,
tool_name=metadata.tool_name,
turn_id=metadata.turn_id,
)
logging.debug(f"Stored artifact {artifact_id} ({len(content_bytes)} bytes) at {artifact_path}")
return ref
def _validate_artifact_path(self, path_or_ref: Union[Path, ArtifactRef]) -> Path:
"""Validate that path is within the artifact store's base directory."""
if isinstance(path_or_ref, ArtifactRef):
path = Path(path_or_ref.path)
else:
path = path_or_ref
resolved = path.expanduser().resolve()
base_resolved = self.base_dir.expanduser().resolve()
# Check if path is within base directory
try:
resolved.relative_to(base_resolved)
except ValueError:
raise PermissionError(f"Artifact path is outside configured store: {path}")
# Validate extension
if resolved.suffix != ".artifact":
raise ValueError(f"Invalid artifact file extension: {path}")
return resolved
def load(self, ref: ArtifactRef) -> Any:
"""
Load full content from an artifact.
Args:
ref: Reference to the artifact
Returns:
The deserialized content
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content_bytes = path.read_bytes()
# Verify checksum if provided
if ref.checksum:
actual_checksum = compute_checksum(content_bytes)
if actual_checksum != ref.checksum:
raise ValueError(f"Checksum mismatch for artifact {ref.artifact_id}")
# Deserialize based on mime type
if ref.mime_type == "application/json":
return json.loads(content_bytes.decode("utf-8"))
else:
return content_bytes.decode("utf-8")
def tail(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the last N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the last N lines
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
content_lines = content.splitlines()
if len(content_lines) <= lines:
return content
return "\n".join(content_lines[-lines:])
def head(self, ref: ArtifactRef, lines: int = 50) -> str:
"""
Get the first N lines of an artifact.
Args:
ref: Reference to the artifact
lines: Number of lines to return
Returns:
String containing the first N lines
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
content_lines = content.splitlines()
if len(content_lines) <= lines:
return content
return "\n".join(content_lines[:lines])
def grep(
self,
ref: ArtifactRef,
pattern: str,
context_lines: int = 2,
max_matches: int = 50,
) -> List[GrepMatch]:
"""
Search for pattern in artifact content.
Args:
ref: Reference to the artifact
pattern: Regex pattern to search for
context_lines: Number of context lines before/after match
max_matches: Maximum number of matches to return
Returns:
List of GrepMatch objects
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
lines = content.splitlines()
matches = []
pattern_re = re.compile(pattern, re.IGNORECASE)
for i, line in enumerate(lines):
if pattern_re.search(line):
# Get context lines
start_idx = max(0, i - context_lines)
end_idx = min(len(lines), i + context_lines + 1)
match = GrepMatch(
line_number=i + 1, # 1-indexed
line_content=line,
context_before=lines[start_idx:i],
context_after=lines[i + 1:end_idx],
)
matches.append(match)
if len(matches) >= max_matches:
break
return matches
def chunk(
self,
ref: ArtifactRef,
start_line: int = 1,
end_line: Optional[int] = None,
) -> str:
"""
Get a chunk of lines from an artifact.
Args:
ref: Reference to the artifact
start_line: Starting line number (1-indexed)
end_line: Ending line number (inclusive), None for end of file
Returns:
String containing the requested lines
"""
path = self._validate_artifact_path(Path(ref.path))
if not path.exists():
raise FileNotFoundError(f"Artifact not found: {ref.path}")
content = path.read_text(encoding="utf-8", errors="replace")
lines = content.splitlines()
# Convert to 0-indexed
start_idx = max(0, start_line - 1)
end_idx = end_line if end_line is None else end_line
return "\n".join(lines[start_idx:end_idx])
def delete(self, ref: ArtifactRef) -> bool:
"""
Delete an artifact.
Args:
ref: Reference to the artifact
Returns:
True if deleted successfully
"""
path = self._validate_artifact_path(Path(ref.path))
meta_path = path.with_suffix(".meta.json")
deleted = False
if path.exists():
path.unlink()
deleted = True
if meta_path.exists():
meta_path.unlink()
return deleted
def list_artifacts(
self,
run_id: Optional[str] = None,
agent_id: Optional[str] = None,
tool_name: Optional[str] = None,
) -> List[ArtifactRef]:
"""
List artifacts matching filters.
Args:
run_id: Filter by run ID
agent_id: Filter by agent ID
tool_name: Filter by tool name
Returns:
List of matching ArtifactRef objects
"""
artifacts = []
# Determine search path based on filters
if agent_id and run_id:
search_path = self.base_dir / agent_id / run_id
if search_path.exists():
search_paths = [search_path]
else:
search_paths = []
elif agent_id:
agent_path = self.base_dir / agent_id
if agent_path.exists():
search_paths = [p for p in agent_path.iterdir() if p.is_dir()]
else:
search_paths = []
else:
# Search all - but respect run_id filter if provided
search_paths = []
for agent_dir in self.base_dir.iterdir():
if agent_dir.is_dir():
for run_dir in agent_dir.iterdir():
if run_dir.is_dir():
# If run_id filter provided, only include matching runs
if run_id is None or run_dir.name == run_id:
search_paths.append(run_dir)
# Search for artifacts
for dir_path in search_paths:
for meta_file in dir_path.glob("*.meta.json"):
try:
meta_data = json.loads(meta_file.read_text())
# Apply tool_name filter if specified
if tool_name and meta_data.get("tool_name") != tool_name:
continue
# Reconstruct artifact path
artifact_file = meta_file.with_suffix("").with_suffix(".artifact")
if not artifact_file.exists():
continue
# Create ArtifactRef
ref = ArtifactRef(
path=str(artifact_file),
summary="", # Not stored in meta, would need to regenerate
size_bytes=meta_data.get("size_bytes", 0),
mime_type=meta_data.get("mime_type", "application/octet-stream"),
checksum=meta_data.get("checksum", ""),
created_at=meta_data.get("created_at", 0),
artifact_id=artifact_file.stem,
agent_id=meta_data.get("agent_id", ""),
run_id=meta_data.get("run_id", ""),
tool_name=meta_data.get("tool_name"),
turn_id=meta_data.get("turn_id", 0),
)
artifacts.append(ref)
except Exception as e:
logging.debug(f"Error loading artifact metadata from {meta_file}: {e}")
# Sort by creation time (newest first)
artifacts.sort(key=lambda x: x.created_at, reverse=True)
return artifacts
def cleanup_old_artifacts(self, days: Optional[int] = None) -> int:
"""
Clean up artifacts older than retention period.
Args:
days: Days to retain (overrides default retention_days)
Returns:
Number of artifacts deleted
"""
days = self.retention_days if days is None else days
cutoff_time = time.time() - (days * 24 * 60 * 60)
deleted_count = 0
for artifact in self.list_artifacts():
if artifact.created_at < cutoff_time:
if self.delete(artifact):
deleted_count += 1
logging.debug(f"Deleted old artifact {artifact.artifact_id}")
return deleted_count
@@ -314,7 +314,8 @@ def read_file(
start_line: Optional[int] = None,
end_line: Optional[int] = None,
context_lines: int = 0,
max_lines: int = 500
max_lines: int = 500,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
"""Read file contents with optional line range.
@@ -328,8 +329,17 @@ def read_file(
Returns:
Dictionary with content, line info, and metadata
"""
filepath = os.path.abspath(filepath)
from praisonaiagents.tools.path_safety import resolve_within_root
safe_path = resolve_within_root(filepath, workspace_root or os.getcwd())
if safe_path is None:
return {
"success": False,
"error": f"Path outside workspace: {filepath}",
"path": filepath,
}
filepath = safe_path
if not os.path.exists(filepath):
return {
"success": False,
@@ -18,6 +18,8 @@ from .protocols import (
GatewayEvent,
GatewayMessage,
EventType,
OperatorScope,
GatewayCloseCode,
# Push protocols and dataclasses
PushChannelProtocol,
PresenceProtocol,
@@ -28,6 +30,28 @@ from .protocols import (
# Home channel and delivery protocols
HomeChannelRegistryProtocol,
DeliveryResolverProtocol,
# Agent-facing outbound messaging
OutboundMessengerProtocol,
DeliveryResult,
TargetInfo,
# Inbound route binding (Issue #2225)
RouteBinding,
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,
@@ -37,6 +61,13 @@ from .protocols import (
GapInfo,
ResumeSnapshot,
)
from .hooks import (
HookAction,
HookConfig,
InboundTriggerProtocol,
render_template,
compute_idempotency_key,
)
from .config import (
GatewayConfig,
SessionConfig,
@@ -97,6 +128,8 @@ __all__ = [
"GatewayEvent",
"GatewayMessage",
"EventType",
"OperatorScope",
"GatewayCloseCode",
# Push protocols (always available)
"PushChannelProtocol",
"PresenceProtocol",
@@ -107,6 +140,28 @@ __all__ = [
# Home channel and delivery protocols
"HomeChannelRegistryProtocol",
"DeliveryResolverProtocol",
# Agent-facing outbound messaging
"OutboundMessengerProtocol",
"DeliveryResult",
"TargetInfo",
# Inbound route binding (Issue #2225)
"RouteBinding",
"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",
@@ -115,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,7 +229,15 @@ 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
successfully authenticated client is granted all scopes — identical
to the previous binary behaviour. Single-operator setups are
unaffected.
"""
host: str = "127.0.0.1"
@@ -246,7 +254,9 @@ 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
def __post_init__(self) -> None:
"""Post-initialization to set bind_host from host if not specified and validate values."""
@@ -256,13 +266,68 @@ 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:
raise ValueError("heartbeat_interval must be >= 0")
if self.reconnect_timeout < 0:
raise ValueError("reconnect_timeout must be >= 0")
@property
def has_scope_policy(self) -> bool:
"""Whether an operator scope policy is configured.
When False (the default), every authenticated client is granted all
scopes — preserving the original binary auth behaviour.
"""
return bool(self.auth_scopes)
def resolve_scopes(self, token: Optional[str]) -> List[str]:
"""Resolve the operator scopes granted to ``token``.
Backward-compatible contract:
* No scope policy configured -> all scopes (today's behaviour).
* Policy configured + token listed -> that token's scopes.
* Policy configured + token absent/None -> no scopes (deny).
Scope names are returned as plain strings so callers in the wrapper
can compare against ``OperatorScope`` values without importing them.
Unknown scope names (e.g. typos in ``gateway.yaml``) are dropped and a
warning is logged so misconfiguration surfaces early instead of
silently denying all access.
"""
from .protocols import OperatorScope
valid_scopes = {s.value for s in OperatorScope.all()}
all_scopes = list(valid_scopes)
if not self.auth_scopes:
return all_scopes
if token is None:
return []
granted = self.auth_scopes.get(token)
if granted is None:
return []
resolved: List[str] = []
unknown: List[str] = []
for s in granted:
name = str(s)
if name in valid_scopes:
resolved.append(name)
else:
unknown.append(name)
if unknown:
import logging
logging.getLogger(__name__).warning(
"Ignoring unknown operator scope(s) %s; valid scopes are %s",
unknown,
sorted(valid_scopes),
)
return resolved
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary (hides sensitive data)."""
return {
@@ -278,7 +343,9 @@ 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,
}
@property
@@ -371,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":
@@ -416,6 +484,29 @@ class MultiChannelGatewayConfig:
metadata=sc_data.get("metadata", {}),
)
# Parse optional operator scope policy. Two supported shapes:
# gateway:
# auth:
# tokens:
# - token: "${VIEWER_TOKEN}"
# scopes: [read]
# or a flat mapping:
# gateway:
# auth_scopes:
# "${VIEWER_TOKEN}": [read]
auth_scopes: Optional[Dict[str, List[str]]] = None
auth_section = gw_data.get("auth")
if isinstance(auth_section, dict) and isinstance(auth_section.get("tokens"), list):
auth_scopes = {}
for entry in auth_section["tokens"]:
if isinstance(entry, dict) and entry.get("token"):
auth_scopes[str(entry["token"])] = list(entry.get("scopes", []))
elif isinstance(gw_data.get("auth_scopes"), dict):
auth_scopes = {
str(tok): list(scopes)
for tok, scopes in gw_data["auth_scopes"].items()
}
gateway_config = GatewayConfig(
host=gw_data.get("host", "127.0.0.1"),
port=gw_data.get("port", 8765),
@@ -429,7 +520,9 @@ 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,
)
# Parse agents section (pass through as dicts)
@@ -451,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]:
@@ -465,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,
@@ -47,6 +48,49 @@ class ConnectErrorCode(str, Enum):
PROTOCOL_UNSUPPORTED = "protocol_unsupported"
PAIRING_REQUIRED = "pairing_required"
AGENT_NOT_FOUND = "agent_not_found"
RATE_LIMITED = "rate_limited"
ORIGIN_NOT_ALLOWED = "origin_not_allowed"
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.
Clients branch on ``(code, next_step)`` to implement deterministic,
uniform reconnect behaviour without parsing free-text reasons:
REAUTHENTICATE: Obtain fresh credentials, then reconnect.
REPAIR: Re-run the device pairing flow, then reconnect.
UPGRADE_CLIENT: The client protocol is too old; update the client.
DOWNGRADE_CLIENT: The client protocol is newer than the server
supports; use an older client or wait for a server upgrade.
WAIT_THEN_RETRY: Back off (see ``retry_after_seconds``) then reconnect.
DO_NOT_RETRY: The rejection is terminal; reconnecting will not help.
"""
REAUTHENTICATE = "reauthenticate"
REPAIR = "repair"
UPGRADE_CLIENT = "upgrade_client"
DOWNGRADE_CLIENT = "downgrade_client"
WAIT_THEN_RETRY = "wait_then_retry"
DO_NOT_RETRY = "do_not_retry"
class EventType(str, Enum):
@@ -109,6 +153,34 @@ class EventType(str, Enum):
HELLO_ERROR = "hello_error"
class OperatorScope(str, Enum):
"""Operator authorisation scopes for multi-operator Gateway access control.
These describe *what an authenticated operator is allowed to do*, layered on
top of (and orthogonal to) authentication. The vocabulary lives in the core
SDK so that protocol clients and the wrapper Gateway share the same names;
enforcement happens in the wrapper where requests are dispatched.
Scopes:
READ: View dashboard / receive session transcripts and status.
WRITE: Send messages as the agent.
APPROVALS: Resolve tool-execution approvals (security-sensitive).
PAIRING: Approve / revoke device pairing.
ADMIN: Channel control (pause / resume / reconnect) and management.
"""
READ = "read"
WRITE = "write"
APPROVALS = "approvals"
PAIRING = "pairing"
ADMIN = "admin"
@classmethod
def all(cls) -> "List[OperatorScope]":
"""Return every scope (granted by default when no policy is configured)."""
return list(cls)
@dataclass
class HelloParams:
"""Parameters for initiating a versioned handshake.
@@ -151,17 +223,56 @@ class HelloResult:
@dataclass
class HelloError:
"""Error response for failed handshake.
"""Structured connect-rejection envelope.
Emitted from *every* connection rejection path — both pre-handshake
transport checks (auth/origin/rate-limit) and handshake negotiation —
so clients can implement deterministic reconnect logic by branching on
``(code, next_step)`` instead of string-matching close reasons.
Attributes:
code: Structured error code
message: Human-readable error message
next_action: Suggested next action (e.g., "upgrade_client", "pair_device")
code: Structured, machine-readable error code.
message: Human-readable error message (display only).
next_step: Machine-readable recovery hint telling the client what to
do next (re-authenticate, re-pair, upgrade, wait then retry, ...).
retry_after_seconds: Optional backoff hint (rate limiting / transient
unavailability). Only meaningful with ``WAIT_THEN_RETRY``.
next_action: Deprecated free-text hint, retained for backward
compatibility. Prefer ``next_step``.
"""
code: ConnectErrorCode
message: str
next_step: Optional[ConnectRecoveryStep] = None
retry_after_seconds: Optional[int] = None
next_action: Optional[str] = None
def to_dict(self) -> Dict[str, Any]:
"""Serialize to a ``hello_error`` wire frame.
The ``next`` key is preserved for backward compatibility with existing
clients; ``next_step`` and ``retry_after_seconds`` are the structured
recovery fields new clients should branch on.
"""
frame: Dict[str, Any] = {
"type": "hello_error",
"code": self.code.value,
"message": self.message,
}
if self.next_step is not None:
frame["next_step"] = self.next_step.value
if self.retry_after_seconds is not None:
frame["retry_after_seconds"] = self.retry_after_seconds
# Backward-compatible legacy field: fall back to next_step's value.
# Omitted entirely when there is no recovery hint so clients using
# strict schema validation or ``if frame["next"]`` are not tripped by
# an explicit ``null``.
legacy_next = self.next_action
if legacy_next is None and self.next_step is not None:
legacy_next = self.next_step.value
if legacy_next is not None:
frame["next"] = legacy_next
return frame
@dataclass
class GatewayEvent:
@@ -175,6 +286,19 @@ class GatewayEvent:
source: Source identifier (agent_id, client_id, etc.)
target: Target identifier (optional, for directed events)
sequence: Monotonic sequence number for gap detection (optional)
Wire Protocol Extensions:
When events are sent over the gateway, additional fields are added:
- seq: Top-level monotonic sequence number for gap detection
- cursor: Event cursor position (also stored in data['cursor'])
Resume Protocol:
The 'joined' acknowledgment includes:
- cursor: Current head cursor position
- oldest_cursor: Oldest event still in buffer
- resync_required: True if requested 'since' is below oldest_cursor
When resync_required=true, a 'snapshot' message follows with full state.
"""
type: Union[EventType, str]
@@ -904,6 +1028,352 @@ 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)
# ---------------------------------------------------------------------------
@dataclass
class RouteBinding:
"""A single declarative inbound-routing rule.
A binding maps a set of optional inbound conditions to a handling agent.
Bindings are evaluated most-specific-first so operators get deterministic,
debuggable routing across a fleet of agents behind one gateway.
All condition fields are optional; ``None`` means "do not constrain on this
field". A binding matches a set of :class:`RouteFacts` only when *every*
non-``None`` condition equals the corresponding fact.
Attributes:
agent: The agent id to route to when this binding matches.
chat_type: Chat type ("dm" | "group" | "channel").
peer: Sender/user id (most specific).
role: Role / guild-role membership of the sender.
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
chat_type: Optional[str] = None
peer: Optional[str] = None
role: Optional[str] = None
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.
_SPECIFICITY = {
"peer": 16,
"role": 8,
"channel_id": 8,
"account": 4,
"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."""
score = 0
for field_name, weight in self._SPECIFICITY.items():
if getattr(self, field_name) is not None:
score += weight
return score
def matches(self, facts: "RouteFacts") -> bool:
"""Return True if every constrained condition equals the facts."""
if self.peer is not None and str(self.peer) != str(facts.peer):
return False
if self.channel_id is not None and str(self.channel_id) != str(facts.channel_id):
return False
if self.account is not None and str(self.account) != str(facts.account):
return False
if self.chat_type is not None and self.chat_type != facts.chat_type:
return False
if self.role is not None:
expected_role = str(self.role)
if expected_role not in [str(role) for role in (facts.roles or [])]:
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.
Accepts ``agent`` (required). Unknown keys are ignored so the shape
can evolve without breaking older configs.
"""
return cls(
agent=data.get("agent", "default"),
chat_type=data.get("chat_type"),
peer=_as_opt_str(data.get("peer")),
role=data.get("role"),
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")),
)
@dataclass
class RouteFacts:
"""Inbound facts extracted from a message, used to resolve a binding.
Attributes:
chat_type: Normalised chat type ("dm" | "group" | "channel" | "default").
peer: Sender/user id.
roles: Roles/guild-role memberships of the sender.
channel_id: The chat/channel id the message arrived in.
account: The receiving bot account (multi-account channels).
"""
chat_type: str = "default"
peer: Optional[str] = None
roles: List[str] = field(default_factory=list)
channel_id: Optional[str] = None
account: Optional[str] = None
@dataclass
class RouteMatch:
"""Result of resolving a route.
Attributes:
agent: The resolved agent id.
binding: The binding that matched, or ``None`` when the fallback was used.
reason: Short human-readable explanation for logging/debugging.
"""
agent: str
binding: Optional[RouteBinding] = None
reason: str = ""
def _as_opt_str(value: Any) -> Optional[str]:
"""Coerce a value to a string, preserving ``None``."""
if value is None:
return None
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,
default_agent: str = "default",
) -> RouteMatch:
"""Resolve the handling agent from priority-ordered bindings.
Bindings are evaluated most-specific-first: the matching binding with the
highest ``priority`` wins; ties are broken by specificity (exact peer →
role/channel → account → chat-type), then by declaration order.
Args:
bindings: Candidate route bindings (any order).
facts: Inbound facts extracted from the message.
default_agent: Agent id to fall back to when nothing matches.
Returns:
A :class:`RouteMatch` with the selected agent and matched binding.
"""
best: Optional[RouteBinding] = None
best_key: tuple = ()
for idx, binding in enumerate(bindings):
if not binding.matches(facts):
continue
# Higher priority wins, then higher specificity, then earlier order.
key = (binding.priority, binding.specificity, -idx)
if best is None or key > best_key:
best = binding
best_key = key
if best is not None:
return RouteMatch(
agent=best.agent,
binding=best,
reason=(
f"matched binding (priority={best.priority}, "
f"specificity={best.specificity})"
),
)
return RouteMatch(
agent=default_agent,
binding=None,
reason="no binding matched; using default",
)
# ---------------------------------------------------------------------------
# Auth Mode protocols and helpers (bind-aware authentication posture)
# ---------------------------------------------------------------------------
@@ -1201,6 +1671,366 @@ class DeliveryResolverProtocol(Protocol):
...
# ---------------------------------------------------------------------------
# Agent-facing outbound messaging (Issue #2183)
# ---------------------------------------------------------------------------
@dataclass
class DeliveryResult:
"""Outcome of an agent-initiated proactive send.
Attributes:
ok: Whether the send was accepted for delivery.
target: The resolved target the message was routed to.
summary: Human-readable summary suitable for returning to the model.
detail: Optional extra information (error text, message id, etc.).
"""
ok: bool
target: str = ""
summary: str = ""
detail: Optional[str] = None
def as_dict(self) -> Dict[str, Any]:
"""Convert to a serializable dictionary."""
return {
"ok": self.ok,
"target": self.target,
"summary": self.summary,
"detail": self.detail,
}
@dataclass
class TargetInfo:
"""A reachable delivery target the agent can address.
Attributes:
target: The token to pass to ``send`` (e.g. "origin", "slack:#ops").
platform: Platform name (e.g. "telegram", "slack").
kind: Target kind ("origin", "home", or "alias").
label: Friendly label for display to the model/user.
"""
target: str
platform: str = ""
kind: str = "alias"
label: str = ""
def as_dict(self) -> Dict[str, Any]:
"""Convert to a serializable dictionary."""
return {
"target": self.target,
"platform": self.platform,
"kind": self.kind,
"label": self.label,
}
@runtime_checkable
class OutboundMessengerProtocol(Protocol):
"""Protocol for agent-facing proactive message delivery.
A concrete implementation is provided by the running gateway/bot (in the
praisonai wrapper) and registered into the per-turn context so the
built-in ``send_message`` tool can resolve it. It bridges to the existing
delivery stack (DeliveryRouter, HomeChannelRegistry, outbox, mirroring).
Example usage (implementation in praisonai wrapper)::
messenger = BotOutboundMessenger(bot, resolver, router)
token = register_outbound_messenger(messenger)
try:
... # agent runs; send_message tool resolves the messenger
finally:
clear_outbound_messenger(token)
"""
async def send(
self,
target: str,
text: str,
*,
media: Optional[List[str]] = None,
) -> "DeliveryResult":
"""Deliver a message to a symbolic target.
Args:
target: Symbolic target token ("origin", "<platform>",
"<platform>:<chat_id>[:<thread_id>]", or a friendly alias).
text: The message text to send.
media: Optional list of local file paths to attach.
Returns:
A :class:`DeliveryResult` describing the outcome.
"""
...
def list_targets(self) -> List["TargetInfo"]:
"""List the targets currently reachable from this runtime."""
...
# ---------------------------------------------------------------------------
# Outbound send-policy guard (Issue #2226)
#
# ``send_message`` lets the model choose where to deliver. Because the target
# is model-controlled, poisoned inbound content (prompt injection) can steer an
# agent into delivering to a channel the operator never intended. The router
# only fails on *unresolvable* targets — a reachability check, not an
# authorisation one. This policy seam sits in core, *before* dispatch, so every
# messenger implementation is constrained, not just one adapter. Absent a
# policy, today's behaviour is preserved (allow-all).
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class SendDecision:
"""Closed decision shape for an outbound send-policy evaluation.
Attributes:
allow: Whether the send to the requested target is permitted.
reason: Optional model-readable explanation (used in the
:class:`DeliveryResult` detail when denied).
"""
allow: bool
reason: str = ""
@runtime_checkable
class SendPolicyProtocol(Protocol):
"""Protocol for authorising agent-initiated proactive sends.
Implementations decide whether an agent may deliver to a model-chosen
``target``. The hook is evaluated inside the ``send_message`` path before
the messenger dispatches, so a denied send returns a clean, model-readable
:class:`DeliveryResult` (``ok=False``) rather than delivering.
A concrete, config-driven implementation (:class:`SendPolicy`) is provided
for the common allow/deny case; richer back-ends may live in plugins.
"""
def evaluate(
self,
target: str,
*,
agent_id: str = "",
session_id: str = "",
origin: Optional[str] = None,
) -> SendDecision:
"""Return a :class:`SendDecision` for the requested ``target``."""
...
class SendPolicy:
"""A lightweight allow/deny send-policy with an optional default-deny posture.
This is the config-driven default referenced by ``send_policy`` blocks in
``gateway.yaml`` and the ``Bot(..., send_policy=...)`` Python surface. It is
intentionally minimal (no heavy dependencies) and lives in core so the
built-in ``send_message`` path is always interceptable.
Matching is exact against the symbolic target token (e.g. ``"origin"``,
``"slack:#ops"``, or a friendly alias). With ``default="deny"`` only listed
targets are permitted; with ``default="allow"`` all targets are permitted
except those in ``deny``.
Example::
# default-deny: only the conversation origin and an ops alias allowed
SendPolicy(default="deny", allow=["origin", "ops-alerts"])
# default-allow: everything permitted except an exec channel
SendPolicy(default="allow", deny=["slack:#exec"])
"""
def __init__(
self,
default: str = "allow",
allow: Optional[List[str]] = None,
deny: Optional[List[str]] = None,
):
default = (default or "allow").lower()
if default not in ("allow", "deny"):
raise ValueError(
f"send_policy default must be 'allow' or 'deny', got {default!r}"
)
self.default = default
self.allow = list(allow or [])
self.deny = list(deny or [])
def evaluate(
self,
target: str,
*,
agent_id: str = "",
session_id: str = "",
origin: Optional[str] = None,
) -> SendDecision:
if target in self.deny:
return SendDecision(
allow=False,
reason=f"target '{target}' is denied by send_policy",
)
if self.default == "deny":
if target in self.allow:
return SendDecision(allow=True)
return SendDecision(
allow=False,
reason=f"target '{target}' is not permitted by send_policy",
)
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)
# ---------------------------------------------------------------------------
@@ -75,6 +75,10 @@ __all__ = [
"MessageReceivedInput",
"MessageSendingInput",
"MessageSentInput",
# Gateway & schedule lifecycle event inputs
"GatewayStartInput",
"GatewayStopInput",
"ScheduleTriggerInput",
# Middleware types
"InvocationContext",
"ModelRequest",
@@ -137,6 +141,9 @@ _LAZY_GROUPS = {
'MessageReceivedInput': ('praisonaiagents.hooks.events', 'MessageReceivedInput'),
'MessageSendingInput': ('praisonaiagents.hooks.events', 'MessageSendingInput'),
'MessageSentInput': ('praisonaiagents.hooks.events', 'MessageSentInput'),
'GatewayStartInput': ('praisonaiagents.hooks.events', 'GatewayStartInput'),
'GatewayStopInput': ('praisonaiagents.hooks.events', 'GatewayStopInput'),
'ScheduleTriggerInput': ('praisonaiagents.hooks.events', 'ScheduleTriggerInput'),
},
'middleware_types': {
'InvocationContext': ('praisonaiagents.hooks.middleware', 'InvocationContext'),
@@ -276,3 +276,52 @@ class MessageSentInput(HookInput):
"message_id": self.message_id,
})
return base
@dataclass
class GatewayStartInput(HookInput):
"""Input for GATEWAY_START hooks (gateway/BotOS started)."""
platforms: List[str] = field(default_factory=list)
bot_count: int = 0
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"platforms": self.platforms,
"bot_count": self.bot_count,
})
return base
@dataclass
class GatewayStopInput(HookInput):
"""Input for GATEWAY_STOP hooks (gateway/BotOS stopped)."""
platforms: List[str] = field(default_factory=list)
bot_count: int = 0
reason: str = "stop"
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"platforms": self.platforms,
"bot_count": self.bot_count,
"reason": self.reason,
})
return base
@dataclass
class ScheduleTriggerInput(HookInput):
"""Input for SCHEDULE_TRIGGER hooks (a scheduled job fired)."""
job_name: str = ""
job_id: str = ""
message: str = ""
def to_dict(self) -> Dict[str, Any]:
base = super().to_dict()
base.update({
"job_name": self.job_name,
"job_id": self.job_id,
"message": self.message[:500] if self.message else "",
})
return base
@@ -15,32 +15,21 @@ import os
import logging
from typing import Any, Dict, Optional
# Module-level cache for litellm (lazy loaded)
_litellm_module = None
_litellm_import_attempted = False
from ._litellm_loader import get_litellm
def _get_litellm():
"""
Lazy import litellm module.
Returns litellm module if available, None otherwise.
Caches the result to avoid repeated import attempts.
"""
global _litellm_module, _litellm_import_attempted
if _litellm_import_attempted:
return _litellm_module
_litellm_import_attempted = True
try:
import litellm
_litellm_module = litellm
return litellm
except ImportError:
logging.debug("litellm not available for cost calculation")
return None
return get_litellm(
on_missing=lambda: logging.debug(
"litellm not available for cost calculation"
)
)
def is_cost_tracking_enabled() -> bool:
@@ -0,0 +1,45 @@
"""
Shared lazy-import helper for the optional litellm dependency.
This centralizes the "import litellm at most once, then cache the result
(None if unavailable)" pattern used across the llm/ package so the lazy-loading
behaviour is expressed in a single place.
litellm remains an optional dependency and is never imported at module level.
"""
# Module-level cache for litellm (lazy loaded)
_litellm_module = None
_litellm_import_attempted = False
def get_litellm(on_missing=None):
"""
Lazy import litellm module.
Returns the litellm module if available, None otherwise.
Caches the result to avoid repeated import attempts.
Args:
on_missing: Optional callable invoked (with no arguments) when litellm
cannot be imported. Useful for logging. It is invoked on every call
that finds litellm unavailable (including cached failures), so each
caller's diagnostics are honoured regardless of call ordering.
Returns:
The litellm module if importable, otherwise None.
"""
global _litellm_module, _litellm_import_attempted
if not _litellm_import_attempted:
_litellm_import_attempted = True
try:
import litellm
_litellm_module = litellm
except ImportError:
_litellm_module = None
if _litellm_module is None and on_missing is not None:
on_missing()
return _litellm_module
@@ -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.
@@ -1897,42 +1857,18 @@ Now provide your final answer using this result. Summarize the information natur
def _fix_array_schemas(self, schema: Dict) -> Dict:
"""
Recursively fix array schemas by adding missing 'items' attribute.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Thin wrapper around the shared ``fix_array_schemas`` helper, kept for
backward compatibility with any external subclassing.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas in properties
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
if isinstance(prop_schema, dict):
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
else:
fixed_properties[prop_name] = prop_schema
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
from .schema_utils import fix_array_schemas
return fix_array_schemas(schema)
def _get_tools_cache_key(self, tools):
"""Generate a cache key for tools list."""
@@ -5598,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:
@@ -19,32 +19,7 @@ Sources:
- https://docs.litellm.ai/docs/completion/prompt_caching
"""
# Module-level cache for litellm (lazy loaded)
_litellm_module = None
_litellm_import_attempted = False
def _get_litellm():
"""
Lazy import litellm module.
Returns litellm module if available, None otherwise.
Caches the result to avoid repeated import attempts.
"""
global _litellm_module, _litellm_import_attempted
if _litellm_import_attempted:
return _litellm_module
_litellm_import_attempted = True
try:
import litellm
_litellm_module = litellm
return litellm
except ImportError:
return None
from ._litellm_loader import get_litellm as _get_litellm
def supports_structured_outputs(model_name: str) -> bool:
@@ -385,42 +385,18 @@ class OpenAIClient:
def _fix_array_schemas(self, schema: Dict) -> Dict:
"""
Recursively fix array schemas by adding missing 'items' attribute.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Thin wrapper around the shared ``fix_array_schemas`` helper, kept for
backward compatibility with any external subclassing.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas in properties
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
if isinstance(prop_schema, dict):
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
else:
fixed_properties[prop_name] = prop_schema
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
from .schema_utils import fix_array_schemas
return fix_array_schemas(schema)
def _get_tools_cache_key(self, tools: List[Any]) -> str:
"""Generate a cache key for tools."""
@@ -0,0 +1,63 @@
"""Shared schema utilities for LLM tool calling.
This module provides helpers for normalising tool/function schemas so they are
compatible with OpenAI-style function calling across the LLM, OpenAI client and
MCP code paths.
"""
from typing import Any, Dict
def fix_array_schemas(schema: Dict[str, Any]) -> Dict[str, Any]:
"""
Recursively fix array schemas by adding missing 'items' attribute.
This is the single canonical implementation shared by the LLM, OpenAI client
and MCP code paths. It ensures compatibility with OpenAI's function calling
format which requires array types to specify the type of items they contain,
and normalises nested ``properties``, ``items``, ``additionalProperties`` and
``anyOf``/``oneOf``/``allOf`` schema constructs.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema (the original is not mutated)
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas in properties
if "properties" in fixed_schema and isinstance(fixed_schema["properties"], dict):
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
if isinstance(prop_schema, dict):
fixed_properties[prop_name] = fix_array_schemas(prop_schema)
else:
fixed_properties[prop_name] = prop_schema
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema and isinstance(fixed_schema["items"], dict):
fixed_schema["items"] = fix_array_schemas(fixed_schema["items"])
# Fix additionalProperties if it's a schema
if isinstance(fixed_schema.get("additionalProperties"), dict):
fixed_schema["additionalProperties"] = fix_array_schemas(
fixed_schema["additionalProperties"]
)
# Fix anyOf/oneOf/allOf schemas
for key in ("anyOf", "oneOf", "allOf"):
if key in fixed_schema and isinstance(fixed_schema[key], list):
fixed_schema[key] = [fix_array_schemas(s) for s in fixed_schema[key]]
return fixed_schema
@@ -632,39 +632,18 @@ class MCP:
def _fix_array_schemas(self, schema):
"""
Fix array schemas by adding missing 'items' attribute required by OpenAI.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Thin wrapper around the shared ``fix_array_schemas`` helper, kept for
backward compatibility with any external subclassing.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas
if "properties" in fixed_schema:
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
fixed_properties[prop_name] = self._fix_array_schemas(prop_schema)
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema:
fixed_schema["items"] = self._fix_array_schemas(fixed_schema["items"])
return fixed_schema
from ..llm.schema_utils import fix_array_schemas
return fix_array_schemas(schema)
def to_openai_tool(self):
"""Convert the MCP tool to an OpenAI-compatible tool definition.
@@ -16,56 +16,26 @@ from typing import Any, Dict, Optional
def fix_array_schemas(schema: Any) -> Any:
"""
Fix array schemas by adding missing 'items' attribute required by OpenAI.
This ensures compatibility with OpenAI's function calling format which
requires array types to specify the type of items they contain.
Thin wrapper around the single canonical ``fix_array_schemas`` helper in
``praisonaiagents.llm.schema_utils``. Kept here for backward compatibility
with the MCP transports (``mcp_sse``, ``mcp_http_stream``, ``mcp_websocket``)
that import this symbol directly.
Args:
schema: The schema dictionary to fix
Returns:
dict: The fixed schema with 'items' added to array types
Example:
>>> schema = {"type": "array"}
>>> fixed = fix_array_schemas(schema)
>>> fixed
{'type': 'array', 'items': {'type': 'string'}}
"""
if not isinstance(schema, dict):
return schema
# Create a copy to avoid modifying the original
fixed_schema = schema.copy()
# Fix array types at the current level
if fixed_schema.get("type") == "array" and "items" not in fixed_schema:
# Add a default items schema for arrays without it
fixed_schema["items"] = {"type": "string"}
# Recursively fix nested schemas
if "properties" in fixed_schema:
fixed_properties = {}
for prop_name, prop_schema in fixed_schema["properties"].items():
fixed_properties[prop_name] = fix_array_schemas(prop_schema)
fixed_schema["properties"] = fixed_properties
# Fix items schema if it exists
if "items" in fixed_schema:
fixed_schema["items"] = fix_array_schemas(fixed_schema["items"])
# Fix additionalProperties if it's a schema
if isinstance(fixed_schema.get("additionalProperties"), dict):
fixed_schema["additionalProperties"] = fix_array_schemas(
fixed_schema["additionalProperties"]
)
# Fix anyOf/oneOf/allOf schemas
for key in ("anyOf", "oneOf", "allOf"):
if key in fixed_schema and isinstance(fixed_schema[key], list):
fixed_schema[key] = [fix_array_schemas(s) for s in fixed_schema[key]]
return fixed_schema
from ..llm.schema_utils import fix_array_schemas as _fix_array_schemas
return _fix_array_schemas(schema)
class ThreadLocalEventLoop:
@@ -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):
@@ -693,6 +693,30 @@ class Memory(SearchMixin, MemoryCoreMixin):
return []
else:
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory. This avoids the legacy short_mem schema mismatch
# where the adapter creates short_term_memory but legacy queries
# short_mem (which is never created by the adapter).
if getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
try:
adapter_results = self.memory_adapter.search_short_term(query, limit=limit, **kwargs)
if min_quality > 0:
adapter_results = [
r for r in adapter_results
if r.get("metadata", {}).get("quality", 0.0) >= min_quality
]
if relevance_cutoff > 0:
adapter_results = [r for r in adapter_results if r.get("score", 1.0) >= relevance_cutoff]
final_results = adapter_results[:limit]
top_score = final_results[0].get("score") if final_results else None
self._emit_memory_event("search", "short_term", query=query,
result_count=len(final_results), top_score=top_score)
return final_results
except Exception as e:
self._log_verbose(f"Adapter search_short_term failed, using legacy fallback: {e}", logging.WARNING)
# Avoid re-triggering the legacy short_mem schema mismatch.
return []
# Local fallback
conn = self._get_stm_conn()
c = conn.cursor()
@@ -770,8 +794,25 @@ class Memory(SearchMixin, MemoryCoreMixin):
ident = str(time.time_ns())
created = time.time()
# Protocol-driven storage: Try adapter first only when the provider has
# fallen back to sqlite/in_memory. This keeps storage consistent with
# search (which delegates to the adapter for the same providers) and
# avoids the legacy long_mem schema mismatch. Other providers (chroma,
# mem0, mongodb) keep their existing dedicated write paths below so the
# embedding model and vector space stay consistent with search.
adapter_success = False
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
and getattr(self, "memory_adapter", None)):
try:
result_id = self.memory_adapter.store_long_term(text, metadata=metadata)
logger.info(f"Successfully stored via memory adapter with ID: {result_id}")
adapter_success = True
ident = result_id # Use adapter-provided ID (mirrors store_short_term)
except Exception as e:
logger.warning(f"Failed to store via memory adapter, falling back to direct storage: {e}")
# Store in MongoDB if enabled (first priority)
if self.use_mongodb and hasattr(self, "mongo_long_term"):
if not adapter_success and self.use_mongodb and hasattr(self, "mongo_long_term"):
try:
doc = {
"_id": ident,
@@ -793,24 +834,36 @@ class Memory(SearchMixin, MemoryCoreMixin):
logger.error(f"Failed to store in MongoDB long-term memory: {e}")
# Continue to SQLite fallback
# Store in SQLite (with write lock for concurrency safety)
try:
conn = self._get_ltm_conn()
with self._write_lock: # Serialize write operations
conn.execute(
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
(ident, text, json.dumps(metadata), created)
)
conn.commit()
logger.info(f"Successfully stored in SQLite with ID: {ident}")
except Exception as e:
logger.error(f"Error storing in SQLite: {e}")
if not (self.use_mongodb and hasattr(self, "mongo_long_term")):
# Only raise if MongoDB is not available as fallback
return
# Store in SQLite (with write lock for concurrency safety).
# Skip the legacy long_mem schema for adapter-driven sqlite/in_memory
# providers: those use the adapter's long_term_memory table and the
# legacy long_mem table is never created, which would silently drop
# the write. Surface the adapter failure instead.
if not adapter_success and getattr(self, "provider", None) in ("sqlite", "in_memory") \
and getattr(self, "memory_adapter", None):
raise RuntimeError(
"Long-term store failed via adapter for sqlite/in_memory provider; "
"the legacy long_mem table is not schema-compatible."
)
if not adapter_success:
try:
conn = self._get_ltm_conn()
with self._write_lock: # Serialize write operations
conn.execute(
"INSERT INTO long_mem (id, content, meta, created_at) VALUES (?,?,?,?)",
(ident, text, json.dumps(metadata), created)
)
conn.commit()
logger.info(f"Successfully stored in SQLite with ID: {ident}")
except Exception as e:
logger.error(f"Error storing in SQLite: {e}")
if not (self.use_mongodb and hasattr(self, "mongo_long_term")):
# Only raise if MongoDB is not available as fallback
raise
# Store in vector database if enabled
if self.use_rag and hasattr(self, "chroma_col"):
if not adapter_success and self.use_rag and hasattr(self, "chroma_col"):
try:
from praisonaiagents.embedding import embedding as get_embedding
@@ -841,7 +894,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
except Exception as e:
logger.error(f"Error storing in ChromaDB: {e}")
elif self.use_mem0 and hasattr(self, "mem0_client"):
elif not adapter_success and self.use_mem0 and hasattr(self, "mem0_client"):
try:
self.mem0_client.add(text, metadata=metadata)
logger.info("Successfully stored in Mem0")
@@ -980,6 +1033,29 @@ class Memory(SearchMixin, MemoryCoreMixin):
except Exception as e:
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
# Delegate to the active adapter when provider falls back to
# sqlite/in_memory. This avoids the legacy long_mem schema mismatch
# where the adapter creates long_term_memory but legacy queries long_mem.
if not found and getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
try:
adapter_results = self.memory_adapter.search_long_term(query, limit=limit, **kwargs)
if min_quality > 0:
adapter_results = [
r for r in adapter_results
if r.get("metadata", {}).get("quality", 0.0) >= min_quality
]
if relevance_cutoff > 0:
adapter_results = [r for r in adapter_results if r.get("score", 1.0) >= relevance_cutoff]
final_results = adapter_results[:limit]
top_score = final_results[0].get("score") if final_results else None
self._emit_memory_event("search", "long_term", query=query,
result_count=len(final_results), top_score=top_score)
return final_results
except Exception as e:
self._log_verbose(f"Adapter search_long_term failed, using legacy fallback: {e}", logging.WARNING)
# Avoid re-triggering the legacy long_mem schema mismatch.
return []
# Always try SQLite as fallback or additional source
conn = self._get_ltm_conn()
c = conn.cursor()
@@ -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
@@ -0,0 +1,298 @@
"""
Shell command parser for command-aware permission matching.
Decomposes a shell command string into its constituent simple-commands,
handling compound operators (``&&``, ``||``, ``;``, ``|``), subshells and
command substitution (``$(...)`` / backticks). Each resulting operation
exposes its executable, arguments and redirection targets so the permission
engine can evaluate file-mutating operations regardless of where they appear
in a compound statement.
Design goals:
- Dependency-light: stdlib ``shlex`` plus a small hand-rolled splitter.
- Best-effort and conservative: on any parse failure we fall back to treating
the whole command as a single operation (today's behaviour) so a rule is
never silently weakened.
"""
import shlex
from dataclasses import dataclass, field
from typing import List
# Operators that separate simple-commands within a compound command.
_SEPARATORS = ("&&", "||", ";", "|", "&", "\n")
# Redirection operators that truncate/overwrite or append to a file.
# These produce an additional ``write:<path>`` sub-target.
_WRITE_REDIRECTS = (">", ">>", ">|", "&>", "&>>")
@dataclass
class ShellOp:
"""A single simple-command extracted from a shell command line.
Attributes:
executable: The command name (e.g. ``rm``), or ``""`` if unknown.
args: Positional/flag arguments following the executable.
write_targets: Paths that are written/truncated via redirection.
"""
executable: str = ""
args: List[str] = field(default_factory=list)
write_targets: List[str] = field(default_factory=list)
@property
def command_string(self) -> str:
"""Reconstruct an ``<exe> <args>`` string for glob matching."""
parts = [self.executable, *self.args]
return " ".join(p for p in parts if p)
def _extract_substitutions(token: str) -> List[str]:
"""Extract inner commands from ``$(...)`` and backtick substitutions.
Returns a list of inner command strings (without the surrounding syntax).
Best-effort; handles simple, non-nested cases which cover the common
evasion vectors.
"""
inner: List[str] = []
# Mask single-quoted spans so substitutions inside them are treated as the
# literals the shell would see (e.g. ``echo '$(rm -rf x)'`` is harmless).
# Double quotes do *not* suppress substitution in the shell, so we leave
# those spans intact.
masked_chars = []
in_single = False
for ch in token:
if ch == "'":
in_single = not in_single
masked_chars.append("\x00")
continue
masked_chars.append("\x00" if in_single else ch)
masked = "".join(masked_chars)
# $(...) substitutions
start = 0
while True:
idx = masked.find("$(", start)
if idx == -1:
break
depth = 0
end = -1
for i in range(idx + 1, len(masked)):
ch = masked[i]
if ch == "(":
depth += 1
elif ch == ")":
depth -= 1
if depth == 0:
end = i
break
if end == -1:
break
inner.append(token[idx + 2:end])
start = end + 1
# `...` backtick substitutions
parts = masked.split("`")
if len(parts) >= 3:
# Odd-indexed segments are inside backticks. Use original-text offsets
# so the extracted command retains its real characters.
offset = 0
for i, part in enumerate(parts):
seg_start = offset
seg_end = offset + len(part)
if i % 2 == 1 and part.strip():
inner.append(token[seg_start:seg_end])
offset = seg_end + 1 # account for the backtick delimiter
return inner
def _split_simple_commands(cmd: str) -> List[str]:
"""Split a command line into simple-command segments on shell separators.
Respects single/double quotes so separators inside quotes are ignored.
Subshell parentheses are stripped and their contents treated as segments.
"""
segments: List[str] = []
current = []
i = 0
n = len(cmd)
quote = None
def flush():
seg = "".join(current).strip()
if seg:
segments.append(seg)
current.clear()
while i < n:
ch = cmd[i]
if quote:
current.append(ch)
if ch == quote:
quote = None
i += 1
continue
if ch in ("'", '"'):
quote = ch
current.append(ch)
i += 1
continue
# Subshell grouping: treat parens as separators (strip them).
if ch in ("(", ")"):
flush()
i += 1
continue
# Two-character separators first.
two = cmd[i:i + 2]
if two in ("&&", "||"):
flush()
i += 2
continue
if ch in (";", "|", "&", "\n"):
flush()
i += 1
continue
current.append(ch)
i += 1
flush()
return segments
def _parse_segment(segment: str) -> List[ShellOp]:
"""Parse a single simple-command segment into ShellOp(s).
May return multiple ops when the segment embeds command substitutions.
"""
ops: List[ShellOp] = []
# First, recurse into any command substitutions so e.g. ``$(rm -rf x)``
# is evaluated as an ``rm`` operation.
for inner in _extract_substitutions(segment):
ops.extend(parse_command(inner))
try:
tokens = shlex.split(segment, comments=False, posix=True)
except ValueError:
# Unbalanced quotes etc. — conservative fallback: whole segment.
tokens = segment.split()
op = ShellOp()
args: List[str] = []
skip_next = False
skip_input_target = False
for tok in tokens:
if skip_next:
op.write_targets.append(tok)
skip_next = False
continue
if skip_input_target:
skip_input_target = False
continue
# Skip leading environment-variable assignments (FOO=bar cmd).
if op.executable == "" and not args and "=" in tok and tok.split("=", 1)[0].isidentifier():
continue
matched_redirect = False
for redir in sorted(_WRITE_REDIRECTS, key=len, reverse=True):
if tok == redir:
skip_next = True
matched_redirect = True
break
if tok.startswith(redir) and len(tok) > len(redir):
op.write_targets.append(tok[len(redir):])
matched_redirect = True
break
# Forms like 2> or 1>> (fd-prefixed redirect).
if redir.startswith(">"):
stripped = tok.lstrip("0123456789")
if stripped == redir and stripped != tok:
skip_next = True
matched_redirect = True
break
if stripped.startswith(redir) and len(stripped) > len(redir) and stripped != tok:
dest = stripped[len(redir):]
# Skip fd-to-fd redirections like ``2>&1`` (dest is ``&N``),
# which alias a file descriptor rather than writing a path.
if not dest.startswith("&"):
op.write_targets.append(dest)
matched_redirect = True
break
if matched_redirect:
continue
# Ignore input redirects and their target (consume the next token so a
# filename like ``< /dev/null`` is never mistaken for the executable).
if tok in ("<", "<<", "<<<"):
skip_input_target = True
continue
stripped_input = tok.lstrip("0123456789")
if stripped_input in ("<", "<<", "<<<") and stripped_input != tok:
skip_input_target = True
continue
if tok.startswith("<") or (
stripped_input.startswith("<") and stripped_input != tok
):
# Inline form like ``<file`` or ``<<<word`` — target is attached.
continue
if op.executable == "":
op.executable = tok
else:
args.append(tok)
op.args = args
if op.executable or op.write_targets:
ops.append(op)
return ops
def parse_command(cmd: str) -> List[ShellOp]:
"""Parse a shell command string into a list of ShellOp operations.
Args:
cmd: The raw shell command (without the ``bash:``/``shell:`` prefix).
Returns:
A list of ShellOp. On parse failure, returns a single best-effort
ShellOp wrapping the whole command so existing behaviour is preserved.
"""
if not cmd or not cmd.strip():
return []
try:
segments = _split_simple_commands(cmd)
ops: List[ShellOp] = []
for seg in segments:
ops.extend(_parse_segment(seg))
if not ops:
# Nothing extracted — fall back to whole command.
return [_fallback_op(cmd)]
return ops
except Exception: # noqa: BLE001 - conservative: never weaken a rule on parse failure
# Any unexpected failure must not weaken the rule: fall back.
return [_fallback_op(cmd)]
def _fallback_op(cmd: str) -> ShellOp:
"""Build a single ShellOp representing the whole command (legacy path)."""
tokens = cmd.split()
if not tokens:
return ShellOp()
return ShellOp(executable=tokens[0], args=tokens[1:])
@@ -196,6 +196,10 @@ class PermissionManager:
if r.agent_name is None or r.agent_name == agent_name
]
# Prefixes that should be decomposed into command-structure-aware
# sub-targets before matching (command-aware permission matching).
_SHELL_PREFIXES = ("bash:", "shell:")
def check(self, target: str, agent_name: Optional[str] = None) -> PermissionResult:
"""
Check permission for a target.
@@ -208,7 +212,23 @@ class PermissionManager:
PermissionResult with the decision
"""
agent = agent_name or self.agent_name
# Command-aware matching: shell targets are decomposed into their
# constituent operations so deny rules fire regardless of where the
# operation appears in a compound command (&&, ;, |, $(), redirects).
prefix = next(
(p for p in self._SHELL_PREFIXES if target.startswith(p)), None
)
if prefix is not None:
command = target[len(prefix):]
structured = self._check_shell_command(prefix, command, agent)
if structured is not None:
return structured
return self._check_flat(target, agent)
def _check_flat(self, target: str, agent: Optional[str]) -> PermissionResult:
"""Legacy flat matching against approvals and rules for a target."""
with self._lock:
# Check persistent approvals first
for approval in self._approvals:
@@ -239,7 +259,75 @@ class PermissionManager:
target=target,
reason="No matching rule, requires approval",
)
def _check_shell_command(
self, prefix: str, command: str, agent: Optional[str]
) -> Optional[PermissionResult]:
"""Command-structure-aware check for a shell command target.
Decomposes the command into its constituent operations and evaluates
each as its own sub-target. Aggregates with deny-wins, then ask,
then allow semantics. Returns ``None`` to defer to flat matching when
decomposition yields a single operation identical to the original
target (no compound structure), preserving legacy behaviour exactly.
"""
original_target = prefix + command
try:
from .command_parser import parse_command
ops = parse_command(command)
except Exception:
return None
# Build the list of sub-targets to evaluate.
sub_targets: List[str] = []
for op in ops:
cmd_str = op.command_string
if cmd_str:
sub_targets.append(prefix + cmd_str)
for path in op.write_targets:
sub_targets.append(f"write:{path}")
# No structured decomposition possible, or it collapses to exactly the
# original target — defer to the existing flat matcher.
if not sub_targets or sub_targets == [original_target]:
return None
results = [(t, self._check_flat(t, agent)) for t in sub_targets]
# Also fold in an explicit match on the *original* compound target so a
# legacy flat rule/approval (e.g. ``deny: bash:cd /tmp && rm *``) still
# participates. The default "No matching rule" ASK is intentionally
# excluded so it does not override allowed sub-operations.
original_res = self._check_flat(original_target, agent)
if (original_res.rule is not None) or original_res.approved is not None:
results.append((original_target, original_res))
# Deny wins.
for sub_target, res in results:
if res.action == PermissionAction.DENY:
res.reason = (
f"Denied sub-operation '{sub_target}' ({res.reason})"
)
res.target = original_target
return res
# Then ask: if any sub-operation requires approval.
for sub_target, res in results:
if res.action == PermissionAction.ASK:
res.reason = (
f"Sub-operation '{sub_target}' requires approval "
f"({res.reason})"
)
res.target = original_target
return res
# All allowed.
return PermissionResult(
action=PermissionAction.ALLOW,
target=original_target,
reason="All shell sub-operations allowed",
)
def approve(
self,
target: str,
@@ -407,6 +407,16 @@ class PluginManager:
Returns:
Number of plugins loaded
"""
import os
if os.environ.get("PRAISONAI_ALLOW_PLUGIN_DISCOVERY", "").strip().lower() not in (
"true", "1", "yes",
):
logger.debug(
"Plugin auto-discovery disabled; set PRAISONAI_ALLOW_PLUGIN_DISCOVERY=true"
)
return 0
from .discovery import discover_and_load_plugins
loaded = discover_and_load_plugins(plugin_dirs=None, include_defaults=True)
@@ -1,14 +1,26 @@
"""
Runtime execution components for PraisonAI agents.
Runtime execution components and capability system for PraisonAI agents.
This module provides runtime execution abstractions and protocols for
standardizing agent execution across different harness types.
This module provides runtime execution abstractions, protocols, and capability
validation for standardizing agent execution across different harness types.
Key Components:
- Runtime execution contexts: PreparedTurnContext, TurnRuntimeProtocol
- Agent runtime registry: AgentRuntimeProtocol, RuntimeRegistry
- Tool result middleware: RuntimeToolResultMiddleware, NormalizedToolResult
- Registry patterns for runtime resolution and middleware management
- Doctor migration protocol: DoctorContractProtocol for config migration
- Capability validation: RuntimeCapability, RuntimeCapabilityMatrix, validate_capabilities
- Runtime protocols: AgentRuntimeProtocol for capability reporting
- Turn-time runtime resolution for handoffs and sub-agents
Features:
- Turn-based runtime execution with PreparedTurnContext
- RuntimeCapability flags for feature detection
- RuntimeCapabilityMatrix for runtime capability sets
- Capability validation at runtime selection time
- Support for native, plugin harness, and managed runtimes
- Dynamic runtime resolution at handoff boundaries
"""
from .._lazy import create_lazy_getattr_with_groups
@@ -47,7 +59,30 @@ __all__ = [
'RuntimeRegistry',
'register_runtime',
'list_runtimes',
# Turn-time runtime resolution for handoffs
'resolve_runtime',
'RuntimeProtocol',
'SessionContext',
'get_runtime_cache',
'clear_runtime_cache',
'set_global_resolver',
# Doctor migration protocol
"DoctorContractProtocol",
"Finding",
"get_default_registry",
"register_rule",
"get_rules",
"collect_findings",
"apply_fixes",
# Capability types
"RuntimeCapability",
"RuntimeCapabilityMatrix",
# Validation
"validate_capabilities",
"CapabilityValidationError",
# Built-in runtimes
"get_native_runtime_capabilities",
"get_reduced_harness_capabilities",
]
# Grouped lazy imports for efficient loading
@@ -92,7 +127,33 @@ _LAZY_GROUPS = {
'RuntimeRegistry': ('praisonaiagents.runtime.registry', 'RuntimeRegistry'),
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
},
'resolve': {
'resolve_runtime': ('praisonaiagents.runtime.resolve', 'resolve_runtime'),
'RuntimeProtocol': ('praisonaiagents.runtime.resolve', 'RuntimeProtocol'),
'SessionContext': ('praisonaiagents.runtime.resolve', 'SessionContext'),
'get_runtime_cache': ('praisonaiagents.runtime.resolve', 'get_runtime_cache'),
'clear_runtime_cache': ('praisonaiagents.runtime.resolve', 'clear_runtime_cache'),
'set_global_resolver': ('praisonaiagents.runtime.resolve', 'set_global_resolver'),
},
'capabilities': {
'RuntimeCapability': ('praisonaiagents.runtime.capabilities', 'RuntimeCapability'),
'RuntimeCapabilityMatrix': ('praisonaiagents.runtime.capabilities', 'RuntimeCapabilityMatrix'),
'validate_capabilities': ('praisonaiagents.runtime.capabilities', 'validate_capabilities'),
'CapabilityValidationError': ('praisonaiagents.runtime.capabilities', 'CapabilityValidationError'),
'get_native_runtime_capabilities': ('praisonaiagents.runtime.capabilities', 'get_native_runtime_capabilities'),
'get_reduced_harness_capabilities': ('praisonaiagents.runtime.capabilities', 'get_reduced_harness_capabilities'),
},
'doctor_protocol': {
'DoctorContractProtocol': ('praisonaiagents.runtime.doctor_protocol', 'DoctorContractProtocol'),
'Finding': ('praisonaiagents.runtime.doctor_protocol', 'Finding'),
},
'doctor_registry': {
'get_default_registry': ('praisonaiagents.runtime.doctor_registry', 'get_default_registry'),
'register_rule': ('praisonaiagents.runtime.doctor_registry', 'register_rule'),
'get_rules': ('praisonaiagents.runtime.doctor_registry', 'get_rules'),
'collect_findings': ('praisonaiagents.runtime.doctor_registry', 'collect_findings'),
'apply_fixes': ('praisonaiagents.runtime.doctor_registry', 'apply_fixes'),
},
}
@@ -189,4 +189,4 @@ class PraisonAIRuntime:
type="error",
content=str(e),
metadata={'runtime': self.runtime_id}
)
)
@@ -0,0 +1,100 @@
"""
Built-in migration rules for doctor.
Contains core migration rules that ship with PraisonAI,
including the cli_backend migration rule.
"""
from typing import Any, Dict, List
from .doctor_protocol import DoctorContractProtocol, Finding
class CliBackendMigrationRule:
"""
Built-in rule to migrate cli_backend to models.<default>.runtime.
Maps known cli_backend values to runtime configuration under
the default model configuration.
"""
# Mapping table for known cli_backend values
CLI_BACKEND_MAPPING = {
"claude-code": "claude-code",
"openai-gpt": "openai-gpt",
"anthropic": "anthropic",
"gemini": "gemini",
# Add more mappings as needed
}
@property
def rule_id(self) -> str:
return "cli_backend_migration"
def collect_findings(self, config: Dict[str, Any]) -> List[Finding]:
"""Detect cli_backend usage in configuration."""
findings = []
# Check for cli_backend in top-level config
if "cli_backend" in config:
backend_value = config["cli_backend"]
findings.append(Finding(
rule_id=self.rule_id,
severity="warning",
message=f"Legacy field 'cli_backend: {backend_value}' should be migrated to model-scoped runtime configuration",
fix_description=f"Move cli_backend to models.<default>.runtime: {self._map_backend(backend_value)}",
context={"field": "cli_backend", "value": backend_value, "location": "root"}
))
# Check for cli_backend in roles
if "roles" in config and isinstance(config["roles"], dict):
for role_name, role_config in config["roles"].items():
if isinstance(role_config, dict) and "cli_backend" in role_config:
backend_value = role_config["cli_backend"]
findings.append(Finding(
rule_id=self.rule_id,
severity="warning",
message=f"Legacy field 'cli_backend: {backend_value}' in role '{role_name}' should be migrated",
fix_description=f"Move cli_backend to models.<default>.runtime: {self._map_backend(backend_value)}",
context={"field": "cli_backend", "value": backend_value, "location": f"roles.{role_name}"}
))
return findings
def apply_fix(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Apply cli_backend migration to configuration."""
import copy
result = copy.deepcopy(config)
# Migrate top-level cli_backend
if "cli_backend" in result:
backend_value = result.pop("cli_backend")
runtime_value = self._map_backend(backend_value)
# Ensure models structure exists
if "models" not in result:
result["models"] = {}
if "default" not in result["models"]:
result["models"]["default"] = {}
result["models"]["default"]["runtime"] = runtime_value
# Migrate cli_backend in roles
if "roles" in result and isinstance(result["roles"], dict):
for role_name, role_config in result["roles"].items():
if isinstance(role_config, dict) and "cli_backend" in role_config:
backend_value = role_config.pop("cli_backend")
runtime_value = self._map_backend(backend_value)
# Ensure models structure exists in role
if "models" not in role_config:
role_config["models"] = {}
if "default" not in role_config["models"]:
role_config["models"]["default"] = {}
role_config["models"]["default"]["runtime"] = runtime_value
return result
def _map_backend(self, backend_value: str) -> str:
"""Map legacy cli_backend value to runtime id."""
return self.CLI_BACKEND_MAPPING.get(backend_value, backend_value)
@@ -0,0 +1,299 @@
"""
Runtime Capability System for PraisonAI Agents.
Defines capability flags and validation logic to detect runtime incompatibilities
at config/preflight time rather than during agent execution.
This addresses the issue where hooks, tools, streaming, and context compaction
behave differently across native and plugin harness runtimes, causing users
to discover incompatibilities mid-turn instead of at startup.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum, auto
from typing import Set, Optional, Dict, Any, Union
class RuntimeCapability(Enum):
"""
Enumeration of runtime capabilities that agents may require.
Used to declare what features a runtime supports and what features
an agent/team configuration requires.
"""
# Hook system capabilities
NATIVE_HOOKS = auto() # Supports in-process hook execution around tool/LLM events
# Core execution capabilities
TOOL_LOOP = auto() # Supports full agent tool execution loop
STREAMING_DELTAS = auto() # Supports real-time streaming of incremental responses
CONTEXT_COMPACTION = auto() # Supports automatic context size management
# Tool and integration capabilities
MCP_TOOLS = auto() # Supports Model Context Protocol tools
CODE_EXECUTION = auto() # Supports safe code execution in sandboxes
# Advanced capabilities
MULTI_MODAL = auto() # Supports images, audio, video processing
ASYNC_EXECUTION = auto() # Supports asynchronous operation
SESSION_PERSISTENCE = auto() # Supports persistent sessions across executions
MEMORY_MANAGEMENT = auto() # Supports advanced memory features
# Plugin harness capabilities (typically reduced subset)
BASIC_CHAT = auto() # Basic text chat only
SIMPLE_TOOLS = auto() # Simple synchronous tool execution only
@dataclass
class RuntimeCapabilityMatrix:
"""
Matrix of capabilities supported by a specific runtime implementation.
Each runtime (native, plugin harness, managed service) declares its
capability set using this matrix. Agent configurations can specify
required capabilities, and validation occurs at runtime selection.
Example:
# Full native runtime
native_matrix = RuntimeCapabilityMatrix(
native_hooks=True,
tool_loop=True,
streaming_deltas=True,
context_compaction=True,
mcp_tools=True,
code_execution=True,
multi_modal=True,
async_execution=True,
session_persistence=True,
memory_management=True
)
# Limited plugin harness
plugin_matrix = RuntimeCapabilityMatrix(
basic_chat=True,
simple_tools=True,
# All other capabilities default to False
)
"""
# Hook system capabilities
native_hooks: bool = False
# Core execution capabilities
tool_loop: bool = False
streaming_deltas: bool = False
context_compaction: bool = False
# Tool and integration capabilities
mcp_tools: bool = False
code_execution: bool = False
# Advanced capabilities
multi_modal: bool = False
async_execution: bool = False
session_persistence: bool = False
memory_management: bool = False
# Basic capabilities (typically for constrained environments)
basic_chat: bool = False
simple_tools: bool = False
# Additional metadata
metadata: Dict[str, Any] = field(default_factory=dict)
def to_capability_set(self) -> Set[RuntimeCapability]:
"""Convert matrix to set of enabled capabilities."""
capabilities = set()
if self.native_hooks:
capabilities.add(RuntimeCapability.NATIVE_HOOKS)
if self.tool_loop:
capabilities.add(RuntimeCapability.TOOL_LOOP)
if self.streaming_deltas:
capabilities.add(RuntimeCapability.STREAMING_DELTAS)
if self.context_compaction:
capabilities.add(RuntimeCapability.CONTEXT_COMPACTION)
if self.mcp_tools:
capabilities.add(RuntimeCapability.MCP_TOOLS)
if self.code_execution:
capabilities.add(RuntimeCapability.CODE_EXECUTION)
if self.multi_modal:
capabilities.add(RuntimeCapability.MULTI_MODAL)
if self.async_execution:
capabilities.add(RuntimeCapability.ASYNC_EXECUTION)
if self.session_persistence:
capabilities.add(RuntimeCapability.SESSION_PERSISTENCE)
if self.memory_management:
capabilities.add(RuntimeCapability.MEMORY_MANAGEMENT)
if self.basic_chat:
capabilities.add(RuntimeCapability.BASIC_CHAT)
if self.simple_tools:
capabilities.add(RuntimeCapability.SIMPLE_TOOLS)
return capabilities
def supports(self, capability: RuntimeCapability) -> bool:
"""Check if this runtime supports a specific capability."""
return capability in self.to_capability_set()
def supports_all(self, required_capabilities: Set[RuntimeCapability]) -> bool:
"""Check if this runtime supports all required capabilities."""
supported = self.to_capability_set()
return required_capabilities.issubset(supported)
def missing_capabilities(self, required_capabilities: Set[RuntimeCapability]) -> Set[RuntimeCapability]:
"""Return set of required capabilities that this runtime doesn't support."""
supported = self.to_capability_set()
return required_capabilities - supported
class CapabilityValidationError(Exception):
"""
Raised when runtime capability validation fails.
Indicates that the selected runtime lacks capabilities required
by the agent or team configuration.
"""
def __init__(
self,
runtime_name: str,
missing_capabilities: Set[RuntimeCapability],
available_capabilities: Optional[Set[RuntimeCapability]] = None
):
self.runtime_name = runtime_name
self.missing_capabilities = missing_capabilities
self.available_capabilities = available_capabilities or set()
missing_names = sorted(cap.name for cap in missing_capabilities)
available_names = sorted(cap.name for cap in self.available_capabilities)
message = (
f"Runtime '{runtime_name}' lacks required capabilities: {missing_names}. "
f"Available capabilities: {available_names}. "
"Remediation: select a runtime that supports the missing capabilities "
"or remove unsupported entries from runtime.required_capabilities."
)
super().__init__(message)
def validate_capabilities(
runtime_matrix: RuntimeCapabilityMatrix,
required_capabilities: Union[Set[RuntimeCapability], RuntimeCapabilityMatrix],
runtime_name: str = "unknown"
) -> bool:
"""
Validate that a runtime supports all required capabilities.
Args:
runtime_matrix: Capability matrix of the runtime
required_capabilities: Required capabilities (set or matrix)
runtime_name: Name of runtime for error messages
Returns:
True if validation passes
Raises:
CapabilityValidationError: If validation fails
"""
# Handle empty or None required capabilities
if not required_capabilities:
return True
if isinstance(required_capabilities, RuntimeCapabilityMatrix):
required_set = required_capabilities.to_capability_set()
else:
required_set = required_capabilities
if runtime_matrix.supports_all(required_set):
return True
missing = runtime_matrix.missing_capabilities(required_set)
available = runtime_matrix.to_capability_set()
raise CapabilityValidationError(
runtime_name=runtime_name,
missing_capabilities=missing,
available_capabilities=available
)
def get_native_runtime_capabilities() -> RuntimeCapabilityMatrix:
"""
Get the capability matrix for the built-in native runtime.
The native runtime supports all capabilities since it runs
in-process with full access to the agent system.
Returns:
Full capability matrix for native runtime
"""
return RuntimeCapabilityMatrix(
# Hook system - native runtime supports full hook system
native_hooks=True,
# Core execution - native runtime supports all core features
tool_loop=True,
streaming_deltas=True,
context_compaction=True,
# Tools and integrations - native runtime supports all
mcp_tools=True,
code_execution=True,
# Advanced features - native runtime supports all
multi_modal=True,
async_execution=True,
session_persistence=True,
memory_management=True,
# Basic features - native runtime also supports basic modes
basic_chat=True,
simple_tools=True,
metadata={
"runtime_type": "native",
"description": "Built-in PraisonAI native runtime with full capabilities"
}
)
def get_reduced_harness_capabilities() -> RuntimeCapabilityMatrix:
"""
Get an example capability matrix for a reduced plugin harness.
This represents a typical external harness that only supports
basic functionality without advanced native features.
Returns:
Reduced capability matrix for plugin harness example
"""
return RuntimeCapabilityMatrix(
# No native hooks - external harness can't hook into native events
native_hooks=False,
# Limited execution capabilities
tool_loop=True, # Can execute tools but without native hooks
streaming_deltas=False, # May not support real-time streaming
context_compaction=False, # May not have context management
# Limited tool support
mcp_tools=False, # May not support MCP protocol
code_execution=False, # May not have safe execution environment
# Basic features only
multi_modal=False,
async_execution=False,
session_persistence=False,
memory_management=False,
# Supports basic operations
basic_chat=True,
simple_tools=True,
metadata={
"runtime_type": "plugin_harness",
"description": "Example reduced plugin harness with limited capabilities"
}
)
@@ -0,0 +1,60 @@
"""
DoctorContractProtocol for runtime config migration.
Provides a protocol for third-party migration rules and built-in rules
for migrating legacy configuration fields like cli_backend to the new
model-scoped runtime configuration.
"""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from dataclasses import dataclass
@dataclass
class Finding:
"""Represents a migration finding from a doctor rule."""
rule_id: str
severity: str # "warning", "error", "info"
message: str
fix_description: Optional[str] = None
context: Optional[Dict[str, Any]] = None
@runtime_checkable
class DoctorContractProtocol(Protocol):
"""
Protocol for doctor migration rules.
Third-party plugins can implement this protocol to provide custom
migration rules for agent configurations.
"""
@property
def rule_id(self) -> str:
"""Unique identifier for this migration rule."""
...
def collect_findings(self, config: Dict[str, Any]) -> List[Finding]:
"""
Analyze configuration and collect migration findings.
Args:
config: Agent configuration dictionary (from YAML or Python)
Returns:
List of findings for this configuration
"""
...
def apply_fix(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""
Apply migration fixes to configuration.
Args:
config: Agent configuration dictionary
Returns:
Updated configuration dictionary
"""
...
@@ -0,0 +1,147 @@
"""
Doctor rules registry with lazy plugin discovery.
Manages built-in and plugin-provided migration rules.
"""
import copy
import warnings
from typing import Any, Dict, List, Optional
from .doctor_protocol import DoctorContractProtocol, Finding
from .builtin_rules import CliBackendMigrationRule
class DoctorRulesRegistry:
"""Registry for doctor migration rules with lazy plugin discovery."""
def __init__(self):
self._rules: List[DoctorContractProtocol] = []
self._plugins_loaded = False
def register_rule(self, rule: DoctorContractProtocol) -> None:
"""Register a migration rule."""
if not isinstance(rule, DoctorContractProtocol):
raise TypeError("Rule must implement DoctorContractProtocol")
# Check for duplicate rule IDs
for existing_rule in self._rules:
if existing_rule.rule_id == rule.rule_id:
warnings.warn(f"Duplicate rule ID '{rule.rule_id}' - replacing existing rule")
self._rules.remove(existing_rule)
break
self._rules.append(rule)
def get_rules(self) -> List[DoctorContractProtocol]:
"""Get all registered rules, loading plugins if not already loaded."""
if not self._plugins_loaded:
self._load_builtin_rules()
self._load_plugin_rules()
self._plugins_loaded = True
return self._rules.copy()
def get_rule(self, rule_id: str) -> Optional[DoctorContractProtocol]:
"""Get a specific rule by ID."""
for rule in self.get_rules():
if rule.rule_id == rule_id:
return rule
return None
def collect_all_findings(self, config: Dict[str, Any]) -> List[Finding]:
"""Collect findings from all registered rules."""
all_findings = []
for rule in self.get_rules():
try:
findings = rule.collect_findings(config)
all_findings.extend(findings)
except Exception as e:
# Log error but continue with other rules
warnings.warn(f"Error in rule '{rule.rule_id}': {e}")
return all_findings
def apply_all_fixes(self, config: Dict[str, Any]) -> Dict[str, Any]:
"""Apply fixes from all rules that have findings."""
result = copy.deepcopy(config)
for rule in self.get_rules():
try:
findings = rule.collect_findings(result)
if findings:
result = rule.apply_fix(result)
except Exception as e:
warnings.warn(f"Error applying fix for rule '{rule.rule_id}': {e}")
return result
def _load_builtin_rules(self) -> None:
"""Load built-in migration rules."""
# Register built-in cli_backend migration rule
self.register_rule(CliBackendMigrationRule())
def _load_plugin_rules(self) -> None:
"""Discover and load plugin-provided rules via entry points."""
# Prioritize importlib.metadata over pkg_resources for better performance
try:
from importlib.metadata import entry_points
eps = entry_points()
if hasattr(eps, 'select'):
doctor_eps = eps.select(group='praisonai.doctor_contracts')
else:
doctor_eps = eps.get('praisonai.doctor_contracts', [])
for entry_point in doctor_eps:
try:
rule_class = entry_point.load()
if callable(rule_class):
rule_instance = rule_class()
self.register_rule(rule_instance)
except Exception as e:
warnings.warn(f"Failed to load doctor rule from {entry_point.name}: {e}")
except ImportError:
# Fallback to pkg_resources if importlib.metadata not available
try:
import pkg_resources
for entry_point in pkg_resources.iter_entry_points('praisonai.doctor_contracts'):
try:
rule_class = entry_point.load()
if callable(rule_class):
rule_instance = rule_class()
self.register_rule(rule_instance)
except Exception as e:
warnings.warn(f"Failed to load doctor rule from {entry_point.name}: {e}")
except ImportError:
pass # No plugin system available
# Global registry singleton
_default_registry: Optional[DoctorRulesRegistry] = None
def get_default_registry() -> DoctorRulesRegistry:
"""Get the default doctor rules registry (singleton)."""
global _default_registry
if _default_registry is None:
_default_registry = DoctorRulesRegistry()
return _default_registry
def register_rule(rule: DoctorContractProtocol) -> None:
"""Register a rule with the default registry."""
get_default_registry().register_rule(rule)
def get_rules() -> List[DoctorContractProtocol]:
"""Get all rules from the default registry."""
return get_default_registry().get_rules()
def collect_findings(config: Dict[str, Any]) -> List[Finding]:
"""Collect findings from all rules in the default registry."""
return get_default_registry().collect_all_findings(config)
def apply_fixes(config: Dict[str, Any]) -> Dict[str, Any]:
"""Apply fixes from all rules in the default registry."""
return get_default_registry().apply_all_fixes(config)
@@ -6,7 +6,8 @@ must implement. These protocols standardize the execution contract
across different runtime types (native, plugin, managed, etc.).
Includes both turn-context protocols and agent runtime protocols to support
different execution patterns.
different execution patterns, along with capability reporting protocols
for runtime compatibility validation.
Protocol-driven design following AGENTS.md:
- Lightweight protocols only (no heavy implementations)
@@ -21,6 +22,7 @@ from typing import Any, AsyncIterator, Dict, List, Optional, Protocol, runtime_c
if TYPE_CHECKING:
from .turn_context import PreparedTurnContext, RuntimeMode
from .capabilities import RuntimeCapabilityMatrix
from ..agent.protocols import AgentProtocol
@@ -121,7 +123,7 @@ class TurnContextBuilderProtocol(Protocol):
...
# Agent runtime protocols and dataclasses from this branch
# Agent runtime protocols and dataclasses
@dataclass
class RuntimeConfig:
@@ -155,14 +157,48 @@ class RuntimeDelta:
@runtime_checkable
class AgentRuntimeProtocol(Protocol):
"""Protocol for agent runtime implementations.
Any object implementing these methods can serve as an Agent runtime.
Follows typing.Protocol pattern from AGENTS.md.
This is a simpler contract than CliBackendProtocol, focused on
the minimal interface needed for runtime execution.
"""
Protocol that all agent runtime implementations must follow.
This protocol defines the interface for different runtime environments:
- Native runtime (built-in praisonai runtime)
- Plugin harness runtimes (external CLI tools, docker containers)
- Managed service runtimes (Anthropic, E2B, Modal, etc.)
Combines turn-based execution with capability reporting to enable
both runtime execution and compatibility validation.
"""
@property
def runtime_name(self) -> str:
"""
Human-readable name of this runtime implementation.
Examples: "native", "claude-code", "e2b-managed", "docker-harness"
"""
...
@property
def runtime_version(self) -> str:
"""
Version string of this runtime implementation.
Used for compatibility tracking and debugging.
"""
...
def capabilities(self) -> "RuntimeCapabilityMatrix":
"""
Report the capability matrix for this runtime.
This is the key method for the capability validation system.
Each runtime must honestly declare what features it supports
to enable fail-fast validation at config/selection time.
Returns:
RuntimeCapabilityMatrix with supported capabilities
"""
...
def supports(self, model_ref: Optional[str] = None) -> bool:
"""Check if this runtime supports the given model reference.
@@ -210,4 +246,72 @@ class AgentRuntimeProtocol(Protocol):
Yields:
RuntimeDelta objects with incremental response content
"""
...
...
async def execute_agent(
self,
agent_config: Dict[str, Any],
prompt: str,
**kwargs
) -> Dict[str, Any]:
"""
Execute agent with the given configuration and prompt.
Args:
agent_config: Agent configuration dict
prompt: User prompt to process
**kwargs: Additional execution parameters
Returns:
Execution result dict with response, metadata, etc.
"""
...
async def stream_agent(
self,
agent_config: Dict[str, Any],
prompt: str,
**kwargs
) -> Any: # AsyncIterator but avoid import
"""
Execute agent with streaming responses.
Only required if runtime declares streaming_deltas capability.
Args:
agent_config: Agent configuration dict
prompt: User prompt to process
**kwargs: Additional execution parameters
Yields:
Streaming response chunks
"""
...
async def validate_config(
self,
agent_config: Dict[str, Any]
) -> List[str]:
"""
Validate agent configuration against runtime capabilities.
Args:
agent_config: Agent configuration to validate
Returns:
List of validation error messages (empty if valid)
"""
...
async def health_check(self) -> Dict[str, Any]:
"""
Perform runtime health check.
Returns:
Health status dict with status, latency, errors, etc.
"""
...
# CliBackendProtocol removed - use the canonical definition from cli_backend.protocols instead
# to avoid duplicate incompatible protocol definitions
@@ -420,4 +420,4 @@ def _discover_entry_point_runtimes() -> None:
# Initialize on module load
_discover_entry_point_runtimes()
_discover_entry_point_runtimes()
@@ -0,0 +1,378 @@
"""
Turn-time runtime resolution API for handoffs and sub-agents.
This module addresses the issue where handoffs and sub-agents inherit
construction-time runtime pins from the parent agent, causing wrong
harness execution when the delegate uses a different model_ref or provider.
The solution provides turn-time resolution that re-resolves runtime
from (agent_id, model_ref) at each handoff/sub-agent invocation.
"""
import logging
import threading
import time
from typing import Any, Dict, Optional, Protocol, Tuple, Union
from dataclasses import dataclass
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
class RuntimeProtocol(Protocol):
"""Protocol for agent runtime implementations."""
def execute(self, prompt: str, **kwargs) -> Any:
"""Execute a prompt with the runtime."""
...
async def aexecute(self, prompt: str, **kwargs) -> Any:
"""Async execute a prompt with the runtime."""
...
@property
def model_ref(self) -> str:
"""Get the model reference for this runtime."""
...
@property
def provider(self) -> str:
"""Get the provider for this runtime."""
...
class AgentRuntimeProtocol(RuntimeProtocol):
"""Extended protocol for agent-specific runtime features."""
@property
def supports_streaming(self) -> bool:
"""Whether this runtime supports streaming responses."""
...
@property
def supports_tools(self) -> bool:
"""Whether this runtime supports tool calling."""
...
@dataclass
class SessionContext:
"""Context for runtime resolution within a session."""
session_id: str
timestamp: float
parent_agent_id: Optional[str] = None
handoff_depth: int = 0
def __post_init__(self):
if self.timestamp <= 0:
self.timestamp = time.time()
class RuntimeResolver(ABC):
"""Abstract base class for runtime resolution strategies."""
@abstractmethod
def resolve(
self,
agent_id: str,
model_ref: str,
session_ctx: SessionContext,
**kwargs
) -> AgentRuntimeProtocol:
"""Resolve runtime for the given agent and model."""
...
@abstractmethod
def supports_model(self, model_ref: str) -> bool:
"""Check if this resolver can handle the given model."""
...
class DefaultRuntimeResolver(RuntimeResolver):
"""Default runtime resolver that creates appropriate runtime instances."""
def __init__(self):
self._model_mapping = {
# OpenAI models
'gpt-4o': 'openai',
'gpt-4o-mini': 'openai',
'gpt-4': 'openai',
'gpt-3.5-turbo': 'openai',
# Anthropic models
'claude-3-sonnet': 'anthropic',
'claude-3-haiku': 'anthropic',
'claude-3-opus': 'anthropic',
# Add more mappings as needed
}
def supports_model(self, model_ref: str) -> bool:
"""Check if we can resolve this model."""
return model_ref in self._model_mapping or model_ref.startswith(('gpt-', 'claude-'))
def resolve(
self,
agent_id: str,
model_ref: str,
session_ctx: SessionContext,
**kwargs
) -> AgentRuntimeProtocol:
"""Resolve runtime based on model reference."""
try:
# Try to import and use the existing LLM class
from ..llm.llm import LLM
# Create a runtime wrapper around the LLM
# Pass through any configuration kwargs
llm_kwargs = {'model': model_ref}
llm_kwargs.update(kwargs)
return LLMRuntimeWrapper(
llm=LLM(**llm_kwargs),
model_ref=model_ref,
agent_id=agent_id
)
except ImportError:
# Fallback to a basic implementation
logger.warning(f"LLM class not available, using fallback runtime for {model_ref}")
return FallbackRuntime(model_ref=model_ref, agent_id=agent_id)
class LLMRuntimeWrapper(AgentRuntimeProtocol):
"""Wrapper around existing LLM class to provide runtime protocol."""
def __init__(self, llm: Any, model_ref: str, agent_id: str):
self.llm = llm
self._model_ref = model_ref
self._agent_id = agent_id
def execute(self, prompt: str, **kwargs) -> Any:
"""Execute prompt using the LLM."""
return self.llm.chat(prompt, **kwargs)
async def aexecute(self, prompt: str, **kwargs) -> Any:
"""Async execute prompt using the LLM."""
if hasattr(self.llm, 'achat'):
return await self.llm.achat(prompt, **kwargs)
else:
# Run sync version in executor
import asyncio
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, lambda: self.llm.chat(prompt, **kwargs))
@property
def model_ref(self) -> str:
return self._model_ref
@property
def provider(self) -> str:
# Extract provider from model_ref or LLM instance
if hasattr(self.llm, 'provider'):
return self.llm.provider
elif self._model_ref.startswith('gpt-'):
return 'openai'
elif self._model_ref.startswith('claude-'):
return 'anthropic'
else:
return 'unknown'
@property
def supports_streaming(self) -> bool:
return hasattr(self.llm, 'stream') or hasattr(self.llm, '_stream_chat')
@property
def supports_tools(self) -> bool:
return True # Most modern LLMs support tools
class FallbackRuntime(AgentRuntimeProtocol):
"""Fallback runtime implementation that raises errors instead of returning stubs."""
def __init__(self, model_ref: str, agent_id: str):
self._model_ref = model_ref
self._agent_id = agent_id
def execute(self, prompt: str, **kwargs) -> Any:
raise RuntimeError(
f"No LLM runtime available for model {self._model_ref}. "
"Please ensure the LLM module is properly installed."
)
async def aexecute(self, prompt: str, **kwargs) -> Any:
raise RuntimeError(
f"No LLM runtime available for model {self._model_ref}. "
"Please ensure the LLM module is properly installed."
)
@property
def model_ref(self) -> str:
return self._model_ref
@property
def provider(self) -> str:
return 'fallback'
@property
def supports_streaming(self) -> bool:
return False
@property
def supports_tools(self) -> bool:
return False
# Global runtime cache - thread-safe and request-scoped
_runtime_cache_lock = threading.RLock()
_runtime_cache: Dict[str, Dict[str, Tuple[AgentRuntimeProtocol, float]]] = {}
_cache_ttl_seconds = 300 # 5 minutes
_global_resolver: Optional[RuntimeResolver] = None
def set_global_resolver(resolver: RuntimeResolver) -> None:
"""Set the global runtime resolver."""
global _global_resolver
_global_resolver = resolver
def get_global_resolver() -> RuntimeResolver:
"""Get the global runtime resolver, creating default if needed."""
global _global_resolver
if _global_resolver is None:
_global_resolver = DefaultRuntimeResolver()
return _global_resolver
def resolve_runtime(
agent_id: str,
model_ref: str,
session_ctx: SessionContext,
**kwargs
) -> AgentRuntimeProtocol:
"""
Resolve runtime for the given agent and model at turn-time.
This is the main API that handoffs and sub-agent invocations should call
to get the appropriate runtime instead of using construction-time pins.
Args:
agent_id: Unique identifier for the agent
model_ref: Model reference (e.g., 'gpt-4o', 'claude-3-sonnet')
session_ctx: Session context for caching and tracking
Returns:
AgentRuntimeProtocol instance configured for the model
Raises:
RuntimeError: If no suitable runtime can be resolved
Example:
```python
session_ctx = SessionContext(
session_id="session_123",
timestamp=time.time(),
handoff_depth=1
)
runtime = resolve_runtime("agent_1", "gpt-4o", session_ctx)
response = runtime.execute("Hello, world!")
```
"""
cache_key = f"{session_ctx.session_id}:{agent_id}:{model_ref}"
current_time = time.time()
# Check cache first
with _runtime_cache_lock:
session_cache = _runtime_cache.get(session_ctx.session_id, {})
if cache_key in session_cache:
runtime, cached_time = session_cache[cache_key]
# Check if cache entry is still valid
if current_time - cached_time < _cache_ttl_seconds:
logger.debug(f"Using cached runtime for {cache_key}")
return runtime
else:
# Remove expired entry
del session_cache[cache_key]
logger.debug(f"Cache expired for {cache_key}")
# Resolve new runtime
logger.info(f"Resolving runtime for agent_id={agent_id}, model_ref={model_ref}")
resolver = get_global_resolver()
if not resolver.supports_model(model_ref):
raise RuntimeError(f"No runtime resolver available for model: {model_ref}")
try:
runtime = resolver.resolve(agent_id, model_ref, session_ctx, **kwargs)
# Cache the resolved runtime
with _runtime_cache_lock:
if session_ctx.session_id not in _runtime_cache:
_runtime_cache[session_ctx.session_id] = {}
_runtime_cache[session_ctx.session_id][cache_key] = (runtime, current_time)
logger.info(f"Successfully resolved runtime for {cache_key}")
return runtime
except Exception as e:
logger.error(f"Failed to resolve runtime for {cache_key}: {e}")
raise RuntimeError(f"Runtime resolution failed for model {model_ref}: {e}") from e
def get_runtime_cache() -> Dict[str, Dict[str, Tuple[AgentRuntimeProtocol, float]]]:
"""Get a copy of the current runtime cache (for debugging/testing)."""
with _runtime_cache_lock:
return {
session_id: {
key: (runtime, timestamp)
for key, (runtime, timestamp) in cache.items()
}
for session_id, cache in _runtime_cache.items()
}
def clear_runtime_cache(session_id: Optional[str] = None) -> None:
"""
Clear runtime cache for a specific session or all sessions.
Args:
session_id: If provided, only clear cache for this session.
If None, clear all cached runtimes.
"""
with _runtime_cache_lock:
if session_id is None:
_runtime_cache.clear()
logger.info("Cleared all runtime cache")
elif session_id in _runtime_cache:
del _runtime_cache[session_id]
logger.info(f"Cleared runtime cache for session: {session_id}")
def _cleanup_expired_cache() -> None:
"""Clean up expired cache entries (called periodically)."""
current_time = time.time()
total_expired = 0
with _runtime_cache_lock:
for session_id, session_cache in list(_runtime_cache.items()):
expired_keys = []
for key, (runtime, cached_time) in session_cache.items():
if current_time - cached_time >= _cache_ttl_seconds:
expired_keys.append(key)
for key in expired_keys:
del session_cache[key]
total_expired += len(expired_keys)
# Remove empty session caches
if not session_cache:
del _runtime_cache[session_id]
if total_expired:
logger.debug(f"Cleaned up {total_expired} expired runtime cache entries")
# Background cleanup (optional - only if needed)
_cleanup_thread = None
_cleanup_interval = 600 # 10 minutes
def _start_cleanup_thread():
"""Start background cache cleanup thread."""
global _cleanup_thread
if _cleanup_thread is None or not _cleanup_thread.is_alive():
def cleanup_worker():
while True:
try:
time.sleep(_cleanup_interval)
_cleanup_expired_cache()
except Exception as e:
logger.error(f"Cache cleanup error: {e}")
_cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True)
_cleanup_thread.start()
logger.debug("Started runtime cache cleanup thread")
# Initialize cleanup thread when module is imported
_start_cleanup_thread()
Loaded 100 of 364 files, more files were not shown because too many files have changed in this diff. Show more