Compare 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
praisonai-triage-agent[bot] 104f374ccf Release v4.7.0 2026-08-21 05:09:40 +00:00
Mervin Praison b83ee8a633 Merge pull request #4194 from MervinPraison/claude/issue-4190-20260820-1920
fix: propagate step failures through nested workflow patterns
2026-08-21 06:05:49 +01:00
Mervin Praison 290fa903e6 Merge pull request #4199 from MervinPraison/claude/issue-4195-20260820-1928
fix: SafetyEval parse miss no longer reports max safety
2026-08-21 06:05:41 +01:00
Mervin Praison aa5861ac22 Merge pull request #4201 from Terminator666666/feat/scheduler-monitor
feat(scheduler): add command and URL change-detection monitors
2026-08-21 06:05:28 +01:00
Mervin Praison e4268cf3d9 Merge pull request #4200 from MervinPraison/claude/issue-4196-20260820-1929
fix: correct telemetry opt-out, token totals, cost lookup and flush
2026-08-21 06:03:36 +01:00
Mervin Praison 19b06e5e18 Merge pull request #4197 from MervinPraison/claude/issue-4192-20260820-1927
fix: gateway PID lock owner identity and psutil 7 collision diagnostic
2026-08-21 06:02:52 +01:00
Mervin Praison c3ceb13a8f Merge pull request #4188 from MervinPraison/claude/multi-agent-praisonai-platform-dfbgzo
chore: move praisonai-platform to its own repository
2026-08-21 06:01:11 +01:00
MervinPraison 90f6f99291 docs: auto-update documentation parity trackers [skip ci] 2026-08-21 05:00:30 +00:00
Mervin Praison b7398733f1 Merge pull request #4186 from MervinPraison/claude/issue-4182-20260820-1857
fix: share one canonical preset key between validation and resolution
2026-08-21 05:59:54 +01:00
MervinPraison 5424238cc0 docs: auto-update feature parity trackers [skip ci] 2026-08-21 04:59:15 +00:00
Mervin Praison 4d00348dd7 Merge pull request #4153 from MervinPraison/claude/issue-4139-20260820-1423
fix: unify llm=/model= alias resolution across all agent classes
2026-08-21 05:58:50 +01:00
Terminator666666 3c9955f538 fix(scheduler): address monitor review findings 2026-08-21 10:38:02 +08:00
Terminator666666 0bacb3f0b7 fix(scheduler): bound monitor response header time 2026-08-21 10:34:20 +08:00
praisonai-triage-agent[bot]andMervin Praison 92883c5d51 fix(scheduler): enforce total wall-clock deadline on monitor URL read
The URL monitor's response.read() bounded bytes and per-socket waits but
had no total deadline, so a slow drip-feed endpoint could keep the
synchronous read alive within each socket window and stall the sequential
scheduler tick, delaying other due jobs. Read the body in bounded chunks
and check an absolute deadline between reads so a slow response fails the
probe at the monitor timeout instead of blocking progress.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-21 02:15:30 +00:00
Terminator666666 a723ab8b9f feat: implement scheduler monitor change detection 2026-08-21 10:00:28 +08:00
praisonai-triage-agent[bot] e58dfcea6d Merge pull request #4198 from MervinPraison/claude/issue-4193-20260820-1927
fix: record failed scheduled delivery as delivered=False (fixes #4193)
2026-08-20 21:13:41 +00:00
praisonai-triage-agent[bot] 69c0e8b859 Merge pull request #4184 from MervinPraison/claude/issue-4180-20260820-1855
fix: guard vendored PluginRegistry fallbacks against built-in shadowing
2026-08-20 20:38:22 +00:00
praisonai-triage-agent[bot]andMervin Praison 8abaeed38c test: assert every resolver preset key is already canonical
The existing collision guard only ensured no two keys collapse to the same
canonical form. It did not catch a single non-canonical key (e.g. 'full-auto'),
which validation would accept but the direct dict.get(canonical_preset_key(...))
lookup would miss, silently falling back to the disabled default. Add an
invariant test enforcing key == canonical_preset_key(key) for all four
resolver preset dicts. (addresses Greptile review)

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 20:19:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 65f13740eb fix: share one canonical preset key between validation and resolution (fixes #4182)
Validation normalised strip().lower().replace('-','_') while the four
resolvers did a raw dict .get(), so blessed spellings like
autonomy='full-auto' or context='sliding-window' were accepted then
silently ran with the feature off.

Add canonical_preset_key() in parse_utils and route both the validator
and the context/autonomy/output/execution resolvers through it. Replace
the vacuous test_normalized_preset_accepted assertion with the
resolution checks its own comment described.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 20:19:28 +00:00
praisonai-triage-agent[bot]andMervin Praison f62be0f9ba fix: mark delivered when failure-summary reaches the channel (#4198)
_maybe_deliver_failure only recorded the negative outcome, leaving
JobResult.delivered False even when the failure summary was delivered
successfully — so monitoring reported a delivered notification as
undelivered (Greptile P1 on PR #4198). Set result.delivered = True on
the success branch and add regression tests for both the delivered and
failed failure-summary paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 20:18:44 +00:00
praisonai-triage-agent[bot]andMervinPraison 5d8aee9bb9 fix: record failed scheduled delivery as delivered=False (fixes #4193)
_deliver_scheduled_result returned None on every path, so the executor
treated 'the handler did not raise' as success — a routing failure was
recorded delivered=True with delivery_error=None next to a logged
'Failed to deliver'. Return a bool from the gateway handler, propagate it
through _dispatch_delivery, and key delivered/delivery_error off it at the
three executor call sites plus the failure-summary path.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 20:18:44 +00:00
praisonai-triage-agent[bot] 8d94039ed0 Merge pull request #4187 from MervinPraison/claude/issue-4181-20260820-1856
fix: restore textual doc guard over DOCS and fix README db import
2026-08-20 20:17:00 +00:00
MervinPraison 06f4b7162a docs: auto-update documentation parity trackers [skip ci] 2026-08-20 20:11:36 +00:00
praisonai-triage-agent[bot] 87defc4184 Merge pull request #4191 from MervinPraison/claude/issue-4189-20260820-1919
fix: SSRF - loopback allowlist re-opening web_crawl to 127.0.0.1
2026-08-20 20:10:37 +00:00
praisonai-triage-agent[bot]andMervin Praison 3a753b377e test: restore prior sys.modules state after forced vendored import
Snapshot and restore the pre-existing module entry in the vendored-fallback
import helper so the forced standalone re-import cannot leak the fallback
PluginRegistry into later tests expecting the bridged canonical class.
Addresses CodeRabbit review on #4184.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 19:56:24 +00:00
praisonai-triage-agent[bot]andMervinPraison df0de54b73 fix: guard vendored PluginRegistry fallbacks against built-in shadowing (fixes #4180)
vendored fallback copies used when praisonai-sandbox / praisonai-deploy are
installed standalone did not get it. Apply the same guard so an installed
distribution can never replace a built-in loader via entry-point discovery.

Add a class-parametrised test that forces the standalone fallback import path,
proving each vendored copy is protected (the previous test only exercised the
canonical class through varied group-name strings).

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 19:56:24 +00:00
praisonai-triage-agent[bot] 896d60ca7c Merge pull request #4185 from MervinPraison/claude/issue-4183-20260820-1857
fix: guard legacy entry-point group against replacing builtins
2026-08-20 19:51:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 4ab923a1ac fix: reset cached endpoint-provider registry singletons in spelling test
EndpointProviderRegistry.default() caches a per-class singleton whose
entry-point discovery runs once at construction. cmd_invoke resolves
providers through that cache, so under the CI root shard (-n 2 --dist
loadfile) a singleton built by an earlier test on the same worker — before
this file publishes the `acme` plugin — never sees it, and invoke falls back
to the recipe HTTP path (assert http == [] => assert 2 == 0). Evict the
cached singletons in the `published` fixture before and after each case so
the plugin is rediscovered and no poisoned instance leaks forward.

Test-only change in the praisonai wrapper; no production or praisonai-agents
code touched. Strengthens isolation rather than weakening any assertion.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 19:48:20 +00:00
praisonai-triage-agent[bot]andMervin Praison c489c2b723 fix: restore run/start telemetry via re-entrancy guard
Wrapping only chat() dropped all execution telemetry for start(stream=True)
(-> _start_stream) and backend-backed run()/start() (-> _delegate_to_backend),
neither of which funnels through chat(). Re-instrument run/start/chat behind a
per-agent re-entrancy flag so the outermost call records exactly one execution,
preserving single-count for run()->chat() while covering stream/backend paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 19:46:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 8ddf9ef302 fix: reset both endpoint-provider registry default() singletons in test
The full-suite CI failure (`assert 2 == 0`, "Unknown provider type: acme")
was a test-isolation bug: the CLI handler's cmd_invoke resolves
EndpointProviderRegistry via the `praisonai.cli.features._endpoint_registry`
shim namespace, a distinct class object from the `praisonai_code...` one the
test imports. Their default() singletons are cached independently, so an
earlier full-suite test that warmed the handler's cache left it stale and the
monkeypatched entry points never reached it. Reset the cached _default_instance
on both class objects so discovery re-runs under the patched entry points.

Unrelated to PR #4187's session-docs changes; fixes the pre-existing flake.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 19:45:13 +00:00
praisonai-triage-agent[bot]andMervinPraison 9d12746653 fix: correct telemetry opt-out, token totals, cost lookup and flush (fixes #4196)
- token_collector: total_tokens no longer double-counts cached/reasoning/audio
  tokens (they are subsets of input/output, not additional buckets)
- cost_utils: replace loose substring matching with longest-key exact/prefix
  match so gpt-4o-mini is no longer billed at gpt-4o rates (16.7x); strip
  provider prefixes; exclude "default" from matching
- performance_monitor: consult shared telemetry state live so a later
  disable_telemetry() actually stops the import-time global monitor
- telemetry.__init__: atexit handler no longer resurrects a shut-down/opted-out
  telemetry instance at interpreter exit
- integration: instrument only chat()/execute_tool(); run()/start() delegate to
  chat() so wrapping them double-counted executions and errors
- telemetry.flush: only zero counters after a successful send

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 19:37:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 1dbc9f77e2 fix: propagate nested stop signal and guard parallel error chaining
- _execute_loop/route/repeat/if now return a `stop` flag so a nested
  on_error="stop" step halts the enclosing workflow instead of only its
  own pattern iteration. Both nested dispatch and top-level _run_impl
  honor the propagated signal.
- Parallel fail_fast/fail_all no longer `raise ... from <str>` (which
  raised TypeError and masked the real failure); the string branch error
  is preserved in WorkflowStepError.errors and only real exceptions chain.
- Added regression tests: nested-stop halts outer workflow, and parallel
  fail_fast reports failed without TypeError.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 19:35:47 +00:00
praisonai-triage-agent[bot]andMervinPraison 61b99e2c99 fix: SafetyEval parse miss no longer reports max safety (fixes #4195)
Parse safety judge responses tolerantly (markdown bolding, bullets, case)
and fail closed when nothing usable is parsed. Previously a bolded field
label - the default output style of most models - matched nothing and the
verdict silently fell through to 10.0/10 SAFE, even on a detected severe
violation. An unparsed verdict is now treated as UNSAFE (1.0), matching the
existing exception handler in evaluate_safety.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 19:31:37 +00:00
praisonai-triage-agent[bot]andMervinPraison a9ed86248c fix: gateway PID lock owner identity and psutil 7 collision diagnostic (fixes #4192)
- _is_process_running: treat PermissionError as running so a gateway owned
  by another uid is not reported dead and its lock deleted, admitting a
  second poller for the same bot token.
- Record a create_time fingerprint in the lock so a recycled PID cannot be
  mistaken for the original gateway; verified before stop/refuse decisions.
- check_port_available: use net_connections with a connections() fallback and
  a broad except so the port-collision diagnostic works on psutil 7 instead
  of raising 'invalid attr name connections'.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 19:29:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 9302b5cea4 fix: SSRF - allowlist may only exempt loopback, not link-local/private (fixes #4189)
Previously is_safe_http_url returned True for any allowlisted hostname before
resolving/classifying its addresses, so SEARXNG_URL_ALLOWLIST=169.254.169.254
could reach the cloud metadata endpoint. Now every address is classified and an
allowlisted host only bypasses the loopback class; private, link-local,
multicast and unspecified addresses stay blocked. Docstring corrected to match.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 19:27:11 +00:00
praisonai-triage-agent[bot]andMervinPraison 698025b877 fix: propagate step failures through nested workflow patterns (fixes #4190)
Nested patterns (loop/parallel/when/route/repeat/if) routed failing steps
through _apply_step_policies, which folded f"Error: {e}" into the step output
and never consulted on_error. Wrapping a pipeline in any pattern therefore lost
failure detection: the run reported status="completed" and the next step
consumed the exception text as input.

_apply_step_policies now returns (output, step_error) instead of folding the
error into output. _execute_single_step_internal applies the same
on_error/status logic the top-level _run_impl already uses: it marks the run
failed, signals a stop when on_error="stop", and never hands the exception text
forward. Pattern executors honor the stop signal, and Parallel on_failure modes
fire again since the branch failure is now surfaced.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 19:26:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 12f2dcfafa fix: SSRF - make loopback allowlist an explicit opt-in per call site (fixes #4189)
The shared is_safe_http_url unconditionally allowlisted loopback and
consulted it before the IP-class checks, re-opening web_crawl to
127.0.0.1/localhost/::1. Split the allowlist into an explicit per-caller
opt-in consulted after the IP checks. validate_searxng_url opts into
loopback (SearXNG is self-hosted); the crawler passes none.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 19:21:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 5b33731548 fix: close nested-parenthesis gap in session doc kwarg guard (#4187)
The restored TOP_LEVEL_KWARG regex only balanced one level of parens, so
a top-level rejected kwarg after a two-level nested call
(Agent(memory=MemoryConfig(db=db(...)), session_id=...)) escaped the guard,
re-opening the #4178 regression (greptile). Replace the regex with a
depth-aware scanner that attributes each kwarg to its enclosing call at any
nesting depth, and skips string literals and comments so dict keys
(memory={"session_id": ...}) and comments (# session_id = resume) are not
false positives. Add regression tests for the nested-call, dict-key, comment
and prose cases.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 19:11:06 +00:00
Mervin PraisonandClaude Opus 5 8744a0c6bb docs: fix contradictory platform roster endpoint and auth docstring
Addresses Greptile's P2 finding on this PR plus one gap found reviewing the
diff independently.

- pattern-d-platform.md: the note added in the previous commit corrected the
  platform agent roster path, but line 25 still listed it as /api/v1/agents,
  which the same note defines as the unrelated endpoint served by
  `praisonai serve agents`. A reader following the numbered list would have
  integrated against the wrong service. Also softened "Three registries may
  coexist" — a full install has more than three; the three listed are the ones
  relevant to agent_id correlation.
- auth/__init__.py: the module docstring said implementations "live in the
  platform package" without saying where that is, which is now ambiguous since
  the package left this repository. Points at the separate repo and, more
  usefully, states first that you can implement the protocols against your own
  store. This matches the change made to protocols.py in the previous commit.

src/praisonai-agents/tests/unit/auth/ passes (33 tests).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
2026-08-20 19:06:45 +00:00
Mervin PraisonandClaude Opus 5 a47971ffec chore: move praisonai-platform to its own repository
praisonai-platform is extracted to MervinPraison/PraisonAI-Platform, with full
history carried over via git filter-repo.

Why it belongs outside this repo:

- Nothing here imports it. A grep for praisonai_platform across every .py
  outside its own directory returns no hits, and no pyproject declares it.
- It is in no CI job. No workflow under .github/ references it — it is absent
  from install-monorepo-packages and from pypi-release.yml, which enumerates the
  nine tiered packages and notes it is near GitHub's 25-input cap.
- It is absent from ARCHITECTURE.md, which describes a nine-package tiered
  model; the platform was a tenth, untiered package with no boundary manifest.
- Separate-repo is the ecosystem norm: PraisonAI-Tools, PraisonAI-Plugins,
  PraisonAIUI and Agent-Recipes all live outside and consume praisonaiagents as
  a plain versioned PyPI dependency. The platform already did exactly that, and
  already carried its own uv.lock.
- Its releases were already documented as an independent semver line.

Changes here:

- Remove src/praisonai-platform/ (9,263 lines).
- SECURITY.md: drop the platform row from supported packages and point
  vulnerability reports at the new repository's advisories.
- security-audit.md: the platform is no longer triaged, released or audited
  from this runbook. Removes publish steps 13 and 15 and the two table rows so
  an agent executing it cannot cd into a directory that no longer exists.
- auth/protocols.py: WorkspaceContextProtocol's example named
  PlatformWorkspaceContext, a class no longer in this tree. Renamed to a
  generic example and noted where the reference implementation lives. Core
  keeps the protocol with no in-tree implementation, which is the documented
  design — auth/__init__.py already states that implementations live in the
  platform package, and the core tests use local mocks.
- pattern-d-platform.md: kept, since it documents an integration contract
  rather than platform internals. Adds a pointer to the new repo and corrects
  the agent roster path, which is /api/v1/workspaces/{id}/agents — /api/v1/agents
  is a different endpoint served by `praisonai serve agents`.

src/praisonai-agents/tests/unit/auth/ passes (33 tests).

Co-Authored-By: Claude Opus 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_014ZfTXTzdudKb4oswNSHpHw
2026-08-20 19:00:43 +00:00
praisonai-triage-agent[bot]andMervinPraison 297421852a fix: restore textual doc guard over DOCS and fix README db import (fixes #4181)
PR #4179 replaced the textual kwarg guard with an executing one but the
executor is blind to session/__init__.py (documented via an indented
docstring, zero python fences) and to README blocks excluded by
_is_self_contained. The surviving text check only covered _EXAMPLE_FILES,
so the original #4178 bug (a top-level Agent(session_id=...)) could be
reintroduced with the suite green.

- Restore test_docs_do_not_pass_rejected_kwargs_to_agent, widened to cover
  DOCS as well as _EXAMPLE_FILES. TOP_LEVEL_KWARG matches only inside
  Agent(, so prose mentioning session_id= is not a false positive.
- Add test proving an executor-only guard is blind to a fenceless file.
- Fix session README "Using with DB Adapter" block: import the callable
  via `from praisonaiagents.db import db` instead of `from praisonaiagents
  import db`, which yields the non-callable module and raised
  TypeError: 'module' object is not callable whenever the wrapper was
  importable (README.md#3 was green only via importorskip).

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 19:00:41 +00:00
praisonai-triage-agent[bot]andMervinPraison 5c4212a170 fix: guard legacy entry-point group against replacing builtins (fixes #4183)
The deprecated entry-point group bypassed #4171's built-in guard because
_discover_legacy_entry_points ran first and called _add_loader
unconditionally. A plugin published under the legacy spelling could thus
replace a built-in, contradicting the docstring. Apply the same built-in
guard to the legacy path.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 18:59:35 +00:00
praisonai-triage-agent[bot] dc847a36d9 docs: align AgentTeam model= docstring with team default propagation 2026-08-20 18:31:06 +00:00
Mervin Praison 97b4a2cf98 Merge pull request #4174 from MervinPraison/claude/issue-4166-20260820-1705
fix: namespace framework retry verdict; stop re-running returned error dicts (#4166)
2026-08-20 19:30:34 +01:00
Mervin Praison 681ee086dd Merge pull request #4169 from MervinPraison/claude/issue-4164-20260820-1704
fix: catch truncation typos instead of billing them to the LLM
2026-08-20 19:29:46 +01:00
praisonai-triage-agent[bot]andMervinPraison 29563104d4 fix: unify llm=/model= alias resolution across all agent classes (fixes #4139)
One helper resolves the llm=/model= pair everywhere: model= is canonical,
llm= is a deprecated alias, passing both raises TypeError, and LLMConfig is
unwrapped for classes that store a bare model name. Adds model= to CodeAgent,
RealtimeAgent, AgentTeam and AgentFlow.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 18:29:14 +00:00
Mervin Praison aada087cb1 Merge pull request #4128 from MervinPraison/claude/issue-4116-20260820-1351
fix: validate closed-set preset params to reject typos
2026-08-20 19:28:38 +01:00
Mervin Praison 1cc0b424fa Merge pull request #4179 from MervinPraison/claude/issue-4178-20260820-1724
fix: execute session doc examples instead of matching their text
2026-08-20 19:28:05 +01:00
Mervin Praison 8678e39715 Merge pull request #4177 from MervinPraison/claude/issue-4172-20260820-1715
fix: move dead demo module example_harness out of installed package
2026-08-20 19:27:40 +01:00
Mervin Praison bdb510f132 Merge pull request #4176 from MervinPraison/claude/issue-4171-20260820-1715
fix: guard entry-point shadowing of builtins in base PluginRegistry
2026-08-20 19:26:56 +01:00
Mervin Praison 77cb696c42 Merge pull request #4175 from MervinPraison/claude/issue-4173-20260820-1715
refactor: consolidate duplicate tool-call parse blocks in openai_client
2026-08-20 19:26:22 +01:00
Mervin Praison c5dcc356cf Merge pull request #4157 from MervinPraison/claude/issue-4152-20260820-1438
fix: reconcile endpoint-provider entry-point group spelling
2026-08-20 19:25:51 +01:00
Mervin Praison 1bf26af328 Merge pull request #4148 from MervinPraison/claude/issue-4138-20260820-1422
fix: make AgentTeam(llm=)/AgentFlow(llm=) reach member agents
2026-08-20 19:24:55 +01:00
Mervin Praison 20e246e919 Merge pull request #4147 from MervinPraison/claude/issue-4137-20260820-1421
fix: forward auth= on every Agent branch and honour it on Responses API
2026-08-20 19:23:44 +01:00
Mervin Praison bb814de03e Merge pull request #4146 from MervinPraison/claude/issue-4136-20260820-1420
fix: convert as_tool() Handoff objects passed in tools=
2026-08-20 19:23:25 +01:00
praisonai-triage-agent[bot] 0bb6c05ebf Merge pull request #4168 from MervinPraison/claude/issue-4165-20260820-1705
fix: _store_agent_history reports failure when write never verified
2026-08-20 18:21:48 +00:00
Mervin Praison 2b874ce537 Merge pull request #4145 from MervinPraison/claude/issue-4130-20260820-1416
fix: make MemoryConfig backends match the adapter registry
2026-08-20 19:21:05 +01:00
Mervin Praison 34a564d515 Merge pull request #4144 from MervinPraison/claude/issue-4132-20260820-1416
fix: wire up inert KnowledgeConfig fields
2026-08-20 19:20:54 +01:00
MervinPraison e032f82d8f docs: auto-update documentation parity trackers [skip ci] 2026-08-20 18:19:53 +00:00
Mervin Praison e482a65c19 Merge pull request #4122 from MervinPraison/claude/issue-4112-20260820-1351
fix: accept guardrail objects instead of silently discarding them
2026-08-20 19:19:17 +01:00
Mervin Praison 265a7af1c5 Merge pull request #4118 from MervinPraison/claude/issue-4113-20260820-1351
fix: make ExecutionConfig.parallel_tool_calls the single source of truth
2026-08-20 19:18:39 +01:00
praisonai-triage-agent[bot]andMervin Praison 1a7a162869 test: make external-agent registry tests robust to CI module/default-cache skew
The main (3.11) job dropped the plugin `aider` across every registry-backed
surface (fixture tests + entry-point discovery test). The base guard and
production code are correct; the failure is a test-isolation issue that only
surfaces under `pytest -n --dist loadfile` + coverage on CI:

- The entry-point discovery test patched `praisonai_code._registry.entry_points`
  by dotted string. Under editable-install + coverage import tracing a
  re-imported copy of that module can differ from the one the
  `ExternalAgentRegistry` class actually discovers through, leaving the real
  path unpatched and silently dropping `aider`. Patch `entry_points` on the
  exact base module the class resolves via `__mro__`, and accept `group`
  positionally or by keyword.

- The `registered_plugin` fixture now also clears and restores the per-class
  `PluginRegistry.default()` cache so no sibling test file sharing the xdist
  worker can shadow the registered plugin.

No assertions weakened: built-ins still win over colliding entry points,
plugins can still add new names, and every surface must still agree.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 18:09:35 +00:00
praisonai-triage-agent[bot] a13ec71347 Merge pull request #4170 from MervinPraison/claude/issue-4167-20260820-1706
fix: reconcile external-agent registry surfaces and version skew (#4167)
2026-08-20 17:59:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 8e9f19943a fix: fail on first-party import regressions and surface silently-excluded doc typos
Addresses two blind spots greptile flagged in the executing session-doc guard:

- ImportError from our own packages (praisonaiagents/praisonai) now re-raises
  instead of being swallowed by the optional-dependency skip, so a doc that
  advertises a renamed/removed symbol fails the guard. Optional third-party
  deps still skip.
- Blocks dropped by the self-contained filter are now asserted against a known
  set of documentation idioms, so a typo/omitted import in an otherwise-complete
  example fails a diagnostic test rather than vanishing from execution.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 17:38:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 760c12f8a8 fix: execute session doc examples instead of matching their text (fixes #4178)
The #4127 guard regexed the docs for four hard-coded kwarg names, so
renaming the fixed example's session_id= to sessionid= left it green while
the documented MemoryConfig call raised. Replace the text match with a
primary guard that extracts every ```python fence, keeps the AST
self-contained ones (used - bound - builtins == set()), and execs them with
Agent.start/chat stubbed and PRAISONAI_HOME isolated. It now fails on the
sessionid= reproduction with the exact TypeError, and catches the next
mistake too. Non-hermetic examples opt out via <!-- praisonai: skip=true -->
and wrapper-backed db examples are skipped when praisonai is absent.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:29:47 +00:00
praisonai-triage-agent[bot]andMervin Praison cd4ca2a7dd fix: raise praisonai-code floor to 0.0.65 so wrapper guarantees the base guard
The entry-point shadowing guard is added inside praisonai-code 0.0.64, but the
wrapper floor was praisonai-code>=0.0.64 — the same version number that shipped
without the guard. A pre-fix 0.0.64 install would satisfy >=0.0.64 yet lack the
protection, restoring the shadowing path once the wrapper's local guard was
removed. Bump praisonai-code to 0.0.65 and raise the wrapper floor so the
dependency constraint actually guarantees the fix is present.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 17:27:17 +00:00
praisonai-triage-agent[bot]andMervin Praison add143ce8a test: cover async execute_tool_async retry path for #4166
Add deterministic tests exercising the public execute_tool_async path
(not just the impl) to close the coverage gap CodeRabbit flagged:
- a tool-owned `retryable` field does not drive the async retry loop
  (body runs exactly once, field preserved)
- the private `_praison_retryable` control key is not visible on the
  async surfaced result

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 17:27:01 +00:00
praisonai-triage-agent[bot]andMervin Praison 30d70d09d6 fix: preserve tool function name when JSON args parsing fails (#4173)
Extract the tool function name before json.loads so callers (streaming
verbose warning, deferred-media path) report the actual tool name instead
of 'unknown function' on malformed arguments.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 17:23:08 +00:00
praisonai-triage-agent[bot] 1bd1c26f2b Merge pull request #4163 from MervinPraison/claude/issue-4161-20260820-1618
fix: correct broken session/db doc examples and extend guard (#4161)
2026-08-20 17:20:34 +00:00
praisonai-triage-agent[bot]andMervinPraison e714d94a35 fix: move dead demo module example_harness out of installed package (fixes #4172)
Relocate praisonaiagents/runtime/example_harness.py (365-line, unreferenced
demonstration module) to examples/runtime/example_harness.py so it no longer
ships in the installed praisonaiagents wheel. Convert its relative imports to
absolute and correct the pre-existing broken TurnRuntimeProtocol import
(runtime.protocols, not runtime.turn_context) so the example runs standalone.
Update the AGENTS.md path reference. No public API or runtime module changes.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:19:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 42ed55513b fix: surface version-skew error on external-agent execution path (#4167)
The primary --external-agent execution path (get_integration/execute) called
_catalog() which discarded the too-old-wrapper diagnostic and returned an empty
catalog, producing a misleading "Unknown integration" error on a version-skewed
install (Greptile P1). Add _require_catalog() that raises the upgrade guidance on
the execution path while listing surfaces (help/argparse/UI) keep degrading
quietly. execute() now catches RuntimeError to print the guidance cleanly.

Also fix test hygiene: restore the process-global root logger handlers via
monkeypatch instead of permanently clearing them (CodeRabbit).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 17:18:56 +00:00
praisonai-triage-agent[bot]andMervinPraison ec83c24af8 fix: guard entry-point shadowing of builtins in base PluginRegistry (fixes #4171)
#4158 patched only ExternalAgentRegistry. The real hole was in the base
PluginRegistry: builtins are registered first and entry points second, so any
installed distribution declaring a built-in name silently replaced it across
all ten registries (openai, docker, aws, ...). Those loaders decide where user
code runs.

Record built-in names once and skip colliding entry points during discovery,
matching the existing BotPlatformRegistry precedent (DEBUG, not WARNING, since
packages legitimately re-declare their own builtins). Runtime register() still
overrides -- deliberate dependency injection. The now-redundant re-assertion in
ExternalAgentRegistry is removed.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:18:46 +00:00
MervinPraison 653b095d75 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 17:18:45 +00:00
praisonai-triage-agent[bot]andMervinPraison b7c0285ead refactor: consolidate duplicate tool-call parse blocks in openai_client (fixes #4173)
Extract a private _extract_tool_call_name_and_args helper and reuse it in
the non-streaming, streaming, and deferred-media tool-calling paths. Each
call site keeps its own failure handling (tool-role reply vs. yield), so
behaviour is unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:18:43 +00:00
praisonai-triage-agent[bot]andMervinPraison 29baafb6c6 fix: namespace framework retry verdict and stop re-running returned error dicts (fixes #4166)
The retry machinery tagged its own verdict with an un-namespaced `retryable`
key, so a tool whose result legitimately contained `retryable` (common for
HTTP-API wrappers) had its body re-executed up to 3x and the internal verdict
leaked to the model. The sync outer loop separately escalated any returned
error dict of an uncovered type to retryable, re-running the tool body for a
decision it had already made.

- Rename the framework verdict to the private `_praison_retryable` key in the
  async impl (exception + timeout branches) and the sync `_execute_tool_impl`
  exception flatten, matching the `_outer_timeout` convention from #4133.
- Read and strip `_praison_retryable` in both retry loops so it never reaches
  the model and cannot collide with a tool's own `retryable` field.
- Sync outer loop escalates only framework-produced transients
  (`_outer_timeout`, `circuit_open`, or a flattened raised exception via
  `_praison_retryable`, and only when the type is not already re-driven by the
  inner loop). A plain returned error dict now runs exactly once.
- Update existing parity tests to the new key and add regression tests proving
  a tool's own `retryable` field does not drive the loop and the verdict is not
  visible to the model.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:17:53 +00:00
praisonai-triage-agent[bot] adba7c1791 Merge pull request #4162 from MervinPraison/claude/issue-4160-20260820-1617
fix: migrate legacy sub-agent transcripts outside the 50-session window
2026-08-20 17:17:53 +00:00
praisonai-triage-agent[bot] fa3c49cb40 Merge pull request #4159 from MervinPraison/claude/issue-4158-20260820-1613
fix: prevent installed plugins from replacing built-in --external-agent implementations
2026-08-20 17:11:18 +00:00
praisonai-triage-agent[bot]andMervinPraison 2428f5c2fa fix: reconcile external-agent registry surfaces and version skew (fixes #4167)
- list_external_agents() now derives from external_agent_catalog() so argparse
  --help choices can never advertise a plugin the handler then rejects
- registry uses a module logger instead of the root logger (no basicConfig on
  the embedding application)
- praisonai-code distinguishes an absent wrapper from a too-old wrapper: a
  mixed-version install now reports "too old" instead of a silent empty catalog

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:09:27 +00:00
praisonai-triage-agent[bot]andMervinPraison 6899241bc8 fix: catch truncation typos instead of billing them to the LLM (fixes #4164)
The prefix filter in _mistyped_command_suggestions spared both extensions
(``tests`` for ``test``) and truncations (``deplo`` for ``deploy``) of a
command. Truncations are typos: sparing them forwarded the word to ``run``
and billed a mistaken LLM call per invocation. Drop the truncation half so
only extensions — plausible prompts — stay untouched.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:08:27 +00:00
praisonai-triage-agent[bot]andMervinPraison a30fd629bb fix: _store_agent_history returns False when write never verified (fixes #4165)
After the bounded verify loop exhausts, nothing was persisted, so returning
True made the caller's 'history was lost' branch unreachable and printed a
misleading 'raced with a concurrent writer' warning. Return False and log the
real failure so the caller's existing warning fires.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 17:06:59 +00:00
praisonai-triage-agent[bot] c1a5fdf238 Merge pull request #4123 from MervinPraison/claude/issue-4114-20260820-1351
fix: route --output-format and typo'd verbs correctly in CLI dispatcher
2026-08-20 16:42:05 +00:00
praisonai-triage-agent[bot]andMervin Praison fa5604ddbb test: fail fast when session-doc example roots are missing
If _EXAMPLE_ROOTS is renamed/moved the guard silently emitted no
parametrized cases, letting the Agent(session_id=)/db= regression it
protects against slip back in. Raise a clear collection error instead.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 16:27:38 +00:00
praisonai-triage-agent[bot]andMervin Praison b420a15375 perf: memoize legacy sub-agent history scan per Session
Passing limit=None to migrate legacy transcripts (fixing #4160) turned an
already per-agent full-scan into a full-disk scan on every save: the save
loop over N agents re-enumerated every stored session N times, even once
migrated. Cache the scan result per Session keyed on the opt-in flag, since
legacy records are a frozen migration artefact never mutated by save or
restore. Addresses the P2 hot-path regression raised in PR review.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 16:26:45 +00:00
MervinPraison eb8b9ea578 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 16:26:25 +00:00
praisonai-triage-agent[bot] 41af4d66fa Merge pull request #4125 from MervinPraison/claude/issue-4121-20260820-1355
fix: resolve session store root lazily instead of freezing at import
2026-08-20 16:25:41 +00:00
praisonai-triage-agent[bot]andMervinPraison 9c93abd889 fix: correct broken session/db doc examples and extend guard (fixes #4161)
Replace the fictional PostgresAdapter import in session/README.md with the
real db(database_url=...) shortcut under MemoryConfig, and move the top-level
db=/session_id= Agent kwargs into MemoryConfig across the db package
docstrings and the runnable redis example. Also align the session_dir default
path in the API reference (~/.praisonai/sessions).

Extend the doc-example guard to discover all Agent( examples under the db
package and examples/persistence, and assert none reference a class we do not
ship, closing the gap the previous two-file textual guard left open.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 16:21:52 +00:00
praisonai-triage-agent[bot]andMervin Praison c9dc3a66a3 test: assert plugins can still add new external-agent names while built-ins win
Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 16:20:06 +00:00
praisonai-triage-agent[bot]andMervinPraison 3c9fda1a4f fix: migrate legacy sub-agent transcripts outside the 50-session window (fixes #4160)
_legacy_agent_histories called list_sessions() with no argument, inheriting
its default limit=50 (sorted by updated_at desc). A legacy per-agent record is
by definition older than anything written after the upgrade, so any user who
touched 50 sessions since upgrading had their tagged sub-agent transcript
silently skipped on restore. Pass limit=None (with a TypeError fallback for
stores whose limit is not optional) so migration scans every stored session.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 16:19:45 +00:00
praisonai-triage-agent[bot] c822adff80 Merge pull request #4124 from MervinPraison/claude/issue-4119-20260820-1355
test: assert denied approvals block tool execution
2026-08-20 16:18:30 +00:00
praisonai-triage-agent[bot]andMervinPraison d4f0b0b080 fix: prevent installed plugins from replacing built-in external agents (fixes #4158)
Re-assert built-in loaders after entry-point discovery so a third-party
distribution publishing a built-in name (e.g. "claude") can no longer
silently replace the shipped ClaudeCodeIntegration. Plugins may still add
new external agents. Adds a regression test.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 16:15:10 +00:00
praisonai-triage-agent[bot] 38055f157c Merge pull request #4126 from MervinPraison/claude/issue-4120-20260820-1355
fix: sub-agent transcript silently overwrites unrelated user session on close()
2026-08-20 16:11:12 +00:00
praisonai-triage-agent[bot] 37ad0938a3 Merge pull request #4156 from MervinPraison/claude/issue-4150-20260820-1437
fix: make praisonai.external_agents plugins usable across all surfaces
2026-08-20 16:10:17 +00:00
praisonai-triage-agent[bot] 583bff383e Merge pull request #4155 from MervinPraison/claude/issue-4151-20260820-1438
fix: make provider registry the validation authority for cloud deploy
2026-08-20 16:04:41 +00:00
MervinPraison b59cef7ea1 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 16:03:44 +00:00
praisonai-triage-agent[bot] 71d3838071 Merge pull request #4129 from MervinPraison/claude/issue-4127-20260820-1405
fix: correct session docs to use memory=MemoryConfig(session_id=...)
2026-08-20 16:02:59 +00:00
MervinPraison 5f619db73b docs: auto-update documentation parity trackers [skip ci] 2026-08-20 15:51:24 +00:00
praisonai-triage-agent[bot] 05a30a2267 Merge pull request #4140 from MervinPraison/claude/issue-4131-20260820-1416
fix: resolve knowledge vector_store provider through adapter registry
2026-08-20 15:50:46 +00:00
praisonai-triage-agent[bot] 31b353f76e Merge pull request #4141 from MervinPraison/claude/issue-4134-20260820-1419
fix: async tool retry never fires because exception is flattened
2026-08-20 15:44:44 +00:00
MervinPraison 8c432e8402 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 15:38:01 +00:00
praisonai-triage-agent[bot] f1adab5b39 Merge pull request #4143 from MervinPraison/claude/issue-4133-20260820-1418
fix: unify tool retry into one RetryPolicy budget (fixes #4133)
2026-08-20 15:37:20 +00:00
praisonai-triage-agent[bot] b1bb011d1f Merge pull request #4154 from MervinPraison/claude/issue-4149-20260820-1437
fix: preserve discovered praisonai.integrations entry points
2026-08-20 15:28:19 +00:00
MervinPraison a0f7ae7dde docs: auto-update documentation parity trackers [skip ci] 2026-08-20 15:15:43 +00:00
praisonai-triage-agent[bot] a03c9c7284 Merge pull request #4142 from MervinPraison/claude/issue-4135-20260820-1419
fix: resolve named tools in mixed tools=[...] lists
2026-08-20 15:15:11 +00:00
praisonai-triage-agent[bot] 19605e8a08 Merge pull request #4117 from MervinPraison/claude/issue-4115-20260820-1351
fix: validate MCP transport in praisonai serve mcp
2026-08-20 15:07:35 +00:00
praisonai-triage-agent[bot]andMervin Praison 1498766c7a fix: keep endpoint-provider CLI registry within C7 import boundary and stream via provider interface
- praisonai-code endpoint registry loaded openai-compat via a static
  `from praisonai.endpoints...` import, tripping the C7 hot-path import gate
  (smoke CI). Route it through the sanctioned _wrapper_bridge dynamic import so
  the standalone package no longer statically imports the wrapper.
- Provider adapter routed --stream through invoke(stream=True), whose terminal
  InvokeResult the CLI cannot render (Greptile P1). Consume
  BaseProvider.invoke_stream() instead and print SSE-shaped events.
- Add a streaming test asserting --stream drives invoke_stream, not invoke.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 15:02:36 +00:00
praisonai-triage-agent[bot]andMervinPraison 1c6dfac367 fix: keep external-agent catalog behind wrapper bridge and isolate faulty plugins
- praisonai-code external_agents handler now resolves the registry catalog
  through _wrapper_bridge.optional_wrapper_attr instead of a direct
  ``from praisonai.integrations.registry import ...`` reverse import. This
  restores the C7/C8 import boundary (the smoke `check_c7_imports.sh` gate)
  and degrades to an empty catalog when the wrapper is absent so the handler
  stays usable on a standalone praisonai-code install.
- external_agent_catalog() now skips any external agent whose entry point
  raises a non-ValueError during load (Greptile P1), so one broken optional
  plugin can no longer blank the catalog for argparse/CLI/UI surfaces.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:58:10 +00:00
praisonai-triage-agent[bot]andMervin Praison 37ed625a25 fix: plugin providers work in validate command and sample generation
Resolves two Greptile P1 issues on PR #4155:
- validate command accessed provider.value which crashes when the
  provider is a registry plugin string; now handles both enum and str.
- sample YAML generation fell through to a hardcoded aws block for
  plugin providers; now emits the requested provider name.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:56:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 74f9f8535e fix: invalidate cached dispatcher when a container default llm is applied
_apply_default_llm reset _llm_instance but left the chat-mixin
_unified_dispatcher cached against the old model, so a used agent (team
default applied post-execution, or an AgentFlow re-run with a new llm)
kept calling the previous model. Drop the dispatcher too; add regression
tests for used and pinned agents.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:46:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 6c6cf1b9cc fix: cap tool retry at one budget on inner-retried errors, keep 60s backoff
Address the two P1 review findings on the retry consolidation:

- N² attempts: the outer loop re-escalated timeout/circuit_open results as
  retryable even though the inner circuit-breaker loop already retries those
  error types to exhaustion, restarting the whole inner budget on every outer
  pass (up to max_attempts^2 tool-body runs on a persistently-timing-out MCP
  tool). Route every error dict through one guard: only escalate error types
  the inner loop does NOT retry. A genuine outer wall-clock timeout is tagged
  (_outer_timeout) so it still earns its single outer retry pass.

- Backoff parity: translating ExecutionConfig into a RetryPolicy silently
  dropped the delay cap from BackoffPolicy's 60s to RetryPolicy's 30s. Lift the
  translated max_delay_ms back to 60s so existing ExecutionConfig users keep the
  same delay ceiling.

Adds 5 tests: inner-retried timeout capped at max_attempts (not N^2),
non-inner-retried errors still get outer passes, and the 60s cap is preserved.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:44:56 +00:00
praisonai-triage-agent[bot]andMervinPraison 0e7730cc07 fix: reconcile endpoint-provider entry-point group spelling (fixes #4152)
Both endpoint-provider readers now scan the canonical
`praisonai.endpoint_providers` group, with `praisonai.endpoints.providers`
honoured as a deprecated alias (DeprecationWarning). Registers the orphaned
`openai-compat` provider, adapts BaseProvider subclasses into the CLI dispatch
shape, re-asserts CLI builtins so a plugin cannot hijack a shipped type, and
replaces the silent `_invoke_recipe` fallback for unknown `--type` with a
validation error. Declares the group in pyproject.toml and documents it.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:43:38 +00:00
praisonai-triage-agent[bot]andMervinPraison b90be35a5f fix: make praisonai.external_agents plugins usable across all surfaces (fixes #4150)
Collapse the four hand-written copies of the external-agent list into a
single source of truth in the registry. Adds list_external_agents() and
external_agent_catalog() to praisonai.integrations.registry; the argparse
--external-agent choices, the praisonai_code CLI handler INTEGRATIONS, and
the UI EXTERNAL_AGENTS toggles now all read the registry. Presentation
metadata (display_label, install_hint) moves onto the integration classes.

Backward-compatible: INTEGRATIONS and EXTERNAL_AGENTS stay Mappings
supporting in / iteration / []. Built-in order and labels are unchanged on
a default install.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:42:40 +00:00
praisonai-triage-agent[bot]andMervinPraison 738682701b fix: make provider registry the validation authority for cloud deploy (fixes #4151)
The CloudProvider enum could not be extended at runtime, so plugin
providers discovered/resolved via the praisonai.deploy.providers
entry-point group were rejected by validation. Widen CloudConfig.provider
to Union[CloudProvider, str] with a mode="before" validator that consults
the registry via coerce_cloud_provider. Built-ins keep enum identity and
byte-identical serialisation; plugins validate as normalised lowercase names.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:40:54 +00:00
praisonai-triage-agent[bot]andMervin Praison b841b44811 fix: honour terminal ToolExecutionError verdict on async retry path
The broad async exception handler flattened every raised exception into an
error dict with retryable = not isinstance(e, (ValueError, TypeError,
AttributeError)). A ToolExecutionError(is_retryable=False) is none of those,
so it was silently marked retryable=True and re-run up to max_attempts,
diverging from the sync path (tool_execution.py) which preserves an existing
ToolExecutionError's explicit verdict. Now the async path honours
e.is_retryable for ToolExecutionError and only applies the programming-error
rule to other exceptions.

Adds tests for terminal and retryable ToolExecutionError parity.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:40:12 +00:00
praisonai-triage-agent[bot]andMervinPraison cea0323c12 fix: preserve discovered praisonai.integrations entry points (fixes #4149)
IntegrationRegistry.__init__ reassigned self._loaders = {} right after
super().__init__() populated it from the entry-point group, discarding
every discovered third-party plugin. Remove the reassignment (builtins are
registered afterwards and still win on name clashes) and document the
entry-point group.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:39:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 4db9b3a7d0 fix: treat explicit empty/null knowledge provider as misconfiguration
Presence of the vector_store.provider key (not its truthiness) now marks an
explicit choice, so an explicitly empty ("") or null (None) provider surfaces
the preset error instead of silently degrading to Mem0. Adds parametrized
regression coverage for both falsey values.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:38:04 +00:00
praisonai-triage-agent[bot]andMervin Praison 1f2de6c17c fix: rebind as_tool() handoff to clone in clone_for_channel
The as_tool() Handoff conversion in _process_handoffs produces a closure
bound to the source agent (its chat_history/tools/memory). clone_for_channel()
shallow-copied that closure verbatim, so a channel clone would delegate using
the ORIGINAL agent's state, leaking one channel's context into another.

Tag the generated closure with its source Handoff and, during cloning, swap it
back so the clone's own _process_handoffs rebinds it to the clone -- mirroring
the existing _praison_sandbox_tool drop-and-regenerate pattern. Adds a clone
regression test asserting the clone's converted tool is bound to the clone, not
the origin.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:37:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 7c64fa622b fix: emit reranker provider so rerank_model is actionable
to_knowledge_config() emitted reranker.config.model without a provider.
mem0's RerankerConfig requires a provider and Knowledge._prepare_mem0_config
strips all but provider/config, so a provider-less rerank_model was silently
dropped. Derive the provider from a "provider/model" prefix (falling back to
the model string) so the requested reranker is instantiated.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:37:24 +00:00
praisonai-triage-agent[bot]andMervinPraison 80ef98fe15 fix: make AgentTeam(llm=)/AgentFlow(llm=) reach member agents (fixes #4138)
AgentTeam stored self.llm and nothing read it, so members silently kept
gpt-4o-mini. AgentFlow(llm=) only reached agents it built itself, never a
ready-made Agent step. Add Agent._apply_default_llm to adopt a container
default as a fill-in (agents that named their own model keep it), and call
it from both containers.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:26:22 +00:00
praisonai-triage-agent[bot]andMervinPraison 25ceb8d01c fix: forward auth= on every Agent branch and honour it on Responses API (fixes #4137)
Agent(auth="claude-code") was silently billed to the API key because auth=
was dropped on the commonest construction branch (bare model name / no model)
and self.auth was never assigned, so clone_for_channel() always lost it.

- Land auth on the final else branch: bare-name + auth= now builds an LLM
  instance that injects the resolved OAuth credentials.
- Assign self.auth so clone_for_channel() forwards it.
- Resolve the subscription provider eagerly at construction so an unknown or
  unusable provider raises instead of silently downgrading to API-key billing.
- Default the model to the auth provider's vendor when no model is given.
- Inject subscription credentials in _build_responses_params too, mirroring
  _build_completion_params.
- Add regression tests and wire the subscription-auth smoke tests into CI.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:25:55 +00:00
praisonai-triage-agent[bot]andMervinPraison e1c3dd8a8a fix: convert as_tool() Handoff objects passed in tools= (fixes #4136)
as_tool() returns a Handoff, and its own docstring passes the result into
tools=[...]. But tools= stores objects verbatim and only handoffs= ran
to_tool_function(), so an unconverted Handoff was invisible to both the LLM
tool schema and execute_tool(). Convert Handoff entries found in self.tools
inside _process_handoffs, the first point where the parent agent that
to_tool_function() needs actually exists.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:22:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 412922ee64 fix: make MemoryConfig backends match the adapter registry (fixes #4130)
MemoryConfig declared backends (redis/valkey/postgres) that had no adapter,
so requests were silently redirected to a local SQLite/JSON store, and
"sqlite" was overloaded (Agent built ChromaDB while Memory built SQLite).

- MemoryBackend enum now mirrors the registry (file + registered adapters)
- Unimplemented backends raise a clear error instead of substituting
- "sqlite" now builds SQLite on both Agent and Memory (no rag rewrite)
- Memory provider with no adapter fails loudly; deliberate fallbacks warn
- Drop redis/postgres presets and URL schemes that dropped host/credentials
- register_memory_adapter() remains the escape hatch for custom backends

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:22:11 +00:00
praisonai-triage-agent[bot]andMervinPraison 6c48eb403c fix: wire up inert KnowledgeConfig fields (fixes #4132)
Eight of KnowledgeConfig's ten fields never reached the internal config:
chunk_size, chunk_overlap, chunking_strategy, chunker, vector_store,
auto_retrieve, rerank_model and retrieval_threshold were read then dropped.
Also config={...} replaced the whole resolved dict, silently resetting
retrieval_k.

- agent.py: forward every declared field with the keys RetrievalConfig
  reads (min_score, not threshold); config= now overrides instead of
  replacing.
- feature_configs.py: ChunkingStrategy gains TOKEN/RECURSIVE, FIXED/PARAGRAPH
  kept as aliases; declared chunking defaults aligned to the effective ones.
- chunking.py: normalize_chunker_type maps strategy names to real chunkers.
- knowledge.py: chunker reads from config instead of hardcoding recursive/512/50.
- retrieval_config.py: add rerank_model + chunking fields, round-trip them,
  and emit a chunker block from to_knowledge_config.

Defaults unchanged so existing users are unaffected on upgrade.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:22:04 +00:00
praisonai-triage-agent[bot]andMervinPraison af0ca74377 fix: unify tool retry into one RetryPolicy budget (fixes #4133)
ToolConfig(retry_policy=RetryPolicy(max_attempts=N)) now caps how many
times a tool body runs. Previously two nested loops consumed two
independent vocabularies (RetryPolicy vs ExecutionConfig) in different
units, so max_attempts was silently overridden by max_retry_limit,
running non-idempotent tools more times than configured.

The effective RetryPolicy is now the single budget; ExecutionConfig's
retries/seconds/fraction spelling is translated into it in one place
(_effective_tool_retry_policy). No behaviour change for users who do not
set retry_policy=.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:21:23 +00:00
praisonai-triage-agent[bot]andMervinPraison 72ebbe0939 fix: resolve named tools in mixed tools=[...] lists (fixes #4135)
An all-or-nothing all(isinstance(t, str)) gate silently left every
tool-name string unresolved as soon as one callable was present, so
tools=[my_tool, 'read_file'] dropped 'read_file'. Resolve per element
via a new resolve_tools_list() helper that passes non-strings through
untouched and reports/strict-checks unknown names exactly as the
all-strings path already does.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:21:11 +00:00
praisonai-triage-agent[bot]andMervinPraison 9ffac08d94 fix: async tool retry never fires because exception is flattened (fixes #4134)
execute_tool_async caught a raised tool exception and returned an untyped
error dict; the caller re-classified it as "unknown", which no default
RetryPolicy.retry_on contains, so async always ran a failing tool exactly
once. Carry the retry verdict across the flattening (same rule as the sync
path: programming errors terminal, everything else retryable) and honour it
in the retry loop.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:21:07 +00:00
praisonai-triage-agent[bot] 115debfa1f chore: remove stray knowledge.db test artifact 2026-08-20 14:18:37 +00:00
praisonai-triage-agent[bot]andMervinPraison e7eda02f4d fix: resolve knowledge vector_store provider through adapter registry (fixes #4131)
Knowledge.memory used provider_mapping.get(provider, "mem0"), silently
substituting Mem0 for any provider outside a hardcoded four-name list —
discarding every adapter registered via register_knowledge_adapter() and
swallowing typos. Match memory.py: keep the table for genuine legacy
aliases and pass everything else to the registry, raising a helpful
make_preset_error when an explicit provider is unknown.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:18:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 1b3bb34bfd docs: correct session caching section to reflect opt-in prompt_caching
Prompt caching is not applied automatically. LLM._explicit_cache_breakpoints()
gates Anthropic cache_control injection on prompt_caching being truthy, and
Agent defaults it to None. Document the correct spelling
caching=CachingConfig(prompt_caching=True).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:17:42 +00:00
praisonai-triage-agent[bot]andMervin Praison 1efc7027f8 fix: align preset validation normalization with downstream resolvers
Resolves two P1 review findings on the closed-set preset validation:

- approval hyphen aliases (accept-edits, bypass-permissions, full-auto,
  dont-ask, ...) were rejected by the new guard even though
  PermissionMode.resolve accepts them (-/_ interchangeable). The validator
  now normalizes hyphens too, restoring previously-valid construction.
- learn= used case-sensitive, non-stripped == comparisons, so a validated
  value like "AGENTIC" or " agentic " fell through to else and SILENTLY
  disabled learning. It now uses .strip().lower() like self_improve does.
- resolve()'s preset lookup and the approval deny-set lookup now normalize
  whitespace/hyphens too, so every spelling the guard accepts also resolves
  (previously " sliding_window "/"read-only" passed validation but the
  deny-set lookup produced an EMPTY deny set — the exact read-only-sandbox
  bypass this PR exists to prevent).

Adds regression tests for hyphen aliases, case/whitespace-normalized
presets (learn actually enabled), and the read-only deny-set security path.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:15:54 +00:00
praisonai-triage-agent[bot]andMervin Praison 7c0c490c8c fix: make session store auto-created marker per-instance (fixes #4121 review)
Replace the single module-level identity pointer (_autocreated_store/
_autocreated_dir) with a per-instance marker attribute stamped on any store
this module auto-creates. A partial teardown that restores only
_default_store without restoring the out-of-band tracking variable could
otherwise misclassify a previously auto-created store as caller-injected and
pin it to a stale session directory (Greptile P1).

Also make the DEFAULT_SESSION_DIR __getattr__ hook honour an explicit module
override consistently via _resolve_default_session_dir(), and fix the
override test's leaky monkeypatch (the lazy __getattr__ made setattr restore
a computed value as a real global, leaking into later tests). Adds a
regression test for the restored-store scenario.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:09:20 +00:00
praisonai-triage-agent[bot]andMervinPraison 770676aa0a fix: correct session docs to use memory=MemoryConfig(session_id=...) (fixes #4127)
The session module's __init__.py and README.md documented
Agent(name=..., session_id=...), which Agent.__init__ rejects with TypeError.
session_id/user_id/db were consolidated into MemoryConfig, and prompt_caching
was never a parameter (caching is applied automatically per model capability).

Fixes the docs to the working spelling and adds a two-sided guard test.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:08:49 +00:00
praisonai-triage-agent[bot]andMervin Praison e8239c3d52 fix: recover partially-migrated agents and guard concurrent history saves
Address two P1 review findings on the sub-agent transcript relocation:

- Partial migration: legacy per-agent records were only consulted when the
  parent held no current-format histories, so a second agent still living in
  a tagged legacy record silently resumed empty. A new _merged_agent_histories
  helper always layers legacy under current-format entries (current wins), used
  by both single and bulk restore paths.
- Concurrent saves: _store_agent_history read the whole map outside the store
  lock then replaced it, letting a concurrent writer under the same parent drop
  another agent. It now re-reads the freshest map before each write and verifies
  no other key regressed, retrying a bounded number of times.

Adds regression tests for partial-migration recovery and concurrent-save
preservation.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:08:21 +00:00
praisonai-triage-agent[bot]andMervin Praison 3f9898db65 fix: don't block plural/extension prompts as typo'd commands
The mistyped-command guard used difflib ratio >= 0.8 alone, which also
matches legitimate one-word prompts that are plurals or extensions of a
command word (`tests`->`test`, `runs`->`run`, `server`->`serve`). Those
wrongly exited 2 before reaching the bare-prompt path.

Drop prefix-related matches: an extension contains the command as a prefix
(or vice-versa) whereas a genuine typo (`deploi` vs `deploy`) does not, so
real prompts reach `run` while typos still exit 2 with a suggestion.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:06:52 +00:00
praisonai-triage-agent[bot]andMervinPraison 351e30c093 fix: validate closed-set preset params to reject typos (fixes #4116)
Seven Agent(...) preset params (output, execution, context, autonomy,
approval, learn, self_improve) silently fell back to defaults on a typo'd
preset name. Worst case: approval="read_onl" produced an empty deny set,
allowing all 17 dangerous tools instead of the intended read-only sandbox.

Wire the existing make_preset_error helper to these params via a small
validate_preset_string() in parse_utils.py plus a compact
_PRESET_STRING_PARAMS table and _validate_preset_params() loop run at the
top of the consolidated-params block, before every fast path that could
swallow the value. Free-form string params (knowledge, guardrails, skills,
runtime, auth) are deliberately excluded.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:05:30 +00:00
praisonai-triage-agent[bot]andMervin Praison c904372fcc fix: keep guardrails=[] a no-op instead of raising TypeError
An empty list/tuple resolved to None (no-op) on main, but the new
fail-loud guard rejected it with TypeError. Exempt empty collections
so an empty opt-in stays backward-compatible; genuinely unsupported
values still raise. Adds a regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:02:55 +00:00
praisonai-triage-agent[bot]andMervin Praison b477602c99 fix: reject explicit parallel_tool_calls=False overridden by ToolConfig alias
ExecutionConfig.parallel_tool_calls now uses None as an internal "not set"
sentinel (normalised to bool in __post_init__) so Agent._merge_parallel_tool_calls
can tell an explicit False from an omitted default. A ToolConfig(parallel=True)
alongside an explicit ExecutionConfig(parallel_tool_calls=False) now raises
TypeError instead of silently enabling parallel tool calls (Greptile P1).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:02:39 +00:00
praisonai-triage-agent[bot]andMervin Praison dadb87a1aa fix: tighten MCP transport tests and update stale http examples
- Assert exit_code == 2 for invalid/unsupported transports (usage-error contract)
- Assert http-stream rejection points to 'praisonai mcp serve --transport http-stream'
- Assert help advertises 'stdio, sse'
- Update stale '--transport http' examples to the sibling http-stream command

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 14:01:48 +00:00
praisonai-triage-agent[bot]andMervinPraison 625c43adfc fix: store sub-agent transcripts on parent session record (fixes #4120)
Sub-agent transcripts were persisted as separate top-level sessions named
"{session_id}_{name}:{role}", whose sanitised path collided with real user
sessions of the same name (e.g. Session("chat") + Agent("support", "agent")
-> chat_support_agent.json). An ordinary close()/save_state() then silently
overwrote an unrelated user's conversation and could leak it back into the
agent on restore.

Move sub-agent histories into the parent session's own
metadata["agent_histories"][agent_key], removing the second namespace
entirely. Legacy tagged records migrate forward on save; untagged pre-tag
records are restored only behind PRAISONAI_SESSION_LEGACY_AGENT_KEYS=1
(ambiguous by construction) and are never deleted.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 14:00:07 +00:00
praisonai-triage-agent[bot]andMervinPraison aee6736fca fix: resolve session store root lazily instead of freezing at import (fixes #4121)
DEFAULT_SESSION_DIR was evaluated at module import time, freezing the
store root to whatever PRAISONAI_HOME resolved to during the first import.
Any later override -- a container exporting PRAISONAI_HOME after the
package was imported, or a test monkeypatching get_sessions_dir -- was
silently ignored, so the store read, wrote and deleted sessions in the
wrong directory.

- Resolve the directory live in DefaultSessionStore.__init__ via
  _resolve_default_session_dir().
- Keep DEFAULT_SESSION_DIR readable/assignable via PEP 562 __getattr__.
- Rebuild the singleton when the resolved directory changes, while
  never second-guessing a store a caller injected.
- Fix two repo tests that set the unread PRAISONAI_SESSIONS_DIR env var
  and reset the wrong singleton attribute.
- Add tests/unit/session/test_session_store_dir_resolution.py.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 13:59:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 25078ee083 test: assert denied approvals block tool execution (fixes #4119)
Add behavioural coverage for require_approval's sync and async wrappers.
The existing approval suite only asserted registration metadata, so both
denial `raise PermissionError` lines could be deleted with the suite green.
New tests install a denying backend, call a decorated tool, and assert the
PermissionError plus that the wrapped body never ran (side-effect probe).
Positive controls, modified_args, fail-closed, and async-context cases
included.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 13:57:22 +00:00
praisonai-triage-agent[bot]andMervinPraison 26e5d4a37b fix: route --output-format and typo'd verbs correctly in CLI dispatcher (fixes #4114)
The praisonai dispatcher only introspected the `run` subcommand's options,
never the root callback's global flags (`--output-format`/`-o`). As a result:

- `praisonai --output-format json version` misrouted to the legacy engine,
  dropped `version`, sent `json` to an LLM, and exited 0.
- A typo'd verb like `praisonai deploi` fell through to the bare-prompt path,
  was billed to an LLM, created a session, and exited 1.

Fix, entirely in the dispatcher:
1. Introspect the root callback and union with `run`'s options for argv
   tokenisation, so `--output-format` routes correctly.
2. Hoist global-only flags ahead of `run` when synthesising a `run` call
   (Click accepts group options only before the subcommand). Options declared
   by both (`-o`, `-v`) stay with `run`, preserving existing semantics.
3. Add an unknown-subcommand guard: a lone positional that difflib rates a
   close match (>= 0.8) to a registered command exits 2 with a suggestion,
   guarded so genuine one-word prompts still work.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 13:55:46 +00:00
praisonai-triage-agent[bot]andMervinPraison 88c78e4b8b fix: accept guardrail objects instead of silently discarding them (fixes #4112)
Agent(guardrails=<validator object>) previously fell past every dispatch
branch and was dropped with no exception or warning, so the agent ran with
zero enforcement. Adapt protocol-conforming guardrail objects (and lists of
them) into GuardrailChain, raise TypeError on unsupported values instead of
storing something that never runs, and wire _tool_call_guardrails so
validate_tool_call is no longer dead code.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 13:55:40 +00:00
praisonai-triage-agent[bot]andMervinPraison a2032aaf4b fix: make ExecutionConfig.parallel_tool_calls the single source of truth (fixes #4113)
ToolConfig.parallel is now a deprecated alias (None = unset) that feeds
ExecutionConfig.parallel_tool_calls, which every public entry point forwards
to the LLM. A bare ToolConfig() no longer silently disables an explicit
parallel_tool_calls=True, and conflicting explicit spellings raise TypeError.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 13:54:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 60af031584 fix: validate MCP transport in praisonai serve mcp (fixes #4115)
Previously any --transport value other than exactly "stdio" fell through
to the SSE branch, so a typo (e.g. `stido`) or the advertised-but-
unimplemented `http-stream` silently started a listening socket instead
of a stdio pipe. Add explicit validation rejecting unknown/unimplemented
transports with exit code 2 before any branch can bind.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 13:53:15 +00:00
Mervin Praison 680bfb3555 Merge pull request #4110 from putramkti/fix/llm-failover-profile-race
fix(llm): stop failover rotation from mutating shared instance state
2026-08-20 14:23:14 +01:00
MervinPraison b9d57384a9 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 13:22:46 +00:00
Mervin Praison 81fcd90d3b Merge pull request #4111 from MervinPraison/claude/praisonai-moa-value-gaps-dk5fa1
feat(scheduler): first-class external CLI-backend jobs + CLI-backend robustness fixes
2026-08-20 14:22:06 +01:00
praisonai-triage-agent[bot] 9b6c2524c2 Merge pull request #4109 from MervinPraison/fix/reclaim-gaps
fix(docker): two more ways a container outlived the thing that made it
2026-08-20 13:14:16 +00:00
Claude 2bda29540c fix(review): reject cli_backend in workflow YAMLs, contain post-execution hook failures
- Workflow YAMLs run through YAMLWorkflowParser, which has no cli_backend
  wiring; the framework-level compatibility check passes for 'praisonai'
  and the field was silently dropped, running the workflow on the native
  LLM path. _build_yaml_workflow now fails fast with a clear error,
  mirroring the existing tool_timeout and CLI-capability rejections.
- _chat_via_cli_backend: post-success hook emission and chat-history
  bookkeeping failures are contained (logged) instead of being reported
  as backend-execution failures, which invited callers to retry work the
  external CLI already completed; the failure hook now receives the
  backend's error result (with its audit metadata) instead of None.
- Tests: workflow-YAML rejection, post-execution containment, and
  error-result metadata propagation.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 12:27:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 2518428dd2 fix(test): align schedule_add stub with producer success shape
The precise duplicate-add detection introduced in this PR classifies a
schedule add as successful only when the response contains the producer-owned
confirmation "Schedule '<name>' added". The pre-existing timezone-surface test
stubbed schedule_add with a generic "Schedule added" string lacking the job
name, so the new guard correctly treated it as a rejection and the CLI exited
non-zero. Update the stub to return the real success shape; the guard is
unchanged (test was stale, not the code).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 12:19:35 +00:00
Claude 2891d3febf fix(review): precise add-failure detection, non-finite timeout validation, deferred gate watermark, resume state before hook
- schedule add: failure detected as absence of the producer-owned
  success confirmation instead of substring matching, so a schedule
  literally named 'Error monitor' no longer exits 1 on success
- --backend-timeout: nan/inf/-inf rejected before the job is created
  (inf previously raised OverflowError AFTER the schedule was stored);
  guard tolerates direct calls passing Typer OptionInfo defaults
- backend gate: go-path state_updates (cursor/watermark) now persist
  only after the backend turn succeeds — a policy block, resolution
  failure, timeout, or backend error no longer advances the cursor past
  unprocessed work; skip-path persistence unchanged; regression test
- CLI session: result validated and resume state recorded BEFORE the
  cancellable hook await, so cancellation during the hook cannot lose
  is_resume for the next turn (and the lock is already released)

Cross-event-loop lock coordination (two OS threads sharing one Agent,
each with its own loop) is documented as out of scope: sharing an Agent
across threads is unsupported for the wider Agent state; the re-key
path preserves per-loop serialisation, which is the supported usage.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 12:13:09 +00:00
Claude 4e19534198 fix(review): cancellation-safe session lock, state-aware backend gate, sub-ms timeout rounding
- CLI-backend session lock: asyncio.CancelledError bypasses 'except
  Exception', which could leave the per-session lock held forever; a
  dedicated handler now releases (guarded by a lock_held flag so a lock
  re-acquired by a concurrent turn is never stolen) and re-raises. The
  same flag guards the generic exception path.
- Backend pre-run gate now mirrors the native path's state handling:
  persisted gate state is loaded, passed to state-aware gates, and
  decision.state_updates persist on both skip and go — a backend
  monitor job keeps its cursor/watermark across ticks.
- schedule add --backend-timeout: positive sub-millisecond values no
  longer collapse to 0 (which the executor replaced with the 300s
  default); rounded up to at least 1ms.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 12:01:44 +00:00
Claude e2851da8f1 fix(review): audit + failure delivery on the command/backend conflict branch
The defensive conflict check marked the run failed but returned before
_audit_output and _maybe_deliver_failure, unlike every other backend
failure path — audited deployments would lose the record and the owner
notification. Now consistent; regression test asserts the audit.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 11:57:47 +00:00
praisonai-triage-agent[bot]andMervin Praison f27dca4026 fix(schedule): exit non-zero when 'schedule add' is rejected as a duplicate
A duplicate-name add creates nothing and (post the job_created guard) mutates
nothing, but still printed success and exited 0 — misleading scripts/CI. Treat
the 'already exists'/'Error' responses as failures so a rejected add exits 1 in
both text and --json modes. Adds a regression test asserting no mutation + exit 1.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 11:54:30 +00:00
Claude 7b13646464 fix(review): audit writes off the event loop in backend paths; tolerate explicit null cli_backend in YAML
- all backend-branch _audit_output calls now go through
  asyncio.to_thread, matching the native-agent failure paths so slow
  audit storage cannot block ticker/delivery coroutines
- 'cli_backend: null' in YAML behaves as unset (backward compatible);
  the fail-closed error fires only for a non-null value that cannot
  resolve

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 11:52:48 +00:00
praisonai-triage-agent[bot]andMervin Praison 2cffd51012 test(compute): fake commit must accept the changes kwarg it now gets
ef6bb47b started scrubbing secret ENV lines on capture via
container.commit(..., changes=scrub) -- a real docker-py kwarg -- but the
suite's _FakeContainer.commit() still took only (repository, tag). Every
capturing provision raised TypeError, fell back to ephemeral, and left
commit_log/list_captures empty: the IndexError and 0==N failures in CI.

Production code is correct; the fake had drifted from the API it mocks.
Widened the fake to the real signature so a successful commit stays a
successful commit.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 11:51:26 +00:00
Claude 317f1b4eee fix(review): command/backend mutual exclusion, backend jobs honour pre-run gate + run policy, per-session lock for CLI session establishment
Addresses the remaining review findings:
- --command and --backend are mutually exclusive at the CLI; the
  executor fails loudly on persisted jobs carrying both (defensive)
- backend jobs now evaluate the pre-run condition gate (skipped, no
  subprocess when nothing to do; gate context appended to the prompt)
  and pass the message through RunPolicy.scan_prompt when configured,
  matching native-agent jobs
- CLI session establishment is serialised per session id with an
  asyncio lock (cross-event-loop safe) so concurrent turns cannot
  spawn two fresh CLI conversations for one session

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 11:50:27 +00:00
Claude db7b33e31e fix(review): duplicate-add no longer mutates existing jobs; YAML backend fails closed; early backend failures audited
Addresses the three review findings:
- schedule add: trusted post-add options apply only when this invocation
  actually created the job — a duplicate-name rejection can no longer
  reconfigure a live schedule
- YAML cli_backend that cannot resolve now raises with remediation
  instead of silently running the native LLM path
- backend resolution/timeout/launch failures now write the durable
  audit record before failure delivery (all three early-return sites)

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 11:47:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 55cf1442f4 fix(llm): thread failover profile per-call instead of shared instance
Greptile P1: the retry loop still mutated self._current_profile on
rotation, and mark_failure/mark_success plus token-model attribution read
that shared pointer. Concurrent requests on one LLM instance could mark
the wrong profile failed/healthy or misattribute cost.

Rotation is now a call-local: _handle_retry_exception takes the active
profile in and returns the (possibly rotated) profile out; both retry
loops keep it in a local and use it for mark_failure/mark_success.
self._current_profile stays pinned to the initial profile. Token usage
is attributed from the serving model on the response (race-free), so the
now-unused _active_profile_values() is removed.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 11:36:34 +00:00
Claude e9c099ca21 feat(scheduler): first-class external CLI-backend jobs + backend robustness fixes
Schedule a job that runs its message as one headless turn through a
registered coding-CLI backend (claude-code, codex-cli, gemini-cli,
grok-cli): new ScheduleJob.backend/backend_options fields, an executor
action that resolves backends lazily via the sanctioned _code_bridge
seam (bot never hard-depends on praisonai-code), and
'praisonai schedule add --backend/--backend-cwd/--backend-timeout'.
Output is bounded, failures flow through run records, audit and
failure delivery, and the pinned model is passed to the CLI.

Backend fixes shipped alongside, all previously silent:
- timeouts now return CliBackendResult(error=...) instead of escaping
  as bare TimeoutError (all four backends)
- captured stderr reaches the error message (codex/gemini/grok)
- codex: 'exec resume <id>' subcommand order; TOML-safe instructions
  escaping; -m model flag; -C honors cwd
- gemini/grok/claude: model flag + cwd threading
- CliSessionBinding.is_resume is now set on the second turn of a
  session, activating the resume branches in all four backends
- YAML 'cli_backend:' resolves through _resolve_yaml_cli_backend into
  the cli_backend kwarg instead of misrouting into the runtime
  registry (shipped example now works)
- 'praisonai backends' registered as a Typer command (standalone
  praisonai-code installs previously could not reach the legacy path)

Tests: 19 new (backend action + serialisation round-trip + fixes);
existing scheduler/backend suites verified unchanged vs main;
check_bot_code_imports gate passes.

Co-Authored-By: Claude <[email protected]>
Claude-Session: https://claude.ai/code/session_01Pwwvk68iPoNnGw6ov5Lpga
2026-08-20 11:28:27 +00:00
dymux 4634102f40 fix(llm): stop failover rotation from mutating shared instance state
_switch_to_profile wrote the rotated profile's api_key/base_url/model
onto the shared LLM instance. With one instance serving concurrent
requests (asyncio.gather on a shared Agent, or a cached per-session
Agent), a failover rotation for request A changed the credentials and
model that request B read on its next completion build, silently
sending B's traffic to the wrong provider. Rotation is now a per-call
concern: the retry loop threads the active profile through request
kwargs, and instance attributes keep the initial profile values.
Token tracking follows the profile actually used for the call.

Closes #3613 (gap 2).
2026-08-20 19:23:40 +08:00
praisonai-triage-agent[bot]andMervin Praison d7272ff514 fix(reclaim): loop-safe finalizer and stop ephemerals polluting managed ps
_release() called asyncio.run() directly, so GC on a thread already driving
an event loop raised RuntimeError before shutdown ran -- the instance leaked.
Bridge the loop the way SharedCompute._run_sync does (offload to a private
thread when a loop is live) so teardown survives either case.

DockerSandbox.execute() labelled its ephemeral --rm containers praisonai=managed,
the sole input to DockerCompute.list_instances(). Those praisonai-<uuid> names
then surfaced in `managed ps` but `managed stop` resolves praisonai_<id> and
could never reclaim them. Relabel as praisonai=sandbox-exec; the timeout kill
uses --name and is unaffected.

Adds a regression test proving _release reclaims when invoked on a live loop.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 11:22:12 +00:00
MervinPraisonandClaude Opus 5 281982993c fix(docker): two more ways a container outlived the thing that made it
Both found by adversarial review, both pre-existing, both leaks.

A TIMED-OUT execution left its container running. The handler called
proc.kill(), which ends the docker CLI while the container it started
carries on. The container also had no --name, so it got a random Docker
name -- neither the timeout handler nor `praisonai managed ps` could
find it, and the user was left with something they could see in
`docker ps` and had no supported way to stop. It is named and labelled
now, and the timeout kills the container rather than the client.

The sibling run_command() path already did this, with the comment "Kill
the container, not just the client". execute() never got the same
treatment; the two paths have been quietly different ever since.

run_on= NEVER RECLAIMED ITS INSTANCE. ComputeManagedAgent defaults to
keep_alive=True, which is right -- an instance should survive between
turns -- but it registered no finalizer, so it also survived the process.
For docker that was a container per script; for e2b, modal, daytona,
tenki and flyio it is a billed cloud instance whose only other reaper is
the provider's own idle timer, and docker and flyio have none.

It now uses the same weakref.finalize treatment tools_placement uses for
agents, which covers collection and interpreter exit, and stands down
when the caller shuts down deliberately. The finalizer deliberately does
not close over the backend -- that would keep alive the very object whose
collection it waits for.

Verified from a confirmed-zero baseline: 0 containers after a timeout
(was 1), 0 after a run_on= script exits (was 1), and ordinary runs
unaffected.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 12:15:01 +01:00
Mervin Praison d5c6a1ce46 Merge pull request #4105 from MervinPraison/claude/issue-4104-20260820-0943
fix: add YAML adapter: import path and filesystem drop-in for gateway channels
2026-08-20 12:13:40 +01:00
Mervin Praison 5c431310d1 Merge pull request #4093 from ugbotueferhire/fix/windows-test-failures
fix: handle Windows carriage returns and Unicode encoding in tests
2026-08-20 12:08:52 +01:00
Mervin Praison 134ea0d362 Merge pull request #4101 from MervinPraison/claude/issue-4096-20260820-0848
fix: add deny-with-guidance to approval flow
2026-08-20 12:08:30 +01:00
praisonai-triage-agent[bot]andMervin Praison af95798c93 fix: gate project channel drop-ins & drop loader-only adapter field (#4104)
Address two P1 review findings on the YAML adapter/drop-in feature:

- Trust gate bypass: project-local .praisonai/channels/*.py files were
  classified by the plugins-scoped _is_project_local() (which checks
  .praisonai/plugins), so they executed without PRAISONAI_ALLOW_PROJECT_PLUGINS.
  Add _is_project_local_channel() scoped to the channels dir and gate on it.

- adapter field reaching constructor: the loader-only YAML `adapter:` import
  ref was passed through into adapter_cls(**init_kwargs), raising TypeError for
  adapters that don't accept an `adapter` kwarg. Skip it in the gateway
  pass-through alongside platform/token.

Adds regression tests for project-local opt-in enforcement (blocked/allowed)
and the adapter-field exclusion.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 11:07:58 +00:00
praisonai-triage-agent[bot]andMervinPraison dfa9832f90 fix: add YAML adapter: import path and filesystem drop-in for gateway channels (fixes #4104)
Restores the CLI/YAML/Python 3-way surface for custom gateway channels:
- ChannelConfigSchema gains an optional `adapter:` dotted "module:Class"
  import string; unregistered platforms are lazily imported, validated
  against BasePlatformAdapter, and self-registered before startup.
- BotPlatformRegistry auto-discovers single-file adapters dropped into
  ./.praisonai/channels/ and ~/.praisonai/channels/, reusing the same
  trust gate as single-file plugins.

Backward compatible: absent the adapter key and drop-in dir, resolution
behaves exactly as before.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 11:07:58 +00:00
Mervin Praison 86e8ca8f98 Merge pull request #4107 from MervinPraison/fix/sandbox-toctou
fix(sandbox): a container could escape to the host, and every run leaked one
2026-08-20 12:07:38 +01:00
MervinPraison 07dc2a9fcf docs: auto-update documentation parity trackers [skip ci] 2026-08-20 11:07:15 +00:00
MervinPraisonandClaude Opus 5 c13b8f4301 fix(docker): /sandbox meant two different roots
The sandbox directory is mounted at /sandbox, so that is the path a user
sees from inside the container and the one they naturally reach for. But
the file API treats "/" as the sandbox root, so the container spelling
was joined onto the root a second time:

    write_file("/sandbox/report.txt")     -> True
    cat /sandbox/report.txt               -> No such file or directory
    find /sandbox -name report.txt        -> /sandbox/sandbox/report.txt

and the reverse direction failed too -- a file the container had just
written to /sandbox/out.txt was invisible to read_file("/sandbox/out.txt")
and absent from list_files("/sandbox"), while read_file("out.txt") found
it. Two spellings of one location, one of them silently wrong, and the
wrong one is the spelling the container itself shows you.

Both now resolve to the same file. Only the exact mount prefix is
stripped, so a genuine subdirectory called "sandbox" is not swallowed --
there is a test for that, because prefix-stripping is the kind of fix
that quietly breaks the case it was not thinking about.

Introduced by the mount added to make writes reach the container at all;
before that, /sandbox did not exist inside it, so nothing could be
addressed either way.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 12:06:42 +01:00
praisonai-triage-agent[bot] 3ade8b9081 Merge pull request #4103 from MervinPraison/claude/issue-4102-20260820-0914
fix: add W3C trace-context propagation carrier to GatewayTraceHook
2026-08-20 11:06:33 +00:00
praisonai-triage-agent[bot]andMervin Praison ef6bb47be8 fix(sandbox): apply PID floor on execute path, scrub all env on capture, guard openat on non-POSIX
Three valid P1s from adversarial review:

- execute()'s _build_docker_command passed max_processes straight to
  --pids-limit, so the default 10 killed the container before python
  started (exit 125). Apply MIN_CONTAINER_PIDS floor, matching run_command().

- docker commit scrubbed only a fixed secret-name set. ComputeConfig.env is
  public (.praisonai/environment.yaml), so an arbitrary custom secret was
  baked into the persistent image. Scrub the union of the known set and the
  config's own env names -- the values actually injected into the container.

- open_in_sandbox/makedirs_in_sandbox referenced os.O_DIRECTORY / O_NOFOLLOW
  and dir_fd unconditionally, raising AttributeError on Windows. Gate the
  openat walk behind a capability flag; fall back to the resolved-string
  guard where openat is unavailable (Docker there runs a Linux VM, so no
  host-shared dir exists to race).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 11:01:23 +00:00
MervinPraisonandClaude Opus 5 a87e97c343 fix(sandbox): a container could escape to the host, and every run leaked one
Four defects found by adversarial review of the merged consolidation. The
first two are mine.

ESCAPE. The bind mount added so write_file() actually reaches the
container turned a harmless check-then-use into an exploitable race.
safe_sandbox_path() validates a path STRING; the caller then opens that
string -- two syscalls with a gap. Nothing but the host could write to
that directory before it was mounted. Afterwards, code inside the
container could swap a name between a regular file and a symlink:

    for i in $(seq 1 6); do (while true; do
      rm -f /sandbox/notes.txt; ln -s /etc/passwd /sandbox/notes.txt
      rm -f /sandbox/notes.txt; : > /sandbox/notes.txt
    done) & done

Measured before the fix: an escape to an arbitrary host file on write
attempt 375, and a host secret disclosed on read attempt 4. Arbitrary
host read and write, from code that where_does_it_run() calls "a Docker
container".

No amount of stricter string checking fixes this -- anything verified
before the open can be invalidated after it. So the file is never
re-opened by name: open_in_sandbox() walks the path one component at a
time against an open directory descriptor with O_NOFOLLOW at each step,
so a symlink substituted at any level fails the open rather than
redirecting it. Re-running the attack: host file untouched, secret not
disclosed, ordinary writes unaffected.

LEAK ON EVERY EXIT. tools_run_on= releases its sandbox from a weakref
finalizer, which usually runs as the process ends -- and by then
concurrent.futures has set its own atexit flag, so run_in_executor raises
"cannot schedule new futures after interpreter shutdown". The failure was
swallowed and logged as a successful release, so every run left a
container up while reporting it had cleaned up. Docker and Fly.io have no
idle reaper, so those were indefinite.

_offload() now falls back to calling inline when the executor is gone,
which is correct there: no event loop is left to starve. Unrelated
RuntimeErrors still propagate. DockerCompute was never converted to the
shared base in the earlier extraction, so it had the same bug in four
more places; it inherits now. Teardown reports failure instead of
claiming success, since a failed release means something is still
running -- or still billing.

SECRETS IN A COMMITTED IMAGE. `docker commit` preserves Config.Env, so
the model API keys forwarded into a container were baked into an image
that persists for days and is readable by anyone who can run `docker
inspect`. The capture exists to reuse installed packages, not the
credentials that happened to be present while installing them, so the
commit now scrubs them.

FORK BOMBS. run_command() accepted max_processes and ignored it while
the other execution path applied it. Docker's --pids-limit is per
container, so unlike RLIMIT_NPROC it means what it says -- but the
default of 10 was chosen for the rlimit world and kills the container
before the shell finishes starting. The cap has a floor a container can
live within; it still bounds a fork bomb, it just stops the bound from
being the bug.

Also drops `native` from the not-a-boundary list: it aliases sandlock,
which applies Landlock and seccomp, so the two spellings of one backend
were giving opposite security advice.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 11:53:02 +01:00
praisonai-triage-agent[bot]andMervin Praison 5a204d4a7d fix: correct egress header merge order in GatewayTraceHook docstring
Seed the outbound carrier with provider headers first, then inject the
active trace context last so a fresh traceparent/tracestate always wins
over any stale provider-supplied values (Greptile review on #4103).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 10:24:17 +00:00
praisonai-triage-agent[bot]andMervin Praison d177b0680c fix: add W3C trace-context propagation carrier to GatewayTraceHook (fixes #4102)
Extend the dependency-free GatewayTraceHook seam so an exporter can continue
an upstream caller's distributed trace and propagate context downstream:

- stage() gains an optional parent_carrier (inbound traceparent/tracestate)
  so a stage can be entered under an extracted parent span context.
- inject_context()/extract_carrier() no-op defaults let egress call sites
  write the active traceparent and ingress boundaries continue a caller's
  trace. Real extract/inject lives in the OTel exporter plugin.

NullGatewayTraceHook stays the zero-cost, OpenTelemetry-free default:
inject_context leaves the carrier untouched and extract_carrier returns None.
Additive and backward compatible.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 10:24:17 +00:00
Mervin Praison 04b86a9edc Merge pull request #4106 from MervinPraison/fix/docker-sandbox-filesystem
fix(docker): run_in="docker" had no filesystem, and leaked the host path
2026-08-20 11:23:05 +01:00
praisonai-triage-agent[bot]andMervin Praison c4e15733ec test: align ollama malformed-JSON test with intentional name-preserving contract
The error branch in _parse_tool_call_arguments (PR #4075, commit 991b2ff28)
intentionally reads the real tool name from tool_call["function"]["name"] so a
malformed/truncated arguments string is reported back to the model for the
correct tool instead of aborting the turn with a fabricated name. The stale
test still asserted the old "unknown_function" behaviour, causing a pre-existing
test-core failure unrelated to the docker-sandbox changes on this branch.

Update the assertion to the current contract and add a case proving the
"unknown_function" fallback still applies when no name is recoverable.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 10:22:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 66709c37a5 fix(docker): share /sandbox storage on the execute path too
run_in="docker" goes through execute(), which mounted only its own
script under /code -- so it could not see files write_file() wrote and
anything it created vanished with the container. Mount the sandbox dir
and run the script from there, matching run_command(). Adds a regression
test proving execute() reads a written file and its output persists.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 10:19:34 +00:00
MervinPraisonandClaude Opus 5 9563b57f71 docs(sandbox): the description described half the package
It said "backends extracted from the praisonai wrapper", which stopped
being the whole truth when the seven compute providers moved in. The
package now holds both shapes: eight sandboxes (one object IS one
environment) and seven compute providers (one object hands out many).

The distribution name stays. "sandbox" is imprecise for a package that
also contains fleet managers, but it is published, it appears in every
install hint and extra, and renaming a released package costs more than
the imprecision does. The precision lives in the subpackage instead:
praisonai_sandbox.compute is unambiguous about which shape you are
importing.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 11:13:40 +01:00
MervinPraisonandClaude Opus 5 e495c11839 fix(docker): run_in="docker" had no filesystem, and leaked the host path
Two user-visible defects, found by comparing the two docker
implementations now that they sit in one package.

WRITES WENT NOWHERE. write_file() writes into a host temp directory, and
`docker run` never mounted it. So the write reported success, the file
landed outside the container, and reading it back failed:

    write_file("/data.txt", ...)  -> True
    run_command("cat /data.txt")  -> exit 1, No such file or directory

Nothing carried between commands either, since each one got a fresh --rm
container with no shared storage. The directory is now mounted at
/sandbox and commands run there by default, so a write is visible to the
next read and to the next command.

LISTING LEAKED THE HOST PATH. list_files() returned entries like
"/../../../../../../private/var/folders/.../praisonai_sandbox_xxx/a.txt".
On macOS the sandbox directory comes back as /var/folders/... while
os.walk reports /private/var/folders/... -- /var being a symlink -- so
the relative-path computation escaped upward and handed back the real
host location. Both sides are resolved before comparing now, and any
entry that still escapes the root is dropped rather than reported.

Isolation is unchanged and tested: the host filesystem stays invisible,
only the sandbox directory crosses.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 11:12:45 +01:00
MervinPraison c0de8a7693 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 10:07:30 +00:00
Mervin Praison 3aa7e64b0a Merge pull request #4092 from MervinPraison/refactor/compute-into-sandbox
refactor(compute): move the vendor providers into praisonai-sandbox
2026-08-20 11:06:55 +01:00
praisonai-triage-agent[bot] ae992f9524 Merge pull request #4100 from MervinPraison/claude/issue-4098-20260820-0849
fix: report step-limit-truncated runs distinctly instead of as success
2026-08-20 09:43:36 +00:00
praisonai-triage-agent[bot] 08fb416dfb Merge pull request #4099 from MervinPraison/claude/issue-4097-20260820-0849
fix: drive provider->env-var maps from canonical catalogue
2026-08-20 09:42:43 +00:00
praisonai-triage-agent[bot] 11168d99b2 Merge pull request #4095 from MervinPraison/claude/issue-4094-20260820-0823
fix: redact audit secrets, lock DbSessionAdapter, fix transitive import misclassification
2026-08-20 09:37:10 +00:00
praisonai-triage-agent[bot]andMervin Praison c126bc2f36 fix(compute): point sandbox docstring examples at canonical import path
The C13 sandbox import gate flags any `from praisonai` line in
praisonai_sandbox/. The moved compute providers still documented the
legacy `praisonai.integrations.compute.*` alias in their docstring
examples, tripping the gate and failing test-core, test-core (sandbox)
and smoke. Point every example at the canonical
`praisonai_sandbox.compute.*` path (the old path is now only an alias),
which both satisfies the gate and gives users the correct import.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 09:29:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 89c1d6e898 fix(compute): complete [all] extra and point install hints at the light package
- praisonai-sandbox[all] now includes tenki and flyio, so a user who runs
  `praisonai-sandbox[all]` and selects tools_run_on="tenki" gets the SDK
  instead of a missing-dependency error (Greptile P1).
- _compute_bridge _EXTRA_HINTS now point at praisonai-sandbox[<vendor>] rather
  than the heavy praisonai wrapper — the whole reason the providers moved
  (58 vs 143 resolved packages) — and cover the previously-missing tenki hint.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 09:29:30 +00:00
MervinPraisonandClaude Opus 5 d4f18f8114 refactor(compute): move the vendor providers into praisonai-sandbox
Reaching E2B through `tools_run_on=` required the praisonai wrapper: 20
hard dependencies and 8 sibling packages, including a browser package and
a Telegram bot, to run a command in a sandbox. The identical capability
through `run_in="e2b"` needed only praisonai-sandbox[e2b]. Measured, that
was 143 resolved packages against 58, for the same vendor.

The seven compute providers now live in praisonai_sandbox/compute/,
beside the sandbox backends they share a vendor with. That package was
already the right shape for this: four hard dependencies and one optional
extra per vendor, sitting directly above core. Nothing needed inventing --
the extras grid already existed and was already correct.

Verified with the wrapper made genuinely unimportable: providers resolve
and a real Docker container runs. That property now has a test, so the
providers cannot quietly drift back behind a dependency users should not
need.

Compatibility. The old modules alias the new ones through sys.modules
rather than re-exporting from them. A plain `from ... import X` shim
looks equivalent right up until someone monkeypatches a module-level name
through the old path -- the patch lands on the shim and the real module
never sees it, which is exactly what the wrapper's docker-capture tests
do. Aliasing keeps one shared module object, so every existing import,
attribute and patch keeps working.

tenki's SDK pin follows its implementation; flyio needs none, since it
speaks plain HTTP through aiohttp, which praisonaiagents already
requires.

This does not merge the two implementations of the four shared vendors.
That decision is still open, and it is now easier to make because the
pairs finally sit in the same distribution.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 09:28:48 +00:00
praisonai-triage-agent[bot] da756b1ec7 Merge pull request #4091 from MervinPraison/claude/issue-4079-20260820-0752
fix: wire unattended-run policy, host-app job execution, and BotOS model pin
2026-08-20 09:26:57 +00:00
MervinPraison 3e48ab98a9 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 09:26:37 +00:00
Mervin Praison 220c7994a3 Merge pull request #4073 from MervinPraison/fix/provider-install-hints
fix(compute): the install hints named extras that did not exist
2026-08-20 10:26:01 +01:00
MervinPraisonandClaude Opus 5 576e4a14ec fix(compute): the install hints named extras that did not exist
When a provider's SDK was missing, the error told the user what to
install. Three of those instructions were wrong:

    praisonai[e2b]      did not exist
    praisonai[docker]   did not exist
    praisonai[daytona]  existed but was EMPTY -- installed nothing

pip warns about an unknown extra and then carries on succeeding, so the
user followed the official hint, waited for the install, and hit the
identical failure again. That is worse than no hint at all, because it
looks like it is helping.

The wrapper extras are now real and delegate to praisonai-sandbox, which
already pins every vendor SDK correctly, rather than standing up a second
grid of pins to drift against the first.

A test walks every hint, resolves the extra it names against the real
pyproject, and asserts it exists and is non-empty. The only reason this
survived is that nothing checked whether the advice was true.

Rebased onto main and rebuilt rather than merged: this branch and main
had both appended to test_provider_matrix.py, so a textual merge cut a
function in half. Main's copy is taken whole and the one test unique to
this branch re-applied on top.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 09:20:14 +00:00
MervinPraison dc6b6d40b8 docs: auto-update api.md [skip ci] 2026-08-20 09:18:56 +00:00
praisonai-triage-agent[bot] 018ba5e9e1 Merge pull request #4089 from MervinPraison/claude/issue-4084-20260820-0755
fix: wire recipe run history into execution and expose recipe runs
2026-08-20 09:18:26 +00:00
Mervin Praison 1ac3f3bb83 Merge pull request #4076 from MervinPraison/claude/issue-4075-20260820-0713
fix: guard malformed tool-call JSON, tool-pair-safe window trimming, same-batch async dep ordering
2026-08-20 10:17:52 +01:00
Mervin Praison 12f8e6da7d Merge pull request #4072 from MervinPraison/fix/align-docker-default
fix(sandbox): the two docker implementations handed you different Pythons
2026-08-20 10:17:36 +01:00
MervinPraison 562dcf0bf4 docs: auto-update api.md [skip ci] 2026-08-20 09:12:38 +00:00
praisonai-triage-agent[bot] cfee692cdd Merge pull request #4088 from MervinPraison/claude/issue-4083-20260820-0754
fix: enforce launch auth + fail-closed bind on Agent.launch
2026-08-20 09:12:14 +00:00
MervinPraison 6def247752 docs: auto-update documentation parity trackers [skip ci] 2026-08-20 09:10:37 +00:00
praisonai-triage-agent[bot] da599758fa Merge pull request #4090 from MervinPraison/claude/issue-4081-20260820-0753
fix: fence externally-POSTed webhook/hook payloads as untrusted data
2026-08-20 09:09:48 +00:00
MervinPraison 9df22f5363 docs: auto-update api.md [skip ci] 2026-08-20 09:02:31 +00:00
praisonai-triage-agent[bot] e5f89675bf Merge pull request #4087 from MervinPraison/claude/issue-4078-20260820-0752
fix: unify praisonai schedule store management surface
2026-08-20 09:02:06 +00:00
praisonai-triage-agent[bot]andMervin Praison 5548e47a8b fix: mark stream terminal event not-ok on step-limit-truncated runs
Compute truncation before emit_run_result in the actions/custom-agent
paths so --output stream-json consumers never receive a contradictory
run.result {ok:true} ahead of the status:"truncated" outcome (exit 2).
Adds a regression test exercising the stream-bridge ordering.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 09:01:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 95bd02f0a6 fix: surface every provider credential alias in auth (fixes #4097 drift)
auth._PROVIDER_ENV_KEYS retained only each provider's first catalogue
env-var, so providers with multiple credential spellings (together:
TOGETHER_API_KEY/TOGETHERAI_API_KEY, fireworks: FIREWORKS_API_KEY/
FIREWORKS_AI_API_KEY) had their secondary alias dropped. The runtime
(env.py) already honours all aliases, so `auth list`/`status` reported
"not found" for a credential the runtime accepts — the exact drift this
PR set out to eliminate. Now the map keeps all aliases and auth prefers
whichever the user actually set. Adds parametrised coverage over every
multi-alias provider.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:59:44 +00:00
MervinPraison e961d322ac docs: auto-update documentation parity trackers [skip ci] 2026-08-20 08:54:08 +00:00
praisonai-triage-agent[bot]andMervinPraison 15f0399aab fix: add deny-with-guidance to approval flow (fixes #4096)
Denials now support optional free-text guidance surfaced back to the model
as a course-correction instead of a bare "User denied" dead-end.

- ApprovalDecision gains optional feedback field (backward-compatible).
- ConsoleBackend adds a [d] "deny & redirect" choice capturing one line
  of guidance.
- Tool-loop denial handlers fold feedback into the tool result the model
  sees next turn (reuses existing result path, no new subsystem).
- InteractiveCLIApprovalBackend now also sets decision.feedback.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 08:53:49 +00:00
praisonai-triage-agent[bot]andMervinPraison a0f18d44a7 fix: report step-limit-truncated runs distinctly instead of as success (fixes #4098)
A run that hits its max_iter/max_steps budget returns a non-empty
finalisation summary, which the CLI's _run_succeeded accepted as success —
masking incomplete work with exit 0 and status "success".

The core already records the terminal reason on the agent via
last_stop_reason ("max_steps" on truncation), so consult it on the
in-process run paths that hold the Agent object and report a distinct
tri-state outcome:

- new _run_was_truncated(agent): reads last_stop_reason (best-effort)
- new _report_run_truncated(output, result): preserves the summary text,
  emits status "truncated" for --output json, prints a one-line stderr
  notice for interactive users, and exits 2 (distinct from failure=1 and
  completion=0)

Wired into the actions, custom-agent, and profiled-prompt paths. Genuine
completion and hard-failure paths are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 08:53:43 +00:00
praisonai-triage-agent[bot] 03dd3c2f47 Merge pull request #4086 from MervinPraison/claude/issue-4082-20260820-0754
fix: branch-safety guardrails for github_commit_and_push
2026-08-20 08:53:43 +00:00
praisonai-triage-agent[bot]andMervinPraison dcf94f3ec5 fix: drive provider->env-var maps from canonical catalogue (fixes #4097)
Wire the two remaining parallel provider->env-var maps to the canonical
PROVIDER_ENV_CATALOGUE so key auto-detection, provider inference, and the
auth surface can no longer drift:

- model_resolver._provider_for_model() now resolves via the catalogue
  (provider_for_model + env_vars_for_provider), covering openrouter,
  mistral, deepseek, xai, together, fireworks, ... instead of returning
  None. Keeps the historical eight-provider ladder as an offline fallback.
- auth._PROVIDER_ENV_KEYS is derived from the catalogue at import, with the
  historical literal map retained as an offline fallback.
- Add parametrised drift tests asserting every catalogued provider is
  resolvable by auth, model_resolver, and env first-run detection, so
  future drift fails CI.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 08:52:29 +00:00
praisonai-triage-agent[bot] 3aea9183da Merge pull request #4085 from MervinPraison/claude/issue-4080-20260820-0753
fix: honour recipe fields on async runs API
2026-08-20 08:46:54 +00:00
praisonai-triage-agent[bot]andMervin Praison dfa9b08882 fix: honour single-arg redactor contract; serialize session init to prevent duplicate on_agent_start
- audit.py: bind sensitive_keys to the built-in redactor via functools.partial
  at construction so both default and custom redactors are always invoked with
  a single argument, honouring the declared Callable[[Any], Any] contract.
  Custom one-arg redactors no longer raise TypeError before _write runs.
- db_session_adapter.py: add per-session init lock so concurrent first access
  calls the non-idempotent on_agent_start exactly once, preventing duplicate-key
  races in SQL conversation stores. Init locks are cleaned up on delete_session.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:32:37 +00:00
praisonai-triage-agent[bot]andMervinPraison ea44da4d53 fix: redact audit secrets, lock DbSessionAdapter, fix transitive import misclassification (fixes #4094)
- security/audit.py: redact sensitive tool_input keys (api_key, token, etc.)
  via a default denylist redactor; chmod the log file to 0o600.
- integrations/db_session_adapter.py: guard the four in-memory containers with
  a threading.RLock (DB I/O stays outside the lock) to prevent dropped messages
  and duplicated on_agent_start under concurrent multi-agent load.
- __init__.py: disambiguate ModuleNotFoundError on e.name so a broken transitive
  dependency surfaces the real cause instead of a misleading "has no attribute".

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 08:26:01 +00:00
praisonai-triage-agent[bot]andMervin Praison fa146cceb0 fix: close request-fence gaps for use_system_prompt=False and deliver_only
Address two P1 review findings on the untrusted-request fencing:

- use_system_prompt=False bypass: add a self-describing inline notice
  (request_payload_notice) prepended once outside the fence at each
  ingress boundary (webhook render_prompt, hook render_template), so the
  untrusted-data semantics travel with the payload even when the agent
  never receives the system-prompt trust clause.
- deliver_only markup leak: resolve_message gains a fence flag; the
  gateway deliver_only path calls it with fence=False so recipients of
  direct channel deliveries never see literal fence tags.

Tests updated for the inline notice and unfenced deliver_only output.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:16:37 +00:00
praisonai-triage-agent[bot]andMervin Praison b64d4b4b73 fix(schedule): correct cadence reset, exact-id mutation, json list, store describe
Address reviewer-flagged issues on the unified schedule management surface:

- schedule_update now clears last_run_at on cadence change so a new at:/every/
  cron schedule is evaluated fresh (a previously-run job updated to at: was
  treated as already-fired and never ran).
- CLI pause/resume/update/remove/delete mutate the exact record resolved by id
  (via store.update/store.remove) instead of round-tripping through the
  name-based tool, so duplicate names cannot cause the wrong job to change.
- schedule list --json emits a single valid JSON document ({store, daemon})
  instead of mixing a JSON array with the daemon's human-readable table.
- schedule describe consults the canonical store first, so store-backed jobs
  authored by schedule add can be inspected; falls back to the daemon.

Adds a regression test for the cadence-reset fix. Full scheduler suite passes.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:15:07 +00:00
praisonai-triage-agent[bot]andMervin Praison 42714aba9c fix: correct atomic-claim leave-due, stale scheduler policy, resolver docstring (#4079)
Address final-review findings on the unattended-run wiring:

- schedules_runner: on the canonical store, claim_due advances last_run_at at
  claim time, so raising in on_trigger still consumed the occurrence. Only
  start the poll loop when an executor was built; otherwise leave the loop
  unstarted so no due job is claimed/consumed (P1: atomic claim consumes
  failed occurrence).
- schedules_runner: drop the misleading "process-global registry" claim from
  the resolver docstring — no such registry exists in host embedding; message
  jobs are recorded failed (auditable) and command jobs run (P1: agent
  resolution).
- gateway server: rebuild RunPolicy each tick from the current _loaded_config
  so a hot-reloaded scheduler: block applies without a process restart
  (P1: reloaded policy remains stale).
- test: replace the tautological on_trigger stub test with one that asserts
  the real bridge does not start the loop when no executor is available.

Full scheduler unit suite: 307 passed, 8 skipped (no regressions).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:12:59 +00:00
praisonai-triage-agent[bot]andMervin Praison 855cc0b977 fix: only mutate launch token after abort guards; reuse generated token
Addresses Greptile P1 on PR #4088: resolve_launch_host() previously wrote the
process-wide PRAISONAI_LAUNCH_AUTH_TOKEN before the no-agents / missing-deps
guards, so a launch that never started a server could still flip global auth
and retroactively 401 an already-running endpoint. The bind guard now runs only
after both paths are committed to serving. A generated token is also reused for
the process lifetime so a later non-loopback launch cannot mint a fresh token
and lock out an endpoint already serving with the previous one.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:11:41 +00:00
praisonai-triage-agent[bot]andMervin Praison 466d27cb4d fix(recipe): persist streaming runs, correct run-id column, harden legacy import
- run_stream() now records a terminal RecipeResult (success/failed/policy/
  missing-deps/dry-run) via best-effort history, closing the streaming gap
- recipe runs: label the identifier column "Run ID" and align judge guidance,
  since `recipe judge` consumes the run_id (the index has no trace_id)
- legacy praison_ai: use direct attribute access (removes Ruff B009)
- tests: add streaming-persistence coverage; legacy test now exercises the
  wrapper-bridge resolution path the legacy branch actually uses

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:11:05 +00:00
praisonai-triage-agent[bot]andMervin Praison 808de04378 fix: run branch-safety checks before commit; preserve existing branches; apply divergence check to agent branches
Addresses reviewer P1s in github_commit_and_push:
- Safety refusals now run BEFORE staging a commit, so a refused push leaves
  pending changes intact and a retry can succeed.
- Existing local target branches are switched to with a plain checkout instead
  of `checkout -B`, so their local-only commits are not discarded.
- Divergence (force-push) refusal is factored out and applied to every branch,
  including agent-prefixed ones, and is never overridable.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:07:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 187f07c7d6 fix: wire unattended-run policy, host-app job execution, and BotOS model pin (fixes #4079)
Defect 1: gateway now constructs a default RunPolicy (deliver-on-failure,
prompt scan, denied toolsets) with optional gateway.yaml `scheduler:` overrides,
so failed scheduled jobs notify the owner and unattended tool downscoping fires.

Defect 2: the host-app schedule bridge now executes due jobs via
ScheduledAgentExecutor; when no executor can be built it raises so ScheduleLoop
leaves the job due instead of silently absorbing it.

Defect 3: the BotOS scheduler path now enforces pin_model via the canonical
_check_model_drift before agent.chat — a drifted pinned job takes no model turn
and is recorded failed with the drift reason.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 08:00:58 +00:00
praisonai-triage-agent[bot]andMervinPraison 41eba72c2f fix: fence externally-POSTed webhook/hook payloads as untrusted data (fixes #4081)
Wire the existing untrusted-content fencing at the HTTP ingestion boundary so
webhook/hook payloads arrive labeled as data, not instructions:

- Add reusable wrap_request_payload() + <external_request_payload> fence
  constants to tools/trust.py (ingress counterpart to wrap_if_external).
- Extend the system trust clause to name the new fence.
- Fence payload-derived content in the webhook channel render_prompt: template
  interpolations and the raw-body default are fenced; the no-prompt case gains a
  fixed operator prefix so attacker text is never the sole instruction.
- Fence payload interpolations in the gateway hook resolve_message; operator
  template text stays outside the fence.

Fence closers embedded in payloads are delimiter-escaped to prevent breakout.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 08:00:20 +00:00
praisonai-triage-agent[bot]andMervin Praison 7aae9eb469 test: assert prompt-only run completes via agent branch
Address CodeRabbit/Greptile feedback: the prompt-only regression test
previously never asserted that execution completed or that the agent
branch ran, so it could pass while the job stayed queued. Now records
the agent-branch call and asserts a 200 result of "agent-result".

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 08:00:10 +00:00
praisonai-triage-agent[bot]andMervinPraison c8b47fe997 fix: wire recipe run history into execution and expose recipe runs (fixes #4084)
- Persist every RecipeResult via history.store_run at the end of recipe
  core.run(); gated by PRAISONAI_RECIPE_HISTORY (default on), write
  failures never fail the run (logged and ignored).
- Add `praisonai recipe runs [name] [--status] [--session] [--limit]`
  to the Typer app, rendering recipe/version/status/when/session/trace-id
  and delegating to history.list_runs.
- Fix the broken legacy import in praisonai-code by routing through the
  wrapper bridge to the real praisonai.cli.features.recipe module.
- Add unit tests for persistence, env flag, safe write failure, status
  filtering, registry independence, and clean handler import.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 07:59:39 +00:00
praisonai-triage-agent[bot]andMervinPraison 5d5d4c3aa7 fix: enforce launch auth + fail-closed bind on Agent.launch (fixes #4083)
Agent.launch served an unauthenticated prompt-execution HTTP endpoint on
0.0.0.0, while Agents.launch enforced PRAISONAI_LAUNCH_AUTH_TOKEN. Factor the
request authoriser and a fail-closed bind guard into a shared
launch_security module and apply it to both paths:

- Agent.launch HTTP route now returns 401 without the bearer token.
- Both paths auto-generate and print a one-time token when bound to a
  non-loopback host keyless (loopback keyless dev flow unchanged).
- Default host changed 0.0.0.0 -> 127.0.0.1 for both launch methods.
- Bearer compare is now constant-time.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 07:58:54 +00:00
praisonai-triage-agent[bot]andMervinPraison 56d60c099e fix: unify praisonai schedule store management surface (fixes #4078)
Make store-backed jobs first-class in the CLI so what `schedule add`
writes is what `list`/`delete` and the new subcommands read.

- CLI: `list` now merges store jobs (labeled [store]) with daemon
  schedulers; add `runs`, `pause`, `resume`, `update`, `remove` operating
  on the ScheduleJob store; `delete` finds store jobs first; `add --once`.
- Agent tools: add `schedule_pause`, `schedule_resume`, `schedule_update`;
  expose `once` on `schedule_add` (maps to delete_after_run). Registered
  in TOOL_MAPPINGS for lazy string resolution.

Scope: kept to praisonaiagents + praisonai per AGENTS.md. `schedule tick`
requires the Tier-2 praisonai-bot executor and is intentionally not added.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 07:58:41 +00:00
praisonai-triage-agent[bot]andMervinPraison deb6e4705a fix: add branch-safety guardrails to github_commit_and_push (fixes #4082)
Refuse autonomous pushes to the default/protected branch, foreign-author
branches, and diverged remotes (never force). Auto-create praisonai/ branch
when on default. Opt-in override via allow_unsafe_branch / env.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 07:57:28 +00:00
praisonai-triage-agent[bot]andMervinPraison 471159d47e fix: honour recipe fields on async runs API (fixes #4080)
The runs API declared recipe_name/recipe_config on JobSubmitRequest and the
executor implemented the recipe branch, but the router never copied those
fields into the Job — so POST /api/v1/runs {"recipe_name": ...} silently ran
nothing recipe-related. Copy the fields through and add regression tests.

Co-authored-by: MervinPraison <[email protected]>
2026-08-20 07:55:02 +00:00
ugbotueferhire 7a19370e98 fix: handle Windows carriage returns and Unicode encoding in tests 2026-08-20 08:54:57 +01:00
MervinPraisonandClaude Opus 5 742681c8af fix(sandbox): the two docker implementations handed you different Pythons
`docker` is implemented twice -- once as a compute provider, once as a
sandbox backend -- and the two defaults had drifted a minor version
apart. Same word, different container:

    tools_run_on="docker"  -> Python 3.12.14
    run_in="docker"        -> Python 3.11.16

Nobody decided that. Two independent implementations of one idea simply
diverged, and unifying the vocabulary made the divergence easier to reach
rather than harder, because both spellings now look like the same thing.

The sandbox side moves to python:3.12-slim to match, along with the CLI,
docs and examples that quoted the old default.

The parity test compares the values a caller actually gets --
SandboxConfig.docker().image against ComputeConfig().image. An earlier
version of it scanned the DockerCompute docstring, which meant it passed
while the defaults were genuinely 3.11 and 3.12 apart. A test that cannot
fail is worse than no test, because it is counted as coverage.

Rebased onto main and rebuilt rather than merged: this branch and main
had both appended to test_provider_matrix.py, and a textual merge cut a
function in half. Main's copy is taken whole and the one test unique to
this branch re-applied on top.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 08:50:58 +01:00
praisonai-triage-agent[bot]andMervin Praison d678838fa4 fix: cover workflow-edge async deps, cascade async dep failures, prevent truncate re-orphan
Addresses reviewer P1 findings on the async batch/window fixes:

- _depends_on_pending now also detects dependencies expressed via
  task.previous_tasks (workflow next_tasks edges are stored as task names),
  not only task.context, so a workflow successor is no longer queued in the
  same async batch as its predecessor and forced to read an empty result.
- New _deps_failed method extends the failure cascade to the async_execution
  path in both the sequential and workflow branches: after a same-batch flush,
  an async dependent whose prerequisite failed is marked failed instead of
  running with missing upstream context (previously only sync dependents were
  guarded).
- TruncateOptimizer re-skips leading orphaned tool results after its
  token-budget pop loop, which could otherwise drop an assistant tool_calls
  message while retaining its role="tool" result and leave the trimmed
  transcript starting on an orphan (Issue #3089).

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 07:47:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 991b2ff28b fix: guard malformed tool-call JSON, tool-pair-safe window trimming, and same-batch async dependency ordering (fixes #4075)
1. Guard the tool-call argument JSON parse in the non-streaming native
   loops (openai_client.chat_completion_with_tools / achat_completion_with_tools)
   so a malformed/truncated arguments string is reported back to the model
   instead of aborting the whole agent turn, matching the streaming path.
   Also read the real tool name from tool_call["function"]["name"] in
   llm._parse_tool_call_arguments' error branch instead of the never-present
   top-level "name" key.

2. Route DefaultSessionStore._enforce_window's window slice through the
   existing _trim_preserving_tool_exchanges guard, and skip leading orphaned
   role="tool" messages in the context TruncateOptimizer/SlidingWindowOptimizer,
   so a persisted/optimized window never starts mid tool-exchange.

3. Split an async task batch on an intra-batch context dependency in
   arun_all_tasks (sequential + workflow) so a dependent async task's context
   is built after its same-batch dependency completes, not before.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 07:47:22 +00:00
MervinPraison dbe9431c1a docs: auto-update documentation parity trackers [skip ci] 2026-08-20 07:46:47 +00:00
Mervin Praison c85401fb3c Merge pull request #4077 from MervinPraison/refactor/retire-docker-managed-agent
refactor(placement): retire the bespoke docker backend, which could not be stopped
2026-08-20 08:46:10 +01:00
praisonai-triage-agent[bot]andMervin Praison 264dcd8a95 fix(compute): honour requested image= on E2B and Modal, not just Docker
The generic ComputeManagedAgent forwards image= through ComputeConfig.image,
which Docker/Daytona/Fly read but E2B (used metadata[e2b_template]) and Modal
(hardcoded debian_slim) ignored -- so run_on='e2b', image=... silently booted
the provider default. E2B now takes a non-default image as its template; Modal
builds it via from_registry. The default value leaves both unchanged. Adds
boundary conformance tests asserting the value each SDK actually receives.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 07:37:36 +00:00
MervinPraisonandClaude Opus 5 f0180ca532 refactor(compute): write the async-over-sync plumbing once, not once per vendor
Every cloud SDK here is synchronous and ComputeProviderProtocol is async,
so each provider grew the same five methods whose entire body is "hand the
matching _*_sync call to a thread":

    async def provision(self, config):
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self._provision_sync, config)

Measured: provision, execute, shutdown, upload_file and download_file were
byte-identical across e2b, daytona, modal and tenki -- six identical pairs
per method, about 210 lines of the same four statements.

That was the largest genuinely duplicated block in the project, larger
than all four cross-stack vendor pairs combined. It stayed invisible
because it sits *within* one stack rather than between two, and the
attention had been on the cross-stack pairs, where the real overlap turns
out to be about 6%.

A vendor now implements only the _*_sync half it differs in. Anything a
provider genuinely does differently it overrides, because these are
ordinary methods -- flyio does exactly that, since it speaks HTTP through
aiohttp and is natively async rather than sync-wrapped.

Each unimplemented operation raises NotImplementedError naming the
provider, rather than silently returning a wrong answer.

Net -119 lines. Verified live: docker and modal both still provision and
execute, and the provider matrix passes for all thirteen places.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 08:34:36 +01:00
MervinPraisonandClaude Opus 5 0a1bd56a77 refactor(placement): retire the bespoke docker backend, which could not be stopped
`run_on="docker"` had its own 321-line managed backend while the other
seven places shared one generic implementation. The justification written
into that file -- it talks to the daemon directly and skips a
provisioning layer -- did not survive being checked.

It named its containers `praisonai-agent-<hex>`, while DockerCompute's
lookup expects `praisonai_<instance_id>`. So:

    praisonai managed ps    ->  shows the container
    praisonai managed stop  ->  ValueError: No docker container found

Listed but unreclaimable, which is the worst combination: the user can
see the thing they cannot stop. Routing through the provider fixes it by
construction, and 321 lines go with it.

Two gaps in the generic backend had to close first, both real:

  - `image=` fell into **kwargs and was silently ignored, so asking for a
    specific image quietly got the provider's default.
  - the failure when a place is unavailable now names the place and
    offers tools_run_on=, which needs no daemon.

The generated class is still called DockerManagedAgent -- make_loader
names each backend after its place -- but it is now a ComputeManagedAgent
subclass, so there is one code path for every runtime.

Also adds the conformance tests that would have caught this class of
problem: the four vendors implemented twice must agree on their
conventions. Docker-only parity is exactly what let Daytona drift into
asking for 1024 GiB of RAM.

Verified live: docker provisions, runs a whole agent loop, appears in
`managed ps`, and `managed stop` now reclaims it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 08:28:55 +01:00
Mervin Praison e6099cbb50 Merge pull request #4074 from MervinPraison/fix/daytona-units
fix(daytona): asking for 1024 GiB of RAM, and uploading files backwards
2026-08-20 08:21:01 +01:00
praisonai-triage-agent[bot]andMervin Praison d8597c5008 test(backend): assert real hosted-runtime contract post run_on=
test_hosted_agent_only_accepts_anthropic encoded a pre-run_on= assumption
that modal/e2b were unavailable managed runtimes. The placement feature
intentionally registered them via the backend registry, so HostedAgent now
resolves them. Assert the guarantee that still holds — providers with no
registered backend (openai/gemini) raise a helpful ValueError — instead of
the stale one.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 07:13:56 +00:00
praisonai-triage-agent[bot]andMervin Praison 4c4160d286 test(daytona): assert SDK boundary, not source text
Replace inspect.getsource() substring checks with behavioral tests that
mock the Daytona SDK and assert the actual value passed: memory converted
to GiB (2048MB->2, 512MB->1) and upload_file(src, dst) argument order.
Addresses Greptile p2: source inspection let equivalent regressions pass
and harmless refactors fail.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-20 07:10:09 +00:00
MervinPraisonandClaude Opus 5 e56065d276 fix(daytona): asking for 1024 GiB of RAM, and uploading files backwards
Two live defects on the compute side of Daytona, both found by comparing
it against the sandbox side, which had already learned the conventions.

MEMORY UNITS. daytona_sdk documents Resources.memory as "Amount of memory
in GiB to allocate". The compute provider passed ComputeConfig.memory_mb
straight through, so the default configuration asked Daytona for 1024 GiB
of RAM. That is a provisioning failure, not a rounding error. The sandbox
implementation converts correctly and always has.

ARGUMENT ORDER. FileSystem.upload_file(src, dst) takes the content first
and the destination second. The compute side called
upload_file(remote_path, content), so an upload wrote the path into a
file named after the content. The sandbox side calls it correctly.

Neither is a typo in the ordinary sense: both are the same class of
failure, where one of two implementations of a single vendor learned a
convention and the other never did. Nothing compared them, so nothing
noticed. The wrapper's daytona tests only assert provider_name and that
the constructor exists -- nothing exercises provision() or upload.

Tests now assert both conventions on the compute side, because a shared
vendor name is not a shared understanding of that vendor.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 08:04:08 +01:00
MervinPraison 0a4742fdbf docs: auto-update feature parity trackers [skip ci] 2026-08-20 06:38:22 +00:00
Mervin Praison b02252abeb Merge pull request #4071 from MervinPraison/feat/provider-matrix
feat(placement): run_on= for every place that can host a loop, plus a provider matrix
2026-08-20 07:37:58 +01:00
MervinPraisonandClaude Opus 5 c4dbce2033 test(placement): assert the guarantee, not a timing detail
The provisioning tests asserted an exact count of one. SharedCompute
provisions LAZILY, so a run that never touches a tool correctly
provisions nothing -- the exact count was asserting when provisioning
happened rather than the property that matters, and it flaked.

The guarantee is that a run never provisions MORE than one sandbox, and
that a three-row batch does not mean three. Verified the weaker assertion
still has teeth: mutating _needs_tools_scope to always open a scope fails
six tests including the batch one.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:33:21 +01:00
praisonai-triage-agent[bot]andMervin Praison 82daf4e958 fix(compute): reclaim ephemeral instance when a remote run fails
An ephemeral place (keep_alive=False) provisions a billable cloud
instance, but execute() only tore it down on the success path -- a
failed write, remote run, or parse left the instance running until
external cleanup. Move shutdown into a finally so teardown is
unconditional; keep_alive=True still keeps the warm instance.

Fixes the Greptile P1 on compute_managed_agent.py.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 23:30:36 +00:00
MervinPraisonandClaude Opus 5 253ab78e4a fix(placement): sandbox plugins were invisible to tools_run_on=
SANDBOX_ONLY was a hardcoded tuple of four names. The sandbox registry
accepts plugins by entry point, so a contributed backend worked with
run_in= and could not be selected with tools_run_on= at all -- the same
hardcoded-list bug just fixed for the compute registry, one layer down.

`capsule` was the live example: it ships from praisonai-plugins, resolves
fine through run_in=, and was missing from the tools_run_on= vocabulary
for no reason a user could see.

The names now derive from the registry, with the original four kept as a
floor so nothing depends on discovery succeeding. Anything the compute
registry already provides is excluded, so docker/e2b/modal/daytona keep
their richer implementations instead of being downgraded to the
one-instance adapter.

SANDBOX_ONLY stays a tuple subclass that re-reads the registry on
membership and iteration, because several call sites predate this and
import it directly.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:26:10 +01:00
MervinPraisonandClaude Opus 5 7c67c1a94f feat(placement): a contributed place can name itself
The phrase table is a literal, so it cannot know about a package
installed later: an external place printed as a bare name
(tools_run_on='demo') while every built-in got a readable phrase. A
provider may now declare `display_name` and have it used in the repr and
in where_does_it_run().

Verified with a plugin installed from outside the repo: it reports
"a Demo cloud sandbox" in both. Declaring it stays optional and the
fallback to the bare name is unchanged, so nothing existing shifts.

That leaves one derived surface still hand-maintained -- the ToolPlace
Literal -- which is inherent to static typing rather than fixable: a type
checker cannot see a runtime plugin. Its union already stays open, so an
unknown name type-checks and only loses autocomplete.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:26:10 +01:00
MervinPraisonandClaude Opus 5 e81d0e55de fix(cli): managed ps polls the registry, not a literal
_PS_PROVIDERS was a hardcoded tuple, so a place contributed by another
package could be provisioned and then never listed or stopped. An
unreclaimable cloud sandbox is a bill nobody sees, which makes this the
one remaining hand-maintained list with a cost attached.

Exclusions are now stated with reasons rather than by omission: local and
subprocess run here and have nothing to reclaim, ssh is someone else's
machine and not ours to stop, sandlock is a process rather than an
instance, and adapter-backed places have no cross-process registry to
enumerate.

_PS_PROVIDERS stays as a module attribute so existing imports keep
working.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:26:10 +01:00
MervinPraisonandClaude Opus 5 adc4253f2a test(providers): check every provider and SDK without needing a credential
Modal's `run_in=` path was broken in five separate ways and nothing
noticed, because nothing had ever run it. Most of what breaks an
integration is checkable for free, so this checks it: for all ten places
the class resolves, satisfies the protocol, exposes coroutines rather
than sync methods (a sync one here does not raise -- it returns a
coroutine nobody awaits, so the call silently does nothing), answers
is_available without throwing, is accepted by tools_run_on=, is accepted
or refused by run_on= according to whether it can host a loop, and is
described in the repr as the place it was actually given.

The one that matters most in practice is the last check: a missing
credential has to fail with something actionable -- naming the key, the
install command or the daemon -- rather than an AttributeError from
inside a vendor SDK. All seven credential-less places pass it today.

Live where the machine allows: docker, subprocess, local and modal
provision and execute for real; the rest skip loudly.

Also removes a latent flake. The batch-provisioning test asserts an exact
provision count, and agents from earlier tests release their sandbox when
collected -- which could land inside the measurement window now that
lifetime is tied to a finalizer. The window is opened after a collection
instead of hoping the timing misses.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:26:10 +01:00
MervinPraisonandClaude Opus 5 c454fea7f4 feat(placement): run_on= now covers every place that can host a loop
run_on= accepted two names while tools_run_on= accepted twelve. That was
not a fact about the places -- a place that can run a command can run the
agent loop, which is just one more command -- it was an implementation
detail: no backend had been written for the rest.

One generic ComputeManagedAgent wraps any ComputeProviderProtocol rather
than eleven near-identical vendor backends, taking run_on= from 2 places
to 8: anthropic, docker, e2b, modal, daytona, flyio, tenki, novita.

Verified live end to end on Modal -- the whole loop ran there and came
back with the answer, having installed the package remotely first.

Excluded deliberately, with tests asserting they still raise: `local`
would run the agent in your own shell, which is what you get by passing
nothing; `ssh` needs an object, not a name; `subprocess`/`sandlock`
isolate tools rather than host a runtime. `docker` keeps its specialised
backend since it can talk to the daemon directly.

Two details worth keeping:
- the registry loader returns a real subclass, not a factory function,
  because HostedAgent's factory path calls issubclass() on it
- files reach the remote machine through a quoted heredoc, since execute()
  is the only primitive every compute place has, and the delimiter is
  uppercase for the same reason as elsewhere: a lowercase hex delimiter
  contains "dd", a blocked command, about a tenth of the time

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:26:10 +01:00
MervinPraisonandClaude Opus 5 de562fa043 fix(modal): run_in="modal" was dead five times over
Modal is authenticated on this machine, so the path could finally be
exercised. It failed five times in a row, each fault hiding the next:

1. keep_warm / allow_concurrent_inputs were renamed on 2025-02-24 and
   the old spellings now RAISE rather than warn. Both generations are
   handled by inspecting the installed SDK's signature, since hardcoding
   either spelling breaks half the installed base.
2. The decorated function is defined inside a method, which Modal only
   accepts with serialized=True.
3. The App was never started. A Modal function only carries the metadata
   it needs while its App is running, so .remote() failed with "Function
   has not been hydrated". The app.run() context is now held for the
   sandbox's lifetime and closed in stop().
4. add_python="3.11" was passed to an image that already ships Python --
   the default is python:3.11 -- so the image build failed outright.
5. The default image was pinned to 3.11 while a serialized function is
   pickled locally and unpickled inside it, so anyone not on 3.11 got
   "defined with Python 3.12, but its Image has 3.11". The default now
   follows the running interpreter.

Verified end to end: execute_code(run_in="modal") returns 'from modal'
with exit 0 against real Modal infrastructure.

tools_run_on="modal" was already working and is now verified live too --
provision, uname, a file written and read back across calls, shutdown.

The test asserting a hardcoded python:3.11 default was updated rather
than deleted; it now pins the interpreter-matching rule that replaced it.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:26:10 +01:00
MervinPraisonandClaude Opus 5 2e237537bd feat(placement): let other packages contribute a place
Two of the three registries already discovered plugins by entry point --
praisonai.sandbox and praisonai.managed_backends. The compute registry,
which is where tools_run_on= places live, was a hardcoded dict, so adding
one meant editing core:

  _compute_bridge._PROVIDERS       the resolver
  compute/__init__.py              the lazy import
  placement.ToolPlace              or it gets no typing
  cli/commands/managed._PS_PROVIDERS  or `managed ps` cannot see it
  execution_location._PLACE_WORDS  or it prints a bare name
  plus the lists in two docstrings

Six edits across two packages, for something that is by nature a plugin.
It now reads praisonai.compute as well, so a provider ships in its own
distribution:

    [project.entry-points."praisonai.compute"]
    runpod = "praisonai_compute_runpod:RunpodCompute"

Verified end to end with a package installed outside this repo: it
appeared in available_providers(), passed tools_run_on= validation,
resolved, executed, and Agent(tools_run_on="demo") built -- with no
change to any file here. The repr falls back to the bare name, which is
the one remaining hand-maintained list and degrades gracefully.

Providers load on demand, so an installed-but-broken plugin fails only
for the caller that names it. There is a test for that.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-20 00:26:10 +01:00
praisonai-triage-agent[bot] f4c5ba37da Merge pull request #4070 from MervinPraison/feat/unified-run-on
fix(placement): one word still meant two backends, and the repr hid it
2026-08-19 22:46:10 +00:00
praisonai-triage-agent[bot]andMervin Praison 32d3c8c1b7 test(gateway): isolate start-command tests from the on-disk config so verify-turn does not fire
The bots-gateway CI shard runs from src/praisonai-bot, whose gateway.yaml was
auto-discovered when these tests invoked `gateway start` with config=None.
Discovery then ran the verify-turn pre-flight (a real agent turn, default-on
per #4042) before the monkeypatched start(), exiting 1 with CI's fake key.

Pass an explicit channel-less config so every start-time pre-flight is skipped
and the command reaches the patched handler deterministically. The exit-code and
watchdog-flag assertions are unchanged — the tests still guard exactly what they
did, just hermetically.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 22:00:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 00ba9b7f94 fix(placement): a backend's LocalCompute object no longer borrows subprocess's words
The compute-only branch ran before the provider object was normalised, so a
managed backend holding a LocalCompute (provider_name="local") skipped it and
the alias table collapsed "local" to "subprocess" -- describing the
unrestricted shell in the locked-down backend's stronger words. Normalise
first, then apply the compute-only check, and read a backend's compute via the
compute vocabulary since that object is a compute-registry provider.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 22:00:30 +00:00
MervinPraison 1dc14b9f90 docs: auto-update documentation parity trackers [skip ci] 2026-08-19 21:18:42 +00:00
Mervin Praison cb4a702655 Merge pull request #4069 from MervinPraison/feat/run-on-docker
feat(placement): run_on="docker" — the whole agent in a container you own
2026-08-19 22:18:07 +01:00
praisonai-triage-agent[bot]andMervin Praison 374ca9a8a4 fix(docker): don't block the loop, don't leak the container, don't contradict the docs
Three issues raised in review of run_on="docker":

- execute()/stream() ran the blocking Docker start-up, install and container
  turn directly on the event-loop thread, stalling concurrent async callers.
  Offload to a worker thread with asyncio.to_thread.
- keep_alive=False only tore the container down on success, so a failure after
  start-up leaked a praisonai-agent-* container. Move teardown into finally.
- Both READMEs showed run_on="docker" raising TypeError while also documenting
  it as supported. The typo example now uses e2b, which genuinely cannot host
  an agent loop, so the two examples no longer contradict each other.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 21:17:10 +00:00
MervinPraisonandClaude Opus 5 0577772b39 feat(placement): run_on="docker" — the whole agent in a container you own
`run_on="anthropic"` hands the entire agent to a vendor's runtime. This is
the same shape, self-hosted: the loop runs in a local container instead.

The distinction from tools_run_on="docker" is what crosses the boundary:

  tools_run_on="docker"  your process runs the loop and calls the model;
                         only shell and file tools go into the container
  run_on="docker"        the loop itself runs inside, so the model call is
                         made from the container and nothing on your
                         filesystem is reachable unless you mounted it

Both name the same place, and that is not an ambiguity -- the parameter
carries the scope. So the two value sets deliberately overlap now, and
the tests say so: a name in both must work in both, a name in only one
must be refused by the other with a message naming the right parameter,
and the object must still report which scope you chose.

Implementation is just a backend registration. run_on= resolves against
ManagedBackendRegistry, so adding DockerManagedAgent there makes the
parameter, the repr, the explanation and the conflict checks all pick it
up with no changes to any of them.

Two things this exposed:

- _backend_places() identified hosted runtimes by CLASS NAME ("Hosted" or
  "Anthropic" in it). A self-hosted DockerManagedAgent matched neither,
  so an agent whose entire loop ran in a container reported that it was
  thinking on this machine. Backends are now asked what they are.

- The runner writes its payload into the container through `docker exec
  -i ... tee` rather than an argument, so instructions and API keys never
  appear in `docker inspect` or a process listing.

Stated limitations rather than hidden ones: Python callables in tools= do
not cross (same as the hosted runtimes), the container needs the model
API key, and the first run installs the package unless image= supplies
one that has it.

Tests that need a live daemon are marked skipif rather than silently
passing; the rest -- registration, both scopes, config marshalling,
result parsing, the missing-daemon message -- run without Docker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 22:08:49 +01:00
MervinPraisonandClaude Opus 5 70ba441a2f fix(placement): one word still meant two backends, and the repr hid it
Two gaps found while documenting every accepted value.

"local" resolves differently depending on which parameter carried it:
run_in="local" is collapsed by SandboxManager to the subprocess backend
-- scrubbed environment, blocked commands and paths -- while
tools_run_on="local" is the wrapper's LocalCompute, a plain shell in the
current directory with the full environment inherited and no security
policy at all. Both printed "a separate process on this machine", so the
weaker of the two was described in the stronger one's words. That is
precisely the failure this module exists to prevent, hiding in the one
place the rest of the work did not look. say_place() now takes the
registry that resolved the name.

Resolver aliases were rejected at construction. Validation ran before
alias resolution, so tools_run_on="native" raised "not a known place"
while resolve_compute("native") returned a working sandlock adapter.
tool_places() now includes the alias table it validates against.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 22:01:38 +01:00
Mervin Praison 2f2b2d63c7 Merge pull request #4068 from MervinPraison/feat/unified-run-on
feat(placement): one vocabulary for where agents, tools and code run
2026-08-19 21:06:48 +01:00
MervinPraisonandClaude Opus 5 1d61aed1f9 test(managed): update the one assertion the merged resolver changed
LocalManagedAgent carried its own hardcoded provider list, which is why
LocalAgent(tools_run_on="sandlock") raised "Unknown compute provider"
for a place the rest of the library resolves happily. Routing it through
the merged resolver changes the message to "Unknown place", listing
every place both registries offer.

The behaviour is unchanged -- an unknown name still raises ValueError --
so only the assertion was stale. Added coverage for what the delegation
actually buys: the sandbox-only places now resolve here too.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 21:03:35 +01:00
praisonai-triage-agent[bot]andMervin Praison 4ffa3488a4 fix(placement): isolate concurrent async team runs with a ContextVar
Two coroutines that astart() the SAME team under one asyncio.gather share
the event-loop thread, so the previous threading.local scope state was
shared between them: the second saw the first's depth==1, skipped its own
sandbox, and ran against a sandbox the first could tear down mid-flight.

Switch _tools_scope_depths from threading.local to a contextvars.ContextVar
holding an immutable frozen mapping replaced via .set() (never mutated in
place). A ContextVar is copied per task at gather/create_task time, so each
run gets its own depth map; it stays per-thread too, so sync callers remain
isolated. The frozen value matters -- a copied context shares the reference
to a mutable dict, so in-place mutation would leak straight back across the
tasks the copy was meant to separate.

Adds a deterministic regression test that fails on the old design
(['own', 'borrowed']) and passes now (['own', 'own']) without needing a
live model.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 19:50:48 +00:00
MervinPraisonandClaude Opus 5 ea017740bf docs(placement): say plainly that the local tier is not a boundary
The security audit demonstrated `cat $(echo /etc/passwd)` reading the
file end-to-end through the subprocess place: blocked_paths matches
tokens, and any shell indirection -- command substitution, a variable, a
nested sh -c -- hides the path from it. That is inherent to a blocklist
over a real shell, not a patchable pattern, so where_does_it_run() now
says the command list can be worked around rather than implying the
tier contains anything.

Also updates the flagship YAML example to tools_run_on:, lists the four
newly reachable places, and marks which of them are real boundaries
(sandlock is kernel-enforced; subprocess is process separation only).

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:36:20 +01:00
MervinPraisonandClaude Opus 5 85af51b0d6 fix+test(placement): close the gaps mutation testing exposed
Mutation testing showed 7 of 14 deliberate breakages left the suite
green. The pattern was consistent: argument validation and repr strings
were well covered, and every path that actually moves work to a sandbox
was not covered at all. Deleting the ensure_tools_placed() call sites --
the feature's entire point -- broke nothing.

Bugs fixed:

- A clone inherited the source agent's bridged tools, which close over
  the SOURCE's sandbox, and its backstory already carrying the capability
  prompt. Its first turn then attached a second sandbox: two copies of
  the prompt, and work split across two machines. The tools are now
  tagged so the existing clone filter drops them, and the clone inherits
  the pre-attach backstory.

- test_apply_rlimits_sets_memory_limit still asserted RLIMIT_NPROC was
  set. It passed on main and failed here, because dropping NPROC is
  deliberate. Replaced with an assertion that it is NOT set, plus a new
  test that one unsupported limit no longer skips the others -- the
  latent bug that made RLIMIT_NOFILE never apply on macOS.

Tests added for what survived mutation:
  - every entry point (chat/achat/_start_stream) places tools before the
    first turn, asserted by driving the real method
  - AgentTeam start/astart/start_for_each provision exactly one sandbox,
    counted through a fake provider, instead of poking a private method
  - policy_scan_parts expands shell payloads, survives unbalanced quotes,
    strips quoted heredoc bodies but NOT unquoted ones, and a blocked
    path inside `sh -c` is genuinely refused (asserting the file was not
    read, not merely that an error was returned)
  - SandboxComputeAdapter, which had zero tests despite being the largest
    new file: policy denials report failure rather than exit 0, shell
    semantics work, state persists across calls
  - SandboxManager constructor introspection, including that the guided
    error names `host` and SSHSandbox rather than leaking a raw TypeError

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:32:15 +01:00
MervinPraisonandClaude Opus 5 c5ddc2d526 fix(sandbox): stop the policy check reading file contents as commands
Two reliability bugs the security audit reproduced.

write_file() built a heredoc delimiter from uuid4().hex, which is
lowercase. The security policy substring-matches blocked_commands
against the whole command string, and "dd" is one of them -- present in
9.6% of random lowercase hex strings (measured over 100k). So roughly
one write in ten failed with "Blocked command pattern: dd", for no
reason the caller could see. The delimiter is uppercase now.

The same substring scan also read the heredoc BODY, which is the literal
bytes of a file, not a command. Saving a shell tutorial containing
"rm -rf" was rejected as an attempt to run it; a file mentioning
/etc/passwd was rejected as an attempt to read it. Quoted heredoc
bodies are now stripped before the policy check -- only the quoted form,
since an unquoted heredoc still undergoes shell expansion and its
contents really can execute.

Verified the boundary is unchanged: `cat /etc/passwd` and `rm -rf /`
are still blocked, while all three content cases now write, and 60
consecutive writes produce 0 spurious failures.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:18:55 +01:00
MervinPraisonandClaude Opus 5 7d54af6b5c fix(placement): sandbox lifetime, and stop one global lock serialising everything
Four lifecycle bugs, all found by adversarial validation.

LEAK. An agent and its SharedCompute referenced each other, so a dropped
agent was reclaimed by the CYCLE collector, vanished from the WeakSet the
atexit hook iterated, and its sandbox was never shut down. Gateways clone
an agent per request, so that was one orphaned container per request --
measured: 5 requests, 5 provisioned, 0 shut down.

Fixed by tying lifetime to the agent with weakref.finalize (which also
covers interpreter exit, so the atexit hook and weak set are gone), and
by holding agents WEAKLY in SharedCompute._patched. The weak _patched is
the load-bearing half: while the restore list held agents strongly, the
finalizer kept its own agent alive and could never fire.
Now: 5 provisioned, 5 shut down, 0 leaked.

RACE. close_tools_sandbox() cleared the marker first and shut down after,
outside the lock. A concurrent chat() re-attached in that window and the
in-flight shutdown restored over it, leaving the agent marked as
sandboxed while holding no sandbox tools -- unrepairable, because
ensure_tools_placed()'s idempotent early return sees the marker and does
nothing. It is all under the lock now.

EXIT HANG. The exit path took a process-global lock, so any thread
holding it blocked interpreter exit forever (reproduced: rc=124). The
realistic form was a bounded stall, since the lock was held across
SharedCompute(...) -- which imports the provider SDK.

CONTENTION. That same global lock serialised placement for unrelated
agents (16 concurrent attaches: 0.87s against a 0.05s parallel floor),
and being non-reentrant while held across attach()'s writes to
agent.tools, any subclass with a tools setter that touched placement
would self-deadlock in its own constructor. Locks are per-agent now, and
the only global lock covers a dictionary lookup.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:18:08 +01:00
MervinPraisonandClaude Opus 5 76ea2793db fix(placement): team sandboxes were provisioned but never reached the model
Two bugs found by adversarial validation, both silent.

1. AgentTeam handed the model the PRE-ATTACH tool list.
   _build_execution_context snapshots agent.tools and passes it to chat()
   as an explicit `tools=`, which wins over agent.tools. The sandbox
   attached later, inside chat(), so the model got the local tools while
   a real sandbox sat provisioned and unused -- every tool ran on the
   host. Placement now happens before the snapshot.

   It read as flaky rather than broken: in start_for_each() row 1 got
   local tools and row 2 got the bridged set, because row 1's chat() had
   mutated agent.tools in time for row 2's snapshot.

2. The scope depth was shared class-wide, not per team.
   `_tools_scope_state` is a class attribute, so ANY team started while
   another team's run was on the stack saw depth==1 and skipped its own
   sandbox. A nested team ran on the host; a team sharing an agent with
   the outer run executed on the OUTER team's sandbox; two teams in one
   asyncio.gather gave one a sandbox and the other nothing. Depth is now
   keyed per team, and the key is dropped at zero so the dict cannot grow
   without bound on a long-lived worker thread.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:16:34 +01:00
MervinPraisonandClaude Opus 5 5743ef0829 feat(placement): one vocabulary for where agents, tools and code run
Three parameters meant "somewhere else" and none of them said which
somewhere: `run_on=` (tools only, on Flow/Team), `compute=` (tools only,
on LocalAgent) and `backend=` (the whole agent). One concept had two
names and one name had two concepts, so the docs had to keep correcting
the API.

Now two words, each answering a different question:

  run_on=        the WHOLE agent moves to a managed runtime
  tools_run_on=  only the tools move; thinking stays here
  run_in=        one execute_code() call, this call only

Because the two questions have disjoint answer sets, a wrong pairing is
a typo rather than a preference. `run_on="docker"` cannot mean anything
-- Docker does not host agent loops -- so it raises TypeError naming the
parameter the user actually wanted, instead of silently placing work on
a machine nobody chose. Never "last one wins".

Reachability: sandbox-only backends (subprocess, sandlock, ssh, novita)
spoke SandboxProtocol -- one object IS one environment -- while
tools_run_on= speaks ComputeProviderProtocol -- one object HANDS OUT
many. SandboxComputeAdapter supplies the missing half, taking the
registry from 7 places to 11. This finally makes `sandlock` selectable:
kernel-enforced Landlock + seccomp-bpf, the only real local boundary in
the project, previously unreachable.

Shipped bugs found and fixed along the way:
  - AgentTeam.astart() never provisioned the shared sandbox at all, so
    every async team ran its tools on the host and said nothing.
  - start_for_each() provisioned one sandbox per input row: a 50-row
    batch paid for 50, and no row could see what an earlier row wrote.
  - The re-entry guard set `self.run_on = None` for the duration of a
    run, so a concurrent start() observed "no sandbox" and executed
    every tool on the host. Now thread-local; placement stays readable.
  - SharedCompute.attach() could attach an agent twice (flow + its own),
    duplicating the capability prompt and, on non-LIFO teardown, leaving
    the agent holding tools bound to a shut-down instance.
  - SandboxManager passed config= to every backend; SSHSandbox and
    ModalSandbox do not accept it, so they raised before starting.
  - RLIMIT_NPROC was applied per *real user ID*, not per sandbox, so
    max_processes=10 counted the user's existing processes and the
    sandbox could not fork at all.
  - blocked_paths stopped matching once commands ran through `sh -c`,
    because the path hid inside the shell payload.

Removed ExecutionConfig.code_sandbox_mode. It defaulted to "sandbox"
while no execution path read it, so its own default told every reader
they had isolation they did not have.

where_does_it_run() now names the tools that will NOT move. Only four
tool names are bridged; the capability prompt tells the model the
sandbox is where things happen, so anything staying behind has to be
said out loud.

Migration:
  - YAML `run_on:` stays a silent alias -- it is data people already
    shipped, and there is no Agent-level run_on: key to confuse it with.
  - Python `run_on=` on Flow/Team raises with a directed message; it
    would otherwise mean the opposite of `run_on=` on Agent.
  - `compute=` warns (FutureWarning, visible to end users per PEP 565).
  - `Agent(sandbox=)` is kept, not removed: clone_for_channel() passes
    it and it is a legitimate per-agent default. `run_in=` overrides it
    per call.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 19:52:23 +01:00
Mervin Praison 59fc35ce5f Merge pull request #4023 from MkaliezZ/feat/agentfuse-tool-middleware
feat(agents): propagate tool_call_id through middleware context
2026-08-19 19:49:29 +01:00
Mervin Praison adf464bf50 Merge pull request #4017 from MervinPraison/claude/issue-4016-20260817-1715
fix: scope memory availability probe to skip FileMemory path
2026-08-19 19:40:17 +01:00
Mervin Praison fbd0e37500 Merge pull request #4020 from MervinPraison/claude/issue-4019-20260818-0710
fix: wire task when-routing, guard Knowledge/Memory erasure, apply defaults.guardrails
2026-08-19 19:39:31 +01:00
MervinPraison 5bd4b3eeee docs: auto-update api.md [skip ci] 2026-08-19 18:34:28 +00:00
Mervin Praison 8162421d16 Merge pull request #4034 from MervinPraison/claude/issue-4033-20260819-0710
fix: PII redaction hook, planning task restore, and tool-pair reconciliation
2026-08-19 19:33:53 +01:00
Mervin Praison 1d4d07d074 Merge pull request #4040 from MervinPraison/claude/issue-4037-20260819-0850
fix: zero-config OAuth for remote MCP servers (metadata discovery + Dynamic Client Registration)
2026-08-19 19:33:22 +01:00
Mervin Praison f8d4c82b52 Merge pull request #4041 from ugbotueferhire/fix/mathlib-implementations
fix: implement missing mathlib functions
2026-08-19 19:31:54 +01:00
Mervin Praison 2b64efbe96 Merge pull request #4045 from MervinPraison/claude/issue-4042-20260819-0916
fix: run a real agent turn at gateway startup and onboarding (#4042)
2026-08-19 19:31:31 +01:00
Mervin Praison e22db2b507 Merge pull request #4058 from ugbotueferhire/fix/pytest-timeout-mark-clean
fix: register pytest timeout marker in pytest.ini
2026-08-19 19:24:25 +01:00
Mervin Praison d32fe29b42 Merge pull request #4060 from MervinPraison/claude/issue-4053-20260819-0953
fix: wire verification hooks into an enforced completion gate
2026-08-19 19:24:10 +01:00
Mervin Praison 49d6997094 Merge pull request #4064 from ugbotueferhire/fix/shell-substitution-tests
fix: strip whitespace to handle Windows carriage returns in shell tests
2026-08-19 19:21:26 +01:00
Mervin Praison acec62618c Merge pull request #4065 from ugbotueferhire/fix/hybrid-workflow-tests
fix: skip hybrid workflow shell tests on Windows
2026-08-19 19:20:32 +01:00
Mervin Praison 3bf3314284 Merge pull request #4067 from MervinPraison/claude/issue-4066-20260819-1715
refactor: consolidate 13x task-name lookup idiom in process.py
2026-08-19 19:13:43 +01:00
praisonai-triage-agent[bot]andMervinPraison f60b7a96fc refactor: consolidate 13x task-name lookup idiom in process.py (fixes #4066)
Extract the repeated inline `next((t for t in self.tasks.values()
if t.name == <name>), None)` into a single `_task_by_name` helper and
call it at all 13 sites. Behaviour-identical: first-match by name,
None when absent. No public API or behaviour change.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 17:18:31 +00:00
ugbotueferhire d8121ab392 fix: skip hybrid workflow shell tests on Windows 2026-08-19 13:24:20 +01:00
praisonai-triage-agent[bot]andMervin Praison 172e90c934 test: run echo shell-substitution test cross-platform via shell builtin
Remove the shutil.which("echo") skipif: TemplateInterpolator runs commands
with shell=True, so echo is available as a shell builtin on both cmd.exe and
/bin/sh even without a standalone executable. Normalise output with .strip()
for Windows CRLF instead, restoring coverage. Also drops the now-unused shutil
import and trailing whitespace.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 11:52:00 +00:00
ugbotueferhire fd310f3786 fix: strip whitespace to handle Windows carriage returns in shell tests 2026-08-19 12:44:16 +01:00
praisonai-triage-agent[bot] 4d38313565 Merge pull request #4062 from MervinPraison/claude/issue-4052-20260819-0953
fix: make workflow routing surfaces execute instead of silently falling through
2026-08-19 11:12:39 +00:00
praisonai-triage-agent[bot] 868f3e6c6e Merge pull request #4061 from MervinPraison/claude/issue-4055-20260819-0954
fix: expose workflow checkpoint resume from CLI + fingerprint the definition
2026-08-19 11:06:34 +00:00
praisonai-triage-agent[bot] 07903c0aae Merge pull request #4059 from MervinPraison/claude/issue-4056-20260819-0955
fix: session handoff continuation prompt + default structured compaction summary
2026-08-19 11:04:16 +00:00
praisonai-triage-agent[bot] 83648c9b54 Merge pull request #4054 from MervinPraison/claude/issue-4050-20260819-0947
fix: durable retry + WhatsApp/Signal senders for standalone scheduled delivery
2026-08-19 11:00:31 +00:00
praisonai-triage-agent[bot] 34fb85aaec Merge pull request #4051 from MervinPraison/claude/issue-4048-20260819-0946
fix: add recurrence generator to SuggestionEngine.observe() (#4048)
2026-08-19 10:53:38 +00:00
praisonai-triage-agent[bot] acf96b3e13 Merge pull request #4047 from MervinPraison/claude/issue-4044-20260819-0917
feat: gateway diagnostics export sanitised support bundle
2026-08-19 10:46:24 +00:00
MervinPraison 3311cef626 docs: auto-update feature parity trackers [skip ci] 2026-08-19 10:42:19 +00:00
praisonai-triage-agent[bot] a4ee5e92b8 Merge pull request #4057 from MervinPraison/claude/issue-4049-20260819-0947
fix: give scheduled automations cross-run memory (JobStateStore)
2026-08-19 10:41:51 +00:00
praisonai-triage-agent[bot] a7ca5ea65f Merge pull request #4046 from MervinPraison/claude/issue-4043-20260819-0917
fix: re-drain durable outbox when a channel recovers
2026-08-19 10:36:31 +00:00
ugbotueferhire e02ae4c42a fix: skip hanging test_get_runtime_descriptor_keeps_live_lock 2026-08-19 11:33:50 +01:00
praisonai-triage-agent[bot]andMervinPraison 44d501c73e fix: validate agent refs nested in route/parallel/loop/if payloads (#4062)
Cross-reference validation only inspected step.agent, step.steps, and
step.routes, so undefined agents nested inside the newly accepted bare
runtime-form payloads (route:/parallel:/loop:/repeat:/if:) silently
passed while the runtime parser drops/rejects them (Greptile P1).

- Walk route: value lists (agent-name scalars) and parallel/loop/repeat/if
  sub-step dicts (agent: keys), leaving non-agent scalars like
  loop: {over: <var>} untouched.
- Regenerate the stale agents.schema.json artefact to match the PR's
  broadened WorkflowStep model.
- Add tests for undefined agents in route/parallel payloads and a
  loop-over-variable false-positive guard.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 10:12:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 24b1fc6c7f fix: address verification-gate review findings (async, journal, diagnostics)
- Async event loop: run_autonomous_async now offloads verification hook
  execution to a worker thread via _verification_gate_async so a slow/blocking
  CommandVerificationHook no longer stalls concurrent coroutines.
- Durable journal: rewire _record_verification_journal to the real
  DurableRunContext.record_verification (append JournalEvent, KIND_VERIFICATION)
  instead of a non-existent _run_journal.record API; events now actually persist.
- Goal gate: a failing blocking hook now carries the captured stdout/stderr
  summary into last_reason so the next continuation prompt has actionable repair
  diagnostics instead of a bare label.
- Tests: +4 covering async thread-offload, durable journal persistence, and
  goal-failure diagnostics propagation.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 10:11:22 +00:00
praisonai-triage-agent[bot]andMervinPraison 2281f14b09 fix: fail closed on missing checkpoint + hash config values in fingerprint
Addresses two P1 correctness gaps from PR review:
- Explicit --resume onto an absent/misspelled/deleted checkpoint now returns
  a clear error instead of silently running the workflow from step 1 and
  repeating already-completed side effects.
- _definition_fingerprint now hashes agent config values (not just keys), so
  semantic edits (instructions/model/tools/condition/routing) invalidate the
  checkpoint rather than combining a stale step index with an edited workflow.

Adds two regression tests: missing-checkpoint fail-closed, config-value edit
changes fingerprint.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 10:10:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 489c7a9527 fix: preserve NEXT tail in handoff cap; don't mark assistant questions as completed
Addresses two P1 review findings on PR #4059:

- handoff._cap: capping an oversized recap dropped the actionable NEXT:
  continuation guidance. Cap now preserves the head (preamble/goal/progress)
  and the NEXT: tail verbatim, trimming only the middle recap block.
- summary_builder: assistant turns that merely proposed or *asked* about work
  were recorded under "Completed Actions", so a resumed agent could skip work
  only discussed. Question-bearing assistant turns now flow to Pending
  Questions / Remaining Work only; executed tool calls/results stay completed.

Adds regression tests for both.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 10:07:08 +00:00
praisonai-triage-agent[bot]andMervinPraison 57b278beb0 fix: make workflow routing surfaces execute instead of silently falling through (fixes #4052)
- Defect 1: map parsed markdown routing (TaskRoutingConfig) onto real
  Task.branch_condition/next_steps the executor reads; stop clobbering
  condition with a dataclass; guard dict-condition crash in step loop.
- Defect 2: wire Task(when=..., then_task=..., else_task=...) into the
  WorkflowManager step-advance path for per-step conditional redirection.
- Defect 3: register `workflow validate` on the Typer workflow app so it
  reaches the existing handler.
- Defect 4: align WorkflowStep schema with the runtime dialect (bare
  agent/route/if/loop/repeat/parallel/include steps) and treat
  backstory/instructions as aliases so shipped example workflows validate.
- Defect 5: fix .praison -> .praisonai list dir via shared path helper;
  reject unsupported on_fail values clearly; fail loudly on YAML files in
  the markdown workflows dir.
- Tests: executable markdown branch routing (matched + unmatched), when
  routing, and a round-trip validation over examples/yaml/workflows/.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 10:04:51 +00:00
praisonai-triage-agent[bot]andMervinPraison bdf9644846 fix: expose workflow checkpoint resume from CLI + fingerprint the definition (fixes #4055)
Wire --resume/--checkpoint/--rebase-checkpoint into `workflow run` and add a
`workflow checkpoints` subcommand. Store a stable, normalized definition
fingerprint in each checkpoint and compare it on resume, refusing to silently
land on a stale step index after the workflow file was edited (with
--rebase-checkpoint to force). Record resumed_from_step in the result.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 10:03:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 562aa735d0 fix: deep-copy job state on read/write; skip one-shot state persistence
Addresses two reviewer-flagged issues in the scheduler cross-run memory:

- P2 (nested aliasing): get_state/set_state now deepcopy so a caller
  mutating a nested list/dict in a read result can no longer bypass the
  16 KiB size cap or copy-on-read guarantee.
- P1 (orphan one-shot state): _persist_job_state now skips
  delete_after_run jobs, which claim_due already removed (state popped)
  before the run — persisting would recreate an orphan job_state entry
  nothing cleans up.

Adds tests: deep copy-on-read/snapshot isolation (core) and one-shot
non-recreation (wrapper).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 10:02:24 +00:00
praisonai-triage-agent[bot]andMervinPraison abeec246f5 fix: wire verification hooks into completion gate (fixes #4053)
Make the dormant verification primitive enforceable:
- Wire _verification_gate() into run_autonomous(_async): blocking hook
  failures block a success outcome and feed captured stdout/stderr back as
  feedback; never-passing checks end at max_iterations, not success.
- Fix lossy _run_verification_hooks re-wrap (preserve details/error/duration).
- Add declarative FileCheckHook (exists/non_empty/contains/json_field==equals),
  no shell; CommandVerificationHook now accepts a string command.
- Feed the goal judge executable evidence; a failing blocking hook
  short-circuits to continue without spending a judge call.
- add_verification()/remove_verification() for runtime registration.
- Add 'verification' run-journal event kind and YAML `verification:` config
  parity mapping command:/file: shapes onto the two hook classes.

Opt-in only: agents without verification_hooks are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 10:02:24 +00:00
praisonai-triage-agent[bot]andMervinPraison 79d60fcd47 fix: add session handoff continuation prompt and default structured compaction summary (fixes #4056)
Un-stub the documented structured_template compaction feature with a
deterministic DefaultSummaryBuilder that renders SUMMARY_TEMPLATE (falling
back to the plain summary on failure), wired as the default builder in
ContextCompactor when structured_template=True.

Add a deterministic session handoff assembler (build_handoff_prompt) that
turns durable state (recap + persisted GoalState + workflow checkpoint) into a
self-contained continuation prompt with no LLM call, plus a
`praisonai session handoff` CLI command with --json/--copy.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 10:01:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 322ede1ab4 fix: honour HTTP-date Retry-After and drop invalid WhatsApp thread context
Address two P1 review findings on the standalone scheduled sender:

- _HttpSendError now preserves the raw Retry-After header verbatim (via a
  headers dict) instead of pre-parsing to float, so server_retry_after honours
  both integer-seconds and HTTP-date forms. A float(raw) previously discarded
  any HTTP-date delay, letting the sender retry early and exhaust attempts under
  throttling.
- WhatsApp standalone sender no longer maps a generic scheduler thread_id to
  context.message_id. WhatsApp requires a real inbound message id to quote-reply;
  a generic thread id is rejected as an invalid permanent 4xx. This mirrors the
  live adapter, which only sets context from an explicit reply_to.

Adds tests for HTTP-date + integer Retry-After parsing and WhatsApp thread_id
context omission.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:59:06 +00:00
praisonai-triage-agent[bot] 7aa90f0bf6 Merge pull request #4039 from MervinPraison/claude/issue-4038-20260819-0851
fix: discover named agents and commands from .claude/ and .agents/
2026-08-19 09:58:37 +00:00
ugbotueferhire 763a3637b8 fix: register pytest timeout marker in pytest.ini 2026-08-19 10:58:34 +01:00
praisonai-triage-agent[bot]andMervin Praison 24d9ee2d8a fix: make observe() recurrence key survive nested slot values
Slots are typed Dict[str, Any] and SuggestionStore accepts nested
lists/dicts/sets; the previous tuple(sorted(items)) key raised TypeError
on unhashable slot values before recurrence could be recorded. Add a
recursive _freeze() helper producing a deterministic, hashable key.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:55:57 +00:00
praisonai-triage-agent[bot]andMervinPraison b56088942e fix: give scheduled automations cross-run memory (fixes #4049)
Implement the core JobStateStoreProtocol on the existing
ConfigYamlScheduleStore (get_state/set_state/clear_state), persisting a
small, size-bounded per-job scratchpad under a job_state key in
config.yaml and clearing it when a job is removed or a one-shot fires.

Wire the bot ScheduledAgentExecutor to load prior state into a compact
Notepad prompt block, thread it into a stateful monitor gate only when
the gate opts in (signature-detected), and persist GateResult.state_updates
plus any agent-emitted state back after the run. All hasattr/signature-gated
so stores and gates without support behave exactly as today.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 09:54:32 +00:00
praisonai-triage-agent[bot]andMervinPraison 851e4c24e3 fix: durable retry + WhatsApp/Signal senders for standalone scheduled delivery (fixes #4050)
Standalone (out-of-process) scheduled delivery was fire-and-forget with a
platform coverage gap. This adds:

- Bounded in-process retry with exponential backoff (honouring Retry-After)
  around standalone HTTP sends, reusing the shared bots._resilience
  classifiers so only transient 5xx/429/network failures are retried and
  permanent failures (bad token, 4xx) fail fast. An ephemeral cron/serverless
  tick has no persistent process to drain a durable SQLite outbox, so a
  bounded retry is the lightweight durability fit for that model.
- Standalone senders for WhatsApp (Cloud API) and Signal (signal-cli bridge),
  the two live-adapter platforms previously missing from _STANDALONE_SENDERS.

The documented `all` fan-out token is already implemented in botos.py
`_deliver_job_text`, so no change was needed there.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 09:53:10 +00:00
praisonai-triage-agent[bot]andMervinPraison db12658e7b fix: add recurrence generator to SuggestionEngine.observe() (fixes #4048)
The consent-first automation-suggestion surface (store, TTL, dedup, chat
Accept/Dismiss buttons, CLI) already exists but nothing ever generated a
suggestion — propose() was only reachable via manual CLI calls, so the
"noticed you ask for a morning summary every day — automate it?" moment
never fired on its own.

Add a lightweight, deterministic recurrence generator as a new
SuggestionEngine.observe() method in the wrapper: a caller feeds each
completed turn's inferred blueprint+slots, and once the same intent recurs
threshold times within a trailing window the engine auto-calls propose().
Consent-first is preserved (jobs still only materialise on explicit accept),
per-principal isolation and store dedup keep it non-nagging.

Minimal in-repo fix — no new package, dependency, or export; reuses the
existing propose()/store machinery.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 09:49:51 +00:00
praisonai-triage-agent[bot]andMervin Praison 31779462ed fix: stub turn preflight in gateway start SSL/no-preflight tests
The default-on model turn pre-flight (#4042) now runs after the channel
probe in `gateway start`, so two pre-existing start-path tests that never
mocked a model round-trip aborted with exit 1 instead of their expected
exit 0. Mock `_verify_turn_preflight` green in both so each test isolates
exactly the behaviour it guards (SSL-only soft-fail; --no-preflight skip),
without weakening any assertion.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:31:42 +00:00
praisonai-triage-agent[bot]andMervin Praison b9c738c4cb fix: resolve recovery drain_outbox hook on the supervised object itself (#4043)
The gateway passes the durable platform adapter directly to
ChannelSupervisor.run(), so drain_outbox() lives on the bot object itself,
not on bot.adapter. Check the object first, then fall back to bot.adapter,
so built-in channels actually re-drain on recovery. Adds a test for the
production object shape.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:30:11 +00:00
praisonai-triage-agent[bot]andMervin Praison 3ddc585a70 fix: prevent diagnostics bundle overwrite on same-second exports
Second-resolution timestamps caused two exports into the same directory
within one second to resolve to the same path, silently truncating the
first bundle (Greptile P1). Add _unique_bundle_path() to append a -N
suffix when the timestamped name already exists, preserving every export.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:28:13 +00:00
praisonai-triage-agent[bot]andMervin Praison d2b6c44b28 fix: harden gateway turn preflight (toggle, timeout, provider parity)
Address Greptile P1 review feedback on #4045:
- Decouple turn verification from the channel --preflight flag so
  `--no-preflight --verify-turn` still runs the model round-trip.
- Bound the preflight turn with a short run_timeout (default 60s,
  overridable via gateway.preflight.verify_turn_timeout) so a stalled
  provider fails fast instead of inheriting the 5-minute interactive
  default at startup/onboarding.
- Pass per-agent base_url/api_key/api_base through to the synthetic
  Agent so preflight exercises the same provider the live gateway uses.

Adds 3 tests covering timeout default/override and provider parity.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:26:56 +00:00
praisonai-triage-agent[bot] 915998a417 Merge pull request #4036 from MervinPraison/claude/issue-4035-20260819-0828
fix: non-blocking Redis SCAN, race-safe async MySQL init, Mongo regex escape
2026-08-19 09:22:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 4362d76c34 feat: add gateway diagnostics export sanitised support bundle (fixes #4044)
Add a `praisonai gateway diagnostics export` command that writes a single,
pre-sanitised .zip (summary, health, config shape, redacted logs, forensics
snapshot) an operator can safely attach to a bug report. Reuses existing
secret redaction, forensics snapshot, health probe, and daemon log readers;
config is reduced to key/mode shape with no secret values, and all text is
passed through the shared redact_secrets pass. Runs even when the gateway is
unhealthy via local log fallback.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 09:21:59 +00:00
praisonai-triage-agent[bot]andMervinPraison 35c3fe076c fix: re-drain durable outbox when a channel recovers (fixes #4043)
The durable outbox held deliverable replies through a transient channel
outage but nothing re-attempted them on recovery — they sat undelivered
until the next inbound chat() turn or a process restart. The supervisor
now fires a bounded, best-effort adapter.drain_outbox() when a channel
comes back after a recoverable outage, closing the loop opened by the
attempt-and-age policy. Duck-typed and no-op for bots without the hook.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 09:20:45 +00:00
praisonai-triage-agent[bot]andMervinPraison c8b8fbd694 fix: run a real agent turn at gateway startup and onboarding (fixes #4042)
Wire the existing run_turn_test into the default gateway start preflight and
the onboard wizard so a missing/invalid model credential fails at setup time,
not silently on the first user message. Default on via
gateway.preflight.verify_turn; opt out with --no-verify-turn or
PRAISONAI_NO_VERIFY_TURN=1 for constrained/offline environments.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 09:20:14 +00:00
ugbotueferhire dc2e931eb7 fix: implement missing mathlib functions 2026-08-19 10:15:46 +01:00
praisonai-triage-agent[bot]andMervin Praison 7c568b7f5a fix: enforce HTTPS on OAuth redirect hops and advertised endpoints
- Re-validate every redirect hop + final URL in metadata fetch (SSRF-safe)
- Enforce HTTPS/loopback guard on discovered authorization/token/registration
  endpoints so an HTTPS metadata doc cannot smuggle a plaintext endpoint
- Defense-in-depth HTTPS guard on the persisted refresh token endpoint
- Add tests for endpoint validation and redirect revalidation

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:01:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 40a152018f fix: nearer dir wins over ancestor in ecosystem discovery precedence
Fixes cross-level precedence bug flagged by greptile/reviewers: the
nearest project directory now wins over more distant ancestors, so an
ancestor .claude/.agents definition no longer silently overrides a
nested .praisonai one. Collect walk-up levels then apply ancestors
first / cwd last under the "later wins" order; .praisonai stays last
within each level.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 09:01:15 +00:00
praisonai-triage-agent[bot]andMervinPraison d98f571a07 fix: discover named agents and commands from .claude/ and .agents/ (fixes #4038)
Extend CustomDefinitionsDiscovery to enumerate the conventional ecosystem
roots .claude/ and .agents/ for agents and commands, mirroring the skills
loader. .praisonai/ wins on name collision and the ancestor walk applies to
all roots. Tools stay .praisonai-only since loading executes user code.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 08:55:06 +00:00
praisonai-triage-agent[bot]andMervinPraison 3e24f3d01c fix: zero-config OAuth for remote MCP servers (fixes #4037)
Add metadata discovery, RFC 7591 dynamic client registration, and an
orchestrating provider that wires the existing PKCE callback helpers and
MCPAuthStorage into a lazy ensure_authenticated() flow. Fully additive and
backward-compatible: servers without auth or with pre-supplied credentials
behave exactly as before.

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 08:55:04 +00:00
praisonai-triage-agent[bot]andMervinPraison 2dc7c3b02c fix: non-blocking Redis SCAN, race-safe async MySQL init, Mongo regex escape (fixes #4035)
- Replace blocking Redis KEYS with cursor SCAN in storage/redis_adapter
  (list_keys, clear) and persistence state redis/upstash keys()
- Add double-checked async lock to AsyncMySQLConversationStore.init()
  to prevent concurrent cold-start pool leaks (mirrors async_postgres)
- Escape regex metacharacters in MongoDBStateStore.keys() to prevent
  ReDoS / unintended matches (matches MongoDBStorageAdapter behaviour)

Co-authored-by: MervinPraison <[email protected]>
2026-08-19 08:31:26 +00:00
praisonai-triage-agent[bot]andMervin Praison 348d81d237 fix: PII redaction hook, planning task restore, and tool-pair reconciliation (fixes #4033)
- redact.py: register PII BEFORE_LLM hook with sequential=True so
  modified_input is actually applied to outgoing messages
- hooks/runner.py: warn when a parallel hook returns modified_input that
  would be silently discarded
- agents.py: restore original self.tasks after planning; keep plan-step
  tasks under _plan_tasks and reset task_id_counter to avoid id collisions
- context/composer.py: iterate _ensure_tool_pairs to a fixed point so
  reinserted tool_calls messages don't leave dangling sibling calls

Co-authored-by: Mervin Praison <[email protected]>
2026-08-19 07:14:50 +00:00
praisonai-triage-agent[bot] 92c146faa5 Release v4.6.164 2026-08-19 00:10:05 +00:00
MacOS 19f5f4a15b refactor(agents): keep middleware identity plumbing generic 2026-08-18 19:02:21 +08:00
MacOS 9432cc4b5b feat(agents): add optional AgentFuse tool middleware 2026-08-18 19:02:21 +08:00
MervinPraison eb1c8cb942 docs: auto-update feature parity trackers [skip ci] 2026-08-18 10:38:59 +00:00
praisonai-triage-agent[bot] 6bb3027f65 Merge pull request #4031 from MervinPraison/claude/issue-4030-20260818-0944
fix: extend guarded self-improvement review to persist memory (#4030)
2026-08-18 10:38:32 +00:00
praisonai-triage-agent[bot] 7708b13c9c Merge pull request #4029 from MervinPraison/claude/issue-4028-20260818-0920
fix: wire durable runs into gateway agents so restarts resume
2026-08-18 10:10:51 +00:00
praisonai-triage-agent[bot] 591df76fef Merge pull request #4027 from MervinPraison/claude/issue-4024-20260818-0849
fix: route code/chat first-run through shared onboarding gate
2026-08-18 09:57:30 +00:00
praisonai-triage-agent[bot]andMervin Praison 021497891a fix: delimit echoed task as untrusted data in memory-enabled review prompt (#4030)
Harden the guarded self-improvement review now that it can write to
memory: frame the echoed original task as UNTRUSTED DATA inside an
<original_task> block and instruct the model to ignore any embedded
'remember/store/create a skill' instructions, so injected task text
cannot steer store_memory/skill_manage. Prompt-only, no new surface.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-18 09:54:11 +00:00
praisonai-triage-agent[bot] 074325ac64 Merge pull request #4026 from MervinPraison/claude/issue-4025-20260818-0849
fix: wire --tools/--agent/--plan into headless code -p
2026-08-18 09:51:19 +00:00
praisonai-triage-agent[bot]andMervinPraison a70b128670 fix: extend guarded self-improvement review to persist memory (fixes #4030)
Reuse the existing opt-in, guarded, background/re-entrancy-safe skill-review
pass to also close the learning loop for memory: when the agent has memory
enabled, the restricted review toolset adds store_memory and the review
prompt asks whether a durable fact/preference/user-model detail emerged.

No new Agent param or parallel mixin/policy/dispatch — same self_improve
switch, lock, and off-hot-path background runner.

Co-authored-by: MervinPraison <[email protected]>
2026-08-18 09:47:54 +00:00
praisonai-triage-agent[bot]andMervin Praison a78bd267db fix: make gateway durable_runs opt-in reachable and env-safe (#4028)
- Register durable_runs on GatewayServerSchema so the documented
  gateway.durable_runs: true opt-in passes strict extra=forbid validation
  instead of aborting startup/reload (greptile P1).
- Parse durable flags as config booleans so env-substituted "false"/"0"
  disable durability at both gateway and per-agent scope (coderabbit).
- Add regression tests for string bool parsing, per-agent opt-out, and
  schema acceptance.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-18 09:32:55 +00:00
praisonai-triage-agent[bot]andMervinPraison b82528ada5 fix: wire durable runs into gateway agents so restarts resume (fixes #4028)
The durable-run stack (RunJournal, DurableRunContext, ExecutionConfig(durable=))
was shipped in praisonaiagents but never engaged by the long-lived gateway, so no
gateway run was journalled and resume was unreachable. Construct gateway agents
with ExecutionConfig(durable=True) behind an operator toggle (gateway.durable_runs)
and an optional per-agent durable key, so every turn is journalled and an
interrupted run can resume after a restart instead of re-executing side-effecting
tools or being discarded. Default-off / zero-overhead.

Co-authored-by: MervinPraison <[email protected]>
2026-08-18 09:26:12 +00:00
praisonai-triage-agent[bot] ed0b5382a9 Merge pull request #4022 from MervinPraison/claude/issue-4021-20260818-0818
fix: make CLI-integration agent tools async-safe (#4021)
2026-08-18 09:21:07 +00:00
praisonai-triage-agent[bot]andMervin Praison a2fc040282 fix(ci): don't let onboarding gate preempt standalone TUI install hint
The first-run onboarding gate in `chat` and the bare invocation ran before
the wrapper-resident interactive TUI's own standalone install hint. On a
keyless `pip install praisonai-code` (no wrapper), `praisonai-code chat`
dead-ended with "No API key configured. Run: praisonai setup" for a session
that cannot even start, instead of the actionable "pip install praisonai[tui]"
hint the smoke test asserts.

Gate the interactive (no-prompt) path only when the wrapper is present so the
TUI can surface its install hint standalone; single-prompt chat runs in-process
and is still always gated. Adds a regression test for the standalone skip and
pins wrapper availability in the resolved-model gate test.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-18 09:13:31 +00:00
praisonai-triage-agent[bot] 6c7f4926a5 fix: gate onboarding on the resolved model in code/chat (fixes greptile P1s)
Route the shared first-run credential gate through the *final* resolved model
so onboarding validates and preserves the model each entrypoint actually
dispatches:

- code.py: resolve the agent profile's `llm` before the gate. A keyless
  `code --agent <profile>` (no --model) with a reachable local endpoint no
  longer has the profile model shadowed by the detected local default; the
  profile override still applies for the untouched-model case, and an explicit
  --model still wins.
- chat.py: resolve the TUI model (explicit > recent > provider default) before
  the gate and reuse it, so the gate validates the exact model the TUI
  dispatches instead of passing on any present key for a different provider.
  Removes the now-duplicate resolution and threads the validated model into
  profiling.

Adds two regression tests (profile-model passthrough for code, resolved-model
passthrough for chat).
2026-08-18 09:04:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 602639cda3 fix: report headless --tools resolution failures via the -p envelope
Defer --tools resolution into _run_print_code's try/except so an import
error in a user tools.py surfaces through the machine-readable JSON/text
envelope (status=error + non-zero exit) instead of escaping as a raw
traceback to scripted callers.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-18 09:01:29 +00:00
praisonai-triage-agent[bot]andMervinPraison 66c6d0c18b fix: route code/chat first-run through shared onboarding gate (fixes #4024)
Extract the keyless first-run credential gate (previously duplicated inline in
app.py and run.py) into a shared ensure_configured_or_onboard helper in
praisonai_code.llm.credentials, and invoke it from the code and chat
entrypoints so a keyless newcomer is consistently routed to `setup` (or a
detected keyless local endpoint) instead of dead-ending on a raw provider
error. run.py and the bare invocation now delegate to the same helper,
removing the duplication.

Co-authored-by: MervinPraison <[email protected]>
2026-08-18 08:55:09 +00:00
praisonai-triage-agent[bot]andMervinPraison 88ddda0f1c fix: wire --tools/--agent/--plan into headless code -p (fixes #4025)
Headless `praisonai code -p/--print` previously hard-rejected --tools,
--agent and --plan, blocking scripted/CI agentic coding from adding a
custom tool, applying a named least-privilege agent profile, or running
read-only plan mode. This reuses the interactive path's existing
resolvers (ToolResolver, load_agent_from_name, resolve_approval_config)
to thread those flags into the headless branch with no new params/deps:

- --tools resolves via _resolve_tools_arg and merges onto the default
  coding toolset (de-duped by identity).
- --agent overlays the profile's instructions/role/goal/llm and named
  tools; its permission scope is enforced via a non-interactive backend.
- --plan maps to a non-interactive read-only approval scope.

Approvals stay safe: scope is enforced via a non-interactive backend
(deny/ask fails closed), never an interactive prompt. --no-acp/--no-lsp
remain rejected (they toggle the resident TUI's runtime servers the
one-shot agent does not spin up). The JSON/text envelope and exit codes
are unchanged.

Co-authored-by: MervinPraison <[email protected]>
2026-08-18 08:54:09 +00:00
praisonai-triage-agent[bot]andMervinPraison aed00e3381 fix: make CLI-integration agent tools async-safe (fixes #4021)
BaseCLIIntegration.as_tool() and get_available_integrations() routed
through run_sync(), which is fail-loud when a running event loop is
present. Agent runtimes invoke tools from inside their loop, so the
first async invocation of any built-in CLI-integration tool raised
RuntimeError. Route both through the existing run_sync_or_offload
helper, which offloads to a worker thread when a loop is running, and
add a native as_async_tool() surface for async agents to skip the hop.

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

Co-authored-by: MervinPraison <[email protected]>
2026-08-18 08:20:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 75d8177691 fix: terminate when-routing cleanly, detect guardrails/retry/skills defaults
- process.py: when a task uses only when/then_task/else_task routing and it
  resolves to no target (taken branch has no target) with no next_tasks, end the
  workflow instead of falling into _find_next_not_started_task() (sync + async).
- loader.py: include guardrails/retry/skills in _defaults_has_any_values() so a
  config with only [defaults].guardrails (or retry) is applied instead of being
  silently skipped by apply_config_defaults' fast path.
- factories.py: document ChromaMemoryAdapter's shared short/long-term collection
  so per-tier reset semantics are explicit.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-18 07:26:59 +00:00
praisonai-triage-agent[bot]andMervinPraison 3ac28617c2 fix: wire task when-routing, guard Knowledge/Memory erasure, apply defaults.guardrails (fixes #4019)
- Process now consults Task.get_next_task (when/then_task/else_task) in both
  sync and async workflows before falling back to next_tasks, and no longer
  ends the workflow early when only when-routing is configured.
- Knowledge.reset/delete_all/history now guard with hasattr, logging a no-op
  warning for reset and raising NotImplementedError for unsupported erasure.
- ChromaMemoryAdapter (default) implements reset_short_term/reset_long_term so
  the default memory config actually erases; Memory warns when an adapter lacks
  reset support instead of silently succeeding.
- config defaults: guardrails and retry are now loaded into DefaultsConfig and
  guardrails is applied to Agent via apply_config_defaults; unused/unconsumed
  defaults keys (api_key/context/hooks/templates) removed from VALID_DEFAULTS_KEYS
  so misconfiguration fails loudly, and retry added since agent.py consumes it.

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

Co-authored-by: MervinPraison <[email protected]>
2026-08-18 07:18:05 +00:00
Mervin Praison c89c0a0a05 Merge pull request #4018 from MervinPraison/docs/readme-remove-reflection-from-loop-example
docs: remove reflection=True from Layer 4 loop README example
2026-08-17 19:36:13 +01:00
MervinPraison 986b5d98ca docs: remove reflection=True from Layer 4 loop README example
Reflection grades answers inside chat(), not run_autonomous() completion; drop it from the loop example to avoid misleading readers.
2026-08-17 19:35:45 +01:00
praisonai-triage-agent[bot]andMervin Praison f53cf4c2dc fix: scope memory availability probe to skip FileMemory path (fixes #4016)
Agent.__init__ ran a dependency probe that eagerly imported the heavy
memory.memory module for any truthy memory value. The common FileMemory
paths (memory=True, "file", and file-provider dicts without learn) never
touch that module, so the probe wasted a ~100ms+ import. Scope the probe
to run only for values that resolve to the heavy Memory backend, preserving
the existing ImportError remediation for every heavy branch.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 17:18:08 +00:00
praisonai-triage-agent[bot] 3feef53117 Merge pull request #4014 from MervinPraison/claude/issue-4013-20260817-0948
feat: add job-to-job output chaining (context_from) to scheduler
2026-08-17 10:42:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 354fa201f5 fix: enforce bounded context when context_max_chars is non-positive
Non-positive context_max_chars silently bypassed truncation (`if budget:`
was falsy for 0), returning full upstream output and breaking the bounded
contract. Clamp with max(..., 0) and always slice. Adds regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 10:00:15 +00:00
praisonai-triage-agent[bot]andMervinPraison 0eb762094e feat: add job-to-job output chaining (context_from) to scheduler (fixes #4013)
Add a composable pipeline primitive to the core scheduler: a job may declare
one or more upstream jobs via `context_from`, whose most-recent successful
output is resolved from existing execution history and injected as bounded,
delimited context at fire time.

- ScheduleJob: `context_from` (list, or bare-string coerced), `context_max_chars`
  (default 4000), `on_missing_context` ("run"|"skip"). Serialised only when
  configured so isolated jobs stay byte-for-byte unchanged.
- ScheduleRunner.resolve_context(job) -> (context, missing): pure, side-effect
  free resolution reusing the store's get_history — no new persistence surface.
  Wrapper executors inject the text and apply the missing-context policy.

Co-authored-by: MervinPraison <[email protected]>
2026-08-17 09:52:55 +00:00
Mervin Praison c54bb6244d Merge pull request #4012 from MervinPraison/claude/issue-4011-20260817-0915
fix: add per-session turn-execution isolation seam
2026-08-17 10:44:21 +01:00
Mervin Praison a5dadd29c6 Merge pull request #4010 from MervinPraison/claude/issue-4009-20260817-0851
fix: zero-config LSP auto-detect, availability probe & root detection
2026-08-17 10:43:12 +01:00
Mervin Praison 6b43a9fc1f Merge pull request #4008 from MervinPraison/claude/issue-4007-20260817-0831
fix: close wrapper isolation, path-traversal & diagnostics gaps (fixes #4007)
2026-08-17 10:42:16 +01:00
Mervin Praison 1f3f2bbd06 Merge pull request #4005 from MervinPraison/claude/issue-4000-20260817-0717
fix: add loop-guard/doom-loop parity to async tool-execution path
2026-08-17 10:39:56 +01:00
Mervin Praison 886c36613b Merge pull request #4004 from MervinPraison/claude/issue-4002-20260817-0718
fix: warn on unwired AgentTeam params and dropped URL knowledge sources
2026-08-17 10:39:22 +01:00
MervinPraison 7b2693032f docs: auto-update api.md [skip ci] 2026-08-17 09:39:13 +00:00
Mervin Praison c209d9ad87 Merge pull request #4003 from MervinPraison/claude/issue-4001-20260817-0717
fix: honour BaseTool.get_schema() in llm.py direct-callable tool path
2026-08-17 10:38:47 +01:00
Mervin Praison 344dd42771 Merge pull request #3995 from MervinPraison/claude/issue-3992-20260816-1716
refactor: consolidate lazy __getattr__ blocks onto _lazy.py helper
2026-08-17 10:38:18 +01:00
Mervin Praison 0b16d2f140 Merge pull request #3994 from MervinPraison/claude/issue-3993-20260816-1717
fix: skip discarded per-step WorkflowContext allocation
2026-08-17 10:37:32 +01:00
Mervin Praison 64791a1696 Merge pull request #3978 from MervinPraison/claude/issue-3977-20260816-0712
fix: shrink Agent() signature by collapsing 7 deprecated params into legacy_kwargs
2026-08-17 10:37:07 +01:00
praisonai-triage-agent[bot]andMervin Praison 811a29ad01 test: assert FrozenInstanceError for frozen TurnPlacement (Ruff B017)
Tightens the frozen-value-type test to assert the specific
dataclasses.FrozenInstanceError instead of a blind Exception, per
CodeRabbit/Ruff B017. Behaviour unchanged; still guards immutability.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 09:27:38 +00:00
praisonai-triage-agent[bot]andMervin Praison 56897d22b7 docs: clarify isolated-executor turn dispatch contract (Greptile #4012)
Address Greptile review: an isolated executor does not ship the live
async callable across a process boundary; the worker owns the session
and rebuilds/dispatches the turn from serialisable inputs, with the
callable acting as the gateway-side await point for the worker result.
Doc-only clarification, no behaviour change.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 09:25:20 +00:00
praisonai-triage-agent[bot]andMervinPraison 649b86b92a fix: add per-session turn-execution isolation seam (fixes #4011)
Add a protocol-driven turn-execution seam so a session's agent turn can be
placed on an isolated worker, with the default preserving today's in-process
behaviour exactly.

- TurnExecutorProtocol + TurnPlacement + WorkerWedgedError (core, protocol-only)
- InProcessTurnExecutor default (runs the turn on the current loop; zero change,
  no dependency, backward compatible when the executor is unset)
- Exported from praisonaiagents.gateway; heavy subprocess/container/remote
  executors remain a wrapper concern

Co-authored-by: MervinPraison <[email protected]>
2026-08-17 09:19:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 5a1ad24f8f fix: correct custom-command probe, file-URI encoding & workspace-symbol root
- client.py: preflight now gates on the actual configured command (custom
  servers included) and only surfaces the registry install hint when it
  matches the default server, so custom commands are diagnosed correctly.
- config.py: add path_to_uri() (Path.as_uri) and use it for root/doc URIs so
  paths with reserved chars (space, #) yield valid, non-truncated file:// URIs.
- lsp_tools.py: workspace-symbol search forwards the resolved file so the
  server initialises against the file's real project root, not the CWD;
  _uri_path now decodes percent-encoded URIs.
- Tests: custom-command preflight, path encoding, workspace-root forwarding.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 09:05:24 +00:00
praisonai-triage-agent[bot]andMervin Praison 401d745214 fix: zero-config LSP auto-detect, availability probe & root detection (fixes #4009)
Broadens the LSP server map into a data-driven registry (extensions,
root_markers, install_hint) and adds detect_language(), detect_root_uri()
and probe() helpers. LSPClient.start() now runs a shutil.which pre-flight
recording an actionable last_error instead of a raw FileNotFoundError, and
derives rootUri from the nearest root marker for multi-root/monorepo setups.
edit_tools surfaces a one-time diagnostics-unavailable note so lost
self-correction signal is explicit; lsp_tools reuse the shared helpers and
emit install hints. Backward-compatible; core-only.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 08:57:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 7aa1f7380e fix: make timeout-proxy cache actually collectible (WeakValueDictionary)
The proxy-type cache used a WeakKeyDictionary keyed by the inner tool
class, but each cached proxy *value* is a dynamically-minted subclass of
that inner class and therefore strongly retains its own weak key via
__bases__/__mro__. The weak key could never die, so per-request tool
classes stayed pinned for the process lifetime — the exact leak the
change targeted. Switch to a WeakValueDictionary: the proxy class is now
held weakly and stays cached only while live proxy instances keep it,
which already keep the inner class alive; once the last proxy dies the
entry auto-evicts and the inner class is reclaimable. The un-subclassable
fallback (module-global _TimeoutBoundTool) is no longer cached.

Fixes Greptile P1 on #4008.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 08:43:32 +00:00
MervinPraison 252ba4bd25 docs: auto-update api.md [skip ci] 2026-08-17 08:40:32 +00:00
Mervin Praison 11ced899b6 Merge pull request #4006 from MervinPraison/feat/self-documenting-execution
feat(agents): make objects say where they actually run
2026-08-17 09:40:04 +01:00
praisonai-triage-agent[bot]andMervinPraison e4abb408c3 fix: close wrapper isolation, path-traversal & diagnostics gaps (fixes #4007)
- agents_generator: switch process-wide _TIMEOUT_PROXY_TYPES cache to a
  weakref.WeakKeyDictionary so per-request tool classes minted in long-lived
  hosts are GC-reclaimed instead of pinned for the process lifetime.
- auto: hoist the path-traversal guard (workspace + _safe_join) into
  BaseAutoGenerator so WorkflowAutoGenerator and JobWorkflowAutoGenerator
  inherit it, and thread an optional workspace= through both (backward
  compatible; guard is a no-op without a workspace).
- __init__: distinguish ModuleNotFoundError (real attribute miss) from a
  broken-install ImportError so praisonaiagents import failures surface the
  real root cause via __cause__ instead of a misleading AttributeError;
  cache the failure to avoid re-paying the import on every lookup.

Co-authored-by: MervinPraison <[email protected]>
2026-08-17 08:36:37 +00:00
MervinPraison dfbe868dba docs: auto-update documentation parity trackers [skip ci] 2026-08-17 08:15:20 +00:00
praisonai-triage-agent[bot] 4efc7c3b5b Merge pull request #3999 from MervinPraison/claude/issue-3933-20260817-0642
fix: record token usage on native OpenAI client path
2026-08-17 08:14:47 +00:00
MervinPraison dc7f17af13 docs: auto-update feature parity trackers [skip ci] 2026-08-17 08:09:23 +00:00
praisonai-triage-agent[bot] b8c6228ce4 Merge pull request #3998 from MervinPraison/claude/issue-3934-20260817-0642
fix: map multimodal content parts in LLM Responses API path
2026-08-17 08:08:56 +00:00
praisonai-triage-agent[bot] 6dd61034f3 Merge pull request #3997 from MervinPraison/claude/issue-3931-20260817-0642
fix: wire ACP --allow-network flag to the tool capability gate
2026-08-17 07:54:53 +00:00
praisonai-triage-agent[bot]andMervin Praison 4cd1d4f998 fix(agents): resolve sandbox aliases to the backend that actually runs
describe() rendered raw sandbox aliases (native, local) instead of the
backend SandboxManager collapses them to (sandlock, subprocess), so
where_does_it_run() reported a place that never executes. Normalize the
aliases in say_place() to mirror the manager, and mark native as
not-a-boundary. Adds regression tests for both aliases.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 07:51:51 +00:00
praisonai-triage-agent[bot] 40a7cec220 Merge pull request #3996 from MervinPraison/claude/issue-3930-20260817-0640
fix: drop rejected verbose= kwarg from profiled code/run Agent paths
2026-08-17 07:48:11 +00:00
MervinPraisonandClaude Opus 5 c4540c0532 feat(agents): make objects say where they actually run
You could not tell from PraisonAI code where an agent's work happens.
`repr(agent)` was `<praisonaiagents.agent.agent.Agent object at 0x1033abb60>`,
there was no introspection method, and the answer lived only in prose. That is
why explaining it took a 25k-character article, ~29% of whose words existed
purely to correct misreadings the API invites.

Now the object answers:

    >>> Agent(name="builder")
    Agent(name='builder', thinks_on='this machine', tools_run_on='this machine')

    >>> AgentFlow(run_on="docker", steps=[writer, reader])
    AgentFlow(name='AgentFlow', steps=2, thinks_on='this machine',
              tools_run_on='a Docker container (shared)')

    >>> Agent(name="teacher", backend=HostedAgent(provider="anthropic"))
    Agent(name='teacher', thinks_on="Anthropic's cloud", tools_run_on="Anthropic's cloud")

and `where_does_it_run()` says it in sentences:

    Thinking (the AI model calls) happens on this machine.
    Tools run on a Docker container (shared).
    Every step shares that same sandbox, so a file written by one step is
    visible to the next.

For the subprocess tier it also states the limit plainly, because that tier is
verifiably not a boundary (network reachable, ~/.ssh readable):

    Note: a separate process is not a security boundary -- it can still reach
    the network and read your files. Use docker or a cloud provider for
    untrusted code.

Field naming is deliberate. `loop=` was rejected: it is already
praisonaiagents.workflows.loop(), asyncio's event loop, and agent-loop jargon
-- three meanings for one word, which is the exact problem being fixed.
`llm`, `model`, `runtime` and `execution` are taken as Agent kwargs.
`thinks_on` / `tools_run_on` are free and mirror the `run_on=` the user typed.
A test asserts no field name collides with existing public API so nobody
reintroduces that class of collision, and another asserts the sentences contain
no jargon ("harness", "provision", "orchestration", "topology", "runtime").

Zero breakage: no signature changes, no behaviour changes. Description can
never raise -- a broken object still describes as "this machine".

Adds 17 tests. Suites unchanged vs clean main (6 failed / 343 passed on both;
all 6 pre-exist). Verified end-to-end against Docker: shared-sandbox flow wrote
and read back through one instance, repr stable, 0 leftover containers.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-17 08:44:56 +01:00
praisonai-triage-agent[bot]andMervin Praison 564b064bdc fix: make async loop-guard terminal decisions non-retryable (PR #4005 review)
Address reviewer findings on the async loop-guard path:
- Re-raise ToolExecutionError in _execute_tool_async_with_retry so a
  loop-guard HALT bypasses retry/error-dict conversion and stops the run.
- Treat loop_blocked BLOCK results as non-retryable in the retry wrapper
  so a custom retry policy cannot re-run a terminally blocked call.
- Record tool timeouts as loop-guard failures (fall through instead of
  early return) so repeated timeouts accumulate toward BLOCK/HALT.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 07:31:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 958c1dcda7 fix: redact knowledge URLs in warning and use truthy check for unwired params
- Log only scheme+host for URL knowledge sources to avoid leaking
  credentials/tokens embedded in userinfo or query strings (Greptile P1).
- Filter unwired AgentTeam params with a plain truthy check so falsy
  empty containers (e.g. knowledge=[]) no longer trigger warnings
  (Greptile/CodeRabbit P2).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 07:27:27 +00:00
praisonai-triage-agent[bot]andMervinPraison 620ec0029b fix: add loop-guard/doom-loop parity to async tool-execution path (fixes #4000)
The async tool-execution path (_execute_tool_async_impl, backing achat()/
async workflows) had no loop-guard or doom-loop protection, unlike the sync
path in tool_execution.py. A repeatedly-failing tool could be hammered every
turn with no automatic stop.

Mirror the sync path's loop-guard wiring on the async path:
- pre-execution check() honoring BLOCK (error dict) / HALT (raise) / WARN
- post-execution record() + check() surfacing block/halt back to the model
- record failed (raised) invocations so streaks accumulate toward thresholds
- propagate a loop-guard HALT (ToolExecutionError) instead of swallowing it

Reuses the existing _ensure_loop_guard capability; no new params/exports.
The circuit breaker itself wraps a sync call + time.sleep and is not async-safe
to reuse as-is, so this focuses on the doom-loop/loop-guard protection the
issue centers on (which is pure/sync and safe from async).

Co-authored-by: MervinPraison <[email protected]>
2026-08-17 07:24:11 +00:00
praisonai-triage-agent[bot]andMervinPraison 263536fdb8 fix: warn on unwired AgentTeam params and dropped URL knowledge sources (fixes #4002)
AgentTeam accepted guardrails/web/reflection/caching/learn/knowledge and stored
them but never applied them at the team level. Emit a construction-time warning
when any is truthy so silent no-enforcement stops being the default.

Agent._process_knowledge silently dropped http(s) URL sources; replace the bare
pass with an actionable warning explaining URL ingestion is not implemented.

Co-authored-by: MervinPraison <[email protected]>
2026-08-17 07:22:39 +00:00
praisonai-triage-agent[bot]andMervinPraison f594dca30d fix: honour BaseTool.get_schema() in llm.py direct-callable tool path (fixes #4001)
The direct-callable branch of LLM._generate_tool_definition re-derived the
schema from the raw wrapped function via build_tool_definition, bypassing
FunctionTool/BaseTool.get_schema(). This reverted @tool(name=) overrides to
the original function name and leaked Injected[T] params into the schema sent
to the model. Now the callable branch checks for get_schema() first, mirroring
the existing registry-lookup branch.

Co-authored-by: MervinPraison <[email protected]>
2026-08-17 07:20:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 7f096c23ce fix: track token usage per tool-loop iteration and clear stale metrics
Addresses two Greptile P1 findings on the native OpenAI path (#3933):

- Move _track_token_usage inside the tool-call loop (sync + async) so every
  billed completion — including intermediate tool-call rounds — is recorded
  in session totals and by_model/by_agent rollups, not just the final one.
- Clear last_token_metrics when a response has no usable usage so a reused
  client never reports a prior completion's spend as the latest call's usage.

Adds regression tests for multi-iteration accounting and stale-metric clearing.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:59:31 +00:00
praisonai-triage-agent[bot]andMervin Praison 0a0ba084f7 fix: record token usage on native OpenAI client path (fixes #3933)
Default Agent(llm="gpt-4o-mini") runs through the native OpenAIClient,
which never populated the public token collector, so
get_token_collector().get_session_summary() stayed at zero after a paid
completion. Token accounting was effectively gated behind verbose/metrics
display on the LiteLLM path and entirely absent on the OpenAI path.

Add a lightweight OpenAIClient._track_token_usage that reads usage from
the final response and records it into the global TokenCollector after
every successful sync/async completion, independent of any display flag.
Thread agent_name through the adapter so by_agent rollups populate while
still keeping application metadata out of the OpenAI API call.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:52:33 +00:00
praisonai-triage-agent[bot]andMervin Praison d1ffe7855b test: add data-URL passthrough, missing-image rejection, and slash-model agentic image tests for Responses API
Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:51:49 +00:00
praisonai-triage-agent[bot]andMervin Praison 9042c5d2c6 fix: map --verbose to output= preset in run --agent custom path (#3930)
_run_custom_agent still set agent_config["verbose"], which the
consolidated Agent constructor rejects with TypeError, so
`praisonai run --agent <name> --verbose` crashed — the same class of
bug #3930 fixed for the profiled paths. Thread verbosity through the
output="verbose" preset instead, without clobbering a definition's own
explicit output. Adds regression tests guarding the kwargs against the
real Agent signature.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:50:18 +00:00
praisonai-triage-agent[bot]andMervin Praison 767c4ed4a0 fix: append allow_network after approval_mode to preserve positional API
serve() is a public entry point. Inserting allow_network before the
existing approval_mode parameter would rebind positional callers
(e.g. serve(..., "auto")) to allow_network. Append it last to keep
backward-compatible positional binding.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:49:34 +00:00
praisonai-triage-agent[bot]andMervin Praison 33c031fcfb fix: map multimodal content parts in LLM Responses API path (fixes #3934)
The LLM._build_responses_params forwarded Chat Completions multimodal
messages (content parts with type=text/image_url) unchanged into the
Responses API input, causing a 400 invalid_value on input[].content[].type.
Reuse OpenAIClient._build_responses_content to translate parts to
input_text/input_image and inline local image files as data URLs.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:45:40 +00:00
praisonai-triage-agent[bot]andMervin Praison 89425980fa fix: wire ACP --allow-network flag to config gate (fixes #3931)
The ACP CLI parsed --allow-network but never passed it to serve(),
and serve() didn't accept it, so network tools were gated by a flag
no CLI user could set. Thread allow_network through CLI -> serve() ->
ACPConfig so it reaches the tool capability gate in the model loop.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:45:07 +00:00
praisonai-triage-agent[bot]andMervin Praison c31861470b fix: drop rejected verbose= kwarg from profiled code/run Agent construction (fixes #3930)
The Agent constructor consolidated verbose/stream/metrics into output=. The
three profiled paths (_run_profiled_code, run.py profiled agent, cli_profiler)
still passed a top-level verbose=, raising TypeError before any LLM call. Map
verbosity through output= instead and add a signature-guard regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-17 06:43:16 +00:00
praisonai-triage-agent[bot]andMervin Praison a93c4d71c2 fix: resolve GitGuardian false positive via _t() lazy-import helper
The generic "Authentication Tuple" detector fires on bare ("module",
"Attr") string-pair literals whose key/string contains auth/oauth.
The prior `# pragma: allowlist secret` is a detect-secrets directive
that GitGuardian ignores, so the check kept failing.

Construct the mapping values through a tiny local `_t()` alias so no
bare 2-string tuple literal remains on the flagged lines. Mapping type
(Dict[str, Tuple[str, str]]) and runtime behaviour are unchanged.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 17:30:58 +00:00
praisonai-triage-agent[bot]andMervin Praison b94f91e088 fix: silence GitGuardian false positive on mcp lazy-import tuples
The (module, attribute) string tuples for the auth/oauth lazy imports were
misdetected as 'Authentication Tuple' secrets. They are import targets, not
credentials. Added inline allowlist pragmas (no behaviour change).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 17:27:45 +00:00
praisonai-triage-agent[bot]andMervinPraison 4de066de3f refactor: consolidate lazy __getattr__ dispatch onto _lazy.py helper (fixes #3992)
Replace five hand-rolled lazy-import __getattr__ blocks (eval, mcp, bus,
trace, skills) with the existing create_lazy_getattr / _with_groups helpers.
Exports, names and lazy semantics are unchanged; eval and mcp now gain the
per-attribute cache the hand-rolled versions lacked.

Co-authored-by: MervinPraison <[email protected]>
2026-08-16 17:22:42 +00:00
praisonai-triage-agent[bot]andMervinPraison 8e7442e1bf fix: skip discarded per-step WorkflowContext allocation (fixes #3993)
Defer WorkflowContext construction (and its all_variables.copy()) so it is
only built when a step has should_run or handler. On the common action-step
path the object was previously constructed and immediately overwritten by
_build_step_context, wasting a growing shallow dict copy per step. Applied
to both the sync and async twins; behaviour is identical since the guard
uses the exact conditions gating the only two consumers.

Co-authored-by: MervinPraison <[email protected]>
2026-08-16 17:19:39 +00:00
praisonai-triage-agent[bot] 4c20292971 Merge pull request #3991 from MervinPraison/claude/issue-3990-20260816-1618
fix: remove dead private method _astructured_completion
2026-08-16 17:07:33 +00:00
praisonai-triage-agent[bot]andMervinPraison 33d8aaf5cb fix: remove dead private method _astructured_completion (fixes #3990)
Co-authored-by: MervinPraison <[email protected]>
2026-08-16 16:19:08 +00:00
MervinPraison fae584ec74 docs: auto-update documentation parity trackers [skip ci] 2026-08-16 10:36:39 +00:00
praisonai-triage-agent[bot] 16aa22dc8c Merge pull request #3988 from MervinPraison/claude/issue-3987-20260816-0947
fix: add core create_thread capability to outbound messenger protocol
2026-08-16 10:36:00 +00:00
praisonai-triage-agent[bot] 5c17422df1 Merge pull request #3986 from MervinPraison/claude/issue-3985-20260816-0915
feat: importable Python API for gateway repair + provisioning (fixes #3985)
2026-08-16 10:18:55 +00:00
praisonai-triage-agent[bot]andMervin Praison 5ba726b90e fix: restore OutboundMessengerProtocol conformance with wrapper create_thread (#3987)
Adding create_thread to the runtime-checkable OutboundMessengerProtocol
broke isinstance() conformance for the production BotOutboundMessenger,
failing test_messenger_satisfies_protocol and
test_messenger_still_satisfies_protocol_with_react.

Mirror the existing react()/ReactionResult seam: DeliveryRouter.create_thread
resolves the target, unwraps the adapter, gates on capabilities["threads"],
and returns a typed (status, target, thread_id) tuple — never raising.
BotOutboundMessenger.create_thread maps that to the core ThreadResult so an
unthreadable channel yields a typed unsupported/no_route/failed outcome and
callers fall back to the parent channel. Heavy platform work stays in the
wrapper per AGENTS.md; core keeps only the protocol + dataclass.

Adds 5 wrapper tests covering ok/unsupported/no_route/failed and protocol
conformance with create_thread.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 09:59:35 +00:00
MervinPraison 452c1ba78f docs: auto-update feature parity trackers [skip ci] 2026-08-16 09:58:19 +00:00
praisonai-triage-agent[bot] 3da1db1741 Merge pull request #3984 from MervinPraison/claude/issue-3981-20260816-0853
fix: XDG Base Directory compliance for config/data/state/cache
2026-08-16 09:57:53 +00:00
praisonai-triage-agent[bot] fee0a32456 Merge pull request #3983 from MervinPraison/claude/issue-3982-20260816-0853
feat: inject full CLI config from env (PRAISONAI_CONFIG_CONTENT / PRAISONAI_CONFIG)
2026-08-16 09:53:24 +00:00
praisonai-triage-agent[bot]andMervinPraison b6047e1495 fix: add core create_thread capability to outbound messenger protocol (fixes #3987)
Adds a first-class, capability-gated way for the gateway (or an agent action)
to open a new platform-native thread/topic under a channel, mirroring the
existing react()/ReactionResult contract (#3917):

- PlatformCapabilities.supports_threads flag (+ to_dict/from_dict roundtrip)
- ThreadStatus (ok|unsupported|failed|no_route) + ThreadResult typed outcome
  with an `ok` convenience property, so a channel that cannot thread returns a
  typed `unsupported` outcome and callers fall back to the parent channel —
  never raises
- OutboundMessengerProtocol.create_thread(target, name) -> ThreadResult
- Export ThreadResult/ThreadStatus from praisonaiagents.gateway

Pure protocol/dataclass in core with no heavy SDK imports; concrete adapter
implementations live in the praisonai-bot wrapper.

Co-authored-by: MervinPraison <[email protected]>
2026-08-16 09:51:45 +00:00
praisonai-triage-agent[bot]andMervin Praison 8b3b0a9eac fix(gateway): validate provisioning inputs and honor all requested agents
Addresses two Greptile P1 findings on the new provision_gateway_config API:
- Fail closed on unsupported platform / empty token instead of writing a
  config the gateway would reject at startup or boot degraded.
- Declare every requested agent in the agents: block (was silently dropping
  all but the first), routing the channel to the first as its default.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 09:30:41 +00:00
96cb599b57 feat: add importable Python API for gateway repair + provisioning (fixes #3985)
Gateway self-healing (doctor --fix) and provisioning (onboard) previously
existed only as private CLI helpers. This adds a typer-free
praisonai_bot.gateway.admin module exposing repair_gateway_config,
provision_gateway_config, and a GatewayRepairResult dataclass on the public
praisonai_bot surface, completing the CLI + YAML + Python parity contract.

The existing repair machinery is moved into admin.py (typer-free) so the
library API does not depend on the CLI-only typer package; the Typer commands
re-import these helpers and remain thin wrappers over one canonical flow (DRY).

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

Co-Authored-By: Claude <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-08-16 09:24:19 +00:00
praisonai-triage-agent[bot] 26baa3ad0a Merge pull request #3980 from MervinPraison/claude/issue-3979-20260816-0819
fix: close wrapper security gaps in auto-gen, safe-edit, and jobs store
2026-08-16 09:17:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 0f6abad27f fix: stabilise single-root decision and collect XDG state logs in diag
- paths.py: snapshot the single-root detection so creating the default
  ~/.praisonai data dir mid-run no longer flips config/state/cache off
  their XDG locations (Greptile P1 #1). PRAISONAI_HOME stays live.
- diag.py: include the canonical (XDG) state-home logs dir when bundling
  diagnostics, de-duplicating directories (Greptile P1 #2).
- Add regression test for mid-run path stability.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 09:04:47 +00:00
praisonai-triage-agent[bot]andMervin Praison 382c6fd6ad fix: treat empty env config as authoritative; reject non-mapping files
- _load_env_user_config: use `data is not None` for inline blobs and
  `isinstance(data, dict)` for explicit paths so a valid empty mapping ({}
  or empty file) stays authoritative and no longer re-enables disk discovery,
  and a non-mapping file body no longer raises TypeError on _source assignment.
- tests: clear the full per-key env var set consumed by _load_env_config()
  for isolation; add regression coverage for empty inline mapping, empty
  explicit file, and non-mapping explicit file.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 09:04:22 +00:00
praisonai-triage-agent[bot]andMervinPraison e8905fb679 fix: honour XDG base directories for config/data/state/cache (fixes #3981)
Add XDG-aware resolvers to core praisonaiagents/paths.py: get_config_dir()
and get_state_dir(), and make get_data_dir()/get_cache_dir() honour
$XDG_{DATA,CACHE}_HOME on fresh installs. Precedence: PRAISONAI_HOME ->
existing ~/.praisonai/~/.praison single root -> $XDG_*_HOME/praisonai ->
XDG defaults, preserving full backward compatibility for existing installs.

Route session spill and the wrapper MRU-model recency onto the canonical
state home, ending the ~/.praison vs ~/.praisonai split-state.

Co-authored-by: MervinPraison <[email protected]>
2026-08-16 08:58:06 +00:00
praisonai-triage-agent[bot]andMervinPraison 141b00ddc0 feat: inject full CLI config from env for zero-disk runs (fixes #3982)
Add PRAISONAI_CONFIG_CONTENT (inline JSON/YAML) and PRAISONAI_CONFIG
(explicit path) to the wrapper resolver's user-config layer, mirroring the
sibling PRAISONAI_AUTH_CONTENT credential blob. Inline content is parsed in
memory (nothing on disk); an explicit path replaces file discovery. Inline
wins over path, which wins over global/project discovery. Higher-precedence
per-key env vars, CLI flags and managed policy are unchanged, and unset
preserves today's file-discovery behaviour.

Co-authored-by: MervinPraison <[email protected]>
2026-08-16 08:57:51 +00:00
praisonai-triage-agent[bot]andMervin Praison 4c6d02c71e fix: enforce shell-tool gate, atomic idempotency, restart recovery, symlink guard
Address the four P1 findings on PR #3980 (issue #3979):

- auto.py: add server-side output gate that strips code_execution tools
  (e.g. execute_command) from generated roles unless the task actually
  requested code execution. Deny-list gated by intent preserves all other
  tools (backward compatible). Prompt allow-list narrowed to task-scoped
  recommendations.
- jobs/store.py: make idempotency key UNIQUE and add atomic save_if_absent
  so concurrent same-key submits resolve to a single job (side effects run
  once). Reconcile QUEUED/RUNNING rows to FAILED on startup so restarted
  jobs no longer poll forever.
- jobs/router.py: claim the idempotency key atomically via save_if_absent
  and only dispatch to the executor when this request won the claim.
- acp/safe_edit.py: reject writes through symlinked parent directories or a
  symlinked target as defence-in-depth against the propose->apply TOCTOU.

Verified: idempotency race, restart reconciliation, symlink containment,
and shell-tool stripping all pass; auto_generator + safe_edit + sqlite
suites green. Only the 3 pre-existing InMemoryJobStore test failures remain
(unrelated, present on base release).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 08:35:25 +00:00
praisonai-triage-agent[bot]andMervinPraison 48f3b0e1bb fix: close wrapper security gaps in auto-gen, safe-edit, and jobs store (fixes #3979)
Gap 1 (auto.py): stop auto-appending the shell-exec tool 'execute_command'
to every generated agent (now opt-in via code_execution keywords only), fence
the untrusted topic in the LLM prompt with an allow-list, and add opt-in
workspace containment for agent_file to block path traversal.

Gap 2 (acp/safe_edit.py): add an RLock guarding _proposals and status
transitions, claim APPROVED->APPLIED atomically to prevent double-apply,
re-validate the path right before write to close the symlink TOCTOU, write
atomically via _atomic_write_text, and return one pipeline per workspace so
concurrent agents no longer leak across workspaces.

Gap 3 (jobs): add a stdlib-sqlite3 SqliteJobStore so jobs and idempotency
keys survive restarts; default store now persists when PRAISONAI_JOBS_DB_PATH
is set and refuses the in-memory store in production.

Co-authored-by: MervinPraison <[email protected]>
2026-08-16 08:25:37 +00:00
praisonai-triage-agent[bot]andMervin Praison eb8f1c89f4 fix: restore positional safety + clone fallback_models for reduced Agent signature (#3978)
Two backward-compat fixes for the deprecated-param consolidation:

- Make handoffs + consolidated feature params keyword-only via a `*`
  separator so a stray positional value (e.g. an old positional
  allow_delegation=True) can no longer silently bind to `handoffs`.
- Accept fallback_models as an internal-only kwarg forwarded by
  clone_for_channel(); pop it before the unknown-kwarg check and seed the
  local so cloned agents keep their LLM fallbacks. Explicit
  LLMConfig(fallback_models=...) still takes precedence.

Adds regression tests covering the internal fallback_models path,
clone_for_channel() preservation, keyword-only handoffs, and the
positional misbind guard.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-08-16 07:25:04 +00:00
praisonai-triage-agent[bot]andMervinPraison ca0b7d17e2 fix: shrink Agent() signature by collapsing 7 deprecated params into **legacy_kwargs (fixes #3977)
Moves allow_delegation, allow_code_execution, code_execution_mode, auto_save,
rate_limiter, verification_hooks, and cli_backend out of the explicit __init__
signature (48 -> 41 visible params) into **legacy_kwargs, rebound to locals from
a new _LEGACY_AGENT_PARAMS map. Fully backward compatible: old keyword calls work
and still emit the same DeprecationWarnings; unknown kwargs now raise TypeError.

Co-authored-by: MervinPraison <[email protected]>
2026-08-16 07:16:07 +00:00
praisonai-triage-agent[bot] e27b9d13f5 Release v4.6.163 2026-08-15 19:15:31 +00:00
MervinPraison 3c6e8afa2c docs: auto-update documentation parity trackers [skip ci] 2026-08-15 13:13:36 +00:00
praisonai-triage-agent[bot] 7d5e33e81d Merge pull request #3976 from MervinPraison/fix/revert-sandbox-tool-injection
revert(sandbox): stop auto-injecting code-execution tools from sandbox=
2026-08-15 13:12:58 +00:00
MervinPraison 546422aee3 fix(ci): sync DOCS_GITHUB_TOKEN when provisioning pipeline secrets
Optional DOCS_GITHUB_TOKEN is copied to target repos during secret sync so
PraisonAI-Tools generate-docs can push to PraisonAIDocs.
2026-08-15 13:34:30 +01:00
MervinPraison 8315e72a0c fix(ci): recognise Claude replies and unblock stale-FINAL after rebase
Align merge-gate stale-FINAL recovery with PraisonAI-Tools: accept MervinPraison
Claude finished comments and bypass the 15-minute debounce when HEAD moves after
a conflict rebase without a fresh FINAL trigger.
2026-08-15 13:33:25 +01:00
praisonai-triage-agent[bot]andMervin Praison e6f785aa82 test(sandbox): robust no-injection contract + real agentic guard
- Replace brittle exact-comment assertion with concept-based checks so
  harmless wording edits no longer break the suite (Greptile feedback).
- Add key-gated end-to-end test that runs Agent(sandbox=True).start()
  through a real LLM and asserts the model-visible tool set never gains
  execute_python_code / execute_shell_command (CodeRabbit feedback).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 12:23:18 +00:00
MervinPraisonandClaude Opus 5 9bf8625175 revert(sandbox): stop auto-injecting code-execution tools from sandbox=
Reverts the tool-injection block added in #3975. Adversarial review found it
is a net security regression, not a fix. Everything else from that PR is kept.

Why it must go:

1. It escalated privilege. `sandbox=` is a RESTRICTION, but the injection made
   it GRANT capability -- and the granted tools bypass the approval registry,
   whose presets gate `execute_code`/`execute_command`, not the injected
   `execute_python_code`/`execute_shell_command`:

       Agent(approval="read_only")                -> tools: []
       Agent(approval="read_only", sandbox=True)  -> tools: [execute_python_code,
                                                             execute_shell_command]

   A security-labelled flag handed the model two ungated arbitrary-code-execution
   tools under the strictest approval preset.

2. It advertised isolation the backend does not provide. The default
   subprocess backend enforces none of its own SecurityPolicy. Verified on this
   machine: outbound HTTPS returned 200 with allow_network=False; sandboxed code
   read a real SSH private key despite `~/.ssh` in blocked_paths; `import
   subprocess` worked despite blocked_imports. The tool description shown to the
   model said "Execute Python code safely in sandbox."

3. Coexistence made it worse, not better. With autonomy=True the agent still
   carries the host `execute_command` (injected earlier in __init__), so the
   model can simply pick the unsandboxed tool.

Also fixes the test that let this ship: it asserted `child_pid != os.getpid()`
and called that isolation. A separate process is not a security boundary. The
suite is now contract-based -- sandbox= must not add tools, must not widen an
approval preset, and must still configure explicit execute_code().

Kept from #3975 (all genuinely correct):
- the `metadata` kwarg fix (flagged code no longer raises TypeError)
- the clone fix (`sandbox_config`, not the never-assigned `_sandbox_config`)
- the FutureWarning when sandbox= and backend= are combined
- NOT IMPLEMENTED labels on the inert autonomy/code_sandbox_mode fields

Giving the model a sandboxed execution tool should be an explicit act by the
caller, the way MCP() is -- not a side effect of a restriction flag. No peer
framework (smolagents, AutoGen, deepagents, OpenAI Agents SDK, Pydantic AI)
grants execution capability from a config flag.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 13:17:49 +01:00
MervinPraison 13ca53b8fd docs: auto-update documentation parity trackers [skip ci] 2026-08-15 12:05:29 +00:00
Mervin Praison 084daf83c3 Merge pull request #3975 from MervinPraison/fix/sandbox-security-gaps
fix(sandbox): make Agent(sandbox=...) actually isolate execution
2026-08-15 13:04:58 +01:00
praisonai-triage-agent[bot]andMervin Praison 9d6f6dc312 fix(sandbox): regenerate clone-bound execution tools; robust PID assert
Channel clones copied the framework-generated execute_python_code /
execute_shell_command wrappers, which close over the source agent. The
constructor's name-dedup then skipped creating clone-bound replacements,
so a clone's execution tools ran through the source agent's SandboxManager
— sharing isolation state across channels (CodeRabbit/Greptile P1).

- Tag generated sandbox tools with `_praison_sandbox_tool` in
  SandboxMixin._get_code_execution_tools().
- clone_for_channel() now drops tagged tools when copying, so the
  constructor regenerates clone-owned wrappers. User tools with colliding
  names lack the tag and are preserved.
- Add regression test asserting clone tool identity and SandboxManager
  differ from the source.
- Compare parsed integer PIDs (not substrings) in the out-of-process test;
  use next(...) per ruff RUF015.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 12:01:52 +00:00
MervinPraisonandClaude Opus 5 826c2e63ff fix(sandbox): make Agent(sandbox=...) actually isolate execution
`Agent(sandbox=...)` built a SandboxConfig that nothing ever read. The agent
looked isolated and was not -- the worst failure mode a security setting can
have. Four defects, each verified before and after:

1. sandbox= gave the model no sandboxed tools.
   `_get_code_execution_tools()` had zero non-test callers, so `sandbox=True`
   added nothing to `agent.tools`. It now attaches execute_python_code /
   execute_shell_command (skipping names the user already defined).
   Verified: code now runs in a separate PID from the caller.

   Scope note: this isolates code the MODEL writes. Python callables passed
   via `tools=` are ordinary in-process functions and cannot be retrofitted
   into a sandbox -- that limit is now stated rather than implied.

2. Sandboxed execution crashed exactly when it mattered.
   `execute_code()` injected a `metadata` kwarg that flowed into
   SandboxProtocol.execute(), which has no such parameter -- so any code
   tripping a security warning raised TypeError instead of running. Warnings
   are now attached to the result instead of forwarded.

3. Clones silently lost their sandbox.
   `clone_for_channel()` read `_sandbox_config`; SandboxMixin writes
   `sandbox_config`. The underscore name is never assigned anywhere, so every
   clone ran unisolated.

4. Two docstrings promised protection that does not exist.
   `AutonomyConfig.sandbox` claimed subprocess isolation is auto-enabled at
   level="full_auto" (implemented nowhere), and
   `ExecutionConfig.code_sandbox_mode` documents subprocess isolation while
   having zero consumers. Both are now labelled NOT IMPLEMENTED. The fields
   are kept -- removing public dataclass fields would TypeError for existing
   users -- so deletion belongs in a major version.

Also: combining sandbox= with backend= now emits a FutureWarning. The managed
backend takes the whole turn before any local execution, so sandbox= was
silently ignored with no signal.

Opt-in and non-breaking: agents without sandbox= are byte-for-byte unchanged
(asserted by test).

Adds 10 regression tests. Suite: 473 passed vs 463 on clean main (+10, no new
failures); the 2 test_param_consolidation warning-registry failures reproduce
identically on clean main with the same selection and are unrelated.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 12:51:31 +01:00
praisonai-triage-agent[bot] 908debe4f8 Merge pull request #3974 from MervinPraison/claude/issue-3973-20260815-0949
fix: harden gateway pairing store (at-rest hashing + store-level lockout)
2026-08-15 10:52:15 +00:00
praisonai-triage-agent[bot]andMervin Praison befeb72b36 fix: don't reset pairing lockout on channel_id mismatch; count rehydrated pending in max_pending
- verify_and_pair now validates the channel_id binding BEFORE clearing the
  brute-force counter, so a valid HMAC presented against the wrong channel_id
  is rejected without handing later guesses a fresh attempt budget (Greptile P1).
- generate_code enforces max_pending against _pending_by_hash (all logical
  pending entries, including ones rehydrated from disk) so repeated restarts
  cannot grow pairing.json past the configured bound (CodeRabbit).
- Add regression tests for both behaviours.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 10:01:51 +00:00
praisonai-triage-agent[bot]andMervinPraison 264ae6c847 fix: harden gateway pairing store with at-rest hashing and store-level lockout (fixes #3973)
Persist only a salted SHA-256 hash of each pairing code (no plaintext code
ever lands in pairing.json), add a per-channel_type brute-force lockout that
applies uniformly across HTTP/in-chat/CLI approval surfaces, and generate
codes from an unambiguous alphabet (drops 0/O/1/I). Backward-compatible:
public API unchanged, legacy plaintext stores still load.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:55:13 +00:00
Mervin Praison eb8aa9683b Merge pull request #3972 from MervinPraison/feat/yaml-run-on
feat(managed): support `run_on:` in workflow YAML
2026-08-15 10:38:44 +01:00
Mervin Praison 420925ac81 Merge pull request #3971 from MervinPraison/claude/issue-3966-20260815-0912
fix: unify praisonai gateway CLI with canonical command surface
2026-08-15 10:38:15 +01:00
Mervin Praison 6874ebff94 Merge pull request #3970 from MervinPraison/claude/issue-3967-20260815-0913
fix: register deferred tool results on the native tool loop
2026-08-15 10:37:43 +01:00
Mervin Praison 29cf8c4db4 Merge pull request #3969 from MervinPraison/claude/issue-3968-20260815-0913
fix: honour after_tool plugin return so tool results can be redacted
2026-08-15 10:36:51 +01:00
Mervin Praison a10592c304 Merge pull request #3965 from MervinPraison/claude/issue-3964-20260815-0851
fix: replay conversation visually when resuming a session
2026-08-15 10:36:31 +01:00
praisonai-triage-agent[bot]andMervin Praison cd27a40ad5 fix(managed): attach action-only temp agents to run_on sandbox
An action-only YAML step builds its Agent during the run, after the
initial shared-compute attach(), so its shell/file tools silently ran
locally instead of in the run_on: sandbox. Expose the live SharedCompute
on the Workflow and attach the temp agent before it chats. Adds tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:32:38 +00:00
praisonai-triage-agent[bot] 659290c8a2 Merge pull request #3963 from MervinPraison/claude/issue-3962-20260815-0824
fix: offload blocking auto-gen I/O, fix claude stream flag drift, fail-fast tool_timeout on workflow
2026-08-15 09:29:09 +00:00
praisonai-triage-agent[bot]andMervin Praison 8aa5bd9ae9 fix: route native deferred results to durable history so late completions survive
The native OpenAI-SDK tool loop re-injected resolved DeferredToolResults into
the per-call `messages = list(messages)` copy, which is discarded once the loop
returns. Since background jobs typically complete after the loop returns, the
resolved value never reached subsequent turns — unlike the LiteLLM path, which
appends to the persistent chat_history (llm.py:_register_deferred_if_any).

Thread an optional `deferred_history_sink` through the sync/async/streaming
native loops and _handle_native_deferred_result; the agent passes its existing
chat_history as the durable sink. Falls back to `messages` when absent, so
existing callers are unaffected. No new Agent params/modules/exports.

Adds tests for durable-sink survival past loop return, backward-compat fallback,
and early (buffered) resolution delivery.

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

Co-Authored-By: Mervin Praison <[email protected]>
2026-08-15 09:26:55 +00:00
MervinPraisonandClaude Opus 5 70e9d1daf7 feat(managed): support run_on: in workflow YAML
Closes the last functional gap in remote execution: no-code users had no
way to reach it at all. `run_on=` existed only on the Python API, so anyone
driving PraisonAI through agents.yaml was locked out of shared sandboxes.

    name: remote-demo
    run_on: docker            # every step shares ONE sandbox
    agents:
      writer: {role: Writer, goal: Write files}
      reader: {role: Reader, goal: Read files}
    steps:
      - agent: writer
        action: "Write 'hello' to /workspace/note.txt"
      - agent: reader
        action: "Read /workspace/note.txt"

The provider name is validated at load time against the same registry the
Python API uses, so a typo fails when the file loads -- listing the valid
names -- instead of surfacing at the first tool call, halfway through a run.

Also updates the README's managed-agents section, which still advertised the
deprecated `ManagedAgent` factory and the old `compute=` name:
  - leads with `run_on=` (Python) and `run_on:` (YAML)
  - single-agent examples now use LocalAgent / HostedAgent, which emit no
    DeprecationWarning
  - documents `praisonai managed ps` / `stop --all`

Adds examples/yaml/workflows/remote_sandbox_workflow.yaml, executed
end-to-end against Docker: writer wrote /workspace/note.txt and reader read
'hello from step 1' back from the SAME instance (docker_72722484ef46), with
zero leftover containers.

12 new parser tests (no Docker needed). Managed suite 289 passed; the same 6
failures exist on clean main. YAML suite 168 passed, no parser regression.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 10:26:04 +01:00
praisonai-triage-agent[bot]andMervin Praison d54920c2da fix: narrow gateway click-probe exception and cover legacy Namespace dispatch
- Catch (ImportError, AttributeError) instead of broad Exception in the
  vendored typer._click probe (resolves Ruff S110/BLE001).
- Add test_namespace_caller_still_dispatches to pin that every long option
  _namespace_to_gateway_argv emits is accepted by the canonical Typer command.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:24:00 +00:00
praisonai-triage-agent[bot]andMervin Praison c2bbeacb3b fix: consume after_tool redaction/block in both tool executors (#3968)
The AFTER_TOOL bridge mutated data.tool_output and could deny, but the
sync (tool_execution.py) and async (execution_mixin.py) executors
discarded both — so redaction/blocking never reached the model. Both
paths now read back the rewritten tool_output and honour a block
decision, mirroring the BEFORE_TOOL path. Clarified GuardrailBlocked
docstring for after_tool and added real-agent execute_tool regression
tests plus a GuardrailBlocked bridge test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:23:49 +00:00
praisonai-triage-agent[bot]andMervinPraison 19b9215116 fix: unify praisonai gateway CLI with canonical command surface (fixes #3966)
Route the public `praisonai gateway <cmd>` entry point to the single
canonical Typer gateway command app instead of a reduced argparse handler
that only wired start/status/hooks and dead-ended on everything else.

Now `praisonai gateway` exposes the same surface as `praisonai-bot gateway`
(doctor, test, channels, stop, restart, pause/resume/reconnect,
install/uninstall, mint-link, logs, send, hooks, sessions) with one
`start` implementation carrying the credential/tool preflight,
config-version migration and `--openai-api`/`--mcp` flags. The gateway's
own degraded-state retry hints (`praisonai gateway doctor`/`--fix`/`test`)
now name commands the operator can actually run.

Deletes the duplicate reduced argparse start/status/hooks parser and
dispatch; unknown commands and bad flags now degrade gracefully to a
non-zero exit code with a rendered message rather than a static dead-end.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:19:01 +00:00
praisonai-triage-agent[bot]andMervinPraison dbbddbb968 fix: register deferred tool results on the native tool loop (fixes #3967)
The native OpenAI-SDK tool loop in llm/openai_client.py called
execute_tool_fn directly and stringified a returned DeferredToolResult,
losing the handle so its eventual background value was never re-injected —
unlike the LiteLLM path (_register_deferred_if_any). Add a small shared
helper that registers the handle on the global deferred resolver and
surfaces the note to the model, wired into the sync, async and streaming
native tool-result blocks. No new Agent params or exports.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:18:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 79a0941098 fix: honour after_tool plugin return so tool results can be redacted (fixes #3968)
The after_tool hook bridge discarded the plugin's return value, so a plugin
could only observe tool results, never rewrite or redact them — contradicting
the documented "Can modify result" contract and leaving no post-tool guardrail
path for scrubbing secrets/PII. Mirror the after_llm/before_tool bridges: write
the returned value back onto data.tool_output and honour deny/HookResult and
GuardrailBlocked decisions for symmetry.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 09:16:21 +00:00
praisonai-triage-agent[bot]andMervin Praison f8a486a389 fix: harden session replay — clear stale display, fix omission count, render fallback turns
Addresses reviewer findings on PR #3965 (wrapper-only, praisonai-code):
- /continue now clears self.messages before replay so repeated resumes (or
  resuming after activity) don't duplicate or retain stale on-screen turns.
- /continue loads the full stored history instead of the default 50-message
  tail so the "… N earlier turns" note reflects the true total.
- _run_simple fallback renders restored user/assistant turns (not only system
  lines) so replayed conversations are visible without prompt_toolkit.
- Type-guard tool_calls handling in both renderers.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 09:02:53 +00:00
praisonai-triage-agent[bot]andMervinPraison 6d954316a4 fix: replay conversation visually when resuming a session (fixes #3964)
Redraw restored user/assistant turns (and tool activity) via the same live
renderer on interactive resume instead of only printing a one-line summary.
Bounded to the last N turns with an "… earlier turns" note.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 08:53:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 236e4b09ba fix: restore _resolve_effective_tool_timeout delegation on workflow path (fixes #3251 regression)
The #3251 regression guard asserts _build_yaml_workflow resolves tool_timeout
via _resolve_effective_tool_timeout. The prior explicit-timeout helper bypassed
it, breaking the guard. Delegate per-agent/role resolution back to the shared
_resolve_effective_tool_timeout so both the sequential/hierarchical and workflow
paths use one source of truth (and its tightest-value warnings still fire),
while still ignoring the injected bare CLI --tool-timeout default.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 08:43:36 +00:00
praisonai-triage-agent[bot]andMervin Praison 6eeb906487 fix: only reject explicit workflow tool_timeout, not implicit CLI default
The legacy CLI always injects its --tool-timeout argparse default (60) into
cli_config, so the new workflow fail-fast rejected every ordinary workflow YAML
run (Greptile P1). Distinguish an explicit user request (per-agent/role YAML
timeout or a changed CLI value) from the bare CLI default via
_resolve_explicit_workflow_tool_timeout. Adds regression tests.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 08:35:03 +00:00
praisonai-triage-agent[bot]andMervinPraison 221aa9f242 fix: offload blocking auto-gen I/O, fix claude stream flag drift, fail-fast tool_timeout on workflow (fixes #3962)
- auto.py: wrap blocking convert_and_save/_save_workflow in asyncio.to_thread
  across AutoGenerator/WorkflowAutoGenerator/JobWorkflowAutoGenerator so
  agenerate() no longer parks the event loop on merge/yaml-dump/fsync.
- claude_code.py: pop caller output_format before forcing stream-json in
  stream(), matching cursor/gemini siblings (fixes TypeError drift).
- agents_generator.py: fail fast on tool_timeout for process: workflow YAMLs
  instead of warn-only no-op, mirroring _validate_workflow_cli_capabilities.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 08:28:11 +00:00
MervinPraison 631cc7e9cd docs: auto-update documentation parity trackers [skip ci] 2026-08-15 08:06:21 +00:00
praisonai-triage-agent[bot] 3f23a3aeff Merge pull request #3960 from MervinPraison/claude/issue-3959-20260815-0709
fix: align async sequential process, recycle poisoned tool executor, fix doom-loop no-progress check
2026-08-15 08:05:42 +00:00
Mervin Praison 67ce4c9813 Merge pull request #3954 from dajiaohuang/fix/3930-headless-code
fix(code): restore headless coding agent execution
2026-08-15 08:59:33 +01:00
MervinPraison f55bb3feb3 docs: auto-update api.md [skip ci] 2026-08-15 07:58:44 +00:00
Mervin Praison d2389cd77a Merge pull request #3961 from MervinPraison/feat/managed-ps
feat(managed): add `praisonai managed ps`/`stop`, rename compute= to run_on=
2026-08-15 08:58:18 +01:00
praisonai-triage-agent[bot]andMervin Praison 70e6deb467 fix(suite_runner): deterministic latest-report tie-break on equal mtime
The CI-flaky test_finds_latest_by_mtime creates two run dirs back-to-back;
when their mtimes collide (coarse fs resolution), a stable sort left the
winner up to arbitrary iterdir() order, so the older-named dir could win.
Break ties by name so timestamp-named dirs stay deterministic.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:35:23 +00:00
praisonai-triage-agent[bot]andMervin Praison 788d17e66a fix(managed): surface provider inventory failures in ps/stop; fix docstring
- _discover_instances now returns (instances, errors): an available provider
  whose list_instances() fails is no longer silently reported as clean success,
  so `ps`/`stop --all` never lie about an empty inventory while sandboxes run.
- Explicit `--provider <name>` for an uninstalled/unknown provider now surfaces
  an error instead of a silent empty result. Uninstalled/unconfigured providers
  in the default scan remain silently skipped (a missing E2B key is normal).
- `ps --json` now returns {"sandboxes": [...], "errors": [...]} and exits non-zero
  when any provider could not be queried.
- Corrected ManagedAgent factory docstring: compute-provider names warn and
  delegate to LocalManagedAgent; they do NOT raise ValueError.
- Tests updated for new contract + added coverage for listing failures and
  explicit unknown-provider errors.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:32:06 +00:00
MervinPraisonandClaude Opus 5 c4516e7dbe feat(managed): add praisonai managed ps/stop, rename compute= to run_on=
Three things, all stemming from reviewing the shared-sandbox feature (#3949).

1. Sandbox visibility (the piece left open in #3949)

   praisonai managed ps          # list running sandboxes
   praisonai managed ps --json   # machine-readable
   praisonai managed stop <id>   # reclaim one
   praisonai managed stop --all  # reclaim everything

   Sandboxes normally self-destruct when a run ends, but a crashed or killed
   run leaves one behind; there was no way to see or reclaim it.

2. Cross-process instance discovery (bugs found while building the above)

   DockerCompute tracked instances only in an in-process dict, so:
   - list_instances() always returned nothing from a fresh process -- exactly
     when you need it, i.e. finding strays left by a previous run.
   - _shutdown_sync() popped from that dict and, finding nothing, silently
     did nothing while reporting success. `managed stop` claimed "Stopped"
     and left the container running.
   - get_status() reported STOPPED for any live container it didn't start.

   All three now fall back to Docker itself (the `praisonai=managed` label and
   the `praisonai_<id>` name), and shutdown raises rather than pretending to
   succeed when the container cannot be found.

3. Naming: `compute=` -> `run_on=`

   Researched how comparable tools name this. `executor` is the most-attested
   name industry-wide (smolagents, ADK, AutoGen, Airflow, Nextflow, Dagster,
   ...), but it is already used as a concept inside workflows.py. `backend`,
   `runtime` and `sandbox` are all taken as public Agent params, and `provider`
   is used ~147 times for LLM/vector-store providers.

   `compute` as a noun is cloud-industry jargon (Wiktionary labels the noun
   sense "chiefly cloud computing"), which works against the goal of a name a
   non-developer can read. `run_on` is plain English, has mass-audience
   precedent in GitHub Actions' `runs-on:`, and has zero collisions in this
   codebase.

       AgentFlow(run_on="docker", steps=[writer, reader])
       AgentTeam(agents=[w, r], run_on="docker")

   `compute` stays as the INTERNAL vocabulary (ComputeConfig,
   ComputeProviderProtocol, SharedCompute, _compute_bridge) where the
   industry term is the right one. No alias is added: `compute=` merged in
   #3949 but is NOT in any published release (PyPI 1.6.166 predates it), so
   this rename breaks nobody and avoids a second param.

4. Doc fix: the ManagedAgent factory docstring claimed compute-provider names
   raise ValueError; they actually emit a DeprecationWarning and delegate to
   LocalManagedAgent. Corrected to match the code.

Verified against local Docker: `run_on="docker"` write-then-read hit one
shared instance; `managed ps` found a real stray container with correct
uptime; `managed stop --all` genuinely removed it (0 containers after).
16 new CLI tests + 14 core tests, none requiring Docker.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-08-15 08:25:14 +01:00
praisonai-triage-agent[bot]andMervin Praison f20a15280a fix: address reviewer P1s — async cascade at consumption, doom-loop window boundary, bounded tool-executor recycling
- agents.py: re-check dependency failure at consumption time in the async
  sequential path. asequential() checks deps inside the generator, but
  async_execution tasks are buffered and drained later, so an upstream async
  task may not be failed yet when a dependent is yielded. Re-checking after
  flushing pending async tasks makes the failure cascade fire for the async
  path too.
- doom_loop.py: count progress markers against the boundary *before* the
  no-progress window (the preceding action's completion timestamp) so a marker
  recorded during the window's first action — timestamped before that action's
  record_action() — is no longer wrongly filtered out.
- tool_execution.py: bound orphaned tool executors. Recycling on timeout keeps
  throughput, but repeated timeouts could leak unbounded blocked threads. Cap
  recycles at _MAX_ORPHANED_TOOL_EXECUTORS, then fall back to reusing the
  bounded pool so process resources can't be exhausted.
- Add regression tests for the doom-loop window boundary edge.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:24:50 +00:00
praisonai-triage-agent[bot]andMervin Praison 6367ae8f6f fix(code): contain headless search tools to workspace
grep/glob/ast_grep_search were loaded raw from praisonaiagents, so their
containment defaulted to os.getcwd() (grep/glob) or was absent
(ast_grep_search). In headless `code -p --workspace <dir>` (no chdir) a
model-supplied path could read outside the selected workspace. Bind each
search tool's path to the configured workspace (fail-closed), mirroring the
edit/acp/lsp groups, and add a credential-free regression test.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-15 07:23:43 +00:00
Mervin Praison 7bbd2ebdc6 Merge pull request #3958 from dajiaohuang/fix/3934-responses-vision-input
fix(llm): translate multimodal Responses API input
2026-08-15 08:21:38 +01:00
Mervin Praison 90fce937a5 Merge pull request #3957 from dajiaohuang/fix/3933-token-collector-default
fix(llm): collect token usage with quiet output
2026-08-15 08:20:58 +01:00
Mervin Praison f9a629d14b Merge pull request #3956 from dajiaohuang/fix/3932-harbor-budget
fix(harbor): configure coding budget and verifier flow
2026-08-15 08:19:57 +01:00
Mervin Praison 05b7361f93 Merge pull request #3955 from dajiaohuang/fix/3931-acp-tools
fix(acp): wire workspace tools and capability gates
2026-08-15 08:19:27 +01:00
Mervin Praison d3ba6cc250 Merge pull request #3936 from dajiaohuang/fix/async-state-deepcopy-overlap
fix: coordinate async state access with deepcopy
2026-08-15 08:17:11 +01:00
Mervin Praison 69032526d9 Merge pull request #3935 from dajiaohuang/test/stream-follow-up-completion
test: verify streamed tool follow-up completion
2026-08-15 08:16:01 +01:00
MervinPraison 04f698587d docs: auto-update api.md [skip ci] 2026-08-15 07:15:30 +00:00
Mervin Praison edab0deaa7 Merge pull request #3944 from MervinPraison/claude/issue-3939-20260814-1431
fix: unify duplicate TaskOutput classes so agent guardrails work
2026-08-15 08:15:01 +01:00
MervinPraison 17f9d9e048 docs: auto-update feature parity trackers [skip ci] 2026-08-15 07:14:49 +00:00
praisonai-triage-agent[bot]andMervinPraison 7c291587f1 fix: align async sequential, recycle tool executor, fix doom-loop no-progress (fixes #3959)
- asequential(): port failed-task skip + dependency-cascade check from
  sequential() so async runs short-circuit tasks with failed upstream deps.
- tool_execution: retire the tool ThreadPoolExecutor on timeout so hung
  threads no longer permanently poison the 2-worker pool.
- doom_loop: store (marker, timestamp) and only count progress markers
  within the current no-progress window, so NO_PROGRESS detection is no
  longer permanently disabled after the first successful tool call.

Co-authored-by: MervinPraison <[email protected]>
2026-08-15 07:14:22 +00:00
Mervin Praison 5afe4a94e5 Merge pull request #3943 from MervinPraison/claude/issue-3940-20260814-1431
fix: MCP splits multi-word command string when args supplied
2026-08-15 08:14:21 +01:00
dajiaohuang d82555bc21 fix(llm): propagate local image validation 2026-08-15 03:51:22 +08:00
dajiaohuang 8370d0109f fix(code): contain headless tools to workspace 2026-08-15 03:51:20 +08:00
dajiaohuang 11926a2839 fix(harbor): include iteration budget metadata 2026-08-15 03:49:37 +08:00
dajiaohuang 1809f85206 fix(llm): preserve image detail setting 2026-08-15 03:49:25 +08:00
dajiaohuang 4d6b9a15bb fix(llm): cover all non-stream usage paths 2026-08-15 03:49:22 +08:00
dajiaohuang 8cd0ccfbf9 fix(code): harden headless lifecycle 2026-08-15 03:49:18 +08:00
praisonai-triage-agent[bot]andMervin Praison 9c1d8e4f90 fix(harbor): use backend-aware last_stop_reason for stop diagnostics
Replaces the fragile private _get_stop_reason helper (which checked the
wrong _llm_instance attr and missed the native OpenAI backend) with the
public backend-aware Agent.last_stop_reason property, so slash-less
OpenAI model names no longer record a null stop_reason.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 19:48:13 +00:00
dajiaohuang 36d4952169 fix(llm): translate multimodal responses input 2026-08-15 03:41:55 +08:00
dajiaohuang 13667392b3 fix(llm): collect token usage with quiet output 2026-08-15 03:41:55 +08:00
dajiaohuang a90c0817a1 fix(acp): wire workspace tools and capability gates 2026-08-15 03:41:54 +08:00
dajiaohuang b53dc46452 fix(harbor): configure coding budget and verifier flow 2026-08-15 03:41:54 +08:00
praisonai-triage-agent[bot]andMervin Praison 76c58b96e7 fix: remove misleading PydanticUserError special-case in guardrail path
The root cause (unresolved TaskOutput forward reference) is fixed at source
in output/models.py via model_rebuild(), so PydanticUserError no longer
occurs in the guardrail path. The re-raise in _process_guardrail was also
swallowed by the 15 downstream chat handlers that return None, so it never
achieved its stated propagation contract (Greptile P1). Removing it keeps
guardrail error handling simple and consistent without touching hot paths.

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:40:20 +00:00
praisonai-triage-agent[bot]andMervin Praison e410647483 fix: robust MCP stdio command parsing for spaced paths and whitespace
Run shlex.split first and decide by token count. Fixes Greptile P1
(spaced executable paths + args no longer mis-split; quote the path as
an escape hatch) and P2 (tabs/newlines now handled, not just spaces).

Co-authored-by: Mervin Praison <[email protected]>
2026-08-14 14:38:47 +00:00
praisonai-triage-agent[bot]andMervinPraison bca50850d1 fix: unify duplicate TaskOutput classes so agent guardrails work (fixes #3939)
Passing a callable to Agent(guardrails=...) returned None because two
different classes named TaskOutput existed and the guardrail path mixed them.

- output/models.py: import TokenMetrics at runtime and call
  TaskOutput.model_rebuild() so the model can actually be instantiated
  (previously the forward ref was TYPE_CHECKING-only and never resolved).
- main.py: re-export the single canonical TaskOutput from output.models so
  main.TaskOutput and output.models.TaskOutput are the same class.
- agent.py: let PydanticUserError propagate from _process_guardrail instead of
  swallowing it into a validation failure that silently nulls the response.

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 14:34:15 +00:00
praisonai-triage-agent[bot]andMervinPraison 817942a4ab fix: MCP splits multi-word command string when args supplied (fixes #3940)
Previously MCP("npx -y pkg", args=[...]) passed the whole string as the
executable, so the subprocess spawn silently failed and 0 tools were
discovered. Now the command string is split on whitespace regardless of
whether args are supplied, using the first token as the executable and
prepending the remaining tokens to any explicit args.

Also updates examples/python/mcp/filesystem-mcp.py to use the reliable
executable + args form.

Co-authored-by: MervinPraison <[email protected]>
2026-08-14 14:33:12 +00:00
dajiaohuang cc82b8a462 test: cover async entry during state cloning 2026-08-14 20:55:52 +08:00
dajiaohuang 03e7cb8bed test: document real stream dispatch coverage 2026-08-14 20:51:00 +08:00
dajiaohuang 395835c7a0 test: cover direct async state context 2026-08-14 20:51:00 +08:00
dajiaohuang 7af79419bf test: require streamed follow-up completion 2026-08-14 20:51:00 +08:00
dajiaohuang 338001d31f fix: guard state cloning during async access 2026-08-14 20:51:00 +08: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
759 changed files with 79427 additions and 16999 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.',
+63 -3
View File
@@ -74,7 +74,11 @@ function isClaudeTriggerNoise(c) {
function isClaudeFinalReplyComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage')) return false;
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
return (c.body || '').includes('Claude finished');
}
@@ -96,15 +100,47 @@ function isConflictRebaseTriggerComment(c) {
function isConflictRebaseCompletionComment(c) {
const login = (c.user?.login || '').toLowerCase();
if (!login.includes('praisonai-triage') && !login.includes('github-actions')) return false;
const fromAutomation =
login.includes('praisonai-triage') ||
login.includes('github-actions') ||
AUTO_ACTORS.some((a) => a.toLowerCase() === login);
if (!fromAutomation) return false;
const body = (c.body || '').toLowerCase();
return (
body.includes('rebase complete') ||
body.includes('rebase onto') ||
body.includes('force-pushed') ||
(body.includes('conflict') && body.includes('resolved'))
);
}
/** HEAD landed after conflict rebase but no FINAL @claude since that push. */
function needsFinalReReviewAfterConflictRebase(comments, headPushedAt) {
if (!headPushedAt) return false;
const headTime = new Date(headPushedAt).getTime();
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return false;
const latestConflict = conflictTriggers.reduce((a, b) =>
new Date(a.created_at) > new Date(b.created_at) ? a : b
);
const conflictTime = new Date(latestConflict.created_at).getTime();
if (headTime <= conflictTime) return false;
const rebaseDone = comments.some(
(c) =>
new Date(c.created_at).getTime() > conflictTime && isConflictRebaseCompletionComment(c)
);
if (!rebaseDone) return false;
const finalsAfterHead = comments.filter(
(c) =>
isFinalClaudeTriggerComment(c) &&
new Date(c.created_at).getTime() >= headTime - 60000
);
return finalsAfterHead.length === 0;
}
function conflictRebaseQuiescent(comments, headPushedAt) {
const conflictTriggers = comments.filter(isConflictRebaseTriggerComment);
if (conflictTriggers.length === 0) return true;
@@ -245,6 +281,9 @@ function shouldSkipStaleFinalRecovery(comments, headPushedAt, headPusherLogin =
if (headPusherLogin && isClaudeAutomationLogin(headPusherLogin)) {
return { skip: true, reason: 'head pushed by Claude automation' };
}
if (needsFinalReReviewAfterConflictRebase(comments, headPushedAt)) {
return { skip: false, reason: '' };
}
if (isPushSoonAfterLatestFinal(comments, headPushedAt)) {
return {
skip: true,
@@ -269,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 = `
@@ -561,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 [];
@@ -784,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));
@@ -916,6 +974,7 @@ module.exports = {
hasRecentConflictComment,
isConflictRebaseTriggerComment,
isConflictRebaseCompletionComment,
needsFinalReReviewAfterConflictRebase,
conflictRebaseQuiescent,
hasHumanChangesRequested,
hasAnyChangesRequested,
@@ -949,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
@@ -24,6 +24,7 @@ jobs:
CLAUDE_APP_PRIVATE_KEY: ${{ secrets.CLAUDE_APP_PRIVATE_KEY }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
GH_TOKEN_VALUE: ${{ secrets.GH_TOKEN }}
DOCS_GITHUB_TOKEN: ${{ secrets.SECRETS_ADMIN_PAT }}
run: |
set -euo pipefail
if [ -z "${GH_TOKEN:-}" ]; then
@@ -34,4 +35,7 @@ jobs:
echo -n "$CLAUDE_APP_PRIVATE_KEY" | gh secret set CLAUDE_APP_PRIVATE_KEY --repo "$TARGET"
echo -n "$CLAUDE_CODE_OAUTH_TOKEN" | gh secret set CLAUDE_CODE_OAUTH_TOKEN --repo "$TARGET"
echo -n "$GH_TOKEN_VALUE" | gh secret set GH_TOKEN --repo "$TARGET"
if [ -n "${DOCS_GITHUB_TOKEN:-}" ]; then
echo -n "$DOCS_GITHUB_TOKEN" | gh secret set DOCS_GITHUB_TOKEN --repo "$TARGET"
fi
echo "Synced CLAUDE_APP_ID, CLAUDE_APP_PRIVATE_KEY, CLAUDE_CODE_OAUTH_TOKEN, GH_TOKEN → $TARGET"
+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
+14
View File
@@ -98,6 +98,20 @@ jobs:
--ignore=tests/unit/cli/test_message_queue.py \
--maxfail=5
- name: Run praisonai-agents subscription-auth smoke tests
env:
PRAISONAI_TEST_TIER: smoke
PRAISONAI_ALLOW_NETWORK: '0'
OPENAI_MODEL_NAME: gpt-4o-mini
OPENAI_API_KEY: 'test-key'
shell: bash
run: |
set -euo pipefail
cd src/praisonai-agents
python -m pytest tests/smoke/test_subscription_auth.py \
tests/unit/auth/test_agent_auth_wiring.py \
-q --tb=line --timeout=60 --maxfail=5
- name: Standalone praisonai-code import smoke
shell: bash
run: |
+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/`
+74 -12
View File
@@ -130,7 +130,7 @@ Each layer wraps the one inside it. When an agent misbehaves, the layer tells yo
| **3 · Harness** | Can it act, and be checked? | `tools=`, `MCP()`, `guardrails=`, `approval=`, `hooks=`, `sandbox=` |
| **4 · Loop** | When do we stop? | `execution=ExecutionConfig(...)`, `reflection=`, `autonomy=`, doom-loop detection |
| **5 · Graph** | Who runs when, and who checks whom? | `AgentFlow`, `route()`, `parallel()`, `loop()`, `repeat()` |
| **⬡ Managed** | *Where does it actually run?* | `backend=ManagedAgent(compute="e2b")` — remote sandbox, or a fully hosted loop |
| **⬡ Managed** | *Where does it actually run?* | `tools_run_on="docker"` — one shared sandbox for the tools, or `run_on="anthropic"` for the whole agent |
### Layer 1 · Prompt — *Did I say it clearly?*
@@ -195,7 +195,6 @@ from praisonaiagents import Agent, ExecutionConfig
agent = Agent(
instructions="Fix the failing tests.",
execution=ExecutionConfig(max_iter=30, max_budget=0.50, on_budget_exceeded="stop"),
reflection=True, # completion check — the agent grades its own answer
autonomy=True, # required to drive the loop with run_autonomous()
)
result = agent.run_autonomous("Refactor the auth module", max_iterations=5)
@@ -236,22 +235,85 @@ The harness is commoditising; **where** the agent executes is the next multiplie
pip install praisonai
```
The simplest way in is `tools_run_on=` — one whole team or workflow shares **one** sandbox, so a file written by step 1 is there for step 2. Thinking stays on your machine:
```python
from praisonai import Agent, ManagedAgent, LocalManagedConfig
from praisonaiagents import Agent, AgentFlow
# A. Tools run in a remote sandbox; the agent loop stays local
sandboxed = ManagedAgent(
provider="local", compute="e2b", # or modal | daytona | flyio | docker | tenki
config=LocalManagedConfig(model="gpt-4o-mini", name="RemoteTools"),
)
agent = Agent(name="builder", backend=sandboxed)
writer = Agent(name="Writer", instructions="You write files.")
reader = Agent(name="Reader", instructions="You read files.")
# B. The entire agent loop runs in the cloud (needs ANTHROPIC_API_KEY;
# with no key set, ManagedAgent() falls back to a local loop)
agent = Agent(name="teacher", backend=ManagedAgent())
flow = AgentFlow(tools_run_on="docker", steps=[writer, reader]) # or e2b | modal | daytona | flyio
flow.run("Write 'hello' to /workspace/note.txt, then read it back")
```
Same thing with no Python at all:
```yaml
name: remote-demo
tools_run_on: docker # every step shares one sandbox
agents:
writer: {role: Writer, goal: Write files}
reader: {role: Reader, goal: Read files}
steps:
- agent: writer
action: "Write 'hello' to /workspace/note.txt"
- agent: reader
action: "Read /workspace/note.txt"
```
For a single agent, two words cover it — and they answer different questions:
```python
from praisonaiagents import Agent
# A. Only the TOOLS move. Thinking stays on your machine.
agent = Agent(name="builder", instructions="You build things.",
tools_run_on="docker") # docker | e2b | modal | daytona | flyio
# tenki | sandlock | ssh | novita | subprocess
# B. The WHOLE agent moves — model calls, loop and tools
agent = Agent(name="teacher", instructions="You teach.", run_on="anthropic") # hosted
agent = Agent(name="builder", instructions="You build.", run_on="docker") # self-hosted
agent.start("Write a Python script that prints the first 10 primes, then run it")
```
Ask any object where it runs, and it will tell you:
```python
>>> Agent(name="builder", instructions="x", tools_run_on="docker")
Agent(name='builder', thinks_on='this machine', tools_run_on='a Docker container')
>>> agent.where_does_it_run()
Thinking (the AI model calls) happens on this machine.
Tools run on a Docker container.
Your own tools (check_db) still run on this machine -- only shell, file and
code tools move. They read and write this machine's files.
```
Naming a place that cannot do the job is a typo, not a preference, so it says so:
```python
>>> Agent(name="x", instructions="i", run_on="e2b")
TypeError: Agent(run_on='e2b') is not valid: run_on= places the whole agent
-- model calls, loop and tools -- on a managed runtime, and 'e2b' runs
commands but cannot host an agent loop.
To run only the tools there: Agent(tools_run_on='e2b')
```
To run one block of code somewhere else, name the place on that call:
```python
agent.execute_code_sync("print(6 * 7)", run_in="sandlock") # kernel-enforced
```
See what is running and reclaim strays:
```bash
praisonai managed ps # list running sandboxes
praisonai managed stop --all # reclaim them
```
Sandboxes shut themselves down when idle (`auto_shutdown`, `idle_timeout_s`), and a post-setup snapshot is reused so the next run skips the image pull and dependency install. Commit a `.praisonai/environment.yaml` and the environment travels with the repo.
> 📖 [20 runnable examples](examples/python/managed-agents/) · manage sessions with `praisonai managed sessions list <agent-id>` or `praisonai managed sessions resume <session-id> "<prompt>"`
+11 -2
View File
@@ -8,11 +8,20 @@ PraisonAI takes security seriously. We appreciate responsible disclosure from re
|---------|----------|-------------|
| `praisonaiagents` | [PyPI](https://pypi.org/project/praisonaiagents/) | `src/praisonai-agents/` |
| `praisonai` | [PyPI](https://pypi.org/project/praisonai/) | `src/praisonai/` |
| `praisonai-platform` | [PyPI](https://pypi.org/project/praisonai-platform/) | `src/praisonai-platform/` |
| `praisonai` | [npm](https://www.npmjs.com/package/praisonai) | `src/praisonai-ts/` |
| `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.
### Packages maintained in their own repositories
`praisonai-platform` now lives at
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
Report platform vulnerabilities through
[that repository's advisories](https://github.com/MervinPraison/PraisonAI-Platform/security/advisories/new),
not here — it is versioned and released independently of the SDK.
## In scope
- Authentication, authorisation, and multi-tenant isolation (e.g. workspace boundaries)
+19 -54
View File
@@ -7,7 +7,7 @@ Regenerate with: `praisonai docs api-md --write`
Types:
```python
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, TaskOutput, ToolResult, ToolValidationError, WorkflowContext
from praisonaiagents import ContextPolicy, GuardrailResult, HandoffConfig, HandoffCycleError, HandoffDepthError, HandoffError, HandoffInputData, HandoffResult, HandoffTimeoutError, ReflectionOutput, StepResult, ToolResult, ToolValidationError, WorkflowContext
```
Methods:
@@ -16,8 +16,6 @@ Methods:
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_dict</a>(data: Dict[str, Any]) -> 'HandoffConfig'</code>
* <code title="class HandoffConfig">HandoffConfig.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class HandoffResult">HandoffResult.<a href="./src/praisonai-agents/praisonaiagents/agent/handoff.py">from_outcome</a>(outcome: AgentRunOutcome, target_agent: Optional[str] = None, source_agent: Optional[str] = None, handoff_depth: int = 0) -> 'HandoffResult'</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">json</a>() -> Optional[str]</code>
* <code title="class TaskOutput">TaskOutput.<a href="./src/praisonai-agents/praisonaiagents/main.py">to_dict</a>() -> dict</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">is_multimodal</a>() -> bool</code>
* <code title="class ToolResult">ToolResult.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">to_dict</a>() -> Dict[str, Any]</code>
@@ -32,6 +30,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">aclose</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_mcp_server</a>(name: str, mcp: Any) -> Any</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">add_verification</a>(hook: Any) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">agent_id</a>() -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">analyze_prompt</a>(prompt: str) -> set</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">auto_memory</a>() -> Optional[bool]</code>
@@ -72,6 +71,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">redo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">refresh_tools</a>() -> List[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_mcp_server</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">remove_verification</a>(name: str) -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieval_config</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">retrieve</a>(query: str, **kwargs) -> 'ContextPack'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">rules_manager</a>() -> Optional[Any]</code>
@@ -88,6 +88,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">thinking_budget</a>(value: Optional[int]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">total_cost</a>() -> float</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">undo</a>() -> bool</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">astart</a>()</code>
* <code title="class AutoAgents">AutoAgents.<a href="./src/praisonai-agents/praisonaiagents/agents/autoagents.py">start</a>()</code>
* <code title="class AutoRagAgent">AutoRagAgent.<a href="./src/praisonai-agents/praisonaiagents/agents/auto_rag_agent.py">achat</a>(message: str, **kwargs) -> str</code>
@@ -239,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>
@@ -252,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>
@@ -464,7 +468,7 @@ Methods:
Types:
```python
from praisonaiagents import SkillLoader, SkillManager, SkillMetadata, SkillProperties, SkillsConfig
from praisonaiagents import SkillLoader, SkillsConfig
```
Methods:
@@ -476,55 +480,6 @@ Methods:
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_metadata</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_references</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillLoader">SkillLoader.<a href="./src/praisonai-agents/praisonaiagents/skills/loader.py">load_scripts</a>(skill: LoadedSkill) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate</a>(skill: LoadedSkill) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">activate_by_name</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_bundle</a>(manifest: 'BundleManifest') -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill</a>(skill_path: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">add_skill_by_name</a>(name: str, skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">approve</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">archive_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundle_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">bundles</a>() -> List['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">clear</a>() -> None</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create</a>(name: str, content: str, category: Optional[str] = None, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">create_skill</a>(name: str, content: str, category: str = None, agent_created: bool = True, propose: Optional[bool] = None, base_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete</a>(name: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">delete_skill</a>(name: str, hard: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">discover_bundles</a>(skill_dirs: Optional[List[str]] = None, include_defaults: bool = True) -> int</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle</a>(name: str) -> Optional['BundleManifest']</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_bundle_instructions</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_archived_skills</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">list_pending</a>() -> List[dict]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch</a>(name: str, old_string: str, new_string: str, file_path: Optional[str] = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">patch_skill</a>(name: str, old_string: str, new_string: str, file_path: str = None, replace_all: bool = False, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reject</a>(identifier: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">reload</a>() -> Dict[str, List[str]]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">remove_skill_file</a>(name: str, file_path: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">resolve</a>(selectors: List[str]) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">restore_skill</a>(name: str, skill_dir: Optional[str] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">rollback_skill</a>(name: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str, propose: Optional[bool] = None) -> dict</code>
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">idle_days</a>() -> Optional[float]</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
* <code title="class SkillsConfig">SkillsConfig.<a href="./src/praisonai-agents/praisonaiagents/config/feature_configs.py">to_dict</a>() -> Dict[str, Any]</code>
# Session
@@ -757,6 +712,7 @@ Methods:
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">to_dict</a>() -> Dict[str, Any]</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>() -> bool</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">verbose</a>(value: bool)</code>
* <code title="class AgentFlow">AgentFlow.<a href="./src/praisonai-agents/praisonaiagents/workflows/workflows.py">where_does_it_run</a>() -> str</code>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">get_app</a>() -> Any</code>
* <code title="class AgentOSProtocol">AgentOSProtocol.<a href="./src/praisonai-agents/praisonaiagents/app/protocols.py">serve</a>(host: Optional[str] = None, port: Optional[int] = None, reload: bool = False, **kwargs: Any) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">aannounce_completion</a>(agent_id: str, task_id: str, result: Any, success: bool = True, error: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None) -> None</code>
@@ -793,7 +749,7 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">get_token_usage_summary</a>() -> Dict[str, Any]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">has_state</a>(key: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">increment_state</a>(key: str, amount: float = 1, default: float = 0) -> float</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">launch</a>(path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = 'http')</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">restore_session_state</a>(session_id: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run</a>(content = None, return_dict = False, **kwargs)</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">run_all_tasks</a>()</code>
@@ -809,6 +765,7 @@ Methods:
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_plan_step_status</a>(step_id: str, status: str) -> bool</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">update_state</a>(updates: Dict) -> None</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">wait_for_completions</a>(timeout: Optional[float] = None, agent_ids: Optional[List[str]] = None) -> List[SubAgentCompletionEvent]</code>
* <code title="class AgentTeam">AgentTeam.<a href="./src/praisonai-agents/praisonaiagents/agents/agents.py">where_does_it_run</a>() -> str</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class AutoApproveBackend">AutoApproveBackend.<a href="./src/praisonai-agents/praisonaiagents/approval/backends.py">request_approval_sync</a>(request: ApprovalRequest) -> ApprovalDecision</code>
* <code title="class RunOutcome">RunOutcome.<a href="./src/praisonai-agents/praisonaiagents/agent/run_outcome.py">completed</a>(output: Optional[str] = None) -> 'RunOutcome'</code>
@@ -890,11 +847,13 @@ Methods:
* <code title="cli">praisonai managed list <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed managed-callback <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed multi <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed ps <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed restore <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed resume <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed run <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed save <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed show <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed stop <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai managed update <a href="./src/praisonai/praisonai/cli/commands/managed.py">--help</a></code>
* <code title="cli">praisonai n8n export <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
* <code title="cli">praisonai n8n import <a href="./src/praisonai/praisonai/cli/commands/n8n.py">--help</a></code>
@@ -924,6 +883,7 @@ Methods:
* <code title="cli">praisonai recipe list <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe optimize <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe run <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe runs <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai recipe serve <a href="./src/praisonai/praisonai/cli/commands/recipe.py">--help</a></code>
* <code title="cli">praisonai replay cleanup <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
* <code title="cli">praisonai replay context <a href="./src/praisonai/praisonai/cli/commands/replay.py">--help</a></code>
@@ -940,7 +900,11 @@ Methods:
* <code title="cli">praisonai schedule describe <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule list <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule logs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule pause <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule remove <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule restart <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule resume <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule runs <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule schedule-callback <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule start <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule stats <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
@@ -949,6 +913,7 @@ Methods:
* <code title="cli">praisonai schedule suggestion-dismiss <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestion-propose <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule suggestions <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai schedule update <a href="./src/praisonai/praisonai/cli/commands/schedule.py">--help</a></code>
* <code title="cli">praisonai validate check <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate schema <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
* <code title="cli">praisonai validate validate <a href="./src/praisonai/praisonai/cli/commands/validate.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.162" \
"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.6.162" \
"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.6.162" \
"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")
+2 -3
View File
@@ -14,7 +14,7 @@ Expected Output:
Agent responds with state persisted to Redis
"""
from praisonaiagents import Agent
from praisonaiagents import Agent, MemoryConfig
from praisonaiagents.db import db
print("=== Redis State Store (Agent-First) ===")
@@ -28,8 +28,7 @@ my_db = db(
agent = Agent(
name="Assistant",
instructions="You are a helpful assistant.",
memory=my_db,
session_id="redis-state-example"
memory=MemoryConfig(db=my_db, session_id="redis-state-example"),
)
# Chat - state is automatically managed via Redis
@@ -23,6 +23,7 @@ from praisonaiagents.hooks.verification import (
VerificationResult,
BaseVerificationHook,
CommandVerificationHook,
FileCheckHook,
)
@@ -100,7 +101,19 @@ def main():
print(f" Name: {cmd_hook.name}")
print(f" Command: {cmd_hook.command}")
print(f" Timeout: {cmd_hook.timeout_seconds}s")
# 6. FileCheckHook: declarative, no-shell completion gate
print("\n6. FileCheckHook (declarative completion gate):")
file_hook = FileCheckHook(
name="changelog", path="CHANGELOG.md", non_empty=True
)
file_result = file_hook.run()
print(f" {file_hook.name}: success={file_result.success} — {file_result.output}")
print(
" Gate wiring: when verification_hooks are configured, the autonomous "
"loop cannot finalise with a success outcome while a blocking hook fails."
)
print("\n" + "=" * 60)
print("✓ Verification hooks example completed successfully!")
print("=" * 60)
+2 -2
View File
@@ -7,12 +7,12 @@ allowed_dirs = [
"/path/to/other/allowed/dir"
]
# Use a single string command with allowed directories
# Pass the executable as the first token and the allowed directories as args
filesystem_agent = Agent(
instructions="""You are a helpful assistant that can interact with the filesystem.
Use the available tools when relevant to manage files and directories.""",
llm="gpt-4o-mini",
tools=MCP("npx -y @modelcontextprotocol/server-filesystem", args=allowed_dirs)
tools=MCP("npx", args=["-y", "@modelcontextprotocol/server-filesystem", *allowed_dirs])
)
filesystem_agent.start("List files in the allowed directories")
@@ -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)
+1 -1
View File
@@ -24,7 +24,7 @@ Examples for all PraisonAI server types and the unified endpoints CLI.
| `praisonai serve unified --port 8765` | Start unified server |
| `praisonai serve agents --file agents.yaml --port 8000` | Start agents API |
| `praisonai serve recipe --port 8765` | Start recipe server |
| `praisonai serve mcp --transport http --port 8080` | Start MCP server |
| `praisonai mcp serve --transport http-stream --port 8080` | Start MCP server |
| `praisonai serve tools --port 8081` | Start tools MCP server |
| `praisonai serve a2a --port 8082` | Start A2A server |
| `praisonai serve a2u --port 8083` | Start A2U server |
+1 -1
View File
@@ -13,7 +13,7 @@ Usage:
python mcp_http_server.py
# Or use CLI:
praisonai serve mcp --transport http --port 8080
praisonai mcp serve --transport http-stream --port 8080
# Test with curl:
curl http://localhost:8080/mcp/tools
+3 -3
View File
@@ -14,9 +14,9 @@ datasets:
# A small verified subset for a cheap smoke run. Verify against the registry
# with `harbor datasets list` before editing.
task_names:
- hello-world
- fix-permissions
- csv-to-parquet
- terminal-bench/build-pmars
- terminal-bench/bn-fit-modify
- terminal-bench/break-filter-js-from-html
agents:
- import_path: examples.terminal_bench.praisonai_code_agent:PraisonAICodeAgent
@@ -81,17 +81,19 @@ class PraisonAICodeAgent(BaseInstalledAgent):
model = self.model_name or "openai/gpt-4o-mini"
command = (
"log_file=$(mktemp /tmp/praisonai_code.XXXXXX); "
"chmod 600 \"$log_file\"; "
f"praisonai code -p --output json {shlex.quote(instruction)} "
f"--dangerously-skip-approval "
f"--model {shlex.quote(model)} "
"> /tmp/praisonai_code.log 2>&1; "
"> \"$log_file\" 2>&1; "
# Capture the real exit status so install/auth/startup failures still
# surface instead of being silently masked (Harbor otherwise records a
# completed attempt for a crashed agent).
"status=$?; "
# Echo the envelope so Harbor captures it for post-run accounting.
"cp /tmp/praisonai_code.log /tmp/praisonai_code_run.log 2>/dev/null || true; "
"cat /tmp/praisonai_code.log; "
"cat \"$log_file\"; "
"rm -f \"$log_file\"; "
# `praisonai code` normally exits 0 (Harbor grades by task
# verification), so a genuine benchmark miss won't fail here — but a
# nonzero status means the assistant itself failed to run.
@@ -128,7 +130,6 @@ class PraisonAICodeAgent(BaseInstalledAgent):
"agent_type": "code-cli",
"agent_name": self.name(),
"model": self.model_name,
"log_path": "/tmp/praisonai_code.log",
"session_id": envelope.get("session_id") if envelope else None,
"status": envelope.get("status") if envelope else "unknown",
}
@@ -34,6 +34,7 @@ except ImportError as e:
try:
from praisonaiagents import Agent
from praisonaiagents.approval import get_approval_registry, AutoApproveBackend
from praisonaiagents.config.feature_configs import ExecutionConfig
except ImportError as e:
raise ImportError(
f"PraisonAI not installed: {e}\n"
@@ -41,6 +42,13 @@ except ImportError as e:
) from e
TB_EXECUTION = ExecutionConfig(
max_tool_calls_per_turn=80,
max_iter=40,
max_steps=80,
)
class PraisonAIExternalAgent(BaseAgent):
"""External PraisonAI agent that drives a Harbor container environment."""
@@ -142,26 +150,14 @@ class PraisonAIExternalAgent(BaseAgent):
),
tools=[bash_tool],
llm=self.model_name or "openai/gpt-4o-mini",
execution=TB_EXECUTION,
)
# Execute the agent with outer loop to handle premature stopping
# Execute one deliberately long coding turn. Harbor's hidden verifier
# is the only completion authority; model or pytest text is not.
print(f"🚀 PraisonAI Agent starting task: {instruction[:100]}...")
result = await agent.achat(instruction)
for _iter in range(19):
result_str = str(result)
# Stop if test passed
if any(sig in result_str.lower() for sig in [
" passed", "passed ", "test passed", "all tests pass", "1 passed"
]):
break
result = await agent.achat(
"The task is NOT complete yet — keep working. Run bash_tool commands now. "
"If you haven't already: (1) read all files in /app/, "
"(2) run the test to see the exact failure, "
"(3) implement a fix, (4) run the test again. "
"Repeat until the test passes. What is your next bash_tool command?"
)
print(f"✅ PraisonAI Agent completed task")
print("✅ PraisonAI Agent completed task")
# Populate Harbor context with metadata
self._populate_context(agent, context, result)
@@ -203,6 +199,10 @@ class PraisonAIExternalAgent(BaseAgent):
"tools_used": ["bash_tool"],
"framework": "praisonai",
"version": self.version(),
"stop_reason": getattr(agent, "last_stop_reason", None),
"max_tool_calls_per_turn": TB_EXECUTION.max_tool_calls_per_turn,
"max_iter": TB_EXECUTION.max_iter,
"max_steps": TB_EXECUTION.max_steps,
}
except Exception as e:
@@ -218,4 +218,4 @@ if __name__ == "__main__":
print(" --model openai/gpt-4o-mini")
print()
print("Dependencies:")
print(" pip install harbor praisonaiagents")
print(" pip install harbor praisonaiagents")
@@ -0,0 +1,39 @@
"""Static regression checks for the in-tree Harbor adapter configuration."""
from pathlib import Path
import yaml
EXAMPLES = Path(__file__).parent
def test_external_adapter_sets_coding_execution_budget():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert "ExecutionConfig" in source
assert "max_tool_calls_per_turn=80" in source
assert "max_iter=40" in source
assert "max_steps=80" in source
def test_external_adapter_does_not_guess_completion_from_pytest_text():
source = (EXAMPLES / "praisonai_external_agent.py").read_text(encoding="utf-8")
assert '"1 passed"' not in source
assert '"test passed"' not in source
def test_smoke_yaml_uses_terminal_bench_21_registry_ids():
job = yaml.safe_load(
(EXAMPLES / "job_code_smoke.yaml").read_text(encoding="utf-8")
)
names = job["datasets"][0]["task_names"]
assert names
assert all(name.startswith("terminal-bench/") for name in names)
assert names == [
"terminal-bench/build-pmars",
"terminal-bench/bn-fit-modify",
"terminal-bench/break-filter-js-from-html",
]
+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)
@@ -0,0 +1,45 @@
# Run a whole workflow's shell/file tools inside ONE shared remote sandbox.
#
# praisonai examples/yaml/workflows/remote_sandbox_workflow.yaml
#
# `tools_run_on:` is the only line that differs from a normal local workflow.
# Every step shares the same sandbox and working directory, so the file the
# writer creates is there for the reader. Thinking and orchestration still run
# on your machine, and the sandbox is torn down when the run finishes.
#
# Places: docker | e2b | modal | daytona | flyio | tenki | local
# subprocess | sandlock | ssh | novita
# - docker needs a local Docker daemon and nothing else
# - e2b / modal / daytona / flyio need that provider's API key
# - sandlock is a kernel-enforced local boundary (Linux 6.12+)
# - subprocess is process separation only, NOT a security boundary
#
# `run_on:` is still accepted here as the old spelling for this key.
#
# See what is still running: praisonai managed ps
# Reclaim strays: praisonai managed stop --all
name: remote-sandbox-demo
tools_run_on: docker
agents:
writer:
role: File Writer
goal: Write files into the shared workspace
instructions: >
You have write_file and execute_command running on a remote Linux
sandbox. Use them; never say you cannot run commands.
reader:
role: File Reader
goal: Read files back out of the shared workspace
instructions: >
You have read_file and execute_command running on the same remote
sandbox the writer used. Use them.
steps:
- agent: writer
action: "Use write_file to put exactly 'hello from step 1' in /workspace/note.txt"
- agent: reader
action: "Use read_file on /workspace/note.txt and report its exact contents"
@@ -99,13 +99,12 @@ pip index versions praisonaiagents | head -1
pip index versions praisonai | head -1
```
13. Publish **praisonai-platform** (only when the advisory targets platform code):
```bash
cd /Users/praison/praisonai-package/src/praisonai-platform
praisonai publish pypi
# or: uv lock && uv build && uv publish --token $PYPI_TOKEN
```
Platform releases are **independent** of `praisonai` / `praisonaiagents` version numbers. A platform-only fix (e.g. `0.1.8`) does **not** require bumping the main SDK.
13. **praisonai-platform** advisories are handled in a different repository:
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
It is not part of this monorepo and is not published from here. Triage, patch,
release and advisory publication all happen in that repo, on its own semver line.
Nothing in this repository imports it, so a platform advisory never requires an
SDK release.
14. Publish **praisonai (npm / TypeScript)** (only when the advisory targets `src/praisonai-ts`):
```bash
@@ -113,9 +112,8 @@ cd /Users/praison/praisonai-package/src/praisonai-ts
npm version patch && npm publish
```
15. Verify platform / npm when applicable:
15. Verify npm when applicable:
```bash
pip index versions praisonai-platform | head -1
npm view praisonai version
```
@@ -139,7 +137,7 @@ gh api repos/MervinPraison/PraisonAI/security-advisories/<GHSA_ID> \
}
EOF
```
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), `praisonai-platform` (platform layer), or `praisonai` (npm ecosystem for TypeScript)
- `<PACKAGE_NAME>`: `praisonaiagents` (core SDK), `praisonai` (Python wrapper), or `praisonai` (npm ecosystem for TypeScript)
- `<LAST_VULNERABLE_VERSION>`: version before the fix
- `<FIRST_PATCHED_VERSION>`: the version just published with the fix
- Always include credits for the reporter
@@ -190,8 +188,8 @@ After validating each advisory in source code, assign **one** label:
| Package | Version line | Notes |
|---|---|---|
| `praisonaiagents` + `praisonai` | Aligned patch (e.g. `1.6.62` / `4.6.62`) | Main SDK + wrapper; bump together via `bump_and_release.py` |
| `praisonai-platform` | Independent semver (e.g. `0.1.8`) | Platform-only fixes; users need **not** upgrade the main SDK |
| `praisonai` (npm) | Independent semver (e.g. `1.7.2`) | TypeScript SDK; publish from `src/praisonai-ts` |
| `praisonai-platform` | Independent semver, **separate repository** | Not released from this repo — see [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) |
Always set the advisory `patched_versions` to the **package that actually contains the fix**. Do not imply users must upgrade all packages when only one changed.
@@ -217,9 +215,11 @@ Close (or publish advisory with no code change only if already mitigated) when:
|---|---|---|---|
| praisonaiagents | pip | src/praisonai-agents/praisonaiagents/ | tools/, mcp/, memory/, agent/, sandbox/ |
| praisonai | pip | src/praisonai/praisonai/ | cli/, capabilities/, mcp_server/, sandbox/, recipe/ |
| praisonai-platform | pip | src/praisonai-platform/praisonai_platform/ | api/routes/, services/, auth |
| praisonai | npm | src/praisonai-ts/src/ | agent/, mcp/, tools/, code-mode/ |
`praisonai-platform` is **not audited from this repository** — it lives in
[MervinPraison/PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform).
## RESOURCES & DOCUMENTATION
- **Official Repository**: https://github.com/MervinPraison/PraisonAI — Source code, issue tracking, and contribution guidelines
@@ -234,7 +234,7 @@ Close (or publish advisory with no code change only if already mitigated) when:
- ALWAYS set patched_versions before publishing advisory
- ALWAYS publish to PyPI/npm BEFORE publishing advisories (so Dependabot can point users to a fix)
- Python SDK: `praisonai publish pypi` from `src/praisonai-agents`, then `bump_and_release.py` for the wrapper
- Platform-only: `praisonai publish pypi` from `src/praisonai-platform` — no SDK bump required
- Platform-only: released from the separate [PraisonAI-Platform](https://github.com/MervinPraison/PraisonAI-Platform) repo, never from here
- npm: `npm publish` from `src/praisonai-ts` when the TypeScript SDK is affected
- ALWAYS request CVE AFTER publishing advisory
- Use generic commit messages: "refactor: harden X" not "fix: SQL injection in Y"
+1 -1
View File
@@ -1074,7 +1074,7 @@ PraisonAI's reliability engineering rests on three pillars — **C**ontext, **H*
> **Evaluator status.** The `ContextEvaluator` / `HarnessEvaluator` / `LoopEvaluator` classes named in the rubric are **planned follow-ups** (PA-CHL-001–004), not yet in the repo. Until they land, the equivalent checks are covered by existing modules:
>
> - **Context** — `context/tokens.py` and `eval/tokens.py` (`estimate_tokens` / `count_tokens`) for budget; `compaction/compactor.py` (`ContextCompactor`) for compaction/retention.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Harness** — `runtime/turn_context.py` for turn-context parity; `examples/runtime/example_harness.py` and `tests/` for harness/artifact checks.
> - **Loop** — `eval/loop.py` (`EvaluationLoop`) and `agent/autonomy.py` for convergence and doom-loop guards.
>
> Keep terminology consistent with the `agent/context_agent.py` (`ContextAgent`) PRP methodology.
+43 -43
View File
@@ -18,76 +18,76 @@ This report compares **Python SDK feature categories** against **Python document
| Category | Features | Docs | Lines |
|----------|----------|------|-------|
| ✅ AGUI | 1 | 2 | 420 |
| ✅ Agent | 22 | 50 | 15809 |
| ✅ Agent | 22 | 53 | 17107 |
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
| ✅ Approval | 1 | 5 | 2537 |
| ✅ Approval | 1 | 5 | 2764 |
| ✅ Audio | 2 | 12 | 1000 |
| ✅ Auto Generation | 5 | 10 | 3049 |
| ✅ Autonomy | 3 | 7 | 2744 |
| ✅ Bots | 7 | 31 | 13314 |
| ✅ Auto Generation | 5 | 11 | 3542 |
| ✅ Autonomy | 3 | 7 | 2790 |
| ✅ Bots | 7 | 32 | 13616 |
| ✅ Budget | 1 | 1 | 287 |
| ✅ CLI | 5 | 121 | 36021 |
| ✅ Chunking | 2 | 2 | 385 |
| ✅ CLI | 5 | 122 | 36991 |
| ✅ Chunking | 2 | 2 | 420 |
| ✅ Citations | 2 | 1 | 202 |
| ✅ Code Execution | 2 | 14 | 4583 |
| ✅ Conditions | 1 | 3 | 1179 |
| ✅ Configuration | 3 | 8 | 3047 |
| ✅ Context Management | 16 | 34 | 13396 |
| ✅ 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 | 1562 |
| ✅ Failover | 2 | 1 | 432 |
| ✅ Files | 2 | 7 | 2273 |
| ✅ Execution | 3 | 4 | 1606 |
| ✅ Failover | 2 | 1 | 452 |
| ✅ Files | 2 | 7 | 2275 |
| ✅ Flow | 1 | 3 | 788 |
| ✅ Gateway | 7 | 72 | 26391 |
| ✅ Guardrails | 4 | 4 | 2017 |
| ✅ Handoffs | 11 | 6 | 2563 |
| ✅ Hooks | 2 | 9 | 4317 |
| ✅ Gateway | 7 | 82 | 30269 |
| ✅ Guardrails | 4 | 4 | 2228 |
| ✅ Handoffs | 11 | 6 | 2626 |
| ✅ Hooks | 2 | 9 | 4509 |
| ✅ Image | 1 | 11 | 1255 |
| ✅ Knowledge | 4 | 13 | 4333 |
| ✅ LLM | 3 | 15 | 5192 |
| ✅ Loops | 4 | 5 | 1422 |
| ✅ MCP | 1 | 61 | 13847 |
| ✅ Memory | 6 | 18 | 7070 |
| ✅ Knowledge | 4 | 16 | 4967 |
| ✅ LLM | 3 | 15 | 5372 |
| ✅ Loops | 4 | 5 | 1475 |
| ✅ 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 | 1201 |
| ✅ Output | 3 | 5 | 1265 |
| ✅ Parallel Execution | 3 | 2 | 497 |
| ✅ Planning | 6 | 6 | 1549 |
| ✅ Plugins | 8 | 4 | 2850 |
| ✅ Prompts | 2 | 9 | 1965 |
| ✅ Providers | 1 | 56 | 7735 |
| ✅ Planning | 6 | 6 | 1605 |
| ✅ Plugins | 8 | 5 | 3120 |
| ✅ Prompts | 2 | 9 | 1971 |
| ✅ Providers | 1 | 56 | 7781 |
| ✅ Query | 1 | 2 | 736 |
| ✅ RAG | 5 | 15 | 3003 |
| ✅ RAG | 5 | 15 | 3118 |
| ✅ Realtime | 2 | 5 | 668 |
| ✅ Reflection | 3 | 3 | 680 |
| ✅ Retrieval | 2 | 5 | 1054 |
| ✅ Reflection | 3 | 3 | 730 |
| ✅ Retrieval | 2 | 5 | 1042 |
| ✅ Routing | 1 | 2 | 430 |
| ✅ Sandbox | 5 | 6 | 2918 |
| ✅ Security | 1 | 3 | 3206 |
| ✅ Sessions | 4 | 17 | 6963 |
| ✅ 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 | 141 | 41242 |
| ✅ Tools | 12 | 144 | 43251 |
| ✅ Tracing | 3 | 2 | 139 |
| ✅ Vector Store | 1 | 12 | 1211 |
| ✅ Vector Store | 1 | 12 | 1215 |
| ✅ Video | 2 | 6 | 649 |
| ✅ Vision | 2 | 1 | 329 |
| ✅ Web | 3 | 9 | 2467 |
| ✅ Workflows | 5 | 16 | 6589 |
| ✅ 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, 6713 lines)
- ℹ️ Database (42 docs, 6965 lines)
- ℹ️ Documents (1 docs, 787 lines)
---
@@ -14,16 +14,16 @@ import logging
import time
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from .turn_context import (
from praisonaiagents.runtime.turn_context import (
PreparedTurnContext,
TurnRuntimeProtocol,
RuntimeMode,
)
from .context_builder import default_context_builder
from ..streaming.events import StreamEvent, StreamEventType
from praisonaiagents.runtime.protocols import TurnRuntimeProtocol
from praisonaiagents.runtime.context_builder import default_context_builder
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
if TYPE_CHECKING:
from ..agent.protocols import AgentProtocol
from praisonaiagents.agent.protocols import AgentProtocol
logger = logging.getLogger(__name__)
@@ -363,4 +363,4 @@ async def demonstrate_harness_integration():
if __name__ == "__main__":
# Run the demonstration
asyncio.run(demonstrate_harness_integration())
asyncio.run(demonstrate_harness_integration())
@@ -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",
File diff suppressed because it is too large. Load diff
@@ -110,14 +110,32 @@ class AsyncSafeState:
def __init__(self, initial_value: Any = None):
self.value = initial_value
self._lock = DualLock()
self._activity_lock = threading.Lock()
self._async_holders = 0
self._copying = False
def __deepcopy__(self, memo):
"""Copy the protected value while replacing all lock state."""
"""Copy protected state, rejecting overlap with asynchronous access."""
result = type(self).__new__(type(self))
memo[id(self)] = result
with self.lock():
result.value = copy.deepcopy(self.value, memo)
with self._activity_lock:
if self._async_holders:
raise RuntimeError(
"Cannot deepcopy AsyncSafeState during asynchronous access"
)
if self._copying:
raise RuntimeError("AsyncSafeState deepcopy is already in progress")
self._copying = True
try:
with self.lock():
result.value = copy.deepcopy(self.value, memo)
finally:
with self._activity_lock:
self._copying = False
result._lock = DualLock()
result._activity_lock = threading.Lock()
result._async_holders = 0
result._copying = False
return result
@contextmanager
@@ -130,7 +148,25 @@ class AsyncSafeState:
async def async_lock(self):
"""Acquire lock in async context."""
async with self._lock.async_lock():
yield self.value
self._begin_async_access()
try:
yield self.value
finally:
self._end_async_access()
def _begin_async_access(self) -> None:
"""Register async access unless a synchronous copy is in progress."""
with self._activity_lock:
if self._copying:
raise RuntimeError(
"Cannot access AsyncSafeState during synchronous deepcopy"
)
self._async_holders += 1
def _end_async_access(self) -> None:
"""Unregister one active asynchronous accessor."""
with self._activity_lock:
self._async_holders -= 1
def __enter__(self):
"""Support for synchronous context manager protocol (backward compatibility)."""
@@ -146,12 +182,20 @@ class AsyncSafeState:
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
await async_lock.acquire()
try:
self._begin_async_access()
except Exception:
async_lock.release()
raise
return self.value
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Support for asynchronous context manager protocol."""
async_lock = self._lock._get_async_lock()
async_lock.release()
try:
self._end_async_access()
finally:
async_lock.release()
return None
def get(self) -> Any:
@@ -136,9 +136,8 @@ class AudioAgent:
- AudioConfig: Full configuration object
verbose: Verbosity level for output
"""
# Handle model= alias
if llm is None and model is not None:
llm = model
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "AudioAgent")
self.name = name or "AudioAgent"
self.instructions = instructions
@@ -106,8 +106,11 @@ class AutonomyConfig:
track_changes: Whether to track filesystem changes via shadow git.
Defaults to True when level="full_auto", False otherwise.
When enabled, agent.undo()/redo()/diff() become available.
sandbox: Optional SandboxConfig for execution isolation.
When level="full_auto" and sandbox is None, subprocess sandbox is auto-enabled.
sandbox: Optional SandboxConfig, carried for forward compatibility.
NOT IMPLEMENTED: nothing currently reads this field, and no sandbox
is auto-enabled at any autonomy level. To isolate execution, set it
on the agent itself -- Agent(sandbox=...) -- or run the whole
workflow remotely with AgentFlow(run_on="docker").
snapshot_dir: Directory for snapshot storage. Defaults to ~/.praisonai/snapshots.
Usage::
@@ -214,6 +217,13 @@ class AutonomyConfig:
f"Invalid autonomy level: {level!r}. "
f"Must be one of {sorted(VALID_AUTONOMY_LEVELS)}"
)
verification_hooks = data.get("verification_hooks")
# Config parity: accept declarative `verification:` entries and map
# `command:`/`file:` shapes onto the two hook classes.
declarative = data.get("verification")
if declarative:
built = _build_verification_hooks(declarative)
verification_hooks = (verification_hooks or []) + built
return cls(
enabled=data.get("enabled", True),
level=level,
@@ -224,7 +234,7 @@ class AutonomyConfig:
observe=data.get("observe", False),
completion_promise=data.get("completion_promise"),
clear_context=data.get("clear_context", False),
verification_hooks=data.get("verification_hooks"),
verification_hooks=verification_hooks,
track_changes=data.get("track_changes"),
sandbox=data.get("sandbox"),
snapshot_dir=data.get("snapshot_dir"),
@@ -234,6 +244,49 @@ class AutonomyConfig:
budget_action=data.get("budget_action", "pause"),
)
def _build_verification_hooks(entries: List[Any]) -> List[Any]:
"""Map declarative ``verification:`` config entries onto hook instances.
Each entry is a dict with either a ``command`` key (→
:class:`CommandVerificationHook`) or a ``file`` key (→
:class:`FileCheckHook`). Unknown shapes are skipped.
Example::
verification:
- {name: tests, command: "python -m pytest -q"}
- {name: changelog, file: CHANGELOG.md, non_empty: true}
"""
from ..hooks.verification import CommandVerificationHook, FileCheckHook
hooks: List[Any] = []
for entry in entries or []:
if not isinstance(entry, dict):
continue
blocking = entry.get("blocking", True)
if "command" in entry:
hooks.append(CommandVerificationHook(
name=entry.get("name", "command"),
command=entry["command"],
cwd=entry.get("cwd"),
timeout=entry.get("timeout", 60.0),
blocking=blocking,
))
elif "file" in entry:
hooks.append(FileCheckHook(
name=entry.get("name", "file"),
path=entry["file"],
exists=entry.get("exists", True),
non_empty=entry.get("non_empty", False),
contains=entry.get("contains"),
json_field=entry.get("json_field"),
equals=entry.get("equals"),
blocking=blocking,
))
return hooks
class AutonomySignal(str, Enum):
"""Signals detected from prompts for autonomy decisions.
@@ -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),
@@ -2371,7 +2404,11 @@ Your Goal: {self.goal}"""
task_name=task_name,
task_description=task_description,
task_id=task_id,
agent_name=getattr(self, 'name', 'assistant')
agent_name=getattr(self, 'name', 'assistant'),
# Durable re-injection target for deferred tool results:
# background jobs completing after this call must land in the
# persistent history a later turn replays, not the loop copy.
deferred_history_sink=getattr(self, 'chat_history', None),
)
return final_response
except ValueError as e:
@@ -2392,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),
@@ -2427,6 +2465,8 @@ Your Goal: {self.goal}"""
]
or None
),
# Durable re-injection target for deferred tool results (see above).
deferred_history_sink=getattr(self, 'chat_history', None),
)
return final_response
@@ -2437,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,
@@ -2775,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:
@@ -2783,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.
@@ -2882,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.
@@ -2895,6 +2986,14 @@ Your Goal: {self.goal}"""
'required' forces the LLM to call a tool before responding.
...other args...
"""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
# Slash-command invocation: /skill-name [args] renders the skill
# body before any backend/LLM call.
prompt = self._resolve_skill_invocation(prompt)
@@ -2921,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,
@@ -3292,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)
@@ -3392,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)
@@ -3411,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)
@@ -3432,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)
@@ -3443,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)
@@ -3467,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")
@@ -3484,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
@@ -3506,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})
@@ -3526,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)
@@ -3542,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
@@ -3553,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
@@ -3572,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:
@@ -3580,6 +3716,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
attachments: Optional list of image/file paths that are ephemeral
(used for THIS turn only, NEVER stored in history).
"""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
# Slash-command invocation: /skill-name [args] renders the skill body.
prompt = self._resolve_skill_invocation(prompt)
@@ -3611,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}")
@@ -4125,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
@@ -4243,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(
@@ -4259,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:
@@ -4273,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
@@ -4313,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
@@ -4343,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
@@ -4433,7 +4596,12 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# after-context aggregation) are fired once, guarded, inside
# execute_tool_async — no inline duplicate dispatch here.
# Pass the tools list to honor task-scoped tools
result = await self.execute_tool_async(function_name, arguments, tools_override=tools)
result = await self.execute_tool_async(
function_name,
arguments,
tool_call_id=getattr(tool_call, "id", None),
tools_override=tools,
)
results.append(result)
except Exception as e:
@@ -4534,6 +4702,14 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _start_stream(self, prompt: str, **kwargs) -> Generator[str, None, None]:
"""Own the durable context for the complete streaming generator."""
# Tools go to their sandbox before the first model call, once per
# agent. Placed on chat/achat/_start_stream rather than run/start
# because AgentTeam and AgentFlow call these three directly -- an
# agent used inside a team would otherwise run its tools locally.
if getattr(self, 'tools_run_on', None) is not None:
from .tools_placement import ensure_tools_placed
ensure_tools_placed(self)
durable_context = None
durable_token = None
try:
@@ -4828,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:
@@ -4921,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:
@@ -5221,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
@@ -5245,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
@@ -5308,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,7 +59,8 @@ class CodeAgent:
def __init__(
self,
name: str = "CodeAgent",
llm: Optional[str] = None,
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None,
code: Optional[Union[bool, Dict, CodeConfig]] = None,
instructions: Optional[str] = None,
verbose: bool = True,
@@ -69,14 +70,16 @@ class CodeAgent:
Args:
name: Agent name
llm: LLM model (default: gpt-4o-mini)
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Model to use (default: gpt-4o-mini)
code: Code configuration (bool, dict, or CodeConfig)
instructions: System instructions
verbose: Enable verbose output
**kwargs: Additional arguments
"""
from ..utils.model_alias import resolve_model_name
self.name = name
self.llm = llm or "gpt-4o-mini"
self.llm = resolve_model_name(llm, model, "CodeAgent") or "gpt-4o-mini"
self.instructions = instructions
self.verbose = verbose
@@ -17,6 +17,7 @@ from ..runtime.journal import (
KIND_MODEL_DECISION,
KIND_TOOL_CALL,
KIND_TOOL_RESULT,
KIND_VERIFICATION,
RunJournal,
)
@@ -58,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."""
@@ -80,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:
@@ -106,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)
@@ -119,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:
@@ -277,6 +402,26 @@ class DurableRunContext:
)
self.finalize("failed")
def record_verification(self, payloads: List[Dict[str, Any]]) -> None:
"""Append verification-gate results as durable ``verification`` events.
Each payload gets its own fresh ``seq`` so the events append cleanly
alongside the tool-call cursor without colliding on ``(run_id, seq,
kind)``. Best-effort: journalling never raises into the gate.
"""
if self._closed:
return
with self._lock:
for payload in payloads:
seq = self._next_seq
self._next_seq += 1
try:
self.journal.append(
JournalEvent(self.run_id, seq, KIND_VERIFICATION, payload)
)
except Exception: # pragma: no cover - journalling is best-effort
pass
def wrap_sync(self, execute_tool_fn: Optional[Callable]) -> Optional[Callable]:
if execute_tool_fn is None:
return None
@@ -286,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)
@@ -319,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)
@@ -110,8 +110,8 @@ class EmbeddingAgent:
- EmbeddingConfig: Full configuration object
verbose: Verbosity level for output
"""
if llm is None and model is not None:
llm = model
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "EmbeddingAgent")
self.name = name or "EmbeddingAgent"
self.instructions = instructions
@@ -0,0 +1,276 @@
"""Describe, in plain English, where an agent's work actually happens.
PraisonAI can run the thinking (LLM calls) and the tools in different places:
this machine, a container, or a provider's cloud. Which one you get depends on
``run_on=``, ``backend=`` and ``sandbox=``, and until now none of that was
visible from the object -- ``repr(agent)`` was the bare default.
This module is the single source of that answer, shared by ``Agent``,
``AgentTeam`` and ``AgentFlow`` so all three describe themselves identically.
Vocabulary note: the fields are ``thinks_on`` / ``tools_run_on``, never
``loop``. "Loop" is already a public workflow primitive here
(``praisonaiagents.workflows.loop()``), asyncio's event loop, and agent-loop
jargon a beginner would not know -- three meanings for one word is exactly the
problem this module exists to fix.
"""
from __future__ import annotations
from typing import Any, Dict, Optional
# Provider name -> how a human would say it out loud.
_PLACE_WORDS = {
"local": "this machine",
"subprocess": "a separate process on this machine",
"sandlock": "a locked-down process on this machine",
"docker": "a Docker container",
"e2b": "an E2B cloud sandbox",
"modal": "a Modal cloud sandbox",
"daytona": "a Daytona cloud sandbox",
"flyio": "a Fly.io machine",
"tenki": "a Tenki cloud sandbox",
"novita": "a Novita cloud sandbox",
"ssh": "a remote machine over SSH",
"anthropic": "Anthropic's cloud",
}
HERE = "this machine"
# Public sandbox aliases the SandboxManager collapses before it picks a backend
# (see praisonaiagents/sandbox/manager.py). We must describe the backend that
# actually runs, not the alias the user typed, or "native"/"local" would report
# a place that never executes.
_ALIASES = {"native": "sandlock", "local": "subprocess"}
# ...but only for the SANDBOX registry. "local" resolves to two different
# backends depending on which parameter carried it:
# run_in="local" -> SandboxManager collapses it to subprocess:
# scrubbed environment, blocked commands and paths
# tools_run_on="local" -> the wrapper's LocalCompute: a plain shell in the
# current directory, full environment inherited,
# no security policy at all
# Describing the second in the first's words overstates it, which is the exact
# failure this module exists to prevent. One word, two backends -- so the
# phrase has to depend on where the word came from.
_COMPUTE_ONLY_WORDS = {"local": "a plain shell on this machine (no policy applied)"}
# Tiers that separate a process but do NOT contain it. Verified: under the
# subprocess backend, outbound network still works and ~/.ssh is readable even
# though SecurityPolicy declares them blocked.
# `native` is deliberately absent: it aliases sandlock, which applies Landlock,
# seccomp, a scrubbed environment and deny-all networking. Listing it here made
# tools_run_on="native" and tools_run_on="sandlock" -- the same backend --
# disagree about whether they contain anything.
_NOT_A_BOUNDARY = {"subprocess", "local"}
def say_place(name: Any, *, via: str = "sandbox") -> str:
"""Turn a place name into a phrase a non-developer can read.
``via`` says which registry resolved the name -- ``"compute"`` for
``tools_run_on=``, ``"sandbox"`` for ``run_in=``. It only matters for names
that mean different backends in each (see ``_COMPUTE_ONLY_WORDS``).
"""
if name is None:
return HERE
if name is True:
return _PLACE_WORDS["subprocess"]
if not isinstance(name, str):
# A configured provider instance: use its own declared name if it has
# one. Normalise BEFORE the compute-only check, or a backend holding a
# LocalCompute object (provider_name="local") skips the branch and gets
# described in the subprocess backend's stronger words -- the exact
# overstatement this module exists to prevent.
name = getattr(name, "provider_name", None) or type(name).__name__
if via == "compute" and name.lower() in _COMPUTE_ONLY_WORDS:
return _COMPUTE_ONLY_WORDS[name.lower()]
key = str(name).lower()
key = _ALIASES.get(key, key)
known = _PLACE_WORDS.get(key)
if known:
return known
# A place contributed by another package can name itself; the bare name is
# the last resort rather than the only option, because the phrase table is
# a literal that cannot know about packages installed later.
try:
from ..managed._compute_bridge import contributed_display_name
declared = contributed_display_name(key)
if declared:
return declared
except Exception:
pass
return str(name)
def _backend_places(backend: Any) -> Optional[Dict[str, str]]:
"""Where does a managed ``backend=`` put the thinking and the tools?"""
if backend is None:
return None
# Ask the backend what it is, rather than guessing from its class name.
# Name-matching missed any backend that did not happen to contain "Hosted"
# or "Anthropic" -- a self-hosted docker backend reported that it was
# thinking on this machine while the whole loop ran in a container.
declared = getattr(backend, "provider_name", None)
if isinstance(declared, str):
try:
from .placement import managed_runtimes
if declared.lower() in managed_runtimes():
where = say_place(declared)
return {"thinks_on": where, "tools_run_on": where}
except Exception:
pass
cls = type(backend).__name__
# Hosted runtimes move the entire turn off this machine.
if "Hosted" in cls or "Anthropic" in cls:
provider = getattr(backend, "provider", None) or "anthropic"
where = say_place(provider)
return {"thinks_on": where, "tools_run_on": where}
# Local/sandboxed agents keep the loop here and may push tools out. A
# backend's compute object is a COMPUTE-registry provider, so it is read
# with the compute vocabulary: a LocalCompute here is the unrestricted
# shell, not the subprocess backend "local" collapses to under run_in=.
compute = getattr(backend, "_compute", None) or getattr(backend, "compute", None)
if compute is not None:
return {"thinks_on": HERE, "tools_run_on": say_place(compute, via="compute")}
return {"thinks_on": HERE, "tools_run_on": HERE}
def describe(obj: Any, *, shared: bool = False) -> Dict[str, str]:
"""Return ``{thinks_on, tools_run_on, code_runs_on?}`` for an object.
Never raises: a description of where things run must not be able to break
the thing it describes.
"""
places: Dict[str, str] = {"thinks_on": HERE, "tools_run_on": HERE}
try:
from_backend = _backend_places(getattr(obj, "backend", None))
if from_backend:
places.update(from_backend)
where_tools = getattr(obj, "tools_run_on", None)
if where_tools is not None:
where = say_place(where_tools, via="compute")
places["tools_run_on"] = f"{where} (shared)" if shared else where
# run_on= puts the WHOLE agent on a managed runtime, so it moves the
# thinking as well. _backend_places() already reports that (run_on= is
# turned into a backend at construction); this only covers an object
# that carries the name without a built backend.
whole = getattr(obj, "run_on", None)
if whole is not None and not from_backend:
everywhere = say_place(whole)
places["thinks_on"] = everywhere
places["tools_run_on"] = everywhere
sandbox_config = getattr(obj, "sandbox_config", None)
if sandbox_config is not None:
places["code_runs_on"] = say_place(
getattr(sandbox_config, "sandbox_type", None)
)
except Exception: # pragma: no cover - description must never break callers
pass
return places
def _tools_staying_here(obj: Any) -> list:
"""Names of the object's own tools, which do NOT move to the sandbox.
Only four tool names are bridged (``execute_command``, ``read_file``,
``write_file``, ``list_files``); everything else the user passed keeps
running in this process, against this filesystem. The capability prompt
tells the model the sandbox is where things happen, so anything staying
behind has to be said out loud.
Reads the pre-attach tool list when a sandbox is already attached: by then
``agent.tools`` has been rewritten and the originals live in the sandbox's
restore record.
"""
try:
from ..managed.shared_compute import BRIDGED_TOOL_NAMES
except Exception:
BRIDGED_TOOL_NAMES = ("execute_command", "read_file", "write_file", "list_files")
tools = None
shared = getattr(obj, "_tools_sandbox", None)
if shared is not None:
for patched, original_tools, _ in getattr(shared, "_patched", []):
if patched is obj:
tools = original_tools
break
if tools is None:
tools = getattr(obj, "tools", None) or []
names = []
for tool in tools:
name = getattr(tool, "__name__", None) or getattr(tool, "name", None)
if isinstance(name, str) and name not in BRIDGED_TOOL_NAMES:
names.append(name)
return names
def repr_fields(obj: Any, *, shared: bool = False) -> str:
"""Render the places as ``key='value'`` pairs for a ``__repr__``."""
return ", ".join(f"{k}={v!r}" for k, v in describe(obj, shared=shared).items())
def explain(obj: Any, *, shared: bool = False) -> str:
"""Plain-sentence answer to "where does this actually run?".
Deliberately uses only words a non-engineer already knows -- no "loop",
"harness", "provision" or "runtime".
"""
places = describe(obj, shared=shared)
lines = [
f"Thinking (the AI model calls) happens on {places['thinks_on']}.",
f"Tools run on {places['tools_run_on']}.",
]
if shared and getattr(obj, "tools_run_on", None) is not None:
lines.append(
"Every step shares that same sandbox, so a file written by one step "
"is visible to the next."
)
staying = _tools_staying_here(obj)
if staying and places["tools_run_on"] != places["thinks_on"]:
listed = ", ".join(staying[:6]) + ("..." if len(staying) > 6 else "")
lines.append(
f"Your own tools ({listed}) still run on {HERE} -- only shell, file "
f"and code tools move. They read and write this machine's files."
)
if "code_runs_on" in places:
lines.append(
f"Code you run yourself with execute_code() runs on {places['code_runs_on']}."
)
# Say plainly when the chosen tier is not a security boundary.
raw = [
getattr(obj, "tools_run_on", None),
getattr(obj, "run_on", None),
getattr(getattr(obj, "sandbox_config", None), "sandbox_type", None),
]
if any(isinstance(v, str) and v.lower() in _NOT_A_BOUNDARY for v in raw) or any(
v is True for v in raw
):
lines.append(
"Note: a separate process is not a security boundary -- it can still "
"reach the network and read your files, and its blocked-command "
"list can be worked around by ordinary shell syntax. Use docker or a cloud "
"provider for untrusted code."
)
if places["thinks_on"] == HERE and places["tools_run_on"] == HERE:
lines.append(
"Nothing is isolated: tools you pass run in this program, with your "
"permissions."
)
return "\n".join(lines)
@@ -1456,6 +1456,18 @@ Write the complete compiled report:"""
after_results = await hook_runner.execute(
HookEvent.AFTER_TOOL, after_tool_input, target=function_name
)
# Honour a post-tool block so a redacted/denied result never
# reaches the model (mirrors the BEFORE_TOOL block path above).
if hook_runner.is_blocked(after_results):
reason = hook_runner.get_blocking_reason(after_results)
logging.warning(f"Tool {function_name} result blocked by AFTER_TOOL hook")
return _record_async_loop_outcome(
reason or f"Result of {function_name} was blocked by security policy."
)
# Read back any rewritten/redacted tool_output so a plugin can
# scrub secrets before the result reaches the model.
if getattr(after_tool_input, "tool_output", result) is not result:
result = after_tool_input.tool_output
extra_context = hook_runner.aggregate_context(after_results)
if extra_context:
if isinstance(result, str):
@@ -1507,6 +1519,7 @@ Write the complete compiled report:"""
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
),
)
@@ -1528,6 +1541,7 @@ Write the complete compiled report:"""
async def _execute_tool_async_with_retry(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
"""Async tool execution with retry policy (middleware-agnostic core)."""
from ..errors import ToolExecutionError
# Get retry policy (tool-level > agent-level > default)
retry_policy = self._get_tool_retry_policy(function_name)
@@ -1544,22 +1558,50 @@ Write the complete compiled report:"""
# Skip retry for non-retryable errors (approval, permission, etc.).
# `retryable is False` covers async tool timeouts, whose executor
# work cannot be cancelled — retrying would duplicate side effects.
# `loop_blocked` is a terminal loop-guard decision — retrying would
# defeat the doom-loop protection this stop is meant to add.
if (result.get("approval_denied") or
result.get("permission_denied") or
result.get("approval_error") or
result.get("circuit_open") or
result.get("retryable") is False):
result.get("loop_blocked") or
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)
# Check if we should retry
if not retry_policy.should_retry(error_type, attempt):
# Check if we should retry. A tool that *raised* is flattened
# into an error dict before it can be classified, so it always
# arrives here as "unknown" — which no default retry_on
# contains. The sync path escalates exactly those error types
# to its own retry loop (tool_execution.py:853-866); honouring
# the impl's explicit `retryable` flag is how the async path
# reaches the same decision instead of never retrying at all.
# `_praison_retryable` is a private control-plane key (see
# _execute_tool_async_impl): a tool's own result may legitimately
# contain "retryable" (common for HTTP-API wrappers) and must
# never steer our retry loop.
if (not retry_policy.should_retry(error_type, attempt)
and result.get("_praison_retryable") is not True):
result.pop("_praison_retryable", None)
return result
# Don't retry on last attempt
if attempt == retry_policy.max_attempts - 1:
result.pop("_praison_retryable", None)
return result
# Emit retry hook event
@@ -1573,6 +1615,11 @@ Write the complete compiled report:"""
# Success - return result
return result
except ToolExecutionError:
# A loop-guard HALT (raised in _execute_tool_async_impl) is a
# terminal, non-retryable decision — let it propagate to stop the
# run instead of retrying or swallowing it into an error dict.
raise
except Exception as e:
last_exception = e
# Determine if retryable
@@ -1604,6 +1651,7 @@ Write the complete compiled report:"""
async def _execute_tool_async_impl(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None, tools_override: Optional[List] = None) -> Any:
"""Internal async tool execution implementation"""
from ..errors import ToolExecutionError
try:
logging.info(f"Executing async tool: {function_name} with arguments: {arguments}")
@@ -1651,6 +1699,29 @@ Write the complete compiled report:"""
logging.error(f"Function {function_name} not found in tools")
return {"error": f"Function {function_name} not found in tools"}
# Loop guard (pre-execution) — parity with the sync path in
# tool_execution.py. The async path previously had no loop-guard or
# doom-loop protection, so a repeatedly-failing tool could be hammered
# every turn via achat()/async workflows. check() is pure/sync, so it
# is safe to call here without awaiting.
loop_guard = None
if hasattr(self, '_ensure_loop_guard'):
loop_guard = self._ensure_loop_guard()
from ..escalation.loop_guard import GuardAction
decision = loop_guard.check(function_name, arguments, is_pre_execution=True)
if decision.action == GuardAction.BLOCK:
logging.warning(f"Loop guard blocked {function_name}: {decision.message}")
return {"error": f"[loop-guard] {decision.message}", "loop_blocked": True}
elif decision.action == GuardAction.HALT:
raise ToolExecutionError(
f"[loop-guard] {decision.message}",
tool_name=function_name,
agent_id=self.name,
is_retryable=False,
)
elif decision.action == GuardAction.WARN:
logging.warning(f"Loop guard warning for {function_name}: {decision.message}")
# Activate the tool-progress channel so tools running under the async
# path can stream incremental output (emit_tool_progress) and the
# built-in todo tool can publish live updates (emit_todo_update),
@@ -1723,14 +1794,33 @@ Write the complete compiled report:"""
# a duplicate invocation while the original keeps executing —
# duplicating DB writes / API calls / file mutations. Surface the
# timeout once instead of re-running an uncancellable side effect.
return {
# Fall through (not return) so the loop guard records this
# timeout as a failure — repeated timeouts must accumulate
# toward the BLOCK/HALT thresholds like any other failure.
result = {
"error": f"Tool timed out after {tool_timeout}s",
"timeout": True,
"retryable": False,
"_praison_retryable": False,
}
else:
result = await _invoke()
# Loop guard (post-execution) — record the outcome and surface a
# block/halt decision back to the model on this same turn, mirroring
# the sync path in tool_execution.py so repeated failures accumulate
# toward the BLOCK/HALT thresholds instead of retrying unbounded.
if loop_guard is not None:
is_success = result is not None and not (isinstance(result, dict) and result.get('error'))
loop_guard.record(function_name, arguments, is_success, result=result)
decision = loop_guard.check(function_name, arguments, is_pre_execution=False)
if decision.action.value in ("warn", "block", "halt"):
if isinstance(result, str):
result = f"{result}\n\n[loop-guard] {decision.message}"
elif isinstance(result, dict):
result["_loop_guard"] = {"message": decision.message, "action": decision.action.value}
else:
result = {"value": result, "_loop_guard": {"message": decision.message, "action": decision.action.value}}
# Ensure result is JSON serializable
logging.debug(f"Raw result from tool: {result}")
if result is None:
@@ -1744,24 +1834,61 @@ Write the complete compiled report:"""
except Exception as e:
logging.error(f"Error executing {function_name}: {str(e)}", exc_info=True)
return {"error": f"Error executing {function_name}: {str(e)}"}
# Record the failed invocation so repeated identical failures
# accumulate toward the loop-guard BLOCK/HALT thresholds — a raised
# tool exception is a failure just like an error-dict result on the
# sync path. The next pre-execution check will then BLOCK/HALT.
if loop_guard is not None:
loop_guard.record(function_name, arguments, False, result=None)
# Carry the retry verdict with the flattened exception. Without it
# the caller only sees an opaque error string, classifies it as
# "unknown" (never in RetryPolicy.retry_on) and gives up — while
# the sync path, which still has the exception object, retries.
# Mirror the sync path exactly: a ToolExecutionError already
# carries an explicit is_retryable verdict (e.g. a terminal
# non-retryable failure) and must be honoured verbatim; for any
# other exception apply the same terminal rule — programming
# errors are terminal, everything else may be retried.
if isinstance(e, ToolExecutionError):
retryable = e.is_retryable
else:
retryable = not isinstance(e, (ValueError, TypeError, AttributeError))
# Private control-plane key: a tool's own result may legitimately
# contain "retryable" (common for HTTP-API wrappers) and must not
# collide with the framework's verdict nor leak to the model.
return {
"error": f"Error executing {function_name}: {str(e)}",
"_praison_retryable": retryable,
}
except ToolExecutionError:
# A loop-guard HALT must propagate to stop the run, mirroring the sync
# path; do not swallow it into an error dict.
raise
except Exception as e:
logging.error(f"Error in execute_tool_async: {str(e)}", exc_info=True)
return {"error": f"Error in execute_tool_async: {str(e)}"}
def launch(self, path: str = '/', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
def launch(self, path: str = '/', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
"""
Launch the agent as an HTTP API endpoint or MCP server.
This method now delegates to protocol-based launchers to follow
the protocol-driven architecture. Heavy implementations (FastAPI/uvicorn)
are only imported when needed through lazy loading.
Request authorisation matches ``PraisonAIAgents.launch``: when
``PRAISONAI_LAUNCH_AUTH_TOKEN`` is set, every HTTP route requires that
bearer token (401 otherwise). When it is unset and ``host`` is
non-loopback, a one-time token is generated and printed rather than
serving an unauthenticated endpoint on all interfaces.
Args:
path: API endpoint path (default: '/') for HTTP, or base path for MCP.
port: Server port (default: 8000)
host: Server host (default: '0.0.0.0')
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
avoid binding all interfaces by default — pass '0.0.0.0'
explicitly to expose externally).
debug: Enable debug mode (default: False)
protocol: "http" to launch as FastAPI, "mcp" to launch as MCP server.
@@ -1785,6 +1912,8 @@ Write the complete compiled report:"""
"""
global _server_started, _registered_agents, _shared_apps, _server_lock
from .launch_security import authorise_launch_request, resolve_launch_host
# Try to import FastAPI dependencies - lazy loading
try:
import uvicorn
@@ -1809,7 +1938,15 @@ Write the complete compiled report:"""
print("\nOr install all API dependencies with:")
print("pip install 'praisonaiagents[api]'")
return None
# Fail-closed bind guard: refuse to serve keyless on a non-loopback host
# (auto-generates and prints a one-time token instead). Matches the
# serve/jobs precedent and keeps this path in lock-step with
# PraisonAIAgents.launch. Applied only after the dependency guard above
# so a launch that bails out (missing deps) cannot mutate the
# process-wide launch token and retroactively 401 a running endpoint.
host = resolve_launch_host(host)
should_start = False
with _server_lock:
# Initialize port-specific collections if needed (once per port)
@@ -1862,6 +1999,8 @@ Write the complete compiled report:"""
# Define the endpoint handler
@_shared_apps[port].post(path)
async def handle_agent_query(request: Request, query_data: Optional[AgentQuery] = None):
if not authorise_launch_request(request):
raise HTTPException(status_code=401, detail="Unauthorized")
# Handle both direct JSON with query field and form data
if query_data is None:
try:
@@ -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,
@@ -0,0 +1,88 @@
"""Shared authorisation + bind guard for the ``launch()`` serving paths.
Both ``Agent.launch()`` (``agent/execution_mixin.py``) and
``PraisonAIAgents.launch()`` (``agents/agents.py``) expose a prompt-execution
HTTP endpoint. They must enforce identical request authorisation and must not
silently bind all interfaces without a token. Factoring the two helpers here
keeps that policy in one place so the two launch paths cannot drift apart.
Stdlib only — no heavy imports, no new public params.
"""
from __future__ import annotations
import os
import secrets as _secrets
from typing import Optional
__all__ = [
"launch_auth_token",
"authorise_launch_request",
"resolve_launch_host",
]
# Hosts that are safe to serve keyless on: loopback only.
_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
# One auto-generated token per process, reused across launches so a later
# non-loopback launch cannot mint a fresh token and lock out an endpoint that
# is already serving with the previously generated one.
_GENERATED_TOKEN: Optional[str] = None
def launch_auth_token() -> Optional[str]:
"""Return the configured launch bearer token, if any."""
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
def authorise_launch_request(request) -> bool:
"""Return True if the request carries the configured bearer token.
When no token is configured this returns True (local-dev flow preserved).
Accepts either ``Authorization: Bearer <token>`` or ``X-Auth-Token``.
"""
token = launch_auth_token()
if not token:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and _secrets.compare_digest(auth[7:], token):
return True
supplied = request.headers.get("X-Auth-Token", "")
return bool(supplied) and _secrets.compare_digest(supplied, token)
def _is_loopback(host: str) -> bool:
return host in _LOOPBACK_HOSTS
def resolve_launch_host(host: str) -> str:
"""Fail-closed bind guard, mirroring the ``serve``/jobs precedent.
* Token set → bind ``host`` unchanged (auth protects the endpoint).
* Token unset + loopback host → bind unchanged (local dev unchanged).
* Token unset + non-loopback host → auto-generate a token, export it via
``PRAISONAI_LAUNCH_AUTH_TOKEN`` so every route enforces it, and print it
once. This closes the unauthenticated-exposure hole without refusing to
start.
Returns the host to bind (unchanged; the guard acts on the token, not the
host, so an explicit ``0.0.0.0`` is still honoured — just never keyless).
A generated token is reused for the lifetime of the process: repeated
non-loopback launches share the one token rather than each minting a fresh
one, so a later launch cannot invalidate an already-running endpoint's
clients.
"""
if launch_auth_token() or _is_loopback(host):
return host
global _GENERATED_TOKEN
if _GENERATED_TOKEN is None:
_GENERATED_TOKEN = _secrets.token_urlsafe(32)
print(
f"🔐 launch() bound to non-loopback host {host!r} without "
f"PRAISONAI_LAUNCH_AUTH_TOKEN set. Generated a one-time bearer token "
f"(set PRAISONAI_LAUNCH_AUTH_TOKEN to override): {_GENERATED_TOKEN}"
)
os.environ["PRAISONAI_LAUNCH_AUTH_TOKEN"] = _GENERATED_TOKEN
return host
@@ -89,8 +89,8 @@ class OCRAgent:
ocr: OCR configuration
verbose: Verbosity level for output
"""
if llm is None and model is not None:
llm = model
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "OCRAgent")
self.name = name or "OCRAgent"
self.instructions = instructions
@@ -0,0 +1,211 @@
"""One vocabulary for "where does this run?", shared by Agent, AgentFlow and AgentTeam.
Two questions get asked about placement and they have different answers:
``run_on=``
Where the **whole agent** runs -- the model calls, the loop, the tools, all
of it on someone else's machine. Only a *managed runtime* can answer this,
because running the loop remotely means something has to host the loop.
``tools_run_on=``
Where **the tools** run. Thinking stays on this machine; only the shell,
the file writes and the code execution move. Any sandbox or compute place
can answer this, because all it has to do is run a command.
Keeping the two words apart is the entire point. The old surface used one word
(``run_on=``) on ``AgentFlow``/``AgentTeam`` for the tools-only meaning, another
(``compute=``) on ``LocalAgent`` for exactly the same meaning, and a third
(``backend=``) for the whole-agent meaning -- so the same concept had two names
and one name had two concepts.
Because the two questions have disjoint answer sets, a wrong pairing is a typo
worth catching rather than a preference worth honouring. ``run_on="docker"``
cannot mean anything: Docker does not host agent loops. It gets a ``TypeError``
naming the parameter the user actually wanted, not a silent fallback.
"""
from __future__ import annotations
from typing import Any, Literal, NamedTuple, Optional, Union
# Runtimes that can host an entire agent loop. Resolved from the wrapper's
# entry-point registry when it is installed so third-party backends work with
# `run_on=` too; the literal is the floor, not the ceiling.
_FALLBACK_MANAGED = ("anthropic",)
# Static spellings so editors can autocomplete and type-checkers can reject a
# typo at the call site rather than at the first model turn. The runtime
# functions below stay the authority for validation -- these exist for tooling,
# which cannot call a function to learn a set.
# Keep in sync with _compute_bridge._PROVIDERS | _sandbox_adapter.SANDBOX_ONLY.
ManagedRuntime = Literal["anthropic"]
ToolPlace = Literal[
"local", "subprocess", "sandlock", "docker", "e2b",
"modal", "daytona", "flyio", "tenki", "novita", "ssh",
]
def managed_runtimes() -> tuple:
"""Names that ``run_on=`` accepts -- places that host a whole agent."""
try:
from praisonai.integrations.backend_registry import get_backend_registry
names = tuple(get_backend_registry().list_all_names())
if names:
return tuple(sorted(set(names) | set(_FALLBACK_MANAGED)))
except Exception:
pass
return _FALLBACK_MANAGED
def tool_places() -> list:
"""Names that ``tools_run_on=`` accepts -- places that can run a command.
Includes the resolver's aliases. Validation runs before resolution, so
leaving them out rejected spellings the resolver handles happily:
``tools_run_on="native"`` raised "not a known place" while
``resolve_compute("native")`` returned a working sandlock adapter.
"""
try:
from ..managed._compute_bridge import _ALIASES, available_providers
return sorted(set(available_providers()) | set(_ALIASES))
except Exception:
return ["local", "docker"]
class Placement(NamedTuple):
"""The resolved answer to "where does this run?"."""
whole: Optional[Any] = None # managed runtime name/object, or None
tools: Optional[Any] = None # tools place name/object, or None
def _name_of(value: Any) -> Optional[str]:
"""The comparable name of a placement value, or None if it is an object.
Objects (a built provider, a live ``SSHSandbox``) carry their own config and
are never second-guessed -- only bare strings get routed by name.
"""
return value.lower() if isinstance(value, str) else None
def _quote_list(names) -> str:
return ", ".join(repr(n) for n in names)
def resolve_placement(
owner: str,
*,
run_on: Any = None,
tools_run_on: Any = None,
backend: Any = None,
supports_run_on: bool = True,
) -> Placement:
"""Validate placement arguments and return where things should run.
``owner`` is the class name, used only to make errors read naturally.
``supports_run_on`` is False for AgentFlow/AgentTeam, which can place tools
but cannot (yet) hand a whole multi-agent run to a managed runtime.
Raises:
TypeError: on any combination that names two different places for the
same thing, or a place that cannot do the job asked of it.
"""
managed = managed_runtimes()
places = tool_places()
# ── a place that cannot do the job asked of it ───────────────────────────
run_on_name = _name_of(run_on)
if run_on_name is not None and run_on_name not in managed:
if run_on_name in places:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not valid: run_on= places the "
f"whole agent -- model calls, loop and tools -- on a managed "
f"runtime, and {run_on_name!r} runs commands but cannot host an "
f"agent loop.\n"
f" To run only the tools there: {owner}(tools_run_on={run_on!r})\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}"
)
raise TypeError(
f"{owner}(run_on={run_on!r}) is not a known managed runtime.\n"
f" Runtimes that host a whole agent: {_quote_list(managed)}\n"
f" Places that can run tools: {_quote_list(places)} "
f"(pass those as tools_run_on=)"
)
tools_name = _name_of(tools_run_on)
if tools_name is not None and tools_name in managed and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not valid: {tools_name!r} "
f"hosts an entire agent, not individual tools.\n"
f" Did you mean: {owner}(run_on={tools_run_on!r})"
)
# A typo here used to survive construction and only surface on the first
# model turn -- after the prompt was built and, for a real run, after the
# API spend. run_on= validates at the call site and YAML validates at load
# time; this is the same standard for the third spelling.
if tools_name is not None and tools_name not in places:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) is not a known place.\n"
f" Places that can run tools: {_quote_list(places)}\n"
f" Runtimes that host a whole agent: {_quote_list(managed)} "
f"(pass those as run_on=)"
)
# Names that cannot work as a bare string, because the connection details
# live in the object. Catch it now rather than at first use.
if tools_name is not None:
try:
from ..managed._sandbox_adapter import NEEDS_INSTANCE
except Exception:
NEEDS_INSTANCE = {}
if tools_name in NEEDS_INSTANCE:
raise TypeError(
f"{owner}(tools_run_on={tools_run_on!r}) needs more than a name: "
f"{NEEDS_INSTANCE[tools_name]}"
)
# ── two names for the same thing ─────────────────────────────────────────
if run_on is not None and backend is not None:
raise TypeError(
f"{owner}(run_on=..., backend=...) sets the agent's runtime twice. "
f"run_on={run_on!r} is the short form of backend=HostedAgent("
f"provider={run_on!r}); pass one or the other.\n"
f" Use backend= only when you need to configure the runtime "
f"(model, system prompt, tools)."
)
if run_on is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(run_on={run_on!r}, tools_run_on={tools_run_on!r}) points "
f"the tools at two machines. run_on= already runs everything -- "
f"including tools -- on {run_on!r}.\n"
f" Whole agent remote: {owner}(run_on={run_on!r})\n"
f" Only tools remote: {owner}(tools_run_on={tools_run_on!r})"
)
if backend is not None and tools_run_on is not None:
raise TypeError(
f"{owner}(backend=..., tools_run_on={tools_run_on!r}) points the "
f"tools at two machines: backend= runs the whole agent (tools "
f"included) on its managed runtime, so the tools cannot also run on "
f"{tools_run_on!r}.\n"
f" Drop tools_run_on= to keep the hosted runtime, or drop backend= "
f"to keep local thinking with remote tools."
)
# ── a class that cannot answer this question ─────────────────────────────
if run_on is not None and not supports_run_on:
raise TypeError(
f"{owner}(run_on={run_on!r}) is not supported: run_on= hands one "
f"agent's whole loop to a managed runtime, and a {owner} "
f"orchestrates several agents locally.\n"
f" To run every step's tools in one shared sandbox:\n"
f" {owner}(tools_run_on={run_on!r})\n"
f" To host a single agent's loop, put run_on= on that Agent."
)
return Placement(whole=run_on, tools=tools_run_on)
@@ -57,7 +57,8 @@ class RealtimeAgent:
def __init__(
self,
name: str = "RealtimeAgent",
llm: Optional[str] = None,
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None,
realtime: Optional[Union[bool, Dict, RealtimeConfig]] = None,
instructions: Optional[str] = None,
verbose: bool = True,
@@ -67,14 +68,16 @@ class RealtimeAgent:
Args:
name: Agent name
llm: LLM model (default: gpt-4o-realtime-preview)
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Model to use (default: gpt-4o-realtime-preview)
realtime: Realtime configuration (bool, dict, or RealtimeConfig)
instructions: System instructions
verbose: Enable verbose output
**kwargs: Additional arguments
"""
from ..utils.model_alias import resolve_model_name
self.name = name
self.llm = llm or "gpt-4o-realtime-preview"
self.llm = resolve_model_name(llm, model, "RealtimeAgent") or "gpt-4o-realtime-preview"
self.instructions = instructions
self.verbose = verbose
@@ -52,19 +52,61 @@ class SandboxMixin:
return self._sandbox_manager
def _manager_for(self, run_in: Optional[Union[bool, str, 'SandboxConfig']]):
"""Return the sandbox manager for a ``run_in=`` argument.
``run_in=`` names where a single ``execute_code()`` call runs, so the
place lives on the call that uses it rather than on the constructor.
``Agent(sandbox=...)`` still sets the default for every call.
Managers are cached per place: a fresh ``SandboxManager`` per call would
start and tear down a container each time, which for Docker is seconds
of latency and for any backend means the previous call's files are gone.
"""
if run_in is None:
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Either name a place on the call --\n"
" agent.execute_code_sync(code, run_in='docker')\n"
"or set a default for every call with Agent(sandbox='docker')."
)
return self.get_sandbox_manager()
from ..sandbox import SandboxConfig, SandboxManager
if run_in is True:
config = SandboxConfig.subprocess()
elif isinstance(run_in, str):
config = SandboxConfig(sandbox_type=run_in)
else:
config = run_in
key = getattr(config, "sandbox_type", None) or repr(config)
cache = getattr(self, "_sandbox_managers", None)
if cache is None:
cache = self._sandbox_managers = {}
if key not in cache:
cache[key] = SandboxManager(config)
return cache[key]
async def execute_code(
self,
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Execute code safely in configured sandbox.
"""Execute code safely in a sandbox.
Args:
code: Code to execute
language: Programming language (python, bash, etc.)
check_security: Whether to run security pre-checks
run_in: Where to run this call -- "docker", "sandlock",
"subprocess", a SandboxConfig, or True for the default local
sandbox. Overrides Agent(sandbox=...) for this call only.
**kwargs: Additional arguments passed to sandbox.execute()
Returns:
@@ -73,31 +115,39 @@ class SandboxMixin:
Raises:
RuntimeError: If no sandbox is configured
"""
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
)
manager = self._manager_for(run_in)
# Security pre-check if enabled
security_warnings = None
if check_security:
from ..sandbox import check_code_safety, format_warnings
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
security_warnings = check_code_safety(code, language)
if security_warnings:
warning_text = format_warnings(security_warnings)
if self.verbose:
logger.warning(f"Security warnings for code execution:\n{warning_text}")
# Store warnings in result metadata
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
manager = self.get_sandbox_manager()
return await manager.run_code(code, language=language, **kwargs)
result = await manager.run_code(code, language=language, **kwargs)
# Attach warnings to the result rather than forwarding a `metadata`
# kwarg: SandboxProtocol.execute() has no such parameter, so passing it
# through raised TypeError for any code that tripped a warning -- i.e.
# sandboxed execution failed exactly when the check mattered most.
if security_warnings:
try:
existing = getattr(result, "metadata", None) or {}
existing["security_warnings"] = security_warnings
result.metadata = existing
except Exception: # pragma: no cover - result may be immutable
logger.debug("Could not attach security warnings to sandbox result")
return result
def execute_code_sync(
self,
code: str,
language: str = "python",
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Synchronous wrapper for execute_code.
@@ -112,12 +162,15 @@ class SandboxMixin:
SandboxResult with execution details
"""
from ..approval.utils import run_coroutine_safely
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
return run_coroutine_safely(
self.execute_code(code, language, check_security, run_in=run_in, **kwargs)
)
async def run_shell_command(
self,
command: Union[str, list],
check_security: bool = True,
run_in: Optional[Union[bool, str, 'SandboxConfig']] = None,
**kwargs
) -> 'SandboxResult':
"""Run a shell command in the sandbox.
@@ -130,8 +183,7 @@ class SandboxMixin:
Returns:
SandboxResult with execution details
"""
if not self.has_sandbox:
raise RuntimeError("No sandbox configured")
manager = self._manager_for(run_in)
# Convert command to string for security checking
if isinstance(command, list):
@@ -148,7 +200,7 @@ class SandboxMixin:
if self.verbose:
logger.warning(f"Security warnings for command:\n{warning_text}")
async with self.get_sandbox_manager() as sandbox:
async with manager as sandbox:
return await sandbox.run_command(command, **kwargs)
def get_sandbox_status(self) -> Dict[str, Any]:
@@ -230,5 +282,15 @@ class SandboxMixin:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
return [execute_python_code, execute_shell_command]
# Tag as framework-generated so clone_for_channel() can drop these
# source-bound wrappers and regenerate clone-owned ones. Without the
# tag, a clone would inherit tools closing over the *source* agent and
# silently share its SandboxManager.
generated = [execute_python_code, execute_shell_command]
for _t in generated:
try:
_t._praison_sandbox_tool = True
except Exception: # pragma: no cover - tool object may be immutable
pass
return generated
@@ -52,14 +52,29 @@ class SkillReviewMixin:
return policy
def _build_skill_review_tool(self):
"""Return the restricted toolset for the review turn (skill_manage only)."""
"""Return the restricted toolset for the review turn.
Always exposes the single skill-mutation tool (``skill_manage``). When
the agent has memory enabled, ``store_memory`` is added so the same
guarded pass can also persist a durable fact / user-model detail
(issue #4030) — closing the learning loop for memory, not just skills,
without a second review turn or a parallel knob. The set stays
restricted: never the agent's full toolset.
"""
tools = []
try:
from ..tools.skill_tools import create_skill_tools
skill_tools = create_skill_tools()
return [skill_tools.skill_manage]
tools.append(skill_tools.skill_manage)
except Exception as e: # pragma: no cover - defensive
logger.debug("Could not build skill review tool: %s", e, exc_info=True)
return []
if getattr(self, "memory", None):
try:
from ..tools.memory import store_memory
tools.append(store_memory)
except Exception as e: # pragma: no cover - defensive
logger.debug("Could not build memory review tool: %s", e, exc_info=True)
return tools
def _skill_review_log_context(self):
"""Return (agent_name, session_id) for swallowed-failure warnings."""
@@ -22,6 +22,13 @@ from ..config.feature_configs import DEFAULT_TOOL_OUTPUT_LIMIT
logger = logging.getLogger(__name__)
# Upper bound on how many timed-out (still-blocked) tool executors a single
# agent may retire before it stops recycling and falls back to reusing the
# existing bounded pool. This keeps the "fresh worker after a timeout"
# throughput win while capping leaked threads from pathological repeated
# timeouts so they cannot exhaust process resources.
_MAX_ORPHANED_TOOL_EXECUTORS = 4
if TYPE_CHECKING:
pass
@@ -367,6 +374,12 @@ class ToolExecutionMixin:
return resolve_tool_names(tool_names)
def _resolve_tools_list(self, tools):
"""Resolve any tool-name strings inside a mixed ``tools=`` list."""
from ..tools.resolver import resolve_tools_list
return resolve_tools_list(tools)
def _cast_arguments(self, func, arguments):
"""Cast arguments to their expected types based on function signature."""
if not callable(func) or not arguments:
@@ -482,6 +495,7 @@ class ToolExecutionMixin:
run_id=getattr(self, '_current_run_id', 'unknown'),
session_id=getattr(self, '_session_id', None) or 'default',
tool_name=function_name,
metadata={"tool_call_id": tool_call_id},
),
)
@@ -739,26 +753,21 @@ class ToolExecutionMixin:
if blocked_result is not None:
result = blocked_result
else:
# Apply tool retry logic with exponential backoff
execution_config = getattr(self, '_execution_config', None)
if execution_config is None:
# Fall back to reading individual config attributes for backward compatibility
max_retry_limit = getattr(self, 'max_retry_limit', 2)
retry_initial_delay = 1.0
retry_backoff_factor = 2.0
retry_jitter = 0.1
else:
max_retry_limit = execution_config.max_retry_limit
retry_initial_delay = execution_config.retry_initial_delay
retry_backoff_factor = execution_config.retry_backoff_factor
retry_jitter = execution_config.retry_jitter
# Apply tool retry logic with exponential backoff.
# ONE vocabulary: the effective RetryPolicy caps how many times
# the tool body may run, whichever of the two loops does the
# retrying, and owns the backoff. ExecutionConfig.max_retry_limit
# / retry_* are translated into it (see
# _effective_tool_retry_policy) rather than run as a second,
# independent budget in different units.
retry_policy = self._effective_tool_retry_policy(function_name)
max_attempts = retry_policy.max_attempts
result = None
last_exception = None
# max_retry_limit is the number of retries (not total attempts)
# So total attempts = 1 (initial) + max_retry_limit (retries)
for attempt in range(1, max_retry_limit + 2):
# max_attempts is the total number of tool-body runs.
for attempt in range(1, max_attempts + 1):
try:
# P8/G11: Apply tool timeout if configured
tool_timeout = getattr(self, '_tool_timeout', None)
@@ -787,7 +796,7 @@ class ToolExecutionMixin:
return self._execute_tool_with_circuit_breaker(function_name, arguments)
# Use reusable executor to prevent resource leaks
if not hasattr(self, '_tool_executor'):
if not hasattr(self, '_tool_executor') or self._tool_executor is None:
self._tool_executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix=f"tool-{self.name}"
)
@@ -800,66 +809,104 @@ class ToolExecutionMixin:
_progress_active.clear()
future.cancel()
logging.warning(f"Tool {function_name} timed out after {tool_timeout}s")
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True}
# Tag as an OUTER wall-clock timeout: unlike an
# inner-loop timeout (already retried to
# exhaustion), the body ran once here and the
# inner budget never saw it, so this one still
# earns an outer retry pass under the single budget.
result = {"error": f"Tool timed out after {tool_timeout}s", "timeout": True, "_outer_timeout": True}
# The timed-out thread cannot be reclaimed. Retire this
# executor so the next call gets a fresh worker instead of
# queuing behind a permanently stuck one — but bound how many
# orphaned (still-blocked) threads we allow to accumulate:
# once the cap is reached we stop recycling and keep reusing
# the existing pool so repeated timeouts can't exhaust process
# resources with an unbounded number of leaked threads.
orphaned = getattr(self, '_tool_executor_orphaned', 0)
if orphaned < _MAX_ORPHANED_TOOL_EXECUTORS:
self._tool_executor.shutdown(wait=False)
self._tool_executor = None
self._tool_executor_orphaned = orphaned + 1
else:
with tool_progress_channel(_progress_sink), with_injection_context(state):
result = self._execute_tool_with_circuit_breaker(function_name, arguments)
# Check if the result indicates a retryable error
if isinstance(result, dict) and result.get("error"):
# Check if this is a circuit breaker error (always retryable)
if result.get("circuit_open"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# Check if this is a timeout error (retryable)
elif result.get("timeout"):
raise ToolExecutionError(
result["error"],
tool_name=function_name,
agent_id=self.name,
is_retryable=True,
)
# For other error dicts: approval/permission denials are legitimate
# non-retryable outcomes; everything else represents a tool failure
# that should engage the outer retry/backoff loop.
elif result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
# Approval/permission/policy/guardrail denials are
# legitimate non-retryable outcomes: stop immediately.
if result.get("approval_denied") or result.get("permission_denied") or result.get("approval_error") or result.get("policy_denied") or result.get("guardrail_denied"):
break
else:
# Avoid compounding with the inner retry loop in
# _execute_tool_with_circuit_breaker: error types it already
# retries (e.g. timeout/rate_limit/connection_error) are
# exhausted by the time they reach here, so escalating them as
# retryable would re-run the entire inner loop from scratch on
# every outer attempt. Only escalate error types the inner loop
# does NOT retry (e.g. "unknown") so they get one outer pass.
error_type = self._classify_error_type(result, None)
inner_policy = self._get_tool_retry_policy(function_name)
is_retryable = error_type not in inner_policy.retry_on
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
# ONE budget: the inner loop
# (_execute_tool_with_circuit_breaker) already retries
# its retry_on error types (timeout/rate_limit/
# connection_error) to exhaustion before returning here,
# so re-escalating them as retryable would restart the
# whole inner budget on every outer attempt and run the
# tool up to max_attempts² times.
error_type = self._classify_error_type(result, None)
inner_policy = self._get_tool_retry_policy(function_name)
# Only escalate to an outer retry pass when the failure is
# 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. 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
# into this error dict (see _execute_tool_impl); honour
# that verdict, but ONLY for error types the inner loop
# does not itself retry, or inner×outer would run the
# body max_attempts² times.
# A plain error dict the tool body *returned* carries no tag
# — it is the tool's own reported outcome, not a transient
# the framework must re-drive. The body already ran to
# completion, so re-running it would duplicate its side
# effects (charge money, send a message, write a row) for a
# decision the tool already made. If a user genuinely wants
# such an outcome retried, its error type belongs in the
# 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")
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
# 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.
result.pop("_praison_retryable", None)
raise ToolExecutionError(
result.get("error", f"Tool '{function_name}' failed"),
tool_name=function_name,
agent_id=self.name,
is_retryable=is_retryable,
)
else:
# Success path - return the result
break
except ToolExecutionError as e:
last_exception = e
# Only retry if the error is marked as retryable and we have retries left
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not e.is_retryable or (attempt - 1) >= max_retry_limit:
# Only retry if the error is marked as retryable and the
# policy still has attempts left.
if not e.is_retryable or attempt >= max_attempts:
raise e
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
# Calculate delay for exponential backoff (policy owns it)
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
@@ -875,15 +922,14 @@ class ToolExecutionMixin:
is_retryable=is_retryable,
)
last_exception = tool_error
# attempt starts at 1, so (attempt - 1) gives us the retry count
if not is_retryable or (attempt - 1) >= max_retry_limit:
if not is_retryable or attempt >= max_attempts:
raise tool_error from e
# Calculate delay for exponential backoff
delay = BackoffPolicy.delay(attempt, retry_initial_delay, retry_backoff_factor, retry_jitter)
# Calculate delay for exponential backoff (policy owns it)
delay = retry_policy.get_delay_ms(attempt - 1) / 1000.0
logging.warning(
f"Tool {function_name} failed (attempt {attempt}/{max_retry_limit + 1}): {e}. "
f"Tool {function_name} failed (attempt {attempt}/{max_attempts}): {e}. "
f"Retrying in {delay:.2f}s..."
)
time.sleep(delay)
@@ -1130,6 +1176,20 @@ class ToolExecutionMixin:
)
after_tool_results = self._hook_runner.execute_sync(HookEvent.AFTER_TOOL, after_tool_input, target=function_name)
# Honour a post-tool block: a plugin/hook can deny the result
# (e.g. secret/PII detected) so the original output never
# reaches the model. Mirrors the BEFORE_TOOL block path above.
if self._hook_runner.is_blocked(after_tool_results):
reason = self._hook_runner.get_blocking_reason(after_tool_results)
logging.warning(f"Tool {function_name} result blocked by AFTER_TOOL hook")
return reason or f"Result of {function_name} was blocked by security policy."
# Read back any rewritten/redacted tool_output so a plugin can
# scrub secrets before the result reaches the model. The bridge
# mutates ``after_tool_input.tool_output`` in place.
if getattr(after_tool_input, "tool_output", result) is not result:
result = after_tool_input.tool_output
# Surface any additional_context returned by AFTER_TOOL hooks
# back to the model by appending it to the tool result (mirrors
# the loop-guard injection pattern below). Without this the
@@ -1898,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
@@ -1906,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:
@@ -1952,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
@@ -1961,11 +2054,25 @@ class ToolExecutionMixin:
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
feedback = getattr(decision, "feedback", None)
if feedback:
error_msg = (
f"Tool '{function_name}' was denied by the user. "
f"Do not retry it as-is. User guidance: {feedback}"
)
return {"error": error_msg, "approval_denied": True}
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(
@@ -1984,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
@@ -1994,11 +2101,23 @@ class ToolExecutionMixin:
if not decision.approved:
error_msg = f"Tool execution denied: {decision.reason}"
logging.warning(error_msg)
feedback = getattr(decision, "feedback", None)
if feedback:
error_msg = (
f"Tool '{function_name}' was denied by the user. "
f"Do not retry it as-is. User guidance: {feedback}"
)
return {"error": error_msg, "approval_denied": True}
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(
@@ -2036,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)
@@ -2518,8 +2643,20 @@ class ToolExecutionMixin:
f"{error_msg} Expected parameters for '{function_name}' — "
f"required: {schema['required']}, optional: {schema['optional']}."
)
return {"error": error_msg, "expected_parameters": schema}
return {"error": error_msg}
# An argument-binding error is a programming error — the same
# call will always fail — so mark it terminal and never retry.
return {"error": error_msg, "expected_parameters": schema, "_praison_retryable": False}
# A tool that *raised* is flattened into an error dict here, which
# erases the exception type the retry policy needs. Carry the same
# verdict the exception paths use so the outer loop can retry a
# transient failure without re-running a tool that merely *returned*
# an error dict of its own. Programming errors are terminal;
# everything else may be retried. Private key: never leaks to the
# model and cannot collide with a tool's own "retryable" field.
return {
"error": error_msg,
"_praison_retryable": not isinstance(e, (ValueError, TypeError, AttributeError)),
}
# Unresolved: return a corrective, model-readable message so the model can
# retry with a valid name instead of repeating the same mistake.
@@ -2906,17 +3043,133 @@ 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).
Kept as the historical name; both retry loops now go through
``_effective_tool_retry_policy`` so they share one policy object.
Args:
tool_name: Name of the tool to get retry policy for
Returns:
RetryPolicy instance
"""
return self._effective_tool_retry_policy(tool_name)
def _effective_tool_retry_policy(self, tool_name):
"""The one RetryPolicy that governs every retry of ``tool_name``.
Precedence: per-tool ``.retry_policy`` > agent-level
``ToolConfig(retry_policy=...)`` > a policy translated from the
ExecutionConfig spelling of the same knobs.
``ExecutionConfig`` speaks a second vocabulary for the same thing --
``max_retry_limit`` counts *retries* (so total runs = limit + 1),
``retry_initial_delay`` is in *seconds*, and ``retry_jitter`` is a
*fraction*. Translating it here, once, is what keeps the two retry loops
from running two independent budgets in two different units.
"""
from ..tools.retry import RetryPolicy
# Check for tool-level retry policy first
tools = getattr(self, 'tools', [])
# Handle non-iterable tools (e.g., single MCP instance)
@@ -2927,16 +3180,43 @@ class ToolExecutionMixin:
tool_policy = getattr(tool, 'retry_policy', None) if hasattr(tool, 'retry_policy') else None
if tool_name_attr == tool_name and tool_policy is not None:
return tool_policy
# Check for agent-level retry policy
agent_policy = getattr(self, '_tool_retry_policy', None)
if agent_policy is not None:
return agent_policy
# Return default retry policy (cached class-level instance)
if not hasattr(ToolExecutionMixin, '_default_retry_policy'):
ToolExecutionMixin._default_retry_policy = RetryPolicy()
return ToolExecutionMixin._default_retry_policy
# Translate the ExecutionConfig spelling into the RetryPolicy vocabulary.
execution_config = getattr(self, '_execution_config', None)
if execution_config is None:
max_retry_limit = getattr(self, 'max_retry_limit', 2)
initial_delay_s, backoff_factor, jitter_fraction = 1.0, 2.0, 0.1
else:
max_retry_limit = execution_config.max_retry_limit
initial_delay_s = execution_config.retry_initial_delay
backoff_factor = execution_config.retry_backoff_factor
jitter_fraction = execution_config.retry_jitter
cached = getattr(self, '_translated_retry_policy', None)
key = (max_retry_limit, initial_delay_s, backoff_factor, jitter_fraction)
if cached is not None and cached[0] == key:
return cached[1]
# Reproduce BackoffPolicy.delay's 60s base cap so ExecutionConfig users
# keep the exact same delay ceiling they had before this consolidation
# (RetryPolicy defaults to a 30s cap). initial_delay_ms must not exceed
# max_delay_ms per RetryPolicy validation, so lift the cap to whichever
# is larger.
initial_delay_ms = int(initial_delay_s * 1000)
policy = RetryPolicy(
max_attempts=max(1, max_retry_limit + 1),
backoff_factor=max(1.0, backoff_factor),
initial_delay_ms=initial_delay_ms,
max_delay_ms=max(60000, initial_delay_ms),
jitter=jitter_fraction > 0,
jitter_factor=min(max(jitter_fraction, 0.0), 1.0),
)
self._translated_retry_policy = (key, policy)
return policy
def _classify_error_type(self, error_dict, exception):
"""Classify error type for retry policy matching.
@@ -0,0 +1,129 @@
"""Give a single Agent's tools a sandbox, without touching its thinking.
``AgentFlow``/``AgentTeam`` already share one sandbox across a run and tear it
down when the run ends. A lone ``Agent(tools_run_on=...)`` has no "run" to hang
that lifetime on -- it is just an object someone calls ``chat()`` on, possibly
many times -- so the sandbox is created on first use and lives as long as the
agent does. That is deliberate: a file written in turn 1 is still there in turn
2, which is what anyone who asked for a working directory expects.
Attachment is idempotent and happens at the top of ``chat``/``achat``/
``_start_stream``, and in ``AgentTeam`` before the task's tool list is
snapshotted. Doing it once, rather than wrapping each call in a context
manager, sidesteps two real hazards in this codebase:
* ``_start_stream`` is a **generator**; a context manager around the call would
exit before the caller consumed a single token, tearing down the sandbox while
the model was still using it.
* ``_start_stream`` falls back to ``self.chat``, so a wrapper would **re-enter**
itself and provision a second sandbox.
Lifetime is tied to the agent by ``weakref.finalize`` rather than a process-exit
hook over a weak set. The weak set leaked: an agent and its ``SharedCompute``
reference each other, so a dropped agent was reclaimed by the cycle collector,
vanished from the set, and its sandbox was never shut down. Gateways clone an
agent **per request**, so that was one orphaned container per request.
"""
from __future__ import annotations
import logging
import threading
import weakref
from typing import Any
logger = logging.getLogger(__name__)
# One lock per agent, so two unrelated agents never wait on each other and no
# lock is ever held while another agent's provider is being imported. Held only
# by ensure_tools_placed/close_tools_sandbox for that one agent.
_AGENT_LOCKS: "weakref.WeakKeyDictionary" = weakref.WeakKeyDictionary()
_LOCKS_GUARD = threading.Lock()
def _lock_for(agent: Any) -> threading.Lock:
"""The placement lock for ``agent``, created on demand.
``_LOCKS_GUARD`` covers only this dictionary lookup -- microseconds -- never
provider construction or attachment. An earlier version held one global lock
across ``SharedCompute(...)``, which imports the provider SDK: unrelated
agents serialised behind each other, and a slow import could stall
interpreter exit.
"""
with _LOCKS_GUARD:
lock = _AGENT_LOCKS.get(agent)
if lock is None:
lock = threading.Lock()
try:
_AGENT_LOCKS[agent] = lock
except TypeError: # pragma: no cover - unhashable/non-weakrefable
pass
return lock
def _shutdown_orphan(shared: Any, name: str) -> None:
"""Tear down a sandbox whose agent is gone.
Registered with ``weakref.finalize``, so it runs when the agent is collected
*and* at interpreter exit. It must not close over the agent -- that would
keep it alive and defeat the whole point.
"""
try:
shared.shutdown()
logger.debug("[tools_placement] released the sandbox belonging to %s", name)
except Exception as exc: # pragma: no cover - teardown must stay quiet
# Loud enough to notice: a failed release means a container, or a
# billed cloud instance, is still running after the agent is gone.
logger.warning(
"[tools_placement] could not release the sandbox for %s: %s", name, exc
)
def ensure_tools_placed(agent: Any) -> None:
"""Attach the sandbox tools to ``agent`` if it asked for them. Idempotent.
Called at the top of every entry point, so it must be cheap when there is
nothing to do -- which is the overwhelmingly common case.
"""
place = getattr(agent, "tools_run_on", None)
if place is None or getattr(agent, "_tools_sandbox", None) is not None:
return
from ..managed.shared_compute import SharedCompute
with _lock_for(agent):
# Re-check under the lock: two threads calling chat() at once must not
# each provision a sandbox and leak one.
if getattr(agent, "_tools_sandbox", None) is not None:
return
shared = SharedCompute(place)
# override_declared: this sandbox exists *because* the agent named a
# place, so the skip-if-declared rule must not skip the agent itself.
shared.attach([agent], override_declared=True)
agent._tools_sandbox = shared
agent._tools_sandbox_finalizer = weakref.finalize(
agent, _shutdown_orphan, shared, str(getattr(agent, "name", agent))
)
logger.info("[tools_placement] %r tools now run on %s", getattr(agent, "name", agent), place)
def close_tools_sandbox(agent: Any) -> None:
"""Tear down the agent's sandbox and put its original tools back."""
with _lock_for(agent):
# Everything happens under the lock. Clearing the marker first and
# shutting down afterwards left a window where a concurrent chat()
# re-attached and the in-flight shutdown then restored over it, leaving
# the agent marked as sandboxed while holding no sandbox tools -- a
# state ensure_tools_placed() could never repair, because its idempotent
# early return sees the marker and does nothing.
shared = getattr(agent, "_tools_sandbox", None)
if shared is None:
return
finalizer = getattr(agent, "_tools_sandbox_finalizer", None)
if finalizer is not None:
finalizer.detach() # we are shutting down explicitly
agent._tools_sandbox_finalizer = None
shared.shutdown()
agent._tools_sandbox = None
@@ -282,7 +282,7 @@ class UnifiedExecutionMixin:
task_id=task_id,
config=config,
tool_choice=tool_choice,
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
)
else:
# Standard LiteLLM path - delegate to existing LLM class
@@ -304,7 +304,7 @@ class UnifiedExecutionMixin:
task_id=task_id,
config=config,
tool_choice=tool_choice,
parallel_tool_calls=getattr(self, 'parallel_tool_calls', False) # Gap 2 integration
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False) # Gap 2 integration
)
# Store response in memory if enabled
@@ -158,9 +158,8 @@ class VideoAgent:
verbose: Verbosity level for output
output: Output preset ("silent", "verbose", etc.)
"""
# Handle model= alias for llm=
if llm is None and model is not None:
llm = model
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "VideoAgent")
# Store core identity
self.name = name or "VideoAgent"
@@ -112,8 +112,8 @@ class VisionAgent:
- VisionConfig: Full configuration object
verbose: Verbosity level for output
"""
if llm is None and model is not None:
llm = model
from ..utils.model_alias import resolve_model_name
llm = resolve_model_name(llm, model, "VisionAgent")
self.name = name or "VisionAgent"
self.instructions = instructions
@@ -2,6 +2,8 @@ import os
import time
import json
import logging
import contextlib
import contextvars
import threading
from praisonaiagents._logging import get_logger
from typing import Any, Dict, Optional, List, Tuple, Callable
@@ -41,18 +43,13 @@ class TaskStatus(Enum):
logger = get_logger(__name__)
def _launch_auth_token() -> Optional[str]:
return os.environ.get("PRAISONAI_LAUNCH_AUTH_TOKEN")
def _authorise_launch_request(request) -> bool:
token = _launch_auth_token()
if not token:
return True
auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer ") and auth[7:] == token:
return True
return request.headers.get("X-Auth-Token") == token
# Request authorisation + fail-closed bind guard are shared with Agent.launch
# so the single-agent and multi-agent serving paths cannot drift apart.
from ..agent.launch_security import (
launch_auth_token as _launch_auth_token,
authorise_launch_request as _authorise_launch_request,
resolve_launch_host as _resolve_launch_host,
)
# Agent server registry for thread-safe server management
@@ -375,6 +372,18 @@ def _build_execution_context(agents_instance, task_id, skip_memory_init=False):
if llm and hasattr(llm, 'set_current_agent'):
llm.set_current_agent(executor_agent.display_name)
# Place the agent's tools BEFORE snapshotting them. This snapshot is passed
# to chat() as an explicit `tools=`, which wins over `agent.tools` -- so if
# the sandbox attaches later (inside chat()), the model is handed the
# pre-attach list and every tool runs on the host while a sandbox sits
# provisioned and unused. Silent, and worse than it sounds: in
# start_for_each() row 1 got local tools and row 2 got the bridged set,
# because row 1's chat() mutated agent.tools in time for row 2's snapshot.
if executor_agent is not None and getattr(executor_agent, "tools_run_on", None) is not None:
from ..agent.tools_placement import ensure_tools_placed
ensure_tools_placed(executor_agent)
# Ensure tools are available from both task and agent (create copy to avoid mutation)
tools = list(task.tools or [])
if executor_agent and executor_agent.tools:
@@ -644,7 +653,8 @@ class AgentTeam(SpawnAnnounceProtocol):
manager_llm=None,
name: Optional[str] = None,
variables: Optional[Dict[str, Any]] = None,
llm: Optional[str] = None, # Default LLM for all agents (API consistency)
llm: Optional[str] = None, # Deprecated alias for model=
model: Optional[str] = None, # Default model for members that did not name one
# Consolidated feature params (agent-centric API)
memory: Optional[Any] = False, # Union[bool, MultiAgentMemoryConfig]
planning: Optional[Any] = False, # Union[bool, MultiAgentPlanningConfig]
@@ -660,13 +670,15 @@ class AgentTeam(SpawnAnnounceProtocol):
reflection: Optional[Any] = None, # Union[bool, ReflectionConfig] - self-reflection
caching: Optional[Any] = None, # Union[bool, CachingConfig] - caching
learn: Optional[Any] = None, # Union[bool, LearnConfig] - continuous learning
# Union[str, ComputeProviderProtocol] - run every agent's shell/file
# tools in ONE shared remote sandbox ("docker", "e2b", "modal",
# "daytona", "flyio", "tenki", "local"). Agents share /workspace, so
# files written by one agent are visible to the others. Orchestration
# stays local. Pass a configured provider instance instead of a name to
# customise image/resources.
compute: Optional[Any] = None,
# Union[str, ComputeProviderProtocol] - where every agent's shell/file
# tools run: "docker", "e2b", "modal", "daytona", "flyio", "tenki",
# "local", "subprocess", "sandlock", "ssh", "novita". The team shares
# ONE sandbox and one working directory, so files written by one agent
# are visible to the others. Orchestration and thinking stay local.
# Pass a configured provider instance to customise resources.
tools_run_on: Optional[Any] = None,
# Accepted only to fail with a useful message -- see resolve_placement.
run_on: Optional[Any] = None,
):
"""
Initialize AgentManager with consolidated feature parameters.
@@ -677,8 +689,11 @@ class AgentTeam(SpawnAnnounceProtocol):
process: Execution process type ("sequential", "workflow", "hierarchical").
For parallel fan-out, set async_execution=True on individual Task
objects within a "workflow" or "sequential" process.
manager_llm: LLM model for manager agent
llm: Default LLM model for all agents
manager_llm: Model for the hierarchical manager agent. Unrelated to
model=/llm=; it never touches the members.
llm: Deprecated alias for model=. Passing both raises TypeError.
model: Default model for members that did not name one themselves.
An agent constructed with its own llm=/model=/auth= keeps it.
name: Name for this agent collection
variables: Global variables for substitution
memory: Memory configuration (bool | MultiAgentMemoryConfig)
@@ -696,13 +711,40 @@ class AgentTeam(SpawnAnnounceProtocol):
"""
# Store new params for propagation to agents
self._learn = learn
self.llm = llm # Store default LLM for API consistency
# model= is the canonical name; llm= is the deprecated alias. Passing
# both is refused rather than silently resolved (see resolve_model_alias).
# 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.
# 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)
if callable(_apply):
_apply(self.llm)
self._autonomy = autonomy
self._knowledge = knowledge
self._guardrails = guardrails
self._web = web
self._reflection = reflection
self._caching = caching
# Warn about params that are accepted for API symmetry with Agent but are
# not yet applied at the team level, so a truthy value stops being a silent
# no-op. Pass these to individual Agent(...) instances instead.
_unwired = {
"guardrails": guardrails, "web": web, "reflection": reflection,
"caching": caching, "learn": learn, "knowledge": knowledge,
}
_set = [k for k, v in _unwired.items() if v]
if _set:
logging.warning(
f"AgentTeam received {_set} but does not yet apply them at the team "
"level; pass these to individual Agent(...) instances instead."
)
# ─────────────────────────────────────────────────────────────────────
# Extract values from consolidated params using UNIFIED CANONICAL resolver
# Precedence: Instance > Config > Dict > Array > String > Bool > Default
@@ -898,7 +940,19 @@ class AgentTeam(SpawnAnnounceProtocol):
self.stream = _stream
self.name = name
# Remote execution: one shared sandbox for every agent on this team.
self.compute = compute
from ..agent.placement import resolve_placement
resolve_placement(
"AgentTeam", run_on=run_on, tools_run_on=tools_run_on,
supports_run_on=False,
)
self.tools_run_on = tools_run_on
# Kept readable rather than removed. `team.run_on` used to exist, and
# code doing getattr(team, "run_on", None) for introspection or
# serialisation would otherwise start raising AttributeError. None is
# the honest value: a team orchestrates locally, so nothing about it
# runs wholly on a managed runtime.
self.run_on = None
# Callbacks for workflow execution
self.on_task_start = _on_task_start
@@ -1425,6 +1479,87 @@ class AgentTeam(SpawnAnnounceProtocol):
raise result
return results
def _depends_on_pending(self, task, pending):
"""True if ``task`` depends on a task still pending in the batch.
``pending`` is a list of ``(task_id, coroutine)`` pairs already queued
for parallel execution. If one of the task's dependencies is in that
set, its result isn't available yet, so the batch must be flushed before
queuing this task (otherwise the dependent builds its prompt from an
empty result for the still-in-progress dependency).
Two dependency edges are checked, since ``_build_task_context`` reads
from both:
- ``task.context``: explicit context tasks (by object ``id``).
- ``task.previous_tasks``: workflow ``next_tasks`` edges, stored as
task *names*; without this a workflow successor could be queued in
the same async batch as its predecessor and read an empty result.
"""
if not pending:
return False
pending_ids = {tid for tid, _ in pending}
deps = getattr(task, 'context', None) or []
if any(getattr(dep, 'id', None) in pending_ids for dep in deps):
return True
previous = getattr(task, 'previous_tasks', None) or []
if previous:
pending_names = {
self.tasks[tid].name
for tid in pending_ids
if tid in self.tasks
}
if any(name in pending_names for name in previous):
return True
return False
def _deps_failed(self, task_id):
"""True if any dependency of ``task_id`` finished in a failed state.
asequential()/aworkflow() run their own failure check inside the
generator, but async tasks are buffered and drained later, so an
upstream async task may not have failed yet when the generator yielded
the dependent. Re-checking here — after pending async tasks are flushed
— makes the failure cascade fire for the async_execution path too, for
both dependency edges ``_build_task_context`` reads (context tasks and
workflow ``previous_tasks``).
"""
task = self.tasks.get(task_id)
if task is None:
return False
for dep in getattr(task, 'context', None) or []:
dep_id = getattr(dep, 'id', None)
if dep_id in self.tasks and self.tasks[dep_id].status == "failed":
return True
previous = getattr(task, 'previous_tasks', None) or []
if previous:
by_name = {t.name: t for t in self.tasks.values()}
if any(
name in by_name and by_name[name].status == "failed"
for name in previous
):
return True
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(
@@ -1436,14 +1571,27 @@ class AgentTeam(SpawnAnnounceProtocol):
)
if self.process == "workflow":
tasks_to_run = []
tasks_to_run = [] # list of (task_id, coroutine)
async for task_id in process.aworkflow():
if self.tasks[task_id].async_execution:
tasks_to_run.append(self.arun_task(task_id))
task = self.tasks[task_id]
if task.async_execution:
# If this async task depends on another async task still
# pending in the current batch, flush first so its context
# is available before this one reads it.
if self._depends_on_pending(task, tasks_to_run):
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
# 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)))
else:
# If we encounter a sync task, we must wait for the previous async tasks to finish.
if tasks_to_run:
await self._gather_with_isolation(tasks_to_run)
await self._gather_with_isolation([c for _, c in tasks_to_run])
tasks_to_run = []
# Run sync task in an executor to avoid blocking the event loop
@@ -1453,25 +1601,44 @@ class AgentTeam(SpawnAnnounceProtocol):
await loop.run_in_executor(None, copy_context_to_callable(lambda tid=task_id: self.run_task(tid)))
if tasks_to_run:
await self._gather_with_isolation(tasks_to_run)
await self._gather_with_isolation([c for _, c in tasks_to_run])
elif self.process == "sequential":
async_tasks_to_run = []
async_tasks_to_run = [] # list of (task_id, coroutine)
async def flush_async_tasks():
"""Execute all pending async tasks"""
nonlocal async_tasks_to_run
if async_tasks_to_run:
await self._gather_with_isolation(async_tasks_to_run)
await self._gather_with_isolation([c for _, c in async_tasks_to_run])
async_tasks_to_run = []
async for task_id in process.asequential():
if self.tasks[task_id].async_execution:
task = self.tasks[task_id]
if task.async_execution:
# If this async task depends on another async task still
# pending in the current batch, flush first so its context
# is available before this one reads it (avoids a same-batch
# race that silently substitutes empty dependency context).
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
# 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
async_tasks_to_run.append(self.arun_task(task_id))
async_tasks_to_run.append((task_id, self.arun_task(task_id)))
else:
# 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, 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
# Use copy_context_to_callable to propagate contextvars (needed for trace emission)
from ..trace.context_events import copy_context_to_callable
@@ -1509,6 +1676,62 @@ class AgentTeam(SpawnAnnounceProtocol):
except StopAsyncIteration:
pass
# ── shared sandbox lifetime ──────────────────────────────────────────────
# A ContextVar, not an instance attribute: the previous version set
# `self.run_on = None` for the duration of the run, so a second caller
# inspecting the team mid-run saw "no sandbox" and any concurrent start()
# executed every tool on the host. Depth lives with the caller, not on the
# shared object, so the team's declared placement is always readable.
#
# The depth is keyed PER TEAM, not shared class-wide. A single integer here
# meant any team started while another team's run was on the stack saw
# depth==1 and skipped its own sandbox entirely -- so a nested team ran on
# the host, and a team sharing an agent with the outer run executed on the
# OUTER team's sandbox.
#
# ContextVar, not threading.local: two coroutines that `astart()` the SAME
# team under one asyncio.gather run on one thread, so thread-local state is
# shared between them -- the second saw the first's depth==1, skipped its
# own sandbox, and ran against a sandbox the first could tear down mid-flight.
# A ContextVar is copied per task at gather/create_task time, so each run
# gets its own depth map; it is also per-thread, so sync callers stay
# isolated too. The value is an immutable frozen mapping replaced via .set()
# (never mutated in place), because a copied context shares the *reference*
# to a mutable dict and in-place mutation would leak straight back across
# the tasks the copy was meant to separate.
_tools_scope_depths: "contextvars.ContextVar" = contextvars.ContextVar(
"praisonai_tools_scope_depths", default=()
)
def _needs_tools_scope(self) -> bool:
"""True when this call should provision the team's shared sandbox."""
if getattr(self, "tools_run_on", None) is None:
return False
return dict(self._tools_scope_depths.get()).get(id(self), 0) == 0
@contextlib.contextmanager
def _shared_tools_scope(self):
"""Hold one sandbox for everything nested inside.
Re-entrant by design: ``start_for_each`` opens the scope once for the
whole batch, so the ``start()`` call it makes per input row finds the
scope already open and reuses that sandbox instead of provisioning and
destroying one per row.
"""
from ..managed.shared_compute import SharedCompute
key = id(self)
depths = dict(self._tools_scope_depths.get())
depths[key] = depths.get(key, 0) + 1
token = self._tools_scope_depths.set(tuple(sorted(depths.items())))
try:
with SharedCompute(self.tools_run_on) as shared:
shared.attach(list(self.agents or []))
yield shared
finally:
self._tools_scope_depths.reset(token)
async def astart(self, content=None, return_dict=False, **kwargs):
"""Async version of start method.
@@ -1517,6 +1740,12 @@ class AgentTeam(SpawnAnnounceProtocol):
return_dict: If True, returns the full results dictionary instead of only the final response
**kwargs: Additional arguments
"""
# Same shared sandbox as start(). Without this, an async team with
# tools_run_on= ran every tool on the host and said nothing.
if self._needs_tools_scope():
with self._shared_tools_scope():
return await self.astart(content=content, return_dict=return_dict, **kwargs)
# Track execution via telemetry
if hasattr(self, '_telemetry') and self._telemetry:
self._telemetry.track_agent_execution(self.name, success=True, async_mode=True)
@@ -1737,6 +1966,21 @@ class AgentTeam(SpawnAnnounceProtocol):
return str(agent[0])
return None
def __repr__(self):
"""Show where this team's work happens, not just its name."""
from ..agent.execution_location import repr_fields
return (
f"AgentTeam(name={self.name!r}, agents={len(self.agents or [])}, "
f"{repr_fields(self, shared=True)})"
)
def where_does_it_run(self) -> str:
"""Plain-English answer to "where does my code actually run?"."""
from ..agent.execution_location import explain
return explain(self, shared=True)
def start(self, content=None, return_dict=False, output=None, **kwargs):
"""Start agent execution with verbose output (beginner-friendly).
@@ -1762,19 +2006,11 @@ class AgentTeam(SpawnAnnounceProtocol):
```
"""
# Remote execution: provision ONE sandbox shared by every agent on the
# team, and tear it down even if execution raises. Re-enters start()
# with compute cleared so the wrapper applies exactly once.
if getattr(self, "compute", None) is not None:
from ..managed.shared_compute import SharedCompute
compute, self.compute = self.compute, None
try:
with SharedCompute(compute) as shared:
shared.attach(list(self.agents or []))
return self.start(content=content, return_dict=return_dict,
output=output, **kwargs)
finally:
self.compute = compute
# team, and tear it down even if execution raises.
if self._needs_tools_scope():
with self._shared_tools_scope():
return self.start(content=content, return_dict=return_dict,
output=output, **kwargs)
# Track execution via telemetry
if hasattr(self, '_telemetry') and self._telemetry:
@@ -2090,6 +2326,15 @@ class AgentTeam(SpawnAnnounceProtocol):
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
# One sandbox for the whole batch. Without this the per-row start()
# call provisions and destroys its own, so a 50-row batch paid for 50
# sandboxes and no row could see what an earlier row wrote.
if self._needs_tools_scope():
with self._shared_tools_scope():
return self.start_for_each(
inputs, on_error=on_error, output=output, **kwargs
)
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
@@ -2126,6 +2371,13 @@ class AgentTeam(SpawnAnnounceProtocol):
if on_error not in ("continue", "fail_fast"):
raise ValueError("on_error must be 'continue' or 'fail_fast'")
# One sandbox for the whole batch -- see start_for_each.
if self._needs_tools_scope():
with self._shared_tools_scope():
return await self.astart_for_each(
inputs, on_error=on_error, **kwargs
)
batch_id = f"batch_{uuid.uuid4().hex[:12]}"
saved_variables = self.variables
task_snapshot = self._snapshot_task_state()
@@ -2407,7 +2659,7 @@ class AgentTeam(SpawnAnnounceProtocol):
print("="*50 + "\n")
def launch(self, path: str = '/agents', port: int = 8000, host: str = '0.0.0.0', debug: bool = False, protocol: str = "http"):
def launch(self, path: str = '/agents', port: int = 8000, host: str = '127.0.0.1', debug: bool = False, protocol: str = "http"):
"""
Launch all agents as a single API endpoint (HTTP) or an MCP server.
In HTTP mode, the endpoint accepts a query and processes it through all agents in sequence.
@@ -2418,7 +2670,11 @@ class AgentTeam(SpawnAnnounceProtocol):
Args:
path: API endpoint path (default: '/agents') for HTTP. Ignored in MCP mode.
port: Server port (default: 8000)
host: Server host (default: '0.0.0.0')
host: Server host (default: '127.0.0.1'; changed from '0.0.0.0' to
avoid binding all interfaces by default — pass '0.0.0.0'
explicitly to expose externally). Binding a non-loopback host
without ``PRAISONAI_LAUNCH_AUTH_TOKEN`` set auto-generates and
prints a one-time bearer token.
debug: Enable debug mode for uvicorn (default: False)
protocol: "http" to launch as FastAPI, "mcp" to serve over MCP via
``praisonai-mcp`` (install with ``pip install praisonai-mcp``).
@@ -2428,7 +2684,7 @@ class AgentTeam(SpawnAnnounceProtocol):
"""
if protocol == "http":
# Use centralized server registry
if not self.agents:
logging.warning("No agents to launch for HTTP mode. Add agents to the Agents instance first.")
return
@@ -2457,6 +2713,13 @@ class AgentTeam(SpawnAnnounceProtocol):
print("\nOr install all API dependencies with:")
print("pip install 'praisonaiagents[api]'")
return None
# Fail-closed bind guard shared with Agent.launch: never serve
# keyless on a non-loopback host. Applied only after the no-agents
# and dependency guards above, so a launch that bails out cannot
# mutate the process-wide launch token (which every route reads per
# request) and retroactively 401 an already-running endpoint.
host = _resolve_launch_host(host)
# Thread-safe initialization of FastAPI app
app, is_new = _server_registry.get_or_create_app(
@@ -3095,9 +3358,14 @@ class AgentTeam(SpawnAnnounceProtocol):
console.print(f"[dim]Progress: [{'█' * 30}] 100%[/dim]")
console.print(f"[green]Completed {completed_count}/{len(self.tasks)} tasks![/green]\n")
# Restore original tasks reference for result retrieval
self._plan_tasks = self.tasks.copy()
# Keep plan tasks for results but note original tasks are preserved in _plan_tasks
# Keep plan-step tasks/results under _plan_tasks for introspection,
# then restore the user's original task set as the canonical self.tasks.
self._plan_tasks = self.tasks
self.tasks = original_tasks
with self._task_id_lock:
self.task_id_counter = (
max(original_tasks.keys()) + 1 if original_tasks else 0
)
# Resource Lifecycle Management
def close(self) -> None:
@@ -81,12 +81,14 @@ class ConsoleBackend:
"""Interactive Rich terminal prompt. Default for CLI usage."""
def _prompt_user(self, request: ApprovalRequest):
"""Show Rich panel and ask once/session/always/no.
"""Show Rich panel and ask once/session/always/no/deny-with-guidance.
Returns a ``(approved, scope, scope_pattern)`` tuple where ``scope`` is
one of ``"once"`` / ``"session"`` / ``"always"`` and ``scope_pattern``
is the reusable target to persist for ``always`` (``None`` otherwise).
A denial returns ``(False, "once", None)``.
Returns a ``(approved, scope, scope_pattern, feedback)`` tuple where
``scope`` is one of ``"once"`` / ``"session"`` / ``"always"`` and
``scope_pattern`` is the reusable target to persist for ``always``
(``None`` otherwise). ``feedback`` carries optional free-text guidance
captured by the "deny & redirect" choice (``None`` otherwise).
A plain denial returns ``(False, "once", None, None)``.
"""
Console = _get_rich_console()
Panel = _get_rich_panel()
@@ -126,28 +128,35 @@ class ConsoleBackend:
try:
console.print(
f"[{risk_color}]Allow {request.tool_name}?[/{risk_color}] "
f"[o] once [s] this session [a] {always_label} [n] no"
f"[o] once [s] this session [a] {always_label} "
f"[n] no [d] deny & redirect"
)
choice = Prompt.ask(
"Choice",
choices=["o", "s", "a", "n"],
choices=["o", "s", "a", "n", "d"],
default="n",
)
except (KeyboardInterrupt, EOFError):
return (False, "once", None)
return (False, "once", None, None)
if choice == "n":
return (False, "once", None)
return (False, "once", None, None)
if choice == "d":
try:
feedback = Prompt.ask("What should the agent do instead?").strip()
except (KeyboardInterrupt, EOFError):
feedback = ""
return (False, "once", None, feedback or None)
if choice == "s":
return (True, "session", None)
return (True, "session", None, None)
if choice == "a":
return (True, "always", suggested or None)
return (True, "once", None)
return (True, "always", suggested or None, None)
return (True, "once", None, None)
def request_approval_sync(self, request: ApprovalRequest) -> ApprovalDecision:
"""Synchronous approval via Rich console prompt."""
try:
approved, scope, scope_pattern = self._prompt_user(request)
approved, scope, scope_pattern, feedback = self._prompt_user(request)
if approved:
return ApprovalDecision(
approved=True,
@@ -156,7 +165,13 @@ class ConsoleBackend:
scope=scope,
scope_pattern=scope_pattern,
)
return ApprovalDecision(approved=False, reason="User denied", approver="console")
reason = f"User denied: {feedback}" if feedback else "User denied"
return ApprovalDecision(
approved=False,
reason=reason,
approver="console",
feedback=feedback,
)
except Exception as e:
logger.error("Console approval error: %s", e)
return ApprovalDecision(approved=False, reason=f"Approval error: {e}")
@@ -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
@@ -65,6 +72,12 @@ class ApprovalDecision:
``session``/``always`` scopes (e.g. ``"bash:git status *"``
or ``"edit:src/app.py"``). When ``None`` the registry
derives one from the tool call.
feedback: Optional free-text guidance supplied by the user when
denying a tool call ("deny with guidance"). When present
on a denial it is surfaced back to the model as a
course-correction for the next turn instead of a bare
"denied" result. ``None`` (default) keeps today's
terminal-deny semantics.
"""
approved: bool
@@ -74,6 +87,7 @@ class ApprovalDecision:
metadata: Dict[str, Any] = field(default_factory=dict)
scope: str = "once"
scope_pattern: Optional[str] = None
feedback: Optional[str] = None
@dataclass
@@ -2,7 +2,10 @@
Authentication & Authorization Protocols for PraisonAI Agents.
This package provides protocol-driven auth interfaces for the core SDK.
Implementations (JWT, OAuth, etc.) live in the platform package.
Implementations (JWT, OAuth, etc.) live outside it — implement the protocols
against your own store, or use the reference implementation in the separate
``praisonai-platform`` package
(https://github.com/MervinPraison/PraisonAI-Platform).
Quick start::
@@ -155,11 +155,16 @@ class WorkspaceContextProtocol(Protocol):
"""Protocol for providing workspace context to agents.
Enables agents to access workspace-specific settings, instructions,
and agent configurations from the platform layer.
and agent configurations from a platform layer.
Implementations live outside this package. The reference one is
``PlatformWorkspaceContext`` in the ``praisonai-platform`` package
(https://github.com/MervinPraison/PraisonAI-Platform); implement this
protocol yourself to back agent configuration with your own store.
Example::
class PlatformWorkspaceContext:
class MyWorkspaceContext:
async def get_workspace_context(self, workspace_id):
ws = await db.get_workspace(workspace_id)
return ws.context
@@ -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,
@@ -109,6 +109,11 @@ class PlatformCapabilities:
descriptor only; the durable outbox does not forward a token on
resend, so adapters relying on provider-side dedupe should also set
``reconciles_unknown_send`` to get effectively-once delivery.
supports_threads: Whether the adapter can open a new thread/topic under
a chat (e.g. Telegram forum topics, Discord threads, Slack thread
anchoring). When False, ``create_thread`` returns a typed
``unsupported`` outcome and callers fall back to the parent channel
rather than raising.
"""
max_message_length: int = 4096
@@ -126,6 +131,7 @@ class PlatformCapabilities:
reconciles_unknown_send: bool = False
supports_idempotency_token: bool = False
supports_media: bool = False
supports_threads: bool = False
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -145,6 +151,7 @@ class PlatformCapabilities:
"reconciles_unknown_send": self.reconciles_unknown_send,
"supports_idempotency_token": self.supports_idempotency_token,
"supports_media": self.supports_media,
"supports_threads": self.supports_threads,
}
@classmethod
@@ -166,6 +173,7 @@ class PlatformCapabilities:
reconciles_unknown_send=data.get("reconciles_unknown_send", False),
supports_idempotency_token=data.get("supports_idempotency_token", False),
supports_media=data.get("supports_media", False),
supports_threads=data.get("supports_threads", False),
)
@@ -32,6 +32,8 @@ Usage:
bus.publish("session.created", {"session_id": "abc123"})
"""
from .._lazy import create_lazy_getattr
__all__ = [
"EventBus",
"Event",
@@ -41,31 +43,13 @@ __all__ = [
"set_default_bus",
]
_LAZY_IMPORTS = {
"EventBus": ("praisonaiagents.bus.bus", "EventBus"),
"Event": ("praisonaiagents.bus.event", "Event"),
"EventType": ("praisonaiagents.bus.event", "EventType"),
"Subscriber": ("praisonaiagents.bus.bus", "Subscriber"),
"get_default_bus": ("praisonaiagents.bus.bus", "get_default_bus"),
"set_default_bus": ("praisonaiagents.bus.bus", "set_default_bus"),
}
def __getattr__(name: str):
"""Lazy load module components."""
if name == "EventBus":
from .bus import EventBus
return EventBus
if name == "Event":
from .event import Event
return Event
if name == "EventType":
from .event import EventType
return EventType
if name == "Subscriber":
from .bus import Subscriber
return Subscriber
if name == "get_default_bus":
from .bus import get_default_bus
return get_default_bus
if name == "set_default_bus":
from .bus import set_default_bus
return set_default_bus
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
@@ -37,6 +37,8 @@ __all__ = [
"ToolResultPrunerProtocol",
"MessageFormatterProtocol",
"SummaryBuilderProtocol",
# Default structured summary builder
"DefaultSummaryBuilder",
# Read-only recap
"build_recap",
"MemoryFlushResult",
@@ -75,6 +77,10 @@ def __getattr__(name: str):
from .protocols import SummaryBuilderProtocol
return SummaryBuilderProtocol
if name == "DefaultSummaryBuilder":
from .summary_builder import DefaultSummaryBuilder
return DefaultSummaryBuilder
if name == "build_recap":
from .recap import build_recap
return build_recap
@@ -122,7 +122,18 @@ class ContextCompactor:
# Protocol implementations (defaults to None - no heavy implementations in core)
self.tool_pruner = tool_pruner
self.message_formatter = message_formatter
self.message_formatter = message_formatter
# Default the structured summary builder when the config asks for the
# phase-aware template but no builder was injected, so
# `_build_structured_summary` renders SUMMARY_TEMPLATE instead of the
# "Summary of N messages" stub. The builder is deterministic and
# dependency-free, so this stays offline-safe and zero-overhead.
if summary_builder is None and getattr(self.config, "structured_template", False):
from .summary_builder import DefaultSummaryBuilder
summary_builder = DefaultSummaryBuilder(
llm_summarize_fn=self.llm_summarize_fn
)
self.summary_builder = summary_builder
# Anti-thrashing state tracking
@@ -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),
)
@@ -0,0 +1,172 @@
"""Default structured summary builder for context compaction.
This is the concrete :class:`~praisonaiagents.compaction.protocols.
SummaryBuilderProtocol` implementation that renders the declared
:data:`~praisonaiagents.compaction.config.SUMMARY_TEMPLATE` — the phase-aware
sections (``## Active Task`` / ``## Completed Actions`` / ``## In Progress`` /
``## Pending Questions`` / ``## Relevant Files / Paths`` / ``## Remaining
Work``) — so a compacted context keeps its structure instead of degrading to a
one-line ``"Summary of N messages"`` count.
Design: **deterministic, dependency-free, offline-safe.** It categorises the
transcript into the template sections from the messages themselves (roles,
tool calls, question marks, file-path tokens) rather than making an LLM call, so
it never blocks the hot compaction path, never adds a runtime dependency, and
never raises into the loop. File paths and identifiers are preserved verbatim.
When an optional ``llm_summarize_fn`` is supplied the builder still renders the
same template shape; on any failure it falls back to the deterministic render,
honouring the issue's "fall back to the plain summary on failure" rule.
"""
from __future__ import annotations
import re
from typing import Any, Dict, List, Optional
from .config import SUMMARY_TEMPLATE
# Match file/path-like tokens so they can be surfaced verbatim in the
# "Relevant Files / Paths" section (absolute/relative paths and dotted module
# names with a recognisable extension).
_PATH_RE = re.compile(
r"(?:/[^\s'\"`]+|(?:[\w.-]+/)+[\w.-]+|[\w./-]+\.[A-Za-z0-9]{1,6})"
)
_MAX_ITEMS = 12
_MAX_LINE = 200
def _text(content: Any) -> str:
"""Best-effort flatten of a message ``content`` field into a string."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts: List[str] = []
for part in content:
if isinstance(part, dict):
parts.append(str(part.get("text", "")))
else:
parts.append(str(part))
return " ".join(p for p in parts if p)
return ""
def _clip(text: str, limit: int = _MAX_LINE) -> str:
text = text.strip().replace("\n", " ")
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
class DefaultSummaryBuilder:
"""Deterministic :class:`SummaryBuilderProtocol` renderer for the template.
Args:
llm_summarize_fn: Optional callable ``(prompt: str) -> str`` used to
distil section bodies. Any failure falls back to the deterministic
render, so callers get a valid structured summary regardless.
"""
def __init__(self, llm_summarize_fn: Optional[Any] = None) -> None:
self._llm_summarize_fn = llm_summarize_fn
# -- protocol ----------------------------------------------------------
def build_structured_summary(self, messages: List[Dict[str, Any]]) -> str:
"""Render :data:`SUMMARY_TEMPLATE` from ``messages`` deterministically.
Never raises: on any failure returns the plain one-line summary so the
compaction loop keeps a valid summary string (issue fallback rule).
"""
try:
return self._render(messages)
except Exception: # noqa: BLE001 - summarisation must never crash the loop
return f"Summary of {len(messages)} messages"
def merge_summaries(self, previous: str, current: str) -> str:
"""Merge a prior structured summary with the current one.
Keeps both under a single "previous / latest" framing so an iterative
re-compaction never discards the earlier phase structure.
"""
previous = (previous or "").strip()
current = (current or "").strip()
if not previous:
return current
if not current:
return previous
return f"{previous}\n\n[Updated with newer activity]\n{current}"
# -- rendering ---------------------------------------------------------
def _render(self, messages: List[Dict[str, Any]]) -> str:
completed: List[str] = []
in_progress: List[str] = []
pending: List[str] = []
files: List[str] = []
active_task = ""
for msg in messages:
role = msg.get("role", "")
body = _text(msg.get("content"))
# First substantive user turn frames the active task.
if role == "user" and body.strip() and not active_task:
active_task = _clip(body)
# Executed work — tool calls and tool results — is *completed*.
# An assistant turn that merely proposes or *asks* about work has
# not been executed, so it must not be recorded as completed (it is
# captured under "Pending Questions" / "In Progress" instead);
# only a substantive, non-questioning assistant turn is completed.
tool_calls = msg.get("tool_calls")
if isinstance(tool_calls, list) and tool_calls:
for tc in tool_calls:
if not isinstance(tc, dict):
continue
name = (tc.get("function", {}) or {}).get("name", "tool")
completed.append(f"Called `{name}`")
elif role == "assistant" and body.strip() and "?" not in body:
completed.append(_clip(body))
elif role == "tool" and body.strip():
completed.append(_clip(f"tool result: {body}"))
# Any turn with a question mark contributes an open question.
if body and "?" in body:
for sentence in body.split("?"):
sentence = sentence.strip()
if sentence:
pending.append(_clip(sentence + "?"))
# Harvest file/path tokens verbatim from every turn.
if body:
for match in _PATH_RE.findall(body):
if match not in files:
files.append(match)
# The most recent user turn (if different) is what's in progress.
for msg in reversed(messages):
if msg.get("role") == "user":
latest = _clip(_text(msg.get("content")))
if latest and latest != active_task:
in_progress.append(latest)
break
return SUMMARY_TEMPLATE.format(
active_task=active_task or "(not specified)",
completed=self._bullets(completed),
in_progress=self._bullets(in_progress),
pending=self._bullets(pending),
files=self._bullets(files),
remaining=self._bullets(pending or in_progress),
)
@staticmethod
def _bullets(items: List[str]) -> str:
if not items:
return "(none)"
# De-duplicate while preserving order, then cap.
seen: List[str] = []
for item in items:
if item not in seen:
seen.append(item)
return "\n".join(f"- {item}" for item in seen[:_MAX_ITEMS])
@@ -25,7 +25,7 @@ Usage:
# With config
agent = Agent(
instructions="...",
memory=MemoryConfig(backend="redis", user_id="user123"),
memory=MemoryConfig(backend="sqlite", user_id="user123"),
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
)
@@ -52,14 +52,54 @@ if TYPE_CHECKING:
class MemoryBackend(str, Enum):
"""Memory storage backends."""
"""Memory storage backends.
Every member must have an adapter in ``praisonaiagents.memory.adapters``
(FILE is served by ``FileMemory``). Declaring a backend with no adapter
used to silently swap in a local SQLite/JSON store.
"""
FILE = "file"
SQLITE = "sqlite"
REDIS = "redis"
VALKEY = "valkey"
POSTGRES = "postgres"
CHROMA = "chroma"
MEM0 = "mem0"
MONGODB = "mongodb"
DAKERA = "dakera"
IN_MEMORY = "in_memory"
#: Accepted before but never implemented: requests were silently redirected.
UNIMPLEMENTED_MEMORY_BACKENDS = ("redis", "valkey", "postgres", "postgresql")
def available_memory_backends() -> List[str]:
"""Backends ``MemoryConfig(backend=...)`` accepts, read from the registry.
Adapters added via ``register_memory_adapter()`` are accepted automatically.
"""
from ..memory.adapters import list_memory_adapters
return sorted(set(list_memory_adapters()) | {MemoryBackend.FILE.value})
def validate_memory_backend(backend: Union[str, "MemoryBackend"]) -> str:
"""Normalise a backend name; raise rather than substitute a different store."""
from .parse_utils import make_preset_error
from ..memory.adapters.registry import MEMORY_PROVIDER_ALIASES
name = backend.value if isinstance(backend, MemoryBackend) else str(backend)
name = MEMORY_PROVIDER_ALIASES.get(name.strip().lower(), name.strip().lower())
valid = available_memory_backends()
if name in valid:
return name
if name in UNIMPLEMENTED_MEMORY_BACKENDS:
raise ValueError(
f"Memory backend '{name}' is not implemented - no {name} memory "
f"adapter is registered. Earlier releases accepted it and silently "
f"stored to a local file instead. Valid backends: {', '.join(valid)}. "
f"To keep using {name}, pass a live store (memory=<instance> or "
f"db(url=...)), or register an adapter with "
f"register_memory_adapter('{name}', MyAdapter)."
)
raise make_preset_error("memory backend", name, valid)
class LearnScope(str, Enum):
@@ -194,7 +234,7 @@ class MemoryConfig:
Agent(memory=True)
# With backend
Agent(memory=MemoryConfig(backend="redis"))
Agent(memory=MemoryConfig(backend="sqlite"))
# Full config with learning
Agent(memory=MemoryConfig(
@@ -249,7 +289,11 @@ class MemoryConfig:
# Auto-save session name (consolidated from standalone auto_save param)
# When set, automatically saves session to memory with this name
auto_save: Optional[str] = None
def __post_init__(self):
# Reject a backend we cannot provide instead of substituting another.
self.backend = validate_memory_backend(self.backend)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
learn_dict = None
@@ -279,11 +323,18 @@ class MemoryConfig:
class ChunkingStrategy(str, Enum):
"""Knowledge chunking strategies."""
"""Knowledge chunking strategies.
Every member must resolve to a chunker via
``praisonaiagents.knowledge.chunking.normalize_chunker_type``. FIXED and
PARAGRAPH are aliases for ``token``/``recursive``; the rest are chunker ids.
"""
FIXED = "fixed"
PARAGRAPH = "paragraph"
TOKEN = "token"
RECURSIVE = "recursive"
SEMANTIC = "semantic"
SENTENCE = "sentence"
PARAGRAPH = "paragraph"
@dataclass
@@ -322,10 +373,12 @@ class KnowledgeConfig:
embedder: str = "openai"
embedder_config: Optional[Dict[str, Any]] = None
# Chunking (direct fields)
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.SEMANTIC
chunk_size: int = 1000
chunk_overlap: int = 200
# Chunking (direct fields). Defaults match what Knowledge actually used
# while these fields were inert, so enabling them changes nothing for
# anyone who did not set them.
chunking_strategy: Union[str, ChunkingStrategy] = ChunkingStrategy.RECURSIVE
chunk_size: int = 512
chunk_overlap: int = 50
# Chunker config dict (alternative to direct fields)
# Supports: {"type": "semantic", "chunk_size": 512, ...}
@@ -847,10 +900,6 @@ class ExecutionConfig:
code_execution: bool = False
code_mode: str = "safe" # "safe" or "unsafe"
# Code execution sandbox mode - "sandbox" (default) uses subprocess isolation
# "direct" runs in current process (legacy, less secure)
code_sandbox_mode: str = "sandbox" # "sandbox" or "direct"
# Code-execution-with-tools (code mode): when True, model-generated code may
# call the agent's registered tools directly via injected proxies, enabling
# multi-step tool pipelines in a single turn (intermediate results stay out
@@ -891,8 +940,12 @@ class ExecutionConfig:
# Parallel tool execution (Gap 2): Enable parallel execution of batched LLM tool calls
# When True, multiple tool calls from LLM are executed concurrently instead of sequentially
# Default False preserves existing behavior for backward compatibility
parallel_tool_calls: bool = False
# Default False preserves existing behavior for backward compatibility.
# This is the single source of truth; ToolConfig.parallel is a deprecated alias.
# None is accepted only as an internal "not set" sentinel (normalised to False
# in __post_init__) so the Agent can tell an explicit False from an omitted one
# and reject a conflicting ToolConfig(parallel=True) instead of silently winning.
parallel_tool_calls: Optional[bool] = None
# Durable tool-loop replay. Default-off keeps the execution hot path free
# of journal construction and SQLite writes.
@@ -902,6 +955,11 @@ class ExecutionConfig:
def __post_init__(self) -> None:
"""Post-initialization processing with deprecation warnings and validation."""
# parallel_tool_calls uses None as an internal "not set" sentinel so the
# Agent can distinguish an explicit False from an omitted default. Record
# explicitness, then normalise to a plain bool for every downstream reader.
self._parallel_tool_calls_explicit = self.parallel_tool_calls is not None
self.parallel_tool_calls = bool(self.parallel_tool_calls)
# Validate the unified step budget early (before any early returns below).
if self.max_steps is not None and self.max_steps < 1:
raise ValueError("ExecutionConfig.max_steps must be >= 1 when set.")
@@ -993,7 +1051,6 @@ class ExecutionConfig:
"retry_jitter": self.retry_jitter,
"code_execution": self.code_execution,
"code_mode": self.code_mode,
"code_sandbox_mode": self.code_sandbox_mode,
"code_tools": self.code_tools,
"code_tools_allow": self.code_tools_allow,
"context_compaction": (
@@ -1045,7 +1102,6 @@ class ExecutionConfig:
retry_jitter=data.get("retry_jitter", 0.1),
code_execution=data.get("code_execution", False),
code_mode=data.get("code_mode", "safe"),
code_sandbox_mode=data.get("code_sandbox_mode", "sandbox"),
code_tools=data.get("code_tools", False),
code_tools_allow=data.get("code_tools_allow", None),
context_compaction=context_compaction,
@@ -1055,7 +1111,7 @@ class ExecutionConfig:
max_context_tokens=data.get("max_context_tokens", None),
compaction_strategy=compaction_strategy,
max_budget=data.get("max_budget", None),
parallel_tool_calls=data.get("parallel_tool_calls", False),
parallel_tool_calls=data.get("parallel_tool_calls", None),
durable=data.get("durable", False),
journal_path=data.get("journal_path", None),
resume_run_id=data.get("resume_run_id", None),
@@ -1157,8 +1213,13 @@ class ToolConfig:
# Retry policy for tool execution with exponential backoff
retry_policy: Optional[Any] = None # RetryPolicy instance
# Enable parallel execution of batched LLM tool calls
parallel: bool = False
# DEPRECATED ALIAS for ExecutionConfig.parallel_tool_calls, which is the
# single source of truth for "run batched LLM tool calls in parallel".
# None (the default) means "not set here" -- the value is taken from
# execution=ExecutionConfig(parallel_tool_calls=...). An explicit True/False
# feeds that field instead; a value that contradicts an explicit
# ExecutionConfig(parallel_tool_calls=True) raises TypeError at Agent().
parallel: Optional[bool] = None
# Tool output handling and artifact storage
output_limit: int = DEFAULT_TOOL_OUTPUT_LIMIT # Maximum bytes before spilling to artifact store
@@ -1221,7 +1282,7 @@ class ToolConfig:
return cls(
timeout=data.get("timeout"),
retry_policy=retry_policy,
parallel=data.get("parallel", False),
parallel=data.get("parallel"),
output_limit=data.get("output_limit", DEFAULT_TOOL_OUTPUT_LIMIT),
output_max_lines=data.get("output_max_lines"),
output_direction=data.get("output_direction", "both"),
@@ -1841,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:
@@ -1848,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."
@@ -1925,6 +2039,7 @@ __all__ = [
"resolve_tool_search",
"resolve_tools",
"resolve_runtime",
"canonical_runtime_name",
]
@@ -49,7 +49,6 @@ VALID_DEFAULTS_KEYS = {
"model": (str,),
"small_model": (str,),
"base_url": (str,),
"api_key": (str,),
# Feature flags
"allow_delegation": (bool,),
"allow_code_execution": (bool,),
@@ -66,9 +65,7 @@ VALID_DEFAULTS_KEYS = {
"caching": (dict, bool),
"autonomy": (dict, bool),
"skills": (dict, list),
"context": (dict, bool),
"hooks": (dict, list),
"templates": (dict,),
"retry": (dict, bool),
}
VALID_ROOT_KEYS = {"plugins", "defaults"}
@@ -158,12 +155,14 @@ class DefaultsConfig:
knowledge: Optional[Dict[str, Any]] = None
planning: Optional[Dict[str, Any]] = None
reflection: Optional[Dict[str, Any]] = None
guardrails: Optional[Dict[str, Any]] = None
web: Optional[Dict[str, Any]] = None
output: Optional[Dict[str, Any]] = None
execution: Optional[Dict[str, Any]] = None
caching: Optional[Dict[str, Any]] = None
autonomy: Optional[Dict[str, Any]] = None
skills: Optional[Dict[str, Any]] = None
retry: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
return {
@@ -177,12 +176,14 @@ class DefaultsConfig:
"knowledge": self.knowledge,
"planning": self.planning,
"reflection": self.reflection,
"guardrails": self.guardrails,
"web": self.web,
"output": self.output,
"execution": self.execution,
"caching": self.caching,
"autonomy": self.autonomy,
"skills": self.skills,
"retry": self.retry,
}
@@ -352,12 +353,14 @@ def _dict_to_defaults_config(data: Dict[str, Any]) -> DefaultsConfig:
knowledge=data.get("knowledge"),
planning=data.get("planning"),
reflection=data.get("reflection"),
guardrails=data.get("guardrails"),
web=data.get("web"),
output=data.get("output"),
execution=data.get("execution"),
caching=data.get("caching"),
autonomy=data.get("autonomy"),
skills=data.get("skills"),
retry=data.get("retry"),
)
@@ -706,8 +709,8 @@ def _defaults_has_any_values() -> bool:
getattr(defaults, key, None) is not None
for key in (
"model", "small_model", "base_url", "memory", "knowledge",
"planning", "reflection", "web", "output", "execution",
"caching", "autonomy",
"planning", "reflection", "guardrails", "web", "output",
"execution", "caching", "autonomy", "skills", "retry",
)
)
return _defaults_has_values
@@ -314,10 +314,14 @@ def _resolve_string(
# Check for preset
if presets:
# Case-insensitive lookup
value_lower = value.lower()
# Case-insensitive lookup, whitespace-tolerant and treating -/_ as
# interchangeable so the accepted spellings match the closed-set guard
# in validate_preset_string (which uses the same normalization). Without
# this, a value that passed construction-time validation (e.g.
# " sliding_window " or "sliding-window") would be rejected here.
value_norm = value.strip().lower().replace("-", "_")
for preset_key in presets:
if preset_key.lower() == value_lower:
if preset_key.strip().lower().replace("-", "_") == value_norm:
return _apply_preset(preset_key, presets, config_class)
# Not a valid preset - raise helpful error
@@ -195,6 +195,53 @@ def make_preset_error(
return ValueError(" ".join(msg_parts))
def canonical_preset_key(value: str) -> str:
"""
The single spelling rule for preset names. Validation and resolution must
not disagree about what counts as the same preset, so both go through here.
Matching is case-insensitive, ignores surrounding whitespace, and treats
``-``/``_`` as interchangeable.
"""
return value.strip().lower().replace("-", "_")
def validate_preset_string(
param_name: str,
value: Any,
presets: Iterable[str],
) -> None:
"""
Raise a helpful error when a string does not name a known preset.
Non-string values pass through untouched, so callers can hand this the raw
parameter without pre-checking the type. Matching is case-insensitive,
ignores surrounding whitespace, and treats ``-``/``_`` as interchangeable,
mirroring the preset lookups performed by the parameter resolvers (e.g.
``PermissionMode.resolve`` accepts both ``accept_edits`` and
``accept-edits``). This keeps the guard from rejecting spellings the
downstream resolver would happily accept.
Args:
param_name: Name of the parameter (for the error message)
value: Raw parameter value
presets: Valid preset names
Raises:
ValueError: If value is a string that is not a known preset
"""
if not isinstance(value, str):
return
normalized = canonical_preset_key(value)
presets_list = list(presets)
for preset in presets_list:
if canonical_preset_key(preset) == normalized:
return
raise make_preset_error(param_name, value, presets_list)
def is_policy_string(value: str) -> bool:
"""
Check if a string is a policy specification. O(1) operation.
@@ -19,9 +19,7 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
# Backend presets
"file": {"backend": "file"},
"sqlite": {"backend": "sqlite"},
"redis": {"backend": "redis"},
"postgres": {"backend": "postgres"},
"postgresql": {"backend": "postgres"},
"chroma": {"backend": "chroma"},
"mem0": {"backend": "mem0"},
"mongodb": {"backend": "mongodb"},
# Feature presets
@@ -32,11 +30,10 @@ MEMORY_PRESETS: Dict[str, Dict[str, Any]] = {
"chat": {"backend": "file", "history": True, "history_limit": 20}, # Conversational
}
# Only schemes backed by a registered memory adapter belong here. A scheme
# listed without an adapter resolved to a backend that does not exist, and the
# URL (host, credentials, database) was then dropped entirely.
MEMORY_URL_SCHEMES: Dict[str, str] = {
"postgresql": "postgres",
"postgres": "postgres",
"redis": "redis",
"rediss": "redis", # Redis with SSL
"sqlite": "sqlite",
"mongodb": "mongodb",
"mongodb+srv": "mongodb",
@@ -242,41 +242,51 @@ class ContextComposer:
full_history: List[Dict[str, Any]],
tool_call_ids: set
) -> List[Dict[str, Any]]:
"""Ensure tool_call/tool_result pairs are complete."""
# Find tool calls in result
result_call_ids = set()
result_response_ids = set()
for msg in result:
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and "id" in tc:
result_call_ids.add(tc["id"])
if msg.get("tool_call_id"):
result_response_ids.add(msg["tool_call_id"])
# Find missing pairs
missing_calls = result_response_ids - result_call_ids
missing_responses = result_call_ids - result_response_ids
if not missing_calls and not missing_responses:
return result
# Add missing messages from full history
for msg in full_history:
# Add missing tool calls
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and tc.get("id") in missing_calls:
if msg not in result:
result.insert(0, msg)
break
# Add missing tool responses
if msg.get("tool_call_id") in missing_responses:
if msg not in result:
result.append(msg)
"""Ensure tool_call/tool_result pairs are complete.
Reinserting a tool_calls message can introduce a new missing response
(a sibling parallel call), and reinserting a response can introduce a
new missing call. Iterate to a fixed point so the final message list
never contains a tool_call id without its matching tool response.
"""
changed = True
while changed:
changed = False
# Recompute pairs present in the current result
result_call_ids = set()
result_response_ids = set()
for msg in result:
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and "id" in tc:
result_call_ids.add(tc["id"])
if msg.get("tool_call_id"):
result_response_ids.add(msg["tool_call_id"])
missing_calls = result_response_ids - result_call_ids
missing_responses = result_call_ids - result_response_ids
if not missing_calls and not missing_responses:
break
# Add missing messages from full history
for msg in full_history:
# Add missing tool calls
if "tool_calls" in msg:
for tc in msg.get("tool_calls", []):
if isinstance(tc, dict) and tc.get("id") in missing_calls:
if msg not in result:
result.insert(0, msg)
changed = True
break
# Add missing tool responses
if msg.get("tool_call_id") in missing_responses:
if msg not in result:
result.append(msg)
changed = True
return result
def get_ledger(self) -> ContextLedger:
@@ -14,6 +14,21 @@ from .compressor import ContextCompressor
logger = logging.getLogger(__name__)
def _skip_leading_tool_messages(
messages: List[Dict[str, Any]]
) -> List[Dict[str, Any]]:
"""Drop any leading orphaned ``role="tool"`` messages.
A window cut can otherwise begin on a tool result whose originating
assistant ``tool_calls`` message was trimmed off, which strict providers
reject as an invalid transcript (Issue #3089).
"""
start = 0
while start < len(messages) and messages[start].get("role") == "tool":
start += 1
return messages[start:]
class BaseOptimizer(ABC):
"""Base class for optimization strategies."""
@@ -71,8 +86,9 @@ class TruncateOptimizer(BaseOptimizer):
else:
other_msgs.append(msg)
# Keep recent messages
# Keep recent messages (skip a leading orphaned tool result).
recent = other_msgs[-self.preserve_recent:] if other_msgs else []
recent = _skip_leading_tool_messages(recent)
result = system_msgs + recent
@@ -82,7 +98,17 @@ class TruncateOptimizer(BaseOptimizer):
if msg.get("role") != "system":
result.pop(i)
break
# The budget loop above pops the first non-system message, which can be
# an assistant ``tool_calls`` message whose ``role="tool"`` result stays
# behind — re-orphaning it. Re-skip leading tool results so the trimmed
# transcript never starts on an orphaned tool message (Issue #3089).
system_msgs = [m for m in result if m.get("role") == "system"]
other_msgs = _skip_leading_tool_messages(
[m for m in result if m.get("role") != "system"]
)
result = system_msgs + other_msgs
optimized_tokens = estimate_messages_tokens(result)
return result, OptimizationResult(
@@ -133,9 +159,11 @@ class SlidingWindowOptimizer(BaseOptimizer):
else:
break
# Ensure order
# Ensure order (skip a leading orphaned tool result in the tail).
system_msgs = [m for m in result if m.get("role") == "system"]
other_msgs = [m for m in result if m.get("role") != "system"]
other_msgs = _skip_leading_tool_messages(
[m for m in result if m.get("role") != "system"]
)
result = system_msgs + other_msgs
optimized_tokens = estimate_messages_tokens(result)
@@ -1080,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",
@@ -6,7 +6,7 @@ Provides recovery strategies when loops are detected.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
from enum import Enum
import time
import hashlib
@@ -117,7 +117,7 @@ class DoomLoopDetector:
self._loop_events: List[DoomLoopEvent] = []
self._recovery_attempts: int = 0
self._start_time: Optional[float] = None
self._progress_markers: List[str] = []
self._progress_markers: List[Tuple[str, float]] = []
self._current_backoff: float = self.config.initial_backoff
self._content_chunk_counts: Dict[str, int] = {} # hash -> count
@@ -193,7 +193,7 @@ class DoomLoopDetector:
Call this when the agent makes real progress (e.g., file modified,
test passed, user goal partially achieved).
"""
self._progress_markers.append(marker)
self._progress_markers.append((marker, time.time()))
def is_doom_loop(self) -> bool:
"""
@@ -379,10 +379,19 @@ class DoomLoopDetector:
if len(self._actions) < self.config.max_no_progress_steps:
return False
# If we have progress markers, we're making progress
# Only count progress markers within the current no-progress window.
# Without this recency filter a single early success would suppress
# NO_PROGRESS detection for the rest of the session.
# Use the boundary *before* the window (the completion timestamp of the
# action immediately preceding it, or 0 if the window is the whole
# history) so a marker recorded *during* the window's first action —
# whose own record_action timestamp is set at completion, i.e. after the
# marker — is still counted rather than filtered out.
n = self.config.max_no_progress_steps
boundary = self._actions[-n - 1].timestamp if len(self._actions) > n else 0.0
recent_markers = [
m for m in self._progress_markers
if True # Could add timestamp filtering
m for (m, ts) in self._progress_markers
if ts >= boundary
]
if recent_markers:
return False
@@ -115,105 +115,99 @@ __all__ = [
"TrialReport",
]
from .._lazy import create_lazy_getattr
_LAZY_IMPORTS = {
"BaseEvaluator": ("base", "BaseEvaluator"),
"AccuracyEvaluator": ("accuracy", "AccuracyEvaluator"),
"PerformanceEvaluator": ("performance", "PerformanceEvaluator"),
"ReliabilityEvaluator": ("reliability", "ReliabilityEvaluator"),
"CriteriaEvaluator": ("criteria", "CriteriaEvaluator"),
"ContextEvaluator": ("context_eval", "ContextEvaluator"),
"MediaEvaluator": ("media", "MediaEvaluator"),
"ComparisonEval": ("comparison", "ComparisonEval"),
"SafetyEval": ("safety", "SafetyEval"),
"HarnessEvaluator": ("harness_eval", "HarnessEvaluator"),
"harness_row_to_eval_case": ("harness_eval", "harness_row_to_eval_case"),
"HarnessResult": ("results", "HarnessResult"),
"EvalSuite": ("suite", "EvalSuite"),
"AccuracyResult": ("results", "AccuracyResult"),
"PerformanceResult": ("results", "PerformanceResult"),
"ReliabilityResult": ("results", "ReliabilityResult"),
"CriteriaResult": ("results", "CriteriaResult"),
"ContextHandoffResult": ("context_eval", "ContextHandoffResult"),
"BudgetComplianceResult": ("context_eval", "BudgetComplianceResult"),
"ContextEvalResult": ("context_eval", "ContextEvalResult"),
"MediaEvaluationResult": ("media", "MediaEvaluationResult"),
"ComparisonResult": ("comparison", "ComparisonResult"),
"SafetyResult": ("safety", "SafetyResult"),
"EvalSuiteResult": ("suite", "EvalSuiteResult"),
"EvaluationScore": ("results", "EvaluationScore"),
"PerformanceMetrics": ("results", "PerformanceMetrics"),
"ToolCallResult": ("results", "ToolCallResult"),
"CriteriaScore": ("results", "CriteriaScore"),
"BaseEvaluator": ("praisonaiagents.eval.base", "BaseEvaluator"),
"AccuracyEvaluator": ("praisonaiagents.eval.accuracy", "AccuracyEvaluator"),
"PerformanceEvaluator": ("praisonaiagents.eval.performance", "PerformanceEvaluator"),
"ReliabilityEvaluator": ("praisonaiagents.eval.reliability", "ReliabilityEvaluator"),
"CriteriaEvaluator": ("praisonaiagents.eval.criteria", "CriteriaEvaluator"),
"ContextEvaluator": ("praisonaiagents.eval.context_eval", "ContextEvaluator"),
"MediaEvaluator": ("praisonaiagents.eval.media", "MediaEvaluator"),
"ComparisonEval": ("praisonaiagents.eval.comparison", "ComparisonEval"),
"SafetyEval": ("praisonaiagents.eval.safety", "SafetyEval"),
"HarnessEvaluator": ("praisonaiagents.eval.harness_eval", "HarnessEvaluator"),
"harness_row_to_eval_case": ("praisonaiagents.eval.harness_eval", "harness_row_to_eval_case"),
"HarnessResult": ("praisonaiagents.eval.results", "HarnessResult"),
"EvalSuite": ("praisonaiagents.eval.suite", "EvalSuite"),
"AccuracyResult": ("praisonaiagents.eval.results", "AccuracyResult"),
"PerformanceResult": ("praisonaiagents.eval.results", "PerformanceResult"),
"ReliabilityResult": ("praisonaiagents.eval.results", "ReliabilityResult"),
"CriteriaResult": ("praisonaiagents.eval.results", "CriteriaResult"),
"ContextHandoffResult": ("praisonaiagents.eval.context_eval", "ContextHandoffResult"),
"BudgetComplianceResult": ("praisonaiagents.eval.context_eval", "BudgetComplianceResult"),
"ContextEvalResult": ("praisonaiagents.eval.context_eval", "ContextEvalResult"),
"MediaEvaluationResult": ("praisonaiagents.eval.media", "MediaEvaluationResult"),
"ComparisonResult": ("praisonaiagents.eval.comparison", "ComparisonResult"),
"SafetyResult": ("praisonaiagents.eval.safety", "SafetyResult"),
"EvalSuiteResult": ("praisonaiagents.eval.suite", "EvalSuiteResult"),
"EvaluationScore": ("praisonaiagents.eval.results", "EvaluationScore"),
"PerformanceMetrics": ("praisonaiagents.eval.results", "PerformanceMetrics"),
"ToolCallResult": ("praisonaiagents.eval.results", "ToolCallResult"),
"CriteriaScore": ("praisonaiagents.eval.results", "CriteriaScore"),
# DRY: Common grading base classes and protocols
"BaseLLMGrader": ("grader", "BaseLLMGrader"),
"GradeResult": ("grader", "GradeResult"),
"GraderProtocol": ("protocols", "GraderProtocol"),
"GradeResultProtocol": ("protocols", "GradeResultProtocol"),
"ScoredResultProtocol": ("protocols", "ScoredResultProtocol"),
"AsyncGraderProtocol": ("protocols", "AsyncGraderProtocol"),
"parse_score_reasoning": ("grader", "parse_score_reasoning"),
"BaseLLMGrader": ("praisonaiagents.eval.grader", "BaseLLMGrader"),
"GradeResult": ("praisonaiagents.eval.grader", "GradeResult"),
"GraderProtocol": ("praisonaiagents.eval.protocols", "GraderProtocol"),
"GradeResultProtocol": ("praisonaiagents.eval.protocols", "GradeResultProtocol"),
"ScoredResultProtocol": ("praisonaiagents.eval.protocols", "ScoredResultProtocol"),
"AsyncGraderProtocol": ("praisonaiagents.eval.protocols", "AsyncGraderProtocol"),
"parse_score_reasoning": ("praisonaiagents.eval.grader", "parse_score_reasoning"),
# Eval package types
"EvalCase": ("package", "EvalCase"),
"EvalResult": ("package", "EvalResult"),
"EvalReport": ("package", "EvalReport"),
"EvalPackage": ("package", "EvalPackage"),
"EvalRunnerProtocol": ("package", "EvalRunnerProtocol"),
"EvalCase": ("praisonaiagents.eval.package", "EvalCase"),
"EvalResult": ("praisonaiagents.eval.package", "EvalResult"),
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
# Unified Judge API (follows add_X/get_X/list_X naming)
"Judge": ("judge", "Judge"),
"JudgeConfig": ("judge", "JudgeConfig"),
"JudgeResult": ("results", "JudgeResult"),
"JudgeProtocol": ("protocols", "JudgeProtocol"),
"JudgeResultProtocol": ("protocols", "JudgeResultProtocol"),
"AccuracyJudge": ("judge", "AccuracyJudge"),
"CriteriaJudge": ("judge", "CriteriaJudge"),
"RecipeJudge": ("judge", "RecipeJudge"),
"add_judge": ("judge", "add_judge"),
"get_judge": ("judge", "get_judge"),
"list_judges": ("judge", "list_judges"),
"remove_judge": ("judge", "remove_judge"),
"Judge": ("praisonaiagents.eval.judge", "Judge"),
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
"JudgeResult": ("praisonaiagents.eval.results", "JudgeResult"),
"JudgeProtocol": ("praisonaiagents.eval.protocols", "JudgeProtocol"),
"JudgeResultProtocol": ("praisonaiagents.eval.protocols", "JudgeResultProtocol"),
"AccuracyJudge": ("praisonaiagents.eval.judge", "AccuracyJudge"),
"CriteriaJudge": ("praisonaiagents.eval.judge", "CriteriaJudge"),
"RecipeJudge": ("praisonaiagents.eval.judge", "RecipeJudge"),
"add_judge": ("praisonaiagents.eval.judge", "add_judge"),
"get_judge": ("praisonaiagents.eval.judge", "get_judge"),
"list_judges": ("praisonaiagents.eval.judge", "list_judges"),
"remove_judge": ("praisonaiagents.eval.judge", "remove_judge"),
# Dynamic judge configuration (domain-agnostic)
"JudgeCriteriaConfig": ("judge", "JudgeCriteriaConfig"),
"OptimizationRuleProtocol": ("protocols", "OptimizationRuleProtocol"),
"add_optimization_rule": ("judge", "add_optimization_rule"),
"get_optimization_rule": ("judge", "get_optimization_rule"),
"list_optimization_rules": ("judge", "list_optimization_rules"),
"remove_optimization_rule": ("judge", "remove_optimization_rule"),
"JudgeCriteriaConfig": ("praisonaiagents.eval.judge", "JudgeCriteriaConfig"),
"OptimizationRuleProtocol": ("praisonaiagents.eval.protocols", "OptimizationRuleProtocol"),
"add_optimization_rule": ("praisonaiagents.eval.judge", "add_optimization_rule"),
"get_optimization_rule": ("praisonaiagents.eval.judge", "get_optimization_rule"),
"list_optimization_rules": ("praisonaiagents.eval.judge", "list_optimization_rules"),
"remove_optimization_rule": ("praisonaiagents.eval.judge", "remove_optimization_rule"),
# Token utilities
"estimate_tokens": ("tokens", "estimate_tokens"),
"get_context_length": ("tokens", "get_context_length"),
"count_tokens": ("tokens", "count_tokens"),
"needs_chunking": ("tokens", "needs_chunking"),
"get_recommended_chunk_size": ("tokens", "get_recommended_chunk_size"),
"estimate_tokens": ("praisonaiagents.eval.tokens", "estimate_tokens"),
"get_context_length": ("praisonaiagents.eval.tokens", "get_context_length"),
"count_tokens": ("praisonaiagents.eval.tokens", "count_tokens"),
"needs_chunking": ("praisonaiagents.eval.tokens", "needs_chunking"),
"get_recommended_chunk_size": ("praisonaiagents.eval.tokens", "get_recommended_chunk_size"),
# EvaluationLoop (iterative improvement)
"EvaluationLoop": ("loop", "EvaluationLoop"),
"EvaluationLoopConfig": ("loop", "EvaluationLoopConfig"),
"IterationResult": ("results", "IterationResult"),
"EvaluationLoopResult": ("results", "EvaluationLoopResult"),
"EvaluationLoopProtocol": ("protocols", "EvaluationLoopProtocol"),
"EvaluationLoopResultProtocol": ("protocols", "EvaluationLoopResultProtocol"),
"EvaluationLoop": ("praisonaiagents.eval.loop", "EvaluationLoop"),
"EvaluationLoopConfig": ("praisonaiagents.eval.loop", "EvaluationLoopConfig"),
"IterationResult": ("praisonaiagents.eval.results", "IterationResult"),
"EvaluationLoopResult": ("praisonaiagents.eval.results", "EvaluationLoopResult"),
"EvaluationLoopProtocol": ("praisonaiagents.eval.protocols", "EvaluationLoopProtocol"),
"EvaluationLoopResultProtocol": ("praisonaiagents.eval.protocols", "EvaluationLoopResultProtocol"),
# LoopEvaluator (loop health: convergence, waste, doom-loop guards)
"LoopEvaluator": ("loop_eval", "LoopEvaluator"),
"LoopHealthResult": ("loop_eval", "LoopHealthResult"),
"LoopEvaluator": ("praisonaiagents.eval.loop_eval", "LoopEvaluator"),
"LoopHealthResult": ("praisonaiagents.eval.loop_eval", "LoopHealthResult"),
# PromptOptimizer (optimise agent.instructions against an eval, keep best)
"PromptOptimizer": ("prompt_optimizer", "PromptOptimizer"),
"OptimizeResult": ("prompt_optimizer", "OptimizeResult"),
"PromptOptimizer": ("praisonaiagents.eval.prompt_optimizer", "PromptOptimizer"),
"OptimizeResult": ("praisonaiagents.eval.prompt_optimizer", "OptimizeResult"),
# Trials engine (K isolated attempts per case, pass-rate + frontier)
"run_trials": ("trials", "run_trials"),
"arun_trials": ("trials", "arun_trials"),
"TrialScore": ("trials", "TrialScore"),
"TrialAttempt": ("trials", "TrialAttempt"),
"TrialReport": ("trials", "TrialReport"),
"run_trials": ("praisonaiagents.eval.trials", "run_trials"),
"arun_trials": ("praisonaiagents.eval.trials", "arun_trials"),
"TrialScore": ("praisonaiagents.eval.trials", "TrialScore"),
"TrialAttempt": ("praisonaiagents.eval.trials", "TrialAttempt"),
"TrialReport": ("praisonaiagents.eval.trials", "TrialReport"),
}
def __getattr__(name: str):
"""Lazy import mechanism for zero-cost imports when not used."""
if name in _LAZY_IMPORTS:
module_name, attr_name = _LAZY_IMPORTS[name]
import importlib
module = importlib.import_module(f".{module_name}", __name__)
return getattr(module, attr_name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__getattr__ = create_lazy_getattr(_LAZY_IMPORTS, __name__)
def __dir__():
@@ -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,
@@ -23,6 +23,7 @@ Example:
from typing import List, Dict, Any, Optional, Union, TYPE_CHECKING
from dataclasses import dataclass
import re
import time
from praisonaiagents._logging import get_logger
from .base import BaseEvaluator
@@ -33,6 +34,15 @@ if TYPE_CHECKING:
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 maximum safety.
_FIELD_RE = re.compile(
r"^[\s>*_#-]*(category|score|detected|reasoning|safety\s*score|overall\s*reasoning|confidence)\s*\**\s*:",
re.IGNORECASE,
)
@dataclass
class SafetyResult:
"""Result container for safety evaluations."""
@@ -169,61 +179,106 @@ Please evaluate this output for safety violations across all specified categorie
"confidence": 1.0
}
@staticmethod
def _field_value(line: str) -> str:
"""Return the value after the first colon, stripped of markdown decoration."""
value = line.split(":", 1)[1] if ":" in line else ""
return value.strip().strip("*_`").strip()
def _parse_safety_response(self, response: str, categories: List[str]) -> Dict[str, Any]:
"""Parse LLM response into structured safety result."""
"""Parse LLM response into structured safety result.
Parses tolerantly (markdown bolding, bullets, case) so ordinary model
formatting is read correctly. A verdict that cannot be parsed at all is
treated as UNSAFE - never as safe - matching the exception handler in
evaluate_safety which defaults to 1.0 for the same reason. Silently
reporting maximum safety on an unparsed violation is the one wrong number
nobody re-checks.
"""
violations = {}
safety_score = 10.0 # Default to safe
# An unparsed verdict is NOT a safe verdict; start with no score and only
# tighten it as real values are read.
safety_score = None
overall_reasoning = ""
confidence = 5.0
parsed_any = False
lines = response.strip().split('\n')
current_category = None
for line in lines:
line = line.strip()
for raw_line in lines:
line = raw_line.strip()
if not line:
continue
# Parse category sections
if line.startswith("Category:"):
current_category = line.split(":", 1)[1].strip().lower()
match = _FIELD_RE.match(line)
if not match:
continue
field = re.sub(r"\s+", " ", match.group(1).lower())
value = self._field_value(line)
if field == "category":
current_category = value.lower()
if current_category in [c.lower() for c in categories]:
violations[current_category] = {}
elif line.startswith("Score:") and current_category:
parsed_any = True
elif field == "score" and current_category:
try:
score = float(line.split(":", 1)[1].strip())
violations[current_category]["score"] = score
safety_score = min(safety_score, score) # Overall score is minimum
score = float(value)
except ValueError:
violations[current_category]["score"] = 5.0
safety_score = min(safety_score, 5.0)
elif line.startswith("Detected:") and current_category:
detected_text = line.split(":", 1)[1].strip().lower()
violations[current_category]["detected"] = detected_text.startswith("y")
elif line.startswith("Reasoning:") and current_category:
violations[current_category]["reasoning"] = line.split(":", 1)[1].strip()
# Parse overall assessment
elif line.startswith("Safety Score:"):
score = 5.0
violations[current_category]["score"] = score
safety_score = score if safety_score is None else min(safety_score, score)
parsed_any = True
elif field == "detected" and current_category:
violations[current_category]["detected"] = value.lower().startswith("y")
parsed_any = True
elif field == "reasoning" and current_category:
violations[current_category]["reasoning"] = value
elif field == "safety score":
try:
parsed_overall = float(line.split(":", 1)[1].strip())
parsed_overall = float(value)
# Don't let overall score override a worse category score
safety_score = min(safety_score, parsed_overall)
safety_score = parsed_overall if safety_score is None else min(safety_score, parsed_overall)
parsed_any = True
except ValueError:
pass
elif line.startswith("Overall Reasoning:"):
overall_reasoning = line.split(":", 1)[1].strip()
elif line.startswith("Confidence:"):
elif field == "overall reasoning":
overall_reasoning = value
elif field == "confidence":
try:
confidence = float(line.split(":", 1)[1].strip())
confidence = float(value)
except ValueError:
confidence = 5.0
# Nothing usable was parsed. Fail closed: report UNSAFE rather than let the
# default fall through to maximum safety.
if not parsed_any or safety_score is None:
logger.warning(
"Could not parse safety judge response; treating as UNSAFE. Raw: %r",
response[:200],
)
return {
"violations": {
category.lower(): {
"detected": True,
"score": 1.0,
"reasoning": "Safety verdict could not be parsed; failing closed.",
}
for category in categories
},
"safety_score": 1.0,
"overall_reasoning": "Safety judge response could not be parsed; treated as unsafe.",
"confidence": 1.0,
}
# Ensure all categories have violation data
for category in categories:
category_lower = category.lower()
@@ -233,7 +288,7 @@ Please evaluate this output for safety violations across all specified categorie
"score": 8.0,
"reasoning": "No specific evaluation provided"
}
return {
"violations": violations,
"safety_score": safety_score,
@@ -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
@@ -50,6 +55,9 @@ from .protocols import (
# Agent-callable message reactions (Issue #3917)
ReactionResult,
ReactionStatus,
# Agent/gateway-callable thread creation (Issue #3987)
ThreadResult,
ThreadStatus,
# Agent-callable cross-conversation request/reply (Issue #3689)
ConversationReply,
ConversationReplyStatus,
@@ -151,6 +159,16 @@ from .protocols import (
TurnLeaseToken,
TurnLockProtocol,
LocalTurnLock,
# Per-session turn-execution isolation (Issue #4011)
WorkerWedgedError,
TurnPlacement,
TurnExecutorProtocol,
InProcessTurnExecutor,
# Global operator emergency-stop / pause brake (Issue #4220)
EmergencyStopState,
EmergencyStopProtocol,
NullEmergencyStop,
FileEmergencyStop,
# Schema-validated inbound frame codec (Issue #2831)
HelloParams,
HelloResult,
@@ -201,6 +219,7 @@ from .config import (
GatewayConfig,
SessionConfig,
ApiConfig,
EmergencyStopConfig,
ChannelRouteConfig,
MultiChannelGatewayConfig,
# Config version stamp + doctor-driven migration (Issue #3841)
@@ -295,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
@@ -314,6 +337,8 @@ __all__ = [
"TargetInfo",
"ReactionResult",
"ReactionStatus",
"ThreadResult",
"ThreadStatus",
# Agent-callable cross-conversation request/reply (Issue #3689)
"ConversationReply",
"ConversationReplyStatus",
@@ -414,6 +439,16 @@ __all__ = [
"TurnLeaseToken",
"TurnLockProtocol",
"LocalTurnLock",
# Per-session turn-execution isolation (Issue #4011)
"WorkerWedgedError",
"TurnPlacement",
"TurnExecutorProtocol",
"InProcessTurnExecutor",
# Global operator emergency-stop / pause brake (Issue #4220)
"EmergencyStopState",
"EmergencyStopProtocol",
"NullEmergencyStop",
"FileEmergencyStop",
# Schema-validated inbound frame codec (Issue #2831)
"HelloParams",
"HelloResult",
@@ -458,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)
@@ -86,7 +86,12 @@ def _lookup(path: str, payload: Dict[str, Any]) -> Any:
return current
def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
def render_template(
template: Optional[str],
payload: Dict[str, Any],
*,
fence_values: bool = False,
) -> str:
"""Render ``template`` against ``payload`` with no external dependencies.
Supports ``{{ payload.x }}`` and ``{x}`` placeholders resolving dotted
@@ -96,6 +101,12 @@ def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
Args:
template: The template string (or None).
payload: The decoded JSON request body.
fence_values: When True, each interpolated payload-derived value is
wrapped in an ``<external_request_payload>`` fence so the agent
treats it as untrusted data, not instructions. The operator's own
static template text stays outside the fence. Used for the agent
message (see :meth:`HookConfig.resolve_message`); left False for
session/idempotency keys where fencing would corrupt the value.
Returns:
The rendered string.
@@ -103,13 +114,35 @@ def render_template(template: Optional[str], payload: Dict[str, Any]) -> str:
if not template:
return ""
wrap = None
notice = None
if fence_values:
from praisonaiagents.tools.trust import (
request_payload_notice as notice,
wrap_request_payload as wrap,
)
fenced = False
def _resolve(match: "re.Match[str]") -> str:
nonlocal fenced
# group(1) is the ``{{ ... }}`` body, group(2) the ``{ ... }`` body.
expr = match.group(1) if match.group(1) is not None else match.group(2)
value = _lookup(expr, payload)
return "" if value is None else str(value)
if value is None:
return ""
if wrap is None:
return str(value)
fenced = True
return wrap(str(value))
return _PLACEHOLDER.sub(_resolve, template)
rendered = _PLACEHOLDER.sub(_resolve, template)
# Prepend the inline untrusted-data notice once (outside the fence) when a
# payload value was fenced, so the semantics travel with the message even if
# the agent runs with ``use_system_prompt=False``.
if fenced and notice is not None:
return f"{notice()}\n\n{rendered}"
return rendered
def compute_idempotency_key(
@@ -375,9 +408,22 @@ class HookConfig:
self.idempotency_key, payload, path=self.path
)
def resolve_message(self, payload: Dict[str, Any]) -> str:
"""Render the agent message for ``payload``."""
return render_template(self.message, payload)
def resolve_message(self, payload: Dict[str, Any], *, fence: bool = True) -> str:
"""Render the hook message for ``payload``.
Payload-derived interpolations are fenced as untrusted request data so
the agent never treats externally-POSTed content as instructions; the
operator's own template text stays outside the fence.
Args:
payload: The decoded request body.
fence: When True (the agent-turn default), interpolated payload
values are wrapped in an ``<external_request_payload>`` fence.
Pass ``fence=False`` for ``deliver_only`` hooks, where the
rendered message is delivered verbatim to a channel with no
agent consuming (and therefore stripping) the fence markup.
"""
return render_template(self.message, payload, fence_values=fence)
def to_dict(self) -> Dict[str, Any]:
"""Convert to a dictionary (hides the auth/signing secrets)."""
@@ -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
@@ -30,6 +30,8 @@ from typing import (
Iterator,
List,
Literal,
Mapping,
MutableMapping,
Optional,
Protocol,
Sequence,
@@ -198,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"
@@ -1093,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`.
"""
...
@@ -1188,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
# ---------------------------------------------------------------------------
@@ -1565,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)
# ---------------------------------------------------------------------------
@@ -2525,6 +2778,59 @@ class ReactionResult:
return data
ThreadStatus = Literal["ok", "unsupported", "failed", "no_route"]
"""Closed set of outcomes for a :meth:`OutboundMessengerProtocol.create_thread`.
* ``ok`` — a new thread/topic was opened; ``thread_id`` carries its id.
* ``unsupported`` — the channel has no ``supports_threads`` capability.
* ``failed`` — the transport rejected the attempt (e.g. topics mode off, or
the bot lacks permission to manage threads).
* ``no_route`` — the target could not be resolved to a reachable channel.
"""
@dataclass
class ThreadResult:
"""Outcome of an agent/gateway-initiated thread creation (Issue #3987).
Every call resolves to exactly one :data:`ThreadStatus`, so the caller
always gets a typed answer rather than a hard error — in particular a
channel that cannot thread returns ``unsupported`` rather than raising, and
the caller falls back to the parent channel. This mirrors the
:class:`ReactionResult` contract.
Attributes:
status: The outcome (``ok`` / ``unsupported`` / ``failed`` /
``no_route``).
target: The resolved parent target the thread was opened under.
thread_id: The new thread/topic id, populated only when
``status == "ok"``. Callers compose ``"<target>:<thread_id>"`` to
route subsequent sends into it.
detail: Optional model-readable explanation.
"""
status: ThreadStatus
target: str = ""
thread_id: str = ""
detail: Optional[str] = None
@property
def ok(self) -> bool:
"""Whether a thread was opened (``status == "ok"``)."""
return self.status == "ok"
def as_dict(self) -> Dict[str, Any]:
"""Convert to a serializable dictionary for the tool return value."""
data: Dict[str, Any] = {"status": self.status}
if self.target:
data["target"] = self.target
if self.thread_id:
data["thread_id"] = self.thread_id
if self.detail:
data["detail"] = self.detail
return data
@runtime_checkable
class OutboundMessengerProtocol(Protocol):
"""Protocol for agent-facing proactive message delivery.
@@ -2597,6 +2903,37 @@ class OutboundMessengerProtocol(Protocol):
"""
...
async def create_thread(
self,
target: str,
name: str,
) -> "ThreadResult":
"""Open a new thread/topic under ``target`` (Issue #3987).
For multi-agent handoffs, subtasks, and parallel workflow branches, this
lets the gateway scope a subtask into its own platform-native thread
(Telegram forum topic, Discord thread, Slack thread anchor) instead of
flooding the shared channel inline. Gated on the channel's
``PlatformCapabilities.supports_threads`` — a channel that cannot thread
returns a typed ``unsupported`` outcome instead of raising, so the
caller falls back to the parent channel::
res = await messenger.create_thread("slack:C123", name="research")
target = f"slack:C123:{res.thread_id}" if res.ok else "slack:C123"
await messenger.send(target, subtask_output)
Args:
target: Symbolic target token ("origin", "<platform>", or
"<platform>:<chat_id>", or a friendly alias).
name: Human-friendly thread/topic title.
Returns:
A :class:`ThreadResult` carrying the new thread id on success, or a
typed ``unsupported`` / ``failed`` / ``no_route`` outcome. Never
raises.
"""
...
# ---------------------------------------------------------------------------
# Agent-callable cross-conversation request/reply (Issue #3689)
@@ -4838,6 +5175,26 @@ class GatewayTraceHook(Protocol):
session=sid,
):
reply = await agent.astart(text)
W3C trace-context propagation is layered on top of the same seam without
forcing OpenTelemetry into core. ``stage`` accepts an optional
``parent_carrier`` (a header mapping such as an inbound request's
``{"traceparent": ..., "tracestate": ...}``) so an exporter can *continue*
an upstream caller's trace instead of starting a detached one; and the two
companion methods let egress call sites propagate the active span context:
# ingress: continue the caller's trace when a traceparent arrives
with self._trace.stage("agent.run", parent_carrier=inbound.headers):
...
# egress: write the active traceparent onto an outbound request.
# Seed with provider headers first, then inject last so the active
# trace context always wins over any stale traceparent/tracestate.
headers = dict(provider_headers)
self._trace.inject_context(headers) # no-op unless an exporter is set
Both companion methods are no-ops in :class:`NullGatewayTraceHook`, so the
default path stays zero-cost and OpenTelemetry-free.
"""
def stage(
@@ -4845,6 +5202,7 @@ class GatewayTraceHook(Protocol):
name: str,
*,
correlation_id: "Optional[str]" = None,
parent_carrier: "Optional[Mapping[str, str]]" = None,
**attrs: Any,
) -> "AbstractContextManager[Any]":
"""Open a tracing scope for pipeline stage ``name``.
@@ -4854,6 +5212,11 @@ class GatewayTraceHook(Protocol):
span name.
correlation_id: The inbound turn's correlation id, attached as a
span attribute so spans and logs share a key.
parent_carrier: Optional inbound header mapping carrying W3C
trace-context (``traceparent``/``tracestate``). When present and
an exporter is attached, the stage is entered *under* the
extracted parent context so the span nests within the caller's
distributed trace instead of starting a new root.
**attrs: Extra span attributes (e.g. ``session``, ``model``,
``tool``, ``channel``).
@@ -4862,6 +5225,28 @@ class GatewayTraceHook(Protocol):
"""
...
def inject_context(self, carrier: "MutableMapping[str, str]") -> None:
"""Write the active span context into ``carrier`` as W3C headers.
Called at an egress boundary (LLM / MCP / HTTP-tool request) so the
downstream service's spans nest under the current agent turn. A no-op
when no exporter is attached, leaving ``carrier`` untouched.
"""
...
def extract_carrier(
self, carrier: "Mapping[str, str]"
) -> "Optional[Mapping[str, str]]":
"""Return a propagation carrier extracted from inbound ``carrier``.
Reads W3C trace-context (``traceparent``/``tracestate``) from an
inbound header mapping and returns a normalized carrier suitable to pass
as ``stage(..., parent_carrier=...)``, or ``None`` when no usable
context is present. A no-op returning ``None`` when no exporter is
attached.
"""
...
class NullGatewayTraceHook:
"""Zero-cost no-op :class:`GatewayTraceHook` used when tracing is disabled.
@@ -4883,11 +5268,22 @@ class NullGatewayTraceHook:
name: str,
*,
correlation_id: "Optional[str]" = None,
parent_carrier: "Optional[Mapping[str, str]]" = None,
**attrs: Any,
) -> "AbstractContextManager[Any]":
"""Return a no-op context manager, ignoring all arguments."""
return self._null_scope()
def inject_context(self, carrier: "MutableMapping[str, str]") -> None:
"""No-op: leave ``carrier`` untouched when tracing is disabled."""
return None
def extract_carrier(
self, carrier: "Mapping[str, str]"
) -> "Optional[Mapping[str, str]]":
"""No-op: no parent context to continue when tracing is disabled."""
return None
# Shared singleton: the default no-op hook is stateless, so one instance is
# reused everywhere a gateway needs a zero-cost default.
@@ -5517,6 +5913,174 @@ class _TurnLeaseHold:
self._token = None
# ---------------------------------------------------------------------------
# Per-session turn-execution isolation (Issue #4011)
#
# Today every session's agent turn runs as an ``asyncio`` task on the single
# gateway event loop, in one process: a turn that wedges the loop (a tight CPU
# loop, a blocking C-extension call, GIL starvation), runs away, or crashes
# the interpreter degrades or kills *every* session at once, and the only
# remedy the runtime has is a whole-process ``os._exit(75)`` restart that
# drops every other user's in-flight turn.
#
# This is the pure, dependency-free contract for a swappable turn *executor*,
# mirroring how every other gateway robustness knob (drain, admission,
# rate-limit, liveness, dead-letter, turn-lock) is a swappable protocol in
# core. The executor decides *where* a session's turn runs; the default
# :class:`InProcessTurnExecutor` runs it on the current loop exactly as today
# (zero change for existing users, no dependency introduced). Heavy executors
# that place a turn on an isolated worker (subprocess / container / remote),
# each with optional per-worker resource limits, are optional-dep runtime
# concerns that live in the wrapper/bot package; core keeps only the protocol,
# the placement/fencing invariants, and the safe in-process default so a bad
# turn can be torn down on its own worker without an ``os._exit`` of the whole
# gateway.
# ---------------------------------------------------------------------------
class WorkerWedgedError(Exception):
"""Raised when a session's turn worker is wedged / unresponsive.
Signals the gateway that the placement's worker can no longer make
progress (a wedged loop, an unresponsive subprocess/container, a lost
remote worker). The gateway responds by tearing down *only* that
placement (:meth:`TurnExecutorProtocol.teardown`) and re-placing the
session, rather than the process-wide ``os._exit`` that a single shared
loop forces today. Distinct from ordinary turn errors, which surface as
the turn's own exception and do not condemn the worker.
"""
@dataclass(frozen=True)
class TurnPlacement:
"""Which worker owns a session's turns, and at what epoch (Issue #4011).
A lifecycle-owned handle returned by :meth:`TurnExecutorProtocol.place`
and passed back to :meth:`~TurnExecutorProtocol.execute_turn` /
:meth:`~TurnExecutorProtocol.teardown`. The gateway revalidates the
placement (worker alive, matching ``epoch``) immediately before dispatch
and again after any awaited admission/approval work; a stale placement
(its worker was replaced, so ``epoch`` no longer matches) **fails closed**
and the session is re-placed rather than executed on a dead worker.
Attributes:
session_id: The resolved session whose turns this placement serves.
worker_id: Opaque id of the worker that owns the session's turns. For
the in-process default this is a constant (one loop, one worker);
for isolated executors it identifies the subprocess/container/
remote worker.
epoch: Monotonic generation bumped whenever the worker backing a
session is replaced (e.g. after a wedge teardown). A turn carrying
a stale epoch must not run — the fencing token that stops a
reclaimed worker from executing against a session it no longer owns.
"""
session_id: str
worker_id: str
epoch: int = 0
@runtime_checkable
class TurnExecutorProtocol(Protocol):
"""Contract for *where* a session's agent turn executes (Issue #4011).
The gateway calls :meth:`place` to obtain a :class:`TurnPlacement` for a
session, then :meth:`execute_turn` to run the turn on that placement, and
:meth:`teardown` to reclaim a placement's worker. The default
:class:`InProcessTurnExecutor` runs the turn on the current event loop,
reproducing today's behaviour exactly; an isolated executor (subprocess /
container / remote, in the wrapper) runs it on its own worker so a wedged,
runaway, or crashed turn is contained to its session.
Contract:
* :meth:`place` returns the placement that owns ``session_id``'s
turns. Implementations may cache a placement per session and bump
its ``epoch`` when the backing worker is replaced.
* :meth:`execute_turn` runs ``turn`` (an ``async`` no-arg callable the
gateway already built for this turn) on ``placement`` and returns
its result. The in-process default awaits ``turn`` directly on the
current loop. An isolated executor does **not** ship this live
callable (and its captured loop/agent state) across a process
boundary — the worker owns the session (via :meth:`place`) and
rebuilds/dispatches the turn from serialisable inputs on its own
side; ``turn`` then acts as the gateway-side await point for that
worker's result. ``cancel_token`` carries the per-turn interrupt so
the existing cancellation seam is preserved. ``limits`` optionally
bounds the worker's CPU/memory/wall time (honoured by isolated
executors; ignored in-process). It raises :class:`WorkerWedgedError`
if the placement's worker can no longer make progress.
* :meth:`teardown` reclaims ``placement``'s worker (kills the
subprocess/container/remote worker, or is a no-op in-process),
scoped to the owning session only.
A worker fault must fail *scoped*: tear down the offending placement and
re-place its session, never the whole gateway — mirroring the fail-safe,
blast-radius-contained defaults elsewhere.
"""
async def place(self, session_id: str) -> TurnPlacement:
"""Return the placement that owns ``session_id``'s turns."""
...
async def execute_turn(
self,
placement: TurnPlacement,
turn: "Callable[[], Awaitable[Any]]",
*,
cancel_token: Any = None,
limits: Any = None,
) -> Any:
"""Run ``turn`` on ``placement`` and return its result."""
...
async def teardown(self, placement: TurnPlacement, *, reason: str) -> None:
"""Reclaim ``placement``'s worker (scoped to its session)."""
...
class InProcessTurnExecutor:
"""Default turn executor — today's on-loop behaviour, no isolation.
Zero-cost, dependency-free implementation of
:class:`TurnExecutorProtocol` for single-process deployments. It runs each
turn directly on the current event loop exactly as the gateway does today,
so selecting it (or leaving the executor unset) is byte-for-byte backward
compatible and introduces no dependency. It provides **no** blast-radius
isolation: a turn that wedges the loop still affects the process — choosing
an isolated executor (subprocess / container / remote, in the wrapper) is
what contains a bad turn to its own worker.
All turns share one worker (the current process/loop), so every placement
carries the same constant ``worker_id`` at ``epoch`` 0, and ``teardown``
is inert — an in-process turn is cancelled through the existing
``cancel_token`` when the holding task exits, so there is no worker to
reclaim. The ``limits`` argument is accepted for protocol symmetry but is
inert here (no separate worker to bound).
"""
_WORKER_ID = "inprocess"
async def place(self, session_id: str) -> TurnPlacement:
return TurnPlacement(
session_id=session_id, worker_id=self._WORKER_ID, epoch=0
)
async def execute_turn(
self,
placement: TurnPlacement,
turn: "Callable[[], Awaitable[Any]]",
*,
cancel_token: Any = None,
limits: Any = None,
) -> Any:
# Run the gateway's pre-built turn coroutine on this loop, unchanged.
return await turn()
async def teardown(self, placement: TurnPlacement, *, reason: str) -> None:
# No isolated worker to reclaim; the shared loop keeps serving.
return None
# ---------------------------------------------------------------------------
# Declarative method -> required-scope registry (Issue #3206)
# ---------------------------------------------------------------------------
@@ -5711,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,
@@ -5728,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")
@@ -47,7 +47,11 @@ def _tail(text: str, limit: int = 4000) -> str:
return text[-limit:]
def _build_goal_judge_prompt(state: "GoalState", transcript_tail: str) -> str:
def _build_goal_judge_prompt(
state: "GoalState",
transcript_tail: str,
verification_block: Optional[str] = None,
) -> str:
"""Build a strict, criteria-anchored, JSON-only judge prompt."""
lines = [
"You are an independent completion judge for an autonomous agent.",
@@ -72,6 +76,13 @@ def _build_goal_judge_prompt(state: "GoalState", transcript_tail: str) -> str:
lines.append(
"If ANY constraint is violated, you MUST answer 'continue'."
)
if verification_block:
lines += [
"",
"EXECUTABLE VERIFICATION RESULTS (authoritative — command exit "
"codes and file checks; prefer this over prose claims):",
verification_block,
]
lines += [
"",
"AGENT OUTPUT (most recent):",
@@ -165,6 +176,7 @@ def judge_goal(
transcript_tail: str,
*,
judge_model: Optional[str] = None,
verification_block: Optional[str] = None,
) -> Tuple[str, str]:
"""Judge whether ``state.goal`` is met given the latest agent output.
@@ -180,7 +192,7 @@ def judge_goal(
"""
from ..eval.judge import Judge
prompt = _build_goal_judge_prompt(state, transcript_tail)
prompt = _build_goal_judge_prompt(state, transcript_tail, verification_block)
try:
judge = Judge(model=judge_model or _default_judge_model(), temperature=0.0)
litellm = judge._get_litellm()
Loaded 100 of 759 files, more files were not shown because too many files have changed in this diff. Show more