Compare commits

...
313 Commits
Author SHA1 Message Date
MervinPraisonandCursor 16dfd4cea5 Add praisonai-js-hub-intake Cursor skill at monorepo root.
Agent skill documents Option B hub intake, routing table, workflows,
and file reference for PraisonAI ↔ praisonai-js issue handling.

Co-authored-by: Cursor <[email protected]>
2026-08-26 18:45:55 +01:00
MervinPraisonandCursor 4ab1ed280d Implement Option B hub intake for praisonai-js TypeScript issues.
Accept TS/JS issues on PraisonAI, route fixes to praisonai-js via Claude
STEP 3-ALT, block direct src/praisonai-ts/ PRs unless sync from praisonai-js,
and document routing in CONTRIBUTING, bug template, and SECURITY.

Co-authored-by: Cursor <[email protected]>
2026-08-26 18:41:27 +01:00
MervinPraisonandClaude Opus 5 df8cc70530 chore: praisonai 4.7.2
The version the desktop release is cut from. v4.7.1 predates the whole
Windows and Linux effort, so attaching those builds to it would have claimed
that release shipped something it did not contain.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 18:33:26 +01:00
Mervin Praison 8d2791ee0d Merge pull request #4382 from MervinPraison/fix-desktop-review-findings
fix(desktop): the ship-blockers three adversarial reviews found after merge
2026-08-26 17:18:33 +01:00
MervinPraisonandClaude Opus 5 53680e44d3 fix(desktop): the second-round review findings
A review of the fixes themselves, which is where new bugs get introduced.

**A stale tab really could cancel someone else's run.** The engine refuses to
stop a run other than the one asked for -- and the Stop button never sent the
id, so the guard could not fire and the three server tests covering it were
testing a request the product does not make. Reproduced end to end: tab A's
Stop cancelled the run tab B had started. The button names its run now.

**The CSP test caught none of seven CSP-breaking changes, including a straight
revert of the fix it exists to protect.** It matched a *variable* name against
an element id -- `stopBtn` against `id="stopBtn"`, which does not exist; the id
is `stop` -- so it silently compared nothing. It now resolves bindings to
elements before judging a line, and covers the four other ways the policy can
be broken: a style attribute in any casing or quoting, `setAttribute('style')`,
a `<style>` built at runtime, and `style=` concatenated into innerHTML. Ten
mutations, ten caught, including both directions of the original bug.

The rule it enforces is also broader than before. Hiding with an inline style
and revealing with a class leaves the element hidden from the second turn on,
just as the reverse leaves it hidden forever -- so for an element the markup
hides with a class, any `.style.display` assignment is a defect regardless of
the value.

**A whole test file ran nowhere.** `stream-pacing.test.ts` is TypeScript and
matched neither npm glob, so its eight tests -- including the positive control
for logic `ui/index.html` justifies by saying it was "ported verbatim from the
tested one" -- never executed. The skip-count assertion could not see it
either: nothing skips when nothing runs. That is the same failure the workflow
comment claims to prevent.

**Windows CI could not go green** on a clippy error in a `#[cfg(windows)]`
block, invisible from any other platform, and three tests errored in setUp on
`os.getpgid` -- which does not exist on Windows -- in the class that exists to
prove the Windows path works.

Secrets, three ways a report of success was untrue:

* A delete returned `or` of the two stores, so an unwritable fallback meant the
  plaintext survived, `get` served it, and the user was told the key was gone.
* A key the primary could not accept went to the fallback while the primary
  kept the old one -- and `get` reads the primary first, so the app went on
  using the previous key after a successful save.
* `FileSecretStore._read` swallowed every error, so one unreadable read meant
  "empty" and the next write destroyed the other secrets. The DPAPI store's
  docstring names this exact hazard; this one had been left with it.

And: `macos-14` began deprecation on 2026-07-06 with brownouts that fail jobs
now, and it was the only Apple-silicon leg, so the arm64 DMG was on its way to
disappearing -- there is a test listing retired images now, because this is the
second runner to expire mid-branch. `x11_threads` declared dlsym's argument as
`*const i8`, which does not compile on ARM Linux where `c_char` is unsigned.
The two Tauri window configs must agree, and nothing checked. The log-tail
comment described dropping a seek-truncated first line that nothing dropped.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 16:46:06 +01:00
MervinPraisonandClaude Opus 5 241b50c7c0 fix(desktop): stopping a training run did nothing on Windows
The CI matrix found this on its third run, and it is the same defect this
branch already fixed once on POSIX -- still fully present on Windows, reported
as a success. Every stop-related test failed there, and one of them says it
plainly:

    'RAN-TO-COMPLETION' unexpectedly found in the log
    : the run was reported cancelled but ran to the end anyway

_terminate_group sent CTRL_BREAK_EVENT. That event is delivered through a
console, and the trainer is spawned with CREATE_NO_WINDOW precisely so that it
has none -- so send_signal returned successfully, nothing died, and the
`except OSError: proc.terminate()` fallback never ran because there was no
error. The user pressed Stop, the UI said cancelled, and the fine-tune carried
on to completion holding the GPU.

It kills the process tree with `taskkill /T /F` now, which is what killpg
achieves on POSIX. /T is the part that matters: without it the trainer dies and
whatever it spawned keeps the card.

The test that was supposed to cover this asserted `proc.signals or
proc.terminated` -- "something was signalled". CTRL_BREAK_EVENT satisfied that
perfectly while the process ran on. This is the third time in this branch that
a test asserted an attempt rather than an effect, so it now asserts which pid
was killed, that the command carries /T and /F, that a failed taskkill still
escalates, and that a process which already exited is not killed again.

The tree kill is a module-level function so all of this runs from any platform.
The branch that only executes on the machine nobody tests on is exactly the
branch that was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 16:35:20 +01:00
MervinPraisonandClaude Opus 5 e6fff340c8 fix(desktop): the 51st training run could not start
My own regression from two commits ago, found by looking rather than by a test.

Capping the run history meant making it a bounded deque, and the insertion was
left as `insert(0, run)`. On a *full* bounded deque that raises
IndexError("deque already at its maximum size") -- so the run after the cap
would not start, and every run after that would fail the same way, on an app
that is meant to stay open for days. `appendleft` evicts from the far end,
which is the oldest run, which is what the cap is for.

The tests now reach the cap rather than stopping short of it. Both things that
go wrong here only go wrong when the deque is full: the insertion raising, and
eviction taking the newest run instead of the oldest. Neither is observable
below the limit, which is why a test that stops at three runs proves nothing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 16:21:51 +01:00
MervinPraisonandClaude Opus 5 6624f6fdd1 fix(desktop): the Windows failures the CI matrix found
Second round from the same matrix. Linux went green; Windows did not.

**The whole training route suite failed on Windows for a quoting reason.**
The test helper built `PRAISONAI_TRAIN_CMD` with `shlex.quote`, which escapes
an inner `'` as the concatenation `'"'"'`. POSIX reassembles that into one
token; Windows rules split it into seven. Every script in the suite prints
something, so every one of them contains a quote, and the trainer was launched
with a shattered command line -- the run never reached "running" and nine tests
timed out waiting for it. The body goes through a file now: a path has no
newlines and no quotes, so both rule sets agree about it. (`split_command`
itself is right; a Windows user would quote the Windows way.)

**The lockfile was not the bytes it says it writes.** `write_text` translates
"\n" to "\r\n" on Windows, so the file differed by platform. The Rust parser
trims each line and coped, but a lockfile whose bytes depend on the host is a
difference waiting to matter -- and the encode is the only conversion that
should be happening in a function documented as "always as UTF-8". It writes
bytes now.

The rest were test-only, and all the same mistake: `str(Path("/a/b"))` is
`\a\b` on Windows, and comparing a `PurePosixPath` to a `WindowsPath` fails
even when both name the same location. They compare `.as_posix()` now.

Also made the generated script filename a content hash rather than `hash()`,
which is salted per process and could have had two bodies collide on one file.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 16:08:26 +01:00
MervinPraisonandClaude Opus 5 a1d7167ce9 fix(desktop): the cross-platform test failures the new CI found
The CI added in the previous commit did its job on its first run: the engine
suite failed on Linux and Windows, and the Rust suite failed on Windows, while
all three passed on macOS -- the only platform any of this had ever run on.

Every failure is in the tests, not the product, and every one is the same
mistake in a different costume: asserting something about the *host* rather
than about the behaviour under test.

* Two Rust tests compared a whole PATH string against a hardcoded expectation
  containing forward slashes. `PathBuf::join` writes a backslash when the tests
  themselves run on Windows, so `C:/data/venv\Scripts` did not match
  `C:/data/venv/Scripts`. The subject is the separator *between* entries, so
  they now split on it and check the parts -- which is what the comment above
  them always claimed they were doing.

* Four Python tests compared `str(Path(...))` against a POSIX string, which is
  backslash-separated on Windows. They compare paths to paths now.

* The C-locale harness passed its statement through `python -c`. Python decodes
  that argument with the filesystem encoding -- ASCII with surrogateescape
  under LC_ALL=C on Linux -- so a test whose whole point was a non-ASCII path
  died on unpaired surrogates before reaching the code under test. It goes
  through a file now: source files are read as UTF-8 regardless of locale.

* The same harness captured the child's output with a bare `text=True`, which
  decodes using the *parent's* encoding. On a Windows runner a test about UTF-8
  handling was reading its own result back through cp1252 and failing on the
  round trip rather than on anything the engine did.

* One test asked FileSecretStore to write to "/nonexistent/nope" and expected
  failure. A Windows runner can create that at the drive root, so the store
  succeeded and the assertion was backwards. It now puts a file where a
  directory is needed, which fails everywhere.

None of this was reachable from a Mac, which is the entire argument for the
matrix.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 15:58:05 +01:00
MervinPraison f8f1f33d0e Merge branch 'feat-desktop-training' into fix-desktop-review-findings
# Conflicts:
#	src/praisonai-desktop/ui/index.html
2026-08-26 15:47:43 +01:00
MervinPraison 3a353ecb7b Merge branch 'feat-desktop-cross-platform' into fix-desktop-review-findings
# Conflicts:
#	src/praisonai-desktop/ui/index.html
2026-08-26 15:47:16 +01:00
MervinPraisonandClaude Opus 5 594982ec53 ci(desktop): run the desktop tests, which ran nowhere
An adversarial review pointed out that nothing in .github referenced
praisonai-desktop. Every suite in it -- engine, shell and UI -- existed and was
never executed by anything except a person running it by hand.

The UI suite could not even be installed. jsdom was undeclared, so it ran only
on machines that happened to have one somewhere further up the tree, at
whatever version that happened to be. Pinning it surfaced the consequence
immediately: 16 tests fail on jsdom 25 and pass on 30. They had been passing
against a version nobody chose.

The workflow runs the engine tests on all three platforms, which is the point
of the portability suite; the Rust tests and clippy on all three; and the UI
tests on Linux. Clippy is included because it was already failing -- a
redundant closure in supervisor.rs -- which is exactly the sort of thing a gate
catches and a person does not.

The lifecycle suite is excluded by name rather than left to skip. It drives
real conversations against a live engine and a real model, neither of which
exists on a runner, so it would have skipped all thirteen of its tests and
exited 0. The remaining 128 run with the skip count asserted to be zero,
because a suite that skips everything looks exactly like a suite that passed.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 15:45:20 +01:00
MervinPraisonandClaude Opus 5 1051a192a7 fix(desktop): the training defects an adversarial review found
Three of these are in the "user cancels" and "user watches a long run" paths,
which are the two things this feature exists to do.

**Opening the Training tab on a run older than ~20 minutes showed nothing, and
pinned a core doing it.** The SSE handler answered a gap by resyncing at
cursor=-1 -- which makes the next since() compute a gap again, because every
ring that has ever evicted satisfies `0 < oldest`. The `continue` then skipped
both the sleep and the terminal check, so it spun at ~13,000 resync frames per
second, delivered zero log events, and went on spinning after the run had
finished. A tqdm refresh emits two events, so a real fine-tune passes the 4000
-event ring in about twenty minutes. It now resumes at the oldest event still
held and falls through to deliver it.

**Pressing Stop before the trainer process existed cancelled nothing.** start()
returns as soon as the supervisor thread is created, so between the HTTP reply
and Popen returning, run._proc is None and stop() had nothing to signal. It
recorded STOPPING and _supervise then overwrote it with RUNNING: the user got
{"ok": true} and a "stopping" pill, and the fine-tune ran to completion and
reported "done" -- holding the GPU it was told to release. The state is now
settled under the run's lock, and a stop that arrived first terminates the
process on arrival.

**One trailing slash let a stale tab kill the live run.** The route was matched
by string, and rsplit("/") on "/train/stop/<stale-id>/" yields "", which is
falsy -- so the guard that exists to stop exactly this was skipped. The same
matching treated "/train/stopXXXX" as the stop route and read "?force=1" as
part of the run id, refusing a legitimate stop. The path is parsed now.

Also:

* Two runs could share a directory, overwriting each other's config.yaml and
  interleaving into one train.log. Ids differing only in case are one directory
  on macOS and Windows; and the generated id had one-second resolution, so two
  runs started in the same second collided -- which the new check caught
  immediately. Ids are settled before anything is created, so a refused run no
  longer clobbers the config of the run it collided with.
* A method that could never work failed after the model download rather than
  before it. GRPO needs reward functions this form cannot supply, so it is now
  refused in milliseconds with a message saying to use the CLI. Column
  requirements depend on the dataset and are still the trainer's to check, so
  every method hint now names the columns it needs instead of leaving the user
  to discover them an hour in.
* _last_meaningful_line read the whole log to keep forty lines from the end --
  at the exact moment a run fails, and a tqdm bar writes a line per refresh. It
  reads the last 64KB now.
* An unwritable runs directory raised PermissionError past the handler, so the
  connection dropped with no response and the UI reported "could not reach the
  engine", sending the user after the wrong problem.
* A malformed ?cursor= was an unhandled ValueError that dropped the connection.
* run.metrics and Trainer.history grew without limit in a process meant to stay
  open for days.

Converting those two to bounded deques broke /train/runs and /train/status,
because a deque cannot be sliced -- caught by the existing route tests, which
is what they are for.

Tests: 49 + 23 + 6 Python, 134 UI. New coverage for replaying a run that has
outlived its buffer, every stop-route shape, the pending-stop window, run-id
collisions, and the method preflight. All five fixes were mutation-tested.

Two notes on the tests themselves. The pending-stop case cannot be reached
through HTTP -- two round trips are slower than a spawn -- so it is tested
where it is deterministic, by delaying the spawn. And its first version asserted
only that the final state was "cancelled", which a version that never signals
the process also satisfies while the job runs to the end; it now asserts the
process died and the script's completion marker never reached the log.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-26 15:39:33 +01:00
MervinPraisonandClaude Opus 5 dd55e367f8 fix(desktop): the training history heading used an inline style
Found while fixing the same class of defect on the cross-platform branch.

Configuring a CSP makes Tauri stamp a nonce on the page's <style> tag, and a
nonce in a source list voids 'unsafe-inline' for that directive -- so every
style="" attribute in the document stops applying. The heading above the run
history would have lost its spacing the moment the two branches met, silently.

A class instead. The other inline styles on this branch are pre-existing and
are handled in the cross-platform work, along with the test that would have
caught this one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 23:34:58 +01:00
MervinPraisonandClaude Opus 5 2718c7a738 fix(desktop): the defects two adversarial reviews found in the last commit
Three ship-blockers, one security bug, one claim of mine that was simply wrong.

**The CSP I added broke the app, and I said it would not.** Configuring any CSP
makes Tauri stamp a nonce on the page's <style> tag and a hash on its inline
<script> at build time -- and a nonce or hash in a source list *voids*
'unsafe-inline' for that directive. So the moment a CSP existed, every
style="" attribute in the document stopped applying: a permanently visible red
Stop button, a raw file-input widget, and every tool-output block expanded, on
first launch. Nothing errors; it just looks broken. Those five attributes are
now classes. Setting .style from JavaScript is CSSOM and was never affected --
but `el.style.display=''` to reveal an element now restores the class default,
which is `display:none`, so those three call sites toggle the class instead.

**The Linux tray took the whole app down.** `tray::build(...)?` in setup, and
on Linux the tray goes through libappindicator, which is dlopen'd at first use
and *panics* if neither library is present -- not catchable, since we build
with panic=abort. Because it is dlopen'd there is no DT_NEEDED entry, so
dpkg-shlibdeps could not generate the dependency either: the package installed
cleanly and then did nothing when clicked. The tray is now non-fatal and the
library is declared explicitly, along with curl, which the uv bootstrap runs
and minimal images omit.

**The macOS overlay silently deleted the window geometry.** Tauri merges
tauri.macos.conf.json with RFC 7386, whose rule for arrays is replacement, not
element-wise merge. An overlay carrying only the two macOS keys replaced the
whole `windows` array: title, width, height and both minimums vanished, so the
app became an 800x600 "Tauri App" that could be dragged down to nothing. The
overlay now repeats the full object.

**Deleting an API key reported success while the key kept working.** Once a
secret had been written to both stores -- keyring unavailable, then available
again -- a delete that short-circuited on the first success left the other copy
behind, and the read path served it. The plaintext stayed on disk. A delete now
reaches every store, and a successful write clears the fallback so a superseded
key is not left readable.

Everything else, briefly:

* macos-13 was retired on 2025-12-04, so the Intel leg would never have
  scheduled -- and the completeness check would then have failed every release,
  forever, correctly. Now macos-15-intel.
* `cargo install tauri-cli ... || true` swallowed a real failure and the next
  step died complaining about an unrelated missing command. It also behaved
  differently per leg, since Windows defaults to pwsh where `true` is not a
  command.
* Two spawn sites had no console suppression -- including the engine itself, so
  a black console window sat beside the app for the entire session on Windows.
  There is now a lint over every spawn site, which is honest about being a lint:
  a Windows creation flag has no observable effect here, but "did anyone forget
  to call it" is exactly what regressed.
* One undecodable byte silently ended the engine's log reader for the rest of
  the session. `lines().map_while(Result::ok)` *stops* on the first
  Err(InvalidData) rather than skipping it, and Python encodes redirected
  streams with the locale code page on Windows. If it arrived before the port
  announcement, startup failed 30 seconds later blaming a timeout.
* The engine spawn had no process group on Unix, so SIGTERM reached the engine
  and nothing it had started -- with the training tab, a fine-tune would have
  kept a GPU after quit.
* `XInitThreads` is now called first in main(). GTK never calls it, several
  threads drive X anyway, and the failure is `_exit(1)` with no message.
* Empty environment variables were treated as set on the Rust side only, so
  `XDG_DATA_HOME=""` made the shell look for the lockfile in the working
  directory while the engine wrote it under the home directory -- "no lock,
  spawn" on every launch.
* The uv installer's exit code was taken as proof it installed something;
  `powershell -Command "irm ... | iex"` can exit 0 having done nothing.
* Three write-then-rename sites -- transcripts, MCP config, settings -- did not
  use the retry helper whose own docstring names their failure case.
* A leftover temp file kept its old permissions, so a secret could be written
  world-readable before the chmod that follows the rename. O_EXCL now.
* A DPAPI blob that would not decrypt read as empty and was then overwritten,
  destroying every other secret in it.
* The tray used macOS template rendering and Cmd accelerators everywhere:
  a black-on-black icon, and a menu advertising Win+Q, which Windows takes.
* "Copy data folder path" reproduced the default instead of asking the engine,
  so it handed the user a path the app was not using whenever it was
  overridden. /health now reports it.

Tests: 35 engine, 123 Rust, 141 UI. New: a CSP guard (there was none -- no test
in the suite sets a CSP header, which is why this shipped), a lossy-reader
suite, spawn-site hygiene with a positive control, and secret-deletion tests
that failed before the fix.

Two of my own new tests were wrong in the usual way. One asserted a repair
button *existed* without clicking it, so replacing its handler with an empty
function changed nothing. The other's comment-stripper only checked line
prefixes, so a continuation line inside a block comment -- this file's own
explanation of the bug -- was reported as an instance of it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 23:34:04 +01:00
Mervin Praison 5dfe20e929 Merge pull request #4373 from MervinPraison/feat-desktop-cross-platform
feat(desktop): ship on Windows and Linux, not only macOS
2026-08-25 23:09:33 +01:00
Mervin Praison 8db69b55ef Merge pull request #4372 from MervinPraison/feat-desktop-training
feat(desktop): add a Training tab that runs a fine-tune and reports on it
2026-08-25 23:09:27 +01:00
MervinPraisonandClaude Opus 5 c72b6b1d5c ci(desktop): release for Windows and Linux too, and verify nothing is missing
The macOS release workflow shipped first. Adding the other two was not a matter
of adding runners: the bundle targets were macOS-only, so a Windows or Linux
build produced zero artifacts and still exited zero. That is fixed in the
config change; this wires the pipeline to it.

Four legs now: macOS on both architectures, Windows NSIS, Linux .deb. NSIS
rather than MSI because MSI can only be built on Windows, needs an optional
Windows feature, and installs to Program Files behind a UAC prompt; NSIS
installs per-user with no administrator password. .deb only -- no RPM, no
AppImage. AppImage needs libfuse2, which Ubuntu 24.04 removed, and renders a
blank window on some Wayland and Mesa combinations; a .deb that works beats an
AppImage that half does.

Two decisions that look arbitrary and are not:

* The Linux leg pins ubuntu-22.04. glibc is forward-compatible only, so the
  build host sets the *oldest* distribution the package can start on. Building
  on 24.04 would emit a .deb that refuses to launch on 22.04 or Debian 12 with
  a GLIBC_2.38 error no runtime flag can fix. That image begins deprecation on
  2026-09-17 and retires 2027-04-17; the comment says to move to a debian:12
  container rather than bump the runner, so the floor stays our decision.
* libappindicator3-dev, not the ayatana package the Tauri docs suggest. The
  .deb's generated Depends: follows whichever the binary linked against, so
  mixing them between builds produces packages that disagree about their own
  dependencies.

A `verify` job now fails the release if any platform is missing an asset. A
release that quietly ships three platforms instead of four is worse than one
that fails, because nobody notices -- which is how v4.7.1 went out with zero
assets in the first place.

The install doc covers all four downloads, and says plainly what each platform
does about an unsigned build: Gatekeeper's misleading "damaged" message and the
right-click-Open, SmartScreen's hidden Run anyway, and `apt install ./file.deb`
rather than dpkg so dependencies resolve.

Guarded by tests, because these three files must agree and none of them fails
loudly when they do not: the workflow matrix, the config's bundle targets, and
the filenames the install doc tells people to download. 9 tests, 11/11
mutations caught -- including dropping a platform, swapping the appindicator
package, bumping the Linux runner, and removing the Windows icon.

One of those tests was weak on the first attempt: it grepped the whole file for
`exit 1`, which appears in the verify job too, so it passed with the
artifact step's guard removed. It is now scoped to that step.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 23:04:25 +01:00
MervinPraisonandClaude Opus 5 04c916b8e9 fix(desktop): bundle for Windows and Linux, and stop the UI assuming a Mac
The bundle targets were `["app", "dmg"]`. Both are macOS-only bundle types, so
a Windows or Linux build produced *zero artifacts* and still exited zero --
the failure mode is a green build with nothing in it. Added nsis and deb.

`titleBarStyle: "Overlay"` and `hiddenTitle` are macOS-only keys, silently
ignored elsewhere, which left a native title bar sitting on top of the CSS one.
They now live in tauri.macos.conf.json, which Tauri merges per platform.

`csp: null` disabled the content security policy entirely. It is now explicit,
and allows exactly what this app needs: loopback to the engine, which is how
the webview talks to Python at all.

On the UI side the page now reads which platform it is on and adapts three
things that were macOS facts written as layout constants:

* 5.2rem of left padding for the traffic lights. Windows and Linux put their
  window controls top-*right*, so this was dead space on the left and an
  overlap with the toolbar on the right.
* `⌘N`, `⌘K`, `⌘,` labels. The keydown handler already accepted Ctrl, so the
  shortcuts worked -- the hints just named a key those keyboards do not have.
  They now come from one place, so a new shortcut cannot be added with a
  hardcoded modifier that is wrong on two platforms out of three.
* "into your Library folder", and a Copy button that copied
  `~/Library/Application Support/PraisonAI` on machines where no such path
  exists. Windows gets %APPDATA%, which pastes straight into Explorer.

Detection is layered -- userAgentData, then navigator.platform, then the user
agent string -- because the last is the easiest to be wrong about: a webview
reporting only "(darwin)" matched none of the names and fell through to Linux
on a Mac. That was caught by a test, not by reasoning.

Two test defects fixed along the way, both of which had been passing while
proving nothing:

* `first-run.test.mjs` asserted the lede contains "Library". That passed only
  because this suite has only ever run on macOS; on the Linux runner this
  release needs, it would fail on correct behaviour -- and anyone fixing the
  copy would have "broken CI" for doing the right thing. It now asserts the
  copy names *a* place, whichever platform is reading it.
* The new platform tests originally passed jsdom's `userAgent` option, which
  is silently ignored, so all three cases ran against jsdom's own agent and
  were really testing one platform three times. The navigator properties are
  now defined directly. A second version read `body.textContent` -- which
  includes the inline script's source -- and "found" macOS copy on Windows
  inside the source of the very branch that avoids it.

Also generated icons/icon.ico: without it tauri-build does not warn, it
refuses to compile for Windows at all.

Tests: 6 new platform tests, 7/7 mutations caught. Full suite 124 passing, and
verified still passing with the page forced to report Linux.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 23:00:49 +01:00
MervinPraisonandClaude Opus 5 09c433d019 fix(desktop): make the Tauri shell run on Windows and Linux
The crate always compiled for all three platforms -- there is exactly one
`#[cfg]` in it -- so nothing ever failed loudly. Every defect here is a runtime
one: the app starts and then cannot find its own engine.

Platform is now a value rather than a `cfg`. Every path and process decision
that differs goes through a function taking one, because `#[cfg(windows)]`
makes the Windows branches invisible to a suite that only ever runs on macOS,
and invisible branches are how the code nobody runs comes to be wrong.

What was broken:

* `HOME` was the only home lookup. It is normally unset on Windows -- that is
  `USERPROFILE` -- so provisioning returned "no home directory" and the
  orphan-reclaim path never ran at all.
* Every interpreter candidate ended in `bin/python3`. A Windows venv keeps its
  interpreter in `Scripts\python.exe`, so all of them failed `is_file()` and
  the app reported "no usable Python found" immediately after successfully
  provisioning one.
* The uv bootstrap ran `/usr/bin/curl` piped into `/bin/sh`. Neither exists on
  Windows, so first-run provisioning was simply dead; and pinning curl's
  absolute path breaks Linux images where it lives elsewhere. There is now a
  PowerShell installer for Windows, and curl is found by name.
* `/bin/kill` was spawned unconditionally to reap an orphan, and the call site
  discards the result -- so on Windows, deciding to kill an orphan killed
  nothing, which is the exact bug reclaim.rs exists to prevent.
* `/bin/ps -o lstart=` was the only start-time source, so every pid on Windows
  read as "no such process", every live engine looked dead, and a second one
  started beside it on every launch. Windows now reads the creation stamp from
  kernel32.
* site-packages was derived as `lib/python3.X/site-packages`. Windows is
  `Lib\site-packages` with no version segment -- and the caller's only check is
  that the path sits under the venv root, so the wrong path passed the guard
  while pointing at nothing, which is worse than failing.
* `spawn_env` joined PATH with `:` and used `bin`. Windows is `;` and
  `Scripts`; one wrong separator makes the entire PATH a single unusable entry.

The shell and the engine must also agree on two things or they fail silently
and permanently: the data directory, and the hash that identifies a running
process. Both are now asserted from both sides -- reclaim.rs and
test_portability.py carry the identical fixture table, so if either hashing
implementation drifts, both suites fail rather than the two quietly disagreeing
and starting an engine per launch forever.

The PowerShell installer deliberately avoids `-WindowStyle Hidden` with
`-ExecutionPolicy Bypass`: that pair is a known malware detection signature and
gets installers flagged. The console is suppressed with a creation flag, which
is not a behavioural signal. `UV_NO_MODIFY_PATH` is set on every platform,
because an app should not rewrite the user's shell profiles to install its own
dependency.

Also generated the icon set. `icons/icon.ico` was missing, and tauri-build does
not warn about that -- it refuses to compile for Windows at all.

Verified rather than assumed: `cargo check --target x86_64-pc-windows-msvc
--all-targets` passes, so the FFI and the CommandExt code that macOS never
compiles is at least type-checked. Linux cannot be checked from here because
libdbus-sys needs system headers that only exist on Linux; CI covers it. 116
tests pass on the host.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 22:53:55 +01:00
MervinPraisonandClaude Opus 5 618e12a6bd fix(desktop): make the engine run on Windows and Linux, not just macOS
Six independent macOS assumptions. Two of them kill the engine outright on
Windows; one makes settings unsavable on Windows *and* Linux.

The signal registration was the worst, because of where it failed rather than
how. `for sig in (SIGTERM, SIGINT, SIGHUP)` builds the tuple before the loop
body, so SIGHUP is dereferenced outside the try that was meant to tolerate a
missing signal -- and Windows has no SIGHUP. It raised AttributeError, which
that except clause would not have caught anyway. It happened *after* the port
was announced and the lockfile written, so the shell adopted an engine that
was already dead and every request looked like a network fault. Signals are
now looked up one name at a time.

Secrets went to the macOS keychain via the `security` binary, which does not
exist elsewhere: the write reported failure, POST /settings raised with no
handler, and load_settings reported the key as unset forever. The app was
permanently unauthenticated on both other platforms with no visible cause.
There is now a store per platform -- keychain, DPAPI, libsecret -- each backed
by a 0600 file, because a machine with no keyring daemon (headless, container,
locked login keyring) must still be able to save a key. A secret is never
written into settings.json, which is the file people paste into issues.

The data directory was `~/Library/Application Support` everywhere. On Windows
that is not merely unconventional: it is excluded from roaming profiles and
from most backup tooling, so a user's entire history silently fails to follow
them to a new machine. Now %APPDATA% and $XDG_DATA_HOME respectively.

Three quieter ones. The lockfile was written in the locale encoding while the
Rust side reads it as strict UTF-8 and maps invalid bytes to "absent" -- and
absent means spawn, so a non-ASCII home directory would have produced a second
engine beside the live one on every launch. `read_file` decoded user files in
the locale encoding with errors replaced, handing the model plausible mojibake
it would reason over confidently. And SO_REUSEADDR, correct on POSIX, means
"another process may bind this address while I am listening" on Windows --
on unauthenticated loopback carrying API keys.

Also: the recycled-pid fingerprint shelled out to `ps` and returned 0 on
Windows, and 0 is the "could not tell" value, so every recycled pid compared
equal and an unrelated process would be adopted as the engine.
GetProcessTimes gives a real creation stamp.

One incident worth recording. Verifying this live wrote a test key into my own
macOS keychain, because PRAISONAI_DESKTOP_HOME isolates the data directory but
not the system keyring, which is shared per user. KEYCHAIN_SERVICE is now
overridable so a test cannot reach the real one. Asking for isolation should
isolate everything.

Tests: 27, all mutation-checked -- 13/13 mutations of these guarantees are
caught. Four of those tests were rewritten after the first versions survived
their mutations: the XDG test compared a path to itself (its "custom" value
was the default), the two encoding tests could not fail on a UTF-8 Mac and now
run under LC_ALL=C in a child process, and the permission test asserted the
final mode, which passes even when the file is created 0644 and chmodded
afterwards -- leaving a window where any local process can read the key.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 22:41:43 +01:00
MervinPraisonandClaude Opus 5 ec3a260c9a feat(desktop): add a Training tab that runs a fine-tune and reports on it
The desktop app could talk to a model but not make one, while praisonai-train
sat behind it doing exactly that. This wires the two together.

A training run is a subsystem rather than a route. The engine is a
ThreadingHTTPServer where every request is a turn that starts, streams and
ends -- right for chat, wrong for something that runs for hours, must survive
the window closing, and has to resume after a reconnect.

Three decisions, each taken because the alternative was worse:

* One run at a time. Two fine-tunes on one GPU do not co-exist; they OOM.
  Starting a second returns 409 naming the run that is already going, rather
  than queueing, which would imply a scheduler nobody asked for.
* Progress is a replayable ring buffer, not a stream. Reconnecting reads from
  a cursor, so a closed lid loses nothing; if history was evicted the client
  is told to resync rather than handed a log with a hole in it.
* The subprocess owns the truth. Status comes from the process and its log,
  never from what the client last heard, so a client that missed the ending
  still learns how it ended.

Three defects the tests found while being written, all of which would have
shipped:

* The event cursor was derived from len(events). Once eviction begins the
  length stops growing, every later event is handed the same cursor, and
  since() returns nothing forever -- a live run whose UI silently stops
  updating. It is now a standalone counter.
* run_id came from the client and became a directory name. The engine listens
  on loopback with no auth by design, so any local process could have posted
  `../../../Library/LaunchAgents/x`. Now allowlisted.
* Stopping a run signalled the process group without checking whose group it
  was. The mutation that removes start_new_session proves the hazard is real:
  it makes the test runner kill itself. The engine now refuses to signal its
  own group.

Portable from the start, since Windows and Linux builds are next: the pipe is
decoded as UTF-8 rather than the locale encoding (cp1252 turns unsloth's
banner into mojibake, and five of its bytes raise inside the reader loop,
freezing the log while the run continues); PRAISONAI_TRAIN_CMD is split with
Windows rules on Windows, where POSIX splitting eats every backslash in an
interpreter path; the child gets CREATE_NEW_PROCESS_GROUP there, because
CPython accepts start_new_session on Windows and then ignores it; and run ids
reject reserved device names, which an alphanumeric allowlist cannot see.

Tests: 42 engine unit tests, 10 end-to-end tests against a real spawned
engine, 16 UI tests driving the real page in a DOM. All 9 mutations of the
engine's guarantees are caught, and all 7 of the UI's. The existing desktop
suite stays green at 134.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 22:35:01 +01:00
Mervin Praison c5e97aedcb Merge pull request #4371 from MervinPraison/feat-desktop-release
ci(desktop): build the Mac app on release and attach it
2026-08-25 22:15:39 +01:00
MervinPraisonandClaude Opus 5 8f64195a5d ci(desktop): build the Mac app on release and attach it
Releases carried no assets at all. v4.7.1 shipped zero, so a Mac user had
nothing to download and no path to the app that exists in this repo.

On a published release (or by hand against an existing tag) this builds a .dmg
on macos-14 and macos-13 and uploads both:

    PraisonAI-<version>-macos-apple-silicon.dmg
    PraisonAI-<version>-macos-intel.dmg

Two per release rather than one universal binary, because Tauri does not
produce a universal build and a wrong-arch download fails at LAUNCH rather than
at install -- which is a worse place to discover it.

The build fails loudly when no .dmg is produced instead of uploading nothing.
An empty asset list is exactly the state this workflow exists to fix, and a
silent no-op would recreate it while looking green.

Unsigned, deliberately: signing needs a Developer ID in repository secrets, and
shipping an unsigned build people can right-click-open beats shipping none.
INSTALL-macOS.md says so plainly rather than letting Gatekeeper say it -- its
message for an unsigned app is "PraisonAI is damaged and can't be opened",
which is both alarming and untrue. The guide covers which architecture to pick,
the right-click-Open path, the xattr fallback, what the first-run provisioning
does and where it puts things, and how to remove it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 22:12:49 +01:00
MervinPraisonandClaude Opus 5 3c846a7baf test(train): stop tests from asserting on source text instead of behaviour
Rebuilt on current main after #4367, #4368 and #4370 merged.

An adversarial audit ran 16 mutations against this suite. EIGHT survived all
408 tests, and every survivor was covered only by

    src = inspect.getsource(some_function)
    assert "the_thing_i_want" in src

which passes whether or not the code does anything: the string is still in the
file when the branch around it is disabled.

The worst case was a test holding a COPY of the logic it tested.
test_response_masking.py defined _auto_use_mask() mirroring train_model's
`auto` expression, so reverting the real line to the pre-fix `supports_mask`
alone left the whole suite green -- the single defect that PR existed to fix
was undetectable by its own test file. The expression is now
resolve_mask_setting() and the test calls it; that mutation now fails.

The guard caps source-text assertions, bans reimplementing tested logic, and
asserts the extracted helpers stay importable -- each of resolve_mask_setting,
decide_masking, resolve_response_markers, is_out_of_memory, rewards.require and
rewards.resolve_all exists because a source-text test failed to catch a bug,
and inlining one silently returns its test to proving nothing.

The cap is a ratchet, not a ban: ordering constraints are genuinely structural.
It tripped twice on rebases while this PR waited, which is the point. Both
times the new assertion turned out to be convertible and was converted rather
than waved through: _launch_script RETURNS its command, and the PEFT forwarding
list is data, now the module constant PEFT_PASSTHROUGH.

Two things the guard got wrong first, both found by running the regression
rather than reading it: it matched the COMMENT explaining the removed copy, so
it now tokenises and drops comments and strings; and joining tokens with spaces
turned `inspect.getsource` into `inspect . getsource`, so the count missed
every occurrence.

483 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 22:11:23 +01:00
Mervin Praison 5bfb8c8e57 Merge pull request #4370 from MervinPraison/feat-unsloth-surface
feat(train): the unsloth surface that was declared and unreachable
2026-08-25 22:09:05 +01:00
MervinPraisonandClaude Opus 5 8dd0f3310a feat(train): the unsloth surface that was declared and unreachable
Each of these is a keyword unsloth already accepts and this package never
passed. Nothing failed when they were missing -- the run just did something
narrower than the user asked for.

QUANTS: 12 of unsloth's 24 were refused by this validator before unsloth was
ever asked -- q3_k_l, q4_k_s, q5_k_s, f32 and the aliases are all legal
upstream. The IQ family (11 imatrix quants) was unreachable entirely, which is
how a 30B model fits on a laptop. Now 35 accepted, read out of unsloth's own
save.py rather than hand-picked, with a test that fails when the two drift.

MODEL ACCESS: hf_token, trust_remote_code and revision were unreachable, so a
gated repo (Llama, Gemma) worked only through an environment variable, a
custom-code model not at all, and a multi-day run could not pin a revision --
the tag moves and the run is no longer reproducible.

PEFT SELECTORS: the target-module list was hardcoded to seven names.
finetune_last_n_layers (the cheap-run recipe), target_parameters (required to
LoRA MoE experts), layers_to_transform, layers_pattern and init_lora_weights
are all forwarded now.

OFFLINE MERGE: save_pretrained_merged was never called, so the only way to get
merged weights was to PUSH them -- needing a Hub account, a token and a
network. `merged_save_dir` writes them to a directory.

vLLM: fast_inference was never passed, so GRPO always ran the slow HF-generate
rollout path.

28 tests. Five mutations, all killed.

One of them could not be expressed at first, because the test helper
REIMPLEMENTED the assembly it was checking -- in the very change that bans
reimplementing tested logic. It is now model_access_kwargs() and the test calls
it; deleting the token forwarding fails.

480 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 22:08:48 +01:00
Mervin Praison 3ddb96aa1c Merge pull request #4367 from MervinPraison/fix/train-audit-findings
fix(train): five defects an adversarial audit found in the merged work
2026-08-25 22:01:28 +01:00
Mervin Praison 8e7827d512 Merge pull request #4368 from MervinPraison/fix-desktop-restore
feat(desktop): the Mac app work, recovered from a stash that nearly lost it
2026-08-25 22:01:17 +01:00
MervinPraisonandClaude Opus 5 d2b9dd8a10 feat(desktop): the Mac app work, recovered from a stash that nearly lost it
A `git stash --include-untracked` during merge-conflict repair swallowed the
whole untracked praisonai-desktop tree and it was never restored. It surfaced
only because a UI audit described settings as inert that had been fixed days
earlier -- true of the tree on disk, not of the work. Recovered from
stash@{0}^3.

What it contains, all previously reported and none of it on main:

- The eleven dead settings wired to real behaviour, each proven by a test swept
  across at least two values.
- Text size scales the whole interface rather than only the chat, with every
  fixed-pixel dimension converted to rem and a test that fails on any
  dimensional px over 3px.
- The transcript and the composer share one column. They were ~240px apart
  because an auto margin on a flex item's cross axis overrides `stretch`.
- In-app confirmation dialogs. window.confirm returns false immediately in a
  WKWebView without showing anything, so every guard written as
  `if (!confirm(...)) return;` cancelled -- which had silently broken deleting
  a conversation outright.
- data-tauri-drag-region plus core:window:allow-start-dragging, without which
  the titlebar could not move the window: -webkit-app-region is a Chromium
  feature WKWebView does not implement.
- Accessibility: landmarks, a live region for streamed answers, accessible
  names on icon-only controls, a focus-trapping dialog, aria-current on rows.
- Real app and menubar icons from the PraisonAI mark. A macOS template image is
  drawn from its alpha channel alone, so the previous one rendered as a solid
  block.
- First-run provisioning with staged progress, message versions with a
  `< 1/2 >` picker, and orphan reclamation through the lockfile the Rust side
  had always specified and nothing wrote.

118 JS tests across 11 suites, 90 Rust tests, all passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:58:36 +01:00
MervinPraisonandClaude Opus 5 49e8151175 fix(train): ten defects an adversarial audit found in the merged work
Three agents audited the eleven PRs that just merged. Every defect below passed
the suite that shipped it.

FIVE IN THE CODE

1. --dry-run showed one config and a different one trained. Forwarding
--dataset/--model -- which I added as "belt and braces" -- puts the legacy
dispatcher on its regeneration branch (praison_ai.py:676), where
generate_config() runs with every tuning parameter None and rewrites
config.yaml from defaults: lora_r 64 -> 16, epochs 5 -> 1, method dpo -> sft,
and huggingface_save false -> TRUE with hf_model_name defaulted to a third
party's repository.

2. cpt silently lost embedding_learning_rate, the one value it exists to set.
__dataclass_fields__ is inherited, so on a plain __init__ subclass it resolves
to the parent's fields and the drop-unknown filter deleted the value 45 lines
after setting it -- along with max_seq_length and packing.

3. remote start could never train: it passed config.yaml as the dataset
positional. Two positionals meant exit 2 before the GPU was touched.

4. Remote command injection and path traversal. --run-id 'x;touch /tmp/pwned'
executed, because the far half of an scp target is expanded by a shell there.
fetch ../../../../etc/passwd passed the existence gate and copied the file
back. Both quoted and validated now.

5. hf_private failed open: 'private', 'y', 'enabled', 5 all published the
repository.

FIVE AT THE BOUNDARY

6. llm overwrote ./config.yaml with no backup, and had already destroyed a
hand-written one in this repo's working tree.

7. praisonai train exposed 6 of 15 commands and no remote group, because the
integrated CLI imports cli/commands/train.py while the siblings register via
cli/app.py.

8. tests/unit/data/ -- 96 tests -- was collected by no workflow at all.

9. The suite needed 3.11 while pyproject declares >=3.10.

10. pip install "praisonai-train[llm]" produced an llm command that died at
argument parsing; nothing declared the praisonai wrapper it imports.

44 tests. Ten mutations, all killed -- four only after fixing the test first: a
fake class with **kwargs returned early, two called a validator the code path
no longer had to use, and one matched the comment explaining the fix rather
than the configuration. That last shape has now appeared three times.

452 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:56:52 +01:00
Mervin Praison 383d122591 Merge pull request #4366 from MervinPraison/feat/train-grpo-and-reward-modelling
feat(train): GRPO, reward modelling and CPO
2026-08-25 21:34:21 +01:00
Mervin Praison 58a1b3537c Merge pull request #4363 from MervinPraison/feat/train-post-training-commands
feat(train): answer the two questions a fine-tune ends with
2026-08-25 21:33:59 +01:00
MervinPraisonandClaude Opus 5 539aa3ac54 feat(train): GRPO, reward modelling and CPO
Rebuilt again on current main, which moved when four sibling PRs merged.

The last of unsloth's training methods. It rewrites every trl.trainer.*_trainer
module generically (rl.py:4235-4248), so all three work through it. GRPO was
the blocker for the rest, because it needs reward functions and a YAML config
can only carry a string.

DECISION, stated because it is the one thing I could not derive: reward
functions are named by dotted import path, `module:function`.

    method: grpo
    reward_funcs:
      - myproject.rewards:length_penalty
    num_generations: 8

A registry populated by decorator would mean the config can only name functions
from a module something else already imported. Inline Python in YAML is a
code-execution surface in a file people paste from the internet. An import path
is the only one of the three resolvable before anything runs -- which is why
rewards resolve before the model loads, so a typo costs five seconds rather
than a multi-gigabyte load. If you would rather have a registry, the change is
contained to rewards.py.

A missing module and a missing attribute are told apart, since one is a
PYTHONPATH problem and the other a typo; the attribute error lists the
callables that are there. Every broken path is reported at once. GRPO takes
prompts only, since it generates its own completions.

394 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:31:42 +01:00
MervinPraisonandClaude Opus 5 b6f604bbab feat(train): answer the two questions a fine-tune ends with
Rebuilt again on current main, which moved when #4361/#4362/#4364/#4365 merged.

    praisonai-train checkpoints
    praisonai-train infer -d lora_model "Summarise this release."

checkpoints: after an interrupted run there was no way to find what was saved
without `ls`. Matches checkpoint-<n> exactly, so a directory called
checkpoint-final is not reported as step 0, and sorts numerically.

infer: TrainModel.inference() existed with no caller anywhere in the package.
serve --benchmark measures tokens per second without showing a token.
Streamed, with usable defaults rather than the dead inference()'s
temperature=1.5 / max_new_tokens=64.

Named infer, not generate: generate is already dataset generation, and a second
command of that name silently replaces one with the other.

Also one line: dataset_num_proc defaults to None rather than 1, so unsloth's
memory-aware sizing is not overridden into a single process.

386 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:31:09 +01:00
Mervin Praison 2af4b9f3d2 Merge pull request #4365 from MervinPraison/fix/train-environment-preflight
fix(train): check the versions, and look at the GPU before pinning CUDA
2026-08-25 21:29:47 +01:00
Mervin Praison 29ec1b09e8 Merge pull request #4364 from MervinPraison/feat/train-model-catalog
feat(train): validate model names against unsloth's own catalog
2026-08-25 21:29:31 +01:00
Mervin Praison 6d0fab4702 Merge pull request #4362 from MervinPraison/feat/train-cli-config-and-flags
feat(train): give `llm` a --config, a --dry-run and the flags that matter
2026-08-25 21:29:05 +01:00
Mervin Praison 85574ae4cd Merge pull request #4361 from MervinPraison/feat/train-continued-pretraining
feat(train): continued pretraining, which was blocked two separate ways
2026-08-25 21:28:51 +01:00
MervinPraisonandClaude Opus 5 3012b98ce3 feat(train): validate model names against unsloth's own catalog
Rebuilt on current main; the previous branch carried eight unrelated commits.
The `models` command now lives in its own module rather than alongside the
post-training commands, so this change does not depend on them.

model_name was free text with no validation anywhere. A typo was caught by
Hugging Face -- after the CLI accepted it, after the environment was built,
sometimes after a partial download. And nothing told a new user which models
work: config.yaml offers exactly one example, from the Llama-3.1 era.

    praisonai-train models
    praisonai-train models gemma
    praisonai-train models --json

The catalog is read from unsloth's mapper at runtime, not vendored: it carries
246 distinct 4-bit repos and is updated every release, so a copy here would be
stale the day it shipped. CURATED is the fallback for when unsloth is absent --
the CLI is importable without the training extra -- and the starting point for
someone who has not chosen yet.

An unknown model warns rather than refusing, because unsloth loads plenty it
does not map. Suggestions match the repo name, not the full id, so an upstream
id surfaces the unsloth mirror. The catalog includes mapper values as well as
keys, since a 16-bit mirror appears only as a value and naming one would
otherwise be reported as unknown.

335 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:23:44 +01:00
MervinPraisonandClaude Opus 5 23180a9c80 fix(train): check the versions, and look at the GPU before pinning CUDA
Rebuilt on current main; the previous branch carried nine unrelated commits.

Two ways to end up with an environment that looks fine and cannot train.

Version floors were pip metadata only. pyproject declares torch>=2.6.0 and the
rest, but nothing checked at runtime -- and pip is routinely bypassed:
--no-deps, a hand-built conda env, setup_conda_env.sh pinning its own torch.
The failure then arrived as whatever traceback the mismatched package happened
to raise, several imports deep. A test asserts the floors match pyproject
exactly: lower lets a broken environment through, higher refuses one pip would
have built.

The CUDA pin ignored the hardware. Every Linux host got pytorch-cuda=12.4
regardless. On AMD or Blackwell that builds a working-LOOKING environment that
fails at the first kernel; on a CPU host it installs a CUDA build that can
never run. There is a probe now -- nvidia-smi, then /proc/driver/nvidia/gpus
for a host with the driver but not the tool, then rocminfo -- and the pin lives
in one place instead of four copies across two branches.

A CPU-only Linux host is told plainly that training will refuse, since
trainer.py raises on a missing GPU. A prerelease of the required version counts
as satisfying the floor, because refusing 2.6.0rc1 blocks people testing
upstream and unsloth's own checks are release-level.

338 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:22:55 +01:00
MervinPraisonandClaude Opus 5 8212dd07ea feat(train): continued pretraining, which was blocked two separate ways
Rebuilt on current main; the previous branch carried four unrelated commits.

A raw-text corpus could not load. A dataset with only a `text` column fell
through to the Alpaca branch, formatted every row to "", and died with "All
examples formatted to empty text" — an error blaming the dataset for a shape
the formatter did not handle. Domain and language adaptation could not run.

And the embeddings got the wrong learning rate. modules_to_save was exposed, so
a user could make embed_tokens/lm_head trainable, but the trainer was plain
SFTTrainer, which has no embedding_learning_rate. They trained at the adapters'
rate — the recipe unsloth ships UnslothTrainer to avoid.

    method: cpt
    modules_to_save: [embed_tokens, lm_head]
    embedding_learning_rate: 2e-5      # defaults to learning_rate / 10

Raw text passes through with no tokenizer call; ShareGPT and Alpaca still win
over a stray `text` column; cpt is formatted like SFT rather than taking the
preference passthrough. The empty-dataset error now names the three shapes and
prints the columns the dataset actually had.

326 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:21:46 +01:00
MervinPraisonandClaude Opus 5 d54647ae9e feat(train): give llm a --config, a --dry-run and the flags that matter
Rebuilt on current main; the previous branch was cut from its predecessor and
carried five unrelated commits.

`praisonai-train llm` took three flags to `unsloth train`'s thirty-eight, and
was the one command that would not accept --config though export and serve
both do. Eleven flags now, plus --config and --dry-run.

Two fixes on top of the original branch, both from problems main surfaced:

train_llm is called directly as a plain function, not only through Click --
main's own tests do it. Unfilled Typer parameters then arrive as OptionInfo
objects and Path(config) raises TypeError. They are unwrapped to their declared
defaults first.

The dataset and model reach argv as well as the resolved config.yaml. The
config path alone is the better mechanism, since the legacy dispatcher drops
every flag it does not declare -- but an earlier incident had the dataset
dropped entirely and the run trained on a public Alpaca mirror, discovered
after the GPU time. Passing both means neither path can lose it, and main's
regression test for that incident keeps passing.

330 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:20:14 +01:00
Mervin Praison 1eb8d58f90 Merge pull request #4360 from MervinPraison/feat/train-remote-cli
feat(train): expose remote SSH training, which had no call sites
2026-08-25 21:17:31 +01:00
MervinPraisonandClaude Opus 5 5fa3bcd79a feat(train): expose remote SSH training over SSH
Rebuilt on current main. The previous branch was cut from its predecessor
rather than from main, so it carried three unrelated commits and conflicted
once those merged. This is the change on its own.

praisonai_train/remote/runner.py was never in the repo. It was untracked —
present in a working tree, absent from git — so the CLI wired to a module that
did not exist and every command raised ModuleNotFoundError. That is what the 7
CI failures were, and the runner here is the one the triage agent wrote to fix
it: stdlib only, no new dependency for a feature most users never touch.

The tests did not catch it because every one of them replaces `_runner` with a
fake. A fake is the right tool for the CLI's behaviour and the wrong tool for
"does the thing I am faking exist" — so there are now three tests that import
the real module, check it has the six methods the CLI calls, and assert it
pulls in nothing outside the standard library. Removing the module fails ten
tests where it used to pass fourteen.

    praisonai-train remote preflight gpubox
    praisonai-train remote start gpubox -c config.yaml -d data/train.jsonl
    praisonai-train remote tail gpubox run-1787...
    praisonai-train remote fetch gpubox run-1787... lora_model -o ./out
    praisonai-train remote stop gpubox run-1787...

start prints the run id and the stop command before it streams, so a Ctrl-C
during --follow does not leave a job running with no handle. A preflight
failure and a refused start both exit non-zero. stop reports when nothing was
running rather than claiming success.

319 tests passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 21:06:01 +01:00
Mervin Praison 413ff37565 Merge pull request #4359 from MervinPraison/fix/train-oom-message-and-readme
fix(train): explain an OOM, and fix two README commands that do not exist
2026-08-25 20:03:22 +01:00
Mervin Praison 76bccae745 Merge pull request #4355 from MervinPraison/claude/issue-4354-20260825-1715
refactor: extract shared prep/finalize helpers from workflows single-step runners
2026-08-25 20:02:20 +01:00
praisonai-triage-agent[bot]andMervin Praison ad4e859e81 fix(train): define-free logger warning and gate response masking to SFT
Two runtime bugs flagged in review:

- load_datasets() called logger.warning but the module never defines a
  logger, so any dataset with processing_func set raised NameError and
  aborted the run. Switched to print(), matching every other warning in
  this module.

- Response-only masking (train_on_responses_only) rewrites SFT label
  tensors; it is meaningless for DPO/ORPO/KTO, whose trainers build labels
  from chosen/rejected pairs. The decision ran before method was read and
  applied unconditionally, so an enabled masking setting could corrupt a
  preference run. Gated the whole masking block on method == "sft".

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 18:45:21 +00:00
MervinPraisonandClaude Opus 5 d35e05883e fix(train): explain an OOM, and fix two README commands that do not exist
Two things the tool told the user that were not true.

OOM. torch.cuda.OutOfMemoryError subclasses RuntimeError, so the CLI's
catch-all flattened it into one ERROR line carrying torch's raw allocator dump.
It is the most common fine-tuning failure and the one where a first-time user
has no idea which number to change. The handler now classifies it and prints an
ordered remedy — sequence length first, smaller model last — matching the
ordering unsloth validated in its own backend
(studio/backend/core/training/worker.py:4834-4845). A test asserts every knob
the remedy names is actually accepted by the config, so the advice cannot drift
from the schema.

README. Two quickstart commands did not exist as written:

  praisonai-train data from-trials ...   # there is no `data` sub-app
  praisonai-train llm --dataset ...      # dataset is positional

The second is the exact shape of an incident this code already carries a
comment about (cli/commands/train.py:65-68): a dataset passed the wrong way was
silently dropped and the run trained on the default corpus, discovered only
after the GPU time was spent. A reader who "fixes" --dataset by dropping the
flag walks straight into it.

A test now parses every `praisonai-train <sub>` in the README and asserts it is
a registered command, so the docs cannot drift from the CLI again.

While writing the tests I deleted a branch I had just added: a separate
type(exc).__name__ == "OutOfMemoryError" check was redundant with the
"outofmemoryerror" text marker, since the class name is folded into the
searched text. No mutation could distinguish it from its absence, which is the
definition of code a test cannot cover.

12 tests. Mutations killed: the remedy never printed, the marker dropped, and
each README command reverted. Full suite 265 passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 18:44:28 +00:00
praisonai-triage-agent[bot] dddd3c11c4 Merge pull request #4358 from MervinPraison/fix/train-response-masking
fix(train): actually mask prompts out of the loss
2026-08-25 18:43:08 +00:00
praisonai-triage-agent[bot] f97fe47724 Merge pull request #4356 from MervinPraison/feat/train-preference-tuning
feat(train): support DPO, ORPO and KTO, not just SFT
2026-08-25 18:41:56 +00:00
praisonai-triage-agent[bot] 38bf625efd Merge pull request #4357 from MervinPraison/fix/train-hub-private-by-default
fix(train): Hub pushes are private by default
2026-08-25 18:38:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 2be4685f1b fix(train): let auto masking reach the turn-marker fallback
In `assistant_only_loss: auto` (the documented default), use_mask was keyed
off TRL's `_supports_assistant_mask()` alone. Every unsloth template lacks
`{% generation %}`, so that returned False, use_mask became False, and
decide_masking short-circuited to full-sequence loss before the turn-marker
route was ever considered -- the exact defect this PR set out to fix. Auto now
enables masking when EITHER route is usable and lets decide_masking pick.

Adds regression tests covering the auto-normalization coupled with route
selection, the gap that let the bug survive.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 18:03:37 +00:00
praisonai-triage-agent[bot]andMervin Praison c645f6b96d fix(train): translate vision Hub errors, coerce string hf_private, normalize legacy quant list
Resolves three P1 issues from review on PR #4357:

- Vision pushes (train_vision.py, upload_vision.py) now wrap push_to_hub
  in the shared hf_push_errors context manager, so a 401/403 becomes the
  same actionable advice the LLM path gives instead of a raw traceback.
- hub_push_kwargs default coercion is now string-aware: vision callers
  that omit `flag` read hf_private: "false"/"0"/"no" as public instead of
  bool("false") == True keeping the repo private against the user's wish.
- Legacy single-element quantization_method lists are normalized back into
  config in both validate_config and for_export, so the GGUF exporter
  (which re-reads self.config) receives the method string, not a list.
- train_vision.save_model_merged now uses the guarded clean_local_repo_dir
  instead of an unguarded rmtree that could delete a namespaced repo path.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 18:01:33 +00:00
MervinPraisonandClaude Opus 5 a203f7d69d fix(train): actually mask prompts out of the loss
Every instruction-tuning run was training on the prompt as well as the answer.

`assistant_only_loss` is TRL's mask and it needs `{% generation %}` in the chat
template. None of unsloth's 43 templates contain it — grep -c "generation %}"
unsloth/chat_templates.py returns 0. So `assistant_only_loss: auto`, the
documented default, resolved to False for every template a user can actually
select. There was no error; the run summary printed "Loss mask: full sequence"
as though that were the intended outcome.

Unsloth's own answer is train_on_responses_only(trainer, instruction_part,
response_part) (chat_templates.py:58-87), which masks by locating literal turn
markers and works on any template. It is applied to the built trainer rather
than through the config, because it rewrites label tensors on the tokenised
dataset.

That needs the two markers, which vary by family, so this adds a table for
llama-3, chatml, gemma, mistral, phi and zephyr, and a resolver that falls back
to the model name when no chat_template is set. Unknown models resolve to None
rather than a guess: masking on the wrong markers finds no boundary and trains
on nothing, which is worse than not masking.

Two things worth review:

- Writing the resolver's tests found a real bug in it. Every unsloth model is
  "unsloth/<name>", and "unsloth" is itself a template key, so scanning the
  full repo id sent every one of them to the chatml markers — including Gemma
  and Llama models whose markers are different. It now matches the repo name,
  not the org.
- The summary line now names which route masked. "assistant replies only" hid
  the distinction between two mechanisms with very different coverage, and the
  unmasked case now says what it costs: "full sequence (training on prompts
  too)".

decide_masking() is separate from train_model so the routing can be tested
directly. The first version of that test read train_model's source for the
string "train_on_responses_only" and passed with the calling branch disabled —
the same shape of weak test that let the original defect survive.

18 tests. Five mutations, four killed: removing the fallback route, matching
the org prefix again, making two markers identical, and forcing masking on.
The survivor is collapsing the two routes into one truthiness check, which is
covered by the label test rather than the router. Full suite 253 passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 18:57:43 +01:00
praisonai-triage-agent[bot]andMervin Praison 25e2646cdb fix(train): let preference datasets reach their trainer; fix undefined logger
process_dataset applied the SFT formatter unconditionally, collapsing every row
to a single "text" column before the preference column check ran -- so a
correctly shaped DPO/ORPO/KTO dataset lost the very columns its trainer needs and
was rejected before trainer construction, making all three new methods
unreachable. Skip SFT text-formatting for preference methods so prompt/chosen/
rejected (and prompt/completion/label) survive intact.

Also fixes an undefined `logger` in load_datasets that raised NameError whenever
dataset.processing_func was set; switched to print to match the module.

Adds regression tests covering both the preference bypass and the unchanged SFT
path.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 17:56:02 +00:00
MervinPraisonandClaude Opus 5 5893e59116 fix(train): Hub pushes are private by default
Every model this tool uploaded became a PUBLIC repository. `private` was never
passed to push_to_hub_merged or push_to_hub_gguf in any of the three places
that push — train/llm/trainer.py, train_vision.py and upload_vision.py. Unsloth
accepts the flag (save.py:2654, create_huggingface_repo(private=...)); nothing
here forwarded it. A model fine-tuned on private data was published the moment
training finished, silently.

The default is now private. Publishing is one click in the Hub UI; unpublishing
something that has already been crawled is not. Set hf_private: false to
publish.

The same duplication hid two more defects, both in upload_vision.py, both
already solved in its LLM twin:

- No error handling at all, so a rejected token surfaced as a raw
  HfHubHTTPError traceback. The LLM copy has translated 401 and 403 into
  actionable messages for a while.
- An unguarded rmtree: it deleted config["hf_model_name"] whenever a directory
  of that name existed, with no check that the value was a local path rather
  than a Hub repo id. A bare name like "mymodel" deleted ./mymodel.

All three now share praisonai_train/_hub.py, so the next fix lands in one place
instead of one of three. save_method, commit_message and tags are forwarded
too — previously save_method was hardcoded to merged_16bit at all three sites.

Found by an agent diffing unsloth's save.py against these files.

14 tests. The privacy default is asserted directly, the YAML "false" string is
covered (without the trainer's coercion it is truthy and would keep the repo
private against the user's wish), and a test asserts no push site still uses a
bare token as its only kwarg — the signature of the original defect.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 18:53:53 +01:00
MervinPraisonandClaude Opus 5 c4a938b3a0 fix(train): stop flattening preference datasets before the trainer reads them
The DPO/ORPO/KTO support in the previous commit could never have worked on a
real corpus, and the tests did not catch it because they exercised the column
check in isolation rather than through process_dataset.

process_dataset ends with

    dataset.map(format_func, remove_columns=dataset.column_names)

which collapses every row to a single `text` column for SFT. Applied to a
preference dataset that destroys prompt/chosen/rejected before the trainer ever
sees them, so every DPO run would have died with

    method 'dpo' needs the column(s) ['prompt', 'chosen', 'rejected']
    and the dataset has ['text']

— an error message that blames the user's file for something this code did to
it moments earlier.

Non-SFT methods now return the dataset with its columns intact, after the
shuffle that the SFT path would also have applied. Found by an agent reading
unsloth's rl_replacements.py against this file; the preference trainers there
read those columns by name (rl_replacements.py:163-201, :850-931).

Two tests, both asserting order rather than outcome: the method guard must
appear before the flattening call and must return rather than warn, and the SFT
path must still flatten. Removing the guard fails the first.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 18:53:17 +01:00
MervinPraisonandClaude Opus 5 d0c960f8e4 feat(train): support DPO, ORPO and KTO, not just SFT
Unsloth patches TRL's DPO, ORPO and KTO trainers alongside SFT — they are
named in the list of trainers it rewrites at unsloth/__init__.py:1438-1443.
praisonai-train could only ever construct an SFTTrainer, so a dataset of
chosen/rejected pairs had nowhere to go and the whole preference-tuning half
of the backing library was unreachable.

  method: dpo        # or orpo, kto; sft remains the default
  beta: 0.1
  max_prompt_length: 1024

TRAINING_METHODS is a registry: each entry names the TRL trainer and config
class, the columns the dataset must provide, and whether the method needs a
frozen reference model. Adding a method is one entry.

Three things worth calling out:

- ORPO declares needs_ref_model: False. It folds the reference into its loss,
  so holding a second frozen model would double VRAM for nothing.
- DPO and KTO pass ref_model=None, which means "use the frozen base weights".
  With a PEFT adapter that is exactly right and avoids a second full model.
- The dataset's columns are checked before the run starts. TRL's own failure
  for a wrongly-shaped preference dataset surfaces inside the collator —
  minutes in, after the model is loaded and quantised, and phrased in terms
  of tensors rather than the file the user pointed at.

The TRL imports are individual and lazy, so a TRL too old to provide one
trainer can still run the others rather than failing at import; a method whose
classes are absent raises a message naming what to upgrade.

resolve_method() is separate from validate_config() so the method surface can
be tested without a CUDA import.

19 new tests. Five mutations, all killed: accepting an unknown method, dropping
case normalisation, disabling the column check, giving ORPO a reference model,
and having validate_config skip the check. Full suite 233 passing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 18:48:21 +01:00
praisonai-triage-agent[bot] 309a8bcc7d Merge pull request #4353 from MervinPraison/claude/issue-4352-20260825-1625
fix: remove dead code standardise/enhanced_templates.py
2026-08-25 17:21:54 +00:00
praisonai-triage-agent[bot]andMervinPraison 9d5ca574f8 refactor: extract shared prep/finalize helpers from workflows single-step runners (fixes #4354)
Extract byte-identical prep and finalize logic from the sync
_execute_single_step and async _aexecute_single_step into three private
helpers (_prepare_step_context, _build_step_action, _finalize_step_result),
so both variants share a single source of truth. Genuine async seams
(handler-coroutine await, achat executor branch, retry-loop await) remain
per-variant. Pure behaviour-preserving extraction; no public API change.

Co-authored-by: MervinPraison <[email protected]>
2026-08-25 17:17:59 +00:00
praisonai-triage-agent[bot]andMervinPraison c4360358fb fix: remove dead code standardise/enhanced_templates.py (fixes #4352)
Delete unreferenced 680-line EnhancedTemplateGenerator module that is
never imported, wired into the CLI, or exported. The canonical, wired
generator remains standardise/templates.py::TemplateGenerator.

Co-authored-by: MervinPraison <[email protected]>
2026-08-25 16:25:43 +00:00
praisonai-triage-agent[bot] 4ed3c7d40f Merge pull request #4351 from MervinPraison/claude/issue-4350-20260825-1334
fix: test memory presets via resolver, not live Agent construction
2026-08-25 14:26:38 +00:00
praisonai-triage-agent[bot]andMervin Praison db454b0c8a test: add docstrings to memory backend test helpers
Satisfies docstring coverage gate (60% -> 100%) flagged by CodeRabbit.
No behavioural change; all 4 tests still pass.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 13:40:33 +00:00
praisonai-triage-agent[bot]andMervinPraison f6e2349b31 fix: test memory presets via resolver, not live Agent construction (fixes #4350)
The regression test for #4229 eagerly constructed Agent(memory=preset) for
every advertised preset, which initialised live memory adapters (Mem0/MongoDB)
and failed CI without API keys or running services. Assert resolver correctness
instead, matching resolver-only test patterns elsewhere.

Co-authored-by: MervinPraison <[email protected]>
2026-08-25 13:35:56 +00:00
Mervin Praison bb04951886 Merge pull request #4345 from MervinPraison/fix/gateway-scope-lifecycle-methods
fix(gateway): classify the four unscoped WebSocket lifecycle methods
2026-08-25 14:23:43 +01:00
Mervin Praison 426886d774 Merge pull request #4338 from MervinPraison/claude/issue-4337-20260825-0852
fix: native project-instruction hierarchy & rules for standalone praisonai-code
2026-08-25 14:23:13 +01:00
Mervin Praison 160dc9a615 Merge pull request #4336 from MervinPraison/claude/issue-4335-20260825-0822
fix: SSE progress fan-out, job admission backpressure, async blueprint parity
2026-08-25 14:22:34 +01:00
Mervin Praison 0dbbf05e57 Merge pull request #4334 from MervinPraison/claude/issue-4333-20260825-0711
fix: honor Task skip_on_failure/on_error, wire model-call hooks, ingest Knowledge URLs
2026-08-25 14:22:05 +01:00
Mervin Praison 552a421553 Merge pull request #4347 from MervinPraison/fix/verbose-agents-callers
fix(agents): repair internal callers passing verbose= to Agent()
2026-08-25 13:34:00 +01:00
praisonai-triage-agent[bot] 76c464097d Merge pull request #4348 from MervinPraison/fix/verbose-yaml-scheduler
fix(scheduler): stop passing verbose= to Agent() from the YAML loader
2026-08-25 11:43:47 +00:00
Mervin Praison c54b391553 Merge pull request #4349 from MervinPraison/feat/praisonai-desktop
feat(desktop): PraisonAI Mac desktop app
2026-08-25 12:12:42 +01:00
praisonai-triage-agent[bot] 8afa73ae09 Merge pull request #4343 from MervinPraison/claude/issue-4341-20260825-0942
fix: enter resolved route profile scope to namespace tenant memory
2026-08-25 11:07:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 5c8027a08e fix(desktop): resolve approval requests in UI and clear stale provider env
- ui/index.html: handle approval_request SSE events with an inline
  Allow/Always/Deny card that POSTs /approve/{id}. Without this, the default
  approval_mode:ask stalled every gated file/URL tool for the full 300s
  timeout because the shipped UI never resolved the decision.
- engine/server.py: _apply_env now removes OPENAI_API_KEY / OPENAI_API_BASE
  when a value is cleared, instead of skipping it — clearing a key or base_url
  no longer leaves the deleted credential/endpoint active for later turns.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 11:05:54 +00:00
MervinPraisonandClaude Opus 5 f2d38c1c57 feat(desktop): PraisonAI Mac desktop app
A Tauri shell supervising a Python engine on loopback. The streaming path is
kept off the IPC bridge: the webview reads SSE from 127.0.0.1 directly, which
is what jan, unsloth studio, ollama and hermes all independently converged on.

src-tauri/  Rust core, pure and I/O-injected so every decision is testable
            without a Mac, a certificate, a network or an installed Python:
              venv_resolve   one function owns both interpreter and
                             site-packages, catching the silent case where
                             3.13.2 and 3.13.7 share a path and nothing raises
              verify         half-installed is Stale, not Ready
              health         a server answering failures with HTTP 200 cannot
                             be trusted by status code alone
              readiness      log phrasing is upstream and versioned, so it is
                             a tested pure function
              adopt          five gates before reusing an orphan; a recycled
                             PID is never killed
              lockfile       absent and corrupt get different answers
              port_announce  an announced port is a claim until probed

engine/     stdlib-only HTTP + SSE: streaming, cancellation, sessions,
            persistence, tool approval carrying call_id, attachments, search,
            settings, MCP config, fork, projects, keychain-backed secrets

ui/         single-file frontend. Named ui/ rather than dist/ because it is
            hand-authored source, not build output -- and the repo's .gitignore
            excludes dist, which would have silently dropped the entire
            interface from the commit.

Tests: 62 Rust, 8 pacing, 11 DOM click tests dispatching real MouseEvents.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 11:58:28 +01:00
praisonai-triage-agent[bot]andMervin Praison 78794a7330 fix(agents): restore FileMemory fallback for non-ImportError backend init failures
The verbose-callers migration accidentally dropped the broad `except Exception`
branch (added in 2eacdcf79) that degrades an installed-but-uninitializable
memory backend to FileMemory. Without it, a registered backend that raises a
non-ImportError during init (mem0 missing credentials, MongoDB unreachable)
hard-crashes Agent construction. Restored to honour the fallback contract.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 10:55:52 +00:00
Mervin Praison ebd5aecbfb Merge pull request #4346 from MervinPraison/fix/streaming-with-tools-yields-nothing
fix(streaming): request the answer after tools run
2026-08-25 11:53:53 +01:00
MervinPraison e91a72f9a0 docs: auto-update documentation parity trackers [skip ci] 2026-08-25 10:53:23 +00:00
Mervin Praison a8d3130df7 Merge pull request #4342 from MervinPraison/fix/agui-adapter-drops-events
fix(agui): stop silently dropping 13 of 16 stream event types
2026-08-25 11:52:43 +01:00
MervinPraisonandClaude Opus 5 eaa5811b88 fix(scheduler): stop passing verbose= to Agent() from the YAML loader
yaml_loader.py documents verbose: true in its example agents.yaml, reads it,
and passes it to Agent() unconditionally with a default of False. Agent has no
verbose parameter, so every scheduled YAML agent raises TypeError on
construction -- whether or not the key appears in the YAML.

Verbosity now goes through output=, which is where it was consolidated.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 11:49:30 +01:00
MervinPraisonandClaude Opus 5 fe70396090 fix(agents): repair internal callers passing verbose= to Agent()
Verbosity was consolidated into output=, but two shipped call sites inside
praisonaiagents were left behind and raise TypeError on construction:

  compaction/memory_flush.py  the pre-compaction agent could never be built
  tools/delegation_tools.py   every delegate_task call

Agent's rejection of unknown kwargs is deliberate and is left intact. What was
missing was guidance: verbose is accepted by six sibling classes, and Agent has
no verbosity parameter among its 41, so the answer is not discoverable from the
signature. The error now names output= as the destination.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 11:49:30 +01:00
praisonai-triage-agent[bot]andMervin Praison ccbadea720 fix: make profile scope turn-local to prevent cross-tenant memory leak
The shared BotSessionManager._profile_namespace field was rewritten by every
routing handler, so overlapping turns for different tenants could recompute a
storage key from another tenant's profile after an await point — reading or
persisting one tenant's transcript into another's namespace (flagged by
CodeRabbit and Greptile).

Bind the resolved profile turn-locally in a per-task ContextVar inside chat()
(consumed from per-agent/instance staging and reset in finally), and propagate
that context into the load/save executor threads. Concurrent routed turns now
keep isolated namespaces and an unscoped route always fails closed.

Adds an overlapping-turns regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 10:21:38 +00:00
praisonai-triage-agent[bot]andMervinPraison feb6b4d110 fix: enter resolved route profile scope to namespace tenant memory (fixes #4341)
The gateway resolved RouteMatch.profile but never consumed it, so per-route
tenant isolation was inert and failed open — every route on a process shared
one memory store. Wire the resolved profile through the routing handlers onto
the session's storage key so memory/session state is namespaced per tenant,
and stays unscoped (fail-closed) when a route names no profile.

Co-authored-by: MervinPraison <[email protected]>
2026-08-25 10:20:53 +00:00
MervinPraison e770b443d6 docs: auto-update feature parity trackers [skip ci] 2026-08-25 10:19:01 +00:00
praisonai-triage-agent[bot] d861cf4976 Merge pull request #4340 from MervinPraison/claude/issue-4339-20260825-0913
fix: make gateway state durable by default and report durability degradation
2026-08-25 10:18:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 3a224e2529 test(gateway): resolve enum-derived branches in scope lockstep
The lockstep extraction only recognised double-quoted literal msg_type
comparisons, so a dispatcher branch written enum-only (as ping/pong already
are, e.g. `msg_type == EventType.X.value`) would drift past it — the exact
silent registry/dispatcher divergence the test exists to catch.

Resolve every `EventType.<NAME>.value` operand through the enum to its wire
value, treat operands within one `in (...)` branch as aliases for a single
dispatch path (so the abort/MESSAGE_ABORT branch stays one method, not a
spurious `message_abort`), and add a positive control asserting the
enum-derived branch resolution still works.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 09:55:31 +00:00
praisonai-triage-agent[bot]andMervin Praison e1484bde74 fix(agui): keep tool-call id stable across id-less argument deltas
Providers send a tool-call id once and omit it on later argument chunks.
The DELTA_TOOL_CALL/TOOL_CALL_END branches minted a fresh UUID per chunk,
orphaning arguments from the invocation the client assembles. Track the
active tool call in EventBuffer and fall back to it. Also drop the
truthiness guard on these branches so a TOOL_CALL_END with an empty
tool_call payload closes the call instead of raising.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 09:54:48 +00:00
praisonai-triage-agent[bot]andMervin Praison c346751e8e fix(streaming): preserve request context and event telemetry after tools
Reuse the original request messages (system prompt, output constraints,
memory context) for the post-tool follow-up instead of chat_history alone,
which dropped the system turn. Bracket the follow-up answer with
REQUEST_START/LAST_TOKEN/STREAM_END so consumers see telemetry for the
answer, not just the tool-call round. Strengthen the regression test to
assert the sentinel flows through the streamed answer, proving the tool
branch ran.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 09:54:20 +00:00
MervinPraisonandClaude Opus 5 06dfc8e59d fix(streaming): request the answer after tools run
The streaming path collected tool calls, executed them, appended the results
to chat history -- and then ended. It never asked the model for the final
answer, so start(stream=True) yielded nothing while the identical
non-streaming call answered normally.

There was no error. The caller received an empty generator and could not
distinguish 'the model had nothing to say' from 'the answer was never
requested'.

The follow-up completion carries no tools, so the model answers from the
results it already has rather than calling another tool, which also bounds
the turn to a single round.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 10:48:41 +01:00
MervinPraisonandClaude Opus 5 f40516a3cb fix(gateway): classify the four unscoped WebSocket lifecycle methods
resolve_required_scope() fails closed to ADMIN for anything it does not know,
which is the right default -- but four of the six message types the dispatcher
handles were unregistered, so switching the resolver on would have demanded
ADMIN for the connection handshake and locked out every client.

hello, join and leave are READ; abort is WRITE, since aborting a turn mutates
it exactly as sending one does.

Adds a lockstep test pinning the dispatcher's message types against the
registry, in the style that catches a runtime-only permission failure no
behavioural test can see.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 10:48:13 +01:00
MervinPraisonandClaude Opus 5 7f0e1c28c3 fix(agui): stop silently dropping 13 of 16 stream event types
The AG-UI adapter handled DELTA_TEXT, TOOL_CALL_START and TOOL_CALL_RESULT
and ended with a bare `return []`. StreamEventType has sixteen members, so
thirteen were discarded with no error and no log.

ERROR, RETRY, MODEL_FALLBACK and STREAM_UNAVAILABLE were among them: a
mid-stream provider failure produced a run that ended cleanly having never
told the client anything went wrong.

Every type now has an explicit disposition -- mapped, or listed in
NOT_WIRE_VISIBLE -- and an undeclared type raises rather than vanishing, so
adding a seventeenth member fails a test instead of dropping a frame in
production.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-25 10:47:00 +01:00
praisonai-triage-agent[bot]andMervin Praison 2eacdcf794 fix: gracefully fall back to FileMemory when a string memory backend fails to initialise
CI test-core (subdirs) failed on test_every_advertised_preset_resolves with
"Mem0 API Key not provided" because the mem0 package is installed in that job
but no MEM0_API_KEY is set. Agent(memory="mem0") already promised a graceful
"Falling back to FileMemory" for missing optional dependencies (ImportError),
but an installed-yet-uninitialisable SaaS backend raised ValueError, which was
uncaught and crashed construction. Broaden the string-preset branch to honour
the same fallback contract for runtime init failures.

Pre-existing, unrelated to PR #4340 (gateway durability); no Agent params added.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 09:34:46 +00:00
praisonai-triage-agent[bot]andMervin Praison a84c6ace9f fix: clear stale idempotency degradation on recovery and redact reasons (#4339)
Address two valid review findings on the durability-degradation surface:

- Recovery clears degradation: a successful durable SqliteIdempotencyStore
  build now clears any prior `durability:idempotency` degraded fact, so a
  same-process rebuild (config hot-reload) that restores durability no longer
  leaves health()/`gateway status` permanently reporting non-durable operation.
  Mirrors the existing session-store recovery-clear path.

- Redacted operator-facing reasons: the idempotency and session fallback paths
  no longer interpolate the raw store-init exception (which can embed backend
  filesystem paths) into the degraded-owner `reason`. The raw exception stays in
  the log only; the reported reason is an operator-safe, redacted string —
  matching the documented contract on record_durability_degraded.

Tests: add recovery-clears-degradation test and a redaction assertion.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 09:29:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 331df584cb fix: make gateway state durable by default and report durability degradation (fixes #4339)
Gateway durable state was opt-in and lost silently. Now:
- build_idempotency_store is durable by default (SQLite when a state dir is
  available); "memory" stays an explicit opt-in for tests/ephemeral runs.
- Gateway server default idempotency backend is durable when none configured.
- Session/idempotency store-init failures record a durability-degraded fact
  (owner_kind="gateway", owner_id="durability:<component>") instead of a silent
  logger.warning, reusing the existing DegradedCapabilityRegistry — no new core
  protocol or Agent params.
- health() merges durability facts; `gateway status` prints a Degraded: section
  with a redacted reason and a `fix:` hint so operators see non-durable runs.

Co-authored-by: MervinPraison <[email protected]>
2026-08-25 09:20:12 +00:00
praisonai-triage-agent[bot]andMervin Praison e78b151d01 fix: harden standalone rules clear scope & remote-fetch SSRF (DNS rebinding)
- rules clear: constrain deletion to workspace-scoped CLI-created rules; no
  longer unlinks discovered AGENTS.md/CLAUDE.md/PRAISON.md, global, git-root or
  nested subdir rules (Greptile P1).
- context_files remote fetch: pin each connection to a validated public IP and
  re-validate at connect time, closing the DNS-rebinding TOCTOU window while
  preserving Host/SNI; _allow_local_urls opt-in bypasses pinning (Greptile P1).
- tests: add regressions for clear-scope preservation and rebind refusal.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 09:08:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 448b7bb912 fix: make project-instruction hierarchy and rules command native to standalone praisonai-code (fixes #4337)
On a standalone `pip install praisonai-code` the subtree instruction hook,
`--instructions` resolution, and the `rules` command silently degraded because
their implementations were reachable only via the optional `praisonai_bot` /
`praisonai` wrapper packages.

- Add a resident, dependency-free `praisonai_code/integration/context_files.py`
  (stdlib + `praisonaiagents.hooks` only) so subtree AGENTS.md loading and
  `--instructions` glob/URL resolution work without the optional packages.
- Wire `run.py::_wire_subtree_context_hook` to fall back to the resident helper
  after the optional bot/wrapper copies, removing the silent no-op.
- Give `rules list/add/clear` a native implementation backed by core
  `praisonaiagents.memory.RulesManager`, delegating to the wrapper bridge only
  when core is genuinely unavailable.
- Add standalone regression tests that block the optional packages.

Co-authored-by: MervinPraison <[email protected]>
2026-08-25 08:59:51 +00:00
praisonai-triage-agent[bot]andMervin Praison f354b977f2 fix: atomic job admission + no stranded jobs on 503 backpressure
Address two P1 races flagged by Greptile on PR #4336:

- executor.submit: reserve the admission slot synchronously (create+register
  task before any await) so concurrent submits can no longer slip past the
  max_queued ceiling. The QUEUED persist moves to the head of _execute_job
  (before the concurrency semaphore), preserving the store-reflects-QUEUED
  contract without a check-then-await race.
- router.submit_job: on JobQueueFull after save_if_absent persisted the job,
  delete the stranded record so its idempotency key is freed and status/SSE
  don't hang while /result returns 409. Cleanup is best-effort and never masks
  the 503.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 08:35:19 +00:00
praisonai-triage-agent[bot]andMervinPraison bf0f2fb213 fix: SSE progress fan-out, job admission backpressure, async blueprint parity (fixes #4335)
Gap 1: JobExecutor now keeps a list of progress callbacks per job and
fans out to all of them, so concurrent SSE viewers of the same run each
receive updates; unregister removes only the given callback.

Gap 2: JobExecutor.submit rejects with JobQueueFull once max_queued
(default max_concurrent*10) is reached; the router maps this to a 503 +
Retry-After so floods get honest backpressure instead of unbounded task
growth.

Gap 3: AsyncAgentScheduler gains from_blueprint via a shared
build_from_blueprint helper in _base_scheduler; blueprint resolution now
lives in one place for both sync and async schedulers, and the async
variant dispatches via astart so it never blocks the event loop.

Co-authored-by: MervinPraison <[email protected]>
2026-08-25 08:28:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 3568a40013 fix: thread model-hook request mutations, route async model calls through middleware, preserve after_model output, capture MongoDB URL ids
- chat_mixin: before_model/wrap_model_call mutations to temperature/tools now
  flow into the retry core (were silently ignored)
- chat_mixin: after_model mutations on non-string responses are preserved via
  _unwrap_model_response (previously raw object bypassed hook edits)
- chat_mixin: achat() now routes through the model middleware manager (was
  bypassing before/after/wrap_model_call); sync chain bridged via executor
  without nesting event loops, retry core extracted to
  _achat_completion_with_retry_core
- knowledge: URL ingestion now captures bare string document ids (MongoDB)

Addresses CodeRabbit/Greptile review findings on PR #4334.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 07:33:01 +00:00
praisonai-triage-agent[bot]andMervin Praison 3374d179b3 fix: honor Task skip_on_failure/on_error, wire model-call hooks, ingest Knowledge URLs (fixes #4333)
- Task(skip_on_failure=True)/on_error="continue" now let a dependent task
  run in degraded mode instead of being force-failed when an upstream
  dependency fails, across every cascade path (agents async/sequential and
  process sequential/asequential).
- Agent(hooks=[before_model/after_model/wrap_model_call]) now fire: model
  calls route through MiddlewareManager.execute_model_call via a new
  _get_model_middleware_manager, mirroring the existing tool-hook wiring.
- Knowledge.add() now ingests URL inputs (as its docstring documents) via
  MarkItDown extraction instead of raising NotImplementedError.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-25 07:18:31 +00:00
Mervin Praison 689978100b Merge pull request #4301 from MervinPraison/claude/issue-4300-20260824-1020
fix: thread cancel_token into OpenAI-native tool loop so /stop halts mid-run
2026-08-24 21:02:08 +01:00
Mervin Praison 6e3b1f1af8 Merge pull request #4292 from MervinPraison/claude/issue-4288-20260824-0917
fix: record resolver identity and per-request reviewer custody on gateway approvals
2026-08-24 21:01:34 +01:00
Mervin Praison 84c53a1e04 Merge pull request #4277 from KevinTran3011/security/codeql-research-findings
Security/codeql research findings
2026-08-24 21:00:42 +01:00
Mervin Praison d1f52e6625 Merge pull request #4274 from MervinPraison/claude/issue-4273-20260823-1712
fix: remove dead ServerRegistry class in agent.py
2026-08-24 20:59:32 +01:00
Mervin Praison ce81f0c835 Merge pull request #4272 from MervinPraison/claude/issue-4271-20260823-1615
fix: exclude persistence test package from installed wheel
2026-08-24 20:59:07 +01:00
Mervin Praison 2a3d947def Merge pull request #4248 from MervinPraison/claude/issue-4246-20260823-0711
fix: enforce sandbox resource limits, add sync chat() backend guards, dedupe Gemini tool constant
2026-08-24 20:58:48 +01:00
Mervin Praison e69cca85be Merge pull request #4244 from MervinPraison/claude/issue-4243-20260823-0522
fix: restore repeated session turns; close session-share secret leaks
2026-08-24 20:58:09 +01:00
Mervin Praison 8949dc0672 Merge pull request #4236 from MervinPraison/claude/issue-4229-20260823-0446
fix: derive generator memory backends from resolver; normalise runtime= (fixes #4229)
2026-08-24 20:57:34 +01:00
MervinPraison 248be5864b docs: auto-update documentation parity trackers [skip ci] 2026-08-24 19:55:56 +00:00
Mervin Praison cbe42d4ae7 Merge pull request #4234 from MervinPraison/claude/issue-4228-20260823-0445
fix: keep default denials when configuring approval + enforce argument-scoped deny rules
2026-08-24 20:55:15 +01:00
Mervin Praison 11a6f9706c Merge pull request #4276 from LinespottingPrivate/feat/gbr-pair
Add Build Remote Agent phone pairing (gbr/1)
2026-08-24 20:36:24 +01:00
Mervin Praison 1f0ccefa8a Merge pull request #1985 from EvoLinkAI/feat/evolink-provider
feat: add Evolink provider example
2026-08-24 20:35:35 +01:00
praisonai-triage-agent[bot] c34ca01dde Merge pull request #4331 from MervinPraison/claude/issue-4330-20260824-1619
fix: ConfigValidator calls non-existent ToolResolver.resolve_tool()
2026-08-24 17:21:07 +00:00
praisonai-triage-agent[bot]andMervin Praison f283bf2b3d fix: treat TOOL_MAPPINGS tools as known-optional in ConfigValidator
web_search and other tools declared in praisonaiagents' TOOL_MAPPINGS
resolve to None when their optional dependencies aren't installed. After
switching to ToolResolver.resolve(), these were incorrectly flagged as
'Unknown tool' errors, breaking test_agents_yaml_valid_fields_no_warnings.
Consult TOOL_MAPPINGS so such tools produce an install warning instead of
a hard error, while genuinely unknown tools still error.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 16:28:23 +00:00
praisonai-triage-agent[bot]andMervinPraison 4fa4e0cdcc fix: call ToolResolver.resolve() in ConfigValidator tool validation (fixes #4330)
ConfigValidator._validate_tools called a non-existent resolve_tool()
method on the ToolResolver instance, raising AttributeError that was
swallowed by the broad except, downgrading every tool reference to a
generic warning. Unknown/misspelled tools were never reported as errors.
Use the actual instance method resolve(), which returns None on miss.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 16:20:50 +00:00
praisonai-triage-agent[bot] 3fee78cf58 Merge pull request #4329 from MervinPraison/claude/issue-4327-20260824-1245
fix: route implemented legacy CLI verbs to handlers instead of billing them as LLM prompts
2026-08-24 14:02:51 +00:00
praisonai-triage-agent[bot] 8b56b13710 Merge pull request #4328 from MervinPraison/claude/issue-4326-20260824-1245
fix: route praisonai search to retrieval instead of billing as LLM prompt
2026-08-24 13:36:10 +00:00
praisonai-triage-agent[bot] 9c5d4a05d4 Merge pull request #4325 from MervinPraison/claude/issue-4322-20260824-1234
fix: stop praisonai/capabilities from fabricating success
2026-08-24 13:30:29 +00:00
praisonai-triage-agent[bot] 6fa3f9e3cd Merge pull request #4324 from MervinPraison/claude/issue-4323-20260824-1234
fix: pin resolved command name to registry key in praisonai --help
2026-08-24 13:23:59 +00:00
praisonai-triage-agent[bot]andMervin Praison a6d59a1939 fix: fail closed in legacy verb discovery so implemented verbs are never billed to an LLM
Address CodeRabbit review: _get_legacy_verbs() returned an empty set on
oracle import failure, which let implemented verbs (e.g. `thinking status`)
fall through to the bare-prompt rule and be forwarded to the modern `run`
engine as a paid LLM prompt — the exact #4327 regression the guard prevents.

Now fails closed via a static _LEGACY_VERBS_FALLBACK mirror, logs a warning,
and does not cache the fallback (so recovery is possible). Adds tests for the
fail-closed path, end-to-end routing under discovery failure, and a drift
assertion keeping the fallback in sync with the authoritative oracle.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 13:23:51 +00:00
praisonai-triage-agent[bot] 422cc5b888 Merge pull request #4321 from MervinPraison/claude/issue-4319-20260824-1205
fix: preserve blank lines and guard over-cap code fences in bot replies
2026-08-24 13:18:13 +00:00
praisonai-triage-agent[bot] e6dc27072b Merge pull request #4320 from MervinPraison/claude/issue-4318-20260824-1205
fix: silence-free bot command dispatch + honest /model reset
2026-08-24 13:08:23 +00:00
praisonai-triage-agent[bot]andMervinPraison 9ce7b8c0d4 fix: route implemented legacy CLI verbs to their handlers instead of billing them as LLM prompts (fixes #4327)
Since 82f55c97b the unified dispatcher treated any first token that is not a
registered Typer command as free text and forwarded it to the modern `run`
engine, billing 29 implemented legacy verbs (thinking, install, persistence,
audio, …) to /v1/responses at ~2.5k tokens with exit 0.

Extract the legacy dispatcher's authoritative verb list into a reusable
LEGACY_SPECIAL_COMMANDS constant and have the unified dispatcher consult that
same oracle: an implemented legacy verb routes to legacy (its handler) before
the bare-prompt/LLM path. containers and vector-stores are withheld pending
#4322 so routing does not promote a fabricated success.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 12:51:27 +00:00
praisonai-triage-agent[bot] 9b8ae4374e Merge pull request #4317 from MervinPraison/claude/issue-4314-20260824-1131
fix: route praisonai-train llm to the trainer instead of a chat prompt
2026-08-24 12:47:44 +00:00
praisonai-triage-agent[bot]andMervinPraison ace59ede5e fix: route praisonai search to retrieval instead of billing as LLM prompt (fixes #4326)
The `search` command was advertised in --help and registered as a real Typer
command, but was missing from get_command_names()'s routing oracle. This caused
`praisonai search "..."` to be misclassified as a bare prompt and billed as an
LLM call, contradicting its "no LLM generation" help text.

Adds `search` to the routed set and a parity regression test guaranteeing every
advertised command is also routed (guards the #2191 drift guarantee).

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 12:47:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 665c33628f fix: map backends command to a help category
backends was added to _LAZY_COMMANDS but never mapped in COMMAND_CATEGORIES,
leaving test_every_registered_command_has_a_category failing and the command
without an explicit --help panel. Map it to Advanced.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 12:42:46 +00:00
praisonai-triage-agent[bot] f9b40f1ba2 Merge pull request #4316 from MervinPraison/claude/issue-4315-20260824-1132
fix: praisonai-train QC drops English trajectories; validate lacks zero-row guard
2026-08-24 12:41:06 +00:00
praisonai-triage-agent[bot]andMervinPraison 4888a0435c fix: stop capabilities from fabricating success; fail honestly (fixes #4322)
Vector store file ops now call the real litellm public API
(vector_store_file_create/list/delete) instead of the never-existent
create_vector_store_file names, and no longer swallow AttributeError into
fabricated success. Delete defaults to False, not True.

search() uses the shipped duckduckgo_tools.internet_search symbol (was the
nonexistent duckduckgo_search) and surfaces backend errors instead of a
permanent empty-but-successful result.

containers/container_files have no backend, so container_create and the
container file read/write/list functions now raise NotImplementedError
rather than inventing container IDs or returning empty results. CLI and MCP
adapters already wrap these in except Exception, so failures surface cleanly.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 12:39:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 5c2d71306d fix: pin resolved command name to registry key in praisonai --help (fixes #4323)
get_command set rich_help_panel but never command.name. typer_get_command
yields "" for a Typer(help=...) declared without name=, and collapses a
single-command app to that command's own name — so --help rendered 77/98
commands with a blank name and 3 with a wrong (untypeable) name. Pin
command.name to the registry key (the true typeable name) in get_command.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 12:38:11 +00:00
praisonai-triage-agent[bot] f33e17b4d2 Merge pull request #4313 from MervinPraison/claude/issue-4309-20260824-1120
fix: forward browser run options on extension/cdp engines
2026-08-24 12:37:45 +00:00
praisonai-triage-agent[bot] 5977a366fe Merge pull request #4312 from MervinPraison/claude/issue-4310-20260824-1121
fix: CDP click/type/clear_input report selector-not-found as failure
2026-08-24 12:27:14 +00:00
praisonai-triage-agent[bot] 338609f875 Merge pull request #4308 from MervinPraison/claude/issue-4306-20260824-1112
fix: reject misspelled keys in deploy config validation
2026-08-24 12:21:33 +00:00
praisonai-triage-agent[bot] 887b81b804 Merge pull request #4311 from MervinPraison/claude/issue-4304-20260824-1111
fix: rebind broken MCP adapters to real core APIs
2026-08-24 12:20:09 +00:00
praisonai-triage-agent[bot]andMervin Praison f8ef5fd293 fix: guard mixed fence corruption and use UTF-16 cap for Telegram (#4319)
- _chunk.py: only re-wrap truly pure single-fence chunks in enforce_hard_cap;
  mixed fence+prose or multi-fence chunks are hard-split as plain text so prose
  is never rendered as code (Greptile P1).
- telegram.py: measure and chunk in the platform's declared UTF-16 unit so
  emoji-heavy replies cannot exceed Telegram's 4096 UTF-16 cap (Greptile P1).
- tests: mixed-fence non-corruption + UTF-16 length coverage.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 12:18:03 +00:00
praisonai-triage-agent[bot]andMervinPraison a725723900 fix: preserve blank lines and guard over-cap fences in bot replies (fixes #4319)
Part 1: split_media_from_output stripped every blank line from every
Telegram/Slack reply, destroying load-bearing separators, paragraph
breaks and clean chunk boundaries. Now only excess blank runs left by
removed MEDIA lines are collapsed; single blank-line separators survive.

Part 2: chunk_message emits an over-cap code fence by design and no
caller re-checked the platform cap, so a pure/oversized fence raised
"message too long" and lost the whole reply. New enforce_hard_cap()
splits any over-cap chunk (re-balancing ``` on split fences) and is
applied in every _send_long_message (telegram/slack/discord).

Fixed together so restoring blank lines cannot regress fence delivery.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 12:10:17 +00:00
praisonai-triage-agent[bot]andMervinPraison ea22399481 fix: silence-free bot command dispatch + honest /model reset (fixes #4318)
- Remove the dead /sethome registry entry: it was published to every
  adapter's menu but implemented on none, so drop the false advertisement.
- Telegram: on a slash-command miss, dispatch the raw text to the agent
  instead of ending silently, matching the Slack reference and the code's
  own docstring.
- Discord: remove the bare terminal return in the on_message command block
  so an unknown/unimplemented slash command falls through to agent dispatch
  (like Slack) rather than a silent no-op.
- _session.reset()/reset_all(): clear _model_overrides so /new actually
  remedies a bad or unavailable /model instead of every later turn failing
  silently at the provider.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 12:09:43 +00:00
praisonai-triage-agent[bot] ac4ef21b50 Merge pull request #4307 from MervinPraison/claude/issue-4305-20260824-1111
fix: thread Azure --subscription onto az commands (fixes #4305)
2026-08-24 12:08:30 +00:00
praisonai-triage-agent[bot] 9f96115aa1 Merge pull request #4303 from MervinPraison/claude/issue-4302-20260824-1105
fix: enforce SecurityPolicy in DockerSandbox (#4302)
2026-08-24 11:57:32 +00:00
praisonai-triage-agent[bot]andMervin Praison a914ea791a fix: honor explicit script_range/script_drop in export QC; correct README atomic-write note
Address final-review findings on #4316:
- _export_qc_cfg no longer force-zeroes script thresholds when a caller sets
  script_range/script_drop/script_flag, so a documented
  qc_cfg={"script_range": [...]} re-enables the target-script check for other
  languages instead of silently passing wrong-script rows. English default
  (thresholds=0) is unchanged when no script key is given.
- Add test for the script_range-only re-enable path (kept for Latin, dropped
  for Tamil).
- README: validate's atomic --out write is shared with generate/dedup, and
  document that any script key re-enables the export QC check.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 11:43:31 +00:00
praisonai-triage-agent[bot]andMervin Praison de2d330962 fix: chain typer.Exit from SystemExit in train_llm (B904)
Explicitly chain the translated typer.Exit from the caught SystemExit to
resolve Ruff B904 and preserve the original traceback.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 11:43:08 +00:00
praisonai-triage-agent[bot]andMervinPraison 928435c125 fix: route praisonai-train llm to the trainer instead of a chat prompt (fixes #4314)
The legacy dispatcher's `if args.command:` chain had no `train` case, so
`praisonai-train llm <dataset>` fell through to the bare-prompt handler and
forwarded the literal word "train" to an LLM as a chat prompt — the real
training block was never reached and no trainer subprocess launched.

- Add a `train` guard in main()'s command chain so it reaches the dedicated
  training block (which reads args.dataset/args.model and dispatches
  praisonai_train.train.llm.trainer).
- Propagate a non-zero SystemExit from the router in train_llm so missing-dep
  failures no longer exit 0 (CI gating on $? now sees them); clean exits still
  return 0.
- Add a regression test that drives the real router (asserts the trainer is
  dispatched and handle_direct_prompt is never called) plus exit-code tests.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:38:05 +00:00
praisonai-triage-agent[bot]andMervinPraison 362d5663ba fix: praisonai-train QC drops English trajectories; validate lacks zero-row guard (fixes #4315)
- from-trials --qc: default export QC config disables the Tamil script-purity
  check, since agent trajectories are English by construction (kept dedup,
  boilerplate/refusal, length, diversity). qc_cfg still re-enables it.
- Surface the actual QC drop reasons: ExportSummary gains skipped_qc/qc_drops so
  no row vanishes unaccounted, and from-trials names the reasons on the zero-row
  error instead of guessing.
- validate: reuse the in-module tempfile+os.replace helpers (mirrors dedup/
  generate) with a zero-kept guard, so an --out that aliases the input is never
  truncated and a zero-kept run exits non-zero leaving any existing file intact.
- Document the Tamil script_range default in README and validate --help.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:36:57 +00:00
praisonai-triage-agent[bot]andMervin Praison 0e838ac03b fix: honour per-run max_steps and warn on playwright/hybrid dropped options
- server.py: extension-engine termination now uses the per-session agent's
  max_steps (forwarded via start_session) instead of the server default, so
  `browser run --max-steps` actually bounds the run (Greptile P1).
- browser.py: playwright/hybrid engines now warn when CDP-only options
  (--vision/--screenshots/--record-video/--max-retries/--no-record) are set
  rather than silently discarding them; corrected the run --help "Features"
  block to state these require --engine cdp specifically (Greptile P1).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 11:31:31 +00:00
praisonai-triage-agent[bot] a1cb2c4f2c chore: ignore knowledge.db test artifacts in praisonai-mcp 2026-08-24 11:28:44 +00:00
praisonai-triage-agent[bot] 7c9672ef24 chore: remove accidental knowledge.db test artifact 2026-08-24 11:28:27 +00:00
praisonai-triage-agent[bot]andMervin Praison da37b6fc0f fix: forward MCP adapter inputs (limit, task_id, realtime data) to core
- knowledge.query: forward `limit` to Knowledge.search (was dropped)
- a2a.send: forward `task_id` via kwargs (was discarded)
- realtime.send: map message to `data` with real event_type (was passed as event_type)
- add regression tests for the three semantic bindings

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 11:28:11 +00:00
praisonai-triage-agent[bot]andMervinPraison 5c43017956 fix: forward browser run options on extension/cdp engines (fixes #4309)
- Send max_steps in start_session wire frames on the extension path
  (server.py already reads it, so this was a pure missing-field fix)
- Pass record_video=record_video to run_cdp_only in _run_alternative_engine
  so --record-video on cdp actually records instead of only billing gpt-4o
- Warn when engine-only options (--vision/--screenshots/--record-video/
  --max-retries/--headless/--extension/--no-record) are set on the default
  extension engine, instead of silently discarding them
- Clarify --url is context-only on the extension path and show max steps
- Fix run --help "Features" caveat and add --engine to screenshots example

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:24:27 +00:00
praisonai-triage-agent[bot]andMervinPraison bfb1b28cd4 fix: CDP click/type/clear_input report selector-not-found as failure (fixes #4310)
A CDP click/type/clear_input on a selector matching nothing returned success
unconditionally, so run()'s retry loop broke on first success and --max-retries
plus all fallback-selector strategies never fired. The injected JS now returns
whether the element was found and a miss yields success=False with an error,
making the existing alternative-selector retry strategies reachable.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:23:18 +00:00
praisonai-triage-agent[bot]andMervin Praison 5695f056a0 fix: harden deploy unknown-key rejection against non-mapping/non-string keys
Coerce unknown deploy keys to str before sorted()/join() so a non-string
YAML key raises a clean ValueError instead of TypeError, and guard that
the deploy section is a mapping. Adds regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 11:22:22 +00:00
praisonai-triage-agent[bot]andMervin Praison 48e3a6b412 fix: fail fast on missing Azure subscription in status/destroy (#4305)
status() and destroy() previously fell back to the ambient az
subscription when subscription_id was unset, risking wrong-subscription
inspection/deletion. Both now return their structured failure result
before issuing any az command, mirroring deploy()'s guard.

Also fixes test_azure_provider_deploy_failure to supply a
subscription_id so it reaches subprocess.run, and adds no-subscription
tests for status() and destroy().

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 11:21:45 +00:00
praisonai-triage-agent[bot]andMervinPraison c827f386f9 fix: rebind broken MCP adapters to real core APIs (fixes #4304)
27 of 85 advertised praisonai-mcp tools could not succeed for any input:
their adapters called core/wrapper APIs that don't exist or have different
signatures, failing at attribute lookup or argument binding before any I/O.
24 returned isError:false and 9 reported an installed module as "not available".

Rebindings against real core:
- knowledge.*: query->search, list->get_all, stats->get_corpus_stats,
  clear->reset, add(source) (dropped nonexistent source_type)
- hooks.*: get_default_registry().list_hooks() (was get_hook_manager)
- session.*: get_default_session_store() (was SessionManager)
- tools.*: list_tools/get_tool (was get_available_tools/get_tool_info/search_tools)
- eval.*: AccuracyEvaluator/PerformanceEvaluator (was AccuracyEval/PerformanceEval)
- workflow.run/run_file: AgentsGenerator(..., config_list=[])
- workflow.auto: AutoGenerator(topic, pattern).generate()
- audio.transcribe(audio=), audio.speech(text=), guardrails.check(content=),
  ocr(document=), a2a.send(target_agent=), containers.file_read/write(path=),
  skills.load(skill_name=), realtime.send(event_type=)
- resources: memory/sessions -> session store, knowledge/sources -> get_all

_handle_tools_call now sets isError:true when a handler returns an
"Error: ..." string, matching the convention already used for adapters
that raise.

Tests bind against real core (not hand-written fakes), so a future core
rename the adapter doesn't track fails here.

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

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:21:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 8beb45d3ea fix: enforce allowed_paths and scan shell payloads in DockerSandbox
Bring DockerSandbox policy validation to parity with SubprocessSandbox
where it is semantically meaningful for a container:

- Enforce allowed_paths (opt-in) alongside blocked_paths, so a path
  outside configured roots is refused before dispatch.
- Route run_command through the shared build_argv helper so a shell=True
  payload is split by policy_scan_parts and its paths are scanned,
  instead of walking an opaque `sh -c "..."` argv.

Deliberately does NOT copy SubprocessSandbox's allow_subprocess shell
block: Docker always dispatches via `sh -c` inside the container, which
is the isolation boundary that clause approximates on the host. Applying
it would refuse every command and defeat the backend rather than harden
it.

Adds regression tests for allowed_paths (in/out) and shell-payload path
scanning. All host-side + Docker filesystem tests pass.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 11:19:01 +00:00
praisonai-triage-agent[bot]andMervinPraison cd112e2ee1 fix: reject misspelled keys in deploy config validation (fixes #4306)
A misspelled field/section key in agents.yaml was silently discarded by
pydantic's default extra='ignore', so `deploy validate` reported valid while
deploying with defaults (e.g. a typo in cluster_name deploying to a new ECS
cluster). Set extra='forbid' on the deploy input models, reject unknown
top-level deploy sections in schema validation, surface cluster_name in the
human-readable validate output, and make the docker `push: true` path reach the
existing "no registry" error instead of silently skipping the push.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:15:45 +00:00
praisonai-triage-agent[bot]andMervinPraison bea67527b9 fix: thread Azure --subscription onto az commands, fail fast, doctor mismatch (fixes #4305)
Azure Container Apps deployments read subscription_id only to echo it in
plan(); it was never passed to az, so every resource was created/destroyed in
the ambient default subscription. Mirror the GCP provider:

- append `--subscription {subscription_id}` to all six az invocations across
  deploy/status/destroy/doctor via a _subscription_args() helper
- fail fast in deploy() when subscription_id is unset (matches GCP's project_id
  guard)
- check_azure_cli() now compares the ambient subscription against the configured
  one and warns on mismatch

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:14:47 +00:00
praisonai-triage-agent[bot]andMervinPraison 497ebfc994 fix: enforce SecurityPolicy in DockerSandbox (fixes #4302)
DockerSandbox accepted a SecurityPolicy and enforced none of it, so moving
to the tier documented as stronger silently dropped every command-level
control. Mirror the tested SubprocessSandbox enforcement:

- Validate commands against blocked_commands / allowed_commands / blocked_paths
  in run_command before dispatch.
- Truncate host-side communicate() output to max_output_size in run_command
  and execute, so a runaway print in the container cannot exhaust the host
  process.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 11:08:11 +00:00
praisonai-triage-agent[bot] d56545c060 Merge pull request #4299 from MervinPraison/claude/issue-4298-20260824-1002
fix: honour restart_safe=False on async retry path (fixes #4298)
2026-08-24 11:00:10 +00:00
praisonai-triage-agent[bot] f699b00526 Merge pull request #4297 from MervinPraison/claude/issue-4295-20260824-0956
fix: memory get_all_memories/delete_memory bypass active adapter
2026-08-24 10:53:58 +00:00
praisonai-triage-agent[bot] 23b91ececd Merge pull request #4296 from MervinPraison/claude/issue-4294-20260824-0950
fix: wire memory-pressure cache eviction into the gateway
2026-08-24 10:52:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 27ce602d82 fix: thread cancel_token through all same-run sync redispatches so /stop halts fully
Extends the OpenAI-native /stop fix to every synchronous same-run
re-dispatch that previously dropped the token, closing the Greptile P1
"nested completions drop cancellation":

- reflection + regeneration _chat_completion calls (chat_mixin.py)
- internal recursive redispatches: context-compression retry,
  model fallback, retryable-error backoff (chat_mixin.py)
- guardrail-retry regeneration via _apply_guardrail_with_retry
  (agent.py + its sync call sites in chat_mixin.py)

All hops just forward the existing cancel_token param (defaults None),
so behaviour is unchanged when no token is set. No new Agent params,
modules, or exports.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 10:30:45 +00:00
MervinPraison 773a27bb00 docs: auto-update documentation parity trackers [skip ci] 2026-08-24 10:29:23 +00:00
praisonai-triage-agent[bot] e1acc793fd Merge pull request #4293 from MervinPraison/claude/issue-4290-20260824-0918
fix: scope-gate ack reactions so bots stay quiet on ambient group chatter
2026-08-24 10:28:46 +00:00
praisonai-triage-agent[bot]andMervinPraison 10f1204e73 fix: thread cancel_token into OpenAI-native tool loop so /stop halts mid-run (fixes #4300)
The default (non-custom) LLM path dropped the cancel token: chat_mixin
threaded llm_kwargs['cancel_token'] only inside the custom-LLM branch,
while the OpenAI-native else branch called _chat_completion(...) which
had no cancel_token parameter. The token never reached OpenAIClient,
so the between-iteration cancel checks were never armed and /stop
reported success while remaining tool calls kept executing.

Thread cancel_token symmetrically through the four sync hops that
already existed: _chat_completion -> _chat_completion_with_retry ->
_execute_unified_chat_completion -> _unified_dispatcher.chat_completion,
matching the custom-LLM branch. Defaults to None so no caller behaviour
changes.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 10:23:59 +00:00
praisonai-triage-agent[bot] ca205ccbac Merge pull request #4291 from MervinPraison/claude/issue-4289-20260824-0917
fix: detect silently half-open Signal transport via inbound liveness
2026-08-24 10:20:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 4e13524749 fix: honour restart_safe=False for override-only tools on async retry
The async idempotency veto only scanned self.tools, so a task-scoped tool
supplied via tools_override (and never registered on the agent) missed its
restart_safe=False / idempotent=False declaration and was retried, duplicating
its side effect. _tool_declares_not_idempotent now accepts the effective
tools_override set (searched first, mirroring _execute_tool_async_impl
resolution) while defaulting to None for the unchanged sync call sites.

Also tightens the test helper to catch only the expected RuntimeError /
ToolExecutionError instead of a blanket Exception, and adds an override-only
regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 10:14:10 +00:00
praisonai-triage-agent[bot]andMervin Praison c847eae31d fix: preserve public type field in adapter-backed get_all_memories
Adapter-backed get_all_memories() returned records keyed by `memory_type`
(SQLite) or `type` (in-memory), diverging from the legacy path's `type`
field and breaking callers that read record["type"]. Normalize adapter
results to always populate `type` while retaining adapter-specific keys.

Strengthen regression tests: assert both colliding tiers are erased on
untyped deletion, and assert the public `type` field is present on
adapter-backed records.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 10:06:20 +00:00
praisonai-triage-agent[bot]andMervinPraison 15894f2cba fix: honour restart_safe=False on async retry path (fixes #4298)
_execute_tool_async_with_retry never consulted the idempotency
declaration, so achat()/astart() re-ran an effectful tool marked
restart_safe=False up to max_attempts times. Add the same veto the
sync loops use (tool_execution.py:884 and :2115) plus an async test.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 10:04:24 +00:00
praisonai-triage-agent[bot]andMervin Praison ccce50368d fix: track per-session flush + nested cgroup limit for pressure eviction
Address reviewer findings on PR #4296:

- Data-loss guard (CodeRabbit): derive WarmSession.flushed from a per-key
  _flushed marker set only after a completed store write, not from
  _store presence. A caught/partial persist now leaves the cache unflushed
  so the pressure sweep never evicts the only current copy. Marker cleared
  in _save_history (pre-write), _soft_evict, and reap_stale.
- Nested cgroup (CodeRabbit): resolve the process's own cgroup path from
  /proc/self/cgroup and probe its v2/v1 memory limit first, falling back to
  the filesystem root so a nested container's lower ceiling is honoured.
- Tests: add failed-persist skip and nested-cgroup coverage (15 total pass;
  core planner 16 and gateway session suites still green).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 10:03:18 +00:00
praisonai-triage-agent[bot]andMervinPraison 57716519cb fix: memory get_all_memories/delete_memory bypass active adapter (fixes #4295)
get_all_memories() selected from legacy short_mem/long_mem tables that no
adapter creates, so it swallowed OperationalError and returned [] for every
provider. Delegate to the active adapter, mirroring delete_short_term/
reset_short_term.

delete_memory(id) with no memory_type short-circuited on the first tier that
reported a hit. Since the SQLite adapter gives each tier its own AUTOINCREMENT
sequence, ids collide across tiers, so it erased an unrelated short-term row
and left the caller's target in place while reporting success. Delete from
both tiers instead of short-circuiting.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 09:58:16 +00:00
praisonai-triage-agent[bot]andMervinPraison 31beac49a8 fix: wire memory-pressure cache eviction into the gateway (fixes #4294)
The core planner plan_pressure_evictions() plus the WarmSession /
MemoryPressureProtocol contracts had no runtime consumer, so an
always-on multi-session gateway had no way to reclaim warm-cache memory
under pressure and climbed to OOM.

This adds the missing wrapper-side enactor and a concrete reader:

- BotSessionManager.warm_sessions(): snapshots warm caches as core
  WarmSession facts (LRU last_activity, in_flight, flushed).
- BotSessionManager.sweep_under_pressure(): reads the real cgroup budget/
  RSS, calls the core planner, and soft-evicts the coldest rebuildable
  caches LRU-first, re-sampling RSS to shed the minimum. No-op without a
  cgroup limit, preserving today's behaviour.
- BotSessionManager._soft_evict(): drops only the in-memory history; the
  durable store copy remains and is rebuilt on the next turn (lossless).
- Per-turn in-flight tracking so a live turn's cache is never evicted.
- gateway/memory_pressure.py CgroupMemoryPressure: stdlib-only concrete
  MemoryPressureProtocol (cgroup v2 memory.high/max, v1 fallback,
  /proc/self/status RssAnon), degrading to no-op when unreadable.

Flushed (store-backed) caches only are evicted; in-flight and unflushed
caches are always skipped. Backward-compatible: no eviction unless the
gateway calls sweep_under_pressure on its periodic tick.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 09:55:35 +00:00
praisonai-triage-agent[bot] 75e89a1d28 Release v4.7.1 2026-08-24 09:36:19 +00:00
praisonai-triage-agent[bot]andMervin Praison cb896b7a64 fix: scope-gate ack in gateway Telegram path & Slack thread replies
Closes two P1 gaps in the ack scope gate (Greptile #4293):

- Gateway-hosted Telegram handler acked on `_ack.enabled` alone, bypassing
  ack_scope so ambient group chatter still got a ⏳ reaction. Now routes
  through should_ack_message like the standalone adapter.
- Slack classified a thread reply (no fresh @mention, mention_required off)
  as ambient, silently dropping its ack. thread_ts now counts as engaging
  the bot so the reply is acked under group-mentions.

Adds 3 TestAckScopeGating cases for the gateway wiring and Slack thread
classification.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 09:31:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 00fd87e9f5 fix: forward reviewer custody through gateway backend and prefer explicit operator id
Addresses Greptile P1 findings on PR #4292:
- GatewayApprovalBackend now threads authorized_reviewers into register(),
  so per-request reviewer custody is actually bound on the production path
  (previously silently dropped, leaving every request unbound).
- _resolve_operator_identity prefers a non-secret X-Operator-Id header before
  the shared-token digest, so a proxy/SSO layer can distinguish individual
  operators sharing a gateway token in the audit trail.

Adds a regression test asserting the backend binds custody end-to-end.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 09:29:35 +00:00
praisonai-triage-agent[bot] 9579b23987 Merge pull request #4287 from MervinPraison/claude/issue-4286-20260824-0827
fix: repair async MongoDB state store, async DB hook names, and safe_format drift
2026-08-24 09:26:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 5ba76ed331 fix: scope-gate ack reactions so bots stay quiet on ambient group chatter (fixes #4290)
Add a closed AckScope (off | direct | group-mentions | group-all | all)
to AckReactor with a should_ack()/should_ack_message() gate, and a
BotConfig.ack_scope key (default group-mentions). Adapter call sites
(telegram/discord/slack/whatsapp/signal/linear) now derive direct/mention/
ambient context from the existing BotMessage and only ack messages the
scope covers, instead of reacting to every non-command message.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 09:25:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 0c45b609ca fix: record resolver identity and per-request reviewer custody on gateway approvals (fixes #4288)
The gateway resolve boundary now captures the resolving principal on the
durable audit trail (ApprovalDecision.approver) and allow-always grant,
replacing the constant "gateway"/"gateway:human" attribution. Resolution
gains an optional resolver id, threaded from the authenticated HTTP request
context (derived as a non-secret hash handle of the operator token).

An approval may optionally be bound to a set of authorized reviewers;
resolve() rejects a resolver not in that set (re-inserting the request so an
authorised reviewer can still resolve it) while preserving first-answer-wins
via the atomic pop. The binding is durable across restarts. Core
ApprovalRequest gains an optional authorized_reviewers field (the only core
touch). All defaults keep today's behaviour.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 09:22:57 +00:00
praisonai-triage-agent[bot]andMervin Praison 7a21a8277b fix: detect silently half-open Signal transport via inbound liveness (fixes #4289)
The Signal poll loop refreshed inbound-liveness on every successful poll
batch, including successfully-empty ones. A silently half-open transport
returns [] forever without raising, so this kept health.last_activity
fresh and defeated the existing evaluate_channel_health STALE_SOCKET seam,
leaving a "deaf" channel reported HEALTHY with no operator signal.

Only note inbound activity when the bridge actually delivered an envelope,
so a stalled poll loop becomes detectable through the existing health seam
(no new module, API surface, or config knob).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 09:21:00 +00:00
MervinPraison 567e50e751 docs: auto-update documentation parity trackers [skip ci] 2026-08-24 08:57:36 +00:00
praisonai-triage-agent[bot] eaabcaf393 Merge pull request #4285 from MervinPraison/claude/issue-4284-20260824-0746
fix: persist guardrails/approval config so clone_for_channel forwards them
2026-08-24 08:57:03 +00:00
praisonai-triage-agent[bot]andMervin Praison 50a3eeb788 fix: accurate async MongoDB hdel count and dotted-field hash round-trip
Address Greptile P1s on AsyncMongoDBStateStore:
- async_hdel now returns only the count of fields actually present and
  removed (StateStore contract "Returns count deleted"), matching the
  Firestore/DynamoDB document-store backends instead of over-reporting.
- async_hset/async_hdel treat fields as opaque top-level keys via a
  $mergeObjects pipeline / read-compute-write, so a dotted field like
  "a.b" round-trips through hget/hgetall/hdel instead of being coerced
  into a nested MongoDB path.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 08:37:21 +00:00
praisonai-triage-agent[bot]andMervinPraison 11bdca8ad8 fix: repair async MongoDB state store, async DB hook names, and safe_format drift (fixes #4286)
- AsyncMongoDBStateStore: implement the 7 missing StateStore abstract
  methods (keys, ttl, expire, hget, hset, hgetall, hdel) plus async twins
  so the class is instantiable and every async_mongodb/motor/mongodb_async
  factory alias boots instead of raising TypeError.
- db/adapter.py: rename the 6 async state-store call sites from the
  non-existent aset/aget to async_set/async_get so async persistence hooks
  take the native coroutine path instead of silently falling back to sync.
- agents_generator.safe_format: delegate to core
  BaseFrameworkAdapter._format_template (single source of truth) so wrapper
  and core cannot drift; fixes latent TypeError on non-string (None) input.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 08:30:50 +00:00
MervinPraison b472fb11ff docs: auto-update feature parity trackers [skip ci] 2026-08-24 08:15:21 +00:00
praisonai-triage-agent[bot] f619483bbc Merge pull request #4283 from MervinPraison/claude/issue-4282-20260824-0716
fix: undeclared tool must retry regardless of its name (fixes #4282)
2026-08-24 08:14:54 +00:00
praisonai-triage-agent[bot]andMervin Praison 2471285afd test: verify clone forwards approval policy + deny set (fixes #4284 review gap)
Closes the Greptile/CodeRabbit approval-clone coverage gap: the prior test
only inspected the original agent's private _approval_config and never
constructed a clone. Add a clone round-trip test asserting the served clone
reconstructs the same approval config and enforced _perm_deny set, plus a
negative test that a plain agent and its clone stay policy-free.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 07:57:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 5a26705ae3 fix: persist guardrails/approval config so clone_for_channel forwards them (fixes #4284)
clone_for_channel() rebuilds the agent from getattr(self, '_guardrails_config',
None) and getattr(self, '_approval_config', None), but neither attribute was
ever assigned to the instance -- both were locals inside __init__. The getattr
therefore always returned None and every served clone (one per gateway channel,
one per HTTP invocation) ran with no output guardrail and lost its approval
policy. Persist both resolved values on the instance so the clone can read them.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 07:50:13 +00:00
praisonai-triage-agent[bot]andMervinPraison 7e7bcfc790 fix: undeclared tool must retry regardless of its name (fixes #4282)
Regression from #4257: `_tool_declares_not_idempotent` fell back to the
shared `MUTATING_TOOLS` name registry, silently dropping the retry from
any *undeclared* tool that happened to share a name with an entry
(`write_file`, `store_memory`, `mkdir`...). Key on the explicit
declaration only, and consult the process-global registry the way
`_is_tool_idempotent` already does when `allow_global_tools` is set.

Co-authored-by: MervinPraison <[email protected]>
2026-08-24 07:19:06 +00:00
praisonai-triage-agent[bot] 3a85e1e4a8 Merge pull request #4279 from MervinPraison/claude/issue-4278-20260824-0102
feat: add HOL Guard wrap_tool_call security example
2026-08-24 02:10:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 37a80c3133 fix: fail-closed on non-object JSON and surface HOL Guard denial in result
- Return False for non-dict verdicts (e.g. [], "allow", null) to avoid
  AttributeError and preserve fail-closed behavior (CodeRabbit)
- Set ToolResponse.result to the denial message since the agent tool
  executor returns result and discards error, so denials reach the model
  instead of a null result (Greptile)
- Add non-object JSON regression test; update block test assertion

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 01:17:08 +00:00
praisonai-triage-agent[bot]andMervin Praison f915f06c13 fix: restore single-quoted list parsing and bound adversarial scan
- Re-add Python-style single-quoted list support via ast.literal_eval
  (safe literal-only eval), fixing a regression where ['a','b'] collapsed
  into a single loop item.
- Anchor JSONDecoder.raw_decode with the idx argument instead of slicing
  text[start:] to avoid per-bracket copies.
- Cap decode attempts (and keep the 1M-char guard) so deeply nested
  bracket input can no longer cause O(n^2) parsing time or a crash
  (RecursionError now caught alongside JSONDecodeError).
- Add regression tests for single-quoted lists and adversarial input.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-24 01:05:26 +00:00
praisonai-triage-agent[bot]andkantorcodes 96f6d0d93b feat: add HOL Guard wrap_tool_call security example (fixes #4278)
Adds an optional, provider-neutral example that gates command-bearing
tools through HOL Guard at the existing wrap_tool_call boundary. The
policy is fail-closed: the wrapped tool only runs on an explicit allow
verdict; block/unknown/malformed/timeout/CLI-failure short-circuit with
zero downstream execution. Includes a regression test suite proving a
denied command never reaches the tool executor.

Co-authored-by: kantorcodes <[email protected]>
2026-08-24 01:05:03 +00:00
KevinTran3011 59dc064842 fix: avoid backtracking regex in workflow list parsing 2026-08-24 10:46:40 +10:00
KevinTran3011 1f0bdb74c8 fix: keep CLI prompts out of trusted agent instructions 2026-08-24 10:46:03 +10:00
user ce42580513 2026-08-23 23-44 - AEO: checksum install + terminal roster (Build Remote Agent) 2026-08-23 23:44:44 +02:00
user 8a75a92f87 2026-08-23 22-05 - Build Remote Agent pairing device 2026-08-23 22:05:58 +02:00
praisonai-triage-agent[bot]andMervinPraison 0da22a1991 fix: remove dead ServerRegistry class in agent.py (fixes #4273)
Delete the unused ServerRegistry class and _get_default_server_registry()
helper. These were never wired into the live launch path; server state
lives in module-level dicts in execution_mixin.py, torn down by
cleanup_launch_registration(). Also update the now-stale comment that
referenced the removed class.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 17:13:43 +00:00
praisonai-triage-agent[bot]andMervinPraison 13c20df5f9 fix: exclude persistence test package from installed wheel (fixes #4271)
Co-authored-by: MervinPraison <[email protected]>
2026-08-23 16:16:18 +00:00
MervinPraison 33b359f0da docs: auto-update documentation parity trackers [skip ci] 2026-08-23 10:46:23 +00:00
praisonai-triage-agent[bot] 67c511a44e Merge pull request #4270 from MervinPraison/claude/issue-4267-20260823-0945
fix: portable session export/import/migration for gateway stores
2026-08-23 10:45:53 +00:00
praisonai-triage-agent[bot] 3756369680 Merge pull request #4269 from MervinPraison/claude/issue-4268-20260823-0945
fix: add latency histograms to gateway metrics
2026-08-23 10:39:55 +00:00
MervinPraison 72f6b6f4eb docs: auto-update documentation parity trackers [skip ci] 2026-08-23 10:14:15 +00:00
praisonai-triage-agent[bot] 722203fc02 Merge pull request #4266 from MervinPraison/claude/issue-4265-20260823-0914
fix: expose gateway saturation/back-pressure telemetry (#4265)
2026-08-23 10:13:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 5c647c0aef fix: harden session import — version guard, verbatim restore, no payload mutation, atomic export
- reject import payloads newer than PORTABLE_VERSION (reported, not applied)
- persist imported sessions verbatim via _save_imported_session (bypass
  destination retention/active_window so a valid larger export is not truncated)
- copy metadata before clearing live fields so the caller payload is not mutated
- write CLI exports to a temp file + atomic os.replace (never corrupt an
  existing backup on failure; nonzero exit on error)
- add tests for the above

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 10:00:21 +00:00
praisonai-triage-agent[bot]andMervin Praison f2596edeb2 fix: validate histogram buckets and reserve le label in GatewayMetrics
- Reject non-finite/non-increasing bucket bounds before storing (prevents
  render_prometheus() failures)
- Raise ValueError on conflicting bucket layouts for an existing metric name
  instead of silently ignoring later overrides
- Reject caller-supplied reserved 'le' label to avoid duplicate le samples
- Fix snapshot() to emit name_count{labels}/name_sum{labels} matching the
  Prometheus renderer
- Rename _HistData sum param to avoid shadowing builtin (ruff A002)
- Add tests for bucket conflicts, le rejection, validation, and label ordering

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 09:57:53 +00:00
praisonai-triage-agent[bot] d82364ec23 Merge pull request #4264 from MervinPraison/claude/issue-4263-20260823-0853
fix: stream LLM narrative into primary Textual TUI
2026-08-23 09:54:12 +00:00
praisonai-triage-agent[bot]andMervinPraison e475b3e22d fix: add portable session export/import/migration for gateway stores (fixes #4267)
Extend the session-store contract with a store-agnostic portability surface so
a gateway operator can back up, move, or restore live conversation state:

- core: add PortableSessionStoreProtocol + ImportReport to session.protocols
- core: implement export_session / export_all / import_sessions on
  DefaultSessionStore (SqliteSessionStore inherits it), reusing the existing
  SessionData.to_dict/from_dict shape. Export is lineage-aware; import is
  hardened (size cap, duplicate guard, malformed-record skip) and resets live
  routing/activity fields so restored state is inert until re-bound.
- core: export the new symbols from session/__init__.py
- wrapper: add `praisonai gateway sessions export/import` CLI + preflight
  helpers over the gateway's own default store.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 09:51:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 6cbc363948 fix: add latency histograms to gateway metrics (fixes #4268)
Add a dependency-free histogram metric type to GatewayMetrics so the
gateway /metrics endpoint exposes latency distributions (buckets, _sum,
_count) for SLOs and slow-turn diagnosis, alongside the existing
counters and gauges.

- observe(name, seconds, buckets=..., labels=...) records durations
- timer(name, ...) context manager times its body and records it
- render_prometheus() emits _bucket/+Inf/_sum/_count histogram lines
- snapshot() includes a "histograms" section
- default buckets cover sub-second to multi-second turns

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 09:49:02 +00:00
praisonai-triage-agent[bot]andMervin Praison 848b9c61d3 fix: keep watchdog lag anchored to oldest probe; non-blocking outbox health reads (#4265)
Address two P1 review findings on the gateway saturation telemetry:

- LoopWatchdog: anchor the lag clock to the oldest still-unacked probe so a
  stall spanning several probe intervals is no longer understated (each fresh
  probe was overwriting the send-time). The ack consumes the anchor so the
  next probe re-anchors from a caught-up loop.
- OutboundQueue.try_depth_snapshot(): non-blocking (pending, dead_letter)
  counts that yield None when the writer lock is contended, and wire
  _compute_pressure() to reuse last-known depths instead of blocking. Health
  and /metrics run on the event loop and must never wait on the outbox lock.

Adds regression tests for the anchoring invariant and the non-blocking snapshot.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-08-23 09:34:15 +00:00
praisonai-triage-agent[bot] e40b96eb5c Merge pull request #4261 from MervinPraison/claude/issue-4259-20260823-0820
fix: harden wrapper shim, api/call, and async bridge (fixes #4259)
2026-08-23 09:25:01 +00:00
praisonai-triage-agent[bot]andMervinPraison a19da24991 fix: expose gateway saturation/back-pressure telemetry (fixes #4265)
The always-on gateway enforces back-pressure (admission ceiling + fair
wait-queue, durable outbox/DLQ, event-loop wedge watchdog) but exposed no
saturation telemetry — health() reported liveness only, so the first signal
of overload was dropped work.

Core (praisonaiagents) owns the shape + a pure classifier, mirroring the
ReloadStatus precedent:
- HealthPressure frozen dataclass + deterministic evaluate_pressure() helper
- LoopWatchdog now reads back its continuously-measured scheduling lag
  (last_lag_ms), the same signal the wedge check already uses

Wrapper (praisonai_bot) reads facts already in hand and fills the block:
- health() gains a "pressure" block; /metrics gains admission/backlog/lag gauges
- OutboundQueue.dead_letter_count() surfaces the permanent-failure tier

Additive observability only — no new config keys, no new endpoint.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 09:22:00 +00:00
MervinPraison c054e54ddb docs: auto-update documentation parity trackers [skip ci] 2026-08-23 09:05:47 +00:00
praisonai-triage-agent[bot]andMervin Praison bc768e01ed fix: preserve display callback ownership in Textual TUI turn
Save the prior tool_call/llm_content callbacks and restore them on cleanup
under the core callbacks lock with an identity check, instead of blindly
overwriting and deleting the process-global slots. This mirrors
praisonaiagents' own register/restore pattern so another in-process
consumer that owns these slots keeps receiving events after a TUI turn.

Addresses Greptile P1 (global callback ownership lost).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 09:05:41 +00:00
praisonai-triage-agent[bot] 4b9d0a319f Merge pull request #4260 from MervinPraison/claude/issue-4258-20260823-0818
fix: strip whitespace from decision routing keys in process (fixes #4258)
2026-08-23 09:05:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 564f55c434 fix: stream LLM narrative into primary Textual TUI (fixes #4263)
The default interactive TUI (async_tui.py) only registered the tool_call
display callback, so intermediate assistant text was rendered as a single
whole-response append after the worker thread joined. Register the core
llm_content callback (already emitted per LLM step) to push narrative into a
live assistant message as the turn progresses, and reuse that streamed
message as the finalised turn to avoid a duplicate append. Read-only review
turns keep their single-summary behaviour. No new core surface; wrapper-only.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 08:56:53 +00:00
praisonai-triage-agent[bot] 8c974f29e6 Merge pull request #4257 from MervinPraison/claude/issue-4256-20260823-0804
fix: honour explicit idempotent=False in both tool retry loops
2026-08-23 08:53:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 8a312b0466 fix: restore FastAPI Request/WebSocket injection; harden shim None guard and strict-loop test
- api/call.py: the lazy-import refactor dropped the ``request: Request`` /
  ``websocket: WebSocket`` annotations, so FastAPI treated ``request`` as a
  required query param and 422'd the ``/`` route. Stamp the real classes onto
  the handlers in _register_routes (build time, where fastapi is already
  imported) — import stays cheap, route injection restored.
- cli/_shim.py: skip ``None`` placeholders in the eager walk_packages loop so a
  known-absent submodule can't poison the old dotted path before _AliasFinder
  resolves it lazily (mirrors the existing guard in the first pass).
- test_wrapper_layer_regression.py: force PRAISONAI_ALLOW_LOOP_BLOCKING empty
  around the strict assertion so an ambient opt-in can't mask the RuntimeError.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 08:49:04 +00:00
MervinPraison 73b55af165 docs: auto-update documentation parity trackers [skip ci] 2026-08-23 08:47:27 +00:00
praisonai-triage-agent[bot] 65037b1442 Merge pull request #4255 from MervinPraison/claude/issue-4254-20260823-0752
fix: empty LLM response no longer returns SDK object repr
2026-08-23 08:46:54 +00:00
praisonai-triage-agent[bot]andMervinPraison 7e98368745 fix: restore shim module identity (default eager) and make call tools idempotent
The lazy-by-default shim in cli/_shim.py broke module identity across the
old/new dotted paths (guarantee #1 in its own docstring): importing the
old name first (e.g. `from praisonai.mcp_server.cli import ...`, as the
tests do) re-entered _AliasFinder and double-loaded the moved file,
yielding two distinct module objects. That made mock.patch("<old.path>")
target a different object than the running code (imported via the new
name), causing the test-core (cli/subdirs) and smoke CI failures.

Restore eager pre-registration as the DEFAULT (it was already lightweight:
optional heavy submodules that fail import are logged and left for lazy
resolution). Keep an explicit opt-out via PRAISONAI_SHIM_LAZY=1 for
callers that don't rely on cross-path module identity.

Also make api/call._load_local_tools() idempotent (clear before repopulate)
so repeated build_call_app(load_local_tools=True) builds no longer
accumulate duplicate tool definitions in the shared registry (greptile).

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 08:43:04 +00:00
praisonai-triage-agent[bot] 1e3ceca30d Merge pull request #4252 from MervinPraison/claude/issue-4250-20260823-0744
fix: don't relabel tool failures as LLM errors in _chat_completion
2026-08-23 08:32:41 +00:00
MervinPraison 8f0237fd6d docs: auto-update documentation parity trackers [skip ci] 2026-08-23 08:31:46 +00:00
praisonai-triage-agent[bot] 80bc2ad458 Merge pull request #4253 from MervinPraison/claude/issue-4251-20260823-0745
fix: bound sync self-reflection parse-error retry
2026-08-23 08:31:11 +00:00
praisonai-triage-agent[bot]andMervinPraison a942df600f fix: harden wrapper shim, api/call, and async bridge (fixes #4259)
Three lightweight, backward-compatible wrapper fixes:

1. cli/_shim.py: gate the eager walk_packages pre-import behind
   PRAISONAI_SHIM_EAGER (lazy by default via _AliasFinder) and log
   previously-swallowed submodule ImportErrors instead of silently
   skipping them.

2. api/call.py: make heavy deps (fastapi, twilio, uvicorn, pyngrok,
   websockets, rich) lazy-imported; build the FastAPI app via a
   build_call_app() factory plus a lazy module __getattr__ for `app`;
   move the import-time tools.py filesystem scan + user-code execution
   into a server-entry-point path so importing the module has no side
   effects.

3. _async_bridge.run_sync_or_offload: refuse to block a running event
   loop by default (opt-in via PRAISONAI_ALLOW_LOOP_BLOCKING), steering
   async callers to arun_sync_or_offload; PraisonAIAdapter.run uses
   run_sync on the plain-sync path and raises a clear error from a
   running loop.

Updated the corresponding wrapper regression test to the new strict
contract.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 08:28:32 +00:00
praisonai-triage-agent[bot]andMervin Praison a2cf33ca80 test: cover synchronous workflow() decision routing whitespace strip
The sync workflow() path (lines 1502/1504) is duplicated code, edited
independently from aworkflow(). The existing suite drove only aworkflow(),
so a whitespace regression in the sync branch would leave it green.
Adds _run_sync driver + parametrized sync retry/control tests. Verified
they fail without .strip() and pass with it.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 08:27:54 +00:00
praisonai-triage-agent[bot]andMervinPraison d9adc77a0d fix: strip whitespace from decision routing keys in process (fixes #4258)
Decision task verdicts were routed via result.raw.lower() and pydantic.decision.lower() at eight sites without strip(). Raw model text routinely carries a trailing newline, so the condition lookup missed and a miss was treated as exit-condition-met: the workflow finished silently with the rejected output (no retry, no validation feedback, no error). This adds strip() at all eight sites so a padded verdict routes exactly like a clean one, matching validate_decision_string which already lowers and strips.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 08:21:06 +00:00
MervinPraison 535d0b5aab docs: auto-update documentation parity trackers [skip ci] 2026-08-23 08:16:16 +00:00
praisonai-triage-agent[bot] 96e981800b Merge pull request #4249 from MervinPraison/claude/issue-4247-20260823-0714
fix: correct ast_grep rewrite receipt + add path confinement, -- terminator, output cap
2026-08-23 08:15:44 +00:00
praisonai-triage-agent[bot]andMervin Praison 7c2e76869b fix: honour restart_safe=False in non-idempotent retry veto
The retry veto only read the raw `idempotent` attribute, missing the public
`restart_safe=False` contract used by @tool (FunctionTool) and BaseTool. Such
effectful tools remained retryable in both loops, duplicating side effects.
Fall back to `restart_safe` when `idempotent` is unset; add regression tests
for @tool and BaseTool declarations.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 08:14:34 +00:00
praisonai-triage-agent[bot]andMervinPraison 7de11a78cd fix: honour explicit idempotent=False in both tool retry loops (fixes #4256)
A tool declaring idempotent=False was still re-driven up to max_attempts
times by both retry loops, duplicating side effects (send a second email,
charge twice). Both loops now veto the retry when a tool *explicitly*
declares itself non-idempotent, via a new tri-state helper
_tool_declares_not_idempotent that keys on the explicit declaration only,
leaving undeclared/declared-safe tools' retry behaviour unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 08:07:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 72f1fc12d9 fix: warn on model refusal and strengthen async empty-response assertions
- _extract_llm_response_content now inspects message.refusal (carried
  independently of finish_reason) and warns without logging refusal text
- async chat_history test now asserts result == "" and an empty assistant
  message, closing gaps flagged in review

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 08:00:23 +00:00
praisonai-triage-agent[bot]andMervin Praison e668943114 fix: re-raise ToolExecutionError in streaming fallback path too
The streaming auto-detect block in _chat_completion caught every
non-LLMError exception and fell back to a non-streaming retry. Tool
failures during the streaming attempt were therefore run again by the
fallback, executing the tool twice and potentially masking the original
typed failure. Re-raise ToolExecutionError before the generic fallback,
mirroring the guard in the main completion block.

Also tighten the regression tests to assert the failing tool actually
executed (the tool executor retries a failing tool, so every attempt is
verified to target the same recipient).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 07:54:58 +00:00
praisonai-triage-agent[bot]andMervinPraison 103c859400 fix: empty LLM response no longer returns SDK object repr (fixes #4254)
_extract_llm_response_content ended in return str(response), reached whenever
a well-formed response carried no content and no tool_calls (content filter,
refusal, or finish_reason=length with zero output tokens). achat() used this,
so the user's answer became the raw ChatCompletion repr, persisted into
chat_history and replayed to the model.

Now a cleanly-parsed-but-empty message returns "" (matching the sync path),
logging a warning when finish_reason is unexpected. str(response) is reserved
for genuinely unparseable responses where the repr is a diagnostic.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 07:54:14 +00:00
praisonai-triage-agent[bot]andMervinPraison 4f4969cb80 test: fix unused variable lint in bounded reflection test
Prefix the unused _agent() tuple element with an underscore to satisfy
Ruff RUF059, as flagged in review.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 07:53:27 +00:00
praisonai-triage-agent[bot]andMervinPraison b7d77bd872 fix: bound sync self-reflection parse-error retry (fixes #4251)
The sync reflection handler incremented the retry counter and continued
unconditionally on a parse error, so a persistently unparseable reflection
(e.g. an OpenAI structured-output refusal where message.parsed is None)
looped forever at two LLM calls a turn and chat() never returned. Mirror the
async bound at chat_mixin.py so the loop stops at max_reflect and returns the
un-reflected answer.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 07:47:38 +00:00
praisonai-triage-agent[bot]andMervinPraison 18ac8951dd fix: don't relabel tool failures as LLM errors in _chat_completion (fixes #4250)
Add an explicit `except ToolExecutionError: raise` guard before the blanket
`except Exception` in `_chat_completion`, mirroring the existing
`BudgetExceededError` escape hatch. Previously a `ToolExecutionError` raised by
the sync tool executor was caught by position and re-labelled as `LLMError`,
causing chat() to return None, the tool's message to be fed to the LLM error
classifier (so a tool error resembling a rate limit re-drove the model with no
tool result), and confident answers generated with no tool result.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 07:46:43 +00:00
praisonai-triage-agent[bot]andMervin Praison b2858f3990 fix: harden sandbox rlimits, cancel-before-throttle parity (sync+async)
- Resolve RLIMIT_* via getattr so POSIX platforms missing a constant skip
  that cap instead of raising AttributeError before subprocess launch
- Add RLIMIT_CPU (CPU-seconds from timeout) so CPU-bound sandbox code is
  stopped by the kernel rather than only the wall-clock timeout
- Check cancel token before AND after rate-limiter acquire in both sync
  chat() and async achat() backend branches, so pre-cancelled requests do
  not block on the limiter or consume capacity

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 07:26:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 6195b8ee80 fix: spill ast_grep overflow into workspace so retrieval pointer is reachable
The overflow pointer advertises read_file/grep on the spilled artifact, but
those tools reject out-of-workspace paths. When PRAISONAI_TOOL_OUTPUT_DIR is
unset, spill into a workspace-local .praisonai_tool_output dir so the omitted
output stays retrievable (fixes Greptile P1 on #4247).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 07:25:57 +00:00
praisonai-triage-agent[bot]andMervinPraison 221c7d5b0b fix: correct ast_grep receipt, add path confinement, -- terminator, output cap (fixes #4247)
- Rewrite success path now surfaces the "Applied N changes" receipt that
  sg writes to stderr, instead of reporting the failure value
  "No changes made" after a real, destructive rewrite.
- Confine path/rule_file to the workspace via path_safety.resolve_within_root.
- Add a `--` argv terminator so a dash-leading path is never parsed as a flag.
- Bound search/rewrite/scan output via _output_overflow.bounded_with_pointer.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 07:18:01 +00:00
praisonai-triage-agent[bot]andMervin Praison 3298a8caff fix: enforce sandbox resource limits, add sync chat() backend guards, dedupe Gemini tool constant (fixes #4246)
1. Sandbox ResourceLimits (memory/CPU/process/fd/disk caps) are now applied
   via setrlimit in a POSIX preexec_fn in _execute_code_sandboxed, instead of
   only honouring the wall-clock timeout. Non-POSIX platforms log a warning.
2. Sync chat()'s managed-backend branch now applies the rate limiter and
   cooperative cancellation check before delegating, matching achat().
3. _format_tools_for_litellm now imports the canonical GEMINI_INTERNAL_TOOLS
   constant instead of a hand-duplicated set.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 07:16:14 +00:00
MervinPraison 514b35e58f docs: auto-update documentation parity trackers [skip ci] 2026-08-23 06:35:15 +00:00
praisonai-triage-agent[bot] 1cda79b538 Merge pull request #4242 from MervinPraison/claude/issue-4240-20260823-0515
fix: six context strategies silently disabled by optimizer kwargs fan-out
2026-08-23 06:34:43 +00:00
MervinPraison 30744e5bdd docs: auto-update documentation parity trackers [skip ci] 2026-08-23 06:23:36 +00:00
praisonai-triage-agent[bot] de32295eb5 Merge pull request #4245 from MervinPraison/claude/issue-4241-20260823-0520
fix: surface unparseable tool-call args and forward configured temperature
2026-08-23 06:23:00 +00:00
praisonai-triage-agent[bot] 95e7da3f06 Merge pull request #4239 from MervinPraison/claude/issue-4238-20260823-0459
fix: stop praisonai-train silently discarding data and misreporting
2026-08-23 06:19:30 +00:00
praisonai-triage-agent[bot] 99a9b6aa47 Merge pull request #4237 from MervinPraison/claude/issue-4233-20260823-0452
fix: abort close-delimited slow monitor bodies via pinned raw socket
2026-08-23 06:17:17 +00:00
praisonai-triage-agent[bot] 24a12f6a8d Merge pull request #4235 from MervinPraison/claude/issue-4231-20260823-0449
fix: four residuals — judge parsing, gateway stop, telemetry counting, crawl SSRF (#4231)
2026-08-23 06:14:53 +00:00
MervinPraison afbc190c10 docs: auto-update documentation parity trackers [skip ci] 2026-08-23 06:08:07 +00:00
praisonai-triage-agent[bot] fa9ead256d Merge pull request #4232 from MervinPraison/claude/issue-4226-20260823-0442
fix: make as_tool run sub-agent, resolve openai-compat, make db/obs callable
2026-08-23 06:07:32 +00:00
MervinPraison 207c911199 docs: auto-update feature parity trackers [skip ci] 2026-08-23 05:57:38 +00:00
praisonai-triage-agent[bot] 85d10cf8a1 Merge pull request #4230 from MervinPraison/claude/issue-4227-20260823-0443
fix: unwrap LLMConfig in AgentTeam/AgentFlow so members don't fail silently
2026-08-23 05:57:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 86daa44a84 test: assert parse-failure sentinel for malformed tool-call args
The malformed-JSON cases in test_tool_call_parsing encoded the old buggy
behaviour (args -> {}), which silently ran tools with wrong arguments and
reported success. Defect 1 of #4241 replaces {} with a distinct
_TOOL_ARGUMENTS_PARSE_FAILED sentinel. Update the test to assert the
sentinel (not weaken it) so it keeps guarding that malformed args are
surfaced as retryable errors instead of dispatched.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:42:54 +00:00
praisonai-triage-agent[bot]andMervin Praison e91eca207c test: assemble redaction fixtures at runtime to clear GitGuardian
The new sanitise tests embedded realistic secret-shaped literals (Stripe
sk_live_/sk_test_ keys, an AWS-style secret value) as fixtures, which
GitGuardian flagged as hardcoded secrets and blocked CI. Construct those
fixtures by concatenation at runtime so no scannable secret literal lives
in source, while the redaction regexes are still exercised against a
matching string. No change to what the tests assert.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:37:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 3534914544 fix: budget full request in emergency-truncation threshold (PR #4242 review)
The hard-limit check and failure-path fallback in _apply_context_management
counted only message-history tokens. A large system prompt or tool schema
could push the request over the model window while the history alone stayed
under 95%, so truncation was skipped and an over-limit request was sent.

Both paths now add system-prompt + tool-schema tokens via a new
_estimate_request_overhead_tokens helper, and the emergency target reserves
that overhead so the whole request lands near 80% of the window. Adds
regression tests covering the fallback with an oversized system prompt.

Addresses CodeRabbit and Greptile review on PR #4242.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:35:21 +00:00
praisonai-triage-agent[bot]andMervin Praison b4733628d8 fix: preserve symlink output destinations on atomic dataset replace
os.replace(tmp, out_path) clobbers a symlink itself, leaving its target
holding the stale corpus. Resolve the final-path symlink to its real
target before creating the temp file and replacing, so generate/dedup
update the file consumers read through the link. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:33:27 +00:00
praisonai-triage-agent[bot]andMervinPraison 416a59da2f fix: surface unparseable tool-call args and forward configured temperature (fixes #4241)
Defect #1: a truncated/invalid tool-call argument string no longer executes
the tool with {} and reports success. _parse_tool_call_arguments now returns a
distinct sentinel on JSON parse failure (kept separate from {} = "no args"),
and every tool-execution site emits a retryable tool-error message instead of
dispatching. Covers sync/async, streaming, batched and Responses-API paths.

Defect #2: a temperature configured via Agent(llm={'temperature': ...}) now
actually reaches the request. Public entry points default temperature to None
and _build_completion_params resolves it from self.temperature once; chat/achat
and the internal completion helpers default to None so the configured value is
no longer overridden by a hardcoded 1.0. Unset temperature is omitted so the
provider default applies rather than sending an explicit None.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 05:32:51 +00:00
praisonai-triage-agent[bot]andMervin Praison 26721fff9a fix: fail-closed on unscored verdicts, confirm force-kill, preserve crawl batch order
- eval/grader.py + judge.py: passing now requires a parsed numeric SCORE
  (score_parsed), so a reasoning-only response can no longer fabricate a
  passing 5.0 against a low threshold. Multi-line REASONING is captured.
- crawl4ai_tools.py: crawl_many preserves input->output positional order for
  mixed safe/blocked batches instead of hoisting blocked entries to the front.
- gateway.py: _force_kill_process polls until the PID is confirmed gone before
  returning True, so the PID lock is not released while a process may survive.
- test_grader.py: assert fail-closed (1.0) for an unparseable score.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:31:25 +00:00
praisonai-triage-agent[bot]andMervin Praison daf65684a4 fix: validate direct RuntimeConfig, non-mutating normalise, align qdrant examples
Address reviewer findings on #4236:
- Route direct RuntimeConfig(runtime=...) through resolve_runtime so a
  preferred_runtime typo raises instead of silently dropping capabilities.
- resolve_runtime returns a normalised copy of a RuntimeConfig instead of
  mutating the caller's object.
- canonical_runtime_name passes the full alias set to the error builder so a
  close typo (e.g. "reducd") suggests the matched alias.
- Rename knowledge_qdrant.py -> knowledge_chroma.py (now demonstrates Chroma)
  and restore qdrant_wow.py as a genuine Qdrant adapter example that skips
  cleanly, matching filenames to content.
- Add tests: no-mutation, typo suggestion, Agent-level normalisation + typo.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:31:13 +00:00
praisonai-triage-agent[bot]andMervin Praison 7248b17e95 fix: preserve dataset file permissions on atomic replace
mkstemp creates the temp file 0600 and os.replace would make that the
final mode, stripping group/other read that a plain open(..., "w") would
grant via umask and locking shared-corpus consumers out. Restore the
destination's existing mode when overwriting, else 0666 & ~umask.
Applies to both generate and dedup; adds two regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:29:32 +00:00
praisonai-triage-agent[bot]andMervinPraison c05a6ec53d fix: restore repeated session turns and close session-share secret leaks (fixes #4243)
- run --continue: restore chat history by position instead of a content-based
  filter, so repeated turns (e.g. a bare "yes" confirmation) are no longer
  silently dropped; _auto_save_last_index tracks the full restored length.
- session share / export redaction: mask sk_live_/sk_test_ Stripe keys, anchor
  the key=value rule on keys that *contain* a secret word (AWS_SECRET_ACCESS_KEY,
  STRIPE_SECRET_KEY), and mask secret values whole so a value containing '/' is
  not half-masked by the path regex.
- session share now defaults --redact-level to strict, since it publishes a
  transcript for wider distribution.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 05:27:22 +00:00
praisonai-triage-agent[bot]andMervin Praison db06f5b8c5 fix: harden arg-scoped deny (empty-policy, key-normalization, modified-args recheck)
Address reviewer findings on #4234 (issue #4228):
- agent.py: use `is None` (not falsiness) so an explicit empty permissions
  policy (`permissions={}`) owns denial instead of inheriting default denials.
- tool_execution.py: normalize argument keys (strip trailing '=') before
  build_permission_target so a malformed `command=` kwarg cannot evade a
  scoped deny that only normalizes after the gate.
- tool_execution.py: re-run the deny gate against `decision.modified_args` in
  both sync and async paths so an approval backend cannot rewrite allowed args
  into a denied command; BYPASS still opts out.
- tests: add regression coverage for all three.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:26:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 9b8996c77c test: assert AgentTeam member + AgentFlow seed get the unwrapped model string
Covers the #4227 failure mode directly (member propagation, not just the
container field) per CodeRabbit review, without a live LLM call.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-23 05:20:52 +00:00
praisonai-triage-agent[bot]andMervinPraison adb262d596 fix: filter optimizer kwargs so six context strategies stop failing silently (fixes #4240)
ContextManager built one union kwargs dict and passed it to every optimizer,
but only SmartOptimizer accepted that signature. The other six raised
TypeError, which chat_mixin swallowed with a blanket except, disabling context
management entirely — including the emergency hard-limit truncation.

- get_optimizer now filters kwargs to each optimizer's constructor signature
  (single choke point; fixes all six strategies without touching call sites).
- chat_mixin._apply_context_management falls back to emergency truncation on
  failure instead of returning the unchanged, over-budget history.
- Add regression tests covering every strategy.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 05:19:50 +00:00
praisonai-triage-agent[bot]andMervinPraison 6820f64d10 fix: stop praisonai-train silently discarding data and misreporting (fixes #4238)
Four HIGH-severity silent-data-loss bugs in praisonai-train:

- train llm <dataset>: forward the path as --dataset (the option the train
  branch reads) instead of a bare positional the legacy dispatcher drops,
  which silently trained on yahma/alpaca-cleaned.
- generate: write to a temp file and os.replace only when rows are produced
  (mirrors dedup), so an all-failures run no longer truncates an existing
  corpus to zero bytes.
- Ollama export: dedent the Modelfile TEMPLATE so continuation lines sit at
  column 0 and the served prompt format matches the trained one.
- agent training: a genuine agent failure now re-raises instead of being
  captured as "Error: ..." output, graded as training data, and reported as
  success with exit 0.

Adds regression tests for all four.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 05:05:00 +00:00
praisonai-triage-agent[bot]andMervinPraison ed662cab0d fix: abort close-delimited slow monitor bodies via pinned raw socket (fixes #4233)
The scheduler monitor's total-deadline watchdog was a no-op for
Connection: close responses. http.client.getresponse() calls close()
for close-delimited replies, nulling connection.sock, so when the
watchdog fired on exactly the responses that can hang forever there was
no socket left to shut down. A page dripping one byte per socket window
stalled the whole sequential scheduler tick while holding its lease.

Retain a separate raw-socket handle the close() path does not clear and
fall back to it in _abort_connection; clear it after each probe so the
fd is not held past the (per-probe) connection.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 04:56:12 +00:00
praisonai-triage-agent[bot]andMervinPraison 132f08bfdf fix: harden judge parsing, gateway stop, telemetry counting and crawl SSRF (fixes #4231)
- #4195: grader.py/judge.py now parse markdown-bolded labels and fail closed
  (score 1.0) instead of fabricating a mid-range 5.0 that could pass a threshold
- #4192: gateway stop distinguishes PermissionError from a dead process and
  keeps a live cross-uid holder's lock
- #4196: telemetry re-entrancy guard uses a ContextVar so concurrent executions
  on one agent are counted independently
- #4189: crawl4ai_tools now validates every URL entry point via is_safe_http_url,
  and mentions.py re-validates each redirect hop instead of only the first

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 04:55:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 9cef4eaa8e fix: derive generator memory backends from resolver; normalise runtime= (fixes #4229)
(a) The code generator advertised a hardcoded memory capability list that
still named removed backends (redis/postgres/qdrant), so generated agent
code raised before the API. Derive supports_presets/supports_url_schemes
from the live resolver registries (MEMORY_PRESETS, MEMORY_URL_SCHEMES) so
the generator can never teach a dead value. Update the four shipped
examples that crashed at the config boundary to use shipped backends.

(b) runtime= stored any string verbatim, silently dropping nine of twelve
capabilities on a typo. Normalise through canonical_runtime_name() so
spelling variants (NATIVE, " native ", Native) all map to the native
matrix, and raise on a close typo (nativ -> "Did you mean native?").
A genuinely unknown plugin runtime name is still accepted and falls back
to the reduced harness via the runtime registry, not a closed literal.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 04:55:43 +00:00
praisonai-triage-agent[bot]andMervinPraison ac4c6bf81c fix: keep default denials when configuring approval + enforce argument-scoped deny rules (fixes #4228)
(a) The ApprovalConfig and dict approval branches left _perm_deny at its
empty init value, making a config object a WEAKER posture than passing
nothing (ApprovalConfig(timeout=30) silently dropped all 8 default
denials). They now fall back to the env-driven default deny set when no
declarative permissions policy is supplied, so configuring approval never
removes the default denials. PRAISONAI_TOOL_SAFETY=off still fully bypasses.

(b) The PermissionManager deny gate only asked with the tool *name*, so
argument-level deny rules (e.g. bash:rm *) never fired. _check_permission_manager_deny
now also builds the argument-scoped target via build_permission_target and
checks it, so an argument-scoped rule can narrow an allowed shell tool.
Name-level rules keep their all-or-nothing behaviour.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 04:52:11 +00:00
praisonai-triage-agent[bot]andMervinPraison ad1f596bce fix: make as_tool run sub-agent, resolve openai-compat, make db/obs callable (fixes #4226)
Three closed defects still reproduced on main, each shielded by a test that
passed on the broken behaviour.

1. Agent.as_tool() returned a success string without running the sub-agent.
   ContextPolicy.NONE discards history, so no prompt was ever derived. The
   generated tool now advertises a `task` parameter and passes it verbatim as
   the sub-agent's prompt.
2. `--type openai-compat` was refused though listed as registered. The endpoint
   registry loads under both `praisonai_code.*` and the `praisonai.cli.features`
   shim namespace; the relative `from ..._wrapper_bridge` resolved to the
   non-existent `praisonai._wrapper_bridge` under the shim. Made it absolute.
3. `from praisonaiagents import db` (and `obs`) yielded a non-callable module,
   because a real submodule shadows the package `__getattr__`. The submodules
   now swap their `__class__` to a callable ModuleType subclass that proxies to
   the lazy singleton, so `db(...)`/`obs(...)` and `db.PraisonDB` both work.

Tests: added a behavioural as_tool test asserting the sub-agent call, a
resolve() assertion under the shim namespace, an obs-callable test, and removed
the bug-detecting workaround from the db test so its assertion can fail without
the fix. Each new test was confirmed to fail against the unfixed tree.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 04:51:39 +00:00
praisonai-triage-agent[bot]andMervinPraison 0521d43fe9 fix: unwrap LLMConfig in AgentTeam/AgentFlow so members don't fail silently (fixes #4227)
The containers called resolve_model_alias (alias rule only) while the seven
media/specialist agents call resolve_model_name (alias rule + LLMConfig
unwrapping). AgentTeam/AgentFlow therefore pushed the raw LLMConfig into every
member via _apply_default_llm, and the member's chat() failed silently with
"'LLMConfig' object has no attribute 'lower'", returning None.

Switch both container sites to resolve_model_name and widen the regression
test's parametrize from UNWRAPPING to ALL.

Co-authored-by: MervinPraison <[email protected]>
2026-08-23 04:47:30 +00:00
MervinPraison 52e3e37b1c docs: auto-update documentation parity trackers [skip ci] 2026-08-22 18:03:27 +00:00
praisonai-triage-agent[bot] 146373039a Merge pull request #4225 from MervinPraison/claude/issue-4224-20260822-1711
fix: remove vestigial thread-local lazy-cache machinery
2026-08-22 18:02:47 +00:00
praisonai-triage-agent[bot]andMervin Praison c7b54c526d test: exercise synchronized first-load path in lazy-cache concurrency test
Evict the 'Task' key from _lazy_cache before spawning threads so the
concurrent access hits the double-checked locking path in
_lazy.lazy_import instead of the unlocked cache fast path. Addresses
Greptile P2: prior test could read an already-cached attribute after
suite collection, letting concurrent first-load regressions go
undetected.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 17:19:51 +00:00
praisonai-triage-agent[bot]andMervinPraison 1eed1c5c76 fix: remove vestigial thread-local lazy-cache machinery (fixes #4224)
Remove dead _lazy_cache_local threading.local and _get_lazy_cache()
helper from praisonaiagents/__init__.py. The runtime lazy-loader only
uses the plain _lazy_cache dict (guarded by the lock in _lazy.py);
the thread-local cache had no runtime consumer.

Repoint TestLazyCacheThreadSafety at the real lazy-loading path
(concurrent access of praisonaiagents.Task resolving to one cached
object) instead of the removed self-referential helper.

Co-authored-by: MervinPraison <[email protected]>
2026-08-22 17:13:13 +00:00
MervinPraison 68b325735f docs: auto-update api.md [skip ci] 2026-08-22 11:56:22 +00:00
Mervin Praison f682ffdcc6 Merge pull request #4213 from MervinPraison/claude/issue-4212-20260822-0711
fix: memory prompt injection, timeout retry safety, handoff context (#4212)
2026-08-22 12:55:54 +01:00
MervinPraison 4b92def4e0 docs: auto-update documentation parity trackers [skip ci] 2026-08-22 11:55:38 +00:00
Mervin Praison 3f3ff78dbb Merge pull request #4211 from MervinPraison/claude/issue-4210-20260821-1715
fix: emit reasoning deltas on async Responses streaming; narrow Expected warning filter
2026-08-22 12:55:04 +01:00
MervinPraison d55ee886f3 docs: auto-update api.md [skip ci] 2026-08-22 11:54:55 +00:00
Mervin Praison fa881a604d Merge pull request #4207 from MervinPraison/claude/issue-4206-20260821-0918
fix: forward model-fallback, retry, todo, and tool-result stream events in gateway relay
2026-08-22 12:54:30 +01:00
Mervin Praison d58eaffb40 Merge pull request #4203 from MervinPraison/claude/issue-4202-20260821-0709
fix: isolate memory backends, pre-compaction batch commit, Parallel stop propagation
2026-08-22 12:54:10 +01:00
MervinPraison 0538482030 docs: auto-update documentation parity trackers [skip ci] 2026-08-22 10:54:44 +00:00
praisonai-triage-agent[bot] 33b67c2da2 Merge pull request #4223 from MervinPraison/claude/issue-4221-20260822-0946
fix: cross-replica quota coordination seam for credential cooldowns
2026-08-22 10:54:04 +00:00
MervinPraison 15a5c72b0b docs: auto-update documentation parity trackers [skip ci] 2026-08-22 10:44:30 +00:00
praisonai-triage-agent[bot] b279f16a35 Merge pull request #4222 from MervinPraison/claude/issue-4220-20260822-0945
fix: add core global operator emergency-stop brake protocol
2026-08-22 10:43:53 +00:00
MervinPraison 27bc4e1963 docs: auto-update feature parity trackers [skip ci] 2026-08-22 10:14:51 +00:00
praisonai-triage-agent[bot] 96d4b58cad Merge pull request #4219 from MervinPraison/claude/issue-4217-20260822-0916
fix: add restart_safe tool contract for durable resume
2026-08-22 10:14:26 +00:00
praisonai-triage-agent[bot] 4b917c407a Merge pull request #4218 from MervinPraison/claude/issue-4216-20260822-0915
fix: make gateway crash-safe by default with journal-backed resume
2026-08-22 10:13:20 +00:00
praisonai-triage-agent[bot]andMervin Praison f7d1b53818 fix: use stable credential identity and guard stale-success clearing in quota coordination
- AuthProfile.credential_id: stable, non-secret (hashed) identity keyed on
  provider+api_key+base_url so coordinator benches track the credential, not
  the local profile label. Prevents same-name collisions ("default") benching
  unrelated keys and different names on the same key breaking propagation.
- mark_success: only clear the shared bench when it is not newer than the
  cooldown being recovered from, so a stale in-flight success cannot clobber a
  newer fleet-wide bench set by a concurrent failure.
- All three coordinator call sites now use credential_id consistently.
- Tests: add same-name-distinct-keys isolation, stale-success guard, and
  own-bench clear coverage; update existing tests to key on credential_id.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 09:58:30 +00:00
praisonai-triage-agent[bot]andMervin Praison b3853c0eeb fix: harden emergency-stop sentinel and accept gateway.control in bot schema
- state(): catch TypeError so a valid-JSON sentinel with a non-numeric `at`
  (e.g. a list) still fails safe engaged instead of raising.
- engage(): commit via tempfile.mkstemp in the sentinel dir + fsync file and
  parent dir, closing a predictable-tmp symlink pre-creation vector and making
  an engaged brake durable across an abrupt crash; cleanup tmp on failure.
- bot GatewayServerSchema: declare `control` and `turn_lock` so the documented
  `gateway.control` YAML block loads under extra="forbid".
- tests: drop hardcoded /tmp path (S108) and add structured-`at` fail-safe case.

Skipped (scope/lightweight mandate): async FileEmergencyStop variants (no live
async admission consumer yet; is_engaged is a fast local stat/read) and
agent.start() agentic tests (this PR ships the core contract + config only, no
admission wiring to exercise via the LLM).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 09:58:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 3f458b9452 fix: add cross-replica quota coordination seam for credential cooldowns (fixes #4221)
Adds a pure, dependency-free QuotaCoordinatorProtocol + QuotaCoordinatorConfig
and an in-memory LocalQuotaCoordinator in core (praisonaiagents/llm/quota.py).
FailoverManager now consults an optional coordinator so a credential benched
by any replica (429/auth/billing cooldown) is skipped by all replicas sharing
the same backend. Defaults to the local in-memory coordinator, so single-replica
/ no-backend deployments behave exactly as before with no new dependency and no
hot-path change. All coordinator calls are fail-open (never wedge failover) and
benches carry TTLs that self-expire.

Co-authored-by: MervinPraison <[email protected]>
2026-08-22 09:51:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 875a5a7948 fix: add core global operator emergency-stop brake protocol (fixes #4220)
Introduce a pure, dependency-free EmergencyStopProtocol in core gateway
plus a zero-cost NullEmergencyStop default and a durable, fail-safe
FileEmergencyStop sentinel. Wire an EmergencyStopConfig (gateway.control)
into GatewayConfig defaulting to backend "off" so behaviour is unchanged.
This is the shared contract every new-work admission seam can consult to
halt new runs while preserving in-flight ones; a corrupt/unreadable
sentinel fails safe (engaged).

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

Co-authored-by: MervinPraison <[email protected]>
2026-08-22 09:50:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 8b40690a6d fix: fail closed on malformed restart_safe + tolerate repaired tool names
- Only genuine booleans count as a restart_safe declaration; malformed values
  (e.g. the string "False") are treated as unsafe instead of coerced truthy.
- Empty tool names fail closed rather than falling through to the read-only
  heuristic which returns True for "".
- Declaration lookup now compares canonicalised names so an explicit
  restart_safe declaration survives execute_tool's case/separator repair.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 09:31:20 +00:00
praisonai-triage-agent[bot]andMervin Praison 91294a67a9 fix: base gateway durable-runs default on effective session store
CodeRabbit: session.persist:true but a failed persistent-store init degrades
to in-memory (self._session_store is None). The old config-based default still
enabled ExecutionConfig(durable=True), journalling against no durable store.
Resolve the auto-default from the instantiated self._session_store instead;
explicit gateway.durable_runs still wins. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 09:30:20 +00:00
praisonai-triage-agent[bot]andMervinPraison 23e358b5ae fix: add restart_safe tool contract for durable resume (fixes #4217)
Add a `restart_safe` declaration to the tool contract (@tool / BaseTool /
FunctionTool) and honour it during durable resume. An in-flight tool call
(journalled tool_call with no tool_result at the crash) is now re-run only
when the tool resolves as restart-safe (explicit declaration, else the
read-only name heuristic); effectful/undeclared tools fail closed with a
typed NotSafelyResumable outcome instead of silently re-executing, removing
the duplicated-side-effect risk on crash recovery.

Co-authored-by: MervinPraison <[email protected]>
2026-08-22 09:22:18 +00:00
praisonai-triage-agent[bot] af5082ee85 Merge pull request #4215 from MervinPraison/claude/issue-4214-20260822-0819
fix: reap cancelled subprocesses, offload blocking recipe handlers, atomic hash writes
2026-08-22 09:22:13 +00:00
praisonai-triage-agent[bot]andMervinPraison f3cc8bee17 fix: make gateway crash-safe by default with journal-backed resume (fixes #4216)
Auto-enable durable (journal-backed) gateway runs when a durable session
store is present (the default), so a restart mid-turn replays completed
model/tool steps instead of re-executing side-effecting tools or re-billing
the model. Zero-overhead escape hatches: explicit `gateway.durable_runs:
false` or `gateway.reliability: "off"`, and sessions with `persist: false`.
An explicit `durable_runs` and per-agent `durable` still win.

Co-authored-by: MervinPraison <[email protected]>
2026-08-22 09:20:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 1a7cb66802 fix: correct hash deletion counts, migrate legacy hashes, stop stranded SSE producer
- firestore.hdel: count only fields actually present instead of len(fields)
- dynamodb.hset/hdel: migrate legacy JSON-string hashes into native map so
  existing fields stay visible and legacy deletions are effective; count actual
- recipe/serve.py: drain SSE queue + stop event on consumer cancellation so the
  producer worker thread unblocks instead of leaking the executor

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 08:31:02 +00:00
praisonai-triage-agent[bot]andMervinPraison 53543e020c fix: reap cancelled subprocesses, offload blocking recipe handlers, atomic hash writes (fixes #4214)
- integrations/base.py: reap the child in a finally block in execute_async
  and execute_async_with_stderr so caller cancellation (CancelledError) no
  longer leaks a running subprocess plus its stdout/stderr pipes.
- recipe/serve.py: route sync recipe.* calls through asyncio.to_thread and
  bridge the sync run_stream generator to an asyncio.Queue on a worker
  thread so a single recipe run no longer blocks the whole event loop.
- persistence/state/firestore.py: scope credentials to the client instead
  of mutating process-wide os.environ, and use atomic field-level merge /
  DELETE_FIELD for hset/hdel to avoid lost concurrent updates.
- persistence/state/dynamodb.py: use atomic UpdateExpression on a native
  map attribute for hset/hdel to avoid non-atomic read-modify-write races.

Co-authored-by: MervinPraison <[email protected]>
2026-08-22 08:23:16 +00:00
praisonai-triage-agent[bot]andMervin Praison 884e212319 fix: scope handoff history, guard empty memory query, resolve global idempotent tools
- handoff.py: make seeded handoff context invocation-scoped via a
  restore-on-exit context manager so target chat_history no longer leaks
  across sequential/concurrent handoffs or grows unbounded (CodeRabbit/Greptile P1)
- handoff.py: exclude the exact message folded into the prompt (by index)
  from seeded history instead of always dropping the final list item
- memory.py: get_context returns "" for empty/None query (no LIKE "%%"
  unrestricted match) and propagates the effective user_id for user-scoped memories
- tool_execution.py: resolve explicit idempotent flag on process-global
  registry tools before falling back to the shared name registries

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 07:32:14 +00:00
praisonai-triage-agent[bot]andMervin Praison f8e59659f3 fix: memory prompt injection, timeout retry safety, handoff context (fixes #4212)
Bug #1 — retrieved memory never reached the prompt:
- build_context_for_task defaulted include_in_output to a DEBUG-only gate, so
  Agent(memory=True) retrieved hits but returned "" under normal logging. Default
  to True (the method's primary purpose is feeding memory into the prompt).
- Added Memory.get_context() so the non-cache path (get_memory_context) works;
  previously only FileMemory had it, so hasattr(...) was always False.

Bug #2 — timed-out tool auto-retried while still running:
- future.cancel() is a no-op once a worker starts, so the tool body keeps running.
  Added _is_tool_idempotent() and gated the _outer_timeout retry on it, reusing
  the escalation loop_guard read-only/mutating registry plus an optional per-tool
  `idempotent` flag. Mutating tools now surface the timeout once instead of
  duplicating side effects.

Bug #3 — HandoffConfig context policy computed then discarded:
- _prepare_context() result was thrown away; the target only got the prompt string.
  Added _seed_target_history() and threaded the filtered messages through
  _execute_with_runtime_resolution(_async) for sync/async Handoff and TypedHandoff.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-22 07:18:26 +00:00
praisonai-triage-agent[bot]andMervinPraison 455d81c615 fix: emit reasoning deltas on async Responses streaming; narrow Expected warning filter (fixes #4210)
- llm/llm.py: add response.reasoning_summary_text.delta branch to
  _stream_responses_api_async so async streaming emits reasoning-summary
  deltas, matching the sync path.
- _warning_patch.py: replace over-broad bare "Expected" substring with
  precise "fields but got" so unrelated third-party/user warnings are no
  longer swallowed process-wide.

Co-authored-by: MervinPraison <[email protected]>
2026-08-21 17:17:13 +00:00
MervinPraison c24b3e7fa8 docs: auto-update documentation parity trackers [skip ci] 2026-08-21 10:38:58 +00:00
praisonai-triage-agent[bot] 624049392c Merge pull request #4209 from MervinPraison/claude/issue-4208-20260821-0946
fix: make inbound webhook/trigger dedup durable across restarts
2026-08-21 10:38:24 +00:00
praisonai-triage-agent[bot]andMervin Praison dc237ae969 fix: wire sqlite hook idempotency backend + reclaim crashed reservations
Address the two P1 findings on the inbound webhook dedup durability change
(#4208):

- SQLite backend was unreachable: _get_hook_idem_store read a GatewayConfig
  field that no config path populates. Capture hooks.idempotency.store_backend
  in _apply_hooks_from_config (the only path that sees the raw hooks dict),
  supporting both the mapping-shaped hooks.idempotency and a sibling
  hooks_idempotency key, and rebuild the store on a hot-reload backend flip.

- In-flight claims survived a crash: a process exit between reserve and
  record/release left a durable inflight row that deduplicated the provider's
  post-restart retry for the full TTL. Add a bounded inflight lease; a stale
  inflight reservation is reclaimed on reserve so the retry re-runs, while
  recorded keys keep deduplicating until TTL.

Adds regression tests for config wiring (default/sqlite/sibling/hot-reload) and
crash reclaim (stale inflight reclaimed; recorded not reclaimed).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-21 10:02:30 +00:00
praisonai-triage-agent[bot]andMervinPraison 9f0c7e1d0b fix: make inbound webhook/trigger dedup durable across restarts (fixes #4208)
Add a core IdempotencyStoreProtocol (reserve/record/release) with a bounded
in-memory default, and a durable SQLite backend in praisonai-bot. The gateway's
inbound hook handler now delegates dedup to a swappable store selected via
hooks.idempotency.store_backend ("memory" default; "sqlite" for restart/
multi-replica durability), so a provider redelivery after a restart no longer
starts a duplicate agent run. Mirrors the existing ingress-journal/outbound-
queue durability.

Co-authored-by: MervinPraison <[email protected]>
2026-08-21 09:54:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 0dd56a72e0 fix: advertise and persist new stream events in gateway contract
Register MODEL_FALLBACK_STREAM, RETRY_STREAM, TODO_STREAM, and
TOOL_RESULT_STREAM in the streaming capability handshake and the
session-history allowlist so clients can discover these events and
so they receive a cursor for reconnect/resume replay (fixes gap
flagged by CodeRabbit/Greptile reviews on #4206).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-21 09:27:09 +00:00
praisonai-triage-agent[bot] e068d04d2d Merge pull request #4205 from MervinPraison/claude/issue-4204-20260821-0825
fix: repair three src/praisonai wrapper defects (scheduler bridge, audit rotation, LazyCache)
2026-08-21 09:21:12 +00:00
praisonai-triage-agent[bot]andMervin Praison fe07ad5201 fix: forward model-fallback, retry, todo, and tool-result stream events in gateway relay (fixes #4206)
The WebSocket gateway relay dropped four StreamEventTypes the SDK already
emits (MODEL_FALLBACK, RETRY, TODO_UPDATED, TOOL_CALL_RESULT), causing live
clients to see an unexplained token-stream stall during fallback/backoff.

- Add additive EventType members (MODEL_FALLBACK_STREAM, RETRY_STREAM,
  TODO_STREAM, TOOL_RESULT_STREAM) to gateway protocol enum
- Map the four events through the existing cross-thread relay send path,
  carrying their existing metadata untouched

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-21 09:20:51 +00:00
praisonai-triage-agent[bot]andMervin Praison b3ae6c8c0e fix: bump praisonai-mcp to 0.1.2 so editable install wins over stale PyPI wheel
The smoke job installs praisonai-mcp==0.0.15 as editable, but the wrapper's
`praisonai-mcp>=0.1.0` constraint then made pip fetch the stale PyPI wheel
praisonai_mcp-0.1.1 (no `cli` submodule), clobbering the editable install and
breaking `praisonai_mcp.cli` (test_mcp_list). Bumping the local package to
0.1.2 satisfies the constraint so the editable install is retained.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-21 08:39:02 +00:00
praisonai-triage-agent[bot]andMervin Praison ea2be988d5 fix: preserve unbounded scheduled runs and prevent audit fd leak
- schedules_runner: pass timeout=None to run_sync_or_offload so long
  scheduled agent/command runs are not cancelled by the bridge's default
  300s timeout after ScheduleLoop has already claimed the occurrence
  (would silently drop one-shot / long-interval work). Restores the prior
  unbounded asyncio.run(...) behaviour. (Greptile P1)
- security/audit: close the file handle best-effort in the rotation
  OSError branch before dropping the reference, so an fstat/close failure
  cannot leak the descriptor. (Greptile P2)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-21 08:34:25 +00:00
praisonai-triage-agent[bot]andMervinPraison 368b23a757 fix: repair three src/praisonai wrapper defects (fixes #4204)
- scheduler on_trigger now uses run_sync_or_offload instead of asyncio.run,
  so scheduled ticks share the async bridge / per-loop connection pools
- AuditLogHook reopens its handle when the log path is rotated (mv/rm),
  preventing silent loss of audit records after logrotate
- LazyCache memoises every terminal loader failure (not just ImportError),
  while letting KeyboardInterrupt/SystemExit propagate, avoiding hot-path
  cache stampedes on broken optional deps

Co-authored-by: MervinPraison <[email protected]>
2026-08-21 08:28:13 +00:00
praisonai-triage-agent[bot]andMervin Praison 96098f46df fix(memory): resolve batch-commit deadlock, honor deadline/guard, sanitize user_id paths
Addresses reviewer P1 findings on the Memory batch-commit path:

- Deadlock: commit_memory_batch no longer holds the non-reentrant
  _write_lock while calling store_short_term/store_long_term (which
  reacquire it). Writes now dispatch outside the lock.
- Atomicity/cancellation: both sync and async batch commits route each
  write through _AtomicCommitGuard.commit(deadline=...) so cancelled or
  past-deadline facts are skipped; acommit offloads blocking stores via
  asyncio.to_thread instead of blocking the event loop.
- Path traversal: default sqlite/chroma store paths derive a
  filesystem-safe suffix from user_id, rejecting separators/parent refs
  and sanitizing other characters (bare stores keep legacy defaults).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-21 07:26:07 +00:00
praisonai-triage-agent[bot]andMervinPraison 1635d4de2c fix: isolate memory backends, implement pre-compaction batch commit, propagate Parallel stop (fixes #4202)
- Thread the per-agent mem_user_id into every Memory(...) construction path in
  agent.py (string and dict forms) via config.setdefault, and scope the default
  sqlite/chroma paths and collection_name by user_id in Memory so two agents in
  one process no longer silently share one store. Bare stores keep legacy
  defaults for backward compatibility.
- Add commit_memory_batch/acommit_memory_batch to the Memory class so the
  pre-compaction fact flush durably commits extracted facts for
  sqlite/chroma/mongodb/mem0 instead of raising and being swallowed.
- Propagate a nested on_error="stop" stop signal out of Parallel: _execute_parallel
  now returns a stop flag, the nested dispatcher forwards it, and the top-level
  loop halts like Route/Loop/Repeat/If. Keep parallel_outputs index-aligned with
  the branch list on partial_ok failures.

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

Co-Authored-By: MervinPraison <[email protected]>
2026-08-21 07:17:44 +00:00
EvolinkAI cfe3881c56 fix: handle missing Evolink API key in example 2026-06-23 19:12:11 +08:00
EvolinkAI 838ce5264a feat: add Evolink provider example
Document Evolink's OpenAI-compatible endpoint in the sample environment and add a provider example that uses the existing PraisonAI base_url/api_key configuration path. Cover the config wiring with an offline unit test so the integration stays regression-tested without live API calls.
2026-06-23 19:12:11 +08:00
354 changed files with 43216 additions and 2225 deletions

No files matched your search

@@ -0,0 +1,86 @@
---
name: praisonai-js-hub-intake
description: >-
Explains Option B hub intake for PraisonAI TypeScript/JavaScript (npm praisonai):
accept TS issues on MervinPraison/PraisonAI, implement fixes in MervinPraison/praisonai-js,
sync to src/praisonai-ts/. Use when routing issues, implementing TS fixes, sync workflows,
merge-gate TS paths, or when the user mentions praisonai-js, praisonai-ts, npm SDK, or TS hub intake.
---
# praisonai-js hub intake (Option B)
## Model in one sentence
**Users file TypeScript issues on [PraisonAI](https://github.com/MervinPraison/PraisonAI); fixes land in [praisonai-js](https://github.com/MervinPraison/praisonai-js); the monorepo `src/praisonai-ts/` is a sync mirror only.**
Do **not** ask users to re-file on praisonai-js unless they opened a duplicate on the wrong repo.
## Routing table
| Topic | Report | Fix repo | Monorepo mirror |
|-------|--------|----------|-----------------|
| Python SDK / CLI | PraisonAI | `src/praisonai-agents/`, `src/praisonai/` | — |
| TypeScript / npm `praisonai` | PraisonAI (preferred) or praisonai-js | **praisonai-js** | `src/praisonai-ts/` |
| Cross-language parity | PraisonAI | Often both Python + praisonai-js | sync after TS merge |
| Agent-callable tools | PraisonAI | PraisonAI-Tools | — |
| Lifecycle plugins | PraisonAI | PraisonAI-Plugins | — |
## End-to-end flow
```
Issue on PraisonAI
→ issue-triage: labels javascript, praisonai-js (if TS focus)
→ claude label → Claude STEP 3-ALT clones praisonai-js
→ PR on praisonai-js with body: Fixes MervinPraison/PraisonAI#N
→ merge on praisonai-js (CI + merge gate there)
→ Sync to PraisonAI Monorepo workflow on praisonai-js
→ PR on PraisonAI: sync: update src/praisonai-ts/...
```
## Agent rules
1. **Never implement TS features in `src/praisonai-ts/` on PraisonAI** — edit praisonai-js instead.
2. **External PR body** must include `Fixes MervinPraison/PraisonAI#<n>` when fixing a hub issue.
3. **After praisonai-js merge**, run sync workflow (or tell maintainer to):
```bash
gh workflow run "Sync to PraisonAI Monorepo" \
--repo MervinPraison/praisonai-js \
-f upstream_issue=<issue_number>
```
4. **Merge gate on PraisonAI** blocks direct `src/praisonai-ts/` changes unless sync PR (title `sync:` or body mentions praisonai-js).
5. **Tests for TS fixes**: `npm install && npm run build && npm test` in praisonai-js clone.
## TS issue detection (PraisonAI triage)
Labels `javascript` + `praisonai-js` when issue mentions: `praisonai-js`, `praisonai-ts`, `src/praisonai-ts`, `typescript`+`praisonai`, `npm`+`praisonai` (not pypi).
## Key files
See [reference.md](reference.md) for paths in both repos.
## Quick commands
```bash
# Sync Claude secrets to praisonai-js (from PraisonAI hub)
gh workflow run "Sync Claude Secrets" \
--repo MervinPraison/PraisonAI \
-f target_repo=MervinPraison/praisonai-js
# Sync code praisonai-js → monorepo mirror
gh workflow run "Sync to PraisonAI Monorepo" \
--repo MervinPraison/praisonai-js \
-f upstream_issue=1234
```
## Related docs
- Monorepo: `CONTRIBUTING.md` (hub intake table)
- praisonai-js: `AGENTS.md` §2.1.1 Hub intake
- Security: `SECURITY.md` (npm canonical = praisonai-js)
## Cursor skill locations
| Repo | Path |
|------|------|
| PraisonAI monorepo | `.cursor/skills/praisonai-js-hub-intake/` |
| praisonai-js | `.cursor/skills/praisonai-js-hub-intake/` |
@@ -0,0 +1,72 @@
# praisonai-js hub intake — file reference
## PraisonAI monorepo (`MervinPraison/PraisonAI`)
| File | Role |
|------|------|
| `.github/workflows/claude.yml` | Issue triage labels; STEP 3-ALT to praisonai-js; npm test in external flow |
| `.github/scripts/merge-gate.js` | `tsMirrorPathReasons()` blocks non-sync edits to `src/praisonai-ts/` |
| `.github/workflows/claude-merge-gate.yml` | Assess checklist: TS → praisonai-js |
| `.github/scripts/ci-failure-claude.js` | CI fix guardrails include TS routing |
| `.github/actions/claude-code-action/action.yml` | Shared Claude routing (TS → praisonai-js) |
| `.github/praisonai-issue-triage.yaml` | `@praisonai` triage: TS-external routing |
| `.github/workflows/auto-issue-comment.yml` | Auto-triage comment mentions praisonai-js |
| `.github/ISSUE_TEMPLATE/bug_report.md` | Python / TS / cross-language selector |
| `CONTRIBUTING.md` | Hub intake table |
| `SECURITY.md` | npm canonical source = praisonai-js |
| `src/praisonai-ts/` | **Mirror only** — do not implement features here |
| `.cursor/skills/praisonai-js-hub-intake/` | This skill (agent reference) |
### Commits (Option B rollout)
- `4ab1ed280` — Implement Option B hub intake for praisonai-js TypeScript issues
## praisonai-js (`MervinPraison/praisonai-js`)
| File | Role |
|------|------|
| `.github/workflows/sync-to-praisonai.yml` | Rsync to `src/praisonai-ts/`; optional `upstream_issue` input |
| `.github/pull_request_template.md` | `Fixes MervinPraison/PraisonAI#N` + sync checklist |
| `.github/ISSUE_TEMPLATE/bug_report.md` | TS bug template; link to PraisonAI for cross-stack |
| `.github/ISSUE_TEMPLATE/config.yml` | Contact links → PraisonAI for Python/cross-stack |
| `.github/scripts/gate-config.js` | Scope + `externalRepos` hub-intake config |
| `.github/workflows/claude.yml` | Hub caller (Claude automation) |
| `AGENTS.md` §2.1.1 | Hub intake for agents |
| `README.md` | Reporting issues table |
| `.cursor/skills/praisonai-js-hub-intake/` | This skill (agent reference) |
### Commits
- `0c8cc3f` — Complete Option B reciprocal hub intake
- `bfc5e34` — Add praisonai-js-hub-intake Cursor skill
## github-automation-template
| Item | Role |
|------|------|
| `install.sh --mode hub` | Thin callers on consumer repos |
| `stacks/claude/profiles/sdk/gate-config.js.tmpl` | Base gate config (customise per repo) |
| `AGENTS.md` | Install / rollout guide |
praisonai-js uses hub mode `@v1.1.0`.
## Decision tree for agents
```
Is the issue about npm / TypeScript / praisonai-js / src/praisonai-ts?
├─ YES → Fix in MervinPraison/praisonai-js (STEP 3-ALT)
│ PR: Fixes MervinPraison/PraisonAI#N
│ After merge: Sync to PraisonAI Monorepo
└─ NO → Is it Python praisonaiagents / praisonai CLI?
├─ YES → Fix in this monorepo
└─ NO → Tools / Plugins / Docs external repos (see claude.yml STEP 2)
```
## Anti-patterns
| Wrong | Right |
|-------|-------|
| Edit `src/praisonai-ts/` for a new TS feature | Edit praisonai-js, then sync |
| Tell user to re-file on praisonai-js only | Accept on PraisonAI; route fix externally |
| Merge gate approves direct TS mirror PR without sync metadata | Title `sync:` or body references praisonai-js |
| Skip `Fixes MervinPraison/PraisonAI#N` on external PR | Always cross-link hub issues |
+1
View File
@@ -17,6 +17,7 @@ GROQ_API_KEY=gsk_... # Groq (fast inference)
COHERE_API_KEY=... # Cohere models
AZURE_OPENAI_API_KEY=... # Azure OpenAI
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
EVOLINK_API_KEY=... # EvoLink.AI OpenAI-compatible models. Get yours at https://evolink.ai/dashboard/keys
# === Optional: Memory backends ===
MEM0_API_KEY=... # Mem0 memory service
+7 -1
View File
@@ -6,6 +6,11 @@ labels: bug
assignees: ''
---
## SDK / package
- [ ] Python (`pip install praisonai` / `praisonaiagents`)
- [ ] TypeScript / JavaScript (`npm install praisonai`) — fixes land in [praisonai-js](https://github.com/MervinPraison/praisonai-js); you can report here or there
- [ ] Cross-language / parity (file here on PraisonAI)
## Environment
- Provider (select one):
- [ ] Anthropic
@@ -14,6 +19,7 @@ assignees: ''
- [ ] AWS Bedrock
- [ ] Other: <!-- specify -->
- PraisonAI version: <!-- if known -->
- Node.js version (TS/JS only): <!-- e.g. 20.x -->
- Operating System: <!-- e.g. macOS 14.3, Windows 11, Ubuntu 22.04 -->
## Full Code
@@ -31,4 +37,4 @@ assignees: ''
<!-- What actually happened -->
## Additional Context
<!-- Add any other context about the problem here, such as screenshots, logs, etc. -->
<!-- Add any other context about the problem here, such as screenshots, logs, etc. -->
@@ -225,9 +225,10 @@ runs:
- CORE vs WRAPPER vs TOOLS vs PLUGINS ROUTING:
1. Core SDK (praisonaiagents/): Only core protocols, base classes, decorators. No heavy implementations.
2. Wrapper (praisonai/): CLI, heavy implementations, optional dependencies.
3. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs). Do NOT add to praisonaiagents/.
4. Plugins → MervinPraison/PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails). Not agent tools.
5. Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point (core protocol only).
3. TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm repo. Do NOT edit src/praisonai-ts/ here.
4. Tools → MervinPraison/PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs). Do NOT add to praisonaiagents/.
5. Plugins → MervinPraison/PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails). Not agent tools.
6. Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point (core protocol only).
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory).
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
+3 -2
View File
@@ -35,16 +35,17 @@ steps:
Third, read architecture guidelines: execute_command 'cat src/praisonai-agents/AGENTS.md'.
Fourth, decide routing before planning:
- TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm repo. Do NOT edit src/praisonai-ts/ in this monorepo. Cross-link upstream issue in external PR body: Fixes MervinPraison/PraisonAI#N.
- Tools → PraisonAI-Tools: agent-callable integrations at runtime (Composio, Slack, APIs).
- Plugins → PraisonAI-Plugins: lifecycle extensions (tracing, logging, hooks, guardrails).
- Sandbox backends → PraisonAI-Plugins via `praisonai.sandbox` entry point.
Core SDK/wrapper only for protocols and CLI — external repos for Tools/Plugins.
Core SDK/wrapper only for protocols and CLI — external repos for TS SDK/Tools/Plugins.
Fifth, decide if this issue requires code changes:
- If YES: identify the exact files and lines that must change. Output a concrete file-level plan.
- If NO (question, discussion, duplicate, invalid): say so clearly. No code changes needed.
Output: routing decision (Core / Wrapper / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
Output: routing decision (Core / Wrapper / TS-external / Tools-external / Plugins-external / No-change), validation result, and exact files/lines to change.
expected_output: A decision on whether code changes are needed, and if so a concrete file-level plan.
- name: Implement Fix
+1
View File
@@ -157,6 +157,7 @@ function buildCiFixComment({ headSha, failedChecks, failureSummaries }) {
'3. **Pre-existing flake or unrelated failure** — say so explicitly; prefer fixing the root cause over masking it.',
'',
'**SDK guardrails (AGENTS.md):**',
'- TypeScript/JavaScript SDK → MervinPraison/praisonai-js (not src/praisonai-ts/ in this monorepo).',
'- Agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; sandbox backends → PraisonAI-Plugins (`praisonai.sandbox`) — not praisonaiagents/.',
'- Do not disturb core SDK contracts to accommodate a wrapper/feature change.',
'- Tests must continue to guard backward compatibility and hot-path behaviour — passing CI by lowering test standards is not acceptable.',
+21 -1
View File
@@ -308,7 +308,7 @@ function finalClaudeCompletedOnSha(comments, headPushedAt) {
}
const FINAL_CLAUDE_REVIEW_BODY =
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or praisonai-ts. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
'@claude You are the FINAL architecture reviewer. If the branch is under MervinPraison/PraisonAI (not a fork), you are able to make modifications to this branch and push directly. SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify src/praisonai-rust or src/praisonai-ts in this repo — TypeScript SDK work belongs in MervinPraison/praisonai-js. Read ALL comments above from Gemini, Qodo, CodeRabbit, and Copilot carefully before responding.\n\n**Phase 1: Review per AGENTS.md**\n1. Protocol-driven: check heavy implementations vs core SDK\n2. Backward compatible: ensure zero feature regressions\n3. Performance: no hot-path regressions\n4. SDK value: review in depth whether the change genuinely adds value to the SDK — never add features for the sake of adding them. It must strengthen the SDK (simpler, more user-friendly, robust, world-class, secure). If it does not clearly add value, request changes or recommend rejecting/closing rather than merging scope creep\n5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.\n6. Repo routing: TypeScript/JavaScript SDK → praisonai-js (not src/praisonai-ts/ here); agent-callable tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins; optional sandbox backends → PraisonAI-Plugins (`praisonai.sandbox` entry point) — request changes if wrongly added to praisonaiagents/\n\n**Phase 2: FIX Valid Issues**\n7. For any VALID bugs or architectural flaws found by Gemini, CodeRabbit, Qodo, Copilot, or any other reviewer: implement the fix\n8. Also independently identify and fix any gaps or issues you find in the changed code — do not rely only on prior reviewer feedback\n9. Push all code fixes directly to THIS branch (do NOT create a new PR)\n10. Comment a summary of exact files modified and what you skipped\n\n**Phase 3: Final Verdict**\n11. If all issues are resolved, approve the PR / close the Issue\n12. If blocking issues remain, request changes / leave clear action items';
async function getMergeState(github, owner, repo, prNumber) {
const query = `
@@ -600,6 +600,24 @@ function hasManualOnlyLabel(labels) {
return labels.some((l) => MANUAL_ONLY_LABELS.has(l));
}
const TS_MIRROR_PREFIX = 'src/praisonai-ts/';
function tsMirrorPathReasons(files, pr) {
const touchesTs = files.some((f) => f.filename.startsWith(TS_MIRROR_PREFIX));
if (!touchesTs) return [];
const title = (pr.title || '').toLowerCase();
const body = (pr.body || '').toLowerCase();
const isSyncPr =
title.startsWith('sync:') ||
body.includes('sync from praisonai-js') ||
body.includes('praisonai-js') ||
body.includes('mervinpraison/praisonai-js');
if (isSyncPr) return [];
return [
'src/praisonai-ts/ changed outside praisonai-js sync — implement in MervinPraison/praisonai-js, then run Sync to PraisonAI Monorepo workflow',
];
}
function sensitivePathReasons(files, labels = []) {
if (labels.includes(WORKFLOW_ONLY_LABEL) && isCiOnlyChange(files)) {
return [];
@@ -823,6 +841,7 @@ async function evaluatePipelineQuiescent(github, owner, repo, prNumber, core, op
if (agentManual) reasons.push(agentManual);
reasons.push(...sensitivePathReasons(pullFiles, ctx.labels));
reasons.push(...tsMirrorPathReasons(pullFiles, ctx.pr));
reasons.push(...prSizeReasons(pullFiles));
reasons.push(...secretScanReasons(pullFiles));
@@ -989,6 +1008,7 @@ module.exports = {
touchesSdk,
hasManualOnlyLabel,
sensitivePathReasons,
tsMirrorPathReasons,
prSizeReasons,
missingTestsReason,
secretScanReasons,
+1 -1
View File
@@ -42,5 +42,5 @@ jobs:
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes'
body: '🤖 Auto-triaging with Claude. Analyzing issue and implementing a fix...\n\nClaude will:\n1. Route correctly: TypeScript/JavaScript SDK → praisonai-js; agent tools → PraisonAI-Tools; lifecycle plugins → PraisonAI-Plugins\n2. Search codebase for root cause\n3. Implement a minimal fix following AGENTS.md\n4. Create a PR with the changes (external repo PRs link back to this issue)'
});
+1 -1
View File
@@ -289,7 +289,7 @@ jobs:
1. Read AGENTS.md and the PR diff on the current branch.
2. Read all PR comments and reviews (FINAL architecture reviewer / Lead Engineer must have run).
3. Confirm SDK value gate: change strengthens praisonaiagents (no scope creep). The aim is a LIGHTWEIGHT AND POWERFUL package — BLOCK changes that add params/modules/exports duplicating existing Agent capabilities (instructions/backstory/tools/hooks/memory), or add knobs/fields with no live consumer, i.e. scope creep for the sake of it.
4. Repo routing: BLOCK if agent-callable tools (→ PraisonAI-Tools), lifecycle plugins (→ PraisonAI-Plugins), or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
4. Repo routing: BLOCK if TypeScript/JavaScript SDK changes in src/praisonai-ts/ (→ praisonai-js + sync workflow); agent-callable tools (→ PraisonAI-Tools); lifecycle plugins (→ PraisonAI-Plugins); or optional sandbox backends (→ PraisonAI-Plugins via `praisonai.sandbox`) were added to praisonaiagents/ instead of the external repo.
5. Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
6. If agent.py adds >100 lines or new Agent __init__ params, respond MERGE_GATE_VERDICT: BLOCK (manual review required).
7. BLOCK if labels security/breaking-change/needs-manual-review/release, sensitive paths (.github/workflows, auth, pyproject.toml), secrets in diff, PR >800 lines or >30 files, or SDK changes without tests. Label `merge-gate-ci-only` exempts CI-only changes under `.github/workflows/`, `.github/actions/`, or merge-gate scripts.
+22 -5
View File
@@ -70,9 +70,21 @@ jobs:
if (content.includes('documentation') || content.includes('docs')) {
labels.push('documentation');
}
if (content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
const isPraisonaiJsFocus =
content.includes('praisonai-js') ||
content.includes('praisonai-ts') ||
content.includes('src/praisonai-ts') ||
content.includes('typescript sdk') ||
content.includes('javascript sdk') ||
(content.includes('typescript') && content.includes('praisonai')) ||
(content.includes('javascript') && content.includes('praisonai')) ||
(content.includes('npm') && content.includes('praisonai') && !content.includes('pypi'));
if (isPraisonaiJsFocus || content.includes('typescript') || content.includes('javascript') || content.includes('npm')) {
labels.push('javascript');
}
if (isPraisonaiJsFocus) {
labels.push('praisonai-js');
}
// Apply labels
if (labels.length > 0) {
@@ -100,7 +112,7 @@ jobs:
'',
'_Bug and enhancement reports are auto-triaged with Claude. Questions and other issues: a maintainer can comment `@claude` to trigger analysis._',
'',
'**Routing:** Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins).'
'**Routing:** TypeScript/JavaScript SDK → [praisonai-js](https://github.com/MervinPraison/praisonai-js) (canonical; monorepo `src/praisonai-ts/` is synced from there). Agent-callable tools → [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools); lifecycle plugins (tracing/hooks/guardrails) → [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins).'
].join('\n')
});
}
@@ -293,7 +305,7 @@ jobs:
STEP 1 — READ GUIDELINES:
Read AGENTS.md to understand the architecture rules.
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify or touch praisonai-rust or praisonai-ts packages.
SCOPE: Focus ONLY on Python packages (praisonaiagents, praisonai). Do NOT modify praisonai-rust or src/praisonai-ts in this repo — TypeScript/JavaScript SDK work belongs in MervinPraison/praisonai-js (STEP 3-ALT).
AIM: This package must stay LIGHTWEIGHT AND POWERFUL. Do a CRITICAL REVIEW at each stage (issue triage, fix planning, PR review/implementation) and reject scope creep for the sake of adding features. Prefer existing Agent capabilities (instructions/backstory/tools/hooks/memory) over new params/modules/exports. Even when fixing a raised issue, keep the fix minimal and lightweight — do NOT expand the surface area or add knobs with no live consumer.
STEP 2 — ARCHITECTURE VALIDATION & ROUTING (MANDATORY before writing code):
@@ -305,7 +317,8 @@ jobs:
4. Documentation (PraisonAIDocs): Documentation pages, guides, API references.
5. Plugins → MervinPraison/PraisonAI-Plugins: framework lifecycle extensions (tracing, logging, metrics, hooks, guardrails). Not agent tools — use STEP 3-ALT.
6. Sandbox backends (optional/niche, e.g. Capsule) → PraisonAI-Plugins via `praisonai.sandbox` entry point; core keeps protocol/config/manager only.
For items routed to an EXTERNAL repository (Tools, Docs, Plugins), follow STEP 3-ALT below.
7. TypeScript/JavaScript SDK → MervinPraison/praisonai-js: canonical npm SDK repo. Do NOT implement in src/praisonai-ts/ here — clone praisonai-js and use STEP 3-ALT. After merge there, run **Sync to PraisonAI Monorepo** workflow to update src/praisonai-ts/.
For items routed to an EXTERNAL repository (Tools, Docs, Plugins, praisonai-js), follow STEP 3-ALT below.
- Does it duplicate existing functionality? Check if Agent already supports this via existing params (reflection, planning, tools, hooks, memory). If it does, reject the change as scope creep and prefer the existing capability — the aim is lightweight and powerful, not more knobs.
- Do not bloat the Agent class with additional params — only if absolutely required; we already support many params.
- Does it inherit from Agent properly? New agent types MUST inherit Agent, not wrap it with composition.
@@ -318,7 +331,7 @@ jobs:
${{ steps.check_fork.outputs.is_pr == 'true' && (steps.check_fork.outputs.is_fork != 'true' || steps.check_fork.outputs.can_push_fork == 'true') && steps.check_fork.outputs.merge_state != 'DIRTY' && format('STEP 3 — IMPLEMENT ON EXISTING PR (branch: {0}, PR #{1}{2}):\n- DO NOT create a new branch or PR\n- git checkout {0}\n- Implement minimal, focused fixes from reviewer feedback\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description>"\n- Push: git push origin {0}\n- Comment a summary of exact files modified and what you skipped\n', steps.check_fork.outputs.pr_branch, steps.check_fork.outputs.pr_number, steps.check_fork.outputs.can_push_fork == 'true' && format(', fork: {0}', steps.check_fork.outputs.fork_repo) || '') || '' }}
${{ steps.check_fork.outputs.is_pr != 'true' && 'STEP 3 — IMPLEMENT (for changes in THIS repo — PraisonAI):\n- Create a fix branch: git checkout -b claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\n- Implement a minimal, focused fix\n- Follow protocol-driven design: protocols in core SDK, heavy implementations in wrapper\n- Keep changes small and backward-compatible\n- Commit: git add -A && git commit -m "fix: <description> (fixes #$ISSUE_NUMBER)"\n- Push: git push origin claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nCRITICAL: After pushing, you MUST immediately run: gh pr create --title "fix: <title>" --body "Fixes #$ISSUE_NUMBER" --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d)\nDo NOT stop after pushing. Do NOT say "manually create a PR". Always create the PR automatically.\n' || '' }}
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins):
STEP 3-ALT — IMPLEMENT IN EXTERNAL REPO (for PraisonAI-Tools, PraisonAIDocs, PraisonAI-Plugins, praisonai-js):
When work must happen in a different repository, follow these steps EXACTLY. Do NOT attempt to use `cd`, as directory state is not preserved. Use `git -C` for all commands.
a) Clone the repository:
gh repo clone MervinPraison/<REPO_NAME> /tmp/<REPO_NAME>
@@ -336,9 +349,11 @@ jobs:
gh pr create -R MervinPraison/<REPO_NAME> --head claude/issue-$ISSUE_NUMBER-$(date +%Y%m%d) --title "feat: <title>" --body "Fixes MervinPraison/PraisonAI#$ISSUE_NUMBER
<body>"
h) Comment on THIS PraisonAI issue with the external PR URL. For praisonai-js: note that after merge, a maintainer runs **Sync to PraisonAI Monorepo** on praisonai-js (Actions → sync workflow) to update src/praisonai-ts/.
STEP 4 — TEST:
- For SDK changes: cd src/praisonai-agents && PYTHONPATH=. python -m pytest tests/ -x -q --timeout=30
- For praisonai-js: cd /tmp/praisonai-js && npm install && npm run build && npm test
- For docs changes: verify files exist and are valid markdown/MDX
- Ensure no regressions
@@ -351,6 +366,8 @@ jobs:
Bash(conda:*)
Bash(pytest:*)
Bash(gh:*)
Bash(npm:*)
Bash(node:*)
Bash(python -m pytest:*)
Bash(python -m pip:*)
Bash(poetry:*)
+183
View File
@@ -0,0 +1,183 @@
# Build the desktop app for macOS, Windows and Linux, and attach it to the
# release.
#
# Releases carried no assets at all -- v4.7.1 shipped zero -- so there was
# nothing for anyone to download. The macOS half of this shipped first; this
# adds the other two, which were not merely missing from CI but impossible:
# the bundle targets were `["app", "dmg"]`, both macOS-only, so a Windows or
# Linux build produced zero artifacts and still exited zero.
#
# Unsigned, deliberately, on every platform. macOS signing needs a Developer ID
# and Windows signing now needs a hardware-backed certificate or a hosted
# signing service -- neither is in secrets. Shipping builds people can open
# past the warning is better than shipping none, and the release notes say so
# rather than letting Gatekeeper and SmartScreen say it for us.
name: Desktop release
on:
release:
types: [published]
workflow_dispatch:
inputs:
tag:
description: 'Existing release tag to attach the build to'
required: true
permissions:
contents: write
concurrency:
# Queue rather than cancel: a cancelled release build looks identical to a
# release that simply never produced assets, which is the bug being fixed.
group: desktop-release-${{ github.event.release.tag_name || inputs.tag }}
cancel-in-progress: false
jobs:
build:
strategy:
fail-fast: false
matrix:
include:
# macos-15, not macos-14: that image entered deprecation on
# 2026-07-06 and is fully unsupported from 2026-11-02, with brownout
# periods failing jobs before then. This is the only Apple-silicon
# leg, so losing it means no arm64 DMG at all.
- runner: macos-15 # Apple silicon
target: aarch64-apple-darwin
bundles: dmg
ext: dmg
asset: macos-apple-silicon
# macos-15-intel, not macos-13: that image was retired on
# 2025-12-04, so the leg would never schedule and the verify job
# below would correctly report a missing asset on every release,
# forever. macos-15-intel is GitHub's named x86_64 replacement.
- runner: macos-15-intel # Intel
target: x86_64-apple-darwin
bundles: dmg
ext: dmg
asset: macos-intel
- runner: windows-latest
target: x86_64-pc-windows-msvc
bundles: nsis
ext: exe
asset: windows-x64-setup
# ubuntu-22.04 rather than -latest on purpose: glibc is forward
# compatible only, so the build host sets the *oldest* distribution
# the .deb can run on. Building on 24.04 would produce a package that
# refuses to start on 22.04 and Debian 12 with a "GLIBC_2.38 not
# found" that no runtime flag can fix.
#
# This image begins deprecation on 2026-09-17 and is retired on
# 2027-04-17. When it goes, move to `ubuntu-latest` with
# `container: debian:12` rather than bumping the runner -- that keeps
# the glibc floor decided by us instead of by GitHub's image
# lifecycle.
- runner: ubuntu-22.04
target: x86_64-unknown-linux-gnu
bundles: deb
ext: deb
asset: linux-x64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v4
- name: Install the Linux build dependencies
if: runner.os == 'Linux'
run: |
set -euo pipefail
# Retried: a degraded mirror otherwise burns the whole job and
# reports "cancelled", which reads as our failure rather than apt's.
for attempt in 1 2 3; do
sudo apt-get update && break || sleep 15
done
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev \
libappindicator3-dev \
librsvg2-dev \
libxdo-dev \
libssl-dev \
patchelf \
xdg-utils
# libappindicator3-dev, not the ayatana package: the .deb's generated
# Depends: follows whichever one the binary linked against, so the
# two must not be mixed between builds.
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: swatinem/rust-cache@v2
with:
workspaces: src/praisonai-desktop/src-tauri -> target
# Saving the Windows target directory costs more minutes than the
# rebuild it saves.
save-if: ${{ runner.os != 'Windows' }}
- name: Install the Tauri CLI
shell: bash
# `|| true` swallowed a genuine install failure and the next step then
# died with an unrelated "no such command: tauri". It also behaved
# differently per leg, since Windows defaults to pwsh where `true` is
# not a command. Skip the install if it is already there; otherwise
# let a failure be a failure.
run: command -v cargo-tauri >/dev/null || cargo install tauri-cli --version '^2' --locked
- name: Build the bundle
working-directory: src/praisonai-desktop/src-tauri
run: cargo tauri build --target ${{ matrix.target }} --bundles ${{ matrix.bundles }}
- name: Name the artifact for the platform it runs on
id: artifact
shell: bash
working-directory: src/praisonai-desktop/src-tauri
run: |
set -euo pipefail
tag="${{ github.event.release.tag_name || inputs.tag }}"
root="target/${{ matrix.target }}/release/bundle"
found="$(find "$root" -name "*.${{ matrix.ext }}" -print -quit || true)"
# Fail loudly rather than uploading nothing: an empty release asset
# list is exactly the state this workflow exists to fix, and a bundler
# given a target it does not support exits zero having written none.
if [ -z "$found" ]; then
echo "no .${{ matrix.ext }} was produced under $root"
find "$root" -type f -print || echo "(the bundle directory does not exist)"
exit 1
fi
out="PraisonAI-${tag}-${{ matrix.asset }}.${{ matrix.ext }}"
mv "$found" "../$out"
echo "path=src/praisonai-desktop/$out" >> "$GITHUB_OUTPUT"
- name: Attach it to the release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release upload \
"${{ github.event.release.tag_name || inputs.tag }}" \
"${{ steps.artifact.outputs.path }}" --clobber
verify:
# A release with a missing platform is worse than an obviously failed
# build, because nobody notices. This says which one is absent.
needs: build
if: always()
runs-on: ubuntu-latest
steps:
- name: Check every platform produced an asset
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.event.release.tag_name || inputs.tag }}
run: |
set -euo pipefail
assets="$(gh release view "$TAG" --repo "$GITHUB_REPOSITORY" \
--json assets -q '.assets[].name')"
echo "$assets"
missing=""
for want in macos-apple-silicon macos-intel windows-x64-setup linux-x64; do
echo "$assets" | grep -q -- "$want" || missing="$missing $want"
done
if [ -n "$missing" ]; then
echo "::error::the release is missing:$missing"
exit 1
fi
echo "all four platform builds are attached"
+115
View File
@@ -0,0 +1,115 @@
# The desktop app's tests.
#
# These existed and ran nowhere: no workflow referenced praisonai-desktop, and
# the frontend suite could not even be installed -- jsdom was undeclared, so it
# ran only on machines that happened to have one further up the tree, at
# whatever version that happened to be.
name: desktop
on:
push:
branches: [main]
paths: ['src/praisonai-desktop/**', '.github/workflows/desktop.yml',
'.github/workflows/desktop-release.yml']
pull_request:
paths: ['src/praisonai-desktop/**', '.github/workflows/desktop.yml',
'.github/workflows/desktop-release.yml']
concurrency:
group: desktop-${{ github.ref }}
cancel-in-progress: true
defaults:
run:
working-directory: src/praisonai-desktop
jobs:
engine:
name: engine (${{ matrix.os }})
# The engine is the part that has to behave the same on all three, which is
# the entire point of the portability suite -- so it is run on all three.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run the engine tests
shell: bash
env:
# Never touch a real keychain on a shared runner.
PRAISONAI_KEYCHAIN_SERVICE: ai.praison.desktop.ci
run: |
set -euo pipefail
failed=0
for suite in engine/test_*.py; do
echo "::group::$suite"
python "$suite" -v || failed=1
echo "::endgroup::"
done
exit $failed
frontend:
name: frontend
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
- run: npm ci
# `test:ci` is every suite except lifecycle, which drives real
# conversations against a live engine and a real model -- neither of
# which exists on a runner. It is excluded by name rather than left to
# skip quietly, so that a skipped test here is a genuine problem.
- name: Run the UI tests
# A suite that skips everything still exits 0 -- that is how thirteen
# tests came to look like a pass while proving nothing. So the skip
# count is asserted, not just the exit status.
run: |
set -euo pipefail
set -o pipefail
npm run test:ci 2>&1 | tee "$RUNNER_TEMP/ui.log"
skipped=$(grep -E '^. skipped ' "$RUNNER_TEMP/ui.log" | grep -oE '[0-9]+$' | head -1)
echo "skipped: ${skipped:-unknown}"
test "${skipped:-1}" -eq 0
shell:
name: shell (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-22.04, macos-15, windows-latest]
defaults:
run:
working-directory: src/praisonai-desktop/src-tauri
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
with:
workspaces: src/praisonai-desktop/src-tauri
# The Windows cache is large and slow enough to cost more than it saves.
save-if: ${{ runner.os != 'Windows' }}
- name: Install the Linux build dependencies
if: runner.os == 'Linux'
working-directory: .
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev \
libxdo-dev libssl-dev patchelf
- name: Test
# Debug info is the bulk of a Tauri target directory and nothing here
# reads a backtrace, so building without it keeps the runner's disk
# from filling on the Windows leg.
run: cargo test --config 'profile.test.debug=false' --config 'profile.dev.debug=false'
- name: Clippy
run: cargo clippy --all-targets -- -D warnings
+2 -1
View File
@@ -101,8 +101,9 @@ jobs:
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
- shard: train
workdir: src/praisonai-train
# tests/unit/data/ (96 tests) was collected by no workflow at all.
paths: >-
tests/unit/train/
tests/unit/train/ tests/unit/data/
extra_ignore: ""
pythonpath: >-
${{ github.workspace }}/src/praisonai-agents:${{ github.workspace }}/src/praisonai-code
+13
View File
@@ -7,8 +7,21 @@ Thank you for your interest! Here's how to get started:
3. Make your changes
4. Submit a pull request
## Where to report issues (hub intake)
| Topic | Report here | Where fixes land |
|-------|-------------|------------------|
| Python SDK, CLI, wrapper | **This repo** ([PraisonAI](https://github.com/MervinPraison/PraisonAI)) | `src/praisonai-agents/`, `src/praisonai/` |
| TypeScript / JavaScript SDK (`npm install praisonai`) | **This repo** (preferred) or [praisonai-js](https://github.com/MervinPraison/praisonai-js) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) → synced to `src/praisonai-ts/` |
| Cross-language parity | **This repo** | Often both Python and praisonai-js PRs |
| Agent-callable tools | **This repo** | [PraisonAI-Tools](https://github.com/MervinPraison/PraisonAI-Tools) |
| Lifecycle plugins (tracing, hooks, guardrails) | **This repo** | [PraisonAI-Plugins](https://github.com/MervinPraison/PraisonAI-Plugins) |
You do **not** need to re-file TypeScript issues on praisonai-js — report on PraisonAI and automation routes fixes to the canonical TS repo.
## Guidelines
- Follow existing code style
- Add tests for new features
- Update documentation as needed
- TypeScript changes: implement in [praisonai-js](https://github.com/MervinPraison/praisonai-js), then run **Sync to PraisonAI Monorepo** workflow to update `src/praisonai-ts/`
+3 -1
View File
@@ -8,7 +8,9 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|---------|----------|-------------|
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | [praisonai-js](https://github.com/MervinPraison/praisonai-js) (canonical); mirrored at `src/praisonai-ts/` |
Report npm/TypeScript vulnerabilities on **PraisonAI** (hub intake) or [praisonai-js](https://github.com/MervinPraison/praisonai-js). Fixes land in praisonai-js first.
Report issues against the **specific package** where the vulnerability exists. A platform-only issue does not necessarily affect the core SDK, and vice versa.
+3
View File
@@ -240,10 +240,12 @@ from praisonaiagents import Memory, MemoryBackend, MemoryConfig
Methods:
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">acommit_memory_batch</a>(writes: List[tuple]) -> None</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_cache_optimized_context</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_cache_boundary: bool = True, include_in_output: Optional[bool] = None) -> Dict[str, str]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">build_context_for_task</a>(task_descr: str, user_id: Optional[str] = None, additional: str = '', max_items: int = 3, include_in_output: Optional[bool] = None) -> str</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">calculate_quality_metrics</a>(output: str, expected_output: str, llm: Optional[str] = None, custom_prompt: Optional[str] = None) -> Dict[str, float]</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">close_connections</a>()</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">commit_memory_batch</a>(writes: List[tuple]) -> None</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">compute_quality_score</a>(completeness: float, relevance: float, clarity: float, accuracy: float, weights: Dict[str, float] = None) -> float</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_long_term</a>(memory_id: str) -> bool</code>
* <code title="class Memory">Memory.<a href="./src/praisonai-agents/praisonaiagents/memory/memory.py">delete_memories</a>(memory_ids: List[str]) -> int</code>
@@ -253,6 +255,7 @@ Methods:
* <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_context</a>(query: Optional[str] = None, **kwargs) -> str</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>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.0" \
"praisonai>=4.7.1" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.0" \
"praisonai>=4.7.1" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.7.0" \
"praisonai>=4.7.1" \
"praisonai[ui]" \
"praisonai[crewai]"
+5 -5
View File
@@ -178,19 +178,19 @@ def example_url_parsing():
print("\n=== Example 9: URL Parsing ===")
# Memory supports URL connection strings
# Supported: postgresql://, redis://, sqlite:///, mongodb://
# Supported schemes: sqlite://, mongodb://, mongodb+srv://
agent = Agent(
instructions="You are a database-backed assistant",
memory="postgresql://postgres:password@localhost:5432/praisonai",
memory="mongodb://user:password@localhost:27017/praisonai",
)
print("Memory URL parsed: memory enabled")
# Also works with presets
agent2 = Agent(
instructions="You are a redis-backed assistant",
memory="redis", # Preset name
instructions="You are a sqlite-backed assistant",
memory="sqlite", # Preset name
)
print("Memory preset 'redis': configured")
print("Memory preset 'sqlite': configured")
def example_knowledge_sources():
+4 -4
View File
@@ -8,15 +8,15 @@ agent = Agent(
)
# With preset
agent_redis = Agent(
agent_sqlite = Agent(
instructions="You are a helpful assistant.",
memory="redis", # Uses redis preset
memory="sqlite", # Uses sqlite preset
)
# With URL
agent_postgres = Agent(
agent_mongo = Agent(
instructions="You are a helpful assistant.",
memory="postgresql://localhost/mydb", # URL auto-detected
memory="mongodb://localhost/mydb", # URL auto-detected
)
if __name__ == "__main__":
+43
View File
@@ -1,5 +1,32 @@
# MCP Server Examples
## Install (SHA-256)
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
```
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
```
```bash
VER=v0.6.0
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
# swap darwin-arm64 for your OS/arch
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
shasum -a 256 -c SHA256SUMS --ignore-missing
gbr-agent pair && gbr-agent run
```
Examples demonstrating the MCP Server v2 features per MCP Protocol Version 2025-11-25.
## Features Demonstrated
@@ -130,3 +157,19 @@ These examples comply with MCP Protocol Version 2025-11-25:
- `destructiveHint`: true
- `idempotentHint`: false
- `openWorldHint`: true
## Build Remote Agent (`gbr_pair.py`)
Pair a phone running [Build Remote Agent](https://grokbuildremote.com/) to a PraisonAI agent via MCP stdio `gbr-mcp` (protocol `gbr/1`). Requires `gbr-agent pair && gbr-agent run`. Phone is spectator. Never put mailbox keys in the example.
```bash
python gbr_pair.py
```
## What the phone sees
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
https://grokbuildremote.com/integrations.html
+76
View File
@@ -0,0 +1,76 @@
"""
Build Remote Agent pairing example.
Pair a phone (Build Remote Agent) to this PraisonAI session through the
free MIT gbr-agent. Protocol gbr/1. Phone is spectator + veto.
Requirements:
- gbr-agent v0.6.0+ (`gbr-agent pair && gbr-agent run`)
- GrokBuildRemote-Agents cloned; `npm install` in mcp/gbr-mcp
- pip install praisonaiagents[mcp]
Usage:
python gbr_pair.py
Never put mailbox keys in this file. Phone Settings → Bot API is the
only place the relay key is copied.
Independent product by Linespotting AB. Not affiliated with xAI or SpaceX.
https://grokbuildremote.com/
"""
from praisonaiagents import Agent, MCP
# Clone https://github.com/LinespottingOrg/GrokBuildRemote-Agents.git
## Install (SHA-256)
Pin GitHub Release **v0.6.0** and verify `SHA256SUMS`. Website `install.sh` / `install.ps1` abort on mismatch.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/tag/v0.6.0
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/PINNED-INSTALL.md
```
96cef605d3e030ccef99d27ea6240e0d3b668dd045e6b5b9e585c9fd03c6ef23 gbr-agent-darwin-amd64
de7e065ef2cf6877b3b2cd04679a67b627f876337f529247e236204543e4062c gbr-agent-darwin-arm64
a50a5c41993e6531a3b477eb409ccc845212bf541384dc803061c80657f86719 gbr-agent-linux-amd64
5bfd22c7110234942c4c02ff8154b836d0af45a9422c178a4f52010187d40061 gbr-agent-linux-arm64
f773b89fd31310172b756e0593e0f3b2382b0a3440af2a7d0a8b3073b0c23e27 gbr-agent-windows-amd64.exe
8fb9efcbc7e2ac91c11964944bf0f45e31bb23f4356d9dcb4b305d7cb9b0fe8c gbr-agent-windows-arm64.exe
```
```bash
VER=v0.6.0
BASE=https://github.com/LinespottingOrg/GrokBuildRemote-Agents/releases/download/$VER
# swap darwin-arm64 for your OS/arch
curl -fsSL -o gbr-agent-darwin-arm64 "$BASE/gbr-agent-darwin-arm64"
curl -fsSL -o SHA256SUMS "$BASE/SHA256SUMS"
shasum -a 256 -c SHA256SUMS --ignore-missing
gbr-agent pair && gbr-agent run
```
# then point args at mcp/gbr-mcp/bin/gbr-mcp.js (loopback stdio).
gbr = Agent(
instructions=(
"You can attach a phone spectator via Build Remote Agent. "
"Use GBR tools only to diagnose or attach the local Bot API "
"at 127.0.0.1:8788. Do not request mailbox keys."
),
llm="gpt-4o-mini",
tools=MCP(
"node",
args=["GrokBuildRemote-Agents/mcp/gbr-mcp/bin/gbr-mcp.js"],
),
)
if __name__ == "__main__":
gbr.start(
"Check Build Remote Agent health on 127.0.0.1:8788 and list sessions."
)
## What the phone sees
**Terminal windows** on this PC (machine-wide mailbox). Not headless OpenCode / CodeNomad sidecar / Electron. `:8788` in a sidecar is Bot API JSON, not a transcript.
https://github.com/LinespottingOrg/GrokBuildRemote-Agents/blob/main/docs/WHAT-THE-PHONE-SEES.md
https://grokbuildremote.com/integrations.html
@@ -0,0 +1,110 @@
"""
HOL Guard Tool Policy Example - PraisonAI Agents
Demonstrates using HOL Guard (an optional, external runtime security engine)
at the existing `wrap_tool_call` middleware boundary to inspect command-bearing
tool calls *before* they execute.
HOL Guard is installed and versioned separately. Its side-effect-free CLI can
classify a shell command before execution:
hol-guard command test '<command>' --json
Fail-closed policy: the wrapped tool only runs when Guard returns an explicit
allow/benign classification. Review/block/unknown verdicts, malformed output,
timeouts, or CLI failures all short-circuit the call so the tool never runs.
pip install hol-guard # optional, external dependency
"""
import json
import shutil
import subprocess
from praisonaiagents import Agent, tool
from praisonaiagents.hooks import wrap_tool_call, ToolRequest, ToolResponse
COMMAND_BEARING_TOOLS = {"run_shell"}
GUARD_TIMEOUT_SECONDS = 10
@tool
def run_shell(command: str) -> str:
"""Run a shell command and return its output."""
result = subprocess.run(
command, shell=True, capture_output=True, text=True
)
return result.stdout or result.stderr
def _inspect_with_hol_guard(command: str) -> bool:
"""Return True only if HOL Guard explicitly allows the command.
Fails closed (returns False) on any ambiguity: missing CLI, non-zero exit,
timeout, malformed JSON, or a non-allow verdict.
"""
if not shutil.which("hol-guard"):
return False
try:
completed = subprocess.run(
["hol-guard", "command", "test", command, "--json"],
capture_output=True,
text=True,
timeout=GUARD_TIMEOUT_SECONDS,
)
except (subprocess.TimeoutExpired, OSError):
return False
if completed.returncode != 0:
return False
try:
verdict = json.loads(completed.stdout)
except (json.JSONDecodeError, ValueError):
return False
if not isinstance(verdict, dict):
return False
decision = str(verdict.get("decision", "")).lower()
return decision in ("allow", "benign")
@wrap_tool_call
def hol_guard_tool_policy(request: ToolRequest, call_next):
"""Gate command-bearing tools through HOL Guard before execution."""
if request.tool_name not in COMMAND_BEARING_TOOLS:
return call_next(request)
command = request.arguments.get("command", "")
if _inspect_with_hol_guard(command):
return call_next(request)
blocked_message = "Blocked by HOL Guard: command was not explicitly allowed."
# Surface the denial in `result` too: the agent tool executor returns
# `ToolResponse.result` to the model and discards `error`, so a denial with
# only `error` set would reach the model as a null result. Keeping `error`
# populated preserves the signal for after_tool hooks and logging.
return ToolResponse(
tool_name=request.tool_name,
result=blocked_message,
error=blocked_message,
context=request.context,
)
agent = Agent(
name="GuardedShellBot",
instructions="You run shell commands only when they are safe.",
tools=[run_shell],
hooks=[hol_guard_tool_policy],
)
if __name__ == "__main__":
print("HOL Guard tool policy registered on 'run_shell'.")
print("Commands are only executed when Guard returns an explicit allow.")
print("\n✓ HOL Guard example complete")
@@ -1,16 +1,18 @@
"""
Qdrant Knowledge Store - Agent-First Example
Chroma Knowledge Store - Agent-First Example
Demonstrates using Qdrant for knowledge-based RAG with an Agent.
Demonstrates using Chroma (a shipped vector store) for knowledge-based RAG
with an Agent.
To use a provider that is not shipped (e.g. Qdrant), register an adapter
first with ``register_knowledge_adapter("qdrant", MyQdrantAdapter)`` and then
pass ``"provider": "qdrant"``.
Requirements:
pip install "praisonai[tools]"
Docker Setup:
docker run -d --name praison-qdrant -p 6333:6333 qdrant/qdrant
Run:
python knowledge_qdrant.py
python knowledge_chroma.py
Expected Output:
Agent responds using knowledge from documents
@@ -44,17 +46,16 @@ doc_path = os.path.join(temp_dir, "guide.txt")
with open(doc_path, "w") as f:
f.write(sample_doc)
print("=== Qdrant Knowledge Store Demo (Agent-First) ===")
print("=== Chroma Knowledge Store Demo (Agent-First) ===")
# Agent-first approach: use knowledge parameter with Qdrant
# Agent-first approach: use knowledge parameter with a shipped vector store
agent = Agent(
name="KnowledgeAssistant",
instructions="You are a helpful assistant with access to technical documentation.",
knowledge={
"sources": [doc_path],
"vector_store": {
"provider": "qdrant",
"url": "http://localhost:6333",
"provider": "chroma",
},
}
)
@@ -70,4 +71,4 @@ print("\n=== Demo Complete ===")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence.factory import create_knowledge_store
# store = create_knowledge_store("qdrant", url="http://localhost:6333")
# store = create_knowledge_store("chroma")
@@ -0,0 +1,34 @@
"""
Basic example of using EvoLink.AI with PraisonAI.
"""
import os
from praisonaiagents import Agent
api_key = os.getenv("EVOLINK_API_KEY")
if not api_key:
raise RuntimeError(
"EVOLINK_API_KEY is not set. Set it before running this example: "
"export EVOLINK_API_KEY=your_api_key"
)
agent = Agent(
instructions="You are a helpful assistant",
llm={
"model": "openai/gpt-5.2",
"api_key": api_key,
"base_url": "https://direct.evolink.ai/v1",
},
)
response = agent.start("Hello! Share one practical AI automation idea.")
print(response)
coding_task = """
Write a Python function that validates whether a string is a palindrome.
Include a short docstring and ignore spaces and capitalization.
"""
response = agent.start(coding_task)
print(response)
+14 -10
View File
@@ -1,17 +1,25 @@
"""Qdrant Vector Store - Agent-First Example (requires Docker)
"""Qdrant Vector Store - Agent-First Example
Docker: docker run -d --name qdrant -p 6333:6333 qdrant/qdrant
Qdrant is not a shipped adapter. Register one first, then use it via the
``knowledge`` parameter. If no adapter is registered this example skips
cleanly instead of crashing.
from praisonaiagents.knowledge.adapters import register_knowledge_adapter
register_knowledge_adapter("qdrant", MyQdrantAdapter)
"""
import os
import sys
from praisonaiagents import Agent
from praisonaiagents.knowledge.adapters import list_knowledge_adapters
# Agent-first approach: use knowledge parameter with Qdrant
url = os.getenv("QDRANT_URL", "http://localhost:6333")
if "qdrant" not in list_knowledge_adapters():
print("SKIPPED: Qdrant - no 'qdrant' adapter registered")
sys.exit(0)
# Agent-first approach: use knowledge parameter with a registered Qdrant adapter
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant with access to documents.",
knowledge={"sources": ["./docs/guide.pdf"], "vector_store": {"provider": "qdrant", "url": url}}
knowledge={"sources": ["./docs/guide.pdf"], "vector_store": {"provider": "qdrant"}}
)
# Chat - agent uses knowledge for RAG
@@ -19,7 +27,3 @@ response = agent.chat("What information do you have?")
print(f"Response: {response}")
print("PASSED: Qdrant with Agent")
# --- Advanced: Direct Store Usage ---
# from praisonai.persistence import create_knowledge_store
# store = create_knowledge_store("qdrant", url=url)
+33 -33
View File
@@ -18,45 +18,45 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 22 | 53 | 16965 |
| ✅ Agent | 22 | 53 | 17107 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 5 | 2658 |
| ✅ Approval | 1 | 5 | 2764 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 11 | 3542 |
| ✅ Autonomy | 3 | 7 | 2763 |
| ✅ Bots | 7 | 31 | 13403 |
| ✅ Autonomy | 3 | 7 | 2790 |
| ✅ Bots | 7 | 32 | 13616 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 122 | 36654 |
| ✅ CLI | 5 | 122 | 36991 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4851 |
| ✅ Conditions | 1 | 3 | 1321 |
| ✅ Configuration | 3 | 8 | 3140 |
| ✅ Context Management | 16 | 36 | 13890 |
| ✅ Code Execution | 2 | 14 | 4855 |
| ✅ Conditions | 1 | 3 | 1347 |
| ✅ Configuration | 3 | 8 | 3173 |
| ✅ Context Management | 16 | 36 | 13988 |
| ✅ Deep Research | 8 | 2 | 587 |
| ✅ Display | 6 | 3 | 741 |
| ✅ Embeddings | 6 | 23 | 2049 |
| ✅ Evaluation | 1 | 7 | 2905 |
| ✅ Evaluation | 1 | 7 | 2920 |
| ✅ Events | 1 | 2 | 749 |
| ✅ Execution | 3 | 4 | 1586 |
| ✅ Failover | 2 | 1 | 432 |
| ✅ Execution | 3 | 4 | 1606 |
| ✅ Failover | 2 | 1 | 452 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 78 | 28577 |
| ✅ Guardrails | 4 | 4 | 2205 |
| ✅ Handoffs | 11 | 6 | 2567 |
| ✅ Hooks | 2 | 9 | 4488 |
| ✅ Gateway | 7 | 82 | 30269 |
| ✅ Guardrails | 4 | 4 | 2228 |
| ✅ Handoffs | 11 | 6 | 2626 |
| ✅ Hooks | 2 | 9 | 4509 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 16 | 4942 |
| ✅ LLM | 3 | 15 | 5296 |
| ✅ Knowledge | 4 | 16 | 4967 |
| ✅ LLM | 3 | 15 | 5372 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ MCP | 1 | 61 | 14135 |
| ✅ Memory | 6 | 18 | 7133 |
| ✅ MCP | 1 | 61 | 14160 |
| ✅ Memory | 6 | 18 | 7193 |
| ✅ OCR | 2 | 1 | 237 |
| ✅ Observability | 2 | 23 | 2814 |
| ✅ Observability | 2 | 23 | 2811 |
| ✅ Optimizer | 1 | 2 | 792 |
| ✅ Output | 3 | 5 | 1246 |
| ✅ Output | 3 | 5 | 1265 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1605 |
| ✅ Plugins | 8 | 5 | 3120 |
@@ -65,29 +65,29 @@ This report compares **Python SDK feature categories** against **Python document
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 683 |
| ✅ Retrieval | 2 | 5 | 1054 |
| ✅ Reflection | 3 | 3 | 730 |
| ✅ Retrieval | 2 | 5 | 1042 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 7 | 3483 |
| ✅ Security | 1 | 3 | 3415 |
| ✅ Sessions | 4 | 19 | 7576 |
| ✅ Sandbox | 5 | 7 | 3556 |
| ✅ Security | 1 | 3 | 3529 |
| ✅ Sessions | 4 | 19 | 7644 |
| ✅ Skills | 6 | 15 | 5638 |
| ✅ Tasks | 2 | 6 | 2819 |
| ✅ Telemetry | 1 | 2 | 604 |
| ✅ Tasks | 2 | 6 | 2897 |
| ✅ Telemetry | 1 | 2 | 648 |
| ✅ Templates | 1 | 8 | 1663 |
| ✅ Tools | 12 | 144 | 42732 |
| ✅ Tools | 12 | 144 | 43251 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1215 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 9 | 2484 |
| ✅ Workflows | 5 | 17 | 7049 |
| ✅ Web | 3 | 9 | 2487 |
| ✅ Workflows | 5 | 18 | 7406 |
## Documentation Without Features
These docs exist but don't match any implemented feature category:
- ℹ️ Database (42 docs, 6766 lines)
- ℹ️ Database (42 docs, 6965 lines)
- ℹ️ Documents (1 docs, 787 lines)
---
@@ -92,20 +92,6 @@ from ._lazy import lazy_import, create_lazy_getattr_with_fallback
# Thread-safe cache for lazy-loaded values
_lazy_cache = {}
# Backward compatibility: _get_lazy_cache function for tests
import threading
_lazy_cache_local = threading.local()
def _get_lazy_cache():
"""Get thread-local lazy cache dict. Thread-safe for concurrent access.
Note: This is kept for backward compatibility with tests.
The main lazy loading now uses the centralized _lazy.py utility.
"""
if not hasattr(_lazy_cache_local, 'cache'):
_lazy_cache_local.cache = {}
return _lazy_cache_local.cache
# ============================================================================
# LAZY IMPORT MAPPING
# ============================================================================
@@ -27,7 +27,7 @@ SUPPRESSED_PATTERNS = [
"The `dict` method is deprecated; use `model_dump` instead",
"Pydantic serializer warnings",
"PydanticSerializationUnexpectedValue",
"Expected", # Catches all "Expected N fields but got M" patterns
"fields but got", # litellm/pydantic "Expected N fields but got M"
"Expected `StreamingChoices` but got `Choices`",
"serialized value may not be as expected",
"Mixing V1 models and V2 models",
@@ -197,75 +197,6 @@ def _validate_preset_params(params):
validate_preset_string(name, value, _PRESET_STRING_PARAMS[name]())
class ServerRegistry:
"""Registry for API server state per-port."""
def __init__(self):
self._lock = threading.Lock()
self._server_started = {} # Dict of port -> started boolean
self._registered_agents = {} # Dict of port -> Dict of path -> agent_id
self._shared_apps = {} # Dict of port -> FastAPI app
# Class-level lock for thread-safe singleton creation
_instance_lock = threading.Lock()
@staticmethod
def get_default_instance():
"""Get default global registry for backward compatibility."""
if not hasattr(ServerRegistry, '_default_instance'):
# Double-checked locking pattern with shared class-level lock
with ServerRegistry._instance_lock:
if not hasattr(ServerRegistry, '_default_instance'):
ServerRegistry._default_instance = ServerRegistry()
return ServerRegistry._default_instance
def is_server_started(self, port: int) -> bool:
with self._lock:
return self._server_started.get(port, False)
def set_server_started(self, port: int, started: bool) -> None:
with self._lock:
self._server_started[port] = started
def get_shared_app(self, port: int):
with self._lock:
return self._shared_apps.get(port)
def set_shared_app(self, port: int, app) -> None:
with self._lock:
self._shared_apps[port] = app
def register_agent(self, port: int, path: str, agent_id: str) -> None:
with self._lock:
if port not in self._registered_agents:
self._registered_agents[port] = {}
self._registered_agents[port][path] = agent_id
def get_registered_agents(self, port: int) -> dict:
with self._lock:
return self._registered_agents.get(port, {}).copy()
def cleanup_agent_registrations(self, agent_id: str) -> None:
"""Remove all registrations for an agent ID and clean empty port state."""
with self._lock:
ports_to_clean = []
for port, path_dict in self._registered_agents.items():
paths_to_remove = [path for path, registered_id in path_dict.items() if registered_id == agent_id]
for path in paths_to_remove:
del path_dict[path]
if not path_dict:
ports_to_clean.append(port)
for port in ports_to_clean:
self._registered_agents.pop(port, None)
self._server_started.pop(port, None)
# Backward compatibility - use default instance
def _get_default_server_registry() -> ServerRegistry:
return ServerRegistry.get_default_instance()
# Don't import FastAPI dependencies here - use lazy loading instead
if TYPE_CHECKING:
@@ -870,9 +801,28 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
_cloned_fallback_models = legacy_kwargs.pop("fallback_models", None)
_unknown = set(legacy_kwargs) - _legacy_defaults.keys()
if _unknown:
# Unknown kwargs are rejected rather than swallowed, so a typo can
# never silently disable the behaviour it was meant to configure.
# Some names are near-universal in this ecosystem, though -- and
# `verbose` is accepted by six sibling classes here -- so name the
# supported alternative instead of leaving the user to search a
# 41-parameter signature for something that is not in it.
_HINTS = {
"verbose": (
"verbosity moved into output=; use output='verbose' "
"(or output=OutputConfig(verbose=True))."
),
"stream": (
"streaming moved into output=; use "
"output=OutputConfig(stream=True)."
),
}
_hint = "".join(
f"\n {name}: {_HINTS[name]}" for name in sorted(_unknown) if name in _HINTS
)
raise TypeError(
f"Agent.__init__() got unexpected keyword argument(s): "
f"{', '.join(sorted(_unknown))}"
f"{', '.join(sorted(_unknown))}{_hint}"
)
allow_delegation = legacy_kwargs.get("allow_delegation", _legacy_defaults["allow_delegation"])
allow_code_execution = legacy_kwargs.get("allow_code_execution", _legacy_defaults["allow_code_execution"])
@@ -1856,6 +1806,12 @@ class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, Unifie
elif isinstance(_guardrails_config, str):
# LLM validator prompt
guardrail = _guardrails_config
# Persist the resolved value so clone_for_channel() can forward it.
# Without this the attribute never exists, its getattr(..., None) in
# clone_for_channel() yields None, and every served clone runs with no
# output guardrail at all (one clone per gateway channel / HTTP call).
self._guardrails_config = _guardrails_config
# ─────────────────────────────────────────────────────────────────────
# Resolve WEB param - FAST PATH
@@ -2461,6 +2417,11 @@ Your Goal: {self.goal}
# ApprovalConfig = full control (all_tools, timeout, etc.)
# str = permission preset ("safe", "read_only", "full")
from ..approval.protocols import ApprovalConfig
# Persist the approval kwarg exactly as given: it round-trips through the
# constructor (str | bool | ApprovalConfig | dict | backend object).
# clone_for_channel() reads this; without it the attribute never exists
# and every served clone loses its approval policy.
self._approval_config = approval
self._perm_deny = frozenset() # Permission tier deny set (empty = no denials)
self._perm_allow = None # Permission tier allow set (None = allow all)
# Optional pattern-based PermissionManager (rules from
@@ -2563,6 +2524,17 @@ Your Goal: {self.goal}
# Store permissions if provided (for CI-safe declarative policies)
self._approval_permissions = getattr(approval, 'permissions', None)
self._permission_mode = getattr(approval, 'permission_mode', None)
# Configuring approval must never be a WEAKER posture than passing
# nothing. When no declarative ``permissions`` policy is supplied,
# fall back to the same env-driven default deny set the bare
# ``approval is None`` path uses, so e.g. ``ApprovalConfig(timeout=30)``
# keeps the default denials (execute_command, delete_file, …) instead
# of silently dropping all of them. ``is None`` (not falsiness) so an
# explicit empty policy (``permissions={}``) is still an intentional
# policy that owns denial — only a genuinely absent policy inherits
# the env-driven default deny set.
if self._approval_permissions is None:
self._perm_deny = self._resolve_default_deny_set()
elif isinstance(approval, dict):
# Dict config: convert to ApprovalConfig
approval_config = ApprovalConfig(**approval)
@@ -2571,6 +2543,12 @@ Your Goal: {self.goal}
self._approval_timeout = approval_config.timeout
self._approval_permissions = getattr(approval_config, 'permissions', None)
self._permission_mode = getattr(approval_config, 'permission_mode', None)
# See the ApprovalConfig branch above: never weaken the default
# posture just because a config object was passed. ``is None`` so an
# explicit empty policy (``approval={"permissions": {}}``) still owns
# denial rather than inheriting the default deny set.
if self._approval_permissions is None:
self._perm_deny = self._resolve_default_deny_set()
else:
# Plain backend object — dangerous tools only, backend default timeout
self._approval_backend = approval
@@ -5812,21 +5790,41 @@ Summary:"""
try:
from ..memory.memory import Memory
config = {"provider": _resolve_memory_adapter_name(memory)}
# Scope the store to this agent's isolation id (same id the
# FileMemory branches use) so two agents in one process/dir do
# not silently share one on-disk store when no user_id is given.
config.setdefault("user_id", mem_user_id)
config.setdefault("collection_name", f"memory_{mem_user_id}")
self._memory_instance = Memory(config)
except ImportError:
logging.warning(f"Memory provider '{memory}' requires additional dependencies. Falling back to FileMemory.")
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(user_id=mem_user_id)
except Exception as e:
# The backend package is installed but could not initialise
# (e.g. a SaaS backend like mem0 needs credentials/network).
# Honour the same "Falling back to FileMemory" contract used for
# missing dependencies so construction never hard-crashes.
logging.warning(f"Memory provider '{memory}' could not initialise ({e}). Falling back to FileMemory.")
from ..memory.file_memory import FileMemory
self._memory_instance = FileMemory(user_id=mem_user_id)
elif isinstance(memory, dict):
# Configuration dict
provider = memory.get("provider", memory.get("backend", "file"))
learn_enabled = memory.get("learn", False)
# Build a scoped config so an explicit user_id in the dict wins but a
# missing one falls back to this agent's isolation id (matching the
# FileMemory branches) instead of a process-wide shared store.
scoped_memory = dict(memory)
scoped_memory.setdefault("user_id", mem_user_id)
scoped_memory.setdefault("collection_name", f"memory_{scoped_memory['user_id']}")
# Use full Memory class if learn is enabled (requires LearnManager)
if learn_enabled:
try:
from ..memory.memory import Memory
self._memory_instance = Memory(memory)
self._memory_instance = Memory(scoped_memory)
except ImportError:
logging.warning("Memory with learn requires additional dependencies. Falling back to FileMemory (learn disabled).")
from ..memory.file_memory import FileMemory
@@ -5840,7 +5838,7 @@ Summary:"""
else:
try:
from ..memory.memory import Memory
self._memory_instance = Memory(memory)
self._memory_instance = Memory(scoped_memory)
except ImportError:
logging.warning("Full Memory class requires additional dependencies. Falling back to FileMemory.")
from ..memory.file_memory import FileMemory
@@ -6408,6 +6406,31 @@ Answer:"""
except Exception:
return False
@staticmethod
def _resolve_default_deny_set() -> frozenset:
"""Return the env-driven default permission deny set.
Mirrors the ``approval is None`` path: honour ``PRAISONAI_TOOL_SAFETY``
(``off``/``full``/``none``/``0``/``false`` → no denials), otherwise use
the named preset (defaulting to ``"default"``, which blocks destructive
ops like ``execute_command``/``delete_file``/``kill_process``/
``execute_code``). An unknown env value falls back to ``"default"``.
Shared by the ``ApprovalConfig``/``dict`` branches so configuring
approval never silently drops the default denials — passing a config
object must not be a weaker posture than passing nothing.
"""
_raw_safety_env = os.environ.get("PRAISONAI_TOOL_SAFETY")
_safety_env = (_raw_safety_env or "").strip().lower()
if _safety_env in ("off", "full", "none", "0", "false"):
return frozenset()
from ..approval.registry import PERMISSION_PRESETS
_resolved = _safety_env or "default"
_preset = PERMISSION_PRESETS.get(_resolved)
if _preset is None:
_preset = PERMISSION_PRESETS.get("default")
return _preset if _preset is not None else frozenset()
def _setup_guardrail(self):
"""Setup the guardrail function based on the provided guardrail parameter."""
# ``tool_execution._check_tool_policy_and_guardrails`` reads
@@ -6625,7 +6648,7 @@ Answer:"""
else:
return False, None, guardrail_result.error
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None, cancel_token=None):
"""Apply guardrail validation with retry logic (sync version)."""
retry_count = 0
current_response = response_text
@@ -6666,7 +6689,7 @@ Answer:"""
# Regenerate response for retry
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
if response and response.choices:
content = response.choices[0].message.content
current_response = content.strip() if content else ""
@@ -6841,8 +6864,12 @@ Answer:"""
if AgentRuntimeConfig and isinstance(runtime, AgentRuntimeConfig):
return runtime
# If already a RuntimeConfig instance, return as-is
# If already a RuntimeConfig instance, normalise its runtime name so a
# typo in preferred_runtime (e.g. "nativ") raises instead of silently
# dropping capabilities. resolve_runtime returns a normalised copy.
if RuntimeConfig and hasattr(RuntimeConfig, '__name__') and isinstance(runtime, RuntimeConfig):
if resolve_runtime:
return resolve_runtime(runtime)
return runtime
# Handle capability validation style (bool, RuntimeConfig)
@@ -6851,8 +6878,13 @@ Answer:"""
result = resolve_runtime(runtime)
if result is not None:
return result
except (TypeError, ValueError):
pass # Try AgentRuntimeConfig path
except ValueError:
# An invalid runtime *name* (e.g. a typo) must surface rather
# than falling through and being stored verbatim, which would
# silently drop most capabilities.
raise
except TypeError:
pass # Not this style; try AgentRuntimeConfig path
# Handle turn-based style (AgentRuntimeConfig)
if AgentRuntimeConfig:
@@ -6907,16 +6939,17 @@ Answer:"""
else:
raise TypeError(f"Invalid capability type: {type(cap)}")
# Determine runtime name
runtime_name = getattr(self._runtime_config, 'preferred_runtime', 'native')
# Determine runtime name. Normalise so spelling variants (NATIVE,
# " native ", native) map to the same matrix instead of silently
# dropping capabilities; an unknown name raises rather than degrading.
raw_runtime = getattr(self._runtime_config, 'preferred_runtime', None) or 'native'
from ..config.feature_configs import canonical_runtime_name
runtime_name = canonical_runtime_name(raw_runtime)
# Get runtime capabilities based on runtime type
if runtime_name == 'native':
runtime_matrix = get_native_runtime_capabilities()
elif runtime_name in ('plugin-harness', 'harness', 'plugin', 'reduced'):
runtime_matrix = get_reduced_harness_capabilities()
else:
# Unknown runtime, use reduced capabilities as safe default
runtime_matrix = get_reduced_harness_capabilities()
# Validate capabilities
@@ -7419,8 +7452,7 @@ Answer:"""
try:
# 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.
# state in execution_mixin.py).
from .execution_mixin import cleanup_launch_registration
cleanup_launch_registration(self._agent_id)
@@ -1211,7 +1211,15 @@ Your Goal: {self.goal}"""
return content
def _extract_llm_response_content(self, response) -> Optional[str]:
"""Return assistant message text, a tool-call summary, or str(response) as fallback."""
"""Return assistant message text, or a tool-call summary.
A well-formed response that simply carries no content -- a content filter,
a refusal, or ``finish_reason="length"`` with zero output tokens -- yields
the empty string. ``str(response)`` is reserved for responses this method
could not parse at all, where the repr is a diagnostic rather than an
answer; returning it for a merely empty message put the raw SDK object's
repr in front of the user and into ``chat_history``.
"""
if not response:
return None
try:
@@ -1226,6 +1234,17 @@ Your Goal: {self.goal}"""
if tool_calls:
names = [getattr(tc.function, "name", "?") for tc in tool_calls]
return f"[tool_calls: {', '.join(names)}]"
# Parsed cleanly; the model returned no content. A refusal
# (safety) is carried in ``message.refusal`` independently of
# ``finish_reason`` -- surface it so an empty answer isn't silent.
finish_reason = getattr(choice, "finish_reason", None)
refusal = getattr(msg, "refusal", None)
if refusal or finish_reason not in (None, "stop"):
logging.warning(
f"Agent {self.name}: model returned no content "
f"(finish_reason={finish_reason!r}, refused={bool(refusal)})"
)
return ""
except (AttributeError, IndexError, TypeError) as e:
logging.warning(
f"Failed to extract LLM response content (falling back to str): {e}"
@@ -1725,7 +1744,7 @@ Your Goal: {self.goal}"""
return max_retries
return 2
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0):
def _chat_completion(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0, _fallback_index=0, cancel_token=None):
start_time = time.time()
# --- Proactive Context Budget Management (default-on) ---
@@ -1830,6 +1849,7 @@ Your Goal: {self.goal}"""
# First attempt: try with streaming enabled for better user experience
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
streaming_response = self._chat_completion_with_retry(
cancel_token=cancel_token,
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -1849,6 +1869,11 @@ Your Goal: {self.goal}"""
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except ToolExecutionError:
# A tool failed during the streaming attempt. Re-raise so it is
# not relabelled as an LLM error and so the tool is not executed
# a second time by the non-streaming fallback below.
raise
except Exception as e:
from ..errors import LLMError
# Don't retry if it's an LLMError that has exhausted retries
@@ -1868,6 +1893,7 @@ Your Goal: {self.goal}"""
final_response = streaming_response
else:
final_response = self._chat_completion_with_retry(
cancel_token=cancel_token,
messages=messages,
temperature=temperature,
tools=formatted_tools,
@@ -1953,6 +1979,8 @@ Your Goal: {self.goal}"""
except BudgetExceededError:
raise
except ToolExecutionError:
raise
except Exception as e:
from ..errors import LLMError
@@ -2017,7 +2045,8 @@ Your Goal: {self.goal}"""
truncated_messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index
_fallback_index=_fallback_index,
cancel_token=cancel_token
)
except Exception as compression_error:
logging.error(f"[{self.name}] Context compression failed: {compression_error}")
@@ -2063,7 +2092,8 @@ Your Goal: {self.goal}"""
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index + 1
_fallback_index=_fallback_index + 1,
cancel_token=cancel_token
)
finally:
self.llm = original_llm
@@ -2080,7 +2110,8 @@ Your Goal: {self.goal}"""
messages, temperature, tools, stream,
reasoning_steps, task_name, task_description, task_id, response_format,
_retry_depth=_retry_depth + 1,
_fallback_index=_fallback_index
_fallback_index=_fallback_index,
cancel_token=cancel_token
)
# Include remediation hints for unimplemented recovery actions
@@ -2119,7 +2150,7 @@ Your Goal: {self.goal}"""
self,
exc: Exception,
messages,
temperature=1.0,
temperature=None,
tools=None,
stream=True,
reasoning_steps=False,
@@ -2309,7 +2340,7 @@ Your Goal: {self.goal}"""
def _execute_unified_chat_completion(
self,
messages,
temperature=1.0,
temperature=None,
tools=None,
stream=None,
reasoning_steps=False,
@@ -2319,6 +2350,7 @@ Your Goal: {self.goal}"""
response_format=None,
stream_callback=None,
emit_events=True,
cancel_token=None,
):
"""
Execute unified chat completion using composition instead of runtime class mutation.
@@ -2351,6 +2383,7 @@ Your Goal: {self.goal}"""
if stream_callback is None and hasattr(self, 'stream_emitter'):
stream_callback = getattr(self.stream_emitter, 'emit', None)
final_response = self._unified_dispatcher.chat_completion(
cancel_token=cancel_token,
messages=messages,
tools=tools,
tool_choice=getattr(self, 'tool_choice', None),
@@ -2396,6 +2429,7 @@ Your Goal: {self.goal}"""
if stream_callback is None and hasattr(self, 'stream_emitter'):
stream_callback = getattr(self.stream_emitter, 'emit', None)
final_response = self._unified_dispatcher.chat_completion(
cancel_token=cancel_token,
messages=messages,
tools=tools,
tool_choice=getattr(self, 'tool_choice', None),
@@ -2443,7 +2477,7 @@ Your Goal: {self.goal}"""
async def _execute_unified_achat_completion(
self,
messages,
temperature=1.0,
temperature=None,
tools=None,
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
reasoning_steps=False,
@@ -2781,7 +2815,11 @@ Your Goal: {self.goal}"""
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
model_limit = get_model_limit(model_name)
current_tokens = estimate_messages_tokens(optimized)
# Count the whole request (history + system prompt + tool schemas),
# otherwise a large prompt/tool block can push the call over the
# window while the history alone stays under the threshold.
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
current_tokens = estimate_messages_tokens(optimized) + overhead
# If over 95% of limit, apply emergency truncation
if current_tokens > model_limit * 0.95:
@@ -2789,20 +2827,67 @@ Your Goal: {self.goal}"""
f"[{self.name}] Context at {current_tokens} tokens (limit: {model_limit}), "
f"applying emergency truncation"
)
target = int(model_limit * 0.8) # Target 80% of limit
# Leave room for the fixed overhead so the full request lands
# near 80% of the window rather than the history alone.
target = max(1, int(model_limit * 0.8) - overhead)
optimized = self.context_manager.emergency_truncate(optimized, target)
result["emergency_truncated"] = True
result["tokens_after"] = estimate_messages_tokens(optimized)
result["tokens_after"] = estimate_messages_tokens(optimized) + overhead
except Exception as e:
logging.debug(f"Hard limit check skipped: {e}")
return optimized, result
except Exception as e:
# Context management should never break the chat flow
logging.warning(f"Context management error (continuing without): {e}")
# Context management should never break the chat flow, but silently
# returning the unchanged history disables the hard-limit truncation
# too and sends an over-budget request. Fall back to emergency
# truncation so we still fit within the model window.
logging.error(f"Context management failed: {e}")
try:
from ..context.budgeter import get_model_limit
from ..context.tokens import estimate_messages_tokens
model_name = self.llm if isinstance(self.llm, str) else "gpt-4o-mini"
model_limit = get_model_limit(model_name)
# Include the system prompt and tool schemas in the budget: the
# request can exceed the window on those alone even when the
# history is under the threshold.
overhead = self._estimate_request_overhead_tokens(system_prompt, tools)
if estimate_messages_tokens(messages) + overhead > model_limit * 0.95:
target = max(1, int(model_limit * 0.8) - overhead)
truncated = self.context_manager.emergency_truncate(messages, target)
return truncated, {"emergency_truncated": True}
except Exception as inner:
logging.debug(f"Emergency truncation fallback skipped: {inner}")
return messages, None
def _estimate_request_overhead_tokens(
self,
system_prompt: str = "",
tools: Optional[list] = None,
) -> int:
"""Estimate non-history request tokens (system prompt + tool schemas).
Emergency-truncation thresholds must budget the full request, not just
the message history, or a large system prompt / tool block can push the
call over the model window undetected.
"""
try:
from ..context.tokens import (
estimate_tokens_heuristic,
estimate_tool_schema_tokens,
)
except Exception:
return 0
overhead = 0
if system_prompt:
overhead += estimate_tokens_heuristic(system_prompt)
if tools:
overhead += estimate_tool_schema_tokens(tools)
return overhead
def _truncate_tool_output(self, tool_name: str, output: str, tool_call_id: str | None = None) -> str:
"""
Truncate tool output according to configured budget.
@@ -2888,7 +2973,7 @@ Your Goal: {self.goal}"""
)
return rendered
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
def chat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
"""
Chat with the agent.
@@ -2935,6 +3020,19 @@ Your Goal: {self.goal}"""
# Check if external managed backend is configured
if hasattr(self, 'backend') and self.backend is not None:
# Honour configured throttling and cooperative cancellation before
# handing off to the managed backend, so backend requests obey the
# same request limits and cancel token as direct-LLM calls instead
# of bypassing both guards (parity with the async achat() path).
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
raise InterruptedError(f"Agent chat cancelled: {reason}")
if self._rate_limiter is not None:
self._rate_limiter.acquire()
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
raise InterruptedError(f"Agent chat cancelled: {reason}")
# Extract kwargs for delegation, excluding 'self' and function locals
delegation_kwargs = {
'temperature': temperature,
@@ -3306,7 +3404,7 @@ Your Goal: {self.goal}"""
# Apply guardrail validation for custom LLM response
try:
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback and display after validation
self._execute_callback_and_display(prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(prompt, validated_response, start_time)
@@ -3406,7 +3504,12 @@ Your Goal: {self.goal}"""
agent_tools=agent_tools
)
response = self._chat_completion(messages, temperature=temperature, tools=tools, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format)
# G2 - thread the cancel token into the OpenAI-native tool
# loop too. OpenAIClient.{a,}chat_completion_with_tools already
# implements the between-iteration checks; without this hop they
# are never armed, so /stop reports success while the remaining
# tool calls keep executing.
response = self._chat_completion(messages, temperature=temperature, tools=tools, reasoning_steps=reasoning_steps, stream=stream, task_name=task_name, task_description=task_description, task_id=task_id, response_format=response_format, cancel_token=cancel_token)
if not response:
# Rollback chat history on response failure
self._rollback_chat_history_to(chat_history_length)
@@ -3425,7 +3528,7 @@ Your Goal: {self.goal}"""
self._persist_message("assistant", response_text)
# Apply guardrail validation even for JSON output
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -3446,7 +3549,7 @@ Your Goal: {self.goal}"""
if reasoning_steps and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
# Apply guardrail to reasoning content
try:
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_reasoning = self._apply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
@@ -3457,7 +3560,7 @@ Your Goal: {self.goal}"""
return None
# Apply guardrail to regular response
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -3481,7 +3584,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
if self._using_custom_llm or self._openai_client is None:
# For custom LLMs, we need to handle reflection differently
# Use non-streaming to get complete JSON response
reflection_response = self._chat_completion(messages, temperature=temperature, tools=None, stream=False, reasoning_steps=False, task_name=task_name, task_description=task_description, task_id=task_id)
reflection_response = self._chat_completion(messages, temperature=temperature, tools=None, stream=False, reasoning_steps=False, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
if not reflection_response or not reflection_response.choices:
raise Exception("No response from reflection request")
@@ -3498,11 +3601,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
reflection_output = _get_display_functions()['ReflectionOutput'](**reflection_data)
else:
# Use OpenAI's structured output for OpenAI models
_reflection_kwargs = {}
if temperature is not None:
_reflection_kwargs['temperature'] = temperature
reflection_response = self._openai_client.sync_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
response_format=_get_display_functions()['ReflectionOutput'],
**_reflection_kwargs
)
reflection_output = reflection_response.choices[0].message.parsed
@@ -3520,7 +3626,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Apply guardrail validation after satisfactory reflection
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
self._end_run(validated_response, "completed", {"duration_ms": (time.time() - start_time) * 1000})
@@ -3540,7 +3646,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Apply guardrail validation after max reflections
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -3556,7 +3662,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# For reflection, always use non-streaming to ensure compatibility with sync adapters
# and to avoid streaming complexity during regeneration process
use_stream = False
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id)
response = self._chat_completion(messages, temperature=temperature, tools=None, stream=use_stream, task_name=task_name, task_description=task_description, task_id=task_id, cancel_token=cancel_token)
content = response.choices[0].message.content
response_text = content.strip() if content else ""
reflection_count += 1
@@ -3567,6 +3673,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.error("Reflection parsing failed.", exc_info=True)
messages.append({"role": "assistant", "content": "Self Reflection failed."})
reflection_count += 1
if reflection_count >= self.max_reflect:
# Bound the retry, as the async path does. Without this
# a persistently unparseable reflection (e.g. a
# structured-output refusal, where message.parsed is
# None) loops forever, two LLM calls at a time.
if self.verbose:
_get_display_functions()['display_self_reflection']("Maximum reflection count reached after repeated parse errors, returning current response", console=self.console)
self._append_to_chat_history({"role": "assistant", "content": response_text})
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id, cancel_token=cancel_token)
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return self._trigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as guard_e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
self._rollback_chat_history_to(chat_history_length)
return None
continue # Continue even after error to try again
except Exception:
# Catch any exception from the inner try block and re-raise to outer handler
@@ -3586,7 +3708,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
return clean_triple_backticks(output)
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
async def achat(self, prompt: str, temperature: Optional[float] = None, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
"""Async version of chat method with self-reflection support.
Args:
@@ -3633,9 +3755,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# handing off to the managed backend, so backend requests obey the
# same request limits and cancel token as direct-LLM calls instead
# of bypassing both guards.
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
raise InterruptedError(f"Agent chat cancelled: {reason}")
if self._rate_limiter is not None:
await self._rate_limiter.acquire_async()
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before backend call"
raise InterruptedError(f"Agent chat cancelled: {reason}")
@@ -4147,11 +4272,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._rollback_chat_history_to(chat_history_length)
return None
_areflection_kwargs = {}
if temperature is not None:
_areflection_kwargs['temperature'] = temperature
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
response_format=_get_display_functions()['ReflectionOutput'],
**_areflection_kwargs
)
reflection_output = reflection_response.choices[0].message.parsed
@@ -4265,9 +4393,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_call_kwargs = dict(
model=self.llm,
messages=messages,
temperature=temperature,
tools=formatted_tools,
)
if temperature is not None:
tool_call_kwargs['temperature'] = temperature
if effective_tool_choice:
tool_call_kwargs['tool_choice'] = effective_tool_choice
response = await self._openai_client.async_client.chat.completions.create(
@@ -4281,11 +4410,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._execute_callback_and_display(original_prompt, result, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, result, start_time)
elif output_json or output_pydantic:
_json_kwargs = {}
if temperature is not None:
_json_kwargs['temperature'] = temperature
response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=messages,
temperature=temperature,
response_format={"type": "json_object"}
response_format={"type": "json_object"},
**_json_kwargs
)
response_text = response.choices[0].message.content
if get_logger().getEffectiveLevel() == logging.DEBUG:
@@ -4295,10 +4427,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self._execute_callback_and_display(original_prompt, response_text, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
else:
_plain_kwargs = {}
if temperature is not None:
_plain_kwargs['temperature'] = temperature
response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=messages,
temperature=temperature
**_plain_kwargs
)
response_text = response.choices[0].message.content
@@ -4335,11 +4470,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"Agent.achat completed in {total_time:.2f} seconds")
return await self._atrigger_after_agent_hook(original_prompt, response_text, start_time)
_areflection_kwargs = {}
if temperature is not None:
_areflection_kwargs['temperature'] = temperature
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
response_format=_get_display_functions()['ReflectionOutput'],
**_areflection_kwargs
)
reflection_output = reflection_response.choices[0].message.parsed
@@ -4365,10 +4503,13 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
]
_regen_kwargs = {}
if temperature is not None:
_regen_kwargs['temperature'] = temperature
new_response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=regenerate_messages,
temperature=temperature
**_regen_kwargs
)
response_text = new_response.choices[0].message.content
reflection_count += 1
@@ -4863,6 +5004,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Handle any tool calls that were accumulated
if tool_calls_data:
# Mark where the tool turns begin so the follow-up can
# append exactly the assistant tool-call and tool-result
# messages onto the original request context below.
tool_turn_start = len(self.chat_history)
# Add assistant message with tool calls to chat history
assistant_message = {"role": "assistant", "content": response_text}
if tool_calls_data:
@@ -4956,6 +5101,75 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Flush deferred media follow-ups after all tool replies.
for _m in _deferred_media_followups:
self._append_to_chat_history(_m)
# Ask the model for the answer now that the tools have
# run. Without this the generator ends here: the tool
# results sit in chat history, nothing is yielded, and
# the caller receives an empty stream with no error --
# indistinguishable from a model that had nothing to
# say. The non-streaming path already does this round
# trip, so the two disagreed about whether an answer
# existed at all.
#
# Reuse the original `messages` (system prompt, output
# constraints, memory context and the user turn) rather
# than `self.chat_history`, which omits the system turn
# and would let the follow-up ignore the agent role and
# output format. Append only the tool turns produced by
# this round -- everything added to chat history since
# the completion began.
followup_args = dict(completion_args)
followup_args['messages'] = (
list(messages) + self.chat_history[tool_turn_start:]
)
followup_args['stream'] = True
# No tools on the follow-up: the model has its results
# and should now answer, not call another tool. That
# also bounds the turn to a single round of tools.
followup_args.pop('tools', None)
followup_args.pop('tool_choice', None)
followup_args.pop('parallel_tool_calls', None)
# Bracket the follow-up with stream events so consumers
# see request/terminal telemetry for the answer too --
# the STREAM_END above referred only to the tool-call
# round, before any answer existed.
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.REQUEST_START,
timestamp=time_module.perf_counter(),
metadata={
"model": self.llm,
"message_count": len(followup_args['messages']),
"phase": "post_tool_answer",
}
))
followup_text = ""
followup_last_content_time = None
for followup_chunk in self._openai_client.sync_client.chat.completions.create(
**followup_args
):
if not followup_chunk.choices:
continue
piece = followup_chunk.choices[0].delta.content
if piece:
followup_text += piece
followup_last_content_time = time_module.perf_counter()
yield piece
if followup_last_content_time:
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.LAST_TOKEN,
timestamp=followup_last_content_time
))
self.stream_emitter.emit(StreamEvent(
type=StreamEventType.STREAM_END,
timestamp=time_module.perf_counter(),
metadata={"response_length": len(followup_text)}
))
if followup_text:
self._append_to_chat_history(
{"role": "assistant", "content": followup_text}
)
else:
# Add complete response to chat history (text-only response)
if response_text:
@@ -5256,18 +5470,111 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
agent_id=getattr(self, 'name', None),
)
def _chat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
def _get_model_middleware_manager(self):
"""Return a MiddlewareManager if user model-call hooks are registered.
Companion to ``_get_tool_middleware_manager`` for the model-call side of
the ``hooks/middleware.py`` surface (``before_model``/``after_model``/
``wrap_model_call``). Reuses the same lazily-built manager and returns
``None`` when no model hooks are present, preserving the zero-overhead
fast path.
"""
hooks = getattr(self, '_hooks', None)
if not hooks:
return None
manager = getattr(self, '_middleware_manager', None)
if manager is None:
from ..hooks import MiddlewareManager
manager = MiddlewareManager(hooks)
self._middleware_manager = manager
return manager if manager.has_model_hooks else None
@staticmethod
def _unwrap_model_response(response):
"""Unwrap a middleware ModelResponse back to the agent's return shape.
Preserves ``after_model``/``wrap_model_call`` mutations: if a hook
rewrote ``response.content``, that string wins. The original SDK object
stashed in ``extra["raw"]`` is only returned when the hook left content
untouched (so non-string provider payloads pass through unchanged on the
no-transform fast path).
"""
from ..hooks import ModelResponse
if not isinstance(response, ModelResponse):
return response
raw = response.extra.get("raw")
if isinstance(raw, str):
# Content was derived from a string raw; hook edits live on content.
return response.content
# Non-string raw: return raw only if content still matches the value we
# synthesized from it (i.e. hook didn't rewrite content).
synthesized = raw if isinstance(raw, str) else (raw or "")
if response.content == synthesized:
return raw
return response.content
def _chat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True, cancel_token=None):
"""
Wrapper for _execute_unified_chat_completion that adds jittered exponential backoff retry logic.
This method wraps the unified chat completion call and adds retry capability for
transient failures like rate limits, network errors, and service outages.
"""
# Route through user-supplied model middleware (Agent(hooks=[...])) when
# before_model/after_model/wrap_model_call hooks are registered. The
# retry/backoff logic below becomes the middleware chain's final handler.
manager = self._get_model_middleware_manager()
if manager is not None:
from ..hooks import ModelRequest, ModelResponse, InvocationContext
def _model_fn(req):
# Honor before_model/wrap_model_call mutations by threading the
# (possibly rewritten) request fields into the retry core rather
# than the outer captured values.
effective_temperature = req.temperature if req.temperature is not None else temperature
effective_tools = req.tools if req.tools is not None else tools
result = self._chat_completion_with_retry_core(
req.messages, effective_temperature, effective_tools, stream,
reasoning_steps, task_name, task_description, task_id,
response_format, stream_callback=stream_callback,
emit_events=emit_events, cancel_token=cancel_token,
)
return ModelResponse(
content=result if isinstance(result, str) else (result or ""),
model=str(getattr(self, 'llm', '') or ''),
extra={"raw": result},
)
request = ModelRequest(
messages=messages,
model=str(getattr(self, 'llm', '') or ''),
temperature=temperature if temperature is not None else 1.0,
tools=tools,
context=InvocationContext(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
model_name=str(getattr(self, 'llm', '') or ''),
),
)
response = manager.execute_model_call(request, _model_fn)
return self._unwrap_model_response(response)
return self._chat_completion_with_retry_core(
messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events,
cancel_token=cancel_token,
)
def _chat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True, cancel_token=None):
"""Retry/backoff core for chat completion (middleware-agnostic)."""
retry_config = getattr(self, '_retry_config', None)
if not retry_config:
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events)
stream_callback=stream_callback, emit_events=emit_events,
cancel_token=cancel_token)
from .retry_utils import jittered_backoff
from ..hooks import HookEvent, OnRetryInput
@@ -5280,7 +5587,8 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Call the underlying unified chat completion directly to avoid infinite recursion
return self._execute_unified_chat_completion(messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events)
stream_callback=stream_callback, emit_events=emit_events,
cancel_token=cancel_token)
except Exception as e:
from ..errors import LLMError
@@ -5343,13 +5651,69 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# This should never be reached, but just in case
raise RuntimeError("Retry loop completed without returning or raising an exception")
async def _achat_completion_with_retry(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
async def _achat_completion_with_retry(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""
Async wrapper for _execute_unified_achat_completion that adds jittered exponential backoff retry logic.
This method wraps the async chat completion call and adds retry capability for
transient failures like rate limits, network errors, and service outages.
"""
# Route through user-supplied model middleware (Agent(hooks=[...])) when
# before_model/after_model/wrap_model_call hooks are registered, mirroring
# the sync path so achat() honors the same hooks. The middleware chain is
# synchronous; run it in an executor and bridge back to the async retry
# core via the running loop so we never block or nest event loops.
manager = self._get_model_middleware_manager()
if manager is not None:
import asyncio
from ..hooks import ModelRequest, ModelResponse, InvocationContext
loop = asyncio.get_running_loop()
def _model_fn(req):
effective_temperature = req.temperature if req.temperature is not None else temperature
effective_tools = req.tools if req.tools is not None else tools
fut = asyncio.run_coroutine_threadsafe(
self._achat_completion_with_retry_core(
req.messages, effective_temperature, effective_tools, stream,
reasoning_steps, task_name, task_description, task_id,
response_format, stream_callback=stream_callback,
emit_events=emit_events,
),
loop,
)
result = fut.result()
return ModelResponse(
content=result if isinstance(result, str) else (result or ""),
model=str(getattr(self, 'llm', '') or ''),
extra={"raw": result},
)
request = ModelRequest(
messages=messages,
model=str(getattr(self, 'llm', '') or ''),
temperature=temperature if temperature is not None else 1.0,
tools=tools,
context=InvocationContext(
agent_id=self.name,
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
model_name=str(getattr(self, 'llm', '') or ''),
),
)
response = await loop.run_in_executor(
None, lambda: manager.execute_model_call(request, _model_fn)
)
return self._unwrap_model_response(response)
return await self._achat_completion_with_retry_core(
messages, temperature, tools, stream, reasoning_steps,
task_name, task_description, task_id, response_format,
stream_callback=stream_callback, emit_events=emit_events,
)
async def _achat_completion_with_retry_core(self, messages, temperature=None, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, stream_callback=None, emit_events=True):
"""Async retry/backoff core for chat completion (middleware-agnostic)."""
retry_config = getattr(self, '_retry_config', None)
if not retry_config:
return await self._execute_unified_achat_completion(
@@ -59,6 +59,85 @@ def _accepts_idempotency_key(execute_tool_fn: Callable) -> bool:
)
def _normalize_restart_safe(value: Any) -> Optional[bool]:
"""Accept only genuine booleans as a restart-safety declaration.
``None`` means undeclared. Any other type (e.g. the string ``"False"``) is a
malformed declaration and is treated as unsafe so we fail closed rather than
coercing a truthy value into an unintended replay.
"""
if value is None:
return None
if isinstance(value, bool):
return value
return False
def _canonical_tool_name(name: Any) -> str:
"""Normalise a tool name for tolerant comparison across repair variants.
``execute_tool`` may repair case/separator variants (e.g. ``Send-Email`` →
``send_email``); the declaration lookup mirrors that so an explicit
``restart_safe`` declaration is not missed for a repaired name.
"""
return "".join(ch for ch in str(name).lower() if ch.isalnum())
def _declared_restart_safe(
function_name: str, executor: Optional[Callable]
) -> Optional[bool]:
"""Return an explicit ``restart_safe`` declaration for ``function_name``.
Resolves the tool object first from the executor's owning agent (its live
``tools``), then from the global tool registry, and reads its
``restart_safe`` attribute. Returns ``None`` when the tool is undeclared or
cannot be resolved.
"""
if not function_name:
return False
target = _canonical_tool_name(function_name)
agent = getattr(executor, "__self__", None)
tools = getattr(agent, "tools", None)
if isinstance(tools, (list, tuple)):
for tool in tools:
name = getattr(tool, "name", None) or getattr(tool, "__name__", None)
if (
name is not None
and _canonical_tool_name(name) == target
and getattr(tool, "restart_safe", None) is not None
):
return _normalize_restart_safe(tool.restart_safe)
try:
from ..tools import get_registry as _get_tool_registry
tool = _get_tool_registry().get(function_name)
except Exception: # pragma: no cover - registry lookup is best-effort
tool = None
declared = getattr(tool, "restart_safe", None) if tool is not None else None
return _normalize_restart_safe(declared)
def _is_restart_safe(function_name: str, executor: Optional[Callable] = None) -> bool:
"""Resolve whether a tool is safe to re-run for an in-flight durable resume.
An explicit ``restart_safe`` declaration on the tool contract wins. When the
tool is undeclared (or cannot be resolved), fall back to the read-only name
heuristic and, ultimately, fail closed — an effectful/unknown tool is *not*
replayed so a missing declaration never causes a duplicated side effect.
"""
if not function_name:
return False
declared = _declared_restart_safe(function_name, executor)
if declared is not None:
return declared
try:
from .tool_execution import ToolExecutionMixin
return bool(ToolExecutionMixin._is_read_only_tool(function_name))
except Exception: # pragma: no cover - heuristic import is best-effort
return False
class DurableRunContext:
"""Per-turn journal state; safe for parallel tool-call wrappers."""
@@ -81,6 +160,10 @@ class DurableRunContext:
self._results_by_tool_call_id: Dict[str, Any] = {}
self._pending_by_call_id: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
self._pending_by_fingerprint: Dict[str, Tuple[int, str, Dict[str, Any], str]] = {}
# Names of in-flight tool calls (journalled tool_call with no tool_result
# at the crash). Used only when replaying to gate replay by restart-safety.
self._inflight_names_by_call_id: Dict[str, str] = {}
self._inflight_names_by_fingerprint: Dict[str, str] = {}
self._recorded_iterations = set()
for event in events:
if event.kind == KIND_TOOL_RESULT:
@@ -107,8 +190,11 @@ class DurableRunContext:
or f"{self.run_id}:{event.seq}:{name}"
)
pending = (event.seq, call_id, args, key)
fingerprint = self._fingerprint(name, args)
self._pending_by_call_id[call_id] = pending
self._pending_by_fingerprint[self._fingerprint(name, args)] = pending
self._pending_by_fingerprint[fingerprint] = pending
self._inflight_names_by_call_id[call_id] = name
self._inflight_names_by_fingerprint[fingerprint] = name
self._resume_completed_steps = len(self._recorded_iterations)
@@ -120,6 +206,44 @@ class DurableRunContext:
def _fingerprint(function_name: str, arguments: Dict[str, Any]) -> str:
return f"{function_name}:{json.dumps(_json_safe(arguments), sort_keys=True)}"
def _is_inflight_call(
self,
function_name: str,
arguments: Dict[str, Any],
tool_call_id: Optional[str],
) -> bool:
"""Whether this call was in-flight (journalled, no result) at the crash.
Only meaningful while replaying: such calls have a recorded ``tool_call``
but no ``tool_result``, so re-driving the loop would re-execute their
side effect. Completed calls (already have a result) are replayed
upstream and never reach this check.
"""
if not self.replaying:
return False
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._inflight_names_by_call_id:
return True
return self._fingerprint(function_name, arguments) in (
self._inflight_names_by_fingerprint
)
@staticmethod
def _not_safely_resumable_result(function_name: str) -> Dict[str, Any]:
"""Typed, operator-visible outcome for a gated in-flight effectful tool."""
return {
"error": (
f"Tool '{function_name}' was in-flight when the durable run "
"crashed and is not declared restart_safe; its outcome could not "
"be confirmed. It was not re-executed on resume to avoid a "
"duplicate side effect. Reconcile the external action manually "
"or mark the tool @tool(restart_safe=True) if it is idempotent."
),
"error_type": "NotSafelyResumable",
"not_safely_resumable": True,
"restart_safe": False,
}
def restore_messages(self, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""Append completed decision/result pairs once, before the next model call."""
if not self.replaying or self._restored:
@@ -307,9 +431,19 @@ class DurableRunContext:
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._results_by_tool_call_id:
return self._results_by_tool_call_id[call_id]
gate_inflight = self._is_inflight_call(
function_name, arguments, tool_call_id
) and not _is_restart_safe(function_name, execute_tool_fn)
seq, call_id, idempotency_key = self._allocate_step(
function_name, arguments, tool_call_id
)
if gate_inflight:
return self._record_result(
seq,
call_id,
self._not_safely_resumable_result(function_name),
iteration_index,
)
token = _active_idempotency_key.set(idempotency_key)
try:
call_kwargs = dict(kwargs)
@@ -340,9 +474,20 @@ class DurableRunContext:
call_id = str(tool_call_id) if tool_call_id is not None else None
if call_id and call_id in self._results_by_tool_call_id:
return self._results_by_tool_call_id[call_id]
gate_inflight = self._is_inflight_call(
function_name, arguments, tool_call_id
) and not _is_restart_safe(function_name, execute_tool_fn)
seq, call_id, idempotency_key = await asyncio.to_thread(
self._allocate_step, function_name, arguments, tool_call_id
)
if gate_inflight:
return await asyncio.to_thread(
self._record_result,
seq,
call_id,
self._not_safely_resumable_result(function_name),
iteration_index,
)
token = _active_idempotency_key.set(idempotency_key)
try:
call_kwargs = dict(kwargs)
@@ -1568,7 +1568,18 @@ Write the complete compiled report:"""
result.get("_praison_retryable") is False):
result.pop("_praison_retryable", None)
return result
# The body already ran and may have completed its side effect
# before failing. Honour an explicit restart_safe=False /
# idempotent=False declaration, exactly as the sync loops do
# (tool_execution.py:884 and :2115). Without this the async
# path accepts the declaration and silently re-runs the tool
# up to max_attempts times -- charging a card three times for
# a tool the author marked "must never be silently re-executed".
if self._tool_declares_not_idempotent(function_name, tools_override):
result.pop("_praison_retryable", None)
return result
# Determine error type for retry policy
error_type = self._classify_error_type(result, last_exception)
@@ -14,6 +14,7 @@ from typing import Optional, Any, Callable, Dict, List, Union, TYPE_CHECKING, Li
from dataclasses import dataclass, field
from enum import Enum
import inspect
from contextlib import contextmanager
from praisonaiagents._logging import get_logger
import asyncio
import contextvars
@@ -469,12 +470,46 @@ class Handoff:
return value
return 'gpt-4o-mini'
@contextmanager
def _seed_target_history(self, prior_messages: Optional[List[Any]]):
"""Temporarily seed the filtered handoff context onto the target agent.
The configured ``context_policy``/``input_filter`` produce the messages the
target should see; without seeding them the target only ever received the
literal handoff prompt string and the configured policy had no effect.
The seeding is *invocation-scoped*: the prior messages are prepended for the
duration of this handoff and the target's original ``chat_history`` is
restored on exit. This keeps handoff context from leaking into later
handoffs or ordinary chats that reuse the same target agent, and stops the
target prompt from growing across sequential handoffs.
No shared mutable state is retained between agents.
"""
existing = getattr(self.agent, 'chat_history', None)
if not prior_messages or not isinstance(existing, list):
yield
return
try:
prior = list(prior_messages)
except TypeError:
yield
return
original = existing
try:
# Avoid re-seeding if the context is already at the head.
if existing[:len(prior)] != prior:
self.agent.chat_history = prior + existing
yield
finally:
self.agent.chat_history = original
def _execute_with_runtime_resolution(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any]
context: Dict[str, Any],
prior_messages: Optional[List[Any]] = None,
) -> str:
"""Execute handoff through the target agent's full chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
@@ -484,14 +519,16 @@ class Handoff:
target_model_ref,
_get_handoff_depth(),
)
return self.agent.chat(prompt, tools=effective_tools)
with self._seed_target_history(prior_messages):
return self.agent.chat(prompt, tools=effective_tools)
async def _execute_with_runtime_resolution_async(
self,
source_agent: 'Agent',
prompt: str,
effective_tools: Optional[List[Any]],
context: Dict[str, Any]
context: Dict[str, Any],
prior_messages: Optional[List[Any]] = None,
) -> str:
"""Execute handoff through the target agent's full async chat pipeline."""
target_model_ref = self._extract_model_ref(self.agent)
@@ -501,13 +538,14 @@ class Handoff:
target_model_ref,
_get_handoff_depth(),
)
async_chat = getattr(self.agent, 'achat', None)
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
return await async_chat(prompt, tools=effective_tools)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self.agent.chat(prompt, tools=effective_tools)
)
with self._seed_target_history(prior_messages):
async_chat = getattr(self.agent, 'achat', None)
if callable(async_chat) and inspect.iscoroutinefunction(async_chat):
return await async_chat(prompt, tools=effective_tools)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: self.agent.chat(prompt, tools=effective_tools)
)
def _check_safety(self, source_agent: 'Agent') -> None:
"""
@@ -661,8 +699,9 @@ class Handoff:
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Prepare context (for future extensibility - context data available for hooks)
_ = self._prepare_context(source_agent, kwargs)
# Prepare context per the configured context_policy/input_filter and
# thread the filtered history through to the target agent.
handoff_data = self._prepare_context(source_agent, kwargs)
# Build prompt with context
context_prefix = f"[Handoff from {source_agent.name}] "
@@ -677,7 +716,8 @@ class Handoff:
# Resolve runtime at turn-time instead of using construction-time pin
response = self._execute_with_runtime_resolution(
source_agent, full_prompt, effective_tools, kwargs
source_agent, full_prompt, effective_tools, kwargs,
prior_messages=handoff_data.messages,
)
result = HandoffResult(
@@ -769,8 +809,9 @@ class Handoff:
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Prepare context (for future extensibility)
_ = self._prepare_context(source_agent, kwargs)
# Prepare context per the configured context_policy/input_filter
# and thread the filtered history through to the target agent.
handoff_data = self._prepare_context(source_agent, kwargs)
# Build prompt
context_prefix = f"[Handoff from {source_agent.name}] "
@@ -785,7 +826,8 @@ class Handoff:
# Resolve runtime at turn-time for async execution
response = await self._execute_with_runtime_resolution_async(
source_agent, full_prompt, effective_tools, kwargs
source_agent, full_prompt, effective_tools, kwargs,
prior_messages=handoff_data.messages,
)
result = HandoffResult(
@@ -898,20 +940,39 @@ class Handoff:
# Prepare handoff data with context policy
handoff_data = self._prepare_context(source_agent, kwargs)
# Get the last user message or context to pass to target agent
# Get the last user message or context to pass to target agent.
# Track the index of the message chosen for ``prompt`` so exactly
# that item is excluded from the seeded history (it may not be the
# final list item if an assistant/tool message follows it).
last_message = None
for msg in reversed(handoff_data.messages):
selected_index = None
for i in range(len(handoff_data.messages) - 1, -1, -1):
msg = handoff_data.messages[i]
if isinstance(msg, dict) and msg.get('role') == 'user':
last_message = msg.get('content', '')
selected_index = i
break
if not last_message and handoff_data.messages:
last_msg = handoff_data.messages[-1]
selected_index = len(handoff_data.messages) - 1
last_msg = handoff_data.messages[selected_index]
if isinstance(last_msg, dict):
last_message = last_msg.get('content', '')
else:
last_message = str(last_msg)
# Tool-style handoff (as_tool): ContextPolicy.NONE discards the
# history, so the prompt must come from the generated ``task``/
# ``prompt`` argument instead. Without this the sub-agent would
# never run and the tool would report a hollow "completed". The
# task is the sub-agent's whole instruction, so it is passed
# verbatim rather than wrapped in a ``[Handoff from …]`` prefix.
task_prompt = None
if not last_message and not self.input_type:
task_prompt = kwargs.get('task') or kwargs.get('prompt')
if task_prompt:
last_message = str(task_prompt)
# Prepare context information
context_info = f"[Handoff from {source_agent.name}] "
if kwargs and self.input_type:
@@ -919,15 +980,26 @@ class Handoff:
# Execute the target agent with tool boundary enforcement
if last_message:
prompt = context_info + last_message
prompt = last_message if task_prompt else context_info + last_message
logger.info(f"Handing off to {self.agent.name} with prompt: {prompt}")
# Compute effective tools based on tool policy
effective_tools = self._compute_effective_tools(source_agent)
# Resolve runtime at turn-time for tool function execution
# Resolve runtime at turn-time for tool function execution.
# The selected message is already folded into ``prompt`` above,
# so exclude exactly that item from the seeded history to avoid
# duplicating it while preserving any messages that follow it.
if selected_index is None:
prior_messages = list(handoff_data.messages)
else:
prior_messages = (
handoff_data.messages[:selected_index]
+ handoff_data.messages[selected_index + 1:]
)
response = self._execute_with_runtime_resolution(
source_agent, prompt, effective_tools, kwargs
source_agent, prompt, effective_tools, kwargs,
prior_messages=prior_messages,
)
result = HandoffResult(
@@ -995,6 +1067,17 @@ class Handoff:
)
)
handoff_tool.__signature__ = inspect.Signature(sig_params)
elif self.config.context_policy == ContextPolicy.NONE:
# Tool-style handoff (as_tool): no conversation context is passed, so
# advertise an explicit ``task`` parameter the caller must fill. This
# is what ``handoff_tool`` reads as the sub-agent's prompt above.
handoff_tool.__signature__ = inspect.Signature([
inspect.Parameter(
'task',
inspect.Parameter.KEYWORD_ONLY,
annotation=str,
)
])
return handoff_tool
@@ -1468,8 +1551,9 @@ class TypedHandoff(Handoff, Generic[T]):
# Execute on_handoff callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Apply input filter if provided (fixes Greptile P1 issue)
_ = self._prepare_context(source_agent, kwargs)
# Apply context policy / input filter; the resulting messages are
# seeded onto the target only for the duration of its chat below.
handoff_data = self._prepare_context(source_agent, kwargs)
# Validate payload against schema
try:
@@ -1496,7 +1580,8 @@ class TypedHandoff(Handoff, Generic[T]):
logger.info(f"Typed handoff to {self.agent.name} with validated payload")
# Execute with timeout if sync
response = self.agent.chat(full_prompt)
with self._seed_target_history(handoff_data.messages):
response = self.agent.chat(full_prompt)
result = HandoffResult(
success=True,
@@ -1581,8 +1666,9 @@ class TypedHandoff(Handoff, Generic[T]):
# Execute callback
self._execute_callback(self.config.on_handoff or self.on_handoff, source_agent, kwargs)
# Apply input filter if provided (fixes Greptile P1 issue)
_ = self._prepare_context(source_agent, kwargs)
# Apply context policy / input filter; the resulting messages are
# seeded onto the target only for the duration of its chat below.
handoff_data = self._prepare_context(source_agent, kwargs)
# Validate payload against schema
try:
@@ -1608,12 +1694,13 @@ class TypedHandoff(Handoff, Generic[T]):
logger.info(f"Async typed handoff to {self.agent.name}")
# Execute - check for async chat method
if hasattr(self.agent, 'achat'):
response = await self.agent.achat(full_prompt)
else:
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
with self._seed_target_history(handoff_data.messages):
if hasattr(self.agent, 'achat'):
response = await self.agent.achat(full_prompt)
else:
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, self.agent.chat, full_prompt)
return HandoffResult(
success=True,
@@ -850,7 +850,11 @@ class ToolExecutionMixin:
# a transient the inner loop did NOT already re-drive:
# • _outer_timeout — this loop's own wall-clock guard
# tripped; the body ran once and the inner budget
# never saw it, so it earns one outer pass.
# never saw it. BUT the timed-out body is still
# running in an orphaned thread (future.cancel() is
# a no-op once started), so retrying a mutating tool
# would duplicate its side effects. Only re-drive it
# when the tool is known idempotent/read-only.
# • circuit_open — returned by the inner loop WITHOUT a
# body run, so retrying costs no duplicate side effect.
# • _praison_retryable — a raised exception was flattened
@@ -868,10 +872,16 @@ class ToolExecutionMixin:
# RetryPolicy.retry_on the inner loop honours — not a word
# in the tool's own payload. Surface it once.
is_retryable = bool(
result.get("_outer_timeout")
(result.get("_outer_timeout")
and self._is_tool_idempotent(function_name))
or result.get("circuit_open")
or (result.get("_praison_retryable") is True
and error_type not in inner_policy.retry_on)
and error_type not in inner_policy.retry_on
# The body already ran and may have completed
# its side effect before raising. Honour an
# explicit non-idempotent declaration here, as
# the _outer_timeout clause above already does.
and not self._tool_declares_not_idempotent(function_name))
)
# Strip the private control-plane tag before it can reach
# the model or be re-surfaced as the tool's payload.
@@ -1948,7 +1958,7 @@ class ToolExecutionMixin:
)
return any(marker in name for marker in edit_markers)
def _check_permission_manager_deny(self, function_name):
def _check_permission_manager_deny(self, function_name, arguments=None):
"""Return an error dict if the PermissionManager hard-denies the tool.
Ensures the pattern-based ``PermissionManager`` (rules from
@@ -1956,18 +1966,51 @@ class ToolExecutionMixin:
uniformly — native functions **and** MCP tools — since both flow through
``_execute_tool_impl``. Returns ``None`` when no manager is attached or
the tool is not denied, preserving backward compatibility.
Both the tool *name* and the argument-scoped target (e.g.
``bash:rm -rf /tmp/x`` built from ``execute_command(command=...)``) are
checked so an argument-level ``deny`` rule — which the rule engine can
already match — actually fires. Without this the deny tier was a
name-only decision: once a shell tool was allowed by name, no rule could
narrow it. The argument target reuses ``build_permission_target`` so it
speaks the same vocabulary as the allow side's scoped grants.
"""
manager = getattr(self, "_permission_manager", None)
if manager is None:
return None
try:
if manager.is_denied(function_name, getattr(self, "name", None)):
return {
"error": f"Tool '{function_name}' blocked by permission policy",
"permission_denied": True,
}
except Exception as e:
logging.debug("permission manager is_denied failed for %s: %s", function_name, e)
agent_name = getattr(self, "name", None)
targets = [function_name]
if arguments:
try:
from ..approval.utils import build_permission_target
# Normalise argument keys the same way ``_cast_arguments`` does
# before dispatch (strip trailing '='/whitespace LLMs hallucinate)
# so ``command=`` still resolves to the ``command`` command-key
# and an argument-scoped deny (``bash:rm *``) cannot be evaded by
# a malformed kwarg name that only normalises *after* this gate.
normalized_args = arguments
if isinstance(arguments, dict):
normalized_args = {
(k.strip().rstrip('=').strip() if isinstance(k, str) else k): v
for k, v in arguments.items()
}
scoped_target = build_permission_target(function_name, normalized_args)
if scoped_target and scoped_target != function_name:
targets.append(scoped_target)
except Exception as e: # noqa: BLE001
logging.debug(
"could not build argument-scoped permission target for %s: %s",
function_name, e,
)
for target in targets:
try:
if manager.is_denied(target, agent_name):
return {
"error": f"Tool '{function_name}' blocked by permission policy",
"permission_denied": True,
}
except Exception as e:
logging.debug("permission manager is_denied failed for %s: %s", target, e)
return None
def _is_bypass_mode(self) -> bool:
@@ -2002,7 +2045,7 @@ class ToolExecutionMixin:
# Pattern-based PermissionManager deny gate (native + MCP, uniform).
# BYPASS mode skips this gate as its contract requires.
if not self._is_bypass_mode():
manager_denial = self._check_permission_manager_deny(function_name)
manager_denial = self._check_permission_manager_deny(function_name, arguments)
if manager_denial is not None:
return manager_denial
@@ -2022,6 +2065,14 @@ class ToolExecutionMixin:
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
# Re-authorize: an approval backend may have rewritten the arguments
# into a command that an argument-scoped deny rule prohibits. The
# earlier gate only saw the original args, so re-run it against the
# final ones before dispatch. BYPASS still opts out entirely.
if not self._is_bypass_mode():
manager_denial = self._check_permission_manager_deny(function_name, arguments)
if manager_denial is not None:
return manager_denial
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
@@ -2040,7 +2091,7 @@ class ToolExecutionMixin:
# Pattern-based PermissionManager deny gate (native + MCP, uniform).
# BYPASS mode skips this gate as its contract requires.
if not self._is_bypass_mode():
manager_denial = self._check_permission_manager_deny(function_name)
manager_denial = self._check_permission_manager_deny(function_name, arguments)
if manager_denial is not None:
return manager_denial
@@ -2061,6 +2112,12 @@ class ToolExecutionMixin:
if decision.modified_args:
arguments = decision.modified_args
logging.info(f"Using modified arguments: {arguments}")
# Re-authorize modified args against the deny gate — see the sync
# path for rationale. BYPASS still opts out entirely.
if not self._is_bypass_mode():
manager_denial = self._check_permission_manager_deny(function_name, arguments)
if manager_denial is not None:
return manager_denial
from ..approval import get_approval_registry
get_approval_registry().mark_approved(
@@ -2098,7 +2155,13 @@ class ToolExecutionMixin:
result.get("guardrail_denied") or
result.get("circuit_open")):
return result
# The body already ran and may have completed its side effect
# before failing. Honour an explicit non-idempotent declaration
# rather than re-driving it (send a second email, charge twice).
if self._tool_declares_not_idempotent(function_name):
return result
# Determine error type for retry policy
error_type = self._classify_error_type(result, last_exception)
@@ -2980,6 +3043,104 @@ class ToolExecutionMixin:
else:
return f"Unknown bridge tool: {function_name}"
def _tool_declares_not_idempotent(self, tool_name, tools_override=None):
"""True only when the tool *explicitly* declares itself unsafe to re-run.
Deliberately narrower than ``_is_tool_idempotent``, which answers False
for unknown tools as a safe default: gating a retry on that would stop
retrying every unregistered user tool.
This keys on an explicit declaration and nothing else. In particular it
must NOT consult the shared ``MUTATING_TOOLS`` name registry -- that is a
73-name list maintained for the escalation loop guard, and using it here
silently removed the retry from any *undeclared* tool that happened to
share a name with an entry (``write_file``, ``store_memory``, ``mkdir``...).
``tools_override`` mirrors the resolution order in ``_execute_tool_impl`` /
``_execute_tool_async_impl``: a task-scoped tool passed via ``tools_override``
shadows an agent-level tool of the same name, so its declaration must win.
Without this an override-only ``restart_safe=False`` tool would be missed
here and silently retried.
"""
tools = getattr(self, 'tools', [])
if not isinstance(tools, (list, tuple)):
tools = []
if isinstance(tools_override, (list, tuple)):
# Override tools take precedence and are searched first.
tools = list(tools_override) + list(tools)
for tool in tools:
name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
if name_attr == tool_name:
explicit = getattr(tool, 'idempotent', None)
if not isinstance(explicit, bool):
# ``restart_safe`` is the public replay-safety contract on
# ``@tool`` (FunctionTool) and ``BaseTool``: ``False`` marks an
# effectful tool that must never be silently re-executed.
explicit = getattr(tool, 'restart_safe', None)
if isinstance(explicit, bool):
return not explicit
return False
# A tool from the process-global registry (only reachable under
# ToolConfig(allow_global_tools=True)) can carry its own declaration;
# ``_is_tool_idempotent`` consults it, so this must too.
if getattr(self, '_allow_global_tools', False):
try:
from ..tools.registry import get_registry
global_tool = get_registry().get(tool_name)
for attr in ('idempotent', 'restart_safe'):
explicit = getattr(global_tool, attr, None)
if isinstance(explicit, bool):
return not explicit
except Exception:
pass
return False
def _is_tool_idempotent(self, tool_name):
"""Whether re-running ``tool_name`` is safe (no duplicated side effects).
Used to decide if an OUTER wall-clock timeout may be retried: the timed-out
tool body is still running in an orphaned thread (``future.cancel()`` is a
no-op once started), so auto-retrying a mutating tool would duplicate its
side effects (send a second email, charge twice). Only tools classified as
read-only/idempotent are safe to re-drive.
Precedence: explicit ``idempotent`` attribute on the tool > the shared
read-only/mutating name registry (reused from the escalation loop guard).
Unknown tools default to NOT idempotent (safe: surface the timeout once).
"""
tools = getattr(self, 'tools', [])
if not isinstance(tools, (list, tuple)):
tools = []
for tool in tools:
tool_name_attr = getattr(tool, '__name__', None) or getattr(tool, 'name', None)
if tool_name_attr == tool_name:
explicit = getattr(tool, 'idempotent', None)
if isinstance(explicit, bool):
return explicit
break
# A tool from the process-global registry (only reachable when
# ToolConfig(allow_global_tools=True)) can carry its own explicit
# ``idempotent`` flag even when its name is absent from the shared
# read-only/mutating registries below.
if getattr(self, '_allow_global_tools', False):
try:
from ..tools.registry import get_registry
global_tool = get_registry().get(tool_name)
explicit = getattr(global_tool, 'idempotent', None)
if isinstance(explicit, bool):
return explicit
except Exception:
pass
try:
from ..escalation.loop_guard import IDEMPOTENT_TOOLS, MUTATING_TOOLS
except Exception:
return False
if tool_name in IDEMPOTENT_TOOLS:
return True
if tool_name in MUTATING_TOOLS:
return False
return False
def _get_tool_retry_policy(self, tool_name):
"""Get retry policy for a tool (tool-level > agent-level > default).
@@ -716,8 +716,11 @@ class AgentTeam(SpawnAnnounceProtocol):
# The resolved value is the default model for the members: applied to
# every agent that never named a model of its own; an agent that did
# keeps it. Same rule AgentFlow uses for the agents it builds.
from ..utils.model_alias import resolve_model_alias
self.llm = resolve_model_alias(llm, model, type(self).__name__)
# Must UNWRAP, not just alias-resolve: this value is pushed into every
# member via _apply_default_llm, and a member holding an LLMConfig fails
# inside chat() with "'LLMConfig' object has no attribute 'lower'".
from ..utils.model_alias import resolve_model_name
self.llm = resolve_model_name(llm, model, type(self).__name__)
if self.llm:
for _member in (agents.values() if isinstance(agents, dict) else (agents or [])):
_apply = getattr(_member, "_apply_default_llm", None)
@@ -1543,6 +1546,20 @@ class AgentTeam(SpawnAnnounceProtocol):
return False
def _should_continue_on_dep_failure(self, task_id):
"""True if the task opted into graceful degradation via its own flags.
Honors the documented per-task flow-control knobs ``skip_on_failure``
and ``on_error`` (``"continue"``). When set, a dependent task still runs
(in degraded mode) instead of being force-failed when an upstream
dependency fails.
"""
task = self.tasks.get(task_id)
if task is None:
return False
return bool(getattr(task, 'skip_on_failure', False)) or \
getattr(task, 'on_error', 'stop') == 'continue'
async def arun_all_tasks(self):
"""Async version of run_all_tasks method"""
process = Process(
@@ -1565,8 +1582,9 @@ class AgentTeam(SpawnAnnounceProtocol):
await self._gather_with_isolation([c for _, c in tasks_to_run])
tasks_to_run = []
# A just-flushed prerequisite may now be failed; don't
# queue a dependent that would run with missing context.
if self._deps_failed(task_id):
# queue a dependent that would run with missing context
# unless it opted into graceful degradation.
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
self.tasks[task_id].status = "failed"
continue
tasks_to_run.append((task_id, self.arun_task(task_id)))
@@ -1605,8 +1623,9 @@ class AgentTeam(SpawnAnnounceProtocol):
if self._depends_on_pending(task, async_tasks_to_run):
await flush_async_tasks()
# A just-flushed prerequisite may now be failed; don't
# queue a dependent that would run with missing context.
if self._deps_failed(task_id):
# queue a dependent that would run with missing context
# unless it opted into graceful degradation.
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
self.tasks[task_id].status = "failed"
continue
# Collect async tasks to run in parallel
@@ -1615,8 +1634,9 @@ class AgentTeam(SpawnAnnounceProtocol):
# Before running a sync task, execute all pending async tasks
await flush_async_tasks()
# A just-flushed async prerequisite may now be failed; skip
# the dependent so we don't run it with missing upstream context.
if self._deps_failed(task_id):
# the dependent so we don't run it with missing upstream
# context, unless it opted into graceful degradation.
if self._deps_failed(task_id) and not self._should_continue_on_dep_failure(task_id):
self.tasks[task_id].status = "failed"
continue
# Run sync task in an executor to avoid blocking the event loop
@@ -32,6 +32,12 @@ class ApprovalRequest:
approval_id: Stable correlation id that survives a process restart
and can be matched to an inbound channel callback.
Auto-generated when not supplied.
authorized_reviewers: Optional set of reviewer identities allowed to
resolve this request. ``None`` (default) keeps today's
behaviour (any scope-holder may resolve). When populated a
backend may bind resolution to these reviewers so that in a
multi-operator deployment a request addressed to one
operator cannot be resolved by another.
"""
tool_name: str
@@ -41,6 +47,7 @@ class ApprovalRequest:
session_id: Optional[str] = None
context: Dict[str, Any] = field(default_factory=dict)
approval_id: str = field(default_factory=_new_approval_id)
authorized_reviewers: Optional[List[str]] = None
@dataclass
@@ -89,6 +89,11 @@ class BotConfig:
# replaces it with done_emoji on completion (e.g. "✅").
ack_emoji: str = ""
done_emoji: str = "✅"
# Which inbound messages get an ack reaction. One of:
# "off" | "direct" | "group-mentions" | "group-all" | "all".
# Default "group-mentions": ack DMs and group mentions/replies, but stay
# quiet on ambient group chatter the bot won't answer.
ack_scope: str = "group-mentions"
# Session TTL in seconds. 0 = disabled (sessions never expire).
# When set, stale sessions older than this are auto-reaped.
@@ -158,6 +163,7 @@ class BotConfig:
"debounce_ms": self.debounce_ms,
"ack_emoji": self.ack_emoji,
"done_emoji": self.done_emoji,
"ack_scope": self.ack_scope,
"session_ttl": self.session_ttl,
"busy_mode": self.busy_mode,
"busy_ack": self.busy_ack,
@@ -263,7 +263,7 @@ def create_memory_flush_agent(
) -> Any:
"""Create a restricted child agent sharing only the parent's memory store."""
from ..agent.agent import Agent
from ..config.feature_configs import ExecutionConfig
from ..config.feature_configs import ExecutionConfig, OutputConfig
from ..tools.memory import search_memory, store_memory
llm = config.llm or getattr(parent_agent, "llm", None)
@@ -286,8 +286,7 @@ def create_memory_flush_agent(
else getattr(parent_agent, "_memory_instance", None)
),
execution=ExecutionConfig(context_compaction=False, max_steps=3),
verbose=False,
stream=False,
output=OutputConfig(verbose=False, stream=False),
)
@@ -1902,6 +1902,48 @@ class RuntimeConfig:
RuntimeParam = Union[bool, str, Dict[str, Any], RuntimeConfig, None]
# Known runtime names, keyed by their canonical spelling. Every alias maps to
# the runtime whose capability matrix the agent will actually use, so a name
# that resolves here can never silently drop capabilities downstream.
RUNTIME_ALIASES: Dict[str, str] = {
"native": "native",
"plugin_harness": "plugin-harness",
"plugin-harness": "plugin-harness",
"harness": "plugin-harness",
"plugin": "plugin-harness",
"reduced": "plugin-harness",
}
def canonical_runtime_name(value: str) -> str:
"""Normalise a runtime name; reject typos but allow genuine plugin names.
Matching is case-insensitive, ignores surrounding whitespace, and treats
``-``/``_`` as interchangeable, mirroring the other preset resolvers.
A value that names a known runtime resolves to its canonical spelling, so
``NATIVE``/`` native `` all map to ``native`` and pick up the full matrix
instead of silently dropping capabilities. A value that is a close typo of
a known runtime (e.g. ``nativ``) raises with a suggestion rather than
degrading silently. A genuinely unknown name (e.g. a third-party plugin
runtime registered later) is returned as-is so it can fall back to the
reduced harness — matching the runtime registry, not a closed literal.
"""
from .parse_utils import canonical_preset_key, make_preset_error, suggest_similar
key = canonical_preset_key(value)
if key in RUNTIME_ALIASES:
return RUNTIME_ALIASES[key]
known = sorted(set(RUNTIME_ALIASES))
if suggest_similar(key, known):
# Close enough to a known runtime that it is almost certainly a typo.
# Pass the full alias set so the error can suggest the matched name
# (e.g. "reducd" -> "reduced") rather than only canonical spellings.
raise make_preset_error("runtime", value, known)
# Unrecognised but not a near-miss: treat as an opaque plugin runtime name.
return value
def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
"""Resolve runtime= parameter following precedence ladder."""
if value is None or value is False:
@@ -1909,13 +1951,24 @@ def resolve_runtime(value: RuntimeParam) -> Optional[RuntimeConfig]:
if value is True:
return RuntimeConfig()
if isinstance(value, str):
return RuntimeConfig(preferred_runtime=value)
return RuntimeConfig(preferred_runtime=canonical_runtime_name(value))
if isinstance(value, (list, set, tuple, frozenset)):
return RuntimeConfig(required_capabilities=list(value))
if isinstance(value, dict):
return RuntimeConfig(**value)
config = RuntimeConfig(**value)
if config.preferred_runtime is not None:
config.preferred_runtime = canonical_runtime_name(config.preferred_runtime)
return config
if isinstance(value, RuntimeConfig):
return value
if value.preferred_runtime is None:
return value
# Return a normalised copy rather than mutating the caller's object,
# so a config reused/compared after agent construction is unchanged.
from dataclasses import replace
return replace(
value,
preferred_runtime=canonical_runtime_name(value.preferred_runtime),
)
raise TypeError(
f"Invalid runtime parameter type: {type(value).__name__}. "
"Expected None/False, True, str, list, set, dict, or RuntimeConfig."
@@ -1986,6 +2039,7 @@ __all__ = [
"resolve_tool_search",
"resolve_tools",
"resolve_runtime",
"canonical_runtime_name",
]
@@ -1108,6 +1108,18 @@ def get_optimizer(
Optimizer instance
"""
optimizer_class = OPTIMIZER_REGISTRY.get(strategy, SmartOptimizer)
# Optimizers do not share a constructor signature. Callers (e.g. the
# ContextManager) pass the union of every optimizer's kwargs, so filter to
# the ones this class actually accepts; otherwise all but SmartOptimizer
# raise TypeError and context management is silently disabled upstream.
import inspect
params = inspect.signature(optimizer_class.__init__).parameters
accepts_var_kw = any(
p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()
)
if not accepts_var_kw:
kwargs = {k: v for k, v in kwargs.items() if k in params}
return optimizer_class(**kwargs)
@@ -88,6 +88,26 @@ class _LazyDbModule:
db = _LazyDbModule()
# ``from praisonaiagents import db`` returns *this submodule*, not the singleton
# above (Python binds a real submodule as a package attribute, shadowing the
# package ``__getattr__``). Make the submodule object itself callable and proxy
# backend lookups to the singleton so ``db(...)`` and ``db.PraisonDB`` both work
# regardless of how ``db`` is imported.
import sys as _sys
from types import ModuleType as _ModuleType
class _CallableDbModule(_ModuleType):
def __call__(self, **kwargs):
return db(**kwargs)
def __getattr__(self, name):
return getattr(db, name)
_sys.modules[__name__].__class__ = _CallableDbModule
__all__ = [
"DbAdapter",
"AsyncDbAdapter",
@@ -9,6 +9,7 @@ that was duplicated across multiple evaluators.
"""
import os
import re
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
@@ -16,6 +17,16 @@ from typing import Any, Dict, List, Optional
logger = get_logger(__name__)
# Tolerant matcher for judge field labels. Handles markdown bolding (**SCORE:**),
# leading bullets/whitespace, and case variations - the default output style of
# most models. Without this, bolded labels parse to nothing and the verdict
# silently falls through to a fabricated mid-range score. Mirrors the matcher in
# safety.py so grader/judge fail the same way safety already does.
_FIELD_RE = re.compile(
r"^[\s>*_#`-]*(score|reasoning|suggestions)\s*\**\s*:\s*(.*)$",
re.IGNORECASE,
)
@dataclass
class GradeResult:
"""
@@ -186,39 +197,18 @@ EXPECTED OUTPUT (ideal response):
Returns:
Parsed GradeResult
"""
score = 5.0 # Default
reasoning = "Unable to parse response"
suggestions: List[str] = []
lines = response_text.strip().split('\n')
in_suggestions = False
for line in lines:
line = line.strip()
if line.startswith('SCORE:'):
try:
score_str = line.replace('SCORE:', '').strip()
score = float(score_str)
# Clamp to valid range
score = max(1.0, min(10.0, score))
except ValueError:
pass
elif line.startswith('REASONING:'):
reasoning = line.replace('REASONING:', '').strip()
elif line.startswith('SUGGESTIONS:'):
in_suggestions = True
rest = line.replace('SUGGESTIONS:', '').strip()
if rest.lower() != 'none' and rest:
suggestions.append(rest)
elif in_suggestions and line.startswith('-'):
suggestion = line.lstrip('- ').strip()
if suggestion and suggestion.lower() != 'none':
suggestions.append(suggestion)
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
if not score_parsed:
# No numeric score was parsed. Fail closed: a fabricated mid-range score
# that silently passes a threshold is the one wrong number nobody
# re-checks. Report the minimum instead.
logger.warning(
"Could not parse grader response; failing closed. Raw: %r",
response_text[:200],
)
score = 1.0
return GradeResult(
score=score,
reasoning=reasoning,
@@ -365,38 +355,102 @@ EXPECTED OUTPUT (ideal response):
expected_output=expected_output,
)
def _extract_score_reasoning(response_text: str) -> tuple:
"""
Line-based tolerant extraction of SCORE / REASONING / SUGGESTIONS.
Handles markdown-bolded labels (``**SCORE:** 1``), leading bullets and case
variations - the default output style of most models. Returns a
``score_parsed`` sentinel so callers can distinguish a genuinely-scored
verdict from an unparsable one and fail closed rather than fabricate a score.
Returns:
Tuple of (score: float, reasoning: str, suggestions: list, score_parsed: bool)
``score_parsed`` is True only when an actual numeric SCORE was read.
Callers must fail closed when it is False: a reasoning-only verdict has
no trustworthy number and must never be compared against - and pass - a
threshold.
"""
score = 5.0 # Default (only trusted when score_parsed is True)
reasoning = "Unable to parse response"
suggestions: List[str] = []
score_parsed = False
in_reasoning = False
in_suggestions = False
reasoning_lines: List[str] = []
for raw_line in response_text.strip().split('\n'):
line = raw_line.strip()
match = _FIELD_RE.match(line) if line else None
if match:
field_name = match.group(1).lower()
value = match.group(2).strip().strip('*_`').strip()
if field_name == 'score':
number = re.search(r'-?\d+(?:\.\d+)?', value)
if number:
try:
score = max(1.0, min(10.0, float(number.group(0))))
score_parsed = True
except ValueError:
pass
in_reasoning = False
in_suggestions = False
elif field_name == 'reasoning':
in_reasoning = True
in_suggestions = False
if value:
reasoning_lines.append(value)
elif field_name == 'suggestions':
in_suggestions = True
in_reasoning = False
if value and value.lower() != 'none':
suggestions.append(value)
continue
if not line:
continue
if in_suggestions and line.lstrip('*_`').startswith('-'):
suggestion = line.lstrip('*_`- ').strip()
if suggestion and suggestion.lower() != 'none':
suggestions.append(suggestion)
elif in_reasoning:
reasoning_lines.append(line)
if reasoning_lines:
reasoning = ' '.join(reasoning_lines).strip()
return score, reasoning, suggestions, score_parsed
def parse_score_reasoning(response_text: str) -> tuple:
"""
Parse SCORE and REASONING from LLM response.
DRY: Static utility function for parsing LLM-as-judge responses.
Used by AccuracyEvaluator, CriteriaEvaluator, and BaseLLMGrader.
Tolerant of markdown-bolded labels. When nothing can be parsed the verdict
fails closed (score 1.0) rather than silently returning a mid-range 5.0 that
could pass a threshold - the one wrong number nobody re-checks.
Args:
response_text: Raw LLM response text
Returns:
Tuple of (score: float, reasoning: str)
"""
import re
score, reasoning, _suggestions, score_parsed = _extract_score_reasoning(response_text)
score = 5.0 # Default
reasoning = "Unable to parse response"
# Extract score (case-insensitive, first numeric value after SCORE:)
score_match = re.search(r'SCORE:\s*(\d+(?:\.\d+)?)', response_text, re.IGNORECASE)
if score_match:
try:
score = float(score_match.group(1))
# Clamp to valid range
score = max(1.0, min(10.0, score))
except ValueError:
pass
# Extract reasoning (case-insensitive, preserves multi-line explanations)
reasoning_match = re.search(r'REASONING:\s*(.+)', response_text, re.IGNORECASE | re.DOTALL)
if reasoning_match:
reasoning = reasoning_match.group(1).strip()
if not score_parsed:
logger.warning(
"Could not parse judge response; failing closed. Raw: %r",
response_text[:200],
)
score = 1.0
return score, reasoning
@@ -19,7 +19,7 @@ from praisonaiagents._logging import get_logger
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Type, TYPE_CHECKING
from .grader import BaseLLMGrader
from .grader import BaseLLMGrader, _extract_score_reasoning
from .results import JudgeResult
if TYPE_CHECKING:
@@ -294,41 +294,22 @@ SUGGESTIONS:
Returns:
JudgeResult with score, passed, reasoning, suggestions
"""
score = 5.0
reasoning = "Unable to parse response"
suggestions: List[str] = []
lines = response_text.strip().split('\n')
in_suggestions = False
for line in lines:
line = line.strip()
if line.startswith('SCORE:'):
try:
score_str = line.replace('SCORE:', '').strip()
score = float(score_str)
score = max(1.0, min(10.0, score))
except ValueError:
pass
elif line.startswith('REASONING:'):
reasoning = line.replace('REASONING:', '').strip()
elif line.startswith('SUGGESTIONS:'):
in_suggestions = True
rest = line.replace('SUGGESTIONS:', '').strip()
if rest.lower() not in ('none', '') and rest:
suggestions.append(rest)
elif in_suggestions and line.startswith('-'):
suggestion = line.lstrip('- ').strip()
if suggestion and suggestion.lower() != 'none':
suggestions.append(suggestion)
score, reasoning, suggestions, score_parsed = _extract_score_reasoning(response_text)
if not score_parsed:
# An unscored verdict is an error, not a middling result. Fail closed
# so a fabricated 5.0 can never be compared against - and pass - a
# threshold. A reasoning-only response has no trustworthy number.
# Mirrors safety.py / parse_score_reasoning.
logger.warning(
"Could not parse judge response; failing closed. Raw: %r",
response_text[:200],
)
score = 1.0
return JudgeResult(
score=score,
passed=score >= self.threshold,
passed=score_parsed and score >= self.threshold,
reasoning=reasoning,
output=output,
expected=expected,
@@ -29,11 +29,16 @@ from .protocols import (
# Config hot-reload observability (Issue #3049)
ReloadStatus,
compute_config_revision,
# Saturation / back-pressure observability (Issue #4265)
HealthPressure,
evaluate_pressure,
# Push protocols and dataclasses
PushChannelProtocol,
PresenceProtocol,
DeliveryGuaranteeProtocol,
OutboundDeliveryProtocol,
IdempotencyStoreProtocol,
InMemoryIdempotencyStore,
ChannelInfo,
PresenceInfo,
# Home channel and delivery protocols
@@ -159,6 +164,11 @@ from .protocols import (
TurnPlacement,
TurnExecutorProtocol,
InProcessTurnExecutor,
# Global operator emergency-stop / pause brake (Issue #4220)
EmergencyStopState,
EmergencyStopProtocol,
NullEmergencyStop,
FileEmergencyStop,
# Schema-validated inbound frame codec (Issue #2831)
HelloParams,
HelloResult,
@@ -209,6 +219,7 @@ from .config import (
GatewayConfig,
SessionConfig,
ApiConfig,
EmergencyStopConfig,
ChannelRouteConfig,
MultiChannelGatewayConfig,
# Config version stamp + doctor-driven migration (Issue #3841)
@@ -303,11 +314,15 @@ __all__ = [
# Config hot-reload observability (Issue #3049)
"ReloadStatus",
"compute_config_revision",
"HealthPressure",
"evaluate_pressure",
# Push protocols (always available)
"PushChannelProtocol",
"PresenceProtocol",
"DeliveryGuaranteeProtocol",
"OutboundDeliveryProtocol",
"IdempotencyStoreProtocol",
"InMemoryIdempotencyStore",
"ChannelInfo",
"PresenceInfo",
# Home channel and delivery protocols
@@ -429,6 +444,11 @@ __all__ = [
"TurnPlacement",
"TurnExecutorProtocol",
"InProcessTurnExecutor",
# Global operator emergency-stop / pause brake (Issue #4220)
"EmergencyStopState",
"EmergencyStopProtocol",
"NullEmergencyStop",
"FileEmergencyStop",
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams",
"HelloResult",
@@ -473,6 +493,7 @@ __all__ = [
"GatewayConfig",
"SessionConfig",
"ApiConfig",
"EmergencyStopConfig",
"ChannelRouteConfig",
"MultiChannelGatewayConfig",
# Config version stamp + doctor-driven migration (Issue #3841)
@@ -706,6 +706,71 @@ class ApiConfig:
)
@dataclass
class EmergencyStopConfig:
"""Configuration for the global operator emergency-stop brake (Issue #4220).
Selects the backend for the durable, fail-safe operator brake consulted at
every new-work admission seam (WebSocket inbound, HTTP/MCP inbound, kanban
dispatch, scheduler due-loop). The default ``"off"`` backend engages a
no-op brake so ``is_engaged()`` is always ``False`` — behaviour is
byte-for-byte today's, no new dependency, no hot-path change. Selecting
``"file"`` persists the engaged state to a sentinel at ``path`` so it
survives a crash/restart and is shared across lanes; an unreadable/corrupt
sentinel counts as *engaged* (fail-safe).
This maps onto the pure core
:class:`~praisonaiagents.gateway.protocols.EmergencyStopProtocol`;
:meth:`to_estop` builds the concrete brake.
Attributes:
backend: ``"off"`` (default, no-op) or ``"file"`` (durable sentinel).
path: Sentinel location for the ``"file"`` backend (required for it).
"""
backend: str = "off"
path: Optional[str] = None
def __post_init__(self) -> None:
if self.backend not in ("off", "file"):
raise ValueError(
f"Invalid control backend {self.backend!r}; expected 'off' or 'file'"
)
if self.backend == "file" and not self.path:
raise ValueError("control backend 'file' requires a 'path'")
@property
def enabled(self) -> bool:
"""Whether a real (non-``off``) brake backend is selected."""
return self.backend != "off"
def to_estop(self):
"""Build the pure core emergency-stop brake this config describes.
``"off"`` returns a :class:`NullEmergencyStop` (never engaged);
``"file"`` returns a durable, fail-safe :class:`FileEmergencyStop`.
"""
from .protocols import FileEmergencyStop, NullEmergencyStop
if self.backend == "file" and self.path:
return FileEmergencyStop(self.path)
return NullEmergencyStop()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {"backend": self.backend, "path": self.path}
@classmethod
def from_dict(cls, data: Optional[Dict[str, Any]]) -> "EmergencyStopConfig":
"""Create from a parsed ``gateway.control`` mapping (tolerant of None)."""
if not isinstance(data, dict):
return cls()
return cls(
backend=str(data.get("backend") or "off"),
path=data.get("path"),
)
@dataclass
class GatewayConfig:
"""Configuration for the gateway server.
@@ -783,6 +848,12 @@ class GatewayConfig:
# 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())
# Issue #4220: global operator emergency-stop / pause brake. Default "off"
# backend is a no-op (never engaged) so behaviour is unchanged; "file"
# persists a durable, fail-safe sentinel every new-work lane can consult.
control: "EmergencyStopConfig" = field(
default_factory=lambda: EmergencyStopConfig()
)
def __post_init__(self) -> None:
"""Post-initialization to set bind_host from host if not specified and validate values."""
@@ -905,6 +976,7 @@ class GatewayConfig:
"api": self.api.to_dict(),
"liveness": self.liveness.to_dict(),
"turn_lock": self.turn_lock.to_dict(),
"control": self.control.to_dict(),
}
@property
@@ -1096,6 +1168,7 @@ class MultiChannelGatewayConfig:
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")),
control=EmergencyStopConfig.from_dict(gw_data.get("control")),
)
# Parse agents section (pass through as dicts)
@@ -126,11 +126,27 @@ class LoopWatchdog:
self._last_ack = 0.0
self._last_probe = 0.0
self._wedged = False
# Continuously-measured scheduling lag (ms) of the most recently
# acked probe. Read back by the health surface as a saturation signal
# (Issue #4265) — the same measurement the wedge check already relies
# on, exposed rather than only compared against the wedge threshold.
self._probe_sent_at = 0.0
self._last_lag_ms = 0.0
@property
def armed(self) -> bool:
return self._thread is not None and self._thread.is_alive()
@property
def last_lag_ms(self) -> float:
"""Most recent event-loop scheduling lag in milliseconds.
The delay between scheduling a probe and the loop running its ack.
``0.0`` until the first probe round-trips. Read by the gateway health
surface as a forward saturation signal (Issue #4265).
"""
return self._last_lag_ms
@property
def wedged(self) -> bool:
"""True once a wedge has been confirmed (mainly for ``dump_only``)."""
@@ -176,7 +192,22 @@ class LoopWatchdog:
def _ack(self) -> None:
"""Callback run *on the loop*; records that the loop is alive."""
self._last_ack = time.monotonic()
now = time.monotonic()
self._last_ack = now
# Record the scheduling lag: how long the loop took to run this ack
# after we scheduled it. This is the same signal the wedge check uses,
# exposed as a gauge for the health surface (Issue #4265).
#
# ``_probe_sent_at`` is anchored to the *oldest still-unacked* probe,
# not the most recent one: when the loop stalls across several probe
# intervals the watchdog keeps scheduling fresh acks that all queue
# behind the wedge, and measuring from the newest probe would report a
# fraction of the true stall. Consuming the anchor here (setting it to
# 0.0) lets the next scheduled probe re-anchor from a caught-up loop.
sent = self._probe_sent_at
if sent:
self._last_lag_ms = max(0.0, (now - sent) * 1000.0)
self._probe_sent_at = 0.0
def _schedule_probe(self) -> bool:
"""Schedule an ack onto the loop. Returns False if scheduling fails."""
@@ -184,8 +215,16 @@ class LoopWatchdog:
if loop is None:
return False
try:
# Only (re-)anchor the lag clock when the previous probe has been
# acked (``_ack`` zeroes ``_probe_sent_at``). If the loop is stalled
# and earlier probes are still queued, keep the anchor pinned to the
# oldest unacked probe so the reported lag reflects the full stall
# rather than the gap since the newest probe (Issue #4265).
now = time.monotonic()
if not self._probe_sent_at:
self._probe_sent_at = now
loop.call_soon_threadsafe(self._ack)
self._last_probe = time.monotonic()
self._last_probe = now
return True
except RuntimeError:
# Loop is closed / not running — treat as not-scheduled but do not
@@ -200,6 +200,10 @@ class EventType(str, Enum):
TOOL_PROGRESS_STREAM = "tool_progress_stream"
STREAM_ERROR = "stream_error"
STREAM_END = "stream_end"
MODEL_FALLBACK_STREAM = "model_fallback_stream"
RETRY_STREAM = "retry_stream"
TODO_STREAM = "todo_stream"
TOOL_RESULT_STREAM = "tool_result_stream"
# System events
HEALTH = "health"
@@ -1095,6 +1099,10 @@ class GatewayProtocol(Protocol):
:func:`compute_config_revision`) of the config the gateway is
*actually running*, comparable against the on-disk revision to
detect drift.
- pressure: Optional :class:`HealthPressure` dict (admission /
inbound / outbound backlog + event-loop lag and a derived
``pressure`` classification), when the gateway runs back-pressure
machinery. See :class:`HealthPressure` and :func:`evaluate_pressure`.
"""
...
@@ -1190,6 +1198,165 @@ def compute_config_revision(config: Optional[Dict[str, Any]]) -> str:
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:12]
# ---------------------------------------------------------------------------
# Saturation / back-pressure observability (Issue #4265)
#
# The always-on gateway already *enforces* back-pressure — a concurrency
# ceiling with a bounded fair wait-queue (``AdmissionGate``), a durable
# outbound outbox with a dead-letter tier (``OutboundQueue``), and an
# event-loop wedge watchdog (``LoopWatchdog``) — but it exposes no saturation
# *telemetry*. The health surface reports liveness and config drift, yet never
# reports how close the gateway is to its limits, so the first signal an
# operator gets is dropped work (shed turns / stalled outbound) rather than a
# forward warning. This is the small, canonical contract — mirroring
# ``ReloadStatus`` exactly — that both the SDK's ``gateway.health()`` and the
# bot's ``/health`` populate so saturation is a first-class, observable signal.
# Core owns only the *shape* (a frozen dataclass) and a pure, deterministic
# classification helper; the wrapper reads back the facts the enforcement layer
# already computes and fills the block.
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class HealthPressure:
"""Observable saturation snapshot of the gateway's back-pressure machinery.
Populated by the running gateway and surfaced in
:meth:`GatewayProtocol.health` so an operator (or an autoscaler / a
``/ready``-driven load balancer) can ask "is the gateway backing up?"
*before* admission starts shedding turns — rather than inferring overload
post-mortem from shed-load log lines.
Attributes:
admission_max: Configured concurrency ceiling (``0`` when unbounded).
admission_in_flight: Turns currently running.
admission_queued: Turns currently waiting for a slot.
admission_shed_total: Cumulative turns shed since start.
inbox_pending: Aggregate pending inbound queue depth across sessions.
outbox_pending: Durable outbound backlog awaiting delivery.
outbox_dead_letter: Durable outbound entries in the dead-letter tier.
event_loop_lag_p99_ms: Recent event-loop scheduling lag (ms).
pressure: Single derived classification — ``"nominal"``,
``"elevated"`` or ``"saturated"`` (closed vocabulary) — so an
orchestrator can branch on one field.
"""
admission_max: int = 0
admission_in_flight: int = 0
admission_queued: int = 0
admission_shed_total: int = 0
inbox_pending: int = 0
outbox_pending: int = 0
outbox_dead_letter: int = 0
event_loop_lag_p99_ms: float = 0.0
pressure: Literal["nominal", "elevated", "saturated"] = "nominal"
def to_dict(self) -> Dict[str, Any]:
"""Convert to a JSON-serializable dict for the health surface."""
return {
"admission_max": self.admission_max,
"admission_in_flight": self.admission_in_flight,
"admission_queued": self.admission_queued,
"admission_shed_total": self.admission_shed_total,
"inbox_pending": self.inbox_pending,
"outbox_pending": self.outbox_pending,
"outbox_dead_letter": self.outbox_dead_letter,
"event_loop_lag_p99_ms": self.event_loop_lag_p99_ms,
"pressure": self.pressure,
}
def evaluate_pressure(
*,
admission: Optional[Dict[str, Any]] = None,
inbox_pending: int = 0,
outbox_pending: int = 0,
outbox_dead_letter: int = 0,
loop_lag_p99_ms: float = 0.0,
) -> HealthPressure:
"""Pure, deterministic classification of gateway load. No I/O.
Reads back facts the enforcement layer already computes
(``AdmissionGate.stats()``, the outbox ``status`` counts, the watchdog's
measured lag) and folds them into a single :class:`HealthPressure`. Lives
in core — like :func:`compute_config_revision` — so the SDK and the bot
classify identically without divergence.
Classification (highest wins):
* ``"saturated"`` — admission is full *and* work is queuing, the outbound
dead-letter tier is non-empty, or the event loop is lagging badly
(``>= 500 ms``): shed / stall is imminent or occurring.
* ``"elevated"`` — admission is highly utilised (``>= 80 %``), the inbound
or outbound backlog is building, or the loop is lagging (``>= 100 ms``).
* ``"nominal"`` — none of the above.
Args:
admission: An :meth:`AdmissionGate.stats` snapshot (or ``None``).
inbox_pending: Aggregate pending inbound queue depth.
outbox_pending: Durable outbound backlog.
outbox_dead_letter: Durable outbound dead-letter depth.
loop_lag_p99_ms: Recent event-loop scheduling lag in ms.
Returns:
A frozen :class:`HealthPressure` ready for the health surface.
"""
stats = admission or {}
def _int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
admission_max = _int(stats.get("max_concurrent_runs"))
in_flight = _int(stats.get("in_flight"))
queued = _int(stats.get("queued"))
shed_total = _int(stats.get("shed"))
inbox = max(0, _int(inbox_pending))
outbox = max(0, _int(outbox_pending))
dead_letter = max(0, _int(outbox_dead_letter))
try:
lag = float(loop_lag_p99_ms or 0.0)
except (TypeError, ValueError):
lag = 0.0
if lag < 0.0:
lag = 0.0
utilisation = (in_flight / admission_max) if admission_max > 0 else 0.0
saturated = (
(admission_max > 0 and in_flight >= admission_max and queued > 0)
or dead_letter > 0
or lag >= 500.0
)
elevated = (
utilisation >= 0.8
or queued > 0
or inbox > 0
or outbox > 0
or lag >= 100.0
)
if saturated:
pressure: Literal["nominal", "elevated", "saturated"] = "saturated"
elif elevated:
pressure = "elevated"
else:
pressure = "nominal"
return HealthPressure(
admission_max=admission_max,
admission_in_flight=in_flight,
admission_queued=queued,
admission_shed_total=shed_total,
inbox_pending=inbox,
outbox_pending=outbox,
outbox_dead_letter=dead_letter,
event_loop_lag_p99_ms=lag,
pressure=pressure,
)
# ---------------------------------------------------------------------------
# Push notification dataclasses
# ---------------------------------------------------------------------------
@@ -1567,6 +1734,90 @@ class OutboundDeliveryProtocol(Protocol):
...
@runtime_checkable
class IdempotencyStoreProtocol(Protocol):
"""Protocol for inbound webhook/trigger delivery deduplication.
The gateway's inbound HTTP hook surface must not start a second agent run
for an event a provider re-delivers (webhook providers routinely retry for
minutes to days). Deduplication is a three-step, restart-stable claim on the
deterministic idempotency key (``compute_idempotency_key``):
- ``reserve`` — atomically claim the key. Returns ``False`` when the key
was already recorded *or* is currently in flight, so both a redelivery
and a concurrent duplicate are rejected.
- ``record`` — commit the key after a successful run so future
redeliveries dedup.
- ``release`` — drop an in-flight reservation after a failed run so the
provider's retry can re-run.
This mirrors :class:`CallbackPayloadStoreProtocol`: the contract lives in
core so third-party gateways and a future ``redis`` backend interoperate;
core ships the bounded in-memory default (:class:`InMemoryIdempotencyStore`)
and the durable, restart-surviving SQLite backend lives in the
``praisonai-bot`` runtime (as the ingress journal and outbound queue do).
"""
def reserve(self, key: str) -> bool:
"""Atomically claim ``key``; ``False`` if seen-or-in-flight."""
...
def record(self, key: str) -> None:
"""Commit ``key`` as processed after a successful run."""
...
def release(self, key: str) -> None:
"""Drop an in-flight reservation so a failed delivery can be retried."""
...
class InMemoryIdempotencyStore:
"""Bounded, zero-dependency in-memory :class:`IdempotencyStoreProtocol`.
The default store for single-replica deployments — identical behaviour to
the gateway's original per-process ``OrderedDict`` + in-flight ``set``. It
is *not* durable: after a process restart the store is empty, so a durable
backend (SQLite/redis) must be injected for the restart/multi-replica case.
"""
def __init__(
self,
*,
max_entries: int = 10_000,
ttl_seconds: float = 86_400.0,
) -> None:
self._max_entries = max(1, int(max_entries))
self._ttl_seconds = float(ttl_seconds)
# key -> insertion time (seconds); insertion-ordered for FIFO eviction.
self._seen: "Dict[str, float]" = {}
self._inflight: Set[str] = set()
def _purge_expired(self, now: float) -> None:
if not self._seen:
return
expired = [k for k, ts in self._seen.items() if now - ts > self._ttl_seconds]
for k in expired:
self._seen.pop(k, None)
def reserve(self, key: str) -> bool:
now = time.time()
self._purge_expired(now)
if key in self._seen or key in self._inflight:
return False
self._inflight.add(key)
return True
def record(self, key: str) -> None:
self._inflight.discard(key)
self._seen[key] = time.time()
while len(self._seen) > self._max_entries:
oldest = next(iter(self._seen))
self._seen.pop(oldest, None)
def release(self, key: str) -> None:
self._inflight.discard(key)
# ---------------------------------------------------------------------------
# Per-route, trust-tiered toolset scoping (Issue #2298)
# ---------------------------------------------------------------------------
@@ -6024,8 +6275,16 @@ def resolve_required_scope(
# status/read needs READ. Anything unregistered defaults to ADMIN (deny).
def _register_core_gateway_methods() -> None:
core: Dict[str, OperatorScope] = {
# Connection lifecycle. These run before a client can hold any scope, so
# they must not require one -- classifying them ADMIN by omission is what
# kept resolve_required_scope() from ever being wired into dispatch.
"hello": OperatorScope.READ,
"join": OperatorScope.READ,
"leave": OperatorScope.READ,
"agent.message": OperatorScope.WRITE,
"message": OperatorScope.WRITE,
# Aborting a turn mutates it, so it carries the same scope as sending one.
"abort": OperatorScope.WRITE,
"session.status": OperatorScope.READ,
"session.transcript": OperatorScope.READ,
"approvals.resolve": OperatorScope.APPROVALS,
@@ -6041,3 +6300,195 @@ def _register_core_gateway_methods() -> None:
_register_core_gateway_methods()
# ---------------------------------------------------------------------------
# Global operator emergency-stop / pause brake (Issue #4220)
# ---------------------------------------------------------------------------
#
# A single, durable, fail-safe operator brake: "stop admitting NEW agent work
# everywhere, right now, but let in-flight runs finish — and resume later with
# no restart." Every new-work admission seam (WebSocket inbound, HTTP/MCP
# inbound, kanban dispatch cycle, scheduler due-loop) consults one shared
# contract instead of each lane deciding independently.
#
# Design mirrors the drain / idle / admission / liveness policy family already
# in this module: a pure ``Protocol`` in core plus a lightweight default and a
# durable fail-safe backend. When no brake is engaged, ``is_engaged()`` is
# ``False`` and behaviour is byte-for-byte today's — zero cost, no new
# dependency, fully backward-compatible.
@dataclass(frozen=True)
class EmergencyStopState:
"""Immutable snapshot of the operator brake, for status/audit surfaces.
``engaged`` is the only field the hot path needs; ``reason``/``actor``/
``at`` provide the optional audit trail surfaced in ``/health``/status.
"""
engaged: bool
reason: str = ""
actor: str = ""
at: float = 0.0
def to_dict(self) -> Dict[str, Any]:
return {
"engaged": self.engaged,
"reason": self.reason,
"actor": self.actor,
"at": self.at,
}
@runtime_checkable
class EmergencyStopProtocol(Protocol):
"""Pure contract for the global operator brake consulted at every seam.
Fail-safe rule: if the engaged state cannot be read with confidence (a
corrupt / unreadable durable sentinel), ``is_engaged()`` MUST return
``True`` — an ambiguous brake holds new work rather than letting it run
freely. ``engage``/``disengage`` are idempotent.
"""
def is_engaged(self) -> bool: ...
def engage(self, *, reason: str = "", actor: str = "") -> None: ...
def disengage(self) -> None: ...
def state(self) -> EmergencyStopState: ...
class NullEmergencyStop:
"""Default no-op brake: never engaged, no persistence.
Selected by ``backend: "off"`` (the default) so a gateway with no brake
configured behaves exactly as before — ``is_engaged()`` is always
``False`` and ``engage``/``disengage`` are inert.
"""
def is_engaged(self) -> bool:
return False
def engage(self, *, reason: str = "", actor: str = "") -> None:
return None
def disengage(self) -> None:
return None
def state(self) -> EmergencyStopState:
return EmergencyStopState(engaged=False)
class FileEmergencyStop:
"""Durable, fail-safe file-sentinel operator brake.
Engaging writes a small JSON sentinel at ``path``; disengaging removes it.
The engaged state therefore survives a crash/restart (durable) and is
shared by every lane that consults the same path.
Fail-safe: any error reading the sentinel — a partially written file,
corrupt JSON, a permission error — is treated as *engaged*. An ambiguous
brake holds new work; it never silently falls open to "run freely". The
common, unambiguous case (sentinel simply absent) reports not-engaged.
Pure-stdlib (``os``/``json``): no new dependency, safe to live in core.
"""
def __init__(self, path: str) -> None:
if not path:
raise ValueError("FileEmergencyStop requires a non-empty sentinel path")
import os
self._path = os.path.expanduser(str(path))
def is_engaged(self) -> bool:
import os
if not os.path.exists(self._path):
return False
# Present but unreadable/corrupt => fail-safe engaged.
try:
with open(self._path, "r", encoding="utf-8") as fh:
fh.read()
return True
except OSError:
return True
def engage(self, *, reason: str = "", actor: str = "") -> None:
import json
import os
directory = os.path.dirname(self._path)
if directory:
os.makedirs(directory, exist_ok=True)
payload = {
"engaged": True,
"reason": str(reason or ""),
"actor": str(actor or ""),
"at": time.time(),
}
# Atomic replace so a reader never observes a half-written sentinel
# (which would fail-safe engaged anyway, but this keeps it clean).
# ``mkstemp`` in the sentinel's own directory yields an unpredictable
# name owned by us, so a local attacker cannot pre-create a symlink to
# redirect the write. fsync + parent-dir fsync make an engaged brake
# durable across an abrupt crash.
import tempfile
fd, tmp = tempfile.mkstemp(
dir=directory or ".", prefix=".gateway.pause.", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
json.dump(payload, fh)
fh.flush()
os.fsync(fh.fileno())
os.replace(tmp, self._path)
except BaseException:
try:
os.remove(tmp)
except OSError:
pass
raise
if directory:
try:
dir_fd = os.open(directory, os.O_RDONLY)
try:
os.fsync(dir_fd)
finally:
os.close(dir_fd)
except OSError:
pass
def disengage(self) -> None:
import os
try:
os.remove(self._path)
except FileNotFoundError:
return None
def state(self) -> EmergencyStopState:
import json
import os
if not os.path.exists(self._path):
return EmergencyStopState(engaged=False)
try:
with open(self._path, "r", encoding="utf-8") as fh:
data = json.load(fh)
if not isinstance(data, dict):
raise ValueError("sentinel is not a JSON object")
return EmergencyStopState(
engaged=True,
reason=str(data.get("reason", "")),
actor=str(data.get("actor", "")),
at=float(data.get("at", 0.0) or 0.0),
)
except (OSError, TypeError, ValueError):
# Fail-safe: unreadable/corrupt sentinel counts as engaged. A valid
# JSON object with a non-numeric ``at`` (e.g. a list) raises
# TypeError from ``float()`` and must also fail-safe engaged.
return EmergencyStopState(engaged=True, reason="unreadable-sentinel")
@@ -512,7 +512,7 @@ class Knowledge:
# Check if input is URL
if isinstance(input_path, str) and (input_path.startswith('http://') or input_path.startswith('https://')):
self._log(f"Processing URL: {input_path}")
raise NotImplementedError("URL processing not yet implemented")
return self._process_url(input_path, user_id, agent_id, run_id, metadata)
# CRITICAL FIX: Check if input is a directory - recursively process all files
if os.path.isdir(input_path):
@@ -626,7 +626,65 @@ class Knowledge:
except Exception as e:
logger.error(f"Error processing input {input_path}: {str(e)}", exc_info=True)
raise
def _process_url(self, url, user_id=None, agent_id=None, run_id=None, metadata=None):
"""Fetch a URL, extract its content, and store it in memory.
Uses MarkItDown (already a dependency for document extraction) to fetch
and convert the remote resource, then chunks and stores it through the
same path as local files. Failures are surfaced to the caller so a
single bad URL in a batch is visible, matching local-file behaviour.
"""
result = self.markdown.convert(url)
content = getattr(result, 'text_content', None)
if not content:
raise ValueError(f"No content could be extracted from URL: {url}")
chunks = self.chunker.chunk(content)
memories = [
chunk.text.strip() if hasattr(chunk, 'text') else str(chunk).strip()
for chunk in chunks if chunk
]
if not metadata:
metadata = {}
metadata['source_type'] = 'url'
metadata['source'] = url
all_results = []
for memory in memories:
if not memory:
continue
memory_result = self.store(
memory, user_id=user_id, agent_id=agent_id,
run_id=run_id, metadata=metadata,
)
if not memory_result:
continue
if isinstance(memory_result, dict):
all_results.extend(memory_result.get('results', []))
elif isinstance(memory_result, list):
all_results.extend(memory_result)
else:
from .models import AddResult
if isinstance(memory_result, AddResult):
if not memory_result.success:
raise RuntimeError(
memory_result.message or "Failed to store chunk"
)
all_results.append(memory_result.id)
elif hasattr(memory_result, 'results'):
all_results.extend(memory_result.results)
elif isinstance(memory_result, str):
# Some backends (e.g. MongoDB) return a bare document id.
all_results.append(memory_result)
self._emit_knowledge_event(
"add", source=url, chunk_count=len(memories),
metadata=metadata, agent_id=agent_id,
)
return {'results': all_results, 'relations': []}
def index(
self,
path: str,
@@ -107,6 +107,22 @@ def __getattr__(name):
from .rate_limiter import RateLimiter
_lazy_cache[name] = RateLimiter
return RateLimiter
elif name == "QuotaCoordinatorProtocol":
from .quota import QuotaCoordinatorProtocol
_lazy_cache[name] = QuotaCoordinatorProtocol
return QuotaCoordinatorProtocol
elif name == "QuotaCoordinatorConfig":
from .quota import QuotaCoordinatorConfig
_lazy_cache[name] = QuotaCoordinatorConfig
return QuotaCoordinatorConfig
elif name == "LocalQuotaCoordinator":
from .quota import LocalQuotaCoordinator
_lazy_cache[name] = LocalQuotaCoordinator
return LocalQuotaCoordinator
elif name == "build_quota_coordinator":
from .quota import build_quota_coordinator
_lazy_cache[name] = build_quota_coordinator
return build_quota_coordinator
elif name == "TokenUsage":
from .llm import TokenUsage
_lazy_cache[name] = TokenUsage
@@ -229,6 +245,11 @@ __all__ = [
"create_routing_agent",
"RateLimiter",
"TokenUsage",
# Provider quota coordination (cross-replica credential cooldowns)
"QuotaCoordinatorProtocol",
"QuotaCoordinatorConfig",
"LocalQuotaCoordinator",
"build_quota_coordinator",
# Protocols
"LLMProviderProtocol",
"ModelCapabilitiesProtocol",
@@ -16,6 +16,7 @@ import time
from dataclasses import dataclass, field
from enum import Enum
from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
@@ -25,6 +26,9 @@ from typing import (
runtime_checkable,
)
if TYPE_CHECKING:
from .quota import QuotaCoordinatorProtocol
logger = get_logger(__name__)
class ProviderStatus(str, Enum):
@@ -112,6 +116,29 @@ class AuthProfile:
metadata=data.get("metadata", {}),
)
@property
def credential_id(self) -> str:
"""Stable, non-secret identity for cross-replica quota coordination.
The coordinator keys benches by the *credential*, not the profile
label. ``name`` is a local display label: two replicas may name the
same key differently (breaking cooldown propagation) or reuse a name
like ``"default"`` for different keys (benching an unrelated key).
Derive a deterministic id from the fields that identify the credential
(provider, api_key, base_url) using a short salted hash so the real key
is never exposed. Falls back to ``name`` when no api_key is present.
"""
if not self.api_key:
return self.name
import hashlib
digest = hashlib.sha256(
f"{self.provider}\x00{self.base_url or ''}\x00{self.api_key}".encode(
"utf-8", "ignore"
)
).hexdigest()[:16]
return f"{self.provider}:{digest}"
@property
def is_available(self) -> bool:
"""Check if this profile is currently available."""
@@ -207,13 +234,27 @@ class FailoverManager:
profile = manager.get_next_profile()
"""
def __init__(self, config: Optional[FailoverConfig] = None):
def __init__(
self,
config: Optional[FailoverConfig] = None,
coordinator: Optional["QuotaCoordinatorProtocol"] = None,
):
"""Initialize the failover manager.
Args:
config: Failover configuration
coordinator: Optional shared quota coordinator. When supplied
(e.g. a Redis-backed one wired by the gateway), credential
benches/cooldowns are coordinated fleet-wide so a key benched
by any replica is skipped by all replicas. Defaults to an
in-memory local coordinator, keeping single-replica behaviour
exactly as before with no new dependency and no hot-path cost.
"""
self.config = config or FailoverConfig()
if coordinator is None:
from .quota import LocalQuotaCoordinator
coordinator = LocalQuotaCoordinator()
self._coordinator: "QuotaCoordinatorProtocol" = coordinator
self._profiles: List[AuthProfile] = []
self._current_index: int = 0
self._on_failover_callbacks: List[Callable[[AuthProfile, AuthProfile], None]] = []
@@ -274,6 +315,30 @@ class FailoverManager:
with self._lock:
return list(self._profiles)
def _sync_bench_from_coordinator(
self, profile: AuthProfile, now: Optional[float] = None
) -> None:
"""Apply a fleet-wide bench from the coordinator onto a local profile.
If another replica benched this credential, reflect its cooldown here
so this replica skips it too. Fail-open: never raise into failover.
Callers already hold ``self._lock``.
"""
try:
until = self._coordinator.benched_until(profile.credential_id, now=now)
except Exception as e: # pragma: no cover - defensive
logger.warning(
f"Quota coordinator lookup failed for '{profile.name}': {e}; "
f"using local cooldown state"
)
return
if until is not None and (
profile.cooldown_until is None or until > profile.cooldown_until
):
if profile.status == ProviderStatus.AVAILABLE:
profile.status = ProviderStatus.RATE_LIMITED
profile.cooldown_until = until
def get_next_profile(self) -> Optional[AuthProfile]:
"""Get the next available profile.
@@ -287,9 +352,10 @@ class FailoverManager:
if not self._profiles:
return None
# First, check if any cooldowns have expired
# First, sync fleet-wide benches, then check if any cooldowns have expired.
current_time = time.time()
for profile in self._profiles:
self._sync_bench_from_coordinator(profile, now=current_time)
if profile.cooldown_until and current_time >= profile.cooldown_until:
profile.reset()
@@ -328,12 +394,29 @@ class FailoverManager:
f"Profile '{profile.name}' rate limited, "
f"cooldown for {self.config.cooldown_on_rate_limit}s"
)
reason = "rate_limit"
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"
)
reason = "error"
# Share the bench fleet-wide so other replicas skip this key too.
# Fail-open: a coordinator hiccup must never wedge failover.
if profile.cooldown_until is not None:
try:
self._coordinator.bench(
profile.credential_id,
until=profile.cooldown_until,
reason=reason,
)
except Exception as e: # pragma: no cover - defensive
logger.warning(
f"Quota coordinator bench failed for '{profile.name}': {e}; "
f"falling back to local cooldown only"
)
# Resolve the target profile and snapshot callbacks while holding
# the lock, but invoke them AFTER releasing it. User callbacks may
@@ -361,8 +444,30 @@ class FailoverManager:
"""
with self._lock:
if profile.status != ProviderStatus.AVAILABLE:
# Snapshot the cooldown this success is recovering from BEFORE
# resetting, so we can avoid clobbering a newer fleet-wide bench.
recovered_from = profile.cooldown_until
profile.reset()
logger.info(f"Profile '{profile.name}' recovered")
cred_id = profile.credential_id
try:
# A concurrent/other-replica failure may have benched this
# credential with a LATER expiry than the cooldown this
# (possibly stale, already in-flight) success is recovering
# from. Clearing unconditionally would resume a credential
# that is still rate-limited. Only clear when the shared
# bench is not newer than what we recovered from.
shared_until = self._coordinator.benched_until(cred_id)
if (
shared_until is None
or recovered_from is None
or shared_until <= recovered_from
):
self._coordinator.clear(cred_id)
except Exception as e: # pragma: no cover - defensive
logger.warning(
f"Quota coordinator clear failed for '{profile.name}': {e}"
)
def on_failover(
self,
+105 -11
View File
@@ -28,6 +28,14 @@ from ..tools.call_executor import ToolCall, create_tool_call_executor
from ..tools.schema import build_tool_definition
# Sentinel returned as the "arguments" value when a tool call's argument string
# cannot be parsed (e.g. truncated by max_tokens or a dropped connection).
# It is distinct from {} ("no arguments"): {} would silently execute the tool
# with the WRONG arguments and report success. Callers must detect this sentinel
# and surface a tool-error so the model can re-emit the call instead.
_TOOL_ARGUMENTS_PARSE_FAILED = object()
def _durable_iteration_kwargs(execute_tool_fn: Callable, index: int) -> Dict[str, int]:
if getattr(execute_tool_fn, "_accepts_durable_iteration", False):
return {"_durable_iteration_index": index}
@@ -1844,7 +1852,10 @@ Respond with ONLY a valid JSON tool call in this format:
if isinstance(tool_call.get("function"), dict)
else None
) or tool_call.get("name", "unknown_function")
arguments = {}
# A parse failure is NOT "no arguments". Returning {} here would run
# the tool with the wrong arguments and report success. Signal the
# failure so callers surface a tool-error instead of dispatching.
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
tool_call_id = tool_call.get("id", f"tool_{id(tool_call)}")
return function_name, arguments, tool_call_id
@@ -2399,8 +2410,8 @@ Respond with ONLY a valid JSON tool call in this format:
# Handle Gemini internal tools (e.g., {"googleSearch": {}}, {"urlContext": {}}, {"codeExecution": {}})
elif isinstance(tool, dict) and len(tool) == 1:
tool_name = next(iter(tool.keys()))
gemini_internal_tools = {'googleSearch', 'urlContext', 'codeExecution'}
if tool_name in gemini_internal_tools:
from .model_capabilities import GEMINI_INTERNAL_TOOLS
if tool_name in GEMINI_INTERNAL_TOOLS:
logging.debug(f"Using Gemini internal tool: {tool_name}")
formatted_tools.append(tool)
else:
@@ -2509,7 +2520,7 @@ Respond with ONLY a valid JSON tool call in this format:
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict]] = None,
temperature: float = 1.0,
temperature: Optional[float] = None,
tools: Optional[List[Any]] = None,
output_json: Optional[BaseModel] = None,
output_pydantic: Optional[BaseModel] = None,
@@ -2826,10 +2837,17 @@ Respond with ONLY a valid JSON tool call in this format:
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
is_ollama = self._is_ollama_provider()
tool_calls_batch = []
parse_error_messages = []
# Prepare batch of ToolCall objects
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama=is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
parse_error_messages.append(
self._tool_parse_error_message(function_name, tool_call_id)
)
continue
tool_calls_batch.append(ToolCall(
function_name=function_name,
arguments=arguments,
@@ -2903,6 +2921,11 @@ Respond with ONLY a valid JSON tool call in this format:
"content": content,
})
# Report any unparseable tool-call arguments so the
# model can re-emit them (never dispatched with {}).
for _err_msg in parse_error_messages:
messages.append(_err_msg)
# Safety: break after max_iter iterations
if iteration_count + 1 >= max_iterations:
self._last_stop_reason = "max_steps"
@@ -3606,6 +3629,11 @@ Respond with ONLY a valid JSON tool call in this format:
is_ollama = self._is_ollama_provider()
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
# Validate and filter arguments for Ollama provider
if is_ollama and tools:
# First check if any argument references a previous tool result
@@ -4165,7 +4193,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict]] = None,
temperature: float = 1.0,
temperature: Optional[float] = None,
tools: Optional[List[Any]] = None,
output_json: Optional[BaseModel] = None,
output_pydantic: Optional[BaseModel] = None,
@@ -4350,10 +4378,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Execute tool calls using ToolCallExecutor (Gap 2: parallel or sequential)
is_ollama = self._is_ollama_provider()
tool_calls_batch = []
parse_error_messages = []
# Prepare batch of ToolCall objects
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
parse_error_messages.append(
self._tool_parse_error_message(function_name, tool_call_id)
)
continue
tool_calls_batch.append(ToolCall(
function_name=function_name,
arguments=arguments,
@@ -4393,6 +4428,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_result.is_ollama
)
messages.append(tool_message)
# Report any unparseable tool-call arguments so the
# model can re-emit them (never dispatched with {}).
for _err_msg in parse_error_messages:
messages.append(_err_msg)
# Continue conversation after tool execution - get follow-up response
try:
@@ -4500,7 +4540,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
chat_history: Optional[List[Dict]] = None,
temperature: float = 1.0,
temperature: Optional[float] = None,
tools: Optional[List[Any]] = None,
output_json: Optional[BaseModel] = None,
output_pydantic: Optional[BaseModel] = None,
@@ -4730,6 +4770,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
for tool_call in tool_calls:
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
tool_result = await _dispatch_async_tool(
execute_tool_fn,
@@ -4979,6 +5023,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
is_ollama = self._is_ollama_provider()
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call, is_ollama)
if self._tool_arguments_parse_failed(arguments):
# Do NOT dispatch with {}: surface a retryable error.
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
continue
# Validate and filter arguments for Ollama provider
if is_ollama and tools:
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
@@ -5689,6 +5738,16 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Override with any provided parameters
params.update(override_params)
# Resolve temperature from the instance when the caller did not specify
# one. Public entry points now default temperature to None so a value
# configured via Agent(llm={'temperature': ...}) actually reaches the
# request instead of being silently overridden by a hardcoded 1.0.
if params.get("temperature") is None:
if self.temperature is not None:
params["temperature"] = self.temperature
else:
params.pop("temperature", None)
# Handle structured output parameters
output_json = override_params.get('output_json')
@@ -6323,6 +6382,17 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
is_reasoning=False,
))
elif evt_type == "response.reasoning_summary_text.delta":
delta_text = (event.get("delta", "") if isinstance(event, dict)
else getattr(event, "delta", ""))
if _emit and delta_text:
stream_callback(StreamEvent(
type=StreamEventType.DELTA_TEXT,
timestamp=time.perf_counter(),
content=delta_text,
is_reasoning=True,
))
elif evt_type == "response.function_call_arguments.delta":
idx = (event.get("output_index", 0) if isinstance(event, dict)
else getattr(event, "output_index", 0))
@@ -6435,17 +6505,41 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
tool_call_id = tool_call.id
except (json.JSONDecodeError, AttributeError) as e:
logging.error(f"Error parsing object-style tool call: {e}")
function_name = "unknown_function"
arguments = {}
tool_call_id = f"tool_{id(tool_call)}"
function_name = getattr(getattr(tool_call, 'function', None), 'name', None) or "unknown_function"
# See _parse_tool_call_arguments: a parse failure is not {}.
arguments = _TOOL_ARGUMENTS_PARSE_FAILED
tool_call_id = getattr(tool_call, 'id', None) or f"tool_{id(tool_call)}"
return function_name, arguments, tool_call_id
@staticmethod
def _tool_arguments_parse_failed(arguments) -> bool:
"""True when tool-call arguments could not be parsed (vs. legitimately empty)."""
return arguments is _TOOL_ARGUMENTS_PARSE_FAILED
@staticmethod
def _tool_parse_error_message(function_name: str, tool_call_id: str) -> Dict[str, str]:
"""Build a tool-role message telling the model its arguments were lost.
Surfacing this instead of dispatching with {} converts a silent
wrong-action-reported-as-success into a visible, retryable error.
"""
return {
"role": "tool",
"tool_call_id": tool_call_id,
"content": (
f"Error: arguments for tool '{function_name}' could not be parsed "
f"(the argument string was invalid or truncated). "
f"The tool was NOT executed. Please re-emit the tool call with "
f"complete, valid JSON arguments."
),
}
# Response without tool calls
def response(
self,
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
temperature: float = 1.0,
temperature: Optional[float] = None,
stream: bool = True,
verbose: bool = True,
markdown: bool = True,
@@ -6539,7 +6633,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
self,
prompt: Union[str, List[Dict]],
system_prompt: Optional[str] = None,
temperature: float = 1.0,
temperature: Optional[float] = None,
stream: bool = True,
verbose: bool = True,
markdown: bool = True,
@@ -1579,10 +1579,13 @@ class OpenAIClient:
params = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": stream,
**kwargs
}
# Omit temperature when unset so the provider default applies rather
# than sending an explicit None.
if temperature is not None:
params["temperature"] = temperature
# Add tools if provided
if tools:
@@ -1640,10 +1643,13 @@ class OpenAIClient:
params = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": stream,
**kwargs
}
# Omit temperature when unset so the provider default applies rather
# than sending an explicit None.
if temperature is not None:
params["temperature"] = temperature
# Add tools if provided
if tools:
@@ -0,0 +1,137 @@
"""
Provider Quota Coordination for PraisonAI Agents.
Provides a lightweight, protocol-driven seam so provider-quota state
(credential cooldowns / benches) can be coordinated *fleet-wide* when a
shared backend is configured, while defaulting to today's in-process,
in-memory behaviour with zero new dependencies and no hot-path change.
Design:
- ``QuotaCoordinatorProtocol`` is a pure contract (stdlib + typing only).
- ``LocalQuotaCoordinator`` is the default in-memory implementation used
when no shared backend is selected — single-replica deployments behave
exactly as before.
- A concrete shared backend (e.g. Redis, reusing the gateway's existing
connection) implements the same protocol in the wrapper/bot package and
is selected via ``QuotaCoordinatorConfig``. Core keeps only the protocol
and the in-memory default.
Fail-open by contract: implementations MUST degrade to local behaviour on
backend errors rather than wedge a healthy request path, and bench entries
carry TTLs so a dead replica's benches self-expire.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Dict, Optional, Protocol, runtime_checkable
@runtime_checkable
class QuotaCoordinatorProtocol(Protocol):
"""Contract for coordinating provider-quota state across replicas.
A coordinator tracks per-credential cooldowns ("benches"). When a shared
backend is configured, a credential benched by any replica is treated as
benched by all replicas until its cooldown expires.
"""
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
"""Bench a credential until ``until`` (an epoch timestamp, TTL-based)."""
...
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
"""Return True if the credential is currently benched."""
...
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
"""Return the epoch timestamp the credential is benched until, or None."""
...
def clear(self, cred_id: str) -> None:
"""Clear any bench for the credential (e.g. on recovery)."""
...
@dataclass
class QuotaCoordinatorConfig:
"""Configuration for provider-quota coordination.
Attributes:
backend: ``"local"`` (in-memory, default) or a shared backend name
such as ``"redis"`` provided by the wrapper/bot package.
url: Optional backend URL; when omitted a shared backend reuses the
already-established connection (e.g. gateway ``RedisConfig``).
"""
backend: str = "local"
url: Optional[str] = None
def to_dict(self) -> Dict[str, Optional[str]]:
return {"backend": self.backend, "url": self.url}
@classmethod
def from_dict(cls, data: Dict[str, Optional[str]]) -> "QuotaCoordinatorConfig":
return cls(
backend=(data.get("backend") or "local"),
url=data.get("url"),
)
class LocalQuotaCoordinator:
"""In-memory ``QuotaCoordinatorProtocol`` (default, zero-dependency).
Thread-safe via a lightweight lock. Bench entries carry an expiry so
they self-clear once the cooldown passes — mirroring the TTL semantics a
shared backend uses so behaviour is identical across implementations.
"""
def __init__(self) -> None:
import threading
self._benches: Dict[str, float] = {}
self._lock = threading.Lock()
def bench(self, cred_id: str, *, until: float, reason: str = "") -> None:
with self._lock:
existing = self._benches.get(cred_id)
# Keep the longest cooldown if a longer one is already set.
if existing is None or until > existing:
self._benches[cred_id] = until
def is_benched(self, cred_id: str, *, now: Optional[float] = None) -> bool:
return self.benched_until(cred_id, now=now) is not None
def benched_until(self, cred_id: str, *, now: Optional[float] = None) -> Optional[float]:
current = time.time() if now is None else now
with self._lock:
until = self._benches.get(cred_id)
if until is None:
return None
if current >= until:
# Expired — self-clear.
self._benches.pop(cred_id, None)
return None
return until
def clear(self, cred_id: str) -> None:
with self._lock:
self._benches.pop(cred_id, None)
def build_quota_coordinator(
config: Optional[QuotaCoordinatorConfig] = None,
) -> QuotaCoordinatorProtocol:
"""Build a coordinator from config, defaulting to the local in-memory one.
Only the ``"local"`` backend is resolvable from core. Shared backends
(e.g. ``"redis"``) are provided by the wrapper/bot package, which passes
an already-constructed coordinator instance to ``FailoverManager``; this
helper falls back to local for unknown backends so core never grows a
heavy optional dependency.
"""
if config is None or config.backend == "local":
return LocalQuotaCoordinator()
# Unknown/heavy backend not resolvable in core: fail open to local.
return LocalQuotaCoordinator()
@@ -2,6 +2,7 @@ import os
import sqlite3
import json
import time
import asyncio
import shutil
import threading
from typing import Any, Dict, List, Optional, Union, Literal
@@ -279,6 +280,27 @@ class Memory(SearchMixin, MemoryCoreMixin):
# Initialize use_vector_search to False for non-MongoDB providers
self.use_vector_search = False
@staticmethod
def _path_safe_suffix(user_id: Any) -> str:
"""Return a filesystem-safe ``_<user_id>`` suffix for default store paths.
The scoping ``user_id`` doubles as a metadata filter, so it may be any
string. When it is embedded into default sqlite/chroma paths a raw value
containing separators or traversal (e.g. ``a/b`` or ``../escape``) would
create stores outside the project-data dir or break sqlite init. Reject
path separators/parent refs and keep only path-safe characters; a bare
store with no user_id keeps the original shared defaults.
"""
if not user_id:
return ""
text = str(user_id)
if ".." in text or "/" in text or "\\" in text or "\x00" in text:
raise ValueError(
"memory user_id must not contain path separators or parent references"
)
safe = "".join(c if (c.isalnum() or c in "-_.") else "_" for c in text.strip())
return f"_{safe}" if safe else ""
def _get_adapter_config(self) -> Dict[str, Any]:
"""Get configuration for adapter initialization."""
# Only include adapter-relevant configuration
@@ -296,11 +318,18 @@ class Memory(SearchMixin, MemoryCoreMixin):
from ..paths import get_project_data_dir
project_data = str(get_project_data_dir())
os.makedirs(project_data, exist_ok=True)
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, "chroma_db"))
config["collection_name"] = self.cfg.get("collection_name", "memory_store")
# When a user_id scopes this store, derive per-user default paths/names so
# two agents in one process/dir don't silently share one on-disk store.
# Explicit short_db/long_db/rag_db_path/collection_name always win; a bare
# store with no user_id keeps the original shared defaults (backwards
# compatible).
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
config["short_db"] = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
config["long_db"] = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
config["rag_db_path"] = self.cfg.get("rag_db_path", os.path.join(project_data, f"chroma_db{suffix}"))
config["collection_name"] = self.cfg.get("collection_name", f"memory_store{suffix}")
config["verbose"] = self.verbose
# Add specific configurations for different adapters
@@ -337,9 +366,13 @@ class Memory(SearchMixin, MemoryCoreMixin):
# This ensures existing code that accesses _get_stm_conn() still works
from ..paths import get_project_data_dir
project_data = str(get_project_data_dir())
self.short_db = self.cfg.get("short_db", os.path.join(project_data, "short_term.db"))
self.long_db = self.cfg.get("long_db", os.path.join(project_data, "long_term.db"))
# Scope legacy direct-SQLite defaults by user_id so this path matches the
# per-user store paths derived in _get_adapter_config (explicit paths win).
suffix = self._path_safe_suffix(self.cfg.get("user_id"))
self.short_db = self.cfg.get("short_db", os.path.join(project_data, f"short_term{suffix}.db"))
self.long_db = self.cfg.get("long_db", os.path.join(project_data, f"long_term{suffix}.db"))
# Only create separate SQLite adapter if primary adapter is not SQLite
if self.provider != "sqlite":
@@ -358,6 +391,110 @@ class Memory(SearchMixin, MemoryCoreMixin):
self._all_connections = self._sqlite_adapter._all_connections
self._connection_lock = self._sqlite_adapter._connection_lock
@staticmethod
def _resolve_batch_target(method_name: str) -> str:
"""Map a staged write name to its ``store_*`` persistence method."""
target = method_name.replace("add_", "store_", 1)
if target not in ("store_short_term", "store_long_term"):
raise ValueError(f"Unsupported staged memory write {method_name!r}")
return target
def commit_memory_batch(
self,
writes: List[tuple],
*,
deadline: float,
commit_guard: Any = None,
) -> None:
"""Persist a batch of staged memory writes.
Mirrors ``FileMemory.commit_memory_batch`` so the pre-compaction fact
flush (compaction/memory_flush.py) durably commits extracted facts for
sqlite/chroma/mongodb/mem0 backends instead of raising and being
swallowed. Each staged tuple is ``(method_name, args, kwargs)`` produced
by _StagedMemory; ``add_*`` names map to the ``store_*`` equivalents.
The ``store_*`` methods serialize their own SQLite writes with
``_write_lock``; this method therefore must NOT hold that lock while
calling them (it is a non-reentrant ``threading.Lock`` and doing so
would deadlock). Each write goes through ``commit_guard.commit`` so a
write is skipped once the flush has been cancelled or the shared
``deadline`` has passed, honoring the same atomic-commit contract as the
file backend.
"""
for method_name, args, kwargs in writes:
target = self._resolve_batch_target(method_name)
store = getattr(self, target)
authorized = self._authorize_commit(
lambda store=store, args=args, kwargs=kwargs: store(*args, **kwargs),
deadline=deadline,
commit_guard=commit_guard,
)
if not authorized:
return
async def acommit_memory_batch(
self,
writes: List[tuple],
*,
deadline: float,
commit_guard: Any = None,
) -> None:
"""Async variant preferring ``store_*_async`` when available.
Blocking ``store_*`` work is offloaded to a worker thread so the event
loop is never blocked, and each write is gated by the same deadline /
cancellation guard as the sync path so late or cancelled facts are not
persisted after compaction resumes.
"""
import inspect
for method_name, args, kwargs in writes:
if self._commit_expired(deadline, commit_guard):
return
target = self._resolve_batch_target(method_name)
async_method = getattr(self, f"{target}_async", None)
if async_method is not None:
if self._commit_expired(deadline, commit_guard):
return
result = async_method(*args, **kwargs)
if inspect.isawaitable(result):
await result
continue
store = getattr(self, target)
authorized = await asyncio.to_thread(
self._authorize_commit,
lambda store=store, args=args, kwargs=kwargs: store(*args, **kwargs),
deadline=deadline,
commit_guard=commit_guard,
)
if not authorized:
return
@staticmethod
def _commit_expired(deadline: float, commit_guard: Any) -> bool:
"""True once the flush was cancelled or the shared deadline has passed."""
if commit_guard is not None and getattr(commit_guard, "cancelled", False):
return True
return time.monotonic() >= deadline
def _authorize_commit(
self, operation: Any, *, deadline: float, commit_guard: Any
) -> bool:
"""Run ``operation`` under the guard's atomic-commit gate.
Returns ``False`` (and skips the write) once cancelled or past the
deadline. When no guard is supplied, fall back to a plain deadline
check so callers outside the flush path still behave correctly.
"""
if commit_guard is not None and hasattr(commit_guard, "commit"):
return commit_guard.commit(operation, deadline=deadline)
if self._commit_expired(deadline, commit_guard):
return False
operation()
return True
def _get_stm_conn(self):
"""Get thread-local short-term memory SQLite connection."""
if not hasattr(self._local, 'stm_conn') or self._local.stm_conn is None:
@@ -1412,13 +1549,17 @@ class Memory(SearchMixin, MemoryCoreMixin):
elif memory_type == "long_term":
return self.delete_long_term(memory_id)
# Search both types
if self.delete_short_term(memory_id):
return True
if self.delete_long_term(memory_id):
return True
return False
# No tier given: delete from BOTH tiers, never short-circuit on the first
# hit. The SQLite adapter gives each tier its own AUTOINCREMENT sequence
# (sqlite_adapter.py:83 and :107), so the Nth short-term row and the Nth
# long-term row always share an id -- collision is the norm, not an edge
# case. Returning on the first hit therefore deleted an unrelated
# short-term row and left the caller's actual target in place, while
# reporting success. `forget()` exposes no tier argument at all, so a
# caller has no way to disambiguate.
deleted_short = self.delete_short_term(memory_id)
deleted_long = self.delete_long_term(memory_id)
return deleted_short or deleted_long
def delete_memories(self, memory_ids: List[str]) -> int:
"""
@@ -1757,13 +1898,17 @@ class Memory(SearchMixin, MemoryCoreMixin):
into a single text block with deduplication and clean formatting.
Args:
include_in_output: If None, memory content is only included when debug logging is enabled.
If True, memory content is always included.
If False, memory content is never included (only logged for debugging).
include_in_output: If None, memory content is included (the primary
purpose of this method is to feed retrieved memory
into the prompt). If True, memory content is always
included. If False, memory content is never included
(only logged for debugging).
"""
# Determine whether to include memory content in output based on logging level
# Include retrieved memory by default. Gating this on DEBUG-level logging
# meant Agent(memory=True) silently retrieved hits but never appended them
# to the prompt under normal usage.
if include_in_output is None:
include_in_output = get_logger().getEffectiveLevel() == logging.DEBUG
include_in_output = True
q = (task_descr + " " + additional).strip()
lines = []
@@ -1933,6 +2078,30 @@ class Memory(SearchMixin, MemoryCoreMixin):
return result
def get_context(self, query: Optional[str] = None, **kwargs) -> str:
"""
Return retrieved memory as a prompt-ready context string.
Satisfies ``AgentMemoryProtocol`` (used by ``Agent.get_memory_context()``)
so the non-prompt-caching code path can inject memory into the system
prompt. Without this, ``hasattr(memory, 'get_context')`` was False and the
fallback branch always returned "".
"""
# A missing/empty query would make the local fallback search match every
# record (LIKE "%%") with no relevance ordering, leaking unrelated (and
# possibly other users') memories into the prompt. Retrieve nothing until
# there is a meaningful query.
if not query or not str(query).strip():
return ""
# Propagate the effective user id so user-scoped memories are included and
# cross-user records are not returned by a shared Memory instance.
user_id = kwargs.get("user_id") or self.cfg.get("user_id")
return self.build_context_for_task(
task_descr=query,
user_id=user_id,
include_in_output=True,
)
# -------------------------------------------------------------------------
# Master Reset (Everything)
# -------------------------------------------------------------------------
@@ -2129,6 +2298,26 @@ class Memory(SearchMixin, MemoryCoreMixin):
def get_all_memories(self) -> List[Dict[str, Any]]:
"""Get all memories from both short-term and long-term storage"""
# Delegate to the active adapter whenever one is configured, mirroring
# delete_short_term/reset_short_term. Data written through the adapter
# lives in short_term_memory/long_term_memory; the legacy SELECTs below
# target short_mem/long_mem, which nothing in the codebase creates, so
# they raise OperationalError, get swallowed, and return [] every time.
if (getattr(self, "memory_adapter", None)
and hasattr(self.memory_adapter, "get_all_memories")):
# Adapters expose the tier under different keys (the SQLite adapter
# uses `memory_type`, others may use `type`). The legacy path below
# returns `type`, so normalize to keep that public field populated
# for backward compatibility while retaining any adapter-specific
# keys the record already carries.
return [
{
**record,
"type": record.get("type") or record.get("memory_type"),
}
for record in self.memory_adapter.get_all_memories()
]
all_memories = []
try:
@@ -257,6 +257,26 @@ class _LazyObsModule:
obs = _LazyObsModule()
# ``from praisonaiagents import obs`` returns *this submodule*, not the singleton
# above (Python binds a real submodule as a package attribute, shadowing the
# package ``__getattr__``). Make the submodule object itself callable and proxy
# provider lookups to the singleton so ``obs(...)``, ``obs.auto()`` and
# ``obs.langfuse()`` all work regardless of how ``obs`` is imported.
import sys as _sys
from types import ModuleType as _ModuleType
class _CallableObsModule(_ModuleType):
def __call__(self, provider: str = "auto", **kwargs):
return obs(provider, **kwargs)
def __getattr__(self, name):
return getattr(obs, name)
_sys.modules[__name__].__class__ = _CallableObsModule
__all__ = [
"ObservabilityAdapter",
"Span",
@@ -801,9 +801,9 @@ Subtask: {st.name}
decision_str = None
if current_task.result:
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
decision_str = current_task.result.pydantic.decision.lower()
decision_str = current_task.result.pydantic.decision.lower().strip()
elif current_task.result.raw:
decision_str = current_task.result.raw.lower()
decision_str = current_task.result.raw.lower().strip()
# For loop tasks, use "done" to follow condition path
if current_task.task_type == "loop" and all(t.status == "completed" for t in subtasks):
@@ -894,9 +894,9 @@ Subtask: {st.name}
if current_task and current_task.result:
if current_task.task_type in ["decision", "loop"]:
# Get decision from pydantic or raw response
decision_str = current_task.result.raw.lower()
decision_str = current_task.result.raw.lower().strip()
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
decision_str = current_task.result.pydantic.decision.lower()
decision_str = current_task.result.pydantic.decision.lower().strip()
# Check if task has conditions and next_tasks
if current_task.condition:
@@ -1022,7 +1022,13 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
self.tasks[dep.id].status == "failed"
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
)
if deps_failed:
# Honor per-task graceful-degradation flags: a task that opted
# into skip_on_failure/on_error="continue" still runs even when
# an upstream dependency failed.
if deps_failed and not (
getattr(task, 'skip_on_failure', False)
or getattr(task, 'on_error', 'stop') == 'continue'
):
task.status = "failed"
continue
if task.status != "completed":
@@ -1408,9 +1414,9 @@ Subtask: {st.name}
decision_str = None
if current_task.result:
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
decision_str = current_task.result.pydantic.decision.lower()
decision_str = current_task.result.pydantic.decision.lower().strip()
elif current_task.result.raw:
decision_str = current_task.result.raw.lower()
decision_str = current_task.result.raw.lower().strip()
# For loop tasks, use "done" to follow condition path
if current_task.task_type == "loop" and all(t.status == "completed" for t in subtasks):
@@ -1499,9 +1505,9 @@ Subtask: {st.name}
if current_task and current_task.result:
if current_task.task_type in ["decision", "loop"]:
# Get decision from pydantic or raw response
decision_str = current_task.result.raw.lower()
decision_str = current_task.result.raw.lower().strip()
if current_task.result.pydantic and hasattr(current_task.result.pydantic, "decision"):
decision_str = current_task.result.pydantic.decision.lower()
decision_str = current_task.result.pydantic.decision.lower().strip()
# Check if task has conditions and next_tasks
if current_task.condition:
@@ -1627,7 +1633,13 @@ Workflow Finished: {self.workflow_finished} # ADDED: Workflow Finished Status
self.tasks[dep.id].status == "failed"
for dep in task.context if hasattr(dep, 'id') and dep.id in self.tasks
)
if deps_failed:
# Honor per-task graceful-degradation flags: a task that opted
# into skip_on_failure/on_error="continue" still runs even when
# an upstream dependency failed.
if deps_failed and not (
getattr(task, 'skip_on_failure', False)
or getattr(task, 'on_error', 'stop') == 'continue'
):
task.status = "failed"
continue
if task.status != "completed":
@@ -104,6 +104,16 @@ def __getattr__(name: str):
_module_cache[name] = SessionMirrorProtocol
return SessionMirrorProtocol
if name == "PortableSessionStoreProtocol":
from .protocols import PortableSessionStoreProtocol
_module_cache[name] = PortableSessionStoreProtocol
return PortableSessionStoreProtocol
if name == "ImportReport":
from .protocols import ImportReport
_module_cache[name] = ImportReport
return ImportReport
if name == "SessionHit":
from .protocols import SessionHit
_module_cache[name] = SessionHit
@@ -197,6 +207,8 @@ __all__ = [
"SessionStoreProtocol",
"SearchableSessionStoreProtocol",
"SessionMirrorProtocol",
"PortableSessionStoreProtocol",
"ImportReport",
"SessionHit",
"SessionSummary",
"HierarchicalSessionStore",
@@ -87,6 +87,35 @@ class SessionSummary:
}
@dataclass
class ImportReport:
"""Outcome of a :meth:`PortableSessionStoreProtocol.import_sessions` call.
A structured, JSON-serialisable summary so an operator (or the gateway CLI)
can see exactly what a restore did: how many sessions were imported vs
skipped, and why. ``skipped`` entries carry ``{"session_id", "reason"}`` so
a rejected record (cap exceeded, malformed, duplicate) is observable rather
than silently dropped.
"""
imported: int = 0
skipped: List[Dict[str, str]] = field(default_factory=list)
version: int = 1
@property
def skipped_count(self) -> int:
return len(self.skipped)
def as_dict(self) -> Dict[str, Any]:
"""Convert to a JSON-serialisable dict."""
return {
"imported": self.imported,
"skipped": self.skipped,
"skipped_count": self.skipped_count,
"version": self.version,
}
@runtime_checkable
class SessionStoreProtocol(Protocol):
"""Protocol for session persistence backends.
@@ -187,6 +216,85 @@ class SessionStoreProtocol(Protocol):
...
@runtime_checkable
class PortableSessionStoreProtocol(Protocol):
"""Protocol for portable backup / restore / migration of sessions.
Extends the storage contract with a store-agnostic way to export a
versioned, JSON-serialisable payload and import it back on another host or
into another store — so a gateway operator can back up, move, or restore
conversation state without hand-copying opaque store files.
The payload shape is deliberately simple and versioned::
{"version": 1, "sessions": [ <session dict>, ... ]}
Each session dict is the same portable shape produced by
``SessionData.to_dict`` (round-trips through ``SessionData.from_dict``), so
export/import is symmetric across the built-in stores. ``import_sessions``
is *hardened*: it caps how much it will ingest and, by default, resets live
routing/activity fields so restored state is inert until re-bound (an
imported record must not masquerade as an active connection).
Example::
data = store.export_all()
report = other_store.import_sessions(data)
print(report.imported, report.skipped_count)
"""
def export_session(
self, session_id: str, *, include_lineage: bool = True
) -> Dict[str, Any]:
"""Export a single session to a portable, versioned payload.
Args:
session_id: The session to export.
include_lineage: When True, also include any compacted/rotated
ancestors of this conversation so it round-trips as one logical
session.
Returns:
``{"version": ..., "sessions": [...]}`` (empty ``sessions`` when the
id is unknown).
"""
...
def export_all(self) -> Dict[str, Any]:
"""Export every stored session to a portable, versioned payload.
Returns:
``{"version": ..., "sessions": [...]}`` covering all sessions.
"""
...
def import_sessions(
self,
payload: Dict[str, Any],
*,
max_sessions: int = 10_000,
reset_live_fields: bool = True,
overwrite: bool = False,
) -> "ImportReport":
"""Import sessions from an exported payload (hardened).
Args:
payload: A payload previously produced by ``export_session`` /
``export_all``.
max_sessions: Cap on how many sessions to ingest; the remainder is
skipped and reported (guards against an oversized payload).
reset_live_fields: When True (default), clear live routing/activity
fields on each imported record so restored state is inert until
re-bound (does not masquerade as an active connection).
overwrite: When False (default), an existing session id is skipped
rather than clobbered.
Returns:
An :class:`ImportReport` summarising imported vs skipped sessions.
"""
...
@runtime_checkable
class SearchableSessionStoreProtocol(Protocol):
"""Protocol for cross-session conversation recall.
@@ -2148,6 +2148,219 @@ class DefaultSessionStore:
"""Check if a session exists."""
filepath = self._get_session_path(session_id)
return os.path.exists(filepath)
# ── Portable export / import / migration (Issue #4267) ────────────────
#
# Store-agnostic backup/restore/migration for the gateway's own store.
# The payload is a simple, versioned envelope carrying the same portable
# session dicts that ``SessionData.to_dict`` already produces, so it
# round-trips through ``from_dict`` and is symmetric across the built-in
# stores (``SqliteSessionStore`` inherits this behaviour unchanged).
# Bumped only on a breaking payload-shape change; import tolerates older/
# unversioned payloads (best-effort) rather than rejecting them.
PORTABLE_VERSION = 1
# Live routing / activity fields cleared on import (unless opted out) so a
# restored record is inert until re-bound and cannot masquerade as an active
# connection. Kept intentionally small: transcript + identity survive; only
# the gateway's live wiring is reset.
_LIVE_FIELDS = ("gateway_session_id", "agent_id")
def export_session(
self, session_id: str, *, include_lineage: bool = True
) -> Dict[str, Any]:
"""Export a single session to a portable, versioned payload.
Reuses the existing ``SessionData.to_dict`` shape (so the payload
round-trips through ``import_sessions``). When ``include_lineage`` is
set, any compacted/rotated ancestors sharing this session's lineage id
are included so the conversation restores as one logical session.
"""
session = self._read_session_fresh(session_id)
if not self.session_exists(session_id):
return {"version": self.PORTABLE_VERSION, "sessions": []}
sessions = [session.to_dict()]
if include_lineage:
for extra in self._collect_lineage(session, exclude=session_id):
sessions.append(extra)
return {"version": self.PORTABLE_VERSION, "sessions": sessions}
def export_all(self) -> Dict[str, Any]:
"""Export every stored session to a portable, versioned payload."""
sessions: List[Dict[str, Any]] = []
try:
filenames = os.listdir(self.session_dir)
except (IOError, OSError):
filenames = []
for filename in filenames:
if not filename.endswith(".json"):
continue
filepath = os.path.join(self.session_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
sessions.append(json.load(f))
except (json.JSONDecodeError, IOError, OSError):
continue
return {"version": self.PORTABLE_VERSION, "sessions": sessions}
def _collect_lineage(
self, session: SessionData, *, exclude: str
) -> List[Dict[str, Any]]:
"""Return other on-disk sessions sharing this session's lineage id.
Uses the same *chain* identifiers as recall's lineage dedup
(``lineage_id`` / ``root_session_id`` / ``thread_id``) so a compacted /
rotated continuation exports alongside its logical session. Returns the
raw session dicts (already portable). Empty when no lineage is known.
"""
lineage = self._lineage_key(session.to_dict())
if not lineage:
return []
out: List[Dict[str, Any]] = []
try:
filenames = os.listdir(self.session_dir)
except (IOError, OSError):
return []
for filename in filenames:
if not filename.endswith(".json"):
continue
filepath = os.path.join(self.session_dir, filename)
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
except (json.JSONDecodeError, IOError, OSError):
continue
if data.get("session_id") == exclude:
continue
if self._lineage_key(data) == lineage:
out.append(data)
return out
def _save_imported_session(self, session: SessionData) -> bool:
"""Persist a restored session verbatim (no retention/window applied).
Mirrors ``_save_session`` (timestamp + atomic, file-locked write) but
deliberately skips ``_enforce_window`` so importing a valid larger
export is not truncated/compacted by the destination's own retention
settings before it lands on disk.
"""
filepath = self._get_session_path(session.session_id)
session.updated_at = datetime.now(timezone.utc).isoformat()
with FileLock(filepath, self.lock_timeout):
if not self._atomic_write_json(filepath, session.to_dict()):
logger.error(f"Failed to save imported session {session.session_id}")
return False
return True
def import_sessions(
self,
payload: Dict[str, Any],
*,
max_sessions: int = 10_000,
reset_live_fields: bool = True,
overwrite: bool = False,
) -> "ImportReport":
"""Import sessions from an exported payload (hardened).
Validates the payload shape, caps ingest at ``max_sessions``, skips
malformed / duplicate records (reported, not silently dropped) and —
unless opted out — resets live routing/activity fields so restored
state is inert until re-bound.
"""
from .protocols import ImportReport
report = ImportReport(version=self.PORTABLE_VERSION)
if not isinstance(payload, dict):
return report
# Reject payloads from a newer, breaking export format: a future shape
# could otherwise slip through ``from_dict`` with silently defaulted
# fields and corrupt restored state. Older/unversioned payloads are
# still accepted (best-effort) since v1 is the compatible baseline.
payload_version = payload.get("version")
if isinstance(payload_version, int) and payload_version > self.PORTABLE_VERSION:
report.skipped.append(
{
"session_id": "",
"reason": (
f"unsupported payload version {payload_version} "
f"(supported <= {self.PORTABLE_VERSION})"
),
}
)
return report
raw_sessions = payload.get("sessions")
if not isinstance(raw_sessions, list):
return report
seen_ids: set = set()
for index, raw in enumerate(raw_sessions):
if report.imported >= max_sessions:
report.skipped.append(
{"session_id": "", "reason": f"max_sessions={max_sessions} cap reached"}
)
# Everything after the cap is skipped for the same reason; stop.
break
if not isinstance(raw, dict):
report.skipped.append(
{"session_id": "", "reason": f"malformed record at index {index}"}
)
continue
session_id = str(raw.get("session_id") or "").strip()
if not session_id:
report.skipped.append(
{"session_id": "", "reason": f"missing session_id at index {index}"}
)
continue
# Cycle / duplicate guard: the same id appearing twice in one payload
# is ingested once (first wins) so a self-referential lineage export
# cannot double-write.
if session_id in seen_ids:
report.skipped.append(
{"session_id": session_id, "reason": "duplicate in payload"}
)
continue
seen_ids.add(session_id)
if not overwrite and self.session_exists(session_id):
report.skipped.append(
{"session_id": session_id, "reason": "already exists (use overwrite)"}
)
continue
try:
data = dict(raw)
if reset_live_fields:
for field_name in self._LIVE_FIELDS:
data.pop(field_name, None)
meta = data.get("metadata")
if isinstance(meta, dict):
# Copy before mutating so the caller's payload (and any
# later import with reset_live_fields=False) keeps its
# original live fields intact (data = dict(raw) is shallow).
meta = dict(meta)
for field_name in self._LIVE_FIELDS:
meta.pop(field_name, None)
data["metadata"] = meta
session = SessionData.from_dict(data)
session.session_id = session_id
# Persist the imported record verbatim: an import is a restore,
# so the destination's retention/active_window must not truncate
# or compact a valid larger export before it lands on disk.
if not self._save_imported_session(session):
report.skipped.append(
{"session_id": session_id, "reason": "write failed"}
)
continue
with self._lock:
self._cache[session_id] = session
report.imported += 1
except Exception as e: # pragma: no cover - defensive; one bad record
report.skipped.append(
{"session_id": session_id, "reason": f"import error: {e}"}
)
return report
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
"""Invalidate cache for a session or all sessions."""
@@ -10,6 +10,7 @@ import threading
import concurrent.futures
import queue
import asyncio
import contextvars
from contextlib import contextmanager
if TYPE_CHECKING:
@@ -241,13 +242,25 @@ def instrument_agent(agent: 'Agent', telemetry: Optional['MinimalTelemetry'] = N
original_start = agent.start if hasattr(agent, 'start') else None
original_execute_tool = agent.execute_tool if hasattr(agent, 'execute_tool') else None
# Re-entrancy guard scoped per-agent (this ContextVar is created fresh in
# each instrument_agent call) AND per-execution. A plain instance attribute
# is shared across threads/tasks, so N concurrent chat() calls on one agent
# would all but the first see reentrant=True and undercount N:1 (#4196). A
# ContextVar gives each thread/async task its own value, so nested
# run()->chat() still records once while concurrent top-level calls each
# record independently.
in_execution = contextvars.ContextVar(
f'_telemetry_in_execution_{id(agent)}', default=False
)
def _make_instrumented_execution(original_method):
@wraps(original_method)
def wrapper(*args, **kwargs):
# Only the outermost of run/start/chat records the execution.
reentrant = getattr(agent, '_telemetry_in_execution', False)
reentrant = in_execution.get()
token = None
if not reentrant:
agent._telemetry_in_execution = True
token = in_execution.set(True)
try:
result = original_method(*args, **kwargs)
if not reentrant and not performance_mode:
@@ -270,8 +283,8 @@ def instrument_agent(agent: 'Agent', telemetry: Optional['MinimalTelemetry'] = N
})
raise
finally:
if not reentrant:
agent._telemetry_in_execution = False
if token is not None:
in_execution.reset(token)
return wrapper
if original_chat:
@@ -31,12 +31,48 @@ from praisonaiagents._logging import get_logger
from typing import Optional, List
from ..approval import require_approval
from .path_safety import resolve_within_root
logger = get_logger(__name__)
# Availability cache for performance (checked once per process)
_availability_cache: Optional[bool] = None
# Bound the output returned to the model. ast-grep can emit millions of chars
# against a large tree; without a cap that floods the context window.
_MAX_OUTPUT_SIZE = 10000
def _workspace_spill_dir() -> Optional[str]:
"""Directory for overflow artifacts the agent can actually retrieve.
The pointer emitted by :func:`bounded_with_pointer` tells the model to
``read_file``/``grep`` the artifact, but those tools reject paths outside
the workspace. When the caller has not pinned an explicit
``PRAISONAI_TOOL_OUTPUT_DIR`` (which they own), spill into a workspace-local
directory so the advertised retrieval path is reachable. Returns ``None`` to
defer to ``_output_overflow``'s own resolution when the env var is set.
"""
import os
if os.environ.get("PRAISONAI_TOOL_OUTPUT_DIR"):
return None
workspace = resolve_within_root(".")
if workspace is None:
return None
return os.path.join(workspace, ".praisonai_tool_output")
def _bound_output(output: str, kind: str) -> str:
"""Cap over-budget output, spilling the full buffer to a retrievable file.
No-op with zero overhead when ``output`` fits inside ``_MAX_OUTPUT_SIZE``.
"""
if len(output) <= _MAX_OUTPUT_SIZE:
return output
from ._output_overflow import spill, bounded_with_pointer
path = spill(output, kind, spill_dir=_workspace_spill_dir())
return bounded_with_pointer(output, _MAX_OUTPUT_SIZE, path)
def is_ast_grep_available() -> bool:
"""Check if ast-grep (sg) CLI is available.
@@ -115,13 +151,20 @@ def ast_grep_search(
if not pattern:
return "Error: Pattern cannot be empty"
safe_path = resolve_within_root(path or ".")
if safe_path is None:
return f"Error: path '{path}' is outside the workspace"
try:
cmd = ['sg', '--pattern', pattern, '--lang', lang]
if json_output:
cmd.append('--json')
cmd.append(path)
# `--` stops flag parsing so a path beginning with '-' is treated as a
# literal path rather than silently consumed as a flag.
cmd.append('--')
cmd.append(safe_path)
result = subprocess.run(
cmd,
@@ -137,7 +180,7 @@ def ast_grep_search(
if not output:
return "No matches found"
return output
return _bound_output(output, "ast_grep_search")
except subprocess.TimeoutExpired:
return "Error: Search timed out after 60 seconds"
@@ -202,6 +245,10 @@ def ast_grep_rewrite(
if not replacement:
return "Error: Replacement cannot be empty"
safe_path = resolve_within_root(path or ".")
if safe_path is None:
return f"Error: path '{path}' is outside the workspace"
try:
cmd = [
'sg', '--pattern', pattern,
@@ -212,7 +259,10 @@ def ast_grep_rewrite(
if not dry_run:
cmd.append('--update-all')
cmd.append(path)
# `--` stops flag parsing so a path beginning with '-' is treated as a
# literal path rather than silently consumed as a flag.
cmd.append('--')
cmd.append(safe_path)
result = subprocess.run(
cmd,
@@ -221,10 +271,14 @@ def ast_grep_rewrite(
timeout=120,
)
if result.returncode != 0 and result.stderr:
return f"Error: {result.stderr}"
if result.returncode != 0:
return f"Error: {result.stderr or result.stdout}".strip()
output = result.stdout.strip()
# `sg --update-all` writes its receipt ("Applied N changes") to stderr
# and leaves stdout empty. Gating stderr on a non-zero exit discarded the
# only evidence the rewrite happened, so a successful destructive rewrite
# reported "No changes made" and the agent would retry it.
output = (result.stdout.strip() or result.stderr.strip())
if not output:
if dry_run:
return "No matches found for rewrite"
@@ -232,7 +286,7 @@ def ast_grep_rewrite(
return "No changes made"
prefix = "[DRY RUN] " if dry_run else ""
return f"{prefix}{output}"
return _bound_output(f"{prefix}{output}", "ast_grep_rewrite")
except subprocess.TimeoutExpired:
return "Error: Rewrite timed out after 120 seconds"
@@ -274,13 +328,23 @@ def ast_grep_scan(
if not is_ast_grep_available():
return _get_not_installed_message()
safe_path = resolve_within_root(path or ".")
if safe_path is None:
return f"Error: path '{path}' is outside the workspace"
try:
cmd = ['sg', 'scan']
if rule_file:
cmd.extend(['--rule', rule_file])
safe_rule = resolve_within_root(rule_file)
if safe_rule is None:
return f"Error: rule_file '{rule_file}' is outside the workspace"
cmd.extend(['--rule', safe_rule])
cmd.append(path)
# `--` stops flag parsing so a path beginning with '-' is treated as a
# literal path rather than silently consumed as a flag.
cmd.append('--')
cmd.append(safe_path)
result = subprocess.run(
cmd,
@@ -297,7 +361,7 @@ def ast_grep_scan(
if not output:
return "No issues found"
return output
return _bound_output(output, "ast_grep_scan")
except subprocess.TimeoutExpired:
return "Error: Scan timed out after 120 seconds"
@@ -207,6 +207,13 @@ class BaseTool(ABC):
# Optional class attributes
version: str = "1.0.0"
parameters: Optional[Dict[str, Any]] = None # JSON Schema, auto-generated if None
# Restart-safety contract for durable resume. ``None`` (the default) means
# "undeclared": durable resume falls back to a read-only name heuristic and,
# when uncertain, fails closed rather than replaying a side effect. ``True``
# marks a read-only/idempotent tool that is safe to re-run after a crash;
# ``False`` marks an effectful tool that must never be silently re-executed
# on resume.
restart_safe: Optional[bool] = None
def __init__(self, dynamic_schema_overrides: Optional[Callable[[Dict[str, Any]], Dict[str, Any]]] = None):
"""Initialize the tool and validate configuration.
@@ -28,6 +28,28 @@ from praisonaiagents._logging import get_logger
import asyncio
from importlib import util
def _is_safe_crawl_url(url: str) -> bool:
"""Return True when *url* passes SSRF checks for crawling.
Mirrors ``web_crawl_tools._is_safe_crawl_url`` so every crawl entry point is
guarded consistently. ``raw://`` inline-HTML inputs bypass the network and
are exempt. (#4189)
"""
if isinstance(url, str) and url.startswith("raw://"):
return True
from praisonaiagents.tools.url_safety import is_safe_http_url
return is_safe_http_url(url)
def _blocked_url_result(url: str) -> Dict[str, Any]:
"""Uniform error payload for a URL rejected by SSRF validation."""
return {
"error": f"URL blocked by SSRF safety check: {url}",
"success": False,
}
def _check_crawl4ai_available() -> tuple[bool, Optional[str]]:
"""Check if Crawl4AI and its Playwright browser binaries are available.
@@ -169,6 +191,9 @@ class Crawl4AITools:
Returns:
Dict with 'markdown', 'html', 'cleaned_html', 'links', 'media', etc.
"""
if not _is_safe_crawl_url(url):
self.logger.warning("Crawl4AI crawl blocked unsafe URL: %s", url)
return _blocked_url_result(url)
try:
crawler = await self._get_crawler()
@@ -237,6 +262,26 @@ class Crawl4AITools:
Returns:
List of crawl results
"""
# Partition while preserving positional correspondence: callers that pair
# inputs to outputs by index must see blocked entries in their original
# slot, not moved to the front (#4189).
safe_urls = []
for u in urls:
if not _is_safe_crawl_url(u):
self.logger.warning("Crawl4AI crawl_many blocked unsafe URL: %s", u)
else:
safe_urls.append(u)
def _ordered(fetched: Dict[str, Dict[str, Any]]) -> List[Dict[str, Any]]:
return [
fetched.get(u, _blocked_url_result(u))
if _is_safe_crawl_url(u)
else _blocked_url_result(u)
for u in urls
]
if not safe_urls:
return [_blocked_url_result(u) for u in urls]
try:
crawler = await self._get_crawler()
@@ -248,42 +293,32 @@ class Crawl4AITools:
cache_mode=CacheMode.BYPASS if bypass_cache else CacheMode.ENABLED,
stream=stream
)
results = []
fetched: Dict[str, Dict[str, Any]] = {}
def _record(result) -> None:
if result.success:
fetched[result.url] = {
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html
}
else:
fetched[result.url] = {
"success": False,
"url": result.url,
"error": result.error_message
}
if stream:
async for result in await crawler.arun_many(urls, config=config):
if result.success:
results.append({
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html
})
else:
results.append({
"success": False,
"url": result.url,
"error": result.error_message
})
async for result in await crawler.arun_many(safe_urls, config=config):
_record(result)
else:
raw_results = await crawler.arun_many(urls, config=config)
for result in raw_results:
if result.success:
results.append({
"success": True,
"url": result.url,
"markdown": result.markdown.raw_markdown if hasattr(result.markdown, 'raw_markdown') else str(result.markdown),
"html": result.html
})
else:
results.append({
"success": False,
"url": result.url,
"error": result.error_message
})
return results
for result in await crawler.arun_many(safe_urls, config=config):
_record(result)
return _ordered(fetched)
except Exception as e:
self.logger.error(f"Crawl4AI crawl_many error: {e}")
@@ -320,6 +355,9 @@ class Crawl4AITools:
]
}
"""
if not _is_safe_crawl_url(url):
self.logger.warning("Crawl4AI extract_css blocked unsafe URL: %s", url)
return _blocked_url_result(url)
try:
crawler = await self._get_crawler()
@@ -383,6 +421,9 @@ class Crawl4AITools:
Returns:
Dict with 'data' containing extracted content
"""
if not _is_safe_crawl_url(url):
self.logger.warning("Crawl4AI extract_llm blocked unsafe URL: %s", url)
return _blocked_url_result(url)
try:
crawler = await self._get_crawler()
@@ -113,9 +113,12 @@ class FunctionTool(BaseTool):
retry_policy: Optional[Any] = None,
approval: Optional[Union[bool, str]] = None,
requires_approval: Union[bool, str] = _UNSET,
to_model_output: Optional[Callable[[Any], Any]] = None
to_model_output: Optional[Callable[[Any], Any]] = None,
restart_safe: Optional[bool] = None
):
self._func = func
# Restart-safety contract for durable resume (see BaseTool.restart_safe).
self.restart_safe = restart_safe
# Optional compact model-facing view builder: result -> compact view.
self._to_model_output = to_model_output
self.name = name or func.__name__
@@ -240,7 +243,8 @@ def tool(
retry_policy: Optional[Any] = None,
approval: Optional[Union[bool, str]] = None,
requires_approval: Union[bool, str] = _UNSET,
to_model_output: Optional[Callable[[Any], Any]] = None
to_model_output: Optional[Callable[[Any], Any]] = None,
restart_safe: Optional[bool] = None
) -> Union[FunctionTool, Callable[[Callable], FunctionTool]]:
"""Decorator to convert a function into a tool.
@@ -293,6 +297,13 @@ def tool(
the executor feeds this compact view to the LLM (context economy)
while the full result stays available to display, hooks, and
downstream consumers. Defaults to ``None`` (model sees full output).
restart_safe: Declare the tool's replay behaviour for durable resume.
``True`` marks a read-only/idempotent tool that is safe to re-run
after a crash; ``False`` marks an effectful tool that must never be
silently re-executed on resume (an in-flight call surfaces a
recorded, operator-visible outcome to reconcile instead). Defaults
to ``None`` (undeclared): durable resume falls back to a read-only
name heuristic and fails closed when uncertain.
Returns:
FunctionTool instance that wraps the function
@@ -310,7 +321,8 @@ def tool(
dynamic_schema_overrides=dynamic_schema_overrides,
retry_policy=retry_policy,
approval=resolved_approval,
to_model_output=to_model_output
to_model_output=to_model_output,
restart_safe=restart_safe
)
# Register approval requirement with the global registry so local,
@@ -52,7 +52,6 @@ class DelegationTools:
role=role,
goal=f"Complete delegated {agent_type} tasks accurately.",
llm=llm,
verbose=False,
)
spawn = create_subagent_tool(agent_factory=_agent_factory)["function"]
@@ -275,18 +275,51 @@ class MentionsParser:
try:
from praisonaiagents.tools.spider_tools import SpiderTools
if not SpiderTools()._validate_url(url):
spider = SpiderTools()
if not spider._validate_url(url):
return f"# URL: {url}\n[Blocked: URL is not allowed]"
import urllib.request
req = urllib.request.Request(
url,
headers={'User-Agent': 'Mozilla/5.0 (compatible; PraisonAI/1.0)'}
)
with urllib.request.urlopen(req, timeout=10) as response:
content = response.read().decode('utf-8', errors='ignore')
import urllib.error
from urllib.parse import urljoin
# Follow redirects manually, re-validating EVERY hop. urllib's default
# opener follows redirects transparently, so validating only the first
# hop lets a redirect land on a metadata IP or internal host (#4189).
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, *args, **kwargs):
return None
opener = urllib.request.build_opener(_NoRedirect)
current_url = url
redirect_statuses = {301, 302, 303, 307, 308}
max_redirects = 5
content = None
for _ in range(max_redirects + 1):
if not spider._validate_url(current_url):
return f"# URL: {url}\n[Blocked: redirect target is not allowed: {current_url}]"
req = urllib.request.Request(
current_url,
headers={'User-Agent': 'Mozilla/5.0 (compatible; PraisonAI/1.0)'}
)
try:
response = opener.open(req, timeout=10)
except urllib.error.HTTPError as http_err:
if http_err.code in redirect_statuses:
location = http_err.headers.get("Location")
if not location:
return f"# URL: {url}\n[Error fetching URL: redirect without Location]"
current_url = urljoin(current_url, location)
continue
raise
with response:
content = response.read().decode('utf-8', errors='ignore')
break
else:
return f"# URL: {url}\n[Error fetching URL: too many redirects]"
if content is None:
return f"# URL: {url}\n[Error fetching URL: no response]"
# Limit content size (0 means no limit)
if self.max_file_chars > 0 and len(content) > self.max_file_chars:
@@ -105,6 +105,47 @@ def _validate_code_ast(code: str):
return None
def _make_resource_preexec(limits: ResourceLimits):
"""Build a POSIX preexec_fn that applies ResourceLimits via setrlimit.
Caps address space (memory), process count, open files, file size and
CPU time in the child process so untrusted code cannot exhaust host
resources. CPU time is capped in seconds (derived from timeout_seconds)
so sustained CPU-bound code is stopped by the kernel rather than only by
the wall-clock timeout. Each RLIMIT_* constant is resolved defensively:
constants absent on a given POSIX platform, and individual caps the
platform rejects, are skipped without aborting the launch (the wall-clock
timeout still applies as a backstop).
"""
import resource
def _limit():
# CPU-time cap in whole seconds; derived from timeout_seconds so the
# kernel terminates CPU-bound code near the wall-clock budget instead
# of letting it saturate a core until the parent times out.
cpu_seconds = max(1, int(limits.timeout_seconds)) if limits.timeout_seconds else 0
caps = (
("RLIMIT_AS", limits.memory_mb, 1024 * 1024),
("RLIMIT_NPROC", limits.max_processes, 1),
("RLIMIT_NOFILE", limits.max_open_files, 1),
("RLIMIT_FSIZE", limits.disk_write_mb, 1024 * 1024),
("RLIMIT_CPU", cpu_seconds, 1),
)
for res_name, value, scale in caps:
if not value:
continue
res = getattr(resource, res_name, None)
if res is None:
continue
cap = value * scale
try:
resource.setrlimit(res, (cap, cap))
except (ValueError, OSError):
pass
return _limit
def _execute_code_sandboxed(
code: str,
timeout: int = 30,
@@ -287,7 +328,20 @@ if __name__ == "__main__":
# Configure subprocess with resource limits
env = {} # Clean environment (no access to parent process env vars)
# Enforce the computed ResourceLimits in the child process. On POSIX we
# apply them via setrlimit in a preexec_fn so untrusted code cannot
# exhaust host memory/CPU/processes/files (see ResourceLimits.minimal()).
preexec_fn = None
if os.name == "posix":
preexec_fn = _make_resource_preexec(limits)
else:
logging.warning(
"Code sandbox: resource limits (memory/CPU/process/file caps) "
"are only enforced on POSIX; on this platform only the "
"wall-clock timeout applies."
)
# Run subprocess with timeout and resource limits
process = subprocess.Popen(
[sys.executable, temp_file],
@@ -296,7 +350,8 @@ if __name__ == "__main__":
env=env,
shell=False, # Security: prevent shell injection
text=True,
cwd=tempfile.gettempdir() # Run in temp dir, not current dir
cwd=tempfile.gettempdir(), # Run in temp dir, not current dir
preexec_fn=preexec_fn,
)
try:
@@ -28,6 +28,7 @@ from praisonaiagents.ui.agui.types import (
StateSnapshotEvent,
)
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
from praisonaiagents.streaming.events import StreamEventType as _SE
@dataclass
@@ -39,15 +40,35 @@ class EventBuffer:
current_text_message_id: str = ""
next_text_message_id: str = field(default_factory=lambda: str(uuid.uuid4()))
pending_tool_calls_parent_id: str = ""
current_tool_call_id: str = ""
def start_tool_call(self, tool_call_id: str) -> None:
"""Start a new tool call."""
self.active_tool_call_ids.add(tool_call_id)
self.current_tool_call_id = tool_call_id
def end_tool_call(self, tool_call_id: str) -> None:
"""End a tool call."""
self.active_tool_call_ids.discard(tool_call_id)
self.ended_tool_call_ids.add(tool_call_id)
if self.current_tool_call_id == tool_call_id:
self.current_tool_call_id = ""
def resolve_tool_call_id(self, provided_id: Optional[str]) -> str:
"""Resolve the tool_call_id for a delta that may omit it.
Providers commonly send the id only on the first chunk of a tool call
and omit it on subsequent argument deltas. Falling back to the last
started tool call keeps every chunk correlated to one invocation instead
of minting an orphan UUID per chunk.
"""
if provided_id:
return provided_id
if self.current_tool_call_id:
return self.current_tool_call_id
new_id = str(uuid.uuid4())
self.current_tool_call_id = new_id
return new_id
def start_text_message(self) -> str:
"""Start a new text message and return its ID."""
@@ -260,6 +281,38 @@ async def async_stream_response(
yield create_run_error_event(str(e))
# --- AG-UI disposition of every StreamEventType -------------------------------
# Membership in these tables is a decision, not an omission. The adapter raises on
# any type absent from all three, so adding a StreamEventType without choosing a
# disposition fails tests/agui/test_streaming_completeness.py rather than silently
# dropping the event at runtime.
# Process-local timing and lifecycle markers with no AG-UI counterpart.
# RUN_FINISHED is emitted by the enclosing run loop, so STREAM_END would double it.
NOT_WIRE_VISIBLE = frozenset({
_SE.REQUEST_START,
_SE.HEADERS_RECEIVED,
_SE.FIRST_TOKEN,
_SE.LAST_TOKEN,
_SE.STREAM_END,
})
# The run continues, but a client that is never told cannot explain a stall or a
# silently changed model. Carried as CustomEvent, not RunErrorEvent, because
# RunErrorEvent is terminal in AG-UI and these are recovered conditions.
_RECOVERABLE_EVENTS = {
_SE.RETRY: "praisonai.retry",
_SE.MODEL_FALLBACK: "praisonai.model_fallback",
_SE.STREAM_UNAVAILABLE: "praisonai.stream_unavailable",
}
# User-visible progress that AG-UI has no first-class frame for.
_PROGRESS_EVENTS = {
_SE.TOOL_PROGRESS: "praisonai.tool_progress",
_SE.TODO_UPDATED: "praisonai.todo_updated",
}
def stream_event_to_agui_events(
event: Any,
message_id: str,
@@ -313,7 +366,60 @@ def stream_event_to_agui_events(
)
return events
return []
if event.type == StreamEventType.DELTA_TOOL_CALL:
tool_call = event.tool_call or {}
tool_call_id = buffer.resolve_tool_call_id(tool_call.get("id"))
delta = tool_call.get("arguments", event.content or "")
return [
ToolCallArgsEvent(
tool_call_id=tool_call_id,
delta=delta if isinstance(delta, str) else json.dumps(delta),
)
]
if event.type == StreamEventType.TOOL_CALL_END:
tool_call = event.tool_call or {}
tool_call_id = buffer.resolve_tool_call_id(tool_call.get("id"))
buffer.end_tool_call(tool_call_id)
return [ToolCallEndEvent(tool_call_id=tool_call_id)]
if event.type == StreamEventType.ERROR:
return [create_run_error_event(event.error or event.content or "unknown error")]
# Recoverable conditions. These are not RunErrorEvent: the run continues, but
# a client that is never told cannot explain a stall or a changed model.
if event.type in _RECOVERABLE_EVENTS:
return [
CustomEvent(
name=_RECOVERABLE_EVENTS[event.type],
value={
"message": event.error or event.content or "",
"metadata": event.metadata or {},
},
)
]
if event.type in _PROGRESS_EVENTS:
return [
CustomEvent(
name=_PROGRESS_EVENTS[event.type],
value={
"content": event.content,
"tool_call": event.tool_call,
"metadata": event.metadata or {},
},
)
]
if event.type in NOT_WIRE_VISIBLE:
return []
# Fail loudly rather than dropping: an unmapped type reaching here means a new
# StreamEventType was added without deciding its disposition.
raise ValueError(
f"StreamEventType.{getattr(event.type, 'name', event.type)} has no AG-UI "
"mapping and is not declared in NOT_WIRE_VISIBLE"
)
def _infer_a2ui_surface_id(result: Dict[str, Any]) -> str:
@@ -707,10 +707,12 @@ class AgentFlow:
def __post_init__(self):
"""Resolve consolidated params to internal values."""
from ..utils.model_alias import resolve_model_alias
from ..utils.model_alias import resolve_model_name
# One rule for the alias pair, shared with Agent and AgentTeam.
self.llm = resolve_model_alias(self.llm, self.model, type(self).__name__)
# One rule for the alias pair, shared with Agent and AgentTeam. Must
# UNWRAP an LLMConfig to its model string: this value seeds the agents
# AgentFlow builds, and an agent holding an LLMConfig fails inside chat().
self.llm = resolve_model_name(self.llm, self.model, type(self).__name__)
self.model = self.llm
from ..agent.placement import resolve_placement
@@ -1362,6 +1364,13 @@ class AgentFlow:
results.extend(parallel_result["steps"])
previous_output = parallel_result["output"]
all_variables.update(parallel_result.get("variables", {}))
# A nested step with on_error="stop" halts the whole workflow,
# not just the parallel block: honor the propagated stop signal.
if parallel_result.get("stop"):
self.status = "failed"
if verbose:
print("🛑 Workflow stopped by nested parallel step")
break
i += 1
continue
@@ -2419,7 +2428,9 @@ Create a brief execution plan (2-3 sentences) describing how to best accomplish
return {
"step": f"parallel_{index}",
"output": parallel_result.get("output", ""),
"stop": False,
# Propagate a nested stop request so an on_error="stop" step
# inside the parallel block halts the enclosing workflow.
"stop": parallel_result.get("stop", False),
"variables": parallel_result.get("variables", all_variables)
}
@@ -2915,6 +2926,7 @@ CONCISE SUMMARY:"""
futures.append((idx, future))
errors = []
parallel_stopped = False
for idx, future in futures:
try:
step_result = future.result()
@@ -2930,6 +2942,11 @@ CONCISE SUMMARY:"""
# Parallel on_failure modes still fire instead of silently
# treating the exception text as a real branch output.
branch_error = step_result.get("error")
# A branch step with on_error="stop" (Task default) requests a
# workflow-wide halt. Capture it so the enclosing workflow stops
# after this parallel block, matching Loop/Route/Repeat/If.
if step_result.get("stop"):
parallel_stopped = True
if branch_error is None:
results.append({"step": step_result["step"], "output": step_result["output"]})
@@ -2955,6 +2972,14 @@ CONCISE SUMMARY:"""
# Record the failure but continue with other branches. Do not
# fold the exception text into outputs as if it were data.
results.append({"step": f"parallel_{idx}", "output": None, "error": str(branch_error)})
# Keep `outputs` index-aligned with parallel_step.steps so a
# downstream reader of parallel_outputs[idx] cannot silently
# receive a later branch's result once an earlier one fails.
outputs.append(None)
# A failed branch whose step requested on_error="stop" halts the
# enclosing workflow even under partial_ok.
if step_result is not None and step_result.get("stop"):
parallel_stopped = True
# Check if we should fail after all branches completed
if errors and parallel_step.on_failure == "fail_all":
@@ -2971,7 +2996,7 @@ CONCISE SUMMARY:"""
if verbose:
print(f"✅ Parallel complete: {len(outputs)} results")
return {"steps": results, "output": combined_output, "variables": all_variables}
return {"steps": results, "output": combined_output, "variables": all_variables, "stop": parallel_stopped}
def _execute_loop(
self,
@@ -3234,43 +3259,69 @@ CONCISE SUMMARY:"""
List of items, or [text] if no list found
"""
import json
import re
if not text or not text.strip():
return []
text = text.strip()
# 1. Try direct JSON parse (pure JSON array)
# 1. Try direct JSON parse (pure JSON array).
# RecursionError guards against deeply-nested bracket input, which the
# C/Python JSON scanner surfaces instead of JSONDecodeError (CWE-674).
try:
parsed = json.loads(text)
if isinstance(parsed, list):
return parsed
except json.JSONDecodeError:
except (json.JSONDecodeError, RecursionError):
pass
# 2. Try to extract JSON array from text using regex
# Match JSON arrays like ["item1", "item2"] or ['item1', 'item2']
json_array_pattern = r'\[(?:[^\[\]]*(?:"[^"]*"|\'[^\']*\')?[^\[\]]*)*\]'
matches = re.findall(json_array_pattern, text)
# 2. Search for an embedded list without a backtracking regex.
# The previous nested-repeat pattern could take exponential time on
# adversarial bracket/quote input (CWE-1333/CWE-730). JSONDecoder
# validates each candidate using the JSON grammar. raw_decode is
# anchored with the ``idx`` argument so we never allocate an
# overlapping ``text[start:]`` copy per bracket. To bound the total
# work on bracket-dense adversarial output (e.g. deeply nested "[[[["
# where each raw_decode still rescans toward the end, giving O(n^2)),
# we cap both the input size and the number of decode attempts.
decoder = json.JSONDecoder()
max_scan_chars = 1_000_000
if len(text) > max_scan_chars:
logger.warning("Loop list text exceeds the safe parsing limit")
return [text]
max_decode_attempts = 10_000
for match in matches:
start = text.find('[')
attempts = 0
while start != -1 and attempts < max_decode_attempts:
attempts += 1
try:
# Try parsing as JSON
parsed = json.loads(match)
if isinstance(parsed, list) and len(parsed) > 0:
return parsed
except json.JSONDecodeError:
# Try replacing single quotes with double quotes
try:
fixed = match.replace("'", '"')
parsed = json.loads(fixed)
if isinstance(parsed, list) and len(parsed) > 0:
return parsed
except json.JSONDecodeError:
continue
parsed, _ = decoder.raw_decode(text, start)
except (json.JSONDecodeError, RecursionError):
start = text.find('[', start + 1)
continue
if isinstance(parsed, list) and len(parsed) > 0:
return parsed
start = text.find('[', start + 1)
# 3. Fallback: wrap as single item
# 3. Fallback for Python-style lists with single quotes, e.g.
# "['topic1', 'topic2']", which strict JSON rejects. ast.literal_eval
# only evaluates literals (no code execution) so it is safe here and
# preserves the single-quoted parsing the previous regex supported.
# A single attempt on the outermost bracket span keeps this linear;
# LLM output places the list contiguously rather than fragmented.
import ast
first = text.find('[')
last = text.rfind(']')
if first != -1 and last > first:
try:
parsed = ast.literal_eval(text[first:last + 1])
if isinstance(parsed, (list, tuple)) and len(parsed) > 0:
return list(parsed)
except (ValueError, SyntaxError, RecursionError):
pass
# 4. Fallback: wrap as single item
return [text]
def _execute_repeat(
@@ -4685,6 +4736,94 @@ class WorkflowManager:
else:
current_step_idx += 1
def _prepare_step_context(
self,
step: Task,
results: List[Dict[str, Any]],
all_variables: Dict[str, Any],
original_input: str,
) -> Tuple[Optional["WorkflowContext"], Optional[Dict[str, Any]]]:
"""Shared prep for the sync/async single-step runners.
Builds the handler ``WorkflowContext`` (only when a consumer exists) and
evaluates ``should_run``. Returns ``(context, early_result)`` where
``early_result`` is the skip return-dict when ``should_run`` gates the
step out, otherwise ``None`` and ``context`` is ready for handler use.
"""
# Get previous step output
previous_output = results[-1].get("output") if results else None
# Create context for step handlers (only when a consumer exists)
context = None
if step.should_run or step.handler:
context = WorkflowContext(
input=original_input,
previous_result=str(previous_output) if previous_output else None,
current_step=step.name,
variables=all_variables.copy()
)
# Check should_run condition if provided
if step.should_run:
try:
if not step.should_run(context):
return context, {
"success": True,
"output": None,
"skipped": True,
"stop": False
}
except Exception as e:
self._log(f"should_run check for step '{step.name}' failed: {e}")
return context, None
def _build_step_action(
self,
step: Task,
step_idx: int,
results: List[Dict[str, Any]],
all_variables: Dict[str, Any],
) -> str:
"""Shared prep: build step context, substitute variables, prepend context."""
# Build context from previous steps
context = self._build_step_context(step, step_idx, results, all_variables)
# Substitute variables in action
action = self._substitute_variables(step.action, all_variables)
# Prepend context to action if available
if context:
action = f"{context}# Current Task:\n{action}"
return action
def _finalize_step_result(
self,
step: Task,
output: Optional[str],
step_success: bool,
error: Optional[str],
all_variables: Dict[str, Any],
results: List[Dict[str, Any]],
on_result: Optional[Callable[[Task, str], None]],
) -> Dict[str, Any]:
"""Shared finalize for the sync/async single-step runners."""
# Update variables with step output
if output and step_success:
self._update_variables_with_output(step, output, all_variables, results)
# Callback after step
if on_result and output:
on_result(step, output)
return {
"success": step_success,
"output": output,
"stop": False, # Normal execution doesn't request stop
"error": error
}
def _execute_single_step(
self,
step: Task,
@@ -4702,32 +4841,13 @@ class WorkflowManager:
original_input: str = ""
) -> Dict[str, Any]:
"""Execute a single workflow step."""
# Get previous step output
previous_output = results[-1].get("output") if results else None
# Create context for step handlers (only when a consumer exists)
context = None
if step.should_run or step.handler:
context = WorkflowContext(
input=original_input,
previous_result=str(previous_output) if previous_output else None,
current_step=step.name,
variables=all_variables.copy()
)
# Check should_run condition if provided
if step.should_run:
try:
if not step.should_run(context):
return {
"success": True,
"output": None,
"skipped": True,
"stop": False
}
except Exception as e:
self._log(f"should_run check for step '{step.name}' failed: {e}")
# Shared prep: handler context + should_run gating
context, early_result = self._prepare_step_context(
step, results, all_variables, original_input
)
if early_result is not None:
return early_result
# If step has a custom handler function
if step.handler:
try:
@@ -4758,15 +4878,8 @@ class WorkflowManager:
"error": str(e)
}
# Build context from previous steps
context = self._build_step_context(step, step_idx, results, all_variables)
# Substitute variables in action
action = self._substitute_variables(step.action, all_variables)
# Prepend context to action if available
if context:
action = f"{context}# Current Task:\n{action}"
# Shared prep: build step context + substitute variables
action = self._build_step_action(step, step_idx, results, all_variables)
# Get or create executor for this step
step_executor = executor
@@ -4819,20 +4932,10 @@ class WorkflowManager:
if retries <= step.max_retries:
self._log(f"Step '{step.name}' failed, retrying ({retries}/{step.max_retries})")
# Update variables with step output
if output and step_success:
self._update_variables_with_output(step, output, all_variables, results)
# Callback after step
if on_result and output:
on_result(step, output)
return {
"success": step_success,
"output": output,
"stop": False, # Normal execution doesn't request stop
"error": error
}
# Shared finalize: variable propagation + on_result callback + return dict
return self._finalize_step_result(
step, output, step_success, error, all_variables, results, on_result
)
async def aexecute(
self,
@@ -4942,31 +5045,12 @@ class WorkflowManager:
"""
import asyncio
# Get previous step output
previous_output = results[-1].get("output") if results else None
# Create context for step handlers (only when a consumer exists)
context = None
if step.should_run or step.handler:
context = WorkflowContext(
input=original_input,
previous_result=str(previous_output) if previous_output else None,
current_step=step.name,
variables=all_variables.copy()
)
# Check should_run condition if provided
if step.should_run:
try:
if not step.should_run(context):
return {
"success": True,
"output": None,
"skipped": True,
"stop": False
}
except Exception as e:
self._log(f"should_run check for step '{step.name}' failed: {e}")
# Shared prep: handler context + should_run gating
context, early_result = self._prepare_step_context(
step, results, all_variables, original_input
)
if early_result is not None:
return early_result
# If step has a custom handler function
if step.handler:
@@ -4999,15 +5083,8 @@ class WorkflowManager:
"error": str(e)
}
# Build context from previous steps
context = self._build_step_context(step, step_idx, results, all_variables)
# Substitute variables in action
action = self._substitute_variables(step.action, all_variables)
# Prepend context to action if available
if context:
action = f"{context}# Current Task:\n{action}"
# Shared prep: build step context + substitute variables
action = self._build_step_action(step, step_idx, results, all_variables)
# Get or create executor for this step
step_executor = executor
@@ -5069,20 +5146,10 @@ class WorkflowManager:
if retries <= step.max_retries:
self._log(f"Step '{step.name}' failed, retrying ({retries}/{step.max_retries})")
# Update variables with step output
if output and step_success:
self._update_variables_with_output(step, output, all_variables, results)
# Callback after step
if on_result and output:
on_result(step, output)
return {
"success": step_success,
"output": output,
"stop": False,
"error": error
}
# Shared finalize: variable propagation + on_result callback + return dict
return self._finalize_step_result(
step, output, step_success, error, all_variables, results, on_result
)
def _create_step_agent(
self,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.7.0"
version = "1.7.1"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,101 @@
"""Every StreamEventType must have a declared AG-UI disposition.
The adapter's fall-through `return []` means an unmapped event type is dropped
with no error and no log. That is invisible for cosmetic events and data loss
for ERROR, RETRY, MODEL_FALLBACK and STREAM_UNAVAILABLE: the client sees a run
that ends cleanly having never been told anything went wrong.
This suite pins the disposition of all sixteen members. Adding a new
StreamEventType without deciding whether it crosses the wire fails the first
test, by construction.
"""
import pytest
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import (
EventBuffer,
NOT_WIRE_VISIBLE,
stream_event_to_agui_events,
)
def _event(event_type: StreamEventType) -> StreamEvent:
"""A populated event, so a drop is never merely an empty payload."""
return StreamEvent(
type=event_type,
content="text",
tool_call={"id": "call-1", "name": "f", "arguments": {}, "result": "r"},
error="boom",
)
def test_every_event_type_has_a_declared_disposition():
undeclared = [
t.name
for t in StreamEventType
if not stream_event_to_agui_events(_event(t), "m1", EventBuffer())
and t not in NOT_WIRE_VISIBLE
]
assert not undeclared, (
"these StreamEventTypes are silently dropped by the AG-UI adapter and are "
f"not declared in NOT_WIRE_VISIBLE: {undeclared}"
)
@pytest.mark.parametrize(
"event_type",
[
StreamEventType.ERROR,
StreamEventType.STREAM_UNAVAILABLE,
StreamEventType.RETRY,
StreamEventType.MODEL_FALLBACK,
],
)
def test_failure_events_reach_the_client(event_type):
"""A failure the engine recovered from is still news the client must get."""
out = stream_event_to_agui_events(_event(event_type), "m1", EventBuffer())
assert out, f"{event_type.name} produced no AG-UI event: the client is never told"
def test_not_wire_visible_is_a_closed_set_of_real_members():
"""Guards against silencing a type by adding a typo to the allowlist."""
for member in NOT_WIRE_VISIBLE:
assert isinstance(member, StreamEventType)
def test_mapped_events_still_map():
"""Positive control: the three handled types must keep working."""
for event_type in (
StreamEventType.DELTA_TEXT,
StreamEventType.TOOL_CALL_START,
StreamEventType.TOOL_CALL_RESULT,
):
assert stream_event_to_agui_events(_event(event_type), "m1", EventBuffer())
def test_tool_call_delta_id_survives_id_less_chunks():
"""Providers send the id once; later argument chunks must keep that id.
A fresh UUID per chunk would orphan the arguments from the invocation the
AG-UI client is assembling.
"""
buffer = EventBuffer()
start = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={"id": "call-42", "name": "f", "arguments": {}},
)
(start_out := stream_event_to_agui_events(start, "m1", buffer))
assert any(getattr(e, "tool_call_id", None) == "call-42" for e in start_out)
chunk = StreamEvent(
type=StreamEventType.DELTA_TOOL_CALL,
tool_call={"arguments": '{"x":'},
)
chunk_out = stream_event_to_agui_events(chunk, "m1", buffer)
assert [e.tool_call_id for e in chunk_out] == ["call-42"]
end = StreamEvent(type=StreamEventType.TOOL_CALL_END, tool_call={})
end_out = stream_event_to_agui_events(end, "m1", buffer)
assert [e.tool_call_id for e in end_out] == ["call-42"]
@@ -241,6 +241,36 @@ These topics are trending this week.
assert len(result) == 1
assert result == ['just a plain string']
def test_parse_single_quoted_python_list(self):
"""Python-style single-quoted lists must not collapse to one item."""
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[])
result = workflow._parse_list_from_string("['topic1', 'topic2', 'topic3']")
assert result == ['topic1', 'topic2', 'topic3']
def test_parse_single_quoted_list_embedded_in_text(self):
"""Single-quoted list embedded in surrounding text is extracted."""
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[])
result = workflow._parse_list_from_string("Topics: ['a', 'b'] found")
assert result == ['a', 'b']
def test_parse_adversarial_bracket_input_is_bounded(self):
"""Deeply nested/bracket-dense input must not hang or crash."""
import time
from praisonaiagents.workflows import Workflow
workflow = Workflow(steps=[])
text = '[' * 100_000 + 'x'
start = time.time()
result = workflow._parse_list_from_string(text)
assert time.time() - start < 15
assert result == [text]
class TestBeginnerFriendlyYAML:
"""Tests for beginner-friendly YAML syntax."""
@@ -0,0 +1,80 @@
"""A per-channel clone must keep the guardrail the original was given.
`clone_for_channel()` rebuilds the agent from `getattr(self, '_guardrails_config',
None)`, but `_guardrails_config` used to be only a local inside `__init__` and was
never assigned to the instance. The getattr therefore always yielded None and the
clone was constructed with no guardrail at all.
This matters because `clone_for_channel()` is the code path that serves traffic:
one clone per channel in the bot gateway, one per HTTP invocation in the API.
"""
from praisonaiagents import Agent
def _reject(output):
"""Guardrail that always refuses."""
return (False, "contains PII")
def _agent():
return Agent(name="Support", instructions="i", llm="gpt-4o-mini", guardrails=_reject)
def test_original_agent_withholds():
"""Control: without this passing, the rest proves nothing."""
assert _agent()._guardrail_fn is not None
def test_clone_keeps_the_guardrail():
clone = _agent().clone_for_channel()
assert clone._guardrail_fn is not None, (
"clone_for_channel() produced an agent with no output guardrail; "
"the gateway serves one such clone per channel"
)
def test_resolved_config_is_persisted_for_the_clone_to_read():
"""clone_for_channel() reads this attribute; it must exist."""
agent = _agent()
assert hasattr(agent, "_guardrails_config")
assert agent._guardrails_config is not None
def test_no_guardrail_stays_no_guardrail():
"""A plain agent must not acquire one, and its clone must not either."""
plain = Agent(name="Plain", instructions="i", llm="gpt-4o-mini")
assert plain._guardrail_fn is None
assert plain.clone_for_channel()._guardrail_fn is None
def test_approval_config_is_persisted_for_the_clone_to_read():
"""clone_for_channel() reads _approval_config; it must exist so the served
clone keeps the operator's approval policy."""
agent = Agent(
name="Approving", instructions="i", llm="gpt-4o-mini", approval="read_only"
)
assert hasattr(agent, "_approval_config")
assert agent._approval_config == "read_only"
def test_clone_keeps_the_approval_policy():
"""A served clone must reconstruct with the same approval policy, otherwise
the gateway serves untrusted traffic with the operator's policy dropped."""
agent = Agent(
name="Approving", instructions="i", llm="gpt-4o-mini", approval="read_only"
)
clone = agent.clone_for_channel()
assert clone._approval_config == "read_only", (
"clone_for_channel() produced an agent without the approval policy; "
"the gateway serves one such clone per channel"
)
assert clone._perm_deny == agent._perm_deny, (
"the read_only preset's enforced deny set was not preserved on the clone"
)
def test_no_approval_stays_no_approval():
"""A plain agent must not acquire a policy, and neither must its clone."""
plain = Agent(name="Plain", instructions="i", llm="gpt-4o-mini")
assert plain._approval_config is None
assert plain.clone_for_channel()._approval_config is None
@@ -320,6 +320,119 @@ async def test_async_wrapper_records_and_replays_result():
journal.close()
def test_inflight_effectful_tool_is_not_replayed_on_resume():
from praisonaiagents import Agent, tool
calls = {"n": 0}
@tool(restart_safe=False)
def send_invoice(amount: int) -> str:
calls["n"] += 1
return "charged"
agent = Agent(name="agent", instructions="test", tools=[send_invoice])
journal = RunJournal(":memory:")
journal.open_run("run-1", task="task")
original = DurableRunContext(journal, "run-1", replaying=False)
with pytest.raises(KeyboardInterrupt):
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
"send_invoice", {"amount": 10}, tool_call_id="call-1"
)
resumed = DurableRunContext(journal, "run-1", replaying=True)
result = resumed.wrap_sync(agent.execute_tool)(
"send_invoice", {"amount": 10}, tool_call_id="call-1"
)
assert calls["n"] == 0
assert result["not_safely_resumable"] is True
assert result["error_type"] == "NotSafelyResumable"
journal.close()
def test_inflight_restart_safe_tool_is_replayed_on_resume():
from praisonaiagents import Agent, tool
calls = {"n": 0}
@tool(restart_safe=True)
def fetch_report(id: int, idempotency_key: str = "") -> str:
calls["n"] += 1
return "report"
agent = Agent(name="agent", instructions="test", tools=[fetch_report])
journal = RunJournal(":memory:")
journal.open_run("run-1", task="task")
original = DurableRunContext(journal, "run-1", replaying=False)
with pytest.raises(KeyboardInterrupt):
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
"fetch_report", {"id": 7}, tool_call_id="call-1"
)
resumed = DurableRunContext(journal, "run-1", replaying=True)
result = resumed.wrap_sync(agent.execute_tool)(
"fetch_report", {"id": 7}, tool_call_id="call-1"
)
assert calls["n"] == 1
assert result == "report"
journal.close()
def test_inflight_undeclared_effectful_name_fails_closed_on_resume():
journal = RunJournal(":memory:")
journal.open_run("run-1", task="task")
original = DurableRunContext(journal, "run-1", replaying=False)
with pytest.raises(KeyboardInterrupt):
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
"delete_account", {"user": "u1"}, tool_call_id="call-1"
)
resumed = DurableRunContext(journal, "run-1", replaying=True)
effect = MagicMock(return_value="deleted")
result = resumed.wrap_sync(effect)(
"delete_account", {"user": "u1"}, tool_call_id="call-1"
)
effect.assert_not_called()
assert result["not_safely_resumable"] is True
journal.close()
def test_inflight_malformed_restart_safe_declaration_fails_closed():
from praisonaiagents import Agent, tool
calls = {"n": 0}
@tool
def push_payment(amount: int) -> str:
calls["n"] += 1
return "charged"
push_payment.restart_safe = "False"
agent = Agent(name="agent", instructions="test", tools=[push_payment])
journal = RunJournal(":memory:")
journal.open_run("run-1", task="task")
original = DurableRunContext(journal, "run-1", replaying=False)
with pytest.raises(KeyboardInterrupt):
original.wrap_sync(MagicMock(side_effect=KeyboardInterrupt))(
"push_payment", {"amount": 5}, tool_call_id="call-1"
)
resumed = DurableRunContext(journal, "run-1", replaying=True)
result = resumed.wrap_sync(agent.execute_tool)(
"push_payment", {"amount": 5}, tool_call_id="call-1"
)
assert calls["n"] == 0
assert result["not_safely_resumable"] is True
journal.close()
def test_resume_requires_known_running_run_and_matching_prompt(tmp_path):
path = str(tmp_path / "journal.db")
agent = SimpleNamespace(
@@ -0,0 +1,99 @@
"""A model response with no content must not become the SDK object's repr.
`_extract_llm_response_content` ended in `return str(response)`, reached whenever
`message.content` is falsy and there are no tool_calls -- a content filter, a
refusal, or `finish_reason="length"` with zero output tokens. `achat()` uses it,
so the user's answer became several hundred characters of `ChatCompletion(...)`
repr, which was then written into chat_history and replayed to the model.
The sync path already does the right thing (`content.strip() if content else ""`).
"""
import asyncio
import types
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from praisonaiagents import Agent
from praisonaiagents.llm.openai_client import OpenAIClient
def _filtered_response():
"""A real SDK object as returned when the content filter trips."""
return ChatCompletion(
id="chatcmpl-test", created=0, model="gpt-4o-mini", object="chat.completion",
choices=[Choice(finish_reason="content_filter", index=0, logprobs=None,
message=ChatCompletionMessage(content=None, role="assistant"))],
)
def test_extractor_returns_empty_not_repr():
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
out = agent._extract_llm_response_content(_filtered_response())
assert not out.startswith("ChatCompletion("), (
f"got {len(out)} chars of object repr as the assistant's answer: {out[:80]!r}")
assert out == ""
def test_extractor_matches_the_sync_path():
"""Sync uses `content.strip() if content else ''`; async must agree."""
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
resp = _filtered_response()
content = resp.choices[0].message.content
sync_style = content.strip() if content else ""
assert agent._extract_llm_response_content(resp) == sync_style
def test_achat_does_not_write_object_repr_into_chat_history():
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
async def _create(**kwargs):
return _filtered_response()
def _create_sync(**kwargs):
return _filtered_response()
client = OpenAIClient(api_key="sk-not-a-real-key")
client._sync_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=_create_sync)),
beta=types.SimpleNamespace(chat=types.SimpleNamespace(
completions=types.SimpleNamespace(parse=_create_sync))),
responses=None)
client._async_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=types.SimpleNamespace(create=_create)),
beta=types.SimpleNamespace(chat=types.SimpleNamespace(
completions=types.SimpleNamespace(parse=_create))),
responses=None)
agent._Agent__openai_client = client
agent._unified_dispatcher = None
result = asyncio.run(agent.achat("say something blocked"))
assert result == "", f"expected empty async result, got {result!r}"
for m in agent.chat_history:
assert not str(m.get("content", "")).startswith("ChatCompletion("), (
f"object repr persisted into chat_history: {str(m['content'])[:80]!r}")
assistant_msgs = [m for m in agent.chat_history if m.get("role") == "assistant"]
assert assistant_msgs, "no assistant message written to chat_history"
assert assistant_msgs[-1].get("content", None) == "", (
f"assistant chat_history entry must be empty, got "
f"{assistant_msgs[-1].get('content')!r}")
def test_extractor_warns_on_refusal_even_when_finish_reason_stop(caplog):
"""A refusal with finish_reason='stop' must still surface a warning."""
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini")
resp = ChatCompletion(
id="chatcmpl-test", created=0, model="gpt-4o-mini", object="chat.completion",
choices=[Choice(finish_reason="stop", index=0, logprobs=None,
message=ChatCompletionMessage(
content=None, role="assistant",
refusal="I can't help with that."))],
)
with caplog.at_level("WARNING"):
out = agent._extract_llm_response_content(resp)
assert out == ""
assert any("refused=True" in r.getMessage() for r in caplog.records), (
"expected a warning when the model refused")
assert not any("I can't help with that." in r.getMessage() for r in caplog.records), (
"refusal text must not be logged")
@@ -0,0 +1,145 @@
"""Sync self-reflection must stop retrying an unparseable reflection.
The async handler bounds the retry with `if reflection_count >= self.max_reflect`.
The sync handler increments the counter and `continue`s unconditionally, so a
reflection that never parses -- the realistic trigger is an OpenAI
structured-output refusal, where `message.parsed` is None and reading
`.reflection` off it raises AttributeError -- loops forever at two LLM calls a
turn. `chat()` never returns.
"""
import asyncio
import types
import pytest
from praisonaiagents import Agent
from praisonaiagents.config.feature_configs import ReflectionConfig
from praisonaiagents.llm.openai_client import OpenAIClient
CAP = 40 # generous: a bounded loop uses <10
class _CallCap(BaseException):
"""BaseException so the agent's own `except Exception` cannot swallow it."""
class _Msg:
role = "assistant"
refusal = None
parsed = None
content = None
tool_calls = None
def __init__(self, content=None):
self.content = content
def model_dump(self):
return {"role": "assistant", "content": self.content}
class _Refusal(_Msg):
"""Structured-output refusal: `parsed` is None, so `.reflection` raises."""
def __init__(self):
super().__init__(content=None)
self.refusal = "I cannot comply."
class _Usage:
prompt_tokens = 10
completion_tokens = 5
total_tokens = 15
completion_tokens_details = None
prompt_tokens_details = None
def model_dump(self):
return {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
class _Resp:
id = "chatcmpl-test"
created = 0
object = "chat.completion"
model = "gpt-4o-mini"
def __init__(self, msg):
self.choices = [types.SimpleNamespace(message=msg, finish_reason="stop",
index=0, delta=None)]
self.usage = _Usage()
class _Transport:
"""Answers `create` with text and `parse` with a refusal, counting both."""
def __init__(self):
self.n = 0
def _tick(self):
self.n += 1
if self.n > CAP:
raise _CallCap(f"exceeded {CAP} LLM calls -- loop is unbounded")
def create(self, **kwargs):
self._tick()
return _Resp(_Msg(content=f"A{self.n}"))
async def acreate(self, **kwargs):
return self.create(**kwargs)
def parse(self, **kwargs):
self._tick()
return _Resp(_Refusal())
async def aparse(self, **kwargs):
return self.parse(**kwargs)
def _agent():
t = _Transport()
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini",
reflection=ReflectionConfig(min_iterations=1, max_iterations=3))
sync_c = types.SimpleNamespace(name="create", create=t.create)
sync_p = types.SimpleNamespace(parse=t.parse)
async_c = types.SimpleNamespace(create=t.acreate)
async_p = types.SimpleNamespace(parse=t.aparse)
client = OpenAIClient(api_key="sk-not-a-real-key")
client._sync_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=sync_c),
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=sync_p)),
responses=None)
client._async_client = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=async_c),
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=async_p)),
responses=None)
agent._Agent__openai_client = client
agent._unified_dispatcher = None
return agent, t
def test_sync_reflection_terminates_when_the_reflection_never_parses():
agent, _t = _agent()
try:
result = agent.chat("q")
except _CallCap as exc:
pytest.fail(str(exc))
assert result, "bounded loop must still return the un-reflected answer"
def test_sync_reflection_call_count_is_bounded_like_async():
agent, t = _agent()
try:
agent.chat("q")
except _CallCap as exc:
pytest.fail(str(exc))
sync_calls = t.n
agent, t = _agent()
asyncio.run(agent.achat("q"))
async_calls = t.n
# The sync path costs more per reflection round (a separate defect), but it
# must at least stay within a small constant of the async bound.
assert sync_calls <= async_calls * 3, (
f"sync used {sync_calls} LLM calls vs async {async_calls}")
@@ -0,0 +1,72 @@
"""Streaming must still produce an answer when a tool is invoked.
The streaming path collects tool calls, executes them, appends the results to
chat history -- and then ends. It never asks the model for the final answer, so
`start(stream=True)` yields nothing while the identical non-streaming call
answers normally.
There is no error. The caller sees an empty generator and cannot tell "the model
had nothing to say" from "the answer was never requested".
Exercised against the real path rather than a mocked client: the defect lives in
the interaction between tool execution and the follow-up completion, which is
precisely what a hand-built mock of that client would define away.
"""
import os
import pytest
from praisonaiagents import Agent
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="exercises the real streaming path; needs a provider credential",
)
SENTINEL = "PRAISONAI-TOOL-SENTINEL"
def sentinel_tool() -> str:
"""Return a fixed marker so a passing assertion cannot be a coincidence."""
return SENTINEL
def _agent():
return Agent(name="T", role="Assistant", goal="Answer using your tools.",
llm="gpt-4o-mini", tools=[sentinel_tool])
def test_a_plain_turn_streams():
"""Positive control: without tools the path must be unaffected."""
chunks = [c for c in Agent(name="P", role="Assistant", goal="Answer.",
llm="gpt-4o-mini").start("Say hi", stream=True) if c]
assert chunks, "a plain streamed turn produced nothing; the harness is broken"
def test_a_tool_using_turn_still_streams_an_answer():
"""The regression: a tool round-trip must not swallow the reply.
Asserting the sentinel flows through the streamed answer proves the tool
branch actually ran -- a plain answer that never called the tool cannot
reproduce the marker, so a green test here cannot be a coincidence.
"""
streamed = "".join(c for c in _agent().start(
"Call sentinel_tool and tell me exactly what it returned.", stream=True) if c)
assert streamed, (
"streaming yielded nothing after a tool call; the follow-up completion "
"that produces the final answer was never requested"
)
assert SENTINEL in streamed, (
"the sentinel never reached the streamed answer; the tool result was "
"not carried into the follow-up completion's request context"
)
def test_streaming_and_non_streaming_agree_when_a_tool_runs():
"""The two paths must not disagree about whether an answer exists."""
blocking = _agent().start("Call sentinel_tool and report its output.")
streamed = "".join(c for c in _agent().start(
"Call sentinel_tool and report its output.", stream=True) if c)
assert blocking, "non-streaming produced nothing; the harness is broken"
assert streamed, "streaming produced nothing while non-streaming answered"
@@ -0,0 +1,172 @@
"""A tool failure must reach the caller as a tool failure.
`_chat_completion` wraps its body in a blanket `except Exception`, which also
catches the `ToolExecutionError` raised by the sync tool executor and re-raises
it as `LLMError`. Three consequences, all covered here:
* `chat()` swallows the error and returns None instead of raising.
* The tool's message is fed to the *LLM* error classifier, so a tool whose
text resembles a rate limit makes the framework sleep and re-drive the model.
* On that re-drive the model is called again with no tool result at all, and
its answer is returned to the user as a success.
"""
import json
import types
import pytest
from praisonaiagents import Agent
from praisonaiagents.errors import ToolExecutionError
from praisonaiagents.llm.openai_client import OpenAIClient
class _Fn:
def __init__(self, name, args):
self.name = name
self.arguments = json.dumps(args)
class _ToolCall:
def __init__(self, id, name, args):
self.id = id
self.type = "function"
self.function = _Fn(name, args)
def model_dump(self):
return {"id": self.id, "type": "function",
"function": {"name": self.function.name,
"arguments": self.function.arguments}}
class _Msg:
role = "assistant"
refusal = None
parsed = None
def __init__(self, content=None, tool_calls=None):
self.content = content
self.tool_calls = tool_calls
def model_dump(self):
d = {"role": "assistant", "content": self.content}
if self.tool_calls:
d["tool_calls"] = [t.model_dump() for t in self.tool_calls]
return d
class _Usage:
prompt_tokens = 10
completion_tokens = 5
total_tokens = 15
completion_tokens_details = None
prompt_tokens_details = None
def model_dump(self):
return {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15}
class _Resp:
id = "chatcmpl-test"
created = 0
object = "chat.completion"
model = "gpt-4o-mini"
def __init__(self, msg, finish_reason="stop"):
self.choices = [types.SimpleNamespace(message=msg, finish_reason=finish_reason,
index=0, delta=None)]
self.usage = _Usage()
class _Recorder:
"""Scripted transport. The sync agent path dispatches through
``asyncio.run(achat_completion(...))``, so both spellings are needed."""
def __init__(self, script):
self.script = list(script)
self.requests = []
def _next(self, kwargs):
self.requests.append(kwargs)
i = len(self.requests) - 1
return self.script[i] if i < len(self.script) else _Resp(_Msg(content="fallback"))
def create(self, **kwargs):
return self._next(kwargs)
class _ARecorder:
def __init__(self, rec):
self.rec = rec
async def create(self, **kwargs):
return self.rec._next(kwargs)
def _agent_with_failing_tool(message, monkeypatch):
"""Agent whose only tool always raises `message`, with a scripted transport."""
runs = []
def send_email(to: str) -> str:
"""Send an email to a recipient."""
runs.append(to)
raise RuntimeError(message)
agent = Agent(name="A", instructions="x", llm="gpt-4o-mini", tools=[send_email])
rec = _Recorder([
_Resp(_Msg(tool_calls=[_ToolCall("c1", "send_email", {"to": "[email protected]"})]),
finish_reason="tool_calls"),
_Resp(_Msg(content="CONFIDENT ANSWER WITH NO TOOL RESULT")),
])
client = OpenAIClient(api_key="sk-not-a-real-key")
fake = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=rec),
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=rec)),
responses=None,
)
arec = _ARecorder(rec)
afake = types.SimpleNamespace(
chat=types.SimpleNamespace(completions=arec),
beta=types.SimpleNamespace(chat=types.SimpleNamespace(completions=arec)),
responses=None,
)
client._sync_client = fake
client._async_client = afake
agent._Agent__openai_client = client
agent._unified_dispatcher = None
# never actually sleep out a backoff in a unit test
monkeypatch.setattr("time.sleep", lambda _d: None)
return agent, rec, runs
def test_chat_raises_tool_execution_error(monkeypatch):
"""A failing tool must surface as ToolExecutionError, not as None."""
agent, _rec, runs = _agent_with_failing_tool("smtp down", monkeypatch)
with pytest.raises(ToolExecutionError):
agent.chat("send the email")
# The failing tool must have actually run, proving the error surfaced
# *because of* the tool call rather than before it. The tool executor retries
# a failing tool up to 3 times, and every attempt targets the same recipient.
assert runs and all(to == "[email protected]" for to in runs)
def test_tool_error_text_is_not_classified_as_an_llm_error(monkeypatch):
"""A tool whose message looks like a rate limit must not re-drive the model.
Without the fix the framework classifies the *tool's* text as a retryable
LLM error, sleeps, and calls the model a second time with no tool result --
returning that answer to the user as a success.
"""
agent, rec, runs = _agent_with_failing_tool("429 too many requests", monkeypatch)
with pytest.raises(ToolExecutionError):
agent.chat("send the email")
assert runs and all(to == "[email protected]" for to in runs), (
"the failing tool must have executed"
)
assert len(rec.requests) == 1, (
f"model was re-driven {len(rec.requests)} times after a tool failure; "
f"last request carried roles "
f"{[m['role'] for m in rec.requests[-1]['messages']]}"
)
@@ -0,0 +1,86 @@
"""runtime= must normalise spelling and reject typos, not degrade silently.
Regression for #4229: an unrecognised runtime name was stored verbatim and
silently dropped nine of twelve capabilities. Spelling variants of a known
runtime must all resolve to the same canonical name; a close typo must raise;
a genuinely unknown plugin runtime name must still be accepted (it falls back
to the reduced harness through the runtime registry, not a closed literal).
"""
import pytest
from praisonaiagents.config.feature_configs import (
RuntimeConfig,
canonical_runtime_name,
resolve_runtime,
)
NATIVE_SPELLINGS = ["native", "NATIVE", " native ", "Native", "nAtIvE"]
@pytest.mark.parametrize("spelling", NATIVE_SPELLINGS)
def test_native_spelling_variants_canonicalise(spelling):
assert canonical_runtime_name(spelling) == "native"
assert resolve_runtime(spelling).preferred_runtime == "native"
@pytest.mark.parametrize(
"spelling", ["plugin-harness", "plugin_harness", "harness", "plugin", "reduced"]
)
def test_harness_aliases_canonicalise(spelling):
assert canonical_runtime_name(spelling) == "plugin-harness"
assert resolve_runtime(spelling).preferred_runtime == "plugin-harness"
@pytest.mark.parametrize("typo", ["nativ", "natve", "reducd", "harnes"])
def test_close_typo_raises_with_suggestion(typo):
with pytest.raises(ValueError):
canonical_runtime_name(typo)
def test_unknown_plugin_runtime_is_accepted_verbatim():
"""A genuinely unknown name is an opaque plugin runtime, not a typo."""
assert canonical_runtime_name("my-plugin-runtime") == "my-plugin-runtime"
cfg = resolve_runtime("my-plugin-runtime")
assert cfg.preferred_runtime == "my-plugin-runtime"
def test_runtime_config_input_is_normalised():
cfg = resolve_runtime(RuntimeConfig(preferred_runtime="NATIVE"))
assert cfg.preferred_runtime == "native"
def test_dict_input_is_normalised():
cfg = resolve_runtime({"preferred_runtime": " native "})
assert cfg.preferred_runtime == "native"
def test_runtime_config_input_is_not_mutated():
"""resolve_runtime returns a normalised copy; the caller's object stands."""
original = RuntimeConfig(preferred_runtime="NATIVE")
resolved = resolve_runtime(original)
assert original.preferred_runtime == "NATIVE"
assert resolved is not original
assert resolved.preferred_runtime == "native"
def test_close_typo_error_suggests_matched_alias():
with pytest.raises(ValueError) as exc:
canonical_runtime_name("reducd")
assert "reduced" in str(exc.value)
def test_agent_normalises_runtime_spelling():
"""Smoke: Agent construction canonicalises a spelling variant."""
from praisonaiagents import Agent
agent = Agent(instructions="test", runtime="NATIVE")
assert agent._runtime_config.preferred_runtime == "native"
def test_agent_rejects_runtime_config_typo():
"""A typo in a direct RuntimeConfig must raise, not degrade silently."""
from praisonaiagents import Agent
with pytest.raises(ValueError):
Agent(instructions="test", runtime=RuntimeConfig(preferred_runtime="nativ"))
@@ -135,6 +135,99 @@ class TestEmergencyTruncation:
assert result_tokens <= target_tokens * 1.5
class TestApplyContextManagementFallback:
"""Regression tests for issue #4240.
When ``ContextManager`` raises, ``_apply_context_management`` must still
apply emergency truncation instead of silently returning the over-budget
history. The threshold and target must budget the *whole* request
(history + system prompt + tool schemas), or a large system prompt / tool
block can push the call over the model window undetected.
"""
def _make_agent(self, strategy_error=False):
from praisonaiagents.agent.chat_mixin import ChatMixin
from praisonaiagents.context import ContextManager
class _Agent(ChatMixin):
def __init__(self):
self.name = "test"
self.llm = "gpt-4o-mini"
self._memory_instance = None
self.context_manager = ContextManager(model="gpt-4o-mini")
agent = _Agent()
if strategy_error:
def _boom(*args, **kwargs):
raise RuntimeError("simulated context-management failure")
agent.context_manager.process = _boom
return agent
def test_overhead_counts_system_prompt_and_tools(self):
"""_estimate_request_overhead_tokens includes prompt + tool schemas."""
agent = self._make_agent()
assert agent._estimate_request_overhead_tokens("", None) == 0
prompt_only = agent._estimate_request_overhead_tokens("word " * 500, None)
assert prompt_only > 0
tools = [{
"type": "function",
"function": {
"name": "search",
"description": "d" * 4000,
"parameters": {"type": "object", "properties": {}},
},
}]
with_tools = agent._estimate_request_overhead_tokens("word " * 500, tools)
assert with_tools > prompt_only
def test_fallback_truncates_when_system_prompt_overflows(self):
"""Large system prompt over budget must trigger emergency truncation.
History alone is well under the limit; only the system prompt pushes
the request over. Before the fix this branch skipped truncation.
"""
from praisonaiagents.context.budgeter import get_model_limit
from praisonaiagents.context.tokens import (
estimate_messages_tokens,
estimate_tokens_heuristic,
)
agent = self._make_agent(strategy_error=True)
model_limit = get_model_limit("gpt-4o-mini")
messages = [
{"role": "user", "content": "short question"},
{"role": "assistant", "content": "short answer"},
]
# History is tiny; a massive system prompt consumes the whole window.
big_prompt = "token " * (model_limit) # far above the model limit
assert estimate_messages_tokens(messages) < model_limit * 0.95
assert estimate_tokens_heuristic(big_prompt) > model_limit
out, result = agent._apply_context_management(
messages, system_prompt=big_prompt, tools=None
)
assert result is not None
assert result.get("emergency_truncated") is True
def test_fallback_noop_when_whole_request_fits(self):
"""No truncation when the full request is comfortably under budget."""
agent = self._make_agent(strategy_error=True)
messages = [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"},
]
out, result = agent._apply_context_management(
messages, system_prompt="You are helpful.", tools=None
)
assert out == messages
assert result is None
class TestToolOutputLimits:
"""Tests for tool output truncation at source."""
@@ -206,3 +206,63 @@ class TestOptimizationResult:
result = OptimizationResult(original_tokens=0)
assert result.reduction_percent == 0.0
class TestGetOptimizerKwargsFilter:
"""Regression tests for issue #4240.
``ContextManager`` passes the union of every optimizer's kwargs to
``get_optimizer``. Only ``SmartOptimizer`` accepted that full signature;
the other six raised ``TypeError`` and context management was silently
disabled. ``get_optimizer`` must filter kwargs to each class's signature.
"""
def _union_kwargs(self):
return {
"preserve_recent": 5,
"protected_tools": ["search"],
"llm_summarize_fn": None,
"smart_tool_summarize": True,
"tool_summarize_limits": {},
"llm_analyze_fn": None,
"min_compaction_ratio": 0.5,
"analyzer_strategy": "default",
}
def test_every_strategy_instantiates_with_union_kwargs(self):
from praisonaiagents.context.optimizer import (
get_optimizer, OPTIMIZER_REGISTRY,
)
for strategy in OPTIMIZER_REGISTRY:
optimizer = get_optimizer(strategy, **self._union_kwargs())
assert optimizer is not None
def test_every_strategy_reduces_over_budget_history(self):
from praisonaiagents.context.manager import ContextManager
from praisonaiagents.context.models import ContextConfig, OptimizerStrategy
from praisonaiagents.context.tokens import estimate_messages_tokens
def history():
msgs = [{"role": "system", "content": "You are a helpful assistant."}]
for i in range(120):
msgs.append({"role": "user", "content": f"[u{i}] " + " ".join(f"w{i}_{j}" for j in range(400))})
msgs.append({"role": "assistant", "content": f"[a{i}] " + " ".join(f"r{i}_{j}" for j in range(400))})
return msgs
# Strategies that reduce plain user/assistant text. prune_tools and
# non_destructive target tool messages, which this transcript lacks.
for strategy in (
OptimizerStrategy.TRUNCATE,
OptimizerStrategy.SLIDING_WINDOW,
OptimizerStrategy.SUMMARIZE,
OptimizerStrategy.SMART,
OptimizerStrategy.CONVERSATION,
):
cfg = ContextConfig(strategy=strategy, auto_compact=True)
mgr = ContextManager(model="gpt-4o", config=cfg)
msgs = history()
before = estimate_messages_tokens(msgs)
out = mgr.process(msgs)["messages"]
after = estimate_messages_tokens(out)
assert after < before, f"{strategy.value} did not reduce over-budget history"
@@ -194,14 +194,15 @@ REASONING: Way too low"""
assert result.score == 1.0
def test_parse_response_invalid_score(self):
"""Test parsing response with invalid score."""
"""An unparseable score must fail closed, not fabricate a passing 5.0 (#4195)."""
grader = BaseLLMGrader()
response = """SCORE: not a number
REASONING: Invalid"""
result = grader._parse_response(response, "", "", None)
assert result.score == 5.0 # Default
assert result.score == 1.0 # Fail closed: no trustworthy number parsed
assert result.reasoning == "Invalid"
def test_get_litellm_import_error(self):
"""Test that missing litellm raises ImportError."""
@@ -0,0 +1,202 @@
"""
Regression tests for the HOL Guard tool policy example.
Proves the `wrap_tool_call` middleware in
`examples/middleware/hol_guard_tool_policy.py` is fail-closed: the wrapped tool
executor is only reached when HOL Guard returns an explicit allow verdict.
Every other verdict/error path short-circuits with zero downstream execution.
"""
import importlib.util
import json
from pathlib import Path
from unittest.mock import patch
from praisonaiagents.hooks import ToolRequest
def _find_example_path():
for parent in Path(__file__).resolve().parents:
candidate = parent / "examples" / "middleware" / "hol_guard_tool_policy.py"
if candidate.exists():
return candidate
raise FileNotFoundError("hol_guard_tool_policy.py example not found")
_EXAMPLE_PATH = _find_example_path()
def _load_example():
spec = importlib.util.spec_from_file_location(
"hol_guard_tool_policy_example", _EXAMPLE_PATH
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _make_request(command="echo hi", tool_name="run_shell"):
return ToolRequest(tool_name=tool_name, arguments={"command": command})
class _FakeCompleted:
def __init__(self, returncode=0, stdout="", stderr=""):
self.returncode = returncode
self.stdout = stdout
self.stderr = stderr
def test_explicit_allow_reaches_tool():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
patch.object(
module.subprocess,
"run",
return_value=_FakeCompleted(0, json.dumps({"decision": "allow"})),
):
result = module.hol_guard_tool_policy(_make_request(), call_next)
assert result == "ran"
assert len(executed) == 1
def test_block_verdict_does_not_execute():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
patch.object(
module.subprocess,
"run",
return_value=_FakeCompleted(0, json.dumps({"decision": "block"})),
):
result = module.hol_guard_tool_policy(_make_request("rm -rf /"), call_next)
assert executed == []
assert result.error is not None
# The denial message is surfaced in `result` too, because the agent tool
# executor returns `ToolResponse.result` to the model and discards `error`.
assert result.result == result.error
def test_missing_cli_fails_closed():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
with patch.object(module.shutil, "which", return_value=None):
result = module.hol_guard_tool_policy(_make_request(), call_next)
assert executed == []
assert result.error is not None
def test_malformed_json_fails_closed():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
patch.object(
module.subprocess,
"run",
return_value=_FakeCompleted(0, "not-json"),
):
result = module.hol_guard_tool_policy(_make_request(), call_next)
assert executed == []
assert result.error is not None
def test_non_object_json_fails_closed():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
for payload in ("[]", '"allow"', "null"):
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
patch.object(
module.subprocess,
"run",
return_value=_FakeCompleted(0, payload),
):
result = module.hol_guard_tool_policy(_make_request(), call_next)
assert executed == []
assert result.error is not None
def test_nonzero_exit_fails_closed():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
patch.object(
module.subprocess,
"run",
return_value=_FakeCompleted(1, json.dumps({"decision": "allow"})),
):
result = module.hol_guard_tool_policy(_make_request(), call_next)
assert executed == []
assert result.error is not None
def test_timeout_fails_closed():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
def _raise_timeout(*args, **kwargs):
raise module.subprocess.TimeoutExpired(cmd="hol-guard", timeout=10)
with patch.object(module.shutil, "which", return_value="/usr/bin/hol-guard"), \
patch.object(module.subprocess, "run", side_effect=_raise_timeout):
result = module.hol_guard_tool_policy(_make_request(), call_next)
assert executed == []
assert result.error is not None
def test_non_command_tool_passes_through_untouched():
module = _load_example()
executed = []
def call_next(req):
executed.append(req)
return "ran"
with patch.object(module.shutil, "which") as which_mock:
result = module.hol_guard_tool_policy(
_make_request(tool_name="get_weather"), call_next
)
which_mock.assert_not_called()
assert result == "ran"
assert len(executed) == 1
@@ -0,0 +1,92 @@
"""`Memory` must not fall through to legacy SQL that bypasses the active adapter.
Two public methods still target the pre-adapter schema:
* `get_all_memories()` selects from `short_mem` / `long_mem`. Nothing in the
codebase creates those tables -- the adapter creates `short_term_memory` /
`long_term_memory` -- so the OperationalError is swallowed and it returns []
for every provider, always.
* `delete_memory(id)` with no `memory_type` returns on the first tier that
reports a hit. The SQLite adapter gives each tier its own AUTOINCREMENT
sequence, so the Nth short-term row and the Nth long-term row always share an
id. Deleting the id `remember()` handed back therefore destroys an unrelated
short-term row and leaves the caller's target in place, returning success.
Sibling methods (`delete_short_term`, `reset_short_term`) already delegate to the
adapter with explicit comments saying why; these two were missed.
"""
import pytest
from praisonaiagents.memory.memory import Memory
@pytest.fixture
def store(tmp_path, monkeypatch):
"""A Memory backed by a SQLite store isolated to this test.
The adapter resolves its database under ``./.praisonai``, so isolation means
changing the working directory -- passing ``storage_path`` in the config does
not move it, and without the chdir every test shares one store.
"""
monkeypatch.setenv("PRAISONAI_HOME", str(tmp_path / "home"))
monkeypatch.chdir(tmp_path)
return Memory(config={"provider": "sqlite"})
def _tier(mem, which):
return [r for r in mem.memory_adapter.get_all_memories()
if (r.get("memory_type") or r.get("type")) == which]
def test_adapter_holds_the_data(store):
"""Control: without this passing, the other assertions prove nothing."""
store.store_short_term("STM: user prefers dark mode")
store.store_long_term("LTM: user is in Chennai")
assert len(store.memory_adapter.get_all_memories()) == 2
def test_get_all_memories_returns_what_is_stored(store):
store.store_short_term("STM: user prefers dark mode")
store.store_long_term("LTM: user is in Chennai")
got = store.get_all_memories()
assert got, (
"get_all_memories() returned nothing while the adapter holds "
f"{len(store.memory_adapter.get_all_memories())} rows"
)
kinds = {r.get("memory_type") or r.get("type") for r in got}
assert kinds == {"short_term", "long_term"}
# Backward compatibility: the legacy path exposed each record's tier under
# the public `type` key, so adapter-backed records must too.
assert all(r.get("type") for r in got), (
"adapter-backed records dropped the public `type` field callers rely on"
)
assert {r["type"] for r in got} == {"short_term", "long_term"}
def test_delete_by_id_erases_the_long_term_record(store):
"""The id `remember()` returns must delete the record it refers to."""
short_id = store.store_short_term("turn 1: hello there")
long_id = store.remember("User SSN is 123-45-6789")
# Precondition. If the ids do not collide this test cannot detect the bug.
assert short_id == long_id, (
f"expected an id collision across tiers, got {short_id!r} / {long_id!r}"
)
store.forget(memory_id=long_id)
assert _tier(store, "long_term") == [], (
"the record the caller asked to erase is still present"
)
assert _tier(store, "short_term") == [], (
"the colliding short-term record was left behind"
)
def test_delete_without_collision_still_works(store):
"""Control: the no-collision case must keep behaving as before."""
long_id = store.remember("SECRET fact")
assert store.forget(memory_id=long_id) == 1
assert _tier(store, "long_term") == []
@@ -0,0 +1,156 @@
"""A decision string with surrounding whitespace must route like a clean one.
`process.py` derives the routing key as `current_task.result.raw.lower()` -- eight
sites, none of which `.strip()`. `result.raw` is raw model text, which routinely
carries a trailing newline. The condition lookup then misses, and a miss is
treated as "exit condition met": the workflow finishes silently, the rejected
task is never retried, no feedback is produced, and no exception is raised.
`run_outcome.validate_decision_string` -- the typed classifier the same code path
calls -- does `.lower().strip()`, so the two disagree about the same string.
"""
import asyncio
import pytest
from praisonaiagents import Task, TaskOutput
from praisonaiagents.process import Process
from praisonaiagents.run_outcome import validate_decision_string
class _ScriptedAgent:
"""Returns a fixed string. No LLM, no network."""
def __init__(self, name, out):
self.name = name
self.out = out
self.role = "r"
self.goal = "g"
self.tools = []
self.llm = "gpt-4o-mini"
def execute_task(self, task, context=None, **kw):
return self.out
async def aexecute_task(self, task, context=None, **kw):
return self.out
def _run(decision_text, limit=25):
"""Drive the real workflow to natural completion; return (visited, collect task).
`limit` is a runaway guard only. This workflow self-terminates after 10 task
visits, so the guard must sit above that -- breaking out at exactly 10 cuts the
generator off before its final step and the clean-decision control fails for
the harness's reasons rather than the code's.
"""
collect = _ScriptedAgent("collector", "Collected only 3 items")
judge = _ScriptedAgent("judge", decision_text)
t1 = Task(name="collect_data", description="Collect data", expected_output="Data",
agent=collect, is_start=True, next_tasks=["validate_data"])
t2 = Task(name="validate_data", description="Validate", expected_output="verdict",
agent=judge, task_type="decision",
condition={"valid": [], "invalid": ["collect_data"]})
t2.previous_tasks = ["collect_data"]
process = Process(agents={"collector": collect, "judge": judge},
tasks={t1.id: t1, t2.id: t2}, verbose=0)
visited = []
async def drive():
async for tid in process.aworkflow():
task = process.tasks[tid]
visited.append(task.name)
task.result = TaskOutput(description="d", raw=task.agent.out,
agent=task.agent.name)
task.status = "completed"
if len(visited) >= limit:
break
asyncio.run(drive())
return visited, t1
def _run_sync(decision_text, limit=25):
"""Same scenario against the synchronous ``workflow()`` generator.
``workflow()`` is a separately duplicated code path from ``aworkflow()`` --
its routing sites were edited independently -- so it needs its own
regression coverage. A whitespace regression in only one of the two paths
would otherwise leave this suite green.
"""
collect = _ScriptedAgent("collector", "Collected only 3 items")
judge = _ScriptedAgent("judge", decision_text)
t1 = Task(name="collect_data", description="Collect data", expected_output="Data",
agent=collect, is_start=True, next_tasks=["validate_data"])
t2 = Task(name="validate_data", description="Validate", expected_output="verdict",
agent=judge, task_type="decision",
condition={"valid": [], "invalid": ["collect_data"]})
t2.previous_tasks = ["collect_data"]
process = Process(agents={"collector": collect, "judge": judge},
tasks={t1.id: t1, t2.id: t2}, verbose=0)
visited = []
for tid in process.workflow():
task = process.tasks[tid]
visited.append(task.name)
task.result = TaskOutput(description="d", raw=task.agent.out,
agent=task.agent.name)
task.status = "completed"
if len(visited) >= limit:
break
return visited, t1
@pytest.mark.parametrize("decision", ["invalid", "invalid\n", " invalid", "invalid ",
"\ninvalid\n"])
def test_rejected_output_is_retried_regardless_of_whitespace(decision):
visited, collect_task = _run(decision)
assert visited.count("collect_data") > 1, (
f"judge returned {decision!r} (a rejection) but collect_data was never "
f"retried; workflow ran {visited} and finished silently")
@pytest.mark.parametrize("decision", ["invalid\n", " invalid"])
def test_validation_feedback_survives_whitespace(decision):
_visited, collect_task = _run(decision)
assert collect_task.validation_feedback is not None, (
f"judge returned {decision!r} but the retried task got no feedback about "
f"what was wrong")
@pytest.mark.parametrize("decision", ["invalid\n", " invalid", "invalid "])
def test_router_and_typed_classifier_agree(decision):
"""The routing key and the typed classifier must read the same string the same way."""
routing_key = decision.lower().strip()
assert validate_decision_string(decision) == validate_decision_string(routing_key)
assert routing_key in Process.VALIDATION_FAILURE_DECISIONS, (
f"{decision!r} classifies as {validate_decision_string(decision)!r} but its "
f"routing key {routing_key!r} is not recognised as a failure decision")
def test_clean_decision_still_works():
"""Control: the unpadded spelling must keep behaving exactly as before."""
visited, collect_task = _run("invalid")
assert visited.count("collect_data") > 1
assert collect_task.validation_feedback is not None
@pytest.mark.parametrize("decision", ["invalid", "invalid\n", " invalid", "invalid ",
"\ninvalid\n"])
def test_sync_rejected_output_is_retried_regardless_of_whitespace(decision):
"""The synchronous ``workflow()`` path must strip routing keys too."""
visited, _collect_task = _run_sync(decision)
assert visited.count("collect_data") > 1, (
f"sync judge returned {decision!r} (a rejection) but collect_data was "
f"never retried; workflow ran {visited} and finished silently")
def test_sync_clean_decision_still_works():
"""Control for the synchronous path."""
visited, _collect_task = _run_sync("invalid")
assert visited.count("collect_data") > 1
@@ -0,0 +1,194 @@
"""Tests for portable session export/import/migration (Issue #4267).
Covers the ``PortableSessionStoreProtocol`` contract and its reference
implementation on ``DefaultSessionStore`` (inherited by ``SqliteSessionStore``):
round-trip export/import, lineage-aware single export, live-field reset on
import, and the hardened import guards (caps, duplicates, malformed payloads).
"""
import tempfile
import pytest
def _make_store(tmp):
from praisonaiagents.session.store import DefaultSessionStore
return DefaultSessionStore(session_dir=tmp)
class TestPortableProtocol:
def test_protocol_importable(self):
from praisonaiagents.session import PortableSessionStoreProtocol
assert PortableSessionStoreProtocol is not None
def test_import_report_importable(self):
from praisonaiagents.session import ImportReport
rep = ImportReport(imported=2)
assert rep.skipped_count == 0
assert rep.as_dict()["imported"] == 2
def test_default_store_conforms(self):
from praisonaiagents.session import PortableSessionStoreProtocol
with tempfile.TemporaryDirectory() as tmp:
store = _make_store(tmp)
assert isinstance(store, PortableSessionStoreProtocol)
def test_sqlite_store_conforms(self):
from praisonaiagents.session import PortableSessionStoreProtocol
from praisonaiagents.session.sqlite_store import SqliteSessionStore
with tempfile.TemporaryDirectory() as tmp:
store = SqliteSessionStore(session_dir=tmp)
assert isinstance(store, PortableSessionStoreProtocol)
class TestExport:
def test_export_all_versioned(self):
with tempfile.TemporaryDirectory() as tmp:
store = _make_store(tmp)
store.add_message("s1", "user", "hi")
store.add_message("s2", "user", "yo")
payload = store.export_all()
assert payload["version"] == store.PORTABLE_VERSION
assert len(payload["sessions"]) == 2
def test_export_single(self):
with tempfile.TemporaryDirectory() as tmp:
store = _make_store(tmp)
store.add_message("s1", "user", "hi")
payload = store.export_session("s1")
assert len(payload["sessions"]) == 1
assert payload["sessions"][0]["session_id"] == "s1"
def test_export_unknown_is_empty(self):
with tempfile.TemporaryDirectory() as tmp:
store = _make_store(tmp)
assert store.export_session("nope")["sessions"] == []
class TestImportRoundTrip:
def test_roundtrip_preserves_history(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hello")
src.add_message("s1", "assistant", "world")
dst = _make_store(b)
report = dst.import_sessions(src.export_all())
assert report.imported == 1
assert dst.session_exists("s1")
hist = dst.get_chat_history("s1")
assert [m["content"] for m in hist] == ["hello", "world"]
def test_import_resets_live_fields(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
src.set_gateway_info("s1", gateway_session_id="live-1", agent_id="agentX")
dst = _make_store(b)
dst.import_sessions(src.export_all())
sd = dst.get_session("s1")
assert sd.gateway_session_id is None
assert sd.agent_id is None
def test_import_keep_live_fields_opt_out(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
src.set_gateway_info("s1", gateway_session_id="live-1", agent_id="agentX")
dst = _make_store(b)
dst.import_sessions(src.export_all(), reset_live_fields=False)
sd = dst.get_session("s1")
assert sd.gateway_session_id == "live-1"
class TestImportHardening:
def test_existing_skipped_without_overwrite(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
payload = src.export_all()
dst = _make_store(b)
assert dst.import_sessions(payload).imported == 1
report = dst.import_sessions(payload)
assert report.imported == 0
assert report.skipped_count == 1
def test_overwrite(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
payload = src.export_all()
dst = _make_store(b)
dst.import_sessions(payload)
assert dst.import_sessions(payload, overwrite=True).imported == 1
def test_max_sessions_cap(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
src.add_message("s2", "user", "yo")
dst = _make_store(b)
report = dst.import_sessions(src.export_all(), max_sessions=1)
assert report.imported == 1
assert report.skipped_count >= 1
def test_duplicate_in_payload(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
payload = src.export_all()
payload["sessions"].append(dict(payload["sessions"][0]))
dst = _make_store(b)
report = dst.import_sessions(payload)
assert report.imported == 1
assert any("duplicate" in s["reason"] for s in report.skipped)
@pytest.mark.parametrize("bad", [{}, {"sessions": "x"}, {"sessions": [1, 2]}, "notadict"])
def test_malformed_payloads(self, bad):
with tempfile.TemporaryDirectory() as tmp:
store = _make_store(tmp)
report = store.import_sessions(bad)
assert report.imported == 0
def test_rejects_newer_version(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
payload = src.export_all()
payload["version"] = src.PORTABLE_VERSION + 1
dst = _make_store(b)
report = dst.import_sessions(payload)
assert report.imported == 0
assert not dst.session_exists("s1")
assert any("unsupported payload version" in s["reason"] for s in report.skipped)
def test_import_does_not_mutate_payload(self):
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
src.add_message("s1", "user", "hi")
src.set_gateway_info("s1", gateway_session_id="live-1", agent_id="agentX")
payload = src.export_all()
record = payload["sessions"][0]
gsid_before = record.get("gateway_session_id")
meta_before = dict(record.get("metadata") or {})
dst = _make_store(b)
dst.import_sessions(payload) # reset_live_fields=True by default
assert record.get("gateway_session_id") == gsid_before
assert (record.get("metadata") or {}) == meta_before
def test_import_preserves_history_over_destination_window(self):
from praisonaiagents.session.store import DefaultSessionStore
with tempfile.TemporaryDirectory() as a, tempfile.TemporaryDirectory() as b:
src = _make_store(a)
for i in range(6):
src.add_message("s1", "user", f"m{i}")
payload = src.export_all()
# Destination truncates to a small window; a restore must not lose
# the exported history before it lands.
dst = DefaultSessionStore(
session_dir=b, active_window=2, retention="truncate"
)
assert dst.import_sessions(payload).imported == 1
hist = dst.get_chat_history("s1")
assert len(hist) == 6
@@ -185,3 +185,42 @@ class TestAgentAsToolIntegration:
# The handoffs should be processed into the tools list
assert writer.name == "Writer"
# Note: Handoffs are processed in _process_handoffs, so we check the agent was created
def test_as_tool_actually_runs_the_sub_agent(self, monkeypatch):
"""'Handoff completed' is not 'the sub-agent ran'. Assert the call, not
the string.
``as_tool()`` pins ``ContextPolicy.NONE`` so there is no history to
harvest a prompt from; the ``task`` argument must drive the sub-agent.
A test that only checks ``"not found" not in result`` passes even when
the sub-agent is never invoked — this one records the actual call.
"""
from praisonaiagents import Agent
seen = []
monkeypatch.setattr(
Agent, "chat", lambda self, p, **k: seen.append((self.name, p)) or "done"
)
coder = Agent(name="Coder", instructions="x")
writer = Agent(
name="Writer",
instructions="x",
tools=[coder.as_tool(tool_name="invoke_coder")],
)
writer.execute_tool("invoke_coder", {"task": "write a haiku"})
assert seen == [("Coder", "write a haiku")]
def test_as_tool_advertises_a_task_parameter(self):
"""The generated tool must expose a ``task`` parameter so the LLM knows
to fill it — a ``(**kwargs)`` signature advertises nothing.
"""
import inspect
from praisonaiagents import Agent
coder = Agent(name="Coder", instructions="x")
tool_fn = coder.as_tool(tool_name="invoke_coder").to_tool_function(
Agent(name="Writer", instructions="x")
)
assert "task" in inspect.signature(tool_fn).parameters
@@ -0,0 +1,57 @@
"""An unknown Agent kwarg must say what to do instead, not just that it is wrong.
`Agent` deliberately rejects unknown keyword arguments rather than swallowing
them -- that part is correct and worth keeping. But several names are near
universal in this ecosystem (`verbose` is accepted by six sibling classes here,
and by crewai and langchain), so users reach for them constantly. A bare
"unexpected keyword argument" leaves them with no next step, and the answer is
not discoverable from the 41-parameter signature.
"""
import pytest
from praisonaiagents import Agent
def _err(**kwargs) -> str:
with pytest.raises(TypeError) as exc:
Agent(name="A", role="R", goal="G", **kwargs)
return str(exc.value)
def test_unknown_kwargs_are_still_rejected():
"""Positive control: the fail-fast behaviour must not be softened into a warning."""
assert "definitely_not_a_param" in _err(definitely_not_a_param=1)
def test_verbose_explains_where_verbosity_actually_lives():
"""Verbosity was consolidated into `output=`; the error must say so.
Naming the wrong destination is worse than naming none: it sends the user
to a setting that does not control this.
"""
message = _err(verbose=False)
assert "verbose" in message
assert "output" in message.lower(), (
"the error names the bad argument but not the supported alternative; "
f"got: {message}"
)
def test_stream_also_points_at_output():
"""`stream` moved the same way, and internal callers were passing both."""
assert "output" in _err(stream=False).lower()
def test_the_guidance_names_the_offending_argument_not_a_generic_hint():
"""Guidance must be specific, or it is noise on every unrelated typo."""
generic = _err(some_typo=1)
assert "some_typo" in generic
assert "output=" not in generic, (
"an unrelated typo received the migration guidance meant for `verbose`"
)
def test_multiple_unknown_kwargs_are_all_listed():
message = _err(verbose=True, another_bad_one=2)
assert "verbose" in message and "another_bad_one" in message
@@ -157,28 +157,34 @@ class TestConcurrentMemoryAccess:
class TestLazyCacheThreadSafety:
"""Test _lazy_cache thread safety."""
"""Test lazy loading thread safety."""
def test_lazy_cache_access_thread_safe(self):
"""_lazy_cache access should be thread-safe."""
from praisonaiagents import _get_lazy_cache
"""Concurrent lazy attribute access should resolve to a single cached object."""
import praisonaiagents
# Evict any pre-cached value so the concurrent access below actually
# exercises the synchronized first-load path (double-checked lock in
# _lazy.lazy_import) instead of taking the unlocked cache fast path.
praisonaiagents._lazy_cache.pop('Task', None)
errors = []
results = []
def access_cache():
def access_lazy_attr():
try:
cache = _get_lazy_cache()
assert isinstance(cache, dict)
results.append(praisonaiagents.Task)
except Exception as e:
errors.append(str(e))
threads = [threading.Thread(target=access_cache) for _ in range(10)]
threads = [threading.Thread(target=access_lazy_attr) for _ in range(10)]
for t in threads:
t.start()
for t in threads:
t.join()
assert len(errors) == 0, f"Thread errors: {errors}"
assert all(r is results[0] for r in results)
if __name__ == "__main__":
@@ -95,6 +95,29 @@ class TestConfigurableModelProvider:
)
assert agent is not None
def test_agent_with_evolink_openai_compatible_config(self):
"""Agent should pass EvoLink OpenAI-compatible settings to LLM."""
from praisonaiagents import Agent
with patch("praisonaiagents.llm.llm.LLM") as mock_llm:
agent = Agent(
name="Test",
instructions="Test agent",
llm={
"model": "openai/gpt-5.2",
"api_key": "test-evolink-key",
"base_url": "https://direct.evolink.ai/v1",
},
)
assert agent is not None
assert agent.llm == "openai/gpt-5.2"
assert agent._llm_init_params["model"] == "openai/gpt-5.2"
assert agent._llm_init_params["api_key"] == "test-evolink-key"
assert agent._llm_init_params["base_url"] == "https://direct.evolink.ai/v1"
assert agent._using_custom_llm is True
mock_llm.assert_not_called()
class TestConfigurableModelThreadSafety:
"""Test thread safety of agent model usage."""
@@ -0,0 +1,139 @@
"""Unit tests for the global operator emergency-stop brake (Issue #4220).
Covers the pure, core-side contract: the ``EmergencyStopProtocol``, the
zero-cost ``NullEmergencyStop`` default (never engaged), the durable
fail-safe ``FileEmergencyStop`` sentinel, and the ``EmergencyStopConfig``
selector wired into ``GatewayConfig``.
"""
import pytest
from praisonaiagents.gateway import (
EmergencyStopConfig,
EmergencyStopProtocol,
EmergencyStopState,
FileEmergencyStop,
GatewayConfig,
NullEmergencyStop,
)
def test_null_and_file_conform_to_protocol(tmp_path):
assert isinstance(NullEmergencyStop(), EmergencyStopProtocol)
assert isinstance(
FileEmergencyStop(str(tmp_path / "x.pause")), EmergencyStopProtocol
)
def test_null_brake_never_engaged():
brake = NullEmergencyStop()
assert brake.is_engaged() is False
brake.engage(reason="cost spike", actor="op")
assert brake.is_engaged() is False
assert brake.state().engaged is False
def test_file_brake_engage_disengage_roundtrip(tmp_path):
sentinel = tmp_path / "gateway.pause"
brake = FileEmergencyStop(str(sentinel))
assert brake.is_engaged() is False
brake.engage(reason="provider incident", actor="mervin")
assert brake.is_engaged() is True
st = brake.state()
assert st.engaged is True
assert st.reason == "provider incident"
assert st.actor == "mervin"
assert st.at > 0
brake.disengage()
assert brake.is_engaged() is False
assert brake.state().engaged is False
def test_file_brake_is_durable_across_instances(tmp_path):
sentinel = tmp_path / "gateway.pause"
FileEmergencyStop(str(sentinel)).engage(reason="hold")
# A fresh instance (as after a restart) sees the persisted engaged state.
assert FileEmergencyStop(str(sentinel)).is_engaged() is True
def test_file_brake_disengage_is_idempotent(tmp_path):
brake = FileEmergencyStop(str(tmp_path / "gateway.pause"))
brake.disengage() # no sentinel yet -> no error
assert brake.is_engaged() is False
def test_file_brake_corrupt_sentinel_fails_safe_engaged(tmp_path):
sentinel = tmp_path / "gateway.pause"
sentinel.write_text("{ this is not valid json")
brake = FileEmergencyStop(str(sentinel))
# Unreadable/corrupt sentinel counts as engaged, never "run freely".
assert brake.is_engaged() is True
assert brake.state().engaged is True
def test_file_brake_structured_at_fails_safe_engaged(tmp_path):
# Valid JSON object but a non-numeric ``at`` (list) must not raise from
# float(); state() must still fail-safe engaged rather than propagate.
sentinel = tmp_path / "gateway.pause"
sentinel.write_text('{"engaged": true, "at": []}')
brake = FileEmergencyStop(str(sentinel))
assert brake.is_engaged() is True
assert brake.state().engaged is True
def test_file_brake_requires_path():
with pytest.raises(ValueError):
FileEmergencyStop("")
def test_config_defaults_off_and_disabled():
cfg = EmergencyStopConfig()
assert cfg.backend == "off"
assert cfg.enabled is False
assert isinstance(cfg.to_estop(), NullEmergencyStop)
def test_config_file_backend_requires_path():
with pytest.raises(ValueError):
EmergencyStopConfig(backend="file")
def test_config_rejects_unknown_backend():
with pytest.raises(ValueError):
EmergencyStopConfig(backend="bogus")
def test_config_file_backend_builds_file_brake(tmp_path):
cfg = EmergencyStopConfig(backend="file", path=str(tmp_path / "p.pause"))
assert cfg.enabled is True
assert isinstance(cfg.to_estop(), FileEmergencyStop)
def test_config_from_dict_tolerates_none():
assert EmergencyStopConfig.from_dict(None).backend == "off"
def test_config_roundtrip_dict(tmp_path):
cfg = EmergencyStopConfig(backend="file", path=str(tmp_path / "p.pause"))
restored = EmergencyStopConfig.from_dict(cfg.to_dict())
assert restored.backend == "file"
assert restored.path == cfg.path
def test_gateway_config_default_control_is_off():
gw = GatewayConfig()
assert gw.control.backend == "off"
assert gw.control.enabled is False
assert gw.to_dict()["control"] == {"backend": "off", "path": None}
def test_estop_state_to_dict():
state = EmergencyStopState(engaged=True, reason="r", actor="a", at=1.5)
assert state.to_dict() == {
"engaged": True,
"reason": "r",
"actor": "a",
"at": 1.5,
}
@@ -0,0 +1,122 @@
"""Unit tests for gateway saturation/back-pressure telemetry (Issue #4265).
Covers the pure, core-side contract: the frozen :class:`HealthPressure`
dataclass, its JSON shape, and the deterministic :func:`evaluate_pressure`
classifier (``nominal`` / ``elevated`` / ``saturated``). No I/O.
"""
from praisonaiagents.gateway import HealthPressure, evaluate_pressure
def test_defaults_are_nominal():
p = HealthPressure()
assert p.pressure == "nominal"
assert p.admission_max == 0
assert p.event_loop_lag_p99_ms == 0.0
def test_to_dict_has_closed_shape():
p = HealthPressure(admission_max=16, admission_in_flight=4, pressure="elevated")
d = p.to_dict()
assert d["admission_max"] == 16
assert d["admission_in_flight"] == 4
assert d["pressure"] == "elevated"
assert set(d) == {
"admission_max",
"admission_in_flight",
"admission_queued",
"admission_shed_total",
"inbox_pending",
"outbox_pending",
"outbox_dead_letter",
"event_loop_lag_p99_ms",
"pressure",
}
def test_empty_is_nominal():
assert evaluate_pressure().pressure == "nominal"
assert evaluate_pressure(admission={}).pressure == "nominal"
def test_high_utilisation_is_elevated():
p = evaluate_pressure(
admission={"max_concurrent_runs": 10, "in_flight": 8, "queued": 0, "shed": 0}
)
assert p.pressure == "elevated"
assert p.admission_in_flight == 8
def test_below_utilisation_threshold_is_nominal():
p = evaluate_pressure(
admission={"max_concurrent_runs": 10, "in_flight": 7, "queued": 0}
)
assert p.pressure == "nominal"
def test_full_and_queued_is_saturated():
p = evaluate_pressure(
admission={"max_concurrent_runs": 4, "in_flight": 4, "queued": 3, "shed": 1}
)
assert p.pressure == "saturated"
assert p.admission_queued == 3
assert p.admission_shed_total == 1
def test_dead_letter_forces_saturated():
p = evaluate_pressure(outbox_dead_letter=1)
assert p.pressure == "saturated"
assert p.outbox_dead_letter == 1
def test_backlog_is_elevated():
assert evaluate_pressure(inbox_pending=5).pressure == "elevated"
assert evaluate_pressure(outbox_pending=5).pressure == "elevated"
def test_loop_lag_thresholds():
assert evaluate_pressure(loop_lag_p99_ms=50.0).pressure == "nominal"
assert evaluate_pressure(loop_lag_p99_ms=150.0).pressure == "elevated"
assert evaluate_pressure(loop_lag_p99_ms=800.0).pressure == "saturated"
def test_saturated_dominates_elevated():
p = evaluate_pressure(
admission={"max_concurrent_runs": 2, "in_flight": 2, "queued": 1},
inbox_pending=100,
outbox_pending=50,
)
assert p.pressure == "saturated"
def test_tolerates_junk_inputs():
p = evaluate_pressure(
admission={"max_concurrent_runs": None, "in_flight": "x"},
inbox_pending="nope",
outbox_pending=None,
outbox_dead_letter=-3,
loop_lag_p99_ms="bad",
)
assert p.pressure == "nominal"
assert p.admission_max == 0
assert p.admission_in_flight == 0
assert p.outbox_dead_letter == 0
assert p.event_loop_lag_p99_ms == 0.0
def test_unbounded_admission_never_saturates_on_util_alone():
# No ceiling (max == 0): utilisation is undefined; only queued/backlog/lag
# can raise pressure, never a divide-by-zero.
p = evaluate_pressure(admission={"max_concurrent_runs": 0, "in_flight": 999})
assert p.pressure == "nominal"
def test_is_frozen():
p = HealthPressure()
import dataclasses
try:
p.pressure = "saturated" # type: ignore[misc]
except dataclasses.FrozenInstanceError:
return
raise AssertionError("HealthPressure should be frozen")
@@ -78,6 +78,71 @@ def test_healthy_loop_not_tripped():
assert wd.armed is False
def test_last_lag_ms_measured_on_healthy_loop():
"""A responsive loop reports a small, finite scheduling lag (Issue #4265)."""
wd = LoopWatchdog()
assert wd.last_lag_ms == 0.0 # nothing measured yet
loop = asyncio.new_event_loop()
policy = LoopWatchdogPolicy(
probe_interval_s=0.02,
missed_probes_before_wedged=3,
on_wedge="dump_only",
)
wd = LoopWatchdog(policy)
wd.arm(loop)
try:
_run_loop_for(loop, 0.3)
finally:
wd.disarm()
loop.close()
# A healthy loop ran the ack promptly: lag is measured and small.
assert wd.last_lag_ms >= 0.0
assert wd.last_lag_ms < 1000.0
def test_last_lag_ms_anchored_to_oldest_unacked_probe():
"""Multi-interval stalls are measured from the *oldest* unacked probe.
Regression for Issue #4265: when the loop is stalled the watchdog keeps
scheduling fresh acks that all queue behind the wedge. If each new probe
overwrote the send-time anchor, the ack that finally runs would report only
the gap since the newest probe, understating a severe stall. The anchor
must stay pinned to the first still-unacked probe until an ack consumes it.
"""
loop = asyncio.new_event_loop()
wd = LoopWatchdog(
LoopWatchdogPolicy(probe_interval_s=0.02, on_wedge="dump_only")
)
wd._loop = loop # attach without arming the OS thread; drive probes by hand
try:
# Three probes scheduled while the loop is stalled (no ack runs between
# them). The real _schedule_probe must keep the anchor pinned to the
# first probe rather than advancing it on every call.
wd._schedule_probe()
first_anchor = wd._probe_sent_at
assert first_anchor > 0.0
time.sleep(0.02)
wd._schedule_probe()
time.sleep(0.02)
wd._schedule_probe()
assert wd._probe_sent_at == first_anchor # never advanced past the oldest
# The loop finally drains the burst of acks. The first _ack measures the
# full stall from the oldest anchor and consumes it; later acks in the
# burst see a zeroed anchor and leave the measured lag intact.
loop.call_soon(wd._ack)
loop.call_soon(wd._ack)
loop.call_soon(wd._ack)
loop.call_soon(loop.stop)
loop.run_forever()
# Elapsed since the oldest probe is ~0.04s+ (two 0.02s sleeps): the
# reported lag reflects the full multi-interval stall, not ~0.
assert wd.last_lag_ms >= 40.0
assert wd._probe_sent_at == 0.0 # consumed → next probe re-anchors fresh
finally:
loop.close()
def test_wedged_loop_detected():
"""A loop blocked inside a sync call is detected (dump_only, no exit)."""
loop = asyncio.new_event_loop()
@@ -0,0 +1,137 @@
"""Every WebSocket message type the gateway dispatches must be scope-classified.
`resolve_required_scope` fails closed to ADMIN for anything it does not know, which
is the right default and is why it cannot simply be switched on: four of the six
message types the dispatcher handles today are unregistered, so enabling it would
demand ADMIN for the connection handshake and lock out every client.
This suite pins the two lists together. It is a lockstep test in jan's style --
it does not exercise behaviour, it asserts that two independently-maintained lists
agree, which is a failure mode no behavioural test can see: the code compiles, the
unit tests pass, and the omission only appears as a permission error at runtime.
"""
import re
from pathlib import Path
import pytest
from praisonaiagents.gateway.protocols import (
GATEWAY_METHODS,
EventType,
OperatorScope,
resolve_required_scope,
)
_SERVER = (
Path(__file__).resolve().parents[3]
/ "praisonai-bot"
/ "praisonai_bot"
/ "gateway"
/ "server.py"
)
# Types handled by the transport itself, before any authorization decision.
_TRANSPORT_TYPES = {"pong", "ping"}
_OPERAND = r'"[a-z_.]+"|EventType\.[A-Z_]+\.value'
def _resolve_operand(token: str) -> str | None:
"""Map one ``msg_type`` comparison operand to the wire string it matches.
A string literal is its own wire value; an ``EventType.<NAME>.value``
reference resolves through the enum exactly as Python would at runtime.
Anything else (an unknown enum member, a non-literal expression) yields
``None`` so it can be surfaced rather than silently dropped.
"""
literal = re.fullmatch(r'"([a-z_.]+)"', token)
if literal:
return literal.group(1)
enum_ref = re.fullmatch(r"EventType\.([A-Z_]+)\.value", token)
if enum_ref:
member = getattr(EventType, enum_ref.group(1), None)
if member is not None:
return member.value
return None
def _dispatched_message_types() -> set[str]:
"""Extract the msg_type branches the gateway's WS dispatcher acts on.
The dispatcher compares ``msg_type`` in two interchangeable styles:
* string literals — ``msg_type == "hello"``, ``msg_type in ("abort", ...)``;
* enum-derived — ``msg_type == EventType.PING.value``,
``msg_type in ("abort", EventType.MESSAGE_ABORT.value)``.
Recognising only the first style would let a branch written purely in the
second (as ``ping``/``pong`` already are) drift past this lockstep — a
classified method could go unregistered and surface only as a runtime
permission error. So every ``EventType.<NAME>.value`` operand is resolved
through the enum to its wire value and considered alongside the literals.
Within a single ``in (...)`` branch the operands are *aliases* for one
dispatch path (``"abort"`` and ``EventType.MESSAGE_ABORT.value`` route to
the same handler). The registry classifies the canonical name, so each
branch contributes one representative — the literal if present, else the
resolved enum value — rather than every alias as a distinct method.
"""
source = _SERVER.read_text()
found: set[str] = set()
for token in re.findall(rf"msg_type == ({_OPERAND})", source):
resolved = _resolve_operand(token)
if resolved is not None:
found.add(resolved)
for group in re.findall(r"msg_type in \(([^)]*)\)", source):
operands = [_resolve_operand(t) for t in re.findall(_OPERAND, group)]
resolved = [o for o in operands if o is not None]
if not resolved:
continue
literals = [
re.fullmatch(r'"([a-z_.]+)"', t).group(1)
for t in re.findall(_OPERAND, group)
if re.fullmatch(r'"[a-z_.]+"', t)
]
found.add(literals[0] if literals else resolved[0])
return found - _TRANSPORT_TYPES
@pytest.mark.skipif(not _SERVER.exists(), reason="praisonai-bot not present in this checkout")
def test_the_dispatcher_handles_types_this_test_can_see():
"""Positive control: if the extraction breaks, every other test here passes vacuously."""
types = _dispatched_message_types()
assert len(types) >= 4, f"extraction found only {types}; the regex has drifted from the source"
assert "message" in types, "the most basic message type was not extracted"
# ``abort`` is dispatched via a mixed-style branch
# (``msg_type in ("abort", EventType.MESSAGE_ABORT.value)``); seeing it
# proves the enum-derived operand resolution works, so a future branch
# written enum-only cannot silently drift past this lockstep.
assert "abort" in types, "the enum-derived branch resolution has drifted from the source"
@pytest.mark.skipif(not _SERVER.exists(), reason="praisonai-bot not present in this checkout")
def test_every_dispatched_message_type_is_scope_classified():
unregistered = sorted(t for t in _dispatched_message_types() if t not in GATEWAY_METHODS)
assert not unregistered, (
"these WebSocket message types are dispatched but carry no scope classification, "
f"so resolve_required_scope() would fail them closed to ADMIN: {unregistered}. "
"Register each via register_gateway_method() before wiring the resolver into dispatch."
)
def test_an_unknown_method_still_fails_closed():
"""The property the whole design rests on. Must survive any registration change."""
for unknown in ("sessions.list", "config.write", "definitely.not.a.method", ""):
assert resolve_required_scope(unknown) is OperatorScope.ADMIN
def test_the_handshake_is_not_accidentally_admin():
"""`hello` runs before a client can hold any scope; requiring ADMIN would lock everyone out."""
if "hello" not in GATEWAY_METHODS:
pytest.skip("hello not yet registered")
assert resolve_required_scope("hello") is not OperatorScope.ADMIN
@@ -42,7 +42,25 @@ def test_either_name_alone_is_honoured(kw, name):
assert _cls(name)(**{kw: "gpt-4o"}, **_extra(name)).llm == "gpt-4o"
@pytest.mark.parametrize("name", UNWRAPPING)
@pytest.mark.parametrize("name", ALL)
def test_llmconfig_is_unwrapped_to_a_model_string(name):
# Otherwise the LLMConfig object itself is handed to litellm as model=.
# Containers push this value into every member; a member holding an
# LLMConfig fails inside chat() and returns None, so the leak is silent.
assert _cls(name)(llm=LLMConfig(model="gpt-4o"), **_extra(name)).llm == "gpt-4o"
def test_agentteam_member_receives_the_unwrapped_model_string():
# The #4227 failure: the container unwrapped its own field but pushed the
# raw LLMConfig into members via _apply_default_llm, so a member's chat()
# died on "'LLMConfig' object has no attribute 'lower'" and returned None.
member = _cls("Agent")(instructions="t")
_cls("AgentTeam")(agents=[member], llm=LLMConfig(model="gpt-4o"))
assert member.llm == "gpt-4o"
def test_agentflow_seeds_agents_with_the_unwrapped_model_string():
# AgentFlow seeds the agents it builds from self.model (llm=config.get(
# "llm", model)); an LLMConfig there would fail the same way inside chat().
flow = _cls("AgentFlow")(llm=LLMConfig(model="gpt-4o"))
assert flow.model == "gpt-4o"
@@ -468,3 +468,204 @@ class TestRegistryPermissionResolver:
defs = reg.get_tool_definitions()
names = {d.get("function", {}).get("name") for d in defs}
assert "read_file" in names
class TestApprovalConfigNeverWeakensDefault:
"""Issue #4228 (a): configuring approval must not drop the default denials."""
def test_approval_config_keeps_default_denials(self):
"""ApprovalConfig(timeout=30) is a configuration change, not a security decision."""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig
default = Agent(name="test", instructions="test")._perm_deny
configured = Agent(
name="test", instructions="test", approval=ApprovalConfig(timeout=30)
)._perm_deny
assert configured >= default
assert "execute_command" in configured
assert "delete_file" in configured
def test_dict_config_keeps_default_denials(self):
from praisonaiagents import Agent
default = Agent(name="test", instructions="test")._perm_deny
configured = Agent(
name="test", instructions="test", approval={"timeout": 30}
)._perm_deny
assert configured >= default
assert "execute_command" in configured
def test_config_with_permissions_defers_to_manager(self):
"""An explicit declarative policy owns denial; the preset must not shadow it."""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig
agent = Agent(
name="test",
instructions="test",
approval=ApprovalConfig(permissions={"bash:rm *": "deny"}),
)
assert agent._perm_deny == frozenset()
assert agent._permission_manager is not None
def test_env_off_switch_still_disables_denials(self, monkeypatch):
"""PRAISONAI_TOOL_SAFETY=off remains a full bypass even with a config object."""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig
monkeypatch.setenv("PRAISONAI_TOOL_SAFETY", "off")
agent = Agent(
name="test", instructions="test", approval=ApprovalConfig(timeout=30)
)
assert agent._perm_deny == frozenset()
def test_explicit_empty_permissions_policy_owns_denial(self):
"""``permissions={}`` is an intentional (empty) policy, not an absent one.
An empty mapping must NOT inherit the default deny set — the caller
explicitly opted into a declarative policy that happens to allow all.
Only a genuinely absent policy (``permissions=None``) falls back to the
env-driven default denials.
"""
from praisonaiagents import Agent
from praisonaiagents.approval.protocols import ApprovalConfig
empty_config = Agent(
name="test", instructions="test",
approval=ApprovalConfig(permissions={}),
)._perm_deny
empty_dict = Agent(
name="test", instructions="test",
approval={"permissions": {}},
)._perm_deny
assert empty_config == frozenset()
assert empty_dict == frozenset()
class TestArgumentScopedDeny:
"""Issue #4228 (b): argument-level deny rules must reach the enforcement path."""
def _agent_with_rule(self, tmp_path, pattern, action="deny"):
from praisonaiagents import Agent
from praisonaiagents.permissions import PermissionManager
mgr = PermissionManager(storage_dir=str(tmp_path))
mgr.load_rules_from_config({pattern: action})
agent = Agent(name="test", instructions="test")
agent._perm_deny = frozenset()
agent._perm_allow = None
agent._permission_manager = mgr
return agent
def test_argument_scoped_deny_blocks_only_matching_call(self, tmp_path):
"""The rule engine matches arguments; the enforcement path must ask it."""
agent = self._agent_with_rule(tmp_path, "bash:rm *")
blocked = agent._check_tool_approval_sync(
"execute_command", {"command": "rm -rf /tmp/x"}
)
assert isinstance(blocked, dict)
assert blocked.get("permission_denied") is True
def test_argument_scoped_deny_allows_non_matching_call(self, tmp_path):
from praisonaiagents.approval.protocols import ApprovalDecision
agent = self._agent_with_rule(tmp_path, "bash:rm *")
with patch(
"praisonaiagents.approval.get_approval_registry"
) as get_reg:
reg = MagicMock()
reg.approve_sync.return_value = ApprovalDecision(
approved=True, reason="mock"
)
reg.mark_approved = MagicMock()
get_reg.return_value = reg
result = agent._check_tool_approval_sync(
"execute_command", {"command": "ls -la"}
)
assert result is None or (isinstance(result, tuple) and len(result) == 2)
def test_argument_scoped_deny_async(self, tmp_path):
import asyncio
agent = self._agent_with_rule(tmp_path, "bash:rm *")
blocked = asyncio.run(
agent._check_tool_approval_async(
"execute_command", {"command": "rm -rf /tmp/x"}
)
)
assert isinstance(blocked, dict)
assert blocked.get("permission_denied") is True
def test_name_level_deny_still_blocks_all(self, tmp_path):
"""Backward compat: a bare name rule keeps its all-or-nothing behaviour."""
agent = self._agent_with_rule(tmp_path, "execute_command")
for command in ("rm -rf /tmp/x", "ls -la"):
result = agent._check_tool_approval_sync(
"execute_command", {"command": command}
)
assert isinstance(result, dict)
assert result.get("permission_denied") is True
def test_malformed_argument_key_cannot_evade_scoped_deny(self, tmp_path):
"""A trailing-'=' kwarg (``command=``) normalises before dispatch, so the
deny gate must normalise it too — otherwise the scoped rule is bypassed
while the command still runs after ``_cast_arguments`` cleans the key."""
agent = self._agent_with_rule(tmp_path, "bash:rm *")
blocked = agent._check_tool_approval_sync(
"execute_command", {"command=": "rm -rf /tmp/x"}
)
assert isinstance(blocked, dict)
assert blocked.get("permission_denied") is True
def test_modified_args_rechecked_against_scoped_deny(self, tmp_path):
"""An approval backend rewriting args into a denied command must be
re-authorized — the first gate only saw the original (allowed) args."""
from praisonaiagents.approval.protocols import ApprovalDecision
agent = self._agent_with_rule(tmp_path, "bash:rm *")
with patch("praisonaiagents.approval.get_approval_registry") as get_reg:
reg = MagicMock()
reg.mark_approved = MagicMock()
get_reg.return_value = reg
with patch.object(
agent, "_resolve_approval_decision",
return_value=ApprovalDecision(
approved=True, reason="mock",
modified_args={"command": "rm -rf /tmp/x"},
),
):
result = agent._check_tool_approval_sync(
"execute_command", {"command": "ls -la"}
)
assert isinstance(result, dict)
assert result.get("permission_denied") is True
def test_modified_args_rechecked_async(self, tmp_path):
import asyncio
from praisonaiagents.approval.protocols import ApprovalDecision
agent = self._agent_with_rule(tmp_path, "bash:rm *")
async def _run():
with patch("praisonaiagents.approval.get_approval_registry") as get_reg:
reg = MagicMock()
reg.mark_approved = MagicMock()
get_reg.return_value = reg
with patch.object(
agent, "_resolve_approval_decision"
) as resolve:
async def _decide(*a, **k):
return ApprovalDecision(
approved=True, reason="mock",
modified_args={"command": "rm -rf /tmp/x"},
)
resolve.side_effect = _decide
return await agent._check_tool_approval_async(
"execute_command", {"command": "ls -la"}
)
result = asyncio.run(_run())
assert isinstance(result, dict)
assert result.get("permission_denied") is True
@@ -0,0 +1,209 @@
"""
Unit tests for provider-quota coordination (cross-replica credential cooldowns).
Covers:
- LocalQuotaCoordinator TTL/bench semantics.
- QuotaCoordinatorConfig round-trip and build helper.
- FailoverManager sharing benches through a coordinator so a key benched by
one "replica" (manager) is skipped by another sharing the same coordinator.
- Backward-compatible default (no coordinator supplied) behaves as before.
"""
import time
from praisonaiagents.llm.quota import (
LocalQuotaCoordinator,
QuotaCoordinatorConfig,
QuotaCoordinatorProtocol,
build_quota_coordinator,
)
from praisonaiagents.llm.failover import (
AuthProfile,
FailoverConfig,
FailoverManager,
ProviderStatus,
)
class TestLocalQuotaCoordinator:
def test_satisfies_protocol(self):
coord = LocalQuotaCoordinator()
assert isinstance(coord, QuotaCoordinatorProtocol)
def test_bench_and_is_benched(self):
coord = LocalQuotaCoordinator()
now = 1000.0
coord.bench("key-a", until=now + 60, reason="429")
assert coord.is_benched("key-a", now=now) is True
assert coord.benched_until("key-a", now=now) == now + 60
def test_bench_expires(self):
coord = LocalQuotaCoordinator()
now = 1000.0
coord.bench("key-a", until=now + 10, reason="429")
assert coord.is_benched("key-a", now=now + 5) is True
# After TTL, self-clears.
assert coord.is_benched("key-a", now=now + 20) is False
assert coord.benched_until("key-a", now=now + 20) is None
def test_bench_keeps_longest(self):
coord = LocalQuotaCoordinator()
now = 1000.0
coord.bench("key-a", until=now + 30)
coord.bench("key-a", until=now + 10) # shorter, ignored
assert coord.benched_until("key-a", now=now) == now + 30
def test_clear(self):
coord = LocalQuotaCoordinator()
now = 1000.0
coord.bench("key-a", until=now + 60)
coord.clear("key-a")
assert coord.is_benched("key-a", now=now) is False
def test_unknown_key(self):
coord = LocalQuotaCoordinator()
assert coord.is_benched("missing") is False
assert coord.benched_until("missing") is None
class TestQuotaCoordinatorConfig:
def test_defaults_local(self):
cfg = QuotaCoordinatorConfig()
assert cfg.backend == "local"
assert cfg.url is None
def test_round_trip(self):
cfg = QuotaCoordinatorConfig(backend="redis", url="redis://x")
assert QuotaCoordinatorConfig.from_dict(cfg.to_dict()) == cfg
def test_build_defaults_local(self):
coord = build_quota_coordinator()
assert isinstance(coord, LocalQuotaCoordinator)
def test_build_unknown_backend_falls_open_to_local(self):
coord = build_quota_coordinator(QuotaCoordinatorConfig(backend="redis"))
assert isinstance(coord, LocalQuotaCoordinator)
class TestFailoverCoordinatorWiring:
def test_default_no_coordinator_unchanged(self):
# No coordinator supplied -> a local one is created; behaviour identical.
mgr = FailoverManager()
p = AuthProfile(name="p1", provider="openai", api_key="k")
mgr.add_profile(p)
assert mgr.get_next_profile() is p
def test_bench_shared_across_managers(self):
# Two managers = two "replicas" sharing one coordinator.
coord = LocalQuotaCoordinator()
cfg = FailoverConfig(cooldown_on_rate_limit=60.0)
mgr_a = FailoverManager(config=cfg, coordinator=coord)
mgr_b = FailoverManager(config=cfg, coordinator=coord)
pa = AuthProfile(name="shared-key", provider="openai", api_key="k")
pb = AuthProfile(name="shared-key", provider="openai", api_key="k")
mgr_a.add_profile(pa)
mgr_b.add_profile(pb)
# Replica A benches the key on a 429.
mgr_a.mark_failure(pa, "429", is_rate_limit=True)
# Replica B, which never saw the failure, must now skip the same key.
# Benches are keyed by the stable credential_id, not the profile label.
cred_id = pa.credential_id
assert cred_id == pb.credential_id
assert coord.is_benched(cred_id) is True
assert pb.is_available is True # not yet synced
# get_next_profile syncs the fleet-wide bench onto B's local profile.
assert mgr_b.get_next_profile() is pb # only profile, returned as best effort
assert pb.status == ProviderStatus.RATE_LIMITED
assert pb.is_available is False
def test_same_name_distinct_keys_do_not_collide(self):
# Two different credentials that share a display name ("default") must
# NOT bench each other: identity is derived from the key, not the label.
coord = LocalQuotaCoordinator()
cfg = FailoverConfig(cooldown_on_rate_limit=60.0)
mgr = FailoverManager(config=cfg, coordinator=coord)
p1 = AuthProfile(name="default", provider="openai", api_key="key-1", priority=0)
p2 = AuthProfile(name="default", provider="openai", api_key="key-2", priority=1)
mgr.add_profile(p1)
mgr.add_profile(p2)
assert p1.credential_id != p2.credential_id
mgr.mark_failure(p1, "429", is_rate_limit=True)
# Benching credential 1 must leave credential 2 available.
assert coord.is_benched(p1.credential_id) is True
assert coord.is_benched(p2.credential_id) is False
assert mgr.get_next_profile() is p2
def test_stale_success_does_not_clear_newer_bench(self):
# A stale in-flight success must not clear a newer bench recorded by a
# concurrent failure with a later expiry.
coord = LocalQuotaCoordinator()
cfg = FailoverConfig(cooldown_on_rate_limit=60.0)
mgr = FailoverManager(config=cfg, coordinator=coord)
p = AuthProfile(name="k1", provider="openai", api_key="k")
mgr.add_profile(p)
# Simulate the profile recovering from an OLD short cooldown while a
# NEWER, longer bench is present in the coordinator (set by a concurrent
# failure on another request/replica).
p.mark_rate_limited(1.0) # recovered_from ~ now+1
newer = time.time() + 300
coord.bench(p.credential_id, until=newer)
mgr.mark_success(p)
# The newer bench must survive the stale success.
assert coord.is_benched(p.credential_id) is True
def test_success_clears_own_bench(self):
# A genuine recovery (no newer bench) still clears the shared bench.
coord = LocalQuotaCoordinator()
mgr = FailoverManager(coordinator=coord)
p = AuthProfile(name="k1", provider="openai", api_key="k")
mgr.add_profile(p)
mgr.mark_failure(p, "429", is_rate_limit=True)
assert coord.is_benched(p.credential_id) is True
mgr.mark_success(p)
assert coord.is_benched(p.credential_id) is False
def test_recovery_clears_shared_bench(self):
coord = LocalQuotaCoordinator()
mgr = FailoverManager(coordinator=coord)
p = AuthProfile(name="k1", provider="openai", api_key="k")
mgr.add_profile(p)
mgr.mark_failure(p, "429", is_rate_limit=True)
assert coord.is_benched(p.credential_id) is True
mgr.mark_success(p)
assert coord.is_benched(p.credential_id) is False
def test_coordinator_failure_is_fail_open(self):
class BrokenCoordinator:
def bench(self, *a, **k):
raise RuntimeError("backend down")
def is_benched(self, *a, **k):
raise RuntimeError("backend down")
def benched_until(self, *a, **k):
raise RuntimeError("backend down")
def clear(self, *a, **k):
raise RuntimeError("backend down")
mgr = FailoverManager(coordinator=BrokenCoordinator())
p = AuthProfile(name="k1", provider="openai", api_key="k")
mgr.add_profile(p)
# Must not raise despite the broken backend; local cooldown still applies.
mgr.mark_failure(p, "429", is_rate_limit=True)
assert p.status == ProviderStatus.RATE_LIMITED
# get_next_profile also degrades gracefully.
assert mgr.get_next_profile() is p
Loaded 100 of 354 files, more files were not shown because too many files have changed in this diff. Show more