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
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
285 changed files with 29702 additions and 3589 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,
};
+202 -43
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());
@@ -187,6 +193,7 @@ jobs:
'Review Context: ' + comments.data.length + ' total comments, ' +
(reviews.coderabbit.length + reviews.qodo.length + reviews.gemini.length + reviews.copilot.length + reviews.greptile.length) + ' bot reviews.\n\n' +
'Phase 1: Review per AGENTS.md principles (protocol-driven, backward compatible, no perf impact, SDK value gate — no feature bloat).\n' +
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n' +
'Phase 2: FIX valid issues found by prior reviewers.\n' +
'Phase 3: Provide final verdict (approve or request changes).';
await github.rest.issues.createComment({issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: claudeBody});
@@ -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\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
});
console.log(`Claude triggered on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-coderabbit.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = parseInt(process.env.PR_NUMBER, 10);
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# ================================================================
# Claude after Qodo fallback path (same polling approach)
# ================================================================
@@ -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\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
});
console.log(`Claude triggered on PR #${pr}`);
- name: Sync pipeline status labels
uses: actions/github-script@v7
env:
PR_NUMBER: ${{ needs.copilot-after-qodo.outputs.pr_number }}
with:
github-token: ${{ secrets.GH_TOKEN }}
script: |
const path = require('path');
const ps = require(path.join(process.env.GITHUB_WORKSPACE, '.github/scripts/pipeline-status.js'));
const pr = parseInt(process.env.PR_NUMBER, 10);
await ps.ensurePipelineLabels(github, context.repo.owner, context.repo.repo, core);
await ps.syncPipelineLabels(github, context.repo.owner, context.repo.repo, pr, core);
# ================================================================
# Claude after Gemini — restores the trigger lost from the deleted
# chain-claude-after-copilot.yml workflow.
@@ -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\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
body: '@claude You are the Lead Engineer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL analysis and reviews above carefully (Gemini, CodeRabbit, Qodo, Copilot, etc).\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n\n**Phase 2: FIX Valid Issues**\n5. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n6. Push all code fixes directly to THIS branch (do NOT create a new PR)\n7. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n8. If all issues are resolved, approve the PR / close the Issue\n9. If blocking issues remain, request changes / leave clear action items'
});
# ================================================================
# 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();
}
+80 -8
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;
}
@@ -198,13 +201,19 @@ jobs:
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
@@ -219,19 +228,24 @@ 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 }}
@@ -241,8 +255,8 @@ jobs:
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):
@@ -265,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):
@@ -321,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}`);
+35 -15
View File
@@ -13,7 +13,7 @@ on:
concurrency:
group: merge-conflict-scan-${{ github.repository }}
cancel-in-progress: false
cancel-in-progress: true
env:
# PAT posts as MervinPraison so issue_comment triggers claude.yml (GITHUB_TOKEN cannot chain workflows)
@@ -35,7 +35,6 @@ jobs:
const owner = context.repo.owner;
const repo = context.repo.repo;
const baseRepo = `${owner}/${repo}`;
const isMainPush = context.eventName === 'push';
const COOLDOWN_MS = 12 * 60 * 60 * 1000;
const AUTO_ACTORS = ['github-actions[bot]', 'MervinPraison'];
const LABEL = 'claude-conflict-pending';
@@ -43,7 +42,8 @@ jobs:
'@claude this PR has merge conflicts with `main`.',
'Please rebase onto latest `main`, resolve conflicts (keep this PR\'s intent, merge in newer main logic),',
'run targeted tests, and force-push with `--force-with-lease`.',
'Comment which files you resolved.'
'Comment which files you resolved.',
'Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.',
].join(' ');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -54,6 +54,7 @@ jobs:
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
mergeStateStatus
maintainerCanModify
isDraft
headRef { repository { nameWithOwner } }
}
@@ -69,6 +70,7 @@ jobs:
status,
isDraft: prGql.isDraft,
headRepo: prGql.headRef?.repository?.nameWithOwner,
maintainerCanModify: prGql.maintainerCanModify === true,
};
}
if (attempt < 2) await sleep(10000);
@@ -78,9 +80,20 @@ jobs:
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) => {
@@ -92,8 +105,8 @@ jobs:
}
async function processPR(prNumber) {
const { status, isDraft, headRepo } = await getMergeState(prNumber);
core.info(`PR #${prNumber}: mergeState=${status}, head=${headRepo}, draft=${isDraft}`);
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,
@@ -114,7 +127,12 @@ jobs:
if (status !== 'DIRTY') return { skipped: true, reason: 'not dirty' };
if (isDraft) return { skipped: true, reason: 'draft' };
if (headRepo && headRepo !== baseRepo) return { skipped: true, reason: 'fork' };
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,
@@ -122,24 +140,26 @@ jobs:
issue_number: prNumber,
per_page: 100,
});
if (!isMainPush && hasRecentAutoComment(comments)) {
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,
});
if (!labels.includes(LABEL)) {
await github.rest.issues.addLabels({
owner,
repo,
issue_number: prNumber,
labels: [LABEL],
});
}
return { triggered: true };
}
+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`
+42 -7
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>
@@ -1103,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>
@@ -1112,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>
@@ -1147,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>
@@ -1342,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.72" \
"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.72" \
"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.72" \
"praisonai>=4.6.80" \
"praisonai[ui]" \
"praisonai[crewai]"
+7 -2
View File
@@ -214,8 +214,13 @@ def example_subagent_delegation():
assert hasattr(agent, "delegate"), "Agent should have delegate method"
assert callable(agent.delegate), "delegate should be callable"
# Check _create_subagent method
subagent = agent._create_subagent("explorer")
# Create a subagent using the public profile API
from praisonaiagents.agents.profiles import BUILTIN_PROFILES, get_profile
explorer_profile = get_profile("explorer") if "explorer" in BUILTIN_PROFILES else None
subagent = Agent(
name="subagent_explorer",
instructions=explorer_profile.system_prompt if explorer_profile else "You are an explorer assistant.",
)
print(f"Created subagent: {subagent.name}")
print(f"Subagent instructions: {subagent.instructions[:50]}...")
@@ -67,7 +67,11 @@ def main():
# 4. Subagent delegation
print("\n4. Subagent Delegation:")
subagent = agent._create_subagent("explorer")
explorer_profile = get_profile("explorer") if "explorer" in BUILTIN_PROFILES else None
subagent = Agent(
name="subagent_explorer",
instructions=explorer_profile.system_prompt if explorer_profile else "You are an explorer assistant.",
)
print(f" Created subagent: {subagent.name}")
print(f" Profile: explorer (read-only)")
+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)
@@ -571,6 +571,17 @@ gh pr diff $PR --repo $REPO | less
### 15b. Merge
**Primary:** Trigger or wait for **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`):
```bash
gh workflow run claude-merge-gate.yml --repo $REPO -f pr_number=$PR
gh pr view $PR --repo $REPO --json state,mergedAt,labels
```
The gate merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. Do not merge while `claude-conflict-pending` or during active `@claude` review/fix.
**Manual fallback** (opt-out or gate unavailable):
```bash
gh pr merge $PR --repo $REPO --squash --delete-branch
# --squash preserves linear main; --delete-branch keeps the repo clean
@@ -137,6 +137,15 @@ Final rule: conclude only when evidence shows missing = 0.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
PUBLISH WORKFLOW (reference)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
GitHub Actions (recommended):
Manual: Actions → PyPI Release → Run workflow on main (bump defaults to patch)
Nightly: `.github/workflows/nightly-release-gate.yml` runs at 00:00 UTC (midnight) when:
- changes exist in src/praisonai or src/praisonai-agents since last v* tag
- Core Tests succeeded on current main HEAD
→ dispatches pypi-release.yml with bump=patch, source=nightly
→ still requires pypi environment approval before publish
Ad-hoc minor/major: manual PyPI Release only (explicit bump selection)
Step 1 — Publish praisonaiagents (Core SDK):
cd /Users/praison/praisonai-package/src/praisonai-agents
praisonai publish pypi
@@ -161,4 +170,4 @@ Step 3 — Publish praisonai-tools (External, if needed):
# Bump version in pyproject.toml, then:
python3.13 -m build && uv run twine upload dist/*
PraisonAI PRs: NEVER merge automatically — user merges manually.
PraisonAI PRs: default merge via **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) when validation passes. Add label `no-auto-merge` to opt out. Manual `gh pr merge` only as fallback when the gate cannot run.
@@ -187,7 +187,7 @@ gh pr create \
**Commit prefixes:** `fix:`, `feat:`, `security:`, `refactor:`, `docs:`, `test:`
> **Do NOT merge automatically** — user merges manually unless explicitly told otherwise.
> **Merge:** Default is the **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`). Do not merge manually unless the PR has `no-auto-merge` or you are explicitly told to use `gh pr merge`.
### 5b. Verify PR state
@@ -129,7 +129,9 @@ python -m pytest tests/ -q \
## Step 6 — Approve or Request Changes
**If the PR is good:**
**Primary (automated):** The **Claude PR merge gate** (`.github/workflows/claude-merge-gate.yml`) assesses readiness and merges when `MERGE_GATE_VERDICT: APPROVE`, CI is green, and the PR lacks `no-auto-merge`. It never posts `@claude` and does not use `gh pr review` (avoids re-triggering `claude.yml`).
**Manual fallback — if the PR is good and gate is skipped or opted out:**
```bash
cd /Users/praison/praisonai-package && \
@@ -147,6 +149,14 @@ gh pr review <NUMBER> --request-changes --body "Requesting changes: <specific fe
## Step 7 — Merge
**Primary:** Wait for merge gate or verify `mergedAt` after a gate run:
```bash
cd /Users/praison/praisonai-package && gh pr view <NUMBER> --json state,mergedAt,labels
```
**Manual fallback** (label `no-auto-merge` or gate disabled):
```bash
cd /Users/praison/praisonai-package && \
gh pr merge <NUMBER> --merge --subject "<PR title>"
@@ -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.
+21 -21
View File
@@ -18,22 +18,22 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 20 | 40 | 10767 |
| ✅ Agent | 21 | 42 | 11340 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2880 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 575 |
| ✅ Approval | 1 | 3 | 1344 |
| ✅ Audio | 2 | 10 | 649 |
| ✅ Auto Generation | 5 | 6 | 1637 |
| ✅ Autonomy | 3 | 5 | 1919 |
| ✅ Bots | 7 | 21 | 6676 |
| ✅ Bots | 7 | 23 | 7714 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 106 | 26315 |
| ✅ CLI | 5 | 108 | 26975 |
| ✅ Chunking | 2 | 2 | 288 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 13 | 3551 |
| ✅ Code Execution | 2 | 13 | 3554 |
| ✅ Conditions | 1 | 3 | 1085 |
| ✅ Configuration | 3 | 7 | 2069 |
| ✅ Context Management | 16 | 33 | 10206 |
| ✅ Configuration | 3 | 7 | 2160 |
| ✅ Context Management | 16 | 33 | 10280 |
| ✅ Database | 1 | 40 | 5373 |
| ✅ Deep Research | 8 | 2 | 437 |
| ✅ Display | 6 | 2 | 1175 |
@@ -42,20 +42,20 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Events | 1 | 2 | 764 |
| ✅ Execution | 3 | 3 | 1099 |
| ✅ Failover | 2 | 1 | 372 |
| ✅ Files | 2 | 6 | 1361 |
| ✅ Files | 2 | 6 | 1632 |
| ✅ Flow | 1 | 2 | 435 |
| ✅ Gateway | 7 | 18 | 6400 |
| ✅ Gateway | 7 | 21 | 7477 |
| ✅ Guardrails | 4 | 4 | 1619 |
| ✅ Handoffs | 11 | 6 | 2499 |
| ✅ Hooks | 2 | 8 | 3100 |
| ✅ Hooks | 2 | 8 | 3150 |
| ✅ Image | 1 | 11 | 1096 |
| ✅ Knowledge | 4 | 23 | 4282 |
| ✅ Knowledge | 4 | 24 | 4525 |
| ✅ LLM | 3 | 10 | 3674 |
| ✅ Loops | 4 | 3 | 774 |
| ✅ MCP | 1 | 51 | 10933 |
| ✅ MCP | 1 | 51 | 10939 |
| ✅ Memory | 6 | 16 | 5879 |
| ✅ OCR | 2 | 1 | 226 |
| ✅ Observability | 2 | 23 | 2652 |
| ✅ Observability | 2 | 23 | 2754 |
| ✅ Optimizer | 1 | 2 | 653 |
| ✅ Output | 3 | 4 | 860 |
| ✅ Parallel Execution | 3 | 2 | 399 |
@@ -64,24 +64,24 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Prompts | 2 | 7 | 1569 |
| ✅ Providers | 1 | 52 | 6214 |
| ✅ Query | 1 | 2 | 616 |
| ✅ RAG | 5 | 15 | 3190 |
| ✅ RAG | 5 | 15 | 3193 |
| ✅ Realtime | 2 | 5 | 584 |
| ✅ Reflection | 3 | 2 | 477 |
| ✅ Retrieval | 2 | 5 | 944 |
| ✅ Retrieval | 2 | 5 | 945 |
| ✅ Routing | 1 | 2 | 597 |
| ✅ Sandbox | 5 | 6 | 2478 |
| ✅ Security | 1 | 3 | 2550 |
| ✅ Sessions | 4 | 11 | 3423 |
| ✅ Skills | 6 | 9 | 2983 |
| ✅ Sandbox | 5 | 6 | 2512 |
| ✅ Security | 1 | 3 | 2563 |
| ✅ Sessions | 4 | 12 | 3793 |
| ✅ Skills | 6 | 10 | 3663 |
| ✅ Tasks | 2 | 6 | 3064 |
| ✅ Telemetry | 1 | 2 | 560 |
| ✅ Templates | 1 | 7 | 1411 |
| ✅ Tools | 12 | 118 | 25868 |
| ✅ Tools | 12 | 118 | 25895 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Video | 2 | 6 | 510 |
| ✅ Vision | 2 | 1 | 283 |
| ✅ Web | 3 | 6 | 1352 |
| ✅ Workflows | 4 | 14 | 5810 |
| ✅ Web | 3 | 7 | 1537 |
| ✅ Workflows | 4 | 14 | 5893 |
## Undocumented Categories (Need Documentation)
@@ -235,6 +235,12 @@ _LAZY_IMPORTS = {
'CliBackendResult': ('praisonaiagents.cli_backend.protocols', 'CliBackendResult'),
'CliBackendDelta': ('praisonaiagents.cli_backend.protocols', 'CliBackendDelta'),
# Runtime Selection (Auto harness selection and registry)
'AgentRuntimeProtocol': ('praisonaiagents.runtime.protocols', 'AgentRuntimeProtocol'),
'resolve_runtime': ('praisonaiagents.runtime.registry', 'resolve_runtime'),
'register_runtime': ('praisonaiagents.runtime.registry', 'register_runtime'),
'list_runtimes': ('praisonaiagents.runtime.registry', 'list_runtimes'),
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
@@ -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__)
@@ -207,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
@@ -214,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()
@@ -540,6 +542,7 @@ 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)
@@ -587,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
@@ -2015,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
@@ -2182,6 +2205,12 @@ 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),
@@ -4034,37 +4063,6 @@ Summary:"""
)
return await handoff_obj.execute_async(self, prompt, context)
def _create_subagent(
self,
profile: str,
context: Optional[Dict[str, Any]] = None,
) -> 'Agent':
"""Create a subagent with the specified profile.
Args:
profile: Agent profile name
context: Additional context
Returns:
Configured Agent instance
"""
from ..agents.profiles import get_profile, BUILTIN_PROFILES
# Get profile config
profile_config = get_profile(profile) if profile in BUILTIN_PROFILES else None
if profile_config:
return Agent(
name=f"subagent_{profile}",
instructions=profile_config.system_prompt,
)
else:
# Default subagent
return Agent(
name=f"subagent_{profile}",
instructions=f"You are a {profile} assistant.",
)
# -------------------------------------------------------------------------
# Runtime Capability Management
# -------------------------------------------------------------------------
@@ -4195,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.
@@ -5615,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()
@@ -5663,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()
@@ -614,24 +614,6 @@ Your Goal: {self.goal}"""
# Fallback to str(response) is still fine, but now it's visible
return str(response)
def _process_stream_response(self, messages, temperature, start_time, formatted_tools=None, reasoning_steps=False):
"""Internal helper for streaming response processing with real-time events."""
if self._openai_client is None:
return None
return self._openai_client.process_stream_response(
messages=messages,
model=self.llm,
temperature=temperature,
tools=formatted_tools,
start_time=start_time,
console=self.console,
display_fn=_get_display_functions()['display_generating'] if self.verbose else None,
reasoning_steps=reasoning_steps,
stream_callback=self.stream_emitter.emit,
emit_events=True
)
def _get_compaction_policy(self):
"""
Get the active compaction policy for this agent.
@@ -786,7 +768,7 @@ Your Goal: {self.goal}"""
from ..context.policy import CompactionRoute
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
def _apply_tool_truncation(self, messages, compactor, policy):
def _apply_tool_truncation(self, messages, compactor, policy, log_tag="tool-truncation"):
"""Apply targeted tool output truncation."""
from ..context.policy import CompactionRoute
import logging
@@ -812,7 +794,7 @@ Your Goal: {self.goal}"""
new_tokens = compactor.count_total_tokens(truncated_msgs)
logging.info(
f"[tool-truncation] {self.name}: {original_tokens}→{new_tokens} tokens "
f"[{log_tag}] {self.name}: {original_tokens}→{new_tokens} tokens "
f"(truncated large tool outputs)"
)
@@ -906,36 +888,8 @@ Your Goal: {self.goal}"""
return CompactionRoute.COMPACT_NEEDED, compacted_msgs
async def _apply_tool_truncation_async(self, messages, compactor, policy):
"""Async version of _apply_tool_truncation."""
from ..context.policy import CompactionRoute
import logging
# Create a copy to avoid modifying original
truncated_msgs = []
for msg in messages:
if msg.get("role") == "tool" or msg.get("tool_call_id"):
content = msg.get("content", "")
if isinstance(content, str) and len(content) > 1000:
# Truncate large tool outputs
truncated_msg = msg.copy()
head = content[:300]
tail = content[-200:] if len(content) > 500 else ""
truncated_msg["content"] = f"{head}\n...[truncated {len(content):,} chars for context budget]...\n{tail}"
truncated_msgs.append(truncated_msg)
continue
truncated_msgs.append(msg)
original_tokens = compactor.count_total_tokens(messages)
new_tokens = compactor.count_total_tokens(truncated_msgs)
logging.info(
f"[tool-truncation-async] {self.name}: {original_tokens}→{new_tokens} tokens "
f"(truncated large tool outputs)"
)
return CompactionRoute.TRUNCATE_TOOLS, truncated_msgs
"""Async wrapper around _apply_tool_truncation (no awaitable work involved)."""
return self._apply_tool_truncation(messages, compactor, policy, log_tag="tool-truncation-async")
def _get_next_fallback_model(self, fallback_index):
"""Get the next fallback model from the chain, if available.
@@ -994,6 +948,29 @@ Your Goal: {self.goal}"""
# before_llm_input.messages; adopt that value for the actual LLM call.
messages = before_llm_input.messages
# Pre-call budget guard (zero overhead when _max_budget is None).
# Estimate this call's minimum cost from the known input size plus the
# configured max_tokens output reservation and refuse to dispatch when
# it would breach the ceiling. This turns max_budget into a genuine hard
# cap instead of one that can be overshot by a whole LLM call.
if self._max_budget and self._on_budget_exceeded == "stop":
_est_min_cost = self._estimate_min_call_cost(
messages, getattr(self, 'max_tokens', None)
)
with self._cost_lock:
_projected_cost = self._total_cost + _est_min_cost
_current_cost = self._total_cost
if _projected_cost >= self._max_budget:
raise BudgetExceededError(
f"Agent '{self.name}' would exceed budget before call: "
f"${_current_cost:.4f} + est ${_est_min_cost:.4f} >= "
f"${self._max_budget:.4f}",
budget_type="cost",
limit=self._max_budget,
used=_current_cost,
agent_id=self.name
)
logging.debug(f"{self.name} sending messages to LLM: {messages}")
# Emit LLM request trace event (zero overhead when not set)
@@ -1672,151 +1649,6 @@ Your Goal: {self.goal}"""
task_name, task_description, task_id, response_format, stream_callback, emit_events
)
async def _finalize_unified_achat_response(
self,
response,
original_prompt,
temperature,
tools,
output_json,
output_pydantic,
reasoning_steps,
stream,
messages,
chat_history_length,
start_time,
task_name=None,
task_description=None,
task_id=None,
):
"""Post-process unified achat completion (parity with sync _chat_impl)."""
if not response:
self._truncate_chat_history(chat_history_length)
return None
if hasattr(response, "choices") and response.choices:
msg = response.choices[0].message
content = getattr(msg, "content", None)
response_text = content.strip() if content else ""
if (
reasoning_steps
and getattr(msg, "reasoning_content", None)
):
response_text = msg.reasoning_content.strip()
else:
extracted = self._extract_llm_response_content(response)
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
if output_json or output_pydantic:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
self._truncate_chat_history(chat_history_length)
return None
if not self.self_reflect:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
self._truncate_chat_history(chat_history_length)
return None
# Self-reflection (legacy OpenAI path)
reflection_count = 0
while True:
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
Provide a "satisfactory" status ('yes' or 'no').
Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
reflection_messages = messages + [
{"role": "assistant", "content": response_text},
{"role": "user", "content": reflection_prompt},
]
try:
if self._openai_client is None:
self._append_to_chat_history({"role": "assistant", "content": response_text})
return await self._atrigger_after_agent_hook(
original_prompt, response_text, start_time,
)
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput'],
)
reflection_output = reflection_response.choices[0].message.parsed
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
break
if reflection_count >= self.max_reflect - 1:
break
regenerate_messages = reflection_messages + [
{
"role": "assistant",
"content": (
f"Self Reflection: {reflection_output.reflection} "
f"Satisfactory?: {reflection_output.satisfactory}"
),
},
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
]
new_response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=regenerate_messages,
temperature=temperature,
)
response_text = new_response.choices[0].message.content
reflection_count += 1
except Exception as e:
logging.error("Reflection parsing failed.", exc_info=True)
reflection_count += 1
if reflection_count >= self.max_reflect:
break
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
self._truncate_chat_history(chat_history_length)
return None
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
"""Helper method to execute callbacks and display interaction.
@@ -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.
@@ -35,10 +35,6 @@ class ExecutionMixin:
"""Synchronous sleep - safe for sync contexts only."""
time.sleep(duration)
async def _safe_sleep_async(self, duration: float) -> None:
"""Async sleep - use this in async contexts."""
await asyncio.sleep(duration)
def generate_task(self) -> 'Task':
"""Generate a Task object from the agent's instructions"""
from ..task.task import Task
@@ -918,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'):
@@ -626,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
@@ -393,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
@@ -676,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'),
@@ -711,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'),
@@ -747,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:
@@ -771,6 +831,56 @@ class ToolExecutionMixin:
response=response,
)
DEFAULT_OUTPUT_RESERVATION_TOKENS = 1024
def _estimate_min_call_cost(self, messages, max_tokens=None) -> float:
"""Estimate the minimum cost of an upcoming LLM call before dispatch.
Uses the known input size (messages) plus an output-token reservation
so the budget can be enforced as a hard pre-call ceiling. When the agent
has no ``max_tokens`` configured, a conservative default reservation
(``DEFAULT_OUTPUT_RESERVATION_TOKENS``) is used so a small prompt with a
large provider-default response cannot trivially bypass the guard and
then overshoot the cap. Input tokens are approximated as ~4 characters
per token, matching the rest of the codebase.
Note: This is intentionally a *minimum* estimate. It counts message
content only and does not include tool-schema tokens, so a tool-heavy
request may still cost more than estimated; the reactive post-call
accounting remains the backstop for that case.
Args:
messages: The messages that will be sent to the LLM.
max_tokens: Optional output-token reservation. Falls back to
``DEFAULT_OUTPUT_RESERVATION_TOKENS`` when not provided.
Returns:
Estimated minimum cost in USD for the call.
"""
prompt_chars = 0
if messages:
for _msg in messages:
_content = _msg.get("content") if isinstance(_msg, dict) else None
if isinstance(_content, str):
prompt_chars += len(_content)
elif isinstance(_content, list):
for _part in _content:
if isinstance(_part, dict):
_text = _part.get("text")
if isinstance(_text, str):
prompt_chars += len(_text)
elif _content is not None:
prompt_chars += len(str(_content))
prompt_tokens = prompt_chars // 4
if max_tokens:
completion_tokens = int(max_tokens)
elif prompt_chars > 0:
completion_tokens = self.DEFAULT_OUTPUT_RESERVATION_TOKENS
else:
completion_tokens = 0
return self._calculate_llm_cost(prompt_tokens, completion_tokens)
def _truncate_dict_fields(self, data: dict, tool_name: str, max_field_chars: int = None, tool_call_id: str = None) -> dict:
"""Truncate large string fields in a dict to prevent context overflow."""
if max_field_chars is None:
@@ -35,22 +35,6 @@ class UnifiedExecutionMixin:
with a single async-first implementation plus sync bridge.
"""
def _resolve_cli_backend_at_turn_time(self):
"""
Resolve CLI backend at turn-time instead of construction time.
This allows for runtime-specific backend selection based on model changes
or other runtime conditions.
Returns:
The resolved CLI backend instance, or current backend if no override needed.
"""
# For now, this is a placeholder for future backend override logic
# The runtime resolution infrastructure is in place, but the policy
# decisions about when to switch backends are not yet implemented
# Always return the current backend until override logic is implemented
return getattr(self, '_cli_backend', None)
async def _unified_chat_impl(
self,
prompt: str,
@@ -42,7 +42,7 @@ from typing import Callable, Dict, List, Literal, Optional, Set
# ── New protocol-driven exports ──────────────────────────────────────────────
from .protocols import ApprovalProtocol, ApprovalRequest, ApprovalConfig # noqa: F401
from .protocols import ApprovalDecision # noqa: F401
from .protocols import ApprovalDecision, ApprovalStoreProtocol # noqa: F401
from .backends import AutoApproveBackend, ConsoleBackend, CallbackBackend, AgentApproval # noqa: F401
from .registry import ApprovalRegistry, DEFAULT_DANGEROUS_TOOLS # noqa: F401
@@ -8,8 +8,14 @@ explicit inheritance (structural subtyping).
from __future__ import annotations
import uuid
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Protocol, runtime_checkable
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
def _new_approval_id() -> str:
"""Generate a stable correlation id for an approval request."""
return uuid.uuid4().hex
@dataclass
@@ -17,12 +23,15 @@ class ApprovalRequest:
"""Immutable request for tool-execution approval.
Attributes:
tool_name: Name of the tool requesting approval.
arguments: Arguments the tool will be called with.
risk_level: Risk classification (critical / high / medium / low).
agent_name: Name of the agent that triggered the call (optional).
session_id: Session identifier for tracking (optional).
context: Arbitrary context dict for backend-specific data.
tool_name: Name of the tool requesting approval.
arguments: Arguments the tool will be called with.
risk_level: Risk classification (critical / high / medium / low).
agent_name: Name of the agent that triggered the call (optional).
session_id: Session identifier for tracking (optional).
context: Arbitrary context dict for backend-specific data.
approval_id: Stable correlation id that survives a process restart
and can be matched to an inbound channel callback.
Auto-generated when not supplied.
"""
tool_name: str
@@ -31,6 +40,7 @@ class ApprovalRequest:
agent_name: Optional[str] = None
session_id: Optional[str] = None
context: Dict[str, Any] = field(default_factory=dict)
approval_id: str = field(default_factory=_new_approval_id)
@dataclass
@@ -114,3 +124,52 @@ class ApprovalProtocol(Protocol):
ApprovalDecision indicating whether execution is approved.
"""
...
@runtime_checkable
class ApprovalStoreProtocol(Protocol):
"""Protocol for durable persistence of pending approvals.
A store lets pending approvals survive a process restart: requests are
persisted with a stable ``approval_id``, outstanding ones are re-hydrated
on startup, and decisions are recorded as a durable audit trail.
Behaviour is unchanged when no store is configured — in-memory remains the
zero-dependency default. Heavy backends (e.g. SQLite) live in the wrapper
package; this contract lives in core so any backend and any channel can
interoperate.
Example::
class MyStore:
async def persist(self, approval_id, request, *, expires_at):
...
async def list_pending(self):
return []
async def resolve(self, approval_id, decision):
...
"""
async def persist(
self,
approval_id: str,
request: ApprovalRequest,
*,
expires_at: float,
) -> None:
"""Durably store a pending approval keyed by ``approval_id``."""
...
async def list_pending(self) -> List[Tuple[str, ApprovalRequest]]:
"""Return outstanding (un-resolved, un-expired) pending approvals."""
...
async def resolve(self, approval_id: str, decision: ApprovalDecision) -> None:
"""Record a final decision for ``approval_id`` as an audit trail.
``decision.metadata['terminal']`` may carry an explicit terminal state
(``"approved"``, ``"denied"`` or ``"expired"``) so the timeout path can
be recorded distinctly from a user denial. When absent the state is
derived from ``decision.approved``.
"""
...
@@ -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,
@@ -44,12 +45,19 @@ from .interactive import (
InteractiveContext,
InteractiveRegistry,
InteractiveHandler,
InteractiveAuthorizer,
encode_action,
decode_callback,
create_registry,
get_registry,
register_handler,
unregister_handler,
make_reply_handler,
REPLY_NAMESPACE,
)
from .agent_reply import (
AgentReply,
extract_presentation,
)
from .config import BotConfig, BotOSConfig
@@ -83,13 +91,19 @@ __all__ = [
"BlockType",
"adapt_presentation",
"PlatformCapabilities",
"WebhookVerifierProtocol",
"InteractiveContext",
"InteractiveRegistry",
"InteractiveHandler",
"InteractiveAuthorizer",
"encode_action",
"decode_callback",
"create_registry",
"get_registry",
"register_handler",
"unregister_handler",
"make_reply_handler",
"REPLY_NAMESPACE",
"AgentReply",
"extract_presentation",
]
@@ -0,0 +1,128 @@
"""
Agent-authored reply contract for interactive bot UI.
Defines the seam that lets an agent (or a hook) emit a portable
``MessagePresentation`` as part of a normal reply, alongside optional text.
Channels render the presentation via the existing per-platform renderers and
fall back to text where rich UI is unsupported.
This is a core protocol with no heavy implementations: channel rendering and
session wiring live in the wrapper (praisonai). Producers return one of:
* ``str`` -> plain text (unchanged, fully backward compatible)
* ``MessagePresentation`` -> interactive UI (text fallback derived from text blocks)
* ``AgentReply`` -> explicit text + presentation pairing
* a dict ``{"text": ..., "presentation": ...}`` -> serialised form
``extract_presentation`` normalises any of these into ``(text, presentation)``
so the bot session can stay agnostic about which form the agent used.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Optional, Tuple
from .presentation import MessagePresentation, BlockType
@dataclass
class AgentReply:
"""An agent reply that may carry interactive UI alongside text.
Attributes:
text: Plain-text answer (used as the text fallback for channels that
cannot render rich UI, and as the spoken/preview content otherwise).
presentation: Optional portable presentation with buttons/selects.
"""
text: str = ""
presentation: Optional[MessagePresentation] = None
def to_dict(self) -> dict:
"""Serialise to a plain dict."""
data: dict = {"text": self.text}
if self.presentation is not None:
data["presentation"] = self.presentation.to_dict()
return data
@classmethod
def from_dict(cls, data: dict) -> "AgentReply":
"""Create from a plain dict."""
pres = data.get("presentation")
return cls(
text=data.get("text", "") or "",
presentation=(
MessagePresentation.from_dict(pres)
if isinstance(pres, dict)
else pres
),
)
def _text_from_presentation(presentation: MessagePresentation) -> str:
"""Derive a plain-text fallback from a presentation's text/context blocks."""
parts = []
for block in presentation.blocks:
btype = block.type.value if isinstance(block.type, BlockType) else block.type
if btype in (BlockType.TEXT.value, BlockType.CONTEXT.value) and block.text:
parts.append(block.text)
return "\n\n".join(parts)
def extract_presentation(
result: Any,
) -> Tuple[str, Optional[MessagePresentation]]:
"""Normalise an agent result into ``(text, presentation)``.
Accepts the plain ``str`` reply path (returns ``(result, None)``), an
``AgentReply``, a ``MessagePresentation`` (text fallback derived from its
text blocks), a serialised dict, or any object exposing a ``presentation``
attribute. This keeps the bot session backward compatible: existing
string-returning agents are unaffected, while presentation-aware agents get
their UI rendered.
Args:
result: The value returned/emitted by an agent turn.
Returns:
A ``(text, presentation)`` tuple. ``presentation`` is ``None`` when the
result carried no interactive UI.
"""
if result is None:
return ("", None)
if isinstance(result, str):
return (result, None)
if isinstance(result, AgentReply):
text = result.text
if not text and result.presentation is not None:
text = _text_from_presentation(result.presentation)
return (text, result.presentation)
if isinstance(result, MessagePresentation):
return (_text_from_presentation(result), result)
if isinstance(result, dict) and (
"presentation" in result or "blocks" in result
):
if "blocks" in result and "presentation" not in result:
pres = MessagePresentation.from_dict(result)
return (_text_from_presentation(pres), pres)
reply = AgentReply.from_dict(result)
text = reply.text
if not text and reply.presentation is not None:
text = _text_from_presentation(reply.presentation)
return (text, reply.presentation)
# Duck-typed: any object exposing a ``presentation`` attribute.
pres = getattr(result, "presentation", None)
if isinstance(pres, MessagePresentation):
text = getattr(result, "text", None)
if not text:
text = getattr(result, "output", None) or _text_from_presentation(pres)
return (str(text), pres)
# Unknown shape: stringify so the text path stays robust.
return (str(result), None)
@@ -45,6 +45,66 @@ class InteractiveContext:
InteractiveHandler = Callable[[InteractiveContext], Awaitable[Optional[str]]]
# An authorizer decides whether the resolving actor (context.user_id) is
# permitted to act on a given interactive callback. Returning False rejects
# the callback before the handler runs, so unauthorized clicks never resolve
# privileged actions (e.g. tool approvals).
InteractiveAuthorizer = Callable[[InteractiveContext], bool]
REPLY_NAMESPACE = "reply"
# Marker prefixing a hashed (non-routable) reply value. Mirrors
# ``presentation.REPLY_HASH_MARKER``; kept local so the inbound handler does not
# import the (heavier) render module. When an agent reply value is too long for
# the channel callback byte-cap, the encoder emits ``reply:#<digest>`` instead
# of a lossy prefix; the handler detects this marker and declines to route a
# value the agent never authored.
_REPLY_HASH_MARKER = "#"
def make_reply_handler(
continue_turn: Callable[[str, "InteractiveContext"], Awaitable[Optional[str]]],
) -> InteractiveHandler:
"""Build a handler for agent-authored ``reply`` button/select callbacks.
A ``reply`` action (see ``PresentationAction.reply``) carries the value the
user chose. When clicked, the channel dispatches the ``reply:<value>``
callback to this handler, which extracts ``<value>`` and feeds it back into
the agent turn via *continue_turn* — without channels inferring intent from
``/``-prefixed strings.
Args:
continue_turn: Async callable ``(value, context) -> Optional[str]`` that
continues the agent loop with the chosen value as the next input and
returns the agent's response (or ``None``).
Returns:
An ``InteractiveHandler`` to register under the ``reply`` namespace.
"""
async def _handler(context: "InteractiveContext") -> Optional[str]:
payload = context.platform_data.get("decoded_payload") or {}
value = payload.get("value")
if value is None:
# Fall back to parsing the raw callback when dispatch didn't decode.
data = context.callback_data or ""
if data.startswith(f"{REPLY_NAMESPACE}:"):
value = data[len(REPLY_NAMESPACE) + 1:]
if value is None:
return None
if value.startswith(_REPLY_HASH_MARKER):
# The original value was too long for the channel callback byte-cap
# and was hashed for transport. Feeding the digest into the turn
# would route a value the agent never authored, so decline instead.
logger.warning(
"Reply value exceeded the channel callback limit and could not "
"be routed; keep quick-reply values short enough to fit the cap."
)
return None
return await continue_turn(value, context)
return _handler
def encode_action(namespace: str, action: "PresentationAction") -> str:
"""Encode an action with namespace for callback data.
@@ -58,7 +118,15 @@ def encode_action(namespace: str, action: "PresentationAction") -> str:
"""
from .presentation import ActionType
if action.type == ActionType.CALLBACK:
if action.type == ActionType.REPLY:
# Reply actions route the chosen value back into the next agent turn.
# The ``reply:`` namespace is reserved for this; the value is the input.
# Only ``None`` falls back to the caller namespace — an explicit empty
# string is a valid reply value (``make_reply_handler`` accepts it).
if action.value is not None:
return f"reply:{action.value}"
return namespace
elif action.type == ActionType.CALLBACK:
# For callback type, encode namespace with the value
if action.value:
return f"{namespace}:{action.value}"
@@ -111,22 +179,35 @@ class InteractiveRegistry:
def __init__(self):
"""Initialize the registry."""
self._handlers: Dict[str, InteractiveHandler] = {}
self._authorizers: Dict[str, "InteractiveAuthorizer"] = {}
self._fallback_handler: Optional[InteractiveHandler] = None
def register(
self,
namespace: str,
handler: InteractiveHandler
handler: InteractiveHandler,
authorize: Optional["InteractiveAuthorizer"] = None,
) -> None:
"""Register a handler for a namespace.
Args:
namespace: The namespace to handle (e.g., "approval", "menu")
handler: Async function to handle callbacks in this namespace
authorize: Optional callable that receives the InteractiveContext
and returns True if the resolving actor (``context.user_id``)
is permitted to act on this callback. When provided, an
unauthorized click is rejected before the handler runs, so it
never resolves the underlying action. When omitted, behaviour
is unchanged (any clicker is allowed) for backward
compatibility.
"""
if namespace in self._handlers:
logger.warning(f"Overwriting existing handler for namespace: {namespace}")
self._handlers[namespace] = handler
if authorize is not None:
self._authorizers[namespace] = authorize
else:
self._authorizers.pop(namespace, None)
logger.debug(f"Registered handler for namespace: {namespace}")
def unregister(self, namespace: str) -> None:
@@ -137,6 +218,7 @@ class InteractiveRegistry:
"""
if namespace in self._handlers:
del self._handlers[namespace]
self._authorizers.pop(namespace, None)
logger.debug(f"Unregistered handler for namespace: {namespace}")
def set_fallback(self, handler: InteractiveHandler) -> None:
@@ -162,6 +244,28 @@ class InteractiveRegistry:
handler = self._handlers.get(namespace)
if handler:
# Enforce actor authorization before resolving the action.
# This is the security boundary: an unauthorized clicker must
# never resolve a privileged callback (e.g. a tool approval).
authorizer = self._authorizers.get(namespace)
if authorizer is not None:
try:
allowed = authorizer(context)
except Exception as e:
logger.error(
f"Authorizer for namespace '{namespace}' raised; "
f"denying callback: {e}"
)
allowed = False
if not allowed:
logger.warning(
f"Unauthorized interactive callback: actor "
f"'{context.user_id}' rejected for namespace "
f"'{namespace}'"
)
context.platform_data["authorized"] = False
return False
try:
# Add decoded payload to context
context.platform_data["decoded_namespace"] = namespace
@@ -28,6 +28,7 @@ class ActionType(str, Enum):
CALLBACK = "callback" # Opaque callback data for the plugin
URL = "url" # Open a URL
WEB_APP = "web_app" # Open a web app (Telegram mini apps, etc)
REPLY = "reply" # Feed value back into the agent turn as next input
class ButtonStyle(str, Enum):
@@ -92,6 +93,33 @@ class PresentationAction:
web_app_url=data.get("web_app_url"),
)
@classmethod
def reply(cls, value: str) -> "PresentationAction":
"""Create a ``reply`` action that feeds *value* back into the agent turn.
When a user clicks a button (or picks a select option) carrying a reply
action, the chosen *value* is routed back through the interactive
registry as the next agent input — no ``/``-prefixed command parsing by
channels required. See ``ActionType.REPLY`` and the ``reply`` namespace
handler in ``interactive.py``.
"""
return cls(type=ActionType.REPLY, value=value)
@classmethod
def callback(cls, value: str) -> "PresentationAction":
"""Create an opaque ``callback`` action carrying *value*."""
return cls(type=ActionType.CALLBACK, value=value)
@classmethod
def command(cls, command: str) -> "PresentationAction":
"""Create a ``command`` action that runs a slash *command*."""
return cls(type=ActionType.COMMAND, command=command)
@classmethod
def open_url(cls, url: str) -> "PresentationAction":
"""Create a ``url`` action that opens *url*."""
return cls(type=ActionType.URL, url=url)
@dataclass
class PresentationButton:
@@ -224,6 +252,33 @@ class PresentationBlock:
def make_context(content: str) -> "PresentationBlock":
"""Create a context block (smaller text)."""
return PresentationBlock(type=BlockType.CONTEXT, text=content)
@staticmethod
def quick_replies(
choices: List[Any],
priority_base: int = 0,
) -> "PresentationBlock":
"""Create a row of quick-reply buttons from ``(label, value)`` pairs.
Each choice may be a ``(label, value)`` tuple or a plain string (used as
both label and value). Every button carries a ``reply`` action so a
click feeds ``value`` back into the next agent turn. This is the
agent-facing shortcut for the common "pick one" interaction.
"""
items: List["PresentationButton"] = []
for choice in choices:
if isinstance(choice, (tuple, list)) and len(choice) >= 2:
label, value = choice[0], choice[1]
else:
label = value = choice
items.append(
PresentationButton(
label=str(label),
action=PresentationAction.reply(str(value)),
priority=priority_base,
)
)
return PresentationBlock(type=BlockType.BUTTONS, buttons=items)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for serialization."""
@@ -471,9 +526,22 @@ def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> Pre
action = button.action
url = button.url
if action is not None and not limits.supports_web_apps:
if action is not None:
action_type = action.type.value if isinstance(action.type, ActionType) else action.type
if action_type == ActionType.WEB_APP.value and action.web_app_url:
# Degrade reply -> callback so existing per-channel renderers (which only
# map command/callback/url/web_app) can carry the payload. The
# ``reply:`` prefix lets the inbound registry route the chosen value
# back into the next agent turn (see interactive.py).
if action_type == ActionType.REPLY.value and action.value is not None:
action = PresentationAction(
type=ActionType.CALLBACK,
value=_encode_reply_callback(action.value),
)
elif (
not limits.supports_web_apps
and action_type == ActionType.WEB_APP.value
and action.web_app_url
):
# Degrade web_app -> url when unsupported
if url is None:
url = action.web_app_url
@@ -489,6 +557,40 @@ def _adapt_button(button: PresentationButton, limits: PresentationLimits) -> Pre
)
REPLY_CALLBACK_PREFIX = "reply:"
# Marker prefixing a hashed (non-routable) reply payload. A reply value that
# does not fit the channel callback byte-cap is encoded as
# ``reply:#<digest>``. The ``#`` marker lets the inbound reply handler detect a
# lossy payload and refuse to feed a corrupted/colliding value into the next
# agent turn (see ``make_reply_handler``), rather than silently routing a
# truncated prefix that two distinct choices could share.
REPLY_HASH_MARKER = "#"
def _encode_reply_callback(value: str) -> str:
"""Build a channel-safe callback payload for a ``reply`` action.
Produces ``reply:<value>`` so the inbound interactive registry can route
the chosen value back into the next agent turn. The size check is measured
in UTF-8 bytes because channel callback caps (e.g. Telegram's 64-byte cap)
are byte limits, not character limits.
When the raw form exceeds ``_MAX_CALLBACK_LEN`` the value is replaced with a
short, collision-resistant hash marked with ``#`` (``reply:#<digest>``).
Truncating to a prefix was unsafe: two long choices sharing a prefix would
collapse to the same payload, and the agent would receive a value it never
authored. The hash keeps distinct choices distinct; the marker lets the
reply handler recognise that the original value could not be carried inline
and avoid routing a lossy value into the turn.
"""
raw = f"{REPLY_CALLBACK_PREFIX}{value}"
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
return raw
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
return f"{REPLY_CALLBACK_PREFIX}{REPLY_HASH_MARKER}{digest}"
def _encode_select_callback(action_id: str, value: str) -> str:
"""Build a channel-safe callback payload for a degraded select option.
@@ -499,7 +601,7 @@ def _encode_select_callback(action_id: str, value: str) -> str:
collision-resistant hash so distinct options stay distinct after truncation.
"""
raw = f"select:{action_id}:{value}"
if len(raw) <= _MAX_CALLBACK_LEN:
if len(raw.encode("utf-8")) <= _MAX_CALLBACK_LEN:
return raw
digest = hashlib.sha1(value.encode("utf-8")).hexdigest()[:16]
prefix = f"select:{action_id}:"
@@ -20,6 +20,7 @@ from typing import (
Callable,
Dict,
List,
Mapping,
Optional,
Protocol,
TypedDict,
@@ -85,6 +86,24 @@ class PlatformCapabilities:
max_files_per_message: Maximum number of file attachments per message
max_file_size_mb: Maximum file size in megabytes
supported_file_types: List of supported file extensions/mime types
accepts_webhooks: Whether the platform delivers inbound messages via webhook
verifies_webhook_signature: Whether the adapter verifies inbound webhook
authenticity. Adapters that accept webhooks MUST set this True and
expose a ``webhook_verifier`` so central ingress can enforce it
fail-closed.
reconciles_unknown_send: Whether the adapter can confirm whether a prior
send attempt actually landed (e.g. via a provider message-status
lookup keyed on the idempotency key). When True the durable outbox
reconciles an in-flight (``sending``) entry on restart before
re-dispatch, upgrading delivery from at-least-once to
effectively-once. When False, delivery remains at-least-once and a
crash mid-send may re-send the message.
supports_idempotency_token: Whether the transport accepts a
provider-level idempotency token so the platform itself
de-duplicates a retried send. This is an informational capability
descriptor only; the durable outbox does not forward a token on
resend, so adapters relying on provider-side dedupe should also set
``reconciles_unknown_send`` to get effectively-once delivery.
"""
max_message_length: int = 4096
@@ -97,6 +116,10 @@ class PlatformCapabilities:
max_files_per_message: int = 1
max_file_size_mb: int = 10
supported_file_types: List[str] = field(default_factory=lambda: ["*"])
accepts_webhooks: bool = False
verifies_webhook_signature: bool = False
reconciles_unknown_send: bool = False
supports_idempotency_token: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -111,6 +134,10 @@ class PlatformCapabilities:
"max_files_per_message": self.max_files_per_message,
"max_file_size_mb": self.max_file_size_mb,
"supported_file_types": self.supported_file_types,
"accepts_webhooks": self.accepts_webhooks,
"verifies_webhook_signature": self.verifies_webhook_signature,
"reconciles_unknown_send": self.reconciles_unknown_send,
"supports_idempotency_token": self.supports_idempotency_token,
}
@classmethod
@@ -127,9 +154,46 @@ class PlatformCapabilities:
max_files_per_message=data.get("max_files_per_message", 1),
max_file_size_mb=data.get("max_file_size_mb", 10),
supported_file_types=data.get("supported_file_types", ["*"]),
accepts_webhooks=data.get("accepts_webhooks", False),
verifies_webhook_signature=data.get("verifies_webhook_signature", False),
reconciles_unknown_send=data.get("reconciles_unknown_send", False),
supports_idempotency_token=data.get("supports_idempotency_token", False),
)
@runtime_checkable
class WebhookVerifierProtocol(Protocol):
"""Protocol for verifying inbound webhook authenticity.
Internet-facing bot adapters (Slack, WhatsApp, Linear, AgentMail, …)
receive webhooks that must be authenticated before they become an agent
run. This protocol declares the contract so the wrapper ingress can
enforce verification centrally and fail-closed: an adapter that declares
``PlatformCapabilities.accepts_webhooks`` must expose a verifier and that
verifier must pass before dispatch.
Implementations live in the ``praisonai`` wrapper package and typically
wrap an HMAC comparison over the raw request body.
Example:
class SlackVerifier:
def verify(self, *, headers, raw_body):
...
"""
def verify(self, *, headers: Mapping[str, str], raw_body: bytes) -> bool:
"""Return True if the inbound webhook is authentic.
Args:
headers: Inbound request headers (case-insensitive mapping).
raw_body: The exact raw request body bytes (pre-parse).
Returns:
True if the signature/secret verifies, False otherwise.
"""
...
class MessageType(str, Enum):
"""Types of bot messages."""
@@ -145,6 +145,10 @@ class LearnConfig:
db_url: Optional[str] = None # Database connection URL (for non-file backends)
store_path: Optional[str] = None # Custom storage path (for file backend)
# Retention / lifecycle governance (0 = unbounded; backward-compatible default)
max_entries: int = 0 # Per-store cap; least-used/oldest archived beyond it
retention_days: int = 0 # Archive entries unused for this many days (staleness)
# LLM for learning extraction
llm: Optional[str] = None # LLM model for extracting learnings (defaults to agent's LLM)
@@ -166,6 +170,8 @@ class LearnConfig:
"backend": self.backend.value if isinstance(self.backend, LearnBackend) else self.backend,
"db_url": self.db_url,
"store_path": self.store_path,
"max_entries": self.max_entries,
"retention_days": self.retention_days,
"llm": self.llm,
}
@@ -739,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
@@ -833,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')
@@ -861,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),
@@ -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)
@@ -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,
@@ -19,6 +19,7 @@ from .protocols import (
GatewayMessage,
EventType,
OperatorScope,
GatewayCloseCode,
# Push protocols and dataclasses
PushChannelProtocol,
PresenceProtocol,
@@ -33,6 +34,24 @@ from .protocols import (
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,
@@ -42,6 +61,13 @@ from .protocols import (
GapInfo,
ResumeSnapshot,
)
from .hooks import (
HookAction,
HookConfig,
InboundTriggerProtocol,
render_template,
compute_idempotency_key,
)
from .config import (
GatewayConfig,
SessionConfig,
@@ -103,6 +129,7 @@ __all__ = [
"GatewayMessage",
"EventType",
"OperatorScope",
"GatewayCloseCode",
# Push protocols (always available)
"PushChannelProtocol",
"PresenceProtocol",
@@ -117,6 +144,24 @@ __all__ = [
"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",
@@ -125,6 +170,12 @@ __all__ = [
"ProtocolHelloOk",
"GapInfo",
"ResumeSnapshot",
# Inbound trigger / webhook contract (Issue #2281)
"HookAction",
"HookConfig",
"InboundTriggerProtocol",
"render_template",
"compute_idempotency_key",
# Config (always available)
"GatewayConfig",
"SessionConfig",
@@ -229,6 +229,9 @@ class GatewayConfig:
ssl_cert: Path to SSL certificate (for HTTPS/WSS)
ssl_key: Path to SSL key
max_buffered_bytes: Maximum buffered bytes before slow consumer disconnect (default 1MB)
max_queued_frames: Maximum queued outbound frames per client before slow
consumer disconnect (0 = unlimited frame count; byte ceiling still
applies). Default 1000.
push: Push notification service configuration
auth_scopes: Optional operator scope policy mapping token -> list of
scope names (see OperatorScope). When None/empty (default), any
@@ -251,6 +254,7 @@ class GatewayConfig:
ssl_cert: Optional[str] = None
ssl_key: Optional[str] = None
max_buffered_bytes: int = 1024 * 1024 # 1MB default
max_queued_frames: int = 1000 # Per-client outbound frame ceiling
push: PushConfig = field(default_factory=PushConfig)
auth_scopes: Optional[Dict[str, List[str]]] = None
@@ -262,6 +266,10 @@ class GatewayConfig:
raise ValueError(
"max_buffered_bytes must be >= 0 (use 0 to disable slow-consumer checks)"
)
if self.max_queued_frames < 0:
raise ValueError(
"max_queued_frames must be >= 0 (use 0 to disable the frame-count ceiling)"
)
if self.max_connections < 0:
raise ValueError("max_connections must be >= 0")
if self.heartbeat_interval < 0:
@@ -335,6 +343,7 @@ class GatewayConfig:
"reconnect_timeout": self.reconnect_timeout,
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
"max_buffered_bytes": self.max_buffered_bytes,
"max_queued_frames": self.max_queued_frames,
"push": self.push.to_dict(),
"scope_policy_enabled": self.has_scope_policy,
}
@@ -429,6 +438,7 @@ class MultiChannelGatewayConfig:
gateway: GatewayConfig = field(default_factory=GatewayConfig)
agents: Dict[str, Dict[str, Any]] = field(default_factory=dict)
channels: Dict[str, ChannelRouteConfig] = field(default_factory=dict)
hooks: List[Any] = field(default_factory=list)
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "MultiChannelGatewayConfig":
@@ -510,7 +520,8 @@ class MultiChannelGatewayConfig:
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
ssl_cert=gw_data.get("ssl_cert"),
ssl_key=gw_data.get("ssl_key"),
max_buffered_bytes=gw_data.get("max_buffered_bytes", 1024 * 1024),
max_buffered_bytes=int(gw_data.get("max_buffered_bytes", 1024 * 1024)),
max_queued_frames=int(gw_data.get("max_queued_frames", 1000)),
auth_scopes=auth_scopes,
)
@@ -533,10 +544,29 @@ class MultiChannelGatewayConfig:
},
)
# Parse inbound trigger hooks (Issue #2281). The hooks live either at
# the top level (``hooks:``) or nested under ``gateway:`` for grouping.
from .hooks import HookConfig
raw_hooks = data.get("hooks")
if raw_hooks is None:
raw_hooks = gw_data.get("hooks")
hooks: List[HookConfig] = []
for entry in raw_hooks or []:
if isinstance(entry, dict) and entry.get("path"):
try:
hooks.append(HookConfig.from_dict(entry))
except (ValueError, TypeError):
import logging
logging.getLogger(__name__).warning(
"Skipping invalid gateway hook entry: %s", entry
)
return cls(
gateway=gateway_config,
agents=agents,
channels=channels,
hooks=hooks,
)
def to_dict(self) -> Dict[str, Any]:
@@ -547,4 +577,5 @@ class MultiChannelGatewayConfig:
"channels": {
name: ch.to_dict() for name, ch in self.channels.items()
},
"hooks": [h.to_dict() for h in self.hooks],
}
@@ -0,0 +1,286 @@
"""
Inbound trigger (webhook) contract for the PraisonAI gateway.
This module is **protocol-only** and carries no heavy dependencies. It defines
the declarative shape of an inbound event trigger — an authenticated HTTP
surface that, on receiving an external payload, runs an agent (or wakes a
session) and delivers the result through a channel.
The HTTP route registration, request authentication, idempotency store, rate
limiting and channel delivery all live in the ``praisonai`` wrapper gateway
(``praisonai.gateway.server``). Here we only describe:
- ``HookConfig``: the declarative trigger definition (path/auth/action/agent/
session/idempotency/delivery/message).
- ``render_template``: a tiny, dependency-free ``{{ payload.x }}`` / ``{x}``
templating helper used to build session keys, idempotency keys and messages
from a JSON payload.
- ``compute_idempotency_key``: derive a stable dedup key from a payload.
- ``InboundTriggerProtocol``: the structural contract a gateway implements to
register hooks.
It sits alongside the existing internal lifecycle hooks (``SCHEDULE_TRIGGER``,
``GATEWAY_START``) as the *inbound* counterpart: those fire from inside the
process; this fires from an external HTTP request.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Protocol, runtime_checkable
__all__ = [
"HookAction",
"HookConfig",
"InboundTriggerProtocol",
"compute_idempotency_key",
"render_template",
]
class HookAction:
"""Allowed hook actions (string constants, kept dependency-free)."""
AGENT = "agent" # Run an agent turn on the templated message.
WAKE = "wake" # Nudge an existing session without a new user message.
@classmethod
def all(cls) -> "list[str]":
return [cls.AGENT, cls.WAKE]
# Matches both ``{{ payload.a.b }}`` (Jinja-ish) and ``{a.b}`` (str.format-ish)
# placeholders so the same template works across the YAML and Python surfaces.
# A single combined pattern is used (double-brace alternative first so it wins)
# so each placeholder is substituted exactly once in one pass — a payload value
# that itself contains ``{...}`` is therefore never re-expanded.
_PLACEHOLDER = re.compile(r"\{\{\s*([^}]+?)\s*\}\}|\{\s*([^{}]+?)\s*\}")
def _lookup(path: str, payload: Dict[str, Any]) -> Any:
"""Resolve a dotted ``path`` against ``payload``.
A leading ``payload.`` prefix is optional, so ``{{ payload.from }}`` and
``{from}`` resolve identically. Missing keys resolve to an empty string so
a template never raises on a partial payload.
"""
key = path.strip()
if key.startswith("payload."):
key = key[len("payload."):]
elif key == "payload":
return payload
current: Any = payload
for part in key.split("."):
part = part.strip()
if not part:
continue
if isinstance(current, dict) and part in current:
current = current[part]
else:
return ""
return current
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
"""Render ``template`` against ``payload`` with no external dependencies.
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
paths into the JSON ``payload``. Returns an empty string when ``template``
is falsy. Unknown placeholders render as empty strings rather than raising.
Args:
template: The template string (or None).
payload: The decoded JSON request body.
Returns:
The rendered string.
"""
if not template:
return ""
def _resolve(match: "re.Match[str]") -> str:
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
expr = match.group(1) if match.group(1) is not None else match.group(2)
value = _lookup(expr, payload)
return "" if value is None else str(value)
return _PLACEHOLDER.sub(_resolve, template)
def compute_idempotency_key(
template: Optional[str],
payload: Dict[str, Any],
*,
path: str = "",
) -> str:
"""Derive a stable idempotency key for a hook delivery.
When ``template`` is provided it is rendered against ``payload`` and the
result (scoped by ``path``) is hashed. When it is not, the whole payload is
hashed deterministically so identical retried deliveries still dedup.
Args:
template: Optional idempotency-key template (e.g. ``"{message_id}"``).
payload: The decoded JSON request body.
path: The hook path, mixed in so the same id on different hooks does
not collide.
Returns:
A hex digest usable as a dedup key.
"""
if template:
rendered = render_template(template, payload).strip()
if rendered:
basis = f"{path}\x00{rendered}"
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
# Fall back to a deterministic hash of the full payload.
import json
try:
canonical = json.dumps(payload, sort_keys=True, separators=(",", ":"))
except (TypeError, ValueError):
canonical = repr(payload)
basis = f"{path}\x00{canonical}"
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
@dataclass
class HookConfig:
"""Declarative definition of an inbound event trigger.
Exposes ``POST /hooks/<path>`` on the gateway. On a request the gateway
authenticates, deduplicates, resolves a session, runs the agent (or wakes a
session), and delivers the reply through the delivery router.
Attributes:
path: URL path segment, e.g. ``"gmail"`` exposes ``POST /hooks/gmail``.
agent: Agent id to run (``action="agent"``). Defaults to the gateway's
first registered agent when omitted.
action: ``"agent"`` runs a turn, ``"wake"`` nudges a session.
auth: Optional bearer token / shared secret required on the request.
When omitted the gateway's own ``auth_token`` is used.
session_key: Template for the session id, so related events share
context, e.g. ``"hook:gmail:{message_id}"``.
idempotency_key: Template for the dedup key, e.g. ``"{message_id}"``.
When omitted the whole payload is hashed.
deliver_to: ``channel:target`` delivery spec for the agent reply, e.g.
``"telegram:123456789"``. Omit to skip outbound delivery.
message: Template for the message built from the payload.
enabled: Whether the hook is active.
metadata: Free-form extra settings.
"""
path: str
agent: Optional[str] = None
action: str = HookAction.AGENT
auth: Optional[str] = None
session_key: Optional[str] = None
idempotency_key: Optional[str] = None
deliver_to: Optional[str] = None
message: Optional[str] = None
enabled: bool = True
metadata: Dict[str, Any] = field(default_factory=dict)
def __post_init__(self) -> None:
self.path = (self.path or "").strip().strip("/")
if not self.path:
raise ValueError("HookConfig.path must be a non-empty path segment")
if self.action not in HookAction.all():
raise ValueError(
f"HookConfig.action must be one of {HookAction.all()}, "
f"got {self.action!r}"
)
@property
def route(self) -> str:
"""The full gateway route this hook is served on."""
return f"/hooks/{self.path}"
def resolve_session_key(self, payload: Dict[str, Any]) -> str:
"""Resolve the session id for ``payload``.
Falls back to a stable per-hook key when no ``session_key`` template is
configured, so a hook without an explicit key still groups its events.
"""
if self.session_key:
rendered = render_template(self.session_key, payload).strip()
if rendered:
return rendered
return f"hook:{self.path}"
def resolve_idempotency_key(self, payload: Dict[str, Any]) -> str:
"""Resolve the dedup key for ``payload``."""
return compute_idempotency_key(
self.idempotency_key, payload, path=self.path
)
def resolve_message(self, payload: Dict[str, Any]) -> str:
"""Render the agent message for ``payload``."""
return render_template(self.message, payload)
def to_dict(self) -> Dict[str, Any]:
"""Convert to a dictionary (hides the auth secret)."""
return {
"path": self.path,
"agent": self.agent,
"action": self.action,
"auth": "***" if self.auth else None,
"session_key": self.session_key,
"idempotency_key": self.idempotency_key,
"deliver_to": self.deliver_to,
"message": self.message,
"enabled": self.enabled,
"metadata": dict(self.metadata),
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "HookConfig":
"""Create a ``HookConfig`` from a parsed YAML/JSON mapping."""
known = {
"path",
"agent",
"action",
"auth",
"session_key",
"idempotency_key",
"deliver_to",
"message",
"enabled",
"metadata",
}
return cls(
path=data.get("path", ""),
agent=data.get("agent"),
action=data.get("action", HookAction.AGENT),
auth=data.get("auth"),
session_key=data.get("session_key"),
idempotency_key=data.get("idempotency_key"),
deliver_to=data.get("deliver_to"),
message=data.get("message"),
enabled=data.get("enabled", True),
metadata={
**(data.get("metadata") or {}),
**{k: v for k, v in data.items() if k not in known},
},
)
@runtime_checkable
class InboundTriggerProtocol(Protocol):
"""Structural contract a gateway implements to host inbound triggers."""
def register_hook(self, hook: "HookConfig | Dict[str, Any]", **kwargs: Any) -> str:
"""Register an inbound trigger, returning its path."""
...
def unregister_hook(self, path: str) -> bool:
"""Remove a registered hook by path."""
...
def list_hooks(self) -> "list[str]":
"""List registered hook paths."""
...
@@ -24,6 +24,7 @@ from typing import (
Literal,
Optional,
Protocol,
Set,
TypedDict,
Union,
runtime_checkable,
@@ -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):
@@ -179,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:
@@ -945,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)
# ---------------------------------------------------------------------------
@@ -1342,6 +1771,266 @@ class OutboundMessengerProtocol(Protocol):
...
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
@@ -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:
@@ -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):
@@ -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
@@ -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)
@@ -189,4 +189,4 @@ class PraisonAIRuntime:
type="error",
content=str(e),
metadata={'runtime': self.runtime_id}
)
)
@@ -26,7 +26,7 @@ if TYPE_CHECKING:
from .parser import parse_schedule
from .runner import ScheduleRunner
from .loop import ScheduleLoop
from .protocols import ScheduleStoreProtocol
from .protocols import ScheduleStoreProtocol, JobConditionProtocol, GateResult
_module_cache = {}
@@ -69,10 +69,30 @@ def __getattr__(name: str):
_module_cache[name] = ConfigYamlScheduleStore
return ConfigYamlScheduleStore
if name == "ScheduleStoreProtocol":
from .protocols import ScheduleStoreProtocol
_module_cache[name] = ScheduleStoreProtocol
return ScheduleStoreProtocol
if name in ("ScheduleStoreProtocol", "JobConditionProtocol", "GateResult"):
from .protocols import (
ScheduleStoreProtocol,
JobConditionProtocol,
GateResult,
)
_module_cache["ScheduleStoreProtocol"] = ScheduleStoreProtocol
_module_cache["JobConditionProtocol"] = JobConditionProtocol
_module_cache["GateResult"] = GateResult
return _module_cache[name]
if name in ("Blueprint", "BlueprintSlot", "BlueprintStoreProtocol"):
from .blueprint_defs import Blueprint, BlueprintSlot, BlueprintStoreProtocol
_module_cache["Blueprint"] = Blueprint
_module_cache["BlueprintSlot"] = BlueprintSlot
_module_cache["BlueprintStoreProtocol"] = BlueprintStoreProtocol
return _module_cache[name]
if name in ("Suggestion", "SuggestionStore", "MAX_PENDING_CAP"):
from .suggestion_store import Suggestion, SuggestionStore, MAX_PENDING_CAP
_module_cache["Suggestion"] = Suggestion
_module_cache["SuggestionStore"] = SuggestionStore
_module_cache["MAX_PENDING_CAP"] = MAX_PENDING_CAP
return _module_cache[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -88,4 +108,12 @@ __all__ = [
"ScheduleRunner",
"ScheduleLoop",
"ScheduleStoreProtocol",
"JobConditionProtocol",
"GateResult",
"Blueprint",
"BlueprintSlot",
"BlueprintStoreProtocol",
"Suggestion",
"SuggestionStore",
"MAX_PENDING_CAP",
]
@@ -0,0 +1,138 @@
"""
Blueprint protocol definitions for PraisonAI Agents.
This module defines the thin protocol layer for automation blueprints —
data structures that carry metadata about parameterized automation
templates without any execution logic. Heavy resolution and resource
handling lives in ``praisonai.scheduler.blueprint_catalogue``.
Usage::
from praisonaiagents.scheduler.blueprint_defs import Blueprint, BlueprintSlot
bp = Blueprint(
name="morning-brief",
description="Daily morning briefing",
slots=[BlueprintSlot(name="hour", type="int", default=8)],
prompt_template="Provide a briefing. Focus: {focus}",
schedule_template="cron:{minute} {hour} * * {weekdays_expression}",
)
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
# ── Slot definition ──────────────────────────────────────────────────────────
@dataclass
class BlueprintSlot:
"""A fillable parameter slot in a blueprint template.
Each slot represents one user-configurable parameter. Slots
have types, defaults, and optional choice constraints that the
catalogue layer validates at resolution time.
"""
name: str
"""Machine-readable slot name (e.g. ``"hour"``, ``"focus"``)."""
type: str = "str"
"""Slot type: ``"int"``, ``"str"``, ``"choice"``, ``"time"``."""
label: str = ""
"""Human-readable label for UI surfaces."""
default: Any = None
"""Default value when the user does not provide one."""
required: bool = True
"""If True, the user must supply a value (unless ``default`` is set)."""
choices: Optional[List[str]] = None
"""Allowed values for ``type="choice"`` slots."""
description: str = ""
"""Help text explaining what this slot controls."""
# ── Blueprint definition ─────────────────────────────────────────────────────
@dataclass
class Blueprint:
"""A parameterized automation template.
Blueprints are named templates with fillable slots. They carry
enough metadata for CLI, YAML, and Python surfaces to present a
consistent authoring experience. The catalogue layer resolves
slots, materializes prompts, and produces concrete schedule
expressions.
This is a protocol-level dataclass — no execution logic here.
"""
name: str
"""Unique blueprint identifier (e.g. ``"morning-brief"``)."""
version: str = "1.0.0"
"""SemVer version string."""
description: str = ""
"""One-paragraph summary shown in listings and help."""
tags: List[str] = field(default_factory=list)
"""Search tags (e.g. ``["daily", "news"]``)."""
category: str = "general"
"""Grouping label for UI surfaces (``"daily"``, ``"monitoring"``, …)."""
slots: List[BlueprintSlot] = field(default_factory=list)
"""Fillable parameters the user can configure."""
default_deliver: str = ""
"""Default delivery target (``"telegram"``, ``"origin"``, etc.)."""
default_agent: str = ""
"""Default agent ID to execute this blueprint."""
prompt_template: str = ""
"""Prompt string with ``{slot_name}`` placeholders filled at resolution."""
schedule_template: str = ""
"""Schedule string with ``{slot_name}`` placeholders.
Supports a special ``{weekdays_expression}`` placeholder that the
catalogue layer converts to a cron day-of-week field.
"""
builtin: bool = True
"""True for the built-in catalogue; False for user-provided YAML blueprints."""
# ── Store protocol ───────────────────────────────────────────────────────────
@runtime_checkable
class BlueprintStoreProtocol(Protocol):
"""Minimal protocol for discovering and registering blueprints.
Any object that satisfies this protocol can serve as a blueprint
source for the catalogue. The built-in :class:`BlueprintCatalogue`
implements this, as could a remote registry or dashboard backend.
"""
def list_blueprints(self, category: Optional[str] = None) -> List[Blueprint]:
"""Return all known blueprints, optionally filtered by *category*."""
...
def get_blueprint(self, name: str) -> Optional[Blueprint]:
"""Return the blueprint named *name*, or ``None`` if not found."""
...
def register_blueprint(self, bp: Blueprint) -> None:
"""Register (or replace) *bp* in the store."""
...
@@ -166,6 +166,18 @@ class ScheduleJob:
to a channel bot (e.g. Telegram chat).
origin: Optional original delivery target where the job was created
(for "origin" token resolution).
pre_run: Optional cheap, deterministic pre-run gate evaluated before
the (expensive) model turn — a shell/python command or a
registered gate name. When it reports "nothing to do" the
tick is recorded as ``skipped`` with no tokens spent and no
delivery. When it reports "go", any output it prints is
appended to ``message`` as context. A cost/efficiency gate,
distinct from the wrapper's safety ``RunPolicy``.
condition: Optional natural-language / expression label describing the
gate's intent. Advisory metadata only — it is round-tripped
and surfaced for readability but is NOT itself enforced; the
default :class:`ShellConditionGate` gates on ``pre_run``. A
custom ``condition_resolver`` may interpret it.
"""
name: str = ""
@@ -180,6 +192,8 @@ class ScheduleJob:
last_run_at: Optional[float] = None
delivery: Optional[DeliveryTarget] = None
origin: Optional[DeliveryTarget] = None
pre_run: Optional[str] = None
condition: Optional[str] = None
# ── serialisation ────────────────────────────────────────────────
@@ -200,6 +214,10 @@ class ScheduleJob:
d["delivery"] = self.delivery.to_dict()
if self.origin is not None:
d["origin"] = self.origin.to_dict()
if self.pre_run is not None:
d["pre_run"] = self.pre_run
if self.condition is not None:
d["condition"] = self.condition
return d
@classmethod
@@ -220,4 +238,6 @@ class ScheduleJob:
last_run_at=d.get("last_run_at"),
delivery=DeliveryTarget.from_dict(delivery_data) if isinstance(delivery_data, dict) else None,
origin=DeliveryTarget.from_dict(origin_data) if isinstance(origin_data, dict) else None,
pre_run=d.get("pre_run"),
condition=d.get("condition"),
)
@@ -7,9 +7,49 @@ Any object implementing these methods can be used as a schedule store.
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, List, Optional, Protocol, runtime_checkable
@dataclass
class GateResult:
"""Decision returned by a job's pre-run condition gate.
Attributes:
run: ``True`` to proceed with the (expensive) model turn,
``False`` to skip this tick — no tokens spent, no delivery.
context: Optional text produced by the gate. When present and
``run`` is ``True``, it is appended to the job message so
the same cheap check both *gates* the run and *seeds* it
with context (e.g. the new emails the agent should summarise).
reason: Optional human-readable note recorded with the run
(e.g. ``"pre-run gate: nothing to do"``).
"""
run: bool = True
context: Optional[str] = None
reason: Optional[str] = None
@runtime_checkable
class JobConditionProtocol(Protocol):
"""Protocol for a cheap, deterministic pre-run gate on a scheduled job.
A gate decides *whether* a job's model turn should happen at all — a
cost/efficiency concern, complementary to (and distinct from) the
wrapper's ``RunPolicy``, which is a *safety* gate on *what* a run may do.
Implementations live in the wrapper (e.g. a shell-command gate) or in a
deployment (a Python callable, an MCP/tool probe). The core only owns this
contract so every front-end (Python ``ScheduleJob``, YAML loader,
agent-callable schedule tools) shares one shape.
"""
def should_run(self, job: Any) -> "GateResult":
"""Return a :class:`GateResult` deciding whether ``job`` should run."""
...
@runtime_checkable
class ScheduleStoreProtocol(Protocol):
"""Protocol for schedule persistence backends.
@@ -0,0 +1,238 @@
"""
Suggestion store — consent-first proposals for proactive automations.
This module provides a thread-safe, atomic JSON-backed store for
pending automation suggestions. The framework may *propose* an
automation (e.g., after detecting a recurring manual ask), but it
only becomes a real scheduled job when the user explicitly accepts it.
**"Nothing is ever auto-created."**
The store enforces:
* **Max-pending cap** (default 20) — prevents flooding.
* **Dedup window** (24 h) — the same blueprint+slots won't be
suggested twice within the window.
* **Atomic writes** — write to temp file, then replace.
Storage location: ``~/.praisonai/suggestions.json``
Usage::
from praisonaiagents.scheduler.suggestion_store import SuggestionStore, Suggestion
store = SuggestionStore()
store.add(Suggestion(
id="sug_abc123",
blueprint_name="morning-brief",
slots={"hour": 8, "weekdays": "mon-fri"},
reason="Daily request pattern detected",
))
"""
from __future__ import annotations
import json
import os
import threading
import time
from dataclasses import dataclass, asdict, field
from typing import Any, Dict, List, Optional
# ── Constants ────────────────────────────────────────────────────────────────
MAX_PENDING_CAP: int = 20
"""Maximum number of pending (undismissed, unaccepted) suggestions."""
DEDUP_WINDOW_SEC: float = 3600 * 24
"""Dedup window: don't suggest the same thing twice within 24 hours."""
DEFAULT_TTL_SEC: float = 3600 * 24 * 3
"""Default TTL for a suggestion (3 days). Expired suggestions are
pruned from the pending list."""
# ── Suggestion dataclass ─────────────────────────────────────────────────────
@dataclass
class Suggestion:
"""A proposed automation that the user has not yet acted on.
Only becomes a real schedule job when the user accepts it via
:meth:`SuggestionStore.accept`.
"""
id: str
"""Unique suggestion identifier (e.g. ``"sug_<random>"``)."""
blueprint_name: str
"""Which blueprint triggered this suggestion."""
slots: Dict[str, Any] = field(default_factory=dict)
"""Filled-in slot values for the blueprint."""
deliver: str = ""
"""Suggested delivery target."""
reason: str = ""
"""Human-readable explanation of why this was suggested."""
created_at: float = field(default_factory=time.time)
"""Unix timestamp when the suggestion was created."""
expires_at: float = 0.0
"""Unix timestamp after which the suggestion auto-expires.
``0`` means no expiry."""
dismissed: bool = False
"""True if the user explicitly dismissed this suggestion."""
accepted: bool = False
"""True if the user accepted this suggestion (job has been created)."""
# ── Suggestion store ─────────────────────────────────────────────────────────
class SuggestionStore:
"""Atomic JSON-backed store for pending automation suggestions.
Thread-safe via :class:`threading.RLock`. Suggestions are persisted
to ``~/.praisonai/suggestions.json`` with atomic writes (write to
``.tmp``, then ``os.replace``).
"""
def __init__(self, path: Optional[str] = None):
if path is None:
from praisonaiagents.paths import get_data_dir
path = str(get_data_dir() / "suggestions.json")
self._path = path
self._lock = threading.RLock()
self._suggestions: Dict[str, Suggestion] = {}
self._load()
# -- persistence -----------------------------------------------------------
def _load(self) -> None:
"""Load existing suggestions from disk (best-effort)."""
if not os.path.exists(self._path):
self._suggestions = {}
return
try:
with open(self._path, "r", encoding="utf-8") as f:
data = json.load(f)
if isinstance(data, dict):
new_suggestions = {}
for sid, d in data.items():
if isinstance(d, dict):
try:
new_suggestions[sid] = Suggestion(**d)
except TypeError:
pass # skip malformed entries
self._suggestions = new_suggestions
except (json.JSONDecodeError, OSError):
self._suggestions = {} # corrupt file → start fresh
def _save(self) -> None:
"""Atomically persist the current state to disk."""
parent = os.path.dirname(self._path)
if parent:
os.makedirs(parent, exist_ok=True)
data = {sid: asdict(s) for sid, s in self._suggestions.items()}
tmp = self._path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, ensure_ascii=False)
os.replace(tmp, self._path)
# -- public API ------------------------------------------------------------
def add(self, suggestion: Suggestion) -> bool:
"""Add a suggestion to the store.
Returns:
``True`` if the suggestion was added, ``False`` if it was
rejected due to the pending cap or dedup window.
"""
with self._lock:
now = time.time()
# Only count active (non-dismissed, non-accepted, non-expired)
# suggestions toward the cap and dedup window.
active = [
s for s in self._suggestions.values()
if not s.dismissed and not s.accepted
and (s.expires_at == 0 or s.expires_at > now)
]
if len(active) >= MAX_PENDING_CAP:
return False
# Dedup check: same blueprint + same slots within the window
for existing in active:
if (
existing.blueprint_name == suggestion.blueprint_name
and existing.slots == suggestion.slots
and (now - existing.created_at) < DEDUP_WINDOW_SEC
):
return False
self._suggestions[suggestion.id] = suggestion
self._save()
return True
def get(self, suggestion_id: str) -> Optional[Suggestion]:
"""Return a suggestion by ID, or ``None``."""
with self._lock:
return self._suggestions.get(suggestion_id)
def list_pending(self) -> List[Suggestion]:
"""Return all undismissed, unaccepted, non-expired suggestions."""
now = time.time()
with self._lock:
return [
s for s in self._suggestions.values()
if not s.dismissed and not s.accepted
and (s.expires_at == 0 or s.expires_at > now)
]
def accept(self, suggestion_id: str) -> bool:
"""Mark a suggestion as accepted.
Returns:
``False`` if the suggestion was not found.
"""
with self._lock:
s = self._suggestions.get(suggestion_id)
if s is None:
return False
s.accepted = True
self._save()
return True
def dismiss(self, suggestion_id: str) -> bool:
"""Mark a suggestion as dismissed (user declined).
Returns:
``False`` if the suggestion was not found.
"""
with self._lock:
s = self._suggestions.get(suggestion_id)
if s is None:
return False
s.dismissed = True
self._save()
return True
def prune_expired(self) -> int:
"""Remove expired suggestions. Returns count of pruned entries."""
now = time.time()
with self._lock:
before = len(self._suggestions)
self._suggestions = {
sid: s for sid, s in self._suggestions.items()
if s.expires_at == 0 or s.expires_at > now
}
pruned = before - len(self._suggestions)
if pruned:
self._save()
return pruned
@@ -37,7 +37,7 @@ from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING, Dict, List, Optional, Any
if TYPE_CHECKING:
from ..gateway.protocols import OutboundMessengerProtocol
from ..gateway.protocols import OutboundMessengerProtocol, SendPolicyProtocol
@dataclass(frozen=True)
@@ -195,6 +195,40 @@ def clear_outbound_messenger(token: Token) -> None:
_MESSENGER.set(None)
# ---------------------------------------------------------------------------
# Outbound send-policy guard (Issue #2226)
#
# An optional task-local policy authorising where an agent may proactively
# send. The running gateway/bot (or a Python ``Bot(send_policy=...)`` caller)
# registers it; the built-in ``send_message`` tool consults it *before*
# dispatch. Absent a policy, today's behaviour is preserved (allow-all).
# ---------------------------------------------------------------------------
_SEND_POLICY: ContextVar[Optional["SendPolicyProtocol"]] = ContextVar(
"praisonai_send_policy", default=None
)
def register_send_policy(
policy: Optional["SendPolicyProtocol"],
) -> Token:
"""Register the active outbound send-policy for this task. Returns a token."""
return _SEND_POLICY.set(policy)
def get_send_policy() -> Optional["SendPolicyProtocol"]:
"""Return the active send-policy, or ``None`` if no policy is configured."""
return _SEND_POLICY.get()
def clear_send_policy(token: Token) -> None:
"""Restore the previous send-policy using the token from register."""
try:
_SEND_POLICY.reset(token)
except (LookupError, ValueError):
_SEND_POLICY.set(None)
# ---------------------------------------------------------------------------
# Gateway event loop registry (Issue #2183)
#
@@ -235,6 +269,9 @@ __all__ = [
"register_outbound_messenger",
"get_outbound_messenger",
"clear_outbound_messenger",
"register_send_policy",
"get_send_policy",
"clear_send_policy",
"register_gateway_loop",
"get_gateway_loop",
"clear_gateway_loop",
@@ -35,6 +35,8 @@ __all__ = [
"to_prompt",
"generate_skills_xml",
"format_skill_for_prompt",
# Learn-from-sources
"build_learn_prompt",
# Discovery
"discover_skills",
"get_default_skill_dirs",
@@ -51,6 +53,8 @@ __all__ = [
"SkillInvocationPolicyProtocol",
"SkillMutatorProtocol",
"SkillActivationProtocol",
"SkillReviewProtocol",
"DefaultSkillReviewPolicy",
# Events
"SkillDiscoveredEvent",
"SkillActivatedEvent",
@@ -80,6 +84,10 @@ def __getattr__(name: str):
if name in ("to_prompt", "generate_skills_xml", "format_skill_for_prompt"):
from .prompt import to_prompt, generate_skills_xml, format_skill_for_prompt
return locals()[name]
if name == "build_learn_prompt":
from .learn import build_learn_prompt
return build_learn_prompt
if name in ("discover_skills", "get_default_skill_dirs"):
from .discovery import discover_skills, get_default_skill_dirs
@@ -105,6 +113,10 @@ def __getattr__(name: str):
from .protocols import SkillSourceProtocol, SkillInvocationPolicyProtocol, SkillMutatorProtocol
return locals()[name]
if name in ("SkillReviewProtocol", "DefaultSkillReviewPolicy"):
from .protocols import SkillReviewProtocol, DefaultSkillReviewPolicy
return locals()[name]
if name == "SkillActivationProtocol":
from .activation import SkillActivationProtocol
return SkillActivationProtocol
@@ -0,0 +1,73 @@
"""Grounded skill-authoring prompt builder for the "learn from sources" flow.
This module hosts :func:`build_learn_prompt`, a curated authoring directive that
turns a user request like *"read ./my-repo and the docs in ./docs/*.pdf and make
a 'deploy-flow' skill"* into a single-turn instruction. The agent then gathers
the named sources with the tools it already has (file read, fetch, PDF read) and
authors **one grounded SKILL.md** via the existing ``skill_manage`` tool.
It is intentionally prompt-only (no heavy imports, no new engine) so it stays in
the core SDK alongside the skills subsystem.
"""
from __future__ import annotations
_LEARN_HOUSE_STYLE = """You are authoring a single, reusable Agent Skill by learning from real source material.
The user's request describes the sources to learn from and the skill to produce:
<request>
{request}
</request>
Follow this procedure exactly:
1. GATHER the named sources using the tools you already have:
- Read local files and directories of code, configs, or notes.
- Read PDFs/manuals if PDF reading is available.
- Fetch API docs / URLs if a fetch tool is available.
- If the request says "what we just did" or "this chat", use the conversation so far as the source.
Only use sources the user actually named. Do not browse unrelated material.
2. DISTIL one grounded SKILL.md. House-style rules that keep it trustworthy:
- Use ONLY flags, paths, commands, environment variables, and API names that appear
VERBATIM in the gathered sources. Never invent or guess names that are not in the source.
- If something is not present in the source, omit it rather than fabricating it.
- Keep SKILL.md tight: roughly 100-200 lines. Move long reference material
(large code, full API tables, transcripts) into supporting files and reference
them by RELATIVE path from SKILL.md.
- Include a YAML frontmatter block with at least `name` and `description`.
- Write a clear, procedural body: when to use the skill, the concrete steps,
and any commands/scripts the user runs, drawn from the real sources.
3. WRITE the skill with the skill_manage tool in a single create call:
skill_manage(action="create", name="<kebab-case-name>", content="<full SKILL.md>")
- Derive the skill name from the request (kebab-case). If the request names one
explicitly (e.g. a 'deploy-flow' skill), use that name.
- For any supporting files, use skill_manage(action="write_file", name=..., file_path=..., file_content=...).
4. Do not perform any work beyond gathering the sources and authoring this one skill.
When the skill is created, briefly summarize the skill name and what it covers.
"""
def build_learn_prompt(request: str) -> str:
"""Build a grounded skill-authoring directive from a user request.
This is the **skill authoring** path: distil a reusable ``SKILL.md`` from
real sources. It is complementary to — and distinct from — the memory
``learn=`` recall path (conversation→memory extraction) and the automatic
``self_improve`` loop.
Args:
request: A 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"``.
Returns:
A prompt string that instructs an agent to gather the named sources with
its existing tools and author one grounded ``SKILL.md`` via
``skill_manage``.
"""
request = (request or "").strip()
return _LEARN_HOUSE_STYLE.replace("{request}", request)
@@ -35,11 +35,16 @@ class SkillManager:
manager.activate(skill)
"""
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None):
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None,
write_approval: Optional[bool] = None):
"""Initialize the SkillManager.
Args:
enforcement_level: Capability enforcement level (defaults to environment-based)
write_approval: When True, skill mutations are staged for human
approval by default (``propose=True``). When None, resolved from
the ``SKILL_WRITE_APPROVAL`` environment variable (default True
for safe-by-default behaviour).
"""
self._skills: Dict[str, LoadedSkill] = {}
self._loader = SkillLoader()
@@ -51,6 +56,19 @@ class SkillManager:
enforcement_level = self._get_default_enforcement_level()
self._validator = CapabilityValidator(enforcement_level)
# Resolve safe-by-default write-approval policy.
if write_approval is None:
write_approval = self._get_default_write_approval()
self._write_approval = write_approval
# Cap the pending-mutation store to bound resource use (configurable
# via SKILL_MAX_PENDING; defaults to 100).
import os
try:
self._max_pending = max(1, int(os.getenv('SKILL_MAX_PENDING', '100')))
except (TypeError, ValueError):
self._max_pending = 100
@property
def skills(self) -> List[LoadedSkill]:
"""Get all loaded skills."""
@@ -226,6 +244,7 @@ class SkillManager:
body = render_shell_blocks(
body, enabled=shell_exec, shell=shell, cwd=skill_dir,
)
self._record_use(skill)
return body
def to_prompt(self) -> str:
@@ -253,6 +272,13 @@ class SkillManager:
if not skill.is_activated:
self.activate(skill)
# Only count a genuinely usable skill: a skill that fails to activate
# (instructions is None) must not accrue use telemetry, otherwise a
# broken skill looks active to the lifecycle curator.
if skill.instructions is None:
return None
self._record_use(skill)
return skill.instructions
def load_resources(self, name: str) -> bool:
@@ -271,17 +297,32 @@ class SkillManager:
self._loader.load_all_resources(skill)
return True
def create_skill(self, name: str, content: str, category: str = None) -> dict:
def create_skill(self, name: str, content: str, category: str = None,
agent_created: bool = True,
propose: Optional[bool] = None) -> dict:
"""Create a new skill with the given content.
Args:
name: Skill name (must be valid identifier)
content: Skill instructions/content for SKILL.md
category: Optional skill category
agent_created: Provenance flag marking the skill as agent-authored.
Defaults to True since skills created via this API originate
from the agent's self-improvement loop. Lifecycle curator
plugins only ever touch agent-created skills.
propose: If True, stage the mutation for human approval instead of
writing to disk. Defaults to the manager's ``write_approval``
policy (safe-by-default). Pass ``propose=False`` to write
directly in trusted/local contexts.
Returns:
Dict with success status and skill info
"""
if self._should_propose(propose):
return self._stage_pending(
"create", name, content=content, category=category,
agent_created=agent_created,
)
try:
# Validate name
if not self._validate_skill_name(name):
@@ -295,28 +336,27 @@ class SkillManager:
if len(content) > 100_000:
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
# Create skill directory
skill_dirs = get_default_skill_dirs()
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
import os
from pathlib import Path
base_path = Path(base_dir).expanduser()
base_path.mkdir(parents=True, exist_ok=True)
# Create skill directory (same base as the pending store target)
base_path = self._skills_base_dir()
skill_path = base_path / name
skill_path.mkdir(exist_ok=True)
# Write SKILL.md with frontmatter
# Write SKILL.md with frontmatter (incl. provenance + usage telemetry)
created_at = self._now_iso()
skill_content = f"""---
name: {name}
version: 1.0.0
"""
if category:
skill_content += f"category: {category}\n"
skill_content += f"""description: Generated skill
author: agent
---
skill_content += "description: Generated skill\n"
skill_content += "author: agent\n"
skill_content += f"agent-created: {str(bool(agent_created)).lower()}\n"
skill_content += f'created-at: "{created_at}"\n'
skill_content += "use-count: 0\n"
skill_content += "patch-count: 0\n"
skill_content += f"""---
{content}
"""
@@ -334,16 +374,22 @@ author: agent
except Exception as e:
return {"success": False, "error": f"Error creating skill: {str(e)}"}
def edit_skill(self, name: str, content: str) -> dict:
def edit_skill(self, name: str, content: str,
propose: Optional[bool] = None) -> dict:
"""Edit an existing skill's content (full rewrite).
Args:
name: Skill name to edit
content: New skill content
propose: If True, stage the mutation for human approval instead of
writing to disk. Defaults to the manager's ``write_approval``
policy. Pass ``propose=False`` to write directly.
Returns:
Dict with success status
"""
if self._should_propose(propose):
return self._stage_pending("edit", name, content=content)
try:
skill = self.get_skill(name)
if not skill:
@@ -364,6 +410,9 @@ author: agent
with open(skill_file, 'r', encoding='utf-8') as f:
existing_content = f.read()
# Snapshot prior content so a broken mutation can be rolled back.
self._save_backup(skill.properties.path, existing_content)
# Extract frontmatter (preserve verbatim, only strip leading fence)
frontmatter = ""
if existing_content.startswith('---\n'):
@@ -379,6 +428,7 @@ author: agent
# Reload the skill
skill.instructions = None # Clear cached content
self.activate(skill)
self._record_patch(skill)
return {"success": True, "skill": name}
@@ -386,7 +436,8 @@ author: agent
return {"success": False, "error": f"Error editing skill: {str(e)}"}
def patch_skill(self, name: str, old_string: str, new_string: str,
file_path: str = None, replace_all: bool = False) -> dict:
file_path: str = None, replace_all: bool = False,
propose: Optional[bool] = None) -> dict:
"""Apply a patch to a skill using fuzzy find-and-replace.
Args:
@@ -395,10 +446,18 @@ author: agent
new_string: Replacement string
file_path: Optional relative path within skill (defaults to SKILL.md)
replace_all: Replace all occurrences
propose: If True, stage the mutation for human approval instead of
writing to disk. Defaults to the manager's ``write_approval``
policy. Pass ``propose=False`` to apply directly.
Returns:
Dict with success status
"""
if self._should_propose(propose):
return self._stage_pending(
"patch", name, old_string=old_string, new_string=new_string,
file_path=file_path, replace_all=replace_all,
)
try:
skill = self.get_skill(name)
if not skill:
@@ -436,12 +495,18 @@ author: agent
# Replace only first occurrence
new_content = content.replace(old_string, new_string, 1)
is_skill_md = file_path is None or file_path == "SKILL.md"
# Snapshot the SKILL.md before mutating so it can be rolled back.
if is_skill_md:
self._save_backup(skill.properties.path, content)
self._write_skill_atomically(target_file, new_content)
# Clear cached content if SKILL.md was modified
if file_path is None or file_path == "SKILL.md":
if is_skill_md:
skill.instructions = None
self.activate(skill)
self._record_patch(skill)
return {"success": True, "skill": name, "replacements": 1}
else:
@@ -450,15 +515,29 @@ author: agent
except Exception as e:
return {"success": False, "error": f"Error patching skill: {str(e)}"}
def delete_skill(self, name: str) -> dict:
"""Delete a skill and its directory.
def delete_skill(self, name: str, hard: bool = False,
propose: Optional[bool] = None) -> dict:
"""Remove a skill.
By default this is a *recoverable* archive: the skill directory is
moved aside into the archive store and can be restored later. This
keeps the self-improvement loop safe — a skill is never lost. Pass
``hard=True`` to permanently delete the directory (legacy behaviour).
Args:
name: Skill name to delete
hard: If True, permanently remove the directory (unrecoverable)
propose: If True, stage the deletion for human approval instead of
removing from disk. Defaults to the manager's ``write_approval``
policy. Pass ``propose=False`` to delete directly.
Returns:
Dict with success status
"""
if self._should_propose(propose):
return self._stage_pending("delete", name, hard=hard)
if not hard:
return self.archive_skill(name)
try:
skill = self.get_skill(name)
if not skill:
@@ -476,18 +555,164 @@ author: agent
except Exception as e:
return {"success": False, "error": f"Error deleting skill: {str(e)}"}
def archive_skill(self, name: str) -> dict:
"""Archive a skill (recoverable, never hard-deleted).
Moves the skill directory into the archive store and drops it from
the active in-memory index. Use :meth:`restore_skill` to recover it.
Lifecycle curator plugins call this for agent-created skills that have
gone stale.
Args:
name: Skill name to archive
Returns:
Dict with success status and archive path
"""
try:
skill = self.get_skill(name)
if not skill:
return {"success": False, "error": f"Skill '{name}' not found"}
if not skill.properties.path:
return {"success": False, "error": f"Cannot archive skill '{name}' - no path available"}
import shutil
from pathlib import Path
archive_dir = self._archive_dir()
archive_dir.mkdir(parents=True, exist_ok=True)
dest = archive_dir / name
# Avoid clobbering a previous archive of the same name.
if dest.exists():
from datetime import datetime, timezone
stamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S")
dest = archive_dir / f"{name}.{stamp}"
shutil.move(str(skill.properties.path), str(dest))
self._skills.pop(name, None)
return {"success": True, "skill": name, "archive_path": str(dest)}
except Exception as e:
return {"success": False, "error": f"Error archiving skill: {str(e)}"}
def restore_skill(self, name: str, skill_dir: Optional[str] = None) -> dict:
"""Restore a previously archived skill back to active use.
Args:
name: Archived skill name
skill_dir: Optional destination directory (defaults to the first
default skill dir)
Returns:
Dict with success status
"""
try:
import shutil
from pathlib import Path
archive_dir = self._archive_dir()
src = archive_dir / name
if not src.exists() or not src.is_dir():
return {"success": False, "error": f"Archived skill '{name}' not found"}
if skill_dir:
base_path = Path(skill_dir).expanduser()
else:
skill_dirs = get_default_skill_dirs()
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
base_path = Path(base_dir).expanduser()
base_path.mkdir(parents=True, exist_ok=True)
dest = base_path / name
if dest.exists():
return {"success": False, "error": f"Skill '{name}' already exists at destination"}
shutil.move(str(src), str(dest))
loaded = self.add_skill(str(dest))
if loaded:
return {"success": True, "skill": name, "path": str(dest)}
return {"success": False, "error": "Failed to load restored skill"}
except Exception as e:
return {"success": False, "error": f"Error restoring skill: {str(e)}"}
def list_archived_skills(self) -> List[str]:
"""List the names of skills currently in the archive store."""
archive_dir = self._archive_dir()
if not archive_dir.exists():
return []
try:
return sorted(
p.name for p in archive_dir.iterdir() if p.is_dir()
)
except OSError:
return []
def rollback_skill(self, name: str) -> dict:
"""Roll a skill's SKILL.md back to its content before the last mutation.
Restores the snapshot saved by the most recent ``edit_skill`` or
``patch_skill`` call, undoing a mutation that broke a previously
working skill.
Note: this is a *single-step* undo. Only the most recent mutation is
snapshotted; sequential ``edit``/``patch`` calls overwrite the prior
snapshot, so rollback cannot step back more than one mutation.
Args:
name: Skill name to roll back
Returns:
Dict with success status
"""
try:
skill = self.get_skill(name)
if not skill:
return {"success": False, "error": f"Skill '{name}' not found"}
if not skill.properties.path:
return {"success": False, "error": f"Cannot roll back skill '{name}' - no path available"}
backup_file = self._backup_path(skill.properties.path)
if not backup_file.exists():
return {"success": False, "error": f"No rollback snapshot for skill '{name}'"}
previous = backup_file.read_text(encoding="utf-8")
skill_file = skill.properties.path / "SKILL.md"
self._write_skill_atomically(skill_file, previous)
# Reload from the restored content.
skill.instructions = None
self.activate(skill)
return {"success": True, "skill": name}
except Exception as e:
return {"success": False, "error": f"Error rolling back skill: {str(e)}"}
def write_skill_file(self, name: str, file_path: str, file_content: str) -> dict:
def write_skill_file(self, name: str, file_path: str, file_content: str,
propose: Optional[bool] = None) -> dict:
"""Write a file within a skill's directory.
Args:
name: Skill name
file_path: Relative path within skill (must be under allowed subdirs)
file_content: File content to write
propose: If True, stage the write for human approval instead of
writing to disk. Defaults to the manager's ``write_approval``
policy. Pass ``propose=False`` to write directly.
Returns:
Dict with success status
"""
if self._should_propose(propose):
return self._stage_pending(
"write_file", name, file_path=file_path,
file_content=file_content,
)
try:
skill = self.get_skill(name)
if not skill:
@@ -525,16 +750,22 @@ author: agent
except Exception as e:
return {"success": False, "error": f"Error writing skill file: {str(e)}"}
def remove_skill_file(self, name: str, file_path: str) -> dict:
def remove_skill_file(self, name: str, file_path: str,
propose: Optional[bool] = None) -> dict:
"""Remove a file from a skill's directory.
Args:
name: Skill name
file_path: Relative path within skill to remove
propose: If True, stage the removal for human approval instead of
removing from disk. Defaults to the manager's ``write_approval``
policy. Pass ``propose=False`` to remove directly.
Returns:
Dict with success status
"""
if self._should_propose(propose):
return self._stage_pending("remove_file", name, file_path=file_path)
try:
skill = self.get_skill(name)
if not skill:
@@ -560,6 +791,117 @@ author: agent
except Exception as e:
return {"success": False, "error": f"Error removing skill file: {str(e)}"}
@staticmethod
def _now_iso() -> str:
"""Return the current UTC time as an ISO 8601 string."""
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()
def _archive_dir(self):
"""Directory where archived skills are stored (recoverable)."""
from pathlib import Path
skill_dirs = get_default_skill_dirs()
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
return Path(base_dir).expanduser().parent / "skills_archive"
@staticmethod
def _backup_path(skill_path):
"""Path to the rollback snapshot for a skill's SKILL.md."""
return skill_path / ".skill.bak"
def _save_backup(self, skill_path, content: str) -> None:
"""Persist a rollback snapshot of the current SKILL.md content."""
try:
self._write_skill_atomically(self._backup_path(skill_path), content)
except Exception:
logger.debug("Failed to save rollback snapshot for %s", skill_path, exc_info=True)
def _record_use(self, skill) -> None:
"""Increment in-memory usage telemetry and persist to frontmatter."""
try:
props = skill.properties
props.use_count = (props.use_count or 0) + 1
props.last_used = self._now_iso()
self._update_frontmatter_fields(
props.path,
{"use-count": props.use_count, "last-used": props.last_used},
)
except Exception:
logger.debug("Failed to record skill use telemetry", exc_info=True)
def _record_patch(self, skill) -> None:
"""Increment in-memory patch telemetry and persist to frontmatter."""
try:
props = skill.properties
props.patch_count = (props.patch_count or 0) + 1
self._update_frontmatter_fields(
props.path, {"patch-count": props.patch_count}
)
except Exception:
logger.debug("Failed to record skill patch telemetry", exc_info=True)
def _update_frontmatter_fields(self, skill_path, fields: dict) -> None:
"""Update or insert simple scalar keys in a SKILL.md frontmatter block.
Operates line-wise so existing (possibly hand-authored) frontmatter is
preserved verbatim. No-op if there is no path or no frontmatter.
"""
if skill_path is None or not fields:
return
from .parser import find_skill_md
skill_md = find_skill_md(skill_path)
if skill_md is None:
return
try:
content = skill_md.read_text(encoding="utf-8")
except OSError:
return
if not content.startswith("---"):
return
# Split only on a fence that sits on its own line so embedded "---"
# sequences inside frontmatter values (e.g. "foo---bar") cannot
# fragment the block and corrupt the file. The opening fence is the
# leading "---"; the closing fence is the next "\n---\n".
after_open = content[3:]
fm_body, sep, body = after_open.partition("\n---\n")
if not sep:
return
def _fmt(value):
# Quote timestamp-like strings so YAML preserves them as strings
# (e.g. the 'T' separator) rather than coercing to a datetime.
if isinstance(value, str) and (":" in value or "-" in value):
return f'"{value}"'
return value
fm_lines = fm_body.split("\n")
remaining = dict(fields)
new_lines = []
for line in fm_lines:
stripped = line.strip()
replaced = False
if stripped and ":" in stripped and not stripped.startswith("#"):
key = stripped.split(":", 1)[0].strip()
if key in remaining:
new_lines.append(f"{key}: {_fmt(remaining.pop(key))}")
replaced = True
if not replaced:
new_lines.append(line)
# Insert any keys that were not already present, before the trailing
# blank line that precedes the closing fence.
for key, value in remaining.items():
insert_at = len(new_lines)
while insert_at > 0 and new_lines[insert_at - 1].strip() == "":
insert_at -= 1
new_lines.insert(insert_at, f"{key}: {_fmt(value)}")
new_content = "---" + "\n".join(new_lines) + "\n---\n" + body
try:
self._write_skill_atomically(skill_md, new_content)
except Exception:
logger.debug("Failed to persist frontmatter telemetry for %s", skill_path, exc_info=True)
def _validate_skill_name(self, name: str) -> bool:
"""Validate skill name according to security constraints."""
import re
@@ -669,7 +1011,301 @@ author: agent
}
return level_map.get(level_str, EnforcementLevel.WARN)
def _get_default_write_approval(self) -> bool:
"""Resolve the default write-approval policy.
Safe-by-default: skill mutations are staged for approval unless the
``SKILL_WRITE_APPROVAL`` environment variable explicitly disables it
(e.g. ``0``/``false``/``off`` for trusted local contexts).
"""
import os
raw = os.getenv('SKILL_WRITE_APPROVAL')
if raw is None:
return True
return raw.strip().lower() not in ('0', 'false', 'off', 'no', 'disabled')
# ── Approval gate (SkillMutatorProtocol) ──────────────────────────
def _should_propose(self, propose: Optional[bool]) -> bool:
"""Decide whether a mutation should be staged for approval."""
if propose is None:
return self._write_approval
return bool(propose)
def _skills_base_dir(self):
"""Return the base skills directory where mutations are written.
Always targets the user-owned skills directory (honours
``PRAISONAI_HOME``). ``get_default_skill_dirs()`` is intentionally not
used here because its first entry can be a project-scoped or
admin-managed (e.g. ``/etc/praison/skills``) location that mutations
must never write into.
"""
from ..paths import get_skills_dir
base = get_skills_dir()
base.mkdir(parents=True, exist_ok=True)
return base
def _pending_store_path(self):
"""Return the path to the JSON-backed pending-mutation store."""
return self._skills_base_dir() / ".pending_skills.json"
def _audit_log_path(self):
"""Return the path to the append-only skill-mutation audit log."""
return self._pending_store_path().parent / ".skill_audit.log"
def _load_pending(self) -> Dict[str, dict]:
"""Load the pending-mutation store (id -> record)."""
import json
store = self._pending_store_path()
if not store.exists():
return {}
try:
with open(store, 'r', encoding='utf-8') as f:
data = json.load(f)
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, OSError) as e:
logger.warning("Failed to read pending skills store: %s", e)
return {}
def _save_pending(self, pending: Dict[str, dict]) -> None:
"""Persist the pending-mutation store atomically."""
import json
self._write_skill_atomically(
self._pending_store_path(), json.dumps(pending, indent=2)
)
def _audit(self, event: str, record: dict) -> None:
"""Append a single audit entry for a skill mutation decision."""
import json
import time
try:
entry = {
"event": event,
"timestamp": time.time(),
"id": record.get("id"),
"action": record.get("action"),
"name": record.get("name"),
}
with open(self._audit_log_path(), 'a', encoding='utf-8') as f:
f.write(json.dumps(entry) + "\n")
except OSError as e:
logger.debug("Failed to write skill audit log: %s", e)
def _stage_pending(self, action: str, name: str, **payload) -> dict:
"""Stage a skill mutation in the pending store instead of writing.
Returns a dict with ``status='pending'`` and a short ``id`` for the
human approval flow (``/skills approve <id>``).
"""
import secrets
import time
# Validate the staged payload up front using the same checks as the
# direct-write path, so invalid/oversized proposals never reach the
# pending store or audit log.
if action in ("create", "edit", "patch", "delete",
"write_file", "remove_file"):
if not self._validate_skill_name(name):
return {"success": False, "error": f"Invalid skill name: {name}"}
if action in ("create", "edit") and len(payload.get("content") or "") > 100_000:
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
if action == "write_file" and len(payload.get("file_content") or "") > 100_000:
return {"success": False, "error": "File content exceeds maximum size (100KB)"}
pending = self._load_pending()
if len(pending) >= self._max_pending:
return {
"success": False,
"error": (
f"Pending skill store is full ({self._max_pending} entries); "
"approve or reject existing proposals first."
),
}
request_id = f"skl-{secrets.token_hex(4)}"
record = {
"id": request_id,
"action": action,
"name": name,
"status": "pending",
"created_at": time.time(),
"payload": {k: v for k, v in payload.items() if v is not None},
}
pending[request_id] = record
self._save_pending(pending)
self._audit("proposed", record)
logger.info(
"Skill mutation staged for approval: %s (action=%s, skill=%s)",
request_id, action, name,
)
return {
"success": True,
"status": "pending",
"id": request_id,
"action": action,
"skill": name,
}
def list_pending(self) -> List[dict]:
"""List all pending skill mutations awaiting approval.
Returns:
List of dicts with keys: id, action, name, status, created_at.
"""
pending = self._load_pending()
return [
{
"id": r.get("id"),
"action": r.get("action"),
"name": r.get("name"),
"status": r.get("status", "pending"),
"created_at": r.get("created_at"),
}
for r in pending.values()
]
def _resolve_pending_id(self, identifier: str, pending: Dict[str, dict]):
"""Resolve a pending record by id, or by skill name as a fallback."""
if identifier in pending:
return identifier
# Fall back to matching by skill name (most recent wins).
matches = [
r for r in pending.values() if r.get("name") == identifier
]
if matches:
matches.sort(key=lambda r: r.get("created_at", 0), reverse=True)
return matches[0].get("id")
return None
def approve(self, identifier: str) -> dict:
"""Approve a pending skill mutation and apply it to disk.
Args:
identifier: The pending mutation id (preferred) or skill name.
Returns:
Dict with the result of applying the mutation.
"""
pending = self._load_pending()
request_id = self._resolve_pending_id(identifier, pending)
if request_id is None:
return {"success": False, "error": f"No pending mutation: {identifier}"}
# Apply first; only remove + audit "approved" once the mutation
# actually succeeds, so a failure neither loses the proposal nor
# records a false approval.
record = pending[request_id]
result = self._apply_pending(record)
result.setdefault("id", request_id)
if result.get("success"):
pending.pop(request_id, None)
self._save_pending(pending)
self._audit("approved", record)
else:
self._audit("approval_failed", record)
return result
def reject(self, identifier: str) -> dict:
"""Reject a pending skill mutation without applying it.
Args:
identifier: The pending mutation id (preferred) or skill name.
Returns:
Dict confirming the rejection.
"""
pending = self._load_pending()
request_id = self._resolve_pending_id(identifier, pending)
if request_id is None:
return {"success": False, "error": f"No pending mutation: {identifier}"}
record = pending.pop(request_id)
self._save_pending(pending)
self._audit("rejected", record)
return {
"success": True,
"status": "rejected",
"id": request_id,
"action": record.get("action"),
"skill": record.get("name"),
}
def _apply_pending(self, record: dict) -> dict:
"""Apply an approved mutation directly (bypassing the approval gate)."""
action = record.get("action")
name = record.get("name")
payload = record.get("payload", {})
if action == "create":
return self.create_skill(
name, payload.get("content", ""),
payload.get("category"),
agent_created=payload.get("agent_created", True),
propose=False,
)
if action == "edit":
return self.edit_skill(name, payload.get("content", ""), propose=False)
if action == "patch":
return self.patch_skill(
name, payload.get("old_string", ""),
payload.get("new_string", ""),
payload.get("file_path"),
payload.get("replace_all", False),
propose=False,
)
if action == "delete":
return self.delete_skill(
name, hard=payload.get("hard", False), propose=False,
)
if action == "write_file":
return self.write_skill_file(
name, payload.get("file_path", ""),
payload.get("file_content", ""), propose=False,
)
if action == "remove_file":
return self.remove_skill_file(
name, payload.get("file_path", ""), propose=False,
)
return {"success": False, "error": f"Unknown action: {action}"}
# ── SkillMutatorProtocol aliases ──────────────────────────────────
def create(self, name: str, content: str, category: Optional[str] = None,
propose: Optional[bool] = None) -> dict:
"""Protocol alias for :meth:`create_skill`."""
return self.create_skill(name, content, category, propose=propose)
def edit(self, name: str, content: str,
propose: Optional[bool] = None) -> dict:
"""Protocol alias for :meth:`edit_skill`."""
return self.edit_skill(name, content, propose=propose)
def patch(self, name: str, old_string: str, new_string: str,
file_path: Optional[str] = None, replace_all: bool = False,
propose: Optional[bool] = None) -> dict:
"""Protocol alias for :meth:`patch_skill`."""
return self.patch_skill(
name, old_string, new_string, file_path, replace_all,
propose=propose,
)
def delete(self, name: str, propose: Optional[bool] = None) -> dict:
"""Protocol alias for :meth:`delete_skill`."""
return self.delete_skill(name, propose=propose)
def write_file(self, name: str, file_path: str, file_content: str,
propose: Optional[bool] = None) -> dict:
"""Protocol alias for :meth:`write_skill_file`."""
return self.write_skill_file(
name, file_path, file_content, propose=propose,
)
def remove_file(self, name: str, file_path: str,
propose: Optional[bool] = None) -> dict:
"""Protocol alias for :meth:`remove_skill_file`."""
return self.remove_skill_file(name, file_path, propose=propose)
def clear(self) -> None:
"""Clear all loaded skills."""
self._skills.clear()
@@ -151,6 +151,13 @@ class SkillProperties:
shell: Optional[str] = None # "bash" | "powershell"
# Capability requirements (new)
requirements: Optional[SkillRequirements] = None
# Provenance + usage telemetry (read by lifecycle/curator plugins).
# These are protocol-only data fields; retention policy lives in plugins.
agent_created: bool = False
created_at: Optional[str] = None # ISO 8601 timestamp
use_count: int = 0
last_used: Optional[str] = None # ISO 8601 timestamp
patch_count: int = 0
def to_dict(self) -> dict:
"""Convert to dictionary, excluding None values and empty metadata."""
@@ -185,8 +192,39 @@ class SkillProperties:
result["paths"] = self.paths
if self.shell is not None:
result["shell"] = self.shell
if self.agent_created:
result["agent-created"] = True
if self.created_at is not None:
result["created-at"] = self.created_at
if self.use_count:
result["use-count"] = self.use_count
if self.last_used is not None:
result["last-used"] = self.last_used
if self.patch_count:
result["patch-count"] = self.patch_count
return result
@property
def idle_days(self) -> Optional[float]:
"""Days since the skill was last used (or created if never used).
Returns None when no timestamp is available. Used by lifecycle
curator plugins to decide active -> stale -> archived transitions.
"""
from datetime import datetime, timezone
ref = self.last_used or self.created_at
if not ref:
return None
try:
ts = datetime.fromisoformat(ref)
except ValueError:
return None
if ts.tzinfo is None:
ts = ts.replace(tzinfo=timezone.utc)
delta = datetime.now(timezone.utc) - ts
return delta.total_seconds() / 86400.0
@dataclass
class SkillMetadata:
@@ -123,6 +123,17 @@ def read_properties(skill_dir: Path) -> SkillProperties:
return v.strip().lower() in ("true", "yes", "1", "on")
return default
def _coerce_int(v, default=0):
try:
return int(v)
except (TypeError, ValueError):
return default
def _coerce_str(v):
if v is None:
return None
return str(v)
return SkillProperties(
name=name.strip(),
description=description.strip(),
@@ -145,4 +156,14 @@ def read_properties(skill_dir: Path) -> SkillProperties:
shell=metadata.get("shell"),
# Parse capability requirements
requirements=SkillRequirements.from_frontmatter(metadata),
# Provenance + usage telemetry (accept hyphen or underscore forms)
agent_created=_coerce_bool(
metadata.get("agent-created", metadata.get("agent_created")), False
),
created_at=_coerce_str(metadata.get("created-at", metadata.get("created_at"))),
use_count=_coerce_int(metadata.get("use-count", metadata.get("use_count")), 0),
last_used=_coerce_str(metadata.get("last-used", metadata.get("last_used"))),
patch_count=_coerce_int(
metadata.get("patch-count", metadata.get("patch_count")), 0
),
)
@@ -175,6 +175,70 @@ class SkillsCatalogProtocol(Protocol):
...
@runtime_checkable
class SkillReviewProtocol(Protocol):
"""Policy for the autonomous skill self-improvement loop.
After a task finishes, an opt-in guarded review pass asks the agent
whether the session revealed a reusable technique worth capturing as a
skill. ``should_review`` decides *whether* to run the pass and
``review_prompt`` produces the directive given to the agent. Both take a
lightweight trajectory dict so wrappers/plugins can swap the policy
without depending on core internals.
"""
def should_review(self, trajectory: Dict[str, Any]) -> bool:
"""Return True if the finished session warrants a skill review."""
...
def review_prompt(self, trajectory: Dict[str, Any]) -> str:
"""Return the directive prompt for the guarded review turn."""
...
class DefaultSkillReviewPolicy:
"""Default, conservative skill-review policy.
Triggers only when the session did real work (at least ``min_tool_calls``
tool invocations, default 1) and asks the agent to create or patch a
single skill if — and only if — a durable, reusable technique emerged.
"""
#: Hard cap on how much of the original prompt is echoed into the review
#: directive. Bounds token cost and shrinks the prompt-injection surface.
MAX_PROMPT_CHARS = 500
def __init__(self, min_tool_calls: int = 1):
# Clamp to >= 1: a value of 0 would trigger a review LLM call after
# every single chat() (even no-op turns that used no tools), silently
# doubling API cost. The minimum unit of "real work" is one tool call.
self.min_tool_calls = max(1, int(min_tool_calls))
def should_review(self, trajectory: Dict[str, Any]) -> bool:
tools_used = trajectory.get("tools_used") or []
return len(tools_used) >= self.min_tool_calls
def review_prompt(self, trajectory: Dict[str, Any]) -> str:
prompt = str(trajectory.get("prompt", "")).strip()
if len(prompt) > self.MAX_PROMPT_CHARS:
prompt = prompt[: self.MAX_PROMPT_CHARS] + "…"
tools_used = trajectory.get("tools_used") or []
tools_str = ", ".join(str(t) for t in tools_used) if tools_used else "none"
return (
"You have just finished a task. Reflect ONLY on whether this "
"session revealed a durable, reusable technique, or exposed a "
"loaded skill that was wrong or incomplete.\n\n"
f"Original task: {prompt}\n"
f"Tools used: {tools_str}\n\n"
"If — and only if — there is a genuinely reusable capability worth "
"saving, call the `skill_manage` tool to create a new skill or "
"patch an existing one. Use a clear, hyphenated skill name and a "
"concise SKILL.md body capturing the reusable steps. "
"If nothing durable was learned, reply with exactly 'NO_SKILL' "
"and do not call any tool."
)
def list_skills_for_api() -> List[Dict[str, Any]]:
"""Default catalog adapter using SkillManager when available."""
try:
@@ -73,6 +73,7 @@ TOOL_MAPPINGS = {
# Python Tools
'execute_code': ('.python_tools', None),
'execute_code_with_tools': ('.python_tools', None),
'analyze_code': ('.python_tools', None),
'format_code': ('.python_tools', None),
'lint_code': ('.python_tools', None),
@@ -243,8 +244,17 @@ _PROFILE_EXPORTS = frozenset({
'register_profile', 'get_profile', 'resolve_profiles', 'list_profiles'
})
# Code-tools bridge exports (lazy loaded to keep import-time cost off the
# default path; only resolved when the opt-in code mode is used).
_TOOL_PROXY_EXPORTS = frozenset({'ToolProxy', 'build_tool_namespace'})
def __getattr__(name: str) -> Any:
"""Smart lazy loading of tools and profiles."""
# Handle code-tools bridge exports
if name in _TOOL_PROXY_EXPORTS:
from .tool_proxy import ToolProxy, build_tool_namespace
return {'ToolProxy': ToolProxy, 'build_tool_namespace': build_tool_namespace}[name]
# Handle circuit breaker imports first
if name in _CIRCUIT_BREAKER_EXPORTS:
from .circuit_breaker import (
@@ -339,7 +349,7 @@ def __getattr__(name: str) -> Any:
if name in [
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
'scrape_page', 'extract_links', 'crawl', 'extract_text',
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
'execute_command', 'execute_code', 'execute_code_with_tools', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
'tavily_search_async', 'tavily_extract_async',
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
@@ -380,6 +390,7 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
'tool', 'FunctionTool',
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool',
'list_tools', 'list_available_tools', 'list_tools_with_allowed_filter', 'list_tools_with_hermes_filter', 'ToolRegistry',
'ToolProxy', 'build_tool_namespace',
'Tools',
# Validation and retry protocols
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
@@ -0,0 +1,90 @@
"""Shared concurrency primitives for the built-in file/edit tools.
This module provides process-wide, per-canonical-path locking and an
automatic last-read-hash registry so that the built-in ``FileTools`` and
``EditTools`` can guarantee two production-robustness properties **by
default**:
1. **Per-file concurrency safety** — a keyed lock per canonical file path
serialises read-modify-write on the same file, so parallel tool calls or
multi-agent teams sharing a workspace cannot interleave and corrupt a
shared file. Different files still proceed in parallel.
2. **Automatic stale-write protection** — a read records the on-disk content
hash for that path; a later edit/write recomputes the on-disk hash and
aborts on mismatch unless the caller opts out (``force=True``) or supplies
an explicit ``expected_hash``.
The state lives at module scope so it is shared across every ``FileTools`` /
``EditTools`` instance (and the module-level default instances), which is what
makes the guarantee hold across separate tool calls in an agent loop.
"""
import threading
import weakref
from collections import OrderedDict
# Guards access to the registries below.
_registry_lock = threading.Lock()
# Per-canonical-path re-entrant locks. ``WeakValueDictionary`` lets unused
# locks be garbage collected once no caller holds a reference, avoiding
# unbounded growth for long-running agents touching many files.
_path_locks: "weakref.WeakValueDictionary[str, threading.RLock]" = (
weakref.WeakValueDictionary()
)
# Maximum number of distinct paths whose last-read hash is retained. The map
# is bounded with LRU-style eviction so a long-running agent reading thousands
# of distinct files cannot grow it without bound. A path evicted here simply
# loses its automatic staleness guard for that one path (the next read re-arms
# it); correctness is preserved, only the convenience guard is dropped.
_MAX_READ_HASHES = 4096
# Automatic last-read content hash per canonical path. Populated on read,
# consulted on write/edit for the default staleness guard. Insertion-ordered
# so the oldest entry can be evicted once the cap is exceeded.
_read_hashes: "OrderedDict[str, str]" = OrderedDict()
def get_lock(canonical_path: str) -> threading.RLock:
"""Return the shared re-entrant lock for ``canonical_path``.
The same lock object is returned for the same path across all callers, so
independent ``FileTools``/``EditTools`` instances serialise correctly on a
shared file. Re-entrant so a single thread can nest read+edit safely.
"""
with _registry_lock:
lock = _path_locks.get(canonical_path)
if lock is None:
lock = threading.RLock()
_path_locks[canonical_path] = lock
return lock
def record_read_hash(canonical_path: str, content_hash: str) -> None:
"""Record the content hash observed when ``canonical_path`` was read.
The map is bounded: once it exceeds ``_MAX_READ_HASHES`` the oldest entry
is evicted, so long-running agents reading many distinct files do not grow
it without bound.
"""
with _registry_lock:
_read_hashes[canonical_path] = content_hash
_read_hashes.move_to_end(canonical_path)
while len(_read_hashes) > _MAX_READ_HASHES:
_read_hashes.popitem(last=False)
def get_read_hash(canonical_path: str):
"""Return the last recorded read hash for ``canonical_path`` (or None)."""
with _registry_lock:
value = _read_hashes.get(canonical_path)
if value is not None:
_read_hashes.move_to_end(canonical_path)
return value
def clear_read_hash(canonical_path: str) -> None:
"""Forget any recorded read hash for ``canonical_path`` (e.g. on delete)."""
with _registry_lock:
_read_hashes.pop(canonical_path, None)
@@ -15,7 +15,7 @@ Usage:
"""
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional, Type, get_type_hints
from typing import Any, Callable, Dict, List, Optional, get_type_hints
import inspect
import json
import logging
@@ -134,18 +134,6 @@ class BaseTool(ABC):
func_name=self.name
)
@staticmethod
def _python_type_to_json(python_type: Type) -> str:
"""Convert Python type to JSON Schema type.
DEPRECATED: Use annotation_to_json_schema() from schema.py instead.
This method is kept for backward compatibility but will be removed.
"""
# Legacy fallback - delegate to new schema utility and extract type
from .schema import annotation_to_json_schema
schema = annotation_to_json_schema(python_type)
return schema.get("type", "string")
@abstractmethod
def run(self, **kwargs) -> Any:
"""Execute the tool with given arguments.
@@ -12,24 +12,65 @@ import hashlib
import difflib
from typing import List, Optional, Tuple
from ..approval import require_approval
from . import _file_locks
logger = logging.getLogger(__name__)
# Minimum similarity ratio for an accepted block-anchor (fuzzy) match.
_BLOCK_ANCHOR_THRESHOLD = 0.7
# Maximum characters of diagnostics output appended to a tool result.
_DIAGNOSTICS_MAX_CHARS = 2000
# Seconds before a diagnostics subprocess is abandoned.
_DIAGNOSTICS_TIMEOUT = 10
# File extension -> LSP language id, used to pick a language server for the
# edited file. Mirrors the languages with a default server in ``lsp/config.py``
# (DEFAULT_SERVERS); anything not listed falls back to the per-language checker.
_LSP_LANGUAGE_BY_EXT = {
".py": "python",
".pyi": "python",
".js": "javascript",
".jsx": "javascript",
".mjs": "javascript",
".cjs": "javascript",
".ts": "typescript",
".tsx": "typescript",
".rs": "rust",
".go": "go",
}
class EditTools:
"""Tools for file editing and patching operations."""
def __init__(self, workspace=None):
def __init__(self, workspace=None, post_edit_diagnostics: str = "auto"):
"""Initialize EditTools with optional workspace containment.
Args:
workspace: Optional Workspace instance for path containment
post_edit_diagnostics: When to run a lightweight, language-appropriate
check on a file after a successful edit/patch and append the
results to the tool output. One of:
- ``"auto"`` (default): run a checker if one is available, but
only append a ``Diagnostics`` section when problems are found
(so clean edits return the plain success string — backward
compatible).
- ``"on"``: always append a ``Diagnostics`` section when a
checker is available, even if it reports no problems.
- ``"off"``: never run diagnostics (zero overhead).
Checkers are auto-detected by file type and silently skipped
when unavailable; a missing linter never fails the edit.
"""
self._workspace = workspace
self._file_cache = {} # Cache for staleness checking
mode = (post_edit_diagnostics or "auto")
if isinstance(mode, str):
mode = mode.lower()
if mode not in ("auto", "on", "off"):
mode = "auto"
self._post_edit_diagnostics = mode
def _validate_path(self, filepath: str) -> str:
"""Validate and resolve a file path within workspace constraints."""
@@ -116,7 +157,272 @@ class EditTools:
diff_lines.append(line)
return ''.join(diff_lines) if diff_lines else "No changes detected"
# ------------------------------------------------------------------
# Post-edit diagnostics
#
# After a successful mutation we optionally run a lightweight, language
# appropriate check on the single modified file and surface concise
# diagnostics so an agent can self-correct within the same loop. All
# imports are deferred and any failure is swallowed: a missing or broken
# checker must never turn a successful edit into a failure.
# ------------------------------------------------------------------
@staticmethod
def _diagnostics_command(safe_path: str) -> Optional[Tuple[str, List[str]]]:
"""Resolve a (tool_name, argv) checker for ``safe_path`` by extension.
Returns ``None`` when no checker is available for the file type or none
of the candidate executables are installed. Lazy-imports ``shutil``.
"""
import shutil
ext = os.path.splitext(safe_path)[1].lower()
# Ordered candidates per extension: first installed executable wins.
candidates: List[Tuple[str, List[str]]] = []
if ext in (".py", ".pyi"):
if shutil.which("ruff"):
# Restrict to error-class rules (pyflakes/syntax) so that
# stylistically-imperfect-but-valid edits do not flood output;
# this keeps behaviour close to a syntax gate. ``--no-cache``
# avoids polluting the workspace.
candidates.append((
"ruff",
["ruff", "check", "--quiet", "--no-cache",
"--select", "E9,F63,F7,F82", safe_path],
))
# py_compile is always available via the running interpreter and
# catches syntax errors with zero third-party dependencies.
import sys
candidates.append(("py_compile", [sys.executable, "-m", "py_compile", safe_path]))
elif ext in (".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"):
if shutil.which("eslint"):
# ``--no-config-lookup`` prevents ESLint from discovering and
# executing repository-controlled config/plugins (arbitrary code
# execution risk in untrusted workspaces).
candidates.append((
"eslint",
["eslint", "--no-config-lookup", safe_path],
))
if shutil.which("tsc") and ext in (".ts", ".tsx"):
# Per-file invocation without a project ``tsconfig.json``: relax
# project-config coupling so a single valid file does not report
# spurious "no inputs"/missing-lib errors.
candidates.append((
"tsc",
["tsc", "--noEmit", "--skipLibCheck", "--allowJs",
"--target", "ESNext", "--moduleResolution", "node",
safe_path],
))
elif ext == ".json":
# Validate JSON with the stdlib; emitted via a tiny inline check.
import sys
candidates.append((
"json",
[sys.executable, "-c",
"import json,sys;json.load(open(sys.argv[1]))", safe_path],
))
for tool_name, argv in candidates:
exe = argv[0]
if os.path.isabs(exe) or shutil.which(exe):
return tool_name, argv
return None
@staticmethod
def _bound_output(output: str) -> str:
"""Truncate ``output`` to the shared diagnostics character budget."""
if len(output) > _DIAGNOSTICS_MAX_CHARS:
return output[:_DIAGNOSTICS_MAX_CHARS] + "\n... (diagnostics truncated)"
return output
def _try_lsp_diagnostics(self, safe_path: str, display_path: str) -> Optional[str]:
"""Query the existing LSP client for diagnostics on ``safe_path``.
Returns a formatted, bounded diagnostics block (matching the shape used
by the per-language checker) when a language server is available for the
file's language, ``None`` when no server is configured/installed so the
caller can fall back to the legacy checker. Everything is lazy: the LSP
client is only imported and spawned when a server actually exists, so
Python-only/silent runs pay no startup cost. Any failure returns
``None`` so a missing/slow server never blocks a successful edit.
"""
import shutil
ext = os.path.splitext(safe_path)[1].lower()
language = _LSP_LANGUAGE_BY_EXT.get(ext)
if language is None:
return None
try:
from ..lsp.config import DEFAULT_SERVERS
except Exception:
return None
server = DEFAULT_SERVERS.get(language)
if not server:
return None
# Only spawn a server whose command is actually installed; otherwise
# fall back to the zero-config checker without importing the client.
if not shutil.which(server["command"]):
return None
try:
import asyncio
from ..lsp.client import LSPClient
from ..lsp.types import DiagnosticSeverity
except Exception as e:
logger.debug("LSP diagnostics unavailable for %s: %s", display_path, e)
return None
async def _collect() -> Optional[list]:
client = LSPClient(language=language)
# Keep the whole exchange inside the shared diagnostics budget so a
# slow server cannot stall the edit result.
client.config.timeout = min(client.config.timeout, _DIAGNOSTICS_TIMEOUT)
try:
if not await client.start():
return None
if not await client.open_document(safe_path):
return None
# Diagnostics arrive asynchronously via publishDiagnostics; poll
# briefly within the timeout budget for the server to report.
# ``get_diagnostics`` returns ``[]`` both before any publish and
# after a clean publish, so also watch the client's diagnostics
# store keyed by URI to tell "not reported yet" from "reported,
# no problems" — that lets a clean file return promptly instead
# of sleeping the full timeout, and distinguishes a real
# all-clear (``[]``) from "never published" (``None``) so the
# caller can fall back to the legacy checker only in the latter.
loop = asyncio.get_event_loop()
uri = f"file://{os.path.abspath(safe_path)}"
published = getattr(client, "_diagnostics", None)
deadline = loop.time() + _DIAGNOSTICS_TIMEOUT
diags: Optional[list] = None
while loop.time() < deadline:
diags = await client.get_diagnostics(safe_path)
if diags or (isinstance(published, dict) and uri in published):
break
diags = None
await asyncio.sleep(0.2)
return diags
finally:
try:
await client.close_document(safe_path)
except Exception:
pass
await client.stop()
try:
try:
asyncio.get_running_loop()
except RuntimeError:
running = False
else:
running = True
if running:
# An event loop is already running (e.g. async agent context);
# don't risk re-entrancy — defer to the checker fallback.
return None
diagnostics = asyncio.run(
asyncio.wait_for(_collect(), timeout=_DIAGNOSTICS_TIMEOUT + 5)
)
except Exception as e:
logger.debug("LSP diagnostics error for %s: %s", display_path, e)
return None
if diagnostics is None:
return None
if not diagnostics:
if self._post_edit_diagnostics == "on":
return f"\n\nDiagnostics (lsp:{language}): no problems found"
return ""
_severity_label = {
DiagnosticSeverity.ERROR: "error",
DiagnosticSeverity.WARNING: "warning",
DiagnosticSeverity.INFORMATION: "info",
DiagnosticSeverity.HINT: "hint",
}
lines = []
for d in diagnostics:
try:
line = d.range.start.line + 1
char = d.range.start.character + 1
label = _severity_label.get(d.severity, "error")
message = " ".join((d.message or "").split())
lines.append(f"{display_path}:{line}:{char}: {label}: {message}")
except Exception:
continue
if not lines:
if self._post_edit_diagnostics == "on":
return f"\n\nDiagnostics (lsp:{language}): no problems found"
return ""
output = self._bound_output("\n".join(lines))
return f"\n\nDiagnostics (lsp:{language}):\n{output}"
def _run_diagnostics(self, safe_path: str, display_path: str) -> str:
"""Run diagnostics on ``safe_path`` and return a bounded diagnostics block.
Tries the wired-in LSP client first (for any language the user has a
language server installed for) and falls back to the zero-config
per-language checker when no server is available. Returns an empty
string when diagnostics are disabled, no checker is available, or (in
``auto`` mode) no problems are found. Any exception is logged and
swallowed so the edit result is never lost.
"""
if self._post_edit_diagnostics == "off":
return ""
# Prefer the existing LSP client when a language server is available so
# multi-language edits get language-aware diagnostics; ``None`` means no
# server, so we fall through to the legacy checker below.
try:
lsp_block = self._try_lsp_diagnostics(safe_path, display_path)
if lsp_block is not None:
return lsp_block
except Exception as e: # never let LSP wiring break a successful edit
logger.debug("LSP diagnostics skipped for %s: %s", display_path, e)
try:
resolved = self._diagnostics_command(safe_path)
if resolved is None:
return ""
tool_name, argv = resolved
import subprocess
try:
proc = subprocess.run(
argv,
capture_output=True,
text=True,
timeout=_DIAGNOSTICS_TIMEOUT,
cwd=os.path.dirname(safe_path) or None,
)
except (subprocess.TimeoutExpired, OSError) as e:
logger.debug("Diagnostics skipped for %s: %s", display_path, e)
return ""
output = (proc.stdout or "") + (proc.stderr or "")
output = output.strip()
# Replace absolute paths with the display path for cleaner output.
if output and safe_path != display_path:
output = output.replace(safe_path, display_path)
has_problems = proc.returncode != 0 or bool(output)
if not has_problems:
if self._post_edit_diagnostics == "on":
return f"\n\nDiagnostics ({tool_name}): no problems found"
return ""
if not output:
output = f"check failed (exit code {proc.returncode})"
output = self._bound_output(output)
return f"\n\nDiagnostics ({tool_name}):\n{output}"
except Exception as e: # never let diagnostics break a successful edit
logger.debug("Diagnostics error for %s: %s", display_path, e)
return ""
# ------------------------------------------------------------------
# Fuzzy matching ladder
#
@@ -274,15 +580,24 @@ class EditTools:
@require_approval(risk_level="high")
def edit_file(self, filepath: str, old_string: str, new_string: str,
replace_all: bool = False, expected_hash: Optional[str] = None) -> str:
replace_all: bool = False, expected_hash: Optional[str] = None,
force: bool = False) -> str:
"""Edit a file by replacing text using precise find-and-replace.
The entire read-modify-write runs under a per-file lock, so concurrent
edits to the same path (parallel tools / multi-agent teams) serialise
instead of corrupting the file. By default the edit also aborts if the
file changed on disk since it was last read via this tool, preventing
silent clobbering of externally-modified files.
Args:
filepath: Path to the file to edit
old_string: Text to find and replace
new_string: Replacement text
replace_all: Whether to replace all occurrences (default: first only)
expected_hash: Optional SHA256 hash of expected content for staleness check
force: Bypass the automatic staleness guard for an intentional
blind write (an explicit ``expected_hash`` is still honoured)
Returns:
Success message with diff or error description
@@ -293,88 +608,109 @@ class EditTools:
if not os.path.exists(safe_path):
return f"Error: File not found: {filepath}"
# Detect BOM and read file content
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
# Check for UTF-8 BOM only (we don't support UTF-16)
bom = b''
if raw_bytes.startswith(b'\xef\xbb\xbf'): # UTF-8 BOM
bom = b'\xef\xbb\xbf'
raw_bytes = raw_bytes[3:]
elif raw_bytes.startswith(b'\xff\xfe') or raw_bytes.startswith(b'\xfe\xff'):
# UTF-16 BOM detected - not supported
return "Error: UTF-16 encoding is not supported. Please convert the file to UTF-8."
# Decode content (UTF-8 only)
content = raw_bytes.decode('utf-8')
# Detect line endings
line_ending = self._detect_line_ending(content)
# Staleness check
if expected_hash is not None:
# Serialise the whole read-modify-write on this canonical path so
# concurrent edits/writes cannot interleave and corrupt the file.
with _file_locks.get_lock(safe_path):
# Detect BOM and read file content
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
# Check for UTF-8 BOM only (we don't support UTF-16)
bom = b''
if raw_bytes.startswith(b'\xef\xbb\xbf'): # UTF-8 BOM
bom = b'\xef\xbb\xbf'
raw_bytes = raw_bytes[3:]
elif raw_bytes.startswith(b'\xff\xfe') or raw_bytes.startswith(b'\xfe\xff'):
# UTF-16 BOM detected - not supported
return "Error: UTF-16 encoding is not supported. Please convert the file to UTF-8."
# Decode content (UTF-8 only)
content = raw_bytes.decode('utf-8')
# Detect line endings
line_ending = self._detect_line_ending(content)
current_hash = self._compute_content_hash(content)
if current_hash != expected_hash:
return (f"Error: File has been modified since last read. "
f"Please re-read the file before editing. "
f"Expected hash: {expected_hash[:8]}..., "
f"Current hash: {current_hash[:8]}...")
# Cache the current content hash for future staleness checks
self._file_cache[safe_path] = self._compute_content_hash(content)
# Validation
if old_string == "":
return "Error: old_string must be non-empty"
# Locate the target using the fuzzy matching ladder. Exact
# substring matches are preferred and behave exactly as before;
# fuzzy strategies only engage when an exact match is not found.
spans = self._find_spans(content, old_string)
if not spans:
preview = old_string[:50] + ("..." if len(old_string) > 50 else "")
return f"Error: String not found in file: '{preview}'"
occurrences = len(spans)
# Check for ambiguous match
if occurrences > 1 and not replace_all:
preview = old_string[:30] + ("..." if len(old_string) > 30 else "")
return (f"Error: Ambiguous match - '{preview}' occurs {occurrences} times. "
f"Please provide more surrounding context to make the match unique, "
f"or use replace_all=True to replace all occurrences.")
# Perform replacement using located spans (apply right-to-left so
# earlier offsets remain valid as we splice in new_string).
if replace_all:
new_content = content
for start, end in sorted(spans, reverse=True):
new_content = new_content[:start] + new_string + new_content[end:]
replacements = occurrences
else:
start, end = spans[0]
new_content = content[:start] + new_string + content[end:]
replacements = 1
# Generate diff
diff = self._render_diff(content, new_content, filepath)
# Normalize line endings for the new content
if line_ending == '\r\n':
new_content = new_content.replace('\r\n', '\n').replace('\n', '\r\n')
# Write updated content back with BOM if present
with open(safe_path, 'wb') as f:
if bom:
f.write(bom)
f.write(new_content.encode('utf-8'))
# Update cache with new content hash
self._file_cache[safe_path] = self._compute_content_hash(new_content)
return f"Success: Made {replacements} replacement(s) in {filepath}\n\nDiff:\n{diff}"
# Explicit staleness check (caller-supplied hash takes priority).
if expected_hash is not None:
if current_hash != expected_hash:
return (f"Error: File has been modified since last read. "
f"Please re-read the file before editing. "
f"Expected hash: {expected_hash[:8]}..., "
f"Current hash: {current_hash[:8]}...")
# Automatic staleness guard: if a prior read recorded a hash for
# this path, abort when the on-disk content has since changed.
elif not force:
recorded = _file_locks.get_read_hash(safe_path)
if recorded is not None and recorded != current_hash:
return (f"Error: File changed since it was read - "
f"re-read before editing (or pass force=True to override). "
f"Recorded hash: {recorded[:8]}..., "
f"Current hash: {current_hash[:8]}...")
# Cache the current content hash for future staleness checks
self._file_cache[safe_path] = current_hash
_file_locks.record_read_hash(safe_path, current_hash)
# Validation
if old_string == "":
return "Error: old_string must be non-empty"
# Locate the target using the fuzzy matching ladder. Exact
# substring matches are preferred and behave exactly as before;
# fuzzy strategies only engage when an exact match is not found.
spans = self._find_spans(content, old_string)
if not spans:
preview = old_string[:50] + ("..." if len(old_string) > 50 else "")
return f"Error: String not found in file: '{preview}'"
occurrences = len(spans)
# Check for ambiguous match
if occurrences > 1 and not replace_all:
preview = old_string[:30] + ("..." if len(old_string) > 30 else "")
return (f"Error: Ambiguous match - '{preview}' occurs {occurrences} times. "
f"Please provide more surrounding context to make the match unique, "
f"or use replace_all=True to replace all occurrences.")
# Perform replacement using located spans (apply right-to-left so
# earlier offsets remain valid as we splice in new_string).
if replace_all:
new_content = content
for start, end in sorted(spans, reverse=True):
new_content = new_content[:start] + new_string + new_content[end:]
replacements = occurrences
else:
start, end = spans[0]
new_content = content[:start] + new_string + content[end:]
replacements = 1
# Generate diff
diff = self._render_diff(content, new_content, filepath)
# Normalize line endings for the new content
if line_ending == '\r\n':
new_content = new_content.replace('\r\n', '\n').replace('\n', '\r\n')
# Write updated content back with BOM if present
with open(safe_path, 'wb') as f:
if bom:
f.write(bom)
f.write(new_content.encode('utf-8'))
# Update caches with the new content hash so a follow-up edit
# in the same session is not flagged as stale. Hash the exact
# on-disk form (CRLF preserved) so a subsequent binary read of
# the same file produces an identical hash and is not falsely
# flagged as stale.
new_hash = self._compute_content_hash(new_content)
self._file_cache[safe_path] = new_hash
_file_locks.record_read_hash(safe_path, new_hash)
result = f"Success: Made {replacements} replacement(s) in {filepath}\n\nDiff:\n{diff}"
return result + self._run_diagnostics(safe_path, filepath)
except Exception as e:
error_msg = f"Error editing file {filepath}: {str(e)}"
@@ -463,7 +799,7 @@ class EditTools:
return ops
@require_approval(risk_level="high")
def apply_patch(self, patch: str) -> str:
def apply_patch(self, patch: str, force: bool = False) -> str:
"""Apply a structured multi-file patch atomically.
The patch may Add, Update, and Delete multiple files in a single
@@ -473,12 +809,20 @@ class EditTools:
the filesystem is left in its original state. BOM and CRLF line
endings are preserved on Update, matching ``edit_file``.
Every affected path is locked for the duration of validate+commit, so
a concurrent edit/write to any of those files serialises rather than
racing. Updated files are also checked against their last-read hash by
default; pass ``force=True`` to bypass that staleness guard.
Args:
patch: Structured patch text (see ``_parse_patch`` for format).
force: Bypass the automatic staleness guard on Update operations.
Returns:
Combined success message with diffs, or an error description.
"""
import contextlib
try:
try:
ops = self._parse_patch(patch)
@@ -488,6 +832,24 @@ class EditTools:
if not ops:
return "Error: Patch contains no operations"
# Acquire per-path locks for every affected file up front, in a
# stable (sorted) order to avoid deadlock when two patches share
# files. Held across validate+commit so no concurrent edit/write
# can interleave with this atomic patch.
lock_paths = sorted({self._validate_path(op["path"]) for op in ops})
with contextlib.ExitStack() as stack:
for lp in lock_paths:
stack.enter_context(_file_locks.get_lock(lp))
return self._apply_patch_locked(ops, force)
except Exception as e:
error_msg = f"Error applying patch: {str(e)}"
logger.error(error_msg)
return error_msg
def _apply_patch_locked(self, ops, force: bool) -> str:
"""Validate and commit ``ops`` while holding the per-path locks."""
try:
# Phase 1: validate every operation and compute new content.
# Each planned entry: (kind, safe_path, display, encoded_bytes,
# diff, content_hash). encoded_bytes is None for deletes.
@@ -506,6 +868,21 @@ class EditTools:
elif op["op"] == "delete":
if not os.path.exists(safe_path):
return f"Error: Cannot delete '{op['path']}': file not found"
# Automatic staleness guard: don't silently delete a file
# that changed on disk since it was last read via these tools.
if not force:
recorded = _file_locks.get_read_hash(safe_path)
if recorded is not None:
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
try:
original, _ = self._decode_with_bom(raw_bytes)
except ValueError as e:
return f"Error: Cannot delete '{op['path']}': {e}"
if recorded != self._compute_content_hash(original):
return (f"Error: Cannot delete '{op['path']}': file changed "
f"since it was read - re-read before editing "
f"(or pass force=True to override).")
planned.append(("delete", safe_path, op["path"], None, "", None))
elif op["op"] == "update":
@@ -518,6 +895,15 @@ class EditTools:
original, bom = self._decode_with_bom(raw_bytes)
except ValueError as e:
return f"Error: Cannot update '{op['path']}': {e}"
# Automatic staleness guard: abort if the file changed on
# disk since it was last read via these tools.
if not force:
original_hash = self._compute_content_hash(original)
recorded = _file_locks.get_read_hash(safe_path)
if recorded is not None and recorded != original_hash:
return (f"Error: Cannot update '{op['path']}': file changed "
f"since it was read - re-read before editing "
f"(or pass force=True to override).")
line_ending = self._detect_line_ending(original)
updated = original
for old_block, new_block in op["hunks"]:
@@ -534,9 +920,13 @@ class EditTools:
start, end = spans[0]
updated = updated[:start] + new_block + updated[end:]
encoded = self._encode_preserving(updated, line_ending, bom)
# Hash the exact on-disk form (CRLF preserved, BOM stripped)
# so a subsequent binary read of this file produces an
# identical hash and is not falsely flagged as stale.
on_disk_form = encoded[len(bom):].decode('utf-8')
planned.append(("update", safe_path, op["path"], encoded,
self._render_diff(original, updated, op["path"]),
self._compute_content_hash(updated)))
self._compute_content_hash(on_disk_form)))
# Phase 2: commit all operations atomically. Writes are staged to
# temp files in the same directory and swapped in with os.replace;
@@ -553,6 +943,7 @@ class EditTools:
os.replace(safe_path, backup)
applied.append(("delete", safe_path, backup))
self._file_cache.pop(safe_path, None)
_file_locks.clear_read_hash(safe_path)
messages.append(f"Deleted {display}")
else:
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
@@ -576,8 +967,10 @@ class EditTools:
raise
applied.append(("write", safe_path, backup))
self._file_cache[safe_path] = content_hash
_file_locks.record_read_hash(safe_path, content_hash)
verb = "Added" if kind == "add" else "Updated"
messages.append(f"{verb} {display}\n{diff}")
diagnostics = self._run_diagnostics(safe_path, display)
messages.append(f"{verb} {display}\n{diff}{diagnostics}")
except Exception:
# Roll back in reverse order to restore the original state.
for entry in reversed(applied):
@@ -627,19 +1020,26 @@ class EditTools:
if not os.path.exists(safe_path):
return f"Error: File not found: {filepath}", ""
# Read in binary mode to match edit_file behavior
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
# Strip UTF-8 BOM if present (matching edit_file logic)
if raw_bytes.startswith(b'\xef\xbb\xbf'):
raw_bytes = raw_bytes[3:]
# Decode content (UTF-8 only, matching edit_file)
content = raw_bytes.decode('utf-8')
content_hash = self._compute_content_hash(content)
self._file_cache[safe_path] = content_hash
# Serialise with concurrent writers on this path so the read (and
# the hash it records) reflects a stable, fully-written file rather
# than content observed mid-write.
with _file_locks.get_lock(safe_path):
# Read in binary mode to match edit_file behavior
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
# Strip UTF-8 BOM if present (matching edit_file logic)
if raw_bytes.startswith(b'\xef\xbb\xbf'):
raw_bytes = raw_bytes[3:]
# Decode content (UTF-8 only, matching edit_file)
content = raw_bytes.decode('utf-8')
content_hash = self._compute_content_hash(content)
self._file_cache[safe_path] = content_hash
# Record the read hash in the shared registry so a later
# edit/write to this path engages the staleness guard by default.
_file_locks.record_read_hash(safe_path, content_hash)
return content, content_hash
@@ -718,7 +1118,8 @@ _edit_tools = EditTools()
@require_approval(risk_level="high")
def edit_file(filepath: str, old_string: str, new_string: str,
replace_all: bool = False, expected_hash: Optional[str] = None) -> str:
replace_all: bool = False, expected_hash: Optional[str] = None,
force: bool = False) -> str:
"""Edit a file by replacing text using precise find-and-replace.
Args:
@@ -727,25 +1128,28 @@ def edit_file(filepath: str, old_string: str, new_string: str,
new_string: Replacement text
replace_all: Whether to replace all occurrences (default: first only)
expected_hash: Optional SHA256 hash of expected content for staleness check
force: Bypass the automatic staleness guard for an intentional blind write
Returns:
Success message with diff or error description
"""
return _edit_tools.edit_file(filepath, old_string, new_string, replace_all, expected_hash)
return _edit_tools.edit_file(filepath, old_string, new_string, replace_all,
expected_hash, force)
@require_approval(risk_level="high")
def apply_patch(patch: str) -> str:
def apply_patch(patch: str, force: bool = False) -> str:
"""Apply a structured multi-file patch atomically (Add/Update/Delete).
Args:
patch: Structured patch text with ``*** Add File:``,
``*** Update File:`` and ``*** Delete File:`` sections.
force: Bypass the automatic staleness guard on Update operations.
Returns:
Combined success message with diffs or an error description.
"""
return _edit_tools.apply_patch(patch)
return _edit_tools.apply_patch(patch, force)
def read_file(filepath: str) -> Tuple[str, str]:
@@ -775,13 +1179,15 @@ def search_files(directory: str, pattern: str,
return _edit_tools.search_files(directory, pattern, file_pattern)
def create_edit_tools(workspace=None) -> EditTools:
def create_edit_tools(workspace=None, post_edit_diagnostics: str = "auto") -> EditTools:
"""Create EditTools instance with optional workspace containment.
Args:
workspace: Optional Workspace instance for path containment
post_edit_diagnostics: Diagnostics mode ("auto"|"on"|"off"); see
``EditTools.__init__`` for details.
Returns:
EditTools instance configured with workspace
"""
return EditTools(workspace=workspace)
return EditTools(workspace=workspace, post_edit_diagnostics=post_edit_diagnostics)
@@ -12,11 +12,13 @@ content = read_file("example.txt")
import os
import json
import hashlib
from typing import List, Dict, Union, Optional
from pathlib import Path
import shutil
import logging
from ..approval import require_approval
from . import _file_locks
class FileTools:
"""Tools for file operations including read, write, list, and information."""
@@ -76,7 +78,23 @@ class FileTools:
raise ValueError(f"Path traversal detected: {filepath} escapes workspace {cwd}")
return absolute
@staticmethod
def _content_hash(safe_path: str, encoding: str = 'utf-8') -> str:
"""Hash the on-disk content in the same form EditTools records.
Reads bytes, strips a UTF-8 BOM, and hashes the decoded string with
line endings preserved. This matches ``EditTools`` (which reads in
binary mode) so the shared staleness registry stays consistent when a
file is read by one tool and written by the other.
"""
with open(safe_path, 'rb') as f:
raw_bytes = f.read()
if raw_bytes.startswith(b'\xef\xbb\xbf'):
raw_bytes = raw_bytes[3:]
content = raw_bytes.decode(encoding)
return hashlib.sha256(content.encode('utf-8')).hexdigest()
def read_file(self, filepath: str, encoding: str = 'utf-8') -> str:
"""
Read content from a file.
@@ -91,22 +109,44 @@ class FileTools:
try:
# Validate path to prevent traversal attacks
safe_path = self._validate_path(filepath)
with open(safe_path, 'r', encoding=encoding) as f:
return f.read()
# Serialise with concurrent writers on this path so the read does
# not observe a partially-written file.
with _file_locks.get_lock(safe_path):
with open(safe_path, 'r', encoding=encoding) as f:
data = f.read()
# Record the read hash so a later write engages the staleness
# guard. Hash the on-disk byte form (CRLF preserved, BOM
# stripped) so the recorded hash matches what EditTools (binary
# mode) records for the same file, keeping the staleness guard
# consistent across tools.
try:
_file_locks.record_read_hash(
safe_path, self._content_hash(safe_path, encoding))
except Exception:
pass
return data
except Exception as e:
error_msg = f"Error reading file {filepath}: {str(e)}"
logging.error(error_msg)
return error_msg
@require_approval(risk_level="high")
def write_file(self, filepath: str, content: str, encoding: str = 'utf-8') -> bool:
def write_file(self, filepath: str, content: str, encoding: str = 'utf-8',
force: bool = False) -> bool:
"""
Write content to a file.
The write runs under a per-file lock shared with the edit tools, so a
concurrent edit/write to the same path serialises instead of racing.
If the file already exists and was previously read via these tools, the
write aborts when the on-disk content changed since that read, unless
``force=True`` is passed.
Args:
filepath: Path to the file
content: Content to write
encoding: File encoding (default: utf-8)
force: Bypass the automatic staleness guard for a blind overwrite
Returns:
bool: True if successful, False otherwise
@@ -116,16 +156,46 @@ class FileTools:
self._require_workspace_access(write=True)
# Validate path to prevent traversal attacks
safe_path = self._validate_path(filepath)
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
# Auto-coerce non-string content (LLMs sometimes pass dicts/lists)
if not isinstance(content, str):
if isinstance(content, (dict, list)):
content = json.dumps(content, indent=2, ensure_ascii=False)
else:
content = str(content)
with open(safe_path, 'w', encoding=encoding) as f:
f.write(content)
# Serialise concurrent writes/edits on this canonical path.
with _file_locks.get_lock(safe_path):
# Automatic staleness guard for existing files.
if not force and os.path.exists(safe_path):
recorded = _file_locks.get_read_hash(safe_path)
if recorded is not None:
# Fail closed: if the verification re-read raises (decode,
# permission, etc.) refuse the write rather than blindly
# clobbering the file.
try:
current = self._content_hash(safe_path, encoding)
except Exception as e:
logging.error(
"Refusing to write %s: could not verify staleness: %s",
filepath, e)
return False
if current != recorded:
logging.error(
"Refusing to write %s: file changed since it was "
"read - re-read before writing (or pass force=True)",
filepath)
return False
# Create directory if it doesn't exist
os.makedirs(os.path.dirname(safe_path), exist_ok=True)
with open(safe_path, 'w', encoding=encoding) as f:
f.write(content)
# Record the new content hash (in the on-disk byte form) so
# follow-up writes are not falsely flagged stale and the hash
# matches what a later read by either tool computes.
try:
_file_locks.record_read_hash(
safe_path, self._content_hash(safe_path, encoding))
except Exception:
pass
return True
except Exception as e:
error_msg = f"Error writing to file {filepath}: {str(e)}"
@@ -7,6 +7,7 @@ logger = logging.getLogger(__name__)
@tool
def github_create_branch(branch_name: str) -> str:
"""Create and checkout a new git branch.
Args:
branch_name: The name of the branch to create and checkout.
@@ -14,7 +15,16 @@ def github_create_branch(branch_name: str) -> str:
try:
# Check if we are in a git repository
subprocess.run(["git", "rev-parse", "--is-inside-work-tree"], check=True, capture_output=True)
# Validate branch name to prevent misinterpretation as git options or invalid refs
try:
subprocess.run(
["git", "check-ref-format", "--branch", branch_name],
check=True, capture_output=True, text=True
)
except subprocess.CalledProcessError as e:
return f"Error: invalid branch name '{branch_name}': {e.stderr.strip() if e.stderr else 'branch name is not a valid git ref'}"
subprocess.run(["git", "checkout", "-B", branch_name], check=True, capture_output=True, text=True)
logger.debug(f"Branch '{branch_name}' checked out successfully.")
logger.debug(f"Successfully checked out branch '{branch_name}'")
return f"Successfully created and checked out branch '{branch_name}'"
except subprocess.CalledProcessError as e:
@@ -139,6 +139,66 @@ def _parse_media(message: str) -> tuple[str, Optional[List[str]]]:
return text, (media or None)
def _check_send_policy(target: str) -> Optional[str]:
"""Evaluate the active send-policy for ``target`` (Issue #2226).
Returns a model-readable denial string when the send is *not* permitted, or
``None`` when allowed (no policy registered, or policy allows). The policy
is consulted from the task-local context and is enriched with the current
session's agent/session/origin so back-ends can scope decisions.
"""
try:
from ..session.context import get_send_policy, get_session_context
from ..gateway.protocols import SendDecision
except Exception:
# The session-context module is unavailable, so the send-policy feature
# cannot have been configured. Preserve today's allow-all behaviour.
return None
policy = get_send_policy()
if policy is None:
return None
agent_id = session_id = ""
origin = None
try:
ctx = get_session_context()
agent_id = getattr(ctx, "user_id", "") or ""
session_id = getattr(ctx, "chat_id", "") or ""
ctx_origin = getattr(ctx, "origin", None)
if ctx_origin is not None and ctx_origin.platform:
origin = ctx_origin.platform
except Exception:
# Context enrichment is best-effort; the policy still evaluates the
# target with empty scope identifiers.
logger.debug("send_policy context enrichment failed", exc_info=True)
try:
decision = policy.evaluate(
target,
agent_id=agent_id,
session_id=session_id,
origin=origin,
)
except Exception as e:
logger.error("send_policy evaluation failed: %s", e, exc_info=True)
return f"Failed to send to {target}: send_policy evaluation error"
# Fail closed: a policy that returns anything other than a SendDecision is
# treated as a denial (consistent with how evaluation exceptions are
# handled), so a non-conforming back-end can never implicitly allow a send.
if not isinstance(decision, SendDecision):
logger.error(
"send_policy.evaluate() returned unexpected type %r; blocking send",
type(decision),
)
return f"Failed to send to {target}: send_policy evaluation error"
if not decision.allow:
reason = decision.reason or "target not permitted by send_policy"
return f"Failed to send to {target}: {reason}"
return None
def send_message(
target: str = "origin",
message: str = "",
@@ -179,6 +239,15 @@ def send_message(
if action != "send":
return f"Unknown action '{action}'. Use 'send' or 'list'."
# Outbound send-policy guard (Issue #2226): the target is
# model-controlled, so a steered/prompt-injected agent could misdeliver
# to any reachable channel. Enforce the operator's allow/deny policy
# *before* dispatch so every messenger implementation is constrained.
# Absent a policy, today's behaviour is preserved (allow-all).
denied = _check_send_policy(target)
if denied is not None:
return denied
text, media = _parse_media(message)
result = _run_async(messenger.send(target, text, media=media))
return result.summary or (
@@ -0,0 +1,24 @@
"""Path containment helpers for tool and context safety."""
from __future__ import annotations
import os
from typing import Optional
def resolve_within_root(path: str, root: Optional[str] = None) -> Optional[str]:
"""Resolve *path* under *root* and reject traversal outside the root."""
base = os.path.realpath(root or os.getcwd())
expanded = os.path.expanduser(path)
candidate = (
os.path.join(base, expanded)
if not os.path.isabs(expanded)
else expanded
)
resolved = os.path.realpath(os.path.normpath(candidate))
try:
if os.path.commonpath([resolved, base]) != base:
return None
except ValueError:
return None
return resolved
@@ -388,12 +388,63 @@ def execute_code(
)
def execute_code_with_tools(
code: str,
allowed_tools: Optional[List[str]] = None,
registry: Optional[Any] = None,
timeout: int = 30,
max_output_size: int = 10000,
) -> Dict[str, Any]:
"""Execute model-generated code that may call the agent's registered tools.
This is the *code-execution-with-tools* (code mode) bridge. Unlike the
subprocess sandbox (which runs in a clean process and therefore cannot see
the agent's tools), this runs in-process with restricted builtins and
injects thin proxies for the tools on ``allowed_tools``. Each proxy call
resolves against the :class:`ToolRegistry`, enforces the allow-list, and
passes through the existing ``require_approval`` gate. Only the script's
stdout / last-expression value is returned — intermediate tool results stay
out of the caller's context.
Args:
code: Python code to execute. May call allowed tools by bare name
(e.g. ``fetch(...)``) or via ``tools.fetch(...)``.
allowed_tools: Explicit per-run allow-list of tool names callable from
code. Empty/None means no tools are exposed.
registry: Optional ToolRegistry; defaults to the global registry.
timeout: Maximum execution time in seconds.
max_output_size: Maximum output size in characters.
Returns:
Dictionary with result, stdout, stderr, and success status.
"""
from .tool_proxy import ToolProxy, build_tool_namespace
allowed = list(allowed_tools or [])
if "tools" in allowed:
raise ValueError(
"'tools' is a reserved name in code mode and cannot be an "
"allow-listed tool; rename the tool."
)
injected: Dict[str, Any] = {}
if allowed:
injected.update(build_tool_namespace(allowed, registry=registry))
injected["tools"] = ToolProxy(allowed, registry=registry)
return _execute_code_direct(
code,
globals_dict=injected or None,
timeout=timeout,
max_output_size=max_output_size,
)
def _execute_code_direct(
code: str,
globals_dict: Optional[Dict[str, Any]] = None,
locals_dict: Optional[Dict[str, Any]] = None,
timeout: int = 30,
max_output_size: int = 10000
max_output_size: int = 10000,
) -> Dict[str, Any]:
"""Execute Python code directly in current process with restricted builtins.
@@ -63,6 +63,7 @@ def schedule_add(
channel_id: str = "",
agent_id: str = "",
session_id: str = "",
accept_suggestion: str = "",
) -> str:
"""Add a new scheduled job.
@@ -85,6 +86,16 @@ def schedule_add(
executor uses its default agent.
session_id: Optional session ID to preserve conversation context.
If set, the agent will have access to prior chat history.
accept_suggestion: If set, accept the suggestion with this ID
after the job is successfully added.
Note:
The ``pre_run`` shell gate is intentionally NOT exposed through this
agent-callable tool. ``pre_run`` runs an arbitrary shell command on the
host on every tick, so accepting it from an LLM would let a prompt-
injected agent persist server-side command execution. It is configured
only by trusted job authors via the ``praisonai schedule add --pre-run``
CLI or by constructing a :class:`ScheduleJob` directly in Python.
Returns:
Confirmation string with the job id.
@@ -155,7 +166,23 @@ def schedule_add(
else:
delivery_note = ""
agent_note = f" agent={agent_id}" if agent_id else ""
return f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
confirmation = f"Schedule '{name}' added (id: {job.id}, {schedule}{agent_note}{delivery_note})."
# ── Suggestion acceptance integration ─────────────────────
if accept_suggestion:
try:
from ..scheduler.suggestion_store import SuggestionStore
store_obj = SuggestionStore()
sug = store_obj.get(accept_suggestion)
if sug is not None and not sug.accepted and not sug.dismissed:
store_obj.accept(accept_suggestion)
except Exception as e:
logger.warning(
"Failed to accept suggestion %s: %s",
accept_suggestion, e,
)
return confirmation
except ValueError as e:
return f"Error adding schedule: {e}"
except Exception as e:
@@ -72,6 +72,13 @@ class SkillTools:
if not os.path.isabs(script_path):
script_path = os.path.join(self._working_directory, script_path)
script_path = os.path.abspath(script_path)
root = os.path.realpath(self._working_directory)
try:
if os.path.commonpath([script_path, root]) != root:
return f"Error: Script path outside working directory: {script_path}"
except ValueError:
return f"Error: Script path outside working directory: {script_path}"
if not os.path.exists(script_path):
return f"Error: Script not found at {script_path}"
@@ -236,17 +243,26 @@ class SkillTools:
return f"Error listing skill scripts: {str(e)}"
@require_approval(risk_level="low")
def skill_manage(self, action: str, name: str, *,
def skill_manage(self, action: str, name: str = "", *,
content=None, category=None, file_path=None,
file_content=None, old_string=None,
new_string=None, replace_all=False) -> str:
new_string=None, replace_all=False,
propose=None) -> str:
"""Hermes-compatible skill management tool.
Actions: create, edit, patch, delete, write_file, remove_file
Mutating actions (create, edit, patch, delete, write_file, remove_file)
are *staged for human approval by default* (safe-by-default). The model
receives a ``{"status": "pending", "id": ...}`` response and a human
approves via the ``approve``/``reject`` actions (or the gateway
``/skills`` commands). Pass ``propose=False`` to write directly in
trusted/local contexts.
Actions: create, edit, patch, delete, write_file, remove_file,
pending, approve, reject
Args:
action: Action to perform
name: Skill name
name: Skill name (or pending id for approve/reject)
content: Skill content (for create/edit)
category: Skill category (for create)
file_path: File path within skill (for patch/write_file/remove_file)
@@ -254,6 +270,9 @@ class SkillTools:
old_string: String to find (for patch)
new_string: Replacement string (for patch)
replace_all: Replace all occurrences (for patch)
propose: Override the staging policy. If None, uses the manager's
write-approval default; True stages for approval; False writes
directly.
Returns:
JSON string with result
@@ -266,30 +285,43 @@ class SkillTools:
if action == "create":
if not content:
return json.dumps({"success": False, "error": "Content required for create action"})
result = self.skill_manager.create_skill(name, content, category)
result = self.skill_manager.create_skill(name, content, category, propose=propose)
elif action == "edit":
if not content:
return json.dumps({"success": False, "error": "Content required for edit action"})
result = self.skill_manager.edit_skill(name, content)
result = self.skill_manager.edit_skill(name, content, propose=propose)
elif action == "patch":
if not old_string or new_string is None:
return json.dumps({"success": False, "error": "old_string and new_string required for patch action"})
result = self.skill_manager.patch_skill(name, old_string, new_string, file_path, replace_all)
result = self.skill_manager.patch_skill(name, old_string, new_string, file_path, replace_all, propose=propose)
elif action == "delete":
result = self.skill_manager.delete_skill(name)
result = self.skill_manager.delete_skill(name, propose=propose)
elif action == "write_file":
if not file_path or not file_content:
if not file_path or file_content is None:
return json.dumps({"success": False, "error": "file_path and file_content required for write_file action"})
result = self.skill_manager.write_skill_file(name, file_path, file_content)
result = self.skill_manager.write_skill_file(name, file_path, file_content, propose=propose)
elif action == "remove_file":
if not file_path:
return json.dumps({"success": False, "error": "file_path required for remove_file action"})
result = self.skill_manager.remove_skill_file(name, file_path)
result = self.skill_manager.remove_skill_file(name, file_path, propose=propose)
elif action == "pending":
result = {"success": True, "pending": self.skill_manager.list_pending()}
elif action == "approve":
if not name:
return json.dumps({"success": False, "error": "id or name required for approve action"})
result = self.skill_manager.approve(name)
elif action == "reject":
if not name:
return json.dumps({"success": False, "error": "id or name required for reject action"})
result = self.skill_manager.reject(name)
else:
return json.dumps({"success": False, "error": f"Unknown action: {action}"})
@@ -412,14 +444,15 @@ def list_skill_scripts(skill_path: str) -> str:
@require_approval(risk_level="low")
def skill_manage(action: str, name: str, **kwargs) -> str:
def skill_manage(action: str, name: str = "", **kwargs) -> str:
"""Skill management tool (hermes-compatible API).
Actions: create, edit, patch, delete, write_file, remove_file
Actions: create, edit, patch, delete, write_file, remove_file,
pending, approve, reject
Args:
action: Action to perform
name: Skill name
name: Skill name (or pending id for approve/reject; omit for pending)
**kwargs: Additional parameters for the action
Returns:
@@ -0,0 +1,226 @@
"""Bridge that lets model-generated code call registered tools (code mode).
This connects three pieces that already live in core but were not wired
together:
* the in-process code executor (``tools/python_tools.py``)
* the runtime tool registry (``tools/registry.py``)
* the approval framework (``approval/__init__.py``)
When enabled, the sandbox namespace is populated with thin proxies for the
agent's *allowed* tools. Each proxy call resolves the name against the
``ToolRegistry``, enforces an explicit per-run allow-list, passes through the
existing ``require_approval`` gate, runs the real tool, and returns the result
into the running script. Only the script's stdout / return value flows back to
the model — intermediate tool results never enter the context window.
Safe by default: tools are callable from code only when the developer turns the
mode on and only for tools on the allow-list; every call still passes the
approval gate.
Security note: the proxy must never expose the underlying ``ToolRegistry`` or
the allow-list to sandboxed code. Storing them as instance attributes is unsafe
because plain ``obj._registry`` attribute access bypasses ``__getattr__`` (the
attribute is found in ``__dict__`` first) and the sandbox's ``getattr`` guard
(direct attribute access compiles to ``LOAD_ATTR``, not a ``getattr`` call). We
therefore keep all state in a closure that is unreachable from any attribute.
"""
from typing import Any, Callable, Dict, Iterable, Optional
from .registry import ToolRegistry, get_registry
def _resolve_callable(tool: Any) -> Callable[..., Any]:
"""Return the underlying callable for a registered tool/BaseTool."""
callable_tool = tool.run if hasattr(tool, "run") and not callable(tool) else tool
if not callable(callable_tool):
callable_tool = getattr(tool, "run", tool)
return callable_tool
def _invoke_with_approval(
name: str, tool: Any, args: tuple, kwargs: Dict[str, Any]
) -> Any:
"""Run a registered tool honouring the existing approval framework.
The ``require_approval`` decorator (when applied to the tool) already gates
the call. For tools that are not decorated but are registered as requiring
approval, we apply the same gate here so code-mode calls cannot bypass it.
Positional arguments are bound to their parameter names before being sent to
the approval backend so the human approver sees every argument value and can
rewrite any of them via ``decision.modified_args``.
"""
from ..approval import (
is_approval_required,
is_yaml_approved,
is_env_auto_approve,
request_approval,
)
callable_tool = _resolve_callable(tool)
# Note: unlike the regular agent path we deliberately do NOT honour the
# per-session ``is_already_approved`` sticky flag, nor do we call
# ``mark_approved`` after approval. In code mode the model controls the whole
# script, so a single approved call must not silently unlock every later call
# to the same tool with attacker-chosen arguments. Each code-mode call is
# gated independently (YAML / env auto-approve still apply as configured).
needs_gate = is_approval_required(name) and not (
is_yaml_approved(name)
or is_env_auto_approve()
)
if needs_gate:
from ..utils.async_bridge import (
is_async_context,
run_coroutine_from_any_context,
)
if is_async_context():
raise PermissionError(
f"Tool '{name}' requires approval but cannot prompt from an "
f"async context. Configure a non-console approval backend."
)
# Bind positional args to parameter names so the approval backend sees
# (and can modify) every argument, not just the keyword ones.
approval_args: Dict[str, Any] = dict(kwargs)
bound = None
try:
import inspect
signature = inspect.signature(callable_tool)
bound = signature.bind_partial(*args, **kwargs)
approval_args = dict(bound.arguments)
except (TypeError, ValueError):
# Signature unavailable/unbindable: fall back to positional preview.
for index, value in enumerate(args):
approval_args.setdefault(f"arg{index}", value)
decision = run_coroutine_from_any_context(
request_approval(name, approval_args)
)
if not decision.approved:
raise PermissionError(
f"Execution of {name} denied: {decision.reason}"
)
if decision.modified_args:
approval_args.update(decision.modified_args)
if bound is not None:
try:
rebound = signature.bind_partial(**approval_args)
return callable_tool(*rebound.args, **rebound.kwargs)
except TypeError:
pass
# Fall back: apply only keyword modifications.
kwargs.update(
{k: v for k, v in decision.modified_args.items() if k in kwargs}
)
return callable_tool(*args, **kwargs)
def _make_proxy(
name: str,
allowed: frozenset,
registry: ToolRegistry,
) -> Callable[..., Any]:
"""Build a single tool proxy enforcing the allow-list and approval gate."""
if name not in allowed:
raise PermissionError(f"tool '{name}' is not allowed from code")
tool = registry.get(name)
if tool is None:
raise NameError(f"tool '{name}' is not registered")
def _proxy(*args: Any, **kwargs: Any) -> Any:
return _invoke_with_approval(name, tool, args, kwargs)
_proxy.__name__ = name
return _proxy
class ToolProxy:
"""Namespace object exposed to sandboxed code as ``tools``.
Resolves tool names against a :class:`ToolRegistry`, enforces an
allow-list, and routes each call through the approval gate.
The registry and allow-list are held in a closure (``__getattribute__``
override) rather than as instance attributes, so sandboxed code cannot reach
them through plain attribute access (e.g. ``tools._registry``) to bypass the
allow-list or approval gate.
Example (inside model-generated code)::
best = min(extract_price(fetch(u)) for u in urls)
print(best)
where ``fetch`` / ``extract_price`` are exposed as proxies.
"""
def __init__(
self,
allowed: Iterable[str],
registry: Optional[ToolRegistry] = None,
) -> None:
allowed_set = frozenset(allowed)
resolved_registry = registry or get_registry()
def _getter(name: str) -> Callable[..., Any]:
return _make_proxy(name, allowed_set, resolved_registry)
# Stash all state inside a closure; never as an instance attribute.
object.__setattr__(self, "_ToolProxy__getter", _getter)
object.__setattr__(self, "_ToolProxy__names", sorted(allowed_set))
def __getattribute__(self, name: str) -> Any:
# Allow a tiny set of dunder methods needed for normal object use;
# everything else is treated as a tool-name lookup.
# NB: ``__init__`` is deliberately excluded — exposing it would let
# sandboxed code call ``tools.__init__([...])`` to re-bind the proxy's
# closure to the global registry with an attacker-chosen allow-list,
# bypassing the per-run allow-list entirely.
if name in ("__class__", "__dict__", "__repr__", "__dir__"):
return object.__getattribute__(self, name)
if name.startswith("_"):
raise AttributeError(name)
getter = object.__getattribute__(self, "_ToolProxy__getter")
return getter(name)
def __setattr__(self, name: str, value: Any) -> None:
raise AttributeError("ToolProxy is read-only")
def __delattr__(self, name: str) -> None:
raise AttributeError("ToolProxy is read-only")
def __dir__(self):
return list(object.__getattribute__(self, "_ToolProxy__names"))
def __repr__(self) -> str:
names = object.__getattribute__(self, "_ToolProxy__names")
return f"ToolProxy(allowed={names})"
def build_tool_namespace(
allowed: Iterable[str],
registry: Optional[ToolRegistry] = None,
) -> Dict[str, Callable[..., Any]]:
"""Build a dict of ``{tool_name: proxy_callable}`` for the allow-list.
The returned mapping can be merged directly into the executor globals so
the model can call ``fetch(...)`` by bare name, in addition to the
``tools.fetch(...)`` form via :class:`ToolProxy`.
"""
allowed_set = frozenset(allowed)
resolved_registry = registry or get_registry()
namespace: Dict[str, Callable[..., Any]] = {}
for name in sorted(allowed_set):
try:
namespace[name] = _make_proxy(name, allowed_set, resolved_registry)
except NameError:
# Tool on the allow-list but not registered yet — skip silently;
# calling it would raise NameError at runtime anyway.
continue
return namespace
@@ -99,6 +99,14 @@ def _crawl_with_crawl4ai(urls: List[str]) -> List[Dict[str, Any]]:
results = []
async with AsyncWebCrawler() as crawler:
for url in urls:
if not _is_safe_crawl_url(url):
results.append({
"url": url,
"content": "",
"error": "URL blocked by SSRF policy",
"provider": "crawl4ai",
})
continue
try:
result = await crawler.arun(url=url)
results.append({
@@ -132,32 +140,9 @@ def _crawl_with_crawl4ai(urls: List[str]) -> List[Dict[str, Any]]:
def _is_safe_crawl_url(url: str) -> bool:
"""Return True when a URL passes SSRF checks for crawling."""
import urllib.parse
import socket
import ipaddress
from praisonaiagents.tools.url_safety import is_safe_http_url
try:
parsed = urllib.parse.urlparse(url)
if parsed.scheme not in ("http", "https"):
return False
hostname = parsed.hostname
if not hostname:
return False
if os.environ.get("ALLOW_LOCAL_CRAWL") == "true":
return True
for info in socket.getaddrinfo(hostname, None):
ip = ipaddress.ip_address(info[4][0])
if (
ip.is_loopback
or ip.is_private
or ip.is_link_local
or ip.is_multicast
or ip.is_unspecified
):
return False
return True
except (socket.gaierror, ValueError, OSError):
return False
return is_safe_http_url(url)
def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
@@ -193,6 +178,8 @@ def _crawl_with_httpx(urls: List[str]) -> List[Dict[str, Any]]:
except ImportError:
# Fallback to urllib
import urllib.request
if not _is_safe_crawl_url(url):
raise ValueError("URL blocked by SSRF policy")
with urllib.request.urlopen(url, timeout=30) as response:
content = response.read().decode('utf-8', errors='ignore')
@@ -104,6 +104,29 @@ def _parse_json_output(output: Any, step_name: str = "step") -> Any:
logger.debug(f"Could not parse JSON from step '{step_name}' output")
return output
def _substitute_action_variables(action: str, all_variables: dict, previous_output: Any, task_input: str) -> str:
"""
Substitute templating placeholders in a prompt/action string.
Performs, in order:
- ``{{key}}`` substitution for each key/value in ``all_variables``
- ``{{previous_output}}`` in-place replacement when present, otherwise
appending a "Context from previous step" section (only if there is a
previous output)
- ``{{input}}`` substitution
Returns the rendered action string.
"""
for key, value in all_variables.items():
action = action.replace(f"{{{{{key}}}}}", str(value))
if previous_output:
if "{{previous_output}}" in action:
action = action.replace("{{previous_output}}", str(previous_output))
else:
# Auto-append context if not explicitly referenced
action = f"{action}\n\nContext from previous step:\n{previous_output}"
return action.replace("{{input}}", task_input)
def _extract_from_schema_echo(data: dict) -> Any:
"""
Extract actual data when LLM echoes the JSON schema structure.
@@ -865,6 +888,15 @@ class AgentFlow:
tools_path = workflow_dir / "tools.py"
if tools_path.exists():
_local_ok = os.environ.get("PRAISONAI_ALLOW_LOCAL_TOOLS", "").strip().lower() in (
"true", "1", "yes",
)
if not _local_ok:
logger.debug(
"Skipping tools.py load for %s: set PRAISONAI_ALLOW_LOCAL_TOOLS=true",
class_name,
)
return None
try:
import importlib.util
spec = importlib.util.spec_from_file_location("tools", tools_path)
@@ -1209,15 +1241,7 @@ class AgentFlow:
# Substitute variables in action
action = step.action or input
for key, value in all_variables.items():
action = action.replace(f"{{{{{key}}}}}", str(value))
if previous_output:
if "{{previous_output}}" in action:
action = action.replace("{{previous_output}}", str(previous_output))
else:
# Auto-append context if not explicitly referenced
action = f"{action}\n\nContext from previous step:\n{previous_output}"
action = action.replace("{{input}}", input)
action = _substitute_action_variables(action, all_variables, previous_output, input)
# Add reasoning prompt if enabled
if self.reasoning:
@@ -1317,15 +1341,7 @@ class AgentFlow:
)
# Substitute variables in action
action = step.action
for key, value in all_variables.items():
action = action.replace(f"{{{{{key}}}}}", str(value))
if previous_output:
if "{{previous_output}}" in action:
action = action.replace("{{previous_output}}", str(previous_output))
else:
# Auto-append context if not explicitly referenced
action = f"{action}\n\nContext from previous step:\n{previous_output}"
action = action.replace("{{input}}", input)
action = _substitute_action_variables(action, all_variables, previous_output, input)
# Add reasoning prompt if enabled
if self.reasoning:
@@ -2170,15 +2186,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
action = normalized.action or input
# Substitute variables
for key, value in all_variables.items():
action = action.replace(f"{{{{{key}}}}}", str(value))
if previous_output:
if "{{previous_output}}" in action:
action = action.replace("{{previous_output}}", str(previous_output))
else:
# Auto-append context if not explicitly referenced
action = f"{action}\n\nContext from previous step:\n{previous_output}"
action = action.replace("{{input}}", input)
action = _substitute_action_variables(action, all_variables, previous_output, input)
# Check if this is a specialized agent (AudioAgent, VideoAgent, ImageAgent, OCRAgent)
agent_class_name = normalized.agent.__class__.__name__
@@ -2213,14 +2221,7 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
hooks=self.hooks, # Propagate hooks/callbacks
)
action = normalized.action
for key, value in all_variables.items():
action = action.replace(f"{{{{{key}}}}}", str(value))
if previous_output:
if "{{previous_output}}" in action:
action = action.replace("{{previous_output}}", str(previous_output))
else:
# Auto-append context if not explicitly referenced
action = f"{action}\n\nContext from previous step:\n{previous_output}"
action = _substitute_action_variables(action, all_variables, previous_output, input)
output = temp_agent.chat(action, stream=stream)
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.72"
version = "1.6.80"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,152 @@
"""Tests for runtime MCP server management on Agent.
These tests use a lightweight fake MCP object so they do not require a live
MCP server or the optional ``mcp`` package to be installed.
"""
import asyncio
import pytest
from praisonaiagents import Agent
class FakeMCP:
"""Minimal stand-in for an MCP instance (iterable of tool callables)."""
def __init__(self, name):
self._name = name
self.shutdown_called = False
def _tool():
return f"{name}-result"
_tool.__name__ = f"{name}_tool"
self._tools = [_tool]
def __iter__(self):
return iter(self._tools)
def get_tools(self):
return self._tools
def shutdown(self):
self.shutdown_called = True
def _make_agent():
return Agent(instructions="test", llm="gpt-4o-mini")
def test_add_mcp_server_makes_tools_available():
agent = _make_agent()
mcp = FakeMCP("notion")
agent.add_mcp_server("notion", mcp)
assert "notion" in agent.list_mcp_servers()
assert mcp in agent.tools
def test_add_duplicate_name_raises():
agent = _make_agent()
agent.add_mcp_server("notion", FakeMCP("notion"))
with pytest.raises(ValueError):
agent.add_mcp_server("notion", FakeMCP("notion2"))
def test_add_empty_name_raises():
agent = _make_agent()
with pytest.raises(ValueError):
agent.add_mcp_server("", FakeMCP("x"))
def test_remove_mcp_server_disconnects_and_deregisters():
agent = _make_agent()
mcp = FakeMCP("notion")
agent.add_mcp_server("notion", mcp)
removed = agent.remove_mcp_server("notion")
assert removed is True
assert mcp.shutdown_called is True
assert mcp not in agent.tools
assert "notion" not in agent.list_mcp_servers()
def test_remove_unknown_returns_false():
agent = _make_agent()
assert agent.remove_mcp_server("nope") is False
def test_refresh_tools_returns_current_tools():
agent = _make_agent()
mcp = FakeMCP("notion")
agent.add_mcp_server("notion", mcp)
tools = agent.refresh_tools()
assert mcp in tools
def test_add_normalizes_non_list_tools():
agent = Agent(instructions="test", llm="gpt-4o-mini")
# Force a non-list tools attribute to exercise normalization
agent.tools = ()
mcp = FakeMCP("notion")
agent.add_mcp_server("notion", mcp)
assert isinstance(agent.tools, list)
assert mcp in agent.tools
def test_add_remove_cycle_allows_reattach():
agent = _make_agent()
agent.add_mcp_server("notion", FakeMCP("notion"))
agent.remove_mcp_server("notion")
# Should be able to attach again under the same name
agent.add_mcp_server("notion", FakeMCP("notion"))
assert "notion" in agent.list_mcp_servers()
def test_close_shuts_down_runtime_mcp_servers():
agent = _make_agent()
mcp = FakeMCP("notion")
agent.add_mcp_server("notion", mcp)
agent.close()
assert mcp.shutdown_called is True
assert agent.list_mcp_servers() == []
def test_aclose_shuts_down_runtime_mcp_servers():
agent = _make_agent()
mcp = FakeMCP("notion")
agent.add_mcp_server("notion", mcp)
asyncio.run(agent.aclose())
assert mcp.shutdown_called is True
assert agent.list_mcp_servers() == []
def test_close_continues_after_one_shutdown_failure():
agent = _make_agent()
failing = FakeMCP("bad")
def _boom():
raise RuntimeError("shutdown failed")
failing.shutdown = _boom
good = FakeMCP("good")
agent.add_mcp_server("bad", failing)
agent.add_mcp_server("good", good)
# One failure must not abort the rest, and the registry is cleared.
agent.close()
assert good.shutdown_called is True
assert agent.list_mcp_servers() == []
@@ -334,6 +334,29 @@ class TestKnowledgePresets:
)
assert isinstance(result, MockKnowledgeConfig)
assert "docs/" in result.sources
def test_knowledge_windows_path_string(self):
"""Windows-style directory paths should be treated as sources."""
from praisonaiagents.config.param_resolver import resolve
result = resolve(
value="C:\\knowledge",
param_name="knowledge",
config_class=MockKnowledgeConfig,
string_mode="path_as_source",
)
assert isinstance(result, MockKnowledgeConfig)
assert result.sources == ["C:\\knowledge"]
for windows_path in ("\\knowledge", "~\\knowledge", "knowledge\\docs"):
result = resolve(
value=windows_path,
param_name="knowledge",
config_class=MockKnowledgeConfig,
string_mode="path_as_source",
)
assert isinstance(result, MockKnowledgeConfig)
assert result.sources == [windows_path]
def test_knowledge_array_sources(self):
"""Array of paths should be sources list."""
@@ -478,3 +478,176 @@ class TestNoCortexParam:
spec = importlib.util.find_spec("praisonaiagents.cortex")
assert spec is None, "praisonaiagents.cortex module should not exist"
class TestUsageTelemetry:
"""Test use_count/last_used usage telemetry on retrieval."""
def setup_method(self):
self.temp_dir = tempfile.mkdtemp()
self.store_path = os.path.join(self.temp_dir, "persona.json")
def teardown_method(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_new_entry_defaults(self):
"""New entries start with use_count 0 and no last_used."""
store = PersonaStore(store_path=self.store_path, user_id="u")
entry = store.add("User likes Python")
assert entry.use_count == 0
assert entry.last_used is None
def test_search_bumps_use_count(self):
"""search() records access on returned entries."""
store = PersonaStore(store_path=self.store_path, user_id="u")
store.add("User likes Python")
results = store.search("Python")
assert len(results) == 1
assert results[0].use_count == 1
assert results[0].last_used is not None
def test_list_all_bumps_use_count(self):
"""list_all() records access on returned entries."""
store = PersonaStore(store_path=self.store_path, user_id="u")
store.add("User likes Python")
store.list_all()
store.list_all()
entry = list(store._entries.values())[0]
assert entry.use_count == 2
def test_telemetry_persisted(self):
"""Usage telemetry survives reload."""
store = PersonaStore(store_path=self.store_path, user_id="u")
store.add("User likes Python")
store.search("Python")
reloaded = PersonaStore(store_path=self.store_path, user_id="u")
entry = list(reloaded._entries.values())[0]
assert entry.use_count == 1
assert entry.last_used is not None
def test_entry_roundtrip_preserves_telemetry(self):
"""LearnEntry to_dict/from_dict preserves telemetry fields."""
entry = LearnEntry(id="x", content="c", use_count=3, last_used="2025-01-01T00:00:00")
restored = LearnEntry.from_dict(entry.to_dict())
assert restored.use_count == 3
assert restored.last_used == "2025-01-01T00:00:00"
class TestBoundedRetention:
"""Test max_entries / retention_days pruning and archival."""
def setup_method(self):
self.temp_dir = tempfile.mkdtemp()
self.store_path = os.path.join(self.temp_dir, "insights.json")
def teardown_method(self):
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_unbounded_by_default(self):
"""No limits configured -> behaviour unchanged, prune is a no-op."""
store = InsightStore(store_path=self.store_path, user_id="u")
for i in range(20):
store.add(f"insight {i}")
assert len(store._entries) == 20
assert store.prune() == 0
def test_max_entries_evicts_on_add(self):
"""Exceeding max_entries archives least-used/oldest on write."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=5)
for i in range(10):
store.add(f"insight {i}")
assert len(store._entries) == 5
def test_max_entries_keeps_most_used(self):
"""Least-used entries are evicted first."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
store.add("keep me")
for i in range(2):
store.add(f"filler {i}")
# Bump usage on the entry we want to keep via the public retrieval path
for _ in range(5):
assert store.search("keep me")
for i in range(5):
store.add(f"new {i}")
contents = [e.content for e in store._entries.values()]
assert "keep me" in contents
assert len(store._entries) == 3
def test_new_entry_not_evicted_when_full(self):
"""A freshly added entry is never the one pruned away on write."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=3)
for i in range(3):
store.add(f"old {i}")
# Give existing entries usage so the newcomer is the least-used
for _ in range(3):
store.search("old")
new = store.add("brand new")
assert store.get(new.id) is not None
contents = [e.content for e in store._entries.values()]
assert "brand new" in contents
assert len(store._entries) == 3
def test_archive_failure_restores_entries(self):
"""If archival cannot persist, pruned entries stay in the active store."""
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
for i in range(5):
store.add(f"insight {i}")
# Force the archive write to fail; entries must not be lost
store._archive = lambda entries: False
before = dict(store._entries)
store.add("trigger prune")
# No entries dropped beyond the new add (archival failed -> restore)
assert set(before).issubset(set(store._entries))
def test_archive_is_recoverable(self):
"""Evicted entries are written to a recoverable archive file."""
from pathlib import Path
import json
store = InsightStore(store_path=self.store_path, user_id="u", max_entries=2)
for i in range(6):
store.add(f"insight {i}")
archive = Path(self.store_path).with_suffix(".archive.json")
assert archive.exists()
with open(archive) as f:
archived = json.load(f)
assert len(archived) == 4
def test_retention_days_archives_stale(self):
"""Entries unused beyond retention_days are archived by prune()."""
from datetime import datetime, timedelta
store = InsightStore(store_path=self.store_path, user_id="u", retention_days=30)
store.add("fresh")
stale = store.add("stale")
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
stale.last_used = old
stale.updated_at = old
stale.created_at = old
store._save()
pruned = store.prune()
assert pruned == 1
contents = [e.content for e in store._entries.values()]
assert "fresh" in contents
assert "stale" not in contents
def test_manager_prune_propagates_config(self):
"""LearnManager.prune() runs across stores with config-driven limits."""
from datetime import datetime, timedelta
config = LearnConfig(persona=True, insights=False, thread=False, retention_days=30)
manager = LearnManager(config=config, user_id="u", store_path=self.temp_dir)
manager.capture_persona("fresh")
stale = manager.capture_persona("stale")
old = (datetime.utcnow() - timedelta(days=60)).isoformat()
stale.last_used = old
stale.updated_at = old
stale.created_at = old
manager._stores["persona"]._save()
result = manager.prune()
assert result["persona"] == 1
assert manager.get_stats()["persona"] == 1
def test_config_to_dict_has_retention(self):
"""LearnConfig exposes retention fields via to_dict."""
config = LearnConfig(max_entries=100, retention_days=90)
d = config.to_dict()
assert d["max_entries"] == 100
assert d["retention_days"] == 90
@@ -0,0 +1,247 @@
"""Tests for skill provenance, usage telemetry, archival, and rollback."""
from pathlib import Path
import tempfile
def _make_manager(tmpdir, name="my-skill", body="Original body."):
from praisonaiagents.skills.manager import SkillManager
skill_dir = Path(tmpdir) / name
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
f"""---
name: {name}
description: A test skill
---
{body}
"""
)
manager = SkillManager()
manager.add_skill(str(skill_dir))
return manager
class TestProvenanceFields:
def test_properties_have_provenance_defaults(self):
from praisonaiagents.skills.models import SkillProperties
props = SkillProperties(name="x", description="y")
assert props.agent_created is False
assert props.created_at is None
assert props.use_count == 0
assert props.last_used is None
assert props.patch_count == 0
def test_to_dict_includes_provenance(self):
from praisonaiagents.skills.models import SkillProperties
props = SkillProperties(
name="x",
description="y",
agent_created=True,
created_at="2024-01-01T00:00:00+00:00",
use_count=3,
patch_count=1,
)
d = props.to_dict()
assert d["agent-created"] is True
assert d["created-at"] == "2024-01-01T00:00:00+00:00"
assert d["use-count"] == 3
assert d["patch-count"] == 1
def test_parser_reads_provenance(self):
from praisonaiagents.skills.parser import read_properties
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "prov-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: prov-skill
description: provenance test
agent-created: true
created-at: "2024-01-01T00:00:00+00:00"
use-count: 5
patch-count: 2
---
Body.
"""
)
props = read_properties(skill_dir)
assert props.agent_created is True
assert props.created_at == "2024-01-01T00:00:00+00:00"
assert props.use_count == 5
assert props.patch_count == 2
def test_idle_days_none_without_timestamps(self):
from praisonaiagents.skills.models import SkillProperties
props = SkillProperties(name="x", description="y")
assert props.idle_days is None
def test_idle_days_positive_for_old_timestamp(self):
from praisonaiagents.skills.models import SkillProperties
props = SkillProperties(
name="x", description="y", created_at="2000-01-01T00:00:00+00:00"
)
assert props.idle_days is not None
assert props.idle_days > 1000
class TestCreateProvenance:
def test_create_skill_marks_agent_created(self):
with tempfile.TemporaryDirectory() as tmpdir:
from praisonaiagents.skills import manager as manager_mod
orig = manager_mod.get_default_skill_dirs
manager_mod.get_default_skill_dirs = lambda: [tmpdir]
try:
manager = manager_mod.SkillManager()
result = manager.create_skill("scrape-x", "Do the scrape.", propose=False)
assert result["success"] is True
skill = manager.get_skill("scrape-x")
assert skill.properties.agent_created is True
assert skill.properties.created_at is not None
assert skill.properties.use_count == 0
finally:
manager_mod.get_default_skill_dirs = orig
class TestUsageTelemetry:
def test_invoke_increments_use_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
manager.invoke("my-skill")
skill = manager.get_skill("my-skill")
assert skill.properties.use_count == 1
assert skill.properties.last_used is not None
def test_get_instructions_increments_use_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
manager.get_instructions("my-skill")
skill = manager.get_skill("my-skill")
assert skill.properties.use_count == 1
def test_use_count_persists_to_frontmatter(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
manager.get_instructions("my-skill")
content = (Path(tmpdir) / "my-skill" / "SKILL.md").read_text()
assert "use-count: 1" in content
assert "last-used:" in content
def test_patch_increments_patch_count(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
res = manager.patch_skill("my-skill", "Original body.", "New body.", propose=False)
assert res["success"] is True
skill = manager.get_skill("my-skill")
assert skill.properties.patch_count == 1
def test_telemetry_preserves_embedded_fence_in_value(self):
"""A frontmatter value containing '---' must not corrupt the file
when telemetry is persisted line-wise."""
from praisonaiagents.skills.manager import SkillManager
skill_dir = Path(tmpdir := tempfile.mkdtemp()) / "fence-skill"
skill_dir.mkdir()
(skill_dir / "SKILL.md").write_text(
"""---
name: fence-skill
description: Parse YAML---based configs
---
The body --- with a fence-like line stays intact.
"""
)
manager = SkillManager()
manager.add_skill(str(skill_dir))
manager.get_instructions("fence-skill")
content = (skill_dir / "SKILL.md").read_text()
assert "description: Parse YAML---based configs" in content
assert "The body --- with a fence-like line stays intact." in content
assert "use-count: 1" in content
class TestArchivalAndRestore:
def test_archive_then_restore(self):
with tempfile.TemporaryDirectory() as tmpdir:
from praisonaiagents.skills import manager as manager_mod
orig = manager_mod.get_default_skill_dirs
manager_mod.get_default_skill_dirs = lambda: [str(Path(tmpdir) / "skills")]
try:
(Path(tmpdir) / "skills").mkdir()
manager = manager_mod.SkillManager()
manager.create_skill("temp-skill", "content", propose=False)
assert "temp-skill" in manager
res = manager.archive_skill("temp-skill")
assert res["success"] is True
assert "temp-skill" not in manager
assert "temp-skill" in manager.list_archived_skills()
restored = manager.restore_skill("temp-skill")
assert restored["success"] is True
assert "temp-skill" in manager
finally:
manager_mod.get_default_skill_dirs = orig
def test_delete_default_is_recoverable(self):
with tempfile.TemporaryDirectory() as tmpdir:
from praisonaiagents.skills import manager as manager_mod
orig = manager_mod.get_default_skill_dirs
manager_mod.get_default_skill_dirs = lambda: [str(Path(tmpdir) / "skills")]
try:
(Path(tmpdir) / "skills").mkdir()
manager = manager_mod.SkillManager()
manager.create_skill("temp-skill", "content", propose=False)
res = manager.delete_skill("temp-skill", propose=False)
assert res["success"] is True
assert "temp-skill" in manager.list_archived_skills()
finally:
manager_mod.get_default_skill_dirs = orig
def test_hard_delete_is_unrecoverable(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
res = manager.delete_skill("my-skill", hard=True, propose=False)
assert res["success"] is True
assert "my-skill" not in manager
class TestRollback:
def test_rollback_patch(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
manager.patch_skill("my-skill", "Original body.", "Broken body.", propose=False)
instructions = manager.get_instructions("my-skill")
assert "Broken body." in instructions
res = manager.rollback_skill("my-skill")
assert res["success"] is True
assert "Original body." in manager.get_instructions("my-skill")
def test_rollback_edit(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
manager.edit_skill("my-skill", "Replacement body.", propose=False)
assert "Replacement body." in manager.get_instructions("my-skill")
res = manager.rollback_skill("my-skill")
assert res["success"] is True
assert "Original body." in manager.get_instructions("my-skill")
def test_rollback_without_snapshot_fails_cleanly(self):
with tempfile.TemporaryDirectory() as tmpdir:
manager = _make_manager(tmpdir)
res = manager.rollback_skill("my-skill")
assert res["success"] is False
assert "No rollback snapshot" in res["error"]
@@ -270,7 +270,7 @@ content
manager = SkillManager()
manager.add_skill(str(skill_dir))
result = manager.patch_skill("safe-skill", "content", "updated", "../outside.md")
result = manager.patch_skill("safe-skill", "content", "updated", "../outside.md", propose=False)
assert result["success"] is False
assert "Path traversal detected" in result["error"]
@@ -291,6 +291,203 @@ content
manager = SkillManager()
manager.add_skill(str(skill_dir))
result = manager.write_skill_file("safe-skill", "scripts/../../outside.py", "print('x')")
result = manager.write_skill_file("safe-skill", "scripts/../../outside.py", "print('x')", propose=False)
assert result["success"] is False
assert "Path traversal detected" in result["error"]
class TestSkillApprovalGate:
"""Tests for the safe-by-default skill mutation approval gate."""
def test_create_stages_by_default(self, monkeypatch):
"""create_skill defaults to staging, not writing to disk."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
result = manager.create_skill("staged-skill", "# body")
assert result["status"] == "pending"
assert result["id"].startswith("skl-")
# Not written to disk / not loaded live.
assert "staged-skill" not in manager
skill_path = Path(tmpdir) / "home" / "skills" / "staged-skill"
assert not skill_path.exists()
def test_propose_false_writes_directly(self, monkeypatch):
"""propose=False bypasses the gate and writes immediately."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
result = manager.create_skill("direct-skill", "# body", propose=False)
assert result["success"] is True
assert result.get("status") != "pending"
assert "direct-skill" in manager
def test_env_disables_approval(self, monkeypatch):
"""SKILL_WRITE_APPROVAL=0 makes writes direct by default."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
monkeypatch.setenv("SKILL_WRITE_APPROVAL", "0")
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
result = manager.create_skill("env-skill", "# body")
assert result.get("status") != "pending"
assert "env-skill" in manager
def test_list_pending_and_approve(self, monkeypatch):
"""Staged mutation appears in list_pending and applies on approve."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
staged = manager.create_skill("approve-me", "# body")
request_id = staged["id"]
pending = manager.list_pending()
assert any(p["id"] == request_id for p in pending)
assert pending[0]["action"] == "create"
result = manager.approve(request_id)
assert result["success"] is True
assert "approve-me" in manager
# Pending entry consumed.
assert manager.list_pending() == []
def test_reject_discards_mutation(self, monkeypatch):
"""Rejecting a staged mutation never writes it."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
staged = manager.create_skill("reject-me", "# body")
request_id = staged["id"]
result = manager.reject(request_id)
assert result["success"] is True
assert result["status"] == "rejected"
assert "reject-me" not in manager
assert manager.list_pending() == []
def test_approve_unknown_id_fails(self, monkeypatch):
"""Approving a non-existent id returns an error."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
result = manager.approve("skl-nonexistent")
assert result["success"] is False
def test_stage_rejects_oversized_content(self, monkeypatch):
"""Oversized proposals are rejected before reaching the pending store."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
result = manager.create_skill("too-big", "x" * 100_001)
assert result["success"] is False
assert manager.list_pending() == []
def test_stage_rejects_invalid_name(self, monkeypatch):
"""Invalid skill names are rejected at staging time for all actions."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
result = manager.edit_skill("../escape", "# body")
assert result["success"] is False
assert manager.list_pending() == []
def test_pending_store_size_cap(self, monkeypatch):
"""The pending store refuses new proposals past SKILL_MAX_PENDING."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
monkeypatch.setenv("SKILL_MAX_PENDING", "2")
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
assert manager.create_skill("one", "# a")["status"] == "pending"
assert manager.create_skill("two", "# b")["status"] == "pending"
full = manager.create_skill("three", "# c")
assert full["success"] is False
assert len(manager.list_pending()) == 2
def test_approve_failure_keeps_record(self, monkeypatch):
"""A failed apply preserves the pending record and does not lie in audit."""
from praisonaiagents.skills.manager import SkillManager
with tempfile.TemporaryDirectory() as tmpdir:
monkeypatch.chdir(tmpdir)
monkeypatch.setenv("PRAISONAI_HOME", str(Path(tmpdir) / "home"))
from praisonaiagents import paths
paths._clear_cache()
manager = SkillManager()
staged = manager.create_skill("flaky", "# body")
request_id = staged["id"]
def _boom(_record):
return {"success": False, "error": "boom"}
monkeypatch.setattr(manager, "_apply_pending", _boom)
result = manager.approve(request_id)
assert result["success"] is False
# Record retained for retry.
assert any(p["id"] == request_id for p in manager.list_pending())
def test_protocol_conformance(self):
"""SkillManager conforms to SkillMutatorProtocol."""
from praisonaiagents.skills.manager import SkillManager
from praisonaiagents.skills.protocols import SkillMutatorProtocol
assert isinstance(SkillManager(), SkillMutatorProtocol)
Loaded 100 of 285 files, more files were not shown because too many files have changed in this diff. Show more