Compare commits

..
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 85ba558a3b fix: real MCP readiness check in example + drop unused SseServerTransport
- simple-mcp-server.py: replace blind 2s sleep with TCP readiness poll so
  the smoke test genuinely verifies the server bound and exits non-zero on
  failure (addresses Greptile P1: daemon exited claiming false success)
- execution_mixin.py: remove now-unused SseServerTransport import/instance
  after switching to FastMCP.sse_app() (dead code flagged by Greptile)

Co-authored-by: Mervin Praison <[email protected]>
2026-07-24 08:57:52 +00:00
1202 changed files with 25423 additions and 127097 deletions

No files matched your search

@@ -1,5 +1,5 @@
name: Install PraisonAI monorepo packages
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, praisonai-deploy, and praisonai wrapper from local paths (mirrors PyPI nine-package install order).
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, and praisonai wrapper from local paths (mirrors PyPI eight-package install order).
inputs:
repo-root:
@@ -40,7 +40,6 @@ runs:
BROWSER_PKG="${BASE}src/praisonai-browser"
MCP_PKG="${BASE}src/praisonai-mcp"
SANDBOX_PKG="${BASE}src/praisonai-sandbox"
DEPLOY_PKG="${BASE}src/praisonai-deploy"
WRAPPER_PKG="${BASE}src/praisonai"
normalise_extras() {
@@ -70,8 +69,6 @@ runs:
uv pip install --system -e .
cd "$SANDBOX_PKG"
uv pip install --system -e .
cd "$DEPLOY_PKG"
uv pip install --system -e .
cd "$WRAPPER_PKG"
uv pip install --system "${WRAPPER_SUFFIX}"
else
@@ -89,8 +86,6 @@ runs:
pip install -e .
cd "$SANDBOX_PKG"
pip install -e .
cd "$DEPLOY_PKG"
pip install -e .
cd "$WRAPPER_PKG"
pip install -e "${WRAPPER_SUFFIX}"
fi
+2 -42
View File
@@ -74,11 +74,7 @@ function isClaudeTriggerNoise(c) {
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
if (!login.includes('praisonai-triage')) return false;
return (c.body || '').includes('Claude finished');
}
@@ -100,47 +96,15 @@ function isConflictRebaseTriggerComment(c) {
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
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('force-pushed') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return false;
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();
if (headTime <= conflictTime) return false;
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
const finalsAfterHead = comments.filter(
(c) =>
isFinalClaudeTriggerComment(c) &&
new Date(c.created_at).getTime() >= headTime - 60000
);
return finalsAfterHead.length === 0;
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
@@ -281,9 +245,6 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
@@ -955,7 +916,6 @@ module.exports = {
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
needsFinalReReviewAfterConflictRebase,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
+22 -87
View File
@@ -11,112 +11,47 @@ assert.ok(versions.currentCode);
assert.ok(versions.currentWrapper);
assert.ok(versions.targetCode);
assert.ok(rg.PACKAGE_PATHS.includes('src/praisonai-code'));
// Every released package must make the gate's path-change check eligible.
for (const p of [
'src/praisonai', 'src/praisonai-agents', 'src/praisonai-code',
'src/praisonai-bot', 'src/praisonai-train', 'src/praisonai-browser',
'src/praisonai-mcp', 'src/praisonai-sandbox', 'src/praisonai-deploy',
]) {
assert.ok(rg.PACKAGE_PATHS.includes(p), `PACKAGE_PATHS missing ${p}`);
}
const noonUtc = new Date('2026-07-08T12:00:00Z');
const dayStart = rg.utcDayStart(noonUtc);
assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
function mockGithub({ runs = [], releases = [] } = {}) {
return {
rest: {
actions: {
listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }),
},
repos: {
listReleases: async () => ({ data: releases }),
},
},
};
}
(async () => {
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);
// Dedupe is release-based: a v* release inside the window blocks…
const recentRelease = mockGithub({
releases: [{ tag_name: 'v4.6.159', published_at: '2026-07-08T09:00:00Z' }],
});
const mockGithub = {
rest: {
actions: {
listWorkflowRuns: async () => ({
data: {
workflow_runs: [
{
conclusion: 'success',
created_at: '2026-07-08T09:00:00Z',
status: 'completed',
},
],
},
}),
},
},
};
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(recentRelease, 'o', 'r', noonUtc),
await rg.hasSuccessfulReleaseToday(mockGithub, 'o', 'r', noonUtc),
true
);
const result = await rg.evaluateReleasePreflight(
recentRelease, 'o', 'r',
mockGithub, 'o', 'r',
{ headSha: 'abc', isCiTrigger: false, bump: 'patch', now: noonUtc },
null
);
assert.ok(result.reasons.some((r) => r.includes('every 3 days')));
// …an old release does not…
const oldRelease = mockGithub({
releases: [{ tag_name: 'v4.6.150', published_at: '2026-07-04T09:00:00Z' }],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(oldRelease, 'o', 'r', noonUtc),
false
);
// …and a dry-run "success" (workflow run, no release) does not consume the
// window: only real releases count.
const dryRunOnly = mockGithub({
runs: [{ conclusion: 'success', created_at: '2026-07-08T09:00:00Z', status: 'completed' }],
releases: [],
});
assert.strictEqual(
await rg.hasSuccessfulReleaseWithinDays(dryRunOnly, 'o', 'r', noonUtc),
false
);
// Active-run blocking: fresh waiting run blocks…
const freshWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T11:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(freshWaiting, 'o', 'r', noonUtc, null),
true
);
// …a waiting run stuck longer than STALE_WAITING_HOURS no longer blocks…
const staleWaiting = mockGithub({
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T01:00:00Z', html_url: 'x' }],
});
const warnings = [];
const fakeCore = { warning: (m) => warnings.push(m), info: () => {} };
assert.strictEqual(rg.STALE_WAITING_HOURS, 6);
assert.strictEqual(
await rg.hasActiveReleaseRun(staleWaiting, 'o', 'r', noonUtc, fakeCore),
false
);
assert.ok(warnings.length === 1 && warnings[0].includes('approve or cancel'));
// …but an in_progress run always blocks regardless of age.
const oldInProgress = mockGithub({
runs: [{ status: 'in_progress', conclusion: null, created_at: '2026-07-07T01:00:00Z', html_url: 'x' }],
});
assert.strictEqual(
await rg.hasActiveReleaseRun(oldInProgress, 'o', 'r', noonUtc, null),
true
);
assert.ok(result.reasons.some((r) => r.includes('already released today')));
console.log('ok: bumpPatch');
console.log('ok: readVersionsFromTree');
console.log('ok: PACKAGE_PATHS covers all 9 released packages');
console.log('ok: pypiVersionExists missing version');
console.log('ok: utcDayStart');
console.log('ok: release-based dedupe (recent blocks, old does not)');
console.log('ok: dry-run success does not consume the release window');
console.log('ok: 3-day dedupe blocks second release');
console.log('ok: fresh waiting run blocks; stale waiting run ignored with warning');
console.log('ok: in_progress always blocks');
console.log('ok: hasSuccessfulReleaseToday');
console.log('ok: daily dedupe blocks second release');
})();
+20 -105
View File
@@ -4,31 +4,10 @@
const https = require('https');
/** Every directory whose changes should make a release eligible — all nine
* published packages (mirrors the skip_* inputs in pypi-release.yml). */
const PACKAGE_PATHS = [
'src/praisonai',
'src/praisonai-agents',
'src/praisonai-code',
'src/praisonai-bot',
'src/praisonai-train',
'src/praisonai-browser',
'src/praisonai-mcp',
'src/praisonai-sandbox',
'src/praisonai-deploy',
];
/** Minimum days between successful patch auto-releases. */
const PATCH_RELEASE_INTERVAL_DAYS = 3;
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
const ACTIVE_RELEASE_STATUSES = new Set([
'queued', 'in_progress', 'waiting', 'pending', 'requested',
]);
/** A run stuck in `waiting` (environment approval) longer than this no longer
* blocks the gate. GitHub only auto-fails unapproved deployments after 30
* days, so without a cutoff one forgotten manual dispatch stalls all
* auto-releases for up to a month. The run is warned about, never cancelled —
* if later approved, the pypi-release concurrency group serializes it and its
* pypi_exists checks no-op anything already published. */
const STALE_WAITING_HOURS = 6;
function bumpPatch(version) {
const parts = version.split('.');
@@ -84,65 +63,36 @@ function pypiVersionExists(packageName, version) {
});
}
async function hasActiveReleaseRun(github, owner, repo, now = new Date(), core = null) {
async function hasActiveReleaseRun(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
workflow_id: 'pypi-release.yml',
per_page: 20,
});
const staleCutoff = now.getTime() - STALE_WAITING_HOURS * 60 * 60 * 1000;
return runs.data.workflow_runs.some((r) => {
if (!ACTIVE_RELEASE_STATUSES.has(r.status) || r.conclusion) return false;
if (r.status === 'waiting' && new Date(r.created_at).getTime() < staleCutoff) {
if (core) {
core.warning(
`Ignoring release run waiting >${STALE_WAITING_HOURS}h for environment approval: `
+ `${r.html_url} — approve or cancel it. It no longer blocks auto-releases.`
);
}
return false;
}
return true;
});
return runs.data.workflow_runs.some(
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
);
}
function utcDayStart(now = new Date()) {
return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
}
function releaseIntervalStart(now = new Date(), intervalDays = PATCH_RELEASE_INTERVAL_DAYS) {
return new Date(now.getTime() - intervalDays * 24 * 60 * 60 * 1000);
}
async function hasSuccessfulReleaseWithinDays(
github,
owner,
repo,
now = new Date(),
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
) {
const windowStart = releaseIntervalStart(now, intervalDays);
// Dedupe on GitHub releases (v* tags) rather than run conclusions: a
// dry_run=true dispatch concludes 'success' without publishing anything and
// must not consume the release window. Only a real full release creates a
// v* GitHub release (bump_and_release.py's `gh release create`).
const releases = await github.rest.repos.listReleases({
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
const dayStart = utcDayStart(now);
const runs = await github.rest.actions.listWorkflowRuns({
owner,
repo,
per_page: 10,
workflow_id: 'pypi-release.yml',
status: 'completed',
per_page: 30,
});
return releases.data.some(
(r) => r.tag_name && r.tag_name.startsWith('v')
&& new Date(r.published_at || r.created_at) >= windowStart
return runs.data.workflow_runs.some(
(r) => r.conclusion === 'success' && new Date(r.created_at) >= dayStart
);
}
/** @deprecated Use hasSuccessfulReleaseWithinDays — kept for selftests. */
async function hasSuccessfulReleaseToday(github, owner, repo, now = new Date()) {
return hasSuccessfulReleaseWithinDays(github, owner, repo, now, 1);
}
async function lastGreenCoreTestsSha(github, owner, repo) {
const runs = await github.rest.actions.listWorkflowRuns({
owner,
@@ -179,18 +129,15 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
return out;
}
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasActiveReleaseRun(github, owner, repo, referenceTime, core)) {
if (await hasActiveReleaseRun(github, owner, repo)) {
reasons.push('PyPI Release already in progress or awaiting approval');
return out;
}
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
reasons.push(
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
+ `max one patch release every ${PATCH_RELEASE_INTERVAL_DAYS} days`
);
const referenceTime = options.now instanceof Date ? options.now : new Date();
if (await hasSuccessfulReleaseToday(github, owner, repo, referenceTime)) {
const day = referenceTime.toISOString().slice(0, 10);
reasons.push(`already released today (UTC ${day}); max one patch release per day`);
return out;
}
@@ -260,35 +207,8 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
out.headSha = evalSha;
const greenSha = await lastGreenCoreTestsSha(github, owner, repo);
if (greenSha !== evalSha) {
// Core Tests has path filters and honors [skip ci] (the release
// safety-net commit uses it), so the tip of main may legitimately have
// no Core Tests run of its own — e.g. a docs-only commit, or the
// "chore(release): … [skip ci]" commit right after a release. Accept a
// green ancestor when nothing under the released package paths changed
// after it; otherwise the cron path stalls until the next src/** push.
let greenAncestorOk = false;
if (greenSha && /^[0-9a-f]{40}$/.test(greenSha)) {
try {
execSync(`git merge-base --is-ancestor ${greenSha} ${evalSha}`);
const delta = execSync(
`git diff --name-only ${greenSha} ${evalSha} -- ${PACKAGE_PATHS.join(' ')}`,
{ encoding: 'utf8' }
).trim();
greenAncestorOk = delta === '';
} catch {
greenAncestorOk = false;
}
}
if (!greenAncestorOk) {
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
if (core) {
core.info(
`HEAD ${evalSha.slice(0, 7)} has no Core Tests run; accepting green ancestor `
+ `${greenSha.slice(0, 7)} (no package-path changes since).`
);
}
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
return out;
}
}
@@ -302,11 +222,6 @@ module.exports = {
bumpPatch,
readVersionsFromTree,
pypiVersionExists,
PATCH_RELEASE_INTERVAL_DAYS,
STALE_WAITING_HOURS,
releaseIntervalStart,
hasActiveReleaseRun,
hasSuccessfulReleaseWithinDays,
hasSuccessfulReleaseToday,
utcDayStart,
evaluateReleasePreflight,
+4 -8
View File
@@ -1,12 +1,12 @@
name: Nightly Release Gate
# Preflight before PyPI Release (patch bump).
# Triggers: Core Tests success on main (workflow_run) or cron every 3 days at 00:00 UTC.
# Dedupe: max one successful patch release every 3 days (see release-gate.js).
# Triggers: Core Tests success on main (workflow_run) or nightly cron at 00:00 UTC.
# Dedupe: max one successful patch release per UTC day (see release-gate.js).
on:
schedule:
- cron: '0 0 */3 * *'
- cron: '0 0 * * *'
workflow_dispatch:
inputs:
dry_run:
@@ -41,7 +41,7 @@ jobs:
github.event.workflow_run.head_branch == 'main' &&
github.event.workflow_run.event == 'push' &&
!startsWith(github.event.workflow_run.head_commit.message, 'Release v') &&
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release):')
!startsWith(github.event.workflow_run.head_commit.message, 'Bump praisonai')
)
runs-on: ubuntu-latest
env:
@@ -69,10 +69,6 @@ jobs:
- name: Fetch main and tags
run: git fetch --tags origin main
# Guards the gate logic itself — no workflow ran these selftests before.
- name: Selftest release-gate script
run: node .github/scripts/release-gate-selftest.js
- name: Preflight — path changes, CI SHA, dedupe
id: preflight
uses: actions/github-script@v7
+170 -427
View File
@@ -61,29 +61,6 @@ on:
required: false
default: false
type: boolean
skip_deploy:
description: 'Skip praisonai-deploy publish (recovery if already on PyPI)'
required: false
default: false
type: boolean
# NOTE: GitHub caps workflow_dispatch at 25 top-level inputs; this
# workflow has 23. Adding a 10th package (skip_* + *_version) hits 25.
only:
description: 'Release ONLY this package (forces skip on all others); all = no restriction'
required: false
default: all
type: choice
options:
- all
- agents
- code
- bot
- train
- browser
- mcp
- sandbox
- deploy
- wrapper
agents_version:
description: 'Override agents version (empty = auto bump from pyproject.toml)'
required: false
@@ -124,11 +101,6 @@ on:
required: false
default: ''
type: string
deploy_version:
description: 'Override praisonai-deploy version (empty = auto bump from pyproject.toml)'
required: false
default: ''
type: string
source:
description: 'Trigger source (audit trail)'
required: false
@@ -162,35 +134,15 @@ jobs:
'pypi-auto' || 'pypi'
}}
env:
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
UV_PUBLISH_USERNAME: __token__
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
# GH_TOKEN is NOT set here: it is a GitHub App installation token minted in
# "Generate GitHub App token" and exported via $GITHUB_ENV, so releases are
# authored by praisonai-triage-agent[bot] rather than MervinPraison.
UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
GH_TOKEN: ${{ secrets.GH_TOKEN }}
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
# Scope to this repo only. Without it the token is minted for every
# repository the owner has; this job never leaves PraisonAI (checkout,
# push to origin, and gh release create all target it).
repositories: ${{ github.event.repository.name }}
- name: Checkout main
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
with:
ref: main
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
# Do not bake the token into .git/config: an http.extraheader would
# outrank the remote URL set in "Configure git" and pin this first
# (1h) token, defeating the refresh before the wrapper release.
persist-credentials: false
token: ${{ secrets.GH_TOKEN }}
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
@@ -201,19 +153,10 @@ jobs:
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
- name: Configure git
env:
APP_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git config user.name "praisonai-triage-agent[bot]"
git config user.email "272766704+praisonai-triage-agent[bot]@users.noreply.github.com"
# persist-credentials:false left the remote unauthenticated; push auth
# comes from the app token embedded here (masked in logs, and the
# token dies with the runner).
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
GH_TOKEN="${APP_TOKEN}" gh auth setup-git
git config user.name "MervinPraison"
git config user.email "[email protected]"
gh auth setup-git
- name: Release summary
run: |
@@ -223,36 +166,6 @@ jobs:
echo "Dry run: ${{ inputs.dry_run }}"
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
# Single source of truth for which packages this run releases. `only`
# inverts into the skip flags here; everything downstream (version
# computation, PyPI detection, publish conditions) consumes these
# outputs, so a dedicated one-package release is one input instead of
# eight skip flags.
- name: Resolve effective skips
id: flags
env:
ONLY: ${{ inputs.only }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
SKIP_CODE: ${{ inputs.skip_code }}
SKIP_BOT: ${{ inputs.skip_bot }}
SKIP_TRAIN: ${{ inputs.skip_train }}
SKIP_BROWSER: ${{ inputs.skip_browser }}
SKIP_MCP: ${{ inputs.skip_mcp }}
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
SKIP_DEPLOY: ${{ inputs.skip_deploy }}
SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
run: |
set -euo pipefail
for pkg in agents code bot train browser mcp sandbox deploy wrapper; do
var="SKIP_$(echo "$pkg" | tr '[:lower:]' '[:upper:]')"
skip="${!var}"
if [ -n "${ONLY}" ] && [ "${ONLY}" != "all" ] && [ "${ONLY}" != "$pkg" ]; then
skip=true
fi
echo "skip_${pkg}=${skip}" >> "$GITHUB_OUTPUT"
echo "skip_${pkg}=${skip}"
done
- name: Compute release versions
id: versions
env:
@@ -265,15 +178,13 @@ jobs:
BROWSER_OVERRIDE: ${{ inputs.browser_version }}
MCP_OVERRIDE: ${{ inputs.mcp_version }}
SANDBOX_OVERRIDE: ${{ inputs.sandbox_version }}
DEPLOY_OVERRIDE: ${{ inputs.deploy_version }}
SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
SKIP_AGENTS: ${{ inputs.skip_agents }}
SKIP_CODE: ${{ inputs.skip_code }}
SKIP_BOT: ${{ inputs.skip_bot }}
SKIP_TRAIN: ${{ inputs.skip_train }}
SKIP_BROWSER: ${{ inputs.skip_browser }}
SKIP_MCP: ${{ inputs.skip_mcp }}
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
run: |
python <<'PY'
import os
@@ -310,7 +221,6 @@ jobs:
browser_override = os.environ.get("BROWSER_OVERRIDE", "").strip()
mcp_override = os.environ.get("MCP_OVERRIDE", "").strip()
sandbox_override = os.environ.get("SANDBOX_OVERRIDE", "").strip()
deploy_override = os.environ.get("DEPLOY_OVERRIDE", "").strip()
validate_version("agents_version", agents_override)
validate_version("wrapper_version", wrapper_override)
validate_version("code_version", code_override)
@@ -319,7 +229,6 @@ jobs:
validate_version("browser_version", browser_override)
validate_version("mcp_version", mcp_override)
validate_version("sandbox_version", sandbox_override)
validate_version("deploy_version", deploy_override)
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
agents_content = agents_pyproject.read_text()
@@ -377,14 +286,6 @@ jobs:
sys.exit(1)
current_sandbox = sandbox_match.group(1)
deploy_pyproject = root / "src/praisonai-deploy/pyproject.toml"
deploy_content = deploy_pyproject.read_text()
deploy_match = re.search(r'^version\s*=\s*"([^"]+)"', deploy_content, re.MULTILINE)
if not deploy_match:
print("Could not read praisonai-deploy version from pyproject.toml", file=sys.stderr)
sys.exit(1)
current_deploy = deploy_match.group(1)
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
wrapper_content = wrapper_version_py.read_text()
wrapper_match = re.search(r'__version__ = "([^"]+)"', wrapper_content)
@@ -401,7 +302,6 @@ jobs:
("browser", current_browser),
("mcp", current_mcp),
("sandbox", current_sandbox),
("deploy", current_deploy),
("wrapper", current_wrapper),
):
if not SEMVER.match(value):
@@ -415,7 +315,6 @@ jobs:
skip_browser = os.environ.get("SKIP_BROWSER", "false").lower() == "true"
skip_mcp = os.environ.get("SKIP_MCP", "false").lower() == "true"
skip_sandbox = os.environ.get("SKIP_SANDBOX", "false").lower() == "true"
skip_deploy = os.environ.get("SKIP_DEPLOY", "false").lower() == "true"
if agents_override:
agents_version = agents_override
@@ -466,13 +365,6 @@ jobs:
else:
sandbox_version = bump_version(current_sandbox)
if deploy_override:
deploy_version = deploy_override
elif skip_deploy:
deploy_version = current_deploy
else:
deploy_version = bump_version(current_deploy)
if wrapper_override:
wrapper_version = wrapper_override
else:
@@ -487,7 +379,6 @@ jobs:
fh.write(f"browser_version={browser_version}\n")
fh.write(f"mcp_version={mcp_version}\n")
fh.write(f"sandbox_version={sandbox_version}\n")
fh.write(f"deploy_version={deploy_version}\n")
fh.write(f"wrapper_version={wrapper_version}\n")
fh.write(f"current_agents={current_agents}\n")
fh.write(f"current_code={current_code}\n")
@@ -496,7 +387,6 @@ jobs:
fh.write(f"current_browser={current_browser}\n")
fh.write(f"current_mcp={current_mcp}\n")
fh.write(f"current_sandbox={current_sandbox}\n")
fh.write(f"current_deploy={current_deploy}\n")
fh.write(f"current_wrapper={current_wrapper}\n")
print(f"Agents: {current_agents} -> {agents_version}")
@@ -506,7 +396,6 @@ jobs:
print(f"Browser: {current_browser} -> {browser_version}")
print(f"MCP: {current_mcp} -> {mcp_version}")
print(f"Sandbox: {current_sandbox} -> {sandbox_version}")
print(f"Deploy: {current_deploy} -> {deploy_version}")
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
PY
@@ -521,17 +410,15 @@ jobs:
BROWSER_VERSION: ${{ steps.versions.outputs.browser_version }}
MCP_VERSION: ${{ steps.versions.outputs.mcp_version }}
SANDBOX_VERSION: ${{ steps.versions.outputs.sandbox_version }}
DEPLOY_VERSION: ${{ steps.versions.outputs.deploy_version }}
WRAPPER_VERSION: ${{ steps.versions.outputs.wrapper_version }}
INPUT_SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
INPUT_SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
INPUT_SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
INPUT_SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
INPUT_SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
INPUT_SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
INPUT_SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
INPUT_SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
INPUT_SKIP_WRAPPER: ${{ steps.flags.outputs.skip_wrapper }}
INPUT_SKIP_AGENTS: ${{ inputs.skip_agents }}
INPUT_SKIP_CODE: ${{ inputs.skip_code }}
INPUT_SKIP_BOT: ${{ inputs.skip_bot }}
INPUT_SKIP_TRAIN: ${{ inputs.skip_train }}
INPUT_SKIP_BROWSER: ${{ inputs.skip_browser }}
INPUT_SKIP_MCP: ${{ inputs.skip_mcp }}
INPUT_SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
INPUT_SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
run: |
set -euo pipefail
skip_agents="${INPUT_SKIP_AGENTS}"
@@ -541,53 +428,30 @@ jobs:
skip_browser="${INPUT_SKIP_BROWSER}"
skip_mcp="${INPUT_SKIP_MCP}"
skip_sandbox="${INPUT_SKIP_SANDBOX}"
skip_deploy="${INPUT_SKIP_DEPLOY}"
skip_wrapper="${INPUT_SKIP_WRAPPER}"
# Returns 0 if published, 1 if definitively absent (404). Any other
# HTTP status (PyPI outage, 5xx, rate limit) FAILS the run: guessing
# "not published" would lead to a duplicate-upload failure midway
# through the release, which is strictly worse than stopping here
# before anything has been published.
pypi_published() { # $1=package $2=version
local code attempt
for attempt in 1 2 3; do
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \
"https://pypi.org/pypi/$1/$2/json" || echo "000")
case "$code" in
200) return 0 ;;
404) return 1 ;;
*) echo "PyPI check $1==$2 returned HTTP ${code} (attempt ${attempt}/3)"; sleep 5 ;;
esac
done
echo "❌ Cannot determine PyPI state for $1==$2; refusing to guess." >&2
exit 2
}
if [ "$skip_agents" != "true" ]; then
pypi_published praisonaiagents "${AGENTS_VERSION}" && skip_agents=true || true
curl -fsSL "https://pypi.org/pypi/praisonaiagents/${AGENTS_VERSION}/json" >/dev/null && skip_agents=true || true
fi
if [ "$skip_code" != "true" ]; then
pypi_published praisonai-code "${CODE_VERSION}" && skip_code=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-code/${CODE_VERSION}/json" >/dev/null && skip_code=true || true
fi
if [ "$skip_bot" != "true" ]; then
pypi_published praisonai-bot "${BOT_VERSION}" && skip_bot=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-bot/${BOT_VERSION}/json" >/dev/null && skip_bot=true || true
fi
if [ "$skip_train" != "true" ]; then
pypi_published praisonai-train "${TRAIN_VERSION}" && skip_train=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-train/${TRAIN_VERSION}/json" >/dev/null && skip_train=true || true
fi
if [ "$skip_browser" != "true" ]; then
pypi_published praisonai-browser "${BROWSER_VERSION}" && skip_browser=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-browser/${BROWSER_VERSION}/json" >/dev/null && skip_browser=true || true
fi
if [ "$skip_mcp" != "true" ]; then
pypi_published praisonai-mcp "${MCP_VERSION}" && skip_mcp=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-mcp/${MCP_VERSION}/json" >/dev/null && skip_mcp=true || true
fi
if [ "$skip_sandbox" != "true" ]; then
pypi_published praisonai-sandbox "${SANDBOX_VERSION}" && skip_sandbox=true || true
fi
if [ "$skip_deploy" != "true" ]; then
pypi_published praisonai-deploy "${DEPLOY_VERSION}" && skip_deploy=true || true
curl -fsSL "https://pypi.org/pypi/praisonai-sandbox/${SANDBOX_VERSION}/json" >/dev/null && skip_sandbox=true || true
fi
if [ "$skip_wrapper" != "true" ]; then
pypi_published praisonai "${WRAPPER_VERSION}" && skip_wrapper=true || true
curl -fsSL "https://pypi.org/pypi/praisonai/${WRAPPER_VERSION}/json" >/dev/null && skip_wrapper=true || true
fi
echo "skip_agents=${skip_agents}" >> "$GITHUB_OUTPUT"
echo "skip_code=${skip_code}" >> "$GITHUB_OUTPUT"
@@ -596,9 +460,8 @@ jobs:
echo "skip_browser=${skip_browser}" >> "$GITHUB_OUTPUT"
echo "skip_mcp=${skip_mcp}" >> "$GITHUB_OUTPUT"
echo "skip_sandbox=${skip_sandbox}" >> "$GITHUB_OUTPUT"
echo "skip_deploy=${skip_deploy}" >> "$GITHUB_OUTPUT"
echo "skip_wrapper=${skip_wrapper}" >> "$GITHUB_OUTPUT"
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_deploy" = "true" ] && [ "$skip_wrapper" = "true" ]; then
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_wrapper" = "true" ]; then
echo "All target versions already on PyPI — nothing to publish."
fi
@@ -612,7 +475,6 @@ jobs:
steps.pypi_exists.outputs.skip_browser == 'true' &&
steps.pypi_exists.outputs.skip_mcp == 'true' &&
steps.pypi_exists.outputs.skip_sandbox == 'true' &&
steps.pypi_exists.outputs.skip_deploy == 'true' &&
steps.pypi_exists.outputs.skip_wrapper == 'true'
run: echo "Idempotent skip — target versions already on PyPI."
@@ -620,43 +482,34 @@ jobs:
if: inputs.dry_run
run: |
echo "Dry run — no publish, commit, or push."
echo "Only: ${{ inputs.only }}"
echo "Agents version: ${{ steps.versions.outputs.agents_version }}"
echo "Code version: ${{ steps.versions.outputs.code_version }}"
echo "Bot version: ${{ steps.versions.outputs.bot_version }}"
echo "Train version: ${{ steps.versions.outputs.train_version }}"
echo "Browser version: ${{ steps.versions.outputs.browser_version }}"
echo "MCP version: ${{ steps.versions.outputs.mcp_version }}"
echo "Sandbox version: ${{ steps.versions.outputs.sandbox_version }}"
echo "Deploy version: ${{ steps.versions.outputs.deploy_version }}"
echo "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
echo ""
if [ "${{ steps.flags.outputs.skip_agents }}" != "true" ]; then
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
fi
if [ "${{ steps.flags.outputs.skip_code }}" != "true" ]; then
if [ "${{ inputs.skip_code }}" != "true" ]; then
echo "Would publish praisonai-code ${{ steps.versions.outputs.code_version }}"
fi
if [ "${{ steps.flags.outputs.skip_bot }}" != "true" ]; then
if [ "${{ inputs.skip_bot }}" != "true" ]; then
echo "Would publish praisonai-bot ${{ steps.versions.outputs.bot_version }}"
fi
if [ "${{ steps.flags.outputs.skip_train }}" != "true" ]; then
if [ "${{ inputs.skip_train }}" != "true" ]; then
echo "Would publish praisonai-train ${{ steps.versions.outputs.train_version }}"
fi
if [ "${{ steps.flags.outputs.skip_browser }}" != "true" ]; then
if [ "${{ inputs.skip_browser }}" != "true" ]; then
echo "Would publish praisonai-browser ${{ steps.versions.outputs.browser_version }}"
fi
if [ "${{ steps.flags.outputs.skip_mcp }}" != "true" ]; then
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
echo "Would publish praisonai-mcp ${{ steps.versions.outputs.mcp_version }}"
fi
if [ "${{ steps.flags.outputs.skip_sandbox }}" != "true" ]; then
echo "Would publish praisonai-sandbox ${{ steps.versions.outputs.sandbox_version }}"
fi
if [ "${{ steps.flags.outputs.skip_deploy }}" != "true" ]; then
echo "Would publish praisonai-deploy ${{ steps.versions.outputs.deploy_version }}"
fi
if [ "${{ steps.flags.outputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }} --sandbox-pin ${{ steps.versions.outputs.sandbox_version }} --deploy-pin ${{ steps.versions.outputs.deploy_version }}"
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }}"
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
fi
@@ -684,9 +537,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonaiagents on PyPI
if: >-
@@ -715,6 +566,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit agents version bump
if: >-
inputs.dry_run != true &&
inputs.skip_agents != true &&
steps.pypi_exists.outputs.skip_agents != 'true'
run: |
set -euo pipefail
git add src/praisonai-agents/pyproject.toml src/praisonai-agents/uv.lock
git diff --cached --quiet && { echo "No agents files to commit"; exit 0; }
git commit -m "Bump praisonaiagents to ${{ steps.versions.outputs.agents_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-code
if: >-
inputs.dry_run != true &&
@@ -743,9 +607,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-code on PyPI
if: >-
@@ -774,6 +636,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-code version bump
if: >-
inputs.dry_run != true &&
inputs.skip_code != true &&
steps.pypi_exists.outputs.skip_code != 'true'
run: |
set -euo pipefail
git add src/praisonai-code/pyproject.toml src/praisonai-code/uv.lock src/praisonai-code/praisonai_code/__init__.py
git diff --cached --quiet && { echo "No code files to commit"; exit 0; }
git commit -m "Bump praisonai-code to ${{ steps.versions.outputs.code_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-bot
if: >-
inputs.dry_run != true &&
@@ -802,9 +677,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-bot on PyPI
if: >-
@@ -833,6 +706,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-bot version bump
if: >-
inputs.dry_run != true &&
inputs.skip_bot != true &&
steps.pypi_exists.outputs.skip_bot != 'true'
run: |
set -euo pipefail
git add src/praisonai-bot/pyproject.toml src/praisonai-bot/uv.lock src/praisonai-bot/praisonai_bot/_version.py
git diff --cached --quiet && { echo "No bot files to commit"; exit 0; }
git commit -m "Bump praisonai-bot to ${{ steps.versions.outputs.bot_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-train
if: >-
inputs.dry_run != true &&
@@ -861,9 +747,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-train on PyPI
if: >-
@@ -892,6 +776,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-train version bump
if: >-
inputs.dry_run != true &&
inputs.skip_train != true &&
steps.pypi_exists.outputs.skip_train != 'true'
run: |
set -euo pipefail
git add src/praisonai-train/pyproject.toml src/praisonai-train/uv.lock src/praisonai-train/praisonai_train/_version.py
git diff --cached --quiet && { echo "No train files to commit"; exit 0; }
git commit -m "Bump praisonai-train to ${{ steps.versions.outputs.train_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-browser
if: >-
inputs.dry_run != true &&
@@ -920,9 +817,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-browser on PyPI
if: >-
@@ -951,6 +846,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-browser version bump
if: >-
inputs.dry_run != true &&
inputs.skip_browser != true &&
steps.pypi_exists.outputs.skip_browser != 'true'
run: |
set -euo pipefail
git add src/praisonai-browser/pyproject.toml src/praisonai-browser/uv.lock src/praisonai-browser/praisonai_browser/_version.py
git diff --cached --quiet && { echo "No browser files to commit"; exit 0; }
git commit -m "Bump praisonai-browser to ${{ steps.versions.outputs.browser_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-mcp
if: >-
inputs.dry_run != true &&
@@ -979,9 +887,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-mcp on PyPI
if: >-
@@ -1010,6 +916,19 @@ jobs:
sleep "$INTERVAL"
done
- name: Commit praisonai-mcp version bump
if: >-
inputs.dry_run != true &&
inputs.skip_mcp != true &&
steps.pypi_exists.outputs.skip_mcp != 'true'
run: |
set -euo pipefail
git add src/praisonai-mcp/pyproject.toml src/praisonai-mcp/uv.lock src/praisonai-mcp/praisonai_mcp/_version.py
git diff --cached --quiet && { echo "No mcp files to commit"; exit 0; }
git commit -m "Bump praisonai-mcp to ${{ steps.versions.outputs.mcp_version }}"
git pull --rebase origin main
git push origin main
- name: Publish praisonai-sandbox
if: >-
inputs.dry_run != true &&
@@ -1038,9 +957,7 @@ jobs:
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
uv publish
- name: Wait for praisonai-sandbox on PyPI
if: >-
@@ -1069,101 +986,20 @@ jobs:
sleep "$INTERVAL"
done
- name: Publish praisonai-deploy
- name: Commit praisonai-sandbox version bump
if: >-
inputs.dry_run != true &&
inputs.skip_deploy != true &&
steps.pypi_exists.outputs.skip_deploy != 'true'
working-directory: src/praisonai-deploy
env:
NEW_VERSION: ${{ steps.versions.outputs.deploy_version }}
CURRENT: ${{ steps.versions.outputs.current_deploy }}
inputs.skip_sandbox != true &&
steps.pypi_exists.outputs.skip_sandbox != 'true'
run: |
set -euo pipefail
python <<'PY'
import os
import re
from pathlib import Path
new = os.environ["NEW_VERSION"]
current = os.environ["CURRENT"]
pyproject = Path("pyproject.toml")
content = pyproject.read_text()
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
version_py = Path("praisonai_deploy/_version.py")
version_content = version_py.read_text()
version_py.write_text(re.sub(r'__version__ = "[^"]+"', f'__version__ = "{new}"', version_content, count=1))
PY
rm -rf dist
uv lock
uv build
# --check-url makes re-uploads idempotent: files already on PyPI are
# skipped instead of failing the publish (partial-upload recovery).
uv publish --check-url https://pypi.org/simple/
- name: Wait for praisonai-deploy on PyPI
if: >-
inputs.dry_run != true &&
inputs.skip_deploy != true &&
steps.pypi_exists.outputs.skip_deploy != 'true'
run: |
set -euo pipefail
PACKAGE="praisonai-deploy"
VERSION="${{ steps.versions.outputs.deploy_version }}"
MAX_WAIT=600
INTERVAL=30
START=$(date +%s)
while true; do
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
exit 0
fi
ELAPSED=$(( $(date +%s) - START ))
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
exit 1
fi
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
sleep "$INTERVAL"
done
# All per-package version bumps are still uncommitted at this point: the
# wrapper release below stages every one of them (bump_and_release.py's
# release_files list) into a SINGLE "Release v…" commit.
# App tokens expire after 1h and this job can exceed that (9 packages ×
# up to a 600s PyPI wait). Re-mint so the final commit/tag/push/release
# never runs on an expired token.
- name: Refresh GitHub App token
id: app-token-final
if: ${{ !cancelled() && inputs.dry_run != true }}
# A failed re-mint must not fail the release: the original token may
# still be valid, and the next step only swaps tokens on success.
continue-on-error: true
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CLAUDE_APP_ID }}
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: ${{ github.event.repository.name }}
# Guarded on the refresh actually succeeding: if the re-mint failed
# (API blip), the ORIGINAL token — still wired into the remote URL and
# GH_TOKEN — may well be valid, and overwriting it with an empty string
# would turn a recoverable situation into a guaranteed auth failure.
- name: Use refreshed GitHub App token
if: ${{ !cancelled() && inputs.dry_run != true && steps.app-token-final.outcome == 'success' }}
env:
APP_TOKEN: ${{ steps.app-token-final.outputs.token }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
git add src/praisonai-sandbox/pyproject.toml src/praisonai-sandbox/uv.lock src/praisonai-sandbox/praisonai_sandbox/_version.py
git diff --cached --quiet && { echo "No sandbox files to commit"; exit 0; }
git commit -m "Bump praisonai-sandbox to ${{ steps.versions.outputs.sandbox_version }}"
git pull --rebase origin main
git push origin main
- name: Bump and release wrapper
id: wrapper
if: >-
inputs.dry_run != true &&
inputs.skip_wrapper != true &&
@@ -1178,8 +1014,7 @@ jobs:
--browser-pin "${{ steps.versions.outputs.browser_version }}" \
--mcp-pin "${{ steps.versions.outputs.mcp_version }}" \
--sandbox-pin "${{ steps.versions.outputs.sandbox_version }}" \
--deploy-pin "${{ steps.versions.outputs.deploy_version }}" \
--wait --max-wait 600 --no-add-all --force
--wait --max-wait 600 --no-add-all
- name: Publish praisonai wrapper to PyPI
if: >-
@@ -1187,7 +1022,7 @@ jobs:
inputs.skip_wrapper != true &&
steps.pypi_exists.outputs.skip_wrapper != 'true'
working-directory: src/praisonai
run: uv publish --check-url https://pypi.org/simple/
run: uv publish
- name: Wait for praisonai on PyPI
if: >-
@@ -1216,139 +1051,47 @@ jobs:
sleep "$INTERVAL"
done
# Actually probes PyPI — previously this step only echoed checkmarks
# from the input flags, so it "verified" publishes that never happened.
- name: Verify PyPI versions
if: inputs.dry_run != true
run: |
set -euo pipefail
verify() { # $1=pypi-name $2=version $3=effective-skip
if [ "$3" = "true" ]; then
echo "⏭️ $1 skipped"
return 0
fi
if curl -fsSL --max-time 30 "https://pypi.org/pypi/$1/$2/json" >/dev/null; then
echo "✅ $1==$2"
else
echo "❌ $1==$2 NOT found on PyPI"
exit 1
fi
}
verify praisonaiagents "${{ steps.versions.outputs.agents_version }}" "${{ steps.flags.outputs.skip_agents }}"
verify praisonai-code "${{ steps.versions.outputs.code_version }}" "${{ steps.flags.outputs.skip_code }}"
verify praisonai-bot "${{ steps.versions.outputs.bot_version }}" "${{ steps.flags.outputs.skip_bot }}"
verify praisonai-train "${{ steps.versions.outputs.train_version }}" "${{ steps.flags.outputs.skip_train }}"
verify praisonai-browser "${{ steps.versions.outputs.browser_version }}" "${{ steps.flags.outputs.skip_browser }}"
verify praisonai-mcp "${{ steps.versions.outputs.mcp_version }}" "${{ steps.flags.outputs.skip_mcp }}"
verify praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}" "${{ steps.flags.outputs.skip_sandbox }}"
verify praisonai-deploy "${{ steps.versions.outputs.deploy_version }}" "${{ steps.flags.outputs.skip_deploy }}"
verify praisonai "${{ steps.versions.outputs.wrapper_version }}" "${{ steps.flags.outputs.skip_wrapper }}"
AGENTS="${{ steps.versions.outputs.agents_version }}"
CODE="${{ steps.versions.outputs.code_version }}"
BOT="${{ steps.versions.outputs.bot_version }}"
TRAIN="${{ steps.versions.outputs.train_version }}"
BROWSER="${{ steps.versions.outputs.browser_version }}"
MCP="${{ steps.versions.outputs.mcp_version }}"
SANDBOX="${{ steps.versions.outputs.sandbox_version }}"
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
# Safety net. On a clean run the wrapper's "Release v…" commit already
# carries every bump and this is a no-op. It only fires when the single
# commit could not happen: a package publish failed midway (those versions
# are already live on PyPI, so the bumps must not be lost), skip_wrapper
# was set, or the wrapper itself failed.
- name: Persist any uncommitted version bumps
if: ${{ !cancelled() && inputs.dry_run != true }}
run: |
set -euo pipefail
# If the wrapper release did not complete, drop any uncommitted
# wrapper-side rewrites (version.py, Dockerfiles, pins): committing
# them would put a wrapper version on main that never reached PyPI,
# and the next auto-bump would skip past it forever. Committed
# changes are untouched (checkout only reverts the working tree),
# and if the wrapper was skipped these files were never modified.
if [ "${{ steps.wrapper.outcome }}" != "success" ]; then
git checkout -- \
src/praisonai/praisonai/version.py \
src/praisonai/pyproject.toml \
src/praisonai/uv.lock \
src/praisonai/README.md \
src/praisonai/praisonai.rb \
src/praisonai-deploy/praisonai_deploy/docker.py \
docker/ 2>/dev/null || true
if [ "${{ inputs.skip_agents }}" != "true" ]; then
echo "✅ praisonaiagents==${AGENTS}"
fi
# -u = tracked modifications only; uv build output under dist/ is
# untracked and stays out of the commit.
git add -u
git diff --cached --quiet && { echo "Nothing left to commit"; exit 0; }
# Name the packages actually staged, so a single-package release
# (every other skip_* set) reads "bump praisonai-mcp" rather than a
# generic message. Falls back when nothing under src/ is staged.
PKGS="$(git diff --cached --name-only \
| sed -n 's#^src/\([^/]*\)/.*#\1#p' | sort -u | tr '\n' ' ' | sed 's/ *$//')"
if [ -n "$PKGS" ]; then
MSG="chore(release): bump ${PKGS} [skip ci]"
else
MSG="chore(release): persist version bumps [skip ci]"
fi
echo "Commit message: ${MSG}"
git commit -m "$MSG"
# This step fires exactly when the release went sideways — i.e. when
# main is most likely to have moved — so the rebase+push retries.
# Losing this commit deadlocks future auto-releases: the packages
# are on PyPI at N+1 while the repo says N, so every later run
# detects "already published" and skips both publish and bump.
for attempt in 1 2 3; do
git fetch origin main
if ! git rebase origin/main; then
git rebase --abort || true
# During rebase, "theirs" is the commit being replayed — the
# version bumps — so they win any conflict.
if ! git rebase -X theirs origin/main; then
git rebase --abort || true
echo "Rebase attempt ${attempt}/3 failed"
sleep 5
continue
fi
fi
if git push origin main; then
echo "Pushed version bumps (attempt ${attempt}/3)"
exit 0
fi
echo "Push attempt ${attempt}/3 rejected; retrying..."
sleep 5
done
echo "❌ Could not push version bumps after 3 attempts."
echo " Repo is now BEHIND PyPI; commit the version files on main manually"
echo " or future auto-releases will no-op ('already published')."
exit 1
# Dedicated releases (skip_wrapper) create no "Release v…" tag — the
# wrapper drives that — so tag each published package individually for
# provenance. Full releases keep their single v… tag unchanged.
- name: Tag dedicated package releases
if: >-
${{ !cancelled() && inputs.dry_run != true &&
steps.flags.outputs.skip_wrapper == 'true' &&
steps.versions.outcome == 'success' }}
run: |
set -euo pipefail
tag_pkg() { # $1=effective-skip $2=pypi-name $3=version
if [ "$1" = "true" ]; then
return 0
fi
local tag="$2-v$3"
# Only tag what is confirmed on PyPI — a package whose publish
# failed must not get a tag claiming it shipped.
if ! curl -fsSL --max-time 30 "https://pypi.org/pypi/$2/$3/json" >/dev/null; then
echo "⏭️ $2==$3 not on PyPI; not tagging"
return 0
fi
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
echo "tag ${tag} already exists"
return 0
fi
git tag "${tag}"
git push origin "refs/tags/${tag}"
echo "🏷️ ${tag}"
}
tag_pkg "${{ steps.flags.outputs.skip_agents }}" praisonaiagents "${{ steps.versions.outputs.agents_version }}"
tag_pkg "${{ steps.flags.outputs.skip_code }}" praisonai-code "${{ steps.versions.outputs.code_version }}"
tag_pkg "${{ steps.flags.outputs.skip_bot }}" praisonai-bot "${{ steps.versions.outputs.bot_version }}"
tag_pkg "${{ steps.flags.outputs.skip_train }}" praisonai-train "${{ steps.versions.outputs.train_version }}"
tag_pkg "${{ steps.flags.outputs.skip_browser }}" praisonai-browser "${{ steps.versions.outputs.browser_version }}"
tag_pkg "${{ steps.flags.outputs.skip_mcp }}" praisonai-mcp "${{ steps.versions.outputs.mcp_version }}"
tag_pkg "${{ steps.flags.outputs.skip_sandbox }}" praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}"
tag_pkg "${{ steps.flags.outputs.skip_deploy }}" praisonai-deploy "${{ steps.versions.outputs.deploy_version }}"
if [ "${{ inputs.skip_code }}" != "true" ]; then
echo "✅ praisonai-code==${CODE}"
fi
if [ "${{ inputs.skip_bot }}" != "true" ]; then
echo "✅ praisonai-bot==${BOT}"
fi
if [ "${{ inputs.skip_train }}" != "true" ]; then
echo "✅ praisonai-train==${TRAIN}"
fi
if [ "${{ inputs.skip_browser }}" != "true" ]; then
echo "✅ praisonai-browser==${BROWSER}"
fi
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
echo "✅ praisonai-mcp==${MCP}"
fi
if [ "${{ inputs.skip_sandbox }}" != "true" ]; then
echo "✅ praisonai-sandbox==${SANDBOX}"
fi
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
echo "✅ praisonai==${WRAPPER}"
fi
@@ -24,7 +24,6 @@ jobs:
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
@@ -35,7 +34,4 @@ jobs:
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
if [ -n "${DOCS_GITHUB_TOKEN:-}" ]; then
echo -n "$DOCS_GITHUB_TOKEN" | gh secret set DOCS_GITHUB_TOKEN --repo "$TARGET"
fi
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
+4 -38
View File
@@ -5,9 +5,6 @@ on:
branches: [ main, develop ]
paths:
- 'src/**'
- 'src/praisonai-deploy/infra/**'
- 'src/praisonai-bot/infra/**'
- 'scripts/check_helm_charts.sh'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
@@ -15,9 +12,6 @@ on:
branches: [ main, develop ]
paths:
- 'src/**'
- 'src/praisonai-deploy/infra/**'
- 'src/praisonai-bot/infra/**'
- 'scripts/check_helm_charts.sh'
- 'pyproject.toml'
- '.github/workflows/test-core.yml'
- '.github/actions/install-monorepo-packages/**'
@@ -120,17 +114,11 @@ jobs:
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents
- shard: deploy
workdir: src/praisonai-deploy
paths: >-
tests/
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents
- shard: subdirs
paths: >-
tests/unit/scheduler/
tests/unit/integrations/
tests/unit/deploy/
tests/unit/mcp/
tests/unit/knowledge/
tests/unit/llm/
@@ -195,9 +183,6 @@ jobs:
if [ "$WORKDIR" = "src/praisonai-sandbox" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
fi
if [ "$WORKDIR" = "src/praisonai-deploy" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
fi
if [ "$WORKDIR" = "src/praisonai-mcp" ]; then
bash "$GITHUB_WORKSPACE/scripts/check_c12_mcp_imports.sh"
fi
@@ -283,27 +268,9 @@ jobs:
--ignore=tests/integration/test_tracker_complex_tasks.py \
--ignore=tests/integration/test_serve_integration.py
helm-charts:
name: helm-charts
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Helm
uses: azure/setup-helm@v3
with:
version: v3.14.4
- name: Lint and template Helm charts
shell: bash
run: |
set -euo pipefail
bash "$GITHUB_WORKSPACE/scripts/check_helm_charts.sh"
test-core:
name: test-core
needs: [test-core-collect, test-core-unit, test-core-integration, helm-charts]
needs: [test-core-collect, test-core-unit, test-core-integration]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 5
@@ -315,9 +282,8 @@ jobs:
collect='${{ needs.test-core-collect.result }}'
unit='${{ needs.test-core-unit.result }}'
integration='${{ needs.test-core-integration.result }}'
helm='${{ needs.helm-charts.result }}'
echo "collect=$collect unit=$unit integration=$integration helm=$helm"
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ] || [ "$helm" != "success" ]; then
echo "collect=$collect unit=$unit integration=$integration"
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ]; then
echo "Core Tests failed"
exit 1
fi
+1 -35
View File
@@ -86,7 +86,6 @@ jobs:
tests/unit/test_c11_browser_backward_compat.py \
tests/unit/test_c12_mcp_backward_compat.py \
tests/unit/test_c13_sandbox_backward_compat.py \
tests/unit/test_c14_deploy_backward_compat.py \
-q --tb=line --timeout=30 --maxfail=5
python -m pytest tests/unit/cli/ \
../praisonai-bot/tests/unit/cli/test_onboard_command.py \
@@ -98,20 +97,6 @@ jobs:
--ignore=tests/unit/cli/test_message_queue.py \
--maxfail=5
- name: Run praisonai-agents subscription-auth smoke tests
env:
PRAISONAI_TEST_TIER: smoke
PRAISONAI_ALLOW_NETWORK: '0'
OPENAI_MODEL_NAME: gpt-4o-mini
OPENAI_API_KEY: 'test-key'
shell: bash
run: |
set -euo pipefail
cd src/praisonai-agents
python -m pytest tests/smoke/test_subscription_auth.py \
tests/unit/auth/test_agent_auth_wiring.py \
-q --tb=line --timeout=60 --maxfail=5
- name: Standalone praisonai-code import smoke
shell: bash
run: |
@@ -284,26 +269,10 @@ jobs:
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
python -m pytest src/praisonai-sandbox/tests -q -m "not network"
- name: Standalone praisonai-deploy smoke (C14)
run: |
set -euo pipefail
python -m venv /tmp/praisonai-deploy-standalone
/tmp/praisonai-deploy-standalone/bin/pip install -q -e src/praisonai-agents -e src/praisonai-deploy
/tmp/praisonai-deploy-standalone/bin/python -c "
from praisonai_deploy._version import __version__ as pkg_version
import praisonai_deploy
assert praisonai_deploy.__version__ == pkg_version
assert 'main' not in __import__('sys').modules
print('deploy standalone ok', praisonai_deploy.__version__)
"
/tmp/praisonai-deploy-standalone/bin/praisonai-deploy --help
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
python -m pytest src/praisonai-deploy/tests -q -m "not network"
- name: Audit hybrid module imports (C8 full stack)
run: |
set -euo pipefail
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai-deploy -e src/praisonai
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai
python scripts/audit_hybrid_modules.py
- name: Smoke Test Summary
@@ -813,9 +782,6 @@ jobs:
tests/unit/test_decorator_simple.py
tests/unit/test_db_adapter_tracing_init.py
../praisonai-bot/tests/unit/test_bot_wiring.py
../praisonai-bot/tests/unit/bots/test_enable_shell.py
../praisonai-bot/tests/unit/gateway/test_preflight.py
../praisonai-bot/tests/unit/gateway/test_gateway_doctor.py
../praisonai-bot/tests/unit/test_bot_history.py
../praisonai-bot/tests/unit/test_bot_fixes.py
tests/unit/test_auto_lazy_loading.py
-3
View File
@@ -12,7 +12,6 @@ assets
.DS_Store
.pytest_cache
praisonAI.egg-info
src/*/build/
flagged
test.yaml
db
@@ -25,8 +24,6 @@ output
!src/praisonai-agents/praisonaiagents/output/
!src/praisonai-code/praisonai_code/cli/output/
!src/praisonai-bot/praisonai_bot/cli/output/
!src/praisonai-ts/src/db/
!src/praisonai-ts/src/cli/output/
.files
threads.db
+2 -2
View File
@@ -7,9 +7,9 @@ You are working on the PraisonAI project.
- Be concise and helpful in responses
- Test implementation thoroughly
- Ensure backward compatibility with existing APIs
- Follow protocol-driven design across the nine packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper
- Follow protocol-driven design across the eight packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, integrations/serve/dashboard in the `praisonai/` wrapper
- Preserve old `praisonai.*` import paths via shims when moving code between packages (see §2.3 in `src/praisonai-agents/AGENTS.md`; shim helpers in `src/praisonai/praisonai/cli/_shim.py`)
- Package boundaries and dependency rules: `ARCHITECTURE.md` §2 (Tier 2 packages must never PyPI-depend on the wrapper; cross-tier access goes through lazy `_*_bridge` modules)
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13), `src/praisonai/tests/PRAISONAI_DEPLOY_MANIFEST.md` (C14)
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13)
- When reviewing a PR or an issue, evaluate whether the change addresses a framework concern or a user goal, and design its surface (params, naming, defaults) accordingly
- The aim of this package is to stay **lightweight and powerful**. Do a critical review at each stage — when triaging an issue, when planning a fix, and when reviewing/implementing a PR. Reject scope creep for the sake of adding features: if a capability already exists (e.g. via existing Agent params like `instructions`/`backstory`/`tools`/`hooks`/`memory`), prefer it over a new API surface. A change must genuinely strengthen the SDK (simpler, more robust, more user-friendly) — do not add knobs, params, modules, or exports that have no live consumer or that merely duplicate existing behaviour.
+10 -29
View File
@@ -1,6 +1,6 @@
# PraisonAI Architecture
> **Last updated:** 2026-07-31 (C14 — nine-package tiered model)
> **Last updated:** 2026-07-17 (C13 — eight-package tiered model)
>
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
> Covers **Python tiered package model (C7.1 + C9 + C10)**, system design, runtime
@@ -11,7 +11,7 @@
## Table of Contents
1. [Executive Summary](#1-executive-summary)
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13--c14)
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13)
3. [System Overview](#3-system-overview)
4. [Layered Architecture](#4-layered-architecture)
5. [Core Data Contracts](#5-core-data-contracts)
@@ -51,18 +51,17 @@ Orchestration + Observability** core to unlock adoption and trust:
---
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai-deploy` · `praisonai`
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai`
The Python monorepo publishes nine packages in three tiers with strict dependency
The Python monorepo publishes eight packages in three tiers with strict dependency
direction. C7 delivered a standalone agentic hot path; C7.1 formalised
code/wrapper ownership; C9 extracted bots, gateway, and channel CLI into
`praisonai-bot`; C10 extracted LLM fine-tuning and agent training into
`praisonai-train`; C11 extracted browser automation into `praisonai-browser`;
C12 extracted the heavy MCP host into `praisonai-mcp`.
C13 extracted sandbox backends into `praisonai-sandbox`.
C14 extracted deployment (API, Docker, cloud) into `praisonai-deploy`.
```mermaid
flowchart TB
@@ -70,14 +69,13 @@ flowchart TB
Agents["praisonaiagents<br/>Agent, tools, memory, hooks, protocols"]
end
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox + Deploy]
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox]
Code["praisonai-code<br/>run, chat, code, Typer, runtime, LLM"]
Bot["praisonai-bot<br/>bots, gateway, channel CLI, OS daemon"]
Train["praisonai-train<br/>LLM fine-tuning, agent training"]
Browser["praisonai-browser<br/>extension bridge, CDP, Playwright"]
MCP["praisonai-mcp<br/>MCP server host, auth, capability adapters"]
Sandbox["praisonai-sandbox<br/>Docker, E2B, Modal, Sandlock backends"]
Deploy["praisonai-deploy<br/>API, Docker, AWS/Azure/GCP deploy"]
end
subgraph tier3 [Tier 3 — Wrapper]
@@ -90,53 +88,36 @@ flowchart TB
Agents --> Browser
Agents --> MCP
Agents --> Sandbox
Agents --> Deploy
Agents --> Wrapper
Code -.->|"lazy _bot_bridge"| Bot
Code -.->|"lazy _train_bridge"| Train
Code -.->|"lazy _browser_bridge"| Browser
Code -.->|"lazy _mcp_bridge"| MCP
Code -.->|"lazy _sandbox_bridge"| Sandbox
Code -.->|"lazy _deploy_bridge"| Deploy
Code -.->|"lazy _wrapper_bridge"| Wrapper
Bot -.->|"lazy _code_bridge / _wrapper_bridge"| Code
Bot -.->|"lazy _wrapper_bridge"| Wrapper
Train -.->|"lazy _code_bridge / _wrapper_bridge"| Code
```
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` + `praisonai-deploy` → `praisonai`
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` → `praisonai`
**Backward compatibility:** `praisonai.bots`, `praisonai.gateway`, `praisonai.train`,
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, `praisonai.deploy`, and related CLI paths remain as
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, and related CLI paths remain as
`alias_package` shims to `praisonai_bot.*` / `praisonai_train.*` /
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*` / `praisonai_deploy.*`.
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*`.
| Tier | Package | Owns | Must not depend on |
|------|---------|------|-------------------|
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox`, `praisonai-deploy` |
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox` |
| 2a | `src/praisonai-code/` | `run`/`chat`/`code`, Typer, runtime, LLM, tool resolution | **`praisonai` as a PyPI dependency** (optional lazy imports via `_wrapper_bridge` only) |
| 2b | `src/praisonai-bot/` | Bots, gateway, channel CLI, OS daemon, gateway scheduler tick | **`praisonai` as a PyPI dependency** (optional lazy `_wrapper_bridge` for jobs/UI) |
| 2c | `src/praisonai-train/` | LLM fine-tuning (Unsloth), agent training, `train` CLI, conda env setup | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for legacy dispatch) |
| 2d | `src/praisonai-browser/` | Extension bridge, CDP/hybrid automation, `browser` CLI | **`praisonai` as a PyPI dependency** (none required; depends on `praisonaiagents` only) |
| 2e | `src/praisonai-mcp/` | MCP server host, auth, transports, `mcp` CLI | **`praisonai` as a PyPI dependency** (lazy `_wrapper_bridge` for full capability registry) |
| 2f | `src/praisonai-sandbox/` | Sandbox backends (Docker, E2B, Modal, Sandlock, SSH), `SandboxRegistry` | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for `PluginRegistry`) |
| 2g | `src/praisonai-deploy/` | Deploy API/Docker/cloud, `deploy` CLI, scheduler integration | **`praisonai` as a PyPI dependency** (lazy `_plugin_registry` via `_code_bridge`) |
| 3 | `src/praisonai/` | `framework_adapters/`, serve, dashboard, async jobs API | — |
### Repo infra (not PyPI)
Deployment packaging that stays in the git checkout, not in any tier-2 wheel:
| Path | Runtime owner | Orchestration |
|------|---------------|---------------|
| `src/praisonai-bot/infra/helm/praisonai-gateway/` | `praisonai-bot` (gateway) | C14 `deploy helm` wrapper |
| `src/praisonai-deploy/infra/helm/praisonai-agents-api/` | Generated API from C14 | C14 cross-link |
| `src/praisonai-deploy/infra/compose/agents-stack/` | Docker Compose | `praisonai deploy compose up/down` |
| `src/praisonai-deploy/infra/starters/` | Starter scaffolds | `praisonai deploy create --template` |
| `docker/` | Mixed (wrapper/bot dev stacks) | Not C14 — see bot manifest for `docker/bots/` |
Same boundary as C14: Helm/K8s manifests are **repo infra**, not `pip install praisonai-deploy` content.
**Config kernel:** Phase 0 `praisonai/common/` was skipped; shared config lives in
`praisonai_code/cli/configuration/` and is reached by the bot tier via lazy
`_code_bridge` (see `src/praisonai/tests/CONFIG_KERNEL.md`).
+20 -250
View File
@@ -41,6 +41,10 @@ curl -fsSL https://praison.ai/install.sh | bash
<img src=".github/images/dashboard.png" alt="PraisonAI Dashboard" width="800" />
</p>
<p align="center">
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
</p>
```
██████╗ ██████╗ █████╗ ██╗███████╗ ██████╗ ███╗ ██╗ █████╗ ██╗
██╔══██╗██╔══██╗██╔══██╗██║██╔════╝██╔═══██╗████╗ ██║ ██╔══██╗██║
@@ -52,6 +56,12 @@ curl -fsSL https://praison.ai/install.sh | bash
pip install praisonai
```
<p align="center">
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
</p>
\* `export TAVILY_API_KEY=xxxxx`
<div align="center">
<a href="https://docs.praison.ai">
<p align="center">
@@ -96,232 +106,6 @@ agent.start("Analyze the top 3 tech trends of 2026 and format as a markdown tabl
---
## 🧬 The Five-Layer Agent Stack
Most frameworks hand you one or two layers and leave the rest as homework. PraisonAI covers **all five** — plus the outer layer that decides *where* your agent actually runs.
Each layer wraps the one inside it. When an agent misbehaves, the layer tells you where to look.
```
┌─────────────────────────────────────────────────────────────────┐
│ ⬡ MANAGED AGENTS — Where does it actually run? │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ 5 · GRAPH — Who runs when, and who checks whom? │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ 4 · LOOP — When do we stop? │ │ │
│ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │
│ │ │ │ 3 · HARNESS — Can it act, and be checked? │ │ │ │
│ │ │ │ ┌─────────────────────────────────────────────────┐ │ │ │ │
│ │ │ │ │ 2 · CONTEXT — Is the right thing in the window? │ │ │ │ │
│ │ │ │ │ ┌─────────────────────────────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ 1 · PROMPT — Did I say it clearly? │ │ │ │ │ │
│ │ │ │ │ └─────────────────────────────────────────────┘ │ │ │ │ │
│ │ │ │ └─────────────────────────────────────────────────┘ │ │ │ │
│ │ │ └─────────────────────────────────────────────────────┘ │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
| Layer | The question it answers | PraisonAI |
|:--|:--|:--|
| **1 · Prompt** | Did I say it clearly? | `instructions=`, `role`/`goal`/`backstory`, `output=`, `templates=` |
| **2 · Context** | Is the right thing in the window? | `memory=`, `knowledge=`, `context=`, handoff `ContextPolicy` |
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
### Layer 1 · Prompt — *Did I say it clearly?*
Role, instructions, examples, output format.
```python
from praisonaiagents import Agent
agent = Agent(
role="Senior Data Analyst",
goal="Turn raw numbers into decisions",
output="verbose", # markdown-formatted output
)
agent.start("Summarise Q3 revenue trends")
```
### Layer 2 · Context — *Is the right thing in the window?*
Write, select, compress, isolate — the four context operations, one parameter each.
```python
from praisonaiagents import Agent
agent = Agent(
instructions="You are a support engineer.",
memory={"user_id": "u-42"}, # write — persists across runs (needs a user_id)
knowledge=["docs/"], # select — retrieves only what's relevant
context="summarize", # compress — auto-compacts before the limit
)
```
> **Isolate** is `handoffs=[specialist]` — a sub-agent inherits the last few messages and the intersection of your tools, not your whole transcript. [📖 Handoffs](https://docs.praison.ai/docs/concepts/handoffs)
### Layer 3 · Harness — *Can it act, and be checked?*
*Agent = Model + Harness.* Tool dispatch, plus the guides that steer before acting and the sensors that observe after.
```python
from praisonaiagents import Agent, MCP, tool
@tool
def deploy(env: str) -> str:
"""Deploy the current build to an environment."""
return f"Deployed to {env}"
agent = Agent(
name="ReleaseEngineer",
instructions="You are a release engineer.",
tools=[deploy, MCP("npx -y @modelcontextprotocol/server-filesystem /tmp")],
approval=True, # guide — human gate before risky tools run
)
agent.start("Deploy to staging, then list the files you can read")
```
### Layer 4 · Loop — *When do we stop?*
Hard iteration caps, budget ceilings, no-progress detection and completion checks — every brake is explicit.
```python
from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
instructions="Fix the failing tests.",
execution=ExecutionConfig(max_iter=30, max_budget=0.50, on_budget_exceeded="stop"),
autonomy=True, # required to drive the loop with run_autonomous()
)
result = agent.run_autonomous("Refactor the auth module", max_iterations=5)
print(result.completion_reason)
# goal | no_tool_calls | max_iterations | timeout | doom_loop | needs_help | error
# (with on_budget_exceeded="stop", hitting the cap raises BudgetExceededError,
# surfaced here as completion_reason="error")
```
> **Doom-loop detection is on by default.** Repeated identical tool calls and A→B→A→B oscillation get caught — while a poller whose output keeps changing does not. [📖 Doom Loop Detection](https://docs.praison.ai/docs/features/doom-loop-detection)
### Layer 5 · Graph — *Who runs when, and who checks whom?*
Topology as a versionable artifact: prompt chaining, routing, parallelisation, orchestrator-worker.
```python
from praisonaiagents import AgentFlow
from praisonaiagents.workflows import route, parallel, repeat
flow = AgentFlow(steps=[
classifier,
route({"bug": [bug_agent], "feature": [feature_agent], "default": [triage]}),
parallel([reviewer, tester]), # fan out, join automatically
repeat(editor, until=lambda ctx: "approved" in ctx.previous_result.lower(),
max_iterations=3), # evaluator–optimizer
])
flow.run("Ticket #123: login fails on Safari")
```
> The same graph is expressible in YAML with no Python at all. [📖 AgentFlow](https://docs.praison.ai/docs/concepts/agentflow)
### ⬡ Outside the stack: Managed Agents — *Where does it actually run?*
The harness is commoditising; **where** the agent executes is the next multiplier. Rather than burning your laptop's CPU, hand an agent a short-lived cloud sandbox — repo, tools and tests run there.
```bash
pip install praisonai
```
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
```python
from praisonaiagents import Agent, AgentFlow
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
```
Same thing with no Python at all:
```yaml
name: remote-demo
tools_run_on: docker # every step shares one sandbox
agents:
writer: {role: Writer, goal: Write files}
reader: {role: Reader, goal: Read files}
steps:
- agent: writer
action: "Write 'hello' to /workspace/note.txt"
- agent: reader
action: "Read /workspace/note.txt"
```
For a single agent, two words cover it — and they answer different questions:
```python
from praisonaiagents import Agent
# A. Only the TOOLS move. Thinking stays on your machine.
agent = Agent(name="builder", instructions="You build things.",
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
# tenki | sandlock | ssh | novita | subprocess
# B. The WHOLE agent moves — model calls, loop and tools
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
Ask any object where it runs, and it will tell you:
```python
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
>>> agent.where_does_it_run()
Thinking (the AI model calls) happens on this machine.
Tools run on a Docker container.
Your own tools (check_db) still run on this machine -- only shell, file and
code tools move. They read and write this machine's files.
```
Naming a place that cannot do the job is a typo, not a preference, so it says so:
```python
>>> Agent(name="x", instructions="i", run_on="e2b")
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
commands but cannot host an agent loop.
To run only the tools there: Agent(tools_run_on='e2b')
```
To run one block of code somewhere else, name the place on that call:
```python
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
```
See what is running and reclaim strays:
```bash
praisonai managed ps # list running sandboxes
praisonai managed stop --all # reclaim them
```
Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), and a post-setup snapshot is reused so the next run skips the image pull and dependency install. Commit a `.praisonai/environment.yaml` and the environment travels with the repo.
> 📖 [20 runnable examples](examples/python/managed-agents/) · manage sessions with `praisonai managed sessions list <agent-id>` or `praisonai managed sessions resume <session-id> "<prompt>"`
<sub>Stack framing adapted from [The Five-Layer Agent Stack](https://mer.vin/2026/07/five-layer-agent-stack-match-bug-to-right-layer/) and [Agent Harnesses vs Orbs](https://mer.vin/2026/08/agent-harnesses-vs-orbs-why-remote-sandboxes-beat-local-agent-loops/).</sub>
---
## 🌌 The PraisonAI Ecosystem
Start simple with the core SDK, or expand to full visual builders and dashboards when you're ready.
@@ -416,9 +200,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
| 🧠 | **Planning Mode** — plan → execute → reason | `planning=True` |
| 🔍 | **Deep Research** — multi-step autonomous research | [Docs](https://docs.praison.ai/docs/agents/deep-research) |
| 🤖 | **External Agents** — orchestrate Claude Code, Gemini CLI, Codex | [Docs](https://docs.praison.ai/docs/code/external-agents) |
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoffs=[other_agent]` |
| 🔄 | **Agent Handoffs** — seamless conversation passing | `handoff=True` |
| 🛡️ | **Guardrails** — input/output validation | [Docs](https://docs.praison.ai/docs/concepts/guardrails) |
| | **Web Search + Fetch** — native browsing | `web=True` |
| | **Web Search + Fetch** — native browsing | `web_search=True` |
| 🪞 | **Self Reflection** — agent reviews its own output | [Docs](https://docs.praison.ai/docs/concepts/reflection) |
| 🔀 | **Workflow Patterns** — route, parallel, loop, repeat | [Docs](https://docs.praison.ai/docs/concepts/agentflow) |
| 🧠 | **Memory (zero deps)** — works out of the box | `memory=True` |
@@ -428,9 +212,9 @@ Powered by 100+ LLMs (OpenAI, Anthropic, Gemini & local models).
| | Feature | How |
|--|---------|-----|
| 💡 | **Prompt Caching** — reduce latency + cost | `caching=True` |
| 💡 | **Prompt Caching** — reduce latency + cost | `prompt_caching=True` |
| 💾 | **Sessions + Auto-Save** — persistent state across restarts | `auto_save="my-project"` |
| 💭 | **Thinking Budgets** — control reasoning depth | `agent.thinking_budget = 1024` |
| 💭 | **Thinking Budgets** — control reasoning depth | `thinking_budget=1024` |
| 📚 | **RAG + Quality-Based RAG** — auto quality scoring retrieval | [Docs](https://docs.praison.ai/docs/concepts/rag) |
| 📊 | **Model Router** — auto-routes to cheapest capable model | [Docs](https://docs.praison.ai/docs/features/model-router) |
| 🧊 | **Shadow Git Checkpoints** — auto-rollback on failure | [Docs](https://docs.praison.ai/docs/features/checkpoints) |
@@ -557,10 +341,8 @@ from praisonaiagents import Agent, db
agent = Agent(
name="Assistant",
memory={
"db": db(database_url="postgresql://localhost/mydb"),
"session_id": "my-session",
},
db=db(database_url="postgresql://localhost/mydb"),
session_id="my-session"
)
agent.chat("Hello!") # Auto-persists messages, runs, traces
```
@@ -1006,16 +788,6 @@ PraisonAI is built for speed, with agent instantiation in around 14μs. This red
---
<p align="center">
<img src=".github/images/agentflow.gif" alt="PraisonAI AgentFlow" width="800" />
</p>
<p align="center">
<img src=".github/images/latest_ai_news_and_crawl_each_url_to_find_info.gif" alt="PraisonAI command execution" width="800" />
</p>
\* `export TAVILY_API_KEY=xxxxx`
## 🔍 Langfuse Tracing
```bash
@@ -1119,10 +891,8 @@ from praisonaiagents import Agent, db
agent = Agent(
name="Assistant",
memory={
"db": db(database_url="postgresql://localhost/mydb"),
"session_id": "my-session",
},
db=db(database_url="postgresql://localhost/mydb"),
session_id="my-session"
)
```
@@ -1138,8 +908,8 @@ from praisonaiagents import Agent
agent = Agent(
name="Assistant",
# Enables file-based memory (no extra deps!)
memory={"user_id": "user123"},
memory=True, # Enables file-based memory (no extra deps!)
user_id="user123"
)
```
+1 -8
View File
@@ -8,18 +8,11 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|---------|----------|-------------|
| `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.
### Packages maintained in their own repositories
`praisonai-platform` now lives at
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
Report platform vulnerabilities through
[that repository's advisories](https://github.com/MervinPraison/PraisonAI-Platform/security/advisories/new),
not here — it is versioned and released independently of the SDK.
## In scope
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
+68 -35
View File
@@ -7,7 +7,7 @@ Regenerate with: `praisonai docs api-md --write`
Types:
```python
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, ToolResult, ToolValidationError, WorkflowContext
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, TaskOutput, ToolResult, ToolValidationError, WorkflowContext
```
Methods:
@@ -16,6 +16,8 @@ Methods:
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_dict</a>(data: Dict[str, Any]) -> 'HandoffConfig'</code>
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class HandoffResult">HandoffResult.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_outcome</a>(outcome: AgentRunOutcome, target_agent: Optional[str] = None, source_agent: Optional[str] = None, handoff_depth: int = 0) -> 'HandoffResult'</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">json</a>() -> Optional[str]</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">to_dict</a>() -> dict</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">is_multimodal</a>() -> bool</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -23,21 +25,24 @@ Methods:
Types:
```python
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PraisonAIAgents, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
```
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">add_verification</a>(hook: Any) -> None</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>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>()</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>(value)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clone_for_channel</a>() -> 'Agent'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
@@ -71,7 +76,6 @@ Methods:
* <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">remove_verification</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>
@@ -79,7 +83,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_autonomous_async</a>(prompt: str, max_iterations: Optional[int] = None, timeout_seconds: Optional[float] = None, completion_promise: Optional[str] = None, clear_context: bool = False)</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until</a>(prompt: str, criteria: str = '', threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False, goal: Optional[str] = None, goal_criteria: Optional[Any] = None, judge_model: Optional[str] = None) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">run_until_async</a>(prompt: str, criteria: str, threshold: float = 8.0, max_iterations: int = 5, mode: str = 'optimize', on_iteration: Optional[Callable[[Any], None]] = None, verbose: bool = False) -> 'EvaluationLoopResult'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">set_snapshot_root</a>(project_path: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">skill_manager</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">store_memory</a>(content: str, memory_type: str = 'short_term', action: str = 'add', **kwargs: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">stream_emitter</a>() -> Optional[Any]</code>
@@ -88,7 +91,6 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value: Optional[int]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">total_cost</a>() -> float</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">undo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
* <code title="class AutoRagAgent">AutoRagAgent.<a href="./src/praisonai-agents/praisonaiagents/agents/auto_rag_agent.py">achat</a>(message: str, **kwargs) -> str</code>
@@ -177,15 +179,14 @@ Methods:
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">get_schema</a>() -> Dict[str, Any]</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">run</a>(**kwargs) -> Any</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">get_schema</a>() -> Dict[str, Any]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">run</a>(**kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">clear</a>() -> None</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_plugins</a>() -> int</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_single_file_plugins</a>() -> int</code>
@@ -251,12 +252,9 @@ Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memory</a>(memory_id: str, memory_type: Optional[str] = None) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_short_term</a>(memory_id: str) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">remember</a>(content: str, **kwargs) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_all</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_entity_only</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_long_term</a>()</code>
@@ -264,8 +262,8 @@ Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_user_memory</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search</a>(query: str, user_id: Optional[str] = None, agent_id: Optional[str] = None, run_id: Optional[str] = None, limit: int = 5, rerank: bool = False, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_entity</a>(query: str, limit: int = 5) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_long_term</a>(query: str, limit: int = 5, relevance_cutoff: float = 0.0, min_quality: float = 0.0, rerank: bool = False, metadata_filter: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_short_term</a>(query: str, limit: int = 5, min_quality: float = 0.0, relevance_cutoff: float = 0.0, rerank: bool = False, metadata_filter: Optional[Dict[str, Any]] = None, user_id: Optional[str] = None, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_long_term</a>(query: str, limit: int = 5, relevance_cutoff: float = 0.0, min_quality: float = 0.0, rerank: bool = False, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_short_term</a>(query: str, limit: int = 5, min_quality: float = 0.0, relevance_cutoff: float = 0.0, rerank: bool = False, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_user_memory</a>(user_id: str, query: str, limit: int = 5, rerank: bool = False, **kwargs) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">search_with_quality</a>(query: str, min_quality: float = 0.0, memory_type: Literal['short', 'long'] = 'long', limit: int = 5) -> List[Dict[str, Any]]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">store_entity</a>(name: str, type_: str, desc: str, relations: str)</code>
@@ -465,7 +463,7 @@ Methods:
Types:
```python
from praisonaiagents import SkillLoader, SkillsConfig
from praisonaiagents import SkillLoader, SkillManager, SkillMetadata, SkillProperties, SkillsConfig
```
Methods:
@@ -477,6 +475,54 @@ Methods:
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_metadata</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_references</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_scripts</a>(skill: LoadedSkill) -> dict</code>
* <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_bundle</a>(manifest: 'BundleManifest') -> None</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">add_skill_by_name</a>(name: str, skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> 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">bundle_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundles</a>() -> List['BundleManifest']</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</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, base_dir: Optional[str] = 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">discover_bundles</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</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>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle</a>(name: str) -> Optional['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle_instructions</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
* <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</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">resolve</a>(selectors: List[str]) -> List[str]</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_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>
# Session
@@ -522,7 +568,6 @@ Methods:
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_prompts</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_resources</a>() -> List[dict]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">get_tools</a>() -> List[Callable]</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">list_active_server_names</a>() -> set</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">shutdown</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">to_openai_tool</a>()</code>
* <code title="class MCP">MCP.<a href="./src/praisonai-agents/praisonaiagents/mcp/mcp.py">with_tool_prefix</a>(prefix: str) -> 'MCP'</code>
@@ -686,7 +731,7 @@ Methods:
Types:
```python
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, RunOutcome, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
from praisonaiagents import AgentAppConfig, AgentAppProtocol, AgentFlow, AgentManager, AgentOSConfig, AgentOSProtocol, AgentTeam, AutoApproveBackend, EmbeddingResult, RetryBackoffConfig, __version__, aembedding, aembeddings, embedding, embeddings, get_dimensions
```
Methods:
@@ -709,7 +754,6 @@ Methods:
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>() -> bool</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>(value: bool)</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">where_does_it_run</a>() -> str</code>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">get_app</a>() -> Any</code>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">serve</a>(host: Optional[str] = None, port: Optional[int] = None, reload: bool = False, **kwargs: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aannounce_completion</a>(agent_id: str, task_id: str, result: Any, success: bool = True, error: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> None</code>
@@ -721,7 +765,6 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_task</a>(task_id)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aspawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart_for_each</a>(inputs, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">await_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clean_json_output</a>(output: str) -> str</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clear_state</a>() -> None</code>
@@ -746,28 +789,22 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_task</a>(task_id)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_output_to_file</a>(task, task_output)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">spawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start_for_each</a>(inputs, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">from_exception</a>(exc: BaseException, output: Optional[str] = None) -> 'RunOutcome'</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">succeeded</a>() -> bool</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembedding</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/embed.py">aembeddings</a>(input: Union[str, List[str]], model: str = 'text-embedding-3-small', dimensions: Optional[int] = None, encoding_format: str = 'float', timeout: float = 600.0, api_key: Optional[str] = None, api_base: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> EmbeddingResult</code>
* <code title="function">praisonaiagents.<a href="./src/praisonai-agents/praisonaiagents/embedding/dimensions.py">get_dimensions</a>(model_name: str) -> int</code>
@@ -776,7 +813,7 @@ Methods:
Types:
```python
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
```
# CLI
@@ -844,13 +881,11 @@ Methods:
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed managed-callback <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed multi <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed ps <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed restore <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed resume <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed run <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed save <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed show <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed stop <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed update <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
@@ -880,7 +915,6 @@ Methods:
* <code title="cli">praisonai recipe list <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe optimize <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe run <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe runs <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai replay cleanup <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
@@ -897,11 +931,7 @@ Methods:
* <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>
* <code title="cli">praisonai schedule logs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule pause <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule remove <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule resume <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule runs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <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>
@@ -910,7 +940,10 @@ Methods:
* <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 schedule update <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai standardise check <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise fix <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise init <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai standardise report <a href="./src/praisonai/praisonai/cli/commands/standardise.py">--help</a></code>
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
-10
View File
@@ -1,10 +0,0 @@
# Deployment infra has moved
Deployment assets are no longer at the monorepo root.
| Asset | New location |
|-------|----------------|
| Gateway Helm chart | [`src/praisonai-bot/infra/helm/praisonai-gateway/`](../src/praisonai-bot/infra/helm/praisonai-gateway/) |
| Agents API Helm, Compose, Starters | [`src/praisonai-deploy/infra/`](../src/praisonai-deploy/infra/) |
See [`src/praisonai-deploy/infra/README.md`](../src/praisonai-deploy/infra/README.md).
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.0" \
"praisonai>=4.6.155" \
"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.7.0" \
"praisonai>=4.6.155" \
"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.7.0" \
"praisonai>=4.6.155" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -36,12 +36,6 @@ if __name__ == "__main__":
print(f"Custom agent max_rpm: {agent.max_rpm}")
print(f"Custom agent max_execution_time: {agent.max_execution_time}")
print(f"Custom agent max_retry_limit: {agent.max_retry_limit}")
# max_rpm auto-creates a live RateLimiter (not just a stored number)
assert agent_rate_limited.max_rpm == 10
assert agent_rate_limited._rate_limiter is not None
assert agent_rate_limited._rate_limiter.requests_per_minute == 10
print(f"Rate-limited agent limiter: {agent_rate_limited._rate_limiter}")
# Test custom agent
print("\n--- Custom Execution ---")
+3 -2
View File
@@ -14,7 +14,7 @@ Expected Output:
Agent responds with state persisted to Redis
"""
from praisonaiagents import Agent, MemoryConfig
from praisonaiagents import Agent
from praisonaiagents.db import db
print("=== Redis State Store (Agent-First) ===")
@@ -28,7 +28,8 @@ my_db = db(
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
memory=MemoryConfig(db=my_db, session_id="redis-state-example"),
memory=my_db,
session_id="redis-state-example"
)
# Chat - state is automatically managed via Redis
@@ -1,111 +0,0 @@
"""
Human Review Between Sequential AgentTeam Tasks
================================================
Demonstrates a human-in-the-loop review gate between sequential tasks using the
existing ``on_task_complete`` hook — no new API required.
IMPORTANT — two different ``on_task_complete`` hooks exist, with DIFFERENT
signatures. Do not confuse them:
AgentTeam(hooks=MultiAgentHooksConfig(on_task_complete=fn))
-> fn(task, task_output) # team-level; gets the Task and its output
Task(on_task_complete=fn)
-> fn(task_output) # 1 param: just the TaskOutput
-> fn(task_output, metadata) # 2+ params: TaskOutput + metadata dict
# NOTE: NOT (task, task_output)
This example uses the TEAM-level hook so we get ``(task, task_output)`` and can
filter which tasks require review by ``task.name``.
Approve / reject / edit:
- approve -> return, workflow continues
- reject -> raise; see "stopping the workflow" note below
- edit -> mutate ``task_output.raw``; downstream tasks that read this task via
``context=[...]`` pick up the edited text
Stopping the workflow on reject
-------------------------------
The TEAM-level hook logs and swallows exceptions, so a bare ``raise`` inside it
does NOT stop the sequential run today. To hard-stop on reject, use the PER-TASK
callback path with the 1-argument signature and ``fail_on_callback_error=True``
(see ``review_output`` / ``t1_strict`` at the bottom of this file).
"""
from praisonaiagents import Agent, Task, AgentTeam
from praisonaiagents.config.feature_configs import MultiAgentHooksConfig
# Only these task names trigger a human review gate.
REVIEW_TASKS = {"research"}
# --- Team-level review gate: signature is (task, task_output) ---------------
def review_gate(task, task_output):
if task.name not in REVIEW_TASKS:
return
print(f"\n--- Review: {task.name} ---\n{task_output.raw}\n")
choice = input("Approve (y), reject (n), edit (e)? ").strip().lower()
if choice == "n":
# NOTE: team-level hook swallows this; see per-task strict path below
# for a version that actually halts the workflow.
raise RuntimeError(f"Human rejected task '{task.name}'")
if choice == "e":
# task.result is the same object as task_output, so mutating .raw here
# propagates to downstream tasks that use context=[this_task].
task_output.raw = input("Edited output: ").strip()
researcher = Agent(name="Researcher", role="Researcher", goal="Research", backstory="Expert")
writer = Agent(name="Writer", role="Writer", goal="Write", backstory="Writer")
t1 = Task(
name="research",
description="List 5 facts about {{topic}}", # {{...}} is the substitution syntax
expected_output="5 bullets",
agent=researcher,
)
t2 = Task(
name="summary",
description="Write 3-sentence summary",
expected_output="3 sentences",
agent=writer,
context=[t1], # reads the (possibly edited) output of t1
)
team = AgentTeam(
agents=[researcher, writer],
tasks=[t1, t2],
process="sequential",
variables={"topic": "REST APIs"}, # substituted into {{topic}} in task descriptions
hooks=MultiAgentHooksConfig(on_task_complete=review_gate),
)
# --- Alternative: per-task strict gate that HALTS on reject -----------------
# Per-task Task.on_task_complete uses the 1-argument signature (task_output).
# With fail_on_callback_error=True a raised exception re-raises and stops the run.
def review_output(task_output):
print(f"\n--- Review output ---\n{task_output.raw}\n")
if input("Approve? (y/n) ").strip().lower() == "n":
raise RuntimeError("Human rejected output")
t1_strict = Task(
name="research",
description="List 5 facts about {{topic}}",
expected_output="5 bullets",
agent=researcher,
variables={"topic": "REST APIs"},
on_task_complete=review_output, # signature: (task_output)
fail_on_callback_error=True, # required so reject halts the workflow
)
if __name__ == "__main__":
# NOTE: template values come from AgentTeam(variables=...), NOT start(inputs=...).
# start() forwards **kwargs but does not consume an ``inputs`` argument.
team.start()
+22 -3
View File
@@ -1,15 +1,34 @@
import socket
import sys
import threading
import time
from praisonaiagents import Agent
def _wait_until_ready(host: str, port: int, timeout: float = 15.0) -> bool:
"""Poll the MCP server port until it accepts connections or times out."""
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.settimeout(1.0)
if sock.connect_ex((host, port)) == 0:
return True
time.sleep(0.25)
return False
if __name__ == "__main__":
host, port = "127.0.0.1", 8080
agent = Agent(name="TweetAgent", instructions="Create a Tweet based on the topic provided")
server = threading.Thread(
target=lambda: agent.launch(port=8080, protocol="mcp", path="/mcp"),
target=lambda: agent.launch(port=port, protocol="mcp", path="/mcp"),
daemon=True,
)
server.start()
time.sleep(2)
print("MCP server started on http://127.0.0.1:8080/mcp")
if _wait_until_ready(host, port):
print(f"MCP server started on http://{host}:{port}/mcp")
else:
print(f"MCP server failed to start on http://{host}:{port}/mcp", file=sys.stderr)
sys.exit(1)
@@ -23,7 +23,6 @@ from praisonaiagents.hooks.verification import (
VerificationResult,
BaseVerificationHook,
CommandVerificationHook,
FileCheckHook,
)
@@ -101,19 +100,7 @@ def main():
print(f" Name: {cmd_hook.name}")
print(f" Command: {cmd_hook.command}")
print(f" Timeout: {cmd_hook.timeout_seconds}s")
# 6. FileCheckHook: declarative, no-shell completion gate
print("\n6. FileCheckHook (declarative completion gate):")
file_hook = FileCheckHook(
name="changelog", path="CHANGELOG.md", non_empty=True
)
file_result = file_hook.run()
print(f" {file_hook.name}: success={file_result.success} — {file_result.output}")
print(
" Gate wiring: when verification_hooks are configured, the autonomous "
"loop cannot finalise with a success outcome while a blocking hook fails."
)
print("\n" + "=" * 60)
print("✓ Verification hooks example completed successfully!")
print("=" * 60)
@@ -86,7 +86,7 @@ def method_1_simple_loop():
agents=[loop_agent],
tasks=[loop_task],
process="workflow",
execution="balanced" # max_iter=10 preset to prevent infinite loops
max_iter=10 # Maximum iterations to prevent infinite loops
)
print(f"🚀 Starting loop processing of {csv_file}")
@@ -64,7 +64,7 @@ def main():
agents=[url_agent],
tasks=[url_task],
process="workflow",
execution="balanced" # max_iter=10 preset; adjust for more URLs
max_iter=10 # Adjust based on how many URLs you have
)
print(f"🚀 Processing URLs from {csv_file}")
+1 -2
View File
@@ -7,8 +7,7 @@ multiple agents and managing real-time communication.
from praisonaiagents import Agent, GatewayConfig, SessionConfig
# Configure the gateway using the typed SDK GatewayConfig.
# In gateway.yaml, the optional ``gateway:`` block is validated at load time (#3050).
# Configure the gateway
gateway_config = GatewayConfig(
host="127.0.0.1",
port=8765,
@@ -141,7 +141,7 @@ class DynamicAgentSystem:
agents=agents,
tasks=tasks,
process=process,
output="verbose" if inputs['depth'] == 'detailed' else "silent"
verbose=inputs['depth'] == 'detailed'
)
result = praison_agents.start()
+2 -2
View File
@@ -7,12 +7,12 @@ allowed_dirs = [
"/path/to/other/allowed/dir"
]
# Pass the executable as the first token and the allowed directories as args
# Use a single string command with allowed directories
filesystem_agent = Agent(
instructions="""You are a helpful assistant that can interact with the filesystem.
Use the available tools when relevant to manage files and directories.""",
llm="gpt-4o-mini",
tools=MCP("npx", args=["-y", "@modelcontextprotocol/server-filesystem", *allowed_dirs])
tools=MCP("npx -y @modelcontextprotocol/server-filesystem", args=allowed_dirs)
)
filesystem_agent.start("List files in the allowed directories")
@@ -66,7 +66,7 @@ research_task1 = Task(
agents_session1 = AgentTeam(
agents=[research_agent],
tasks=[research_task1],
output="verbose"
session=session1, output="verbose"
)
# Execute first session
@@ -105,7 +105,7 @@ analysis_task = Task(
agents_session2 = AgentTeam(
agents=[analysis_agent],
tasks=[analysis_task],
output="verbose"
session=session2, output="verbose"
)
print("Starting analysis session with previous context...")
@@ -155,7 +155,7 @@ synthesis_task = Task(
agents_recovery = AgentTeam(
agents=[synthesis_agent],
tasks=[synthesis_task],
output="verbose"
session=recovery_session, output="verbose"
)
print("Starting recovery session with full context from previous sessions...")
@@ -205,7 +205,7 @@ review_task = Task(
agents_reviewer = AgentTeam(
agents=[review_agent],
tasks=[review_task],
output="verbose"
session=reviewer_session, output="verbose"
)
print("Starting peer review session by different user...")
@@ -77,35 +77,13 @@ class TrafilaturaTools:
except ValueError:
# Not an IP address, continue with domain validation
pass
# Block common internal domains
if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
return False
# Block metadata service endpoints
if hostname in ['169.254.169.254', 'metadata.google.internal']:
return False
# Resolve the hostname and reject if any address is internal. The
# checks above only inspect IP *literals*, so a plain domain (or an
# encoded form like a decimal IP or "<internal-ip>.nip.io") still
# passes even though it resolves to an internal address. Resolving
# here closes that SSRF bypass, mirroring the resolve-and-check
# approach already used in praisonaiagents/tools/file_tools.py.
import socket
default_port = 443 if parsed.scheme == 'https' else 80
try:
addrinfos = socket.getaddrinfo(hostname, parsed.port or default_port)
except socket.gaierror:
# Hostname does not resolve; treat it as unsafe.
return False
for addrinfo in addrinfos:
resolved = ipaddress.ip_address(addrinfo[4][0])
if (resolved.is_private or resolved.is_reserved
or resolved.is_loopback or resolved.is_link_local):
return False
return True
return hostname not in ['169.254.169.254', 'metadata.google.internal']
except Exception:
return False
@@ -279,17 +257,7 @@ class TrafilaturaTools:
"newspaper": None,
"spider": None
}
# Validate the URL once before any fetcher runs so the newspaper and
# requests paths below are protected against SSRF, just like the
# trafilatura path (which validates inside extract_content).
if not self._validate_url(url):
error = {"error": f"Invalid or unsafe URL: {url}"}
comparison["trafilatura"] = error
comparison["newspaper"] = error
comparison["spider"] = error
return comparison
# Get Trafilatura extraction
try:
comparison["trafilatura"] = self.extract_content(url)
+1 -1
View File
@@ -24,7 +24,7 @@ Examples for all PraisonAI server types and the unified endpoints CLI.
| `praisonai serve unified --port 8765` | Start unified server |
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
| `praisonai serve recipe --port 8765` | Start recipe server |
| `praisonai mcp serve --transport http-stream --port 8080` | Start MCP server |
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
| `praisonai serve tools --port 8081` | Start tools MCP server |
| `praisonai serve a2a --port 8082` | Start A2A server |
| `praisonai serve a2u --port 8083` | Start A2U server |
+1 -1
View File
@@ -13,7 +13,7 @@ Usage:
python mcp_http_server.py
# Or use CLI:
praisonai mcp serve --transport http-stream --port 8080
praisonai serve mcp --transport http --port 8080
# Test with curl:
curl http://localhost:8080/mcp/tools
+3 -3
View File
@@ -14,9 +14,9 @@ datasets:
# A small verified subset for a cheap smoke run. Verify against the registry
# with `harbor datasets list` before editing.
task_names:
- terminal-bench/build-pmars
- terminal-bench/bn-fit-modify
- terminal-bench/break-filter-js-from-html
- hello-world
- fix-permissions
- csv-to-parquet
agents:
- import_path: examples.terminal_bench.praisonai_code_agent:PraisonAICodeAgent
+14 -29
View File
@@ -13,14 +13,15 @@ Usage:
-n 4
Architecture:
Harbor Container → `praisonai code -p --output json "TASK" --dangerously-skip-approval`
Harbor Container → `praisonai code "TASK" --dangerously-skip-approval` (headless)
Notes:
- `--dangerously-skip-approval` sets PRAISON_APPROVAL_MODE=auto +
PRAISONAI_TOOL_SAFETY=off so the assistant runs fully autonomously in the
container (no approval hang in a non-TTY session).
- The JSON envelope provides token, cost, session, and status metadata while
the real exit status still surfaces install/auth/startup failures.
- `praisonai code` normally exits 0 and Harbor grades by task verification,
so a benchmark miss does not fail the run — but the real exit status is
propagated so genuine install/auth/startup failures still surface.
- The base `praisonai` package is sufficient; heavy `code` extras are not
required (ACP tools degrade gracefully).
@@ -28,7 +29,6 @@ Dependencies:
pip install harbor praisonai praisonaiagents
"""
import json
import shlex
try:
@@ -81,19 +81,16 @@ class PraisonAICodeAgent(BaseInstalledAgent):
model = self.model_name or "openai/gpt-4o-mini"
command = (
"log_file=$(mktemp /tmp/praisonai_code.XXXXXX); "
"chmod 600 \"$log_file\"; "
f"praisonai code -p --output json {shlex.quote(instruction)} "
f"praisonai code {shlex.quote(instruction)} "
f"--dangerously-skip-approval "
f"--model {shlex.quote(model)} "
"> \"$log_file\" 2>&1; "
"> /tmp/praisonai_code.log 2>&1; "
# Capture the real exit status so install/auth/startup failures still
# surface instead of being silently masked (Harbor otherwise records a
# completed attempt for a crashed agent).
"status=$?; "
# Echo the envelope so Harbor captures it for post-run accounting.
"cat \"$log_file\"; "
"rm -f \"$log_file\"; "
# Tee the log into a stable path so populate_context_post_run can read it.
"cp /tmp/praisonai_code.log /tmp/praisonai_code_run.log 2>/dev/null || true; "
# `praisonai code` normally exits 0 (Harbor grades by task
# verification), so a genuine benchmark miss won't fail here — but a
# nonzero status means the assistant itself failed to run.
@@ -103,35 +100,23 @@ class PraisonAICodeAgent(BaseInstalledAgent):
# `--ae` / job YAML env vars arrive via BaseAgent.extra_env and are wired
# into the container exec context by Harbor Trial, so no per-exec `env=`
# is needed here.
result = await self.exec_as_agent(
await self.exec_as_agent(
environment,
command=command,
)
self._last_stdout = getattr(result, "stdout", "") or ""
def populate_context_post_run(self, context: AgentContext) -> None:
"""Parse the headless JSON envelope into Harbor's accounting fields."""
envelope = None
for line in reversed(getattr(self, "_last_stdout", "").splitlines()):
try:
candidate = json.loads(line)
except (TypeError, ValueError, json.JSONDecodeError):
continue
if isinstance(candidate, dict) and "status" in candidate:
envelope = candidate
break
"""`praisonai code` exposes no machine-readable metrics headlessly.
usage = envelope.get("usage", {}) if envelope else {}
context.n_input_tokens = usage.get("in")
context.n_output_tokens = usage.get("out")
context.cost_usd = usage.get("cost")
Record identifying metadata; token/cost accounting is not available from
the CLI's mixed stdout, so we leave those fields unset rather than guess.
"""
context.metadata = {
"framework": "praisonai",
"agent_type": "code-cli",
"agent_name": self.name(),
"model": self.model_name,
"session_id": envelope.get("session_id") if envelope else None,
"status": envelope.get("status") if envelope else "unknown",
"log_path": "/tmp/praisonai_code.log",
}
@@ -34,7 +34,6 @@ except ImportError as e:
try:
from praisonaiagents import Agent
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
from praisonaiagents.config.feature_configs import ExecutionConfig
except ImportError as e:
raise ImportError(
f"PraisonAI not installed: {e}\n"
@@ -42,13 +41,6 @@ except ImportError as e:
) from e
TB_EXECUTION = ExecutionConfig(
max_tool_calls_per_turn=80,
max_iter=40,
max_steps=80,
)
class PraisonAIExternalAgent(BaseAgent):
"""External PraisonAI agent that drives a Harbor container environment."""
@@ -150,14 +142,26 @@ class PraisonAIExternalAgent(BaseAgent):
),
tools=[bash_tool],
llm=self.model_name or "openai/gpt-4o-mini",
execution=TB_EXECUTION,
)
# Execute one deliberately long coding turn. Harbor's hidden verifier
# is the only completion authority; model or pytest text is not.
# Execute the agent with outer loop to handle premature stopping
print(f"🚀 PraisonAI Agent starting task: {instruction[:100]}...")
result = await agent.achat(instruction)
print("✅ PraisonAI Agent completed task")
for _iter in range(19):
result_str = str(result)
# Stop if test passed
if any(sig in result_str.lower() for sig in [
" passed", "passed ", "test passed", "all tests pass", "1 passed"
]):
break
result = await agent.achat(
"The task is NOT complete yet — keep working. Run bash_tool commands now. "
"If you haven't already: (1) read all files in /app/, "
"(2) run the test to see the exact failure, "
"(3) implement a fix, (4) run the test again. "
"Repeat until the test passes. What is your next bash_tool command?"
)
print(f"✅ PraisonAI Agent completed task")
# Populate Harbor context with metadata
self._populate_context(agent, context, result)
@@ -199,10 +203,6 @@ class PraisonAIExternalAgent(BaseAgent):
"tools_used": ["bash_tool"],
"framework": "praisonai",
"version": self.version(),
"stop_reason": getattr(agent, "last_stop_reason", None),
"max_tool_calls_per_turn": TB_EXECUTION.max_tool_calls_per_turn,
"max_iter": TB_EXECUTION.max_iter,
"max_steps": TB_EXECUTION.max_steps,
}
except Exception as e:
@@ -218,4 +218,4 @@ if __name__ == "__main__":
print(" --model openai/gpt-4o-mini")
print()
print("Dependencies:")
print(" pip install harbor praisonaiagents")
print(" pip install harbor praisonaiagents")
@@ -1,39 +0,0 @@
"""Static regression checks for the in-tree Harbor adapter configuration."""
from pathlib import Path
import yaml
EXAMPLES = Path(__file__).parent
def test_external_adapter_sets_coding_execution_budget():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert "ExecutionConfig" in source
assert "max_tool_calls_per_turn=80" in source
assert "max_iter=40" in source
assert "max_steps=80" in source
def test_external_adapter_does_not_guess_completion_from_pytest_text():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert '"1 passed"' not in source
assert '"test passed"' not in source
def test_smoke_yaml_uses_terminal_bench_21_registry_ids():
job = yaml.safe_load(
(EXAMPLES / "job_code_smoke.yaml").read_text(encoding="utf-8")
)
names = job["datasets"][0]["task_names"]
assert names
assert all(name.startswith("terminal-bench/") for name in names)
assert names == [
"terminal-bench/build-pmars",
"terminal-bench/bn-fit-modify",
"terminal-bench/break-filter-js-from-html",
]
+18 -18
View File
@@ -14,18 +14,18 @@ def test_imports():
"""Test that all required modules can be imported."""
try:
from praisonaiagents import Agent
print("[OK] Agent imported successfully")
print("✅ Agent imported successfully")
from praisonaiagents.tools import execute_command
print("[OK] execute_command tool imported successfully")
print("✅ execute_command tool imported successfully")
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
print("[OK] Approval system imported successfully")
print("✅ Approval system imported successfully")
return True
except ImportError as e:
print(f"[FAIL] Import failed: {ascii(e)}")
print(f"❌ Import failed: {e}")
return False
def test_agent_creation():
@@ -40,11 +40,11 @@ def test_agent_creation():
)
assert agent.name == 'test-agent'
print("[OK] Agent creation successful")
print("✅ Agent creation successful")
return True
except Exception as e:
print(f"[FAIL] Agent creation failed: {ascii(e)}")
print(f"❌ Agent creation failed: {e}")
return False
def test_approval_system():
@@ -55,11 +55,11 @@ def test_approval_system():
registry = get_approval_registry()
registry.set_backend(AutoApproveBackend())
print("[OK] Approval system configuration successful")
print("✅ Approval system configuration successful")
return True
except Exception as e:
print(f"[FAIL] Approval system test failed: {ascii(e)}")
print(f"❌ Approval system test failed: {e}")
return False
def test_external_agent_import():
@@ -80,11 +80,11 @@ def test_external_agent_import():
# Now try to import our agent
import praisonai_external_agent
print("[OK] External agent import successful")
print("✅ External agent import successful")
return True
except Exception as e:
print(f"[FAIL] External agent import failed: {ascii(e)}")
print(f"❌ External agent import failed: {e}")
return False
def test_multi_agent_import():
@@ -104,11 +104,11 @@ def test_multi_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import multi_agent_example
print("[OK] Multi-agent example import successful")
print("✅ Multi-agent example import successful")
return True
except Exception as e:
print(f"[FAIL] Multi-agent example import failed: {ascii(e)}")
print(f"❌ Multi-agent example import failed: {e}")
return False
def test_wrapper_agent_import():
@@ -128,11 +128,11 @@ def test_wrapper_agent_import():
sys.modules['harbor.models.agent.context'] = MagicMock()
import praisonai_wrapper_agent
print("[OK] Wrapper agent import successful (CLI-based approach)")
print("✅ Wrapper agent import successful (CLI-based approach)")
return True
except Exception as e:
print(f"[FAIL] Wrapper agent import failed: {ascii(e)}")
print(f"❌ Wrapper agent import failed: {e}")
return False
if __name__ == "__main__":
@@ -152,19 +152,19 @@ if __name__ == "__main__":
total = len(tests)
for test_name, test_func in tests:
print(f"\n[TEST] {test_name}...")
print(f"\n🧪 Testing {test_name}...")
try:
if test_func():
passed += 1
except Exception as e:
print(f"[FAIL] Test {test_name} crashed: {ascii(e)}")
print(f"❌ Test {test_name} crashed: {e}")
print("\n" + "=" * 55)
print(f"Results: {passed}/{total} tests passed")
if passed == total:
print("[OK] ALL BASIC TESTS PASSED!")
print("🎉 ALL BASIC TESTS PASSED!")
print("The Terminal-Bench integration components are properly structured.")
else:
print("[FAIL] Some tests failed - check output above")
print("❌ Some tests failed - check output above")
sys.exit(1)
@@ -1,64 +0,0 @@
# Simple Sequential Team (YAML)
A minimal 2-agent sequential team: a **researcher** gathers facts, then a
**writer** summarizes them. This is the copy-paste starting point for the most
common multi-agent pattern (2–5 agents, run in order).
```
research_task -> summary_task
(researcher) (writer)
```
## Files
| File | Purpose |
|------|---------|
| `agents.yaml` | Roles (who) + their tasks (what); tasks run in declaration order |
> **Which loader applies?** This example uses the canonical single-file format
> consumed by `AgentsGenerator.generate_crew_and_kickoff()`
> (`src/praisonai/praisonai/agents_generator.py`). Agents live under `roles:`,
> each with nested `tasks:`. **Tasks run in the order they are declared** — the
> researcher's task first, then the writer's. (An optional top-level
> `dependencies:` block is included to document intent and mirror the canonical
> fixture, but the roles-file loader sequences by declaration order, so keep
> tasks in the order you want them to run.) No new loader module is required —
> this is the existing YAML path.
## Run it
```bash
export OPENAI_API_KEY=sk-...
praisonai examples/yaml/teams/research-writer/agents.yaml
```
This positional-file form is the canonical roles-file entry point. From Python
the equivalent is `praisonai.run("agents.yaml")` (see
`src/praisonai/praisonai/_entrypoint.py`), which dispatches to the same
`AgentsGenerator`.
Change the subject by editing the `topic:` line in `agents.yaml` (interpolated
into every `{topic}` placeholder).
## When to use this vs a workflow YAML
| You need… | Use | Example |
|-----------|-----|---------|
| 2–5 agents, sequential/hierarchical | **This** (`roles:` + `tasks:`) | `agents.yaml` here |
| Routing by classifier output | workflow YAML | `../../workflows/routing_workflow.yaml` |
| Parallel branches | workflow YAML | `../../workflows/parallel_workflow.yaml` |
| Loops / planning / memory | workflow YAML | `../../workflows/complete_workflow.yaml` |
## Python equivalent
```python
from praisonaiagents import Agent, Task, PraisonAIAgents
researcher = Agent(role="Senior Researcher", goal="Find accurate information")
writer = Agent(role="Report Writer", goal="Turn research into summaries")
t1 = Task(description="Research renewable energy and list 5 key facts", agent=researcher)
t2 = Task(description="Write a 3-sentence summary from the research", agent=writer, context=[t1])
PraisonAIAgents(agents=[researcher, writer], tasks=[t1, t2]).start()
```
@@ -1,34 +0,0 @@
# yaml-language-server: $schema=https://docs.praison.ai/schemas/agents.schema.json
# Minimal sequential team: researcher -> writer
# Run: praisonai examples/yaml/teams/research-writer/agents.yaml
framework: "praisonai"
topic: "renewable energy"
roles:
researcher:
role: "Senior Researcher"
goal: "Find accurate information about {topic}"
backstory: "Expert at concise, factual research."
tasks:
research_task:
description: "Research {topic} and list 5 key facts."
expected_output: "A bullet list of 5 facts."
writer:
role: "Report Writer"
goal: "Turn research into readable summaries about {topic}"
backstory: "Clear technical writer."
tasks:
summary_task:
description: "Write a 3-sentence executive summary from the research."
expected_output: "3 sentences, plain text."
# Ordering is by task declaration order above (researcher's task runs first,
# then the writer's), which is how the roles-file loader sequences tasks.
# The optional `dependencies:` block below mirrors the canonical fixture
# (src/praisonai/tests/agents.yaml) and documents intent; the roles-file
# loader does not consume it, so keep tasks in the order you want them run.
dependencies:
- task: summary_task
depends_on: research_task
@@ -1,45 +0,0 @@
# Run a whole workflow's shell/file tools inside ONE shared remote sandbox.
#
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
#
# `tools_run_on:` is the only line that differs from a normal local workflow.
# Every step shares the same sandbox and working directory, so the file the
# writer creates is there for the reader. Thinking and orchestration still run
# on your machine, and the sandbox is torn down when the run finishes.
#
# Places: docker | e2b | modal | daytona | flyio | tenki | local
# subprocess | sandlock | ssh | novita
# - docker needs a local Docker daemon and nothing else
# - e2b / modal / daytona / flyio need that provider's API key
# - sandlock is a kernel-enforced local boundary (Linux 6.12+)
# - subprocess is process separation only, NOT a security boundary
#
# `run_on:` is still accepted here as the old spelling for this key.
#
# See what is still running: praisonai managed ps
# Reclaim strays: praisonai managed stop --all
name: remote-sandbox-demo
tools_run_on: docker
agents:
writer:
role: File Writer
goal: Write files into the shared workspace
instructions: >
You have write_file and execute_command running on a remote Linux
sandbox. Use them; never say you cannot run commands.
reader:
role: File Reader
goal: Read files back out of the shared workspace
instructions: >
You have read_file and execute_command running on the same remote
sandbox the writer used. Use them.
steps:
- agent: writer
action: "Use write_file to put exactly 'hello from step 1' in /workspace/note.txt"
- agent: reader
action: "Use read_file on /workspace/note.txt and report its exact contents"
-55
View File
@@ -1,55 +0,0 @@
#!/usr/bin/env bash
# C14 import-direction gate for praisonai-deploy.
set -euo pipefail
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CODE_ROOT="${C14_CODE_ROOT:-src/praisonai-code/praisonai_code}"
DEPLOY_ROOT="${C14_DEPLOY_ROOT:-src/praisonai-deploy/praisonai_deploy}"
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^[[:space:]]*import praisonai($|\.))'
ANY_CODE_RE='(^from praisonai_code([[:space:]]|\.)|^import praisonai_code($|\.))'
ANY_DEPLOY_RE='(^[[:space:]]*from praisonai_deploy([[:space:]]|\.)|^[[:space:]]*import praisonai_deploy($|\.))'
echo "== C14 praisonai_deploy wrapper import gate (must be zero outside bridges) =="
if command -v rg >/dev/null 2>&1; then
MATCHES="$(rg -n "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
else
MATCHES="$(grep -rEn --include='*.py' "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
fi
if [ -n "$MATCHES" ]; then
echo "$MATCHES"
echo "FAIL: praisonai_deploy imports the praisonai wrapper"
exit 1
fi
echo "deploy wrapper import gate ok"
echo "== C14 praisonai_deploy module-level praisonai_code import gate =="
if command -v rg >/dev/null 2>&1; then
MATCHES="$(rg -n "$ANY_CODE_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
else
MATCHES="$(grep -rEn --include='*.py' "$ANY_CODE_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
fi
if [ -n "$MATCHES" ]; then
echo "$MATCHES"
echo "FAIL: module-level praisonai_code import in praisonai_deploy"
exit 1
fi
echo "deploy code import gate ok"
echo "== C14 praisonai-code hot-path gate (no praisonai_deploy at module level) =="
for f in \
"$CODE_ROOT/cli/main.py" \
"$CODE_ROOT/cli/app.py" \
"$CODE_ROOT/cli/commands/run.py" \
"$CODE_ROOT/cli/commands/chat.py" \
"$CODE_ROOT/cli/commands/code.py"
do
if [ -f "$f" ] && head -n 80 "$f" | grep -E "$ANY_DEPLOY_RE" 2>/dev/null; then
echo "FAIL: module-level praisonai_deploy import in code hot path $f"
exit 1
fi
done
echo "code hot-path gate ok"
echo "C14 import gates passed"
+1 -3
View File
@@ -18,9 +18,7 @@ for f in \
"$ROOT/cli/app.py" \
"$ROOT/cli/commands/run.py" \
"$ROOT/cli/commands/chat.py" \
"$ROOT/cli/commands/code.py" \
"$ROOT/cli/interactive/async_tui.py" \
"$ROOT/cli/interactive/core.py"
"$ROOT/cli/commands/code.py"
do
if grep -E "$HOT_PATH_RE" "$f" 2>/dev/null; then
echo "FAIL: module-level wrapper import in $f"
-60
View File
@@ -1,60 +0,0 @@
#!/usr/bin/env bash
# Lint and template-render all Helm charts under package infra/ trees.
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if ! command -v helm >/dev/null 2>&1; then
echo "FAIL: helm CLI not found (install via https://helm.sh/docs/intro/install/)"
exit 1
fi
HELM_ROOTS=(
"$ROOT/src/praisonai-bot/infra/helm"
"$ROOT/src/praisonai-deploy/infra/helm"
)
charts=()
for helm_root in "${HELM_ROOTS[@]}"; do
if [ ! -d "$helm_root" ]; then
continue
fi
shopt -s nullglob
for chart in "$helm_root"/*/; do
if [ -f "${chart}Chart.yaml" ]; then
charts+=("$chart")
fi
done
shopt -u nullglob
done
if [ ${#charts[@]} -eq 0 ]; then
echo "No charts found under package infra/helm trees"
exit 1
fi
for chart in "${charts[@]}"; do
name="$(basename "$chart")"
echo "== helm lint: $name =="
helm lint "$chart"
echo "== helm template: $name =="
case "$name" in
praisonai-gateway)
helm template "ci-smoke-$name" "$chart" \
--set auth.existingSecret=ci-smoke-auth \
>/dev/null
;;
praisonai-agents-api)
helm template "ci-smoke-$name" "$chart" \
--set auth.existingSecret=ci-smoke-auth \
--set postgres.auth.existingSecret=ci-smoke-postgres \
>/dev/null
;;
*)
helm template "ci-smoke-$name" "$chart" >/dev/null
;;
esac
done
echo "Helm chart gates passed (${#charts[@]} chart(s))"
@@ -99,12 +99,13 @@ pip index versions praisonaiagents | head -1
pip index versions praisonai | head -1
```
13. **praisonai-platform** advisories are handled in a different repository:
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
It is not part of this monorepo and is not published from here. Triage, patch,
release and advisory publication all happen in that repo, on its own semver line.
Nothing in this repository imports it, so a platform advisory never requires an
SDK release.
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.
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
```bash
@@ -112,8 +113,9 @@ cd /Users/praison/praisonai-package/src/praisonai-ts
npm version patch && npm publish
```
15. Verify npm when applicable:
15. Verify platform / npm when applicable:
```bash
pip index versions praisonai-platform | head -1
npm view praisonai version
```
@@ -137,7 +139,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
}
EOF
```
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), or `praisonai` (npm ecosystem for TypeScript)
- `<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
@@ -188,8 +190,8 @@ After validating each advisory in source code, assign **one** label:
| 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` |
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
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.
@@ -215,11 +217,9 @@ Close (or publish advisory with no code change only if already mitigated) when:
|---|---|---|---|
| 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/ |
`praisonai-platform` is **not audited from this repository** — it lives in
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
## RESOURCES & DOCUMENTATION
- **Official Repository**: https://github.com/MervinPraison/PraisonAI — Source code, issue tracking, and contribution guidelines
@@ -234,7 +234,7 @@ Close (or publish advisory with no code change only if already mitigated) when:
- ALWAYS set patched_versions before publishing advisory
- 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: released from the separate [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) repo, never from here
- 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"
+2 -2
View File
@@ -140,7 +140,7 @@ The **agentic terminal CLI** (`run`, `chat`, `code`, warm runtime, CLI backends,
**Monorepo bootstrap:** `praisonai._bootstrap.ensure_praisonai_code()` adds sibling `src/praisonai-code` to `sys.path` so `PYTHONPATH=src/praisonai-agents:src/praisonai` works without an explicit code entry.
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai-bot` → `praisonai-train` → `praisonai-browser` → `praisonai-mcp` → `praisonai-sandbox` → `praisonai-deploy` → `praisonai` (see `.github/actions/install-monorepo-packages`).
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai` (see `.github/actions/install-monorepo-packages`).
**Invocation paths that must remain valid:**
@@ -1074,7 +1074,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
>
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
>
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
+47 -47
View File
@@ -18,76 +18,76 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 22 | 53 | 16965 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 5 | 2658 |
| ✅ Agent | 21 | 45 | 14186 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2965 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
| ✅ Approval | 1 | 5 | 2286 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 11 | 3542 |
| ✅ Autonomy | 3 | 7 | 2763 |
| ✅ Bots | 7 | 31 | 13403 |
| ✅ Auto Generation | 5 | 10 | 2868 |
| ✅ Autonomy | 3 | 7 | 2707 |
| ✅ Bots | 7 | 28 | 11301 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 122 | 36654 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ CLI | 5 | 119 | 33852 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4851 |
| ✅ Conditions | 1 | 3 | 1321 |
| ✅ Configuration | 3 | 8 | 3140 |
| ✅ Context Management | 16 | 36 | 13890 |
| ✅ Code Execution | 2 | 13 | 3970 |
| ✅ Conditions | 1 | 3 | 1179 |
| ✅ Configuration | 3 | 7 | 2772 |
| ✅ Context Management | 16 | 34 | 13204 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2905 |
| ✅ Evaluation | 1 | 7 | 2902 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1586 |
| ✅ Failover | 2 | 1 | 432 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 78 | 28577 |
| ✅ Guardrails | 4 | 4 | 2205 |
| ✅ Handoffs | 11 | 6 | 2567 |
| ✅ Hooks | 2 | 9 | 4488 |
| ✅ Execution | 3 | 4 | 1528 |
| ✅ Failover | 2 | 1 | 400 |
| ✅ Files | 2 | 6 | 1964 |
| ✅ Flow | 1 | 3 | 777 |
| ✅ Gateway | 7 | 54 | 19347 |
| ✅ Guardrails | 4 | 4 | 1673 |
| ✅ Handoffs | 11 | 6 | 2454 |
| ✅ Hooks | 2 | 9 | 4124 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 16 | 4942 |
| ✅ LLM | 3 | 15 | 5296 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 61 | 14135 |
| ✅ Memory | 6 | 18 | 7133 |
| ✅ Knowledge | 4 | 13 | 4160 |
| ✅ LLM | 3 | 14 | 4830 |
| ✅ Loops | 4 | 4 | 1202 |
| ✅ MCP | 1 | 58 | 13322 |
| ✅ Memory | 6 | 17 | 6635 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2814 |
| ✅ Observability | 2 | 23 | 2779 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1246 |
| ✅ Output | 3 | 5 | 1158 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1605 |
| ✅ Plugins | 8 | 5 | 3120 |
| ✅ Prompts | 2 | 9 | 1971 |
| ✅ Providers | 1 | 56 | 7781 |
| ✅ Planning | 6 | 6 | 1545 |
| ✅ Plugins | 8 | 3 | 1900 |
| ✅ Prompts | 2 | 9 | 1910 |
| ✅ Providers | 1 | 55 | 7502 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ RAG | 5 | 15 | 2936 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 683 |
| ✅ Retrieval | 2 | 5 | 1054 |
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1044 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3483 |
| ✅ Security | 1 | 3 | 3415 |
| ✅ Sessions | 4 | 19 | 7576 |
| ✅ Skills | 6 | 15 | 5638 |
| ✅ Tasks | 2 | 6 | 2819 |
| ✅ Sandbox | 5 | 6 | 2700 |
| ✅ Security | 1 | 3 | 2966 |
| ✅ Sessions | 4 | 13 | 5286 |
| ✅ Skills | 6 | 13 | 4946 |
| ✅ Tasks | 2 | 6 | 2743 |
| ✅ Telemetry | 1 | 2 | 604 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 144 | 42732 |
| ✅ Templates | 1 | 8 | 1657 |
| ✅ Tools | 12 | 133 | 37869 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1215 |
| ✅ Vector Store | 1 | 12 | 1193 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 9 | 2484 |
| ✅ Workflows | 5 | 17 | 7049 |
| ✅ Web | 3 | 8 | 2055 |
| ✅ Workflows | 5 | 16 | 6546 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (42 docs, 6766 lines)
- ℹ️ Database (40 docs, 6184 lines)
- ℹ️ Documents (1 docs, 787 lines)
---
@@ -143,9 +143,9 @@ _LAZY_IMPORTS = {
'has_toolset': ('praisonaiagents.toolsets', 'has_toolset'),
# Model-family-aware harness (base prompt + edit-format selection)
'HarnessProfile': ('praisonaiagents.model_harness.profiles', 'HarnessProfile'),
'resolve_harness': ('praisonaiagents.model_harness.profiles', 'resolve_harness'),
'register_profile': ('praisonaiagents.model_harness.profiles', 'register_profile'),
'HarnessProfile': ('praisonaiagents.model_harness', 'HarnessProfile'),
'resolve_harness': ('praisonaiagents.model_harness', 'resolve_harness'),
'register_profile': ('praisonaiagents.model_harness', 'register_profile'),
# Run outcomes - typed validation and agent execution results
'AgentRunOutcome': ('praisonaiagents.run_outcome', 'AgentRunOutcome'),
@@ -258,7 +258,6 @@ _LAZY_IMPORTS = {
# Agent classes
'Agent': ('praisonaiagents.agent.agent', 'Agent'),
'RunOutcome': ('praisonaiagents.agent.run_outcome', 'RunOutcome'),
'RetryBackoffConfig': ('praisonaiagents.agent.retry_utils', 'RetryBackoffConfig'),
'BudgetExceededError': ('praisonaiagents.errors', 'BudgetExceededError'),
@@ -304,10 +303,9 @@ _LAZY_IMPORTS = {
'CodeAgent': ('praisonaiagents.agent.code_agent', 'CodeAgent'),
'CodeConfig': ('praisonaiagents.agent.code_agent', 'CodeConfig'),
# AgentTeam (primary) / AgentManager, PraisonAIAgents (aliases)
# AgentTeam (primary) / AgentManager (alias)
'AgentTeam': ('praisonaiagents.agents.agents', 'AgentTeam'),
'AgentManager': ('praisonaiagents.agents.agents', 'AgentManager'), # Silent alias
'PraisonAIAgents': ('praisonaiagents.agents.agents', 'PraisonAIAgents'), # Silent alias (pre-1.0 name)
# Note: 'Agents' is handled by _custom_handler for deprecation warning
'Task': ('praisonaiagents.task.task', 'Task'),
'AutoAgents': ('praisonaiagents.agents.autoagents', 'AutoAgents'),
@@ -426,7 +424,6 @@ _LAZY_IMPORTS = {
'WebConfig': ('praisonaiagents.config.feature_configs', 'WebConfig'),
'OutputConfig': ('praisonaiagents.config.feature_configs', 'OutputConfig'),
'ExecutionConfig': ('praisonaiagents.config.feature_configs', 'ExecutionConfig'),
'PreCompactionMemoryFlushConfig': ('praisonaiagents.config.feature_configs', 'PreCompactionMemoryFlushConfig'),
'TemplateConfig': ('praisonaiagents.config.feature_configs', 'TemplateConfig'),
'CachingConfig': ('praisonaiagents.config.feature_configs', 'CachingConfig'),
'HooksConfig': ('praisonaiagents.config.feature_configs', 'HooksConfig'),
@@ -841,11 +838,9 @@ __all__ = [
# Core classes - the essentials
'Agent',
'RunOutcome',
'RetryBackoffConfig',
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
'AgentManager', # Silent alias for AgentTeam
'PraisonAIAgents', # Silent alias for AgentTeam (pre-1.0 name)
'Agents', # Deprecated alias for AgentTeam (emits warning)
'Task',
@@ -28,11 +28,8 @@ from typing import Any, Callable, Dict, Optional, Tuple
_logger = logging.getLogger(__name__)
# Global cache lock for thread-safe access.
# Reentrant so that a lazy import which triggers another lazy import on the
# same thread (e.g. a package attribute that re-exports from a submodule via
# its own __getattr__) degrades to redundant work instead of self-deadlocking.
_cache_lock = threading.RLock()
# Global cache lock for thread-safe access
_cache_lock = threading.Lock()
# Global module cache
_module_cache: Dict[str, Any] = {}
@@ -4,6 +4,7 @@ This module consolidates all logging configuration in one place to avoid duplica
"""
import os
import json
import logging
from typing import Any, Dict, List, Optional, Union
@@ -195,9 +196,6 @@ class StructuredFormatter(logging.Formatter):
extra = {k: v for k, v in record.extra_data.items() if k not in self._STANDARD_FIELDS}
log_data.update(extra)
# Deferred: json is only needed for opt-in structured logging
import json
return json.dumps(log_data)
@@ -37,15 +37,6 @@ def __getattr__(name):
from .interrupt import InterruptController
_lazy_cache[name] = InterruptController
return InterruptController
if name in ('RunOutcome', 'TerminalReason'):
from . import run_outcome as _run_outcome_module
value = getattr(_run_outcome_module, name)
_lazy_cache[name] = value
return value
if name == 'prompt_prefix_signature':
from .prompt_cache import prompt_prefix_signature
_lazy_cache[name] = prompt_prefix_signature
return prompt_prefix_signature
# Specialized agents - lazy loaded (import rich)
if name == 'ImageAgent':
@@ -224,9 +215,6 @@ __all__ = [
'Heartbeat',
'HeartbeatConfig',
'InterruptController',
'RunOutcome',
'TerminalReason',
'prompt_prefix_signature',
'ImageAgent',
'VideoAgent',
'VideoConfig',
File diff suppressed because it is too large. Load diff
@@ -7,6 +7,7 @@ async capabilities following the AsyncMemoryProtocol.
"""
import asyncio
import logging
from typing import List, Dict, Any, Optional, Union
from praisonaiagents._logging import get_logger
from ..memory.protocols import AsyncMemoryProtocol
@@ -84,36 +85,21 @@ class AsyncMemoryMixin:
if not hasattr(self, '_memory_instance') or self._memory_instance is None:
logger.debug("No memory configured for async search")
return []
# Shared adapters must receive the configured identity explicitly.
# FileMemory is already bound to a user-specific path and accepts these
# as harmless protocol kwargs.
scoped_kwargs = dict(kwargs)
config = getattr(self, "_memory_config", None)
user_id = getattr(config, "user_id", None)
session_id = getattr(config, "session_id", None)
if user_id:
scoped_kwargs["user_id"] = user_id
if session_id:
scoped_kwargs["session_id"] = session_id
metadata_filter = dict(scoped_kwargs.get("metadata_filter") or {})
metadata_filter["session_id"] = session_id
scoped_kwargs["metadata_filter"] = metadata_filter
# Check if memory adapter supports async operations
if isinstance(self._memory_instance, AsyncMemoryProtocol):
try:
if memory_type == "long_term":
return await self._memory_instance.asearch_long_term(query, limit, **scoped_kwargs)
return await self._memory_instance.asearch_long_term(query, limit, **kwargs)
else:
return await self._memory_instance.asearch_short_term(query, limit, **scoped_kwargs)
return await self._memory_instance.asearch_short_term(query, limit, **kwargs)
except Exception as e:
logger.error(f"Error in async memory search: {e}")
return []
else:
# Fallback: run sync memory operations in thread pool
return await self._run_memory_in_thread(
"search", query, memory_type, limit=limit, **scoped_kwargs
"search", query, memory_type, limit=limit, **kwargs
)
async def _run_memory_in_thread(
@@ -230,4 +216,4 @@ class AsyncMemoryMixin:
)
return False
return True
return False
return False
@@ -5,9 +5,8 @@ This module provides dual-lock abstractions that automatically select
the appropriate lock type based on the execution context (sync vs async).
"""
import asyncio
import copy
import threading
from typing import Any
from typing import Any, Optional, Union
from contextlib import contextmanager, asynccontextmanager
from weakref import WeakKeyDictionary
@@ -40,12 +39,6 @@ class DualLock:
"""Initialize with separate threading and asyncio locks."""
self._thread_lock = threading.RLock() # Re-entrant lock to handle nested acquisitions
self._async_locks = WeakKeyDictionary() # Per-event-loop async locks
def __deepcopy__(self, memo):
"""Return an unlocked primitive with no event-loop state sharing."""
result = type(self)()
memo[id(self)] = result
return result
@contextmanager
def sync(self):
@@ -110,33 +103,6 @@ class AsyncSafeState:
def __init__(self, initial_value: Any = None):
self.value = initial_value
self._lock = DualLock()
self._activity_lock = threading.Lock()
self._async_holders = 0
self._copying = False
def __deepcopy__(self, memo):
"""Copy protected state, rejecting overlap with asynchronous access."""
result = type(self).__new__(type(self))
memo[id(self)] = result
with self._activity_lock:
if self._async_holders:
raise RuntimeError(
"Cannot deepcopy AsyncSafeState during asynchronous access"
)
if self._copying:
raise RuntimeError("AsyncSafeState deepcopy is already in progress")
self._copying = True
try:
with self.lock():
result.value = copy.deepcopy(self.value, memo)
finally:
with self._activity_lock:
self._copying = False
result._lock = DualLock()
result._activity_lock = threading.Lock()
result._async_holders = 0
result._copying = False
return result
@contextmanager
def lock(self):
@@ -148,25 +114,7 @@ class AsyncSafeState:
async def async_lock(self):
"""Acquire lock in async context."""
async with self._lock.async_lock():
self._begin_async_access()
try:
yield self.value
finally:
self._end_async_access()
def _begin_async_access(self) -> None:
"""Register async access unless a synchronous copy is in progress."""
with self._activity_lock:
if self._copying:
raise RuntimeError(
"Cannot access AsyncSafeState during synchronous deepcopy"
)
self._async_holders += 1
def _end_async_access(self) -> None:
"""Unregister one active asynchronous accessor."""
with self._activity_lock:
self._async_holders -= 1
yield self.value
def __enter__(self):
"""Support for synchronous context manager protocol (backward compatibility)."""
@@ -182,20 +130,12 @@ class AsyncSafeState:
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
await async_lock.acquire()
try:
self._begin_async_access()
except Exception:
async_lock.release()
raise
return self.value
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
try:
self._end_async_access()
finally:
async_lock.release()
async_lock.release()
return None
def get(self) -> Any:
@@ -204,4 +144,4 @@ class AsyncSafeState:
def is_async_context(self) -> bool:
"""Check if we're in an async context."""
return self._lock.is_async_context()
return self._lock.is_async_context()
@@ -136,8 +136,9 @@ class AudioAgent:
- AudioConfig: Full configuration object
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "AudioAgent")
# Handle model= alias
if llm is None and model is not None:
llm = model
self.name = name or "AudioAgent"
self.instructions = instructions
@@ -34,6 +34,7 @@ Usage:
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List, Set
from enum import Enum
import logging
from praisonaiagents._logging import get_logger
logger = get_logger(__name__)
@@ -106,11 +107,8 @@ class AutonomyConfig:
track_changes: Whether to track filesystem changes via shadow git.
Defaults to True when level="full_auto", False otherwise.
When enabled, agent.undo()/redo()/diff() become available.
sandbox: Optional SandboxConfig, carried for forward compatibility.
NOT IMPLEMENTED: nothing currently reads this field, and no sandbox
is auto-enabled at any autonomy level. To isolate execution, set it
on the agent itself -- Agent(sandbox=...) -- or run the whole
workflow remotely with AgentFlow(run_on="docker").
sandbox: Optional SandboxConfig for execution isolation.
When level="full_auto" and sandbox is None, subprocess sandbox is auto-enabled.
snapshot_dir: Directory for snapshot storage. Defaults to ~/.praisonai/snapshots.
Usage::
@@ -217,13 +215,6 @@ class AutonomyConfig:
f"Invalid autonomy level: {level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
verification_hooks = data.get("verification_hooks")
# Config parity: accept declarative `verification:` entries and map
# `command:`/`file:` shapes onto the two hook classes.
declarative = data.get("verification")
if declarative:
built = _build_verification_hooks(declarative)
verification_hooks = (verification_hooks or []) + built
return cls(
enabled=data.get("enabled", True),
level=level,
@@ -234,7 +225,7 @@ class AutonomyConfig:
observe=data.get("observe", False),
completion_promise=data.get("completion_promise"),
clear_context=data.get("clear_context", False),
verification_hooks=verification_hooks,
verification_hooks=data.get("verification_hooks"),
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
@@ -244,49 +235,6 @@ class AutonomyConfig:
budget_action=data.get("budget_action", "pause"),
)
def _build_verification_hooks(entries: List[Any]) -> List[Any]:
"""Map declarative ``verification:`` config entries onto hook instances.
Each entry is a dict with either a ``command`` key (→
:class:`CommandVerificationHook`) or a ``file`` key (→
:class:`FileCheckHook`). Unknown shapes are skipped.
Example::
verification:
- {name: tests, command: "python -m pytest -q"}
- {name: changelog, file: CHANGELOG.md, non_empty: true}
"""
from ..hooks.verification import CommandVerificationHook, FileCheckHook
hooks: List[Any] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
blocking = entry.get("blocking", True)
if "command" in entry:
hooks.append(CommandVerificationHook(
name=entry.get("name", "command"),
command=entry["command"],
cwd=entry.get("cwd"),
timeout=entry.get("timeout", 60.0),
blocking=blocking,
))
elif "file" in entry:
hooks.append(FileCheckHook(
name=entry.get("name", "file"),
path=entry["file"],
exists=entry.get("exists", True),
non_empty=entry.get("non_empty", False),
contains=entry.get("contains"),
json_field=entry.get("json_field"),
equals=entry.get("equals"),
blocking=blocking,
))
return hooks
class AutonomySignal(str, Enum):
"""Signals detected from prompts for autonomy decisions.
@@ -315,6 +263,17 @@ class AutonomySignal(str, Enum):
)
super().__init_subclass__(**kwargs)
def _warn_autonomy_signal():
"""Emit deprecation warning when AutonomySignal is accessed."""
from ..utils.deprecation import warn_deprecated_param
warn_deprecated_param(
"AutonomySignal",
since="1.0.0",
removal="2.0.0",
alternative="use EscalationSignal from praisonaiagents.escalation.types instead",
stacklevel=4
)
class AutonomyTrigger:
"""Detects signals from prompts for autonomy decisions.
File diff suppressed because it is too large. Load diff
@@ -59,8 +59,7 @@ class CodeAgent:
def __init__(
self,
name: str = "CodeAgent",
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None,
llm: Optional[str] = None,
code: Optional[Union[bool, Dict, CodeConfig]] = None,
instructions: Optional[str] = None,
verbose: bool = True,
@@ -70,16 +69,14 @@ class CodeAgent:
Args:
name: Agent name
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Model to use (default: gpt-4o-mini)
llm: LLM model (default: gpt-4o-mini)
code: Code configuration (bool, dict, or CodeConfig)
instructions: System instructions
verbose: Enable verbose output
**kwargs: Additional arguments
"""
from ..utils.model_alias import resolve_model_name
self.name = name
self.llm = resolve_model_name(llm, model, "CodeAgent") or "gpt-4o-mini"
self.llm = llm or "gpt-4o-mini"
self.instructions = instructions
self.verbose = verbose
@@ -22,6 +22,7 @@ from typing import Optional, Any, Dict, Union, List, TYPE_CHECKING
# Lazy imports for performance - these are only loaded when needed
_subprocess = None
_glob = None
_ast = None
_asyncio = None
def _get_subprocess():
@@ -40,6 +41,14 @@ def _get_glob():
_glob = glob
return _glob
def _get_ast():
"""Lazy import ast to avoid import-time overhead."""
global _ast
if _ast is None:
import ast
_ast = ast
return _ast
def _get_asyncio():
"""Lazy import asyncio to avoid import-time overhead."""
global _asyncio
@@ -12,6 +12,7 @@ Usage::
"""
import json
import logging
from praisonaiagents._logging import get_logger
import os
import time
@@ -34,6 +34,7 @@ Example:
"""
import os
import logging
from praisonaiagents._logging import get_logger
import time
import asyncio
@@ -1,469 +0,0 @@
"""Opt-in durable tool-loop context backed by :class:`RunJournal`."""
from __future__ import annotations
import asyncio
import contextvars
import inspect
import json
import threading
from typing import Any, Callable, Dict, List, Optional, Tuple
from uuid import uuid4
from ..errors import ToolExecutionError
from ..runtime.journal import (
JournalEvent,
KIND_ITERATION,
KIND_MODEL_DECISION,
KIND_TOOL_CALL,
KIND_TOOL_RESULT,
KIND_VERIFICATION,
RunJournal,
)
_active_durable_run: contextvars.ContextVar[Optional["DurableRunContext"]] = (
contextvars.ContextVar("praisonai_active_durable_run", default=None)
)
_active_idempotency_key: contextvars.ContextVar[Optional[str]] = (
contextvars.ContextVar("praisonai_idempotency_key", default=None)
)
def _json_safe(value: Any) -> Any:
try:
return json.loads(json.dumps(value))
except (TypeError, ValueError):
return json.loads(json.dumps(value, default=str))
def _tool_result_content(result: Any) -> str:
if result is None:
return "Function returned an empty output"
try:
return json.dumps(result)
except (TypeError, ValueError):
return json.dumps({"result": str(result)})
def _accepts_idempotency_key(execute_tool_fn: Callable) -> bool:
"""Return whether an executor accepts the optional durable key contract."""
try:
parameters = inspect.signature(execute_tool_fn).parameters.values()
except (TypeError, ValueError):
return False
return any(
parameter.name == "idempotency_key"
or parameter.kind == inspect.Parameter.VAR_KEYWORD
for parameter in parameters
)
class DurableRunContext:
"""Per-turn journal state; safe for parallel tool-call wrappers."""
def __init__(
self,
journal: RunJournal,
run_id: str,
*,
replaying: bool,
) -> None:
self.journal = journal
self.run_id = run_id
self.replaying = replaying
self._lock = threading.RLock()
self._replay = journal.replay_index(run_id)
events = journal.events(run_id)
self._next_seq = max((event.seq for event in events), default=-1) + 1
self._restored = False
self._closed = False
self._results_by_tool_call_id: Dict[str, Any] = {}
self._pending_by_call_id: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
self._pending_by_fingerprint: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
self._recorded_iterations = set()
for event in events:
if event.kind == KIND_TOOL_RESULT:
tool_call_id = event.payload.get("tool_call_id")
if tool_call_id:
self._results_by_tool_call_id[str(tool_call_id)] = event.payload.get(
"result"
)
elif event.kind == KIND_ITERATION:
self._recorded_iterations.add(
int(event.payload.get("index", event.seq))
)
for event in events:
if event.kind != KIND_TOOL_CALL:
continue
call_id = str(event.payload.get("tool_call_id") or "")
if not call_id or call_id in self._results_by_tool_call_id:
continue
name = str(event.payload.get("name") or "")
args = event.payload.get("args") or {}
key = str(
event.payload.get("idempotency_key")
or f"{self.run_id}:{event.seq}:{name}"
)
pending = (event.seq, call_id, args, key)
self._pending_by_call_id[call_id] = pending
self._pending_by_fingerprint[self._fingerprint(name, args)] = pending
self._resume_completed_steps = len(self._recorded_iterations)
@property
def completed_steps(self) -> int:
return len(self._recorded_iterations)
@staticmethod
def _fingerprint(function_name: str, arguments: Dict[str, Any]) -> str:
return f"{function_name}:{json.dumps(_json_safe(arguments), sort_keys=True)}"
def restore_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Append completed decision/result pairs once, before the next model call."""
if not self.replaying or self._restored:
return messages
existing_ids = set()
for message in messages:
for tool_call in message.get("tool_calls") or []:
if isinstance(tool_call, dict) and tool_call.get("id"):
existing_ids.add(str(tool_call["id"]))
for seq in sorted({key[0] for key in self._replay}):
decision = self._replay.get((seq, KIND_MODEL_DECISION))
result = self._replay.get((seq, KIND_TOOL_RESULT))
if not decision or result is None:
continue
message = decision.get("message")
tool_call_id = result.get("tool_call_id")
if not isinstance(message, dict) or not tool_call_id:
continue
if str(tool_call_id) in existing_ids:
continue
messages.append(_json_safe(message))
messages.append(
{
"role": "tool",
"tool_call_id": str(tool_call_id),
"content": _tool_result_content(result.get("result")),
}
)
existing_ids.add(str(tool_call_id))
self._restored = True
return messages
def _allocate_step(
self,
function_name: str,
arguments: Dict[str, Any],
tool_call_id: Optional[str],
) -> Tuple[int, str, str]:
with self._lock:
requested_call_id = str(tool_call_id) if tool_call_id is not None else None
pending = (
self._pending_by_call_id.pop(requested_call_id, None)
if requested_call_id
else None
)
fingerprint = self._fingerprint(function_name, arguments)
if pending is None and self.replaying:
pending = self._pending_by_fingerprint.pop(fingerprint, None)
if pending is not None:
seq, call_id, _args, idempotency_key = pending
self._pending_by_call_id.pop(call_id, None)
self._pending_by_fingerprint.pop(fingerprint, None)
return seq, call_id, idempotency_key
seq = self._next_seq
self._next_seq += 1
call_id = str(tool_call_id or f"{self.run_id}-tool-{seq}")
idempotency_key = f"{self.run_id}:{seq}:{function_name}"
safe_arguments = _json_safe(arguments)
message = {
"role": "assistant",
"content": "",
"tool_calls": [
{
"id": call_id,
"type": "function",
"function": {
"name": function_name,
"arguments": json.dumps(safe_arguments),
},
}
],
}
self.journal.append(
JournalEvent(
self.run_id,
seq,
KIND_MODEL_DECISION,
{"message": message},
)
)
self.journal.append(
JournalEvent(
self.run_id,
seq,
KIND_TOOL_CALL,
{
"name": function_name,
"args": safe_arguments,
"tool_call_id": call_id,
"idempotency_key": idempotency_key,
},
)
)
return seq, call_id, idempotency_key
def _record_result(
self,
seq: int,
tool_call_id: str,
result: Any,
iteration_index: Optional[int],
) -> Any:
safe_result = _json_safe(result)
with self._lock:
self.journal.append(
JournalEvent(
self.run_id,
seq,
KIND_TOOL_RESULT,
{"tool_call_id": tool_call_id, "result": safe_result},
)
)
if iteration_index is None:
global_iteration = max(self._recorded_iterations, default=-1) + 1
else:
global_iteration = self._resume_completed_steps + int(iteration_index)
if global_iteration not in self._recorded_iterations:
self.journal.append(
JournalEvent(
self.run_id,
global_iteration,
KIND_ITERATION,
{"index": global_iteration},
)
)
self._recorded_iterations.add(global_iteration)
self._results_by_tool_call_id[tool_call_id] = safe_result
self._replay[(seq, KIND_TOOL_RESULT)] = {
"tool_call_id": tool_call_id,
"result": safe_result,
}
return safe_result
def _record_terminal_failure(
self,
seq: int,
tool_call_id: str,
error: ToolExecutionError,
iteration_index: Optional[int],
) -> None:
"""Persist a fatal tool outcome and make the run non-resumable."""
self._record_result(
seq,
tool_call_id,
{
"error": str(error),
"error_type": type(error).__name__,
"is_retryable": bool(error.is_retryable),
"terminal": True,
},
iteration_index,
)
self.finalize("failed")
def record_verification(self, payloads: List[Dict[str, Any]]) -> None:
"""Append verification-gate results as durable ``verification`` events.
Each payload gets its own fresh ``seq`` so the events append cleanly
alongside the tool-call cursor without colliding on ``(run_id, seq,
kind)``. Best-effort: journalling never raises into the gate.
"""
if self._closed:
return
with self._lock:
for payload in payloads:
seq = self._next_seq
self._next_seq += 1
try:
self.journal.append(
JournalEvent(self.run_id, seq, KIND_VERIFICATION, payload)
)
except Exception: # pragma: no cover - journalling is best-effort
pass
def wrap_sync(self, execute_tool_fn: Optional[Callable]) -> Optional[Callable]:
if execute_tool_fn is None:
return None
def execute(function_name, arguments, tool_call_id=None, **kwargs):
iteration_index = kwargs.pop("_durable_iteration_index", None)
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._results_by_tool_call_id:
return self._results_by_tool_call_id[call_id]
seq, call_id, idempotency_key = self._allocate_step(
function_name, arguments, tool_call_id
)
token = _active_idempotency_key.set(idempotency_key)
try:
call_kwargs = dict(kwargs)
call_kwargs["tool_call_id"] = tool_call_id
if _accepts_idempotency_key(execute_tool_fn):
call_kwargs["idempotency_key"] = idempotency_key
result = execute_tool_fn(function_name, arguments, **call_kwargs)
except ToolExecutionError as exc:
if not exc.is_retryable:
self._record_terminal_failure(
seq, call_id, exc, iteration_index
)
raise
except Exception as exc:
result = {"error": str(exc)}
finally:
_active_idempotency_key.reset(token)
return self._record_result(seq, call_id, result, iteration_index)
return execute
def wrap_async(self, execute_tool_fn: Optional[Callable]) -> Optional[Callable]:
if execute_tool_fn is None:
return None
async def execute(function_name, arguments, tool_call_id=None, **kwargs):
iteration_index = kwargs.pop("_durable_iteration_index", None)
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._results_by_tool_call_id:
return self._results_by_tool_call_id[call_id]
seq, call_id, idempotency_key = await asyncio.to_thread(
self._allocate_step, function_name, arguments, tool_call_id
)
token = _active_idempotency_key.set(idempotency_key)
try:
call_kwargs = dict(kwargs)
call_kwargs["tool_call_id"] = tool_call_id
if _accepts_idempotency_key(execute_tool_fn):
call_kwargs["idempotency_key"] = idempotency_key
result = execute_tool_fn(function_name, arguments, **call_kwargs)
if inspect.isawaitable(result):
result = await result
except ToolExecutionError as exc:
if not exc.is_retryable:
await asyncio.to_thread(
self._record_terminal_failure,
seq,
call_id,
exc,
iteration_index,
)
raise
except Exception as exc:
result = {"error": str(exc)}
finally:
_active_idempotency_key.reset(token)
return await asyncio.to_thread(
self._record_result, seq, call_id, result, iteration_index
)
return execute
def finalize(self, outcome: str = "succeeded") -> None:
if not self._closed:
self.journal.close_run(self.run_id, outcome)
self._closed = True
def close(self) -> None:
self.journal.close()
async def arestore_messages(
self, messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
return await asyncio.to_thread(self.restore_messages, messages)
async def afinalize(self, outcome: str = "succeeded") -> None:
await asyncio.to_thread(self.finalize, outcome)
async def aclose(self) -> None:
await asyncio.to_thread(self.close)
def begin_durable_run(agent: Any, prompt: str):
"""Create and activate a context, or return ``(None, None)`` when disabled."""
execution = getattr(agent, "execution", None)
if execution is None or not getattr(execution, "durable", False):
return None, None
if _active_durable_run.get() is not None:
return None, None
context = _open_durable_run(agent, prompt)
token = _active_durable_run.set(context)
return context, token
def _open_durable_run(agent: Any, prompt: str) -> DurableRunContext:
"""Open journal state without mutating the caller's ContextVar."""
execution = agent.execution
journal = RunJournal(getattr(execution, "journal_path", None))
resume_run_id = getattr(execution, "resume_run_id", None)
replaying = bool(resume_run_id)
run_id = str(resume_run_id or uuid4())
if replaying:
metadata = journal.run_meta(run_id)
if metadata is None:
journal.close()
raise ValueError(f"Cannot resume unknown durable run {run_id!r}")
if metadata.status != "running":
journal.close()
raise ValueError(
f"Cannot resume terminal durable run {run_id!r} "
f"(status={metadata.status!r})"
)
if metadata.task and metadata.task != prompt:
journal.close()
raise ValueError(
"Resume prompt does not match the prompt recorded for durable "
f"run {run_id!r}"
)
journal.open_run(
run_id,
agent=getattr(agent, "name", ""),
task=prompt,
metadata={"version": 1, "explicit_resume": replaying},
)
context = DurableRunContext(journal, run_id, replaying=replaying)
agent._last_durable_run_id = run_id
return context
async def abegin_durable_run(agent: Any, prompt: str):
"""Async durable-run creation with all SQLite I/O off the event loop."""
execution = getattr(agent, "execution", None)
if execution is None or not getattr(execution, "durable", False):
return None, None
if _active_durable_run.get() is not None:
return None, None
context = await asyncio.to_thread(_open_durable_run, agent, prompt)
token = _active_durable_run.set(context)
return context, token
def end_durable_run(token) -> None:
if token is not None:
_active_durable_run.reset(token)
def get_durable_run() -> Optional[DurableRunContext]:
return _active_durable_run.get()
def get_durable_idempotency_key() -> Optional[str]:
"""Return the stable key for the currently executing durable tool call."""
return _active_idempotency_key.get()
@@ -110,8 +110,8 @@ class EmbeddingAgent:
- EmbeddingConfig: Full configuration object
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "EmbeddingAgent")
if llm is None and model is not None:
llm = model
self.name = name or "EmbeddingAgent"
self.instructions = instructions
@@ -1,276 +0,0 @@
"""Describe, in plain English, where an agent's work actually happens.
PraisonAI can run the thinking (LLM calls) and the tools in different places:
this machine, a container, or a provider's cloud. Which one you get depends on
``run_on=``, ``backend=`` and ``sandbox=``, and until now none of that was
visible from the object -- ``repr(agent)`` was the bare default.
This module is the single source of that answer, shared by ``Agent``,
``AgentTeam`` and ``AgentFlow`` so all three describe themselves identically.
Vocabulary note: the fields are ``thinks_on`` / ``tools_run_on``, never
``loop``. "Loop" is already a public workflow primitive here
(``praisonaiagents.workflows.loop()``), asyncio's event loop, and agent-loop
jargon a beginner would not know -- three meanings for one word is exactly the
problem this module exists to fix.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# Provider name -> how a human would say it out loud.
_PLACE_WORDS = {
"local": "this machine",
"subprocess": "a separate process on this machine",
"sandlock": "a locked-down process on this machine",
"docker": "a Docker container",
"e2b": "an E2B cloud sandbox",
"modal": "a Modal cloud sandbox",
"daytona": "a Daytona cloud sandbox",
"flyio": "a Fly.io machine",
"tenki": "a Tenki cloud sandbox",
"novita": "a Novita cloud sandbox",
"ssh": "a remote machine over SSH",
"anthropic": "Anthropic's cloud",
}
HERE = "this machine"
# Public sandbox aliases the SandboxManager collapses before it picks a backend
# (see praisonaiagents/sandbox/manager.py). We must describe the backend that
# actually runs, not the alias the user typed, or "native"/"local" would report
# a place that never executes.
_ALIASES = {"native": "sandlock", "local": "subprocess"}
# ...but only for the SANDBOX registry. "local" resolves to two different
# backends depending on which parameter carried it:
# run_in="local" -> SandboxManager collapses it to subprocess:
# scrubbed environment, blocked commands and paths
# tools_run_on="local" -> the wrapper's LocalCompute: a plain shell in the
# current directory, full environment inherited,
# no security policy at all
# Describing the second in the first's words overstates it, which is the exact
# failure this module exists to prevent. One word, two backends -- so the
# phrase has to depend on where the word came from.
_COMPUTE_ONLY_WORDS = {"local": "a plain shell on this machine (no policy applied)"}
# Tiers that separate a process but do NOT contain it. Verified: under the
# subprocess backend, outbound network still works and ~/.ssh is readable even
# though SecurityPolicy declares them blocked.
# `native` is deliberately absent: it aliases sandlock, which applies Landlock,
# seccomp, a scrubbed environment and deny-all networking. Listing it here made
# tools_run_on="native" and tools_run_on="sandlock" -- the same backend --
# disagree about whether they contain anything.
_NOT_A_BOUNDARY = {"subprocess", "local"}
def say_place(name: Any, *, via: str = "sandbox") -> str:
"""Turn a place name into a phrase a non-developer can read.
``via`` says which registry resolved the name -- ``"compute"`` for
``tools_run_on=``, ``"sandbox"`` for ``run_in=``. It only matters for names
that mean different backends in each (see ``_COMPUTE_ONLY_WORDS``).
"""
if name is None:
return HERE
if name is True:
return _PLACE_WORDS["subprocess"]
if not isinstance(name, str):
# A configured provider instance: use its own declared name if it has
# one. Normalise BEFORE the compute-only check, or a backend holding a
# LocalCompute object (provider_name="local") skips the branch and gets
# described in the subprocess backend's stronger words -- the exact
# overstatement this module exists to prevent.
name = getattr(name, "provider_name", None) or type(name).__name__
if via == "compute" and name.lower() in _COMPUTE_ONLY_WORDS:
return _COMPUTE_ONLY_WORDS[name.lower()]
key = str(name).lower()
key = _ALIASES.get(key, key)
known = _PLACE_WORDS.get(key)
if known:
return known
# A place contributed by another package can name itself; the bare name is
# the last resort rather than the only option, because the phrase table is
# a literal that cannot know about packages installed later.
try:
from ..managed._compute_bridge import contributed_display_name
declared = contributed_display_name(key)
if declared:
return declared
except Exception:
pass
return str(name)
def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
"""Where does a managed ``backend=`` put the thinking and the tools?"""
if backend is None:
return None
# Ask the backend what it is, rather than guessing from its class name.
# Name-matching missed any backend that did not happen to contain "Hosted"
# or "Anthropic" -- a self-hosted docker backend reported that it was
# thinking on this machine while the whole loop ran in a container.
declared = getattr(backend, "provider_name", None)
if isinstance(declared, str):
try:
from .placement import managed_runtimes
if declared.lower() in managed_runtimes():
where = say_place(declared)
return {"thinks_on": where, "tools_run_on": where}
except Exception:
pass
cls = type(backend).__name__
# Hosted runtimes move the entire turn off this machine.
if "Hosted" in cls or "Anthropic" in cls:
provider = getattr(backend, "provider", None) or "anthropic"
where = say_place(provider)
return {"thinks_on": where, "tools_run_on": where}
# Local/sandboxed agents keep the loop here and may push tools out. A
# backend's compute object is a COMPUTE-registry provider, so it is read
# with the compute vocabulary: a LocalCompute here is the unrestricted
# shell, not the subprocess backend "local" collapses to under run_in=.
compute = getattr(backend, "_compute", None) or getattr(backend, "compute", None)
if compute is not None:
return {"thinks_on": HERE, "tools_run_on": say_place(compute, via="compute")}
return {"thinks_on": HERE, "tools_run_on": HERE}
def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
"""Return ``{thinks_on, tools_run_on, code_runs_on?}`` for an object.
Never raises: a description of where things run must not be able to break
the thing it describes.
"""
places: Dict[str, str] = {"thinks_on": HERE, "tools_run_on": HERE}
try:
from_backend = _backend_places(getattr(obj, "backend", None))
if from_backend:
places.update(from_backend)
where_tools = getattr(obj, "tools_run_on", None)
if where_tools is not None:
where = say_place(where_tools, via="compute")
places["tools_run_on"] = f"{where} (shared)" if shared else where
# run_on= puts the WHOLE agent on a managed runtime, so it moves the
# thinking as well. _backend_places() already reports that (run_on= is
# turned into a backend at construction); this only covers an object
# that carries the name without a built backend.
whole = getattr(obj, "run_on", None)
if whole is not None and not from_backend:
everywhere = say_place(whole)
places["thinks_on"] = everywhere
places["tools_run_on"] = everywhere
sandbox_config = getattr(obj, "sandbox_config", None)
if sandbox_config is not None:
places["code_runs_on"] = say_place(
getattr(sandbox_config, "sandbox_type", None)
)
except Exception: # pragma: no cover - description must never break callers
pass
return places
def _tools_staying_here(obj: Any) -> list:
"""Names of the object's own tools, which do NOT move to the sandbox.
Only four tool names are bridged (``execute_command``, ``read_file``,
``write_file``, ``list_files``); everything else the user passed keeps
running in this process, against this filesystem. The capability prompt
tells the model the sandbox is where things happen, so anything staying
behind has to be said out loud.
Reads the pre-attach tool list when a sandbox is already attached: by then
``agent.tools`` has been rewritten and the originals live in the sandbox's
restore record.
"""
try:
from ..managed.shared_compute import BRIDGED_TOOL_NAMES
except Exception:
BRIDGED_TOOL_NAMES = ("execute_command", "read_file", "write_file", "list_files")
tools = None
shared = getattr(obj, "_tools_sandbox", None)
if shared is not None:
for patched, original_tools, _ in getattr(shared, "_patched", []):
if patched is obj:
tools = original_tools
break
if tools is None:
tools = getattr(obj, "tools", None) or []
names = []
for tool in tools:
name = getattr(tool, "__name__", None) or getattr(tool, "name", None)
if isinstance(name, str) and name not in BRIDGED_TOOL_NAMES:
names.append(name)
return names
def repr_fields(obj: Any, *, shared: bool = False) -> str:
"""Render the places as ``key='value'`` pairs for a ``__repr__``."""
return ", ".join(f"{k}={v!r}" for k, v in describe(obj, shared=shared).items())
def explain(obj: Any, *, shared: bool = False) -> str:
"""Plain-sentence answer to "where does this actually run?".
Deliberately uses only words a non-engineer already knows -- no "loop",
"harness", "provision" or "runtime".
"""
places = describe(obj, shared=shared)
lines = [
f"Thinking (the AI model calls) happens on {places['thinks_on']}.",
f"Tools run on {places['tools_run_on']}.",
]
if shared and getattr(obj, "tools_run_on", None) is not None:
lines.append(
"Every step shares that same sandbox, so a file written by one step "
"is visible to the next."
)
staying = _tools_staying_here(obj)
if staying and places["tools_run_on"] != places["thinks_on"]:
listed = ", ".join(staying[:6]) + ("..." if len(staying) > 6 else "")
lines.append(
f"Your own tools ({listed}) still run on {HERE} -- only shell, file "
f"and code tools move. They read and write this machine's files."
)
if "code_runs_on" in places:
lines.append(
f"Code you run yourself with execute_code() runs on {places['code_runs_on']}."
)
# Say plainly when the chosen tier is not a security boundary.
raw = [
getattr(obj, "tools_run_on", None),
getattr(obj, "run_on", None),
getattr(getattr(obj, "sandbox_config", None), "sandbox_type", None),
]
if any(isinstance(v, str) and v.lower() in _NOT_A_BOUNDARY for v in raw) or any(
v is True for v in raw
):
lines.append(
"Note: a separate process is not a security boundary -- it can still "
"reach the network and read your files, and its blocked-command "
"list can be worked around by ordinary shell syntax. Use docker or a cloud "
"provider for untrusted code."
)
if places["thinks_on"] == HERE and places["tools_run_on"] == HERE:
lines.append(
"Nothing is isolated: tools you pass run in this program, with your "
"permissions."
)
return "\n".join(lines)
File diff suppressed because it is too large. Load diff
@@ -14,9 +14,9 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
from dataclasses import dataclass, field
from enum import Enum
import inspect
import logging
from praisonaiagents._logging import get_logger
import asyncio
import contextvars
import threading
import time
import json
@@ -163,52 +163,33 @@ from ..errors import (
HandoffValidationError
)
# Per-task/per-thread storage for tracking handoff chains.
#
# ``contextvars.ContextVar`` isolates the chain per :class:`asyncio.Task`
# (and per OS thread), unlike ``threading.local()`` which shares one list
# across every coroutine running on the same event-loop thread. Concurrent
# async handoffs (``asyncio.gather``, a server handling parallel requests, or
# any ``max_concurrent`` workflow) would otherwise push/pop into the same list
# and corrupt each other's cycle/depth state. Combined with the copy-on-write
# push/pop below, this fully isolates sibling handoff tasks (e.g. those spawned
# by ``parallel_handoffs`` via ``asyncio.gather``).
_handoff_chain_var: "contextvars.ContextVar[Optional[List[str]]]" = contextvars.ContextVar(
"handoff_chain", default=None
)
# Thread-local storage for tracking handoff chains
_handoff_context = threading.local()
def _get_handoff_chain() -> List[str]:
"""Get current handoff chain for this task/thread."""
chain = _handoff_chain_var.get()
if chain is None:
chain = []
_handoff_chain_var.set(chain)
return chain
"""Get current handoff chain from thread-local storage."""
if not hasattr(_handoff_context, 'chain'):
_handoff_context.chain = []
return _handoff_context.chain
def _get_handoff_depth() -> int:
"""Get current handoff depth."""
return len(_get_handoff_chain())
def _push_handoff(agent_name: str) -> None:
"""Push agent to handoff chain.
Uses copy-on-write so a push made inside a child task does not leak back
into the parent task's chain once the child completes.
"""
chain = list(_get_handoff_chain())
"""Push agent to handoff chain."""
chain = _get_handoff_chain()
chain.append(agent_name)
_handoff_chain_var.set(chain)
def _pop_handoff() -> Optional[str]:
"""Pop agent from handoff chain."""
chain = list(_get_handoff_chain())
popped = chain.pop() if chain else None
_handoff_chain_var.set(chain)
return popped
chain = _get_handoff_chain()
return chain.pop() if chain else None
def _clear_handoff_chain() -> None:
"""Clear the handoff chain."""
_handoff_chain_var.set([])
if hasattr(_handoff_context, 'chain'):
_handoff_context.chain = []
@dataclass
class HandoffInputData:
@@ -308,14 +289,12 @@ class Handoff:
- Cycle detection and depth limiting
- Configurable context policies
"""
# Class-level lock guarding lazy creation of each instance's async
# semaphore. The semaphore itself is per-instance (so every Handoff
# enforces its own max_concurrent), but the brief init critical section is
# only ever touched under this shared threading.Lock, which keeps
# double-checked lazy creation thread-safe without a per-instance lock.
_semaphore_lock: threading.Lock = threading.Lock()
# Class-level semaphore for concurrency control
_semaphore: Optional[asyncio.Semaphore] = None
_sync_semaphore: Optional[threading.Semaphore] = None
_semaphore_lock: threading.Lock = threading.Lock() # Lock for semaphore initialization
def __init__(
self,
agent: 'Agent',
@@ -346,40 +325,10 @@ class Handoff:
self.input_type = input_type
self.input_filter = input_filter
self.config = config or HandoffConfig()
# Instance-level concurrency control. Each Handoff enforces its own
# max_concurrent instead of sharing one process-wide semaphore, so
# different handoffs don't silently override each other's limits based
# on execution order. The async semaphore is created lazily (and
# recreated if bound to a stale event loop) since it binds to the
# running loop on first use.
self._semaphore: Optional[asyncio.Semaphore] = None
self._semaphore_loop: Optional[asyncio.AbstractEventLoop] = None
# Override config callback if on_handoff provided directly
if on_handoff and not self.config.on_handoff:
self.config.on_handoff = on_handoff
def _get_semaphore(self) -> Optional[asyncio.Semaphore]:
"""Return this handoff's async semaphore, bound to the current loop.
Returns None when concurrency limiting is disabled (max_concurrent <= 0).
The semaphore is created lazily and recreated if the running event loop
differs from the one it was originally bound to, so a process that calls
asyncio.run(...) more than once doesn't crash with a "bound to a
different event loop" RuntimeError.
"""
if self.config.max_concurrent <= 0:
return None
try:
current_loop = asyncio.get_event_loop()
except RuntimeError:
current_loop = None
with self._semaphore_lock:
if self._semaphore is None or self._semaphore_loop is not current_loop:
self._semaphore = asyncio.Semaphore(self.config.max_concurrent)
self._semaphore_loop = current_loop
return self._semaphore
@property
def tool_name(self) -> str:
@@ -648,15 +597,13 @@ class Handoff:
"""
start_time = time.time()
kwargs = context or {}
pushed = False
try:
# Safety checks (may raise before anything is pushed for this call)
# Safety checks
self._check_safety(source_agent)
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -732,8 +679,7 @@ class Handoff:
logger.error(f"Handoff error: {e}")
return result
finally:
if pushed:
_pop_handoff()
_pop_handoff()
async def execute_async(
self,
@@ -752,19 +698,20 @@ class Handoff:
Returns:
HandoffResult with response or error
"""
# Per-instance semaphore, bound to the current event loop
semaphore = self._get_semaphore()
# Initialize semaphore if needed (thread-safe)
if self.config.max_concurrent > 0 and Handoff._semaphore is None:
with Handoff._semaphore_lock: # Thread-safe initialization
if Handoff._semaphore is None: # Double-check after acquiring lock
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
start_time = time.time()
kwargs = context or {}
async def _execute():
pushed = False
try:
# Safety checks (may raise before anything is pushed for this call)
# Safety checks
self._check_safety(source_agent)
_push_handoff(source_agent.name)
pushed = True
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -813,12 +760,11 @@ class Handoff:
return result
finally:
if pushed:
_pop_handoff()
_pop_handoff()
try:
if semaphore is not None:
async with semaphore:
if self.config.max_concurrent > 0 and Handoff._semaphore:
async with Handoff._semaphore:
if self.config.timeout_seconds > 0:
result = await asyncio.wait_for(
_execute(),
@@ -882,15 +828,13 @@ class Handoff:
def handoff_tool(**kwargs):
"""Execute the handoff to the target agent."""
start_time = time.time()
pushed = False
try:
# Safety checks (may raise before anything is pushed for this call)
# Safety checks
self._check_safety(source_agent)
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -976,8 +920,7 @@ class Handoff:
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
return f"Error during handoff to {self.agent.name}: {str(e)}"
finally:
if pushed:
_pop_handoff()
_pop_handoff()
# Set function metadata for tool definition generation
handoff_tool.__name__ = self.tool_name
@@ -1228,13 +1171,6 @@ async def parallel_handoffs(
semaphore = asyncio.Semaphore(effective_max_concurrent) if effective_max_concurrent > 0 else None
async def _run_one(agent, prompt):
# Each gathered task shares a copied context, but a copied ContextVar
# binding still points at the *same* parent list object. Rebind to a
# fresh, per-task copy so sibling handoffs cannot corrupt each other's
# cycle/depth tracking when parallel_handoffs runs inside a non-empty
# parent handoff chain.
_handoff_chain_var.set(list(_handoff_chain_var.get() or []))
async def _do_handoff():
try:
return await source.handoff_to_async(agent, prompt, config=config)
@@ -1455,15 +1391,13 @@ class TypedHandoff(Handoff, Generic[T]):
start_time = time.time()
kwargs = context or {}
pushed = False
try:
# Safety checks (may raise before anything is pushed for this call)
# Safety checks
self._check_safety(source_agent)
# Track handoff chain
_push_handoff(source_agent.name)
pushed = True
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -1536,8 +1470,7 @@ class TypedHandoff(Handoff, Generic[T]):
logger.error(f"Typed handoff error: {e}")
return result
finally:
if pushed:
_pop_handoff()
_pop_handoff()
async def execute_async(
self,
@@ -1564,19 +1497,20 @@ class TypedHandoff(Handoff, Generic[T]):
if isinstance(payload, str):
return await super().execute_async(source_agent, payload, context)
# Per-instance semaphore, bound to the current event loop
semaphore = self._get_semaphore()
# Initialize semaphore if needed (thread-safe)
if self.config.max_concurrent > 0 and Handoff._semaphore is None:
with Handoff._semaphore_lock:
if Handoff._semaphore is None:
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
start_time = time.time()
kwargs = context or {}
async def _execute():
pushed = False
try:
# Safety checks (may raise before anything is pushed for this call)
# Safety checks
self._check_safety(source_agent)
_push_handoff(source_agent.name)
pushed = True
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
@@ -1624,12 +1558,11 @@ class TypedHandoff(Handoff, Generic[T]):
handoff_depth=_get_handoff_depth(),
)
finally:
if pushed:
_pop_handoff()
_pop_handoff()
try:
if semaphore is not None:
async with semaphore:
if self.config.max_concurrent > 0 and Handoff._semaphore:
async with Handoff._semaphore:
if self.config.timeout_seconds > 0:
result = await asyncio.wait_for(
_execute(),
@@ -13,6 +13,7 @@ Usage::
"""
import asyncio
import logging
from praisonaiagents._logging import get_logger
import threading
import time
@@ -6,7 +6,7 @@ operations. Follows protocol-driven design with zero overhead when not used.
"""
import threading
from typing import Optional, Protocol, Set
from typing import Optional, Protocol
from dataclasses import dataclass, field
__all__ = ["InterruptControllerProtocol", "InterruptController"]
@@ -57,10 +57,6 @@ class InterruptController:
_flag: threading.Event = field(default_factory=threading.Event, init=False, repr=False)
_reason: Optional[str] = field(default=None, init=False)
_active_turns: Set[int] = field(default_factory=set, init=False, repr=False)
_cancelled_turns: Set[int] = field(default_factory=set, init=False, repr=False)
_next_turn_id: int = field(default=0, init=False, repr=False)
_pending_turn_cancel: bool = field(default=False, init=False, repr=False)
_lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False)
def request(self, reason: str = "user") -> None:
@@ -73,40 +69,12 @@ class InterruptController:
if not self._flag.is_set():
self._reason = reason
self._flag.set()
if self._active_turns:
self._cancelled_turns.update(self._active_turns)
else:
self._pending_turn_cancel = True
def _begin_turn(self) -> int:
"""Register an Agent turn and consume any request made while idle."""
with self._lock:
self._next_turn_id += 1
turn_id = self._next_turn_id
self._active_turns.add(turn_id)
if self._pending_turn_cancel:
self._cancelled_turns.add(turn_id)
self._pending_turn_cancel = False
return turn_id
def _turn_is_cancelled(self, turn_id: int) -> bool:
"""Return whether a registered turn was targeted by a request."""
with self._lock:
return turn_id in self._cancelled_turns
def _end_turn(self, turn_id: int) -> None:
"""Unregister a turn so its request cannot affect a future turn."""
with self._lock:
self._active_turns.discard(turn_id)
self._cancelled_turns.discard(turn_id)
def clear(self) -> None:
"""Clear the cancellation request."""
with self._lock:
self._reason = None
self._flag.clear()
self._pending_turn_cancel = False
self._cancelled_turns.clear()
def is_set(self) -> bool:
"""Check if cancellation has been requested.
@@ -1,88 +0,0 @@
"""Shared authorisation + bind guard for the ``launch()`` serving paths.
Both ``Agent.launch()`` (``agent/execution_mixin.py``) and
``PraisonAIAgents.launch()`` (``agents/agents.py``) expose a prompt-execution
HTTP endpoint. They must enforce identical request authorisation and must not
silently bind all interfaces without a token. Factoring the two helpers here
keeps that policy in one place so the two launch paths cannot drift apart.
Stdlib only — no heavy imports, no new public params.
"""
from __future__ import annotations
import os
import secrets as _secrets
from typing import Optional
__all__ = [
"launch_auth_token",
"authorise_launch_request",
"resolve_launch_host",
]
# Hosts that are safe to serve keyless on: loopback only.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
# One auto-generated token per process, reused across launches so a later
# non-loopback launch cannot mint a fresh token and lock out an endpoint that
# is already serving with the previously generated one.
_GENERATED_TOKEN: Optional[str] = None
def launch_auth_token() -> Optional[str]:
"""Return the configured launch bearer token, if any."""
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
def authorise_launch_request(request) -> bool:
"""Return True if the request carries the configured bearer token.
When no token is configured this returns True (local-dev flow preserved).
Accepts either ``Authorization: Bearer <token>`` or ``X-Auth-Token``.
"""
token = launch_auth_token()
if not token:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and _secrets.compare_digest(auth[7:], token):
return True
supplied = request.headers.get("X-Auth-Token", "")
return bool(supplied) and _secrets.compare_digest(supplied, token)
def _is_loopback(host: str) -> bool:
return host in _LOOPBACK_HOSTS
def resolve_launch_host(host: str) -> str:
"""Fail-closed bind guard, mirroring the ``serve``/jobs precedent.
* Token set → bind ``host`` unchanged (auth protects the endpoint).
* Token unset + loopback host → bind unchanged (local dev unchanged).
* Token unset + non-loopback host → auto-generate a token, export it via
``PRAISONAI_LAUNCH_AUTH_TOKEN`` so every route enforces it, and print it
once. This closes the unauthenticated-exposure hole without refusing to
start.
Returns the host to bind (unchanged; the guard acts on the token, not the
host, so an explicit ``0.0.0.0`` is still honoured — just never keyless).
A generated token is reused for the lifetime of the process: repeated
non-loopback launches share the one token rather than each minting a fresh
one, so a later launch cannot invalidate an already-running endpoint's
clients.
"""
if launch_auth_token() or _is_loopback(host):
return host
global _GENERATED_TOKEN
if _GENERATED_TOKEN is None:
_GENERATED_TOKEN = _secrets.token_urlsafe(32)
print(
f"🔐 launch() bound to non-loopback host {host!r} without "
f"PRAISONAI_LAUNCH_AUTH_TOKEN set. Generated a one-time bearer token "
f"(set PRAISONAI_LAUNCH_AUTH_TOKEN to override): {_GENERATED_TOKEN}"
)
os.environ["PRAISONAI_LAUNCH_AUTH_TOKEN"] = _GENERATED_TOKEN
return host
@@ -22,6 +22,7 @@ from __future__ import annotations
import hashlib
import json
import logging
from praisonaiagents._logging import get_logger
import time
from dataclasses import dataclass, field
@@ -160,32 +161,6 @@ def record_tool_call(
if len(history) > max_size:
del history[: len(history) - max_size]
def reset_matching_history(
history: List[ToolCallRecord],
tool_name: str,
args: Any,
) -> int:
"""Drop every record for this exact ``(tool_name, args)`` pair in-place.
Used when a critical loop is *explicitly approved* to continue: simply
popping the just-recorded call would leave the streak one below the
threshold, so the very next identical call would re-trigger the gate. By
removing the whole matching streak, an approved legitimate repeat gets a
fresh window (another full ``critical_threshold`` run) before it is flagged
again. Records for *other* tools/args are preserved, so concurrent activity
on the same agent is unaffected.
Returns the number of records removed.
"""
args_hash = hash_tool_call(tool_name, args)
before = len(history)
history[:] = [
rec for rec in history
if not (rec.get("tool_name") == tool_name and rec.get("args_hash") == args_hash)
]
return before - len(history)
def record_tool_outcome(
history: List[ToolCallRecord],
tool_name: str,
@@ -9,18 +9,8 @@ for maintainability.
import os
import inspect
import logging
import contextvars
from typing import Optional
# Per-turn ownership of appended chat-history messages. Each chat()/achat() turn
# runs in its own thread (sync) or task (async); both get an isolated copy of
# this context var. The append helpers record every message THIS turn appends
# into its own list, so a failing turn's rollback removes only its own messages
# and never a concurrent turn's - even though all turns share one chat_history.
_active_turn_owned: "contextvars.ContextVar[Optional[list]]" = contextvars.ContextVar(
"praisonai_active_turn_owned", default=None
)
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
from ._lazy_display import _get_console, _get_live, _get_display_functions
@@ -67,11 +57,7 @@ class MemoryMixin:
content: Message content
"""
with self._history_lock:
msg = {"role": role, "content": content}
self.chat_history.append(msg)
owned = _active_turn_owned.get()
if owned is not None:
owned.append(msg)
self.chat_history.append({"role": role, "content": content})
def _add_to_chat_history_if_not_duplicate(self, role, content) -> bool:
"""Thread-safe method to add messages to chat history only if not duplicate.
@@ -93,11 +79,7 @@ class MemoryMixin:
return False
# Not a duplicate, add the message
msg = {"role": role, "content": content}
self.chat_history.append(msg)
owned = _active_turn_owned.get()
if owned is not None:
owned.append(msg)
self.chat_history.append({"role": role, "content": content})
return True
def _get_chat_history_length(self):
@@ -114,66 +96,6 @@ class MemoryMixin:
with self._history_lock:
self.chat_history = self.chat_history[:length]
def _begin_turn_tracking(self):
"""Start recording the messages appended by the current turn.
Returns a token to reset the context var when the turn ends. Each turn
runs in its own thread (sync ``chat``) or task (async ``achat``), so the
context var copy is isolated per turn. Safe to call unconditionally; when
a turn opts in, its rollback removes only its own messages by identity.
"""
return _active_turn_owned.set([])
def _end_turn_tracking(self, token):
"""Stop recording for the current turn and drop its ownership list."""
try:
_active_turn_owned.reset(token)
except (ValueError, LookupError):
_active_turn_owned.set(None)
def _clear_turn_tracking(self):
"""Drop any per-turn ownership list from a prior turn on this context.
Used at the start of an async turn (whose control flow has no single
wrapping ``finally``) so a stale list from a completed turn on the same
task cannot accumulate messages from unrelated turns.
"""
_active_turn_owned.set(None)
def _rollback_chat_history_to(self, rollback_length):
"""Thread-safe rollback that never clobbers a concurrent turn's messages.
A turn snapshots ``len(self.chat_history)`` before a multi-second LLM
call, then rolls back to that length on failure.
Ownership-safe path (default for ``chat``/``achat`` turns): when the turn
opted into per-turn tracking via ``_begin_turn_tracking()``, remove ONLY
the messages this turn actually appended, identified by object identity.
A concurrent turn that interleaved its own messages after this snapshot
keeps every one of them - the old positional ``del chat_history[n:]``
would have erased them and corrupted the transcript.
Positional fallback (untracked callers): remove the tail from
``rollback_length`` onward, and only if the history actually grew that
far. Backward compatible for any path that does not track ownership.
Args:
rollback_length: History length to roll back to (this turn's snapshot).
"""
owned = _active_turn_owned.get()
with self._history_lock:
if owned:
owned_ids = {id(m) for m in owned}
self.chat_history[:] = [
m for m in self.chat_history if id(m) not in owned_ids
]
owned.clear()
return
# Untracked turn (or nothing appended): safe positional rollback.
if len(self.chat_history) > rollback_length:
del self.chat_history[rollback_length:]
def _cache_get(self, cache_dict, key):
"""Thread-safe LRU cache get operation.
@@ -318,25 +240,21 @@ class MemoryMixin:
if hasattr(self._history_lock, 'is_async_context') and self._history_lock.is_async_context():
# Cannot use asyncio.Lock in sync context - use thread lock as fallback
import hashlib
import uuid
from datetime import datetime, timezone
with self._history_lock._lock._thread_lock:
if self._session_id is None: # Double-check after acquiring lock
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
# per-instance suffix so same-named agents can never collide
self._session_id = f"{hour_str}-{agent_hash}-{uuid.uuid4().hex[:8]}"
self._session_id = f"{hour_str}-{agent_hash}"
else:
# Use sync lock directly
import hashlib
import uuid
from datetime import datetime, timezone
with self._history_lock.lock():
if self._session_id is None: # Double-check after acquiring lock
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
# per-instance suffix so same-named agents can never collide
self._session_id = f"{hour_str}-{agent_hash}-{uuid.uuid4().hex[:8]}"
self._session_id = f"{hour_str}-{agent_hash}"
# Call db adapter's on_agent_start to get previous messages
try:
@@ -513,28 +431,6 @@ class MemoryMixin:
except Exception as e:
logging.warning(f"Failed to persist message to session store: {e}")
async def _apersist_message(
self,
role: str,
content: str,
tool_calls=None,
tool_call_id: Optional[str] = None,
):
"""Async wrapper for :meth:`_persist_message`.
The default JSON session store performs a file-locked, blocking
read-modify-write to disk on every turn. Calling ``_persist_message``
directly from ``_achat_impl`` would stall the entire event loop (other
agents, streaming, background tasks) for the duration of that I/O on
every user/assistant/tool turn. Offloading to a worker thread — the
same treatment ``_persist_compaction_checkpoint`` already gets — keeps
``achat()`` non-blocking as documented.
"""
import asyncio
await asyncio.to_thread(
self._persist_message, role, content, tool_calls, tool_call_id
)
def _add_message_with_tool_fields(self, role, content, **tool_fields):
"""Add a message carrying tool fields, tolerating stores without them.
@@ -8,6 +8,7 @@ Thread-safe implementation for concurrent access.
import threading
import heapq
import time
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, Any, List, Tuple
from dataclasses import dataclass, field
@@ -89,8 +89,8 @@ class OCRAgent:
ocr: OCR configuration
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "OCRAgent")
if llm is None and model is not None:
llm = model
self.name = name or "OCRAgent"
self.instructions = instructions
@@ -1,211 +0,0 @@
"""One vocabulary for "where does this run?", shared by Agent, AgentFlow and AgentTeam.
Two questions get asked about placement and they have different answers:
``run_on=``
Where the **whole agent** runs -- the model calls, the loop, the tools, all
of it on someone else's machine. Only a *managed runtime* can answer this,
because running the loop remotely means something has to host the loop.
``tools_run_on=``
Where **the tools** run. Thinking stays on this machine; only the shell,
the file writes and the code execution move. Any sandbox or compute place
can answer this, because all it has to do is run a command.
Keeping the two words apart is the entire point. The old surface used one word
(``run_on=``) on ``AgentFlow``/``AgentTeam`` for the tools-only meaning, another
(``compute=``) on ``LocalAgent`` for exactly the same meaning, and a third
(``backend=``) for the whole-agent meaning -- so the same concept had two names
and one name had two concepts.
Because the two questions have disjoint answer sets, a wrong pairing is a typo
worth catching rather than a preference worth honouring. ``run_on="docker"``
cannot mean anything: Docker does not host agent loops. It gets a ``TypeError``
naming the parameter the user actually wanted, not a silent fallback.
"""
from __future__ import annotations
from typing import Any, Literal, NamedTuple, Optional, Union
# Runtimes that can host an entire agent loop. Resolved from the wrapper's
# entry-point registry when it is installed so third-party backends work with
# `run_on=` too; the literal is the floor, not the ceiling.
_FALLBACK_MANAGED = ("anthropic",)
# Static spellings so editors can autocomplete and type-checkers can reject a
# typo at the call site rather than at the first model turn. The runtime
# functions below stay the authority for validation -- these exist for tooling,
# which cannot call a function to learn a set.
# Keep in sync with _compute_bridge._PROVIDERS | _sandbox_adapter.SANDBOX_ONLY.
ManagedRuntime = Literal["anthropic"]
ToolPlace = Literal[
"local", "subprocess", "sandlock", "docker", "e2b",
"modal", "daytona", "flyio", "tenki", "novita", "ssh",
]
def managed_runtimes() -> tuple:
"""Names that ``run_on=`` accepts -- places that host a whole agent."""
try:
from praisonai.integrations.backend_registry import get_backend_registry
names = tuple(get_backend_registry().list_all_names())
if names:
return tuple(sorted(set(names) | set(_FALLBACK_MANAGED)))
except Exception:
pass
return _FALLBACK_MANAGED
def tool_places() -> list:
"""Names that ``tools_run_on=`` accepts -- places that can run a command.
Includes the resolver's aliases. Validation runs before resolution, so
leaving them out rejected spellings the resolver handles happily:
``tools_run_on="native"`` raised "not a known place" while
``resolve_compute("native")`` returned a working sandlock adapter.
"""
try:
from ..managed._compute_bridge import _ALIASES, available_providers
return sorted(set(available_providers()) | set(_ALIASES))
except Exception:
return ["local", "docker"]
class Placement(NamedTuple):
"""The resolved answer to "where does this run?"."""
whole: Optional[Any] = None # managed runtime name/object, or None
tools: Optional[Any] = None # tools place name/object, or None
def _name_of(value: Any) -> Optional[str]:
"""The comparable name of a placement value, or None if it is an object.
Objects (a built provider, a live ``SSHSandbox``) carry their own config and
are never second-guessed -- only bare strings get routed by name.
"""
return value.lower() if isinstance(value, str) else None
def _quote_list(names) -> str:
return ", ".join(repr(n) for n in names)
def resolve_placement(
owner: str,
*,
run_on: Any = None,
tools_run_on: Any = None,
backend: Any = None,
supports_run_on: bool = True,
) -> Placement:
"""Validate placement arguments and return where things should run.
``owner`` is the class name, used only to make errors read naturally.
``supports_run_on`` is False for AgentFlow/AgentTeam, which can place tools
but cannot (yet) hand a whole multi-agent run to a managed runtime.
Raises:
TypeError: on any combination that names two different places for the
same thing, or a place that cannot do the job asked of it.
"""
managed = managed_runtimes()
places = tool_places()
# ── a place that cannot do the job asked of it ───────────────────────────
run_on_name = _name_of(run_on)
if run_on_name is not None and run_on_name not in managed:
if run_on_name in places:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not valid: run_on= places the "
f"whole agent -- model calls, loop and tools -- on a managed "
f"runtime, and {run_on_name!r} runs commands but cannot host an "
f"agent loop.\n"
f" To run only the tools there: {owner}(tools_run_on={run_on!r})\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}"
)
raise TypeError(
f"{owner}(run_on={run_on!r}) is not a known managed runtime.\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}\n"
f" Places that can run tools: {_quote_list(places)} "
f"(pass those as tools_run_on=)"
)
tools_name = _name_of(tools_run_on)
if tools_name is not None and tools_name in managed and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
f"hosts an entire agent, not individual tools.\n"
f" Did you mean: {owner}(run_on={tools_run_on!r})"
)
# A typo here used to survive construction and only surface on the first
# model turn -- after the prompt was built and, for a real run, after the
# API spend. run_on= validates at the call site and YAML validates at load
# time; this is the same standard for the third spelling.
if tools_name is not None and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not a known place.\n"
f" Places that can run tools: {_quote_list(places)}\n"
f" Runtimes that host a whole agent: {_quote_list(managed)} "
f"(pass those as run_on=)"
)
# Names that cannot work as a bare string, because the connection details
# live in the object. Catch it now rather than at first use.
if tools_name is not None:
try:
from ..managed._sandbox_adapter import NEEDS_INSTANCE
except Exception:
NEEDS_INSTANCE = {}
if tools_name in NEEDS_INSTANCE:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) needs more than a name: "
f"{NEEDS_INSTANCE[tools_name]}"
)
# ── two names for the same thing ─────────────────────────────────────────
if run_on is not None and backend is not None:
raise TypeError(
f"{owner}(run_on=..., backend=...) sets the agent's runtime twice. "
f"run_on={run_on!r} is the short form of backend=HostedAgent("
f"provider={run_on!r}); pass one or the other.\n"
f" Use backend= only when you need to configure the runtime "
f"(model, system prompt, tools)."
)
if run_on is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(run_on={run_on!r}, tools_run_on={tools_run_on!r}) points "
f"the tools at two machines. run_on= already runs everything -- "
f"including tools -- on {run_on!r}.\n"
f" Whole agent remote: {owner}(run_on={run_on!r})\n"
f" Only tools remote: {owner}(tools_run_on={tools_run_on!r})"
)
if backend is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(backend=..., tools_run_on={tools_run_on!r}) points the "
f"tools at two machines: backend= runs the whole agent (tools "
f"included) on its managed runtime, so the tools cannot also run on "
f"{tools_run_on!r}.\n"
f" Drop tools_run_on= to keep the hosted runtime, or drop backend= "
f"to keep local thinking with remote tools."
)
# ── a class that cannot answer this question ─────────────────────────────
if run_on is not None and not supports_run_on:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not supported: run_on= hands one "
f"agent's whole loop to a managed runtime, and a {owner} "
f"orchestrates several agents locally.\n"
f" To run every step's tools in one shared sandbox:\n"
f" {owner}(tools_run_on={run_on!r})\n"
f" To host a single agent's loop, put run_on= on that Agent."
)
return Placement(whole=run_on, tools=tools_run_on)
@@ -1,138 +0,0 @@
"""Prompt-cache-stability contract for the Agent turn (Issue #3352).
Provider prompt caching (Anthropic/OpenAI) only hits when the *leading* bytes
of a request are byte-identical to the previous request. For a long-lived
conversation the cached prefix — the system instructions, the serialised tool
schemas, and the model identity — is the dominant cost and latency lever.
Gateways run many users on a single shared ``Agent`` instance and swap
``tools``/``llm`` in and out per turn (per-route tool scoping, per-user
``/model`` override). Those swaps silently change the cached prefix and the
provider cache misses with zero operator visibility.
``prompt_prefix_signature`` gives callers a single, cheap, deterministic
fingerprint of exactly the cache-relevant inputs — model, the sorted set of
tool names, and a fingerprint of the system instructions — and *nothing*
volatile per-turn (arrival time, routing facts, memory/RAG recall). When the
signature is unchanged across turns the prefix is guaranteed stable, so the
provider cache keeps hitting; when it changes, that is the one auditable point
at which cache warmth is knowingly sacrificed.
"""
from __future__ import annotations
import hashlib
from typing import Any
def _tool_name(tool: Any) -> str:
"""Best-effort stable name for a tool entry.
Tools may be plain callables, dicts holding an OpenAI-style function
schema, or objects exposing ``__name__``/``name``. The name identifies the
schema slot in the serialised tool block.
"""
if isinstance(tool, dict):
fn = tool.get("function")
if isinstance(fn, dict) and fn.get("name"):
return str(fn["name"])
for key in ("name", "type"):
if tool.get(key):
return str(tool[key])
return str(sorted(tool.keys()))
for attr in ("__name__", "name"):
value = getattr(tool, attr, None)
if value:
return str(value)
return str(tool)
def _tool_fingerprint(tool: Any) -> str:
"""Best-effort stable fingerprint of a tool's *provider-visible* schema.
The serialised tool block sent to the provider is not just the tool name —
it is the full function schema (name + description + parameters). A change
to a tool's description, parameters, or required fields *without* a rename
changes those bytes and therefore invalidates the prompt cache, so the
fingerprint must reflect the whole schema, not only the name (Issue #3352
review). Falls back to the name when no richer schema is exposed.
"""
name = _tool_name(tool)
# Dict tools already carry an OpenAI-style function schema — fingerprint the
# description + parameters alongside the name so a same-name schema edit is
# detected. ``json.dumps(sort_keys=True)`` makes the render deterministic.
if isinstance(tool, dict):
fn = tool.get("function")
schema = fn if isinstance(fn, dict) else tool
desc = schema.get("description") if isinstance(schema, dict) else None
params = schema.get("parameters") if isinstance(schema, dict) else None
try:
import json
return "\x01".join(
(
name,
str(desc or ""),
json.dumps(params or {}, sort_keys=True, default=str),
)
)
except Exception: # pragma: no cover — defensive
return name
# Callable/object tools: fold in the docstring (the description the schema
# generator emits) so an edited tool doc flips the signature too.
doc = getattr(tool, "__doc__", None)
if doc:
return f"{name}\x01{doc}"
return name
def prompt_prefix_signature(agent: Any) -> str:
"""Return a sha256 signature of an agent's cache-relevant prompt prefix.
The signature is computed over, and only over:
* the model identity (``agent.llm``),
* the sorted set of tool *schema fingerprints* (name + description +
parameters, ``agent.tools``) — so a same-name schema edit is detected,
* a fingerprint of the system instructions (``agent.instructions``).
Volatile per-turn data is intentionally excluded so that an unchanged
route/model keeps a byte-identical prefix and the provider cache keeps
hitting. Callers compare the signature turn-over-turn: an unchanged value
means the cached prefix is reused; a changed value is the single auditable
point of prompt-cache invalidation.
Best-effort and never raises: any attribute access failure degrades the
corresponding component to an empty string rather than breaking the turn.
"""
try:
llm = getattr(agent, "llm", None)
model = llm if isinstance(llm, str) else getattr(llm, "model", None) or str(llm)
except Exception:
model = ""
try:
tools = getattr(agent, "tools", None) or []
tool_fps = sorted(_tool_fingerprint(t) for t in tools)
except Exception:
tool_fps = []
try:
instructions = getattr(agent, "instructions", None) or ""
instructions = str(instructions)
except Exception:
instructions = ""
hasher = hashlib.sha256()
hasher.update(b"model\x00")
hasher.update(str(model).encode("utf-8", "replace"))
hasher.update(b"\x00tools\x00")
hasher.update("\x00".join(tool_fps).encode("utf-8", "replace"))
hasher.update(b"\x00instructions\x00")
hasher.update(instructions.encode("utf-8", "replace"))
return hasher.hexdigest()
__all__ = ["prompt_prefix_signature"]
@@ -57,8 +57,7 @@ class RealtimeAgent:
def __init__(
self,
name: str = "RealtimeAgent",
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None,
llm: Optional[str] = None,
realtime: Optional[Union[bool, Dict, RealtimeConfig]] = None,
instructions: Optional[str] = None,
verbose: bool = True,
@@ -68,16 +67,14 @@ class RealtimeAgent:
Args:
name: Agent name
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Model to use (default: gpt-4o-realtime-preview)
llm: LLM model (default: gpt-4o-realtime-preview)
realtime: Realtime configuration (bool, dict, or RealtimeConfig)
instructions: System instructions
verbose: Enable verbose output
**kwargs: Additional arguments
"""
from ..utils.model_alias import resolve_model_name
self.name = name
self.llm = resolve_model_name(llm, model, "RealtimeAgent") or "gpt-4o-realtime-preview"
self.llm = llm or "gpt-4o-realtime-preview"
self.instructions = instructions
self.verbose = verbose
@@ -6,6 +6,7 @@ model selection based on task characteristics.
"""
import os
import logging
from praisonaiagents._logging import get_logger
from typing import Dict, List, Optional, Any, Union
from .agent import Agent
@@ -1,95 +0,0 @@
"""Canonical terminal-outcome contract for agent runs.
A single, closed description of *how* a run ended, produced once by the core
run and consumed everywhere. Long-running hosts (e.g. gateways/bots) can make
delivery/retry/DLQ/status decisions from one field instead of inferring the
terminal state from exception identity.
Precedence is explicit and sticky: a ``hard_timeout`` is never silently
downgraded by later cleanup. See ``RunOutcome.from_exception``.
"""
from dataclasses import dataclass
from typing import Optional
try:
from typing import Literal
except ImportError: # pragma: no cover - py<3.8 fallback
Literal = None # type: ignore
if Literal is not None:
TerminalReason = Literal[
"completed", "hard_timeout", "cancelled", "aborted", "failed"
]
else: # pragma: no cover
TerminalReason = str # type: ignore
# Precedence: higher wins and is sticky (a hard timeout is not downgraded).
_REASON_PRECEDENCE = {
"completed": 0,
"failed": 1,
"aborted": 2,
"cancelled": 3,
"hard_timeout": 4,
}
@dataclass(frozen=True)
class RunOutcome:
"""Closed, canonical description of how an agent run ended.
Attributes:
reason: One of ``completed | hard_timeout | cancelled | aborted | failed``.
output: Partial or final text, if any.
error: Redacted message when ``reason == "failed"``.
"""
reason: "TerminalReason"
output: Optional[str] = None
error: Optional[str] = None
@property
def succeeded(self) -> bool:
"""True only when the run completed normally."""
return self.reason == "completed"
@classmethod
def completed(cls, output: Optional[str] = None) -> "RunOutcome":
return cls(reason="completed", output=output)
@classmethod
def from_exception(
cls, exc: BaseException, output: Optional[str] = None
) -> "RunOutcome":
"""Normalise a raised exception into a canonical terminal outcome.
Maps well-known exception types to their terminal reason. Anything
unrecognised is treated as ``failed`` with a redacted message.
Note on ``hard_timeout``: the run-level budget is the authoritative
source of ``hard_timeout`` and is applied explicitly by the caller
(see ``_astart_with_outcome``). A bare ``asyncio.TimeoutError`` reaching
this normaliser is a *nested* operation timeout (e.g. a handoff/tool that
exhausted its own budget while the run budget remained) and is therefore
classified as ``failed`` — not silently promoted to a run-budget
``hard_timeout``, which would drive the wrong host retry/DLQ decision.
Real cooperative cancellation is intercepted and re-raised by the run
wrapper (see ``_astart_with_outcome``) *before* it reaches this
normaliser, so host shutdown is honoured. A supersede/interrupt named
error that does surface here is a domain "cancelled" outcome.
"""
import asyncio
if _name_matches(exc, ("hardtimeout", "runtimeout", "budgettimeout")):
return cls(reason="hard_timeout", output=output)
if isinstance(exc, asyncio.CancelledError) or _name_matches(
exc, ("supersed", "interrupt", "cancelled")
):
return cls(reason="cancelled", output=output)
if _name_matches(exc, ("abort", "drain", "shutdown")):
return cls(reason="aborted", output=output)
return cls(reason="failed", output=output, error=str(exc))
def _name_matches(exc: BaseException, needles: tuple) -> bool:
name = type(exc).__name__.lower()
return any(n in name for n in needles)
@@ -52,61 +52,19 @@ class SandboxMixin:
return self._sandbox_manager
def _manager_for(self, run_in: Optional[Union[bool, str, 'SandboxConfig']]):
"""Return the sandbox manager for a ``run_in=`` argument.
``run_in=`` names where a single ``execute_code()`` call runs, so the
place lives on the call that uses it rather than on the constructor.
``Agent(sandbox=...)`` still sets the default for every call.
Managers are cached per place: a fresh ``SandboxManager`` per call would
start and tear down a container each time, which for Docker is seconds
of latency and for any backend means the previous call's files are gone.
"""
if run_in is None:
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Either name a place on the call --\n"
" agent.execute_code_sync(code, run_in='docker')\n"
"or set a default for every call with Agent(sandbox='docker')."
)
return self.get_sandbox_manager()
from ..sandbox import SandboxConfig, SandboxManager
if run_in is True:
config = SandboxConfig.subprocess()
elif isinstance(run_in, str):
config = SandboxConfig(sandbox_type=run_in)
else:
config = run_in
key = getattr(config, "sandbox_type", None) or repr(config)
cache = getattr(self, "_sandbox_managers", None)
if cache is None:
cache = self._sandbox_managers = {}
if key not in cache:
cache[key] = SandboxManager(config)
return cache[key]
async def execute_code(
self,
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Execute code safely in a sandbox.
"""Execute code safely in configured sandbox.
Args:
code: Code to execute
language: Programming language (python, bash, etc.)
check_security: Whether to run security pre-checks
run_in: Where to run this call -- "docker", "sandlock",
"subprocess", a SandboxConfig, or True for the default local
sandbox. Overrides Agent(sandbox=...) for this call only.
**kwargs: Additional arguments passed to sandbox.execute()
Returns:
@@ -115,39 +73,31 @@ class SandboxMixin:
Raises:
RuntimeError: If no sandbox is configured
"""
manager = self._manager_for(run_in)
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
)
# Security pre-check if enabled
security_warnings = None
if check_security:
from ..sandbox import check_code_safety, format_warnings
security_warnings = check_code_safety(code, language)
if security_warnings:
warning_text = format_warnings(security_warnings)
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
if self.verbose:
logger.warning(f"Security warnings for code execution:\n{warning_text}")
result = await manager.run_code(code, language=language, **kwargs)
# Attach warnings to the result rather than forwarding a `metadata`
# kwarg: SandboxProtocol.execute() has no such parameter, so passing it
# through raised TypeError for any code that tripped a warning -- i.e.
# sandboxed execution failed exactly when the check mattered most.
if security_warnings:
try:
existing = getattr(result, "metadata", None) or {}
existing["security_warnings"] = security_warnings
result.metadata = existing
except Exception: # pragma: no cover - result may be immutable
logger.debug("Could not attach security warnings to sandbox result")
return result
# Store warnings in result metadata
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
manager = self.get_sandbox_manager()
return await manager.run_code(code, language=language, **kwargs)
def execute_code_sync(
self,
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Synchronous wrapper for execute_code.
@@ -162,15 +112,12 @@ class SandboxMixin:
SandboxResult with execution details
"""
from ..approval.utils import run_coroutine_safely
return run_coroutine_safely(
self.execute_code(code, language, check_security, run_in=run_in, **kwargs)
)
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
async def run_shell_command(
self,
command: Union[str, list],
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Run a shell command in the sandbox.
@@ -183,7 +130,8 @@ class SandboxMixin:
Returns:
SandboxResult with execution details
"""
manager = self._manager_for(run_in)
if not self.has_sandbox:
raise RuntimeError("No sandbox configured")
# Convert command to string for security checking
if isinstance(command, list):
@@ -200,7 +148,7 @@ class SandboxMixin:
if self.verbose:
logger.warning(f"Security warnings for command:\n{warning_text}")
async with manager as sandbox:
async with self.get_sandbox_manager() as sandbox:
return await sandbox.run_command(command, **kwargs)
def get_sandbox_status(self) -> Dict[str, Any]:
@@ -282,15 +230,5 @@ class SandboxMixin:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
# Tag as framework-generated so clone_for_channel() can drop these
# source-bound wrappers and regenerate clone-owned ones. Without the
# tag, a clone would inherit tools closing over the *source* agent and
# silently share its SandboxManager.
generated = [execute_python_code, execute_shell_command]
for _t in generated:
try:
_t._praison_sandbox_tool = True
except Exception: # pragma: no cover - tool object may be immutable
pass
return generated
return [execute_python_code, execute_shell_command]
@@ -52,29 +52,14 @@ class SkillReviewMixin:
return policy
def _build_skill_review_tool(self):
"""Return the restricted toolset for the review turn.
Always exposes the single skill-mutation tool (``skill_manage``). When
the agent has memory enabled, ``store_memory`` is added so the same
guarded pass can also persist a durable fact / user-model detail
(issue #4030) — closing the learning loop for memory, not just skills,
without a second review turn or a parallel knob. The set stays
restricted: never the agent's full toolset.
"""
tools = []
"""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()
tools.append(skill_tools.skill_manage)
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)
if getattr(self, "memory", None):
try:
from ..tools.memory import store_memory
tools.append(store_memory)
except Exception as e: # pragma: no cover - defensive
logger.debug("Could not build memory review tool: %s", e, exc_info=True)
return tools
return []
def _skill_review_log_context(self):
"""Return (agent_name, session_id) for swallowed-failure warnings."""
@@ -5,6 +5,7 @@ Provides automatic summarization when context window fills up.
Reuses existing telemetry/token_collector for token tracking.
"""
import logging
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any
File diff suppressed because it is too large. Load diff
@@ -1,129 +0,0 @@
"""Give a single Agent's tools a sandbox, without touching its thinking.
``AgentFlow``/``AgentTeam`` already share one sandbox across a run and tear it
down when the run ends. A lone ``Agent(tools_run_on=...)`` has no "run" to hang
that lifetime on -- it is just an object someone calls ``chat()`` on, possibly
many times -- so the sandbox is created on first use and lives as long as the
agent does. That is deliberate: a file written in turn 1 is still there in turn
2, which is what anyone who asked for a working directory expects.
Attachment is idempotent and happens at the top of ``chat``/``achat``/
``_start_stream``, and in ``AgentTeam`` before the task's tool list is
snapshotted. Doing it once, rather than wrapping each call in a context
manager, sidesteps two real hazards in this codebase:
* ``_start_stream`` is a **generator**; a context manager around the call would
exit before the caller consumed a single token, tearing down the sandbox while
the model was still using it.
* ``_start_stream`` falls back to ``self.chat``, so a wrapper would **re-enter**
itself and provision a second sandbox.
Lifetime is tied to the agent by ``weakref.finalize`` rather than a process-exit
hook over a weak set. The weak set leaked: an agent and its ``SharedCompute``
reference each other, so a dropped agent was reclaimed by the cycle collector,
vanished from the set, and its sandbox was never shut down. Gateways clone an
agent **per request**, so that was one orphaned container per request.
"""
from __future__ import annotations
import logging
import threading
import weakref
from typing import Any
logger = logging.getLogger(__name__)
# One lock per agent, so two unrelated agents never wait on each other and no
# lock is ever held while another agent's provider is being imported. Held only
# by ensure_tools_placed/close_tools_sandbox for that one agent.
_AGENT_LOCKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
_LOCKS_GUARD = threading.Lock()
def _lock_for(agent: Any) -> threading.Lock:
"""The placement lock for ``agent``, created on demand.
``_LOCKS_GUARD`` covers only this dictionary lookup -- microseconds -- never
provider construction or attachment. An earlier version held one global lock
across ``SharedCompute(...)``, which imports the provider SDK: unrelated
agents serialised behind each other, and a slow import could stall
interpreter exit.
"""
with _LOCKS_GUARD:
lock = _AGENT_LOCKS.get(agent)
if lock is None:
lock = threading.Lock()
try:
_AGENT_LOCKS[agent] = lock
except TypeError: # pragma: no cover - unhashable/non-weakrefable
pass
return lock
def _shutdown_orphan(shared: Any, name: str) -> None:
"""Tear down a sandbox whose agent is gone.
Registered with ``weakref.finalize``, so it runs when the agent is collected
*and* at interpreter exit. It must not close over the agent -- that would
keep it alive and defeat the whole point.
"""
try:
shared.shutdown()
logger.debug("[tools_placement] released the sandbox belonging to %s", name)
except Exception as exc: # pragma: no cover - teardown must stay quiet
# Loud enough to notice: a failed release means a container, or a
# billed cloud instance, is still running after the agent is gone.
logger.warning(
"[tools_placement] could not release the sandbox for %s: %s", name, exc
)
def ensure_tools_placed(agent: Any) -> None:
"""Attach the sandbox tools to ``agent`` if it asked for them. Idempotent.
Called at the top of every entry point, so it must be cheap when there is
nothing to do -- which is the overwhelmingly common case.
"""
place = getattr(agent, "tools_run_on", None)
if place is None or getattr(agent, "_tools_sandbox", None) is not None:
return
from ..managed.shared_compute import SharedCompute
with _lock_for(agent):
# Re-check under the lock: two threads calling chat() at once must not
# each provision a sandbox and leak one.
if getattr(agent, "_tools_sandbox", None) is not None:
return
shared = SharedCompute(place)
# override_declared: this sandbox exists *because* the agent named a
# place, so the skip-if-declared rule must not skip the agent itself.
shared.attach([agent], override_declared=True)
agent._tools_sandbox = shared
agent._tools_sandbox_finalizer = weakref.finalize(
agent, _shutdown_orphan, shared, str(getattr(agent, "name", agent))
)
logger.info("[tools_placement] %r tools now run on %s", getattr(agent, "name", agent), place)
def close_tools_sandbox(agent: Any) -> None:
"""Tear down the agent's sandbox and put its original tools back."""
with _lock_for(agent):
# Everything happens under the lock. Clearing the marker first and
# shutting down afterwards left a window where a concurrent chat()
# re-attached and the in-flight shutdown then restored over it, leaving
# the agent marked as sandboxed while holding no sandbox tools -- a
# state ensure_tools_placed() could never repair, because its idempotent
# early return sees the marker and does nothing.
shared = getattr(agent, "_tools_sandbox", None)
if shared is None:
return
finalizer = getattr(agent, "_tools_sandbox_finalizer", None)
if finalizer is not None:
finalizer.detach() # we are shutting down explicitly
agent._tools_sandbox_finalizer = None
shared.shutdown()
agent._tools_sandbox = None
@@ -282,7 +282,7 @@ class UnifiedExecutionMixin:
task_id=task_id,
config=config,
tool_choice=tool_choice,
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
)
else:
# Standard LiteLLM path - delegate to existing LLM class
@@ -304,7 +304,7 @@ class UnifiedExecutionMixin:
task_id=task_id,
config=config,
tool_choice=tool_choice,
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
)
# Store response in memory if enabled
@@ -158,8 +158,9 @@ class VideoAgent:
verbose: Verbosity level for output
output: Output preset ("silent", "verbose", etc.)
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "VideoAgent")
# Handle model= alias for llm=
if llm is None and model is not None:
llm = model
# Store core identity
self.name = name or "VideoAgent"
@@ -112,8 +112,8 @@ class VisionAgent:
- VisionConfig: Full configuration object
verbose: Verbosity level for output
"""
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "VisionAgent")
if llm is None and model is not None:
llm = model
self.name = name or "VisionAgent"
self.instructions = instructions
File diff suppressed because it is too large. Load diff
@@ -17,6 +17,7 @@ Usage:
result = auto_rag.chat("Hello!") # Skips retrieval
"""
import logging
from praisonaiagents._logging import get_logger
import time
from dataclasses import dataclass, field
@@ -5,6 +5,7 @@ Provides delegation primitives for spawning subagents with scoped permissions
and context. Inspired by Gemini CLI's delegate-to-agent pattern.
"""
import logging
from praisonaiagents._logging import get_logger
import asyncio
import warnings
@@ -140,7 +141,6 @@ class SubagentDelegator:
# State
self._tasks: Dict[str, DelegationTask] = {}
self._running_asyncio_tasks: Dict[str, "asyncio.Task"] = {}
self._running_count: int = 0
self._total_count: int = 0
self._task_counter: int = 0
@@ -221,37 +221,9 @@ class SubagentDelegator:
self._tasks[task_id] = task
self._total_count += 1
# Execute with concurrency control. Wrap in a real asyncio.Task so
# cancel_task/cancel_all can actually propagate cancellation to the
# awaited subagent coroutine instead of only flipping a status enum.
# Execute with concurrency control
async with self._get_semaphore():
# If cancelled while queued on the semaphore, don't start the work.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
exec_task = asyncio.ensure_future(self._execute_task(task))
self._running_asyncio_tasks[task_id] = exec_task
try:
return await exec_task
except asyncio.CancelledError:
# Distinguish a delegator-initiated cancel (via cancel_task/cancel_all,
# which sets status=CANCELLED) from an external/cooperative cancel of
# the caller. Only the former is swallowed into a result; an external
# cancel must propagate so wait_for/shutdown behave correctly.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
raise
finally:
self._running_asyncio_tasks.pop(task_id, None)
return await self._execute_task(task)
async def delegate_parallel(
self,
@@ -311,25 +283,14 @@ class SubagentDelegator:
True if task was cancelled
"""
task = self._tasks.get(task_id)
# Only terminal tasks are un-cancellable; PENDING/RUNNING can be cancelled.
_terminal = (
DelegationStatus.COMPLETED,
DelegationStatus.FAILED,
DelegationStatus.CANCELLED,
DelegationStatus.TIMEOUT,
)
if not task or task.status in _terminal:
if not task:
return False
# Mark cancelled first so a PENDING task (still waiting on the semaphore)
# or a RUNNING coroutine both observe CANCELLED and refuse to complete.
task.status = DelegationStatus.CANCELLED
if task.status == DelegationStatus.RUNNING:
task.status = DelegationStatus.CANCELLED
return True
# Propagate cancellation to the actual coroutine if it is already running.
asyncio_task = self._running_asyncio_tasks.get(task_id)
if asyncio_task is not None:
asyncio_task.cancel()
return True
return False
async def cancel_all(self) -> int:
"""
@@ -339,8 +300,9 @@ class SubagentDelegator:
Number of tasks cancelled
"""
cancelled = 0
for task_id in list(self._tasks.keys()):
if await self.cancel_task(task_id):
for task in self._tasks.values():
if task.status == DelegationStatus.RUNNING:
task.status = DelegationStatus.CANCELLED
cancelled += 1
return cancelled
@@ -376,15 +338,6 @@ class SubagentDelegator:
import time
start_time = time.time()
# A cancel may have landed between scheduling and execution; honour it.
if task.status == DelegationStatus.CANCELLED:
return DelegationResult(
task_id=task.task_id,
agent_name=task.agent_name,
success=False,
error="Task was cancelled",
)
task.status = DelegationStatus.RUNNING
self._running_count += 1
@@ -404,9 +357,7 @@ class SubagentDelegator:
timeout=task.timeout_seconds,
)
task.result = result
# Don't clobber a cancellation that landed while we were running.
if task.status != DelegationStatus.CANCELLED:
task.status = DelegationStatus.COMPLETED
task.status = DelegationStatus.COMPLETED
except asyncio.TimeoutError:
task.status = DelegationStatus.TIMEOUT
@@ -31,7 +31,6 @@ from __future__ import annotations
import asyncio
import contextvars
import inspect
import json
import logging
from praisonaiagents._logging import get_logger
@@ -97,19 +96,11 @@ def get_approval_callback() -> Optional[Callable]:
"""Get the current approval callback function (legacy API)."""
return approval_callback
def mark_approved(
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
) -> None:
get_approval_registry().mark_approved(tool_name, arguments, agent_name)
def mark_approved(tool_name: str) -> None:
get_approval_registry().mark_approved(tool_name)
def is_already_approved(
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
) -> bool:
return get_approval_registry().is_already_approved(tool_name, arguments, agent_name)
def is_already_approved(tool_name: str) -> bool:
return get_approval_registry().is_already_approved(tool_name)
def is_yaml_approved(tool_name: str) -> bool:
return get_approval_registry().is_yaml_approved(tool_name)
@@ -175,29 +166,6 @@ async def request_approval(function_name: str, arguments: Dict) -> ApprovalDecis
RiskLevel = Literal["critical", "high", "medium", "low"]
def _bind_call_args(func: Callable, args: tuple, kwargs: Dict) -> Dict:
"""Normalise a call's positional + keyword args into a single dict.
The approval cache is keyed by the tool's arguments, so a directly-invoked
``@require_approval`` tool must reduce positional args to the same named
form the cache uses — otherwise ``read_file("/a")`` and ``read_file("/b")``
would share the empty-argument key and one approval would unlock the other.
Falls back to a shallow merge of ``kwargs`` plus an ``__args__`` tuple when
the signature cannot be bound (e.g. C-implemented callables), so distinct
positional calls still produce distinct keys instead of collapsing.
"""
try:
bound = inspect.signature(func).bind_partial(*args, **kwargs)
return dict(bound.arguments)
except (TypeError, ValueError):
merged = dict(kwargs)
if args:
merged["__args__"] = args
return merged
def require_approval(risk_level: RiskLevel = "high"):
"""Decorator to mark a tool as requiring human approval."""
def decorator(func):
@@ -209,18 +177,13 @@ def require_approval(risk_level: RiskLevel = "high"):
@wraps(func)
def wrapper(*args, **kwargs):
# Bind positional args into a normalized dict so the approval key
# reflects the *actual* call. Without this, positional-only calls
# (e.g. read_file("/etc/passwd")) would all collapse to the same
# empty-argument key, letting one approval unlock any later value.
approval_args = _bind_call_args(func, args, kwargs)
if is_already_approved(tool_name, approval_args):
if is_already_approved(tool_name):
return func(*args, **kwargs)
if is_yaml_approved(tool_name):
mark_approved(tool_name, approval_args)
mark_approved(tool_name)
return func(*args, **kwargs)
if is_env_auto_approve():
mark_approved(tool_name, approval_args)
mark_approved(tool_name)
return func(*args, **kwargs)
try:
from ..utils.async_bridge import is_async_context, run_coroutine_from_any_context
@@ -233,35 +196,32 @@ def require_approval(risk_level: RiskLevel = "high"):
)
else:
# Safe to run async approval using registry
decision = run_coroutine_from_any_context(request_approval(tool_name, approval_args))
decision = run_coroutine_from_any_context(request_approval(tool_name, kwargs))
except Exception as e:
logging.warning(f"Approval request failed: {e}", exc_info=True)
# Fail closed - do not fall back to console
raise PermissionError(f"Approval request failed for {tool_name}: {e}") from e
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
mark_approved(tool_name)
kwargs.update(decision.modified_args)
approval_args.update(decision.modified_args)
mark_approved(tool_name, approval_args)
return func(*args, **kwargs)
@wraps(func)
async def async_wrapper(*args, **kwargs):
approval_args = _bind_call_args(func, args, kwargs)
if is_already_approved(tool_name, approval_args):
if is_already_approved(tool_name):
return await func(*args, **kwargs)
if is_yaml_approved(tool_name):
mark_approved(tool_name, approval_args)
mark_approved(tool_name)
return await func(*args, **kwargs)
if is_env_auto_approve():
mark_approved(tool_name, approval_args)
mark_approved(tool_name)
return await func(*args, **kwargs)
decision = await request_approval(tool_name, approval_args)
decision = await request_approval(tool_name, kwargs)
if not decision.approved:
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
mark_approved(tool_name)
kwargs.update(decision.modified_args)
approval_args.update(decision.modified_args)
mark_approved(tool_name, approval_args)
return await func(*args, **kwargs)
if asyncio.iscoroutinefunction(func):
@@ -11,6 +11,7 @@ Provides lightweight backends that ship with the core SDK:
from __future__ import annotations
import asyncio
import logging
from praisonaiagents._logging import get_logger
from typing import Any
@@ -81,14 +82,12 @@ class ConsoleBackend:
"""Interactive Rich terminal prompt. Default for CLI usage."""
def _prompt_user(self, request: ApprovalRequest):
"""Show Rich panel and ask once/session/always/no/deny-with-guidance.
"""Show Rich panel and ask once/session/always/no.
Returns a ``(approved, scope, scope_pattern, feedback)`` tuple where
``scope`` is one of ``"once"`` / ``"session"`` / ``"always"`` and
``scope_pattern`` is the reusable target to persist for ``always``
(``None`` otherwise). ``feedback`` carries optional free-text guidance
captured by the "deny & redirect" choice (``None`` otherwise).
A plain denial returns ``(False, "once", None, None)``.
Returns a ``(approved, scope, scope_pattern)`` tuple where ``scope`` is
one of ``"once"`` / ``"session"`` / ``"always"`` and ``scope_pattern``
is the reusable target to persist for ``always`` (``None`` otherwise).
A denial returns ``(False, "once", None)``.
"""
Console = _get_rich_console()
Panel = _get_rich_panel()
@@ -128,35 +127,28 @@ class ConsoleBackend:
try:
console.print(
f"[{risk_color}]Allow {request.tool_name}?[/{risk_color}] "
f"[o] once [s] this session [a] {always_label} "
f"[n] no [d] deny & redirect"
f"[o] once [s] this session [a] {always_label} [n] no"
)
choice = Prompt.ask(
"Choice",
choices=["o", "s", "a", "n", "d"],
choices=["o", "s", "a", "n"],
default="n",
)
except (KeyboardInterrupt, EOFError):
return (False, "once", None, None)
return (False, "once", None)
if choice == "n":
return (False, "once", None, None)
if choice == "d":
try:
feedback = Prompt.ask("What should the agent do instead?").strip()
except (KeyboardInterrupt, EOFError):
feedback = ""
return (False, "once", None, feedback or None)
return (False, "once", None)
if choice == "s":
return (True, "session", None, None)
return (True, "session", None)
if choice == "a":
return (True, "always", suggested or None, None)
return (True, "once", None, None)
return (True, "always", suggested or None)
return (True, "once", None)
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
"""Synchronous approval via Rich console prompt."""
try:
approved, scope, scope_pattern, feedback = self._prompt_user(request)
approved, scope, scope_pattern = self._prompt_user(request)
if approved:
return ApprovalDecision(
approved=True,
@@ -165,13 +157,7 @@ class ConsoleBackend:
scope=scope,
scope_pattern=scope_pattern,
)
reason = f"User denied: {feedback}" if feedback else "User denied"
return ApprovalDecision(
approved=False,
reason=reason,
approver="console",
feedback=feedback,
)
return ApprovalDecision(approved=False, reason="User denied", approver="console")
except Exception as e:
logger.error("Console approval error: %s", e)
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
@@ -65,12 +65,6 @@ class ApprovalDecision:
``session``/``always`` scopes (e.g. ``"bash:git status *"``
or ``"edit:src/app.py"``). When ``None`` the registry
derives one from the tool call.
feedback: Optional free-text guidance supplied by the user when
denying a tool call ("deny with guidance"). When present
on a denial it is surfaced back to the model as a
course-correction for the next turn instead of a bare
"denied" result. ``None`` (default) keeps today's
terminal-deny semantics.
"""
approved: bool
@@ -80,7 +74,6 @@ class ApprovalDecision:
metadata: Dict[str, Any] = field(default_factory=dict)
scope: str = "once"
scope_pattern: Optional[str] = None
feedback: Optional[str] = None
@dataclass
@@ -18,6 +18,9 @@ from __future__ import annotations
import asyncio
import contextvars
import hashlib
import json
import logging
from praisonaiagents._logging import get_logger
import os
from typing import Dict, List, Optional, Set
@@ -47,20 +50,6 @@ DEFAULT_DANGEROUS_TOOLS: Dict[str, str] = {
"scrape_page": "medium",
}
# Internal, namespaced synthetic approval target for the runaway-safety gate.
# A detected doom/repeat loop routes through the approval pipeline as this
# target instead of unconditionally blocking. The double-underscore name can
# never collide with a real agent tool (tool names are identifiers the model
# emits), so registering it does NOT force approval onto — or block — an
# ordinary user tool, and it is intentionally kept OUT of
# ``DEFAULT_DANGEROUS_TOOLS`` so the ``safe``/``read_only`` presets are
# unaffected. It is registered at ``critical`` risk (see ``__init__``) so the
# default posture (no explicit allow) still stops, preserving the historical
# hard-block; an explicit allow (``__doom_loop__``/``doom_loop`` policy,
# env/YAML auto-approve, or a human continue) lets a legitimate repeat
# (e.g. polling a build status) proceed.
DOOM_LOOP_TARGET = "__doom_loop__"
# Permission presets — resolved to deny frozensets at Agent.__init__ time.
# Usage: Agent(approval="safe") — or set PRAISONAI_TOOL_SAFETY=<preset>
# which applies as the default when no ``approval=`` kwarg is passed.
@@ -102,19 +91,10 @@ class ApprovalRegistry:
self._global_backend = None # type: ignore[assignment]
self._agent_backends: Dict[str, object] = {}
# Tool requirements (mirrors old APPROVAL_REQUIRED_TOOLS / TOOL_RISK_LEVELS).
# These hold process-wide defaults (e.g. DEFAULT_DANGEROUS_TOOLS and any
# intentional global registration).
# Tool requirements (mirrors old APPROVAL_REQUIRED_TOOLS / TOOL_RISK_LEVELS)
self._required_tools: Set[str] = set()
self._risk_levels: Dict[str, str] = {}
# Per-agent tool requirements. A PermissionManager ``ask`` rule belongs
# to a single agent, so it must not leak an approval gate onto unrelated
# agents sharing the same process. Mirrors the agent-keyed pattern used
# by ``_agent_backends`` / ``_agent_tool_auto_approve``.
self._agent_required_tools: Dict[str, Set[str]] = {}
self._agent_risk_levels: Dict[tuple[str, str], str] = {}
# Per-agent, per-tool auto-approval (G-A fix)
self._agent_tool_auto_approve: Dict[tuple[str, str], bool] = {}
@@ -140,13 +120,6 @@ class ApprovalRegistry:
self._required_tools.add(tool_name)
self._risk_levels[tool_name] = risk
# Register the internal runaway-safety gate at ``critical`` risk so a
# detected doom-loop stops by default (backward compatible) while still
# routing through the approval pipeline. Kept separate from the public
# dangerous-tool set so it can never gate a real user tool.
self._required_tools.add(DOOM_LOOP_TARGET)
self._risk_levels[DOOM_LOOP_TARGET] = "critical"
# ── Backend management ───────────────────────────────────────────────
def set_backend(self, backend: object, agent_name: Optional[str] = None) -> None:
@@ -180,51 +153,18 @@ class ApprovalRegistry:
# ── Tool requirement management ──────────────────────────────────────
def add_requirement(
self,
tool_name: str,
risk_level: str = "high",
agent_name: Optional[str] = None,
) -> None:
"""Mark *tool_name* as requiring approval.
def add_requirement(self, tool_name: str, risk_level: str = "high") -> None:
self._required_tools.add(tool_name)
self._risk_levels[tool_name] = risk_level
When *agent_name* is given the requirement is scoped to that agent only
(used for per-agent ``PermissionManager`` ``ask`` rules), so it never
forces approval onto other agents in the same process. Omitting
*agent_name* keeps the historical process-wide behaviour used for
genuinely dangerous tools registered at startup.
"""
if agent_name:
self._agent_required_tools.setdefault(agent_name, set()).add(tool_name)
self._agent_risk_levels[(agent_name, tool_name)] = risk_level
else:
self._required_tools.add(tool_name)
self._risk_levels[tool_name] = risk_level
def remove_requirement(self, tool_name: str) -> None:
self._required_tools.discard(tool_name)
self._risk_levels.pop(tool_name, None)
def remove_requirement(
self, tool_name: str, agent_name: Optional[str] = None
) -> None:
if agent_name:
tools = self._agent_required_tools.get(agent_name)
if tools is not None:
tools.discard(tool_name)
self._agent_risk_levels.pop((agent_name, tool_name), None)
else:
self._required_tools.discard(tool_name)
self._risk_levels.pop(tool_name, None)
def is_required(self, tool_name: str, agent_name: Optional[str] = None) -> bool:
if agent_name and tool_name in self._agent_required_tools.get(agent_name, ()):
return True
def is_required(self, tool_name: str) -> bool:
return tool_name in self._required_tools
def get_risk_level(
self, tool_name: str, agent_name: Optional[str] = None
) -> Optional[str]:
if agent_name:
level = self._agent_risk_levels.get((agent_name, tool_name))
if level is not None:
return level
def get_risk_level(self, tool_name: str) -> Optional[str]:
return self._risk_levels.get(tool_name)
# ── Per-tool auto-approval (G-A fix) ─────────────────────────────────
@@ -244,37 +184,20 @@ class ApprovalRegistry:
# ── Context helpers ──────────────────────────────────────────────────
@staticmethod
def _approval_cache_key(
tool_name: str, arguments: Dict, agent_name: Optional[str] = None
) -> str:
# Scope the key to the requesting agent so one agent's approval never
# silently pre-authorizes an identical call from a different, stricter
# agent in the same context. ``*`` is the sentinel for calls made
# outside any Agent (e.g. bare module-level tool calls).
from .utils import hash_tool_args
return f"{agent_name or '*'}:{tool_name}:{hash_tool_args(arguments)}"
def _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,
agent_name: Optional[str] = None,
) -> None:
def mark_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> None:
approved = self._approved_context.get(set())
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name))
approved.add(self._approval_cache_key(tool_name, arguments or {}))
self._approved_context.set(approved)
def is_already_approved(
self,
tool_name: str,
arguments: Optional[Dict] = None,
agent_name: Optional[str] = None,
) -> bool:
# Honour an explicit mark_approved() from the agent approval path even
# for critical tools (e.g. execute_command after AutoApproveBackend).
if self._approval_cache_key(tool_name, arguments or {}, agent_name) in self._approved_context.get(set()):
return True
return False
def is_already_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> bool:
if self.get_risk_level(tool_name) == "critical":
return False
return self._approval_cache_key(tool_name, arguments or {}) in self._approved_context.get(set())
def _is_session_scoped(
self, agent_name: Optional[str], tool_name: str, arguments: Optional[Dict]
@@ -421,41 +344,34 @@ class ApprovalRegistry:
agent_name: Optional[str],
tool_name: str,
arguments: Dict,
force: bool = False,
) -> ApprovalDecision:
"""Synchronous approval — used by ``Agent._execute_tool_impl``.
``force`` gates this single call even when the tool is not otherwise
registered as requiring approval (e.g. a per-agent ``PermissionManager``
``ask`` rule). It applies only to this call and never mutates shared
registry state, so it cannot leak an approval gate onto other agents.
"""
# Fast-path: not required (checks both global and this agent's scope)
if not force and not self.is_required(tool_name, agent_name):
"""Synchronous approval — used by ``Agent._execute_tool_impl``."""
# Fast-path: not required
if not self.is_required(tool_name):
return ApprovalDecision(approved=True, reason="No approval required")
# Already approved in this context
if self.is_already_approved(tool_name, arguments, agent_name):
if self.is_already_approved(tool_name, arguments):
return ApprovalDecision(approved=True, reason="Already approved in context")
# "This session" scoped grant covers matching calls for the run
if self._is_session_scoped(agent_name, tool_name, arguments):
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name, arguments, agent_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, arguments, agent_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, arguments, agent_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
# Delegate to backend
@@ -463,7 +379,7 @@ class ApprovalRegistry:
request = ApprovalRequest(
tool_name=tool_name,
arguments=arguments,
risk_level=self.get_risk_level(tool_name, agent_name) or "medium",
risk_level=self._risk_levels.get(tool_name, "medium"),
agent_name=agent_name,
)
@@ -479,7 +395,7 @@ class ApprovalRegistry:
)
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
return decision
@@ -488,43 +404,38 @@ class ApprovalRegistry:
agent_name: Optional[str],
tool_name: str,
arguments: Dict,
force: bool = False,
) -> ApprovalDecision:
"""Asynchronous approval — used by async tool execution path.
See :meth:`approve_sync` for the ``force`` semantics (per-call gate,
no shared-state mutation).
"""
# Fast-path: not required (checks both global and this agent's scope)
if not force and not self.is_required(tool_name, agent_name):
"""Asynchronous approval — used by async tool execution path."""
# Fast-path: not required
if not self.is_required(tool_name):
return ApprovalDecision(approved=True, reason="No approval required")
if self.is_already_approved(tool_name, arguments, agent_name):
if self.is_already_approved(tool_name, arguments):
return ApprovalDecision(approved=True, reason="Already approved in context")
# "This session" scoped grant covers matching calls for the run
if self._is_session_scoped(agent_name, tool_name, arguments):
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
# Check per-tool auto-approval (G-A fix)
if self.is_auto_approved(tool_name, agent_name):
self.mark_approved(tool_name, arguments, agent_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, arguments, agent_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, arguments, agent_name)
self.mark_approved(tool_name, arguments)
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
backend = self.get_backend(agent_name)
request = ApprovalRequest(
tool_name=tool_name,
arguments=arguments,
risk_level=self.get_risk_level(tool_name, agent_name) or "medium",
risk_level=self._risk_levels.get(tool_name, "medium"),
agent_name=agent_name,
)
@@ -537,6 +448,6 @@ class ApprovalRegistry:
decision = ApprovalDecision(approved=False, reason="Approval timed out")
if decision.approved:
self.mark_approved(tool_name, arguments, agent_name)
self.mark_approved(tool_name, arguments)
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
return decision
@@ -7,31 +7,11 @@ across the approval system.
import asyncio
import concurrent.futures
import hashlib
import json
from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar
T = TypeVar('T')
def hash_tool_args(arguments: Optional[Dict[str, Any]]) -> str:
"""Return the canonical 16-char identity hash of a tool call's arguments.
Produces a 16-character SHA-256 digest over the canonical JSON encoding
(``sort_keys=True, default=str``) of the arguments. This is the single
source of truth for the tool-call identity key shared by approval
de-duplication (``ApprovalRegistry``) and doom-loop detection, so the two
safety subsystems cannot silently diverge.
Falls back to ``"unhashable"`` if the arguments cannot be serialised.
"""
try:
payload = json.dumps(arguments or {}, sort_keys=True, default=str)
return hashlib.sha256(payload.encode()).hexdigest()[:16]
except (TypeError, ValueError):
return "unhashable"
# Tool names that map to a shell-command permission target (``bash:<command>``)
# so the reusable command-prefix machinery in ``PermissionManager`` applies.
_SHELL_TOOLS = frozenset({
@@ -76,14 +56,6 @@ def build_permission_target(
* file tools -> ``<edit|write|delete|…>:<path>``
* everything else -> ``tool:<tool_name>``
The command identity is preserved verbatim so command-specific rules
(e.g. ``deny: bash:rm *``) still match. The out-of-workspace boundary is
enforced downstream by :class:`~praisonaiagents.permissions.PermissionManager`
(its ``external_dir:`` gate), which decomposes the ``bash:<command>`` target
and gates any escaping path — so a broad ``bash:*`` / "allow shell" /
session grant cannot silently authorise out-of-workspace access while a
command-specific ``deny`` still fires.
Falls back to ``tool:<tool_name>`` whenever the expected argument is missing
so a target is always produced.
@@ -2,10 +2,7 @@
Authentication & Authorization Protocols for PraisonAI Agents.
This package provides protocol-driven auth interfaces for the core SDK.
Implementations (JWT, OAuth, etc.) live outside it — implement the protocols
against your own store, or use the reference implementation in the separate
``praisonai-platform`` package
(https://github.com/MervinPraison/PraisonAI-Platform).
Implementations (JWT, OAuth, etc.) live in the platform package.
Quick start::
@@ -155,16 +155,11 @@ class WorkspaceContextProtocol(Protocol):
"""Protocol for providing workspace context to agents.
Enables agents to access workspace-specific settings, instructions,
and agent configurations from a platform layer.
Implementations live outside this package. The reference one is
``PlatformWorkspaceContext`` in the ``praisonai-platform`` package
(https://github.com/MervinPraison/PraisonAI-Platform); implement this
protocol yourself to back agent configuration with your own store.
and agent configurations from the platform layer.
Example::
class MyWorkspaceContext:
class PlatformWorkspaceContext:
async def get_workspace_context(self, workspace_id):
ws = await db.get_workspace(workspace_id)
return ws.context
@@ -40,8 +40,6 @@ __all__ = [
"TaskStatus",
# Configuration
"BackgroundConfig",
# Shared runner accessor
"get_background_runner",
]
@@ -63,8 +61,4 @@ def __getattr__(name: str):
from .config import BackgroundConfig
return BackgroundConfig
if name == "get_background_runner":
from .runner import get_background_runner
return get_background_runner
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -5,6 +5,7 @@ Manages background task execution.
"""
import asyncio
import logging
import threading
from praisonaiagents._logging import get_logger
from typing import Optional, List, Dict, Any, Callable, Union
@@ -15,24 +16,6 @@ from .config import BackgroundConfig
logger = get_logger(__name__)
def _safe_on_complete(
on_complete: Optional[Callable[["BackgroundTask"], None]],
task: "BackgroundTask",
) -> None:
"""Invoke ``on_complete`` for a terminal task, swallowing callback errors.
Shared by every terminal branch (success, failure, timeout, cancellation)
so the completion contract holds regardless of how the task ended.
"""
if on_complete is None:
return
try:
on_complete(task)
except Exception as cb_error:
logger.warning(f"on_complete callback error: {cb_error}")
class BackgroundRunner:
"""
Manages background task execution.
@@ -137,15 +120,11 @@ class BackgroundRunner:
# Create the execution coroutine
async def execute():
# The semaphore acquisition is inside the ``try`` so cancellation
# while queued (waiting for a slot) still routes through the
# ``CancelledError`` handler and fires ``on_complete`` — matching
# the terminal-notification contract for every other outcome.
try:
async with self._get_semaphore():
task.start()
logger.info(f"Background task started: {task.name} ({task.id})")
async with self._get_semaphore():
task.start()
logger.info(f"Background task started: {task.name} ({task.id})")
try:
# Check if function is async
if asyncio.iscoroutinefunction(func):
result = await asyncio.wait_for(
@@ -159,33 +138,36 @@ class BackgroundRunner:
loop.run_in_executor(None, lambda: func(*args, **kwargs)),
timeout=timeout or self.config.default_timeout
)
task.complete(result)
logger.info(f"Background task completed: {task.name} ({task.id})")
_safe_on_complete(on_complete, task)
if on_complete:
try:
on_complete(task)
except Exception as e:
logger.warning(f"on_complete callback error: {e}")
return result
except asyncio.TimeoutError:
task.fail(f"Task timed out after {timeout}s")
logger.warning(f"Background task timed out: {task.name}")
_safe_on_complete(on_complete, task)
except asyncio.CancelledError:
task.cancel()
logger.info(f"Background task cancelled: {task.name}")
_safe_on_complete(on_complete, task)
raise
except Exception as e:
task.fail(str(e))
logger.error(f"Background task failed: {task.name} - {e}")
_safe_on_complete(on_complete, task)
except asyncio.TimeoutError:
task.fail(f"Task timed out after {timeout}s")
logger.warning(f"Background task timed out: {task.name}")
except asyncio.CancelledError:
task.cancel()
logger.info(f"Background task cancelled: {task.name}")
raise
except Exception as e:
task.fail(str(e))
logger.error(f"Background task failed: {task.name} - {e}")
if on_complete:
try:
on_complete(task)
except Exception as cb_error:
logger.warning(f"on_complete callback error: {cb_error}")
# Create and store the future
task._future = asyncio.create_task(execute())
@@ -381,24 +363,6 @@ class BackgroundRunner:
# Block briefly to get the BackgroundTask object (not the task result).
return future.result(timeout=5)
def cancel_task_sync(self, task_id: str) -> bool:
"""Cancel a task from synchronous code (or an unrelated event loop).
Schedules :meth:`cancel_task` on the shared background loop where the
task's future lives, so the underlying execution is actually stopped
(not merely marked cancelled). Safe to call from within a running
event loop because it hops threads via ``run_coroutine_threadsafe``.
Args:
task_id: ID of task to cancel
Returns:
True if cancelled, False if not found or already completed
"""
loop = _get_bg_loop()
future = asyncio.run_coroutine_threadsafe(self.cancel_task(task_id), loop)
return future.result(timeout=5)
def submit_agent_sync(
self,
agent: Any,
@@ -488,25 +452,3 @@ def _shutdown_bg_loop():
# Register cleanup on process exit
import atexit
atexit.register(_shutdown_bg_loop)
# ── Shared process-wide runner ──────────────────────────────────────────
_shared_runner: Optional["BackgroundRunner"] = None
_shared_runner_lock = _threading.Lock()
def get_background_runner() -> "BackgroundRunner":
"""Return the process-wide shared :class:`BackgroundRunner`.
Interactive surfaces (REPL/TUI) and bots submit and inspect background
tasks through this single instance so ``/tasks`` sees the same tasks
regardless of which surface submitted them. Created lazily on first use.
"""
global _shared_runner
if _shared_runner is not None:
return _shared_runner
with _shared_runner_lock:
if _shared_runner is None:
_shared_runner = BackgroundRunner()
return _shared_runner
@@ -6,6 +6,7 @@ Represents a task running in the background.
import uuid
import asyncio
import logging
from praisonaiagents._logging import get_logger
from enum import Enum
from dataclasses import dataclass, field
@@ -27,22 +27,16 @@ from .protocols import (
EmailProtocol,
EmailInbox,
SupportsPresentation,
PresentationRendererProtocol,
PlatformCapabilities,
ChannelField,
ChannelDescriptor,
WebhookVerifierProtocol,
CallbackPayloadStoreProtocol,
InMemoryCallbackPayloadStore,
GatewayRuntimeSeams,
SupportsGatewayRuntime,
GatewayAdapterContractError,
)
from .base import (
BasePlatformAdapter,
SendResult,
SendErrorKind,
classify_send_error,
)
from .presentation import (
MessagePresentation,
@@ -55,12 +49,6 @@ from .presentation import (
ButtonStyle,
BlockType,
adapt_presentation,
adapt_presentation_with_report,
DegradedDelivery,
table_to_markdown,
chart_to_text,
register_presentation_renderer,
get_presentation_renderer,
)
from .interactive import (
InteractiveContext,
@@ -83,33 +71,13 @@ from .agent_reply import (
extract_completion,
append_completion_note,
)
from .format import (
format_for_dialect,
escape_markdown_v2,
markdown_to_slack,
strip_markdown,
)
from .webhook_filter import (
WebhookFilter,
evaluate_webhook_filter,
resolve_field,
)
from .config import BotConfig, BotOSConfig, DisplayPolicy, resolve_display_policy
from .silence import (
SILENT_REPLY_TOKEN,
is_intentional_silence_response,
classify_final,
BotLoopPolicy,
BotLoopGuard,
)
from .failure import (
FailureReply,
render_failure_reply,
)
from .admission import (
IngressDecision,
resolve_ingress_admission,
)
from .run_status import (
RunPhase,
RunStatusController,
@@ -148,29 +116,14 @@ __all__ = [
"ButtonStyle",
"BlockType",
"adapt_presentation",
"adapt_presentation_with_report",
"DegradedDelivery",
"table_to_markdown",
"chart_to_text",
"PresentationRendererProtocol",
"register_presentation_renderer",
"get_presentation_renderer",
"PlatformCapabilities",
"ChannelField",
"ChannelDescriptor",
"WebhookVerifierProtocol",
"WebhookFilter",
"evaluate_webhook_filter",
"resolve_field",
"CallbackPayloadStoreProtocol",
"InMemoryCallbackPayloadStore",
"GatewayRuntimeSeams",
"SupportsGatewayRuntime",
"GatewayAdapterContractError",
"BasePlatformAdapter",
"SendResult",
"SendErrorKind",
"classify_send_error",
"InteractiveContext",
"InteractiveRegistry",
"InteractiveHandler",
@@ -188,19 +141,10 @@ __all__ = [
"extract_presentation",
"extract_completion",
"append_completion_note",
"format_for_dialect",
"escape_markdown_v2",
"markdown_to_slack",
"strip_markdown",
"SILENT_REPLY_TOKEN",
"is_intentional_silence_response",
"classify_final",
"BotLoopPolicy",
"BotLoopGuard",
"FailureReply",
"render_failure_reply",
"IngressDecision",
"resolve_ingress_admission",
"RunPhase",
"RunStatusController",
"StallState",
Loaded 100 of 1202 files, more files were not shown because too many files have changed in this diff. Show more