mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 11:51:32 -10:00
Compare commits
568
Commits
No files matched your search
@@ -1,5 +1,5 @@
|
||||
name: Install PraisonAI monorepo packages
|
||||
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, and praisonai wrapper from local paths (mirrors PyPI eight-package install order).
|
||||
description: Install praisonai-agents, praisonai-code, praisonai-bot, praisonai-train, praisonai-browser, praisonai-mcp, praisonai-sandbox, praisonai-deploy, and praisonai wrapper from local paths (mirrors PyPI nine-package install order).
|
||||
|
||||
inputs:
|
||||
repo-root:
|
||||
@@ -40,6 +40,7 @@ runs:
|
||||
BROWSER_PKG="${BASE}src/praisonai-browser"
|
||||
MCP_PKG="${BASE}src/praisonai-mcp"
|
||||
SANDBOX_PKG="${BASE}src/praisonai-sandbox"
|
||||
DEPLOY_PKG="${BASE}src/praisonai-deploy"
|
||||
WRAPPER_PKG="${BASE}src/praisonai"
|
||||
|
||||
normalise_extras() {
|
||||
@@ -69,6 +70,8 @@ runs:
|
||||
uv pip install --system -e .
|
||||
cd "$SANDBOX_PKG"
|
||||
uv pip install --system -e .
|
||||
cd "$DEPLOY_PKG"
|
||||
uv pip install --system -e .
|
||||
cd "$WRAPPER_PKG"
|
||||
uv pip install --system "${WRAPPER_SUFFIX}"
|
||||
else
|
||||
@@ -86,6 +89,8 @@ runs:
|
||||
pip install -e .
|
||||
cd "$SANDBOX_PKG"
|
||||
pip install -e .
|
||||
cd "$DEPLOY_PKG"
|
||||
pip install -e .
|
||||
cd "$WRAPPER_PKG"
|
||||
pip install -e "${WRAPPER_SUFFIX}"
|
||||
fi
|
||||
@@ -11,71 +11,112 @@ assert.ok(versions.currentCode);
|
||||
assert.ok(versions.currentWrapper);
|
||||
assert.ok(versions.targetCode);
|
||||
assert.ok(rg.PACKAGE_PATHS.includes('src/praisonai-code'));
|
||||
// Every released package must make the gate's path-change check eligible.
|
||||
for (const p of [
|
||||
'src/praisonai', 'src/praisonai-agents', 'src/praisonai-code',
|
||||
'src/praisonai-bot', 'src/praisonai-train', 'src/praisonai-browser',
|
||||
'src/praisonai-mcp', 'src/praisonai-sandbox', 'src/praisonai-deploy',
|
||||
]) {
|
||||
assert.ok(rg.PACKAGE_PATHS.includes(p), `PACKAGE_PATHS missing ${p}`);
|
||||
}
|
||||
|
||||
const noonUtc = new Date('2026-07-08T12:00:00Z');
|
||||
const dayStart = rg.utcDayStart(noonUtc);
|
||||
assert.strictEqual(dayStart.toISOString(), '2026-07-08T00:00:00.000Z');
|
||||
|
||||
(async () => {
|
||||
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
|
||||
|
||||
const mockGithub = {
|
||||
function mockGithub({ runs = [], releases = [] } = {}) {
|
||||
return {
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRuns: async () => ({
|
||||
data: {
|
||||
workflow_runs: [
|
||||
{
|
||||
conclusion: 'success',
|
||||
created_at: '2026-07-08T09:00:00Z',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
listWorkflowRuns: async () => ({ data: { workflow_runs: runs } }),
|
||||
},
|
||||
repos: {
|
||||
listReleases: async () => ({ data: releases }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assert.strictEqual(await rg.pypiVersionExists('praisonaiagents', '0.0.0'), false);
|
||||
|
||||
assert.strictEqual(rg.PATCH_RELEASE_INTERVAL_DAYS, 3);
|
||||
|
||||
// Dedupe is release-based: a v* release inside the window blocks…
|
||||
const recentRelease = mockGithub({
|
||||
releases: [{ tag_name: 'v4.6.159', published_at: '2026-07-08T09:00:00Z' }],
|
||||
});
|
||||
assert.strictEqual(
|
||||
await rg.hasSuccessfulReleaseWithinDays(mockGithub, 'o', 'r', noonUtc),
|
||||
await rg.hasSuccessfulReleaseWithinDays(recentRelease, 'o', 'r', noonUtc),
|
||||
true
|
||||
);
|
||||
|
||||
const result = await rg.evaluateReleasePreflight(
|
||||
mockGithub, 'o', 'r',
|
||||
recentRelease, 'o', 'r',
|
||||
{ headSha: 'abc', isCiTrigger: false, bump: 'patch', now: noonUtc },
|
||||
null
|
||||
);
|
||||
assert.ok(result.reasons.some((r) => r.includes('every 3 days')));
|
||||
|
||||
const oldReleaseGithub = {
|
||||
rest: {
|
||||
actions: {
|
||||
listWorkflowRuns: async () => ({
|
||||
data: {
|
||||
workflow_runs: [
|
||||
{
|
||||
conclusion: 'success',
|
||||
created_at: '2026-07-04T09:00:00Z',
|
||||
status: 'completed',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
// …an old release does not…
|
||||
const oldRelease = mockGithub({
|
||||
releases: [{ tag_name: 'v4.6.150', published_at: '2026-07-04T09:00:00Z' }],
|
||||
});
|
||||
assert.strictEqual(
|
||||
await rg.hasSuccessfulReleaseWithinDays(oldReleaseGithub, 'o', 'r', noonUtc),
|
||||
await rg.hasSuccessfulReleaseWithinDays(oldRelease, 'o', 'r', noonUtc),
|
||||
false
|
||||
);
|
||||
|
||||
// …and a dry-run "success" (workflow run, no release) does not consume the
|
||||
// window: only real releases count.
|
||||
const dryRunOnly = mockGithub({
|
||||
runs: [{ conclusion: 'success', created_at: '2026-07-08T09:00:00Z', status: 'completed' }],
|
||||
releases: [],
|
||||
});
|
||||
assert.strictEqual(
|
||||
await rg.hasSuccessfulReleaseWithinDays(dryRunOnly, 'o', 'r', noonUtc),
|
||||
false
|
||||
);
|
||||
|
||||
// Active-run blocking: fresh waiting run blocks…
|
||||
const freshWaiting = mockGithub({
|
||||
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T11:00:00Z', html_url: 'x' }],
|
||||
});
|
||||
assert.strictEqual(
|
||||
await rg.hasActiveReleaseRun(freshWaiting, 'o', 'r', noonUtc, null),
|
||||
true
|
||||
);
|
||||
|
||||
// …a waiting run stuck longer than STALE_WAITING_HOURS no longer blocks…
|
||||
const staleWaiting = mockGithub({
|
||||
runs: [{ status: 'waiting', conclusion: null, created_at: '2026-07-08T01:00:00Z', html_url: 'x' }],
|
||||
});
|
||||
const warnings = [];
|
||||
const fakeCore = { warning: (m) => warnings.push(m), info: () => {} };
|
||||
assert.strictEqual(rg.STALE_WAITING_HOURS, 6);
|
||||
assert.strictEqual(
|
||||
await rg.hasActiveReleaseRun(staleWaiting, 'o', 'r', noonUtc, fakeCore),
|
||||
false
|
||||
);
|
||||
assert.ok(warnings.length === 1 && warnings[0].includes('approve or cancel'));
|
||||
|
||||
// …but an in_progress run always blocks regardless of age.
|
||||
const oldInProgress = mockGithub({
|
||||
runs: [{ status: 'in_progress', conclusion: null, created_at: '2026-07-07T01:00:00Z', html_url: 'x' }],
|
||||
});
|
||||
assert.strictEqual(
|
||||
await rg.hasActiveReleaseRun(oldInProgress, 'o', 'r', noonUtc, null),
|
||||
true
|
||||
);
|
||||
|
||||
console.log('ok: bumpPatch');
|
||||
console.log('ok: readVersionsFromTree');
|
||||
console.log('ok: PACKAGE_PATHS covers all 9 released packages');
|
||||
console.log('ok: pypiVersionExists missing version');
|
||||
console.log('ok: utcDayStart');
|
||||
console.log('ok: hasSuccessfulReleaseWithinDays');
|
||||
console.log('ok: release-based dedupe (recent blocks, old does not)');
|
||||
console.log('ok: dry-run success does not consume the release window');
|
||||
console.log('ok: 3-day dedupe blocks second release');
|
||||
console.log('ok: fresh waiting run blocks; stale waiting run ignored with warning');
|
||||
console.log('ok: in_progress always blocks');
|
||||
})();
|
||||
@@ -4,12 +4,31 @@
|
||||
|
||||
const https = require('https');
|
||||
|
||||
const PACKAGE_PATHS = ['src/praisonai', 'src/praisonai-agents', 'src/praisonai-code'];
|
||||
/** Every directory whose changes should make a release eligible — all nine
|
||||
* published packages (mirrors the skip_* inputs in pypi-release.yml). */
|
||||
const PACKAGE_PATHS = [
|
||||
'src/praisonai',
|
||||
'src/praisonai-agents',
|
||||
'src/praisonai-code',
|
||||
'src/praisonai-bot',
|
||||
'src/praisonai-train',
|
||||
'src/praisonai-browser',
|
||||
'src/praisonai-mcp',
|
||||
'src/praisonai-sandbox',
|
||||
'src/praisonai-deploy',
|
||||
];
|
||||
/** Minimum days between successful patch auto-releases. */
|
||||
const PATCH_RELEASE_INTERVAL_DAYS = 3;
|
||||
const ACTIVE_RELEASE_STATUSES = new Set([
|
||||
'queued', 'in_progress', 'waiting', 'pending', 'requested',
|
||||
]);
|
||||
/** A run stuck in `waiting` (environment approval) longer than this no longer
|
||||
* blocks the gate. GitHub only auto-fails unapproved deployments after 30
|
||||
* days, so without a cutoff one forgotten manual dispatch stalls all
|
||||
* auto-releases for up to a month. The run is warned about, never cancelled —
|
||||
* if later approved, the pypi-release concurrency group serializes it and its
|
||||
* pypi_exists checks no-op anything already published. */
|
||||
const STALE_WAITING_HOURS = 6;
|
||||
|
||||
function bumpPatch(version) {
|
||||
const parts = version.split('.');
|
||||
@@ -65,16 +84,27 @@ function pypiVersionExists(packageName, version) {
|
||||
});
|
||||
}
|
||||
|
||||
async function hasActiveReleaseRun(github, owner, repo) {
|
||||
async function hasActiveReleaseRun(github, owner, repo, now = new Date(), core = null) {
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'pypi-release.yml',
|
||||
per_page: 20,
|
||||
});
|
||||
return runs.data.workflow_runs.some(
|
||||
(r) => ACTIVE_RELEASE_STATUSES.has(r.status) && !r.conclusion
|
||||
);
|
||||
const staleCutoff = now.getTime() - STALE_WAITING_HOURS * 60 * 60 * 1000;
|
||||
return runs.data.workflow_runs.some((r) => {
|
||||
if (!ACTIVE_RELEASE_STATUSES.has(r.status) || r.conclusion) return false;
|
||||
if (r.status === 'waiting' && new Date(r.created_at).getTime() < staleCutoff) {
|
||||
if (core) {
|
||||
core.warning(
|
||||
`Ignoring release run waiting >${STALE_WAITING_HOURS}h for environment approval: `
|
||||
+ `${r.html_url} — approve or cancel it. It no longer blocks auto-releases.`
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function utcDayStart(now = new Date()) {
|
||||
@@ -93,15 +123,18 @@ async function hasSuccessfulReleaseWithinDays(
|
||||
intervalDays = PATCH_RELEASE_INTERVAL_DAYS,
|
||||
) {
|
||||
const windowStart = releaseIntervalStart(now, intervalDays);
|
||||
const runs = await github.rest.actions.listWorkflowRuns({
|
||||
// Dedupe on GitHub releases (v* tags) rather than run conclusions: a
|
||||
// dry_run=true dispatch concludes 'success' without publishing anything and
|
||||
// must not consume the release window. Only a real full release creates a
|
||||
// v* GitHub release (bump_and_release.py's `gh release create`).
|
||||
const releases = await github.rest.repos.listReleases({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: 'pypi-release.yml',
|
||||
status: 'completed',
|
||||
per_page: 30,
|
||||
per_page: 10,
|
||||
});
|
||||
return runs.data.workflow_runs.some(
|
||||
(r) => r.conclusion === 'success' && new Date(r.created_at) >= windowStart
|
||||
return releases.data.some(
|
||||
(r) => r.tag_name && r.tag_name.startsWith('v')
|
||||
&& new Date(r.published_at || r.created_at) >= windowStart
|
||||
);
|
||||
}
|
||||
|
||||
@@ -146,12 +179,13 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
return out;
|
||||
}
|
||||
|
||||
if (await hasActiveReleaseRun(github, owner, repo)) {
|
||||
const referenceTime = options.now instanceof Date ? options.now : new Date();
|
||||
|
||||
if (await hasActiveReleaseRun(github, owner, repo, referenceTime, core)) {
|
||||
reasons.push('PyPI Release already in progress or awaiting approval');
|
||||
return out;
|
||||
}
|
||||
|
||||
const referenceTime = options.now instanceof Date ? options.now : new Date();
|
||||
if (await hasSuccessfulReleaseWithinDays(github, owner, repo, referenceTime)) {
|
||||
reasons.push(
|
||||
`already released within last ${PATCH_RELEASE_INTERVAL_DAYS} days; `
|
||||
@@ -226,8 +260,35 @@ async function evaluateReleasePreflight(github, owner, repo, options, core) {
|
||||
out.headSha = evalSha;
|
||||
const greenSha = await lastGreenCoreTestsSha(github, owner, repo);
|
||||
if (greenSha !== evalSha) {
|
||||
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
|
||||
return out;
|
||||
// Core Tests has path filters and honors [skip ci] (the release
|
||||
// safety-net commit uses it), so the tip of main may legitimately have
|
||||
// no Core Tests run of its own — e.g. a docs-only commit, or the
|
||||
// "chore(release): … [skip ci]" commit right after a release. Accept a
|
||||
// green ancestor when nothing under the released package paths changed
|
||||
// after it; otherwise the cron path stalls until the next src/** push.
|
||||
let greenAncestorOk = false;
|
||||
if (greenSha && /^[0-9a-f]{40}$/.test(greenSha)) {
|
||||
try {
|
||||
execSync(`git merge-base --is-ancestor ${greenSha} ${evalSha}`);
|
||||
const delta = execSync(
|
||||
`git diff --name-only ${greenSha} ${evalSha} -- ${PACKAGE_PATHS.join(' ')}`,
|
||||
{ encoding: 'utf8' }
|
||||
).trim();
|
||||
greenAncestorOk = delta === '';
|
||||
} catch {
|
||||
greenAncestorOk = false;
|
||||
}
|
||||
}
|
||||
if (!greenAncestorOk) {
|
||||
reasons.push(`CI not green on HEAD (last green: ${greenSha ? greenSha.slice(0, 7) : 'none'})`);
|
||||
return out;
|
||||
}
|
||||
if (core) {
|
||||
core.info(
|
||||
`HEAD ${evalSha.slice(0, 7)} has no Core Tests run; accepting green ancestor `
|
||||
+ `${greenSha.slice(0, 7)} (no package-path changes since).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +303,9 @@ module.exports = {
|
||||
readVersionsFromTree,
|
||||
pypiVersionExists,
|
||||
PATCH_RELEASE_INTERVAL_DAYS,
|
||||
STALE_WAITING_HOURS,
|
||||
releaseIntervalStart,
|
||||
hasActiveReleaseRun,
|
||||
hasSuccessfulReleaseWithinDays,
|
||||
hasSuccessfulReleaseToday,
|
||||
utcDayStart,
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
!startsWith(github.event.workflow_run.head_commit.message, 'Release v') &&
|
||||
!startsWith(github.event.workflow_run.head_commit.message, 'Bump praisonai')
|
||||
!startsWith(github.event.workflow_run.head_commit.message, 'chore(release):')
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
@@ -69,6 +69,10 @@ jobs:
|
||||
- name: Fetch main and tags
|
||||
run: git fetch --tags origin main
|
||||
|
||||
# Guards the gate logic itself — no workflow ran these selftests before.
|
||||
- name: Selftest release-gate script
|
||||
run: node .github/scripts/release-gate-selftest.js
|
||||
|
||||
- name: Preflight — path changes, CI SHA, dedupe
|
||||
id: preflight
|
||||
uses: actions/github-script@v7
|
||||
|
||||
+424
-169
@@ -61,6 +61,29 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
skip_deploy:
|
||||
description: 'Skip praisonai-deploy publish (recovery if already on PyPI)'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
# NOTE: GitHub caps workflow_dispatch at 25 top-level inputs; this
|
||||
# workflow has 23. Adding a 10th package (skip_* + *_version) hits 25.
|
||||
only:
|
||||
description: 'Release ONLY this package (forces skip on all others); all = no restriction'
|
||||
required: false
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- agents
|
||||
- code
|
||||
- bot
|
||||
- train
|
||||
- browser
|
||||
- mcp
|
||||
- sandbox
|
||||
- deploy
|
||||
- wrapper
|
||||
agents_version:
|
||||
description: 'Override agents version (empty = auto bump from pyproject.toml)'
|
||||
required: false
|
||||
@@ -101,6 +124,11 @@ on:
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
deploy_version:
|
||||
description: 'Override praisonai-deploy version (empty = auto bump from pyproject.toml)'
|
||||
required: false
|
||||
default: ''
|
||||
type: string
|
||||
source:
|
||||
description: 'Trigger source (audit trail)'
|
||||
required: false
|
||||
@@ -137,14 +165,32 @@ jobs:
|
||||
# PyPI API token auth: username __token__, password = pypi-… token (see pypi.org/help/#invalid-auth)
|
||||
UV_PUBLISH_USERNAME: __token__
|
||||
UV_PUBLISH_PASSWORD: ${{ secrets.PYPI_TOKEN || secrets.PYPI_API_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GH_TOKEN }}
|
||||
# GH_TOKEN is NOT set here: it is a GitHub App installation token minted in
|
||||
# "Generate GitHub App token" and exported via $GITHUB_ENV, so releases are
|
||||
# authored by praisonai-triage-agent[bot] rather than MervinPraison.
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
# Scope to this repo only. Without it the token is minted for every
|
||||
# repository the owner has; this job never leaves PraisonAI (checkout,
|
||||
# push to origin, and gh release create all target it).
|
||||
repositories: ${{ github.event.repository.name }}
|
||||
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
# Do not bake the token into .git/config: an http.extraheader would
|
||||
# outrank the remote URL set in "Configure git" and pin this first
|
||||
# (1h) token, defeating the refresh before the wrapper release.
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -155,10 +201,19 @@ jobs:
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
|
||||
- name: Configure git
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
git config user.name "MervinPraison"
|
||||
git config user.email "[email protected]"
|
||||
gh auth setup-git
|
||||
set -euo pipefail
|
||||
git config user.name "praisonai-triage-agent[bot]"
|
||||
git config user.email "272766704+praisonai-triage-agent[bot]@users.noreply.github.com"
|
||||
# persist-credentials:false left the remote unauthenticated; push auth
|
||||
# comes from the app token embedded here (masked in logs, and the
|
||||
# token dies with the runner).
|
||||
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
|
||||
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
|
||||
GH_TOKEN="${APP_TOKEN}" gh auth setup-git
|
||||
|
||||
- name: Release summary
|
||||
run: |
|
||||
@@ -168,6 +223,36 @@ jobs:
|
||||
echo "Dry run: ${{ inputs.dry_run }}"
|
||||
echo "Environment: ${{ (inputs.source == 'ci' || inputs.source == 'nightly') && inputs.bump == 'patch' && 'pypi-auto' || 'pypi' }}"
|
||||
|
||||
# Single source of truth for which packages this run releases. `only`
|
||||
# inverts into the skip flags here; everything downstream (version
|
||||
# computation, PyPI detection, publish conditions) consumes these
|
||||
# outputs, so a dedicated one-package release is one input instead of
|
||||
# eight skip flags.
|
||||
- name: Resolve effective skips
|
||||
id: flags
|
||||
env:
|
||||
ONLY: ${{ inputs.only }}
|
||||
SKIP_AGENTS: ${{ inputs.skip_agents }}
|
||||
SKIP_CODE: ${{ inputs.skip_code }}
|
||||
SKIP_BOT: ${{ inputs.skip_bot }}
|
||||
SKIP_TRAIN: ${{ inputs.skip_train }}
|
||||
SKIP_BROWSER: ${{ inputs.skip_browser }}
|
||||
SKIP_MCP: ${{ inputs.skip_mcp }}
|
||||
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
|
||||
SKIP_DEPLOY: ${{ inputs.skip_deploy }}
|
||||
SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for pkg in agents code bot train browser mcp sandbox deploy wrapper; do
|
||||
var="SKIP_$(echo "$pkg" | tr '[:lower:]' '[:upper:]')"
|
||||
skip="${!var}"
|
||||
if [ -n "${ONLY}" ] && [ "${ONLY}" != "all" ] && [ "${ONLY}" != "$pkg" ]; then
|
||||
skip=true
|
||||
fi
|
||||
echo "skip_${pkg}=${skip}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_${pkg}=${skip}"
|
||||
done
|
||||
|
||||
- name: Compute release versions
|
||||
id: versions
|
||||
env:
|
||||
@@ -180,13 +265,15 @@ jobs:
|
||||
BROWSER_OVERRIDE: ${{ inputs.browser_version }}
|
||||
MCP_OVERRIDE: ${{ inputs.mcp_version }}
|
||||
SANDBOX_OVERRIDE: ${{ inputs.sandbox_version }}
|
||||
SKIP_AGENTS: ${{ inputs.skip_agents }}
|
||||
SKIP_CODE: ${{ inputs.skip_code }}
|
||||
SKIP_BOT: ${{ inputs.skip_bot }}
|
||||
SKIP_TRAIN: ${{ inputs.skip_train }}
|
||||
SKIP_BROWSER: ${{ inputs.skip_browser }}
|
||||
SKIP_MCP: ${{ inputs.skip_mcp }}
|
||||
SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
|
||||
DEPLOY_OVERRIDE: ${{ inputs.deploy_version }}
|
||||
SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
|
||||
SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
|
||||
SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
|
||||
SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
|
||||
SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
|
||||
SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
|
||||
SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
|
||||
SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
|
||||
run: |
|
||||
python <<'PY'
|
||||
import os
|
||||
@@ -223,6 +310,7 @@ jobs:
|
||||
browser_override = os.environ.get("BROWSER_OVERRIDE", "").strip()
|
||||
mcp_override = os.environ.get("MCP_OVERRIDE", "").strip()
|
||||
sandbox_override = os.environ.get("SANDBOX_OVERRIDE", "").strip()
|
||||
deploy_override = os.environ.get("DEPLOY_OVERRIDE", "").strip()
|
||||
validate_version("agents_version", agents_override)
|
||||
validate_version("wrapper_version", wrapper_override)
|
||||
validate_version("code_version", code_override)
|
||||
@@ -231,6 +319,7 @@ jobs:
|
||||
validate_version("browser_version", browser_override)
|
||||
validate_version("mcp_version", mcp_override)
|
||||
validate_version("sandbox_version", sandbox_override)
|
||||
validate_version("deploy_version", deploy_override)
|
||||
|
||||
agents_pyproject = root / "src/praisonai-agents/pyproject.toml"
|
||||
agents_content = agents_pyproject.read_text()
|
||||
@@ -288,6 +377,14 @@ jobs:
|
||||
sys.exit(1)
|
||||
current_sandbox = sandbox_match.group(1)
|
||||
|
||||
deploy_pyproject = root / "src/praisonai-deploy/pyproject.toml"
|
||||
deploy_content = deploy_pyproject.read_text()
|
||||
deploy_match = re.search(r'^version\s*=\s*"([^"]+)"', deploy_content, re.MULTILINE)
|
||||
if not deploy_match:
|
||||
print("Could not read praisonai-deploy version from pyproject.toml", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
current_deploy = deploy_match.group(1)
|
||||
|
||||
wrapper_version_py = root / "src/praisonai/praisonai/version.py"
|
||||
wrapper_content = wrapper_version_py.read_text()
|
||||
wrapper_match = re.search(r'__version__ = "([^"]+)"', wrapper_content)
|
||||
@@ -304,6 +401,7 @@ jobs:
|
||||
("browser", current_browser),
|
||||
("mcp", current_mcp),
|
||||
("sandbox", current_sandbox),
|
||||
("deploy", current_deploy),
|
||||
("wrapper", current_wrapper),
|
||||
):
|
||||
if not SEMVER.match(value):
|
||||
@@ -317,6 +415,7 @@ jobs:
|
||||
skip_browser = os.environ.get("SKIP_BROWSER", "false").lower() == "true"
|
||||
skip_mcp = os.environ.get("SKIP_MCP", "false").lower() == "true"
|
||||
skip_sandbox = os.environ.get("SKIP_SANDBOX", "false").lower() == "true"
|
||||
skip_deploy = os.environ.get("SKIP_DEPLOY", "false").lower() == "true"
|
||||
|
||||
if agents_override:
|
||||
agents_version = agents_override
|
||||
@@ -367,6 +466,13 @@ jobs:
|
||||
else:
|
||||
sandbox_version = bump_version(current_sandbox)
|
||||
|
||||
if deploy_override:
|
||||
deploy_version = deploy_override
|
||||
elif skip_deploy:
|
||||
deploy_version = current_deploy
|
||||
else:
|
||||
deploy_version = bump_version(current_deploy)
|
||||
|
||||
if wrapper_override:
|
||||
wrapper_version = wrapper_override
|
||||
else:
|
||||
@@ -381,6 +487,7 @@ jobs:
|
||||
fh.write(f"browser_version={browser_version}\n")
|
||||
fh.write(f"mcp_version={mcp_version}\n")
|
||||
fh.write(f"sandbox_version={sandbox_version}\n")
|
||||
fh.write(f"deploy_version={deploy_version}\n")
|
||||
fh.write(f"wrapper_version={wrapper_version}\n")
|
||||
fh.write(f"current_agents={current_agents}\n")
|
||||
fh.write(f"current_code={current_code}\n")
|
||||
@@ -389,6 +496,7 @@ jobs:
|
||||
fh.write(f"current_browser={current_browser}\n")
|
||||
fh.write(f"current_mcp={current_mcp}\n")
|
||||
fh.write(f"current_sandbox={current_sandbox}\n")
|
||||
fh.write(f"current_deploy={current_deploy}\n")
|
||||
fh.write(f"current_wrapper={current_wrapper}\n")
|
||||
|
||||
print(f"Agents: {current_agents} -> {agents_version}")
|
||||
@@ -398,6 +506,7 @@ jobs:
|
||||
print(f"Browser: {current_browser} -> {browser_version}")
|
||||
print(f"MCP: {current_mcp} -> {mcp_version}")
|
||||
print(f"Sandbox: {current_sandbox} -> {sandbox_version}")
|
||||
print(f"Deploy: {current_deploy} -> {deploy_version}")
|
||||
print(f"Wrapper: {current_wrapper} -> {wrapper_version}")
|
||||
PY
|
||||
|
||||
@@ -412,15 +521,17 @@ jobs:
|
||||
BROWSER_VERSION: ${{ steps.versions.outputs.browser_version }}
|
||||
MCP_VERSION: ${{ steps.versions.outputs.mcp_version }}
|
||||
SANDBOX_VERSION: ${{ steps.versions.outputs.sandbox_version }}
|
||||
DEPLOY_VERSION: ${{ steps.versions.outputs.deploy_version }}
|
||||
WRAPPER_VERSION: ${{ steps.versions.outputs.wrapper_version }}
|
||||
INPUT_SKIP_AGENTS: ${{ inputs.skip_agents }}
|
||||
INPUT_SKIP_CODE: ${{ inputs.skip_code }}
|
||||
INPUT_SKIP_BOT: ${{ inputs.skip_bot }}
|
||||
INPUT_SKIP_TRAIN: ${{ inputs.skip_train }}
|
||||
INPUT_SKIP_BROWSER: ${{ inputs.skip_browser }}
|
||||
INPUT_SKIP_MCP: ${{ inputs.skip_mcp }}
|
||||
INPUT_SKIP_SANDBOX: ${{ inputs.skip_sandbox }}
|
||||
INPUT_SKIP_WRAPPER: ${{ inputs.skip_wrapper }}
|
||||
INPUT_SKIP_AGENTS: ${{ steps.flags.outputs.skip_agents }}
|
||||
INPUT_SKIP_CODE: ${{ steps.flags.outputs.skip_code }}
|
||||
INPUT_SKIP_BOT: ${{ steps.flags.outputs.skip_bot }}
|
||||
INPUT_SKIP_TRAIN: ${{ steps.flags.outputs.skip_train }}
|
||||
INPUT_SKIP_BROWSER: ${{ steps.flags.outputs.skip_browser }}
|
||||
INPUT_SKIP_MCP: ${{ steps.flags.outputs.skip_mcp }}
|
||||
INPUT_SKIP_SANDBOX: ${{ steps.flags.outputs.skip_sandbox }}
|
||||
INPUT_SKIP_DEPLOY: ${{ steps.flags.outputs.skip_deploy }}
|
||||
INPUT_SKIP_WRAPPER: ${{ steps.flags.outputs.skip_wrapper }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
skip_agents="${INPUT_SKIP_AGENTS}"
|
||||
@@ -430,30 +541,53 @@ jobs:
|
||||
skip_browser="${INPUT_SKIP_BROWSER}"
|
||||
skip_mcp="${INPUT_SKIP_MCP}"
|
||||
skip_sandbox="${INPUT_SKIP_SANDBOX}"
|
||||
skip_deploy="${INPUT_SKIP_DEPLOY}"
|
||||
skip_wrapper="${INPUT_SKIP_WRAPPER}"
|
||||
# Returns 0 if published, 1 if definitively absent (404). Any other
|
||||
# HTTP status (PyPI outage, 5xx, rate limit) FAILS the run: guessing
|
||||
# "not published" would lead to a duplicate-upload failure midway
|
||||
# through the release, which is strictly worse than stopping here
|
||||
# before anything has been published.
|
||||
pypi_published() { # $1=package $2=version
|
||||
local code attempt
|
||||
for attempt in 1 2 3; do
|
||||
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 30 \
|
||||
"https://pypi.org/pypi/$1/$2/json" || echo "000")
|
||||
case "$code" in
|
||||
200) return 0 ;;
|
||||
404) return 1 ;;
|
||||
*) echo "PyPI check $1==$2 returned HTTP ${code} (attempt ${attempt}/3)"; sleep 5 ;;
|
||||
esac
|
||||
done
|
||||
echo "❌ Cannot determine PyPI state for $1==$2; refusing to guess." >&2
|
||||
exit 2
|
||||
}
|
||||
if [ "$skip_agents" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonaiagents/${AGENTS_VERSION}/json" >/dev/null && skip_agents=true || true
|
||||
pypi_published praisonaiagents "${AGENTS_VERSION}" && skip_agents=true || true
|
||||
fi
|
||||
if [ "$skip_code" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-code/${CODE_VERSION}/json" >/dev/null && skip_code=true || true
|
||||
pypi_published praisonai-code "${CODE_VERSION}" && skip_code=true || true
|
||||
fi
|
||||
if [ "$skip_bot" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-bot/${BOT_VERSION}/json" >/dev/null && skip_bot=true || true
|
||||
pypi_published praisonai-bot "${BOT_VERSION}" && skip_bot=true || true
|
||||
fi
|
||||
if [ "$skip_train" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-train/${TRAIN_VERSION}/json" >/dev/null && skip_train=true || true
|
||||
pypi_published praisonai-train "${TRAIN_VERSION}" && skip_train=true || true
|
||||
fi
|
||||
if [ "$skip_browser" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-browser/${BROWSER_VERSION}/json" >/dev/null && skip_browser=true || true
|
||||
pypi_published praisonai-browser "${BROWSER_VERSION}" && skip_browser=true || true
|
||||
fi
|
||||
if [ "$skip_mcp" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-mcp/${MCP_VERSION}/json" >/dev/null && skip_mcp=true || true
|
||||
pypi_published praisonai-mcp "${MCP_VERSION}" && skip_mcp=true || true
|
||||
fi
|
||||
if [ "$skip_sandbox" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai-sandbox/${SANDBOX_VERSION}/json" >/dev/null && skip_sandbox=true || true
|
||||
pypi_published praisonai-sandbox "${SANDBOX_VERSION}" && skip_sandbox=true || true
|
||||
fi
|
||||
if [ "$skip_deploy" != "true" ]; then
|
||||
pypi_published praisonai-deploy "${DEPLOY_VERSION}" && skip_deploy=true || true
|
||||
fi
|
||||
if [ "$skip_wrapper" != "true" ]; then
|
||||
curl -fsSL "https://pypi.org/pypi/praisonai/${WRAPPER_VERSION}/json" >/dev/null && skip_wrapper=true || true
|
||||
pypi_published praisonai "${WRAPPER_VERSION}" && skip_wrapper=true || true
|
||||
fi
|
||||
echo "skip_agents=${skip_agents}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_code=${skip_code}" >> "$GITHUB_OUTPUT"
|
||||
@@ -462,8 +596,9 @@ jobs:
|
||||
echo "skip_browser=${skip_browser}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_mcp=${skip_mcp}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_sandbox=${skip_sandbox}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_deploy=${skip_deploy}" >> "$GITHUB_OUTPUT"
|
||||
echo "skip_wrapper=${skip_wrapper}" >> "$GITHUB_OUTPUT"
|
||||
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_wrapper" = "true" ]; then
|
||||
if [ "$skip_agents" = "true" ] && [ "$skip_code" = "true" ] && [ "$skip_bot" = "true" ] && [ "$skip_train" = "true" ] && [ "$skip_browser" = "true" ] && [ "$skip_mcp" = "true" ] && [ "$skip_sandbox" = "true" ] && [ "$skip_deploy" = "true" ] && [ "$skip_wrapper" = "true" ]; then
|
||||
echo "All target versions already on PyPI — nothing to publish."
|
||||
fi
|
||||
|
||||
@@ -477,6 +612,7 @@ jobs:
|
||||
steps.pypi_exists.outputs.skip_browser == 'true' &&
|
||||
steps.pypi_exists.outputs.skip_mcp == 'true' &&
|
||||
steps.pypi_exists.outputs.skip_sandbox == 'true' &&
|
||||
steps.pypi_exists.outputs.skip_deploy == 'true' &&
|
||||
steps.pypi_exists.outputs.skip_wrapper == 'true'
|
||||
run: echo "Idempotent skip — target versions already on PyPI."
|
||||
|
||||
@@ -484,34 +620,43 @@ jobs:
|
||||
if: inputs.dry_run
|
||||
run: |
|
||||
echo "Dry run — no publish, commit, or push."
|
||||
echo "Only: ${{ inputs.only }}"
|
||||
echo "Agents version: ${{ steps.versions.outputs.agents_version }}"
|
||||
echo "Code version: ${{ steps.versions.outputs.code_version }}"
|
||||
echo "Bot version: ${{ steps.versions.outputs.bot_version }}"
|
||||
echo "Train version: ${{ steps.versions.outputs.train_version }}"
|
||||
echo "Browser version: ${{ steps.versions.outputs.browser_version }}"
|
||||
echo "MCP version: ${{ steps.versions.outputs.mcp_version }}"
|
||||
echo "Sandbox version: ${{ steps.versions.outputs.sandbox_version }}"
|
||||
echo "Deploy version: ${{ steps.versions.outputs.deploy_version }}"
|
||||
echo "Wrapper version: ${{ steps.versions.outputs.wrapper_version }}"
|
||||
echo ""
|
||||
if [ "${{ inputs.skip_agents }}" != "true" ]; then
|
||||
if [ "${{ steps.flags.outputs.skip_agents }}" != "true" ]; then
|
||||
echo "Would publish praisonaiagents ${{ steps.versions.outputs.agents_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_code }}" != "true" ]; then
|
||||
if [ "${{ steps.flags.outputs.skip_code }}" != "true" ]; then
|
||||
echo "Would publish praisonai-code ${{ steps.versions.outputs.code_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_bot }}" != "true" ]; then
|
||||
if [ "${{ steps.flags.outputs.skip_bot }}" != "true" ]; then
|
||||
echo "Would publish praisonai-bot ${{ steps.versions.outputs.bot_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_train }}" != "true" ]; then
|
||||
if [ "${{ steps.flags.outputs.skip_train }}" != "true" ]; then
|
||||
echo "Would publish praisonai-train ${{ steps.versions.outputs.train_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_browser }}" != "true" ]; then
|
||||
if [ "${{ steps.flags.outputs.skip_browser }}" != "true" ]; then
|
||||
echo "Would publish praisonai-browser ${{ steps.versions.outputs.browser_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
|
||||
if [ "${{ steps.flags.outputs.skip_mcp }}" != "true" ]; then
|
||||
echo "Would publish praisonai-mcp ${{ steps.versions.outputs.mcp_version }}"
|
||||
fi
|
||||
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
|
||||
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }}"
|
||||
if [ "${{ steps.flags.outputs.skip_sandbox }}" != "true" ]; then
|
||||
echo "Would publish praisonai-sandbox ${{ steps.versions.outputs.sandbox_version }}"
|
||||
fi
|
||||
if [ "${{ steps.flags.outputs.skip_deploy }}" != "true" ]; then
|
||||
echo "Would publish praisonai-deploy ${{ steps.versions.outputs.deploy_version }}"
|
||||
fi
|
||||
if [ "${{ steps.flags.outputs.skip_wrapper }}" != "true" ]; then
|
||||
echo "Would run bump_and_release.py ${{ steps.versions.outputs.wrapper_version }} --agents ${{ steps.versions.outputs.agents_version }} --code-pin ${{ steps.versions.outputs.code_version }} --bot-pin ${{ steps.versions.outputs.bot_version }} --train-pin ${{ steps.versions.outputs.train_version }} --browser-pin ${{ steps.versions.outputs.browser_version }} --mcp-pin ${{ steps.versions.outputs.mcp_version }} --sandbox-pin ${{ steps.versions.outputs.sandbox_version }} --deploy-pin ${{ steps.versions.outputs.deploy_version }}"
|
||||
echo "Would uv publish praisonai ${{ steps.versions.outputs.wrapper_version }}"
|
||||
fi
|
||||
|
||||
@@ -539,7 +684,9 @@ jobs:
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonaiagents on PyPI
|
||||
if: >-
|
||||
@@ -568,19 +715,6 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit agents version bump
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_agents != true &&
|
||||
steps.pypi_exists.outputs.skip_agents != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add src/praisonai-agents/pyproject.toml src/praisonai-agents/uv.lock
|
||||
git diff --cached --quiet && { echo "No agents files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonaiagents to ${{ steps.versions.outputs.agents_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Publish praisonai-code
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
@@ -609,7 +743,9 @@ jobs:
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai-code on PyPI
|
||||
if: >-
|
||||
@@ -638,19 +774,6 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit praisonai-code version bump
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_code != true &&
|
||||
steps.pypi_exists.outputs.skip_code != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add src/praisonai-code/pyproject.toml src/praisonai-code/uv.lock src/praisonai-code/praisonai_code/__init__.py
|
||||
git diff --cached --quiet && { echo "No code files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonai-code to ${{ steps.versions.outputs.code_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Publish praisonai-bot
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
@@ -679,7 +802,9 @@ jobs:
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai-bot on PyPI
|
||||
if: >-
|
||||
@@ -708,19 +833,6 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit praisonai-bot version bump
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_bot != true &&
|
||||
steps.pypi_exists.outputs.skip_bot != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add src/praisonai-bot/pyproject.toml src/praisonai-bot/uv.lock src/praisonai-bot/praisonai_bot/_version.py
|
||||
git diff --cached --quiet && { echo "No bot files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonai-bot to ${{ steps.versions.outputs.bot_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Publish praisonai-train
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
@@ -749,7 +861,9 @@ jobs:
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai-train on PyPI
|
||||
if: >-
|
||||
@@ -778,19 +892,6 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit praisonai-train version bump
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_train != true &&
|
||||
steps.pypi_exists.outputs.skip_train != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add src/praisonai-train/pyproject.toml src/praisonai-train/uv.lock src/praisonai-train/praisonai_train/_version.py
|
||||
git diff --cached --quiet && { echo "No train files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonai-train to ${{ steps.versions.outputs.train_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Publish praisonai-browser
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
@@ -819,7 +920,9 @@ jobs:
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai-browser on PyPI
|
||||
if: >-
|
||||
@@ -848,19 +951,6 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit praisonai-browser version bump
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_browser != true &&
|
||||
steps.pypi_exists.outputs.skip_browser != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add src/praisonai-browser/pyproject.toml src/praisonai-browser/uv.lock src/praisonai-browser/praisonai_browser/_version.py
|
||||
git diff --cached --quiet && { echo "No browser files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonai-browser to ${{ steps.versions.outputs.browser_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Publish praisonai-mcp
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
@@ -889,7 +979,9 @@ jobs:
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai-mcp on PyPI
|
||||
if: >-
|
||||
@@ -918,19 +1010,6 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit praisonai-mcp version bump
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_mcp != true &&
|
||||
steps.pypi_exists.outputs.skip_mcp != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add src/praisonai-mcp/pyproject.toml src/praisonai-mcp/uv.lock src/praisonai-mcp/praisonai_mcp/_version.py
|
||||
git diff --cached --quiet && { echo "No mcp files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonai-mcp to ${{ steps.versions.outputs.mcp_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
|
||||
- name: Publish praisonai-sandbox
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
@@ -959,7 +1038,9 @@ jobs:
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
uv publish
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai-sandbox on PyPI
|
||||
if: >-
|
||||
@@ -988,20 +1069,101 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
- name: Commit praisonai-sandbox version bump
|
||||
- name: Publish praisonai-deploy
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_sandbox != true &&
|
||||
steps.pypi_exists.outputs.skip_sandbox != 'true'
|
||||
inputs.skip_deploy != true &&
|
||||
steps.pypi_exists.outputs.skip_deploy != 'true'
|
||||
working-directory: src/praisonai-deploy
|
||||
env:
|
||||
NEW_VERSION: ${{ steps.versions.outputs.deploy_version }}
|
||||
CURRENT: ${{ steps.versions.outputs.current_deploy }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git add src/praisonai-sandbox/pyproject.toml src/praisonai-sandbox/uv.lock src/praisonai-sandbox/praisonai_sandbox/_version.py
|
||||
git diff --cached --quiet && { echo "No sandbox files to commit"; exit 0; }
|
||||
git commit -m "Bump praisonai-sandbox to ${{ steps.versions.outputs.sandbox_version }}"
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
python <<'PY'
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
new = os.environ["NEW_VERSION"]
|
||||
current = os.environ["CURRENT"]
|
||||
pyproject = Path("pyproject.toml")
|
||||
content = pyproject.read_text()
|
||||
pyproject.write_text(content.replace(f'version = "{current}"', f'version = "{new}"', 1))
|
||||
version_py = Path("praisonai_deploy/_version.py")
|
||||
version_content = version_py.read_text()
|
||||
version_py.write_text(re.sub(r'__version__ = "[^"]+"', f'__version__ = "{new}"', version_content, count=1))
|
||||
PY
|
||||
|
||||
rm -rf dist
|
||||
uv lock
|
||||
uv build
|
||||
# --check-url makes re-uploads idempotent: files already on PyPI are
|
||||
# skipped instead of failing the publish (partial-upload recovery).
|
||||
uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai-deploy on PyPI
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_deploy != true &&
|
||||
steps.pypi_exists.outputs.skip_deploy != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE="praisonai-deploy"
|
||||
VERSION="${{ steps.versions.outputs.deploy_version }}"
|
||||
MAX_WAIT=600
|
||||
INTERVAL=30
|
||||
START=$(date +%s)
|
||||
|
||||
while true; do
|
||||
if curl -fsSL "https://pypi.org/pypi/${PACKAGE}/${VERSION}/json" >/dev/null; then
|
||||
echo "✅ ${PACKAGE}==${VERSION} is available on PyPI"
|
||||
exit 0
|
||||
fi
|
||||
ELAPSED=$(( $(date +%s) - START ))
|
||||
if [ "$ELAPSED" -ge "$MAX_WAIT" ]; then
|
||||
echo "❌ Timeout waiting for ${PACKAGE}==${VERSION} on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
echo "⏳ Waiting for ${PACKAGE}==${VERSION} ($(( MAX_WAIT - ELAPSED ))s remaining...)"
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
# All per-package version bumps are still uncommitted at this point: the
|
||||
# wrapper release below stages every one of them (bump_and_release.py's
|
||||
# release_files list) into a SINGLE "Release v…" commit.
|
||||
|
||||
# App tokens expire after 1h and this job can exceed that (9 packages ×
|
||||
# up to a 600s PyPI wait). Re-mint so the final commit/tag/push/release
|
||||
# never runs on an expired token.
|
||||
- name: Refresh GitHub App token
|
||||
id: app-token-final
|
||||
if: ${{ !cancelled() && inputs.dry_run != true }}
|
||||
# A failed re-mint must not fail the release: the original token may
|
||||
# still be valid, and the next step only swaps tokens on success.
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@v1
|
||||
with:
|
||||
app-id: ${{ secrets.CLAUDE_APP_ID }}
|
||||
private-key: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: ${{ github.event.repository.name }}
|
||||
|
||||
# Guarded on the refresh actually succeeding: if the re-mint failed
|
||||
# (API blip), the ORIGINAL token — still wired into the remote URL and
|
||||
# GH_TOKEN — may well be valid, and overwriting it with an empty string
|
||||
# would turn a recoverable situation into a guaranteed auth failure.
|
||||
- name: Use refreshed GitHub App token
|
||||
if: ${{ !cancelled() && inputs.dry_run != true && steps.app-token-final.outcome == 'success' }}
|
||||
env:
|
||||
APP_TOKEN: ${{ steps.app-token-final.outputs.token }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git remote set-url origin "https://x-access-token:${APP_TOKEN}@github.com/${REPO}.git"
|
||||
echo "GH_TOKEN=${APP_TOKEN}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Bump and release wrapper
|
||||
id: wrapper
|
||||
if: >-
|
||||
inputs.dry_run != true &&
|
||||
inputs.skip_wrapper != true &&
|
||||
@@ -1016,7 +1178,8 @@ jobs:
|
||||
--browser-pin "${{ steps.versions.outputs.browser_version }}" \
|
||||
--mcp-pin "${{ steps.versions.outputs.mcp_version }}" \
|
||||
--sandbox-pin "${{ steps.versions.outputs.sandbox_version }}" \
|
||||
--wait --max-wait 600 --no-add-all
|
||||
--deploy-pin "${{ steps.versions.outputs.deploy_version }}" \
|
||||
--wait --max-wait 600 --no-add-all --force
|
||||
|
||||
- name: Publish praisonai wrapper to PyPI
|
||||
if: >-
|
||||
@@ -1024,7 +1187,7 @@ jobs:
|
||||
inputs.skip_wrapper != true &&
|
||||
steps.pypi_exists.outputs.skip_wrapper != 'true'
|
||||
working-directory: src/praisonai
|
||||
run: uv publish
|
||||
run: uv publish --check-url https://pypi.org/simple/
|
||||
|
||||
- name: Wait for praisonai on PyPI
|
||||
if: >-
|
||||
@@ -1053,47 +1216,139 @@ jobs:
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
|
||||
# Actually probes PyPI — previously this step only echoed checkmarks
|
||||
# from the input flags, so it "verified" publishes that never happened.
|
||||
- name: Verify PyPI versions
|
||||
if: inputs.dry_run != true
|
||||
run: |
|
||||
set -euo pipefail
|
||||
AGENTS="${{ steps.versions.outputs.agents_version }}"
|
||||
CODE="${{ steps.versions.outputs.code_version }}"
|
||||
BOT="${{ steps.versions.outputs.bot_version }}"
|
||||
TRAIN="${{ steps.versions.outputs.train_version }}"
|
||||
BROWSER="${{ steps.versions.outputs.browser_version }}"
|
||||
MCP="${{ steps.versions.outputs.mcp_version }}"
|
||||
SANDBOX="${{ steps.versions.outputs.sandbox_version }}"
|
||||
WRAPPER="${{ steps.versions.outputs.wrapper_version }}"
|
||||
verify() { # $1=pypi-name $2=version $3=effective-skip
|
||||
if [ "$3" = "true" ]; then
|
||||
echo "⏭️ $1 skipped"
|
||||
return 0
|
||||
fi
|
||||
if curl -fsSL --max-time 30 "https://pypi.org/pypi/$1/$2/json" >/dev/null; then
|
||||
echo "✅ $1==$2"
|
||||
else
|
||||
echo "❌ $1==$2 NOT found on PyPI"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
verify praisonaiagents "${{ steps.versions.outputs.agents_version }}" "${{ steps.flags.outputs.skip_agents }}"
|
||||
verify praisonai-code "${{ steps.versions.outputs.code_version }}" "${{ steps.flags.outputs.skip_code }}"
|
||||
verify praisonai-bot "${{ steps.versions.outputs.bot_version }}" "${{ steps.flags.outputs.skip_bot }}"
|
||||
verify praisonai-train "${{ steps.versions.outputs.train_version }}" "${{ steps.flags.outputs.skip_train }}"
|
||||
verify praisonai-browser "${{ steps.versions.outputs.browser_version }}" "${{ steps.flags.outputs.skip_browser }}"
|
||||
verify praisonai-mcp "${{ steps.versions.outputs.mcp_version }}" "${{ steps.flags.outputs.skip_mcp }}"
|
||||
verify praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}" "${{ steps.flags.outputs.skip_sandbox }}"
|
||||
verify praisonai-deploy "${{ steps.versions.outputs.deploy_version }}" "${{ steps.flags.outputs.skip_deploy }}"
|
||||
verify praisonai "${{ steps.versions.outputs.wrapper_version }}" "${{ steps.flags.outputs.skip_wrapper }}"
|
||||
|
||||
if [ "${{ inputs.skip_agents }}" != "true" ]; then
|
||||
echo "✅ praisonaiagents==${AGENTS}"
|
||||
# Safety net. On a clean run the wrapper's "Release v…" commit already
|
||||
# carries every bump and this is a no-op. It only fires when the single
|
||||
# commit could not happen: a package publish failed midway (those versions
|
||||
# are already live on PyPI, so the bumps must not be lost), skip_wrapper
|
||||
# was set, or the wrapper itself failed.
|
||||
- name: Persist any uncommitted version bumps
|
||||
if: ${{ !cancelled() && inputs.dry_run != true }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# If the wrapper release did not complete, drop any uncommitted
|
||||
# wrapper-side rewrites (version.py, Dockerfiles, pins): committing
|
||||
# them would put a wrapper version on main that never reached PyPI,
|
||||
# and the next auto-bump would skip past it forever. Committed
|
||||
# changes are untouched (checkout only reverts the working tree),
|
||||
# and if the wrapper was skipped these files were never modified.
|
||||
if [ "${{ steps.wrapper.outcome }}" != "success" ]; then
|
||||
git checkout -- \
|
||||
src/praisonai/praisonai/version.py \
|
||||
src/praisonai/pyproject.toml \
|
||||
src/praisonai/uv.lock \
|
||||
src/praisonai/README.md \
|
||||
src/praisonai/praisonai.rb \
|
||||
src/praisonai-deploy/praisonai_deploy/docker.py \
|
||||
docker/ 2>/dev/null || true
|
||||
fi
|
||||
# -u = tracked modifications only; uv build output under dist/ is
|
||||
# untracked and stays out of the commit.
|
||||
git add -u
|
||||
git diff --cached --quiet && { echo "Nothing left to commit"; exit 0; }
|
||||
# Name the packages actually staged, so a single-package release
|
||||
# (every other skip_* set) reads "bump praisonai-mcp" rather than a
|
||||
# generic message. Falls back when nothing under src/ is staged.
|
||||
PKGS="$(git diff --cached --name-only \
|
||||
| sed -n 's#^src/\([^/]*\)/.*#\1#p' | sort -u | tr '\n' ' ' | sed 's/ *$//')"
|
||||
if [ -n "$PKGS" ]; then
|
||||
MSG="chore(release): bump ${PKGS} [skip ci]"
|
||||
else
|
||||
MSG="chore(release): persist version bumps [skip ci]"
|
||||
fi
|
||||
echo "Commit message: ${MSG}"
|
||||
git commit -m "$MSG"
|
||||
# This step fires exactly when the release went sideways — i.e. when
|
||||
# main is most likely to have moved — so the rebase+push retries.
|
||||
# Losing this commit deadlocks future auto-releases: the packages
|
||||
# are on PyPI at N+1 while the repo says N, so every later run
|
||||
# detects "already published" and skips both publish and bump.
|
||||
for attempt in 1 2 3; do
|
||||
git fetch origin main
|
||||
if ! git rebase origin/main; then
|
||||
git rebase --abort || true
|
||||
# During rebase, "theirs" is the commit being replayed — the
|
||||
# version bumps — so they win any conflict.
|
||||
if ! git rebase -X theirs origin/main; then
|
||||
git rebase --abort || true
|
||||
echo "Rebase attempt ${attempt}/3 failed"
|
||||
sleep 5
|
||||
continue
|
||||
fi
|
||||
fi
|
||||
if git push origin main; then
|
||||
echo "Pushed version bumps (attempt ${attempt}/3)"
|
||||
exit 0
|
||||
fi
|
||||
echo "Push attempt ${attempt}/3 rejected; retrying..."
|
||||
sleep 5
|
||||
done
|
||||
echo "❌ Could not push version bumps after 3 attempts."
|
||||
echo " Repo is now BEHIND PyPI; commit the version files on main manually"
|
||||
echo " or future auto-releases will no-op ('already published')."
|
||||
exit 1
|
||||
|
||||
if [ "${{ inputs.skip_code }}" != "true" ]; then
|
||||
echo "✅ praisonai-code==${CODE}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_bot }}" != "true" ]; then
|
||||
echo "✅ praisonai-bot==${BOT}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_train }}" != "true" ]; then
|
||||
echo "✅ praisonai-train==${TRAIN}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_browser }}" != "true" ]; then
|
||||
echo "✅ praisonai-browser==${BROWSER}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_mcp }}" != "true" ]; then
|
||||
echo "✅ praisonai-mcp==${MCP}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_sandbox }}" != "true" ]; then
|
||||
echo "✅ praisonai-sandbox==${SANDBOX}"
|
||||
fi
|
||||
|
||||
if [ "${{ inputs.skip_wrapper }}" != "true" ]; then
|
||||
echo "✅ praisonai==${WRAPPER}"
|
||||
fi
|
||||
# Dedicated releases (skip_wrapper) create no "Release v…" tag — the
|
||||
# wrapper drives that — so tag each published package individually for
|
||||
# provenance. Full releases keep their single v… tag unchanged.
|
||||
- name: Tag dedicated package releases
|
||||
if: >-
|
||||
${{ !cancelled() && inputs.dry_run != true &&
|
||||
steps.flags.outputs.skip_wrapper == 'true' &&
|
||||
steps.versions.outcome == 'success' }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_pkg() { # $1=effective-skip $2=pypi-name $3=version
|
||||
if [ "$1" = "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
local tag="$2-v$3"
|
||||
# Only tag what is confirmed on PyPI — a package whose publish
|
||||
# failed must not get a tag claiming it shipped.
|
||||
if ! curl -fsSL --max-time 30 "https://pypi.org/pypi/$2/$3/json" >/dev/null; then
|
||||
echo "⏭️ $2==$3 not on PyPI; not tagging"
|
||||
return 0
|
||||
fi
|
||||
if git rev-parse -q --verify "refs/tags/${tag}" >/dev/null; then
|
||||
echo "tag ${tag} already exists"
|
||||
return 0
|
||||
fi
|
||||
git tag "${tag}"
|
||||
git push origin "refs/tags/${tag}"
|
||||
echo "🏷️ ${tag}"
|
||||
}
|
||||
tag_pkg "${{ steps.flags.outputs.skip_agents }}" praisonaiagents "${{ steps.versions.outputs.agents_version }}"
|
||||
tag_pkg "${{ steps.flags.outputs.skip_code }}" praisonai-code "${{ steps.versions.outputs.code_version }}"
|
||||
tag_pkg "${{ steps.flags.outputs.skip_bot }}" praisonai-bot "${{ steps.versions.outputs.bot_version }}"
|
||||
tag_pkg "${{ steps.flags.outputs.skip_train }}" praisonai-train "${{ steps.versions.outputs.train_version }}"
|
||||
tag_pkg "${{ steps.flags.outputs.skip_browser }}" praisonai-browser "${{ steps.versions.outputs.browser_version }}"
|
||||
tag_pkg "${{ steps.flags.outputs.skip_mcp }}" praisonai-mcp "${{ steps.versions.outputs.mcp_version }}"
|
||||
tag_pkg "${{ steps.flags.outputs.skip_sandbox }}" praisonai-sandbox "${{ steps.versions.outputs.sandbox_version }}"
|
||||
tag_pkg "${{ steps.flags.outputs.skip_deploy }}" praisonai-deploy "${{ steps.versions.outputs.deploy_version }}"
|
||||
@@ -5,6 +5,9 @@ on:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'src/praisonai-deploy/infra/**'
|
||||
- 'src/praisonai-bot/infra/**'
|
||||
- 'scripts/check_helm_charts.sh'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
@@ -12,6 +15,9 @@ on:
|
||||
branches: [ main, develop ]
|
||||
paths:
|
||||
- 'src/**'
|
||||
- 'src/praisonai-deploy/infra/**'
|
||||
- 'src/praisonai-bot/infra/**'
|
||||
- 'scripts/check_helm_charts.sh'
|
||||
- 'pyproject.toml'
|
||||
- '.github/workflows/test-core.yml'
|
||||
- '.github/actions/install-monorepo-packages/**'
|
||||
@@ -114,11 +120,17 @@ jobs:
|
||||
extra_ignore: ""
|
||||
pythonpath: >-
|
||||
${{ github.workspace }}/src/praisonai-agents
|
||||
- shard: deploy
|
||||
workdir: src/praisonai-deploy
|
||||
paths: >-
|
||||
tests/
|
||||
extra_ignore: ""
|
||||
pythonpath: >-
|
||||
${{ github.workspace }}/src/praisonai-agents
|
||||
- shard: subdirs
|
||||
paths: >-
|
||||
tests/unit/scheduler/
|
||||
tests/unit/integrations/
|
||||
tests/unit/deploy/
|
||||
tests/unit/mcp/
|
||||
tests/unit/knowledge/
|
||||
tests/unit/llm/
|
||||
@@ -183,6 +195,9 @@ jobs:
|
||||
if [ "$WORKDIR" = "src/praisonai-sandbox" ]; then
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
|
||||
fi
|
||||
if [ "$WORKDIR" = "src/praisonai-deploy" ]; then
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
|
||||
fi
|
||||
if [ "$WORKDIR" = "src/praisonai-mcp" ]; then
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c12_mcp_imports.sh"
|
||||
fi
|
||||
@@ -268,9 +283,27 @@ jobs:
|
||||
--ignore=tests/integration/test_tracker_complex_tasks.py \
|
||||
--ignore=tests/integration/test_serve_integration.py
|
||||
|
||||
helm-charts:
|
||||
name: helm-charts
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Helm
|
||||
uses: azure/setup-helm@v3
|
||||
with:
|
||||
version: v3.14.4
|
||||
- name: Lint and template Helm charts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_helm_charts.sh"
|
||||
|
||||
test-core:
|
||||
name: test-core
|
||||
needs: [test-core-collect, test-core-unit, test-core-integration]
|
||||
needs: [test-core-collect, test-core-unit, test-core-integration, helm-charts]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
@@ -282,8 +315,9 @@ jobs:
|
||||
collect='${{ needs.test-core-collect.result }}'
|
||||
unit='${{ needs.test-core-unit.result }}'
|
||||
integration='${{ needs.test-core-integration.result }}'
|
||||
echo "collect=$collect unit=$unit integration=$integration"
|
||||
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ]; then
|
||||
helm='${{ needs.helm-charts.result }}'
|
||||
echo "collect=$collect unit=$unit integration=$integration helm=$helm"
|
||||
if [ "$collect" != "success" ] || [ "$unit" != "success" ] || [ "$integration" != "success" ] || [ "$helm" != "success" ]; then
|
||||
echo "Core Tests failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -86,6 +86,7 @@ jobs:
|
||||
tests/unit/test_c11_browser_backward_compat.py \
|
||||
tests/unit/test_c12_mcp_backward_compat.py \
|
||||
tests/unit/test_c13_sandbox_backward_compat.py \
|
||||
tests/unit/test_c14_deploy_backward_compat.py \
|
||||
-q --tb=line --timeout=30 --maxfail=5
|
||||
python -m pytest tests/unit/cli/ \
|
||||
../praisonai-bot/tests/unit/cli/test_onboard_command.py \
|
||||
@@ -269,10 +270,26 @@ jobs:
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c13_sandbox_imports.sh"
|
||||
python -m pytest src/praisonai-sandbox/tests -q -m "not network"
|
||||
|
||||
- name: Standalone praisonai-deploy smoke (C14)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python -m venv /tmp/praisonai-deploy-standalone
|
||||
/tmp/praisonai-deploy-standalone/bin/pip install -q -e src/praisonai-agents -e src/praisonai-deploy
|
||||
/tmp/praisonai-deploy-standalone/bin/python -c "
|
||||
from praisonai_deploy._version import __version__ as pkg_version
|
||||
import praisonai_deploy
|
||||
assert praisonai_deploy.__version__ == pkg_version
|
||||
assert 'main' not in __import__('sys').modules
|
||||
print('deploy standalone ok', praisonai_deploy.__version__)
|
||||
"
|
||||
/tmp/praisonai-deploy-standalone/bin/praisonai-deploy --help
|
||||
bash "$GITHUB_WORKSPACE/scripts/check_c14_deploy_imports.sh"
|
||||
python -m pytest src/praisonai-deploy/tests -q -m "not network"
|
||||
|
||||
- name: Audit hybrid module imports (C8 full stack)
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai
|
||||
pip install -q -e src/praisonai-agents -e src/praisonai-code -e src/praisonai-bot -e src/praisonai-train -e src/praisonai-browser -e src/praisonai-mcp -e src/praisonai-sandbox -e src/praisonai-deploy -e src/praisonai
|
||||
python scripts/audit_hybrid_modules.py
|
||||
|
||||
- name: Smoke Test Summary
|
||||
|
||||
@@ -7,9 +7,9 @@ You are working on the PraisonAI project.
|
||||
- Be concise and helpful in responses
|
||||
- Test implementation thoroughly
|
||||
- Ensure backward compatibility with existing APIs
|
||||
- Follow protocol-driven design across the eight packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, integrations/serve/dashboard in the `praisonai/` wrapper
|
||||
- Follow protocol-driven design across the nine packages: core protocols in `praisonaiagents/`, agentic terminal CLI in `praisonai-code/`, bots/gateway in `praisonai-bot/`, LLM fine-tuning + agent training in `praisonai-train/`, browser automation in `praisonai-browser/`, MCP server host in `praisonai-mcp/`, sandbox backends in `praisonai-sandbox/`, deployment in `praisonai-deploy/`, integrations/serve/dashboard in the `praisonai/` wrapper
|
||||
- Preserve old `praisonai.*` import paths via shims when moving code between packages (see §2.3 in `src/praisonai-agents/AGENTS.md`; shim helpers in `src/praisonai/praisonai/cli/_shim.py`)
|
||||
- Package boundaries and dependency rules: `ARCHITECTURE.md` §2 (Tier 2 packages must never PyPI-depend on the wrapper; cross-tier access goes through lazy `_*_bridge` modules)
|
||||
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13)
|
||||
- Boundary manifests: `src/praisonai/tests/PRAISONAI_BOT_MANIFEST.md` (C9), `src/praisonai/tests/PRAISONAI_TRAIN_MANIFEST.md` (C10), `src/praisonai/tests/PRAISONAI_BROWSER_MANIFEST.md` (C11), `src/praisonai/tests/PRAISONAI_MCP_MANIFEST.md` (C12), `src/praisonai/tests/PRAISONAI_SANDBOX_MANIFEST.md` (C13), `src/praisonai/tests/PRAISONAI_DEPLOY_MANIFEST.md` (C14)
|
||||
- When reviewing a PR or an issue, evaluate whether the change addresses a framework concern or a user goal, and design its surface (params, naming, defaults) accordingly
|
||||
- The aim of this package is to stay **lightweight and powerful**. Do a critical review at each stage — when triaging an issue, when planning a fix, and when reviewing/implementing a PR. Reject scope creep for the sake of adding features: if a capability already exists (e.g. via existing Agent params like `instructions`/`backstory`/`tools`/`hooks`/`memory`), prefer it over a new API surface. A change must genuinely strengthen the SDK (simpler, more robust, more user-friendly) — do not add knobs, params, modules, or exports that have no live consumer or that merely duplicate existing behaviour.
|
||||
+29
-10
@@ -1,6 +1,6 @@
|
||||
# PraisonAI Architecture
|
||||
|
||||
> **Last updated:** 2026-07-17 (C13 — eight-package tiered model)
|
||||
> **Last updated:** 2026-07-31 (C14 — nine-package tiered model)
|
||||
>
|
||||
> Strategic architecture document for PraisonAI — a multi-agent AI framework.
|
||||
> Covers **Python tiered package model (C7.1 + C9 + C10)**, system design, runtime
|
||||
@@ -11,7 +11,7 @@
|
||||
## Table of Contents
|
||||
|
||||
1. [Executive Summary](#1-executive-summary)
|
||||
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13)
|
||||
2. [Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)](#2-python-tiered-package-model-c71--c9--c10--c11--c12--c13--c14)
|
||||
3. [System Overview](#3-system-overview)
|
||||
4. [Layered Architecture](#4-layered-architecture)
|
||||
5. [Core Data Contracts](#5-core-data-contracts)
|
||||
@@ -51,17 +51,18 @@ Orchestration + Observability** core to unlock adoption and trust:
|
||||
|
||||
---
|
||||
|
||||
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13)
|
||||
## 2. Python Tiered Package Model (C7.1 + C9 + C10 + C11 + C12 + C13 + C14)
|
||||
|
||||
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai`
|
||||
**Release:** v4.6.110+ · `praisonaiagents` · `praisonai-code` · `praisonai-bot` · `praisonai-train` · `praisonai-browser` · `praisonai-mcp` · `praisonai-sandbox` · `praisonai-deploy` · `praisonai`
|
||||
|
||||
The Python monorepo publishes eight packages in three tiers with strict dependency
|
||||
The Python monorepo publishes nine packages in three tiers with strict dependency
|
||||
direction. C7 delivered a standalone agentic hot path; C7.1 formalised
|
||||
code/wrapper ownership; C9 extracted bots, gateway, and channel CLI into
|
||||
`praisonai-bot`; C10 extracted LLM fine-tuning and agent training into
|
||||
`praisonai-train`; C11 extracted browser automation into `praisonai-browser`;
|
||||
C12 extracted the heavy MCP host into `praisonai-mcp`.
|
||||
C13 extracted sandbox backends into `praisonai-sandbox`.
|
||||
C14 extracted deployment (API, Docker, cloud) into `praisonai-deploy`.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
@@ -69,13 +70,14 @@ flowchart TB
|
||||
Agents["praisonaiagents<br/>Agent, tools, memory, hooks, protocols"]
|
||||
end
|
||||
|
||||
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox]
|
||||
subgraph tier2 [Tier 2 — Terminal + Bot + Train + Browser + MCP + Sandbox + Deploy]
|
||||
Code["praisonai-code<br/>run, chat, code, Typer, runtime, LLM"]
|
||||
Bot["praisonai-bot<br/>bots, gateway, channel CLI, OS daemon"]
|
||||
Train["praisonai-train<br/>LLM fine-tuning, agent training"]
|
||||
Browser["praisonai-browser<br/>extension bridge, CDP, Playwright"]
|
||||
MCP["praisonai-mcp<br/>MCP server host, auth, capability adapters"]
|
||||
Sandbox["praisonai-sandbox<br/>Docker, E2B, Modal, Sandlock backends"]
|
||||
Deploy["praisonai-deploy<br/>API, Docker, AWS/Azure/GCP deploy"]
|
||||
end
|
||||
|
||||
subgraph tier3 [Tier 3 — Wrapper]
|
||||
@@ -88,36 +90,53 @@ flowchart TB
|
||||
Agents --> Browser
|
||||
Agents --> MCP
|
||||
Agents --> Sandbox
|
||||
Agents --> Deploy
|
||||
Agents --> Wrapper
|
||||
Code -.->|"lazy _bot_bridge"| Bot
|
||||
Code -.->|"lazy _train_bridge"| Train
|
||||
Code -.->|"lazy _browser_bridge"| Browser
|
||||
Code -.->|"lazy _mcp_bridge"| MCP
|
||||
Code -.->|"lazy _sandbox_bridge"| Sandbox
|
||||
Code -.->|"lazy _deploy_bridge"| Deploy
|
||||
Code -.->|"lazy _wrapper_bridge"| Wrapper
|
||||
Bot -.->|"lazy _code_bridge / _wrapper_bridge"| Code
|
||||
Bot -.->|"lazy _wrapper_bridge"| Wrapper
|
||||
Train -.->|"lazy _code_bridge / _wrapper_bridge"| Code
|
||||
```
|
||||
|
||||
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` → `praisonai`
|
||||
**PyPI publish order:** `praisonaiagents` → `praisonai-code` + `praisonai-bot` + `praisonai-train` + `praisonai-browser` + `praisonai-mcp` + `praisonai-sandbox` + `praisonai-deploy` → `praisonai`
|
||||
|
||||
**Backward compatibility:** `praisonai.bots`, `praisonai.gateway`, `praisonai.train`,
|
||||
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, and related CLI paths remain as
|
||||
`praisonai.browser`, `praisonai.mcp_server`, `praisonai.sandbox`, `praisonai.deploy`, and related CLI paths remain as
|
||||
`alias_package` shims to `praisonai_bot.*` / `praisonai_train.*` /
|
||||
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*`.
|
||||
`praisonai_browser.*` / `praisonai_mcp.*` / `praisonai_sandbox.*` / `praisonai_deploy.*`.
|
||||
|
||||
| Tier | Package | Owns | Must not depend on |
|
||||
|------|---------|------|-------------------|
|
||||
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox` |
|
||||
| 1 | `src/praisonai-agents/` | Agent, tools, memory, hooks, `frameworks/` protocols, sandbox protocols | `praisonai`, `praisonai-code`, `praisonai-bot`, `praisonai-train`, `praisonai-browser`, `praisonai-mcp`, `praisonai-sandbox`, `praisonai-deploy` |
|
||||
| 2a | `src/praisonai-code/` | `run`/`chat`/`code`, Typer, runtime, LLM, tool resolution | **`praisonai` as a PyPI dependency** (optional lazy imports via `_wrapper_bridge` only) |
|
||||
| 2b | `src/praisonai-bot/` | Bots, gateway, channel CLI, OS daemon, gateway scheduler tick | **`praisonai` as a PyPI dependency** (optional lazy `_wrapper_bridge` for jobs/UI) |
|
||||
| 2c | `src/praisonai-train/` | LLM fine-tuning (Unsloth), agent training, `train` CLI, conda env setup | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for legacy dispatch) |
|
||||
| 2d | `src/praisonai-browser/` | Extension bridge, CDP/hybrid automation, `browser` CLI | **`praisonai` as a PyPI dependency** (none required; depends on `praisonaiagents` only) |
|
||||
| 2e | `src/praisonai-mcp/` | MCP server host, auth, transports, `mcp` CLI | **`praisonai` as a PyPI dependency** (lazy `_wrapper_bridge` for full capability registry) |
|
||||
| 2f | `src/praisonai-sandbox/` | Sandbox backends (Docker, E2B, Modal, Sandlock, SSH), `SandboxRegistry` | **`praisonai` as a PyPI dependency** (lazy `_code_bridge` for `PluginRegistry`) |
|
||||
| 2g | `src/praisonai-deploy/` | Deploy API/Docker/cloud, `deploy` CLI, scheduler integration | **`praisonai` as a PyPI dependency** (lazy `_plugin_registry` via `_code_bridge`) |
|
||||
| 3 | `src/praisonai/` | `framework_adapters/`, serve, dashboard, async jobs API | — |
|
||||
|
||||
### Repo infra (not PyPI)
|
||||
|
||||
Deployment packaging that stays in the git checkout, not in any tier-2 wheel:
|
||||
|
||||
| Path | Runtime owner | Orchestration |
|
||||
|------|---------------|---------------|
|
||||
| `src/praisonai-bot/infra/helm/praisonai-gateway/` | `praisonai-bot` (gateway) | C14 `deploy helm` wrapper |
|
||||
| `src/praisonai-deploy/infra/helm/praisonai-agents-api/` | Generated API from C14 | C14 cross-link |
|
||||
| `src/praisonai-deploy/infra/compose/agents-stack/` | Docker Compose | `praisonai deploy compose up/down` |
|
||||
| `src/praisonai-deploy/infra/starters/` | Starter scaffolds | `praisonai deploy create --template` |
|
||||
| `docker/` | Mixed (wrapper/bot dev stacks) | Not C14 — see bot manifest for `docker/bots/` |
|
||||
|
||||
Same boundary as C14: Helm/K8s manifests are **repo infra**, not `pip install praisonai-deploy` content.
|
||||
|
||||
**Config kernel:** Phase 0 `praisonai/common/` was skipped; shared config lives in
|
||||
`praisonai_code/cli/configuration/` and is reached by the bot tier via lazy
|
||||
`_code_bridge` (see `src/praisonai/tests/CONFIG_KERNEL.md`).
|
||||
|
||||
@@ -25,7 +25,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
|
||||
from praisonaiagents import Agent, AutoAgents, AutoRagAgent, ContextAgent, DeepResearchAgent, ImageAgent, PlanningAgent, PraisonAIAgents, PromptExpanderAgent, QueryRewriterAgent, create_context_agent
|
||||
```
|
||||
|
||||
Methods:
|
||||
@@ -36,13 +36,9 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">background</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>()</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_history</a>(value)</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clone_for_channel</a>() -> 'Agent'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
|
||||
@@ -180,14 +176,15 @@ Methods:
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">get_schema</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">run</a>(**kwargs) -> Any</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">get_schema</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">run</a>(**kwargs) -> Any</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">to_model_output</a>(result: Any) -> Optional[Any]</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">clear</a>() -> None</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_plugins</a>() -> int</code>
|
||||
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">discover_single_file_plugins</a>() -> int</code>
|
||||
@@ -253,9 +250,12 @@ Methods:
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memory</a>(memory_id: str, memory_type: Optional[str] = None) -> bool</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_short_term</a>(memory_id: str) -> bool</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">finalize_task_output</a>(content: str, agent_name: str, quality_score: float, threshold: float = 0.7, metrics: Dict[str, Any] = None, task_id: str = None)</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">forget</a>(**kwargs) -> int</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_all_memories</a>() -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">get_learn_context</a>() -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">learn</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">recall</a>(query: str, **kwargs) -> List[Dict[str, Any]]</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">remember</a>(content: str, **kwargs) -> str</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_all</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_entity_only</a>()</code>
|
||||
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">reset_long_term</a>()</code>
|
||||
@@ -510,6 +510,7 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reload</a>() -> Dict[str, List[str]]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">resolve</a>(selectors: List[str]) -> List[str]</code>
|
||||
@@ -767,6 +768,7 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">arun_task</a>(task_id)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aspawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart</a>(content = None, return_dict = False, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">astart_for_each</a>(inputs, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">await_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clean_json_output</a>(output: str) -> str</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">clear_state</a>() -> None</code>
|
||||
@@ -797,10 +799,11 @@ Methods:
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_task</a>(task_id)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_output_to_file</a>(task, task_output)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> None</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">save_session_state</a>(session_id: str, include_memory: bool = True) -> bool</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">set_state</a>(key: str, value: Any) -> None</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">spawn_sub_agent</a>(agent: Agent, task: Any, completion_callback: Optional[Callable[[SubAgentCompletionEvent], Any]] = None, metadata: Optional[Dict[str, Any]] = None) -> SpawnedSubAgent</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start</a>(content = None, return_dict = False, output = None, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">start_for_each</a>(inputs, **kwargs)</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">stream_emitter</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">todo_list</a>()</code>
|
||||
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
|
||||
@@ -819,7 +822,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
```
|
||||
|
||||
# CLI
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# Deployment infra has moved
|
||||
|
||||
Deployment assets are no longer at the monorepo root.
|
||||
|
||||
| Asset | New location |
|
||||
|-------|----------------|
|
||||
| Gateway Helm chart | [`src/praisonai-bot/infra/helm/praisonai-gateway/`](../src/praisonai-bot/infra/helm/praisonai-gateway/) |
|
||||
| Agents API Helm, Compose, Starters | [`src/praisonai-deploy/infra/`](../src/praisonai-deploy/infra/) |
|
||||
|
||||
See [`src/praisonai-deploy/infra/README.md`](../src/praisonai-deploy/infra/README.md).
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.157" \
|
||||
"praisonai>=4.6.162" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.157" \
|
||||
"praisonai>=4.6.162" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.157" \
|
||||
"praisonai>=4.6.162" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -36,6 +36,12 @@ if __name__ == "__main__":
|
||||
print(f"Custom agent max_rpm: {agent.max_rpm}")
|
||||
print(f"Custom agent max_execution_time: {agent.max_execution_time}")
|
||||
print(f"Custom agent max_retry_limit: {agent.max_retry_limit}")
|
||||
|
||||
# max_rpm auto-creates a live RateLimiter (not just a stored number)
|
||||
assert agent_rate_limited.max_rpm == 10
|
||||
assert agent_rate_limited._rate_limiter is not None
|
||||
assert agent_rate_limited._rate_limiter.requests_per_minute == 10
|
||||
print(f"Rate-limited agent limiter: {agent_rate_limited._rate_limiter}")
|
||||
|
||||
# Test custom agent
|
||||
print("\n--- Custom Execution ---")
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
Human Review Between Sequential AgentTeam Tasks
|
||||
================================================
|
||||
|
||||
Demonstrates a human-in-the-loop review gate between sequential tasks using the
|
||||
existing ``on_task_complete`` hook — no new API required.
|
||||
|
||||
IMPORTANT — two different ``on_task_complete`` hooks exist, with DIFFERENT
|
||||
signatures. Do not confuse them:
|
||||
|
||||
AgentTeam(hooks=MultiAgentHooksConfig(on_task_complete=fn))
|
||||
-> fn(task, task_output) # team-level; gets the Task and its output
|
||||
|
||||
Task(on_task_complete=fn)
|
||||
-> fn(task_output) # 1 param: just the TaskOutput
|
||||
-> fn(task_output, metadata) # 2+ params: TaskOutput + metadata dict
|
||||
# NOTE: NOT (task, task_output)
|
||||
|
||||
This example uses the TEAM-level hook so we get ``(task, task_output)`` and can
|
||||
filter which tasks require review by ``task.name``.
|
||||
|
||||
Approve / reject / edit:
|
||||
- approve -> return, workflow continues
|
||||
- reject -> raise; see "stopping the workflow" note below
|
||||
- edit -> mutate ``task_output.raw``; downstream tasks that read this task via
|
||||
``context=[...]`` pick up the edited text
|
||||
|
||||
Stopping the workflow on reject
|
||||
-------------------------------
|
||||
The TEAM-level hook logs and swallows exceptions, so a bare ``raise`` inside it
|
||||
does NOT stop the sequential run today. To hard-stop on reject, use the PER-TASK
|
||||
callback path with the 1-argument signature and ``fail_on_callback_error=True``
|
||||
(see ``review_output`` / ``t1_strict`` at the bottom of this file).
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Task, AgentTeam
|
||||
from praisonaiagents.config.feature_configs import MultiAgentHooksConfig
|
||||
|
||||
# Only these task names trigger a human review gate.
|
||||
REVIEW_TASKS = {"research"}
|
||||
|
||||
|
||||
# --- Team-level review gate: signature is (task, task_output) ---------------
|
||||
def review_gate(task, task_output):
|
||||
if task.name not in REVIEW_TASKS:
|
||||
return
|
||||
|
||||
print(f"\n--- Review: {task.name} ---\n{task_output.raw}\n")
|
||||
choice = input("Approve (y), reject (n), edit (e)? ").strip().lower()
|
||||
|
||||
if choice == "n":
|
||||
# NOTE: team-level hook swallows this; see per-task strict path below
|
||||
# for a version that actually halts the workflow.
|
||||
raise RuntimeError(f"Human rejected task '{task.name}'")
|
||||
|
||||
if choice == "e":
|
||||
# task.result is the same object as task_output, so mutating .raw here
|
||||
# propagates to downstream tasks that use context=[this_task].
|
||||
task_output.raw = input("Edited output: ").strip()
|
||||
|
||||
|
||||
researcher = Agent(name="Researcher", role="Researcher", goal="Research", backstory="Expert")
|
||||
writer = Agent(name="Writer", role="Writer", goal="Write", backstory="Writer")
|
||||
|
||||
t1 = Task(
|
||||
name="research",
|
||||
description="List 5 facts about {{topic}}", # {{...}} is the substitution syntax
|
||||
expected_output="5 bullets",
|
||||
agent=researcher,
|
||||
)
|
||||
t2 = Task(
|
||||
name="summary",
|
||||
description="Write 3-sentence summary",
|
||||
expected_output="3 sentences",
|
||||
agent=writer,
|
||||
context=[t1], # reads the (possibly edited) output of t1
|
||||
)
|
||||
|
||||
team = AgentTeam(
|
||||
agents=[researcher, writer],
|
||||
tasks=[t1, t2],
|
||||
process="sequential",
|
||||
variables={"topic": "REST APIs"}, # substituted into {{topic}} in task descriptions
|
||||
hooks=MultiAgentHooksConfig(on_task_complete=review_gate),
|
||||
)
|
||||
|
||||
|
||||
# --- Alternative: per-task strict gate that HALTS on reject -----------------
|
||||
# Per-task Task.on_task_complete uses the 1-argument signature (task_output).
|
||||
# With fail_on_callback_error=True a raised exception re-raises and stops the run.
|
||||
def review_output(task_output):
|
||||
print(f"\n--- Review output ---\n{task_output.raw}\n")
|
||||
if input("Approve? (y/n) ").strip().lower() == "n":
|
||||
raise RuntimeError("Human rejected output")
|
||||
|
||||
|
||||
t1_strict = Task(
|
||||
name="research",
|
||||
description="List 5 facts about {{topic}}",
|
||||
expected_output="5 bullets",
|
||||
agent=researcher,
|
||||
variables={"topic": "REST APIs"},
|
||||
on_task_complete=review_output, # signature: (task_output)
|
||||
fail_on_callback_error=True, # required so reject halts the workflow
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# NOTE: template values come from AgentTeam(variables=...), NOT start(inputs=...).
|
||||
# start() forwards **kwargs but does not consume an ``inputs`` argument.
|
||||
team.start()
|
||||
@@ -86,7 +86,7 @@ def method_1_simple_loop():
|
||||
agents=[loop_agent],
|
||||
tasks=[loop_task],
|
||||
process="workflow",
|
||||
max_iter=10 # Maximum iterations to prevent infinite loops
|
||||
execution="balanced" # max_iter=10 preset to prevent infinite loops
|
||||
)
|
||||
|
||||
print(f"🚀 Starting loop processing of {csv_file}")
|
||||
|
||||
@@ -64,7 +64,7 @@ def main():
|
||||
agents=[url_agent],
|
||||
tasks=[url_task],
|
||||
process="workflow",
|
||||
max_iter=10 # Adjust based on how many URLs you have
|
||||
execution="balanced" # max_iter=10 preset; adjust for more URLs
|
||||
)
|
||||
|
||||
print(f"🚀 Processing URLs from {csv_file}")
|
||||
|
||||
@@ -141,7 +141,7 @@ class DynamicAgentSystem:
|
||||
agents=agents,
|
||||
tasks=tasks,
|
||||
process=process,
|
||||
verbose=inputs['depth'] == 'detailed'
|
||||
output="verbose" if inputs['depth'] == 'detailed' else "silent"
|
||||
)
|
||||
|
||||
result = praison_agents.start()
|
||||
|
||||
@@ -66,7 +66,7 @@ research_task1 = Task(
|
||||
agents_session1 = AgentTeam(
|
||||
agents=[research_agent],
|
||||
tasks=[research_task1],
|
||||
session=session1, output="verbose"
|
||||
output="verbose"
|
||||
)
|
||||
|
||||
# Execute first session
|
||||
@@ -105,7 +105,7 @@ analysis_task = Task(
|
||||
agents_session2 = AgentTeam(
|
||||
agents=[analysis_agent],
|
||||
tasks=[analysis_task],
|
||||
session=session2, output="verbose"
|
||||
output="verbose"
|
||||
)
|
||||
|
||||
print("Starting analysis session with previous context...")
|
||||
@@ -155,7 +155,7 @@ synthesis_task = Task(
|
||||
agents_recovery = AgentTeam(
|
||||
agents=[synthesis_agent],
|
||||
tasks=[synthesis_task],
|
||||
session=recovery_session, output="verbose"
|
||||
output="verbose"
|
||||
)
|
||||
|
||||
print("Starting recovery session with full context from previous sessions...")
|
||||
@@ -205,7 +205,7 @@ review_task = Task(
|
||||
agents_reviewer = AgentTeam(
|
||||
agents=[review_agent],
|
||||
tasks=[review_task],
|
||||
session=reviewer_session, output="verbose"
|
||||
output="verbose"
|
||||
)
|
||||
|
||||
print("Starting peer review session by different user...")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# Simple Sequential Team (YAML)
|
||||
|
||||
A minimal 2-agent sequential team: a **researcher** gathers facts, then a
|
||||
**writer** summarizes them. This is the copy-paste starting point for the most
|
||||
common multi-agent pattern (2–5 agents, run in order).
|
||||
|
||||
```
|
||||
research_task -> summary_task
|
||||
(researcher) (writer)
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `agents.yaml` | Roles (who) + their tasks (what); tasks run in declaration order |
|
||||
|
||||
> **Which loader applies?** This example uses the canonical single-file format
|
||||
> consumed by `AgentsGenerator.generate_crew_and_kickoff()`
|
||||
> (`src/praisonai/praisonai/agents_generator.py`). Agents live under `roles:`,
|
||||
> each with nested `tasks:`. **Tasks run in the order they are declared** — the
|
||||
> researcher's task first, then the writer's. (An optional top-level
|
||||
> `dependencies:` block is included to document intent and mirror the canonical
|
||||
> fixture, but the roles-file loader sequences by declaration order, so keep
|
||||
> tasks in the order you want them to run.) No new loader module is required —
|
||||
> this is the existing YAML path.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY=sk-...
|
||||
praisonai examples/yaml/teams/research-writer/agents.yaml
|
||||
```
|
||||
|
||||
This positional-file form is the canonical roles-file entry point. From Python
|
||||
the equivalent is `praisonai.run("agents.yaml")` (see
|
||||
`src/praisonai/praisonai/_entrypoint.py`), which dispatches to the same
|
||||
`AgentsGenerator`.
|
||||
|
||||
Change the subject by editing the `topic:` line in `agents.yaml` (interpolated
|
||||
into every `{topic}` placeholder).
|
||||
|
||||
## When to use this vs a workflow YAML
|
||||
|
||||
| You need… | Use | Example |
|
||||
|-----------|-----|---------|
|
||||
| 2–5 agents, sequential/hierarchical | **This** (`roles:` + `tasks:`) | `agents.yaml` here |
|
||||
| Routing by classifier output | workflow YAML | `../../workflows/routing_workflow.yaml` |
|
||||
| Parallel branches | workflow YAML | `../../workflows/parallel_workflow.yaml` |
|
||||
| Loops / planning / memory | workflow YAML | `../../workflows/complete_workflow.yaml` |
|
||||
|
||||
## Python equivalent
|
||||
|
||||
```python
|
||||
from praisonaiagents import Agent, Task, PraisonAIAgents
|
||||
|
||||
researcher = Agent(role="Senior Researcher", goal="Find accurate information")
|
||||
writer = Agent(role="Report Writer", goal="Turn research into summaries")
|
||||
|
||||
t1 = Task(description="Research renewable energy and list 5 key facts", agent=researcher)
|
||||
t2 = Task(description="Write a 3-sentence summary from the research", agent=writer, context=[t1])
|
||||
|
||||
PraisonAIAgents(agents=[researcher, writer], tasks=[t1, t2]).start()
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
# yaml-language-server: $schema=https://docs.praison.ai/schemas/agents.schema.json
|
||||
# Minimal sequential team: researcher -> writer
|
||||
# Run: praisonai examples/yaml/teams/research-writer/agents.yaml
|
||||
|
||||
framework: "praisonai"
|
||||
topic: "renewable energy"
|
||||
|
||||
roles:
|
||||
researcher:
|
||||
role: "Senior Researcher"
|
||||
goal: "Find accurate information about {topic}"
|
||||
backstory: "Expert at concise, factual research."
|
||||
tasks:
|
||||
research_task:
|
||||
description: "Research {topic} and list 5 key facts."
|
||||
expected_output: "A bullet list of 5 facts."
|
||||
|
||||
writer:
|
||||
role: "Report Writer"
|
||||
goal: "Turn research into readable summaries about {topic}"
|
||||
backstory: "Clear technical writer."
|
||||
tasks:
|
||||
summary_task:
|
||||
description: "Write a 3-sentence executive summary from the research."
|
||||
expected_output: "3 sentences, plain text."
|
||||
|
||||
# Ordering is by task declaration order above (researcher's task runs first,
|
||||
# then the writer's), which is how the roles-file loader sequences tasks.
|
||||
# The optional `dependencies:` block below mirrors the canonical fixture
|
||||
# (src/praisonai/tests/agents.yaml) and documents intent; the roles-file
|
||||
# loader does not consume it, so keep tasks in the order you want them run.
|
||||
dependencies:
|
||||
- task: summary_task
|
||||
depends_on: research_task
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env bash
|
||||
# C14 import-direction gate for praisonai-deploy.
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
CODE_ROOT="${C14_CODE_ROOT:-src/praisonai-code/praisonai_code}"
|
||||
DEPLOY_ROOT="${C14_DEPLOY_ROOT:-src/praisonai-deploy/praisonai_deploy}"
|
||||
|
||||
ANY_WRAPPER_RE='(^[[:space:]]*from praisonai([[:space:]]|\.)|^[[:space:]]*import praisonai($|\.))'
|
||||
ANY_CODE_RE='(^from praisonai_code([[:space:]]|\.)|^import praisonai_code($|\.))'
|
||||
ANY_DEPLOY_RE='(^[[:space:]]*from praisonai_deploy([[:space:]]|\.)|^[[:space:]]*import praisonai_deploy($|\.))'
|
||||
|
||||
echo "== C14 praisonai_deploy wrapper import gate (must be zero outside bridges) =="
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
MATCHES="$(rg -n "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
|
||||
else
|
||||
MATCHES="$(grep -rEn --include='*.py' "$ANY_WRAPPER_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_wrapper_bridge.py' | grep -v '_bootstrap.py' | grep -v '_code_bridge.py' || true)"
|
||||
fi
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "$MATCHES"
|
||||
echo "FAIL: praisonai_deploy imports the praisonai wrapper"
|
||||
exit 1
|
||||
fi
|
||||
echo "deploy wrapper import gate ok"
|
||||
|
||||
echo "== C14 praisonai_deploy module-level praisonai_code import gate =="
|
||||
if command -v rg >/dev/null 2>&1; then
|
||||
MATCHES="$(rg -n "$ANY_CODE_RE" "$DEPLOY_ROOT" --glob '*.py' 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
|
||||
else
|
||||
MATCHES="$(grep -rEn --include='*.py' "$ANY_CODE_RE" "$DEPLOY_ROOT" 2>/dev/null | grep -v '_code_bridge.py' | grep -v '_plugin_registry.py' || true)"
|
||||
fi
|
||||
if [ -n "$MATCHES" ]; then
|
||||
echo "$MATCHES"
|
||||
echo "FAIL: module-level praisonai_code import in praisonai_deploy"
|
||||
exit 1
|
||||
fi
|
||||
echo "deploy code import gate ok"
|
||||
|
||||
echo "== C14 praisonai-code hot-path gate (no praisonai_deploy at module level) =="
|
||||
for f in \
|
||||
"$CODE_ROOT/cli/main.py" \
|
||||
"$CODE_ROOT/cli/app.py" \
|
||||
"$CODE_ROOT/cli/commands/run.py" \
|
||||
"$CODE_ROOT/cli/commands/chat.py" \
|
||||
"$CODE_ROOT/cli/commands/code.py"
|
||||
do
|
||||
if [ -f "$f" ] && head -n 80 "$f" | grep -E "$ANY_DEPLOY_RE" 2>/dev/null; then
|
||||
echo "FAIL: module-level praisonai_deploy import in code hot path $f"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "code hot-path gate ok"
|
||||
|
||||
echo "C14 import gates passed"
|
||||
Executable
+60
@@ -0,0 +1,60 @@
|
||||
#!/usr/bin/env bash
|
||||
# Lint and template-render all Helm charts under package infra/ trees.
|
||||
set -euo pipefail
|
||||
|
||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
if ! command -v helm >/dev/null 2>&1; then
|
||||
echo "FAIL: helm CLI not found (install via https://helm.sh/docs/intro/install/)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HELM_ROOTS=(
|
||||
"$ROOT/src/praisonai-bot/infra/helm"
|
||||
"$ROOT/src/praisonai-deploy/infra/helm"
|
||||
)
|
||||
|
||||
charts=()
|
||||
for helm_root in "${HELM_ROOTS[@]}"; do
|
||||
if [ ! -d "$helm_root" ]; then
|
||||
continue
|
||||
fi
|
||||
shopt -s nullglob
|
||||
for chart in "$helm_root"/*/; do
|
||||
if [ -f "${chart}Chart.yaml" ]; then
|
||||
charts+=("$chart")
|
||||
fi
|
||||
done
|
||||
shopt -u nullglob
|
||||
done
|
||||
|
||||
if [ ${#charts[@]} -eq 0 ]; then
|
||||
echo "No charts found under package infra/helm trees"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for chart in "${charts[@]}"; do
|
||||
name="$(basename "$chart")"
|
||||
echo "== helm lint: $name =="
|
||||
helm lint "$chart"
|
||||
|
||||
echo "== helm template: $name =="
|
||||
case "$name" in
|
||||
praisonai-gateway)
|
||||
helm template "ci-smoke-$name" "$chart" \
|
||||
--set auth.existingSecret=ci-smoke-auth \
|
||||
>/dev/null
|
||||
;;
|
||||
praisonai-agents-api)
|
||||
helm template "ci-smoke-$name" "$chart" \
|
||||
--set auth.existingSecret=ci-smoke-auth \
|
||||
--set postgres.auth.existingSecret=ci-smoke-postgres \
|
||||
>/dev/null
|
||||
;;
|
||||
*)
|
||||
helm template "ci-smoke-$name" "$chart" >/dev/null
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
echo "Helm chart gates passed (${#charts[@]} chart(s))"
|
||||
@@ -140,7 +140,7 @@ The **agentic terminal CLI** (`run`, `chat`, `code`, warm runtime, CLI backends,
|
||||
|
||||
**Monorepo bootstrap:** `praisonai._bootstrap.ensure_praisonai_code()` adds sibling `src/praisonai-code` to `sys.path` so `PYTHONPATH=src/praisonai-agents:src/praisonai` works without an explicit code entry.
|
||||
|
||||
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai` (see `.github/actions/install-monorepo-packages`).
|
||||
**Install order (CI and local dev):** `praisonai-agents` → `praisonai-code` → `praisonai-bot` → `praisonai-train` → `praisonai-browser` → `praisonai-mcp` → `praisonai-sandbox` → `praisonai-deploy` → `praisonai` (see `.github/actions/install-monorepo-packages`).
|
||||
|
||||
**Invocation paths that must remain valid:**
|
||||
|
||||
|
||||
@@ -18,76 +18,76 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 420 |
|
||||
| ✅ Agent | 21 | 46 | 14663 |
|
||||
| ✅ Agent | 22 | 49 | 15473 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2965 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 585 |
|
||||
| ✅ Approval | 1 | 5 | 2312 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 684 |
|
||||
| ✅ Approval | 1 | 5 | 2500 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 10 | 2880 |
|
||||
| ✅ Autonomy | 3 | 7 | 2707 |
|
||||
| ✅ Bots | 7 | 31 | 12353 |
|
||||
| ✅ Auto Generation | 5 | 10 | 3005 |
|
||||
| ✅ Autonomy | 3 | 7 | 2736 |
|
||||
| ✅ Bots | 7 | 31 | 13274 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 119 | 34537 |
|
||||
| ✅ CLI | 5 | 121 | 35658 |
|
||||
| ✅ Chunking | 2 | 2 | 385 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 13 | 4031 |
|
||||
| ✅ Code Execution | 2 | 14 | 4548 |
|
||||
| ✅ Conditions | 1 | 3 | 1179 |
|
||||
| ✅ Configuration | 3 | 7 | 2773 |
|
||||
| ✅ Context Management | 16 | 34 | 13206 |
|
||||
| ✅ Configuration | 3 | 8 | 3047 |
|
||||
| ✅ Context Management | 16 | 34 | 13388 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2905 |
|
||||
| ✅ Events | 1 | 2 | 749 |
|
||||
| ✅ Execution | 3 | 4 | 1560 |
|
||||
| ✅ Failover | 2 | 1 | 400 |
|
||||
| ✅ Files | 2 | 6 | 2066 |
|
||||
| ✅ Flow | 1 | 3 | 777 |
|
||||
| ✅ Gateway | 7 | 59 | 21584 |
|
||||
| ✅ Guardrails | 4 | 4 | 1673 |
|
||||
| ✅ Handoffs | 11 | 6 | 2454 |
|
||||
| ✅ Hooks | 2 | 9 | 4128 |
|
||||
| ✅ Failover | 2 | 1 | 432 |
|
||||
| ✅ Files | 2 | 7 | 2273 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 66 | 24288 |
|
||||
| ✅ Guardrails | 4 | 4 | 1833 |
|
||||
| ✅ Handoffs | 11 | 6 | 2563 |
|
||||
| ✅ Hooks | 2 | 9 | 4281 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 13 | 4160 |
|
||||
| ✅ LLM | 3 | 14 | 4864 |
|
||||
| ✅ Knowledge | 4 | 13 | 4233 |
|
||||
| ✅ LLM | 3 | 14 | 4955 |
|
||||
| ✅ Loops | 4 | 5 | 1422 |
|
||||
| ✅ MCP | 1 | 58 | 13322 |
|
||||
| ✅ Memory | 6 | 17 | 6635 |
|
||||
| ✅ MCP | 1 | 60 | 13724 |
|
||||
| ✅ Memory | 6 | 17 | 6766 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2779 |
|
||||
| ✅ Observability | 2 | 23 | 2800 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
| ✅ Output | 3 | 5 | 1158 |
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 6 | 1545 |
|
||||
| ✅ Plugins | 8 | 3 | 1900 |
|
||||
| ✅ Planning | 6 | 6 | 1549 |
|
||||
| ✅ Plugins | 8 | 4 | 2703 |
|
||||
| ✅ Prompts | 2 | 9 | 1965 |
|
||||
| ✅ Providers | 1 | 55 | 7502 |
|
||||
| ✅ Providers | 1 | 56 | 7723 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 2936 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
| ✅ Reflection | 3 | 3 | 680 |
|
||||
| ✅ Retrieval | 2 | 5 | 1044 |
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 6 | 2766 |
|
||||
| ✅ Security | 1 | 3 | 2970 |
|
||||
| ✅ Sessions | 4 | 13 | 5302 |
|
||||
| ✅ Skills | 6 | 13 | 4998 |
|
||||
| ✅ Tasks | 2 | 6 | 2809 |
|
||||
| ✅ Sandbox | 5 | 6 | 2806 |
|
||||
| ✅ Security | 1 | 3 | 3135 |
|
||||
| ✅ Sessions | 4 | 17 | 6960 |
|
||||
| ✅ Skills | 6 | 15 | 5638 |
|
||||
| ✅ Tasks | 2 | 6 | 2819 |
|
||||
| ✅ Telemetry | 1 | 2 | 604 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 133 | 38149 |
|
||||
| ✅ Tools | 12 | 138 | 40206 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1193 |
|
||||
| ✅ Vector Store | 1 | 12 | 1207 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 8 | 2055 |
|
||||
| ✅ Workflows | 5 | 16 | 6546 |
|
||||
| ✅ Web | 3 | 9 | 2467 |
|
||||
| ✅ Workflows | 5 | 16 | 6589 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (41 docs, 6478 lines)
|
||||
- ℹ️ Database (42 docs, 6703 lines)
|
||||
- ℹ️ Documents (1 docs, 787 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -304,9 +304,10 @@ _LAZY_IMPORTS = {
|
||||
'CodeAgent': ('praisonaiagents.agent.code_agent', 'CodeAgent'),
|
||||
'CodeConfig': ('praisonaiagents.agent.code_agent', 'CodeConfig'),
|
||||
|
||||
# AgentTeam (primary) / AgentManager (alias)
|
||||
# AgentTeam (primary) / AgentManager, PraisonAIAgents (aliases)
|
||||
'AgentTeam': ('praisonaiagents.agents.agents', 'AgentTeam'),
|
||||
'AgentManager': ('praisonaiagents.agents.agents', 'AgentManager'), # Silent alias
|
||||
'PraisonAIAgents': ('praisonaiagents.agents.agents', 'PraisonAIAgents'), # Silent alias (pre-1.0 name)
|
||||
# Note: 'Agents' is handled by _custom_handler for deprecation warning
|
||||
'Task': ('praisonaiagents.task.task', 'Task'),
|
||||
'AutoAgents': ('praisonaiagents.agents.autoagents', 'AutoAgents'),
|
||||
@@ -843,6 +844,7 @@ __all__ = [
|
||||
'RetryBackoffConfig',
|
||||
'AgentTeam', # Primary class for multi-agent coordination (v1.0+)
|
||||
'AgentManager', # Silent alias for AgentTeam
|
||||
'PraisonAIAgents', # Silent alias for AgentTeam (pre-1.0 name)
|
||||
'Agents', # Deprecated alias for AgentTeam (emits warning)
|
||||
'Task',
|
||||
|
||||
|
||||
@@ -859,8 +859,6 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
planning_tools = None
|
||||
planning_reasoning = False
|
||||
policy = None
|
||||
background = None
|
||||
checkpoints = None
|
||||
output_style = None
|
||||
thinking_budget = None
|
||||
skills_dirs = None
|
||||
@@ -1655,7 +1653,14 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
|
||||
else:
|
||||
llm = model # model= takes precedence
|
||||
|
||||
# Store rate limiter (optional, zero overhead when None)
|
||||
# Store rate limiter (optional, zero overhead when None).
|
||||
# Auto-build a RateLimiter from max_rpm when no explicit limiter is
|
||||
# provided so ExecutionConfig(max_rpm=N) actually throttles requests.
|
||||
if max_rpm is not None and max_rpm <= 0:
|
||||
raise ValueError(f"max_rpm must be a positive int, got {max_rpm!r}")
|
||||
if rate_limiter is None and max_rpm is not None:
|
||||
from praisonaiagents.llm.rate_limiter import RateLimiter
|
||||
rate_limiter = RateLimiter(requests_per_minute=max_rpm)
|
||||
self._rate_limiter = rate_limiter
|
||||
|
||||
# Store OpenAI client parameters for lazy initialization (kept separate)
|
||||
@@ -1977,21 +1982,27 @@ Your Goal: {self.goal}
|
||||
# None = normal rule flow. Consulted at tool-call approval time.
|
||||
self._permission_mode = None
|
||||
if isinstance(approval, str) and approval not in ('True', 'False'):
|
||||
# Permission preset: "safe", "read_only", "full"
|
||||
# One string entry point for "how much the agent may do":
|
||||
# • deny-set presets — "safe"/"read_only"/"full"/"off"/"default"
|
||||
# • PermissionMode presets — "plan"/"bypass"/"accept_edits"/
|
||||
# "dont_ask" (+ aliases like "yolo", "auto_edit", "reject").
|
||||
# Deny-set presets are matched first so their exact behaviour is
|
||||
# unchanged; anything else falls through to the canonical
|
||||
# PermissionMode resolver, so all spellings share one model.
|
||||
from ..approval.registry import PERMISSION_PRESETS
|
||||
preset_deny = PERMISSION_PRESETS.get(approval)
|
||||
preset_deny = PERMISSION_PRESETS.get(approval.strip().lower())
|
||||
self._approval_backend = None
|
||||
self._approve_all_tools = False
|
||||
self._approval_timeout = 0
|
||||
self._approval_permissions = None
|
||||
if preset_deny is not None:
|
||||
self._perm_deny = preset_deny
|
||||
self._approval_backend = None
|
||||
self._approve_all_tools = False
|
||||
self._approval_timeout = 0
|
||||
self._approval_permissions = None
|
||||
else:
|
||||
# Unknown string — treat as no approval
|
||||
self._approval_backend = None
|
||||
self._approve_all_tools = False
|
||||
self._approval_timeout = 0
|
||||
self._approval_permissions = None
|
||||
# Not a deny-set preset — try the canonical PermissionMode
|
||||
# presets (plan/bypass/accept_edits/dont_ask + aliases).
|
||||
from ..permissions.rules import PermissionMode
|
||||
self._permission_mode = PermissionMode.resolve(approval)
|
||||
# Unknown string with no mode → treat as no approval.
|
||||
elif approval is True:
|
||||
from ..approval.backends import AutoApproveBackend
|
||||
self._approval_backend = AutoApproveBackend()
|
||||
@@ -2257,8 +2268,6 @@ Your Goal: {self.goal}
|
||||
# Agent-centric feature instances (lazy loaded for zero performance impact)
|
||||
self._auto_memory = auto_memory
|
||||
self._policy = policy
|
||||
self._background = background
|
||||
self._checkpoints = checkpoints
|
||||
self._output_style = output_style
|
||||
self._thinking_budget = thinking_budget
|
||||
|
||||
@@ -2532,24 +2541,6 @@ Your Goal: {self.goal}
|
||||
def policy(self, value: Optional[Any]) -> None:
|
||||
self._policy = value
|
||||
|
||||
@property
|
||||
def background(self) -> Optional[bool]:
|
||||
"""BackgroundRunner instance for async task execution."""
|
||||
return self._background
|
||||
|
||||
@background.setter
|
||||
def background(self, value: Optional[bool]) -> None:
|
||||
self._background = value
|
||||
|
||||
@property
|
||||
def checkpoints(self) -> Optional[bool]:
|
||||
"""CheckpointService instance for file-level undo/restore."""
|
||||
return self._checkpoints
|
||||
|
||||
@checkpoints.setter
|
||||
def checkpoints(self, value: Optional[bool]) -> None:
|
||||
self._checkpoints = value
|
||||
|
||||
@property
|
||||
def output_style(self) -> Optional[str]:
|
||||
"""OutputStyle instance for response formatting."""
|
||||
@@ -2712,16 +2703,7 @@ Your Goal: {self.goal}
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
if isinstance(self._context_param, _ContextConfig):
|
||||
# Build ManagerConfig from ContextConfig fields
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=self._context_param.auto_compact,
|
||||
compact_threshold=self._context_param.compact_threshold,
|
||||
strategy=self._context_param.strategy,
|
||||
output_reserve=self._context_param.output_reserve,
|
||||
default_tool_output_max=self._context_param.tool_output_max, # Map field name
|
||||
protected_tools=list(self._context_param.protected_tools),
|
||||
keep_recent_turns=self._context_param.keep_recent_turns,
|
||||
monitor_enabled=self._context_param.monitor.enabled if self._context_param.monitor else False,
|
||||
)
|
||||
manager_config = self._manager_config_from_context_config(self._context_param)
|
||||
# Check if llm_summarize is enabled in ContextConfig
|
||||
llm_summarize_enabled = getattr(self._context_param, 'llm_summarize', False)
|
||||
if llm_summarize_enabled:
|
||||
@@ -2750,16 +2732,7 @@ Your Goal: {self.goal}
|
||||
try:
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
context_config = _ContextConfig(**preset_config)
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=context_config.auto_compact,
|
||||
compact_threshold=context_config.compact_threshold,
|
||||
strategy=context_config.strategy,
|
||||
output_reserve=context_config.output_reserve,
|
||||
default_tool_output_max=context_config.tool_output_max,
|
||||
protected_tools=list(context_config.protected_tools),
|
||||
keep_recent_turns=context_config.keep_recent_turns,
|
||||
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
|
||||
)
|
||||
manager_config = self._manager_config_from_context_config(context_config)
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
config=manager_config,
|
||||
@@ -2778,16 +2751,7 @@ Your Goal: {self.goal}
|
||||
try:
|
||||
from ..context.models import ContextConfig as _ContextConfig
|
||||
context_config = _ContextConfig(**self._context_param)
|
||||
manager_config = ManagerConfig(
|
||||
auto_compact=context_config.auto_compact,
|
||||
compact_threshold=context_config.compact_threshold,
|
||||
strategy=context_config.strategy,
|
||||
output_reserve=context_config.output_reserve,
|
||||
default_tool_output_max=context_config.tool_output_max,
|
||||
protected_tools=list(context_config.protected_tools),
|
||||
keep_recent_turns=context_config.keep_recent_turns,
|
||||
monitor_enabled=context_config.monitor.enabled if context_config.monitor else False,
|
||||
)
|
||||
manager_config = self._manager_config_from_context_config(context_config)
|
||||
llm_summarize_enabled = self._context_param.get('llm_summarize', False)
|
||||
self._context_manager = ContextManager(
|
||||
model=self.llm if isinstance(self.llm, str) else "gpt-4o-mini",
|
||||
@@ -2812,6 +2776,20 @@ Your Goal: {self.goal}
|
||||
self._context_manager = value
|
||||
self._context_manager_initialized = True
|
||||
|
||||
def _manager_config_from_context_config(self, cc: Any) -> Any:
|
||||
"""Build a ManagerConfig from a ContextConfig (single source of truth)."""
|
||||
from ..context import ManagerConfig
|
||||
return ManagerConfig(
|
||||
auto_compact=cc.auto_compact,
|
||||
compact_threshold=cc.compact_threshold,
|
||||
strategy=cc.strategy,
|
||||
output_reserve=cc.output_reserve,
|
||||
default_tool_output_max=cc.tool_output_max, # Map field name
|
||||
protected_tools=list(cc.protected_tools),
|
||||
keep_recent_turns=cc.keep_recent_turns,
|
||||
monitor_enabled=cc.monitor.enabled if cc.monitor else False,
|
||||
)
|
||||
|
||||
def _create_llm_summarize_fn(self) -> Optional[Callable]:
|
||||
"""
|
||||
Create an LLM summarization function using the agent's LLM.
|
||||
@@ -2839,9 +2817,19 @@ Conversation:
|
||||
|
||||
Summary:"""
|
||||
|
||||
# Use agent's LLM to generate summary
|
||||
# Use agent's LLM to generate summary. Route this internal,
|
||||
# non-user-facing call through the configured auxiliary
|
||||
# ``small_model`` when set; otherwise fall back to the primary
|
||||
# model (unchanged behaviour).
|
||||
client = _get_llm_functions()['get_openai_client'](self.llm, self.base_url, self.api_key)
|
||||
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
|
||||
primary_model = self.llm if isinstance(self.llm, str) else None
|
||||
try:
|
||||
from ..config.loader import get_small_model
|
||||
model_name = get_small_model(
|
||||
primary_model=primary_model, fallback="gpt-4o-mini"
|
||||
) or "gpt-4o-mini"
|
||||
except Exception:
|
||||
model_name = primary_model or "gpt-4o-mini"
|
||||
|
||||
response = client.chat.completions.create(
|
||||
model=model_name,
|
||||
@@ -4969,8 +4957,20 @@ Summary:"""
|
||||
self._memory_instance = None
|
||||
return
|
||||
|
||||
# Determine user_id
|
||||
mem_user_id = user_id or getattr(self, 'user_id', None) or "default"
|
||||
# Determine user_id. A shared constant default (e.g. "praison") would
|
||||
# silently merge different sessions'/users' private memory onto the same
|
||||
# on-disk path. When memory is enabled and no explicit user_id was given,
|
||||
# fall back to a per-instance, non-shared id so agents are isolated by
|
||||
# default. Pass user_id=... explicitly to persist/share memory across runs.
|
||||
mem_user_id = user_id
|
||||
if not mem_user_id:
|
||||
import uuid
|
||||
mem_user_id = f"agent-{uuid.uuid4().hex[:12]}"
|
||||
logging.warning(
|
||||
"Agent memory enabled without an explicit user_id; using an "
|
||||
"auto-generated, non-shared id (%s). Pass user_id=... explicitly "
|
||||
"to persist/share memory across runs.", mem_user_id,
|
||||
)
|
||||
|
||||
if memory is True or memory == "file":
|
||||
# Use FileMemory (zero dependencies)
|
||||
@@ -5612,6 +5612,16 @@ Answer:"""
|
||||
# Prefer the configured LLM instance (with api_key/base_url/client
|
||||
# overrides) over the bare model-name string in self.llm.
|
||||
llm = getattr(self, 'llm_instance', None) or getattr(self, 'llm', None)
|
||||
# Guardrail validation is an internal, non-user-facing LLM call.
|
||||
# When it would fall back to the bare primary model-name string
|
||||
# (no explicit LLM instance), route through the auxiliary
|
||||
# ``small_model`` (unset -> primary model, i.e. unchanged behaviour).
|
||||
if isinstance(llm, str):
|
||||
try:
|
||||
from ..config.loader import get_small_model
|
||||
llm = get_small_model(primary_model=llm, fallback=llm) or llm
|
||||
except Exception:
|
||||
pass
|
||||
self._guardrail_fn = LLMGuardrail(description=self.guardrail, llm=llm)
|
||||
else:
|
||||
raise ValueError("Agent guardrail must be either a callable or a string description")
|
||||
@@ -6416,10 +6426,14 @@ Answer:"""
|
||||
"""Clean up global server registry entries for this agent."""
|
||||
if getattr(self, '_agent_id', None) is None:
|
||||
return # No ID generated, nothing registered
|
||||
|
||||
|
||||
try:
|
||||
_get_default_server_registry().cleanup_agent_registrations(self._agent_id)
|
||||
|
||||
# Tear down the routes launch() actually registered (module-level
|
||||
# state in execution_mixin.py). ServerRegistry above is a separate,
|
||||
# unpopulated structure and cleaning it up is a no-op.
|
||||
from .execution_mixin import cleanup_launch_registration
|
||||
cleanup_launch_registration(self._agent_id)
|
||||
|
||||
except Exception as e:
|
||||
import sys
|
||||
if sys.meta_path is not None:
|
||||
|
||||
@@ -1305,6 +1305,104 @@ Your Goal: {self.goal}"""
|
||||
return None
|
||||
return self.fallback_models[fallback_index]
|
||||
|
||||
def _emit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
|
||||
stream_callback=None):
|
||||
"""Surface a mid-turn model fallback as an observable state transition.
|
||||
|
||||
Fired at the exact point the recovery loop swaps the primary model for
|
||||
the next entry in ``fallback_models``. Emits a ``MODEL_FALLBACK`` hook
|
||||
(so plugins/gateways can react — notice, alert, metrics) and, when a
|
||||
stream callback is active, a ``StreamEventType.MODEL_FALLBACK`` event so
|
||||
the streaming/draft layer can mark the switch. Only the failure class is
|
||||
exposed; provider internals stay redacted. Fully guarded and a cheap
|
||||
no-op when nothing is subscribed — preserving today's behaviour plus the
|
||||
existing log line (Issue #3820).
|
||||
"""
|
||||
try:
|
||||
from ..hooks.types import HookEvent
|
||||
|
||||
runner = getattr(self, '_hook_runner', None)
|
||||
if runner is not None and runner.registry.has_hooks(HookEvent.MODEL_FALLBACK):
|
||||
runner.execute_sync(
|
||||
HookEvent.MODEL_FALLBACK,
|
||||
self._build_model_fallback_input(
|
||||
from_model, to_model, reason_category, fallback_index, HookEvent
|
||||
),
|
||||
)
|
||||
except Exception: # observability must never break the fallback path
|
||||
logging.debug("MODEL_FALLBACK hook failed", exc_info=True)
|
||||
|
||||
self._emit_model_fallback_stream(
|
||||
from_model, to_model, reason_category, fallback_index, stream_callback
|
||||
)
|
||||
|
||||
async def _aemit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
|
||||
stream_callback=None):
|
||||
"""Async variant of :meth:`_emit_model_fallback` for the async recovery path.
|
||||
|
||||
Awaits the hook runner in the active event loop (``execute_sync`` would
|
||||
raise inside a running loop, silently dropping ``MODEL_FALLBACK`` hooks).
|
||||
Same guarantees: cheap no-op when nothing is subscribed and never raises
|
||||
into the fallback path.
|
||||
"""
|
||||
try:
|
||||
from ..hooks.types import HookEvent
|
||||
|
||||
runner = getattr(self, '_hook_runner', None)
|
||||
if runner is not None and runner.registry.has_hooks(HookEvent.MODEL_FALLBACK):
|
||||
await runner.execute(
|
||||
HookEvent.MODEL_FALLBACK,
|
||||
self._build_model_fallback_input(
|
||||
from_model, to_model, reason_category, fallback_index, HookEvent
|
||||
),
|
||||
)
|
||||
except Exception: # observability must never break the fallback path
|
||||
logging.debug("MODEL_FALLBACK hook failed", exc_info=True)
|
||||
|
||||
self._emit_model_fallback_stream(
|
||||
from_model, to_model, reason_category, fallback_index, stream_callback
|
||||
)
|
||||
|
||||
def _build_model_fallback_input(self, from_model, to_model, reason_category,
|
||||
fallback_index, HookEvent):
|
||||
"""Build the typed ``ModelFallbackInput`` (only the failure class is exposed)."""
|
||||
from ..hooks.events import ModelFallbackInput
|
||||
|
||||
return ModelFallbackInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.MODEL_FALLBACK.value,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=getattr(self, 'name', None),
|
||||
from_model=str(from_model),
|
||||
to_model=str(to_model),
|
||||
reason_category=str(reason_category or ""),
|
||||
fallback_index=fallback_index,
|
||||
)
|
||||
|
||||
def _emit_model_fallback_stream(self, from_model, to_model, reason_category,
|
||||
fallback_index, stream_callback):
|
||||
"""Emit the ``StreamEventType.MODEL_FALLBACK`` event when a callback is active."""
|
||||
if stream_callback is None:
|
||||
return
|
||||
try:
|
||||
from ..streaming.events import StreamEvent, StreamEventType
|
||||
|
||||
stream_callback(StreamEvent(
|
||||
type=StreamEventType.MODEL_FALLBACK,
|
||||
metadata={
|
||||
"from_model": str(from_model),
|
||||
"to_model": str(to_model),
|
||||
"reason_category": str(reason_category or ""),
|
||||
"fallback_index": fallback_index,
|
||||
},
|
||||
agent_id=getattr(self, 'name', None),
|
||||
session_id=getattr(self, '_session_id', None),
|
||||
run_id=getattr(self, '_current_run_id', None),
|
||||
))
|
||||
except Exception:
|
||||
logging.debug("MODEL_FALLBACK stream event failed", exc_info=True)
|
||||
|
||||
def _max_retry_depth(self) -> int:
|
||||
"""Maximum LLM retry depth honoured by the recovery loop.
|
||||
|
||||
@@ -1345,34 +1443,36 @@ Your Goal: {self.goal}"""
|
||||
self._apply_context_compaction(messages, _HookEvent)
|
||||
|
||||
# Trigger BEFORE_LLM hook
|
||||
# Only build the input if a BEFORE_LLM hook is actually registered
|
||||
from ..hooks import HookEvent, BeforeLLMInput
|
||||
before_llm_input = BeforeLLMInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_LLM,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
messages=messages,
|
||||
model=self.llm if isinstance(self.llm, str) else str(self.llm),
|
||||
temperature=temperature
|
||||
)
|
||||
_before_llm_results = self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
|
||||
# Honour a blocking BEFORE_LLM hook/plugin (POLICY/GUARDRAIL) by
|
||||
# refusing to dispatch the request, mirroring how BEFORE_TOOL/BEFORE_AGENT
|
||||
# enforce blocks. Without this, a plugin that returns PluginDecision.deny()
|
||||
# (or raises GuardrailBlocked) would fail open and still hit the model.
|
||||
if self._hook_runner.is_blocked(_before_llm_results):
|
||||
_block_reason = next(
|
||||
(getattr(r.output, "reason", None) for r in _before_llm_results
|
||||
if r.output and getattr(r.output, "is_denied", lambda: False)()),
|
||||
None,
|
||||
) or "Blocked by hook"
|
||||
logging.warning(f"Agent {self.name} LLM request blocked by BEFORE_LLM hook: {_block_reason}")
|
||||
return f"[LLM request blocked by hook: {_block_reason}]"
|
||||
# C7 - honour any BEFORE_LLM hook that mutated the message stream
|
||||
# (e.g. PII redactor). The runner applies modified_input in-place on
|
||||
# before_llm_input.messages; adopt that value for the actual LLM call.
|
||||
messages = before_llm_input.messages
|
||||
if self._hook_runner.registry.has_hooks(HookEvent.BEFORE_LLM):
|
||||
before_llm_input = BeforeLLMInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_LLM,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
messages=messages,
|
||||
model=self.llm if isinstance(self.llm, str) else str(self.llm),
|
||||
temperature=temperature
|
||||
)
|
||||
_before_llm_results = self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
|
||||
# Honour a blocking BEFORE_LLM hook/plugin (POLICY/GUARDRAIL) by
|
||||
# refusing to dispatch the request, mirroring how BEFORE_TOOL/BEFORE_AGENT
|
||||
# enforce blocks. Without this, a plugin that returns PluginDecision.deny()
|
||||
# (or raises GuardrailBlocked) would fail open and still hit the model.
|
||||
if self._hook_runner.is_blocked(_before_llm_results):
|
||||
_block_reason = next(
|
||||
(getattr(r.output, "reason", None) for r in _before_llm_results
|
||||
if r.output and getattr(r.output, "is_denied", lambda: False)()),
|
||||
None,
|
||||
) or "Blocked by hook"
|
||||
logging.warning(f"Agent {self.name} LLM request blocked by BEFORE_LLM hook: {_block_reason}")
|
||||
return f"[LLM request blocked by hook: {_block_reason}]"
|
||||
# C7 - honour any BEFORE_LLM hook that mutated the message stream
|
||||
# (e.g. PII redactor). The runner applies modified_input in-place on
|
||||
# before_llm_input.messages; adopt that value for the actual LLM call.
|
||||
messages = before_llm_input.messages
|
||||
|
||||
# Pre-call budget guard (zero overhead when _max_budget is None).
|
||||
# Estimate this call's minimum cost from the known input size plus the
|
||||
@@ -1526,19 +1626,21 @@ Your Goal: {self.goal}"""
|
||||
self._on_budget_exceeded(current_cost, self._max_budget)
|
||||
|
||||
# Trigger AFTER_LLM hook
|
||||
# Only build the input if an AFTER_LLM hook is actually registered
|
||||
from ..hooks import HookEvent, AfterLLMInput
|
||||
after_llm_input = AfterLLMInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_LLM,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
messages=messages,
|
||||
response=str(final_response),
|
||||
model=self.llm if isinstance(self.llm, str) else str(self.llm),
|
||||
latency_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.AFTER_LLM, after_llm_input)
|
||||
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_LLM):
|
||||
after_llm_input = AfterLLMInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_LLM,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
messages=messages,
|
||||
response=str(final_response),
|
||||
model=self.llm if isinstance(self.llm, str) else str(self.llm),
|
||||
latency_ms=(time.time() - start_time) * 1000
|
||||
)
|
||||
self._hook_runner.execute_sync(HookEvent.AFTER_LLM, after_llm_input)
|
||||
|
||||
return final_response
|
||||
|
||||
@@ -1624,7 +1726,22 @@ Your Goal: {self.goal}"""
|
||||
if next_model:
|
||||
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
|
||||
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
|
||||
|
||||
|
||||
# Surface the otherwise-silent switch as an observable event.
|
||||
# Forward the active stream callback so streaming consumers
|
||||
# receive the MODEL_FALLBACK event when this follows a
|
||||
# streaming request.
|
||||
_sync_stream_callback = (
|
||||
self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
|
||||
)
|
||||
self._emit_model_fallback(
|
||||
from_model=current_model,
|
||||
to_model=next_model,
|
||||
reason_category=classification.error_category,
|
||||
fallback_index=_fallback_index,
|
||||
stream_callback=_sync_stream_callback,
|
||||
)
|
||||
|
||||
# Apply backoff if suggested
|
||||
if classification.backoff_seconds and classification.backoff_seconds > 0:
|
||||
time.sleep(classification.backoff_seconds)
|
||||
@@ -1787,7 +1904,20 @@ Your Goal: {self.goal}"""
|
||||
if next_model:
|
||||
current_model = self.llm if isinstance(self.llm, str) else str(self.llm)
|
||||
logging.info(f"[{self.name}] {current_model} unavailable — falling back to {next_model}")
|
||||
|
||||
|
||||
# Surface the otherwise-silent switch as an observable event.
|
||||
# Await the async emitter so MODEL_FALLBACK hooks run in the
|
||||
# active event loop, and honour emit_events so internal calls
|
||||
# (emit_events=False) don't leak a stream event into the public
|
||||
# stream.
|
||||
await self._aemit_model_fallback(
|
||||
from_model=current_model,
|
||||
to_model=next_model,
|
||||
reason_category=classification.error_category,
|
||||
fallback_index=_fallback_index,
|
||||
stream_callback=stream_callback if emit_events else None,
|
||||
)
|
||||
|
||||
# Apply backoff if suggested
|
||||
if classification.backoff_seconds and classification.backoff_seconds > 0:
|
||||
await asyncio.sleep(classification.backoff_seconds)
|
||||
@@ -2327,7 +2457,7 @@ Your Goal: {self.goal}"""
|
||||
return output
|
||||
|
||||
try:
|
||||
run_id = getattr(self, '_run_id', None)
|
||||
run_id = getattr(self, '_current_run_id', None)
|
||||
return self.context_manager.truncate_tool_output(tool_name, output, tool_call_id, run_id)
|
||||
except Exception as e:
|
||||
logging.warning(f"Tool output truncation error: {e}")
|
||||
@@ -3749,56 +3879,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
_get_display_functions()['display_error'](f"Tool {function_name} not found")
|
||||
continue
|
||||
|
||||
# --- BEFORE_TOOL hook ---
|
||||
try:
|
||||
from ..hooks import HookEvent, BeforeToolInput
|
||||
_before_tool_input = BeforeToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.BEFORE_TOOL,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
)
|
||||
_before_results = await self._hook_runner.execute(HookEvent.BEFORE_TOOL, _before_tool_input)
|
||||
_tool_blocked = self._hook_runner.is_blocked(_before_results)
|
||||
except Exception as _hook_err:
|
||||
logging.debug(f"BEFORE_TOOL hook error (non-fatal): {_hook_err}")
|
||||
_tool_blocked = False
|
||||
if _tool_blocked:
|
||||
# Reason extraction must not be able to re-open a block:
|
||||
# use attributes present on both HookResult (plugin
|
||||
# bridge) and HookOutput, defaulting safely.
|
||||
_block_reason = next(
|
||||
(getattr(r.output, "reason", None) for r in _before_results
|
||||
if r.output and getattr(r.output, "is_denied", lambda: False)()),
|
||||
None,
|
||||
) or "Blocked by hook"
|
||||
results.append(f"[Tool blocked by hook: {_block_reason}]")
|
||||
continue
|
||||
|
||||
# Route through safety pipeline instead of direct execution
|
||||
# Route through safety pipeline instead of direct execution.
|
||||
# BEFORE_TOOL/AFTER_TOOL hooks (blocking, arg mutation and
|
||||
# after-context aggregation) are fired once, guarded, inside
|
||||
# execute_tool_async — no inline duplicate dispatch here.
|
||||
# Pass the tools list to honor task-scoped tools
|
||||
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
|
||||
|
||||
# --- AFTER_TOOL hook ---
|
||||
try:
|
||||
from ..hooks import AfterToolInput
|
||||
_after_tool_input = AfterToolInput(
|
||||
session_id=getattr(self, '_session_id', 'default'),
|
||||
cwd=os.getcwd(),
|
||||
event_name=HookEvent.AFTER_TOOL,
|
||||
timestamp=str(time.time()),
|
||||
agent_name=self.name,
|
||||
tool_name=function_name,
|
||||
tool_input=arguments,
|
||||
tool_output=result,
|
||||
)
|
||||
await self._hook_runner.execute(HookEvent.AFTER_TOOL, _after_tool_input)
|
||||
except Exception as _hook_err:
|
||||
logging.debug(f"AFTER_TOOL hook error (non-fatal): {_hook_err}")
|
||||
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
_get_display_functions()['display_error'](f"Error executing tool {function_name}: {e}")
|
||||
@@ -3887,8 +3974,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Force streaming, no display by default (app-friendly)
|
||||
kwargs['stream'] = True
|
||||
|
||||
# Use the internal streaming generator
|
||||
|
||||
# Use the internal streaming generator (guardrail-bypass warning is
|
||||
# emitted inside _start_stream so it covers start(stream=True) too).
|
||||
for chunk in self._start_stream(prompt, **kwargs):
|
||||
yield chunk
|
||||
|
||||
@@ -3897,6 +3985,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
|
||||
"""Stream generator for real-time response chunks."""
|
||||
# Warn if an output guardrail is configured: token-level streaming
|
||||
# yields chunks before a full response exists to validate, so the
|
||||
# guardrail cannot be applied without breaking the streaming contract.
|
||||
# This lives here (the single common streaming path) so both
|
||||
# iter_stream() and start(stream=True) surface the bypass loudly.
|
||||
if getattr(self, 'guardrail', None) is not None:
|
||||
logging.warning(
|
||||
f"Agent {getattr(self, 'name', '')}: output guardrail is not "
|
||||
"applied to streamed responses (iter_stream / stream=True). "
|
||||
"Use chat() for guardrail-validated output."
|
||||
)
|
||||
try:
|
||||
# Reset the final display flag for each new conversation
|
||||
self._final_display_shown = False
|
||||
|
||||
@@ -28,6 +28,44 @@ _server_started: Dict[int, bool] = {}
|
||||
_registered_agents: Dict[int, Dict[str, str]] = {}
|
||||
_shared_apps: Dict[int, Any] = {}
|
||||
|
||||
|
||||
def cleanup_launch_registration(agent_id: str) -> None:
|
||||
"""Remove launch() endpoints registered for ``agent_id`` from the shared
|
||||
HTTP state that ``Agent.launch()`` actually populates.
|
||||
|
||||
``Agent.launch()`` writes routes into the module-level ``_registered_agents``
|
||||
and ``_shared_apps`` dicts above. This tears those routes back down so a
|
||||
closed agent's endpoint stops accepting requests and the request handler
|
||||
closure no longer keeps the Agent object graph alive.
|
||||
"""
|
||||
with _server_lock:
|
||||
for port, paths in list(_registered_agents.items()):
|
||||
stale_paths = [p for p, aid in paths.items() if aid == agent_id]
|
||||
for p in stale_paths:
|
||||
del paths[p]
|
||||
app = _shared_apps.get(port)
|
||||
if app is not None:
|
||||
try:
|
||||
# Only drop the agent-owned POST route. launch() always
|
||||
# registers the handler via ``.post(path)``, so filtering
|
||||
# on both path AND method preserves unrelated routes that
|
||||
# share the path with a different verb (e.g. the built-in
|
||||
# GET /health and GET /).
|
||||
app.router.routes = [
|
||||
r for r in app.router.routes
|
||||
if not (
|
||||
getattr(r, "path", None) == p
|
||||
and "POST" in (getattr(r, "methods", None) or set())
|
||||
)
|
||||
]
|
||||
# Invalidate cached OpenAPI schema so removed routes
|
||||
# disappear from /openapi.json and /docs.
|
||||
app.openapi_schema = None
|
||||
except Exception:
|
||||
pass
|
||||
if not paths:
|
||||
_registered_agents.pop(port, None)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
@@ -1481,20 +1519,40 @@ Write the complete compiled report:"""
|
||||
logging.error(f"Function {function_name} not found in tools")
|
||||
return {"error": f"Function {function_name} not found in tools"}
|
||||
|
||||
# Activate the tool-progress channel so tools running under the async
|
||||
# path can stream incremental output (emit_tool_progress) and the
|
||||
# built-in todo tool can publish live updates (emit_todo_update),
|
||||
# mirroring the sync execute_tool path. Zero overhead when no stream
|
||||
# callbacks are registered — the sink stays None and the contextvar
|
||||
# is set to None (a no-op for emitters).
|
||||
_progress_sink = None
|
||||
_stream_emitter = self._get_existing_stream_emitter() if hasattr(self, "_get_existing_stream_emitter") else None
|
||||
if _stream_emitter is not None and _stream_emitter.has_callbacks:
|
||||
def _progress_sink(_event, _emitter=_stream_emitter): # noqa: ANN001 — StreamEvent forwarder
|
||||
_event.tool_call = {"name": function_name, "id": tool_call_id}
|
||||
_event.agent_id = self.name
|
||||
_emitter.emit(_event)
|
||||
|
||||
from ..streaming.events import tool_progress_channel
|
||||
|
||||
try:
|
||||
# BaseTool instances (plugin system, e.g. BrowserBaseTool) are not
|
||||
# directly callable — dispatch to their .run() method like the sync path.
|
||||
call_target = func.run if isinstance(func, BaseTool) else func
|
||||
if inspect.iscoroutinefunction(call_target):
|
||||
logging.debug(f"Executing async function: {function_name}")
|
||||
result = await call_target(**arguments)
|
||||
with tool_progress_channel(_progress_sink):
|
||||
result = await call_target(**arguments)
|
||||
else:
|
||||
logging.debug(f"Executing sync function in executor: {function_name}")
|
||||
loop = asyncio.get_running_loop()
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
result = await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: call_target(**arguments))
|
||||
)
|
||||
# Set the channel BEFORE copy_context_to_callable so the sink
|
||||
# propagates into the executor thread via contextvars.
|
||||
with tool_progress_channel(_progress_sink):
|
||||
result = await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: call_target(**arguments))
|
||||
)
|
||||
|
||||
# Ensure result is JSON serializable
|
||||
logging.debug(f"Raw result from tool: {result}")
|
||||
@@ -1740,75 +1798,31 @@ Write the complete compiled report:"""
|
||||
|
||||
def _launch_mcp_server(self, path: str, port: int, host: str, debug: bool):
|
||||
"""
|
||||
Launch MCP server (internal implementation).
|
||||
|
||||
NOTE: This implementation will be moved to wrapper layer in future version.
|
||||
For now, it maintains backward compatibility while following lazy import patterns.
|
||||
Launch this single agent as an MCP server.
|
||||
|
||||
Delegates to the ``praisonai-mcp`` agent adapter via ``serve_agents([self])``
|
||||
so that ``Agent.launch(protocol="mcp")`` and
|
||||
``PraisonAIAgents.launch(protocol="mcp")`` share one code path and one
|
||||
vocabulary (publishing ``ask_{agent_name}`` + ``list_agents``). The mcp
|
||||
package is an optional dependency imported lazily here so core keeps no
|
||||
hard dependency on it.
|
||||
"""
|
||||
# For now, delegate to the existing MCP implementation
|
||||
# This will be extracted to a proper adapter in the future
|
||||
try:
|
||||
import uvicorn
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.routing import Mount
|
||||
import threading
|
||||
import time
|
||||
import asyncio
|
||||
|
||||
mcp_server_instance_name = f"{self.name}_mcp_server" if self.name else "agent_mcp_server"
|
||||
mcp = FastMCP(mcp_server_instance_name)
|
||||
from praisonai_mcp import serve_agents
|
||||
|
||||
# Determine the MCP tool name based on self.name
|
||||
actual_mcp_tool_name = f"execute_{self.name.lower().replace(' ', '_').replace('-', '_')}_task" if self.name else "execute_task"
|
||||
|
||||
@mcp.tool(name=actual_mcp_tool_name)
|
||||
async def execute_agent_task(prompt: str) -> str:
|
||||
"""Executes the agent's primary task with the given prompt."""
|
||||
try:
|
||||
if hasattr(self, 'achat') and asyncio.iscoroutinefunction(self.achat):
|
||||
response = await self.achat(prompt, tools=self.tools, task_name=None, task_description=None, task_id=None)
|
||||
elif hasattr(self, 'chat'):
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
loop = asyncio.get_event_loop()
|
||||
response = await loop.run_in_executor(None, copy_context_to_callable(lambda p=prompt: self.chat(p, tools=self.tools)))
|
||||
else:
|
||||
return f"Error: Agent {self.name} misconfigured for MCP."
|
||||
return response if response is not None else "Agent returned no response."
|
||||
except Exception as e:
|
||||
return f"Error executing task: {str(e)}"
|
||||
|
||||
# Create and run MCP server
|
||||
base_path = (path or "/mcp").rstrip("/") or "/mcp"
|
||||
transport = SseServerTransport(f"{base_path}/sse")
|
||||
starlette_app = Starlette(
|
||||
routes=[Mount(base_path, mcp.sse_app())]
|
||||
# Keep the call inside the ImportError guard: serve_agents() lazily
|
||||
# imports its transport backend, so a package that is installed
|
||||
# without its optional transport extras surfaces the missing
|
||||
# dependency here rather than at the import line above. Catching it
|
||||
# in the same place yields one actionable install message instead of
|
||||
# an uncaught traceback.
|
||||
return serve_agents([self], host=host, port=port)
|
||||
except ImportError:
|
||||
_get_display_functions()['display_error'](
|
||||
"MCP serving requires the 'praisonai-mcp' package."
|
||||
)
|
||||
|
||||
def run_mcp_server():
|
||||
try:
|
||||
uvicorn.run(starlette_app, host=host, port=port, log_level="debug" if debug else "info")
|
||||
except Exception as e:
|
||||
logging.error(f"Error starting MCP server: {str(e)}", exc_info=True)
|
||||
|
||||
server_thread = threading.Thread(target=run_mcp_server, daemon=True)
|
||||
server_thread.start()
|
||||
self._safe_sleep(0.5)
|
||||
|
||||
try:
|
||||
print("\nKeeping MCP server alive. Press Ctrl+C to stop.")
|
||||
while True:
|
||||
self._safe_sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nMCP Server stopped")
|
||||
print("\nTo add MCP capabilities, install: pip install praisonai-mcp")
|
||||
return None
|
||||
|
||||
except ImportError as e:
|
||||
missing_module = str(e).split("No module named '")[-1].rstrip("'")
|
||||
_get_display_functions()['display_error'](f"Missing dependency: {missing_module}. Required for MCP mode.")
|
||||
print(f"\nTo add MCP capabilities, install: pip install {missing_module}")
|
||||
return None
|
||||
|
||||
async def _emit_retry_hook_async(self, tool_name, attempt, delay_ms, error, max_attempts, error_type):
|
||||
"""Emit ON_RETRY hook event (async version).
|
||||
|
||||
@@ -170,7 +170,9 @@ from ..errors import (
|
||||
# across every coroutine running on the same event-loop thread. Concurrent
|
||||
# async handoffs (``asyncio.gather``, a server handling parallel requests, or
|
||||
# any ``max_concurrent`` workflow) would otherwise push/pop into the same list
|
||||
# and corrupt each other's cycle/depth state.
|
||||
# and corrupt each other's cycle/depth state. Combined with the copy-on-write
|
||||
# push/pop below, this fully isolates sibling handoff tasks (e.g. those spawned
|
||||
# by ``parallel_handoffs`` via ``asyncio.gather``).
|
||||
_handoff_chain_var: "contextvars.ContextVar[Optional[List[str]]]" = contextvars.ContextVar(
|
||||
"handoff_chain", default=None
|
||||
)
|
||||
@@ -306,12 +308,14 @@ class Handoff:
|
||||
- Cycle detection and depth limiting
|
||||
- Configurable context policies
|
||||
"""
|
||||
|
||||
# Class-level semaphore for concurrency control
|
||||
_semaphore: Optional[asyncio.Semaphore] = None
|
||||
_sync_semaphore: Optional[threading.Semaphore] = None
|
||||
_semaphore_lock: threading.Lock = threading.Lock() # Lock for semaphore initialization
|
||||
|
||||
|
||||
# Class-level lock guarding lazy creation of each instance's async
|
||||
# semaphore. The semaphore itself is per-instance (so every Handoff
|
||||
# enforces its own max_concurrent), but the brief init critical section is
|
||||
# only ever touched under this shared threading.Lock, which keeps
|
||||
# double-checked lazy creation thread-safe without a per-instance lock.
|
||||
_semaphore_lock: threading.Lock = threading.Lock()
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent: 'Agent',
|
||||
@@ -342,10 +346,40 @@ class Handoff:
|
||||
self.input_type = input_type
|
||||
self.input_filter = input_filter
|
||||
self.config = config or HandoffConfig()
|
||||
|
||||
# Instance-level concurrency control. Each Handoff enforces its own
|
||||
# max_concurrent instead of sharing one process-wide semaphore, so
|
||||
# different handoffs don't silently override each other's limits based
|
||||
# on execution order. The async semaphore is created lazily (and
|
||||
# recreated if bound to a stale event loop) since it binds to the
|
||||
# running loop on first use.
|
||||
self._semaphore: Optional[asyncio.Semaphore] = None
|
||||
self._semaphore_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
# Override config callback if on_handoff provided directly
|
||||
if on_handoff and not self.config.on_handoff:
|
||||
self.config.on_handoff = on_handoff
|
||||
|
||||
def _get_semaphore(self) -> Optional[asyncio.Semaphore]:
|
||||
"""Return this handoff's async semaphore, bound to the current loop.
|
||||
|
||||
Returns None when concurrency limiting is disabled (max_concurrent <= 0).
|
||||
The semaphore is created lazily and recreated if the running event loop
|
||||
differs from the one it was originally bound to, so a process that calls
|
||||
asyncio.run(...) more than once doesn't crash with a "bound to a
|
||||
different event loop" RuntimeError.
|
||||
"""
|
||||
if self.config.max_concurrent <= 0:
|
||||
return None
|
||||
try:
|
||||
current_loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
current_loop = None
|
||||
with self._semaphore_lock:
|
||||
if self._semaphore is None or self._semaphore_loop is not current_loop:
|
||||
self._semaphore = asyncio.Semaphore(self.config.max_concurrent)
|
||||
self._semaphore_loop = current_loop
|
||||
return self._semaphore
|
||||
|
||||
@property
|
||||
def tool_name(self) -> str:
|
||||
@@ -614,13 +648,15 @@ class Handoff:
|
||||
"""
|
||||
start_time = time.time()
|
||||
kwargs = context or {}
|
||||
pushed = False
|
||||
|
||||
try:
|
||||
# Safety checks
|
||||
# Safety checks (may raise before anything is pushed for this call)
|
||||
self._check_safety(source_agent)
|
||||
|
||||
# Track handoff chain
|
||||
_push_handoff(source_agent.name)
|
||||
pushed = True
|
||||
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
@@ -696,7 +732,8 @@ class Handoff:
|
||||
logger.error(f"Handoff error: {e}")
|
||||
return result
|
||||
finally:
|
||||
_pop_handoff()
|
||||
if pushed:
|
||||
_pop_handoff()
|
||||
|
||||
async def execute_async(
|
||||
self,
|
||||
@@ -715,20 +752,19 @@ class Handoff:
|
||||
Returns:
|
||||
HandoffResult with response or error
|
||||
"""
|
||||
# Initialize semaphore if needed (thread-safe)
|
||||
if self.config.max_concurrent > 0 and Handoff._semaphore is None:
|
||||
with Handoff._semaphore_lock: # Thread-safe initialization
|
||||
if Handoff._semaphore is None: # Double-check after acquiring lock
|
||||
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
|
||||
# Per-instance semaphore, bound to the current event loop
|
||||
semaphore = self._get_semaphore()
|
||||
|
||||
start_time = time.time()
|
||||
kwargs = context or {}
|
||||
|
||||
async def _execute():
|
||||
pushed = False
|
||||
try:
|
||||
# Safety checks
|
||||
# Safety checks (may raise before anything is pushed for this call)
|
||||
self._check_safety(source_agent)
|
||||
_push_handoff(source_agent.name)
|
||||
pushed = True
|
||||
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
@@ -777,11 +813,12 @@ class Handoff:
|
||||
|
||||
return result
|
||||
finally:
|
||||
_pop_handoff()
|
||||
if pushed:
|
||||
_pop_handoff()
|
||||
|
||||
try:
|
||||
if self.config.max_concurrent > 0 and Handoff._semaphore:
|
||||
async with Handoff._semaphore:
|
||||
if semaphore is not None:
|
||||
async with semaphore:
|
||||
if self.config.timeout_seconds > 0:
|
||||
result = await asyncio.wait_for(
|
||||
_execute(),
|
||||
@@ -845,13 +882,15 @@ class Handoff:
|
||||
def handoff_tool(**kwargs):
|
||||
"""Execute the handoff to the target agent."""
|
||||
start_time = time.time()
|
||||
pushed = False
|
||||
|
||||
try:
|
||||
# Safety checks
|
||||
# Safety checks (may raise before anything is pushed for this call)
|
||||
self._check_safety(source_agent)
|
||||
|
||||
# Track handoff chain
|
||||
_push_handoff(source_agent.name)
|
||||
pushed = True
|
||||
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
@@ -937,7 +976,8 @@ class Handoff:
|
||||
self._execute_callback(self.config.on_error, source_agent, kwargs, result)
|
||||
return f"Error during handoff to {self.agent.name}: {str(e)}"
|
||||
finally:
|
||||
_pop_handoff()
|
||||
if pushed:
|
||||
_pop_handoff()
|
||||
|
||||
# Set function metadata for tool definition generation
|
||||
handoff_tool.__name__ = self.tool_name
|
||||
@@ -1188,6 +1228,13 @@ async def parallel_handoffs(
|
||||
semaphore = asyncio.Semaphore(effective_max_concurrent) if effective_max_concurrent > 0 else None
|
||||
|
||||
async def _run_one(agent, prompt):
|
||||
# Each gathered task shares a copied context, but a copied ContextVar
|
||||
# binding still points at the *same* parent list object. Rebind to a
|
||||
# fresh, per-task copy so sibling handoffs cannot corrupt each other's
|
||||
# cycle/depth tracking when parallel_handoffs runs inside a non-empty
|
||||
# parent handoff chain.
|
||||
_handoff_chain_var.set(list(_handoff_chain_var.get() or []))
|
||||
|
||||
async def _do_handoff():
|
||||
try:
|
||||
return await source.handoff_to_async(agent, prompt, config=config)
|
||||
@@ -1408,13 +1455,15 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
|
||||
start_time = time.time()
|
||||
kwargs = context or {}
|
||||
pushed = False
|
||||
|
||||
try:
|
||||
# Safety checks
|
||||
# Safety checks (may raise before anything is pushed for this call)
|
||||
self._check_safety(source_agent)
|
||||
|
||||
# Track handoff chain
|
||||
_push_handoff(source_agent.name)
|
||||
pushed = True
|
||||
|
||||
# Execute on_handoff callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
@@ -1487,7 +1536,8 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
logger.error(f"Typed handoff error: {e}")
|
||||
return result
|
||||
finally:
|
||||
_pop_handoff()
|
||||
if pushed:
|
||||
_pop_handoff()
|
||||
|
||||
async def execute_async(
|
||||
self,
|
||||
@@ -1514,20 +1564,19 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
if isinstance(payload, str):
|
||||
return await super().execute_async(source_agent, payload, context)
|
||||
|
||||
# Initialize semaphore if needed (thread-safe)
|
||||
if self.config.max_concurrent > 0 and Handoff._semaphore is None:
|
||||
with Handoff._semaphore_lock:
|
||||
if Handoff._semaphore is None:
|
||||
Handoff._semaphore = asyncio.Semaphore(self.config.max_concurrent)
|
||||
# Per-instance semaphore, bound to the current event loop
|
||||
semaphore = self._get_semaphore()
|
||||
|
||||
start_time = time.time()
|
||||
kwargs = context or {}
|
||||
|
||||
async def _execute():
|
||||
pushed = False
|
||||
try:
|
||||
# Safety checks
|
||||
# Safety checks (may raise before anything is pushed for this call)
|
||||
self._check_safety(source_agent)
|
||||
_push_handoff(source_agent.name)
|
||||
pushed = True
|
||||
|
||||
# Execute callback
|
||||
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
|
||||
@@ -1575,11 +1624,12 @@ class TypedHandoff(Handoff, Generic[T]):
|
||||
handoff_depth=_get_handoff_depth(),
|
||||
)
|
||||
finally:
|
||||
_pop_handoff()
|
||||
if pushed:
|
||||
_pop_handoff()
|
||||
|
||||
try:
|
||||
if self.config.max_concurrent > 0 and Handoff._semaphore:
|
||||
async with Handoff._semaphore:
|
||||
if semaphore is not None:
|
||||
async with semaphore:
|
||||
if self.config.timeout_seconds > 0:
|
||||
result = await asyncio.wait_for(
|
||||
_execute(),
|
||||
|
||||
@@ -240,21 +240,25 @@ class MemoryMixin:
|
||||
if hasattr(self._history_lock, 'is_async_context') and self._history_lock.is_async_context():
|
||||
# Cannot use asyncio.Lock in sync context - use thread lock as fallback
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
with self._history_lock._lock._thread_lock:
|
||||
if self._session_id is None: # Double-check after acquiring lock
|
||||
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
|
||||
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
|
||||
self._session_id = f"{hour_str}-{agent_hash}"
|
||||
# per-instance suffix so same-named agents can never collide
|
||||
self._session_id = f"{hour_str}-{agent_hash}-{uuid.uuid4().hex[:8]}"
|
||||
else:
|
||||
# Use sync lock directly
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
with self._history_lock.lock():
|
||||
if self._session_id is None: # Double-check after acquiring lock
|
||||
hour_str = datetime.now(timezone.utc).strftime("%Y%m%d%H")
|
||||
agent_hash = hashlib.sha256((self.name or "agent").encode()).hexdigest()[:6]
|
||||
self._session_id = f"{hour_str}-{agent_hash}"
|
||||
# per-instance suffix so same-named agents can never collide
|
||||
self._session_id = f"{hour_str}-{agent_hash}-{uuid.uuid4().hex[:8]}"
|
||||
|
||||
# Call db adapter's on_agent_start to get previous messages
|
||||
try:
|
||||
|
||||
@@ -872,12 +872,34 @@ class ToolExecutionMixin:
|
||||
# model-visible image parts via format_tool_result_messages().
|
||||
_is_multimodal_result = _normalize_multimodal_result(result) is not None
|
||||
|
||||
# Context economy: if the tool declared a compact, model-facing view
|
||||
# (ToolResult.model_output, or a to_model_output(result) hook on the
|
||||
# @tool/BaseTool), resolve it now while the FULL result is still
|
||||
# intact for tracing/hooks/display below. The compact view is only
|
||||
# swapped in at the very end, right before the string returned to
|
||||
# the model is built, so downstream consumers keep the full payload.
|
||||
_model_facing_override = None
|
||||
if not _is_multimodal_result:
|
||||
_model_facing_override = self._resolve_model_facing_result(
|
||||
function_name, result
|
||||
)
|
||||
|
||||
# Apply prompt injection protection for external tools
|
||||
# Zero-cost for trusted tools, wraps external content in security markers
|
||||
if not _is_multimodal_result:
|
||||
try:
|
||||
from ..tools.trust import wrap_if_external
|
||||
result = wrap_if_external(function_name, result)
|
||||
# The compact, model-facing view is untrusted external
|
||||
# content too when the producing tool is external, so it
|
||||
# MUST pass through the same prompt-injection fence before
|
||||
# it can reach the model. Resolving it above (pre-fence)
|
||||
# kept the full payload intact for hooks/tracing; fence the
|
||||
# override here so it never bypasses the security markers.
|
||||
if _model_facing_override is not None:
|
||||
_model_facing_override = wrap_if_external(
|
||||
function_name, _model_facing_override
|
||||
)
|
||||
except Exception:
|
||||
# Trust module unavailable (partial/broken install) must not
|
||||
# abort tool execution; fall through with the raw result.
|
||||
@@ -970,8 +992,12 @@ class ToolExecutionMixin:
|
||||
logging.debug(f"Tool truncation skipped: {e}")
|
||||
|
||||
# Emit tool call end event (truncation handled by context_events.py)
|
||||
# Only stringify the result if the emitter is actually enabled; the
|
||||
# default emitter is a disabled NoOp, so str(result) would otherwise
|
||||
# re-serialise the whole structure for nothing on every tool call.
|
||||
_duration_ms = (_time.time() - _tool_start_time) * 1000
|
||||
_trace_emitter.tool_call_end(self.name, function_name, str(result) if result else None, _duration_ms)
|
||||
if _trace_emitter.enabled:
|
||||
_trace_emitter.tool_call_end(self.name, function_name, str(result) if result else None, _duration_ms)
|
||||
|
||||
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
@@ -1013,7 +1039,18 @@ class ToolExecutionMixin:
|
||||
tool_error = self._last_normalized_result.error_message
|
||||
# Clean up temporary attribute
|
||||
delattr(self, '_last_normalized_result')
|
||||
|
||||
|
||||
# Context economy: ``result`` stays the FULL output through the
|
||||
# AFTER_TOOL hook, result-aware loop detection, and the loop guard
|
||||
# (below), so those channels keep seeing real, changing outcomes.
|
||||
# A tool-declared compact model-facing view (if any) is only swapped
|
||||
# in at the very end, right before ``return``, purely to shrink what
|
||||
# the LLM sees. No override => behaviour is unchanged. Model-facing
|
||||
# annotations added below (AFTER_TOOL additional_context, loop-guard
|
||||
# notices) are collected here so they can be re-applied to the
|
||||
# compact view and therefore always reach the model.
|
||||
_model_facing_annotations = []
|
||||
|
||||
# Only build the input if an AFTER_TOOL hook is actually registered
|
||||
if self._hook_runner.registry.has_hooks(HookEvent.AFTER_TOOL):
|
||||
after_tool_input = AfterToolInput(
|
||||
@@ -1042,6 +1079,9 @@ class ToolExecutionMixin:
|
||||
result.setdefault("_additional_context", extra_context)
|
||||
else:
|
||||
result = {"value": result, "_additional_context": extra_context}
|
||||
# Mirror any model-facing annotation onto the compact view so
|
||||
# it survives the context-economy swap at the end.
|
||||
_model_facing_annotations.append(extra_context)
|
||||
|
||||
# Back-fill the result hash so the result-aware detector can tell a
|
||||
# genuine stall (identical output) from legitimate polling (changing
|
||||
@@ -1085,10 +1125,28 @@ class ToolExecutionMixin:
|
||||
else:
|
||||
# Wrap non-string/dict/list results to preserve original data plus warning
|
||||
result = {"value": result, "_loop_guard": {"message": decision.message, "action": decision.action.value}}
|
||||
# Ensure the loop-guard notice also reaches the model when a
|
||||
# compact model-facing view replaces ``result`` below.
|
||||
_model_facing_annotations.append(f"[loop-guard] {decision.message}")
|
||||
|
||||
# Increment per-turn tool count for no-tool-call detection
|
||||
self._autonomy_turn_tool_count = getattr(self, '_autonomy_turn_tool_count', 0) + 1
|
||||
|
||||
|
||||
# Context economy: now that tracing, hooks, loop detection and the
|
||||
# loop guard have all observed the FULL result, swap in the compact,
|
||||
# tool-declared model-facing view for the LLM. Re-apply any
|
||||
# model-facing annotations so they are never lost. No override =>
|
||||
# the full result is returned exactly as before.
|
||||
if _model_facing_override is not None:
|
||||
result = _model_facing_override
|
||||
for _annotation in _model_facing_annotations:
|
||||
if isinstance(result, str):
|
||||
result = f"{result}\n\n{_annotation}"
|
||||
elif isinstance(result, dict):
|
||||
result.setdefault("_additional_context", _annotation)
|
||||
else:
|
||||
result = {"value": result, "_additional_context": _annotation}
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
# Emit tool call end with error for exceptions that escape the retry loop
|
||||
@@ -1357,7 +1415,7 @@ class ToolExecutionMixin:
|
||||
try:
|
||||
from ..runtime.tool_output_store import get_tool_output_store
|
||||
from uuid import uuid4
|
||||
store = get_tool_output_store(getattr(self, '_run_id', None))
|
||||
store = get_tool_output_store(getattr(self, '_current_run_id', None))
|
||||
# Add unique suffix to prevent collisions with repeated keys
|
||||
unique_suffix = uuid4().hex[:8]
|
||||
field_call_id = f"{tool_call_id}_{key}_{unique_suffix}" if tool_call_id else f"{tool_name}_{key}_{unique_suffix}"
|
||||
@@ -1565,7 +1623,9 @@ class ToolExecutionMixin:
|
||||
- ``PLAN``: read-only exploration → deny any tool not tagged read-only.
|
||||
- ``DONT_ASK``: auto-deny anything that would otherwise prompt for input
|
||||
(an explicit ``ask`` rule or a known dangerous tool).
|
||||
- ``DEFAULT``/``ACCEPT_EDITS``/unset: defer (return ``None``).
|
||||
- ``ACCEPT_EDITS``: auto-approve file edit/write tools; defer the rest so
|
||||
non-edit prompts (shell exec, deletes, external tools) still apply.
|
||||
- ``DEFAULT``/unset: defer (return ``None``).
|
||||
"""
|
||||
mode = getattr(self, "_permission_mode", None)
|
||||
if mode is None:
|
||||
@@ -1623,6 +1683,17 @@ class ToolExecutionMixin:
|
||||
))
|
||||
return None
|
||||
|
||||
if mode == PermissionMode.ACCEPT_EDITS:
|
||||
# Auto-approve file edit/write tools so edits flow without prompting,
|
||||
# while deferring everything else (shell exec, deletes, external
|
||||
# tools) to the normal approval flow so they still gate as usual.
|
||||
if self._is_edit_tool(function_name):
|
||||
return _wrap(ApprovalDecision(
|
||||
approved=True,
|
||||
reason=f"PermissionMode.ACCEPT_EDITS: auto-approved edit tool '{function_name}'",
|
||||
))
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
def _tool_would_prompt(self, function_name) -> bool:
|
||||
@@ -1669,6 +1740,23 @@ class ToolExecutionMixin:
|
||||
)
|
||||
return not any(marker in name for marker in write_markers)
|
||||
|
||||
@staticmethod
|
||||
def _is_edit_tool(function_name) -> bool:
|
||||
"""Best-effort heuristic for whether a tool edits/writes a file.
|
||||
|
||||
Used by ``PermissionMode.ACCEPT_EDITS`` to auto-approve edit-class tools
|
||||
(write/edit/append/create/save/patch/mkdir) while leaving destructive or
|
||||
execution tools (delete/exec/shell/…) to the normal approval flow.
|
||||
"""
|
||||
if not function_name:
|
||||
return False
|
||||
name = str(function_name).lower()
|
||||
edit_markers = (
|
||||
"write", "edit", "append", "create", "mkdir", "save",
|
||||
"insert", "patch", "apply_patch",
|
||||
)
|
||||
return any(marker in name for marker in edit_markers)
|
||||
|
||||
def _check_permission_manager_deny(self, function_name):
|
||||
"""Return an error dict if the PermissionManager hard-denies the tool.
|
||||
|
||||
@@ -1734,12 +1822,14 @@ class ToolExecutionMixin:
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(function_name)
|
||||
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(
|
||||
function_name, arguments, agent_name=getattr(self, "name", None)
|
||||
)
|
||||
return None, arguments
|
||||
|
||||
async def _check_tool_approval_async(self, function_name, arguments):
|
||||
@@ -1765,12 +1855,14 @@ class ToolExecutionMixin:
|
||||
logging.warning(error_msg)
|
||||
return {"error": error_msg, "approval_denied": True}
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(function_name)
|
||||
|
||||
if decision.modified_args:
|
||||
arguments = decision.modified_args
|
||||
logging.info(f"Using modified arguments: {arguments}")
|
||||
|
||||
from ..approval import get_approval_registry
|
||||
get_approval_registry().mark_approved(
|
||||
function_name, arguments, agent_name=getattr(self, "name", None)
|
||||
)
|
||||
return None, arguments
|
||||
|
||||
def _execute_tool_with_circuit_breaker(self, function_name, arguments):
|
||||
@@ -2268,6 +2360,49 @@ class ToolExecutionMixin:
|
||||
return getattr(tool, '__name__', None)
|
||||
return None
|
||||
|
||||
def _resolve_model_facing_result(self, function_name, result):
|
||||
"""Return a tool's compact, model-facing view for ``result``, or ``None``.
|
||||
|
||||
Resolution order (first hit wins):
|
||||
|
||||
1. ``result.model_output`` — a ToolResult carrying its own compact view.
|
||||
2. The producing tool's ``to_model_output(output)`` hook — set via
|
||||
``@tool(to_model_output=fn)`` or a ``BaseTool`` override.
|
||||
|
||||
The hook is invoked with the tool's raw output **value**, matching the
|
||||
single ``BaseTool.to_model_output`` contract: if ``result`` is a
|
||||
``ToolResult`` the enclosed ``output`` is passed, otherwise ``result``
|
||||
itself. ``None`` means the tool opted out, so the caller keeps today's
|
||||
full stringification (fully backward-compatible).
|
||||
"""
|
||||
try:
|
||||
from ..tools.base import resolve_model_output
|
||||
compact = resolve_model_output(result)
|
||||
if compact is not None:
|
||||
return compact
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Unwrap to the raw output value so the hook receives the same payload
|
||||
# BaseTool.safe_run passes (the ``output`` channel), keeping one contract.
|
||||
hook_input = getattr(result, 'output', result)
|
||||
try:
|
||||
for name, tool in self._iter_active_named_tools():
|
||||
if name != function_name:
|
||||
continue
|
||||
hook = getattr(tool, 'to_model_output', None)
|
||||
if callable(hook):
|
||||
try:
|
||||
return hook(hook_input)
|
||||
except Exception as e:
|
||||
logging.debug(
|
||||
f"to_model_output hook failed for '{function_name}': {e}"
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def _iter_active_named_tools(self):
|
||||
"""Yield ``(name, tool)`` for every active tool.
|
||||
|
||||
|
||||
@@ -388,6 +388,18 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
|
||||
|
||||
# Build context first to include in task prompt
|
||||
context_text = ""
|
||||
# Inter-task context / validation feedback assembled by the workflow process
|
||||
# engine (Process._build_task_context) is stored on the task; fold it in so
|
||||
# downstream/retried tasks actually see upstream output and rejection reasons.
|
||||
extra_context = getattr(task, '_execution_context', None)
|
||||
if extra_context:
|
||||
context_text = extra_context
|
||||
# NOTE: We intentionally do NOT clear _execution_context here. Task
|
||||
# retries (guardrail/completion failures) re-enter this helper via the
|
||||
# run_task/arun_task retry loops, and clearing would strip the upstream
|
||||
# output + validation feedback the retry needs. The Process engine owns
|
||||
# this field's lifecycle: it re-sets it before each task yield and resets
|
||||
# every task's _execution_context to None before selecting the next task.
|
||||
if task.context:
|
||||
context_results = [] # Collect contexts then de-duplicate
|
||||
for context_item in task.context:
|
||||
@@ -404,7 +416,11 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
|
||||
for i, ctx in enumerate(unique_contexts):
|
||||
logger.debug(f"Context {i+1}: {ctx[:100]}...")
|
||||
context_separator = '\n\n'
|
||||
context_text = context_separator.join(unique_contexts)
|
||||
joined_contexts = context_separator.join(unique_contexts)
|
||||
if context_text and joined_contexts:
|
||||
context_text = context_text + context_separator + joined_contexts
|
||||
elif joined_contexts:
|
||||
context_text = joined_contexts
|
||||
|
||||
# Build task prompt using DRY helper
|
||||
task_prompt = _prepare_task_prompt(task, task_description, context_text)
|
||||
@@ -473,26 +489,59 @@ def _process_task_result(agents_instance, context, agent_output):
|
||||
except Exception as e:
|
||||
logger.warning(f"Warning: Could not clean output of task {task_id}: {e}")
|
||||
|
||||
json_parse_error = None
|
||||
pydantic_parse_error = None
|
||||
|
||||
if task.output_json:
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
task_output.json_dict = parsed
|
||||
task_output.output_format = "JSON"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON")
|
||||
except Exception as e:
|
||||
json_parse_error = e
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as JSON: {e}")
|
||||
logger.debug(f"Output that failed JSON parsing: {agent_output}")
|
||||
|
||||
if task.output_pydantic:
|
||||
try:
|
||||
parsed = json.loads(cleaned)
|
||||
pyd_obj = task.output_pydantic(**parsed)
|
||||
if hasattr(task.output_pydantic, "model_validate"):
|
||||
pyd_obj = task.output_pydantic.model_validate(parsed)
|
||||
else:
|
||||
pyd_obj = task.output_pydantic(**parsed)
|
||||
task_output.pydantic = pyd_obj
|
||||
task_output.output_format = "Pydantic"
|
||||
except Exception:
|
||||
logger.warning(f"Warning: Could not parse output of task {task_id} as Pydantic Model")
|
||||
except Exception as e:
|
||||
pydantic_parse_error = e
|
||||
schema_name = getattr(task.output_pydantic, "__name__", str(task.output_pydantic))
|
||||
logger.warning(
|
||||
f"Warning: Could not parse output of task {task_id} as Pydantic Model "
|
||||
f"({schema_name}): {e}"
|
||||
)
|
||||
logger.debug(f"Output that failed Pydantic parsing: {agent_output}")
|
||||
|
||||
task.result = task_output
|
||||
|
||||
# Fail-closed: when structured output was requested but not produced,
|
||||
# the task did not fulfil its contract. Surface it as a failed result so
|
||||
# callers relying on TaskResult.success (and the retry loop, which uses
|
||||
# completion_checker) do not treat freeform prose as a success.
|
||||
if task.output_pydantic and task_output.pydantic is None:
|
||||
schema_name = getattr(task.output_pydantic, "__name__", str(task.output_pydantic))
|
||||
detail = f" ({pydantic_parse_error})" if pydantic_parse_error is not None else ""
|
||||
return TaskResult(
|
||||
task_output=task_output,
|
||||
success=False,
|
||||
error=f"Structured output validation failed for {schema_name}: could not parse agent output as the requested Pydantic model.{detail}",
|
||||
)
|
||||
if task.output_json and task_output.json_dict is None:
|
||||
detail = f" ({json_parse_error})" if json_parse_error is not None else ""
|
||||
return TaskResult(
|
||||
task_output=task_output,
|
||||
success=False,
|
||||
error=f"Structured output validation failed: could not parse agent output as JSON.{detail}",
|
||||
)
|
||||
|
||||
return TaskResult(task_output=task_output, success=True)
|
||||
else:
|
||||
task.status = "failed"
|
||||
@@ -823,6 +872,9 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
_max_retries = 3
|
||||
self.completion_checker = _completion_checker if _completion_checker else self.default_completion_checker
|
||||
self.task_id_counter = 0
|
||||
# Last user-supplied task id, tracked for hierarchical runs so the
|
||||
# return path never surfaces the synthetic manager_task's output.
|
||||
self._last_real_task_id = None
|
||||
self._task_id_lock = threading.Lock() # Thread-safe task ID assignment
|
||||
self._state_lock = threading.Lock() # Thread-safe state mutations
|
||||
self.verbose = _verbose
|
||||
@@ -1039,6 +1091,26 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self.task_id_counter += 1
|
||||
return task_id
|
||||
|
||||
def _last_hierarchical_task_id(self):
|
||||
"""Return the last user-supplied task id before hierarchical injection.
|
||||
|
||||
The hierarchical process injects a synthetic ``manager_task`` that always
|
||||
lands last in the insertion-ordered ``self.tasks`` dict. Snapshotting the
|
||||
real task ids up front lets the return path surface the final delegated
|
||||
task's result instead of the Manager's own generic answer.
|
||||
|
||||
A ``manager_task`` from a prior run of the same team can still be present
|
||||
in ``self.tasks`` (it is not removed after a run), so it is explicitly
|
||||
skipped here; otherwise a second ``.start()`` would return the previous
|
||||
run's Manager output instead of the final user-task result.
|
||||
"""
|
||||
for task_id in reversed(self.tasks):
|
||||
task = self.tasks.get(task_id)
|
||||
if getattr(task, "name", None) == "manager_task":
|
||||
continue
|
||||
return task_id
|
||||
return None
|
||||
|
||||
def clean_json_output(self, output: str) -> str:
|
||||
# NOTE: This method is duplicated in chat_mixin.ChatMixin.clean_json_output.
|
||||
# Keep both implementations in sync when modifying either.
|
||||
@@ -1097,10 +1169,14 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
return self._context_manager
|
||||
|
||||
def default_completion_checker(self, task, agent_output):
|
||||
if task.output_json and task.result and task.result.json_dict:
|
||||
return True
|
||||
if task.output_pydantic and task.result and task.result.pydantic:
|
||||
return True
|
||||
# Fail-closed for structured output: if a structured model was requested
|
||||
# but not produced, the task is NOT complete. Returning False here drives
|
||||
# the existing retry loop and, once retries are exhausted, leaves the task
|
||||
# in a "failed" state instead of silently succeeding with freeform prose.
|
||||
if task.output_json:
|
||||
return task.result is not None and task.result.json_dict is not None
|
||||
if task.output_pydantic:
|
||||
return task.result is not None and task.result.pydantic is not None
|
||||
return len(agent_output.strip()) > 0
|
||||
|
||||
async def aexecute_task(self, task_id):
|
||||
@@ -1332,6 +1408,20 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
task.status = "failed" # Set failed status to match sync behavior
|
||||
logger.info(f"Task {task_id} failed after {task_max} retries.")
|
||||
|
||||
@staticmethod
|
||||
async def _gather_with_isolation(coros):
|
||||
"""Gather async tasks with exception isolation.
|
||||
|
||||
Uses return_exceptions=True so a single failure does not orphan its
|
||||
siblings (leaving them running in the background to mutate shared state).
|
||||
The first exception is re-raised after all siblings have settled.
|
||||
"""
|
||||
results = await asyncio.gather(*coros, return_exceptions=True)
|
||||
for result in results:
|
||||
if isinstance(result, BaseException):
|
||||
raise result
|
||||
return results
|
||||
|
||||
async def arun_all_tasks(self):
|
||||
"""Async version of run_all_tasks method"""
|
||||
process = Process(
|
||||
@@ -1350,7 +1440,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
else:
|
||||
# If we encounter a sync task, we must wait for the previous async tasks to finish.
|
||||
if tasks_to_run:
|
||||
await asyncio.gather(*tasks_to_run)
|
||||
await self._gather_with_isolation(tasks_to_run)
|
||||
tasks_to_run = []
|
||||
|
||||
# Run sync task in an executor to avoid blocking the event loop
|
||||
@@ -1360,7 +1450,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
await loop.run_in_executor(None, copy_context_to_callable(lambda tid=task_id: self.run_task(tid)))
|
||||
|
||||
if tasks_to_run:
|
||||
await asyncio.gather(*tasks_to_run)
|
||||
await self._gather_with_isolation(tasks_to_run)
|
||||
|
||||
elif self.process == "sequential":
|
||||
async_tasks_to_run = []
|
||||
@@ -1369,7 +1459,7 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""Execute all pending async tasks"""
|
||||
nonlocal async_tasks_to_run
|
||||
if async_tasks_to_run:
|
||||
await asyncio.gather(*async_tasks_to_run)
|
||||
await self._gather_with_isolation(async_tasks_to_run)
|
||||
async_tasks_to_run = []
|
||||
|
||||
async for task_id in process.asequential():
|
||||
@@ -1388,6 +1478,12 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Execute any remaining async tasks at the end
|
||||
await flush_async_tasks()
|
||||
elif self.process == "hierarchical":
|
||||
# Snapshot the real (user-supplied) task ids before the hierarchical
|
||||
# generator injects its synthetic manager_task. The manager_task is
|
||||
# always added last (highest id), so relying on dict-insertion order
|
||||
# in the return path would surface the Manager's own generic answer
|
||||
# instead of the final delegated task's result.
|
||||
self._last_real_task_id = self._last_hierarchical_task_id()
|
||||
async for task_id in process.ahierarchical():
|
||||
if isinstance(task_id, Task):
|
||||
task_id = self.add_task(task_id)
|
||||
@@ -1430,10 +1526,14 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# By default, return only the final agent's response
|
||||
if not return_dict:
|
||||
# Get the last task (assuming sequential processing)
|
||||
task_ids = list(self.tasks.keys())
|
||||
if task_ids:
|
||||
last_task_id = task_ids[-1]
|
||||
# Prefer the tracked last real task id (set by hierarchical runs so
|
||||
# the synthetic manager_task never masks the real final result);
|
||||
# otherwise fall back to the last task in insertion order.
|
||||
last_task_id = getattr(self, "_last_real_task_id", None)
|
||||
if last_task_id is None:
|
||||
task_ids = list(self.tasks.keys())
|
||||
last_task_id = task_ids[-1] if task_ids else None
|
||||
if last_task_id is not None:
|
||||
last_result = self.get_task_result(last_task_id)
|
||||
if last_result:
|
||||
return last_result.raw
|
||||
@@ -1581,6 +1681,10 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
for task_id in process.sequential():
|
||||
self.run_task(task_id)
|
||||
elif self.process == "hierarchical":
|
||||
# See arun_all_tasks: capture the last real task id before the
|
||||
# synthetic manager_task is injected so the return path doesn't
|
||||
# surface the Manager's own generic output.
|
||||
self._last_real_task_id = self._last_hierarchical_task_id()
|
||||
for task_id in process.hierarchical():
|
||||
if isinstance(task_id, Task):
|
||||
task_id = self.add_task(task_id)
|
||||
@@ -1817,9 +1921,14 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
# By default, return only the final agent's response
|
||||
if not return_dict:
|
||||
task_ids = list(self.tasks.keys())
|
||||
if task_ids:
|
||||
last_task_id = task_ids[-1]
|
||||
# Prefer the tracked last real task id (set by hierarchical runs so
|
||||
# the synthetic manager_task never masks the real final result);
|
||||
# otherwise fall back to the last task in insertion order.
|
||||
last_task_id = getattr(self, "_last_real_task_id", None)
|
||||
if last_task_id is None:
|
||||
task_ids = list(self.tasks.keys())
|
||||
last_task_id = task_ids[-1] if task_ids else None
|
||||
if last_task_id is not None:
|
||||
last_result = self.get_task_result(last_task_id)
|
||||
if last_result:
|
||||
return last_result.raw
|
||||
@@ -1840,6 +1949,170 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
# Always run silently - no verbose output
|
||||
return self.start(content=content, return_dict=return_dict, output="silent", **kwargs)
|
||||
|
||||
def _snapshot_task_state(self):
|
||||
"""Snapshot per-task run state so a batch can restore it afterwards.
|
||||
|
||||
Captures the fields that ``run_task``/``arun_task`` mutate during a run
|
||||
(``status``, ``result``, ``retry_count``) plus caller-defined
|
||||
``variables``, keeping the public Task API backward-compatible.
|
||||
"""
|
||||
snapshot = {}
|
||||
for task_id, task in self.tasks.items():
|
||||
snapshot[task_id] = {
|
||||
"status": getattr(task, "status", None),
|
||||
"result": getattr(task, "result", None),
|
||||
"retry_count": getattr(task, "retry_count", None),
|
||||
"variables": getattr(task, "variables", None),
|
||||
}
|
||||
return snapshot
|
||||
|
||||
def _reset_task_state_for_item(self):
|
||||
"""Reset per-task run state before each batch item executes.
|
||||
|
||||
Without this, ``run_task``/``arun_task`` see ``status == "completed"``
|
||||
from the previous item and skip execution, returning stale results.
|
||||
Clearing ``variables`` lets the current item's global ``variables``
|
||||
re-propagate (see ``_run_task_start_hook``).
|
||||
"""
|
||||
for task in self.tasks.values():
|
||||
task.status = "not started"
|
||||
task.result = None
|
||||
if hasattr(task, "retry_count"):
|
||||
task.retry_count = 0
|
||||
task.variables = {}
|
||||
|
||||
def _restore_task_state(self, snapshot):
|
||||
"""Restore per-task run state captured by ``_snapshot_task_state``."""
|
||||
for task_id, state in snapshot.items():
|
||||
task = self.tasks.get(task_id)
|
||||
if task is None:
|
||||
continue
|
||||
task.status = state["status"]
|
||||
task.result = state["result"]
|
||||
if state["retry_count"] is not None:
|
||||
task.retry_count = state["retry_count"]
|
||||
task.variables = state["variables"]
|
||||
|
||||
def _build_batch_result(self, batch_id, items):
|
||||
"""Aggregate per-item results into a batch summary dict.
|
||||
|
||||
``token_usage_total`` is the session-level summary from the shared token
|
||||
collector (cumulative, not batch-scoped) — kept lightweight; use
|
||||
per-item outputs for finer accounting.
|
||||
"""
|
||||
succeeded = sum(1 for item in items if item["success"])
|
||||
return {
|
||||
"batch_id": batch_id,
|
||||
"items": items,
|
||||
"outputs": [item["output"] for item in items],
|
||||
"succeeded": succeeded,
|
||||
"failed": len(items) - succeeded,
|
||||
"total": len(items),
|
||||
"token_usage_total": self.get_token_usage_summary(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _validate_batch_input(item_input, index):
|
||||
"""Coerce/validate a single batch input into a dict of variables."""
|
||||
if item_input is None:
|
||||
return {}
|
||||
if not isinstance(item_input, dict):
|
||||
raise ValueError(
|
||||
f"inputs[{index}] must be a dict of variables, got "
|
||||
f"{type(item_input).__name__}"
|
||||
)
|
||||
return item_input
|
||||
|
||||
def start_for_each(
|
||||
self,
|
||||
inputs,
|
||||
*,
|
||||
on_error="continue",
|
||||
output="silent",
|
||||
**kwargs,
|
||||
):
|
||||
"""Run this team once per input dict, interpolating ``{{key}}`` placeholders.
|
||||
|
||||
Each input dict is applied as per-run ``variables`` (existing
|
||||
``{{placeholder}}`` interpolation in task descriptions), so the original
|
||||
task templates are never permanently mutated.
|
||||
|
||||
Args:
|
||||
inputs: List of dicts; each runs the team once with those variables.
|
||||
on_error: "continue" (default) collects errors per item and keeps
|
||||
going; "fail_fast" re-raises on the first failing item.
|
||||
output: Output preset forwarded to ``start`` (default "silent").
|
||||
**kwargs: Additional arguments forwarded to ``start``.
|
||||
|
||||
Returns:
|
||||
dict with ``batch_id``, ``items`` (per-item ``index``/``input``/
|
||||
``success``/``output``/``error``), ``outputs``, ``succeeded``,
|
||||
``failed``, ``total`` and ``token_usage_total``.
|
||||
"""
|
||||
if on_error not in ("continue", "fail_fast"):
|
||||
raise ValueError("on_error must be 'continue' or 'fail_fast'")
|
||||
|
||||
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||
saved_variables = self.variables
|
||||
task_snapshot = self._snapshot_task_state()
|
||||
items = []
|
||||
try:
|
||||
for index, item_input in enumerate(inputs):
|
||||
result = {"index": index, "input": item_input, "success": False,
|
||||
"output": None, "error": None}
|
||||
try:
|
||||
item_vars = self._validate_batch_input(item_input, index)
|
||||
self.variables = {**saved_variables, **item_vars}
|
||||
self._reset_task_state_for_item()
|
||||
result["output"] = self.start(output=output, **kwargs)
|
||||
result["success"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if on_error == "fail_fast":
|
||||
raise
|
||||
result["error"] = str(exc)
|
||||
items.append(result)
|
||||
finally:
|
||||
self.variables = saved_variables
|
||||
self._restore_task_state(task_snapshot)
|
||||
|
||||
return self._build_batch_result(batch_id, items)
|
||||
|
||||
async def astart_for_each(
|
||||
self,
|
||||
inputs,
|
||||
*,
|
||||
on_error="continue",
|
||||
**kwargs,
|
||||
):
|
||||
"""Async twin of :meth:`start_for_each` (sequential per-item execution)."""
|
||||
if on_error not in ("continue", "fail_fast"):
|
||||
raise ValueError("on_error must be 'continue' or 'fail_fast'")
|
||||
|
||||
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
|
||||
saved_variables = self.variables
|
||||
task_snapshot = self._snapshot_task_state()
|
||||
items = []
|
||||
try:
|
||||
for index, item_input in enumerate(inputs):
|
||||
result = {"index": index, "input": item_input, "success": False,
|
||||
"output": None, "error": None}
|
||||
try:
|
||||
item_vars = self._validate_batch_input(item_input, index)
|
||||
self.variables = {**saved_variables, **item_vars}
|
||||
self._reset_task_state_for_item()
|
||||
result["output"] = await self.astart(**kwargs)
|
||||
result["success"] = True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if on_error == "fail_fast":
|
||||
raise
|
||||
result["error"] = str(exc)
|
||||
items.append(result)
|
||||
finally:
|
||||
self.variables = saved_variables
|
||||
self._restore_task_state(task_snapshot)
|
||||
|
||||
return self._build_batch_result(batch_id, items)
|
||||
|
||||
def set_state(self, key: str, value: Any) -> None:
|
||||
"""Set a state value"""
|
||||
with self._state_lock:
|
||||
@@ -1916,38 +2189,100 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
|
||||
return self._state[key]
|
||||
|
||||
def save_session_state(self, session_id: str, include_memory: bool = True) -> None:
|
||||
"""Save current state to memory for session persistence"""
|
||||
if self.shared_memory and include_memory:
|
||||
state_data = {
|
||||
"session_id": session_id,
|
||||
"user_id": self.user_id,
|
||||
"run_id": self.run_id,
|
||||
"state": self._state,
|
||||
"agents": [agent.display_name for agent in self.agents],
|
||||
"process": self.process
|
||||
}
|
||||
self.shared_memory.store_short_term(
|
||||
text=f"Session state for {session_id}",
|
||||
metadata={
|
||||
"type": "session_state",
|
||||
"session_id": session_id,
|
||||
"user_id": self.user_id,
|
||||
"state_data": state_data
|
||||
}
|
||||
def _team_state_payload(self, session_id: str) -> Dict[str, Any]:
|
||||
"""Build the serialisable team-state payload for durable persistence."""
|
||||
with self._state_lock:
|
||||
state_copy = dict(self._state)
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"user_id": self.user_id,
|
||||
"run_id": self.run_id,
|
||||
"state": state_copy,
|
||||
"agents": [agent.display_name for agent in self.agents],
|
||||
"process": self.process,
|
||||
}
|
||||
|
||||
def save_session_state(self, session_id: str, include_memory: bool = True) -> bool:
|
||||
"""Persist team session state for deterministic resume (Issue #3635).
|
||||
|
||||
Writes the shared team ``_state`` (plus run bookkeeping) to the durable,
|
||||
project-scoped ``SessionStore`` — the same store single ``Agent`` resume
|
||||
uses — keyed deterministically by ``session_id``. This gives multi-agent
|
||||
runs the same locked, atomic, memory-independent continuity as a single
|
||||
agent: it works even with ``memory=False``.
|
||||
|
||||
``shared_memory`` is kept as an *optional enrichment* (not a gate) so
|
||||
legacy semantic-recall lookups still find the state when memory is on.
|
||||
|
||||
Returns ``True`` when the durable write succeeded, ``False`` otherwise,
|
||||
so callers can tell whether a resumable state actually reached disk.
|
||||
"""
|
||||
state_data = self._team_state_payload(session_id)
|
||||
|
||||
# Primary, durable path: SessionStore (locked, atomic, memory-independent).
|
||||
durable_saved = False
|
||||
try:
|
||||
from ..session.store import get_default_session_store
|
||||
durable_saved = bool(
|
||||
get_default_session_store().update_session_metadata(
|
||||
session_id, team_session_state=state_data
|
||||
)
|
||||
)
|
||||
|
||||
if not durable_saved:
|
||||
logger.warning(
|
||||
f"Durable team session save reported no write for {session_id}"
|
||||
)
|
||||
except Exception as e: # never fail a completed run on save
|
||||
logger.warning(f"Durable team session save failed for {session_id}: {e}")
|
||||
|
||||
# Optional enrichment: mirror into shared memory for semantic recall.
|
||||
if self.shared_memory and include_memory:
|
||||
try:
|
||||
self.shared_memory.store_short_term(
|
||||
text=f"Session state for {session_id}",
|
||||
metadata={
|
||||
"type": "session_state",
|
||||
"session_id": session_id,
|
||||
"user_id": self.user_id,
|
||||
"state_data": state_data,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"Shared-memory session enrichment failed: {e}")
|
||||
|
||||
return durable_saved
|
||||
|
||||
def restore_session_state(self, session_id: str) -> bool:
|
||||
"""Restore state from memory for session persistence. Returns True if restored."""
|
||||
"""Restore team session state for deterministic resume. Returns True if restored.
|
||||
|
||||
Reads first from the durable ``SessionStore`` keyed by ``session_id``
|
||||
(no fuzzy search, no memory requirement). Falls back to the legacy
|
||||
``shared_memory`` semantic lookup only for sessions written before this
|
||||
durable path existed, preserving backward compatibility.
|
||||
"""
|
||||
# Primary, durable path: SessionStore lookup by exact session_id.
|
||||
try:
|
||||
from ..session.store import get_default_session_store
|
||||
store = get_default_session_store()
|
||||
if store.session_exists(session_id):
|
||||
metadata = store.get_session(session_id).metadata or {}
|
||||
state_data = metadata.get("team_session_state")
|
||||
if isinstance(state_data, dict) and "state" in state_data:
|
||||
with self._state_lock:
|
||||
self._state.update(state_data["state"])
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.debug(f"Durable team session restore failed for {session_id}: {e}")
|
||||
|
||||
# Legacy fallback: shared-memory semantic lookup (pre-#3635 sessions).
|
||||
if not self.shared_memory:
|
||||
return False
|
||||
|
||||
# Use metadata-based search for better SQLite compatibility
|
||||
|
||||
results = self.shared_memory.search_short_term(
|
||||
query=f"type:session_state",
|
||||
query="type:session_state",
|
||||
limit=10 # Get more results to filter by session_id
|
||||
)
|
||||
|
||||
|
||||
# Filter results by session_id in metadata
|
||||
for result in results:
|
||||
metadata = result.get("metadata", {})
|
||||
@@ -2038,14 +2373,17 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
"""
|
||||
Launch all agents as a single API endpoint (HTTP) or an MCP server.
|
||||
In HTTP mode, the endpoint accepts a query and processes it through all agents in sequence.
|
||||
In MCP mode, an MCP server is started, exposing a tool to run the agent workflow.
|
||||
In MCP mode, serving is delegated to the ``praisonai-mcp`` agent adapter
|
||||
(``serve_agents``), publishing ``ask_{agent_name}`` per agent plus
|
||||
``list_agents`` — the same path used by ``Agent.launch(protocol="mcp")``.
|
||||
|
||||
Args:
|
||||
path: API endpoint path (default: '/agents') for HTTP, or base path for MCP.
|
||||
path: API endpoint path (default: '/agents') for HTTP. Ignored in MCP mode.
|
||||
port: Server port (default: 8000)
|
||||
host: Server host (default: '0.0.0.0')
|
||||
debug: Enable debug mode for uvicorn (default: False)
|
||||
protocol: "http" to launch as FastAPI, "mcp" to launch as MCP server.
|
||||
protocol: "http" to launch as FastAPI, "mcp" to serve over MCP via
|
||||
``praisonai-mcp`` (install with ``pip install praisonai-mcp``).
|
||||
|
||||
Returns:
|
||||
None
|
||||
@@ -2308,144 +2646,26 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
logging.warning("No agents to launch for MCP mode. Add agents to the Agents instance first.")
|
||||
return
|
||||
|
||||
# Delegate to the praisonai-mcp agent adapter so multi-agent MCP
|
||||
# serving shares one blessed entry point with the single-agent
|
||||
# Agent.launch(protocol="mcp") path (both go through serve_agents).
|
||||
# praisonai-mcp is an optional dependency imported lazily so core
|
||||
# keeps no hard dependency on it.
|
||||
try:
|
||||
import uvicorn
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
from mcp.server.sse import SseServerTransport
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import Mount, Route
|
||||
# from mcp.server import Server as MCPServer # Not directly needed if using FastMCP's server
|
||||
import threading
|
||||
import time
|
||||
import inspect
|
||||
import asyncio
|
||||
# logging is already imported at the module level
|
||||
|
||||
except ImportError as e:
|
||||
missing_module = str(e).split("No module named '")[-1].rstrip("'")
|
||||
display_error(f"Missing dependency: {missing_module}. Required for launch() method with MCP mode.")
|
||||
logging.error(f"Missing dependency: {missing_module}. Required for launch() method with MCP mode.")
|
||||
print(f"\nTo add MCP capabilities, install the required dependencies:")
|
||||
print(f"pip install {missing_module} mcp praison-mcp starlette uvicorn")
|
||||
print("\nOr install all MCP dependencies with relevant packages.")
|
||||
from praisonai_mcp import serve_agents
|
||||
|
||||
# Keep the call inside the ImportError guard: serve_agents()
|
||||
# lazily imports its transport backend, so a praisonai-mcp
|
||||
# installed without its optional transport extras surfaces the
|
||||
# missing dependency here rather than at the import line above.
|
||||
# Handling it in one place yields a single actionable install
|
||||
# message instead of an uncaught traceback.
|
||||
return serve_agents(self.agents, host=host, port=port)
|
||||
except ImportError:
|
||||
display_error("MCP serving requires the 'praisonai-mcp' package.")
|
||||
logging.error("MCP serving requires the 'praisonai-mcp' package.")
|
||||
print("\nTo add MCP capabilities, install: pip install praisonai-mcp")
|
||||
return None
|
||||
|
||||
mcp_instance = FastMCP("praisonai_workflow_mcp_server")
|
||||
|
||||
# Determine the MCP tool name for the workflow based on self.name
|
||||
actual_mcp_tool_name = (f"execute_{self.name.lower().replace(' ', '_').replace('-', '_')}_workflow" if self.name
|
||||
else "execute_workflow")
|
||||
|
||||
@mcp_instance.tool(name=actual_mcp_tool_name)
|
||||
async def execute_workflow_tool(query: str) -> str: # Renamed for clarity
|
||||
"""Executes the defined agent workflow with the given query."""
|
||||
logging.info(f"MCP tool '{actual_mcp_tool_name}' called with query: {query}")
|
||||
current_input = query
|
||||
final_response = "No agents in workflow or workflow did not produce a final response."
|
||||
|
||||
for agent_instance in self.agents:
|
||||
try:
|
||||
logging.debug(f"Processing with agent: {agent_instance.display_name}")
|
||||
if hasattr(agent_instance, 'achat') and asyncio.iscoroutinefunction(agent_instance.achat):
|
||||
response = await agent_instance.achat(current_input, tools=agent_instance.tools, task_name=None, task_description=None, task_id=None)
|
||||
elif hasattr(agent_instance, 'chat'): # Fallback to sync chat if achat not suitable
|
||||
# Use copy_context_to_callable to propagate contextvars (needed for trace emission)
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
loop = asyncio.get_running_loop()
|
||||
response = await loop.run_in_executor(None, copy_context_to_callable(lambda ci=current_input: agent_instance.chat(ci, tools=agent_instance.tools)))
|
||||
else:
|
||||
logging.warning(f"Agent {agent_instance.display_name} has no suitable chat or achat method.")
|
||||
response = f"Error: Agent {agent_instance.display_name} has no callable chat method."
|
||||
|
||||
current_input = response if response is not None else "Agent returned no response."
|
||||
final_response = current_input # Keep track of the last valid response
|
||||
logging.debug(f"Agent {agent_instance.display_name} responded. Current intermediate output: {current_input}")
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"Error during agent {agent_instance.display_name} execution in MCP workflow: {str(e)}", exc_info=True)
|
||||
current_input = f"Error from agent {agent_instance.display_name}: {str(e)}"
|
||||
final_response = current_input # Update final response to show error
|
||||
# Optionally break or continue based on desired error handling for the workflow
|
||||
# For now, we continue, and the error is passed to the next agent or returned.
|
||||
|
||||
logging.info(f"MCP tool '{actual_mcp_tool_name}' completed. Final response: {final_response}")
|
||||
return final_response
|
||||
|
||||
base_mcp_path = path.rstrip('/')
|
||||
sse_mcp_path = f"{base_mcp_path}/sse"
|
||||
messages_mcp_path_prefix = f"{base_mcp_path}/messages"
|
||||
if not messages_mcp_path_prefix.endswith('/'):
|
||||
messages_mcp_path_prefix += '/'
|
||||
|
||||
sse_transport_mcp = SseServerTransport(messages_mcp_path_prefix)
|
||||
|
||||
async def handle_mcp_sse_connection(request: Request) -> None:
|
||||
logging.debug(f"MCP SSE connection request from {request.client} for path {request.url.path}")
|
||||
async with sse_transport_mcp.connect_sse(
|
||||
request.scope, request.receive, request._send,
|
||||
) as (read_stream, write_stream):
|
||||
await mcp_instance._mcp_server.run(
|
||||
read_stream, write_stream, mcp_instance._mcp_server.create_initialization_options(),
|
||||
)
|
||||
|
||||
starlette_mcp_app = Starlette(
|
||||
debug=debug,
|
||||
routes=[
|
||||
Route(sse_mcp_path, endpoint=handle_mcp_sse_connection),
|
||||
Mount(messages_mcp_path_prefix, app=sse_transport_mcp.handle_post_message),
|
||||
],
|
||||
)
|
||||
|
||||
print(f"🚀 Agents MCP Workflow server starting on http://{host}:{port}")
|
||||
print(f"📡 MCP SSE endpoint available at {sse_mcp_path}")
|
||||
print(f"📢 MCP messages post to {messages_mcp_path_prefix}")
|
||||
# Instead of trying to extract tool names, hardcode the known tool name
|
||||
mcp_tool_names = [actual_mcp_tool_name] # Use the determined dynamic tool name
|
||||
print(f"🛠️ Available MCP tools: {', '.join(mcp_tool_names)}")
|
||||
agent_names_in_workflow = ", ".join([a.display_name for a in self.agents])
|
||||
print(f"🔄 Agents in MCP workflow: {agent_names_in_workflow}")
|
||||
|
||||
def run_praison_mcp_server():
|
||||
try:
|
||||
uvicorn.run(starlette_mcp_app, host=host, port=port, log_level="debug" if debug else "info")
|
||||
except Exception as e:
|
||||
logging.error(f"Error starting Agents MCP server: {str(e)}", exc_info=True)
|
||||
print(f"❌ Error starting Agents MCP server: {str(e)}")
|
||||
|
||||
mcp_server_thread = threading.Thread(target=run_praison_mcp_server, daemon=True)
|
||||
mcp_server_thread.start()
|
||||
time.sleep(0.5)
|
||||
|
||||
import inspect
|
||||
stack = inspect.stack()
|
||||
if len(stack) > 1 and stack[1].filename.endswith('.py'):
|
||||
caller_frame = stack[1]
|
||||
caller_line = caller_frame.lineno
|
||||
try:
|
||||
with open(caller_frame.filename, 'r') as f:
|
||||
lines = f.readlines()
|
||||
has_more_launches = False
|
||||
for line_content in lines[caller_line:]:
|
||||
if '.launch(' in line_content and not line_content.strip().startswith('#'):
|
||||
has_more_launches = True
|
||||
break
|
||||
if not has_more_launches:
|
||||
try:
|
||||
print("\nAgents MCP server running. Press Ctrl+C to stop.")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nAgents MCP Server stopped")
|
||||
except Exception as e:
|
||||
logging.error(f"Error in Agents MCP launch detection: {e}")
|
||||
try:
|
||||
print("\nKeeping Agents MCP server alive. Press Ctrl+C to stop.")
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
print("\nAgents MCP Server stopped")
|
||||
return None
|
||||
else:
|
||||
display_error(f"Invalid protocol: {protocol}. Choose 'http' or 'mcp'.")
|
||||
return None
|
||||
|
||||
@@ -140,6 +140,7 @@ class SubagentDelegator:
|
||||
|
||||
# State
|
||||
self._tasks: Dict[str, DelegationTask] = {}
|
||||
self._running_asyncio_tasks: Dict[str, "asyncio.Task"] = {}
|
||||
self._running_count: int = 0
|
||||
self._total_count: int = 0
|
||||
self._task_counter: int = 0
|
||||
@@ -220,9 +221,37 @@ class SubagentDelegator:
|
||||
self._tasks[task_id] = task
|
||||
self._total_count += 1
|
||||
|
||||
# Execute with concurrency control
|
||||
# Execute with concurrency control. Wrap in a real asyncio.Task so
|
||||
# cancel_task/cancel_all can actually propagate cancellation to the
|
||||
# awaited subagent coroutine instead of only flipping a status enum.
|
||||
async with self._get_semaphore():
|
||||
return await self._execute_task(task)
|
||||
# If cancelled while queued on the semaphore, don't start the work.
|
||||
if task.status == DelegationStatus.CANCELLED:
|
||||
return DelegationResult(
|
||||
task_id=task.task_id,
|
||||
agent_name=task.agent_name,
|
||||
success=False,
|
||||
error="Task was cancelled",
|
||||
)
|
||||
exec_task = asyncio.ensure_future(self._execute_task(task))
|
||||
self._running_asyncio_tasks[task_id] = exec_task
|
||||
try:
|
||||
return await exec_task
|
||||
except asyncio.CancelledError:
|
||||
# Distinguish a delegator-initiated cancel (via cancel_task/cancel_all,
|
||||
# which sets status=CANCELLED) from an external/cooperative cancel of
|
||||
# the caller. Only the former is swallowed into a result; an external
|
||||
# cancel must propagate so wait_for/shutdown behave correctly.
|
||||
if task.status == DelegationStatus.CANCELLED:
|
||||
return DelegationResult(
|
||||
task_id=task.task_id,
|
||||
agent_name=task.agent_name,
|
||||
success=False,
|
||||
error="Task was cancelled",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
self._running_asyncio_tasks.pop(task_id, None)
|
||||
|
||||
async def delegate_parallel(
|
||||
self,
|
||||
@@ -282,14 +311,25 @@ class SubagentDelegator:
|
||||
True if task was cancelled
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
# Only terminal tasks are un-cancellable; PENDING/RUNNING can be cancelled.
|
||||
_terminal = (
|
||||
DelegationStatus.COMPLETED,
|
||||
DelegationStatus.FAILED,
|
||||
DelegationStatus.CANCELLED,
|
||||
DelegationStatus.TIMEOUT,
|
||||
)
|
||||
if not task or task.status in _terminal:
|
||||
return False
|
||||
|
||||
if task.status == DelegationStatus.RUNNING:
|
||||
task.status = DelegationStatus.CANCELLED
|
||||
return True
|
||||
# Mark cancelled first so a PENDING task (still waiting on the semaphore)
|
||||
# or a RUNNING coroutine both observe CANCELLED and refuse to complete.
|
||||
task.status = DelegationStatus.CANCELLED
|
||||
|
||||
return False
|
||||
# Propagate cancellation to the actual coroutine if it is already running.
|
||||
asyncio_task = self._running_asyncio_tasks.get(task_id)
|
||||
if asyncio_task is not None:
|
||||
asyncio_task.cancel()
|
||||
return True
|
||||
|
||||
async def cancel_all(self) -> int:
|
||||
"""
|
||||
@@ -299,9 +339,8 @@ class SubagentDelegator:
|
||||
Number of tasks cancelled
|
||||
"""
|
||||
cancelled = 0
|
||||
for task in self._tasks.values():
|
||||
if task.status == DelegationStatus.RUNNING:
|
||||
task.status = DelegationStatus.CANCELLED
|
||||
for task_id in list(self._tasks.keys()):
|
||||
if await self.cancel_task(task_id):
|
||||
cancelled += 1
|
||||
return cancelled
|
||||
|
||||
@@ -337,6 +376,15 @@ class SubagentDelegator:
|
||||
import time
|
||||
start_time = time.time()
|
||||
|
||||
# A cancel may have landed between scheduling and execution; honour it.
|
||||
if task.status == DelegationStatus.CANCELLED:
|
||||
return DelegationResult(
|
||||
task_id=task.task_id,
|
||||
agent_name=task.agent_name,
|
||||
success=False,
|
||||
error="Task was cancelled",
|
||||
)
|
||||
|
||||
task.status = DelegationStatus.RUNNING
|
||||
self._running_count += 1
|
||||
|
||||
@@ -356,7 +404,9 @@ class SubagentDelegator:
|
||||
timeout=task.timeout_seconds,
|
||||
)
|
||||
task.result = result
|
||||
task.status = DelegationStatus.COMPLETED
|
||||
# Don't clobber a cancellation that landed while we were running.
|
||||
if task.status != DelegationStatus.CANCELLED:
|
||||
task.status = DelegationStatus.COMPLETED
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
task.status = DelegationStatus.TIMEOUT
|
||||
|
||||
@@ -31,6 +31,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
@@ -96,11 +97,19 @@ def get_approval_callback() -> Optional[Callable]:
|
||||
"""Get the current approval callback function (legacy API)."""
|
||||
return approval_callback
|
||||
|
||||
def mark_approved(tool_name: str) -> None:
|
||||
get_approval_registry().mark_approved(tool_name)
|
||||
def mark_approved(
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
) -> None:
|
||||
get_approval_registry().mark_approved(tool_name, arguments, agent_name)
|
||||
|
||||
def is_already_approved(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_already_approved(tool_name)
|
||||
def is_already_approved(
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
return get_approval_registry().is_already_approved(tool_name, arguments, agent_name)
|
||||
|
||||
def is_yaml_approved(tool_name: str) -> bool:
|
||||
return get_approval_registry().is_yaml_approved(tool_name)
|
||||
@@ -166,6 +175,29 @@ async def request_approval(function_name: str, arguments: Dict) -> ApprovalDecis
|
||||
|
||||
RiskLevel = Literal["critical", "high", "medium", "low"]
|
||||
|
||||
|
||||
def _bind_call_args(func: Callable, args: tuple, kwargs: Dict) -> Dict:
|
||||
"""Normalise a call's positional + keyword args into a single dict.
|
||||
|
||||
The approval cache is keyed by the tool's arguments, so a directly-invoked
|
||||
``@require_approval`` tool must reduce positional args to the same named
|
||||
form the cache uses — otherwise ``read_file("/a")`` and ``read_file("/b")``
|
||||
would share the empty-argument key and one approval would unlock the other.
|
||||
|
||||
Falls back to a shallow merge of ``kwargs`` plus an ``__args__`` tuple when
|
||||
the signature cannot be bound (e.g. C-implemented callables), so distinct
|
||||
positional calls still produce distinct keys instead of collapsing.
|
||||
"""
|
||||
try:
|
||||
bound = inspect.signature(func).bind_partial(*args, **kwargs)
|
||||
return dict(bound.arguments)
|
||||
except (TypeError, ValueError):
|
||||
merged = dict(kwargs)
|
||||
if args:
|
||||
merged["__args__"] = args
|
||||
return merged
|
||||
|
||||
|
||||
def require_approval(risk_level: RiskLevel = "high"):
|
||||
"""Decorator to mark a tool as requiring human approval."""
|
||||
def decorator(func):
|
||||
@@ -177,13 +209,18 @@ def require_approval(risk_level: RiskLevel = "high"):
|
||||
|
||||
@wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if is_already_approved(tool_name):
|
||||
# Bind positional args into a normalized dict so the approval key
|
||||
# reflects the *actual* call. Without this, positional-only calls
|
||||
# (e.g. read_file("/etc/passwd")) would all collapse to the same
|
||||
# empty-argument key, letting one approval unlock any later value.
|
||||
approval_args = _bind_call_args(func, args, kwargs)
|
||||
if is_already_approved(tool_name, approval_args):
|
||||
return func(*args, **kwargs)
|
||||
if is_yaml_approved(tool_name):
|
||||
mark_approved(tool_name)
|
||||
mark_approved(tool_name, approval_args)
|
||||
return func(*args, **kwargs)
|
||||
if is_env_auto_approve():
|
||||
mark_approved(tool_name)
|
||||
mark_approved(tool_name, approval_args)
|
||||
return func(*args, **kwargs)
|
||||
try:
|
||||
from ..utils.async_bridge import is_async_context, run_coroutine_from_any_context
|
||||
@@ -196,32 +233,35 @@ def require_approval(risk_level: RiskLevel = "high"):
|
||||
)
|
||||
else:
|
||||
# Safe to run async approval using registry
|
||||
decision = run_coroutine_from_any_context(request_approval(tool_name, kwargs))
|
||||
decision = run_coroutine_from_any_context(request_approval(tool_name, approval_args))
|
||||
except Exception as e:
|
||||
logging.warning(f"Approval request failed: {e}", exc_info=True)
|
||||
# Fail closed - do not fall back to console
|
||||
raise PermissionError(f"Approval request failed for {tool_name}: {e}") from e
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
mark_approved(tool_name)
|
||||
kwargs.update(decision.modified_args)
|
||||
approval_args.update(decision.modified_args)
|
||||
mark_approved(tool_name, approval_args)
|
||||
return func(*args, **kwargs)
|
||||
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
if is_already_approved(tool_name):
|
||||
approval_args = _bind_call_args(func, args, kwargs)
|
||||
if is_already_approved(tool_name, approval_args):
|
||||
return await func(*args, **kwargs)
|
||||
if is_yaml_approved(tool_name):
|
||||
mark_approved(tool_name)
|
||||
mark_approved(tool_name, approval_args)
|
||||
return await func(*args, **kwargs)
|
||||
if is_env_auto_approve():
|
||||
mark_approved(tool_name)
|
||||
mark_approved(tool_name, approval_args)
|
||||
return await func(*args, **kwargs)
|
||||
decision = await request_approval(tool_name, kwargs)
|
||||
decision = await request_approval(tool_name, approval_args)
|
||||
if not decision.approved:
|
||||
raise PermissionError(f"Execution of {tool_name} denied: {decision.reason}")
|
||||
mark_approved(tool_name)
|
||||
kwargs.update(decision.modified_args)
|
||||
approval_args.update(decision.modified_args)
|
||||
mark_approved(tool_name, approval_args)
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
|
||||
@@ -18,8 +18,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextvars
|
||||
import hashlib
|
||||
import json
|
||||
from praisonaiagents._logging import get_logger
|
||||
import os
|
||||
from typing import Dict, List, Optional, Set
|
||||
@@ -225,20 +223,35 @@ class ApprovalRegistry:
|
||||
# ── Context helpers ──────────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _approval_cache_key(tool_name: str, arguments: Dict) -> str:
|
||||
payload = json.dumps(arguments or {}, sort_keys=True, default=str)
|
||||
digest = hashlib.sha256(payload.encode()).hexdigest()[:16]
|
||||
return f"{tool_name}:{digest}"
|
||||
def _approval_cache_key(
|
||||
tool_name: str, arguments: Dict, agent_name: Optional[str] = None
|
||||
) -> str:
|
||||
# Scope the key to the requesting agent so one agent's approval never
|
||||
# silently pre-authorizes an identical call from a different, stricter
|
||||
# agent in the same context. ``*`` is the sentinel for calls made
|
||||
# outside any Agent (e.g. bare module-level tool calls).
|
||||
from .utils import hash_tool_args
|
||||
return f"{agent_name or '*'}:{tool_name}:{hash_tool_args(arguments)}"
|
||||
|
||||
def mark_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> None:
|
||||
def mark_approved(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
) -> None:
|
||||
approved = self._approved_context.get(set())
|
||||
approved.add(self._approval_cache_key(tool_name, arguments or {}))
|
||||
approved.add(self._approval_cache_key(tool_name, arguments or {}, agent_name))
|
||||
self._approved_context.set(approved)
|
||||
|
||||
def is_already_approved(self, tool_name: str, arguments: Optional[Dict] = None) -> bool:
|
||||
def is_already_approved(
|
||||
self,
|
||||
tool_name: str,
|
||||
arguments: Optional[Dict] = None,
|
||||
agent_name: Optional[str] = None,
|
||||
) -> bool:
|
||||
# Honour an explicit mark_approved() from the agent approval path even
|
||||
# for critical tools (e.g. execute_command after AutoApproveBackend).
|
||||
if self._approval_cache_key(tool_name, arguments or {}) in self._approved_context.get(set()):
|
||||
if self._approval_cache_key(tool_name, arguments or {}, agent_name) in self._approved_context.get(set()):
|
||||
return True
|
||||
return False
|
||||
|
||||
@@ -401,27 +414,27 @@ class ApprovalRegistry:
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
# Already approved in this context
|
||||
if self.is_already_approved(tool_name, arguments):
|
||||
if self.is_already_approved(tool_name, arguments, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# "This session" scoped grant covers matching calls for the run
|
||||
if self._is_session_scoped(agent_name, tool_name, arguments):
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
# Env auto-approve
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
# YAML auto-approve
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
# Delegate to backend
|
||||
@@ -445,7 +458,7 @@ class ApprovalRegistry:
|
||||
)
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
|
||||
return decision
|
||||
|
||||
@@ -465,25 +478,25 @@ class ApprovalRegistry:
|
||||
if not force and not self.is_required(tool_name, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="No approval required")
|
||||
|
||||
if self.is_already_approved(tool_name, arguments):
|
||||
if self.is_already_approved(tool_name, arguments, agent_name):
|
||||
return ApprovalDecision(approved=True, reason="Already approved in context")
|
||||
|
||||
# "This session" scoped grant covers matching calls for the run
|
||||
if self._is_session_scoped(agent_name, tool_name, arguments):
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Approved (session)", approver="session")
|
||||
|
||||
# Check per-tool auto-approval (G-A fix)
|
||||
if self.is_auto_approved(tool_name, agent_name):
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (skill)", approver="skill")
|
||||
|
||||
if self.is_env_auto_approve():
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (env)", approver="env")
|
||||
|
||||
if self.is_yaml_approved(tool_name):
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
return ApprovalDecision(approved=True, reason="Auto-approved (yaml)", approver="yaml")
|
||||
|
||||
backend = self.get_backend(agent_name)
|
||||
@@ -503,6 +516,6 @@ class ApprovalRegistry:
|
||||
decision = ApprovalDecision(approved=False, reason="Approval timed out")
|
||||
|
||||
if decision.approved:
|
||||
self.mark_approved(tool_name, arguments)
|
||||
self.mark_approved(tool_name, arguments, agent_name)
|
||||
self._persist_scoped_decision(agent_name, tool_name, arguments, decision)
|
||||
return decision
|
||||
@@ -7,11 +7,31 @@ across the approval system.
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Awaitable, Callable, Dict, Optional, TypeVar
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
|
||||
def hash_tool_args(arguments: Optional[Dict[str, Any]]) -> str:
|
||||
"""Return the canonical 16-char identity hash of a tool call's arguments.
|
||||
|
||||
Produces a 16-character SHA-256 digest over the canonical JSON encoding
|
||||
(``sort_keys=True, default=str``) of the arguments. This is the single
|
||||
source of truth for the tool-call identity key shared by approval
|
||||
de-duplication (``ApprovalRegistry``) and doom-loop detection, so the two
|
||||
safety subsystems cannot silently diverge.
|
||||
|
||||
Falls back to ``"unhashable"`` if the arguments cannot be serialised.
|
||||
"""
|
||||
try:
|
||||
payload = json.dumps(arguments or {}, sort_keys=True, default=str)
|
||||
return hashlib.sha256(payload.encode()).hexdigest()[:16]
|
||||
except (TypeError, ValueError):
|
||||
return "unhashable"
|
||||
|
||||
|
||||
# Tool names that map to a shell-command permission target (``bash:<command>``)
|
||||
# so the reusable command-prefix machinery in ``PermissionManager`` applies.
|
||||
_SHELL_TOOLS = frozenset({
|
||||
@@ -56,6 +76,14 @@ def build_permission_target(
|
||||
* file tools -> ``<edit|write|delete|…>:<path>``
|
||||
* everything else -> ``tool:<tool_name>``
|
||||
|
||||
The command identity is preserved verbatim so command-specific rules
|
||||
(e.g. ``deny: bash:rm *``) still match. The out-of-workspace boundary is
|
||||
enforced downstream by :class:`~praisonaiagents.permissions.PermissionManager`
|
||||
(its ``external_dir:`` gate), which decomposes the ``bash:<command>`` target
|
||||
and gates any escaping path — so a broad ``bash:*`` / "allow shell" /
|
||||
session grant cannot silently authorise out-of-workspace access while a
|
||||
command-specific ``deny`` still fires.
|
||||
|
||||
Falls back to ``tool:<tool_name>`` whenever the expected argument is missing
|
||||
so a target is always produced.
|
||||
|
||||
|
||||
@@ -40,6 +40,8 @@ __all__ = [
|
||||
"TaskStatus",
|
||||
# Configuration
|
||||
"BackgroundConfig",
|
||||
# Shared runner accessor
|
||||
"get_background_runner",
|
||||
]
|
||||
|
||||
|
||||
@@ -61,4 +63,8 @@ def __getattr__(name: str):
|
||||
from .config import BackgroundConfig
|
||||
return BackgroundConfig
|
||||
|
||||
if name == "get_background_runner":
|
||||
from .runner import get_background_runner
|
||||
return get_background_runner
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -362,6 +362,24 @@ class BackgroundRunner:
|
||||
# Block briefly to get the BackgroundTask object (not the task result).
|
||||
return future.result(timeout=5)
|
||||
|
||||
def cancel_task_sync(self, task_id: str) -> bool:
|
||||
"""Cancel a task from synchronous code (or an unrelated event loop).
|
||||
|
||||
Schedules :meth:`cancel_task` on the shared background loop where the
|
||||
task's future lives, so the underlying execution is actually stopped
|
||||
(not merely marked cancelled). Safe to call from within a running
|
||||
event loop because it hops threads via ``run_coroutine_threadsafe``.
|
||||
|
||||
Args:
|
||||
task_id: ID of task to cancel
|
||||
|
||||
Returns:
|
||||
True if cancelled, False if not found or already completed
|
||||
"""
|
||||
loop = _get_bg_loop()
|
||||
future = asyncio.run_coroutine_threadsafe(self.cancel_task(task_id), loop)
|
||||
return future.result(timeout=5)
|
||||
|
||||
def submit_agent_sync(
|
||||
self,
|
||||
agent: Any,
|
||||
@@ -451,3 +469,25 @@ def _shutdown_bg_loop():
|
||||
# Register cleanup on process exit
|
||||
import atexit
|
||||
atexit.register(_shutdown_bg_loop)
|
||||
|
||||
|
||||
# ── Shared process-wide runner ──────────────────────────────────────────
|
||||
|
||||
_shared_runner: Optional["BackgroundRunner"] = None
|
||||
_shared_runner_lock = _threading.Lock()
|
||||
|
||||
|
||||
def get_background_runner() -> "BackgroundRunner":
|
||||
"""Return the process-wide shared :class:`BackgroundRunner`.
|
||||
|
||||
Interactive surfaces (REPL/TUI) and bots submit and inspect background
|
||||
tasks through this single instance so ``/tasks`` sees the same tasks
|
||||
regardless of which surface submitted them. Created lazily on first use.
|
||||
"""
|
||||
global _shared_runner
|
||||
if _shared_runner is not None:
|
||||
return _shared_runner
|
||||
with _shared_runner_lock:
|
||||
if _shared_runner is None:
|
||||
_shared_runner = BackgroundRunner()
|
||||
return _shared_runner
|
||||
@@ -27,6 +27,7 @@ from .protocols import (
|
||||
EmailProtocol,
|
||||
EmailInbox,
|
||||
SupportsPresentation,
|
||||
PresentationRendererProtocol,
|
||||
PlatformCapabilities,
|
||||
ChannelField,
|
||||
ChannelDescriptor,
|
||||
@@ -52,6 +53,12 @@ from .presentation import (
|
||||
ButtonStyle,
|
||||
BlockType,
|
||||
adapt_presentation,
|
||||
adapt_presentation_with_report,
|
||||
DegradedDelivery,
|
||||
table_to_markdown,
|
||||
chart_to_text,
|
||||
register_presentation_renderer,
|
||||
get_presentation_renderer,
|
||||
)
|
||||
from .interactive import (
|
||||
InteractiveContext,
|
||||
@@ -74,13 +81,33 @@ from .agent_reply import (
|
||||
extract_completion,
|
||||
append_completion_note,
|
||||
)
|
||||
from .format import (
|
||||
format_for_dialect,
|
||||
escape_markdown_v2,
|
||||
markdown_to_slack,
|
||||
strip_markdown,
|
||||
)
|
||||
from .webhook_filter import (
|
||||
WebhookFilter,
|
||||
evaluate_webhook_filter,
|
||||
resolve_field,
|
||||
)
|
||||
from .config import BotConfig, BotOSConfig, DisplayPolicy, resolve_display_policy
|
||||
from .silence import (
|
||||
SILENT_REPLY_TOKEN,
|
||||
is_intentional_silence_response,
|
||||
classify_final,
|
||||
BotLoopPolicy,
|
||||
BotLoopGuard,
|
||||
)
|
||||
from .failure import (
|
||||
FailureReply,
|
||||
render_failure_reply,
|
||||
)
|
||||
from .admission import (
|
||||
IngressDecision,
|
||||
resolve_ingress_admission,
|
||||
)
|
||||
from .run_status import (
|
||||
RunPhase,
|
||||
RunStatusController,
|
||||
@@ -119,10 +146,20 @@ __all__ = [
|
||||
"ButtonStyle",
|
||||
"BlockType",
|
||||
"adapt_presentation",
|
||||
"adapt_presentation_with_report",
|
||||
"DegradedDelivery",
|
||||
"table_to_markdown",
|
||||
"chart_to_text",
|
||||
"PresentationRendererProtocol",
|
||||
"register_presentation_renderer",
|
||||
"get_presentation_renderer",
|
||||
"PlatformCapabilities",
|
||||
"ChannelField",
|
||||
"ChannelDescriptor",
|
||||
"WebhookVerifierProtocol",
|
||||
"WebhookFilter",
|
||||
"evaluate_webhook_filter",
|
||||
"resolve_field",
|
||||
"CallbackPayloadStoreProtocol",
|
||||
"InMemoryCallbackPayloadStore",
|
||||
"GatewayRuntimeSeams",
|
||||
@@ -147,10 +184,19 @@ __all__ = [
|
||||
"extract_presentation",
|
||||
"extract_completion",
|
||||
"append_completion_note",
|
||||
"format_for_dialect",
|
||||
"escape_markdown_v2",
|
||||
"markdown_to_slack",
|
||||
"strip_markdown",
|
||||
"SILENT_REPLY_TOKEN",
|
||||
"is_intentional_silence_response",
|
||||
"classify_final",
|
||||
"BotLoopPolicy",
|
||||
"BotLoopGuard",
|
||||
"FailureReply",
|
||||
"render_failure_reply",
|
||||
"IngressDecision",
|
||||
"resolve_ingress_admission",
|
||||
"RunPhase",
|
||||
"RunStatusController",
|
||||
"StallState",
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Inbound admission primitive for bot gateways.
|
||||
|
||||
Provides a single, pure, dependency-free decision for "should the bot act on
|
||||
this inbound message?" — channel/user allowlist, block-list, group policy
|
||||
(``respond_all`` / ``mention_only`` / ``command_only`` / ``observe``) and
|
||||
pairing for unknown users — returning a typed verdict with a machine-readable
|
||||
reason code an operator can inspect.
|
||||
|
||||
Like :func:`praisonaiagents.bots.protocols.evaluate_channel_health` and
|
||||
:class:`praisonaiagents.bots.silence.BotLoopGuard`, this is a zero-dependency
|
||||
decision primitive: every channel adapter (built-in or plugin) feeds it native
|
||||
message facts and acts on the verdict, so the admission decision cannot drift
|
||||
across transports and a drop is no longer a silent ``logger.debug`` line but a
|
||||
recorded ``reason_code``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable, Optional
|
||||
|
||||
# Machine-readable reason codes. Kept as module constants so callers can record
|
||||
# and compare them without re-typing string literals (which is exactly how the
|
||||
# per-channel logic drifted).
|
||||
REASON_ALLOWED = "allowed"
|
||||
REASON_NOT_IN_ALLOWLIST = "not_in_allowlist"
|
||||
REASON_BLOCKED = "blocked"
|
||||
REASON_GROUP_MENTION_ONLY = "group_mention_only"
|
||||
REASON_OBSERVE = "observe"
|
||||
REASON_PAIRING_REQUIRED = "pairing_required"
|
||||
REASON_COMMAND_ONLY = "command_only"
|
||||
|
||||
# Gate names — which stage of the ladder produced the verdict.
|
||||
GATE_BLOCKLIST = "blocklist"
|
||||
GATE_ALLOWLIST = "allowlist"
|
||||
GATE_PAIRING = "pairing"
|
||||
GATE_GROUP_POLICY = "group_policy"
|
||||
GATE_DIRECT = "direct"
|
||||
|
||||
# Group policies, matching the existing YAML ``group_policy`` values.
|
||||
_GROUP_POLICIES = frozenset(
|
||||
{"respond_all", "mention_only", "command_only", "observe"}
|
||||
)
|
||||
|
||||
# Chat types treated as one-to-one (no group policy applies).
|
||||
_DIRECT_CHAT_TYPES = frozenset({"dm", "private", "direct", "im"})
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IngressDecision:
|
||||
"""Typed verdict for an inbound admission decision.
|
||||
|
||||
Attributes:
|
||||
admit: Whether the bot should run the agent on this message. ``False``
|
||||
means the message is dropped (or only passively observed).
|
||||
reason_code: A machine-readable reason (one of the ``REASON_*``
|
||||
constants) an operator can inspect to answer "why didn't the bot
|
||||
reply to that message?".
|
||||
gate: Which gate decided (one of the ``GATE_*`` constants), for
|
||||
diagnostics.
|
||||
observe: When ``True`` the message was not admitted for a run but SHOULD
|
||||
be recorded into the session transcript as passive context (the
|
||||
``observe`` group policy). Adapters record it and skip the run.
|
||||
"""
|
||||
|
||||
admit: bool
|
||||
reason_code: str
|
||||
gate: str
|
||||
observe: bool = False
|
||||
|
||||
|
||||
def _contains(collection: Optional[Iterable[str]], value: Optional[str]) -> bool:
|
||||
"""Membership test tolerant of ``None`` collection/value.
|
||||
|
||||
An empty or ``None`` collection means "no restriction configured" and is
|
||||
handled by the caller — this only answers whether ``value`` is present.
|
||||
"""
|
||||
if not collection or value is None:
|
||||
return False
|
||||
return value in collection
|
||||
|
||||
|
||||
def resolve_ingress_admission(
|
||||
*,
|
||||
chat_type: Optional[str],
|
||||
sender_id: Optional[str],
|
||||
is_mention: bool = False,
|
||||
is_command: bool = False,
|
||||
allowlist: Optional[Iterable[str]] = None,
|
||||
blocklist: Optional[Iterable[str]] = None,
|
||||
group_policy: Optional[str] = None,
|
||||
paired: bool = True,
|
||||
) -> IngressDecision:
|
||||
"""Decide whether the bot should act on an inbound message.
|
||||
|
||||
Pure, deterministic and zero-dependency: the same inputs always yield the
|
||||
same verdict, so the decision cannot drift across transports or plugin
|
||||
channels. Every adapter replaces its bespoke ladder with one call and
|
||||
records ``decision.reason_code``.
|
||||
|
||||
The ladder (first matching gate wins):
|
||||
|
||||
1. **Block-list** — an explicitly blocked sender is always dropped
|
||||
(``blocked``), taking precedence over any allowlist entry.
|
||||
2. **Allow-list** — when an ``allowlist`` is configured and the sender is
|
||||
not in it, drop (``not_in_allowlist``). An empty/``None`` allowlist means
|
||||
"no allowlist restriction".
|
||||
3. **Pairing** — an unknown but not-yet-``paired`` sender that passed the
|
||||
allowlist gate requires pairing (``pairing_required``).
|
||||
4. **Group policy** — for group/channel chats only:
|
||||
- ``command_only``: admit commands, else drop (``command_only``);
|
||||
- ``mention_only``: admit mentions/commands, else drop
|
||||
(``group_mention_only``);
|
||||
- ``observe``: admit mentions/commands, else *observe* (recorded, no
|
||||
run) with reason ``observe``;
|
||||
- ``respond_all``: admit everything;
|
||||
- unset/unknown: treated as ``mention_only`` (the live
|
||||
``BotConfig.group_policy`` default), so an adapter forwarding an
|
||||
unset policy does not silently reply to all group traffic.
|
||||
|
||||
Direct (DM/private) chats skip the group-policy gate entirely.
|
||||
|
||||
Args:
|
||||
chat_type: Native chat type (e.g. ``"dm"``, ``"private"``, ``"group"``,
|
||||
``"channel"``, ``"supergroup"``). Anything not in the direct set is
|
||||
treated as a group for policy purposes.
|
||||
sender_id: Stable sender identifier used for allow/block-list checks.
|
||||
is_mention: Whether the bot was mentioned/@-addressed in the message.
|
||||
is_command: Whether the message is a bot command (always allowed under
|
||||
``mention_only`` / ``command_only`` / ``observe``).
|
||||
allowlist: Optional collection of allowed sender ids to *enforce*.
|
||||
Empty/``None`` disables the allowlist gate (no restriction). This is
|
||||
the already-resolved list — an adapter that wants an empty allowlist
|
||||
to mean "deny unknown" (``BotConfig.is_explicitly_allowed`` +
|
||||
``unknown_user_policy``) resolves that upstream into the ``paired``
|
||||
flag rather than passing an empty ``allowlist`` here.
|
||||
blocklist: Optional collection of blocked sender ids.
|
||||
group_policy: One of ``respond_all`` / ``mention_only`` /
|
||||
``command_only`` / ``observe``. ``None`` or unknown values behave as
|
||||
``mention_only`` — the live ``BotConfig.group_policy`` default — so
|
||||
an unset policy fails safe (mention-gated) rather than open.
|
||||
paired: Whether the sender is already paired/known. ``True`` (the
|
||||
default) means pairing is not required.
|
||||
|
||||
Returns:
|
||||
An :class:`IngressDecision` with the verdict, a machine-readable
|
||||
``reason_code``, the deciding ``gate`` and the ``observe`` flag.
|
||||
"""
|
||||
# 1. Block-list wins over everything: an explicitly blocked sender never
|
||||
# reaches an agent run regardless of allowlist membership.
|
||||
if _contains(blocklist, sender_id):
|
||||
return IngressDecision(
|
||||
admit=False, reason_code=REASON_BLOCKED, gate=GATE_BLOCKLIST
|
||||
)
|
||||
|
||||
# 2. Allow-list: only enforced when configured. Absent/empty => no restriction.
|
||||
if allowlist and not _contains(allowlist, sender_id):
|
||||
return IngressDecision(
|
||||
admit=False,
|
||||
reason_code=REASON_NOT_IN_ALLOWLIST,
|
||||
gate=GATE_ALLOWLIST,
|
||||
)
|
||||
|
||||
# 3. Pairing: an allowed-but-unknown sender must complete pairing first.
|
||||
if not paired:
|
||||
return IngressDecision(
|
||||
admit=False,
|
||||
reason_code=REASON_PAIRING_REQUIRED,
|
||||
gate=GATE_PAIRING,
|
||||
)
|
||||
|
||||
# Direct chats bypass group policy entirely.
|
||||
is_direct = (chat_type or "").lower() in _DIRECT_CHAT_TYPES
|
||||
if is_direct:
|
||||
return IngressDecision(
|
||||
admit=True, reason_code=REASON_ALLOWED, gate=GATE_DIRECT
|
||||
)
|
||||
|
||||
# 4. Group policy for group/channel chats. Unknown/None => mention_only,
|
||||
# matching the live ``BotConfig.group_policy`` default so an adapter that
|
||||
# forwards an unset policy does not silently start replying to all group
|
||||
# traffic (a security-relevant regression).
|
||||
policy = (group_policy or "mention_only").lower()
|
||||
|
||||
if policy == "command_only":
|
||||
if is_command:
|
||||
return IngressDecision(
|
||||
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
|
||||
)
|
||||
return IngressDecision(
|
||||
admit=False,
|
||||
reason_code=REASON_COMMAND_ONLY,
|
||||
gate=GATE_GROUP_POLICY,
|
||||
)
|
||||
|
||||
if policy in ("mention_only", "observe"):
|
||||
if is_mention or is_command:
|
||||
return IngressDecision(
|
||||
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
|
||||
)
|
||||
if policy == "observe":
|
||||
# Recorded as passive context (no run) so the bot has memory of the
|
||||
# conversation when next addressed.
|
||||
return IngressDecision(
|
||||
admit=False,
|
||||
reason_code=REASON_OBSERVE,
|
||||
gate=GATE_GROUP_POLICY,
|
||||
observe=True,
|
||||
)
|
||||
return IngressDecision(
|
||||
admit=False,
|
||||
reason_code=REASON_GROUP_MENTION_ONLY,
|
||||
gate=GATE_GROUP_POLICY,
|
||||
)
|
||||
|
||||
# respond_all (or any unknown policy): admit everything.
|
||||
return IngressDecision(
|
||||
admit=True, reason_code=REASON_ALLOWED, gate=GATE_GROUP_POLICY
|
||||
)
|
||||
@@ -278,8 +278,26 @@ class BasePlatformAdapter(ABC):
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def format_message(self, text: str) -> str:
|
||||
"""Apply per-platform formatting. Default: identity."""
|
||||
return text
|
||||
"""Render *text* for the platform's declared ``markdown_dialect``.
|
||||
|
||||
This is the seam that finally *consumes* the ``markdown_dialect``
|
||||
capability every adapter advertises: the default keys off
|
||||
``capabilities.markdown_dialect`` and returns text rendered in that
|
||||
flavour (e.g. MarkdownV2-escaped for Telegram, Slack ``mrkdwn`` for
|
||||
Slack) so replies render correctly and are never dropped by a transport
|
||||
that rejects unescaped specials. The default dialect ``"markdown"``
|
||||
yields a safe plain-text reduction, so existing adapters are unaffected.
|
||||
|
||||
Adapters that need the transport ``parse_mode`` (e.g. Telegram's
|
||||
``"MarkdownV2"``) can call :func:`~praisonaiagents.bots.format_for_dialect`
|
||||
directly in their send path; override this method to change formatting.
|
||||
"""
|
||||
from .format import format_for_dialect
|
||||
|
||||
rendered, _parse_mode = format_for_dialect(
|
||||
text, self._cap("markdown_dialect", "markdown")
|
||||
)
|
||||
return rendered
|
||||
|
||||
def chunk(self, text: str) -> List[str]:
|
||||
"""Split *text* to respect the platform max length.
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
"""
|
||||
Visible failure-outcome primitive for bot gateways.
|
||||
|
||||
:func:`classify_final` (``bots/silence.py``) guarantees a visible outcome for a
|
||||
*blank* turn, but says nothing about a turn that **failed** (expired auth,
|
||||
missing key, rate limit, timeout, budget/doom-loop) — where the user today sees
|
||||
a generic ``Error: <msg>`` or a silent downgrade. A rich error taxonomy already
|
||||
exists in :mod:`praisonaiagents.errors` and :mod:`praisonaiagents.run_outcome`,
|
||||
but nothing maps it to a *user-facing, actionable* reply, so every channel
|
||||
adapter re-decides it ad hoc (the drift this closes).
|
||||
|
||||
This module is the failure-path counterpart of ``classify_final`` /
|
||||
``resolve_ingress_admission``: a single, pure, dependency-free decision that maps
|
||||
an :class:`~praisonaiagents.run_outcome.AgentRunOutcome` or a
|
||||
:class:`~praisonaiagents.errors.PraisonAIError` to a typed :class:`FailureReply`
|
||||
with next-step copy keyed by failure class, reusing the existing
|
||||
``remediation_hint``. Every adapter renders ``reply.text`` and records
|
||||
``reply.reason_code`` instead of hand-rolling ``Error: …`` strings.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
if TYPE_CHECKING: # pragma: no cover - typing only, avoids import cost/cycles
|
||||
from ..errors import PraisonAIError
|
||||
from ..run_outcome import AgentRunOutcome
|
||||
|
||||
|
||||
# Machine-readable failure reason codes. Module constants (like the ``REASON_*``
|
||||
# codes in ``admission.py``) so callers record/compare without re-typing string
|
||||
# literals — exactly the drift that produced the per-adapter ``Error: …`` copy.
|
||||
REASON_AUTH_EXPIRED = "auth_expired"
|
||||
REASON_AUTH_PERMANENT = "auth_permanent"
|
||||
REASON_MISSING_KEY = "missing_key"
|
||||
REASON_RATE_LIMIT = "rate_limit"
|
||||
REASON_OVERLOADED = "overloaded"
|
||||
REASON_TIMEOUT = "timeout"
|
||||
REASON_BUDGET_EXHAUSTED = "budget_exhausted"
|
||||
REASON_DOOM_LOOP = "doom_loop"
|
||||
REASON_NEEDS_HELP = "needs_help"
|
||||
REASON_CANCELLED = "cancelled"
|
||||
REASON_CONTEXT_OVERFLOW = "context_overflow"
|
||||
REASON_MODEL_NOT_FOUND = "model_not_found"
|
||||
REASON_FORMAT_ERROR = "format_error"
|
||||
REASON_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# Static, user-facing next-step copy keyed by failure class. Kept deterministic
|
||||
# and channel-agnostic so the same guidance appears on every transport. A
|
||||
# concrete ``remediation_hint`` (e.g. from ``PraisonAIConfigError``) always wins
|
||||
# over these defaults when present.
|
||||
_REASON_TEXT = {
|
||||
REASON_AUTH_EXPIRED: (
|
||||
"I couldn't complete that - the model credential has expired. "
|
||||
"Run `praisonai onboard` to re-authenticate, then resend."
|
||||
),
|
||||
REASON_AUTH_PERMANENT: (
|
||||
"I couldn't complete that - the model credential was rejected. "
|
||||
"Check the API key/permissions, then run `praisonai onboard` and resend."
|
||||
),
|
||||
REASON_MISSING_KEY: (
|
||||
"I couldn't complete that - a required credential is missing. "
|
||||
"Run `praisonai onboard` (or `praisonai doctor`) to set it up, then resend."
|
||||
),
|
||||
REASON_RATE_LIMIT: (
|
||||
"I couldn't complete that - the provider is rate-limiting requests. "
|
||||
"Please wait a moment and resend."
|
||||
),
|
||||
REASON_OVERLOADED: (
|
||||
"I couldn't complete that - the provider is temporarily overloaded. "
|
||||
"Please wait a moment and resend."
|
||||
),
|
||||
REASON_TIMEOUT: (
|
||||
"I couldn't complete that in time - the request timed out. "
|
||||
"Please resend, or try a shorter request."
|
||||
),
|
||||
REASON_BUDGET_EXHAUSTED: (
|
||||
"I stopped before finishing - this turn hit its budget limit. "
|
||||
"Raise the budget or narrow the task, then resend."
|
||||
),
|
||||
REASON_DOOM_LOOP: (
|
||||
"I stopped before finishing - I detected a repeating loop and halted to "
|
||||
"avoid wasting effort. Try rephrasing the request, then resend."
|
||||
),
|
||||
REASON_NEEDS_HELP: (
|
||||
"I need a bit more to continue - please clarify or provide the missing "
|
||||
"detail, then resend."
|
||||
),
|
||||
REASON_CANCELLED: "That request was cancelled before it finished.",
|
||||
REASON_CONTEXT_OVERFLOW: (
|
||||
"I couldn't complete that - the conversation is too long for the model. "
|
||||
"Start a fresh thread or shorten the request, then resend."
|
||||
),
|
||||
REASON_MODEL_NOT_FOUND: (
|
||||
"I couldn't complete that - the configured model is unavailable. "
|
||||
"Check the model name in your config, then resend."
|
||||
),
|
||||
REASON_FORMAT_ERROR: (
|
||||
"I couldn't complete that - the request or configuration was invalid. "
|
||||
"Please check the input and resend."
|
||||
),
|
||||
REASON_UNKNOWN: "I couldn't complete that due to an unexpected error. Please resend.",
|
||||
}
|
||||
|
||||
# Which failure classes are worth retrying without user intervention. Mirrors the
|
||||
# taxonomy in ``errors.AgentErrorKind`` / ``AgentRunOutcome.is_retryable`` so the
|
||||
# adapter can decide whether to offer a "retry" affordance.
|
||||
_RETRYABLE_REASONS = frozenset(
|
||||
{REASON_RATE_LIMIT, REASON_OVERLOADED, REASON_TIMEOUT}
|
||||
)
|
||||
|
||||
# Map the closed ``AgentErrorKind`` taxonomy to a user-facing reason code. Auth
|
||||
# is split into expired vs permanent so re-authentication guidance is precise.
|
||||
_ERROR_KIND_TO_REASON = {
|
||||
"auth": REASON_AUTH_EXPIRED,
|
||||
"auth_permanent": REASON_AUTH_PERMANENT,
|
||||
"rate_limit": REASON_RATE_LIMIT,
|
||||
"overloaded": REASON_OVERLOADED,
|
||||
"context_overflow": REASON_CONTEXT_OVERFLOW,
|
||||
"idle_timeout": REASON_TIMEOUT,
|
||||
"billing": REASON_BUDGET_EXHAUSTED,
|
||||
"model_not_found": REASON_MODEL_NOT_FOUND,
|
||||
"format_error": REASON_FORMAT_ERROR,
|
||||
"validation": REASON_FORMAT_ERROR,
|
||||
"unknown": REASON_UNKNOWN,
|
||||
}
|
||||
|
||||
# Map an ``AgentRunOutcome`` termination context / status to a reason code.
|
||||
_TERMINATION_TO_REASON = {
|
||||
"budget_exhausted": REASON_BUDGET_EXHAUSTED,
|
||||
"doom_loop": REASON_DOOM_LOOP,
|
||||
"needs_help": REASON_NEEDS_HELP,
|
||||
"timeout": REASON_TIMEOUT,
|
||||
"cancelled": REASON_CANCELLED,
|
||||
"interrupted": REASON_CANCELLED,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FailureReply:
|
||||
"""A typed, user-facing reply for a failed turn.
|
||||
|
||||
Attributes:
|
||||
text: The visible, actionable message to deliver to the user. Includes
|
||||
next-step copy keyed by the failure class (how to re-authenticate,
|
||||
run onboarding, wait out a rate limit, etc.).
|
||||
reason_code: A machine-readable failure class (one of the ``REASON_*``
|
||||
constants) an operator/model can inspect and record.
|
||||
retryable: Whether the failure is worth retrying without user
|
||||
intervention (rate limit, overload, transient timeout).
|
||||
"""
|
||||
|
||||
text: str
|
||||
reason_code: str
|
||||
retryable: bool
|
||||
|
||||
|
||||
def _reason_from_error(error: "PraisonAIError") -> str:
|
||||
"""Derive a reason code from a :class:`PraisonAIError`.
|
||||
|
||||
A missing/blank config key surfaces as ``missing_key`` (distinct from an
|
||||
expired credential) so the onboarding vs re-auth guidance stays precise.
|
||||
"""
|
||||
category = getattr(error, "error_category", None) or "unknown"
|
||||
# A config error carrying a config_key is a *missing* credential/setting,
|
||||
# not a rejected one — steer the user to onboarding rather than re-auth.
|
||||
if getattr(error, "config_key", None) and category == "format_error":
|
||||
return REASON_MISSING_KEY
|
||||
return _ERROR_KIND_TO_REASON.get(category, REASON_UNKNOWN)
|
||||
|
||||
|
||||
def _reason_from_outcome(outcome: "AgentRunOutcome") -> str:
|
||||
"""Derive a reason code from an :class:`AgentRunOutcome`.
|
||||
|
||||
Prefers a specific ``termination_reason`` recorded in the outcome context
|
||||
(budget/doom-loop/needs-help) before falling back to the error category and
|
||||
finally the coarse status.
|
||||
"""
|
||||
context = getattr(outcome, "context", None) or {}
|
||||
termination = context.get("termination_reason") or context.get("termination")
|
||||
if termination is not None:
|
||||
key = getattr(termination, "value", termination)
|
||||
mapped = _TERMINATION_TO_REASON.get(str(key))
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
|
||||
category = getattr(outcome, "error_category", None)
|
||||
if category:
|
||||
mapped = _ERROR_KIND_TO_REASON.get(category)
|
||||
if mapped is not None:
|
||||
return mapped
|
||||
|
||||
status = getattr(outcome, "status", None)
|
||||
if status == "timeout":
|
||||
return REASON_TIMEOUT
|
||||
if status == "cancelled":
|
||||
return REASON_CANCELLED
|
||||
if status == "invalid_output":
|
||||
return REASON_FORMAT_ERROR
|
||||
return REASON_UNKNOWN
|
||||
|
||||
|
||||
def render_failure_reply(outcome: Any) -> FailureReply:
|
||||
"""Map a run outcome or error to a visible, actionable :class:`FailureReply`.
|
||||
|
||||
This is the failure-path counterpart of
|
||||
:func:`praisonaiagents.bots.silence.classify_final`: a single decision point
|
||||
so no adapter re-decides how a failure looks. It accepts either an
|
||||
:class:`~praisonaiagents.run_outcome.AgentRunOutcome` or a
|
||||
:class:`~praisonaiagents.errors.PraisonAIError` (duck-typed to avoid an
|
||||
import dependency), reuses the existing ``remediation_hint`` when present,
|
||||
and otherwise selects deterministic next-step copy keyed by the failure
|
||||
class.
|
||||
|
||||
Args:
|
||||
outcome: An ``AgentRunOutcome``, a ``PraisonAIError`` (or subclass), or
|
||||
any object exposing ``error_category`` / ``status``. A plain string
|
||||
or unknown object degrades to a generic (but still visible)
|
||||
``unknown`` reply rather than raising.
|
||||
|
||||
Returns:
|
||||
A :class:`FailureReply` with actionable ``text``, a machine-readable
|
||||
``reason_code`` and a ``retryable`` flag.
|
||||
"""
|
||||
# Duck-type the two supported inputs without importing them (keeps this a
|
||||
# zero-dependency leaf like the other bot primitives).
|
||||
is_error = isinstance(outcome, BaseException)
|
||||
|
||||
# Preserve an affirmative retryability signal from the source outcome (e.g.
|
||||
# AgentRunOutcome.is_retryable() is True for invalid_output) so a
|
||||
# reason-code that is not in the static ``_RETRYABLE_REASONS`` default does
|
||||
# not silently drop the retry affordance the outcome already promised.
|
||||
source_retryable = False
|
||||
|
||||
if is_error:
|
||||
reason = _reason_from_error(outcome) # type: ignore[arg-type]
|
||||
remediation = getattr(outcome, "remediation_hint", None)
|
||||
elif hasattr(outcome, "status") or hasattr(outcome, "error_category"):
|
||||
reason = _reason_from_outcome(outcome)
|
||||
context = getattr(outcome, "context", None) or {}
|
||||
remediation = context.get("remediation_hint")
|
||||
is_retryable = getattr(outcome, "is_retryable", None)
|
||||
if callable(is_retryable):
|
||||
try:
|
||||
source_retryable = bool(is_retryable())
|
||||
except Exception:
|
||||
source_retryable = False
|
||||
else:
|
||||
reason = REASON_UNKNOWN
|
||||
remediation = None
|
||||
|
||||
text: Optional[str] = None
|
||||
if isinstance(remediation, str) and remediation.strip():
|
||||
# A concrete remediation hint (e.g. from PraisonAIConfigError) is the
|
||||
# most precise guidance available — prefer it over the static default.
|
||||
text = f"I couldn't complete that. {remediation.strip()}"
|
||||
if text is None:
|
||||
text = _REASON_TEXT.get(reason, _REASON_TEXT[REASON_UNKNOWN])
|
||||
|
||||
return FailureReply(
|
||||
text=text,
|
||||
reason_code=reason,
|
||||
retryable=source_retryable or reason in _RETRYABLE_REASONS,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FailureReply",
|
||||
"render_failure_reply",
|
||||
"REASON_AUTH_EXPIRED",
|
||||
"REASON_AUTH_PERMANENT",
|
||||
"REASON_MISSING_KEY",
|
||||
"REASON_RATE_LIMIT",
|
||||
"REASON_OVERLOADED",
|
||||
"REASON_TIMEOUT",
|
||||
"REASON_BUDGET_EXHAUSTED",
|
||||
"REASON_DOOM_LOOP",
|
||||
"REASON_NEEDS_HELP",
|
||||
"REASON_CANCELLED",
|
||||
"REASON_CONTEXT_OVERFLOW",
|
||||
"REASON_MODEL_NOT_FOUND",
|
||||
"REASON_FORMAT_ERROR",
|
||||
"REASON_UNKNOWN",
|
||||
]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Markdown dialect conversion for agent replies.
|
||||
|
||||
A pure, dependency-free (stdlib-only) default that finally *consumes* the
|
||||
``markdown_dialect`` capability every platform adapter already declares
|
||||
(:class:`~praisonaiagents.bots.protocols.PlatformCapabilities`). It turns an
|
||||
agent's ordinary markdown reply into the flavour the target platform speaks so
|
||||
formatting renders correctly and a reply is never dropped by a transport that
|
||||
rejects unescaped special characters (e.g. Telegram's ``400 can't parse
|
||||
entities``).
|
||||
|
||||
The single entry point is :func:`format_for_dialect`, which returns the
|
||||
``(rendered_text, parse_mode)`` pair a send path needs::
|
||||
|
||||
text, mode = format_for_dialect(agent_text, caps.markdown_dialect)
|
||||
await bot.send_message(chat_id, text, parse_mode=mode)
|
||||
|
||||
Supported dialects:
|
||||
|
||||
* ``"telegram_markdown_v2"`` -> MarkdownV2-escaped text, ``parse_mode="MarkdownV2"``
|
||||
* ``"slack"`` -> Slack mrkdwn text, ``parse_mode=None``
|
||||
* ``"discord_markdown"`` -> passthrough (Discord speaks CommonMark),
|
||||
``parse_mode=None``
|
||||
* ``"markdown"``/unknown -> safe plain text, ``parse_mode=None``
|
||||
|
||||
This lives in core because it is the light default for a core protocol seam:
|
||||
pure string work, no heavy imports, and driven by the capability contract each
|
||||
adapter already advertises. Adapters call it from their send paths.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Optional, Tuple
|
||||
|
||||
__all__ = [
|
||||
"format_for_dialect",
|
||||
"escape_markdown_v2",
|
||||
"markdown_to_slack",
|
||||
"strip_markdown",
|
||||
]
|
||||
|
||||
# Characters Telegram MarkdownV2 requires escaping outside entities.
|
||||
# See https://core.telegram.org/bots/api#markdownv2-style
|
||||
_MDV2_SPECIAL = r"_*[]()~`>#+-=|{}.!\\"
|
||||
_MDV2_ESCAPE_RE = re.compile("([" + re.escape(_MDV2_SPECIAL) + "])")
|
||||
|
||||
|
||||
def escape_markdown_v2(text: str) -> str:
|
||||
"""Escape every Telegram MarkdownV2 special character in ``text``.
|
||||
|
||||
This is the conservative, always-safe escape: it treats the input as plain
|
||||
text and backslash-escapes each reserved character so Telegram accepts the
|
||||
message verbatim without a ``can't parse entities`` error. Existing markup
|
||||
(``**bold**``, ``[links](url)``) is shown literally rather than reinterpreted;
|
||||
correctness (never dropping a reply) is preferred over best-effort styling.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
return _MDV2_ESCAPE_RE.sub(r"\\\1", text)
|
||||
|
||||
|
||||
# Inline markdown -> Slack mrkdwn conversions (order matters: bold before italic).
|
||||
_MD_BOLD_RE = re.compile(r"\*\*(.+?)\*\*", re.DOTALL)
|
||||
_MD_LINK_RE = re.compile(r"\[([^\]]+)\]\((https?://[^\s)]+)\)")
|
||||
_MD_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+(.*)$", re.MULTILINE)
|
||||
|
||||
|
||||
def markdown_to_slack(text: str) -> str:
|
||||
"""Convert common markdown to Slack ``mrkdwn``.
|
||||
|
||||
Slack uses ``*bold*`` (single asterisk), ``_italic_``, ``<url|label>`` links
|
||||
and has no ``#`` headings. This maps the frequent cases and otherwise leaves
|
||||
text untouched, so a plain reply passes through unchanged.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
# Links: [label](url) -> <url|label>
|
||||
text = _MD_LINK_RE.sub(lambda m: f"<{m.group(2)}|{m.group(1)}>", text)
|
||||
# Bold: **x** -> *x* (do before single-asterisk handling is unnecessary here)
|
||||
text = _MD_BOLD_RE.sub(lambda m: f"*{m.group(1)}*", text)
|
||||
# Headings: "# Title" -> "*Title*" (Slack has no headings)
|
||||
text = _MD_HEADING_RE.sub(lambda m: f"*{m.group(1).strip()}*", text)
|
||||
return text
|
||||
|
||||
|
||||
# Markdown constructs to drop when falling back to plain text.
|
||||
_STRIP_LINK_RE = re.compile(r"\[([^\]]+)\]\((?:https?://[^\s)]+)\)")
|
||||
_STRIP_HEADING_RE = re.compile(r"^\s{0,3}#{1,6}\s+", re.MULTILINE)
|
||||
# Only unwrap *paired* emphasis/code delimiters so literal characters
|
||||
# (e.g. ``svc_1``, ``*.py``, ``a*b``) are preserved rather than deleted.
|
||||
_STRIP_BOLD_RE = re.compile(r"(\*\*|__)(?=\S)(.+?)(?<=\S)\1", re.DOTALL)
|
||||
_STRIP_ITALIC_RE = re.compile(r"(?<![*_\w])([*_])(?=\S)(.+?)(?<=\S)\1(?![*_\w])")
|
||||
_STRIP_CODE_RE = re.compile(r"`([^`]+)`")
|
||||
|
||||
|
||||
def strip_markdown(text: str) -> str:
|
||||
"""Reduce markdown to readable plain text (safe fallback).
|
||||
|
||||
Unwraps *paired* emphasis/code spans and heading hashes and unwraps links
|
||||
to their label, so a reply reads cleanly on a platform whose dialect we
|
||||
don't specifically target. Unpaired or literal delimiters (``svc_1``,
|
||||
``*.py``, ``a*b``) are left untouched so identifiers and code are never
|
||||
corrupted.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
text = _STRIP_LINK_RE.sub(r"\1", text)
|
||||
text = _STRIP_HEADING_RE.sub("", text)
|
||||
text = _STRIP_CODE_RE.sub(r"\1", text)
|
||||
text = _STRIP_BOLD_RE.sub(r"\2", text)
|
||||
text = _STRIP_ITALIC_RE.sub(r"\2", text)
|
||||
return text
|
||||
|
||||
|
||||
def format_for_dialect(text: str, dialect: str) -> Tuple[str, Optional[str]]:
|
||||
"""Render ``text`` for a platform's declared ``markdown_dialect``.
|
||||
|
||||
Args:
|
||||
text: The agent's reply text, authored in ordinary markdown.
|
||||
dialect: The platform's ``markdown_dialect`` capability value.
|
||||
|
||||
Returns:
|
||||
``(rendered_text, parse_mode)`` — ``parse_mode`` is the value the
|
||||
transport expects (e.g. ``"MarkdownV2"`` for Telegram) or ``None`` when
|
||||
the text is already in the platform's native form / plain text.
|
||||
"""
|
||||
if text is None:
|
||||
return ("", None)
|
||||
if dialect == "telegram_markdown_v2":
|
||||
return (escape_markdown_v2(text), "MarkdownV2")
|
||||
if dialect == "slack":
|
||||
return (markdown_to_slack(text), None)
|
||||
if dialect == "discord_markdown":
|
||||
return (text, None)
|
||||
# "markdown" / unknown: safe plain-text fallback.
|
||||
return (strip_markdown(text), None)
|
||||
@@ -15,7 +15,16 @@ import hashlib
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Literal, Optional, Union
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Literal,
|
||||
Optional,
|
||||
Tuple,
|
||||
Union,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .protocols import CallbackPayloadStoreProtocol
|
||||
@@ -76,6 +85,8 @@ class BlockType(str, Enum):
|
||||
SELECT = "select" # Dropdown/select menu
|
||||
DIVIDER = "divider" # Visual separator
|
||||
CONTEXT = "context" # Contextual info (smaller text)
|
||||
TABLE = "table" # Tabular data (columns + rows)
|
||||
CHART = "chart" # Chart/visualisation (kind + series)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -245,6 +256,12 @@ class PresentationBlock:
|
||||
options: Optional[List[SelectOption]] = None
|
||||
placeholder: Optional[str] = None
|
||||
action_id: Optional[str] = None
|
||||
# Tabular data (TABLE blocks)
|
||||
columns: Optional[List[str]] = None
|
||||
rows: Optional[List[List[str]]] = None
|
||||
# Chart data (CHART blocks)
|
||||
chart_kind: Optional[str] = None
|
||||
series: Optional[List[Dict[str, Any]]] = None
|
||||
|
||||
@staticmethod
|
||||
def make_text(content: str, markdown: bool = True) -> "PresentationBlock":
|
||||
@@ -280,6 +297,47 @@ class PresentationBlock:
|
||||
"""Create a context block (smaller text)."""
|
||||
return PresentationBlock(type=BlockType.CONTEXT, text=content)
|
||||
|
||||
@staticmethod
|
||||
def make_table(
|
||||
columns: List[str],
|
||||
rows: List[List[str]],
|
||||
) -> "PresentationBlock":
|
||||
"""Create a table block from *columns* and *rows*.
|
||||
|
||||
Describe tabular data once; channels with a native table widget render
|
||||
it directly, and everywhere else it degrades to a deterministic
|
||||
markdown table (see :func:`adapt_presentation`).
|
||||
"""
|
||||
return PresentationBlock(
|
||||
type=BlockType.TABLE,
|
||||
columns=[str(c) for c in columns],
|
||||
rows=[[str(c) for c in row] for row in rows],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def make_chart(
|
||||
chart_kind: str,
|
||||
series: List[Dict[str, Any]],
|
||||
text: Optional[str] = None,
|
||||
) -> "PresentationBlock":
|
||||
"""Create a chart block.
|
||||
|
||||
Args:
|
||||
chart_kind: One of ``"bar"``, ``"line"``, ``"pie"``, ``"area"``.
|
||||
series: A list of ``{"label": str, "points": list[float]}`` dicts.
|
||||
text: Optional caption/title for the chart.
|
||||
|
||||
Channels with native visualisation render the series directly; elsewhere
|
||||
the chart degrades to a compact text summary (see
|
||||
:func:`adapt_presentation`).
|
||||
"""
|
||||
return PresentationBlock(
|
||||
type=BlockType.CHART,
|
||||
chart_kind=chart_kind,
|
||||
series=series,
|
||||
text=text,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def quick_replies(
|
||||
choices: List[Any],
|
||||
@@ -320,6 +378,14 @@ class PresentationBlock:
|
||||
data["placeholder"] = self.placeholder
|
||||
if self.action_id is not None:
|
||||
data["action_id"] = self.action_id
|
||||
if self.columns is not None:
|
||||
data["columns"] = self.columns
|
||||
if self.rows is not None:
|
||||
data["rows"] = self.rows
|
||||
if self.chart_kind is not None:
|
||||
data["chart_kind"] = self.chart_kind
|
||||
if self.series is not None:
|
||||
data["series"] = self.series
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
@@ -338,6 +404,10 @@ class PresentationBlock:
|
||||
),
|
||||
placeholder=data.get("placeholder"),
|
||||
action_id=data.get("action_id"),
|
||||
columns=data.get("columns"),
|
||||
rows=data.get("rows"),
|
||||
chart_kind=data.get("chart_kind"),
|
||||
series=data.get("series"),
|
||||
)
|
||||
|
||||
|
||||
@@ -484,6 +554,10 @@ class PresentationLimits:
|
||||
supports_markdown: Whether channel supports markdown
|
||||
supports_select: Whether channel supports select menus
|
||||
supports_web_apps: Whether channel supports web apps
|
||||
supports_tables: Whether channel has a native table widget
|
||||
supports_charts: Whether channel has native chart/visualisation
|
||||
max_table_rows: Maximum rows in a table block
|
||||
max_table_cols: Maximum columns in a table block
|
||||
"""
|
||||
|
||||
max_buttons: int = 10
|
||||
@@ -495,6 +569,10 @@ class PresentationLimits:
|
||||
supports_markdown: bool = True
|
||||
supports_select: bool = True
|
||||
supports_web_apps: bool = False
|
||||
supports_tables: bool = False
|
||||
supports_charts: bool = False
|
||||
max_table_rows: int = 50
|
||||
max_table_cols: int = 10
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for serialization."""
|
||||
@@ -508,6 +586,10 @@ class PresentationLimits:
|
||||
"supports_markdown": self.supports_markdown,
|
||||
"supports_select": self.supports_select,
|
||||
"supports_web_apps": self.supports_web_apps,
|
||||
"supports_tables": self.supports_tables,
|
||||
"supports_charts": self.supports_charts,
|
||||
"max_table_rows": self.max_table_rows,
|
||||
"max_table_cols": self.max_table_cols,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
@@ -523,6 +605,10 @@ class PresentationLimits:
|
||||
supports_markdown=data.get("supports_markdown", True),
|
||||
supports_select=data.get("supports_select", True),
|
||||
supports_web_apps=data.get("supports_web_apps", False),
|
||||
supports_tables=data.get("supports_tables", False),
|
||||
supports_charts=data.get("supports_charts", False),
|
||||
max_table_rows=data.get("max_table_rows", 50),
|
||||
max_table_cols=data.get("max_table_cols", 10),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -758,6 +844,80 @@ def _select_to_buttons(
|
||||
return PresentationBlock(type=BlockType.BUTTONS, buttons=buttons)
|
||||
|
||||
|
||||
def _clamp_table(
|
||||
block: PresentationBlock,
|
||||
limits: PresentationLimits,
|
||||
) -> PresentationBlock:
|
||||
"""Return a copy of a TABLE block clamped to the channel's row/column caps."""
|
||||
columns = list(block.columns or [])
|
||||
rows = [list(r) for r in (block.rows or [])]
|
||||
if limits.max_table_cols and len(columns) > limits.max_table_cols:
|
||||
columns = columns[: limits.max_table_cols]
|
||||
rows = [r[: limits.max_table_cols] for r in rows]
|
||||
if limits.max_table_rows and len(rows) > limits.max_table_rows:
|
||||
rows = rows[: limits.max_table_rows]
|
||||
return PresentationBlock(type=BlockType.TABLE, columns=columns, rows=rows)
|
||||
|
||||
|
||||
def table_to_markdown(columns: List[str], rows: List[List[str]]) -> str:
|
||||
"""Render a table as a deterministic GitHub-flavoured markdown table.
|
||||
|
||||
Cells are coerced to strings and pipes escaped so the table stays valid.
|
||||
Short rows are padded and long rows trimmed to the header width.
|
||||
"""
|
||||
def _cell(value: Any) -> str:
|
||||
return str(value).replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
header = [_cell(c) for c in columns]
|
||||
ncols = len(header)
|
||||
lines = ["| " + " | ".join(header) + " |"]
|
||||
lines.append("| " + " | ".join(["---"] * ncols) + " |")
|
||||
for row in rows:
|
||||
cells = [_cell(c) for c in row][:ncols]
|
||||
cells += [""] * (ncols - len(cells))
|
||||
lines.append("| " + " | ".join(cells) + " |")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def chart_to_text(
|
||||
chart_kind: Optional[str],
|
||||
series: List[Dict[str, Any]],
|
||||
caption: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Render a chart as a compact, deterministic text summary.
|
||||
|
||||
Produces a caption/kind header followed by one line per series listing its
|
||||
label and points, so the data is never silently dropped on channels without
|
||||
a native visualisation.
|
||||
"""
|
||||
lines: List[str] = []
|
||||
kind = chart_kind or "chart"
|
||||
header = caption or f"{kind.capitalize()} chart"
|
||||
lines.append(header)
|
||||
for entry in series or []:
|
||||
label = str(entry.get("label", "series"))
|
||||
points = entry.get("points", []) or []
|
||||
rendered = ", ".join(str(p) for p in points)
|
||||
lines.append(f"{label}: {rendered}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _table_to_text_block(block: PresentationBlock) -> PresentationBlock:
|
||||
"""Degrade a TABLE block to a markdown-table TEXT block."""
|
||||
return PresentationBlock(
|
||||
type=BlockType.TEXT,
|
||||
text=table_to_markdown(block.columns or [], block.rows or []),
|
||||
)
|
||||
|
||||
|
||||
def _chart_to_text_block(block: PresentationBlock) -> PresentationBlock:
|
||||
"""Degrade a CHART block to a text-summary TEXT block."""
|
||||
return PresentationBlock(
|
||||
type=BlockType.TEXT,
|
||||
text=chart_to_text(block.chart_kind, block.series or [], block.text),
|
||||
)
|
||||
|
||||
|
||||
def adapt_presentation(
|
||||
presentation: MessagePresentation,
|
||||
limits: PresentationLimits,
|
||||
@@ -868,6 +1028,43 @@ def adapt_presentation(
|
||||
)
|
||||
continue
|
||||
|
||||
if block_type == BlockType.TABLE.value:
|
||||
clamped = _clamp_table(block, limits)
|
||||
if limits.supports_tables:
|
||||
adapted_blocks.append(clamped)
|
||||
else:
|
||||
# Degrade to a deterministic markdown table (then clamp text).
|
||||
text_block = _table_to_text_block(clamped)
|
||||
if (
|
||||
limits.max_text_length
|
||||
and text_block.text
|
||||
and len(text_block.text) > limits.max_text_length
|
||||
):
|
||||
text_block = PresentationBlock(
|
||||
type=BlockType.TEXT,
|
||||
text=text_block.text[: limits.max_text_length],
|
||||
)
|
||||
adapted_blocks.append(text_block)
|
||||
continue
|
||||
|
||||
if block_type == BlockType.CHART.value:
|
||||
if limits.supports_charts:
|
||||
adapted_blocks.append(block)
|
||||
else:
|
||||
# Degrade to a compact text summary (then clamp text).
|
||||
text_block = _chart_to_text_block(block)
|
||||
if (
|
||||
limits.max_text_length
|
||||
and text_block.text
|
||||
and len(text_block.text) > limits.max_text_length
|
||||
):
|
||||
text_block = PresentationBlock(
|
||||
type=BlockType.TEXT,
|
||||
text=text_block.text[: limits.max_text_length],
|
||||
)
|
||||
adapted_blocks.append(text_block)
|
||||
continue
|
||||
|
||||
adapted_blocks.append(block)
|
||||
|
||||
return MessagePresentation(
|
||||
@@ -878,3 +1075,257 @@ def adapt_presentation(
|
||||
)
|
||||
|
||||
|
||||
# Machine-readable degradation reason codes, mirroring the ``REASON_*`` style in
|
||||
# ``admission.py``/``failure.py`` so a dropped control is recorded, not silent.
|
||||
DEGRADE_SELECT_UNSUPPORTED = "select_unsupported"
|
||||
DEGRADE_WEB_APP_UNAVAILABLE = "web_app_unavailable"
|
||||
DEGRADE_BUTTONS_TRUNCATED = "buttons_truncated"
|
||||
DEGRADE_OPTIONS_TRUNCATED = "options_truncated"
|
||||
DEGRADE_TABLE_AS_TEXT = "table_rendered_as_text"
|
||||
DEGRADE_CHART_AS_TEXT = "chart_rendered_as_text"
|
||||
DEGRADE_CALLBACK_DATA_TOO_LONG = "callback_data_too_long"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DegradedDelivery:
|
||||
"""A record of controls a channel could not render natively.
|
||||
|
||||
``adapt_presentation`` already downgrades charts/tables/buttons/selects to
|
||||
deterministic text/callbacks, but returns *no record* of what it dropped, so
|
||||
the downgrade is silent — the user (and the model) never learns a button
|
||||
vanished or a chart became text. This is the typed report of that
|
||||
degradation, the presentation-path counterpart of
|
||||
:class:`~praisonaiagents.bots.failure.FailureReply`: an adapter appends
|
||||
:attr:`fallback_text` so the loss is *visible*, and records
|
||||
:attr:`reasons` so it is *machine-readable*.
|
||||
|
||||
Attributes:
|
||||
dropped: Human-readable descriptions of each degraded/dropped control
|
||||
(e.g. ``"1 button rendered as text"``).
|
||||
reasons: Machine-readable reason codes (the ``DEGRADE_*`` constants),
|
||||
aligned by intent with ``dropped``.
|
||||
fallback_text: A short, user-facing note the adapter can append so the
|
||||
degradation is never silent (e.g. ``"(Delivered 1 button as text -
|
||||
Telegram callback data exceeded 64 bytes.)"``). Empty when nothing
|
||||
degraded.
|
||||
"""
|
||||
|
||||
dropped: Tuple[str, ...]
|
||||
reasons: Tuple[str, ...]
|
||||
fallback_text: str
|
||||
|
||||
|
||||
def _callback_is_lossy(value: Optional[str]) -> bool:
|
||||
"""True when an *adapted* callback value carries the lossy hash marker.
|
||||
|
||||
:func:`_encode_reply_callback` / :func:`_encode_select_callback` emit a
|
||||
``#<digest>`` payload only when the original value overflowed the channel
|
||||
byte-cap *and* no store was available to preserve it losslessly. Detecting
|
||||
that marker on the already-adapted value is the single source of truth for
|
||||
"callback data too long" — so the report never disagrees with the
|
||||
adaptation (e.g. no false positive when a store round-trips the value, and
|
||||
no miss for a degraded select option).
|
||||
"""
|
||||
if not value:
|
||||
return False
|
||||
if value.startswith(f"{REPLY_CALLBACK_PREFIX}{REPLY_HASH_MARKER}"):
|
||||
return True
|
||||
# Degraded select options: ``select:...:`` with no ``@<ref>`` store marker
|
||||
# means the value was hashed (lossy). A stored ref carries CALLBACK_REF_MARKER.
|
||||
if value.startswith("select:"):
|
||||
tail = value.rsplit(":", 1)[-1]
|
||||
return not tail.startswith(CALLBACK_REF_MARKER)
|
||||
return False
|
||||
|
||||
|
||||
def _presentation_degradation(
|
||||
presentation: MessagePresentation,
|
||||
limits: PresentationLimits,
|
||||
callback_store: Optional["CallbackPayloadStoreProtocol"] = None,
|
||||
) -> Optional[DegradedDelivery]:
|
||||
"""Compute the :class:`DegradedDelivery` report for adapting to ``limits``.
|
||||
|
||||
Derives the report from the *same* conversion and selection decisions as
|
||||
:func:`adapt_presentation` (select->buttons, priority/cap button truncation,
|
||||
web_app->url, option truncation, table/chart->text) — inspecting only the
|
||||
controls actually retained and reporting callback shortening only when the
|
||||
adapter genuinely produced a lossy payload. Returns ``None`` when nothing
|
||||
degrades.
|
||||
"""
|
||||
dropped: List[str] = []
|
||||
reasons: List[str] = []
|
||||
|
||||
for block in presentation.blocks:
|
||||
block_type = block.type.value if isinstance(block.type, BlockType) else block.type
|
||||
|
||||
if block_type == BlockType.SELECT.value and not limits.supports_select:
|
||||
# Follow the adapter: select -> buttons (encoding option callbacks
|
||||
# exactly as adapt_presentation does), then treat as a buttons block.
|
||||
n = len(block.options or [])
|
||||
dropped.append(f"select menu ({n} options) rendered as buttons")
|
||||
reasons.append(DEGRADE_SELECT_UNSUPPORTED)
|
||||
block = _select_to_buttons(block, callback_store)
|
||||
block_type = BlockType.BUTTONS.value
|
||||
|
||||
if block_type == BlockType.BUTTONS.value and block.buttons:
|
||||
buttons = list(block.buttons)
|
||||
rows = limits.max_button_rows if limits.max_button_rows else 1
|
||||
total_cap = limits.max_buttons * rows if limits.max_buttons else len(buttons)
|
||||
if total_cap <= 0:
|
||||
total_cap = len(buttons)
|
||||
if len(buttons) > total_cap:
|
||||
n = len(buttons) - total_cap
|
||||
dropped.append(f"{n} button(s) dropped (over channel cap)")
|
||||
reasons.append(DEGRADE_BUTTONS_TRUNCATED)
|
||||
# Only the *retained* buttons are actually rendered; mirror the
|
||||
# priority-aware selection so we don't report a dropped
|
||||
# button's web_app/callback degradation.
|
||||
indexed = list(enumerate(buttons))
|
||||
kept = sorted(
|
||||
indexed, key=lambda iv: (iv[1].priority, -iv[0]), reverse=True
|
||||
)[:total_cap]
|
||||
kept.sort(key=lambda iv: iv[0])
|
||||
buttons = [b for _, b in kept]
|
||||
|
||||
for btn in buttons:
|
||||
if btn.action is None:
|
||||
continue
|
||||
# Compare against the adapter's actual output for this button.
|
||||
adapted_btn = _adapt_button(btn, limits, callback_store)
|
||||
a_type = (
|
||||
btn.action.type.value
|
||||
if isinstance(btn.action.type, ActionType)
|
||||
else btn.action.type
|
||||
)
|
||||
if (
|
||||
not limits.supports_web_apps
|
||||
and a_type == ActionType.WEB_APP.value
|
||||
and btn.action.web_app_url
|
||||
):
|
||||
dropped.append("web-app button rendered as URL")
|
||||
reasons.append(DEGRADE_WEB_APP_UNAVAILABLE)
|
||||
elif adapted_btn.action is not None and _callback_is_lossy(
|
||||
adapted_btn.action.value
|
||||
):
|
||||
dropped.append("button callback shortened (data exceeded byte cap)")
|
||||
reasons.append(DEGRADE_CALLBACK_DATA_TOO_LONG)
|
||||
|
||||
elif block_type == BlockType.SELECT.value and block.options:
|
||||
if limits.max_options and len(block.options) > limits.max_options:
|
||||
n = len(block.options) - limits.max_options
|
||||
dropped.append(f"{n} select option(s) dropped (over channel cap)")
|
||||
reasons.append(DEGRADE_OPTIONS_TRUNCATED)
|
||||
|
||||
elif block_type == BlockType.TABLE.value and not limits.supports_tables:
|
||||
dropped.append("table rendered as text")
|
||||
reasons.append(DEGRADE_TABLE_AS_TEXT)
|
||||
|
||||
elif block_type == BlockType.CHART.value and not limits.supports_charts:
|
||||
dropped.append("chart rendered as text")
|
||||
reasons.append(DEGRADE_CHART_AS_TEXT)
|
||||
|
||||
if not dropped:
|
||||
return None
|
||||
|
||||
fallback_text = "(" + "; ".join(dropped) + ".)"
|
||||
return DegradedDelivery(
|
||||
dropped=tuple(dropped),
|
||||
reasons=tuple(reasons),
|
||||
fallback_text=fallback_text,
|
||||
)
|
||||
|
||||
|
||||
def adapt_presentation_with_report(
|
||||
presentation: MessagePresentation,
|
||||
limits: PresentationLimits,
|
||||
*,
|
||||
callback_store: Optional["CallbackPayloadStoreProtocol"] = None,
|
||||
) -> "tuple[MessagePresentation, Optional[DegradedDelivery]]":
|
||||
"""Adapt a presentation *and* report what degraded.
|
||||
|
||||
Identical to :func:`adapt_presentation` for the returned presentation, but
|
||||
additionally returns a typed :class:`DegradedDelivery` (or ``None``) so the
|
||||
adapter can append a readable text fallback and record machine-readable
|
||||
reasons instead of downgrading silently. ``adapt_presentation`` is retained
|
||||
unchanged for callers that do not need the report.
|
||||
|
||||
Returns:
|
||||
``(adapted_presentation, degraded_or_none)``.
|
||||
"""
|
||||
adapted = adapt_presentation(presentation, limits, callback_store=callback_store)
|
||||
report = _presentation_degradation(presentation, limits, callback_store)
|
||||
return adapted, report
|
||||
|
||||
|
||||
# The renderer contract lives in ``protocols.py`` (alongside the other bot
|
||||
# extension-point protocols). Re-exported here for backward-compatible imports
|
||||
# (``from praisonaiagents.bots.presentation import PresentationRendererProtocol``).
|
||||
from .protocols import PresentationRendererProtocol # noqa: E402,F401
|
||||
|
||||
|
||||
# Registry keyed by a normalized (lowercased, stripped) platform id. Both
|
||||
# built-in (registered by the wrapper at import time) and plugin renderers
|
||||
# register here identically, so no channel is second-class for interactive UX.
|
||||
# Consumers resolve with ``get_presentation_renderer`` and fall back to plain
|
||||
# text only when genuinely no renderer exists for a platform.
|
||||
_PRESENTATION_RENDERERS: Dict[str, type] = {}
|
||||
|
||||
|
||||
def _normalize_platform(platform: str) -> str:
|
||||
"""Normalize a platform id the same way the channel registry does.
|
||||
|
||||
Channel identifiers are matched case-insensitively elsewhere, so a
|
||||
mixed-case plugin id (``"Matrix"``) must resolve to the same renderer slot
|
||||
as ``"matrix"``. Without this, a channel could register/resolve as a channel
|
||||
but silently miss its renderer and degrade to plain text.
|
||||
"""
|
||||
return platform.strip().lower()
|
||||
|
||||
|
||||
def register_presentation_renderer(platform: str, renderer: type) -> None:
|
||||
"""Register *renderer* as the presentation renderer for *platform*.
|
||||
|
||||
Any channel — built-in or a pip-installed plugin — calls this (e.g. from its
|
||||
``setup`` hook or entry point) so its interactive presentations render
|
||||
natively instead of degrading to plain text. Re-registering a platform
|
||||
overrides the previous renderer, letting a plugin intentionally supersede a
|
||||
built-in.
|
||||
|
||||
Args:
|
||||
platform: The channel/platform id (e.g. ``"telegram"``, ``"matrix"``);
|
||||
matched case-insensitively.
|
||||
renderer: A class satisfying :class:`PresentationRendererProtocol`
|
||||
(exposing ``get_limits`` and ``render``).
|
||||
|
||||
Raises:
|
||||
ValueError: If *platform* is empty/blank.
|
||||
TypeError: If *renderer* does not expose callable ``get_limits`` and
|
||||
``render`` — so a misconfigured renderer fails loudly at
|
||||
registration rather than later inside :func:`render_for`.
|
||||
"""
|
||||
key = _normalize_platform(platform) if isinstance(platform, str) else ""
|
||||
if not key:
|
||||
raise ValueError(
|
||||
"register_presentation_renderer: platform id must be a non-empty "
|
||||
"string (e.g. 'telegram', 'matrix')."
|
||||
)
|
||||
if not (callable(getattr(renderer, "get_limits", None))
|
||||
and callable(getattr(renderer, "render", None))):
|
||||
raise TypeError(
|
||||
"register_presentation_renderer: renderer for "
|
||||
f"'{platform}' must satisfy PresentationRendererProtocol — expose "
|
||||
"static/callable 'get_limits()' and 'render(presentation)'."
|
||||
)
|
||||
_PRESENTATION_RENDERERS[key] = renderer
|
||||
|
||||
|
||||
def get_presentation_renderer(platform: str) -> Optional[type]:
|
||||
"""Return the registered renderer class for *platform*, or ``None``.
|
||||
|
||||
Lookup is case-insensitive to mirror registration.
|
||||
"""
|
||||
if not isinstance(platform, str):
|
||||
return None
|
||||
return _PRESENTATION_RENDERERS.get(_normalize_platform(platform))
|
||||
|
||||
|
||||
@@ -1185,6 +1185,34 @@ class SupportsPresentation(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PresentationRendererProtocol(Protocol):
|
||||
"""Contract a channel presentation renderer implements.
|
||||
|
||||
A renderer converts a portable :class:`MessagePresentation` into a native,
|
||||
platform-specific payload (Telegram inline keyboard, Slack blocks, …). It
|
||||
should run ``adapt_presentation`` against its own :meth:`get_limits` first
|
||||
so capability-driven degradation is applied uniformly.
|
||||
|
||||
This protocol is the core seam that lets *any* channel — built-in or a
|
||||
pip-installed plugin — register a native renderer via
|
||||
``register_presentation_renderer``, mirroring how a channel already
|
||||
contributes config via :class:`ChannelDescriptor`. Heavy renderer
|
||||
implementations still live in ``praisonai-bot``; only the contract and the
|
||||
registry live in core.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def get_limits() -> "PresentationLimits":
|
||||
"""Return this channel's capability limits."""
|
||||
...
|
||||
|
||||
@staticmethod
|
||||
def render(presentation: "MessagePresentation") -> Dict[str, Any]:
|
||||
"""Render *presentation* into a native, platform-specific payload."""
|
||||
...
|
||||
|
||||
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
# EmailProtocol — email-specific bot capabilities
|
||||
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
|
||||
@@ -66,6 +66,39 @@ def is_intentional_silence_response(text: str | None) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def classify_final(text: str | None) -> str:
|
||||
"""Classify an agent's final reply for the visible-outcome guarantee.
|
||||
|
||||
Every inbound turn must end in a visible outcome or a recorded, intentional
|
||||
non-outcome. This is the single decision point the bot/gateway layer uses to
|
||||
tell those apart, so no adapter re-decides "blank" differently:
|
||||
|
||||
- ``"silence"``: a deliberate no-reply (an exact ``NO_REPLY``/``[SILENT]``
|
||||
marker). The gateway suppresses the send on purpose.
|
||||
- ``"empty"``: blank, whitespace-only, or the machine ``[tool_calls: …]``
|
||||
placeholder — NOT deliberate silence. The gateway must substitute a
|
||||
recorded, user-facing fallback rather than drop it or leak the token.
|
||||
- ``"text"``: real user-facing content to deliver as-is.
|
||||
|
||||
Examples:
|
||||
>>> classify_final("NO_REPLY")
|
||||
'silence'
|
||||
>>> classify_final("")
|
||||
'empty'
|
||||
>>> classify_final(" ")
|
||||
'empty'
|
||||
>>> classify_final("[tool_calls: search_web]")
|
||||
'empty'
|
||||
>>> classify_final("Here is your answer.")
|
||||
'text'
|
||||
"""
|
||||
if is_intentional_silence_response(text):
|
||||
return "silence"
|
||||
if not text or not text.strip() or text.strip().startswith("[tool_calls:"):
|
||||
return "empty"
|
||||
return "text"
|
||||
|
||||
|
||||
@dataclass
|
||||
class BotLoopPolicy:
|
||||
"""Sliding-window pair budget for bot-to-bot loop protection.
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"""Declarative webhook filter — a tiny, import-light matcher for HTTP events.
|
||||
|
||||
A generic webhook channel needs to decide, purely from configuration, whether
|
||||
an inbound HTTP event should trigger an agent. This module provides that
|
||||
decision as a small, side-effect-free predicate tree so the heavy HTTP-serving
|
||||
ingress (in ``praisonai-bot``) — and any third-party channel — can reuse one
|
||||
canonical filter semantics instead of re-implementing payload matching.
|
||||
|
||||
The filter is expressed as plain dict/JSON so it round-trips through YAML:
|
||||
|
||||
when:
|
||||
all:
|
||||
- { field: headers.X-GitHub-Event, equals: issues }
|
||||
- { field: payload.action, in: [opened, reopened] }
|
||||
|
||||
Grammar
|
||||
-------
|
||||
A node is either a *combinator* or a *leaf*.
|
||||
|
||||
Combinators (compose sub-nodes)::
|
||||
|
||||
{"all": [<node>, ...]} # AND — every child must match (empty = True)
|
||||
{"any": [<node>, ...]} # OR — at least one child matches (empty = False)
|
||||
{"not": <node>} # negation
|
||||
|
||||
Leaf (a single field test)::
|
||||
|
||||
{"field": "payload.action", "<op>": <value>}
|
||||
|
||||
where ``<op>`` is one of:
|
||||
|
||||
- ``exists`` — truthy check the field is present (value: bool, default True)
|
||||
- ``equals`` — equality (string-insensitive for headers is *not* implied;
|
||||
compares the resolved value to ``value``)
|
||||
- ``contains``— substring / membership (``value in resolved``)
|
||||
- ``in`` — resolved value is one of ``value`` (a list)
|
||||
- ``regex`` — ``re.search(value, str(resolved))``
|
||||
|
||||
``field`` is a dotted path into the event mapping, e.g. ``payload.issue.number``
|
||||
or ``headers.X-GitHub-Event``. Header lookup is case-insensitive. A missing path
|
||||
resolves to ``None`` and simply fails the leaf (fail-safe), it never raises.
|
||||
|
||||
This module has no third-party imports and does not perform any I/O, so it is
|
||||
safe to keep in the core SDK; the wrapper composes it with HMAC verification and
|
||||
the ingress journal.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any, Mapping, Optional
|
||||
|
||||
__all__ = ["WebhookFilter", "evaluate_webhook_filter", "resolve_field"]
|
||||
|
||||
|
||||
def resolve_field(event: Mapping[str, Any], path: str) -> Any:
|
||||
"""Resolve a dotted ``path`` into ``event``, case-insensitively for headers.
|
||||
|
||||
Args:
|
||||
event: The normalised event mapping, typically
|
||||
``{"payload": ..., "headers": ..., "query": ...}``.
|
||||
path: A dotted path like ``payload.issue.number`` or
|
||||
``headers.X-GitHub-Event``.
|
||||
|
||||
Returns:
|
||||
The resolved value, or ``None`` if any segment is missing. Never raises.
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
parts = path.split(".")
|
||||
current: Any = event
|
||||
header_section = parts[0] == "headers"
|
||||
for i, part in enumerate(parts):
|
||||
if isinstance(current, Mapping):
|
||||
if part in current:
|
||||
current = current[part]
|
||||
continue
|
||||
# Headers are case-insensitive: fall back to a lowered-key match for
|
||||
# the segment directly under ``headers``.
|
||||
if header_section and i == 1:
|
||||
lowered = {str(k).lower(): v for k, v in current.items()}
|
||||
if part.lower() in lowered:
|
||||
current = lowered[part.lower()]
|
||||
continue
|
||||
return None
|
||||
return None
|
||||
return current
|
||||
|
||||
|
||||
def _match_leaf(event: Mapping[str, Any], node: Mapping[str, Any]) -> bool:
|
||||
"""Evaluate a single ``{"field": ..., "<op>": value}`` leaf. Fail-safe."""
|
||||
field = node.get("field")
|
||||
if not isinstance(field, str):
|
||||
return False
|
||||
resolved = resolve_field(event, field)
|
||||
|
||||
if "exists" in node:
|
||||
want = node["exists"]
|
||||
present = resolved is not None
|
||||
return present if bool(want) else (not present)
|
||||
|
||||
if "equals" in node:
|
||||
return resolved == node["equals"]
|
||||
|
||||
if "contains" in node:
|
||||
needle = node["contains"]
|
||||
try:
|
||||
return needle in resolved # type: ignore[operator]
|
||||
except TypeError:
|
||||
return False
|
||||
|
||||
if "in" in node:
|
||||
options = node["in"]
|
||||
if isinstance(options, (list, tuple, set)):
|
||||
return resolved in options
|
||||
return False
|
||||
|
||||
if "regex" in node:
|
||||
pattern = node["regex"]
|
||||
if not isinstance(pattern, str) or resolved is None:
|
||||
return False
|
||||
try:
|
||||
return re.search(pattern, str(resolved)) is not None
|
||||
except re.error:
|
||||
return False
|
||||
|
||||
# A leaf with only ``field`` and no operator is treated as an existence
|
||||
# check — matches when the field is present.
|
||||
return resolved is not None
|
||||
|
||||
|
||||
def evaluate_webhook_filter(
|
||||
event: Mapping[str, Any], node: Optional[Any]
|
||||
) -> bool:
|
||||
"""Evaluate a declarative filter ``node`` against a normalised ``event``.
|
||||
|
||||
Args:
|
||||
event: Normalised event, e.g. ``{"payload", "headers", "query"}``.
|
||||
node: The filter tree (dict). ``None`` or ``{}`` matches everything so a
|
||||
route with no ``when`` is an unconditional catch-all.
|
||||
|
||||
Returns:
|
||||
True if the event matches. Never raises on malformed input — an
|
||||
unrecognised node fails closed to ``False`` (except the empty/None
|
||||
catch-all above).
|
||||
"""
|
||||
if node is None or node == {}:
|
||||
return True
|
||||
if not isinstance(node, Mapping):
|
||||
return False
|
||||
|
||||
if "all" in node:
|
||||
children = node["all"] or []
|
||||
return all(evaluate_webhook_filter(event, c) for c in children)
|
||||
if "any" in node:
|
||||
children = node["any"] or []
|
||||
return any(evaluate_webhook_filter(event, c) for c in children)
|
||||
if "not" in node:
|
||||
return not evaluate_webhook_filter(event, node["not"])
|
||||
|
||||
if "field" in node:
|
||||
return _match_leaf(event, node)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
class WebhookFilter:
|
||||
"""A reusable, config-driven predicate over a normalised webhook event.
|
||||
|
||||
Wraps a declarative filter tree so callers can validate it once and match
|
||||
many events::
|
||||
|
||||
f = WebhookFilter({"all": [
|
||||
{"field": "headers.X-GitHub-Event", "equals": "issues"},
|
||||
{"field": "payload.action", "in": ["opened", "reopened"]},
|
||||
]})
|
||||
if f.matches({"headers": {...}, "payload": {...}}):
|
||||
...
|
||||
|
||||
A ``None``/empty tree is an unconditional match (catch-all route).
|
||||
"""
|
||||
|
||||
__slots__ = ("_tree",)
|
||||
|
||||
def __init__(self, tree: Optional[Any] = None) -> None:
|
||||
self._tree = tree
|
||||
|
||||
@property
|
||||
def tree(self) -> Optional[Any]:
|
||||
return self._tree
|
||||
|
||||
def matches(self, event: Mapping[str, Any]) -> bool:
|
||||
"""Return whether ``event`` satisfies the filter tree."""
|
||||
return evaluate_webhook_filter(event, self._tree)
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - trivial
|
||||
return f"WebhookFilter({self._tree!r})"
|
||||
@@ -37,6 +37,8 @@ __all__ = [
|
||||
"ToolResultPrunerProtocol",
|
||||
"MessageFormatterProtocol",
|
||||
"SummaryBuilderProtocol",
|
||||
# Read-only recap
|
||||
"build_recap",
|
||||
]
|
||||
|
||||
|
||||
@@ -70,4 +72,8 @@ def __getattr__(name: str):
|
||||
from .protocols import SummaryBuilderProtocol
|
||||
return SummaryBuilderProtocol
|
||||
|
||||
if name == "build_recap":
|
||||
from .recap import build_recap
|
||||
return build_recap
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -459,6 +459,58 @@ class ContextCompactor:
|
||||
return self.tool_pruner.prune(messages, self.config.max_tool_result_size)
|
||||
return messages, 0
|
||||
|
||||
def _snap_to_pair_boundary(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
cut_index: int,
|
||||
) -> int:
|
||||
"""Move ``cut_index`` so it never splits an assistant ``tool_calls``
|
||||
message from its matching ``tool`` result.
|
||||
|
||||
``messages[:cut_index]`` is the older segment that gets dropped or
|
||||
summarised; ``messages[cut_index:]`` is the recent segment that is
|
||||
kept verbatim. If the boundary would leave a ``tool`` result at the
|
||||
head of the kept segment whose originating assistant ``tool_calls``
|
||||
message sits in the older segment (or vice versa), strict providers
|
||||
reject the transcript with a 400. This snaps the boundary *outward*
|
||||
(to a lower index) so the whole pair is kept together on the recent
|
||||
side, which is the safe direction for the provider contract.
|
||||
|
||||
Returns a boundary index in ``[0, len(messages)]``.
|
||||
"""
|
||||
if cut_index <= 0 or cut_index >= len(messages):
|
||||
return max(0, min(cut_index, len(messages)))
|
||||
|
||||
# Collect the tool_call ids produced by assistant messages that fall
|
||||
# in the older (dropped/summarised) segment. Any tool result in the
|
||||
# kept segment referencing one of these would be orphaned.
|
||||
def _call_ids(msg: Dict[str, Any]) -> set:
|
||||
ids = set()
|
||||
for tc in (msg.get("tool_calls") or []):
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
ids.add(tc["id"])
|
||||
return ids
|
||||
|
||||
# Walk the boundary outward while the first kept message is an orphaned
|
||||
# tool result, i.e. its tool_call_id was emitted before the cut.
|
||||
older_call_ids = set()
|
||||
for msg in messages[:cut_index]:
|
||||
older_call_ids |= _call_ids(msg)
|
||||
|
||||
while cut_index > 0:
|
||||
head = messages[cut_index]
|
||||
head_response_id = head.get("tool_call_id")
|
||||
if head.get("role") == "tool" and head_response_id in older_call_ids:
|
||||
# Pull the boundary back to include the preceding message; keep
|
||||
# going until the emitting assistant tool_calls message is on
|
||||
# the kept side too.
|
||||
cut_index -= 1
|
||||
older_call_ids -= _call_ids(messages[cut_index])
|
||||
else:
|
||||
break
|
||||
|
||||
return cut_index
|
||||
|
||||
def _truncate(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Truncate oldest messages."""
|
||||
result = []
|
||||
@@ -476,25 +528,49 @@ class ContextCompactor:
|
||||
# Always keep system messages
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
|
||||
# Keep recent messages. Snap the boundary outward so a tool result at
|
||||
# the head of the kept window is never orphaned from its assistant
|
||||
# tool_calls message (strict providers 400 on an orphaned tool result).
|
||||
if other_msgs and len(other_msgs) > self.preserve_recent:
|
||||
cut = self._snap_to_pair_boundary(
|
||||
other_msgs, len(other_msgs) - self.preserve_recent
|
||||
)
|
||||
else:
|
||||
cut = 0
|
||||
recent = other_msgs[cut:]
|
||||
|
||||
# Add recent messages
|
||||
result.extend(recent)
|
||||
|
||||
# If still over limit, truncate more
|
||||
# If still over limit, truncate more. Drop the whole leading tool pair
|
||||
# together (an assistant tool_calls message plus its tool results) so we
|
||||
# never leave an orphaned tool result at the head of the kept window.
|
||||
while self.count_total_tokens(result) > self.target_tokens and len(result) > 1:
|
||||
# Remove oldest message (respecting preserve_system setting)
|
||||
removed = False
|
||||
# Find the oldest droppable (non-system when preserved) message.
|
||||
start = None
|
||||
for i, msg in enumerate(result):
|
||||
# Skip system messages only if preserve_system is True
|
||||
if not (self.preserve_system and msg.get("role") == "system"):
|
||||
result.pop(i)
|
||||
removed = True
|
||||
start = i
|
||||
break
|
||||
if not removed:
|
||||
if start is None:
|
||||
# Only system messages remain but still over budget — stop to avoid infinite loop
|
||||
break
|
||||
|
||||
# If the oldest droppable message emits tool_calls, drop it together
|
||||
# with all of its immediately-following tool results.
|
||||
drop_ids = set()
|
||||
for tc in (result[start].get("tool_calls") or []):
|
||||
if isinstance(tc, dict) and tc.get("id"):
|
||||
drop_ids.add(tc["id"])
|
||||
end = start + 1
|
||||
while (
|
||||
drop_ids
|
||||
and end < len(result)
|
||||
and result[end].get("role") == "tool"
|
||||
and result[end].get("tool_call_id") in drop_ids
|
||||
):
|
||||
end += 1
|
||||
del result[start:end]
|
||||
|
||||
return result
|
||||
|
||||
@@ -507,20 +583,25 @@ class ContextCompactor:
|
||||
if self.preserve_system and msg.get("role") == "system":
|
||||
result.append(msg)
|
||||
|
||||
# Add messages from end until we hit target
|
||||
# Add messages from end until we hit target. Track the boundary index
|
||||
# into non_system so we can snap it to keep tool pairs together.
|
||||
non_system = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
kept = 0
|
||||
window: List[Dict[str, Any]] = []
|
||||
for msg in reversed(non_system):
|
||||
if self.count_total_tokens(result + [msg]) <= self.target_tokens:
|
||||
result.insert(len([m for m in result if m.get("role") == "system"]), msg)
|
||||
if self.count_total_tokens(result + [msg] + window) <= self.target_tokens:
|
||||
window.insert(0, msg)
|
||||
kept += 1
|
||||
else:
|
||||
break
|
||||
|
||||
# Ensure messages are in order
|
||||
system_msgs = [m for m in result if m.get("role") == "system"]
|
||||
other_msgs = [m for m in result if m.get("role") != "system"]
|
||||
# Snap the boundary outward so a tool result kept at the window head is
|
||||
# not orphaned from its assistant tool_calls left outside the window.
|
||||
cut = self._snap_to_pair_boundary(non_system, len(non_system) - kept)
|
||||
window = non_system[cut:]
|
||||
|
||||
return system_msgs + other_msgs
|
||||
return result + window
|
||||
|
||||
def _summarize(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Summarize old messages (simplified version)."""
|
||||
@@ -532,11 +613,16 @@ class ContextCompactor:
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
|
||||
# Summarize older messages
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
# Choose the recent/older boundary by count, then snap it so a
|
||||
# tool_calls message and its result are never split across it.
|
||||
if len(other_msgs) > self.preserve_recent:
|
||||
cut = self._snap_to_pair_boundary(
|
||||
other_msgs, len(other_msgs) - self.preserve_recent
|
||||
)
|
||||
else:
|
||||
cut = 0
|
||||
recent = other_msgs[cut:]
|
||||
older = other_msgs[:cut]
|
||||
|
||||
if older:
|
||||
# Create a simple summary
|
||||
@@ -580,9 +666,17 @@ class ContextCompactor:
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages intact
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
# Keep recent messages intact. Snap the boundary so a tool_calls
|
||||
# message and its result are pruned/kept together (prune only rewrites
|
||||
# tool-result *content*, but a snapped boundary keeps intent consistent).
|
||||
if len(other_msgs) > self.preserve_recent:
|
||||
cut = self._snap_to_pair_boundary(
|
||||
other_msgs, len(other_msgs) - self.preserve_recent
|
||||
)
|
||||
else:
|
||||
cut = 0
|
||||
recent = other_msgs[cut:]
|
||||
older = other_msgs[:cut]
|
||||
|
||||
# Prune older messages
|
||||
for msg in older:
|
||||
@@ -665,8 +759,15 @@ class ContextCompactor:
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
# Keep recent messages, snapping the boundary so tool_calls/result
|
||||
# pairs are never split across the recent/older divide.
|
||||
if len(other_msgs) > self.preserve_recent:
|
||||
recent_cut = self._snap_to_pair_boundary(
|
||||
other_msgs, len(other_msgs) - self.preserve_recent
|
||||
)
|
||||
else:
|
||||
recent_cut = 0
|
||||
recent = other_msgs[recent_cut:]
|
||||
|
||||
# Determine what to summarize
|
||||
total_original_messages = len(messages)
|
||||
@@ -678,13 +779,14 @@ class ContextCompactor:
|
||||
messages_since_summary = total_original_messages - self._previous_summary_global_idx
|
||||
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
|
||||
if new_older_messages > 0:
|
||||
to_summarize = other_msgs[-messages_since_summary:-self.preserve_recent]
|
||||
to_summarize = other_msgs[-messages_since_summary:recent_cut]
|
||||
else:
|
||||
to_summarize = []
|
||||
self._used_previous_summary = True
|
||||
else:
|
||||
# Fresh summary: summarize all older messages
|
||||
to_summarize = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
# Fresh summary: summarize all older messages (kept in sync with the
|
||||
# snapped recent boundary so a tool pair is never split)
|
||||
to_summarize = other_msgs[:recent_cut]
|
||||
self._used_previous_summary = False
|
||||
|
||||
if to_summarize or self._previous_summary:
|
||||
@@ -775,8 +877,15 @@ class ContextCompactor:
|
||||
|
||||
result.extend(system_msgs)
|
||||
|
||||
# Keep recent messages
|
||||
recent = other_msgs[-self.preserve_recent:]
|
||||
# Keep recent messages, snapping the boundary so tool_calls/result
|
||||
# pairs are never split across the recent/older divide.
|
||||
if len(other_msgs) > self.preserve_recent:
|
||||
recent_cut = self._snap_to_pair_boundary(
|
||||
other_msgs, len(other_msgs) - self.preserve_recent
|
||||
)
|
||||
else:
|
||||
recent_cut = 0
|
||||
recent = other_msgs[recent_cut:]
|
||||
|
||||
# Determine what to summarize based on iterative settings
|
||||
total_original_messages = len(messages)
|
||||
@@ -787,13 +896,14 @@ class ContextCompactor:
|
||||
messages_since_summary = total_original_messages - self._previous_summary_global_idx
|
||||
new_older_messages = max(0, messages_since_summary - self.preserve_recent)
|
||||
if new_older_messages > 0:
|
||||
older = other_msgs[-messages_since_summary:-self.preserve_recent]
|
||||
older = other_msgs[-messages_since_summary:recent_cut]
|
||||
else:
|
||||
older = []
|
||||
self._used_previous_summary = True
|
||||
else:
|
||||
# Fresh summary: summarize all older messages
|
||||
older = other_msgs[:-self.preserve_recent] if len(other_msgs) > self.preserve_recent else []
|
||||
# Fresh summary: summarize all older messages (kept in sync with the
|
||||
# snapped recent boundary so a tool pair is never split)
|
||||
older = other_msgs[:recent_cut]
|
||||
self._used_previous_summary = False
|
||||
|
||||
if older and self.llm_summarize_fn:
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Read-only session recap built on the existing summariser.
|
||||
|
||||
The compaction machinery in this package normally runs to *shrink* context.
|
||||
``build_recap`` reuses that same summariser purely to *inform* the user: it
|
||||
renders a short "where were we" block from a transcript **without mutating the
|
||||
conversation or triggering compaction**. This is the shared core primitive
|
||||
behind the ``/recap`` bot command and ``praisonai session show --recap``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
RECAP_HEADER = "Recap — where we were:"
|
||||
|
||||
# A recap is a *glanceable* re-entry aid, not a full transcript. Bound the
|
||||
# rendered block so a large persisted/compaction summary can never produce an
|
||||
# output that overflows a delivery channel (e.g. Telegram's 4096-char message
|
||||
# limit) or scrolls a terminal. Kept comfortably under that ceiling so channel
|
||||
# framing/markdown never tips it over.
|
||||
RECAP_MAX_CHARS = 3500
|
||||
|
||||
|
||||
def _persisted_summary(history: List[Dict[str, Any]]) -> str:
|
||||
"""Return an already-distilled summary embedded in the transcript, if any.
|
||||
|
||||
Prefers the most recent compaction checkpoint (a system message tagged
|
||||
``_compacted``) or the naive ``[Previous conversation summary]`` line, so a
|
||||
recap can start cheaply from what the model already carries (Issue #3062).
|
||||
"""
|
||||
found = ""
|
||||
for msg in history:
|
||||
content = msg.get("content")
|
||||
if msg.get("_compacted") and isinstance(content, str):
|
||||
found = content
|
||||
elif (
|
||||
msg.get("role") == "system"
|
||||
and isinstance(content, str)
|
||||
and content.startswith("[Previous conversation summary]")
|
||||
):
|
||||
found = content
|
||||
return found
|
||||
|
||||
|
||||
def _naive_summary(history: List[Dict[str, Any]]) -> str:
|
||||
"""Distil older turns with the compactor's offline summarise pass.
|
||||
|
||||
Operates on a shallow copy so the caller's transcript is never modified and
|
||||
no compaction event is emitted. Returns ``""`` on any failure so recap can
|
||||
never crash a chat.
|
||||
"""
|
||||
try:
|
||||
from .compactor import ContextCompactor
|
||||
from .strategy import CompactionStrategy
|
||||
|
||||
compactor = ContextCompactor(strategy=CompactionStrategy.SUMMARIZE)
|
||||
summarised = compactor._summarize([dict(m) for m in history])
|
||||
for msg in summarised:
|
||||
content = msg.get("content")
|
||||
if (
|
||||
msg.get("role") == "system"
|
||||
and isinstance(content, str)
|
||||
and content.startswith("[Previous conversation summary]")
|
||||
):
|
||||
return content
|
||||
except Exception: # noqa: BLE001 - recap must never raise
|
||||
return ""
|
||||
return ""
|
||||
|
||||
|
||||
def build_recap(
|
||||
history: List[Dict[str, Any]],
|
||||
tail: int = 4,
|
||||
max_chars: int = RECAP_MAX_CHARS,
|
||||
) -> str:
|
||||
"""Render a read-only "where were we" summary of a transcript.
|
||||
|
||||
Reuses the existing summariser (a persisted compaction summary when one is
|
||||
embedded, otherwise the naive summarise pass) and always tops up with the
|
||||
most recent ``tail`` messages so the recap reflects the latest activity.
|
||||
The input ``history`` is never modified and no compaction is triggered.
|
||||
|
||||
The rendered block is bounded to ``max_chars`` so a large persisted summary
|
||||
can never overflow a delivery channel (e.g. Telegram's 4096-char message
|
||||
limit) or flood a terminal; the recent tail is always preserved and the
|
||||
(older) summary is what gets trimmed, keeping the most relevant context.
|
||||
|
||||
Args:
|
||||
history: The conversation messages (not modified).
|
||||
tail: How many recent messages to append verbatim.
|
||||
max_chars: Upper bound on the rendered recap length. ``0`` or negative
|
||||
disables the cap.
|
||||
|
||||
Returns:
|
||||
A short, human-readable recap block, or a "nothing yet" note.
|
||||
"""
|
||||
if not history:
|
||||
return "Nothing to recap yet — this session has no messages."
|
||||
|
||||
summary_text = _persisted_summary(history) or _naive_summary(history)
|
||||
|
||||
tail_lines: List[str] = []
|
||||
for msg in history[-tail:] if tail > 0 else []:
|
||||
role = msg.get("role", "?")
|
||||
content = msg.get("content", "")
|
||||
if isinstance(content, str) and content.strip():
|
||||
snippet = content.strip().replace("\n", " ")
|
||||
if len(snippet) > 160:
|
||||
snippet = snippet[:160] + "…"
|
||||
tail_lines.append(f"• {role}: {snippet}")
|
||||
|
||||
header = f"📌 {RECAP_HEADER}"
|
||||
|
||||
if max_chars and max_chars > 0 and summary_text:
|
||||
# Reserve room for the header, the recent tail, and joining newlines so
|
||||
# the *most recent* activity is never dropped in favour of the older
|
||||
# summary. Only the summary is trimmed to fit the budget.
|
||||
tail_block = ""
|
||||
if tail_lines:
|
||||
tail_block = "\n".join(["Recent:", *tail_lines])
|
||||
# +2 accounts for the newlines that will join header/summary/tail.
|
||||
reserved = len(header) + len(tail_block) + 2
|
||||
budget = max_chars - reserved
|
||||
if budget <= 0:
|
||||
summary_text = ""
|
||||
elif len(summary_text) > budget:
|
||||
summary_text = summary_text[: max(0, budget - 1)].rstrip() + "…"
|
||||
|
||||
parts: List[str] = [header]
|
||||
if summary_text:
|
||||
parts.append(summary_text)
|
||||
if tail_lines:
|
||||
parts.append("Recent:")
|
||||
parts.extend(tail_lines)
|
||||
return "\n".join(parts)
|
||||
@@ -778,6 +778,9 @@ class ExecutionConfig:
|
||||
max_steps: Optional[int] = None
|
||||
|
||||
# Rate limiting
|
||||
# When set (positive int) and ``rate_limiter`` is omitted, the Agent
|
||||
# auto-creates ``RateLimiter(requests_per_minute=max_rpm)``. Provide
|
||||
# ``rate_limiter`` explicitly for burst/TPM control (it takes precedence).
|
||||
max_rpm: Optional[int] = None
|
||||
|
||||
# Time limits
|
||||
|
||||
@@ -41,6 +41,7 @@ VALID_PLUGINS_KEYS = {
|
||||
"enabled": (bool, list, str), # bool, list of plugin names, or "true"/"false"
|
||||
"auto_discover": (bool,),
|
||||
"directories": (list,),
|
||||
"allow_project_plugins": (bool,), # opt-in trust gate for ./.praisonai/plugins/*.py
|
||||
}
|
||||
|
||||
VALID_DEFAULTS_KEYS = {
|
||||
@@ -110,6 +111,10 @@ class PluginsConfig:
|
||||
"""Configuration for the plugin system."""
|
||||
enabled: Union[bool, List[str]] = False # True, False, or list of plugin names
|
||||
auto_discover: bool = True
|
||||
# Opt-in trust gate for executing project-local single-file plugins
|
||||
# (./.praisonai/plugins/*.py). Default False so a cloned repo cannot run
|
||||
# arbitrary plugin code until the user explicitly authorises it.
|
||||
allow_project_plugins: bool = False
|
||||
directories: List[str] = field(default_factory=lambda: [
|
||||
str(_default_project_plugins_dir()),
|
||||
str(_default_global_plugins_dir())
|
||||
@@ -124,6 +129,7 @@ class PluginsConfig:
|
||||
result = {
|
||||
"enabled": self.enabled,
|
||||
"auto_discover": self.auto_discover,
|
||||
"allow_project_plugins": self.allow_project_plugins,
|
||||
"directories": self.directories,
|
||||
}
|
||||
# Flatten per-plugin option maps back to top level for round-tripping.
|
||||
@@ -315,7 +321,7 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
|
||||
system; any other key whose value is a mapping is treated as a per-plugin
|
||||
option map delivered to that plugin's ``on_config`` hook.
|
||||
"""
|
||||
reserved = {"enabled", "auto_discover", "directories"}
|
||||
reserved = {"enabled", "auto_discover", "directories", "allow_project_plugins"}
|
||||
options = {
|
||||
name: value
|
||||
for name, value in data.items()
|
||||
@@ -324,6 +330,7 @@ def _dict_to_plugins_config(data: Dict[str, Any]) -> PluginsConfig:
|
||||
return PluginsConfig(
|
||||
enabled=data.get("enabled", False),
|
||||
auto_discover=data.get("auto_discover", True),
|
||||
allow_project_plugins=data.get("allow_project_plugins", False),
|
||||
directories=data.get("directories", [
|
||||
str(_default_project_plugins_dir()),
|
||||
str(_default_global_plugins_dir())
|
||||
@@ -583,6 +590,113 @@ def get_plugin_options() -> Dict[str, Dict[str, Any]]:
|
||||
return dict(get_plugins_config().options)
|
||||
|
||||
|
||||
def _config_write_target() -> Path:
|
||||
"""Resolve the config file the CLI should write plugin enable/disable to.
|
||||
|
||||
Returns the existing config file if one is already present (so the CLI
|
||||
mutates the *same* file the runtime reads), otherwise the project-local
|
||||
``.praisonai/config.yaml`` — the unified surface — as the default target.
|
||||
This closes the config split-brain where the CLI wrote a JSON file the
|
||||
runtime never read.
|
||||
"""
|
||||
existing = _find_config_file()
|
||||
if existing is not None:
|
||||
return existing
|
||||
return get_project_data_dir() / "config.yaml"
|
||||
|
||||
|
||||
def _dump_config_file(path: Path, data: Dict[str, Any]) -> None:
|
||||
"""Write ``data`` to ``path`` as YAML or TOML based on its suffix."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.suffix.lower() in (".yaml", ".yml"):
|
||||
import yaml
|
||||
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False, default_flow_style=False)
|
||||
else:
|
||||
try:
|
||||
import tomli_w
|
||||
except ImportError:
|
||||
# Do NOT write a YAML sidecar: the runtime keeps reading the
|
||||
# original .toml (higher precedence), so the write would be
|
||||
# silently lost and re-introduce the config split-brain. Fail
|
||||
# fast with a remediation hint instead.
|
||||
raise RuntimeError(
|
||||
f"Cannot write TOML config {path}: no TOML writer available. "
|
||||
"Install tomli-w (pip install tomli-w), or convert the config "
|
||||
"to .praisonai/config.yaml."
|
||||
) from None
|
||||
with open(path, "wb") as f:
|
||||
tomli_w.dump(data, f)
|
||||
|
||||
|
||||
def set_plugin_enabled(name: str, enabled: bool) -> Path:
|
||||
"""Persist a plugin's enabled state to the config the runtime reads.
|
||||
|
||||
Mutates the ``plugins.enabled`` allow-list in the project's config file
|
||||
(the same file ``get_enabled_plugins`` reads), creating
|
||||
``.praisonai/config.yaml`` if no config exists yet. This is the single
|
||||
source of truth the CLI and runtime share — no separate JSON file.
|
||||
|
||||
Args:
|
||||
name: Plugin name to enable/disable.
|
||||
enabled: True to add to the allow-list, False to remove it.
|
||||
|
||||
Returns:
|
||||
The path of the config file that was written.
|
||||
"""
|
||||
target = _config_write_target()
|
||||
raw = _load_config() if _find_config_file() is not None else {}
|
||||
if not isinstance(raw, dict):
|
||||
raw = {}
|
||||
|
||||
plugins = raw.get("plugins")
|
||||
if not isinstance(plugins, dict):
|
||||
plugins = {}
|
||||
raw["plugins"] = plugins
|
||||
|
||||
has_enabled_key = "enabled" in plugins
|
||||
current = plugins.get("enabled")
|
||||
# Normalise the enabled flag into a list of names we can mutate.
|
||||
if isinstance(current, list):
|
||||
names = [str(n) for n in current]
|
||||
elif isinstance(current, str) and current.strip():
|
||||
names = [current.strip()]
|
||||
elif current is True:
|
||||
# Explicit "all plugins enabled" mode. Collapsing this to an allow-list
|
||||
# would silently disable every other plugin, so we only touch this
|
||||
# state when the requested change is well-defined:
|
||||
# - enable: already enabled, no-op (leave "all enabled" intact)
|
||||
# - disable: cannot be expressed as an allow-list without naming the
|
||||
# full set, so refuse rather than disable everything.
|
||||
if enabled:
|
||||
clear_config_cache()
|
||||
return target
|
||||
raise ValueError(
|
||||
f"Cannot disable '{name}': plugins.enabled is currently set to "
|
||||
"'all' (true). Set plugins.enabled to an explicit list of plugin "
|
||||
"names first, then disable individual plugins."
|
||||
)
|
||||
elif current is None and not has_enabled_key:
|
||||
# No allow-list configured yet (fresh config): start an empty list so
|
||||
# the first enable produces [name] and the first disable is a no-op.
|
||||
names = []
|
||||
else:
|
||||
# Falsy explicit value (e.g. enabled: false) — start from empty.
|
||||
names = []
|
||||
|
||||
if enabled:
|
||||
if name not in names:
|
||||
names.append(name)
|
||||
else:
|
||||
names = [n for n in names if n != name]
|
||||
|
||||
plugins["enabled"] = names
|
||||
_dump_config_file(target, raw)
|
||||
clear_config_cache()
|
||||
return target
|
||||
|
||||
|
||||
def _defaults_has_any_values() -> bool:
|
||||
"""Return True if config file defines any defaults (cached after first check)."""
|
||||
global _defaults_has_values
|
||||
|
||||
@@ -60,16 +60,54 @@ OUTPUT_RESERVES: Dict[str, int] = {
|
||||
}
|
||||
|
||||
|
||||
def _litellm_model_info(model: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Look up a model's info from litellm's ``model_cost`` registry.
|
||||
|
||||
Lazy-imported so there is zero overhead (and no hard dependency) when
|
||||
litellm is not installed. Returns ``None`` when litellm is absent or the
|
||||
model is unknown.
|
||||
"""
|
||||
try:
|
||||
from litellm import model_cost
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
try:
|
||||
model_lower = model.lower()
|
||||
if model_lower in model_cost:
|
||||
return model_cost[model_lower]
|
||||
# Try with provider prefix removed (e.g. "openai/gpt-4o" -> "gpt-4o")
|
||||
if "/" in model:
|
||||
base_model = model.split("/")[-1].lower()
|
||||
if base_model in model_cost:
|
||||
return model_cost[base_model]
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def get_model_limit(model: str) -> int:
|
||||
"""
|
||||
Get context window limit for a model.
|
||||
|
||||
|
||||
Resolves against litellm's ``model_cost`` registry first (``max_input_tokens``),
|
||||
so any model the underlying LLM layer knows gets the correct window — then
|
||||
falls back to the static ``MODEL_LIMITS`` table for offline use.
|
||||
|
||||
Args:
|
||||
model: Model name or identifier
|
||||
|
||||
|
||||
Returns:
|
||||
Context window size in tokens
|
||||
"""
|
||||
# Prefer data-driven lookup via litellm (context window = max_input_tokens)
|
||||
info = _litellm_model_info(model)
|
||||
if info:
|
||||
limit = info.get("max_input_tokens") or info.get("max_tokens")
|
||||
if limit:
|
||||
return limit
|
||||
|
||||
# Exact match
|
||||
if model in MODEL_LIMITS:
|
||||
return MODEL_LIMITS[model]
|
||||
@@ -86,13 +124,24 @@ def get_model_limit(model: str) -> int:
|
||||
def get_output_reserve(model: str) -> int:
|
||||
"""
|
||||
Get recommended output token reserve for a model.
|
||||
|
||||
|
||||
Resolves against litellm's ``model_cost`` registry first
|
||||
(``max_output_tokens``), then falls back to the static ``OUTPUT_RESERVES``
|
||||
table for offline use.
|
||||
|
||||
Args:
|
||||
model: Model name
|
||||
|
||||
|
||||
Returns:
|
||||
Output reserve in tokens
|
||||
"""
|
||||
# Prefer data-driven lookup via litellm (output reserve = max_output_tokens)
|
||||
info = _litellm_model_info(model)
|
||||
if info:
|
||||
reserve = info.get("max_output_tokens")
|
||||
if reserve:
|
||||
return reserve
|
||||
|
||||
model_lower = model.lower()
|
||||
|
||||
for key, reserve in OUTPUT_RESERVES.items():
|
||||
|
||||
@@ -39,10 +39,21 @@ from .protocols import (
|
||||
# Home channel and delivery protocols
|
||||
HomeChannelRegistryProtocol,
|
||||
DeliveryResolverProtocol,
|
||||
# Creation-time delivery-target pre-flight (Issue #3800)
|
||||
DeliveryPreflightProtocol,
|
||||
DeliveryValidation,
|
||||
ScheduleTargetError,
|
||||
# Agent-facing outbound messaging
|
||||
OutboundMessengerProtocol,
|
||||
DeliveryResult,
|
||||
TargetInfo,
|
||||
# Agent-callable cross-conversation request/reply (Issue #3689)
|
||||
ConversationReply,
|
||||
ConversationReplyStatus,
|
||||
ConversationRequestProtocol,
|
||||
# Agent-facing live status/health (Issue #3688)
|
||||
GatewayStatusProtocol,
|
||||
GatewayStatus,
|
||||
# Inbound route binding (Issue #2225)
|
||||
RouteBinding,
|
||||
RouteFacts,
|
||||
@@ -74,11 +85,20 @@ from .protocols import (
|
||||
ResourceSample,
|
||||
ResourcePressurePolicyProtocol,
|
||||
MemoryPressurePolicy,
|
||||
# Gateway memory-pressure cache eviction (Issue #3804)
|
||||
WarmSession,
|
||||
MemoryPressureProtocol,
|
||||
plan_pressure_evictions,
|
||||
# Gateway rate-limit admission (Issue #2532)
|
||||
RateLimitDecision,
|
||||
RateLimitPolicyProtocol,
|
||||
RateLimitPolicy, # backward-compat alias
|
||||
SlidingWindowRateLimitPolicy,
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
PERMANENT_ERROR_CLASSES,
|
||||
DeadLetterDecision,
|
||||
DeadLetterPolicyProtocol,
|
||||
AttemptAndAgeDeadLetterPolicy,
|
||||
# Port-less, restart-safe external drain trigger (Issue #2390)
|
||||
current_epoch,
|
||||
DrainMarkerPolicy,
|
||||
@@ -96,6 +116,8 @@ from .protocols import (
|
||||
FatalConfigError,
|
||||
classify_exit_reason,
|
||||
RestartLoopGuard,
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
FleetSupervisionPolicy,
|
||||
# Protocol version negotiation
|
||||
PROTOCOL_VERSION,
|
||||
MIN_PROTOCOL_VERSION,
|
||||
@@ -122,6 +144,10 @@ from .protocols import (
|
||||
LivenessDecision,
|
||||
LivenessPolicyProtocol,
|
||||
LivenessPolicy,
|
||||
# Cluster-wide per-turn serialisation (Issue #3643)
|
||||
TurnLeaseToken,
|
||||
TurnLockProtocol,
|
||||
LocalTurnLock,
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
HelloParams,
|
||||
HelloResult,
|
||||
@@ -146,6 +172,18 @@ from .liveness import (
|
||||
LoopWatchdogPolicy,
|
||||
LoopWatchdog,
|
||||
)
|
||||
from .degraded_state import (
|
||||
# Unified degraded-capability registry (Issue #3518)
|
||||
DegradedOwner,
|
||||
DegradedCapabilityProtocol,
|
||||
DegradedCapabilityRegistry,
|
||||
OWNER_KINDS,
|
||||
DEGRADED_STATES,
|
||||
# Fail-closed read of the degraded-owner contract (Issue #3640)
|
||||
DegradedCapabilityLookupProtocol,
|
||||
OwnerUnavailable,
|
||||
assert_owner_available,
|
||||
)
|
||||
from .hooks import (
|
||||
HookAction,
|
||||
HookConfig,
|
||||
@@ -160,6 +198,13 @@ from .config import (
|
||||
ApiConfig,
|
||||
ChannelRouteConfig,
|
||||
MultiChannelGatewayConfig,
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
GATEWAY_CONFIG_VERSION,
|
||||
ConfigVersionError,
|
||||
LegacyConfigRule,
|
||||
GATEWAY_CONFIG_RULES,
|
||||
is_config_current,
|
||||
migrate_config_with_doctor,
|
||||
# Push config
|
||||
PushConfig,
|
||||
RedisConfig,
|
||||
@@ -167,10 +212,14 @@ from .config import (
|
||||
DeliveryConfig,
|
||||
PollingConfig,
|
||||
LivenessConfig,
|
||||
TurnLockConfig,
|
||||
# Hot-reload registry (Issue #3378)
|
||||
HOT_APPLIABLE_KEYS,
|
||||
SupportsHotReload,
|
||||
is_hot_appliable,
|
||||
# Reload scope classification (Issue #3440)
|
||||
ReloadScope,
|
||||
classify_reload,
|
||||
)
|
||||
|
||||
# Lazy loading cache
|
||||
@@ -251,10 +300,20 @@ __all__ = [
|
||||
# Home channel and delivery protocols
|
||||
"HomeChannelRegistryProtocol",
|
||||
"DeliveryResolverProtocol",
|
||||
"DeliveryPreflightProtocol",
|
||||
"DeliveryValidation",
|
||||
"ScheduleTargetError",
|
||||
# Agent-facing outbound messaging
|
||||
"OutboundMessengerProtocol",
|
||||
"DeliveryResult",
|
||||
"TargetInfo",
|
||||
# Agent-callable cross-conversation request/reply (Issue #3689)
|
||||
"ConversationReply",
|
||||
"ConversationReplyStatus",
|
||||
"ConversationRequestProtocol",
|
||||
# Agent-facing live status/health (Issue #3688)
|
||||
"GatewayStatusProtocol",
|
||||
"GatewayStatus",
|
||||
# Inbound route binding (Issue #2225)
|
||||
"RouteBinding",
|
||||
"RouteFacts",
|
||||
@@ -285,11 +344,20 @@ __all__ = [
|
||||
"ResourceSample",
|
||||
"ResourcePressurePolicyProtocol",
|
||||
"MemoryPressurePolicy",
|
||||
# Gateway memory-pressure cache eviction (Issue #3804)
|
||||
"WarmSession",
|
||||
"MemoryPressureProtocol",
|
||||
"plan_pressure_evictions",
|
||||
# Gateway rate-limit admission (Issue #2532)
|
||||
"RateLimitDecision",
|
||||
"RateLimitPolicyProtocol",
|
||||
"RateLimitPolicy",
|
||||
"SlidingWindowRateLimitPolicy",
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
"PERMANENT_ERROR_CLASSES",
|
||||
"DeadLetterDecision",
|
||||
"DeadLetterPolicyProtocol",
|
||||
"AttemptAndAgeDeadLetterPolicy",
|
||||
# Port-less, restart-safe external drain trigger (Issue #2390)
|
||||
"current_epoch",
|
||||
"DrainMarkerPolicy",
|
||||
@@ -307,6 +375,8 @@ __all__ = [
|
||||
"FatalConfigError",
|
||||
"classify_exit_reason",
|
||||
"RestartLoopGuard",
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
"FleetSupervisionPolicy",
|
||||
# Protocol version negotiation
|
||||
"PROTOCOL_VERSION",
|
||||
"MIN_PROTOCOL_VERSION",
|
||||
@@ -333,6 +403,10 @@ __all__ = [
|
||||
"LivenessDecision",
|
||||
"LivenessPolicyProtocol",
|
||||
"LivenessPolicy",
|
||||
# Cluster-wide per-turn serialisation (Issue #3643)
|
||||
"TurnLeaseToken",
|
||||
"TurnLockProtocol",
|
||||
"LocalTurnLock",
|
||||
# Schema-validated inbound frame codec (Issue #2831)
|
||||
"HelloParams",
|
||||
"HelloResult",
|
||||
@@ -354,6 +428,16 @@ __all__ = [
|
||||
# Event-loop liveness watchdog (Issue #3385)
|
||||
"LoopWatchdogPolicy",
|
||||
"LoopWatchdog",
|
||||
# Unified degraded-capability registry (Issue #3518)
|
||||
"DegradedOwner",
|
||||
"DegradedCapabilityProtocol",
|
||||
"DegradedCapabilityRegistry",
|
||||
"OWNER_KINDS",
|
||||
"DEGRADED_STATES",
|
||||
# Fail-closed read of the degraded-owner contract (Issue #3640)
|
||||
"DegradedCapabilityLookupProtocol",
|
||||
"OwnerUnavailable",
|
||||
"assert_owner_available",
|
||||
# Inbound trigger / webhook contract (Issue #2281)
|
||||
"HookAction",
|
||||
"HookConfig",
|
||||
@@ -367,16 +451,26 @@ __all__ = [
|
||||
"ApiConfig",
|
||||
"ChannelRouteConfig",
|
||||
"MultiChannelGatewayConfig",
|
||||
# Config version stamp + doctor-driven migration (Issue #3841)
|
||||
"GATEWAY_CONFIG_VERSION",
|
||||
"ConfigVersionError",
|
||||
"LegacyConfigRule",
|
||||
"GATEWAY_CONFIG_RULES",
|
||||
"is_config_current",
|
||||
"migrate_config_with_doctor",
|
||||
"PushConfig",
|
||||
"RedisConfig",
|
||||
"PresenceConfig",
|
||||
"DeliveryConfig",
|
||||
"PollingConfig",
|
||||
"LivenessConfig",
|
||||
"TurnLockConfig",
|
||||
# Hot-reload registry (Issue #3378)
|
||||
"HOT_APPLIABLE_KEYS",
|
||||
"SupportsHotReload",
|
||||
"is_hot_appliable",
|
||||
"ReloadScope",
|
||||
"classify_reload",
|
||||
# Implementations (lazy loaded from praisonai wrapper)
|
||||
"WebSocketGateway",
|
||||
"GatewaySession",
|
||||
|
||||
@@ -5,7 +5,187 @@ Provides configuration dataclasses for gateway and session settings.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Mapping, Optional, Protocol, Set, runtime_checkable
|
||||
from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
List,
|
||||
Mapping,
|
||||
Optional,
|
||||
Protocol,
|
||||
Set,
|
||||
Tuple,
|
||||
runtime_checkable,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canonical config version + doctor-driven migration (Issue #3841)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The gateway config carries a ``config_version`` stamp so the runtime, the
|
||||
# operator, and ``gateway doctor --fix`` can all tell whether a config predates
|
||||
# a breaking change. Migration is expressed as an ordered list of *declarative
|
||||
# rules* (a detect predicate + a fix mutation as one unit) that ``doctor --fix``
|
||||
# applies once to move an out-of-date config forward, then stamps the current
|
||||
# version. This keeps migration a one-time repair rather than a permanent
|
||||
# load-time heuristic, and gives the canonical config shape a single owner.
|
||||
GATEWAY_CONFIG_VERSION = 1
|
||||
|
||||
|
||||
class ConfigVersionError(ValueError):
|
||||
"""Raised when a config carries a ``config_version`` this build can't handle.
|
||||
|
||||
Two cases, both operator-actionable rather than silently corrupting data:
|
||||
* the stamp is a version *newer* than :data:`GATEWAY_CONFIG_VERSION` — an
|
||||
older binary must not downgrade / migrate a config written by a newer
|
||||
one (that would drop keys the newer schema added), so migration refuses;
|
||||
* the stamp is present but not a non-boolean integer — a malformed stamp
|
||||
(``true``, ``"1"``, ``1.0``) is a mistake, not "current", so it is
|
||||
rejected instead of being treated as version 1 via ``True == 1``.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LegacyConfigRule:
|
||||
"""A single declarative config-migration rule.
|
||||
|
||||
``detect`` returns True when the (old) shape this rule fixes is present in
|
||||
the raw config mapping; ``fix`` returns the mutated mapping moving it toward
|
||||
the current version; ``reason`` is an operator-facing description rendered by
|
||||
``gateway doctor --fix``. Rules are pure functions of the raw mapping so the
|
||||
same set can be reasoned about, tested, and applied identically everywhere.
|
||||
"""
|
||||
|
||||
detect: Callable[[Dict[str, Any]], bool]
|
||||
fix: Callable[[Dict[str, Any]], Dict[str, Any]]
|
||||
reason: str
|
||||
|
||||
|
||||
def _detect_allowed_users_csv(raw: Dict[str, Any]) -> bool:
|
||||
channels = raw.get("channels")
|
||||
if not isinstance(channels, dict):
|
||||
return False
|
||||
return any(
|
||||
isinstance(ch, dict) and isinstance(ch.get("allowed_users"), str)
|
||||
for ch in channels.values()
|
||||
)
|
||||
|
||||
|
||||
def _fix_allowed_users_csv(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for ch in raw.get("channels", {}).values():
|
||||
if isinstance(ch, dict) and isinstance(ch.get("allowed_users"), str):
|
||||
value = ch["allowed_users"]
|
||||
ch["allowed_users"] = (
|
||||
[u.strip() for u in value.split(",") if u.strip()] if value else []
|
||||
)
|
||||
return raw
|
||||
|
||||
|
||||
def _detect_missing_group_policy(raw: Dict[str, Any]) -> bool:
|
||||
channels = raw.get("channels")
|
||||
if not isinstance(channels, dict):
|
||||
return False
|
||||
return any(
|
||||
isinstance(ch, dict) and "group_policy" not in ch
|
||||
for ch in channels.values()
|
||||
)
|
||||
|
||||
|
||||
def _fix_missing_group_policy(raw: Dict[str, Any]) -> Dict[str, Any]:
|
||||
for ch in raw.get("channels", {}).values():
|
||||
if isinstance(ch, dict) and "group_policy" not in ch:
|
||||
ch["group_policy"] = "mention_only"
|
||||
return raw
|
||||
|
||||
|
||||
# Ordered set of migration rules. Each moves an older config shape toward the
|
||||
# current canonical form; ``migrate_config_with_doctor`` applies them once and
|
||||
# stamps ``config_version``. New breaking renames/retirements append a rule here
|
||||
# and bump ``GATEWAY_CONFIG_VERSION`` — the canonical shape has one owner.
|
||||
GATEWAY_CONFIG_RULES: "List[LegacyConfigRule]" = [
|
||||
LegacyConfigRule(
|
||||
detect=_detect_allowed_users_csv,
|
||||
fix=_fix_allowed_users_csv,
|
||||
reason="migrating allowed_users (string) -> list [rule: allowed_users_csv_to_list]",
|
||||
),
|
||||
LegacyConfigRule(
|
||||
detect=_detect_missing_group_policy,
|
||||
fix=_fix_missing_group_policy,
|
||||
reason="setting group_policy secure default 'mention_only' [rule: group_policy_default]",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _parse_config_version(raw: Mapping[str, Any]) -> Optional[int]:
|
||||
"""Return the config's ``config_version`` as an int, or None if unstamped.
|
||||
|
||||
Rejects a malformed stamp so ``config_version: true`` cannot masquerade as
|
||||
version 1 (``True == 1``) and a string/float stamp cannot slip through.
|
||||
"""
|
||||
if "config_version" not in raw:
|
||||
return None
|
||||
value = raw["config_version"]
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise ConfigVersionError(
|
||||
f"Invalid gateway config_version {value!r}: expected an integer "
|
||||
f"(current is {GATEWAY_CONFIG_VERSION}). Fix or remove the stamp."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def is_config_current(raw: Mapping[str, Any]) -> bool:
|
||||
"""Return whether ``raw`` already carries the current ``config_version``.
|
||||
|
||||
A malformed stamp (non-integer / boolean) is not "current" — it raises
|
||||
:class:`ConfigVersionError` so operators fix it rather than having it
|
||||
silently coerced (``True == 1``).
|
||||
"""
|
||||
return _parse_config_version(raw) == GATEWAY_CONFIG_VERSION
|
||||
|
||||
|
||||
def migrate_config_with_doctor(
|
||||
raw: Dict[str, Any],
|
||||
) -> "Tuple[Dict[str, Any], List[str]]":
|
||||
"""Apply the declarative migration rules once and stamp ``config_version``.
|
||||
|
||||
Returns ``(migrated, applied_reasons)``. The input is copied shallowly (and
|
||||
per-channel dicts copied) so the caller's mapping is not mutated in place.
|
||||
Only rules whose ``detect`` fires contribute a reason, so a config already
|
||||
at the current shape migrates cleanly with an empty reason list while still
|
||||
receiving the version stamp. This is the single migration executor behind
|
||||
``gateway doctor --fix``.
|
||||
|
||||
Raises :class:`ConfigVersionError` when the config was written by a *newer*
|
||||
build (its stamp exceeds :data:`GATEWAY_CONFIG_VERSION`) or the stamp is
|
||||
malformed — an older binary must never downgrade a newer config or drop
|
||||
keys it does not understand.
|
||||
"""
|
||||
source_version = _parse_config_version(raw)
|
||||
if source_version is not None and source_version > GATEWAY_CONFIG_VERSION:
|
||||
raise ConfigVersionError(
|
||||
f"gateway config_version {source_version} is newer than this "
|
||||
f"build supports ({GATEWAY_CONFIG_VERSION}). Upgrade praisonai / "
|
||||
"praisonai-bot to a version that understands this config instead "
|
||||
"of migrating it with an older one."
|
||||
)
|
||||
|
||||
migrated: Dict[str, Any] = dict(raw)
|
||||
channels = migrated.get("channels")
|
||||
if isinstance(channels, dict):
|
||||
migrated["channels"] = {
|
||||
name: (dict(ch) if isinstance(ch, dict) else ch)
|
||||
for name, ch in channels.items()
|
||||
}
|
||||
|
||||
applied: List[str] = []
|
||||
for rule in GATEWAY_CONFIG_RULES:
|
||||
if rule.detect(migrated):
|
||||
migrated = rule.fix(migrated)
|
||||
applied.append(rule.reason)
|
||||
|
||||
migrated["config_version"] = GATEWAY_CONFIG_VERSION
|
||||
return migrated, applied
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -39,6 +219,65 @@ def is_hot_appliable(path: str) -> bool:
|
||||
return any(path.startswith(key + ".") for key in HOT_APPLIABLE_KEYS)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reload scope classification (Issue #3440)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The wrapper/bot gateway builds a concrete reload plan (which channels to
|
||||
# bounce, whether to recreate agents, whether to full-restart). The *rules*
|
||||
# for that plan — hot-appliable vs channel-scoped vs full — must stay
|
||||
# canonical in core so every runtime reloads identically, rather than being
|
||||
# duplicated ad-hoc per runtime. This is a pure string classification with no
|
||||
# heavy imports; the wrapper consumes it and only implements the effects.
|
||||
class ReloadScope:
|
||||
"""Canonical classification of a changed config ``path``'s reload scope.
|
||||
|
||||
Values are plain strings so wrapper/runtime code can compare without
|
||||
importing this class. ``FULL`` is the fail-safe default for unknown or
|
||||
structural changes.
|
||||
|
||||
- ``HOT``: apply in place, no restart (see :func:`is_hot_appliable`).
|
||||
- ``CHANNEL``: a change under ``channels.<name>`` — restart only that one
|
||||
channel; other channels keep their connections and in-flight turns.
|
||||
- ``AGENTS``: an agent/provider/guardrails change — recreate agents only,
|
||||
without bouncing channels.
|
||||
- ``FULL``: unknown or structural change — full restart (fail-safe).
|
||||
"""
|
||||
|
||||
HOT = "hot"
|
||||
CHANNEL = "channel"
|
||||
AGENTS = "agents"
|
||||
FULL = "full"
|
||||
|
||||
|
||||
def classify_reload(path: str) -> str:
|
||||
"""Classify a changed dotted config ``path`` into a :class:`ReloadScope`.
|
||||
|
||||
Canonical, side-effect-free classification shared by every runtime so a
|
||||
hot-reload plan is built identically regardless of who loads the config.
|
||||
Anything not explicitly recognised falls through to ``ReloadScope.FULL``,
|
||||
keeping full restart the fail-safe default for structural changes.
|
||||
"""
|
||||
if is_hot_appliable(path):
|
||||
return ReloadScope.HOT
|
||||
|
||||
parts = path.split(".")
|
||||
head = parts[0] if parts else ""
|
||||
|
||||
# A change scoped to a single channel (``channels.<name>...``) only needs
|
||||
# that channel restarted. The bare ``channels`` section (no name) — and a
|
||||
# malformed empty name like ``channels.`` — is a structural change and
|
||||
# stays a full restart (fail-safe).
|
||||
if head == "channels" and len(parts) >= 2 and parts[1]:
|
||||
return ReloadScope.CHANNEL
|
||||
|
||||
# Agent-affecting changes recreate agents without bouncing channels.
|
||||
if head in ("agents", "provider", "guardrails"):
|
||||
return ReloadScope.AGENTS
|
||||
|
||||
return ReloadScope.FULL
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsHotReload(Protocol):
|
||||
"""Protocol a gateway implements to apply hot-reloadable config in place.
|
||||
@@ -61,7 +300,11 @@ class SessionConfig:
|
||||
Attributes:
|
||||
timeout: Session timeout in seconds (0 = no timeout)
|
||||
max_messages: Maximum messages to keep in history (0 = unlimited)
|
||||
persist: Whether to persist session state
|
||||
persist: Whether to persist session state. Defaults to True so a
|
||||
gateway started from the out-of-box path remembers conversations
|
||||
across restarts/redeploys via the SQLite transcript store. Set
|
||||
``persist: false`` in the config to opt into ephemeral, in-memory
|
||||
sessions.
|
||||
persist_path: Path for session persistence
|
||||
store: Persistence backend when ``persist`` is set — ``"sqlite"``
|
||||
(default: transcripts in a WAL SQLite DB with concurrent readers
|
||||
@@ -74,7 +317,7 @@ class SessionConfig:
|
||||
|
||||
timeout: int = 3600 # 1 hour default
|
||||
max_messages: int = 1000
|
||||
persist: bool = False
|
||||
persist: bool = True # durable by default; set persist=False for ephemeral
|
||||
persist_path: Optional[str] = None
|
||||
store: str = "sqlite" # "sqlite" (concurrent, indexed) | "file" (legacy JSON)
|
||||
resume_window: int = 86400 # 24 hours default
|
||||
@@ -189,7 +432,11 @@ class DeliveryConfig:
|
||||
max_retries: Maximum retry attempts
|
||||
retry_backoff: Exponential backoff multiplier
|
||||
message_ttl: How long to retain unacknowledged messages (seconds)
|
||||
store_backend: Message store backend ("memory" or "redis")
|
||||
store_backend: Message store backend — ``"sqlite"`` (default,
|
||||
zero-dependency durable store so the at-least-once guarantee
|
||||
survives a gateway restart/redeploy), ``"redis"`` (durable +
|
||||
multi-process horizontal fan-out) or ``"memory"`` (explicit,
|
||||
ephemeral opt-out for testing/single-process throwaway use).
|
||||
"""
|
||||
|
||||
enabled: bool = True
|
||||
@@ -197,7 +444,15 @@ class DeliveryConfig:
|
||||
max_retries: int = 3
|
||||
retry_backoff: float = 2.0
|
||||
message_ttl: int = 86400
|
||||
store_backend: str = "memory"
|
||||
store_backend: str = "sqlite"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate configuration values."""
|
||||
if self.store_backend not in ("sqlite", "redis", "memory"):
|
||||
raise ValueError(
|
||||
f"Invalid delivery store_backend {self.store_backend!r}; "
|
||||
"expected 'sqlite', 'redis' or 'memory'"
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -341,6 +596,73 @@ class LivenessConfig:
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnLockConfig:
|
||||
"""Configuration for cluster-wide per-turn serialisation (Issue #3643).
|
||||
|
||||
Selects the backend for the gateway's per-turn lock — the guarantee that
|
||||
only one turn runs against a given resolved session at a time. The default
|
||||
``"local"`` backend reproduces today's in-process ``asyncio.Lock`` /
|
||||
``LockMap`` behaviour exactly (zero cost, no new dependency), so
|
||||
single-replica deployments are byte-for-byte unchanged. Selecting
|
||||
``"redis"`` extends serialisation across every replica so a
|
||||
horizontally-scaled gateway (``replicas > 1``) no longer runs concurrent
|
||||
turns on one session — the concrete distributed lock lives in the
|
||||
wrapper/bot package and reuses the existing ``RedisConfig`` connection and
|
||||
the scheduler's proven owner+TTL lease pattern.
|
||||
|
||||
This maps onto the pure core
|
||||
:class:`~praisonaiagents.gateway.protocols.TurnLockProtocol`.
|
||||
|
||||
Attributes:
|
||||
backend: ``"local"`` (default, in-process ``asyncio.Lock``) or
|
||||
``"redis"`` (distributed lease, cluster-wide serialisation).
|
||||
ttl: Lease time-to-live in seconds for a distributed backend. Bounds
|
||||
how long a crashed holder's lease survives before it is reclaimable,
|
||||
so a dead replica cannot wedge a healthy session (fail-open /
|
||||
self-healing, as the scheduler already does). Inert for ``"local"``.
|
||||
url: Optional Redis URL for the ``"redis"`` backend. When omitted the
|
||||
distributed lock reuses the gateway's configured ``RedisConfig``.
|
||||
"""
|
||||
|
||||
backend: str = "local"
|
||||
ttl: float = 60.0
|
||||
url: Optional[str] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.backend not in ("local", "redis"):
|
||||
raise ValueError(
|
||||
f"Invalid turn_lock backend {self.backend!r}; "
|
||||
"expected 'local' or 'redis'"
|
||||
)
|
||||
if not self.ttl > 0:
|
||||
raise ValueError("turn_lock ttl must be > 0")
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
"""Whether a distributed (cross-replica) turn lock is selected."""
|
||||
return self.backend != "local"
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary (hides sensitive URL)."""
|
||||
return {
|
||||
"backend": self.backend,
|
||||
"ttl": self.ttl,
|
||||
"url": "***" if self.url else None,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "TurnLockConfig":
|
||||
"""Create from a parsed ``gateway.turn_lock`` mapping (tolerant of None)."""
|
||||
if not isinstance(data, dict):
|
||||
return cls()
|
||||
return cls(
|
||||
backend=str(data.get("backend") or "local"),
|
||||
ttl=float(data.get("ttl") or 60.0),
|
||||
url=data.get("url"),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ApiConfig:
|
||||
"""Configuration for additive protocol surfaces on the gateway app.
|
||||
@@ -419,6 +741,12 @@ class GatewayConfig:
|
||||
session_config: SessionConfig = field(default_factory=SessionConfig)
|
||||
heartbeat_interval: int = 30
|
||||
reconnect_timeout: int = 60
|
||||
# Issue #3467: per-turn wall-clock ceiling. When > 0, a single agent turn
|
||||
# that runs longer than this many seconds is cancelled (cooperatively via
|
||||
# the agent's interrupt controller and by cancelling the driving task) so a
|
||||
# runaway turn cannot wedge the serial per-session queue. 0 = no timeout
|
||||
# (today's behaviour: a turn runs to completion).
|
||||
per_turn_timeout: float = 0.0
|
||||
ssl_cert: Optional[str] = None
|
||||
ssl_key: Optional[str] = None
|
||||
max_buffered_bytes: int = 1024 * 1024 # 1MB default
|
||||
@@ -445,6 +773,11 @@ class GatewayConfig:
|
||||
# Issue #2798: application-level connection liveness (ping/pong heartbeat +
|
||||
# half-open reaper). Opt-in; disabled by default so behaviour is unchanged.
|
||||
liveness: LivenessConfig = field(default_factory=LivenessConfig)
|
||||
# Issue #3643: cluster-wide per-turn serialisation. Default "local" backend
|
||||
# keeps today's in-process asyncio.Lock behaviour (single-replica); "redis"
|
||||
# serialises turns across replicas so a horizontally-scaled gateway does not
|
||||
# run concurrent turns on one session.
|
||||
turn_lock: "TurnLockConfig" = field(default_factory=lambda: TurnLockConfig())
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Post-initialization to set bind_host from host if not specified and validate values."""
|
||||
@@ -464,6 +797,10 @@ class GatewayConfig:
|
||||
raise ValueError("heartbeat_interval must be >= 0")
|
||||
if self.reconnect_timeout < 0:
|
||||
raise ValueError("reconnect_timeout must be >= 0")
|
||||
if self.per_turn_timeout < 0:
|
||||
raise ValueError(
|
||||
"per_turn_timeout must be >= 0 (use 0 to disable the per-turn timeout)"
|
||||
)
|
||||
if self.max_concurrent_runs < 0:
|
||||
raise ValueError(
|
||||
"max_concurrent_runs must be >= 0 (use 0 to disable admission control)"
|
||||
@@ -549,6 +886,7 @@ class GatewayConfig:
|
||||
"session_config": self.session_config.to_dict(),
|
||||
"heartbeat_interval": self.heartbeat_interval,
|
||||
"reconnect_timeout": self.reconnect_timeout,
|
||||
"per_turn_timeout": self.per_turn_timeout,
|
||||
"ssl_enabled": bool(self.ssl_cert and self.ssl_key),
|
||||
"max_buffered_bytes": self.max_buffered_bytes,
|
||||
"max_queued_frames": self.max_queued_frames,
|
||||
@@ -561,6 +899,7 @@ class GatewayConfig:
|
||||
"scope_policy_enabled": self.has_scope_policy,
|
||||
"api": self.api.to_dict(),
|
||||
"liveness": self.liveness.to_dict(),
|
||||
"turn_lock": self.turn_lock.to_dict(),
|
||||
}
|
||||
|
||||
@property
|
||||
@@ -692,7 +1031,7 @@ class MultiChannelGatewayConfig:
|
||||
session_config = SessionConfig(
|
||||
timeout=sc_data.get("timeout", 3600),
|
||||
max_messages=sc_data.get("max_messages", 1000),
|
||||
persist=sc_data.get("persist", False),
|
||||
persist=sc_data.get("persist", True),
|
||||
persist_path=sc_data.get("persist_path"),
|
||||
store=sc_data.get("store", "sqlite"),
|
||||
resume_window=sc_data.get("resume_window", 86400),
|
||||
@@ -734,6 +1073,7 @@ class MultiChannelGatewayConfig:
|
||||
session_config=session_config,
|
||||
heartbeat_interval=gw_data.get("heartbeat_interval", 30),
|
||||
reconnect_timeout=gw_data.get("reconnect_timeout", 60),
|
||||
per_turn_timeout=float(gw_data.get("per_turn_timeout", 0.0) or 0.0),
|
||||
ssl_cert=gw_data.get("ssl_cert"),
|
||||
ssl_key=gw_data.get("ssl_key"),
|
||||
max_buffered_bytes=int(gw_data.get("max_buffered_bytes", 1024 * 1024)),
|
||||
@@ -750,6 +1090,7 @@ class MultiChannelGatewayConfig:
|
||||
auth_scopes=auth_scopes,
|
||||
api=ApiConfig.from_dict(gw_data.get("api")),
|
||||
liveness=LivenessConfig.from_dict(gw_data.get("liveness")),
|
||||
turn_lock=TurnLockConfig.from_dict(gw_data.get("turn_lock")),
|
||||
)
|
||||
|
||||
# Parse agents section (pass through as dicts)
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""
|
||||
Unified degraded-capability registry for the gateway (Issue #3518).
|
||||
|
||||
The gateway records degradation in several disconnected places but historically
|
||||
exposed only *degraded channels* through ``health()``. Provider/model credential
|
||||
failures, unresolved ``SecretRef`` capabilities, and route-level failures were
|
||||
classified-but-not-surfaced or not surfaced at all — a partial degraded state
|
||||
where some failures are effectively invisible until a message silently goes
|
||||
nowhere.
|
||||
|
||||
This module provides one small, process-local contract that any owner records
|
||||
into at the boundary that owns it, so ``gateway status`` / ``gateway doctor`` /
|
||||
``health()`` can list *every* degraded owner with a consistent, redacted shape
|
||||
and an actionable next step. It is a generic protocol + default in-process impl
|
||||
with no heavy third-party imports, mirroring the existing gateway
|
||||
policy-protocol + default-impl pattern in ``protocols.py``.
|
||||
|
||||
Owners call :meth:`DegradedCapabilityRegistry.mark` when they enter a degraded
|
||||
state and :meth:`DegradedCapabilityRegistry.clear` on recovery; readers call
|
||||
:meth:`DegradedCapabilityRegistry.list_degraded`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
|
||||
# Closed vocabularies kept as module constants so owners and readers agree on
|
||||
# the shape without importing an enum-heavy dependency.
|
||||
OWNER_KINDS: Tuple[str, ...] = ("channel", "provider", "capability", "route", "gateway")
|
||||
DEGRADED_STATES: Tuple[str, ...] = ("cold", "stale")
|
||||
|
||||
|
||||
class OwnerUnavailable(Exception):
|
||||
"""Typed, redacted outcome raised when work targets a degraded owner.
|
||||
|
||||
Issue #3640: the fail-closed read of the degraded-owner contract. When a
|
||||
request targets an owner already recorded as degraded, the dispatch path
|
||||
calls :meth:`DegradedCapabilityRegistry.assert_owner_available` (or the
|
||||
module-level :func:`assert_owner_available`) and this is raised instead of
|
||||
letting the work proceed and fail silently. It carries only the redacted,
|
||||
operator-safe fields already on :class:`DegradedOwner` — never token/secret
|
||||
material — so the loop can turn it into a visible "unavailable, here is the
|
||||
next action" outcome.
|
||||
"""
|
||||
|
||||
def __init__(self, owner: "DegradedOwner") -> None:
|
||||
self.owner = owner
|
||||
self.owner_kind = owner.owner_kind
|
||||
self.owner_id = owner.owner_id
|
||||
self.state = owner.state
|
||||
self.reason = owner.reason
|
||||
self.retry_hint = owner.retry_hint
|
||||
super().__init__(
|
||||
f"{owner.owner_kind} {owner.owner_id!r} unavailable ({owner.state}): "
|
||||
f"{owner.reason}"
|
||||
+ (f" — {owner.retry_hint}" if owner.retry_hint else "")
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
"""Render the redacted outcome for a JSON/operator surface."""
|
||||
return self.owner.to_dict()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DegradedOwner:
|
||||
"""A single degraded owner, redacted and operator-safe.
|
||||
|
||||
Attributes:
|
||||
owner_kind: One of ``channel | provider | capability | route | gateway``.
|
||||
owner_id: Stable identity, e.g. ``telegram:main``, ``openai``, ``mcp:notion``.
|
||||
state: ``cold`` (no last-known-good) or ``stale`` (reusing last-known-good).
|
||||
reason: Redacted, operator-safe explanation — never leaks token/secret material.
|
||||
retry_hint: The exact next action, and it MUST name a command that
|
||||
exists — e.g. ``praisonai gateway doctor --fix`` (implemented in the
|
||||
wrapper CLI as a detect → repair → re-validate loop). Never point an
|
||||
operator at a non-existent command.
|
||||
"""
|
||||
|
||||
owner_kind: str
|
||||
owner_id: str
|
||||
state: str
|
||||
reason: str
|
||||
retry_hint: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
# Enforce the closed vocabularies at construction so an unsupported
|
||||
# owner_kind/state can never reach an operator-facing record. Frozen
|
||||
# dataclass validation belongs here rather than being duplicated by
|
||||
# every writer and reader (Issue #3518).
|
||||
if self.owner_kind not in OWNER_KINDS:
|
||||
raise ValueError(
|
||||
f"owner_kind {self.owner_kind!r} not in {OWNER_KINDS!r}"
|
||||
)
|
||||
if self.state not in DEGRADED_STATES:
|
||||
raise ValueError(
|
||||
f"state {self.state!r} not in {DEGRADED_STATES!r}"
|
||||
)
|
||||
|
||||
def to_dict(self) -> Dict[str, str]:
|
||||
return {
|
||||
"owner_kind": self.owner_kind,
|
||||
"owner_id": self.owner_id,
|
||||
"state": self.state,
|
||||
"reason": self.reason,
|
||||
"retry_hint": self.retry_hint,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DegradedCapabilityProtocol(Protocol):
|
||||
"""Contract every degraded-capability registry implements.
|
||||
|
||||
Owners write facts where they happen (``mark``/``clear``); readers
|
||||
(``health()``, ``gateway status``/``doctor``) read them where needed
|
||||
(``list_degraded``).
|
||||
|
||||
Note (Issue #3640): the fail-closed point read ``find`` is deliberately
|
||||
*not* on this base contract. Adding it here would break structural
|
||||
``isinstance`` conformance for any existing external registry that only
|
||||
implemented ``mark``/``clear``/``list_degraded``. The point read lives on
|
||||
the extended :class:`DegradedCapabilityLookupProtocol`, and the module
|
||||
guard degrades gracefully to ``list_degraded()`` when ``find`` is absent.
|
||||
"""
|
||||
|
||||
def mark(self, owner: DegradedOwner) -> None: ...
|
||||
|
||||
def clear(self, owner_kind: str, owner_id: str) -> None: ...
|
||||
|
||||
def list_degraded(self) -> List[DegradedOwner]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DegradedCapabilityLookupProtocol(DegradedCapabilityProtocol, Protocol):
|
||||
"""Extended contract for registries that support the point read (Issue #3640).
|
||||
|
||||
Kept separate from :class:`DegradedCapabilityProtocol` so upgrading does not
|
||||
silently break structural conformance for pre-existing external registries.
|
||||
"""
|
||||
|
||||
def find(self, owner_kind: str, owner_id: str) -> Optional[DegradedOwner]: ...
|
||||
|
||||
|
||||
class DegradedCapabilityRegistry:
|
||||
"""Default in-process, thread-safe degraded-capability registry.
|
||||
|
||||
Keyed on ``(owner_kind, owner_id)`` so re-marking an already-degraded owner
|
||||
updates its record in place rather than duplicating it. Reads return a
|
||||
stable, sorted snapshot so operator output is deterministic.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._owners: Dict[Tuple[str, str], DegradedOwner] = {}
|
||||
|
||||
def mark(self, owner: DegradedOwner) -> None:
|
||||
"""Record (or update) a degraded owner. Last write wins per key."""
|
||||
with self._lock:
|
||||
self._owners[(owner.owner_kind, owner.owner_id)] = owner
|
||||
|
||||
def clear(self, owner_kind: str, owner_id: str) -> None:
|
||||
"""Remove a degraded owner on recovery. Idempotent."""
|
||||
with self._lock:
|
||||
self._owners.pop((owner_kind, owner_id), None)
|
||||
|
||||
def list_degraded(self) -> List[DegradedOwner]:
|
||||
"""Return every currently-degraded owner as a stable, sorted list."""
|
||||
with self._lock:
|
||||
values = list(self._owners.values())
|
||||
return sorted(values, key=lambda o: (o.owner_kind, o.owner_id))
|
||||
|
||||
def find(self, owner_kind: str, owner_id: str) -> Optional[DegradedOwner]:
|
||||
"""Return the degraded record for one owner, or ``None`` if healthy.
|
||||
|
||||
Issue #3640: the point read the fail-closed dispatch path uses to ask
|
||||
"is *this* owner currently degraded?" without scanning the whole list.
|
||||
"""
|
||||
with self._lock:
|
||||
return self._owners.get((owner_kind, owner_id))
|
||||
|
||||
def assert_owner_available(self, owner_kind: str, owner_id: str) -> None:
|
||||
"""Fail-closed guard: raise :class:`OwnerUnavailable` if degraded.
|
||||
|
||||
Issue #3640: the read that turns a recorded degradation into a fact the
|
||||
request path consults. A dispatch path calls this before doing work for
|
||||
an owner; if that owner is degraded the request short-circuits to a
|
||||
typed, redacted "unavailable — next action" outcome instead of
|
||||
proceeding and failing silently. No-op when the owner is healthy.
|
||||
"""
|
||||
owner = self.find(owner_kind, owner_id)
|
||||
if owner is not None:
|
||||
raise OwnerUnavailable(owner)
|
||||
|
||||
def to_list(self) -> List[Dict[str, str]]:
|
||||
"""Convenience: ``list_degraded()`` rendered as plain dicts for JSON."""
|
||||
return [owner.to_dict() for owner in self.list_degraded()]
|
||||
|
||||
|
||||
def assert_owner_available(
|
||||
registry: Optional[DegradedCapabilityProtocol],
|
||||
owner_kind: str,
|
||||
owner_id: str,
|
||||
) -> None:
|
||||
"""Fail-closed guard for a possibly-absent registry (Issue #3640).
|
||||
|
||||
The dispatch path holds an *optional* shared registry (a gateway may run
|
||||
without one). This wrapper is a no-op when ``registry`` is ``None`` and
|
||||
otherwise delegates to :meth:`DegradedCapabilityRegistry.assert_owner_available`,
|
||||
raising :class:`OwnerUnavailable` if the owner is degraded — so a caller can
|
||||
guard a request without repeating the ``None`` check at every call site.
|
||||
"""
|
||||
if registry is None:
|
||||
return
|
||||
guard = getattr(registry, "assert_owner_available", None)
|
||||
if callable(guard):
|
||||
guard(owner_kind, owner_id)
|
||||
return
|
||||
# Backward compatibility (Issue #3640): a pre-existing registry may implement
|
||||
# only ``mark``/``clear``/``list_degraded`` and have neither the new guard nor
|
||||
# ``find``. Prefer the point read when present, otherwise derive the owner
|
||||
# from ``list_degraded()`` so a legacy registry still fails-closed rather than
|
||||
# raising ``AttributeError``.
|
||||
find = getattr(registry, "find", None)
|
||||
if callable(find):
|
||||
owner = find(owner_kind, owner_id)
|
||||
else:
|
||||
owner = next(
|
||||
(
|
||||
o
|
||||
for o in registry.list_degraded()
|
||||
if o.owner_kind == owner_kind and o.owner_id == owner_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
if owner is not None:
|
||||
raise OwnerUnavailable(owner)
|
||||
@@ -11,9 +11,14 @@ The ``praisonai`` wrapper provides backward-compatible shims.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
import uuid
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from contextlib import (
|
||||
AbstractAsyncContextManager,
|
||||
AbstractContextManager,
|
||||
contextmanager,
|
||||
)
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import (
|
||||
@@ -27,6 +32,7 @@ from typing import (
|
||||
Literal,
|
||||
Optional,
|
||||
Protocol,
|
||||
Sequence,
|
||||
Set,
|
||||
Tuple,
|
||||
TypedDict,
|
||||
@@ -39,6 +45,7 @@ GATEWAY_PROTOCOL_VERSION = 1
|
||||
MIN_CLIENT_PROTOCOL_VERSION = 1
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import asyncio
|
||||
from praisonai.gateway.pairing import PairedChannel
|
||||
from ..agent import Agent
|
||||
from ..bots.presentation import MessagePresentation
|
||||
@@ -187,6 +194,9 @@ class EventType(str, Enum):
|
||||
# Streaming events (relayed from agent's StreamEventEmitter)
|
||||
TOKEN_STREAM = "token_stream"
|
||||
TOOL_CALL_STREAM = "tool_call_stream"
|
||||
REASONING_STREAM = "reasoning_stream"
|
||||
TOOL_PROGRESS_STREAM = "tool_progress_stream"
|
||||
STREAM_ERROR = "stream_error"
|
||||
STREAM_END = "stream_end"
|
||||
|
||||
# System events
|
||||
@@ -2255,6 +2265,51 @@ class HomeChannelRegistryProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryValidation:
|
||||
"""Result of a creation-time delivery-target pre-flight (Issue #3800).
|
||||
|
||||
A scheduled job or agent-initiated proactive message carries a
|
||||
``DeliveryTarget`` whose reachability is otherwise only discovered when the
|
||||
job *fires* — potentially hours later, where an unroutable target is
|
||||
silently dropped or dead-target self-healed. This closed shape lets a
|
||||
resolver answer "will this route?" the moment the send is created, so the
|
||||
creator gets an immediate, actionable error instead of a late invisible
|
||||
drop.
|
||||
|
||||
Attributes:
|
||||
ok: Whether the target resolves to a reachable channel/route.
|
||||
reason: On failure, a human-readable explanation of why it is
|
||||
unroutable (empty when ``ok``).
|
||||
hint: On failure, an actionable next step (e.g. the configured
|
||||
channels, or a command to list them); empty when ``ok``.
|
||||
preview: A dry-run preview of the destination (e.g.
|
||||
``"telegram:@alice (session main)"``) suitable for surfacing to the
|
||||
creator before commit.
|
||||
"""
|
||||
|
||||
ok: bool
|
||||
reason: str = ""
|
||||
hint: str = ""
|
||||
preview: str = ""
|
||||
|
||||
|
||||
class ScheduleTargetError(ValueError):
|
||||
"""Raised when a scheduled/agent-initiated send has an unroutable target.
|
||||
|
||||
Carries the structured :class:`DeliveryValidation` reason/hint so the
|
||||
scheduler/CLI can fail fast at *creation* time with an actionable message
|
||||
(``channel 'telegramm' is not configured. Configured: telegram, slack.``)
|
||||
rather than accepting a target that is only discovered dead at fire time.
|
||||
"""
|
||||
|
||||
def __init__(self, reason: str, hint: str = ""):
|
||||
self.reason = reason
|
||||
self.hint = hint
|
||||
message = f"{reason} {hint}".strip() if hint else reason
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeliveryResolverProtocol(Protocol):
|
||||
"""Protocol for resolving delivery routing tokens.
|
||||
@@ -2287,6 +2342,46 @@ class DeliveryResolverProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeliveryPreflightProtocol(Protocol):
|
||||
"""Optional creation-time pre-flight extension for delivery resolvers.
|
||||
|
||||
Kept separate from :class:`DeliveryResolverProtocol` so the base contract
|
||||
stays ``resolve()``-only: an existing resolver that implements just
|
||||
``resolve`` still satisfies ``DeliveryResolverProtocol`` under
|
||||
``isinstance``/``runtime_checkable``. A resolver that can additionally
|
||||
pre-flight or preview a target against its live registry advertises that by
|
||||
also satisfying this protocol; callers duck-type on it and fall back to a
|
||||
structural, registry-free check (:meth:`DeliveryTarget.preview`) otherwise.
|
||||
"""
|
||||
|
||||
def validate_target(
|
||||
self, target: "DeliveryTarget"
|
||||
) -> "DeliveryValidation":
|
||||
"""Pre-flight ``target`` against the live channel/route registry.
|
||||
|
||||
Called at *creation* time (when a scheduled/agent-initiated send is
|
||||
registered) so an unroutable target is rejected or warned on with an
|
||||
actionable message, instead of being silently dropped when the job
|
||||
fires.
|
||||
|
||||
Returns:
|
||||
A :class:`DeliveryValidation` (``ok`` / ``reason`` / ``hint`` /
|
||||
``preview``).
|
||||
"""
|
||||
...
|
||||
|
||||
def preview_target(
|
||||
self, target: "DeliveryTarget"
|
||||
) -> str:
|
||||
"""Return a dry-run preview of where ``target`` will deliver.
|
||||
|
||||
A short, display-only string (e.g. ``"telegram:@alice (session
|
||||
main)"``) so the creator sees the destination before commit.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-facing outbound messaging (Issue #2183)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -2387,6 +2482,192 @@ class OutboundMessengerProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-callable cross-conversation request/reply (Issue #3689)
|
||||
#
|
||||
# ``send_message`` is fire-and-deliver: it returns a delivery receipt, not the
|
||||
# target's answer. This adds the missing *ask another conversation and await
|
||||
# the reply* capability — an agent can route a question to a symbolic target
|
||||
# and get the next correlated inbound reply back into its own turn, bounded by
|
||||
# a timeout. It reuses ``send_message``'s target resolution and the outbound
|
||||
# send-policy guard; the only new surface is a one-shot reply correlation.
|
||||
#
|
||||
# Core owns only the *shape*: the typed outcome (:class:`ConversationReply`),
|
||||
# the protocol seam (:class:`ConversationRequestProtocol`), the context-var
|
||||
# registration slot (in ``session.context``), and the built-in
|
||||
# ``ask_conversation`` tool. The correlation-aware reply source is bound by the
|
||||
# running gateway/bot exactly as ``register_outbound_messenger`` binds the
|
||||
# outbound side — no heavy import lives in core. Every path ends in a recorded
|
||||
# outcome (reply | timeout | undelivered | no_route) — never a silent hang.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ConversationReplyStatus = Literal["reply", "timeout", "undelivered", "no_route"]
|
||||
"""Closed set of outcomes for an :func:`ask_conversation` request.
|
||||
|
||||
* ``reply`` — the target replied within the timeout; ``text`` carries it.
|
||||
* ``timeout`` — the prompt was delivered but no reply arrived in time.
|
||||
* ``undelivered`` — the prompt could not be delivered to the target.
|
||||
* ``no_route`` — the target could not be resolved to a reachable channel.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversationReply:
|
||||
"""Outcome of an agent-initiated cross-conversation request (Issue #3689).
|
||||
|
||||
Every request resolves to exactly one of the :data:`ConversationReplyStatus`
|
||||
outcomes, so the agent always gets a typed answer back into its turn rather
|
||||
than a silent hang.
|
||||
|
||||
Attributes:
|
||||
status: The outcome (``reply`` / ``timeout`` / ``undelivered`` /
|
||||
``no_route``).
|
||||
target: The resolved target the prompt was routed to.
|
||||
text: The reply text, populated only when ``status == "reply"``.
|
||||
detail: Optional extra information (error text, message id, etc.).
|
||||
"""
|
||||
|
||||
status: ConversationReplyStatus
|
||||
target: str = ""
|
||||
text: str = ""
|
||||
detail: Optional[str] = None
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary for the tool return value."""
|
||||
data: Dict[str, Any] = {"status": self.status}
|
||||
if self.target:
|
||||
data["from"] = self.target
|
||||
if self.status == "reply":
|
||||
data["text"] = self.text
|
||||
if self.detail:
|
||||
data["detail"] = self.detail
|
||||
return data
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ConversationRequestProtocol(Protocol):
|
||||
"""Protocol for agent-facing cross-conversation request/reply.
|
||||
|
||||
A concrete implementation is provided by the running gateway/bot (in the
|
||||
praisonai wrapper) and registered into the per-turn context so the built-in
|
||||
``ask_conversation`` tool can resolve it. It sends the prompt via the same
|
||||
delivery stack ``send_message`` uses, then correlates the *next inbound
|
||||
reply* from that target (via the existing ``correlation_id``) with a bounded
|
||||
timeout, returning a typed :class:`ConversationReply`.
|
||||
|
||||
Example usage (implementation in praisonai_bot.gateway)::
|
||||
|
||||
requester = BotConversationRequester(router, origin=origin)
|
||||
token = register_conversation_requester(requester)
|
||||
try:
|
||||
... # agent runs; ask_conversation tool resolves the requester
|
||||
finally:
|
||||
clear_conversation_requester(token)
|
||||
"""
|
||||
|
||||
async def ask(
|
||||
self,
|
||||
target: str,
|
||||
text: str,
|
||||
*,
|
||||
timeout_s: float = 120.0,
|
||||
) -> "ConversationReply":
|
||||
"""Send ``text`` to ``target`` and await the next correlated reply.
|
||||
|
||||
Args:
|
||||
target: Symbolic target token ("origin", "<platform>",
|
||||
"<platform>:<chat_id>[:<thread_id>]", or a friendly alias).
|
||||
text: The prompt to send.
|
||||
timeout_s: Maximum seconds to wait for a reply before returning a
|
||||
``timeout`` outcome.
|
||||
|
||||
Returns:
|
||||
A :class:`ConversationReply` describing the outcome.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-callable live status/health (Issue #3688)
|
||||
#
|
||||
# The gateway already computes rich live state (per-turn run status, active
|
||||
# sessions, delivery/DLQ backlog, degraded owners) but only humans/CLI/HTTP can
|
||||
# read it. This read-only protocol lets the running gateway bind a live source
|
||||
# into the per-turn context so the built-in ``gateway_status`` tool can report
|
||||
# it — mirroring how ``OutboundMessengerProtocol`` backs ``send_message``. Core
|
||||
# ships only the protocol + snapshot shape; the concrete binding (reading
|
||||
# ``health()`` / ``metrics_snapshot()`` / the session registry) lives in the
|
||||
# praisonai-bot wrapper. It is strictly read-only, redaction-aware and
|
||||
# visibility-scoped (no secrets, no cross-tenant leakage).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class GatewayStatus:
|
||||
"""Read-only snapshot of the gateway's live self-state (Issue #3688).
|
||||
|
||||
A neutral, serializable shape the agent can reason about and report. All
|
||||
fields default to empty so a partial/minimal binding is valid and the tool
|
||||
never dead-ends. The concrete binding populates only the visibility-scoped
|
||||
facts it can safely expose.
|
||||
|
||||
Attributes:
|
||||
run: Current turn/run status (e.g. "idle", "busy", "queued").
|
||||
queued: Number of turns queued behind the current one.
|
||||
active_sessions: Count of active sessions (visibility-scoped).
|
||||
sessions_by_channel: Active-session counts keyed by channel/platform.
|
||||
delivery: Delivery-health facts (e.g. outbox_depth, dlq, dead_targets).
|
||||
degraded: Degraded owners as ``{"owner": ..., "reason": ...}`` entries
|
||||
(channels/capabilities/routes flagged configured-unavailable).
|
||||
detail: Optional free-form extra context for the model.
|
||||
"""
|
||||
|
||||
run: str = "idle"
|
||||
queued: int = 0
|
||||
active_sessions: int = 0
|
||||
sessions_by_channel: Dict[str, int] = field(default_factory=dict)
|
||||
delivery: Dict[str, Any] = field(default_factory=dict)
|
||||
degraded: List[Dict[str, Any]] = field(default_factory=list)
|
||||
detail: str = ""
|
||||
|
||||
def as_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a serializable dictionary."""
|
||||
return {
|
||||
"run": self.run,
|
||||
"queued": self.queued,
|
||||
"active_sessions": self.active_sessions,
|
||||
"sessions_by_channel": dict(self.sessions_by_channel),
|
||||
"delivery": dict(self.delivery),
|
||||
"degraded": list(self.degraded),
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class GatewayStatusProtocol(Protocol):
|
||||
"""Protocol for agent-facing, read-only live status/health reporting.
|
||||
|
||||
A concrete implementation is provided by the running gateway/bot (in the
|
||||
praisonai wrapper) and registered into the per-turn context so the built-in
|
||||
``gateway_status`` tool can resolve it. It reads the same live objects the
|
||||
HTTP endpoints already serve (``health()`` / ``metrics_snapshot()`` / the
|
||||
session registry) and returns a redaction-aware, visibility-scoped
|
||||
:class:`GatewayStatus`.
|
||||
|
||||
Example usage (implementation in praisonai_bot.gateway)::
|
||||
|
||||
status = BotGatewayStatus(gateway)
|
||||
token = register_gateway_status(status)
|
||||
try:
|
||||
... # agent runs; gateway_status tool resolves the source
|
||||
finally:
|
||||
clear_gateway_status(token)
|
||||
"""
|
||||
|
||||
def snapshot(self) -> "GatewayStatus":
|
||||
"""Return a read-only snapshot of the gateway's live self-state."""
|
||||
...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
#
|
||||
@@ -3057,6 +3338,136 @@ class MemoryPressurePolicy:
|
||||
return AdmissionDecision.ADMIT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway memory-pressure cache eviction (Issue #3804)
|
||||
#
|
||||
# ``MemoryPressurePolicy`` above sheds *new* inbound turns under RSS pressure,
|
||||
# but it never reclaims the memory already held by *idle* warm per-session
|
||||
# agent caches. On a memory-limited host (a "$5 VPS", a Fly machine, a k8s pod
|
||||
# with a cgroup limit) a busy gateway accumulates dozens of warm caches and,
|
||||
# absent eviction, keeps climbing until the kernel OOM-kills the whole process
|
||||
# — dropping *every* live session at once. This adds a pure, import-free
|
||||
# planner that, given a memory *budget* and the LRU order of warm sessions,
|
||||
# names the coldest rebuildable caches to soft-evict *before* the OOM killer
|
||||
# fires. Each victim is transparently rebuilt from the persisted session store
|
||||
# on its next turn, so eviction is cheap and lossless — as long as we never
|
||||
# evict a session with an unflushed transcript or an in-flight turn.
|
||||
#
|
||||
# The *decision* lives here (provable in isolation, no event loop, no heavy
|
||||
# imports), mirroring :class:`MemoryPressurePolicy`; the *mechanism* — the warm
|
||||
# registry, the LRU order and the flushed/in-flight signals, plus reading the
|
||||
# cgroup limit and anon RSS — lives in the running gateway (the wrapper).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WarmSession:
|
||||
"""A warm per-session agent cache eligible for memory-pressure eviction.
|
||||
|
||||
A pure, import-free fact carried from the running gateway to
|
||||
:func:`plan_pressure_evictions`. Attributes:
|
||||
|
||||
* ``session_id``: the cache key to soft-evict.
|
||||
* ``last_activity``: monotonic/epoch seconds of the session's last turn;
|
||||
the planner evicts the coldest (smallest ``last_activity``) first.
|
||||
* ``in_flight``: ``True`` while a turn is executing — never evicted (would
|
||||
abort live work).
|
||||
* ``flushed``: ``True`` when the transcript is durably persisted — only a
|
||||
flushed cache is rebuildable, so an unflushed one is never evicted
|
||||
(would lose data).
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
last_activity: float = 0.0
|
||||
in_flight: bool = False
|
||||
flushed: bool = True
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MemoryPressureProtocol(Protocol):
|
||||
"""Protocol for reading the memory budget and pressure of a gateway host.
|
||||
|
||||
Pure contract the gateway implements over its own process: report the
|
||||
container's memory limit (from the cgroup v1/v2 memory limit) and the
|
||||
current anonymous (non-reclaimable) RSS, so the eviction budget tracks the
|
||||
*real* container ceiling rather than a hard-coded number. Both return
|
||||
``None``/``0`` gracefully when the platform can't report them, so the
|
||||
planner degrades to a no-op instead of crashing the gateway it protects.
|
||||
"""
|
||||
|
||||
def cgroup_limit_mb(self) -> Optional[float]:
|
||||
"""Return the container memory limit in MiB, or ``None`` if unknown."""
|
||||
...
|
||||
|
||||
def anon_rss_mb(self) -> float:
|
||||
"""Return current anonymous (non-reclaimable) RSS in MiB."""
|
||||
...
|
||||
|
||||
|
||||
def plan_pressure_evictions(
|
||||
budget_mb: Optional[float],
|
||||
rss_mb: float,
|
||||
warm_sessions: Sequence[WarmSession],
|
||||
*,
|
||||
headroom_ratio: float = 0.9,
|
||||
) -> List[str]:
|
||||
"""Return the ``session_id``s to soft-evict, coldest (LRU) first.
|
||||
|
||||
A pure planner: it takes the container memory *budget* (typically the
|
||||
cgroup limit), the current anonymous ``rss_mb`` and the warm-cache
|
||||
registry, and names the coldest rebuildable caches to shed until RSS is
|
||||
back within ``headroom_ratio`` of the budget. It never touches the event
|
||||
loop or the caches themselves — the gateway enacts the returned plan.
|
||||
|
||||
Guards (a victim is *skipped*, never evicted, when):
|
||||
|
||||
* ``in_flight`` is ``True`` — an executing turn must not be aborted, or
|
||||
* ``flushed`` is ``False`` — an unflushed transcript is not yet rebuildable
|
||||
from the store, so evicting it would lose data.
|
||||
|
||||
Returns an empty list when RSS is within budget, the budget is unknown
|
||||
(``None``/``<= 0``), or nothing evictable remains — so a host that can't
|
||||
report a cgroup limit simply never soft-evicts (legacy behaviour).
|
||||
"""
|
||||
if budget_mb is None:
|
||||
return []
|
||||
try:
|
||||
budget = float(budget_mb)
|
||||
rss = float(rss_mb)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
# Reject non-finite measurements (NaN/inf): a NaN budget or rss would slip
|
||||
# past the ``rss <= target`` check (every comparison with NaN is False) and
|
||||
# spuriously evict *every* warm cache — the opposite of protecting them.
|
||||
if not math.isfinite(budget) or not math.isfinite(rss) or budget <= 0:
|
||||
return []
|
||||
try:
|
||||
ratio = float(headroom_ratio)
|
||||
except (TypeError, ValueError):
|
||||
ratio = 0.9
|
||||
if not (math.isfinite(ratio) and 0.0 < ratio <= 1.0):
|
||||
ratio = 0.9
|
||||
target = budget * ratio
|
||||
if rss <= target:
|
||||
return []
|
||||
|
||||
# Name the coldest evictable caches, LRU-first. We deliberately do not
|
||||
# track per-cache bytes (guessing sizes would be scope creep) so we cannot
|
||||
# remeasure RSS mid-plan; instead the planner returns every evictable cache
|
||||
# coldest-first and the gateway evicts down that ordered list, re-sampling
|
||||
# its own RSS as it goes and stopping as soon as it is back within target.
|
||||
# Over-shedding is thus avoided by the enactor and under-shedding is caught
|
||||
# on the next pass — keeping this decision pure and byte-agnostic.
|
||||
evictable = [
|
||||
s for s in warm_sessions
|
||||
if not s.in_flight and s.flushed
|
||||
]
|
||||
if not evictable:
|
||||
return []
|
||||
evictable.sort(key=lambda s: (s.last_activity, s.session_id))
|
||||
return [s.session_id for s in evictable]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway rate-limit admission (Issue #2532)
|
||||
#
|
||||
@@ -3264,6 +3675,163 @@ class SlidingWindowRateLimitPolicy:
|
||||
RateLimitPolicy = RateLimitPolicyProtocol
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Durable-queue dead-letter decision (Issue #3519)
|
||||
#
|
||||
# The gateway's durable inbound journal and outbound queue must decide when a
|
||||
# repeatedly-failing entry is a genuine *poison message* (dead-letter it) vs a
|
||||
# victim of a *transient channel outage* (keep retrying). Deciding on the
|
||||
# attempt counter alone dead-letters deliverable traffic during a routine
|
||||
# few-minute API incident, because the exponential backoff burns the default
|
||||
# five attempts in well under a minute — a silent-loss failure the durable
|
||||
# queue exists to prevent.
|
||||
#
|
||||
# The fix is a pure, import-free *decision* contract, symmetric with the other
|
||||
# gateway policy protocols above (``SendPolicyProtocol``,
|
||||
# ``RateLimitPolicyProtocol``): a recoverable/transient failure is only
|
||||
# dead-lettered once it is BOTH attempt-exhausted AND genuinely old, while a
|
||||
# permanently-classified error (credentials revoked, permanent target) still
|
||||
# short-circuits immediately. The durable-queue runtime in ``praisonai-bot``
|
||||
# consumes it where it currently tests ``attempts >= max_attempts``.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# Error classes that are already *known-permanent* and should dead-letter
|
||||
# immediately regardless of age — no amount of retrying recovers a revoked
|
||||
# credential or a permanently-invalid target.
|
||||
PERMANENT_ERROR_CLASSES: Tuple[str, ...] = ("credential", "permanent_target")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeadLetterDecision:
|
||||
"""Result of a dead-letter evaluation.
|
||||
|
||||
Attributes:
|
||||
dead_letter: Whether the entry should be routed to the dead-letter
|
||||
queue / marked ``permanent_failure`` now. When ``False`` the
|
||||
caller reschedules the entry under its normal capped backoff.
|
||||
reason: Short machine-readable explanation (``"permanent_error"``,
|
||||
``"attempts_and_age"``, ``"retry"``) for logging/metrics.
|
||||
"""
|
||||
|
||||
dead_letter: bool
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class DeadLetterPolicyProtocol(Protocol):
|
||||
"""Protocol for the durable-queue dead-letter decision.
|
||||
|
||||
Pure, import-free decision contract consumed by the outbound queue's
|
||||
``drain`` and the inbound journal's redelivery/replay paths. The runtime
|
||||
supplies typed facts about a repeatedly-failing entry (its ``attempts``,
|
||||
the ``first_seen_epoch`` it was first received, the current ``now_epoch``,
|
||||
and a coarse ``error_class``) and the policy returns a
|
||||
:class:`DeadLetterDecision`. Concrete queue state and side effects (SQLite
|
||||
rows, DLQ enqueue) stay in the implementation; this keeps the *policy*
|
||||
injectable and testable in isolation, symmetric with
|
||||
:class:`SendPolicyProtocol` / :class:`RateLimitPolicyProtocol`.
|
||||
|
||||
A config-driven default (:class:`AttemptAndAgeDeadLetterPolicy`) is
|
||||
provided for the common "poison vs transient" case.
|
||||
"""
|
||||
|
||||
def should_dead_letter(
|
||||
self,
|
||||
*,
|
||||
attempts: int,
|
||||
first_seen_epoch: float,
|
||||
now_epoch: float,
|
||||
error_class: str = "",
|
||||
) -> DeadLetterDecision:
|
||||
"""Return a :class:`DeadLetterDecision` for the supplied facts."""
|
||||
...
|
||||
|
||||
|
||||
class AttemptAndAgeDeadLetterPolicy:
|
||||
"""Default dead-letter policy: require BOTH attempt-exhaustion AND age.
|
||||
|
||||
Distinguishes a *poison message* (fails repeatedly over a long time) from
|
||||
a *transient outage* (fails a few times quickly, then recovers). A
|
||||
recoverable/transient failure is dead-lettered only once it satisfies
|
||||
**both**:
|
||||
|
||||
1. ``attempts >= max_attempts``, and
|
||||
2. ``age >= min_age_seconds`` (wall-clock age since first receipt).
|
||||
|
||||
Until an entry is genuinely old it keeps retrying under capped backoff
|
||||
rather than being discarded, so a brief channel incident results in
|
||||
delayed-but-delivered messages rather than a DLQ full of manual-replay
|
||||
work. A truly poisoned entry still dead-letters — it keeps failing past
|
||||
both thresholds.
|
||||
|
||||
An error whose ``error_class`` is known-permanent (see
|
||||
:data:`PERMANENT_ERROR_CLASSES` — a revoked credential or a permanently
|
||||
invalid target) short-circuits to dead-letter immediately regardless of
|
||||
age, since retrying can never recover it.
|
||||
|
||||
``min_age_seconds=0`` restores the legacy attempt-count-only behaviour,
|
||||
keeping the knob fully backward-compatible for callers that opt in.
|
||||
|
||||
Example::
|
||||
|
||||
AttemptAndAgeDeadLetterPolicy(max_attempts=5, min_age_seconds=6*3600)
|
||||
"""
|
||||
|
||||
#: Default minimum age (6 hours) before a transient failure may dead-letter.
|
||||
DEFAULT_MIN_AGE_SECONDS: int = 6 * 3600
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_attempts: int = 5,
|
||||
min_age_seconds: int = DEFAULT_MIN_AGE_SECONDS,
|
||||
) -> None:
|
||||
try:
|
||||
attempts_ceiling = int(max_attempts)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"max_attempts must be an integer, got {max_attempts!r}"
|
||||
) from err
|
||||
if attempts_ceiling < 1:
|
||||
raise ValueError(
|
||||
f"max_attempts must be >= 1, got {max_attempts!r}"
|
||||
)
|
||||
try:
|
||||
min_age = float(min_age_seconds)
|
||||
except (TypeError, ValueError) as err:
|
||||
raise ValueError(
|
||||
f"min_age_seconds must be a number, got {min_age_seconds!r}"
|
||||
) from err
|
||||
if min_age < 0:
|
||||
raise ValueError(
|
||||
f"min_age_seconds must be >= 0, got {min_age_seconds!r}"
|
||||
)
|
||||
self.max_attempts = attempts_ceiling
|
||||
self.min_age_seconds = min_age
|
||||
|
||||
def should_dead_letter(
|
||||
self,
|
||||
*,
|
||||
attempts: int,
|
||||
first_seen_epoch: float,
|
||||
now_epoch: float,
|
||||
error_class: str = "",
|
||||
) -> DeadLetterDecision:
|
||||
# Known-permanent conditions can never be recovered by retrying.
|
||||
if error_class in PERMANENT_ERROR_CLASSES:
|
||||
return DeadLetterDecision(dead_letter=True, reason="permanent_error")
|
||||
|
||||
exhausted = attempts >= self.max_attempts
|
||||
# Guard against a missing/zero first-seen stamp: treat it as "just now"
|
||||
# so a malformed row is never prematurely dead-lettered on age.
|
||||
age = now_epoch - first_seen_epoch if first_seen_epoch else 0.0
|
||||
old_enough = age >= self.min_age_seconds
|
||||
|
||||
if exhausted and old_enough:
|
||||
return DeadLetterDecision(dead_letter=True, reason="attempts_and_age")
|
||||
return DeadLetterDecision(dead_letter=False, reason="retry")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Port-less, restart-safe external drain trigger (Issue #2390)
|
||||
#
|
||||
@@ -3782,6 +4350,143 @@ class RestartLoopGuard:
|
||||
self._events = []
|
||||
|
||||
|
||||
class FleetSupervisionPolicy:
|
||||
"""Pure fleet-level crash-loop breaker for channel supervision (Issue #3840).
|
||||
|
||||
Per-channel restart budgets (``ChannelHealthMonitor`` /
|
||||
``ChannelRestartHistory`` in ``praisonai-bot``) throttle one misbehaving
|
||||
channel, but they are blind to a *systemic* fault — a bad shared provider,
|
||||
a network partition, an org-wide expired token — that makes *every* channel
|
||||
restart at once. Each channel then independently stays "under budget" while
|
||||
the fleet as a whole thrashes: a reconnect storm that floods logs, burns
|
||||
CPU, and risks an upstream rate-limit ban with no single operator-visible
|
||||
signal.
|
||||
|
||||
This is the aggregate breaker that sits *on top of* the per-channel budgets.
|
||||
Like :class:`RestartLoopGuard` it is intentionally side-effect free (records
|
||||
timestamps only, no I/O, no heavy deps) so the *decision* lives in core and
|
||||
is provable in isolation; the wrapper owns the side effects (halting
|
||||
restarts, recording one ``gateway`` degraded-owner entry).
|
||||
|
||||
The breaker trips when *either* aggregate signal crosses its threshold
|
||||
within the trailing window:
|
||||
|
||||
* the fleet restart rate reaches ``fleet_restarts_per_hour`` restarts across
|
||||
all channels, or
|
||||
* the fraction of channels in a failing/parked state reaches
|
||||
``failing_channel_fraction``.
|
||||
|
||||
Once tripped it stays tripped for ``breaker_cooldown_s`` so the caller
|
||||
applies backpressure (stops auto-restarting, backs off) instead of feeding
|
||||
the storm; after the cooldown it re-arms automatically.
|
||||
|
||||
Example::
|
||||
|
||||
policy = FleetSupervisionPolicy(fleet_restarts_per_hour=40)
|
||||
if policy.note_restart(now=time.monotonic()):
|
||||
# fleet breaker tripped — stop auto-restarting, surface degraded
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fleet_restarts_per_hour: int = 40,
|
||||
failing_channel_fraction: float = 0.5,
|
||||
breaker_cooldown_s: float = 120.0,
|
||||
):
|
||||
if fleet_restarts_per_hour < 1:
|
||||
raise ValueError(
|
||||
f"fleet_restarts_per_hour must be >= 1, got {fleet_restarts_per_hour!r}"
|
||||
)
|
||||
if not 0.0 < failing_channel_fraction <= 1.0:
|
||||
raise ValueError(
|
||||
"failing_channel_fraction must be in (0.0, 1.0], got "
|
||||
f"{failing_channel_fraction!r}"
|
||||
)
|
||||
if breaker_cooldown_s < 0:
|
||||
raise ValueError(
|
||||
f"breaker_cooldown_s must be >= 0, got {breaker_cooldown_s!r}"
|
||||
)
|
||||
self.fleet_restarts_per_hour = int(fleet_restarts_per_hour)
|
||||
self.failing_channel_fraction = float(failing_channel_fraction)
|
||||
self.breaker_cooldown_s = float(breaker_cooldown_s)
|
||||
self._window_seconds = 3600.0 # restart-rate window is per-hour
|
||||
self._events: "List[float]" = []
|
||||
self._tripped_until: Optional[float] = None
|
||||
|
||||
def _prune(self, now: float) -> None:
|
||||
cutoff = now - self._window_seconds
|
||||
self._events = [t for t in self._events if t >= cutoff]
|
||||
|
||||
def note_restart(self, now: float) -> bool:
|
||||
"""Record a fleet restart at ``now`` and return whether the breaker is tripped.
|
||||
|
||||
Args:
|
||||
now: A monotonic timestamp for this restart event.
|
||||
|
||||
Returns:
|
||||
``True`` when the aggregate restart rate has reached
|
||||
``fleet_restarts_per_hour`` within the trailing hour (or the breaker
|
||||
is still within its cooldown); ``False`` otherwise.
|
||||
"""
|
||||
# While actively cooling down, do NOT record new events: the caller has
|
||||
# already HELD the restart, so counting held (non-)restarts would keep
|
||||
# renewing the window and leave the breaker stuck until the full hour
|
||||
# expires. Just report that we are still tripped.
|
||||
if self.tripped(now):
|
||||
return True
|
||||
self._prune(now)
|
||||
self._events.append(now)
|
||||
if len(self._events) >= self.fleet_restarts_per_hour:
|
||||
self._tripped_until = now + self.breaker_cooldown_s
|
||||
return True
|
||||
return False
|
||||
|
||||
def note_fleet_state(
|
||||
self, failing_channels: int, total_channels: int, now: float
|
||||
) -> bool:
|
||||
"""Trip the breaker when too large a fraction of the fleet is failing.
|
||||
|
||||
A systemic fault often shows up as many channels simultaneously in a
|
||||
failing/parked state rather than as a raw restart rate. When at least
|
||||
``failing_channel_fraction`` of the fleet is failing, trip and start the
|
||||
cooldown.
|
||||
|
||||
Args:
|
||||
failing_channels: Number of channels currently failing/parked.
|
||||
total_channels: Total number of supervised channels.
|
||||
now: A monotonic timestamp for this evaluation.
|
||||
|
||||
Returns:
|
||||
``True`` when the breaker is tripped (now or still cooling down).
|
||||
"""
|
||||
if total_channels > 0:
|
||||
fraction = failing_channels / total_channels
|
||||
if fraction >= self.failing_channel_fraction:
|
||||
self._tripped_until = now + self.breaker_cooldown_s
|
||||
return True
|
||||
return self.tripped(now)
|
||||
|
||||
def tripped(self, now: float) -> bool:
|
||||
"""Return whether the breaker is currently tripped without recording.
|
||||
|
||||
When the cooldown has elapsed the breaker re-arms cleanly: the accrued
|
||||
event window is cleared so the accumulated pre-trip restarts cannot
|
||||
immediately re-trip on the very next ``note_restart``.
|
||||
"""
|
||||
if self._tripped_until is not None:
|
||||
if now < self._tripped_until:
|
||||
return True
|
||||
self._tripped_until = None
|
||||
self._events = []
|
||||
return False
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear recorded restart history and any active trip (clean recovery)."""
|
||||
self._events = []
|
||||
self._tripped_until = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Version Negotiation (Issue #2130)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -4523,6 +5228,179 @@ class LivenessPolicy:
|
||||
return LivenessDecision.KEEP
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cluster-wide per-turn serialisation contract (Issue #3643)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Turn serialisation — "only one turn runs against a given resolved session at
|
||||
# a time" — is enforced in-process by an ``asyncio.Lock`` (``LockMap``). That
|
||||
# guarantee evaporates the moment a gateway is scaled to ``replicas > 1`` (the
|
||||
# sanctioned HA topology behind ``redis_pubsub.py`` + the Helm chart): two
|
||||
# messages for one session land on two replicas and run concurrent turns with
|
||||
# no serialisation between them, corrupting the shared transcript, tripping
|
||||
# provider strict-alternation, duplicating replies and double-billing.
|
||||
#
|
||||
# This is the pure, dependency-free contract for a *distributed* turn lock,
|
||||
# mirroring how every other gateway robustness knob (drain, admission,
|
||||
# rate-limit, liveness, dead-letter) is a swappable pure protocol in core. The
|
||||
# heavy Redis/network implementation is an optional-dep runtime concern that
|
||||
# lives in the wrapper/bot package; core keeps only the protocol, the lease
|
||||
# token, and a zero-cost in-process default so single-replica behaviour is
|
||||
# unchanged and no new dependency is introduced.
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TurnLeaseToken:
|
||||
"""An opaque handle to a held turn lease (Issue #3643).
|
||||
|
||||
Returned by :meth:`TurnLockProtocol.acquire` and passed back to
|
||||
:meth:`TurnLockProtocol.release`. The ``owner`` token identifies the
|
||||
replica/process that holds the lease so a distributed backend can make
|
||||
``release`` **identity-checked and idempotent** — a replica can only
|
||||
release the lease it actually owns, and a stale/expired lease that has
|
||||
since been reclaimed by another owner is never clobbered.
|
||||
|
||||
Attributes:
|
||||
key: The resolved session id the lease serialises on.
|
||||
owner: The holder's opaque owner token (e.g. a per-replica id).
|
||||
expires_at: Absolute wall-clock expiry (same clock the backend uses).
|
||||
A dead holder's lease is reclaimable once ``now`` passes this, so a
|
||||
crashed replica cannot wedge a healthy session forever.
|
||||
"""
|
||||
|
||||
key: str
|
||||
owner: str
|
||||
expires_at: float
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TurnLockProtocol(Protocol):
|
||||
"""Contract for serialising a session's turns — cluster-wide or in-process.
|
||||
|
||||
The gateway holds this lock for the *whole* agent turn keyed on the
|
||||
resolved session id, so only one turn ever runs against one session's
|
||||
transcript at a time. With the default in-process backend
|
||||
(:class:`LocalTurnLock`) this reproduces today's ``asyncio.Lock`` behaviour
|
||||
exactly and adds no dependency. With a distributed backend (a
|
||||
``RedisTurnLock`` in the wrapper/bot package, reusing the scheduler's proven
|
||||
``owner``+TTL lease pattern) the same ``async with`` seam serialises turns
|
||||
across every replica.
|
||||
|
||||
Contract:
|
||||
* :meth:`acquire` blocks until the lease for ``key`` is held, then
|
||||
returns a :class:`TurnLeaseToken`. ``ttl`` bounds how long the lease
|
||||
survives without renewal so a crashed holder self-heals.
|
||||
* :meth:`release` is identity-checked against the token's ``owner`` and
|
||||
idempotent: releasing an already-expired/reclaimed lease is a no-op,
|
||||
never an error and never another owner's lease.
|
||||
* :meth:`hold` is the ergonomic async context manager wrapping
|
||||
acquire/release, used at the ``async with self._turn_lock.hold(...)``
|
||||
call site.
|
||||
|
||||
A backend outage must fail *open* (degrade to a loud warning rather than
|
||||
wedging a healthy session), mirroring the fail-safe defaults elsewhere.
|
||||
"""
|
||||
|
||||
async def acquire(self, key: str, *, owner: str, ttl: float) -> TurnLeaseToken:
|
||||
"""Block until the lease for ``key`` is held; return its token."""
|
||||
...
|
||||
|
||||
async def release(self, token: TurnLeaseToken) -> None:
|
||||
"""Release ``token``'s lease (identity-checked, idempotent)."""
|
||||
...
|
||||
|
||||
def hold(
|
||||
self, key: str, *, owner: str, ttl: float
|
||||
) -> "AbstractAsyncContextManager[TurnLeaseToken]":
|
||||
"""Return an async context manager holding the lease for the block."""
|
||||
...
|
||||
|
||||
|
||||
class LocalTurnLock:
|
||||
"""Default in-process turn lock — today's ``asyncio.Lock`` behaviour.
|
||||
|
||||
Zero-cost, dependency-free implementation of :class:`TurnLockProtocol` for
|
||||
single-replica / no-backend deployments. It serialises turns within one
|
||||
process (per event loop) exactly as the existing ``LockMap`` does, so
|
||||
upgrading is byte-for-byte backward compatible. It provides **no** cross-
|
||||
process guarantee — selecting a distributed backend (e.g. ``redis``) is what
|
||||
extends serialisation across replicas.
|
||||
|
||||
The ``owner``/``ttl`` arguments are accepted for protocol symmetry but are
|
||||
inert here: an in-process ``asyncio.Lock`` is released deterministically
|
||||
when the holding task exits, so there is no crashed-holder lease to expire.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._locks: Dict[str, "asyncio.Lock"] = {}
|
||||
# Current lease holder per key, so release() is identity-checked: only
|
||||
# the exact token handed out by the latest acquire() may release, so a
|
||||
# stale token can never clobber a waiter that took the lock after it.
|
||||
self._holders: Dict[str, TurnLeaseToken] = {}
|
||||
|
||||
def _lock_for(self, key: str) -> "asyncio.Lock":
|
||||
lock = self._locks.get(key)
|
||||
if lock is None:
|
||||
import asyncio
|
||||
|
||||
lock = asyncio.Lock()
|
||||
self._locks[key] = lock
|
||||
return lock
|
||||
|
||||
async def acquire(self, key: str, *, owner: str, ttl: float) -> TurnLeaseToken:
|
||||
lock = self._lock_for(key)
|
||||
await lock.acquire()
|
||||
token = TurnLeaseToken(key=key, owner=owner, expires_at=0.0)
|
||||
self._holders[key] = token
|
||||
return token
|
||||
|
||||
async def release(self, token: TurnLeaseToken) -> None:
|
||||
# Identity-checked & idempotent: a stale/duplicate token whose lease has
|
||||
# since been reclaimed by a waiter is a harmless no-op, never another
|
||||
# holder's lease. ``is`` (not ``==``) so equal-valued tokens from two
|
||||
# acquires are not conflated (``TurnLeaseToken`` is frozen/value-equal).
|
||||
if self._holders.get(token.key) is not token:
|
||||
return
|
||||
del self._holders[token.key]
|
||||
lock = self._locks.get(token.key)
|
||||
if lock is not None and lock.locked():
|
||||
lock.release()
|
||||
|
||||
def hold(
|
||||
self, key: str, *, owner: str, ttl: float
|
||||
) -> "AbstractAsyncContextManager[TurnLeaseToken]":
|
||||
return _TurnLeaseHold(self, key, owner=owner, ttl=ttl)
|
||||
|
||||
|
||||
class _TurnLeaseHold:
|
||||
"""Async context manager wrapping acquire/release for any turn lock.
|
||||
|
||||
Reusable by any :class:`TurnLockProtocol` implementation so the concrete
|
||||
lock only needs ``acquire``/``release``; ``hold`` composes them safely
|
||||
(releasing on every exit path, including exceptions).
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, lock: "TurnLockProtocol", key: str, *, owner: str, ttl: float
|
||||
) -> None:
|
||||
self._lock = lock
|
||||
self._key = key
|
||||
self._owner = owner
|
||||
self._ttl = ttl
|
||||
self._token: Optional[TurnLeaseToken] = None
|
||||
|
||||
async def __aenter__(self) -> TurnLeaseToken:
|
||||
self._token = await self._lock.acquire(
|
||||
self._key, owner=self._owner, ttl=self._ttl
|
||||
)
|
||||
return self._token
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
if self._token is not None:
|
||||
await self._lock.release(self._token)
|
||||
self._token = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Declarative method -> required-scope registry (Issue #3206)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -27,8 +27,8 @@ class LLMGuardrail:
|
||||
llm: The LLM instance to use for validation (can be string or LLM instance)
|
||||
"""
|
||||
self.description = description
|
||||
self.llm = self._initialize_llm(llm)
|
||||
self.logger = get_logger(__name__)
|
||||
self.llm = self._initialize_llm(llm)
|
||||
|
||||
def _initialize_llm(self, llm: Any) -> Any:
|
||||
"""Initialize the LLM instance from string identifier or existing instance.
|
||||
@@ -88,9 +88,13 @@ class LLMGuardrail:
|
||||
else:
|
||||
raw_text = task_output.raw
|
||||
|
||||
if not self.llm:
|
||||
self.logger.warning("No LLM provided for guardrail validation")
|
||||
return True, task_output
|
||||
if self.llm is None:
|
||||
# Fail-closed: without an LLM the guardrail cannot validate, so
|
||||
# block the output rather than silently rubber-stamping it.
|
||||
self.logger.error(
|
||||
"No LLM available for guardrail validation - failing closed"
|
||||
)
|
||||
return False, "Guardrail validation unavailable: no LLM configured"
|
||||
|
||||
# Create validation prompt
|
||||
validation_prompt = f"""
|
||||
@@ -118,8 +122,14 @@ Your response:"""
|
||||
# For simple callable LLMs
|
||||
response = self.llm(validation_prompt)
|
||||
else:
|
||||
self.logger.error(f"Unsupported LLM type: {type(self.llm)}")
|
||||
return True, task_output
|
||||
# Fail-closed: an unusable LLM cannot validate, so block.
|
||||
self.logger.error(
|
||||
f"Unsupported LLM type: {type(self.llm)} - failing closed"
|
||||
)
|
||||
return False, (
|
||||
f"Guardrail validation unavailable: "
|
||||
f"unsupported LLM type {type(self.llm)}"
|
||||
)
|
||||
|
||||
# Parse response
|
||||
response = str(response).strip()
|
||||
@@ -131,9 +141,10 @@ Your response:"""
|
||||
reason = response[5:].strip(": ")
|
||||
return False, f"Guardrail validation failed: {reason}"
|
||||
else:
|
||||
# Unclear response, log and pass through
|
||||
# Unclear response - fail closed, matching _llm_validate() and the
|
||||
# class's documented "fail-closed by default" contract.
|
||||
self.logger.warning(f"Unclear guardrail response: {response}")
|
||||
return True, task_output
|
||||
return False, f"Guardrail validation unclear: {response}"
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error in LLM guardrail validation: {str(e)}")
|
||||
@@ -216,7 +227,15 @@ Please respond with either:
|
||||
Your response:"""
|
||||
|
||||
# Get LLM response
|
||||
if hasattr(self.llm, 'complete'):
|
||||
if hasattr(self.llm, 'get_response'):
|
||||
# praisonaiagents.llm.llm.LLM interface
|
||||
response = self.llm.get_response(
|
||||
prompt=prompt,
|
||||
verbose=False,
|
||||
markdown=False,
|
||||
stream=False,
|
||||
)
|
||||
elif hasattr(self.llm, 'complete'):
|
||||
response = self.llm.complete(prompt)
|
||||
elif hasattr(self.llm, 'invoke'):
|
||||
response = self.llm.invoke(prompt)
|
||||
|
||||
@@ -70,6 +70,8 @@ __all__ = [
|
||||
"AfterLLMInput",
|
||||
"SessionStartInput",
|
||||
"SessionEndInput",
|
||||
"SessionPersistFailedInput",
|
||||
"ModelFallbackInput",
|
||||
"OnErrorInput",
|
||||
"OnRetryInput",
|
||||
# Message lifecycle event inputs
|
||||
@@ -140,6 +142,8 @@ _LAZY_GROUPS = {
|
||||
'AfterLLMInput': ('praisonaiagents.hooks.events', 'AfterLLMInput'),
|
||||
'SessionStartInput': ('praisonaiagents.hooks.events', 'SessionStartInput'),
|
||||
'SessionEndInput': ('praisonaiagents.hooks.events', 'SessionEndInput'),
|
||||
'SessionPersistFailedInput': ('praisonaiagents.hooks.events', 'SessionPersistFailedInput'),
|
||||
'ModelFallbackInput': ('praisonaiagents.hooks.events', 'ModelFallbackInput'),
|
||||
'OnErrorInput': ('praisonaiagents.hooks.events', 'OnErrorInput'),
|
||||
'OnRetryInput': ('praisonaiagents.hooks.events', 'OnRetryInput'),
|
||||
'MessageReceivedInput': ('praisonaiagents.hooks.events', 'MessageReceivedInput'),
|
||||
|
||||
@@ -140,6 +140,38 @@ class SessionEndInput(HookInput):
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionPersistFailedInput(HookInput):
|
||||
"""Input for SESSION_PERSIST_FAILED hooks (a durable session write failed).
|
||||
|
||||
Fired by the session store when an ``add_message``/``set_chat_history``
|
||||
write cannot be persisted (disk-full, SQLite/FTS corruption, permission /
|
||||
``OSError``). Instead of losing the already-produced turn silently, the
|
||||
store spills it to an atomic fallback file and fires this hook so operators
|
||||
can observe/route the failure (metrics, alerting, forensics) — mirroring the
|
||||
``MESSAGE_UNDELIVERED`` posture on the outbound side (Issue #3597).
|
||||
|
||||
``spilled`` reports whether the last-resort fallback write itself
|
||||
succeeded; ``spill_path`` is the file the turn was salvaged to (when any).
|
||||
"""
|
||||
role: str = ""
|
||||
content: str = ""
|
||||
error: str = ""
|
||||
spilled: bool = False
|
||||
spill_path: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"role": self.role,
|
||||
"content": self.content[:500] if self.content else "",
|
||||
"error": self.error,
|
||||
"spilled": self.spilled,
|
||||
"spill_path": self.spill_path,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class BeforeLLMInput(HookInput):
|
||||
"""Input for BeforeLLM hooks."""
|
||||
@@ -180,6 +212,34 @@ class AfterLLMInput(HookInput):
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelFallbackInput(HookInput):
|
||||
"""Input for MODEL_FALLBACK hooks (primary model unavailable mid-turn).
|
||||
|
||||
Fired by the agent's LLM recovery loop at the exact point it swaps the
|
||||
primary model for the next entry in the configured ``fallback_models``
|
||||
chain, so an otherwise silent quality/cost degradation becomes an
|
||||
observable state transition (Issue #3820). Notification only: the turn
|
||||
continues on ``to_model``. Only the failure class is exposed
|
||||
(``reason_category``) — provider internals stay redacted, mirroring the
|
||||
redaction discipline of the other error-path events.
|
||||
"""
|
||||
from_model: str = ""
|
||||
to_model: str = ""
|
||||
reason_category: str = ""
|
||||
fallback_index: int = 0
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
base = super().to_dict()
|
||||
base.update({
|
||||
"from_model": self.from_model,
|
||||
"to_model": self.to_model,
|
||||
"reason_category": self.reason_category,
|
||||
"fallback_index": self.fallback_index,
|
||||
})
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class OnErrorInput(HookInput):
|
||||
"""Input for OnError hooks."""
|
||||
|
||||
@@ -124,6 +124,16 @@ class HookRunner:
|
||||
|
||||
return results
|
||||
|
||||
async def execute_async(
|
||||
self,
|
||||
event: HookEvent,
|
||||
input_data: HookInput,
|
||||
target: Optional[str] = None,
|
||||
_hooks: Optional[List["HookDefinition"]] = None
|
||||
) -> List[HookExecutionResult]:
|
||||
"""Alias of execute() for call sites expecting an explicit async entry point."""
|
||||
return await self.execute(event, input_data, target=target, _hooks=_hooks)
|
||||
|
||||
def execute_sync(
|
||||
self,
|
||||
event: HookEvent,
|
||||
|
||||
@@ -32,10 +32,19 @@ class HookEvent(str, Enum):
|
||||
# Session lifecycle
|
||||
SESSION_START = "session_start"
|
||||
SESSION_END = "session_end"
|
||||
# Fired when a durable session write fails (disk-full/corruption/permission).
|
||||
# The already-produced turn is spilled to a fallback file and this hook makes
|
||||
# the otherwise-silent failure observable for metrics/alerting (Issue #3597).
|
||||
SESSION_PERSIST_FAILED = "session_persist_failed"
|
||||
|
||||
# LLM lifecycle
|
||||
BEFORE_LLM = "before_llm"
|
||||
AFTER_LLM = "after_llm"
|
||||
# Primary model became unavailable mid-turn and the runtime transparently
|
||||
# switched to the next entry in the configured ``fallback_models`` chain.
|
||||
# Makes the otherwise silent quality/cost degradation observable so a
|
||||
# gateway/plugin can react (notice, alert, metrics) — Issue #3820.
|
||||
MODEL_FALLBACK = "model_fallback"
|
||||
|
||||
# Prompt-cache stability (advisory): fired when a turn's cached prompt
|
||||
# prefix (model + tool schemas + system-prompt fingerprint) changes from
|
||||
|
||||
@@ -150,6 +150,10 @@ class MongoDBKnowledgeAdapter:
|
||||
self,
|
||||
text: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""
|
||||
@@ -158,6 +162,9 @@ class MongoDBKnowledgeAdapter:
|
||||
Args:
|
||||
text: Content to add
|
||||
metadata: Optional metadata dictionary
|
||||
user_id: Optional user scope for tenant isolation
|
||||
agent_id: Optional agent scope for tenant isolation
|
||||
run_id: Optional run scope for tenant isolation
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
@@ -180,6 +187,9 @@ class MongoDBKnowledgeAdapter:
|
||||
document = {
|
||||
"content": text,
|
||||
"metadata": metadata or {},
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"run_id": run_id,
|
||||
"timestamp": datetime.utcnow(),
|
||||
"embedding": embedding
|
||||
}
|
||||
@@ -196,6 +206,10 @@ class MongoDBKnowledgeAdapter:
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 5,
|
||||
*,
|
||||
user_id: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
run_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -204,6 +218,9 @@ class MongoDBKnowledgeAdapter:
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
user_id: Optional user scope for tenant isolation
|
||||
agent_id: Optional agent scope for tenant isolation
|
||||
run_id: Optional run scope for tenant isolation
|
||||
**kwargs: Additional search parameters
|
||||
|
||||
Returns:
|
||||
@@ -211,6 +228,15 @@ class MongoDBKnowledgeAdapter:
|
||||
"""
|
||||
try:
|
||||
results = []
|
||||
|
||||
# Build tenant/scope filter honored by both vector and text search
|
||||
scope_filter = {
|
||||
k: v for k, v in {
|
||||
"user_id": user_id,
|
||||
"agent_id": agent_id,
|
||||
"run_id": run_id,
|
||||
}.items() if v is not None
|
||||
}
|
||||
|
||||
# Try vector search first if available
|
||||
if self.use_vector_search and self.embedding_model and self._is_atlas_connection():
|
||||
@@ -223,16 +249,19 @@ class MongoDBKnowledgeAdapter:
|
||||
query_embedding = response.data[0].embedding
|
||||
|
||||
# Vector search pipeline
|
||||
vector_search_stage = {
|
||||
"$vectorSearch": {
|
||||
"index": "default", # Assumes default vector index name
|
||||
"path": "embedding",
|
||||
"queryVector": query_embedding,
|
||||
"numCandidates": limit * 10,
|
||||
"limit": limit
|
||||
}
|
||||
}
|
||||
if scope_filter:
|
||||
vector_search_stage["$vectorSearch"]["filter"] = scope_filter
|
||||
pipeline = [
|
||||
{
|
||||
"$vectorSearch": {
|
||||
"index": "default", # Assumes default vector index name
|
||||
"path": "embedding",
|
||||
"queryVector": query_embedding,
|
||||
"numCandidates": limit * 10,
|
||||
"limit": limit
|
||||
}
|
||||
},
|
||||
vector_search_stage,
|
||||
{
|
||||
"$project": {
|
||||
"_id": 1,
|
||||
@@ -263,8 +292,10 @@ class MongoDBKnowledgeAdapter:
|
||||
logger.warning(f"Vector search failed, falling back to text search: {e}")
|
||||
|
||||
# Fallback to text search
|
||||
text_query: Dict[str, Any] = {"$text": {"$search": query}}
|
||||
text_query.update(scope_filter)
|
||||
text_results = self.collection.find(
|
||||
{"$text": {"$search": query}},
|
||||
text_query,
|
||||
{"score": {"$meta": "textScore"}}
|
||||
).sort([("score", {"$meta": "textScore"})]).limit(limit)
|
||||
|
||||
|
||||
@@ -34,6 +34,10 @@ def __getattr__(name):
|
||||
from .llm import LLMContextLengthExceededException
|
||||
_lazy_cache[name] = LLMContextLengthExceededException
|
||||
return LLMContextLengthExceededException
|
||||
elif name == "LLMResponseError":
|
||||
from .llm import LLMResponseError
|
||||
_lazy_cache[name] = LLMResponseError
|
||||
return LLMResponseError
|
||||
|
||||
elif name == "OpenAIClient":
|
||||
from .openai_client import OpenAIClient
|
||||
@@ -206,6 +210,7 @@ def __getattr__(name):
|
||||
__all__ = [
|
||||
"LLM",
|
||||
"LLMContextLengthExceededException",
|
||||
"LLMResponseError",
|
||||
"OpenAIClient",
|
||||
"get_openai_client",
|
||||
"ChatCompletionMessage",
|
||||
|
||||
@@ -20,6 +20,7 @@ __all__ = [
|
||||
"classify_error",
|
||||
"classify_llm_error",
|
||||
"should_retry",
|
||||
"is_replay_unsafe",
|
||||
"get_retry_delay",
|
||||
"extract_retry_after",
|
||||
"get_error_context",
|
||||
@@ -459,6 +460,92 @@ def should_retry(category: ErrorCategory) -> bool:
|
||||
}
|
||||
|
||||
|
||||
# Signals that a transient failure happened AFTER the request was dispatched, so
|
||||
# the provider may already have produced output (and any side-effecting tool
|
||||
# calls in that turn may already have run). Replaying such a call is unsafe: it
|
||||
# risks double-executing the turn. Read timeouts, connection resets and
|
||||
# incomplete/streaming reads all occur once bytes have been sent.
|
||||
_REPLAY_UNSAFE_EXCEPTION_NAMES = frozenset({
|
||||
"readtimeout", # httpx.ReadTimeout / requests ReadTimeout
|
||||
"readtimeouterror", # urllib3 ReadTimeoutError
|
||||
"connectionreseterror", # stdlib socket reset mid-response
|
||||
"chunkedencodingerror", # response body truncated after dispatch
|
||||
"incompleteread", # http.client partial read after dispatch
|
||||
"remoteprotocolerror", # httpx: peer closed connection mid-response
|
||||
"protocolerror", # urllib3 protocol error mid-response
|
||||
"apitimeouterror", # provider SDK request timeout (post-send)
|
||||
})
|
||||
|
||||
# Signals that a transient failure happened BEFORE the request was dispatched,
|
||||
# so the provider provably did not process it — safe to replay automatically.
|
||||
_REPLAY_SAFE_EXCEPTION_NAMES = frozenset({
|
||||
"connecttimeout", # TCP connect never completed
|
||||
"connecttimeouterror", # urllib3 connect timeout
|
||||
"connectionrefusederror", # nothing accepted the connection
|
||||
"connecterror", # httpx connect error (pre-dispatch)
|
||||
"newconnectionerror", # urllib3: connection never established
|
||||
"gaierror", # DNS resolution failure
|
||||
"sslerror", # TLS handshake failure (pre-dispatch)
|
||||
"sslcertverificationerror",
|
||||
})
|
||||
|
||||
_REPLAY_UNSAFE_MESSAGE_PATTERNS = (
|
||||
r"read.?timed?.?out",
|
||||
r"read.?timeout",
|
||||
r"connection.?reset",
|
||||
r"peer.?closed",
|
||||
r"incomplete.?read",
|
||||
r"chunked",
|
||||
r"response.?ended.?prematurely",
|
||||
r"server.?disconnected",
|
||||
)
|
||||
|
||||
_REPLAY_SAFE_MESSAGE_PATTERNS = (
|
||||
r"connect.?timed?.?out",
|
||||
r"connection.?timed?.?out",
|
||||
r"connection.?refused",
|
||||
r"name.?resolution",
|
||||
r"failed.?to.?resolve",
|
||||
r"getaddrinfo",
|
||||
r"ssl",
|
||||
r"handshake",
|
||||
r"tls",
|
||||
)
|
||||
|
||||
|
||||
def is_replay_unsafe(error: Exception) -> bool:
|
||||
"""Return True if replaying the request could double-execute the turn.
|
||||
|
||||
Distinguishes *pre-dispatch* failures (the request provably never reached
|
||||
the provider — DNS/connect/TLS failures) which are safe to replay, from
|
||||
*post-dispatch* failures (read timeout, connection reset mid-response) where
|
||||
the provider may already have produced output and any side-effecting tool
|
||||
calls may already have run. Post-dispatch failures are replay-unsafe.
|
||||
|
||||
Defaults to ``False`` (safe/retryable) for ambiguous transient errors so
|
||||
existing auto-retry behaviour is preserved unless a post-dispatch signal is
|
||||
present. Callers should only block the retry when the turn is side-effecting.
|
||||
"""
|
||||
# 1. Exception type (walk the MRO to catch provider subclasses) — most stable.
|
||||
for cls in type(error).__mro__:
|
||||
name = cls.__name__.lower()
|
||||
if name in _REPLAY_SAFE_EXCEPTION_NAMES:
|
||||
return False
|
||||
if name in _REPLAY_UNSAFE_EXCEPTION_NAMES:
|
||||
return True
|
||||
|
||||
# 2. Message text — pre-dispatch signals win over the generic "timeout".
|
||||
error_text = f"{type(error).__name__} {error}".lower()
|
||||
for pattern in _REPLAY_SAFE_MESSAGE_PATTERNS:
|
||||
if re.search(pattern, error_text):
|
||||
return False
|
||||
for pattern in _REPLAY_UNSAFE_MESSAGE_PATTERNS:
|
||||
if re.search(pattern, error_text):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_retry_delay(category: ErrorCategory, attempt: int = 1, base_delay: float = 1.0) -> float:
|
||||
"""Get the appropriate delay before retrying based on error category.
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ Heavy implementations live in the praisonai wrapper.
|
||||
from __future__ import annotations
|
||||
|
||||
from praisonaiagents._logging import get_logger
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
@@ -216,6 +217,12 @@ class FailoverManager:
|
||||
self._profiles: List[AuthProfile] = []
|
||||
self._current_index: int = 0
|
||||
self._on_failover_callbacks: List[Callable[[AuthProfile, AuthProfile], None]] = []
|
||||
# Re-entrant lock: FailoverManager is a shared credential pool by design
|
||||
# (one instance across many concurrent Agent/LLM instances), so every
|
||||
# read/mutation of _profiles and each AuthProfile status transition must
|
||||
# be synchronized. RLock allows methods like mark_failure to call
|
||||
# get_next_profile while already holding the lock.
|
||||
self._lock = threading.RLock()
|
||||
|
||||
def add_profile(self, profile: AuthProfile) -> None:
|
||||
"""Add an auth profile.
|
||||
@@ -223,8 +230,9 @@ class FailoverManager:
|
||||
Args:
|
||||
profile: The profile to add
|
||||
"""
|
||||
self._profiles.append(profile)
|
||||
self._profiles.sort(key=lambda p: p.priority)
|
||||
with self._lock:
|
||||
self._profiles.append(profile)
|
||||
self._profiles.sort(key=lambda p: p.priority)
|
||||
|
||||
def remove_profile(self, name: str) -> bool:
|
||||
"""Remove a profile by name.
|
||||
@@ -235,11 +243,12 @@ class FailoverManager:
|
||||
Returns:
|
||||
True if removed, False if not found
|
||||
"""
|
||||
for i, profile in enumerate(self._profiles):
|
||||
if profile.name == name:
|
||||
self._profiles.pop(i)
|
||||
return True
|
||||
return False
|
||||
with self._lock:
|
||||
for i, profile in enumerate(self._profiles):
|
||||
if profile.name == name:
|
||||
self._profiles.pop(i)
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_profile(self, name: str) -> Optional[AuthProfile]:
|
||||
"""Get a profile by name.
|
||||
@@ -250,10 +259,11 @@ class FailoverManager:
|
||||
Returns:
|
||||
The profile or None if not found
|
||||
"""
|
||||
for profile in self._profiles:
|
||||
if profile.name == name:
|
||||
return profile
|
||||
return None
|
||||
with self._lock:
|
||||
for profile in self._profiles:
|
||||
if profile.name == name:
|
||||
return profile
|
||||
return None
|
||||
|
||||
def list_profiles(self) -> List[AuthProfile]:
|
||||
"""List all profiles.
|
||||
@@ -261,7 +271,8 @@ class FailoverManager:
|
||||
Returns:
|
||||
List of all profiles
|
||||
"""
|
||||
return list(self._profiles)
|
||||
with self._lock:
|
||||
return list(self._profiles)
|
||||
|
||||
def get_next_profile(self) -> Optional[AuthProfile]:
|
||||
"""Get the next available profile.
|
||||
@@ -272,29 +283,30 @@ class FailoverManager:
|
||||
Returns:
|
||||
The next available profile, or None if all are unavailable
|
||||
"""
|
||||
if not self._profiles:
|
||||
with self._lock:
|
||||
if not self._profiles:
|
||||
return None
|
||||
|
||||
# First, check if any cooldowns have expired
|
||||
current_time = time.time()
|
||||
for profile in self._profiles:
|
||||
if profile.cooldown_until and current_time >= profile.cooldown_until:
|
||||
profile.reset()
|
||||
|
||||
# Find first available profile
|
||||
for profile in self._profiles:
|
||||
if profile.is_available:
|
||||
return profile
|
||||
|
||||
# If none available, return the one with shortest remaining cooldown
|
||||
available_profiles = [p for p in self._profiles if p.status != ProviderStatus.DISABLED]
|
||||
if available_profiles:
|
||||
return min(
|
||||
available_profiles,
|
||||
key=lambda p: p.cooldown_until or 0
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# First, check if any cooldowns have expired
|
||||
current_time = time.time()
|
||||
for profile in self._profiles:
|
||||
if profile.cooldown_until and current_time >= profile.cooldown_until:
|
||||
profile.reset()
|
||||
|
||||
# Find first available profile
|
||||
for profile in self._profiles:
|
||||
if profile.is_available:
|
||||
return profile
|
||||
|
||||
# If none available, return the one with shortest remaining cooldown
|
||||
available_profiles = [p for p in self._profiles if p.status != ProviderStatus.DISABLED]
|
||||
if available_profiles:
|
||||
return min(
|
||||
available_profiles,
|
||||
key=lambda p: p.cooldown_until or 0
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
def mark_failure(
|
||||
self,
|
||||
@@ -309,27 +321,37 @@ class FailoverManager:
|
||||
error: Error message
|
||||
is_rate_limit: Whether this is a rate limit error
|
||||
"""
|
||||
if is_rate_limit:
|
||||
profile.mark_rate_limited(self.config.cooldown_on_rate_limit)
|
||||
logger.warning(
|
||||
f"Profile '{profile.name}' rate limited, "
|
||||
f"cooldown for {self.config.cooldown_on_rate_limit}s"
|
||||
with self._lock:
|
||||
if is_rate_limit:
|
||||
profile.mark_rate_limited(self.config.cooldown_on_rate_limit)
|
||||
logger.warning(
|
||||
f"Profile '{profile.name}' rate limited, "
|
||||
f"cooldown for {self.config.cooldown_on_rate_limit}s"
|
||||
)
|
||||
else:
|
||||
profile.mark_error(error, self.config.cooldown_on_error)
|
||||
logger.warning(
|
||||
f"Profile '{profile.name}' error: {error}, "
|
||||
f"cooldown for {self.config.cooldown_on_error}s"
|
||||
)
|
||||
|
||||
# Resolve the target profile and snapshot callbacks while holding
|
||||
# the lock, but invoke them AFTER releasing it. User callbacks may
|
||||
# block or acquire external locks; running them under our shared
|
||||
# credential-pool lock would stall every concurrent agent and risks
|
||||
# lock-ordering deadlocks.
|
||||
next_profile = self.get_next_profile()
|
||||
callbacks = (
|
||||
list(self._on_failover_callbacks)
|
||||
if next_profile and next_profile != profile
|
||||
else []
|
||||
)
|
||||
else:
|
||||
profile.mark_error(error, self.config.cooldown_on_error)
|
||||
logger.warning(
|
||||
f"Profile '{profile.name}' error: {error}, "
|
||||
f"cooldown for {self.config.cooldown_on_error}s"
|
||||
)
|
||||
|
||||
# Notify callbacks
|
||||
next_profile = self.get_next_profile()
|
||||
if next_profile and next_profile != profile:
|
||||
for callback in self._on_failover_callbacks:
|
||||
try:
|
||||
callback(profile, next_profile)
|
||||
except Exception as e:
|
||||
logger.error(f"Failover callback error: {e}")
|
||||
|
||||
for callback in callbacks:
|
||||
try:
|
||||
callback(profile, next_profile)
|
||||
except Exception as e:
|
||||
logger.error(f"Failover callback error: {e}")
|
||||
|
||||
def mark_success(self, profile: AuthProfile) -> None:
|
||||
"""Mark a profile as successful.
|
||||
@@ -337,9 +359,10 @@ class FailoverManager:
|
||||
Args:
|
||||
profile: The profile that succeeded
|
||||
"""
|
||||
if profile.status != ProviderStatus.AVAILABLE:
|
||||
profile.reset()
|
||||
logger.info(f"Profile '{profile.name}' recovered")
|
||||
with self._lock:
|
||||
if profile.status != ProviderStatus.AVAILABLE:
|
||||
profile.reset()
|
||||
logger.info(f"Profile '{profile.name}' recovered")
|
||||
|
||||
def on_failover(
|
||||
self,
|
||||
@@ -350,7 +373,8 @@ class FailoverManager:
|
||||
Args:
|
||||
callback: Function called with (failed_profile, new_profile)
|
||||
"""
|
||||
self._on_failover_callbacks.append(callback)
|
||||
with self._lock:
|
||||
self._on_failover_callbacks.append(callback)
|
||||
|
||||
def get_retry_delay(self, attempt: int) -> float:
|
||||
"""Calculate retry delay for an attempt.
|
||||
@@ -374,18 +398,20 @@ class FailoverManager:
|
||||
Returns:
|
||||
Status information
|
||||
"""
|
||||
available = sum(1 for p in self._profiles if p.is_available)
|
||||
return {
|
||||
"total_profiles": len(self._profiles),
|
||||
"available_profiles": available,
|
||||
"profiles": [p.to_dict() for p in self._profiles],
|
||||
"config": self.config.to_dict(),
|
||||
}
|
||||
with self._lock:
|
||||
available = sum(1 for p in self._profiles if p.is_available)
|
||||
return {
|
||||
"total_profiles": len(self._profiles),
|
||||
"available_profiles": available,
|
||||
"profiles": [p.to_dict() for p in self._profiles],
|
||||
"config": self.config.to_dict(),
|
||||
}
|
||||
|
||||
def reset_all(self) -> None:
|
||||
"""Reset all profiles to available status."""
|
||||
for profile in self._profiles:
|
||||
profile.reset()
|
||||
with self._lock:
|
||||
for profile in self._profiles:
|
||||
profile.reset()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@@ -87,6 +87,18 @@ except ImportError:
|
||||
# NOTE: The custom-LLM path (Agent.chat → get_response) and OpenAI path
|
||||
# (Agent.chat → _chat_completion) are separate code paths, not duplicate
|
||||
# API calls per request. This is a DRY/maintenance concern, not a billing issue.
|
||||
class LLMResponseError(Exception):
|
||||
"""Raised when the LLM tool-calling loop fails and cannot produce a response.
|
||||
|
||||
This ensures a mid-loop failure surfaces as a distinguishable exception to
|
||||
the caller instead of being silently swallowed and returned as an empty
|
||||
string.
|
||||
"""
|
||||
def __init__(self, message: str):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class LLMContextLengthExceededException(Exception):
|
||||
"""Raised when LLM context length is exceeded"""
|
||||
def __init__(self, message: str):
|
||||
@@ -805,6 +817,21 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
# Default fallback
|
||||
return "unknown"
|
||||
|
||||
def _is_post_dispatch_failure(self, error: Exception) -> bool:
|
||||
"""Whether a transient error occurred after the request was dispatched.
|
||||
|
||||
A post-dispatch failure (read timeout, connection reset mid-response)
|
||||
means the provider may already have processed the request — replaying it
|
||||
is unsafe on a side-effecting turn. A pre-dispatch failure (connect
|
||||
timeout, connection refused, DNS/TLS failure) provably never reached the
|
||||
provider and is always safe to replay.
|
||||
"""
|
||||
try:
|
||||
from .error_classifier import is_replay_unsafe
|
||||
return is_replay_unsafe(error)
|
||||
except Exception: # noqa: BLE001 - classification must never break retry
|
||||
return False
|
||||
|
||||
def resolve_failover_decision(self, error: Exception, attempt_state: dict) -> FailoverDecision:
|
||||
"""
|
||||
Resolve failover decision based on error kind and attempt state.
|
||||
@@ -838,7 +865,25 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
reason=error_kind,
|
||||
is_retryable=False
|
||||
)
|
||||
|
||||
|
||||
# Replay-safety gate (applies to ALL retryable error kinds).
|
||||
# A transient failure that happened AFTER the request was dispatched
|
||||
# (read timeout, connection reset mid-response, incomplete/chunked read,
|
||||
# remote protocol error) may already have produced output server-side and
|
||||
# already run any side-effecting tool calls in that turn. Blindly
|
||||
# replaying it can double-execute the turn (double billing, re-run of a
|
||||
# side-effecting tool). These post-dispatch signals are not all captured
|
||||
# by classify_error_kind (e.g. a bare "connection reset" falls through to
|
||||
# "unknown"), so the gate is evaluated here — before any retry branch —
|
||||
# rather than inside a single error-kind branch. Only block when the turn
|
||||
# is side-effecting (exposes tools); a tool-less turn is safe to replay.
|
||||
if attempt_state.get("side_effecting") and self._is_post_dispatch_failure(error):
|
||||
return FailoverDecision(
|
||||
action="surface_error",
|
||||
reason="provider_outcome_unknown",
|
||||
is_retryable=False
|
||||
)
|
||||
|
||||
# Rate limiting - extract retry delay
|
||||
if error_kind == "rate_limit":
|
||||
backoff = self._parse_retry_delay(str(error)) # Returns seconds
|
||||
@@ -861,6 +906,7 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
)
|
||||
|
||||
# Overloaded/timeout - retry with exponential backoff
|
||||
# (Post-dispatch replay-safety is already handled by the gate above.)
|
||||
if error_kind in ["overloaded", "idle_timeout"]:
|
||||
# For idle timeouts, check circuit breaker
|
||||
if error_kind == "idle_timeout":
|
||||
@@ -1009,8 +1055,23 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
error_str: String form of the exception.
|
||||
kwargs: The (possibly updated) keyword arguments.
|
||||
"""
|
||||
# A turn is "side-effecting" when it exposes tools the model may call:
|
||||
# a post-dispatch failure on such a turn could have already run a
|
||||
# side-effecting tool, so it must not be silently replayed. A turn with
|
||||
# no tools (pure completion) has no external side effects, so replaying a
|
||||
# post-dispatch failure is safe (at most re-bills the completion, which
|
||||
# the provider already dedupes far less than a double tool action).
|
||||
side_effecting = bool(kwargs.get("tools"))
|
||||
|
||||
# Use new typed failover decision instead of old classification
|
||||
decision = self.resolve_failover_decision(e, {"attempt": attempt + 1, "max_retries": self._max_retries})
|
||||
decision = self.resolve_failover_decision(
|
||||
e,
|
||||
{
|
||||
"attempt": attempt + 1,
|
||||
"max_retries": self._max_retries,
|
||||
"side_effecting": side_effecting,
|
||||
},
|
||||
)
|
||||
|
||||
error_str = str(e)
|
||||
|
||||
@@ -1817,6 +1878,48 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
|
||||
return False, None, iteration_count
|
||||
|
||||
def _register_deferred_if_any(self, tool_result: Any) -> None:
|
||||
"""Register a deferred tool result so its eventual value is not lost.
|
||||
|
||||
Closes the deferred loop (Issue #3716): when a tool returns
|
||||
``defer(handle_id=...)`` the run loop already surfaces the note (carried
|
||||
in ``tool_result.result``); here we also register the handle on the
|
||||
global :class:`DeferredResolver` so a later ``resolve_deferred(handle_id,
|
||||
value)`` (fired on background completion) re-injects the result into the
|
||||
shared ``messages`` history as a follow-up tool message.
|
||||
|
||||
No-op for non-deferred results, preserving existing behaviour. A gateway
|
||||
that wants to deliver the result to an external channel can register its
|
||||
own resolver for the same handle first — this only registers when the
|
||||
handle is not already pending, so it never overrides that.
|
||||
"""
|
||||
deferred = getattr(tool_result, "deferred", None)
|
||||
if deferred is None:
|
||||
return
|
||||
try:
|
||||
from ..tools.call_executor import get_deferred_resolver
|
||||
resolver = get_deferred_resolver()
|
||||
tool_call_id = tool_result.tool_call_id
|
||||
function_name = tool_result.function_name
|
||||
|
||||
def _reinject(handle_id: str, value: Any, session_id: Optional[str]) -> None:
|
||||
# Best-effort: this closure is invoked from a background worker
|
||||
# thread; appending to the LLM's persistent ``chat_history``
|
||||
# (the same list follow-up turns replay via _build_messages)
|
||||
# makes the resolved value available on the next turn.
|
||||
self.chat_history.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"content": f"[deferred:{function_name}] {value}",
|
||||
})
|
||||
|
||||
# Atomic: only register when not already pending, so a gateway that
|
||||
# registered its own resolver for this handle first is never
|
||||
# overridden and concurrent turns cannot clobber each other.
|
||||
resolver.register_if_absent(deferred.handle_id, _reinject)
|
||||
except Exception as e: # noqa: BLE001 — registration must never break the turn
|
||||
logging.debug("Deferred registration skipped (non-fatal): %s", e)
|
||||
|
||||
def _finalise_on_limit(self, messages: List[Dict], response_text: str,
|
||||
temperature: float = 0.2, **kwargs) -> str:
|
||||
"""
|
||||
@@ -2656,6 +2759,9 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
for tool_call_obj, tool_result_obj in zip(tool_calls_batch, tool_results_batch):
|
||||
if tool_result_obj.error is not None:
|
||||
raise tool_result_obj.error
|
||||
# Register any deferred handle so its eventual
|
||||
# background result is re-injected, not lost.
|
||||
self._register_deferred_if_any(tool_result_obj)
|
||||
tool_result = tool_result_obj.result
|
||||
tool_results.append(tool_result)
|
||||
accumulated_tool_results.append(tool_result)
|
||||
@@ -3563,9 +3669,16 @@ Respond with ONLY a valid JSON tool call in this format:
|
||||
final_response_text = response_text.strip() if response_text else ""
|
||||
break
|
||||
|
||||
except LLMResponseError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logging.error(f"Error in LLM iteration {iteration_count}: {e}")
|
||||
break
|
||||
# Don't swallow the failure and return an empty string as if
|
||||
# the call succeeded. Re-raise as a distinguishable exception
|
||||
# so the caller (e.g. Agent.chat) can surface the real error.
|
||||
raise LLMResponseError(
|
||||
f"LLM tool-calling loop failed at iteration {iteration_count}: {e}"
|
||||
) from e
|
||||
|
||||
# End of while loop - return final response
|
||||
if final_response_text:
|
||||
@@ -4138,6 +4251,9 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
)
|
||||
|
||||
for tool_result in tool_results:
|
||||
# Register any deferred handle so its eventual
|
||||
# background result is re-injected, not lost.
|
||||
self._register_deferred_if_any(tool_result)
|
||||
if tool_result.error is None:
|
||||
# Successful execution
|
||||
tool_message = self._create_tool_message(
|
||||
|
||||
@@ -4,6 +4,12 @@ Model capabilities configuration for different LLM providers.
|
||||
This module uses LiteLLM's helper functions as the primary source for model capability detection.
|
||||
LiteLLM is maintained by many contributors and is more accurate and up-to-date.
|
||||
|
||||
When LiteLLM is not installed (lean provider-native installs), each ``supports_*`` helper
|
||||
falls back to a small, conservative static heuristic instead of silently returning ``False``,
|
||||
so capability gating stays usable without pulling in the optional ``litellm`` dependency.
|
||||
The fallbacks are pattern-based only (no network, no new dependency); LiteLLM remains
|
||||
authoritative whenever it is installed.
|
||||
|
||||
LiteLLM Helper Functions:
|
||||
- litellm.supports_web_search(model=) - Check web search support
|
||||
- litellm.supports_function_calling(model=) - Check function calling support
|
||||
@@ -19,9 +25,84 @@ Sources:
|
||||
- https://docs.litellm.ai/docs/completion/prompt_caching
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
|
||||
from ._litellm_loader import get_litellm as _get_litellm
|
||||
|
||||
|
||||
def _base_model_name(model_name: str) -> str:
|
||||
"""Strip a leading ``provider/`` prefix and lowercase for pattern matching."""
|
||||
name = model_name.lower()
|
||||
if "/" in name:
|
||||
name = name.split("/", 1)[1]
|
||||
return name
|
||||
|
||||
|
||||
def _fallback_supports_structured_outputs(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable.
|
||||
|
||||
Keeps capability gating correct for lean (litellm-free) installs instead of
|
||||
silently returning False. Deliberately conservative and pattern-based; no
|
||||
network, no new dependency.
|
||||
"""
|
||||
name = _base_model_name(model_name)
|
||||
return any(
|
||||
p in name
|
||||
for p in ("gpt-4o", "gpt-4.1", "gpt-5", "o1", "o3", "o4", "gemini", "claude-3", "claude-sonnet", "claude-opus", "claude-haiku")
|
||||
)
|
||||
|
||||
|
||||
def _fallback_supports_function_calling(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable."""
|
||||
name = _base_model_name(model_name)
|
||||
if any(x in name for x in ("embedding", "whisper", "tts", "dall-e")):
|
||||
return False
|
||||
return any(
|
||||
p in name
|
||||
for p in ("gpt-4", "gpt-5", "gpt-3.5", "o1", "o3", "o4", "gemini", "claude", "llama-3", "mistral", "mixtral", "grok")
|
||||
)
|
||||
|
||||
|
||||
def _fallback_supports_parallel_function_calling(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable.
|
||||
|
||||
Deliberately narrower than :func:`_fallback_supports_function_calling`:
|
||||
parallel tool calls are a stricter capability than serial function calling,
|
||||
so this only reports ``True`` for provider families known to support issuing
|
||||
multiple tool calls in a single turn. Models that support only serial tool
|
||||
calls conservatively return ``False``.
|
||||
"""
|
||||
if not _fallback_supports_function_calling(model_name):
|
||||
return False
|
||||
name = _base_model_name(model_name)
|
||||
return any(
|
||||
p in name
|
||||
for p in ("gpt-4", "gpt-5", "gpt-3.5", "o1", "o3", "o4", "gemini", "claude")
|
||||
)
|
||||
|
||||
|
||||
def _fallback_supports_web_search(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable.
|
||||
|
||||
Note: Anthropic Claude models are intentionally excluded here — they use
|
||||
``web_fetch`` (see :func:`supports_web_fetch`), not native ``web_search``,
|
||||
mirroring litellm's own reporting.
|
||||
"""
|
||||
name = _base_model_name(model_name)
|
||||
if "perplexity" in name or name.startswith("sonar"):
|
||||
return True
|
||||
if "search" in name: # e.g. gpt-4o-search-preview
|
||||
return True
|
||||
return any(p in name for p in ("gemini-2", "grok-3"))
|
||||
|
||||
|
||||
def _fallback_supports_prompt_caching(model_name: str) -> bool:
|
||||
"""Static heuristic used only when litellm is unavailable."""
|
||||
name = _base_model_name(model_name)
|
||||
return any(p in name for p in ("claude-3", "claude-sonnet", "claude-opus", "claude-haiku", "gpt-4o", "gpt-4.1", "gpt-5", "deepseek"))
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_structured_outputs(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports structured outputs (JSON schema).
|
||||
@@ -37,19 +118,26 @@ def supports_structured_outputs(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return False
|
||||
# litellm genuinely unavailable: use conservative static heuristic.
|
||||
return _fallback_supports_structured_outputs(model_name)
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_response_schema'):
|
||||
return litellm.supports_response_schema(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
# litellm is installed but the helper is missing or raised: keep litellm
|
||||
# authoritative (return False) rather than overriding it with the heuristic.
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_structured_outputs(model_name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_function_calling(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports function calling.
|
||||
@@ -65,19 +153,23 @@ def supports_function_calling(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return False
|
||||
return _fallback_supports_function_calling(model_name)
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_function_calling'):
|
||||
return litellm.supports_function_calling(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_function_calling(model_name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_parallel_function_calling(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports parallel function calling.
|
||||
@@ -93,17 +185,20 @@ def supports_parallel_function_calling(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return False
|
||||
return _fallback_supports_parallel_function_calling(model_name)
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_parallel_function_calling'):
|
||||
return litellm.supports_parallel_function_calling(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_parallel_function_calling(model_name)
|
||||
|
||||
|
||||
def supports_streaming_with_tools(model_name: str) -> bool:
|
||||
@@ -124,6 +219,7 @@ def supports_streaming_with_tools(model_name: str) -> bool:
|
||||
GEMINI_INTERNAL_TOOLS = {'googleSearch', 'urlContext', 'codeExecution'}
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_web_search(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports native web search via LiteLLM.
|
||||
@@ -149,19 +245,23 @@ def supports_web_search(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return False
|
||||
return _fallback_supports_web_search(model_name)
|
||||
# Use LiteLLM's built-in check - most accurate and up-to-date
|
||||
if hasattr(litellm, 'supports_web_search'):
|
||||
return litellm.supports_web_search(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_web_search(model_name)
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_prompt_caching(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports prompt caching via LiteLLM.
|
||||
@@ -186,16 +286,19 @@ def supports_prompt_caching(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
|
||||
litellm = None
|
||||
try:
|
||||
litellm = _get_litellm()
|
||||
if litellm is None:
|
||||
return False
|
||||
return _fallback_supports_prompt_caching(model_name)
|
||||
if hasattr(litellm, 'utils') and hasattr(litellm.utils, 'supports_prompt_caching'):
|
||||
return litellm.utils.supports_prompt_caching(model=model_name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return False
|
||||
|
||||
if litellm is not None:
|
||||
return False
|
||||
return _fallback_supports_prompt_caching(model_name)
|
||||
|
||||
|
||||
# Models that support web fetch via LiteLLM (Anthropic only)
|
||||
@@ -219,6 +322,7 @@ MODELS_SUPPORTING_WEB_FETCH = {
|
||||
}
|
||||
|
||||
|
||||
@lru_cache(maxsize=256)
|
||||
def supports_web_fetch(model_name: str) -> bool:
|
||||
"""
|
||||
Check if a model supports web fetch via LiteLLM.
|
||||
|
||||
@@ -26,6 +26,11 @@ from .protocols import (
|
||||
InstanceInfo,
|
||||
InstanceStatus,
|
||||
ManagedRuntimeProtocol,
|
||||
SupportsCapture,
|
||||
load_environment_definition,
|
||||
find_environment_definition,
|
||||
definition_hash,
|
||||
capture_key,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
@@ -44,6 +49,11 @@ __all__ = [
|
||||
"InstanceStatus",
|
||||
"ManagedRuntimeProtocol",
|
||||
"ManagedBackendProtocol",
|
||||
"SupportsCapture",
|
||||
"load_environment_definition",
|
||||
"find_environment_definition",
|
||||
"definition_hash",
|
||||
"capture_key",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ class ComputeConfig:
|
||||
cpu=2,
|
||||
memory_mb=2048,
|
||||
packages={"pip": ["pandas", "numpy"]},
|
||||
setup=["pip install -e ."],
|
||||
env={"OPENAI_API_KEY": "sk-..."},
|
||||
auto_shutdown=True,
|
||||
idle_timeout_s=300,
|
||||
@@ -66,6 +67,7 @@ class ComputeConfig:
|
||||
memory_mb: int = 1024
|
||||
gpu: Optional[str] = None
|
||||
packages: Dict[str, List[str]] = field(default_factory=dict)
|
||||
setup: List[str] = field(default_factory=list)
|
||||
env: Dict[str, str] = field(default_factory=dict)
|
||||
working_dir: str = "/workspace"
|
||||
mount_paths: List[str] = field(default_factory=list)
|
||||
@@ -75,6 +77,262 @@ class ComputeConfig:
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
_ENV_FILENAME = "environment.yaml"
|
||||
_ENV_DIRNAME = ".praisonai"
|
||||
|
||||
_ENV_KNOWN_KEYS = {
|
||||
"image", "packages", "setup", "refresh", "env", "resources",
|
||||
"network", "backend", "working_dir", "mount_paths", "capture",
|
||||
}
|
||||
|
||||
|
||||
def find_environment_definition(start: Optional[str] = None) -> Optional[str]:
|
||||
"""Discover ``.praisonai/environment.yaml`` by walking up from ``start``.
|
||||
|
||||
Args:
|
||||
start: Directory to start searching from (defaults to CWD).
|
||||
|
||||
Returns:
|
||||
Absolute path to the definition file, or ``None`` if not found.
|
||||
"""
|
||||
import os
|
||||
|
||||
current = os.path.abspath(start or os.getcwd())
|
||||
while True:
|
||||
candidate = os.path.join(current, _ENV_DIRNAME, _ENV_FILENAME)
|
||||
if os.path.isfile(candidate):
|
||||
return candidate
|
||||
parent = os.path.dirname(current)
|
||||
if parent == current:
|
||||
return None
|
||||
current = parent
|
||||
|
||||
|
||||
def load_environment_definition(
|
||||
path: Optional[str] = None,
|
||||
) -> Optional[ComputeConfig]:
|
||||
"""Load a repo-committed ``.praisonai/environment.yaml`` into a ComputeConfig.
|
||||
|
||||
This is the single resolution path shared by every ``compute=`` consumer:
|
||||
the file declares image / packages / setup / env / resources / network /
|
||||
backend, and is mapped onto the existing :class:`ComputeConfig` schema.
|
||||
|
||||
Args:
|
||||
path: Explicit path to a definition file. If ``None``, discovery walks
|
||||
up from the current directory looking for ``.praisonai/environment.yaml``.
|
||||
|
||||
Returns:
|
||||
A :class:`ComputeConfig`, or ``None`` when no file is found (callers then
|
||||
fall back to today's defaults — the file is strictly opt-in).
|
||||
|
||||
Raises:
|
||||
ValueError: If the file contains unknown top-level keys or is malformed.
|
||||
"""
|
||||
if path is None:
|
||||
path = find_environment_definition()
|
||||
if path is None:
|
||||
return None
|
||||
|
||||
import yaml # lazy — stdlib-adjacent, already a dependency
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError(
|
||||
f"{path}: environment definition must be a mapping, got {type(data).__name__}"
|
||||
)
|
||||
|
||||
unknown = set(data) - _ENV_KNOWN_KEYS
|
||||
if unknown:
|
||||
raise ValueError(
|
||||
f"{path}: unknown key(s) {sorted(unknown)}; "
|
||||
f"allowed keys are {sorted(_ENV_KNOWN_KEYS)}"
|
||||
)
|
||||
|
||||
def _require(key: str, value: Any, expected: type, kind: str) -> None:
|
||||
if value is not None and not isinstance(value, expected):
|
||||
raise ValueError(
|
||||
f"{path}: '{key}' must be {kind}, got {type(value).__name__}"
|
||||
)
|
||||
|
||||
cfg = ComputeConfig()
|
||||
|
||||
if "image" in data:
|
||||
_require("image", data["image"], str, "a string")
|
||||
cfg.image = data["image"]
|
||||
if "packages" in data:
|
||||
pkgs = data["packages"] or {}
|
||||
_require("packages", pkgs, dict, "a mapping")
|
||||
cfg.packages = pkgs
|
||||
if "setup" in data:
|
||||
setup = data["setup"] or []
|
||||
if isinstance(setup, str):
|
||||
setup = [setup]
|
||||
_require("setup", setup, list, "a string or list of strings")
|
||||
cfg.setup = list(setup)
|
||||
if "refresh" in data:
|
||||
refresh = data["refresh"] or []
|
||||
if isinstance(refresh, str):
|
||||
refresh = [refresh]
|
||||
_require("refresh", refresh, list, "a string or list of strings")
|
||||
# ``refresh`` runs only when starting from a capture (cheap incremental
|
||||
# step). Carried in metadata so no new dataclass field is added without
|
||||
# a typed consumer; the docker capture path reads it.
|
||||
cfg.metadata["refresh"] = list(refresh)
|
||||
if "env" in data:
|
||||
env = data["env"] or {}
|
||||
_require("env", env, dict, "a mapping")
|
||||
cfg.env = {str(k): str(v) for k, v in env.items()}
|
||||
if "working_dir" in data:
|
||||
_require("working_dir", data["working_dir"], str, "a string")
|
||||
cfg.working_dir = data["working_dir"]
|
||||
if "mount_paths" in data:
|
||||
mounts = data["mount_paths"] or []
|
||||
_require("mount_paths", mounts, list, "a list")
|
||||
cfg.mount_paths = list(mounts)
|
||||
|
||||
resources = data.get("resources") or {}
|
||||
_require("resources", resources, dict, "a mapping")
|
||||
if "cpu" in resources:
|
||||
cfg.cpu = int(resources["cpu"])
|
||||
if "memory_mb" in resources:
|
||||
cfg.memory_mb = int(resources["memory_mb"])
|
||||
if "gpu" in resources:
|
||||
cfg.gpu = resources["gpu"]
|
||||
|
||||
# Carry network / backend preferences in existing typed fields so no new
|
||||
# dataclass surface is added without a consumer.
|
||||
if "network" in data:
|
||||
cfg.networking = {"type": data["network"]}
|
||||
if "backend" in data:
|
||||
cfg.metadata["backend"] = data["backend"]
|
||||
if "capture" in data:
|
||||
# Opt-in flag for backends where capture may carry provider cost
|
||||
# (cloud snapshots). Local docker commit is free and always on.
|
||||
cfg.metadata["capture"] = bool(data["capture"])
|
||||
|
||||
return cfg
|
||||
|
||||
|
||||
def _definition_payload(config: "ComputeConfig") -> Dict[str, Any]:
|
||||
"""Normalised, order-insensitive view of the fields that change *what gets
|
||||
built* (image, packages, setup, env variable names, resources, working_dir).
|
||||
|
||||
``env`` **values** are deliberately excluded here — see :func:`definition_hash`.
|
||||
"""
|
||||
def _norm_packages(pkgs: Dict[str, List[str]]) -> Dict[str, List[str]]:
|
||||
return {k: sorted(map(str, v or [])) for k, v in sorted((pkgs or {}).items())}
|
||||
|
||||
return {
|
||||
"image": config.image,
|
||||
"packages": _norm_packages(config.packages),
|
||||
"setup": list(config.setup or []),
|
||||
"env_names": sorted((config.env or {}).keys()),
|
||||
"cpu": config.cpu,
|
||||
"memory_mb": config.memory_mb,
|
||||
"gpu": config.gpu,
|
||||
"working_dir": config.working_dir,
|
||||
}
|
||||
|
||||
|
||||
def definition_hash(config: "ComputeConfig") -> str:
|
||||
"""Stable content hash of an environment definition, for display/logging.
|
||||
|
||||
Hashes only the fields that change *what gets built* (image, packages,
|
||||
setup, env variable names, resources, working_dir). Values of ``env`` are
|
||||
excluded because they are typically secrets/tenant-specific and must not
|
||||
leak into a hash that may be logged or shown in ``list_captures``. The
|
||||
result is a short, stable sha256 hex digest: reordering keys/lists that
|
||||
describe the same environment yields the same hash; changing a package or
|
||||
setup step yields a new one.
|
||||
|
||||
.. note::
|
||||
This value-free hash is safe to log but is **not** a safe reuse key on
|
||||
its own: ``setup:`` runs with ``env`` values injected and may bake
|
||||
env-derived state into the filesystem, so a capture must not be reused
|
||||
across differing secret values. Use :func:`capture_key` for the actual
|
||||
reuse/cache key — it additionally binds the ``env`` **values** so
|
||||
different secrets produce a different capture, while never being logged.
|
||||
|
||||
Args:
|
||||
config: The :class:`ComputeConfig` to fingerprint.
|
||||
|
||||
Returns:
|
||||
A 12-char hex digest.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
blob = json.dumps(
|
||||
_definition_payload(config), sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
def capture_key(config: "ComputeConfig") -> str:
|
||||
"""Secret-aware reuse key for capturing/reusing a provisioned environment.
|
||||
|
||||
Extends :func:`definition_hash` by additionally binding the ``env``
|
||||
**values**. ``setup:`` executes with those values injected and may persist
|
||||
env-derived state (tokens, tenant config) into the committed filesystem, so
|
||||
a capture is only safe to reuse when the values match too. Two definitions
|
||||
that are identical except for their secret values therefore get *different*
|
||||
capture keys and never share a committed image.
|
||||
|
||||
The env values are folded into the digest (never returned in the clear and
|
||||
never logged), so this key is suitable for an image tag
|
||||
(``praisonai-env:{key}``) but should not itself be surfaced to users.
|
||||
|
||||
Args:
|
||||
config: The :class:`ComputeConfig` to fingerprint.
|
||||
|
||||
Returns:
|
||||
A 12-char hex digest that changes with any build input *or* secret value.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
payload = dict(_definition_payload(config))
|
||||
payload["env_values"] = {
|
||||
str(k): str(v) for k, v in sorted((config.env or {}).items())
|
||||
}
|
||||
blob = json.dumps(payload, sort_keys=True, separators=(",", ":"))
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()[:12]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SupportsCapture(Protocol):
|
||||
"""Optional capability: capture a provisioned instance and reuse it.
|
||||
|
||||
Backends that can snapshot post-setup state (docker ``commit``, cloud SDK
|
||||
pause/snapshot) implement this so a second provision of the same definition
|
||||
starts *from the capture* — skipping pull + package install + ``setup:``.
|
||||
|
||||
Backends that cannot (subprocess/ssh) simply don't implement it; the
|
||||
provision flow then falls back to today's ephemeral behaviour. Methods
|
||||
return ``None`` to signal "no capture available", never raising, so a failed
|
||||
capture degrades to ephemeral rather than blocking the run.
|
||||
"""
|
||||
|
||||
def capture(self, instance_id: str, ref: str) -> Optional[str]:
|
||||
"""Capture ``instance_id``'s current state under ``ref``.
|
||||
|
||||
Args:
|
||||
instance_id: Instance to capture.
|
||||
ref: Stable name to record the capture under (e.g. a definition hash).
|
||||
|
||||
Returns:
|
||||
An opaque capture reference (image tag / snapshot id) on success,
|
||||
or ``None`` if capture was unavailable or failed.
|
||||
"""
|
||||
...
|
||||
|
||||
def has_capture(self, ref: str) -> bool:
|
||||
"""Whether a reusable capture exists for ``ref``."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceInfo:
|
||||
"""Runtime information about a provisioned compute instance."""
|
||||
|
||||
@@ -78,12 +78,14 @@ class MCPToolRunner(threading.Thread):
|
||||
response_queue, kind, name, arguments = item
|
||||
try:
|
||||
if kind == "resource":
|
||||
result = await session.read_resource(name)
|
||||
result = await asyncio.wait_for(session.read_resource(name), timeout=self.timeout)
|
||||
elif kind == "prompt":
|
||||
result = await session.get_prompt(name, arguments or None)
|
||||
result = await asyncio.wait_for(session.get_prompt(name, arguments or None), timeout=self.timeout)
|
||||
else:
|
||||
result = await session.call_tool(name, arguments)
|
||||
result = await asyncio.wait_for(session.call_tool(name, arguments), timeout=self.timeout)
|
||||
response_queue.put((True, result))
|
||||
except asyncio.TimeoutError:
|
||||
response_queue.put((False, f"MCP {kind} call timed out after {self.timeout} seconds (server side)"))
|
||||
except Exception as e:
|
||||
response_queue.put((False, str(e)))
|
||||
except queue.Empty:
|
||||
@@ -385,8 +387,11 @@ class MCP:
|
||||
|
||||
# Check if this is an HTTP URL
|
||||
if isinstance(command_or_string, str) and re.match(r'^https?://', command_or_string):
|
||||
# Determine transport type based on URL or kwargs
|
||||
if command_or_string.endswith('/sse') and 'transport_type' not in kwargs:
|
||||
# Determine transport type based on URL or kwargs. Delegate the
|
||||
# URL->transport classification to the shared helper so there is a
|
||||
# single source of truth (see mcp_transport.get_transport_type).
|
||||
from .mcp_transport import get_transport_type
|
||||
if get_transport_type(command_or_string) == "sse" and 'transport_type' not in kwargs:
|
||||
# Legacy SSE URL - use SSE transport for backward compatibility
|
||||
from .mcp_sse import SSEMCPClient
|
||||
self.sse_client = SSEMCPClient(command_or_string, debug=debug, timeout=timeout)
|
||||
|
||||
@@ -16,6 +16,7 @@ Storage Structure:
|
||||
└── summaries.json # LLM-generated summaries
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import sys
|
||||
@@ -235,19 +236,40 @@ class FileMemory:
|
||||
|
||||
|
||||
def _write_json(self, filepath: Path, data: Any) -> bool:
|
||||
"""Write JSON file with file locking (Unix only)."""
|
||||
"""Write JSON file atomically, with file locking (Unix only).
|
||||
|
||||
Writes to a uniquely-named temp file in the same directory and atomically
|
||||
renames it into place via os.replace so a crash mid-write cannot leave the
|
||||
memory store truncated (open(path, 'w') truncates before any lock can be
|
||||
acquired). tempfile.mkstemp guarantees a distinct temp name per write, so
|
||||
concurrent writers in the same process (multiple threads or FileMemory
|
||||
instances) cannot clobber each other's temp file (matches the atomic-write
|
||||
pattern used across the SDK, e.g. storage/base.py and session/store.py).
|
||||
"""
|
||||
import tempfile
|
||||
tmp_fd, tmp_name = tempfile.mkstemp(
|
||||
dir=str(filepath.parent), prefix=f".{filepath.name}.", suffix=".tmp"
|
||||
)
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
with os.fdopen(tmp_fd, 'w', encoding='utf-8') as f:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
finally:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
os.replace(tmp_path, filepath)
|
||||
return True
|
||||
except IOError as e:
|
||||
except (IOError, OSError) as e:
|
||||
self._log(f"Error writing {filepath}: {e}", logging.ERROR)
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return False
|
||||
|
||||
|
||||
@@ -329,6 +351,13 @@ class FileMemory:
|
||||
The generated memory ID
|
||||
"""
|
||||
with self._lock:
|
||||
# Re-read the current on-disk state before mutating so a concurrent
|
||||
# writer sharing the same files (e.g. another agent with the same
|
||||
# user_id) doesn't get its entries silently discarded by this save.
|
||||
self._short_term = [
|
||||
MemoryItem.from_dict(i)
|
||||
for i in self._read_json(self.short_term_file, [])
|
||||
]
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
@@ -367,6 +396,12 @@ class FileMemory:
|
||||
def _auto_promote_to_long_term(self):
|
||||
"""Promote high-importance short-term memories to long-term."""
|
||||
# Note: This method is called within _lock context from add_short_term
|
||||
# Re-read long-term from disk before appending so concurrent writers
|
||||
# sharing the same files aren't overwritten by the promotion save.
|
||||
self._long_term = [
|
||||
MemoryItem.from_dict(i)
|
||||
for i in self._read_json(self.long_term_file, [])
|
||||
]
|
||||
threshold = self.config["importance_threshold"]
|
||||
promoted = []
|
||||
|
||||
@@ -404,6 +439,13 @@ class FileMemory:
|
||||
The generated memory ID
|
||||
"""
|
||||
with self._lock:
|
||||
# Re-read the current on-disk state before mutating so a concurrent
|
||||
# writer sharing the same files (e.g. another agent with the same
|
||||
# user_id) doesn't get its entries silently discarded by this save.
|
||||
self._long_term = [
|
||||
MemoryItem.from_dict(i)
|
||||
for i in self._read_json(self.long_term_file, [])
|
||||
]
|
||||
item = MemoryItem(
|
||||
id=self._generate_id(content),
|
||||
content=content,
|
||||
@@ -460,6 +502,13 @@ class FileMemory:
|
||||
The entity ID
|
||||
"""
|
||||
with self._lock:
|
||||
# Re-read the current on-disk state before mutating so a concurrent
|
||||
# writer sharing the same files (e.g. another agent with the same
|
||||
# user_id) doesn't get its entries silently discarded by this save.
|
||||
self._entities = {
|
||||
k: EntityItem.from_dict(v)
|
||||
for k, v in self._read_json(self.entities_file, {}).items()
|
||||
}
|
||||
entity_id = self._generate_id(f"{name}:{entity_type}")
|
||||
|
||||
# Check if entity exists
|
||||
|
||||
@@ -142,6 +142,10 @@ class BaseStore(ABC):
|
||||
Checks for exact content matches to prevent duplicate learnings.
|
||||
If a duplicate is found, returns the existing entry instead of creating a new one.
|
||||
"""
|
||||
# Re-read the current on-disk state before mutating so a concurrent
|
||||
# writer sharing the same store (e.g. another agent with the same
|
||||
# user_id) isn't silently overwritten by this save.
|
||||
self._load()
|
||||
# Deduplication: Check for exact content match
|
||||
content_normalized = content.strip().lower()
|
||||
for existing_entry in self._entries.values():
|
||||
|
||||
@@ -98,7 +98,7 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
vector-based memory for enhanced relationship-aware retrieval.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], verbose: int = 0):
|
||||
def __init__(self, config: Optional[Dict[str, Any]] = None, verbose: int = 0):
|
||||
self.cfg = config or {}
|
||||
self.verbose = verbose
|
||||
|
||||
@@ -707,11 +707,13 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
return []
|
||||
|
||||
else:
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory. This avoids the legacy short_mem schema mismatch
|
||||
# where the adapter creates short_term_memory but legacy queries
|
||||
# short_mem (which is never created by the adapter).
|
||||
if getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
|
||||
# Delegate to the active adapter whenever one is configured. This
|
||||
# mirrors the adapter-agnostic store path (store_short_term uses any
|
||||
# configured memory_adapter regardless of provider name), so data
|
||||
# stored via a registered adapter (e.g. "dakera") remains findable.
|
||||
# Otherwise the legacy short_mem query — never written to when the
|
||||
# adapter store succeeded — would always return [].
|
||||
if getattr(self, "memory_adapter", None):
|
||||
try:
|
||||
adapter_results = self.memory_adapter.search_short_term(query, limit=limit, **kwargs)
|
||||
if min_quality > 0:
|
||||
@@ -757,12 +759,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
|
||||
def reset_short_term(self):
|
||||
"""Completely clears short-term memory."""
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory, mirroring search_short_term. The adapter creates
|
||||
# Delegate to the active adapter whenever one is configured, mirroring
|
||||
# store_short_term/search_short_term. The adapter creates
|
||||
# short_term_memory; the legacy short_mem table below is never created
|
||||
# by the adapter, so DELETE FROM short_mem would raise.
|
||||
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
|
||||
and getattr(self, "memory_adapter", None)):
|
||||
if getattr(self, "memory_adapter", None):
|
||||
if hasattr(self.memory_adapter, "reset_short_term"):
|
||||
self.memory_adapter.reset_short_term()
|
||||
return
|
||||
@@ -817,15 +818,14 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
ident = str(time.time_ns())
|
||||
created = time.time()
|
||||
|
||||
# Protocol-driven storage: Try adapter first only when the provider has
|
||||
# fallen back to sqlite/in_memory. This keeps storage consistent with
|
||||
# search (which delegates to the adapter for the same providers) and
|
||||
# avoids the legacy long_mem schema mismatch. Other providers (chroma,
|
||||
# mem0, mongodb) keep their existing dedicated write paths below so the
|
||||
# embedding model and vector space stay consistent with search.
|
||||
# Protocol-driven storage: Try adapter first whenever one is configured.
|
||||
# This mirrors store_short_term (adapter-agnostic) and keeps storage
|
||||
# symmetric with search_long_term, so data stored via a registered
|
||||
# adapter (e.g. "dakera") is written to — and later found in — the same
|
||||
# place. The dedicated chroma/mem0/mongodb write paths below are only
|
||||
# used when no adapter handled the store.
|
||||
adapter_success = False
|
||||
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
|
||||
and getattr(self, "memory_adapter", None)):
|
||||
if getattr(self, "memory_adapter", None):
|
||||
try:
|
||||
result_id = self.memory_adapter.store_long_term(text, metadata=metadata)
|
||||
logger.info(f"Successfully stored via memory adapter with ID: {result_id}")
|
||||
@@ -858,14 +858,13 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
# Continue to SQLite fallback
|
||||
|
||||
# Store in SQLite (with write lock for concurrency safety).
|
||||
# Skip the legacy long_mem schema for adapter-driven sqlite/in_memory
|
||||
# providers: those use the adapter's long_term_memory table and the
|
||||
# legacy long_mem table is never created, which would silently drop
|
||||
# the write. Surface the adapter failure instead.
|
||||
if not adapter_success and getattr(self, "provider", None) in ("sqlite", "in_memory") \
|
||||
and getattr(self, "memory_adapter", None):
|
||||
# Skip the legacy long_mem schema for adapter-driven providers: those use
|
||||
# the adapter's long_term_memory table and the legacy long_mem table is
|
||||
# never created, which would silently drop the write (and search delegates
|
||||
# to the adapter, not long_mem). Surface the adapter failure instead.
|
||||
if not adapter_success and getattr(self, "memory_adapter", None):
|
||||
raise RuntimeError(
|
||||
"Long-term store failed via adapter for sqlite/in_memory provider; "
|
||||
"Long-term store failed via memory adapter; "
|
||||
"the legacy long_mem table is not schema-compatible."
|
||||
)
|
||||
|
||||
@@ -1057,10 +1056,12 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
except Exception as e:
|
||||
self._log_verbose(f"Error searching ChromaDB: {e}", logging.ERROR)
|
||||
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory. This avoids the legacy long_mem schema mismatch
|
||||
# where the adapter creates long_term_memory but legacy queries long_mem.
|
||||
if not found and getattr(self, "provider", None) in ("sqlite", "in_memory") and getattr(self, "memory_adapter", None):
|
||||
# Delegate to the active adapter whenever one is configured. This mirrors
|
||||
# the adapter-agnostic store path (store_long_term uses any configured
|
||||
# memory_adapter regardless of provider name), so data stored via a
|
||||
# registered adapter (e.g. "dakera") remains findable rather than being
|
||||
# lost to the legacy long_mem query that was never written to.
|
||||
if not found and getattr(self, "memory_adapter", None):
|
||||
try:
|
||||
adapter_results = self.memory_adapter.search_long_term(query, limit=limit, **kwargs)
|
||||
if min_quality > 0:
|
||||
@@ -1129,12 +1130,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
|
||||
def reset_long_term(self):
|
||||
"""Clear local LTM DB, plus Chroma, MongoDB, or mem0 if in use."""
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory, mirroring search_long_term. The adapter creates
|
||||
# long_term_memory; the legacy long_mem table below is never created
|
||||
# by the adapter, so DELETE FROM long_mem would raise.
|
||||
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
|
||||
and getattr(self, "memory_adapter", None)):
|
||||
# Delegate to the active adapter whenever one is configured, mirroring
|
||||
# store_long_term/search_long_term. The adapter creates long_term_memory;
|
||||
# the legacy long_mem table below is never created by the adapter, so
|
||||
# DELETE FROM long_mem would raise.
|
||||
if getattr(self, "memory_adapter", None):
|
||||
if hasattr(self.memory_adapter, "reset_long_term"):
|
||||
self.memory_adapter.reset_long_term()
|
||||
return
|
||||
@@ -1186,12 +1186,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory, mirroring search_short_term. Data written through
|
||||
# the adapter lives in short_term_memory, not the legacy short_mem
|
||||
# table, so a direct DELETE below would silently match zero rows.
|
||||
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
|
||||
and getattr(self, "memory_adapter", None)
|
||||
# Delegate to the active adapter whenever one is configured, mirroring
|
||||
# store_short_term/search_short_term. Data written through the adapter
|
||||
# lives in short_term_memory, not the legacy short_mem table, so a direct
|
||||
# DELETE below would silently match zero rows.
|
||||
if (getattr(self, "memory_adapter", None)
|
||||
and hasattr(self.memory_adapter, "delete_memory")):
|
||||
return self.memory_adapter.delete_memory(memory_id, tier="short")
|
||||
|
||||
@@ -1236,12 +1235,11 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
Returns:
|
||||
True if memory was found and deleted, False otherwise
|
||||
"""
|
||||
# Delegate to the active adapter when provider falls back to
|
||||
# sqlite/in_memory, mirroring search_long_term. Data written through
|
||||
# the adapter lives in long_term_memory, not the legacy long_mem
|
||||
# table, so a direct DELETE below would silently match zero rows.
|
||||
if (getattr(self, "provider", None) in ("sqlite", "in_memory")
|
||||
and getattr(self, "memory_adapter", None)
|
||||
# Delegate to the active adapter whenever one is configured, mirroring
|
||||
# store_long_term/search_long_term. Data written through the adapter
|
||||
# lives in long_term_memory, not the legacy long_mem table, so a direct
|
||||
# DELETE below would silently match zero rows.
|
||||
if (getattr(self, "memory_adapter", None)
|
||||
and hasattr(self.memory_adapter, "delete_memory")):
|
||||
return self.memory_adapter.delete_memory(memory_id, tier="long")
|
||||
|
||||
@@ -1397,6 +1395,62 @@ class Memory(SearchMixin, MemoryCoreMixin):
|
||||
|
||||
return deleted
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Convenience API (remember / recall / forget)
|
||||
# -------------------------------------------------------------------------
|
||||
# Thin, intuitive aliases over the existing long-term store/search/delete
|
||||
# methods. They do not replace any backend or add new storage — they simply
|
||||
# provide a friendlier entry point for standalone scripts and agents.
|
||||
def remember(
|
||||
self,
|
||||
content: str,
|
||||
*,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Store a fact in long-term memory; returns the memory id.
|
||||
|
||||
Convenience alias over :meth:`store_long_term`.
|
||||
"""
|
||||
return self.store_long_term(content, metadata=metadata, **kwargs)
|
||||
|
||||
def recall(
|
||||
self,
|
||||
query: str,
|
||||
*,
|
||||
limit: int = 5,
|
||||
**kwargs,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Retrieve matching facts from long-term memory.
|
||||
|
||||
Convenience alias over :meth:`search_long_term`.
|
||||
"""
|
||||
return self.search_long_term(query, limit=limit, **kwargs)
|
||||
|
||||
def forget(
|
||||
self,
|
||||
*,
|
||||
memory_id: Optional[str] = None,
|
||||
query: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> int:
|
||||
"""Delete memories by id or matching query; returns count deleted.
|
||||
|
||||
Convenience wrapper over :meth:`delete_memory` /
|
||||
:meth:`delete_memories_matching`. Provide exactly one of
|
||||
``memory_id`` or ``query``.
|
||||
"""
|
||||
if (memory_id is None) == (query is None):
|
||||
raise ValueError("forget() requires exactly one of 'memory_id' or 'query'")
|
||||
if memory_id is not None:
|
||||
return 1 if self.delete_memory(memory_id, **kwargs) else 0
|
||||
if not query.strip():
|
||||
raise ValueError("forget() query must be a non-empty string")
|
||||
# Scope query-based deletion to long-term memory to mirror
|
||||
# remember()/recall(), which operate on long-term storage only.
|
||||
kwargs.setdefault("memory_type", "long_term")
|
||||
return self.delete_memories_matching(query, **kwargs)
|
||||
|
||||
# -------------------------------------------------------------------------
|
||||
# Entity Memory Methods
|
||||
# -------------------------------------------------------------------------
|
||||
|
||||
@@ -117,6 +117,20 @@ def get_sessions_dir() -> Path:
|
||||
return get_data_dir() / "sessions"
|
||||
|
||||
|
||||
def get_session_spill_dir() -> Path:
|
||||
"""
|
||||
Get the session spill directory (last-resort salvage on write failure).
|
||||
|
||||
When a durable session write fails (disk-full / corruption / permission),
|
||||
the already-produced turn is spilled here atomically and re-ingested on the
|
||||
next load (Issue #3597).
|
||||
|
||||
Returns:
|
||||
Path to ~/.praisonai/state/session_spill/
|
||||
"""
|
||||
return get_data_dir() / "state" / "session_spill"
|
||||
|
||||
|
||||
def get_skills_dir() -> Path:
|
||||
"""
|
||||
Get user skills directory.
|
||||
|
||||
@@ -102,14 +102,8 @@ class DoomLoopDetector:
|
||||
|
||||
def _hash_arguments(self, arguments: Dict[str, Any]) -> str:
|
||||
"""Create a hash of tool arguments."""
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
try:
|
||||
arg_str = json.dumps(arguments, sort_keys=True, default=str)
|
||||
return hashlib.sha256(arg_str.encode()).hexdigest()[:16]
|
||||
except (TypeError, ValueError):
|
||||
return "unhashable"
|
||||
from praisonaiagents.approval.utils import hash_tool_args
|
||||
return hash_tool_args(arguments)
|
||||
|
||||
def _cleanup_old_records(self):
|
||||
"""Remove records outside the time window."""
|
||||
|
||||
@@ -430,14 +430,19 @@ class PermissionManager:
|
||||
"""Legacy flat matching against approvals and rules for a target."""
|
||||
with self._lock:
|
||||
# Check persistent approvals first
|
||||
for approval in self._approvals:
|
||||
for approval in list(self._approvals):
|
||||
if approval.matches(target, agent):
|
||||
return PermissionResult(
|
||||
result = PermissionResult(
|
||||
action=PermissionAction.ALLOW if approval.approved else PermissionAction.DENY,
|
||||
target=target,
|
||||
reason=f"Persistent approval: {'approved' if approval.approved else 'denied'}",
|
||||
approved=approval.approved,
|
||||
)
|
||||
# A "once" approval/denial is consumed on first match so it
|
||||
# does not silently become permanent for the process.
|
||||
if approval.scope == "once":
|
||||
self._approvals.remove(approval)
|
||||
return result
|
||||
|
||||
# Check rules
|
||||
for rule in self._rules:
|
||||
|
||||
@@ -39,6 +39,55 @@ class PermissionMode(str, Enum):
|
||||
BYPASS = "bypass_permissions"
|
||||
PLAN = "plan"
|
||||
|
||||
@classmethod
|
||||
def resolve(cls, value: Any) -> Optional["PermissionMode"]:
|
||||
"""Resolve a preset name/alias to a canonical ``PermissionMode``.
|
||||
|
||||
This is the single place that maps the many historical spellings for
|
||||
"how much the agent may do" onto one enum, so CLI, YAML and Python all
|
||||
agree. Returns ``None`` for values that are not a permission preset
|
||||
(e.g. the deny-set presets ``safe``/``read_only``/``full``/``off``),
|
||||
letting callers fall through to their existing handling.
|
||||
|
||||
Recognised aliases (case-insensitive, ``-``/``_`` interchangeable):
|
||||
- ``plan`` … PLAN (read-only)
|
||||
- ``bypass``, ``bypass_permissions``, ``yolo``, ``full_auto`` … BYPASS
|
||||
- ``accept_edits``, ``auto_edit`` … ACCEPT_EDITS
|
||||
- ``dont_ask``, ``reject``, ``no_ask`` … DONT_ASK
|
||||
- ``default``, ``ask``, ``suggest``, ``prompt`` … DEFAULT
|
||||
|
||||
Note: the deny-set presets ``safe``/``read_only``/``full``/``off`` are
|
||||
deliberately *not* modes and return ``None`` here — they are handled by
|
||||
their own deny-set machinery in ``Agent``.
|
||||
"""
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
key = value.strip().lower().replace("-", "_")
|
||||
return _MODE_ALIASES.get(key)
|
||||
|
||||
|
||||
# Deprecated/alternate spellings (AutonomyMode / ApprovalMode / CLI flags) that
|
||||
# resolve onto the single ``PermissionMode``. Kept as thin aliases so the same
|
||||
# preset name means the same thing across every surface (backward-compatible).
|
||||
_MODE_ALIASES: Dict[str, PermissionMode] = {
|
||||
"default": PermissionMode.DEFAULT,
|
||||
"ask": PermissionMode.DEFAULT,
|
||||
"suggest": PermissionMode.DEFAULT,
|
||||
"prompt": PermissionMode.DEFAULT,
|
||||
"plan": PermissionMode.PLAN,
|
||||
"accept_edits": PermissionMode.ACCEPT_EDITS,
|
||||
"auto_edit": PermissionMode.ACCEPT_EDITS,
|
||||
"dont_ask": PermissionMode.DONT_ASK,
|
||||
"no_ask": PermissionMode.DONT_ASK,
|
||||
"reject": PermissionMode.DONT_ASK,
|
||||
"bypass": PermissionMode.BYPASS,
|
||||
"bypass_permissions": PermissionMode.BYPASS,
|
||||
"yolo": PermissionMode.BYPASS,
|
||||
"full_auto": PermissionMode.BYPASS,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PermissionRule:
|
||||
@@ -227,7 +276,12 @@ class PersistentApproval:
|
||||
if not self.is_valid():
|
||||
return False
|
||||
|
||||
if self.agent_name and agent_name and self.agent_name != agent_name:
|
||||
# An approval scoped to a specific agent must never match a call that
|
||||
# can't prove it's that agent — including calls with no agent_name at
|
||||
# all. ``agent_name != self.agent_name`` covers both "different name"
|
||||
# and "no name" (None != "trusted-agent" is True), closing the bypass
|
||||
# while leaving unscoped approvals (self.agent_name is None) unaffected.
|
||||
if self.agent_name is not None and agent_name != self.agent_name:
|
||||
return False
|
||||
|
||||
if fnmatch.fnmatch(target, self.pattern):
|
||||
|
||||
@@ -32,7 +32,7 @@ class TodoItem:
|
||||
Attributes:
|
||||
id: Unique identifier
|
||||
description: What needs to be done
|
||||
status: Current status (pending, in_progress, completed)
|
||||
status: Current status (pending, in_progress, completed, cancelled)
|
||||
dependencies: List of item IDs that must complete first
|
||||
agent: Name of the agent responsible
|
||||
priority: Priority level (low, medium, high)
|
||||
@@ -40,7 +40,7 @@ class TodoItem:
|
||||
"""
|
||||
description: str
|
||||
id: str = field(default_factory=lambda: str(uuid.uuid4())[:8])
|
||||
status: Literal["pending", "in_progress", "completed"] = "pending"
|
||||
status: Literal["pending", "in_progress", "completed", "cancelled"] = "pending"
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
agent: Optional[str] = None
|
||||
priority: Literal["low", "medium", "high"] = "medium"
|
||||
|
||||
@@ -43,6 +43,7 @@ __all__ = [
|
||||
"maybe_enable_from_config",
|
||||
"list_plugins",
|
||||
"is_enabled",
|
||||
"get_plugin_registry",
|
||||
# Core
|
||||
"PluginManager",
|
||||
"Plugin",
|
||||
@@ -304,6 +305,119 @@ def is_enabled(name: str = None) -> bool:
|
||||
return manager.is_enabled(name)
|
||||
|
||||
|
||||
def get_plugin_registry() -> list:
|
||||
"""Return a truthful, unified view of all discoverable plugins.
|
||||
|
||||
Combines three real sources — no hardcoded/fake entries:
|
||||
|
||||
- Entry-point plugins (installed pip packages registering in the
|
||||
``praisonai.plugins`` group), reported with their ``source`` as their
|
||||
distribution/entry-point provenance.
|
||||
- Registered ``Plugin`` instances held by the :class:`PluginManager`.
|
||||
- Project/user single-file plugins discovered on disk (``.praisonai/plugins``
|
||||
and ``~/.praisonai/plugins``), reported without executing them.
|
||||
|
||||
Each entry is a dict with ``name``, ``version``, ``source``
|
||||
(``entry_point`` | ``registered`` | ``single_file``), ``enabled`` state,
|
||||
``hooks`` and ``description``. Enabled state reflects both the live manager
|
||||
and the persisted config allow-list, so a truthful CLI can render it
|
||||
without a running agent.
|
||||
|
||||
Returns:
|
||||
List of plugin entry dicts.
|
||||
"""
|
||||
from .manager import get_plugin_manager
|
||||
|
||||
manager = get_plugin_manager()
|
||||
|
||||
# Config-driven enabled allow-list (None => all enabled when plugins on).
|
||||
config_enabled = None
|
||||
try:
|
||||
from ..config.loader import get_enabled_plugins
|
||||
|
||||
config_enabled = get_enabled_plugins()
|
||||
except Exception:
|
||||
config_enabled = None
|
||||
|
||||
def _config_says_enabled(name: str) -> bool:
|
||||
if config_enabled is None:
|
||||
return manager.is_enabled(name)
|
||||
return name in config_enabled
|
||||
|
||||
entries: list = []
|
||||
seen: set = set()
|
||||
|
||||
# 1. Registered Plugin instances (includes entry-point plugins already
|
||||
# loaded via discover_entry_points()).
|
||||
for info in manager.list_plugins():
|
||||
name = getattr(info, "name", None)
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
hooks = [
|
||||
h.value if hasattr(h, "value") else str(h)
|
||||
for h in (getattr(info, "hooks", None) or [])
|
||||
]
|
||||
entries.append({
|
||||
"name": name,
|
||||
"version": getattr(info, "version", "1.0.0"),
|
||||
"description": getattr(info, "description", ""),
|
||||
"source": "registered",
|
||||
"enabled": manager.is_enabled(name) or _config_says_enabled(name),
|
||||
"hooks": hooks,
|
||||
})
|
||||
|
||||
# 2. Entry-point plugins present on the system but not yet loaded, so
|
||||
# `list` shows provenance even before enable().
|
||||
try:
|
||||
import importlib.metadata as _md
|
||||
|
||||
try:
|
||||
eps = _md.entry_points(group="praisonai.plugins")
|
||||
except TypeError:
|
||||
eps = _md.entry_points().get("praisonai.plugins", [])
|
||||
for ep in eps:
|
||||
if ep.name in seen:
|
||||
continue
|
||||
seen.add(ep.name)
|
||||
dist = getattr(getattr(ep, "dist", None), "name", None)
|
||||
entries.append({
|
||||
"name": ep.name,
|
||||
"version": "-",
|
||||
"description": "",
|
||||
"source": f"entry_point:{dist}" if dist else "entry_point",
|
||||
"enabled": _config_says_enabled(ep.name),
|
||||
"hooks": [],
|
||||
})
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(f"Entry-point plugin scan failed: {e}")
|
||||
|
||||
# 3. Single-file plugins discovered on disk (metadata only, no exec).
|
||||
try:
|
||||
from .discovery import discover_plugins
|
||||
|
||||
for meta in discover_plugins():
|
||||
name = meta.get("name")
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
entries.append({
|
||||
"name": name,
|
||||
"version": meta.get("version", "1.0.0"),
|
||||
"description": meta.get("description", ""),
|
||||
"source": "single_file",
|
||||
"enabled": manager.is_enabled(name) or _config_says_enabled(name),
|
||||
"hooks": list(meta.get("hooks", []) or []),
|
||||
"path": meta.get("path"),
|
||||
})
|
||||
except Exception as e:
|
||||
import logging
|
||||
logging.getLogger(__name__).debug(f"Single-file plugin discovery failed: {e}")
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components."""
|
||||
# Core classes
|
||||
|
||||
@@ -19,6 +19,8 @@ Usage:
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import os
|
||||
import threading
|
||||
from praisonaiagents._logging import get_logger
|
||||
import sys
|
||||
from pathlib import Path
|
||||
@@ -29,6 +31,70 @@ from ..paths import get_plugins_dir, get_project_data_dir
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# Maps a loaded plugin's generated module name to the tool names it harvested,
|
||||
# so unload_plugin can unregister them instead of leaking active tools.
|
||||
_loaded_plugin_tools: Dict[str, List[str]] = {}
|
||||
# Guards the read-modify-write of _loaded_plugin_tools so concurrent load and
|
||||
# unload calls from different threads cannot leave a tool registered with no
|
||||
# tracking entry.
|
||||
_loaded_plugin_tools_lock = threading.Lock()
|
||||
|
||||
|
||||
def _project_plugins_allowed() -> bool:
|
||||
"""Whether executing project-local single-file plugins is authorised.
|
||||
|
||||
Single-file plugins in ``./.praisonai/plugins/*.py`` can hook every
|
||||
lifecycle event and intercept every tool call, so they are the most
|
||||
privileged extension surface. They therefore share the same opt-in trust
|
||||
gate that project-local tools use: an explicit environment flag. This
|
||||
mirrors ``PRAISONAI_ALLOW_LOCAL_TOOLS`` for tools; a cloned repo carrying a
|
||||
malicious ``.praisonai/plugins/exfil.py`` will not run until the user opts
|
||||
in.
|
||||
|
||||
User-global plugins (``~/.praisonai/plugins/``) are treated as trusted
|
||||
(the user placed them there themselves), matching entry-point plugins
|
||||
installed via pip.
|
||||
"""
|
||||
env = os.environ.get("PRAISONAI_ALLOW_PROJECT_PLUGINS", "").strip().lower()
|
||||
if env in ("true", "1", "yes", "on"):
|
||||
return True
|
||||
if env in ("false", "0", "no", "off"):
|
||||
return False
|
||||
try:
|
||||
from ..config.loader import get_plugins_config
|
||||
|
||||
return bool(getattr(get_plugins_config(), "allow_project_plugins", False))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _is_project_local(path: Path) -> bool:
|
||||
"""True when ``path`` is reached via the project-local plugins directory.
|
||||
|
||||
The trust gate must fire on *how the file was reached*, not on where a
|
||||
symlink ultimately points. A repository-controlled symlink at
|
||||
``.praisonai/plugins/evil.py -> /tmp/evil.py`` is still project-controlled
|
||||
code, so we compare the file's own (un-resolved) location against the
|
||||
project plugins directory. We resolve only the parent directories (not the
|
||||
final component) so a project ``plugins`` symlink is still recognised while
|
||||
a symlinked plugin *file* inside it cannot slip the gate by resolving
|
||||
elsewhere.
|
||||
"""
|
||||
try:
|
||||
project_plugins = (get_project_data_dir() / "plugins").resolve()
|
||||
except Exception:
|
||||
return False
|
||||
try:
|
||||
# Resolve the containing directory (following any symlinked dirs) but
|
||||
# keep the file's own name un-resolved, so a symlinked plugin file is
|
||||
# judged by its location under .praisonai/plugins, not its target.
|
||||
located = path.expanduser()
|
||||
candidate = located.parent.resolve() / located.name
|
||||
candidate.relative_to(project_plugins)
|
||||
return True
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def get_default_plugin_dirs() -> List[Path]:
|
||||
"""Get default plugin directory locations.
|
||||
|
||||
@@ -132,8 +198,12 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
|
||||
Returns:
|
||||
Plugin metadata dict with 'tools' and 'hooks' lists, or None on error
|
||||
"""
|
||||
path = Path(filepath).resolve()
|
||||
|
||||
# Keep the caller's location un-resolved for the trust gate so a
|
||||
# repository-controlled symlink under .praisonai/plugins cannot escape the
|
||||
# gate by pointing its target elsewhere.
|
||||
located = Path(filepath).expanduser()
|
||||
path = located.resolve()
|
||||
|
||||
if not path.exists():
|
||||
logger.error(f"Plugin file not found: {filepath}")
|
||||
return None
|
||||
@@ -141,7 +211,20 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
|
||||
if not path.suffix == '.py':
|
||||
logger.error(f"Plugin must be a Python file: {filepath}")
|
||||
return None
|
||||
|
||||
|
||||
# Trust gate: project-local single-file plugins are the most privileged
|
||||
# extension surface (they can hook every lifecycle event and intercept
|
||||
# every tool call), so refuse to exec them unless explicitly authorised —
|
||||
# matching the opt-in required for project-local tools.
|
||||
if _is_project_local(located) and not _project_plugins_allowed():
|
||||
logger.warning(
|
||||
"Refusing to load project plugin %s: set "
|
||||
"PRAISONAI_ALLOW_PROJECT_PLUGINS=true (or plugins.allow_project_plugins "
|
||||
"in .praisonai/config.yaml) to enable.",
|
||||
path,
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Parse header first
|
||||
metadata = parse_plugin_header_from_file(str(path))
|
||||
@@ -166,7 +249,15 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
|
||||
|
||||
# Snapshot the registry before exec: the @tool decorator auto-registers
|
||||
# on module load, so any tool name present *after* exec but not before
|
||||
# is one this module contributed and therefore owns.
|
||||
try:
|
||||
pre_exec_tools = set(registry.list_tools())
|
||||
except Exception:
|
||||
pre_exec_tools = set()
|
||||
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception as e:
|
||||
@@ -178,20 +269,31 @@ def load_plugin(filepath: str) -> Optional[Dict[str, Any]]:
|
||||
# The @tool decorator creates FunctionTool instances but may not
|
||||
# register them if the registry wasn't fully initialized
|
||||
new_tools = []
|
||||
owned_tools = []
|
||||
for attr_name in dir(module):
|
||||
if attr_name.startswith('_'):
|
||||
continue
|
||||
attr = getattr(module, attr_name, None)
|
||||
if isinstance(attr, BaseTool):
|
||||
tool_name = attr.name
|
||||
# Register if not already registered
|
||||
# Register any tool not already present (e.g. if the decorator
|
||||
# could not auto-register).
|
||||
if registry.get(tool_name) is None:
|
||||
registry.register(attr)
|
||||
# A tool is "owned" (safe to unregister on unload) only if it
|
||||
# was absent before this module executed — tools already present
|
||||
# belong to another plugin or the core registry.
|
||||
if tool_name not in pre_exec_tools:
|
||||
owned_tools.append(tool_name)
|
||||
new_tools.append(tool_name)
|
||||
|
||||
# Add discovered tools to metadata
|
||||
metadata["tools"] = new_tools
|
||||
metadata["module"] = module_name
|
||||
# Remember only the tools this module actually registered so unload can
|
||||
# clean them up without removing tools still in use elsewhere.
|
||||
with _loaded_plugin_tools_lock:
|
||||
_loaded_plugin_tools[module_name] = owned_tools
|
||||
|
||||
logger.info(f"Loaded plugin: {metadata['name']} (tools: {new_tools})")
|
||||
return metadata
|
||||
@@ -230,17 +332,33 @@ def discover_and_load_plugins(
|
||||
return loaded
|
||||
|
||||
def unload_plugin(module_name: str) -> bool:
|
||||
"""Unload a plugin module.
|
||||
|
||||
Note: This removes the module from sys.modules but does NOT
|
||||
unregister tools or hooks that were already registered.
|
||||
|
||||
"""Unload a plugin module and unregister the tools it harvested.
|
||||
|
||||
Removes the module from ``sys.modules`` and unregisters every tool the
|
||||
module contributed to the global tool registry, so a disable-by-unload no
|
||||
longer leaks active tools.
|
||||
|
||||
Args:
|
||||
module_name: The module name (from plugin metadata)
|
||||
|
||||
|
||||
Returns:
|
||||
True if unloaded, False if not found
|
||||
True if the module was present and unloaded, False if not found
|
||||
"""
|
||||
with _loaded_plugin_tools_lock:
|
||||
tools = _loaded_plugin_tools.pop(module_name, [])
|
||||
if tools:
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
for tool_name in tools:
|
||||
try:
|
||||
registry.unregister(tool_name)
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to unregister tool '{tool_name}': {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to access tool registry during unload: {e}")
|
||||
|
||||
if module_name in sys.modules:
|
||||
del sys.modules[module_name]
|
||||
return True
|
||||
|
||||
@@ -21,6 +21,21 @@ if TYPE_CHECKING:
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def _env_plugins_suppressed() -> bool:
|
||||
"""Return True when external plugins are suppressed via env for this run.
|
||||
|
||||
Honours ``PRAISONAI_NO_PLUGINS`` (set by the CLI ``--pure`` / ``--no-plugins``
|
||||
flag) so a single invocation can skip discovery of external plugins without
|
||||
mutating any persisted enable/disable state.
|
||||
"""
|
||||
import os
|
||||
|
||||
return os.environ.get("PRAISONAI_NO_PLUGINS", "").strip().lower() in (
|
||||
"true", "1", "yes",
|
||||
)
|
||||
|
||||
|
||||
class PluginManager:
|
||||
"""
|
||||
Manages plugin discovery, loading, and execution.
|
||||
@@ -44,7 +59,7 @@ class PluginManager:
|
||||
args = await manager.async_execute_hook(PluginHook.BEFORE_TOOL, "bash", {"cmd": "ls"})
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, disabled: Optional[bool] = None):
|
||||
self._plugins: Dict[str, Plugin] = {}
|
||||
self._enabled: Dict[str, bool] = {}
|
||||
self._single_file_plugins: Dict[str, Dict[str, Any]] = {} # WordPress-style plugins
|
||||
@@ -52,6 +67,30 @@ class PluginManager:
|
||||
# delivered to each plugin's on_config hook. {plugin_name: options}.
|
||||
self._plugin_options: Dict[str, Dict[str, Any]] = {}
|
||||
self._lock = threading.RLock() # Thread safety for multi-agent environments
|
||||
# Ephemeral, per-process suppression of external plugin discovery.
|
||||
# ``None`` (default) defers to the ``PRAISONAI_NO_PLUGINS`` env var so
|
||||
# the CLI ``--pure`` / ``--no-plugins`` flag works; passing ``True``
|
||||
# (e.g. Python ``plugins=False`` parity) forces suppression regardless.
|
||||
self._disabled = disabled
|
||||
self._suppression_notified = False
|
||||
|
||||
def is_discovery_disabled(self) -> bool:
|
||||
"""Return True when external plugin discovery is suppressed this run.
|
||||
|
||||
A constructor ``disabled=True`` wins; otherwise defers to the
|
||||
``PRAISONAI_NO_PLUGINS`` env var. Never mutates persisted state.
|
||||
"""
|
||||
if self._disabled is not None:
|
||||
return bool(self._disabled)
|
||||
return _env_plugins_suppressed()
|
||||
|
||||
def _notify_suppressed_once(self) -> None:
|
||||
"""Emit a single 'running without external plugins' notice per manager."""
|
||||
if not self._suppression_notified:
|
||||
self._suppression_notified = True
|
||||
logger.info(
|
||||
"Running without external plugins (--pure / PRAISONAI_NO_PLUGINS)"
|
||||
)
|
||||
|
||||
def set_plugin_options(
|
||||
self,
|
||||
@@ -488,6 +527,10 @@ class PluginManager:
|
||||
"""
|
||||
import os
|
||||
|
||||
if self.is_discovery_disabled():
|
||||
self._notify_suppressed_once()
|
||||
return 0
|
||||
|
||||
if os.environ.get("PRAISONAI_ALLOW_PLUGIN_DISCOVERY", "").strip().lower() not in (
|
||||
"true", "1", "yes",
|
||||
):
|
||||
@@ -525,6 +568,10 @@ class PluginManager:
|
||||
Returns:
|
||||
Number of plugins loaded successfully.
|
||||
"""
|
||||
if self.is_discovery_disabled():
|
||||
self._notify_suppressed_once()
|
||||
return 0
|
||||
|
||||
try:
|
||||
import importlib.metadata as _md
|
||||
except ImportError:
|
||||
|
||||
@@ -11,10 +11,19 @@ Format:
|
||||
Author: Your Name
|
||||
Hooks: before_tool, after_tool
|
||||
Dependencies: requests, aiohttp
|
||||
Channels: telegram, slack
|
||||
Provides: get_weather, send_email
|
||||
Config: api_key, timeout
|
||||
Auto Enable When Configured: TELEGRAM_TOKEN
|
||||
'''
|
||||
|
||||
This is the SIMPLEST possible plugin format - just a Python file
|
||||
with a docstring header at the top.
|
||||
|
||||
The optional ``Channels``/``Provides``/``Config``/``Auto Enable When
|
||||
Configured`` fields form a static capability manifest: they let discovery,
|
||||
config validation and capability gating read *what a plugin offers* WITHOUT
|
||||
importing its runtime (the header is parsed as plain text).
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -38,6 +47,11 @@ class PluginMetadata:
|
||||
hooks: List[str] = field(default_factory=list)
|
||||
dependencies: List[str] = field(default_factory=list)
|
||||
path: Optional[str] = None
|
||||
# Static capability manifest (read without importing the plugin's runtime):
|
||||
channels: List[str] = field(default_factory=list)
|
||||
provides: List[str] = field(default_factory=list)
|
||||
config: List[str] = field(default_factory=list)
|
||||
auto_enable_when_configured: List[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary."""
|
||||
@@ -49,6 +63,10 @@ class PluginMetadata:
|
||||
"hooks": self.hooks,
|
||||
"dependencies": self.dependencies,
|
||||
"path": self.path,
|
||||
"channels": self.channels,
|
||||
"provides": self.provides,
|
||||
"config": self.config,
|
||||
"auto_enable_when_configured": self.auto_enable_when_configured,
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +125,25 @@ def parse_plugin_header(content: str) -> Dict[str, Any]:
|
||||
"hooks": "hooks",
|
||||
"dependencies": "dependencies",
|
||||
"deps": "dependencies",
|
||||
# Static capability manifest fields (no import required to read):
|
||||
"channels": "channels",
|
||||
"provides": "provides",
|
||||
"provides_tools": "provides",
|
||||
"tools": "provides",
|
||||
"config": "config",
|
||||
"config_schema": "config",
|
||||
"auto enable when configured": "auto_enable_when_configured",
|
||||
"auto_enable_when_configured": "auto_enable_when_configured",
|
||||
}
|
||||
|
||||
list_fields = (
|
||||
"hooks",
|
||||
"dependencies",
|
||||
"channels",
|
||||
"provides",
|
||||
"config",
|
||||
"auto_enable_when_configured",
|
||||
)
|
||||
|
||||
for line in docstring.strip().split('\n'):
|
||||
line = line.strip()
|
||||
@@ -123,7 +159,7 @@ def parse_plugin_header(content: str) -> Dict[str, Any]:
|
||||
field_name = field_mapping.get(key)
|
||||
if field_name:
|
||||
# Handle list fields (comma-separated)
|
||||
if field_name in ("hooks", "dependencies"):
|
||||
if field_name in list_fields:
|
||||
if value:
|
||||
metadata[field_name] = [v.strip() for v in value.split(',') if v.strip()]
|
||||
else:
|
||||
@@ -193,4 +229,8 @@ def create_plugin_metadata(data: Dict[str, Any]) -> PluginMetadata:
|
||||
hooks=data.get("hooks", []),
|
||||
dependencies=data.get("dependencies", []),
|
||||
path=data.get("path"),
|
||||
channels=data.get("channels", []),
|
||||
provides=data.get("provides", []),
|
||||
config=data.get("config", []),
|
||||
auto_enable_when_configured=data.get("auto_enable_when_configured", []),
|
||||
)
|
||||
@@ -334,16 +334,61 @@ def create_read_only_policy(name: str = "read_only") -> Policy:
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:*_write*",
|
||||
resource="tool:write_*",
|
||||
reason="Write tools not allowed in read-only mode",
|
||||
name="deny_write_tools"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:*_delete*",
|
||||
resource="tool:*_write*",
|
||||
reason="Write tools not allowed in read-only mode",
|
||||
name="deny_write_tools_suffix"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:delete_*",
|
||||
reason="Delete tools not allowed in read-only mode",
|
||||
name="deny_delete_tools"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:*_delete*",
|
||||
reason="Delete tools not allowed in read-only mode",
|
||||
name="deny_delete_tools_suffix"
|
||||
),
|
||||
# Other built-in mutating tools shipped by the SDK
|
||||
# (edit_file, apply_patch, copy_file, move_file, append_file, ...)
|
||||
# whose names don't contain "write"/"delete".
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:edit_*",
|
||||
reason="Edit tools not allowed in read-only mode",
|
||||
name="deny_edit_tools"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:apply_patch*",
|
||||
reason="Patch tools not allowed in read-only mode",
|
||||
name="deny_patch_tools"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:copy_*",
|
||||
reason="Copy tools not allowed in read-only mode",
|
||||
name="deny_copy_tools"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:move_*",
|
||||
reason="Move tools not allowed in read-only mode",
|
||||
name="deny_move_tools"
|
||||
),
|
||||
PolicyRule(
|
||||
action=PolicyAction.DENY,
|
||||
resource="tool:append_*",
|
||||
reason="Append tools not allowed in read-only mode",
|
||||
name="deny_append_tools"
|
||||
),
|
||||
],
|
||||
priority=100
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""OpenAI-compatible schema for hierarchical manager delegation."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ManagerInstructions(BaseModel):
|
||||
"""Instructions emitted by the hierarchical manager each delegation turn.
|
||||
|
||||
``extra="forbid"`` makes Pydantic emit ``additionalProperties: false`` in the
|
||||
generated JSON schema, which OpenAI's strict structured-output API requires.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
task_id: int = Field(
|
||||
...,
|
||||
description=(
|
||||
"Exact task_id integer of the task to run next, chosen from the "
|
||||
"task_id values shown in the tasks list (these ids are 0-based). "
|
||||
"Never select manager_task and never invent an id."
|
||||
),
|
||||
)
|
||||
agent_name: str = Field(..., description="Name of the agent assigned to the task")
|
||||
action: str = Field(..., description="'execute' to run the task or 'stop' to end the workflow")
|
||||
@@ -9,6 +9,7 @@ from ..agent.agent import Agent
|
||||
from ..task.task import Task
|
||||
from ..main import display_error
|
||||
from ..run_outcome import AgentRunOutcome, RunStatus, validate_decision_string
|
||||
from .manager_schema import ManagerInstructions
|
||||
import csv
|
||||
import os
|
||||
|
||||
@@ -971,11 +972,6 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
reflection=False
|
||||
)
|
||||
|
||||
class ManagerInstructions(BaseModel):
|
||||
task_id: int
|
||||
agent_name: str
|
||||
action: str
|
||||
|
||||
manager_task = Task(
|
||||
name="manager_task",
|
||||
description="Decide the order of tasks and which agent executes them",
|
||||
@@ -997,7 +993,7 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
while completed_count < total_tasks:
|
||||
tasks_summary = []
|
||||
for tid, tk in self.tasks.items():
|
||||
if tk.name == "manager_task":
|
||||
if tid == manager_task_id:
|
||||
continue
|
||||
task_info = {
|
||||
"task_id": tid,
|
||||
@@ -1069,16 +1065,22 @@ Provide a JSON with the structure:
|
||||
logging.info("Manager decided to stop task execution")
|
||||
break
|
||||
|
||||
if selected_task_id not in self.tasks:
|
||||
# Re-prompt the manager with valid task IDs instead of terminating
|
||||
selected_task = self.tasks.get(selected_task_id)
|
||||
if selected_task is None or selected_task_id == manager_task_id:
|
||||
# Reject unknown ids and the synthetic manager_task by its actual id
|
||||
# (it must never delegate to itself). Matching by id avoids colliding
|
||||
# with a legitimate user task that happens to be named "manager_task".
|
||||
# Re-prompt with the delegable IDs only.
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
logging.error(
|
||||
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
|
||||
)
|
||||
break
|
||||
|
||||
valid_task_ids = list(self.tasks.keys())
|
||||
|
||||
valid_task_ids = [
|
||||
tid for tid in self.tasks if tid != manager_task_id
|
||||
]
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
|
||||
@@ -1086,7 +1088,8 @@ Provide a JSON with the structure:
|
||||
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
|
||||
error_context = (
|
||||
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
|
||||
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
|
||||
f"Valid task IDs are: {valid_task_ids}. Never select manager_task. "
|
||||
f"Please select again from the valid options."
|
||||
)
|
||||
continue # Re-prompt the manager instead of breaking
|
||||
|
||||
@@ -1094,10 +1097,10 @@ Provide a JSON with the structure:
|
||||
invalid_selection_attempts = 0
|
||||
error_context = ""
|
||||
|
||||
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
|
||||
original_agent = selected_task.agent.name if selected_task.agent else "None"
|
||||
for a in self.agents:
|
||||
if a.name == selected_agent_name:
|
||||
self.tasks[selected_task_id].agent = a
|
||||
selected_task.agent = a
|
||||
logging.info(f"Changed agent for task {selected_task_id} from {original_agent} to {selected_agent_name}")
|
||||
break
|
||||
|
||||
@@ -1558,11 +1561,6 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
reflection=False
|
||||
)
|
||||
|
||||
class ManagerInstructions(BaseModel):
|
||||
task_id: int
|
||||
agent_name: str
|
||||
action: str
|
||||
|
||||
manager_task = Task(
|
||||
name="manager_task",
|
||||
description="Decide the order of tasks and which agent executes them",
|
||||
@@ -1584,7 +1582,7 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
|
||||
while completed_count < total_tasks:
|
||||
tasks_summary = []
|
||||
for tid, tk in self.tasks.items():
|
||||
if tk.name == "manager_task":
|
||||
if tid == manager_task_id:
|
||||
continue
|
||||
task_info = {
|
||||
"task_id": tid,
|
||||
@@ -1629,16 +1627,22 @@ Provide a JSON with the structure:
|
||||
logging.info("Manager decided to stop task execution")
|
||||
break
|
||||
|
||||
if selected_task_id not in self.tasks:
|
||||
# Re-prompt the manager with valid task IDs instead of terminating
|
||||
selected_task = self.tasks.get(selected_task_id)
|
||||
if selected_task is None or selected_task_id == manager_task_id:
|
||||
# Reject unknown ids and the synthetic manager_task by its actual id
|
||||
# (it must never delegate to itself). Matching by id avoids colliding
|
||||
# with a legitimate user task that happens to be named "manager_task".
|
||||
# Re-prompt with the delegable IDs only.
|
||||
invalid_selection_attempts += 1
|
||||
if invalid_selection_attempts > MAX_INVALID_SELECTIONS:
|
||||
logging.error(
|
||||
f"Manager produced {invalid_selection_attempts} invalid task selections; aborting."
|
||||
)
|
||||
break
|
||||
|
||||
valid_task_ids = list(self.tasks.keys())
|
||||
|
||||
valid_task_ids = [
|
||||
tid for tid in self.tasks if tid != manager_task_id
|
||||
]
|
||||
logging.warning(
|
||||
f"Manager selected invalid task_id={selected_task_id} "
|
||||
f"(attempt {invalid_selection_attempts}/{MAX_INVALID_SELECTIONS}); valid IDs: {valid_task_ids}"
|
||||
@@ -1646,7 +1650,8 @@ Provide a JSON with the structure:
|
||||
# Set error context for next iteration (instead of appending to prompt that gets rebuilt)
|
||||
error_context = (
|
||||
f"\n\n[ERROR] Your previous selection of task_id={selected_task_id} was invalid. "
|
||||
f"Valid task IDs are: {valid_task_ids}. Please select again from the valid options."
|
||||
f"Valid task IDs are: {valid_task_ids}. Never select manager_task. "
|
||||
f"Please select again from the valid options."
|
||||
)
|
||||
continue # Re-prompt the manager instead of breaking
|
||||
|
||||
@@ -1654,10 +1659,10 @@ Provide a JSON with the structure:
|
||||
invalid_selection_attempts = 0
|
||||
error_context = ""
|
||||
|
||||
original_agent = self.tasks[selected_task_id].agent.name if self.tasks[selected_task_id].agent else "None"
|
||||
original_agent = selected_task.agent.name if selected_task.agent else "None"
|
||||
for a in self.agents:
|
||||
if a.name == selected_agent_name:
|
||||
self.tasks[selected_task_id].agent = a
|
||||
selected_task.agent = a
|
||||
logging.info(f"Changed agent for task {selected_task_id} from {original_agent} to {selected_agent_name}")
|
||||
break
|
||||
|
||||
|
||||
@@ -10,54 +10,40 @@ No heavy imports - only stdlib and typing.
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
from ..context.budgeter import MODEL_LIMITS as _CANONICAL_LIMITS
|
||||
|
||||
# Model context window mapping (tokens)
|
||||
# Updated with latest model information
|
||||
MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
|
||||
|
||||
# RAG-specific context windows not present in the canonical budgeter table
|
||||
# (extra Anthropic/Google aliases, Mistral, Llama, DeepSeek, Cohere, etc.).
|
||||
# Exact values for models shared with the canonical table are sourced from
|
||||
# context/budgeter.MODEL_LIMITS below, so a single source of truth is kept.
|
||||
_RAG_EXTRA_CONTEXT_WINDOWS: Dict[str, int] = {
|
||||
# OpenAI models
|
||||
"gpt-4": 8192,
|
||||
"gpt-4-32k": 32768,
|
||||
"gpt-4-turbo": 128000,
|
||||
"gpt-4-turbo-preview": 128000,
|
||||
"gpt-4o": 128000,
|
||||
"gpt-4o-mini": 128000,
|
||||
"gpt-4.1": 1000000,
|
||||
"gpt-4.1-mini": 1000000,
|
||||
"gpt-4.1-nano": 1000000,
|
||||
"gpt-3.5-turbo": 16385,
|
||||
"gpt-3.5-turbo-16k": 16385,
|
||||
"o1": 200000,
|
||||
"o1-mini": 128000,
|
||||
"o1-preview": 128000,
|
||||
"o3": 200000,
|
||||
"o3-mini": 200000,
|
||||
"o4-mini": 200000,
|
||||
|
||||
|
||||
# Anthropic models
|
||||
"claude-3-opus": 200000,
|
||||
"claude-3-sonnet": 200000,
|
||||
"claude-3-haiku": 200000,
|
||||
"claude-3.5-sonnet": 200000,
|
||||
"claude-3.5-haiku": 200000,
|
||||
"claude-3.7-sonnet": 200000,
|
||||
"claude-4-sonnet": 200000,
|
||||
"claude-sonnet-4": 200000,
|
||||
|
||||
|
||||
# Google models
|
||||
"gemini-pro": 32768,
|
||||
"gemini-1.0-pro": 32768,
|
||||
"gemini-1.5-pro": 1000000,
|
||||
"gemini-1.5-flash": 1000000,
|
||||
"gemini-2.0-flash": 1000000,
|
||||
"gemini-2.5-pro": 1000000,
|
||||
"gemini-2.5-flash": 1000000,
|
||||
|
||||
|
||||
# Mistral models
|
||||
"mistral-large": 128000,
|
||||
"mistral-medium": 32768,
|
||||
"mistral-small": 32768,
|
||||
"mixtral-8x7b": 32768,
|
||||
|
||||
|
||||
# Llama models
|
||||
"llama-3.1-405b": 128000,
|
||||
"llama-3.1-70b": 128000,
|
||||
@@ -67,17 +53,39 @@ MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
|
||||
"llama-3.3-70b": 128000,
|
||||
"llama-4-maverick": 1000000,
|
||||
"llama-4-scout": 10000000,
|
||||
|
||||
|
||||
# DeepSeek models
|
||||
"deepseek-chat": 64000,
|
||||
"deepseek-coder": 64000,
|
||||
"deepseek-r1": 64000,
|
||||
|
||||
|
||||
# Cohere models
|
||||
"command-r": 128000,
|
||||
"command-r-plus": 128000,
|
||||
}
|
||||
|
||||
# Guard the invariant that RAG-only extras never silently shadow canonical
|
||||
# entries. If a key is later added to the canonical budgeter table, it should be
|
||||
# removed from _RAG_EXTRA_CONTEXT_WINDOWS so the single source of truth stays
|
||||
# authoritative (this is exactly the drift that #3567 fixed). Mirrors the same
|
||||
# assertion in eval/tokens.py.
|
||||
_overlapping_keys = set(_RAG_EXTRA_CONTEXT_WINDOWS) & {
|
||||
k for k in _CANONICAL_LIMITS if k != "default"
|
||||
}
|
||||
assert not _overlapping_keys, (
|
||||
"_RAG_EXTRA_CONTEXT_WINDOWS keys overlap with canonical MODEL_LIMITS; remove "
|
||||
f"the duplicate(s) from the RAG table: {sorted(_overlapping_keys)}"
|
||||
)
|
||||
|
||||
# Single source of truth for exact context-window values: canonical budgeter
|
||||
# table takes precedence, with RAG-specific extras layered underneath. This
|
||||
# mirrors how eval/tokens.py merges eval-specific extras onto the canonical
|
||||
# table and removes the previously drifted duplicate copy.
|
||||
MODEL_CONTEXT_WINDOWS: Dict[str, int] = {
|
||||
**_RAG_EXTRA_CONTEXT_WINDOWS,
|
||||
**{k: v for k, v in _CANONICAL_LIMITS.items() if k != "default"},
|
||||
}
|
||||
|
||||
# Default fallback for unknown models
|
||||
DEFAULT_CONTEXT_WINDOW = 8192
|
||||
|
||||
|
||||
@@ -177,7 +177,12 @@ def get_tool_output_store(run_id: Optional[str] = None) -> ToolOutputStore:
|
||||
"""
|
||||
global _store_instance
|
||||
with _store_lock:
|
||||
if _store_instance is None or (run_id and run_id != _store_instance.run_id):
|
||||
if run_id is None:
|
||||
# No run context available - don't silently reuse/adopt whichever
|
||||
# store happens to be live process-wide; scope to a fresh instance
|
||||
# so unrelated runs never share the first run's store directory.
|
||||
return ToolOutputStore(None)
|
||||
if _store_instance is None or run_id != _store_instance.run_id:
|
||||
_store_instance = ToolOutputStore(run_id)
|
||||
return _store_instance
|
||||
|
||||
|
||||
@@ -67,18 +67,43 @@ class ConfigYamlScheduleStore:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
|
||||
def get_by_name(
|
||||
self,
|
||||
name: str,
|
||||
principal: Optional[str] = None,
|
||||
) -> Optional[ScheduleJob]:
|
||||
"""Return a job by name, optionally scoped to ``principal``.
|
||||
|
||||
When ``principal`` is given, a job owned by a *different* identity is
|
||||
treated as not found (returns ``None``) so one gateway user cannot
|
||||
read another's automation by guessing its name. ``None`` (the default)
|
||||
preserves the pre-scoping global lookup.
|
||||
"""
|
||||
with self._lock:
|
||||
for job in self._jobs.values():
|
||||
if job.name == name:
|
||||
if principal is not None and job.principal != principal:
|
||||
continue
|
||||
return job
|
||||
return None
|
||||
|
||||
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
|
||||
def list(
|
||||
self,
|
||||
agent_id: Optional[str] = None,
|
||||
principal: Optional[str] = None,
|
||||
) -> List[ScheduleJob]:
|
||||
"""List jobs, optionally filtered by owning agent and/or principal.
|
||||
|
||||
``principal`` isolates a gateway end-user's automations from
|
||||
another's. ``None`` (the default) returns everything, preserving
|
||||
the pre-scoping global behaviour.
|
||||
"""
|
||||
with self._lock:
|
||||
jobs = list(self._jobs.values())
|
||||
if agent_id is not None:
|
||||
jobs = [j for j in jobs if j.agent_id == agent_id]
|
||||
if principal is not None:
|
||||
jobs = [j for j in jobs if j.principal == principal]
|
||||
return jobs
|
||||
|
||||
def update(self, job: ScheduleJob) -> None:
|
||||
@@ -104,11 +129,20 @@ class ConfigYamlScheduleStore:
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_by_name(self, name: str) -> bool:
|
||||
def remove_by_name(self, name: str, principal: Optional[str] = None) -> bool:
|
||||
"""Remove a job by name, optionally scoped to ``principal``.
|
||||
|
||||
When ``principal`` is given, a job owned by a *different* identity is
|
||||
skipped (not removed) so one gateway user cannot delete another's
|
||||
automation by guessing its name. ``None`` (the default) preserves the
|
||||
pre-scoping global removal.
|
||||
"""
|
||||
with self._lock, self._file_lock():
|
||||
self._reload_locked()
|
||||
for jid, job in list(self._jobs.items()):
|
||||
if job.name == name:
|
||||
if principal is not None and job.principal != principal:
|
||||
continue
|
||||
del self._jobs[jid]
|
||||
self._save()
|
||||
return True
|
||||
|
||||
@@ -17,6 +17,19 @@ from praisonaiagents._logging import get_logger
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def next_fire_time(cron_expr: str, base: float) -> float:
|
||||
"""Return the next cron fire time (epoch) strictly after ``base``.
|
||||
|
||||
Pure helper owning the "compute next fire from a base" step so wrapper
|
||||
tickers can share core's cron timing rather than re-implementing it.
|
||||
Requires the optional ``croniter`` engine; callers guard its import and
|
||||
the ``(ValueError, KeyError, TypeError)`` raised by malformed expressions.
|
||||
"""
|
||||
from croniter import croniter # type: ignore[import-untyped]
|
||||
|
||||
return croniter(cron_expr, base).get_next(float)
|
||||
|
||||
|
||||
def is_due(job: Any, now: float) -> bool:
|
||||
"""Return ``True`` if ``job`` is due to run at epoch ``now``.
|
||||
|
||||
@@ -63,8 +76,7 @@ def is_due(job: Any, now: float) -> bool:
|
||||
return False
|
||||
base_time = job.last_run_at or job.created_at
|
||||
try:
|
||||
cron = croniter(sched.cron_expr, base_time)
|
||||
next_run = cron.get_next(float)
|
||||
next_run = next_fire_time(sched.cron_expr, base_time)
|
||||
except (ValueError, KeyError, TypeError) as e:
|
||||
# A malformed cron expression must not abort the whole tick loop
|
||||
# (which iterates all jobs) — treat this job as not-due instead.
|
||||
|
||||
@@ -156,6 +156,39 @@ class DeliveryTarget:
|
||||
# Bare platform name → resolve to its home channel via the router.
|
||||
return cls(channel=token, deliver=token)
|
||||
|
||||
def preview(self, *, session_target: str = "") -> str:
|
||||
"""Return a human-readable, dry-run preview of where this will deliver.
|
||||
|
||||
A pure, dependency-free description of the resolved destination so the
|
||||
creator — user, agent, or a blueprint accept — sees "where will this
|
||||
go?" the moment a scheduled / agent-initiated send is created, not only
|
||||
at fire time. Symbolic tokens (``origin`` / ``all``) are surfaced as
|
||||
such; concrete targets render as ``platform:channel_id[:thread_id]``.
|
||||
|
||||
Args:
|
||||
session_target: Optional ``"main"`` / ``"isolated"`` session hint to
|
||||
append (e.g. ``" (session main)"``), when known by the caller.
|
||||
|
||||
Returns:
|
||||
A short, display-only string such as ``"telegram:@alice"`` or
|
||||
``"telegram:123:789 (session main)"``.
|
||||
"""
|
||||
token = (self.deliver or "").strip()
|
||||
symbolic = token.lower()
|
||||
if symbolic in ("origin", "all"):
|
||||
base = symbolic
|
||||
elif self.channel:
|
||||
base = self.channel
|
||||
if self.channel_id:
|
||||
base = f"{base}:{self.channel_id}"
|
||||
if self.thread_id:
|
||||
base = f"{base}:{self.thread_id}"
|
||||
else:
|
||||
base = token or "<unrouted>"
|
||||
if session_target:
|
||||
base = f"{base} (session {session_target})"
|
||||
return base
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunRecord:
|
||||
@@ -239,6 +272,43 @@ class ScheduleJob:
|
||||
and surfaced for readability but is NOT itself enforced; the
|
||||
default :class:`ShellConditionGate` gates on ``pre_run``. A
|
||||
custom ``condition_resolver`` may interpret it.
|
||||
principal: Optional resolved canonical identity of the *end-user* who
|
||||
owns this job (from the gateway's identity resolver).
|
||||
Distinct from ``agent_id`` (the owning agent) and ``origin``
|
||||
(where it was created): ``principal`` is the access-control
|
||||
key used by ``store.list(principal=...)`` to isolate one
|
||||
gateway user's automations from another's. ``None`` means
|
||||
global / single-tenant — preserving pre-scoping behaviour.
|
||||
command: Optional shell command whose stdout is delivered verbatim.
|
||||
When set, the job runs this command on its schedule and
|
||||
delivers the output as-is to ``delivery`` — with NO agent
|
||||
resolved and NO model turn taken. This is a first-class
|
||||
model-free execution *action* (a cheap, deterministic
|
||||
watchdog: ``df -h``, ``uptime``, a health-check ``curl``),
|
||||
distinct from ``pre_run`` which is only a go/no-go *gate*
|
||||
feeding the model turn. Additive and backward-compatible:
|
||||
jobs without a ``command`` keep the existing agent path.
|
||||
command_timeout: Maximum seconds the ``command`` may run before it is
|
||||
killed (with its process group on POSIX) and the tick is
|
||||
recorded as ``failed``. Bounds the action so a hung command
|
||||
cannot stall the ticker. Defaults to 60s.
|
||||
provider: Optional model *provider* snapshotted when the job was
|
||||
created (e.g. ``"openai"``). Advisory metadata paired with
|
||||
``model`` so a drift check can report both. ``None`` means no
|
||||
snapshot was taken — the job keeps today's follow-the-default
|
||||
behaviour and no drift is enforced.
|
||||
model: Optional model identifier snapshotted when the job was created
|
||||
(e.g. ``"gpt-4o-mini"``). Because unattended runs fire with no
|
||||
human present, a job created against a cheap/local default
|
||||
must not silently inherit a later, pricier default. When set
|
||||
and ``pin_model`` is ``True`` the wrapper executor pins the run
|
||||
to this model and *fails closed* if the resolved agent has
|
||||
drifted. ``None`` preserves the pre-snapshot behaviour, so
|
||||
existing jobs are fully backward-compatible.
|
||||
pin_model: When ``True`` (default) a ``model`` snapshot is enforced —
|
||||
the run is pinned and drift fails closed. Set ``False`` to opt
|
||||
into following whatever the default becomes. Only meaningful
|
||||
when ``model`` is set; a job with no snapshot never enforces.
|
||||
"""
|
||||
|
||||
name: str = ""
|
||||
@@ -255,6 +325,12 @@ class ScheduleJob:
|
||||
origin: Optional[DeliveryTarget] = None
|
||||
pre_run: Optional[str] = None
|
||||
condition: Optional[str] = None
|
||||
principal: Optional[str] = None
|
||||
command: Optional[str] = None
|
||||
command_timeout: float = 60.0
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
pin_model: bool = True
|
||||
|
||||
# ── serialisation ────────────────────────────────────────────────
|
||||
|
||||
@@ -279,6 +355,23 @@ class ScheduleJob:
|
||||
d["pre_run"] = self.pre_run
|
||||
if self.condition is not None:
|
||||
d["condition"] = self.condition
|
||||
if self.principal is not None:
|
||||
d["principal"] = self.principal
|
||||
if self.command is not None:
|
||||
d["command"] = self.command
|
||||
# Only persist the timeout when a command is configured and it
|
||||
# differs from the default, keeping agent-only jobs unchanged.
|
||||
if self.command_timeout != 60.0:
|
||||
d["command_timeout"] = self.command_timeout
|
||||
# Model pin snapshot. Only persist when a snapshot exists so agent-only
|
||||
# jobs stay byte-for-byte unchanged; ``pin_model`` is likewise persisted
|
||||
# only when opting out of the default (True), keeping the payload minimal.
|
||||
if self.provider is not None:
|
||||
d["provider"] = self.provider
|
||||
if self.model is not None:
|
||||
d["model"] = self.model
|
||||
if not self.pin_model:
|
||||
d["pin_model"] = False
|
||||
# Atomic-claim lease metadata (set dynamically by stores that support
|
||||
# ``claim_due``). Persisted so a lease is visible across processes and
|
||||
# survives a restart; omitted when no lease is held.
|
||||
@@ -308,6 +401,12 @@ class ScheduleJob:
|
||||
origin=DeliveryTarget.from_dict(origin_data) if isinstance(origin_data, dict) else None,
|
||||
pre_run=d.get("pre_run"),
|
||||
condition=d.get("condition"),
|
||||
principal=d.get("principal"),
|
||||
command=d.get("command"),
|
||||
command_timeout=d.get("command_timeout", 60.0),
|
||||
provider=d.get("provider"),
|
||||
model=d.get("model"),
|
||||
pin_model=d.get("pin_model", True),
|
||||
)
|
||||
# Restore atomic-claim lease metadata if present (see ``to_dict``).
|
||||
job._lease_until = d.get("lease_until", 0.0) or 0.0
|
||||
|
||||
@@ -116,8 +116,17 @@ class ScheduleStoreProtocol(Protocol):
|
||||
"""Get a job by its unique ID."""
|
||||
...
|
||||
|
||||
def list(self, agent_id: Optional[str] = None) -> List[Any]:
|
||||
"""List all jobs, optionally filtered by agent_id."""
|
||||
def list(
|
||||
self,
|
||||
agent_id: Optional[str] = None,
|
||||
principal: Optional[str] = None,
|
||||
) -> List[Any]:
|
||||
"""List all jobs, optionally filtered by agent_id and/or principal.
|
||||
|
||||
``principal`` is the resolved end-user identity used to isolate one
|
||||
gateway user's automations from another's. ``None`` returns
|
||||
everything (global / single-tenant behaviour).
|
||||
"""
|
||||
...
|
||||
|
||||
def update(self, job: Any) -> None:
|
||||
@@ -128,12 +137,26 @@ class ScheduleStoreProtocol(Protocol):
|
||||
"""Remove a job by ID. Returns True if found and removed."""
|
||||
...
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[Any]:
|
||||
"""Get a job by its human-readable name."""
|
||||
def get_by_name(
|
||||
self,
|
||||
name: str,
|
||||
principal: Optional[str] = None,
|
||||
) -> Optional[Any]:
|
||||
"""Get a job by its human-readable name.
|
||||
|
||||
``principal`` scopes the lookup to the resolved end-user identity: a
|
||||
job owned by a different principal is treated as not found. ``None``
|
||||
returns any match (global / single-tenant behaviour).
|
||||
"""
|
||||
...
|
||||
|
||||
def remove_by_name(self, name: str) -> bool:
|
||||
"""Remove a job by name. Returns True if found and removed."""
|
||||
def remove_by_name(self, name: str, principal: Optional[str] = None) -> bool:
|
||||
"""Remove a job by name. Returns True if found and removed.
|
||||
|
||||
``principal`` scopes the removal to the resolved end-user identity so
|
||||
one gateway user cannot delete another's automation by name. ``None``
|
||||
removes any match (global / single-tenant behaviour).
|
||||
"""
|
||||
...
|
||||
|
||||
# ── Atomic claim / lease (optional) ──────────────────────────────
|
||||
|
||||
@@ -65,18 +65,43 @@ class FileScheduleStore:
|
||||
with self._lock:
|
||||
return self._jobs.get(job_id)
|
||||
|
||||
def get_by_name(self, name: str) -> Optional[ScheduleJob]:
|
||||
def get_by_name(
|
||||
self,
|
||||
name: str,
|
||||
principal: Optional[str] = None,
|
||||
) -> Optional[ScheduleJob]:
|
||||
"""Return a job by name, optionally scoped to ``principal``.
|
||||
|
||||
When ``principal`` is given, a job owned by a *different* identity is
|
||||
treated as not found (returns ``None``) so one gateway user cannot
|
||||
read another's automation by guessing its name. ``None`` (the default)
|
||||
preserves the pre-scoping global lookup.
|
||||
"""
|
||||
with self._lock:
|
||||
for job in self._jobs.values():
|
||||
if job.name == name:
|
||||
if principal is not None and job.principal != principal:
|
||||
continue
|
||||
return job
|
||||
return None
|
||||
|
||||
def list(self, agent_id: Optional[str] = None) -> List[ScheduleJob]:
|
||||
def list(
|
||||
self,
|
||||
agent_id: Optional[str] = None,
|
||||
principal: Optional[str] = None,
|
||||
) -> List[ScheduleJob]:
|
||||
"""List jobs, optionally filtered by owning agent and/or principal.
|
||||
|
||||
``principal`` isolates a gateway end-user's automations from
|
||||
another's. ``None`` (the default) returns everything, preserving
|
||||
the pre-scoping global behaviour.
|
||||
"""
|
||||
with self._lock:
|
||||
jobs = list(self._jobs.values())
|
||||
if agent_id is not None:
|
||||
jobs = [j for j in jobs if j.agent_id == agent_id]
|
||||
if principal is not None:
|
||||
jobs = [j for j in jobs if j.principal == principal]
|
||||
return jobs
|
||||
|
||||
def update(self, job: ScheduleJob) -> None:
|
||||
@@ -102,11 +127,20 @@ class FileScheduleStore:
|
||||
return True
|
||||
return False
|
||||
|
||||
def remove_by_name(self, name: str) -> bool:
|
||||
def remove_by_name(self, name: str, principal: Optional[str] = None) -> bool:
|
||||
"""Remove a job by name, optionally scoped to ``principal``.
|
||||
|
||||
When ``principal`` is given, a job owned by a *different* identity is
|
||||
skipped (not removed) so one gateway user cannot delete another's
|
||||
automation by guessing its name. ``None`` (the default) preserves the
|
||||
pre-scoping global removal.
|
||||
"""
|
||||
with self._lock, self._file_lock():
|
||||
self._reload_locked()
|
||||
for jid, job in list(self._jobs.items()):
|
||||
if job.name == name:
|
||||
if principal is not None and job.principal != principal:
|
||||
continue
|
||||
del self._jobs[jid]
|
||||
self._save()
|
||||
return True
|
||||
|
||||
@@ -91,6 +91,11 @@ class Suggestion:
|
||||
accepted: bool = False
|
||||
"""True if the user accepted this suggestion (job has been created)."""
|
||||
|
||||
principal: Optional[str] = None
|
||||
"""Resolved canonical identity of the owner (from the gateway's
|
||||
identity resolver). ``None`` means the suggestion is global /
|
||||
single-tenant — preserving pre-scoping behaviour."""
|
||||
|
||||
|
||||
# ── Suggestion store ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -150,19 +155,30 @@ class SuggestionStore:
|
||||
def add(self, suggestion: Suggestion) -> bool:
|
||||
"""Add a suggestion to the store.
|
||||
|
||||
The pending cap and dedup window are evaluated *within the
|
||||
suggestion's ``principal``* so one gateway user's pending queue
|
||||
cannot exhaust the cap or collide with another's. A suggestion with
|
||||
``principal=None`` is measured against the global pool (pre-scoping
|
||||
single-tenant behaviour).
|
||||
|
||||
Returns:
|
||||
``True`` if the suggestion was added, ``False`` if it was
|
||||
rejected due to the pending cap or dedup window.
|
||||
rejected due to the (per-principal) pending cap or dedup window.
|
||||
"""
|
||||
with self._lock:
|
||||
now = time.time()
|
||||
|
||||
# Only count active (non-dismissed, non-accepted, non-expired)
|
||||
# suggestions toward the cap and dedup window.
|
||||
# suggestions toward the cap and dedup window. Scope both to the
|
||||
# incoming suggestion's owner so one tenant's pending queue cannot
|
||||
# exhaust the cap (or its dedup window collide) for another —
|
||||
# ``principal is None`` keeps the pre-scoping global pool.
|
||||
owner = suggestion.principal
|
||||
active = [
|
||||
s for s in self._suggestions.values()
|
||||
if not s.dismissed and not s.accepted
|
||||
and (s.expires_at == 0 or s.expires_at > now)
|
||||
and (owner is None or s.principal == owner)
|
||||
]
|
||||
if len(active) >= MAX_PENDING_CAP:
|
||||
return False
|
||||
@@ -185,40 +201,67 @@ class SuggestionStore:
|
||||
with self._lock:
|
||||
return self._suggestions.get(suggestion_id)
|
||||
|
||||
def list_pending(self) -> List[Suggestion]:
|
||||
"""Return all undismissed, unaccepted, non-expired suggestions."""
|
||||
def list_pending(self, principal: Optional[str] = None) -> List[Suggestion]:
|
||||
"""Return all undismissed, unaccepted, non-expired suggestions.
|
||||
|
||||
Args:
|
||||
principal: When provided, only suggestions owned by this
|
||||
resolved identity are returned (multi-user isolation).
|
||||
``None`` returns everything — the pre-scoping global
|
||||
behaviour used by single-tenant deployments.
|
||||
"""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
return [
|
||||
s for s in self._suggestions.values()
|
||||
if not s.dismissed and not s.accepted
|
||||
and (s.expires_at == 0 or s.expires_at > now)
|
||||
and (principal is None or s.principal == principal)
|
||||
]
|
||||
|
||||
def accept(self, suggestion_id: str) -> bool:
|
||||
def accept(self, suggestion_id: str, principal: Optional[str] = None) -> bool:
|
||||
"""Mark a suggestion as accepted.
|
||||
|
||||
Args:
|
||||
suggestion_id: The suggestion to accept.
|
||||
principal: When provided, the call is refused (returns
|
||||
``False``) unless the suggestion is owned by this
|
||||
identity — preventing one user from accepting another's.
|
||||
``None`` skips the ownership check (global behaviour).
|
||||
|
||||
Returns:
|
||||
``False`` if the suggestion was not found.
|
||||
``False`` if the suggestion was not found or is owned by a
|
||||
different principal.
|
||||
"""
|
||||
with self._lock:
|
||||
s = self._suggestions.get(suggestion_id)
|
||||
if s is None:
|
||||
return False
|
||||
if principal is not None and s.principal != principal:
|
||||
return False
|
||||
s.accepted = True
|
||||
self._save()
|
||||
return True
|
||||
|
||||
def dismiss(self, suggestion_id: str) -> bool:
|
||||
def dismiss(self, suggestion_id: str, principal: Optional[str] = None) -> bool:
|
||||
"""Mark a suggestion as dismissed (user declined).
|
||||
|
||||
Args:
|
||||
suggestion_id: The suggestion to dismiss.
|
||||
principal: When provided, the call is refused (returns
|
||||
``False``) unless the suggestion is owned by this
|
||||
identity. ``None`` skips the ownership check.
|
||||
|
||||
Returns:
|
||||
``False`` if the suggestion was not found.
|
||||
``False`` if the suggestion was not found or is owned by a
|
||||
different principal.
|
||||
"""
|
||||
with self._lock:
|
||||
s = self._suggestions.get(suggestion_id)
|
||||
if s is None:
|
||||
return False
|
||||
if principal is not None and s.principal != principal:
|
||||
return False
|
||||
s.dismissed = True
|
||||
self._save()
|
||||
return True
|
||||
|
||||
@@ -31,7 +31,7 @@ if TYPE_CHECKING:
|
||||
from .store import DefaultSessionStore, SessionMessage, SessionData, CompactionCheckpoint
|
||||
from .sqlite_store import SqliteSessionStore
|
||||
from .sqlite_transcript_store import SqliteTranscriptStore
|
||||
from .protocols import SessionStoreProtocol
|
||||
from .protocols import SessionStoreProtocol, SessionMirrorProtocol
|
||||
from .hierarchy import HierarchicalSessionStore, SessionSnapshot, ExtendedSessionData
|
||||
|
||||
# Lazy loading for zero import overhead
|
||||
@@ -93,6 +93,11 @@ def __getattr__(name: str):
|
||||
_module_cache[name] = SearchableSessionStoreProtocol
|
||||
return SearchableSessionStoreProtocol
|
||||
|
||||
if name == "SessionMirrorProtocol":
|
||||
from .protocols import SessionMirrorProtocol
|
||||
_module_cache[name] = SessionMirrorProtocol
|
||||
return SessionMirrorProtocol
|
||||
|
||||
if name == "SessionHit":
|
||||
from .protocols import SessionHit
|
||||
_module_cache[name] = SessionHit
|
||||
@@ -179,6 +184,7 @@ __all__ = [
|
||||
"get_default_session_store",
|
||||
"SessionStoreProtocol",
|
||||
"SearchableSessionStoreProtocol",
|
||||
"SessionMirrorProtocol",
|
||||
"SessionHit",
|
||||
"SessionSummary",
|
||||
"HierarchicalSessionStore",
|
||||
|
||||
@@ -37,7 +37,12 @@ from dataclasses import asdict, dataclass
|
||||
from typing import TYPE_CHECKING, Dict, List, Optional, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..gateway.protocols import OutboundMessengerProtocol, SendPolicyProtocol
|
||||
from ..gateway.protocols import (
|
||||
ConversationRequestProtocol,
|
||||
OutboundMessengerProtocol,
|
||||
SendPolicyProtocol,
|
||||
GatewayStatusProtocol,
|
||||
)
|
||||
|
||||
|
||||
def neutralize_untrusted_text(value: object, *, max_chars: int = 240) -> str:
|
||||
@@ -234,6 +239,40 @@ def clear_outbound_messenger(token: Token) -> None:
|
||||
_MESSENGER.set(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cross-conversation request/reply requester (Issue #3689)
|
||||
#
|
||||
# The running gateway/bot registers a concrete ConversationRequestProtocol impl
|
||||
# into this task-local slot so the built-in ``ask_conversation`` tool can ask
|
||||
# another conversation something and await the correlated reply mid-turn. When
|
||||
# unbound the tool reports that no gateway is available (gating, not a hang).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CONVERSATION_REQUESTER: ContextVar[Optional["ConversationRequestProtocol"]] = ContextVar(
|
||||
"praisonai_conversation_requester", default=None
|
||||
)
|
||||
|
||||
|
||||
def register_conversation_requester(
|
||||
requester: Optional["ConversationRequestProtocol"],
|
||||
) -> Token:
|
||||
"""Register the active conversation requester for this task. Returns a token."""
|
||||
return _CONVERSATION_REQUESTER.set(requester)
|
||||
|
||||
|
||||
def get_conversation_requester() -> Optional["ConversationRequestProtocol"]:
|
||||
"""Return the active conversation requester, or ``None`` if no gateway is running."""
|
||||
return _CONVERSATION_REQUESTER.get()
|
||||
|
||||
|
||||
def clear_conversation_requester(token: Token) -> None:
|
||||
"""Restore the previous conversation requester using the token from register."""
|
||||
try:
|
||||
_CONVERSATION_REQUESTER.reset(token)
|
||||
except (LookupError, ValueError):
|
||||
_CONVERSATION_REQUESTER.set(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Outbound send-policy guard (Issue #2226)
|
||||
#
|
||||
@@ -268,6 +307,41 @@ def clear_send_policy(token: Token) -> None:
|
||||
_SEND_POLICY.set(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway live status/health source (Issue #3688)
|
||||
#
|
||||
# The running gateway/bot registers a concrete GatewayStatusProtocol impl into
|
||||
# this task-local slot so the built-in ``gateway_status`` tool can report the
|
||||
# gateway's live self-state (run status, active sessions, delivery/DLQ backlog,
|
||||
# degraded owners). When nothing is registered (CLI / one-shot runs), the tool
|
||||
# is simply not offered — a clean gate, never a dead-end failure.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_GATEWAY_STATUS: ContextVar[Optional["GatewayStatusProtocol"]] = ContextVar(
|
||||
"praisonai_gateway_status", default=None
|
||||
)
|
||||
|
||||
|
||||
def register_gateway_status(
|
||||
source: Optional["GatewayStatusProtocol"],
|
||||
) -> Token:
|
||||
"""Register the active gateway status source for this task. Returns a token."""
|
||||
return _GATEWAY_STATUS.set(source)
|
||||
|
||||
|
||||
def get_gateway_status() -> Optional["GatewayStatusProtocol"]:
|
||||
"""Return the active gateway status source, or ``None`` if no gateway is running."""
|
||||
return _GATEWAY_STATUS.get()
|
||||
|
||||
|
||||
def clear_gateway_status(token: Token) -> None:
|
||||
"""Restore the previous gateway status source using the token from register."""
|
||||
try:
|
||||
_GATEWAY_STATUS.reset(token)
|
||||
except (LookupError, ValueError):
|
||||
_GATEWAY_STATUS.set(None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gateway event loop registry (Issue #2183)
|
||||
#
|
||||
@@ -309,9 +383,15 @@ __all__ = [
|
||||
"register_outbound_messenger",
|
||||
"get_outbound_messenger",
|
||||
"clear_outbound_messenger",
|
||||
"register_conversation_requester",
|
||||
"get_conversation_requester",
|
||||
"clear_conversation_requester",
|
||||
"register_send_policy",
|
||||
"get_send_policy",
|
||||
"clear_send_policy",
|
||||
"register_gateway_status",
|
||||
"get_gateway_status",
|
||||
"clear_gateway_status",
|
||||
"register_gateway_loop",
|
||||
"get_gateway_loop",
|
||||
"clear_gateway_loop",
|
||||
|
||||
@@ -159,7 +159,9 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
contract so the write-abort protection is honoured here too:
|
||||
|
||||
* File does not exist → fresh empty session.
|
||||
* Malformed JSON → treat as corrupted; start fresh (logged).
|
||||
* Malformed JSON → quarantine the corrupt file aside and surface the
|
||||
event before starting fresh, so its raw bytes are not silently
|
||||
overwritten by the next write (Issue #3715).
|
||||
* Transient ``OSError`` on an existing file → re-raise so the write
|
||||
paths (``_modify_session_locked``) abort instead of overwriting real
|
||||
history with an empty session. Previously this override swallowed
|
||||
@@ -172,10 +174,19 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return ExtendedSessionData.from_dict(data)
|
||||
except json.JSONDecodeError as e:
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
# Invalid UTF-8 (``UnicodeDecodeError``) is handled alongside
|
||||
# malformed JSON so a corrupt binary file is quarantined here too
|
||||
# rather than propagating and matching the base-store contract.
|
||||
quarantine_path = self._quarantine_corrupt(filepath)
|
||||
logger.error(
|
||||
f"Session file {filepath} contains invalid JSON; starting fresh: {e}"
|
||||
"Session file %s contains invalid JSON; quarantined to %s and "
|
||||
"starting fresh: %s",
|
||||
filepath,
|
||||
quarantine_path or "<quarantine failed>",
|
||||
e,
|
||||
)
|
||||
self._fire_corruption_hook(session_id, str(e), quarantine_path)
|
||||
return ExtendedSessionData(session_id=session_id)
|
||||
except OSError as e:
|
||||
logger.error(
|
||||
|
||||
@@ -325,6 +325,75 @@ class RuntimeStateMirroringProtocol(Protocol):
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SessionMirrorProtocol(Protocol):
|
||||
"""Protocol for mirroring session transcripts to a remote backend (Issue #3646).
|
||||
|
||||
A mirror is a *pluggable, local-first* sink: the session store keeps
|
||||
writing to local disk exactly as today, and — when a mirror is configured
|
||||
— new append-only records are also handed to the mirror so a session can
|
||||
be continued on another machine. The contract is deliberately tiny:
|
||||
|
||||
- :meth:`append` receives the records produced by a turn (each an already
|
||||
timestamped, id-tagged dict, so mirroring is conflict-free by
|
||||
construction — last-writer per record id, no merge logic);
|
||||
- :meth:`load` returns all mirrored records for a session so a
|
||||
non-local id can be hydrated on resume;
|
||||
- :meth:`list_sessions` (optional) enumerates mirrored sessions for a
|
||||
``session list --remote`` surface.
|
||||
|
||||
The mirror must be safe to call off the local write path: an implementation
|
||||
is expected to be tolerant of transient outages, since the caller queues
|
||||
records and never lets a mirror failure block or corrupt the local session.
|
||||
|
||||
Wrapper backends (postgres/supabase/turso/sqlite, …) adapt the existing
|
||||
``praisonai.persistence.conversation`` stores onto this protocol; core
|
||||
ships only the contract (zero new dependencies).
|
||||
|
||||
Example::
|
||||
|
||||
store: SessionMirrorProtocol = MyRemoteMirror()
|
||||
store.append("s_abc", [{"id": "m1", "role": "user", "content": "hi",
|
||||
"timestamp": 1.0}])
|
||||
records = store.load("s_abc") # full transcript, incl. tool calls
|
||||
"""
|
||||
|
||||
def append(self, session_id: str, records: List[Dict[str, Any]]) -> None:
|
||||
"""Append append-only transcript records for a session.
|
||||
|
||||
Args:
|
||||
session_id: The session these records belong to.
|
||||
records: New records to mirror. Each is a JSON-serialisable dict
|
||||
carrying at least an ``id`` and ``timestamp`` so re-appends are
|
||||
idempotent (last-writer per record id).
|
||||
"""
|
||||
...
|
||||
|
||||
def load(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Load all mirrored records for a session.
|
||||
|
||||
Args:
|
||||
session_id: The session to hydrate.
|
||||
|
||||
Returns:
|
||||
The full ordered list of mirrored records (empty if unknown).
|
||||
"""
|
||||
...
|
||||
|
||||
def list_sessions(
|
||||
self, *, user_id: Optional[str] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Enumerate mirrored sessions (optional).
|
||||
|
||||
Args:
|
||||
user_id: Optional filter to a single user's sessions.
|
||||
|
||||
Returns:
|
||||
List of session metadata dicts (at minimum ``session_id``).
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CheckpointQueryProtocol(Protocol):
|
||||
"""Read path for session checkpoints / rollback snapshots."""
|
||||
|
||||
Loaded 100 of 531 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user