mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-08 17:31:00 -10:00
Compare commits
140
Commits
No files matched your search
@@ -140,13 +140,32 @@ jobs:
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.tag }}
|
||||
run: |
|
||||
$expected = $env:RELEASE_TAG -replace '^v', ''
|
||||
$numeric = ($expected -split '[-+]')[0] + '.0'
|
||||
$version = (Get-Item "target/${{ matrix.target }}/release/praisonai-desktop.exe").VersionInfo
|
||||
$core = ($expected -split '[-+]')[0]
|
||||
$exe = "target/${{ matrix.target }}/release/praisonai-desktop.exe"
|
||||
$version = (Get-Item $exe).VersionInfo
|
||||
Write-Host "ProductVersion=$($version.ProductVersion) FileVersion=$($version.FileVersion)"
|
||||
|
||||
if ($version.ProductVersion -ne $expected) {
|
||||
throw "ProductVersion $($version.ProductVersion) does not match $expected"
|
||||
}
|
||||
if ($version.FileVersion -ne $numeric) {
|
||||
throw "FileVersion $($version.FileVersion) does not match $numeric"
|
||||
|
||||
# Compare the three components that carry meaning, not the string.
|
||||
#
|
||||
# This gate blocked the v4.7.4 Windows build with
|
||||
# FileVersion 4.7.4 does not match 4.7.4.0
|
||||
# It appended ".0" and compared literally, but Windows reports what
|
||||
# was stamped -- three parts here. The stamping was correct and the
|
||||
# check was not, so the release shipped with no Windows installer at
|
||||
# all: the one platform it was cut to fix.
|
||||
#
|
||||
# [version] normalises a missing fourth component to -1, so both
|
||||
# "4.7.4" and "4.7.4.0" compare equal on major.minor.build.
|
||||
$got = [version]$version.FileVersion
|
||||
$want = [version]$core
|
||||
if ($got.Major -ne $want.Major -or
|
||||
$got.Minor -ne $want.Minor -or
|
||||
$got.Build -ne $want.Build) {
|
||||
throw "FileVersion $($version.FileVersion) does not match $core"
|
||||
}
|
||||
|
||||
- name: The macOS bundle must carry a valid signature
|
||||
|
||||
@@ -26,9 +26,18 @@ name: mobile
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
|
||||
paths: &watched
|
||||
- 'src/praisonai-mobile/**'
|
||||
- '.github/workflows/mobile.yml'
|
||||
# The `upstream parity` job below typechecks the REAL praisonai-ts Agent
|
||||
# against the structural contract in engines/src/praisonai-ts/agent-api.ts.
|
||||
# Watching only praisonai-mobile made that gate fire when the CONSUMER
|
||||
# changed and stay silent when the thing it watches did -- so upstream
|
||||
# drift landed on main unchecked. Verified on 71197fa80, which edited
|
||||
# src/praisonai-ts/src/agent/simple.ts: no parity check ran on it.
|
||||
- 'src/praisonai-ts/src/**'
|
||||
pull_request:
|
||||
paths: ['src/praisonai-mobile/**', '.github/workflows/mobile.yml']
|
||||
paths: *watched
|
||||
|
||||
concurrency:
|
||||
group: mobile-${{ github.ref }}
|
||||
@@ -146,3 +155,58 @@ jobs:
|
||||
run: cargo test
|
||||
- name: Clippy
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# The mobile targets, which nothing compiled.
|
||||
#
|
||||
# `src/lib.rs` is `#[cfg_attr(mobile, tauri::mobile_entry_point)]`, and
|
||||
# `tauri_build::build()` emits `cargo:rustc-cfg=mobile` only for android and
|
||||
# ios targets. Both legs of `rust shell` above are HOST targets, so that
|
||||
# attribute -- the one that generates the Android JNI symbol and the iOS
|
||||
# start_app symbol -- expanded exactly zero times in CI, and neither did the
|
||||
# `#[cfg(target_os = "android")]` / `ios` arms of `commands.rs`. The job
|
||||
# above says it covers cfg mistakes; it covers the ones in `shell/`, not
|
||||
# these.
|
||||
#
|
||||
# `cargo check`, not `build`: it type-checks and macro-expands under the
|
||||
# mobile cfg, which is the thing that was never done, and needs no NDK and no
|
||||
# Xcode project. Verified at 18s for the android target.
|
||||
# ------------------------------------------------------------------
|
||||
cross-check:
|
||||
name: cross-check (${{ matrix.target }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: aarch64-linux-android
|
||||
os: ubuntu-22.04
|
||||
- target: aarch64-apple-ios
|
||||
os: macos-15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: src/praisonai-mobile/src-tauri
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22.18'
|
||||
cache: npm
|
||||
cache-dependency-path: src/praisonai-mobile/package-lock.json
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: src/praisonai-mobile/src-tauri
|
||||
key: ${{ matrix.target }}
|
||||
# generate_context! expands frontendDist at COMPILE time and dist/ is
|
||||
# gitignored, so the crate cannot be checked without this -- the same
|
||||
# reason the job above needs it.
|
||||
- name: Build the webview (creates ../dist for generate_context!)
|
||||
working-directory: src/praisonai-mobile
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
- name: Does the crate still expand and type-check for a phone?
|
||||
run: cargo check --lib --target ${{ matrix.target }}
|
||||
@@ -0,0 +1,79 @@
|
||||
name: Parity Gate
|
||||
|
||||
# Validates Python <-> TypeScript SDK parity on pull requests and FAILS on drift.
|
||||
#
|
||||
# Two named checks, so each kind of drift is visible in the checks list:
|
||||
# names - the exported-name tracker (PARITY.md / FEATURE_PARITY_TRACKER.json)
|
||||
# must be regenerated in the PR when the public surface changes.
|
||||
# signatures - constructor/method parameters of the curated surfaces in
|
||||
# src/praisonai/praisonai/_dev/parity/signatures/surface.yaml must
|
||||
# match (after camelCase/alias/flattening rules) or be waived with a
|
||||
# reason in waivers.yaml. Stale or expired waivers also fail.
|
||||
#
|
||||
# The regenerate-and-commit job stays in update-parity-tracker.yml (push to main).
|
||||
# This workflow never commits; it only reports.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
- 'src/praisonai/praisonai/_dev/parity/**'
|
||||
- 'src/praisonai/praisonai/cli/features/**'
|
||||
- 'src/praisonai-ts/PARITY.md'
|
||||
- 'src/praisonai-ts/FEATURE_PARITY_TRACKER.json'
|
||||
- 'src/praisonai-ts/SIGNATURE_PARITY.md'
|
||||
- 'src/praisonai-ts/signature-parity.json'
|
||||
- 'src/praisonai-rust/PARITY.md'
|
||||
- 'src/praisonai-rust/FEATURE_PARITY_TRACKER.json'
|
||||
- '.github/workflows/parity-gate.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
names:
|
||||
name: names (exported-symbol tracker up to date)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: pip install pyyaml
|
||||
- name: Tracker files match the sources
|
||||
run: |
|
||||
if ! python3 src/praisonai/scripts/generate_parity_tracker.py --check; then
|
||||
echo "::error::Parity tracker is out of date. Run: python3 src/praisonai/scripts/generate_parity_tracker.py and commit src/praisonai-ts/PARITY.md, FEATURE_PARITY_TRACKER.json and the Rust pair."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
signatures:
|
||||
name: signatures (constructor and method parameters)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
- run: pip install pyyaml
|
||||
- name: Install the TypeScript compiler only
|
||||
# praisonai-ts's full dependency tree is not needed (and does not
|
||||
# install cleanly); the extractor needs only the `typescript` package.
|
||||
run: |
|
||||
mkdir -p "$RUNNER_TEMP/parity-ts"
|
||||
npm install --prefix "$RUNNER_TEMP/parity-ts" --no-audit --no-fund [email protected]
|
||||
echo "PARITY_TS_NODE_MODULES=$RUNNER_TEMP/parity-ts/node_modules" >> "$GITHUB_ENV"
|
||||
- name: Signatures match, or are waived with a reason
|
||||
env:
|
||||
PYTHONPATH: src/praisonai
|
||||
run: |
|
||||
if ! python3 -m praisonai._dev.parity.signatures --check; then
|
||||
echo "::error::Signature parity failed. See the output above for the surface and parameter. Fix the TypeScript side, or add a waiver with a reason to src/praisonai/praisonai/_dev/parity/signatures/waivers.yaml, then run: PYTHONPATH=src/praisonai python3 -m praisonai._dev.parity.signatures --write"
|
||||
exit 1
|
||||
fi
|
||||
@@ -6,7 +6,11 @@ on:
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
- 'src/praisonai-rust/src/**/*.rs'
|
||||
# The crate lives at src/praisonai-rust/praisonai/src -- the old
|
||||
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
|
||||
# never triggered either tracker and the Rust numbers only ever
|
||||
# refreshed incidentally, on an unrelated push.
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
|
||||
- '.github/workflows/update-docs-parity.yml'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
name: Update Feature Parity Tracker
|
||||
|
||||
# Regenerates the exported-name parity trackers on push to main and commits them.
|
||||
# Validation on pull requests (fail on drift) lives in parity-gate.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths:
|
||||
- 'src/praisonai-agents/praisonaiagents/**/*.py'
|
||||
- 'src/praisonai-ts/src/**/*.ts'
|
||||
- 'src/praisonai-rust/src/**/*.rs'
|
||||
# The crate lives at src/praisonai-rust/praisonai/src -- the old
|
||||
# 'src/praisonai-rust/src/**' path does not exist, so a Rust change
|
||||
# never triggered either tracker and the Rust numbers only ever
|
||||
# refreshed incidentally, on an unrelated push.
|
||||
- 'src/praisonai-rust/praisonai/src/**/*.rs'
|
||||
# The generator's own code and the wrapper features it counts. Change
|
||||
# either and nothing regenerated until an unrelated push landed.
|
||||
- 'src/praisonai/praisonai/_dev/parity/**/*.py'
|
||||
- 'src/praisonai/praisonai/cli/features/**/*.py'
|
||||
- '.github/workflows/update-parity-tracker.yml'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
update-parity-tracker:
|
||||
# Push only. On a PR the parity-gate.yml `names` job CHECKS instead --
|
||||
# committing a regenerated tracker onto a contributor's branch is not this
|
||||
# workflow's business, and would fail on a fork besides.
|
||||
if: github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -36,7 +50,7 @@ jobs:
|
||||
run: |
|
||||
cd $GITHUB_WORKSPACE
|
||||
python3 src/praisonai/scripts/generate_parity_tracker.py
|
||||
|
||||
|
||||
- name: Check for changes
|
||||
id: check_changes
|
||||
run: |
|
||||
@@ -58,4 +72,3 @@ jobs:
|
||||
commit_user_name: "MervinPraison"
|
||||
commit_user_email: "[email protected]"
|
||||
commit_author: "MervinPraison <[email protected]>"
|
||||
|
||||
@@ -781,7 +781,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, DB, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, __version__, arun, run
|
||||
```
|
||||
|
||||
# CLI
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.4" \
|
||||
"praisonai>=4.7.5" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.4" \
|
||||
"praisonai>=4.7.5" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.7.4" \
|
||||
"praisonai>=4.7.5" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"""
|
||||
Meeting Notes Agent
|
||||
|
||||
An AI meeting-notes pipeline composed entirely from existing PraisonAI
|
||||
primitives — no new SDK surface required:
|
||||
|
||||
1. AudioAgent.transcribe() -> Whisper speech-to-text
|
||||
2. Agent (summary) -> structured summary + action items
|
||||
3. Agent (knowledge=[...]) -> index transcript for cross-meeting Q&A
|
||||
|
||||
Usage:
|
||||
python meeting-notes-agent.py path/to/recording.mp3
|
||||
|
||||
Requires: OPENAI_API_KEY (or any LiteLLM-supported STT/LLM provider).
|
||||
"""
|
||||
|
||||
import sys
|
||||
from praisonaiagents import Agent, AudioAgent
|
||||
|
||||
|
||||
def transcribe_meeting(audio_path: str) -> str:
|
||||
"""Speech-to-text using the built-in Whisper AudioAgent."""
|
||||
stt = AudioAgent(llm="openai/whisper-1")
|
||||
return stt.transcribe(audio_path)
|
||||
|
||||
|
||||
def summarize_meeting(transcript: str) -> str:
|
||||
"""Summarize decisions, topics, and action items."""
|
||||
notetaker = Agent(
|
||||
name="Meeting Notetaker",
|
||||
instructions=(
|
||||
"You are an expert meeting notetaker. Given a raw transcript, produce:\n"
|
||||
"1. A concise summary (3-5 sentences).\n"
|
||||
"2. Key decisions made.\n"
|
||||
"3. Action items as a checklist with owners when mentioned.\n"
|
||||
"Only include action items that were actually spoken."
|
||||
),
|
||||
)
|
||||
return notetaker.start(f"Transcript:\n\n{transcript}")
|
||||
|
||||
|
||||
def build_qa_agent(transcript_path: str) -> Agent:
|
||||
"""Index the transcript for semantic search and cross-meeting Q&A."""
|
||||
return Agent(
|
||||
name="Meeting Q&A",
|
||||
instructions="Answer questions using only the indexed meeting transcripts. Cite what was said.",
|
||||
knowledge=[transcript_path],
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python meeting-notes-agent.py <audio_file>")
|
||||
sys.exit(1)
|
||||
|
||||
audio_path = sys.argv[1]
|
||||
|
||||
transcript = transcribe_meeting(audio_path)
|
||||
transcript_path = "meeting_transcript.txt"
|
||||
with open(transcript_path, "w", encoding="utf-8") as f:
|
||||
f.write(transcript)
|
||||
|
||||
notes = summarize_meeting(transcript)
|
||||
print("\n=== Meeting Notes ===\n")
|
||||
print(notes)
|
||||
|
||||
qa = build_qa_agent(transcript_path)
|
||||
print("\n=== Q&A ===\n")
|
||||
print(qa.start("What were the main action items and who owns them?"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -18,41 +18,41 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| Category | Features | Docs | Lines |
|
||||
|----------|----------|------|-------|
|
||||
| ✅ AGUI | 1 | 2 | 511 |
|
||||
| ✅ Agent | 22 | 54 | 17350 |
|
||||
| ✅ Agent | 22 | 56 | 17728 |
|
||||
| ✅ Agent-to-Agent (A2A) | 1 | 7 | 2996 |
|
||||
| ✅ Agent-to-User (A2U) | 1 | 3 | 689 |
|
||||
| ✅ Approval | 1 | 7 | 3311 |
|
||||
| ✅ Approval | 1 | 7 | 3503 |
|
||||
| ✅ Audio | 2 | 12 | 1000 |
|
||||
| ✅ Auto Generation | 5 | 12 | 3747 |
|
||||
| ✅ Autonomy | 3 | 7 | 2790 |
|
||||
| ✅ Bots | 7 | 32 | 13622 |
|
||||
| ✅ Bots | 7 | 33 | 13860 |
|
||||
| ✅ Budget | 1 | 1 | 287 |
|
||||
| ✅ CLI | 5 | 122 | 37626 |
|
||||
| ✅ CLI | 5 | 123 | 37946 |
|
||||
| ✅ Chunking | 2 | 2 | 420 |
|
||||
| ✅ Citations | 2 | 1 | 202 |
|
||||
| ✅ Code Execution | 2 | 14 | 4855 |
|
||||
| ✅ Conditions | 1 | 3 | 1347 |
|
||||
| ✅ Configuration | 3 | 8 | 3292 |
|
||||
| ✅ Context Management | 16 | 36 | 14071 |
|
||||
| ✅ Context Management | 16 | 36 | 14090 |
|
||||
| ✅ Deep Research | 8 | 2 | 587 |
|
||||
| ✅ Display | 6 | 3 | 741 |
|
||||
| ✅ Embeddings | 6 | 23 | 2049 |
|
||||
| ✅ Evaluation | 1 | 7 | 2920 |
|
||||
| ✅ Evaluation | 1 | 8 | 3220 |
|
||||
| ✅ Events | 1 | 2 | 767 |
|
||||
| ✅ Execution | 3 | 4 | 1606 |
|
||||
| ✅ Execution | 3 | 4 | 1631 |
|
||||
| ✅ Failover | 2 | 1 | 452 |
|
||||
| ✅ Files | 2 | 7 | 2275 |
|
||||
| ✅ Files | 2 | 7 | 2376 |
|
||||
| ✅ Flow | 1 | 3 | 788 |
|
||||
| ✅ Gateway | 7 | 83 | 30723 |
|
||||
| ✅ Guardrails | 4 | 4 | 2253 |
|
||||
| ✅ Gateway | 7 | 85 | 31517 |
|
||||
| ✅ Guardrails | 4 | 4 | 2288 |
|
||||
| ✅ Handoffs | 11 | 6 | 2626 |
|
||||
| ✅ Hooks | 2 | 9 | 4542 |
|
||||
| ✅ Hooks | 2 | 9 | 4690 |
|
||||
| ✅ Image | 1 | 11 | 1255 |
|
||||
| ✅ Knowledge | 4 | 17 | 5226 |
|
||||
| ✅ LLM | 3 | 15 | 5395 |
|
||||
| ✅ Loops | 4 | 5 | 1475 |
|
||||
| ✅ MCP | 1 | 62 | 14418 |
|
||||
| ✅ Memory | 6 | 18 | 7237 |
|
||||
| ✅ MCP | 1 | 62 | 14436 |
|
||||
| ✅ Memory | 6 | 19 | 7512 |
|
||||
| ✅ OCR | 2 | 1 | 237 |
|
||||
| ✅ Observability | 2 | 23 | 2811 |
|
||||
| ✅ Optimizer | 1 | 2 | 792 |
|
||||
@@ -60,8 +60,8 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Parallel Execution | 3 | 2 | 497 |
|
||||
| ✅ Planning | 6 | 7 | 1825 |
|
||||
| ✅ Plugins | 8 | 5 | 3120 |
|
||||
| ✅ Prompts | 2 | 9 | 1971 |
|
||||
| ✅ Providers | 1 | 56 | 7793 |
|
||||
| ✅ Prompts | 2 | 9 | 2011 |
|
||||
| ✅ Providers | 1 | 57 | 8197 |
|
||||
| ✅ Query | 1 | 2 | 736 |
|
||||
| ✅ RAG | 5 | 15 | 3118 |
|
||||
| ✅ Realtime | 2 | 5 | 668 |
|
||||
@@ -70,24 +70,24 @@ This report compares **Python SDK feature categories** against **Python document
|
||||
| ✅ Routing | 1 | 2 | 430 |
|
||||
| ✅ Sandbox | 5 | 7 | 3556 |
|
||||
| ✅ Security | 1 | 3 | 3529 |
|
||||
| ✅ Sessions | 4 | 19 | 7644 |
|
||||
| ✅ Skills | 6 | 16 | 5903 |
|
||||
| ✅ Sessions | 4 | 19 | 7648 |
|
||||
| ✅ Skills | 6 | 16 | 5911 |
|
||||
| ✅ Tasks | 2 | 6 | 2897 |
|
||||
| ✅ Telemetry | 1 | 2 | 648 |
|
||||
| ✅ Templates | 1 | 8 | 1663 |
|
||||
| ✅ Tools | 12 | 145 | 43981 |
|
||||
| ✅ Templates | 1 | 8 | 1667 |
|
||||
| ✅ Tools | 12 | 145 | 44115 |
|
||||
| ✅ Tracing | 3 | 2 | 139 |
|
||||
| ✅ Vector Store | 1 | 12 | 1219 |
|
||||
| ✅ Video | 2 | 6 | 649 |
|
||||
| ✅ Video | 2 | 6 | 728 |
|
||||
| ✅ Vision | 2 | 1 | 329 |
|
||||
| ✅ Web | 3 | 9 | 2487 |
|
||||
| ✅ Workflows | 5 | 18 | 7406 |
|
||||
| ✅ Workflows | 5 | 18 | 7410 |
|
||||
|
||||
## Documentation Without Features
|
||||
|
||||
These docs exist but don't match any implemented feature category:
|
||||
|
||||
- ℹ️ Database (43 docs, 7130 lines)
|
||||
- ℹ️ Database (43 docs, 7293 lines)
|
||||
- ℹ️ Documents (1 docs, 798 lines)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
import json
|
||||
import logging
|
||||
@@ -8,6 +9,7 @@ import contextlib
|
||||
import threading
|
||||
import concurrent.futures
|
||||
import random
|
||||
import re
|
||||
from typing import List, Optional, Any, Dict, Union, Literal, TYPE_CHECKING, Callable, Generator
|
||||
from collections import OrderedDict
|
||||
import inspect
|
||||
@@ -220,6 +222,22 @@ from ..errors import BudgetExceededError
|
||||
# Import retry configuration
|
||||
from .retry_utils import RetryBackoffConfig
|
||||
|
||||
# Completion-signal detection patterns (compiled once at import — see
|
||||
# Agent._is_completion_signal). Negation patterns that should NOT be treated
|
||||
# as completion.
|
||||
_COMPLETION_NEGATION_RE = re.compile(
|
||||
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
|
||||
r'.{0,20}' # up to 20 chars between negation and keyword
|
||||
)
|
||||
# Word-boundary patterns to avoid substring false positives
|
||||
_COMPLETION_PATTERNS = (
|
||||
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
|
||||
(re.compile(r'\bcompleted\s+successfully\b'), False),
|
||||
(re.compile(r'\ball\s+done\b'), False),
|
||||
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
|
||||
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
|
||||
)
|
||||
|
||||
class Agent(GoalLoopMixin, SteeringMixin, SandboxMixin, SkillReviewMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
|
||||
# Class-level counter for generating unique display names for nameless agents
|
||||
_agent_counter = 0
|
||||
@@ -3186,9 +3204,21 @@ Your Goal: {self.goal}
|
||||
return self.__chat_history_state
|
||||
|
||||
def _append_to_chat_history(self, message: dict):
|
||||
"""Thread-safe append to chat history using proper locking."""
|
||||
"""Thread-safe append to chat history using proper locking.
|
||||
|
||||
Also records the appended message into the current turn's per-turn
|
||||
ownership tracker (when one is active) so a failing turn's rollback
|
||||
removes only its own messages by identity and never a concurrent
|
||||
turn's. This is the append path every real chat()/achat() turn uses,
|
||||
so without recording here _rollback_chat_history_to would always take
|
||||
the unsafe positional-delete branch.
|
||||
"""
|
||||
from .memory_mixin import _active_turn_owned
|
||||
with self._history_lock.lock():
|
||||
self._history_lock.value.append(message)
|
||||
owned = _active_turn_owned.get()
|
||||
if owned is not None:
|
||||
owned.append(message)
|
||||
|
||||
def _replace_chat_history(self, new_history: List[Dict[str, Any]]):
|
||||
"""Thread-safe replacement of entire chat history using proper locking."""
|
||||
@@ -4179,21 +4209,7 @@ Summary:"""
|
||||
Returns:
|
||||
True if a completion signal is detected
|
||||
"""
|
||||
import re
|
||||
response_lower = response_text.lower()
|
||||
# Negation patterns that should NOT be treated as completion
|
||||
_NEGATION_RE = re.compile(
|
||||
r'\b(?:not|never|no longer|hardly|barely|isn\'t|aren\'t|wasn\'t|weren\'t|hasn\'t|haven\'t|hadn\'t|won\'t|wouldn\'t|can\'t|couldn\'t|shouldn\'t|don\'t|doesn\'t|didn\'t)\b'
|
||||
r'.{0,20}' # up to 20 chars between negation and keyword
|
||||
)
|
||||
# Word-boundary patterns to avoid substring false positives
|
||||
_COMPLETION_PATTERNS = [
|
||||
(re.compile(r'\btask\s+completed?\b'), False), # no negation check needed
|
||||
(re.compile(r'\bcompleted\s+successfully\b'), False),
|
||||
(re.compile(r'\ball\s+done\b'), False),
|
||||
(re.compile(r'\bdone\b'), True), # 'done' needs negation check
|
||||
(re.compile(r'\bfinished\b'), True), # 'finished' needs negation check
|
||||
]
|
||||
for pattern, needs_negation_check in _COMPLETION_PATTERNS:
|
||||
match = pattern.search(response_lower)
|
||||
if match:
|
||||
@@ -4201,7 +4217,7 @@ Summary:"""
|
||||
# Check if a negation word precedes the match within 30 chars
|
||||
start = max(0, match.start() - 30)
|
||||
prefix = response_lower[start:match.start()]
|
||||
if _NEGATION_RE.search(prefix):
|
||||
if _COMPLETION_NEGATION_RE.search(prefix):
|
||||
continue # Skip — negated completion
|
||||
# Also check "not X yet" pattern
|
||||
end = min(len(response_lower), match.end() + 10)
|
||||
|
||||
@@ -1638,6 +1638,47 @@ Your Goal: {self.goal}"""
|
||||
return None
|
||||
return self.fallback_models[fallback_index]
|
||||
|
||||
def _build_fallback_dispatcher(self, model):
|
||||
"""Build a throwaway dispatcher bound to a fallback ``model``.
|
||||
|
||||
The async fallback path passes the result to
|
||||
``_execute_unified_achat_completion(..., _dispatcher=...)`` for a single
|
||||
retry so it NEVER mutates the shared ``self.llm`` /
|
||||
``self._unified_dispatcher``. Concurrent async turns on the same Agent
|
||||
therefore keep dispatching on the original model with correct cost /
|
||||
observability attribution — closing the ordinary-turn fallback race.
|
||||
Returns ``None`` for custom-LLM agents (the custom client owns its own
|
||||
model), which keeps that path on the shared dispatcher as before.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
return None
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
return create_llm_dispatcher(openai_client=self._openai_client, model=model)
|
||||
|
||||
def _get_model_fallback_lock(self):
|
||||
"""Lazily create the per-instance lock guarding model-fallback swaps.
|
||||
|
||||
Async fallback now runs against a per-call throwaway dispatcher
|
||||
(``_build_fallback_dispatcher``), so it does not mutate shared state and
|
||||
ordinary async turns can never observe the fallback model. The sync
|
||||
fallback path still recurses through the full ``_chat_completion``
|
||||
wrapper (which reads ``self.llm`` for BEFORE_LLM hook attribution and
|
||||
budget), so it temporarily overrides ``self.llm`` /
|
||||
``self._unified_dispatcher`` for a single retry. This dual (sync/async)
|
||||
lock serialises that mutate/restore window; normal, non-fallback turns
|
||||
never acquire it and stay fully concurrent. Created on demand so no
|
||||
extra state is added for agents without ``fallback_models``.
|
||||
"""
|
||||
lock = getattr(self, '_model_fallback_lock', None)
|
||||
if lock is None:
|
||||
from .async_safety import DualLock
|
||||
lock = DualLock()
|
||||
self._model_fallback_lock = lock
|
||||
return lock
|
||||
|
||||
def _emit_model_fallback(self, from_model, to_model, reason_category, fallback_index,
|
||||
stream_callback=None):
|
||||
"""Surface a mid-turn model fallback as an observable state transition.
|
||||
@@ -2094,22 +2135,26 @@ Your Goal: {self.goal}"""
|
||||
if classification.backoff_seconds and classification.backoff_seconds > 0:
|
||||
time.sleep(classification.backoff_seconds)
|
||||
|
||||
# Temporarily override the model and clear dispatcher cache for this call
|
||||
original_llm = self.llm
|
||||
original_dispatcher = getattr(self, '_unified_dispatcher', None)
|
||||
try:
|
||||
self.llm = next_model
|
||||
self._unified_dispatcher = None # Force recreation with new model
|
||||
return self._chat_completion(
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index + 1,
|
||||
cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
|
||||
# Temporarily override the model and clear dispatcher cache
|
||||
# for this call. Serialise the mutate/restore window so a
|
||||
# concurrent turn on a shared Agent can't observe the wrong
|
||||
# model; only fallback turns contend on this lock.
|
||||
with self._get_model_fallback_lock().sync():
|
||||
original_llm = self.llm
|
||||
original_dispatcher = getattr(self, '_unified_dispatcher', None)
|
||||
try:
|
||||
self.llm = next_model
|
||||
self._unified_dispatcher = None # Force recreation with new model
|
||||
return self._chat_completion(
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
_retry_depth=_retry_depth + 1,
|
||||
_fallback_index=_fallback_index + 1,
|
||||
cancel_token=cancel_token
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
|
||||
else:
|
||||
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
|
||||
# Continue to error handling without retry
|
||||
@@ -2272,16 +2317,21 @@ Your Goal: {self.goal}"""
|
||||
if classification.backoff_seconds and classification.backoff_seconds > 0:
|
||||
await asyncio.sleep(classification.backoff_seconds)
|
||||
|
||||
# Temporarily override the model and clear dispatcher cache for this call
|
||||
original_llm = self.llm
|
||||
original_dispatcher = getattr(self, '_unified_dispatcher', None)
|
||||
# Run this single retry against a throwaway dispatcher bound to
|
||||
# the fallback model, passed per-call. This never mutates the
|
||||
# shared self.llm / self._unified_dispatcher, so a concurrent
|
||||
# async turn on this shared Agent keeps dispatching on the
|
||||
# original model (correct cost / observability attribution) even
|
||||
# while this turn is suspended on the await — no lock needed on
|
||||
# the async path. Custom-LLM agents get None here and fall back
|
||||
# to the shared dispatcher, matching prior behaviour.
|
||||
_fallback_dispatcher = self._build_fallback_dispatcher(next_model)
|
||||
try:
|
||||
self.llm = next_model
|
||||
self._unified_dispatcher = None # Force recreation with new model
|
||||
return await self._execute_unified_achat_completion(
|
||||
messages, temperature, tools, stream,
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
stream_callback, emit_events
|
||||
stream_callback, emit_events,
|
||||
_dispatcher=_fallback_dispatcher,
|
||||
)
|
||||
except Exception as retry_error:
|
||||
return await self._handle_async_llm_error(
|
||||
@@ -2289,9 +2339,6 @@ Your Goal: {self.goal}"""
|
||||
reasoning_steps, task_name, task_description, task_id, response_format,
|
||||
stream_callback, emit_events, _retry_depth + 1, _fallback_index + 1
|
||||
)
|
||||
finally:
|
||||
self.llm = original_llm
|
||||
self._unified_dispatcher = original_dispatcher # Restore original dispatcher
|
||||
else:
|
||||
logging.warning(f"[{self.name}] {classification.user_message} (no more fallback models available)")
|
||||
# Continue to error handling without retry
|
||||
@@ -2499,29 +2546,43 @@ Your Goal: {self.goal}"""
|
||||
response_format=None,
|
||||
stream_callback=None,
|
||||
emit_events=True,
|
||||
_dispatcher=None,
|
||||
):
|
||||
"""
|
||||
Execute unified async chat completion using composition instead of runtime class mutation.
|
||||
|
||||
This method provides a unified async dispatch for all LLM providers using
|
||||
composition for safety and maintainability.
|
||||
|
||||
``_dispatcher`` lets the model-fallback path run a single retry against a
|
||||
caller-supplied throwaway dispatcher WITHOUT mutating the shared
|
||||
``self.llm`` / ``self._unified_dispatcher``, so concurrent async turns on
|
||||
the same Agent never observe another turn's fallback model.
|
||||
"""
|
||||
from ..llm import create_llm_dispatcher
|
||||
|
||||
# Get or create unified dispatcher
|
||||
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
|
||||
# Provider selection based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
# Resolve the dispatcher for THIS call. A caller-supplied dispatcher
|
||||
# (async model fallback) is used as-is and never cached onto the
|
||||
# instance, so the shared cached dispatcher is left intact for
|
||||
# concurrent turns.
|
||||
dispatcher = _dispatcher
|
||||
if dispatcher is None:
|
||||
# Get or create the shared cached dispatcher
|
||||
if not hasattr(self, '_unified_dispatcher') or self._unified_dispatcher is None:
|
||||
# Provider selection based on existing agent configuration
|
||||
if self._using_custom_llm and hasattr(self, 'llm_instance'):
|
||||
dispatcher = create_llm_dispatcher(llm_instance=self.llm_instance)
|
||||
else:
|
||||
# Initialize OpenAI client if not present
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
else:
|
||||
# Initialize OpenAI client if not present
|
||||
if not hasattr(self, '_openai_client') or self._openai_client is None:
|
||||
from ..llm import get_openai_client
|
||||
self._openai_client = get_openai_client(api_key=getattr(self, 'api_key', None))
|
||||
dispatcher = create_llm_dispatcher(openai_client=self._openai_client, model=self.llm)
|
||||
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
dispatcher = self._unified_dispatcher
|
||||
|
||||
# Pre-call budget guard (parity with sync _chat_completion). Bots and
|
||||
# other async callers route through here, not _chat_completion.
|
||||
@@ -2577,7 +2638,7 @@ Your Goal: {self.goal}"""
|
||||
try:
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = await self._unified_dispatcher.achat_completion(
|
||||
final_response = await dispatcher.achat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
@@ -4856,7 +4917,21 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
try:
|
||||
# Use the new streaming generator from LLM class
|
||||
response_content = ""
|
||||
stream_history = self.chat_history
|
||||
system_prompt_for_stream = self._build_system_prompt(
|
||||
tool_param,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
)
|
||||
# Apply context management (auto-compaction) to the history
|
||||
# that is actually sent, the same way the non-streaming
|
||||
# custom-LLM path does. Without this a configured
|
||||
# ContextManager -- auto-enabled whenever tools are present
|
||||
# -- is silently skipped on stream=True (Issue #4714).
|
||||
# Zero overhead when context=False.
|
||||
stream_history, _stream_context_result = self._apply_context_management(
|
||||
messages=self.chat_history,
|
||||
system_prompt=system_prompt_for_stream,
|
||||
tools=tool_param,
|
||||
)
|
||||
durable_context = self._get_durable_run_context()
|
||||
if durable_context is not None:
|
||||
stream_history = durable_context.restore_messages(
|
||||
@@ -4864,10 +4939,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
)
|
||||
for chunk in self.llm_instance.get_response_stream(
|
||||
prompt=actual_prompt,
|
||||
system_prompt=self._build_system_prompt(
|
||||
tool_param,
|
||||
memory_prefetch_context=memory_prefetch_context,
|
||||
),
|
||||
system_prompt=system_prompt_for_stream,
|
||||
chat_history=stream_history,
|
||||
temperature=kwargs.get('temperature', 1.0),
|
||||
tools=tool_param,
|
||||
@@ -4945,6 +5017,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
self.chat_history[-1].get("content") == normalized_content):
|
||||
self._append_to_chat_history({"role": "user", "content": normalized_content})
|
||||
|
||||
# Apply context management before the LLM call (auto-compaction),
|
||||
# exactly as the non-streaming OpenAI path does. Without this a
|
||||
# configured ContextManager -- auto-enabled whenever tools are
|
||||
# present -- is silently skipped on stream=True and the full
|
||||
# history is sent (Issue #4714). Zero overhead when context=False.
|
||||
system_prompt_content = messages[0].get("content", "") if messages and messages[0].get("role") == "system" else ""
|
||||
messages, _stream_context_result = self._apply_context_management(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt_content,
|
||||
tools=tool_param,
|
||||
)
|
||||
|
||||
try:
|
||||
# Check if OpenAI client is available
|
||||
if self._openai_client is None:
|
||||
|
||||
@@ -802,21 +802,31 @@ Write the complete compiled report:"""
|
||||
Args:
|
||||
new_model: The new model name to switch to (e.g., "gpt-4o", "claude-3-sonnet")
|
||||
"""
|
||||
# Store the new model name
|
||||
self.llm = new_model
|
||||
|
||||
# Recreate the LLM instance with the new model
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(
|
||||
model=new_model,
|
||||
base_url=self._openai_base_url,
|
||||
api_key=self._openai_api_key,
|
||||
)
|
||||
self._using_custom_llm = True
|
||||
except ImportError:
|
||||
# If LLM class not available, just update the model string
|
||||
pass
|
||||
# Serialise against an in-flight sync model fallback (which also swaps
|
||||
# self.llm / self._unified_dispatcher) so the two can't interleave.
|
||||
with self._get_model_fallback_lock().sync():
|
||||
# Store the new model name
|
||||
self.llm = new_model
|
||||
|
||||
# Drop any cached dispatcher bound to the old model so the next
|
||||
# chat()/achat() rebuilds against new_model. Without this, the
|
||||
# OpenAI path keeps dispatching on the old model even though
|
||||
# self.llm now reads the new name (mirrors _apply_default_llm).
|
||||
if getattr(self, '_unified_dispatcher', None) is not None:
|
||||
self._unified_dispatcher = None
|
||||
|
||||
# Recreate the LLM instance with the new model
|
||||
try:
|
||||
from ..llm.llm import LLM
|
||||
self._llm_instance = LLM(
|
||||
model=new_model,
|
||||
base_url=self._openai_base_url,
|
||||
api_key=self._openai_api_key,
|
||||
)
|
||||
self._using_custom_llm = True
|
||||
except ImportError:
|
||||
# If LLM class not available, just update the model string
|
||||
pass
|
||||
|
||||
def start(self, prompt: Optional[str] = None, **kwargs: Any) -> Union[str, Generator[str, None, None], None]:
|
||||
"""Start the agent interactively with verbose output.
|
||||
@@ -1702,6 +1712,27 @@ Write the complete compiled report:"""
|
||||
return policy_result # Error dict
|
||||
_, arguments = policy_result
|
||||
|
||||
# MCP tools first — parity with the sync path (_execute_tool_impl).
|
||||
# Without this an MCP-backed tool call hard-fails via achat()/astart().
|
||||
# Only consult MCP when searching the agent's own tools (no override),
|
||||
# matching the sync path which resolves MCP against self.tools.
|
||||
if tools_override is None:
|
||||
resolve_mcp = getattr(self, "_resolve_mcp_tool_result", None)
|
||||
if resolve_mcp is not None:
|
||||
found, mcp_result = resolve_mcp(function_name, arguments)
|
||||
if found:
|
||||
if inspect.isawaitable(mcp_result):
|
||||
mcp_result = await mcp_result
|
||||
# Normalize MCP transport/timeout failures into a native
|
||||
# error dict — parity with the sync path so an async MCP
|
||||
# timeout classifies as a retryable failure instead of
|
||||
# being handed to the model as a bare "Error: ..." string
|
||||
# that looks like a successful result.
|
||||
normalize_mcp = getattr(self, "_normalize_mcp_result", None)
|
||||
if normalize_mcp is not None:
|
||||
mcp_result = normalize_mcp(mcp_result)
|
||||
return mcp_result
|
||||
|
||||
# Try to find the function in the override tools list first, then agent's tools list.
|
||||
# Resolve by BaseTool/FunctionTool ``.name`` (instances like BrowserBaseTool or
|
||||
# aliased decorated tools), plain callable ``__name__``, or class name so async
|
||||
@@ -1720,8 +1751,36 @@ Write the complete compiled report:"""
|
||||
(inspect.isclass(tool) and tool.__name__ == function_name):
|
||||
func = tool
|
||||
break
|
||||
|
||||
|
||||
# Name self-repair — parity with the sync path. A slightly hallucinated
|
||||
# tool name (e.g. 'WebSearch' for 'web_search') is re-matched instead of
|
||||
# hard-failing. Only over the agent's own active tools (no override).
|
||||
if func is None and tools_override is None:
|
||||
repair = getattr(self, "_self_repair_tool_name", None)
|
||||
if repair is not None:
|
||||
func = repair(function_name)
|
||||
|
||||
if func is None:
|
||||
# Corrective, model-readable message with the available tools and a
|
||||
# closest-match hint, mirroring the sync path so the model can retry.
|
||||
available = None
|
||||
if hasattr(self, "_available_active_tool_names"):
|
||||
try:
|
||||
available = self._available_active_tool_names()
|
||||
except Exception:
|
||||
available = None
|
||||
if available is not None:
|
||||
suggestion = None
|
||||
try:
|
||||
import difflib
|
||||
near = difflib.get_close_matches(function_name, available, n=1, cutoff=0.5)
|
||||
suggestion = near[0] if near else None
|
||||
except Exception:
|
||||
pass
|
||||
hint = f" Did you mean '{suggestion}'?" if suggestion else ""
|
||||
error_msg = f"Tool '{function_name}' not found.{hint} Available tools: {available}"
|
||||
logging.error(error_msg)
|
||||
return {"error": error_msg, "available_tools": available}
|
||||
logging.error(f"Function {function_name} not found in tools")
|
||||
return {"error": f"Function {function_name} not found in tools"}
|
||||
|
||||
@@ -1800,17 +1859,42 @@ Write the complete compiled report:"""
|
||||
call_arguments = dict(arguments)
|
||||
call_arguments["idempotency_key"] = durable_key
|
||||
|
||||
# Establish the injection context (Injected[T] params, including
|
||||
# the cooperative cancel_event) around dispatch so the async path
|
||||
# matches the sync execute_tool path. Without this, a long-running
|
||||
# tool (e.g. shell) executed via achat()/astart() never observes an
|
||||
# interrupt and runs until its own timeout. Built lazily and reused
|
||||
# for both the coroutine and executor branches.
|
||||
from ..tools.injected import with_injection_context
|
||||
_inject_state = None
|
||||
if hasattr(self, "_build_agent_state"):
|
||||
try:
|
||||
_inject_state = self._build_agent_state()
|
||||
except Exception:
|
||||
_inject_state = None
|
||||
|
||||
async def _invoke():
|
||||
# Set the tool-progress channel BEFORE dispatch so the sink
|
||||
# propagates into the executor thread via contextvars, mirroring
|
||||
# the sync execute_tool path.
|
||||
if inspect.iscoroutinefunction(call_target):
|
||||
logging.debug(f"Executing async function: {function_name}")
|
||||
if _inject_state is not None:
|
||||
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
|
||||
return await call_target(**call_arguments)
|
||||
with tool_progress_channel(_progress_sink):
|
||||
return await call_target(**call_arguments)
|
||||
logging.debug(f"Executing sync function in executor: {function_name}")
|
||||
loop = asyncio.get_running_loop()
|
||||
from ..trace.context_events import copy_context_to_callable
|
||||
# copy_context_to_callable snapshots the CURRENT context (incl.
|
||||
# the injection contextvar) into the executor thread, so setting
|
||||
# it here propagates cancel_event to the sync tool body too.
|
||||
if _inject_state is not None:
|
||||
with with_injection_context(_inject_state), tool_progress_channel(_progress_sink):
|
||||
return await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: call_target(**call_arguments))
|
||||
)
|
||||
with tool_progress_channel(_progress_sink):
|
||||
return await loop.run_in_executor(
|
||||
None, copy_context_to_callable(lambda: call_target(**call_arguments))
|
||||
|
||||
@@ -6,14 +6,26 @@ operations. Follows protocol-driven design with zero overhead when not used.
|
||||
"""
|
||||
|
||||
import threading
|
||||
from typing import Optional, Protocol, Set
|
||||
from typing import Optional, Protocol, Set, runtime_checkable
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
__all__ = ["InterruptControllerProtocol", "InterruptController"]
|
||||
__all__ = [
|
||||
"InterruptControllerProtocol",
|
||||
"CancellableInterruptControllerProtocol",
|
||||
"InterruptController",
|
||||
]
|
||||
|
||||
|
||||
class InterruptControllerProtocol(Protocol):
|
||||
"""Protocol for interrupt controller extension point."""
|
||||
"""Protocol for interrupt controller extension point.
|
||||
|
||||
``event`` is intentionally NOT part of this base contract: exposing the
|
||||
underlying cancellation Event is an OPTIONAL capability. Existing third-party
|
||||
controllers that only implement request/clear/is_set/reason/check remain
|
||||
valid; the runtime probes for ``event`` via ``getattr(..., 'event', None)``.
|
||||
Controllers that want in-tool cooperative abort can implement
|
||||
:class:`CancellableInterruptControllerProtocol`.
|
||||
"""
|
||||
|
||||
def request(self, reason: str = "user") -> None:
|
||||
"""Request cancellation with optional reason."""
|
||||
@@ -31,12 +43,27 @@ class InterruptControllerProtocol(Protocol):
|
||||
def reason(self) -> Optional[str]:
|
||||
"""Get interrupt reason if set."""
|
||||
...
|
||||
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for interrupt and raise if set."""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CancellableInterruptControllerProtocol(InterruptControllerProtocol, Protocol):
|
||||
"""Optional extension: controllers that expose the cancellation Event.
|
||||
|
||||
A running tool body can wait on / poll this Event to abort promptly instead
|
||||
of running until its own timeout. Implementing it is entirely optional and
|
||||
fully backward compatible with :class:`InterruptControllerProtocol`.
|
||||
"""
|
||||
|
||||
@property
|
||||
def event(self) -> "threading.Event":
|
||||
"""Get the underlying cancellation Event for in-tool observation."""
|
||||
...
|
||||
|
||||
|
||||
@dataclass
|
||||
class InterruptController:
|
||||
"""Thread-safe cooperative cancellation for agent runs.
|
||||
@@ -95,10 +122,19 @@ class InterruptController:
|
||||
return turn_id in self._cancelled_turns
|
||||
|
||||
def _end_turn(self, turn_id: int) -> None:
|
||||
"""Unregister a turn so its request cannot affect a future turn."""
|
||||
"""Unregister a turn so its request cannot affect a future turn.
|
||||
|
||||
Also clears the underlying Event once no turn still needs it (no active
|
||||
or still-cancelled turns and no pending request). The Event is exposed to
|
||||
running tool bodies via :attr:`event`; leaving it set after an interrupted
|
||||
turn would make a reused controller abort tools launched by the NEXT turn.
|
||||
"""
|
||||
with self._lock:
|
||||
self._active_turns.discard(turn_id)
|
||||
self._cancelled_turns.discard(turn_id)
|
||||
if not self._active_turns and not self._cancelled_turns and not self._pending_turn_cancel:
|
||||
self._flag.clear()
|
||||
self._reason = None
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear the cancellation request."""
|
||||
@@ -126,6 +162,16 @@ class InterruptController:
|
||||
with self._lock:
|
||||
return self._reason
|
||||
|
||||
@property
|
||||
def event(self) -> threading.Event:
|
||||
"""Expose the underlying cancellation Event.
|
||||
|
||||
Lets a running tool body observe the interrupt cooperatively (e.g. wait
|
||||
on it with a timeout, or poll ``is_set()``) so an in-flight subprocess/
|
||||
request can be aborted rather than left running until its own timeout.
|
||||
"""
|
||||
return self._flag
|
||||
|
||||
def check(self) -> None:
|
||||
"""Check for cancellation and raise if requested.
|
||||
|
||||
|
||||
@@ -9,8 +9,9 @@ for maintainability.
|
||||
import os
|
||||
import inspect
|
||||
import logging
|
||||
import threading
|
||||
import contextvars
|
||||
from typing import Optional
|
||||
from typing import Optional, Dict
|
||||
|
||||
# Per-turn ownership of appended chat-history messages. Each chat()/achat() turn
|
||||
# runs in its own thread (sync) or task (async); both get an isolated copy of
|
||||
@@ -21,6 +22,38 @@ _active_turn_owned: "contextvars.ContextVar[Optional[list]]" = contextvars.Conte
|
||||
"praisonai_active_turn_owned", default=None
|
||||
)
|
||||
|
||||
# Registry of the ownership lists of every currently in-flight chat()/achat()
|
||||
# turn, keyed by id() of the list. A turn registers its list in
|
||||
# _begin_turn_tracking and removes it in _end_turn_tracking. ephemeral() reads
|
||||
# this at exit so a message a *concurrent live turn* appended is preserved even
|
||||
# when that append did not flow through the ephemeral tracker (e.g. the turn
|
||||
# ran on another thread that never entered this block's context). Guarded by
|
||||
# _live_turns_lock because turns on different threads mutate it concurrently.
|
||||
_live_turn_owners: "Dict[int, list]" = {}
|
||||
_live_turns_lock = threading.Lock()
|
||||
|
||||
|
||||
def _register_live_turn(owned: list) -> None:
|
||||
with _live_turns_lock:
|
||||
_live_turn_owners[id(owned)] = owned
|
||||
|
||||
|
||||
def _unregister_live_turn(owned: Optional[list]) -> None:
|
||||
if owned is None:
|
||||
return
|
||||
with _live_turns_lock:
|
||||
_live_turn_owners.pop(id(owned), None)
|
||||
|
||||
|
||||
def _live_turn_message_ids() -> set:
|
||||
"""ids of every message any currently in-flight turn has appended so far."""
|
||||
with _live_turns_lock:
|
||||
owners = list(_live_turn_owners.values())
|
||||
ids = set()
|
||||
for owned in owners:
|
||||
ids.update(id(m) for m in owned)
|
||||
return ids
|
||||
|
||||
# Shared lazy display helpers (cached, thread-safe; avoid circular imports)
|
||||
from ._lazy_display import _get_console, _get_live, _get_display_functions
|
||||
|
||||
@@ -121,11 +154,18 @@ class MemoryMixin:
|
||||
runs in its own thread (sync ``chat``) or task (async ``achat``), so the
|
||||
context var copy is isolated per turn. Safe to call unconditionally; when
|
||||
a turn opts in, its rollback removes only its own messages by identity.
|
||||
|
||||
The list is also registered in the process-wide live-turn registry so a
|
||||
concurrent ``ephemeral()`` block on another thread can preserve messages
|
||||
this in-flight turn appends (see ``_live_turn_message_ids``).
|
||||
"""
|
||||
return _active_turn_owned.set([])
|
||||
owned: list = []
|
||||
_register_live_turn(owned)
|
||||
return _active_turn_owned.set(owned)
|
||||
|
||||
def _end_turn_tracking(self, token):
|
||||
"""Stop recording for the current turn and drop its ownership list."""
|
||||
_unregister_live_turn(_active_turn_owned.get())
|
||||
try:
|
||||
_active_turn_owned.reset(token)
|
||||
except (ValueError, LookupError):
|
||||
@@ -136,8 +176,10 @@ class MemoryMixin:
|
||||
|
||||
Used at the start of an async turn (whose control flow has no single
|
||||
wrapping ``finally``) so a stale list from a completed turn on the same
|
||||
task cannot accumulate messages from unrelated turns.
|
||||
task cannot accumulate messages from unrelated turns. Also unregisters
|
||||
that stale list from the live-turn registry so it cannot linger there.
|
||||
"""
|
||||
_unregister_live_turn(_active_turn_owned.get())
|
||||
_active_turn_owned.set(None)
|
||||
|
||||
def _rollback_chat_history_to(self, rollback_length):
|
||||
@@ -162,7 +204,7 @@ class MemoryMixin:
|
||||
"""
|
||||
owned = _active_turn_owned.get()
|
||||
with self._history_lock:
|
||||
if owned:
|
||||
if owned is not None:
|
||||
owned_ids = {id(m) for m in owned}
|
||||
self.chat_history[:] = [
|
||||
m for m in self.chat_history if id(m) not in owned_ids
|
||||
@@ -200,9 +242,12 @@ class MemoryMixin:
|
||||
Also resets _auto_save_last_index to prevent silent message loss
|
||||
when auto_save is enabled.
|
||||
"""
|
||||
self.chat_history = []
|
||||
# Reset auto-save index to prevent stale index causing message loss
|
||||
self._auto_save_last_index = 0
|
||||
# Hold the same lock every other history mutator takes so a concurrent
|
||||
# _add_to_chat_history append can't land on the list object we discard.
|
||||
with self._history_lock:
|
||||
self.chat_history = []
|
||||
# Reset auto-save index to prevent stale index causing message loss
|
||||
self._auto_save_last_index = 0
|
||||
|
||||
def prune_history(self, keep_last: int = 5) -> int:
|
||||
"""
|
||||
@@ -296,16 +341,32 @@ class MemoryMixin:
|
||||
response = agent.chat("[IMAGE] Analyze this")
|
||||
# After block, history is restored - image NOT persisted
|
||||
"""
|
||||
# Save current history state
|
||||
# Restore by identity, not by a blind ``chat_history = saved_history``.
|
||||
# A message a concurrent chat()/achat() turn on the same shared Agent
|
||||
# commits during the block must survive; only the ephemeral additions go.
|
||||
#
|
||||
# A message is KEPT on exit when it is either:
|
||||
# * present in the pre-block snapshot (it predates the block), or
|
||||
# * owned by a chat()/achat() turn that is still in flight (a live
|
||||
# concurrent turn appended it - preserving it is the fix for the
|
||||
# history-loss race).
|
||||
# Everything else that appeared during the block is an ephemeral addition
|
||||
# and is removed. This also drops direct ``chat_history.append`` writes
|
||||
# made inside the block, which carry no turn ownership. Nested blocks
|
||||
# compose: the inner block removes its own additions first, and the outer
|
||||
# block's snapshot still holds everything that predates it.
|
||||
with self._history_lock:
|
||||
saved_history = self.chat_history.copy()
|
||||
|
||||
snapshot_ids = {id(m) for m in self.chat_history}
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# Restore history to pre-block state
|
||||
live_ids = _live_turn_message_ids()
|
||||
keep_ids = snapshot_ids | live_ids
|
||||
with self._history_lock:
|
||||
self.chat_history = saved_history
|
||||
self.chat_history[:] = [
|
||||
m for m in self.chat_history if id(m) in keep_ids
|
||||
]
|
||||
|
||||
def _init_db_session(self):
|
||||
"""Initialize DB session if db adapter is provided (lazy, first chat only)."""
|
||||
|
||||
@@ -436,6 +436,50 @@ class ToolExecutionMixin:
|
||||
logging.debug(f"Type casting failed for {getattr(func, '__name__', 'unknown function')}: {e}")
|
||||
return arguments
|
||||
|
||||
def _current_cancel_event(self):
|
||||
"""Return a cancellation Event a running tool body can observe, or None.
|
||||
|
||||
Sourced from the agent's ``interrupt_controller`` when present. The event
|
||||
is only meaningful while a cancellation is actually in effect for the
|
||||
current turn — the controller clears its underlying flag once an
|
||||
interrupted turn ends (see ``InterruptController._end_turn``), so a stale
|
||||
request from a previous turn does not abort newly started tool bodies.
|
||||
A controller without an ``event`` member yields ``None`` (fully optional).
|
||||
"""
|
||||
controller = getattr(self, 'interrupt_controller', None)
|
||||
if controller is None:
|
||||
return None
|
||||
return getattr(controller, 'event', None)
|
||||
|
||||
def _build_agent_state(self):
|
||||
"""Construct the AgentState injected into tools for the current call.
|
||||
|
||||
Shared by the sync (``execute_tool``) and async
|
||||
(``_execute_tool_async_impl``) paths so both establish an identical
|
||||
injection context — including the cooperative ``cancel_event`` — avoiding
|
||||
drift where one path silently omits injected state.
|
||||
"""
|
||||
from ..tools.injected import AgentState
|
||||
try:
|
||||
from .durable import get_durable_idempotency_key
|
||||
|
||||
idempotency_key = get_durable_idempotency_key()
|
||||
except ImportError:
|
||||
idempotency_key = None
|
||||
state_metadata = {'agent_name': self.name}
|
||||
if idempotency_key:
|
||||
state_metadata['idempotency_key'] = idempotency_key
|
||||
return AgentState(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
|
||||
memory=getattr(self, '_memory_instance', None),
|
||||
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
|
||||
metadata=state_metadata,
|
||||
cancel_event=self._current_cancel_event(),
|
||||
)
|
||||
|
||||
def execute_tool(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Execute a tool dynamically based on the function name and arguments.
|
||||
@@ -459,25 +503,8 @@ class ToolExecutionMixin:
|
||||
# Do NOT call it here to avoid duplicate output
|
||||
|
||||
# Set up injection context for tools with Injected parameters
|
||||
from ..tools.injected import AgentState
|
||||
try:
|
||||
from .durable import get_durable_idempotency_key
|
||||
|
||||
idempotency_key = get_durable_idempotency_key()
|
||||
except ImportError:
|
||||
idempotency_key = None
|
||||
state_metadata = {'agent_name': self.name}
|
||||
if idempotency_key:
|
||||
state_metadata['idempotency_key'] = idempotency_key
|
||||
state = AgentState(
|
||||
agent_id=self.name,
|
||||
run_id=getattr(self, '_current_run_id', 'unknown'),
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
last_user_message=self.chat_history[-1].get('content') if self.chat_history else None,
|
||||
memory=getattr(self, '_memory_instance', None),
|
||||
learn_manager=getattr(getattr(self, '_memory_instance', None), 'learn', None),
|
||||
metadata=state_metadata,
|
||||
)
|
||||
state = self._build_agent_state()
|
||||
cancel_event = state.cancel_event
|
||||
|
||||
# Route through user-supplied tool middleware (Agent(hooks=[...])) when
|
||||
# present. Zero overhead when no hooks: the fast path calls straight
|
||||
@@ -496,6 +523,7 @@ class ToolExecutionMixin:
|
||||
session_id=getattr(self, '_session_id', None) or 'default',
|
||||
tool_name=function_name,
|
||||
metadata={"tool_call_id": tool_call_id},
|
||||
cancel_event=cancel_event,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -2557,86 +2585,15 @@ class ToolExecutionMixin:
|
||||
return policy_result # Error dict
|
||||
_, arguments = policy_result
|
||||
|
||||
# Special handling for MCP tools
|
||||
# Check if tools is an MCP instance with the requested function name
|
||||
MCP = None
|
||||
try:
|
||||
from ..mcp.mcp import MCP
|
||||
except ImportError:
|
||||
pass # MCP not available
|
||||
|
||||
# Normalize MCP transport/timeout failures (returned as bare "Error: ..."
|
||||
# strings by MCPClient.call_tool) into the same {"error": ...} dict shape
|
||||
# used by native tools, so they participate in retry, doom-loop detection
|
||||
# and circuit-breaking instead of looking like a successful string result.
|
||||
#
|
||||
# IMPORTANT: a successful MCP tool may legitimately return text that
|
||||
# begins with "Error: " (e.g. a linter/grep/echo tool). To avoid
|
||||
# misclassifying valid output as a failure, we only convert MCP's own
|
||||
# unambiguous internally-generated failure signatures, not any string
|
||||
# that merely starts with the "Error: " prefix.
|
||||
def _normalize_mcp_result(res):
|
||||
if not isinstance(res, str) or not res.startswith("Error: "):
|
||||
return res
|
||||
message = res[len("Error: "):]
|
||||
# MCP-internal timeout messages are unambiguous:
|
||||
# "MCP tool call timed out after Ns"
|
||||
# "MCP initialization timed out after Ns"
|
||||
is_mcp_timeout = (
|
||||
message.startswith("MCP tool call timed out after")
|
||||
or message.startswith("MCP initialization timed out after")
|
||||
)
|
||||
if is_mcp_timeout:
|
||||
return {"error": message, "timeout": True}
|
||||
return res
|
||||
|
||||
# Helper function to execute MCP tool
|
||||
def _execute_mcp_tool(mcp_instance, func_name, args):
|
||||
"""Execute a tool from an MCP instance."""
|
||||
# Handle SSE MCP client
|
||||
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
|
||||
if hasattr(mcp_instance, 'sse_client'):
|
||||
for tool in mcp_instance.sse_client.tools:
|
||||
if tool.name == func_name:
|
||||
logging.debug(f"Found matching SSE MCP tool: {func_name}")
|
||||
return True, tool(**args)
|
||||
# Handle HTTP Stream MCP client
|
||||
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
|
||||
if hasattr(mcp_instance, 'http_stream_client'):
|
||||
for tool in mcp_instance.http_stream_client.tools:
|
||||
if tool.name == func_name:
|
||||
logging.debug(f"Found matching HTTP Stream MCP tool: {func_name}")
|
||||
return True, tool(**args)
|
||||
# Handle WebSocket MCP client
|
||||
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
|
||||
if hasattr(mcp_instance, 'websocket_client'):
|
||||
for tool in mcp_instance.websocket_client.tools:
|
||||
if tool.name == func_name:
|
||||
logging.debug(f"Found matching WebSocket MCP tool: {func_name}")
|
||||
return True, tool(**args)
|
||||
# Handle stdio MCP client
|
||||
if hasattr(mcp_instance, 'runner'):
|
||||
for mcp_tool in mcp_instance.runner.tools:
|
||||
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
|
||||
logging.debug(f"Found matching MCP tool: {func_name}")
|
||||
return True, mcp_instance.runner.call_tool(func_name, args)
|
||||
return False, None
|
||||
|
||||
# Check if tools is a single MCP instance
|
||||
if MCP is not None and isinstance(self.tools, MCP):
|
||||
logging.debug(f"Looking for MCP tool {function_name}")
|
||||
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
|
||||
if found:
|
||||
return _normalize_mcp_result(result)
|
||||
|
||||
# Check if tools is a list that may contain MCP instances
|
||||
if isinstance(self.tools, (list, tuple)):
|
||||
for tool in self.tools:
|
||||
if MCP is not None and isinstance(tool, MCP):
|
||||
logging.debug(f"Looking for MCP tool {function_name} in MCP instance")
|
||||
found, result = _execute_mcp_tool(tool, function_name, arguments)
|
||||
if found:
|
||||
return _normalize_mcp_result(result)
|
||||
# Resolve MCP-backed tools via the shared helper (also used by the async
|
||||
# path) so chat()/achat() behave identically for MCP tool calls. MCP
|
||||
# transport/timeout failures are normalized (via the shared
|
||||
# _normalize_mcp_result) into a native {"error": ..., "timeout": True}
|
||||
# dict so they participate in retry/doom-loop/circuit handling instead of
|
||||
# looking like a successful string result.
|
||||
found, result = self._resolve_mcp_tool_result(function_name, arguments)
|
||||
if found:
|
||||
return self._normalize_mcp_result(result)
|
||||
|
||||
# Try to find the function in the agent's tools list first
|
||||
func = None
|
||||
@@ -2670,21 +2627,10 @@ class ToolExecutionMixin:
|
||||
if func is None:
|
||||
# Tool not found in declared tools or registry — do not fall back to
|
||||
# globals() or __main__ as that allows undeclared callables to execute.
|
||||
# Cheap, deterministic self-repair: the model often emits a name that
|
||||
# only differs by case/separator (e.g. 'WebSearch' -> 'web_search').
|
||||
# Build a normalised index of the agent's active tools and re-match.
|
||||
def _norm(n):
|
||||
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
|
||||
|
||||
normalised = {}
|
||||
for name, tool in self._iter_active_named_tools():
|
||||
normalised.setdefault(_norm(name), []).append((name, tool))
|
||||
match = normalised.get(_norm(function_name))
|
||||
if match and len(match) == 1:
|
||||
matched_name, func = match[0]
|
||||
logging.debug(
|
||||
f"Self-repaired tool name {function_name!r} -> {matched_name!r}"
|
||||
)
|
||||
# Cheap, deterministic self-repair via the shared helper (also used by
|
||||
# the async path): the model often emits a name that only differs by
|
||||
# case/separator (e.g. 'WebSearch' -> 'web_search').
|
||||
func = self._self_repair_tool_name(function_name)
|
||||
|
||||
if func:
|
||||
bind_target = func
|
||||
@@ -2714,6 +2660,16 @@ class ToolExecutionMixin:
|
||||
scoped["idempotency_key"] = durable_key
|
||||
return scoped
|
||||
|
||||
# Any of the callables below (BaseTool.run, LangChain .run,
|
||||
# CrewAI ._run, or a plain function) may be `async def`. On this
|
||||
# sync tool-calling path we must await it, otherwise a bare
|
||||
# un-awaited coroutine is handed to the model as the tool result
|
||||
# and the tool body never runs (silent data loss).
|
||||
def _resolve_result(value):
|
||||
if inspect.iscoroutine(value):
|
||||
return self._run_async_in_sync_context(value)
|
||||
return value
|
||||
|
||||
# BaseTool instances (plugin system) - call run() method
|
||||
from ..tools.base import BaseTool
|
||||
if isinstance(func, BaseTool):
|
||||
@@ -2721,7 +2677,7 @@ class ToolExecutionMixin:
|
||||
keyed_arguments = _with_durable_key(func.run, arguments)
|
||||
casted_arguments = self._cast_arguments(func.run, keyed_arguments)
|
||||
bind_arguments = casted_arguments
|
||||
return func.run(**casted_arguments)
|
||||
return _resolve_result(func.run(**casted_arguments))
|
||||
|
||||
# Langchain: If it's a class with run but not _run, instantiate and call run
|
||||
if inspect.isclass(func) and hasattr(func, 'run') and not hasattr(func, '_run'):
|
||||
@@ -2733,7 +2689,7 @@ class ToolExecutionMixin:
|
||||
and k != 'self'}
|
||||
casted_params = self._cast_arguments(instance.run, run_params)
|
||||
bind_arguments = casted_params
|
||||
return instance.run(**casted_params)
|
||||
return _resolve_result(instance.run(**casted_params))
|
||||
|
||||
# CrewAI: If it's a class with an _run method, instantiate and call _run
|
||||
elif inspect.isclass(func) and hasattr(func, '_run'):
|
||||
@@ -2745,7 +2701,7 @@ class ToolExecutionMixin:
|
||||
and k != 'self'}
|
||||
casted_params = self._cast_arguments(instance._run, run_params)
|
||||
bind_arguments = casted_params
|
||||
return instance._run(**casted_params)
|
||||
return _resolve_result(instance._run(**casted_params))
|
||||
|
||||
# Otherwise treat as regular function
|
||||
elif callable(func):
|
||||
@@ -2753,7 +2709,7 @@ class ToolExecutionMixin:
|
||||
keyed_arguments = _with_durable_key(func, arguments)
|
||||
casted_arguments = self._cast_arguments(func, keyed_arguments)
|
||||
bind_arguments = casted_arguments
|
||||
return func(**casted_arguments)
|
||||
return _resolve_result(func(**casted_arguments))
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
logging.error(f"Error executing tool {function_name}: {error_msg}")
|
||||
@@ -2904,6 +2860,103 @@ class ToolExecutionMixin:
|
||||
names = [name for name, _tool in self._iter_active_named_tools()]
|
||||
return sorted(set(names))
|
||||
|
||||
@staticmethod
|
||||
def _normalize_mcp_result(res):
|
||||
"""Normalize MCP transport/timeout failures into a native error dict.
|
||||
|
||||
``MCPClient.call_tool`` surfaces transport/timeout failures as bare
|
||||
``"Error: ..."`` strings. Converting MCP's own unambiguous
|
||||
internally-generated timeout signatures into ``{"error": ..., "timeout":
|
||||
True}`` lets them participate in retry, doom-loop detection and circuit
|
||||
breaking instead of looking like a successful string result. Shared by
|
||||
the sync (``_execute_tool_impl``) and async (``_execute_tool_async_impl``)
|
||||
paths so achat()/chat() classify an MCP timeout identically.
|
||||
|
||||
A successful MCP tool may legitimately return text starting with
|
||||
``"Error: "`` (e.g. a linter/grep/echo tool), so only MCP's own
|
||||
unambiguous timeout messages are converted, never any string that merely
|
||||
starts with the prefix.
|
||||
"""
|
||||
if not isinstance(res, str) or not res.startswith("Error: "):
|
||||
return res
|
||||
message = res[len("Error: "):]
|
||||
is_mcp_timeout = (
|
||||
message.startswith("MCP tool call timed out after")
|
||||
or message.startswith("MCP initialization timed out after")
|
||||
)
|
||||
if is_mcp_timeout:
|
||||
return {"error": message, "timeout": True}
|
||||
return res
|
||||
|
||||
def _resolve_mcp_tool_result(self, function_name, arguments):
|
||||
"""Resolve an MCP-backed tool and return ``(found, result)``.
|
||||
|
||||
Shared by the sync (``_execute_tool_impl``) and async
|
||||
(``_execute_tool_async_impl``) paths so an MCP tool call behaves
|
||||
identically regardless of chat()/achat(). Returns ``(False, None)``
|
||||
when no MCP tool matches. The ``result`` may be awaitable (the async
|
||||
caller awaits it); the sync caller normalizes/returns it directly.
|
||||
"""
|
||||
MCP = None
|
||||
try:
|
||||
from ..mcp.mcp import MCP
|
||||
except ImportError:
|
||||
return False, None
|
||||
|
||||
def _execute_mcp_tool(mcp_instance, func_name, args):
|
||||
if hasattr(mcp_instance, 'is_sse') and mcp_instance.is_sse:
|
||||
if hasattr(mcp_instance, 'sse_client'):
|
||||
for tool in mcp_instance.sse_client.tools:
|
||||
if tool.name == func_name:
|
||||
return True, tool(**args)
|
||||
if hasattr(mcp_instance, 'is_http_stream') and mcp_instance.is_http_stream:
|
||||
if hasattr(mcp_instance, 'http_stream_client'):
|
||||
for tool in mcp_instance.http_stream_client.tools:
|
||||
if tool.name == func_name:
|
||||
return True, tool(**args)
|
||||
if hasattr(mcp_instance, 'is_websocket') and mcp_instance.is_websocket:
|
||||
if hasattr(mcp_instance, 'websocket_client'):
|
||||
for tool in mcp_instance.websocket_client.tools:
|
||||
if tool.name == func_name:
|
||||
return True, tool(**args)
|
||||
if hasattr(mcp_instance, 'runner'):
|
||||
for mcp_tool in mcp_instance.runner.tools:
|
||||
if hasattr(mcp_tool, 'name') and mcp_tool.name == func_name:
|
||||
return True, mcp_instance.runner.call_tool(func_name, args)
|
||||
return False, None
|
||||
|
||||
if isinstance(self.tools, MCP):
|
||||
found, result = _execute_mcp_tool(self.tools, function_name, arguments)
|
||||
if found:
|
||||
return True, result
|
||||
if isinstance(self.tools, (list, tuple)):
|
||||
for tool in self.tools:
|
||||
if isinstance(tool, MCP):
|
||||
found, result = _execute_mcp_tool(tool, function_name, arguments)
|
||||
if found:
|
||||
return True, result
|
||||
return False, None
|
||||
|
||||
def _self_repair_tool_name(self, function_name):
|
||||
"""Case/separator-insensitive re-match of a hallucinated tool name.
|
||||
|
||||
Returns the matched tool object when exactly one active tool normalizes
|
||||
to the same key (e.g. 'WebSearch' -> 'web_search'), else ``None``.
|
||||
Shared by the sync and async tool-resolution paths.
|
||||
"""
|
||||
def _norm(n):
|
||||
return str(n).lower().replace('_', '').replace('-', '').replace(' ', '')
|
||||
|
||||
normalised = {}
|
||||
for name, tool in self._iter_active_named_tools():
|
||||
normalised.setdefault(_norm(name), []).append((name, tool))
|
||||
match = normalised.get(_norm(function_name))
|
||||
if match and len(match) == 1:
|
||||
matched_name, tool = match[0]
|
||||
logging.debug(f"Self-repaired tool name {function_name!r} -> {matched_name!r}")
|
||||
return tool
|
||||
return None
|
||||
|
||||
def _resolve_callable_signature_target(self, func):
|
||||
"""Return the callable whose signature describes ``func``'s arguments."""
|
||||
target = func
|
||||
|
||||
@@ -3536,8 +3536,13 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
This method is safe to call from sync contexts only.
|
||||
"""
|
||||
with self._spawn_lock:
|
||||
# Create unique IDs
|
||||
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
|
||||
# the (rare) truncated-UUID collision rather than silently clobbering
|
||||
# an existing spawn record - the same guard the endpoint-path
|
||||
# generator uses.
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
while agent_id in self._spawned_agents:
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
task_id = f"task_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
# Ensure event bus is initialized
|
||||
@@ -3740,8 +3745,13 @@ class AgentTeam(SpawnAnnounceProtocol):
|
||||
self._async_spawn_lock = asyncio.Lock()
|
||||
|
||||
async with self._async_spawn_lock:
|
||||
# Create unique IDs
|
||||
# Create unique IDs. agent_id keys self._spawned_agents, so retry on
|
||||
# the (rare) truncated-UUID collision rather than silently clobbering
|
||||
# an existing spawn record - the same guard the endpoint-path
|
||||
# generator uses.
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
while agent_id in self._spawned_agents:
|
||||
agent_id = f"{self._team_id}_{str(uuid.uuid4())[:8]}"
|
||||
task_id = f"task_{str(uuid.uuid4())[:8]}"
|
||||
|
||||
# Ensure event bus is initialized
|
||||
|
||||
@@ -10,7 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, runtime_checkable
|
||||
|
||||
|
||||
def _new_approval_id() -> str:
|
||||
@@ -38,6 +38,14 @@ class ApprovalRequest:
|
||||
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.
|
||||
liveness: Optional zero-arg predicate returning ``True`` while the
|
||||
originating turn is still the current one. ``None``
|
||||
(default) is a no-op — the request is treated as always
|
||||
live, preserving today's behaviour. A backend may consult
|
||||
this at the resolution/execution boundary to drop a
|
||||
resolution that arrives after the turn was superseded or
|
||||
stopped (fail-closed), so a stale approval never fires a
|
||||
tool or delivers a reply for an abandoned turn.
|
||||
"""
|
||||
|
||||
tool_name: str
|
||||
@@ -48,6 +56,7 @@ class ApprovalRequest:
|
||||
context: Dict[str, Any] = field(default_factory=dict)
|
||||
approval_id: str = field(default_factory=_new_approval_id)
|
||||
authorized_reviewers: Optional[List[str]] = None
|
||||
liveness: Optional[Callable[[], bool]] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -441,6 +441,44 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
await self.disconnect()
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Identity canonicalization seam. #
|
||||
# #
|
||||
# The gateway keys each conversation on the platform-supplied user id.#
|
||||
# When a platform changes the *native* id for the *same human* #
|
||||
# (WhatsApp JID→LID migration, a renamed handle-as-id, a #
|
||||
# phone-number↔UUID alias flip) the raw id changes and the session #
|
||||
# would silently fork — history/memory/run-state orphaned. This seam #
|
||||
# lets an adapter map a volatile raw id to a stable canonical id #
|
||||
# *before* it becomes part of the session key; the resolver's #
|
||||
# explicit link/pairing lookup then runs on the stabilised id. #
|
||||
# #
|
||||
# It fulfils ``IdentityCanonicalizerProtocol`` (praisonaiagents. #
|
||||
# gateway.protocols) so an adapter *is* its own canonicalizer: pass #
|
||||
# ``self`` where that protocol is accepted. #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def canonicalize(self, platform: str, raw_user_id: str) -> str:
|
||||
"""Map a raw, potentially-volatile platform id to a stable canonical id.
|
||||
|
||||
Consulted on the session-key path *before* link/pairing resolution so
|
||||
an alias/format change for the *same* human (e.g. WhatsApp's JID→LID
|
||||
migration, a number↔UUID alias flip) collapses to one stable session
|
||||
key — preserving transcript, memory and in-flight run-state — instead
|
||||
of silently forking the conversation.
|
||||
|
||||
This is the :class:`~praisonaiagents.gateway.protocols.
|
||||
IdentityCanonicalizerProtocol` method, so a ``BasePlatformAdapter``
|
||||
conforms structurally and can be handed anywhere that protocol is
|
||||
accepted.
|
||||
|
||||
Default is the identity function, so adapters that do not override this
|
||||
are fully backward-compatible (the raw id keys the session exactly as
|
||||
before). Implementations MUST be deterministic and total: return the
|
||||
raw id unchanged when no canonical form is known, never raise.
|
||||
"""
|
||||
return raw_user_id
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Capability helpers #
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
@@ -335,6 +335,13 @@ class GatewayRuntimeSeams:
|
||||
Attributes:
|
||||
identity_resolver: Cross-platform identity resolver; unifies the same
|
||||
human across platforms onto one session key.
|
||||
identity_canonicalizer: Identity-canonicalization seam (Issue #4661) —
|
||||
an :class:`IdentityCanonicalizerProtocol`
|
||||
(``canonicalize(platform, raw_user_id) -> str``) applied *before*
|
||||
the resolver, e.g. an adapter itself (see
|
||||
:meth:`BasePlatformAdapter.canonicalize`). Stabilises a volatile
|
||||
platform id (WhatsApp JID→LID, a number↔UUID alias flip) so the same
|
||||
human keeps one session instead of silently forking.
|
||||
delivery_router: Backing router for the built-in ``send_message`` tool,
|
||||
so an agent turn can proactively reach the user mid-task.
|
||||
admission_gate: Gateway-wide admission control (concurrency ceiling /
|
||||
@@ -347,6 +354,7 @@ class GatewayRuntimeSeams:
|
||||
"""
|
||||
|
||||
identity_resolver: Optional[Any] = None
|
||||
identity_canonicalizer: Optional[Any] = None
|
||||
delivery_router: Optional[Any] = None
|
||||
admission_gate: Optional[Any] = None
|
||||
turn_lock_map: Optional[Any] = None
|
||||
|
||||
@@ -139,7 +139,12 @@ class ContextCompactor:
|
||||
# Anti-thrashing state tracking
|
||||
self._last_savings_pct: float = 100.0 # Start high to allow first compaction
|
||||
self._low_savings_streak: int = 0
|
||||
|
||||
# Token count at the moment the streak reached its cap. The guard is
|
||||
# released once the conversation grows materially past this, so a
|
||||
# compactor that once could not shrink the context is never disabled
|
||||
# for the rest of the session.
|
||||
self._low_savings_tokens: int = 0
|
||||
|
||||
# Iterative summary state
|
||||
self._previous_summary: Optional[str] = None
|
||||
self._previous_summary_global_idx: int = 0
|
||||
@@ -208,14 +213,56 @@ class ContextCompactor:
|
||||
- Token count is below threshold
|
||||
- We've had too many consecutive low-savings attempts
|
||||
"""
|
||||
if self.count_total_tokens(messages) <= self.max_tokens:
|
||||
total_tokens = self.count_total_tokens(messages)
|
||||
if total_tokens <= self.max_tokens:
|
||||
return False
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
|
||||
# Anti-thrashing: skip if we've had too many low-savings attempts,
|
||||
# unless the conversation has grown enough to be worth re-testing.
|
||||
if self._should_skip_low_savings(total_tokens):
|
||||
return False
|
||||
|
||||
|
||||
return True
|
||||
|
||||
#: Growth factor that releases the anti-thrashing guard. A conversation
|
||||
#: 25% larger than the one we gave up on is materially different input,
|
||||
#: so the earlier "not worth compacting" verdict is re-tested.
|
||||
_LOW_SAVINGS_RETRY_GROWTH = 1.25
|
||||
|
||||
def _should_skip_low_savings(self, original_tokens: int) -> bool:
|
||||
"""Whether the anti-thrashing guard should skip this pass.
|
||||
|
||||
The guard stops a compactor that cannot shrink a conversation from
|
||||
burning a summarisation call every turn. It must not be permanent:
|
||||
``_low_savings_streak`` is only cleared *after* a compaction actually
|
||||
runs, so a compactor that trips the cap would otherwise skip forever
|
||||
and let the context grow without bound -- the precise opposite of what
|
||||
the guard is for.
|
||||
|
||||
Growth past the size we gave up on is new evidence, so the verdict is
|
||||
retried and the streak reset.
|
||||
"""
|
||||
if self._low_savings_streak < self.config.max_consecutive_low_savings:
|
||||
return False
|
||||
if self._low_savings_tokens and original_tokens >= (
|
||||
self._low_savings_tokens * self._LOW_SAVINGS_RETRY_GROWTH
|
||||
):
|
||||
self._low_savings_streak = 0
|
||||
self._low_savings_tokens = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _record_savings_outcome(self, savings_pct: float, original_tokens: int) -> None:
|
||||
"""Update anti-thrashing state after a completed compaction pass."""
|
||||
self._last_savings_pct = savings_pct
|
||||
if savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
self._low_savings_tokens = 0
|
||||
return
|
||||
self._low_savings_streak += 1
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
# Remember the size we gave up at, so growth can release the guard.
|
||||
self._low_savings_tokens = original_tokens
|
||||
|
||||
def compact(
|
||||
self,
|
||||
@@ -254,7 +301,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing: if prior passes yielded too little savings, skip and
|
||||
# surface it on the result so callers can detect the skip. Without this
|
||||
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
if self._should_skip_low_savings(original_tokens):
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
@@ -323,11 +370,7 @@ class ContextCompactor:
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
self._record_savings_outcome(result.savings_pct, original_tokens)
|
||||
|
||||
return compacted, result
|
||||
|
||||
@@ -365,7 +408,7 @@ class ContextCompactor:
|
||||
# Anti-thrashing: if prior passes yielded too little savings, skip and
|
||||
# surface it on the result so callers can detect the skip. Without this
|
||||
# the documented ``was_skipped_due_to_low_savings`` flag never fired.
|
||||
if self._low_savings_streak >= self.config.max_consecutive_low_savings:
|
||||
if self._should_skip_low_savings(original_tokens):
|
||||
result = CompactionResult(
|
||||
original_tokens=original_tokens,
|
||||
compacted_tokens=original_tokens,
|
||||
@@ -416,11 +459,7 @@ class ContextCompactor:
|
||||
result.calculate_savings_pct()
|
||||
|
||||
# Update anti-thrashing tracking based on actual results
|
||||
self._last_savings_pct = result.savings_pct
|
||||
if result.savings_pct >= self.config.min_savings_pct:
|
||||
self._low_savings_streak = 0
|
||||
else:
|
||||
self._low_savings_streak += 1
|
||||
self._record_savings_outcome(result.savings_pct, original_tokens)
|
||||
|
||||
return compacted, result
|
||||
|
||||
|
||||
@@ -204,52 +204,61 @@ def evaluate_condition(
|
||||
else:
|
||||
substituted = substituted.replace(placeholder, str(value))
|
||||
|
||||
# A comparison operator in the *template* (not the substituted values)
|
||||
# is what makes this a comparison condition. When the template has no
|
||||
# operator, it is a plain flag/contains/truthy check and the comparison
|
||||
# parsers below must be skipped, otherwise a placeholder whose value
|
||||
# happens to look like a comparison (e.g. "{{flag}}" -> "5 < 3") would be
|
||||
# mis-parsed as a comparison instead of honoring the truthy-string path.
|
||||
template_has_comparison = re.search(r'>=|<=|==|!=|>|<', condition) is not None
|
||||
|
||||
# Now evaluate the substituted condition
|
||||
try:
|
||||
# Handle different condition formats
|
||||
|
||||
# Check if we have unsubstituted empty values in comparisons
|
||||
# If a variable was missing, it becomes empty string which can cause issues
|
||||
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
|
||||
# Check for empty left side in comparison (missing variable)
|
||||
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
|
||||
return False
|
||||
if substituted.strip().startswith('='):
|
||||
return False
|
||||
|
||||
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
|
||||
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
|
||||
numeric_match = re.match(numeric_pattern, substituted.strip())
|
||||
if numeric_match:
|
||||
left = float(numeric_match.group(1))
|
||||
op = numeric_match.group(2)
|
||||
right = float(numeric_match.group(3))
|
||||
if template_has_comparison:
|
||||
# Check if we have unsubstituted empty values in comparisons
|
||||
# If a variable was missing, it becomes empty string which can cause issues
|
||||
if ' > ' in substituted or ' < ' in substituted or ' >= ' in substituted or ' <= ' in substituted:
|
||||
# Check for empty left side in comparison (missing variable)
|
||||
if substituted.strip().startswith('>') or substituted.strip().startswith('<'):
|
||||
return False
|
||||
if substituted.strip().startswith('='):
|
||||
return False
|
||||
|
||||
if op == '>':
|
||||
return left > right
|
||||
if op == '>=':
|
||||
return left >= right
|
||||
if op == '<':
|
||||
return left < right
|
||||
if op == '<=':
|
||||
return left <= right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
|
||||
# String equality: "approved == approved", "status != rejected"
|
||||
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
|
||||
string_match = re.match(string_eq_pattern, substituted.strip())
|
||||
if string_match:
|
||||
left = string_match.group(1).strip()
|
||||
op = string_match.group(2)
|
||||
right = string_match.group(3).strip()
|
||||
# Numeric comparisons: "90 > 80", "50 >= 50", "10 < 20", etc.
|
||||
numeric_pattern = r'^(-?\d+(?:\.\d+)?)\s*(>|>=|<|<=|==|!=)\s*(-?\d+(?:\.\d+)?)$'
|
||||
numeric_match = re.match(numeric_pattern, substituted.strip())
|
||||
if numeric_match:
|
||||
left = float(numeric_match.group(1))
|
||||
op = numeric_match.group(2)
|
||||
right = float(numeric_match.group(3))
|
||||
|
||||
if op == '>':
|
||||
return left > right
|
||||
if op == '>=':
|
||||
return left >= right
|
||||
if op == '<':
|
||||
return left < right
|
||||
if op == '<=':
|
||||
return left <= right
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
# String equality: "approved == approved", "status != rejected"
|
||||
string_eq_pattern = r'^(.+?)\s*(==|!=)\s*(.+)$'
|
||||
string_match = re.match(string_eq_pattern, substituted.strip())
|
||||
if string_match:
|
||||
left = string_match.group(1).strip()
|
||||
op = string_match.group(2)
|
||||
right = string_match.group(3).strip()
|
||||
|
||||
if op == '==':
|
||||
return left == right
|
||||
if op == '!=':
|
||||
return left != right
|
||||
|
||||
# Contains check: "error in some message", "status contains success"
|
||||
if ' in ' in substituted:
|
||||
@@ -272,7 +281,16 @@ def evaluate_condition(
|
||||
return True
|
||||
if substituted.strip().lower() == 'false':
|
||||
return False
|
||||
|
||||
|
||||
# A comparison operator in the template survived every format-specific
|
||||
# check above, so the comparison it was meant to drive never matched
|
||||
# (e.g. a missing or non-numeric operand). Fail safe per this
|
||||
# function's own contract instead of treating the leftover operator
|
||||
# text as a truthy string. Plain flag values (no template operator)
|
||||
# are untouched and fall through to the truthy check below.
|
||||
if template_has_comparison:
|
||||
return False
|
||||
|
||||
# Non-empty string is truthy
|
||||
return bool(substituted.strip())
|
||||
|
||||
|
||||
@@ -68,6 +68,10 @@ __all__ = [
|
||||
"EvalReport",
|
||||
"EvalPackage",
|
||||
"EvalRunnerProtocol",
|
||||
# EvalPort adapter (framework-neutral interop; dependency-free)
|
||||
"to_evalport",
|
||||
"from_evalport",
|
||||
"report_to_evalport",
|
||||
# Unified Judge API (follows add_X/get_X/list_X naming)
|
||||
"Judge",
|
||||
"JudgeConfig",
|
||||
@@ -160,6 +164,10 @@ _LAZY_IMPORTS = {
|
||||
"EvalReport": ("praisonaiagents.eval.package", "EvalReport"),
|
||||
"EvalPackage": ("praisonaiagents.eval.package", "EvalPackage"),
|
||||
"EvalRunnerProtocol": ("praisonaiagents.eval.package", "EvalRunnerProtocol"),
|
||||
# EvalPort adapter (framework-neutral interop; dependency-free)
|
||||
"to_evalport": ("praisonaiagents.eval.evalport", "to_evalport"),
|
||||
"from_evalport": ("praisonaiagents.eval.evalport", "from_evalport"),
|
||||
"report_to_evalport": ("praisonaiagents.eval.evalport", "report_to_evalport"),
|
||||
# Unified Judge API (follows add_X/get_X/list_X naming)
|
||||
"Judge": ("praisonaiagents.eval.judge", "Judge"),
|
||||
"JudgeConfig": ("praisonaiagents.eval.judge", "JudgeConfig"),
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
"""
|
||||
EvalPort adapter - framework-neutral interop for the eval package.
|
||||
|
||||
Converts PraisonAI's native eval data models to/from the EvalPort open spec
|
||||
(https://github.com/adhabnr-ux/evalport), so a suite built here can run against
|
||||
another tool's harness, and results can be diffed/aggregated across tools.
|
||||
|
||||
Mapping (1:1 with EvalPort's ``Suite`` / ``ResultSet`` concepts):
|
||||
|
||||
EvalPackage <-> EvalPort Suite (name/description/version + cases + thresholds)
|
||||
EvalCase <-> EvalPort test case (input / expected_output / grader refs)
|
||||
EvalReport -> EvalPort ResultSet (aggregate + per-case pass/score/latency)
|
||||
|
||||
Design: this module is intentionally **dependency-free**. It emits and consumes
|
||||
plain spec-shaped dicts (JSON), so the core SDK does not take on ``evalport-sdk``
|
||||
as a dependency. Callers who want schema validation can pass the returned dict to
|
||||
``openeval.validate.validate_suite()`` / ``validate_result_set()`` themselves.
|
||||
|
||||
Example:
|
||||
>>> from praisonaiagents.eval import EvalPackage, EvalCase
|
||||
>>> from praisonaiagents.eval import to_evalport, from_evalport, report_to_evalport
|
||||
>>> pkg = EvalPackage(name="support", cases=[
|
||||
... EvalCase(name="refund", input="refund policy?", expected="30 days")
|
||||
... ], thresholds={"accuracy": 0.9})
|
||||
>>> suite = to_evalport(pkg) # EvalPackage -> EvalPort Suite dict
|
||||
>>> pkg2 = from_evalport(suite) # EvalPort Suite dict -> EvalPackage
|
||||
>>> pkg2.name == pkg.name
|
||||
True
|
||||
"""
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from .package import EvalCase, EvalPackage, EvalReport
|
||||
|
||||
EVALPORT_SPEC_VERSION = "1.0"
|
||||
|
||||
|
||||
def _case_to_evalport(case: EvalCase) -> Dict[str, Any]:
|
||||
"""Map an ``EvalCase`` to an EvalPort test-case dict.
|
||||
|
||||
The native ``timeout_seconds`` is emitted as a dedicated top-level field so
|
||||
it never collides with a user-supplied ``metadata["timeout_seconds"]`` key,
|
||||
keeping round trips lossless for both.
|
||||
"""
|
||||
item: Dict[str, Any] = {
|
||||
"id": case.name,
|
||||
"input": case.input,
|
||||
}
|
||||
if case.expected is not None:
|
||||
item["expected_output"] = case.expected
|
||||
if case.criteria:
|
||||
item["graders"] = list(case.criteria)
|
||||
if case.timeout_seconds is not None:
|
||||
item["timeout_seconds"] = case.timeout_seconds
|
||||
metadata = dict(case.metadata or {})
|
||||
if metadata:
|
||||
item["metadata"] = metadata
|
||||
return item
|
||||
|
||||
|
||||
def _case_from_evalport(item: Dict[str, Any]) -> EvalCase:
|
||||
"""Map an EvalPort test-case dict to an ``EvalCase``.
|
||||
|
||||
Prefers the dedicated top-level ``timeout_seconds`` field; falls back to a
|
||||
``metadata["timeout_seconds"]`` value for suites emitted by other tools,
|
||||
without mutating the caller's metadata.
|
||||
"""
|
||||
metadata = dict(item.get("metadata") or {})
|
||||
if "timeout_seconds" in item:
|
||||
timeout = item["timeout_seconds"]
|
||||
else:
|
||||
timeout = metadata.get("timeout_seconds", 30.0)
|
||||
return EvalCase(
|
||||
name=item.get("id") or item.get("name") or "case",
|
||||
input=item.get("input", ""),
|
||||
expected=item.get("expected_output", item.get("expected")),
|
||||
criteria=list(item.get("graders") or item.get("criteria") or []),
|
||||
metadata=metadata,
|
||||
timeout_seconds=timeout,
|
||||
)
|
||||
|
||||
|
||||
def to_evalport(package: EvalPackage) -> Dict[str, Any]:
|
||||
"""Convert an ``EvalPackage`` to an EvalPort ``Suite`` dict.
|
||||
|
||||
Args:
|
||||
package: The native PraisonAI eval package.
|
||||
|
||||
Returns:
|
||||
A spec-shaped dict suitable for ``openeval.validate.validate_suite()``.
|
||||
"""
|
||||
return {
|
||||
"evalport_version": EVALPORT_SPEC_VERSION,
|
||||
"kind": "suite",
|
||||
"name": package.name,
|
||||
"description": package.description,
|
||||
"version": package.version,
|
||||
"cases": [_case_to_evalport(c) for c in package.cases],
|
||||
"thresholds": dict(package.thresholds or {}),
|
||||
"seed": package.seed,
|
||||
}
|
||||
|
||||
|
||||
def from_evalport(suite: Dict[str, Any]) -> EvalPackage:
|
||||
"""Convert an EvalPort ``Suite`` dict to a native ``EvalPackage``.
|
||||
|
||||
Args:
|
||||
suite: A spec-shaped EvalPort suite dict (e.g. from the Benchmark Hub).
|
||||
|
||||
Returns:
|
||||
An ``EvalPackage`` whose cases can be run via ``EvalSuite`` /
|
||||
``HarnessEvaluator`` as native ``EvalCase`` objects.
|
||||
"""
|
||||
cases: List[EvalCase] = [_case_from_evalport(c) for c in suite.get("cases", [])]
|
||||
return EvalPackage(
|
||||
name=suite.get("name", "suite"),
|
||||
description=suite.get("description", ""),
|
||||
version=suite.get("version", "1.0.0"),
|
||||
cases=cases,
|
||||
thresholds=dict(suite.get("thresholds") or {}),
|
||||
seed=suite.get("seed"),
|
||||
)
|
||||
|
||||
|
||||
def report_to_evalport(report: EvalReport) -> Dict[str, Any]:
|
||||
"""Convert an ``EvalReport`` to an EvalPort ``ResultSet`` dict.
|
||||
|
||||
Args:
|
||||
report: The aggregated report from running an eval package.
|
||||
|
||||
Returns:
|
||||
A spec-shaped dict suitable for
|
||||
``openeval.validate.validate_result_set()``.
|
||||
"""
|
||||
results: List[Dict[str, Any]] = []
|
||||
for r in report.results:
|
||||
item: Dict[str, Any] = {
|
||||
"case_id": r.case_name,
|
||||
"passed": r.passed,
|
||||
"score": r.score,
|
||||
"latency_ms": r.latency_ms,
|
||||
}
|
||||
if r.actual_output is not None:
|
||||
item["actual_output"] = r.actual_output
|
||||
if r.error is not None:
|
||||
item["error"] = r.error
|
||||
if r.criteria_scores:
|
||||
item["grader_scores"] = dict(r.criteria_scores)
|
||||
if r.record is not None:
|
||||
item["record"] = r.record
|
||||
results.append(item)
|
||||
|
||||
return {
|
||||
"evalport_version": EVALPORT_SPEC_VERSION,
|
||||
"kind": "result_set",
|
||||
"suite_name": report.package_name,
|
||||
"summary": {
|
||||
"total": report.total_cases,
|
||||
"passed": report.passed_cases,
|
||||
"failed": report.failed_cases,
|
||||
"pass_rate": report.pass_rate,
|
||||
"average_score": report.average_score,
|
||||
"thresholds_met": dict(report.thresholds_met or {}),
|
||||
},
|
||||
"results": results,
|
||||
}
|
||||
@@ -127,6 +127,12 @@ from .protocols import (
|
||||
FatalConfigError,
|
||||
classify_exit_reason,
|
||||
RestartLoopGuard,
|
||||
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
LifecycleRecord,
|
||||
classify_unclean_exit,
|
||||
RestartStoreProtocol,
|
||||
PersistentRestartLoopGuard,
|
||||
classify_resource_pressure,
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
FleetSupervisionPolicy,
|
||||
# Protocol version negotiation
|
||||
@@ -407,6 +413,12 @@ __all__ = [
|
||||
"FatalConfigError",
|
||||
"classify_exit_reason",
|
||||
"RestartLoopGuard",
|
||||
# Durable process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
"LifecycleRecord",
|
||||
"classify_unclean_exit",
|
||||
"RestartStoreProtocol",
|
||||
"PersistentRestartLoopGuard",
|
||||
"classify_resource_pressure",
|
||||
# Fleet-level crash-loop breaker for channel supervision (Issue #3840)
|
||||
"FleetSupervisionPolicy",
|
||||
# Protocol version negotiation
|
||||
|
||||
@@ -4803,6 +4803,308 @@ class RestartLoopGuard:
|
||||
self._events = []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Process-lifecycle record + unclean/OOM classification (Issue #4603)
|
||||
#
|
||||
# ``RestartLoopGuard`` above tracks the *rate* of restart-worthy boots in
|
||||
# memory, and ``classify_exit_reason`` maps a caught exception to an exit code.
|
||||
# Neither survives a ``SIGKILL``/OOM-kill — no in-process hook runs for those —
|
||||
# so a hosted gateway cannot tell on its next boot that its previous process
|
||||
# died uncleanly. These three pure primitives close that gap: a small durable
|
||||
# lifecycle *record* the runtime restamps on boot/shutdown, a pure predicate
|
||||
# that classifies "was the last exit clean?", and a restart-loop guard whose
|
||||
# events load from / flush to an injected durable store so the breaker survives
|
||||
# the very death it guards. All I/O — the ``/proc``/cgroup sampling and the
|
||||
# durable sentinel — lives in the ``praisonai-bot`` runtime; core stays
|
||||
# protocol + pure default policy, symmetric with the block above.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LifecycleRecord:
|
||||
"""Durable, boot-scoped snapshot of the gateway process's lifecycle.
|
||||
|
||||
Written by the runtime at boot (``phase="running"``) and on a graceful stop
|
||||
(``phase="exited"``), with a memory sample folded in by the heartbeat. On
|
||||
the *next* boot the runtime reads the prior record and runs
|
||||
:func:`classify_unclean_exit`: a record still marked ``running`` means the
|
||||
previous process never reached its graceful-stop path — an unclean exit,
|
||||
and (given a high last memory sample) a suspected OOM-kill.
|
||||
|
||||
Pure and JSON-round-trippable so both the durable store and the health
|
||||
surface share one shape; the wrapper owns persistence.
|
||||
|
||||
Attributes:
|
||||
phase: ``"running"`` | ``"draining"`` | ``"exited"`` — the last phase
|
||||
the process recorded for itself.
|
||||
exit_kind: ``"clean"`` | ``"unclean"`` | ``"unknown"`` — set by
|
||||
:func:`classify_unclean_exit` on the *next* boot (never
|
||||
``"unknown"``-treated-as-healthy downstream).
|
||||
suspected_oom: ``True`` when an unclean exit coincided with a high last
|
||||
memory sample (memory pressure at death).
|
||||
last_mem_fraction: Last sampled memory-budget fraction in ``[0, 1]``, or
|
||||
``None`` when the platform could not report it.
|
||||
restart_events: Persisted monotonic-independent wall-clock timestamps of
|
||||
restart-worthy boots, for :class:`PersistentRestartLoopGuard`.
|
||||
"""
|
||||
|
||||
phase: Literal["running", "draining", "exited"] = "running"
|
||||
exit_kind: Literal["clean", "unclean", "unknown"] = "unknown"
|
||||
suspected_oom: bool = False
|
||||
last_mem_fraction: Optional[float] = None
|
||||
restart_events: List[float] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to a JSON-serializable dict for the durable sentinel."""
|
||||
return {
|
||||
"phase": self.phase,
|
||||
"exit_kind": self.exit_kind,
|
||||
"suspected_oom": self.suspected_oom,
|
||||
"last_mem_fraction": self.last_mem_fraction,
|
||||
"restart_events": list(self.restart_events),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Optional[Mapping[str, Any]]) -> "LifecycleRecord":
|
||||
"""Rebuild from a persisted dict, tolerating partial/legacy shapes."""
|
||||
if not isinstance(data, Mapping):
|
||||
return cls()
|
||||
|
||||
phase = data.get("phase")
|
||||
if phase not in ("running", "draining", "exited"):
|
||||
phase = "running"
|
||||
exit_kind = data.get("exit_kind")
|
||||
if exit_kind not in ("clean", "unclean", "unknown"):
|
||||
exit_kind = "unknown"
|
||||
|
||||
frac = data.get("last_mem_fraction")
|
||||
try:
|
||||
frac_val: Optional[float] = None if frac is None else float(frac)
|
||||
except (TypeError, ValueError):
|
||||
frac_val = None
|
||||
|
||||
raw_events = data.get("restart_events") or []
|
||||
events: List[float] = []
|
||||
if isinstance(raw_events, (list, tuple)):
|
||||
for item in raw_events:
|
||||
try:
|
||||
events.append(float(item))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
|
||||
return cls(
|
||||
phase=phase, # type: ignore[arg-type]
|
||||
exit_kind=exit_kind, # type: ignore[arg-type]
|
||||
suspected_oom=bool(data.get("suspected_oom", False)),
|
||||
last_mem_fraction=frac_val,
|
||||
restart_events=events,
|
||||
)
|
||||
|
||||
|
||||
def classify_unclean_exit(
|
||||
prev: Optional[LifecycleRecord],
|
||||
*,
|
||||
oom_fraction: float = 0.9,
|
||||
) -> LifecycleRecord:
|
||||
"""Classify the previous boot's exit and return a fresh boot record (pure).
|
||||
|
||||
Given the record persisted by the *previous* process, decide whether that
|
||||
process exited cleanly and stamp a new ``phase="running"`` record for *this*
|
||||
boot. Side-effect free so it is provable in isolation, mirroring
|
||||
:func:`classify_exit_reason`.
|
||||
|
||||
Rules:
|
||||
|
||||
* No prior record ⇒ first boot / fresh store: ``exit_kind="unknown"`` (the
|
||||
caller must never treat ``"unknown"`` as healthy, but there is nothing to
|
||||
resume).
|
||||
* Prior ``phase == "exited"`` ⇒ the previous process reached its graceful
|
||||
stop: ``exit_kind="clean"``.
|
||||
* Prior ``phase in ("running", "draining")`` ⇒ the previous process died
|
||||
before recording a clean exit (SIGKILL/OOM/segfault/orchestrator kill):
|
||||
``exit_kind="unclean"``, and ``suspected_oom`` when its last memory sample
|
||||
was at/above ``oom_fraction`` of the budget.
|
||||
|
||||
The returned record carries forward the prior ``restart_events`` so the
|
||||
persistent restart-loop guard sees the full cross-boot history.
|
||||
|
||||
Args:
|
||||
prev: The previous process's persisted :class:`LifecycleRecord`, or
|
||||
``None`` on a fresh store.
|
||||
oom_fraction: Memory-budget fraction at/above which an unclean exit is
|
||||
flagged as a suspected OOM-kill.
|
||||
|
||||
Returns:
|
||||
A fresh ``phase="running"`` :class:`LifecycleRecord` for this boot.
|
||||
"""
|
||||
carried = list(prev.restart_events) if prev is not None else []
|
||||
if prev is None:
|
||||
return LifecycleRecord(
|
||||
phase="running", exit_kind="unknown", restart_events=carried
|
||||
)
|
||||
if prev.phase == "exited":
|
||||
return LifecycleRecord(
|
||||
phase="running", exit_kind="clean", restart_events=carried
|
||||
)
|
||||
# Still "running"/"draining" in the prior record ⇒ never reached graceful stop.
|
||||
frac = prev.last_mem_fraction
|
||||
suspected_oom = frac is not None and frac >= oom_fraction
|
||||
return LifecycleRecord(
|
||||
phase="running",
|
||||
exit_kind="unclean",
|
||||
suspected_oom=suspected_oom,
|
||||
last_mem_fraction=frac,
|
||||
restart_events=carried,
|
||||
)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class RestartStoreProtocol(Protocol):
|
||||
"""Durable-store contract for cross-boot restart timestamps (Issue #4603).
|
||||
|
||||
The pure decision (:class:`PersistentRestartLoopGuard`) lives in core; the
|
||||
wrapper injects a concrete store that reads/writes the timestamps to a small
|
||||
sentinel file. Both methods are best-effort: an implementation that cannot
|
||||
reach its backing store returns ``[]`` from :meth:`load_events` and silently
|
||||
drops :meth:`save_events`, so the breaker degrades to in-memory behaviour
|
||||
rather than crashing the gateway it protects.
|
||||
"""
|
||||
|
||||
def load_events(self) -> List[float]:
|
||||
"""Return the persisted restart timestamps (``[]`` when unavailable)."""
|
||||
...
|
||||
|
||||
def save_events(self, events: List[float]) -> None:
|
||||
"""Persist the restart timestamps (best-effort, never raises)."""
|
||||
...
|
||||
|
||||
|
||||
class PersistentRestartLoopGuard(RestartLoopGuard):
|
||||
"""A :class:`RestartLoopGuard` whose events survive a process restart.
|
||||
|
||||
The in-memory guard is reconstructed fresh on every boot, so a persisted
|
||||
turn that hard-crashes the *whole* process (OOM, segfault) can drive an
|
||||
infinite ``supervisor → boot → auto-resume → crash`` loop the breaker never
|
||||
sees. This subclass loads its event history from an injected
|
||||
:class:`RestartStoreProtocol` at construction and flushes back on every
|
||||
mutation, so the breaker observes the true cross-boot restart rate and can
|
||||
trip on a crash cycle *slower* than a single in-process window.
|
||||
|
||||
The window defaults to one hour (vs. the in-process 60s) because a
|
||||
cross-boot crash loop is paced by process (re)start latency, not by an
|
||||
in-loop retry. Pure decision preserved: all persistence is delegated to the
|
||||
injected store, so the trip logic remains provable in isolation.
|
||||
|
||||
Example::
|
||||
|
||||
guard = PersistentRestartLoopGuard(store, max_restarts=3,
|
||||
window_seconds=3600)
|
||||
if guard.record(now=time.time()):
|
||||
# too many restart-interrupted boots — stop auto-resuming the turn
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: RestartStoreProtocol,
|
||||
*,
|
||||
max_restarts: int = 3,
|
||||
window_seconds: float = 3600.0,
|
||||
):
|
||||
super().__init__(max_restarts=max_restarts, window_seconds=window_seconds)
|
||||
self._store = store
|
||||
try:
|
||||
loaded = store.load_events()
|
||||
except Exception:
|
||||
loaded = []
|
||||
if loaded:
|
||||
self._events = [float(t) for t in loaded]
|
||||
|
||||
def _flush(self) -> None:
|
||||
try:
|
||||
self._store.save_events(list(self._events))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def record(self, now: float) -> bool:
|
||||
"""Record a restart at wall-clock ``now`` and flush to the store.
|
||||
|
||||
Unlike the base class this expects a *wall-clock* timestamp
|
||||
(``time.time()``), because a monotonic clock resets across process
|
||||
boots and would make persisted events meaningless.
|
||||
"""
|
||||
tripped = super().record(now)
|
||||
self._flush()
|
||||
return tripped
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Clear the history in memory *and* in the durable store."""
|
||||
super().reset()
|
||||
self._flush()
|
||||
|
||||
|
||||
def classify_resource_pressure(
|
||||
*,
|
||||
mem_fraction: Optional[float] = None,
|
||||
disk_free_mb: Optional[float] = None,
|
||||
mem_elevated: float = 0.75,
|
||||
mem_critical: float = 0.9,
|
||||
disk_min_free_mb: float = 512.0,
|
||||
) -> Dict[str, str]:
|
||||
"""Classify coarse memory + disk pressure for the health surface (pure).
|
||||
|
||||
A tiny, redaction-safe classifier that folds the wrapper's ``/proc``/cgroup
|
||||
memory-budget fraction and free-disk sample into a closed ``ok`` /
|
||||
``elevated`` / ``critical`` / ``unknown`` vocabulary. Lives in core so the
|
||||
SDK and the bot classify identically; the wrapper owns the sampling.
|
||||
|
||||
``unknown`` is returned when a signal is missing and — per the issue — must
|
||||
never be treated as healthy by callers (it is deliberately *not* ``ok``).
|
||||
|
||||
Args:
|
||||
mem_fraction: Anonymous-RSS / memory-budget fraction in ``[0, ∞)``, or
|
||||
``None`` when the platform has no cgroup limit to report against.
|
||||
disk_free_mb: Free disk headroom in MiB for the gateway's state dir, or
|
||||
``None`` when it could not be sampled.
|
||||
mem_elevated: Fraction at/above which memory is ``"elevated"``.
|
||||
mem_critical: Fraction at/above which memory is ``"critical"``.
|
||||
disk_min_free_mb: Free-MiB floor below which disk is ``"critical"``;
|
||||
``"elevated"`` below twice that.
|
||||
|
||||
Returns:
|
||||
``{"memory": <level>, "disk": <level>}`` with values drawn from the
|
||||
closed vocabulary above.
|
||||
"""
|
||||
|
||||
def _mem(frac: Optional[float]) -> str:
|
||||
if frac is None:
|
||||
return "unknown"
|
||||
try:
|
||||
f = float(frac)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
if f >= mem_critical:
|
||||
return "critical"
|
||||
if f >= mem_elevated:
|
||||
return "elevated"
|
||||
return "ok"
|
||||
|
||||
def _disk(free_mb: Optional[float]) -> str:
|
||||
if free_mb is None:
|
||||
return "unknown"
|
||||
try:
|
||||
mb = float(free_mb)
|
||||
except (TypeError, ValueError):
|
||||
return "unknown"
|
||||
if mb < disk_min_free_mb:
|
||||
return "critical"
|
||||
if mb < disk_min_free_mb * 2:
|
||||
return "elevated"
|
||||
return "ok"
|
||||
|
||||
return {"memory": _mem(mem_fraction), "disk": _disk(disk_free_mb)}
|
||||
|
||||
|
||||
class FleetSupervisionPolicy:
|
||||
"""Pure fleet-level crash-loop breaker for channel supervision (Issue #3840).
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class InvocationContext:
|
||||
tool_name: Optional[str] = None
|
||||
model_name: Optional[str] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
cancel_event: Optional[Any] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -53,6 +53,11 @@ class HookRegistry:
|
||||
}
|
||||
self._enabled = enabled
|
||||
self._global_timeout = 60.0
|
||||
# Guard mutation/iteration of the per-event hook lists. The default
|
||||
# registry is a cross-agent process-wide singleton, so a plugin
|
||||
# (un)registering a hook on one thread can race a get_hooks() iteration
|
||||
# on another. RLock keeps re-entrant calls (register -> register_*) safe.
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
@@ -74,7 +79,8 @@ class HookRegistry:
|
||||
Returns:
|
||||
The hook ID
|
||||
"""
|
||||
self._hooks[hook.event].append(hook)
|
||||
with self._lock:
|
||||
self._hooks[hook.event].append(hook)
|
||||
logger.debug(f"Registered hook '{hook.name}' for event '{hook.event.value}'")
|
||||
return hook.id
|
||||
|
||||
@@ -214,12 +220,13 @@ class HookRegistry:
|
||||
Returns:
|
||||
True if found and removed, False otherwise
|
||||
"""
|
||||
for event in HookEvent:
|
||||
for i, hook in enumerate(self._hooks[event]):
|
||||
if hook.id == hook_id:
|
||||
self._hooks[event].pop(i)
|
||||
logger.debug(f"Unregistered hook '{hook_id}'")
|
||||
return True
|
||||
with self._lock:
|
||||
for event in HookEvent:
|
||||
for i, hook in enumerate(self._hooks[event]):
|
||||
if hook.id == hook_id:
|
||||
self._hooks[event].pop(i)
|
||||
logger.debug(f"Unregistered hook '{hook_id}'")
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_hooks(
|
||||
@@ -240,7 +247,11 @@ class HookRegistry:
|
||||
if not self._enabled:
|
||||
return []
|
||||
|
||||
hooks = self._hooks.get(event, [])
|
||||
# Snapshot under the lock so callers never iterate the live, mutable
|
||||
# list (a concurrent unregister()/clear() could otherwise raise
|
||||
# "list changed size during iteration" or skip/duplicate a hook).
|
||||
with self._lock:
|
||||
hooks = list(self._hooks.get(event, []))
|
||||
|
||||
if target is None:
|
||||
return [h for h in hooks if h.enabled]
|
||||
@@ -258,11 +269,12 @@ class HookRegistry:
|
||||
Args:
|
||||
event: Optional event to clear hooks for
|
||||
"""
|
||||
if event is None:
|
||||
for e in HookEvent:
|
||||
self._hooks[e] = []
|
||||
else:
|
||||
self._hooks[event] = []
|
||||
with self._lock:
|
||||
if event is None:
|
||||
for e in HookEvent:
|
||||
self._hooks[e] = []
|
||||
else:
|
||||
self._hooks[event] = []
|
||||
|
||||
def list_hooks(self) -> Dict[str, List[Dict]]:
|
||||
"""
|
||||
@@ -290,20 +302,22 @@ class HookRegistry:
|
||||
|
||||
def enable_hook(self, hook_id: str) -> bool:
|
||||
"""Enable a specific hook."""
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = True
|
||||
return True
|
||||
with self._lock:
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = True
|
||||
return True
|
||||
return False
|
||||
|
||||
def disable_hook(self, hook_id: str) -> bool:
|
||||
"""Disable a specific hook."""
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = False
|
||||
return True
|
||||
with self._lock:
|
||||
for event in HookEvent:
|
||||
for hook in self._hooks[event]:
|
||||
if hook.id == hook_id:
|
||||
hook.enabled = False
|
||||
return True
|
||||
return False
|
||||
|
||||
def set_global_timeout(self, timeout: float):
|
||||
|
||||
@@ -354,13 +354,18 @@ class HookRunner:
|
||||
"""Execute a function hook."""
|
||||
if hook.func is None:
|
||||
duration = (time.time() - start_time) * 1000
|
||||
# Fail closed: a hook with no callable cannot produce a verdict, so a
|
||||
# gating event (BEFORE_TOOL) must block rather than silently allow.
|
||||
# Without an ``output`` here ``is_blocked`` returned False and the tool
|
||||
# ran — the same fail-open the exception/timeout paths already fix.
|
||||
return HookExecutionResult(
|
||||
hook_id=hook.id,
|
||||
hook_name=hook.name or "unknown",
|
||||
event=event,
|
||||
success=False,
|
||||
error="Hook function is None",
|
||||
duration_ms=duration
|
||||
duration_ms=duration,
|
||||
output=HookResult(decision="deny", reason=f"Hook '{hook.name}' has no callable")
|
||||
)
|
||||
|
||||
timeout = hook.timeout or self._default_timeout
|
||||
@@ -450,6 +455,11 @@ class HookRunner:
|
||||
exit_code: int
|
||||
) -> HookResult:
|
||||
"""Parse command output into HookResult."""
|
||||
# Whether the process exited with a code we understand. Any other code
|
||||
# (127 command-not-found, 126 not-executable, etc.) means the hook could
|
||||
# not render a trustworthy verdict, so JSON stdout must not override it.
|
||||
expected_exit = exit_code in (EXIT_CODE_SUCCESS, EXIT_CODE_BLOCKING_ERROR)
|
||||
|
||||
# Try to parse JSON from stdout
|
||||
if stdout.strip():
|
||||
try:
|
||||
@@ -459,8 +469,22 @@ class HookRunner:
|
||||
if isinstance(data, str):
|
||||
data = json.loads(data)
|
||||
|
||||
decision = data.get("decision", "allow")
|
||||
# Fail closed: valid JSON with an ``allow`` decision must not
|
||||
# bypass an unexpected exit code. A gating hook (BEFORE_TOOL)
|
||||
# that prints allow-JSON but then crashes (e.g. exit 127) has
|
||||
# not actually approved the call, so deny instead of allowing.
|
||||
if decision == "allow" and not expected_exit:
|
||||
return HookResult(
|
||||
decision="deny",
|
||||
reason=(
|
||||
f"Hook exited with unexpected code {exit_code} "
|
||||
f"after emitting an allow decision: "
|
||||
f"{stderr.strip() or 'no stderr'}"
|
||||
)
|
||||
)
|
||||
return HookResult(
|
||||
decision=data.get("decision", "allow"),
|
||||
decision=decision,
|
||||
reason=data.get("reason"),
|
||||
modified_input=data.get("modified_input"),
|
||||
additional_context=data.get("additional_context"),
|
||||
@@ -481,9 +505,19 @@ class HookRunner:
|
||||
reason=stderr.strip() or stdout.strip() or "Blocked by hook"
|
||||
)
|
||||
else:
|
||||
# Fail closed: any *unexpected* exit code (e.g. 127 command-not-found,
|
||||
# 126 not-executable, or any other non-zero, non-blocking code) means
|
||||
# the hook could not render a verdict. Treating that as ``allow`` would
|
||||
# silently bypass a gating hook (BEFORE_TOOL). Deny so the gate matches
|
||||
# the fail-closed posture of the exception/timeout paths and
|
||||
# GuardrailChain. Emitting a real HookResult (decision + reason) also
|
||||
# keeps ``get_blocking_reason`` informative for the audit trail.
|
||||
return HookResult(
|
||||
decision="allow",
|
||||
additional_context=f"Warning: {stderr.strip() or stdout.strip()}"
|
||||
decision="deny",
|
||||
reason=(
|
||||
f"Hook exited with unexpected code {exit_code}: "
|
||||
f"{stderr.strip() or stdout.strip() or 'no output'}"
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -219,6 +219,10 @@ def __getattr__(name):
|
||||
from .error_classifier import classify_error
|
||||
_lazy_cache[name] = classify_error
|
||||
return classify_error
|
||||
elif name == "resolve_provider":
|
||||
from .model_providers import resolve_provider
|
||||
_lazy_cache[name] = resolve_provider
|
||||
return resolve_provider
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
@@ -278,5 +282,7 @@ __all__ = [
|
||||
"sanitize_text",
|
||||
# Error Classification (G5)
|
||||
"ErrorCategory",
|
||||
"classify_error"
|
||||
"classify_error",
|
||||
# Model-name → provider-id resolution
|
||||
"resolve_provider",
|
||||
]
|
||||
@@ -4580,6 +4580,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
execute_tool_fn: Optional[Callable] = None,
|
||||
max_tool_calls_per_turn: int = 10, # Loop guardrails
|
||||
stream: bool = True,
|
||||
parallel_tool_calls: bool = False,
|
||||
**kwargs
|
||||
) -> str:
|
||||
"""Async version of get_response with identical functionality."""
|
||||
@@ -4606,6 +4607,36 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
except Exception as _steer_err:
|
||||
logging.debug(f"Steering drain failed (ignored): {_steer_err}")
|
||||
|
||||
async def _dispatch_tool_batch(dispatch_specs, iteration_index):
|
||||
"""Run a batch of (function_name, arguments, tool_call_id) dispatches.
|
||||
|
||||
When ``parallel_tool_calls`` is set, the independent tool coroutines
|
||||
are awaited concurrently via ``asyncio.gather`` (mirroring the sync
|
||||
path's ``create_tool_call_executor(parallel=True)``); otherwise they
|
||||
run one-at-a-time. Results are always returned in the original call
|
||||
order so the existing message-building loop stays unchanged.
|
||||
"""
|
||||
if parallel_tool_calls and len(dispatch_specs) > 1:
|
||||
# return_exceptions=True so a single failing tool does not cancel
|
||||
# its still-in-flight siblings and orphan their coroutines (the
|
||||
# exact leak class this PR fixes). We then re-raise the first
|
||||
# error after every sibling has settled, preserving the
|
||||
# sequential path's fail-fast propagation semantics.
|
||||
gathered = await asyncio.gather(*(
|
||||
_dispatch_async_tool(execute_tool_fn, fn, args, tc_id, iteration_index)
|
||||
for (fn, args, tc_id) in dispatch_specs
|
||||
), return_exceptions=True)
|
||||
for item in gathered:
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
return gathered
|
||||
results = []
|
||||
for (fn, args, tc_id) in dispatch_specs:
|
||||
results.append(await _dispatch_async_tool(
|
||||
execute_tool_fn, fn, args, tc_id, iteration_index
|
||||
))
|
||||
return results
|
||||
|
||||
try:
|
||||
import litellm
|
||||
logging.debug(f"Getting async response from {self.model}")
|
||||
@@ -4789,6 +4820,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
tool_calls = tool_calls[:remaining_calls]
|
||||
logging.warning(f"Limiting batch to {remaining_calls} tool calls to stay within limit of {max_tool_calls_per_turn}.")
|
||||
|
||||
# Pre-resolve each tool call, then dispatch the valid ones
|
||||
# as a batch (concurrently when parallel_tool_calls is set).
|
||||
_parsed_calls = []
|
||||
_dispatch_specs = []
|
||||
for tool_call in tool_calls:
|
||||
function_name, arguments, tool_call_id = self._extract_tool_call_info(tool_call)
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
@@ -4796,13 +4831,10 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
continue
|
||||
logging.debug(f"[RESPONSES_API_ASYNC] Executing tool {function_name}")
|
||||
tool_result = await _dispatch_async_tool(
|
||||
execute_tool_fn,
|
||||
function_name,
|
||||
arguments,
|
||||
tool_call_id,
|
||||
iteration_count,
|
||||
)
|
||||
_parsed_calls.append((function_name, arguments, tool_call_id))
|
||||
_dispatch_specs.append((function_name, arguments, tool_call_id))
|
||||
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
|
||||
for (function_name, arguments, tool_call_id), tool_result in zip(_parsed_calls, _batch_results):
|
||||
tool_call_count += 1 # Increment tool call counter for guardrails
|
||||
accumulated_tool_results.append(tool_result)
|
||||
|
||||
@@ -5041,6 +5073,11 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# flushed only AFTER every tool reply for this assistant
|
||||
# turn, keeping all tool replies consecutive (provider contract).
|
||||
_deferred_media_followups: List[Dict[str, Any]] = []
|
||||
# First pass: parse/validate every call and record a plan so
|
||||
# the valid dispatches can be batched (concurrently when
|
||||
# parallel_tool_calls is set) while preserving call order.
|
||||
_call_plan = [] # ('error', msg) | ('call', fn, args, tc_id)
|
||||
_dispatch_specs = []
|
||||
for tool_call in tool_calls:
|
||||
# Handle both object and dict access patterns
|
||||
is_ollama = self._is_ollama_provider()
|
||||
@@ -5048,20 +5085,24 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
if self._tool_arguments_parse_failed(arguments):
|
||||
# Do NOT dispatch with {}: surface a retryable error.
|
||||
messages.append(self._tool_parse_error_message(function_name, tool_call_id))
|
||||
_call_plan.append(('error', self._tool_parse_error_message(function_name, tool_call_id)))
|
||||
continue
|
||||
|
||||
# Validate and filter arguments for Ollama provider
|
||||
if is_ollama and tools:
|
||||
arguments = self._validate_and_filter_ollama_arguments(function_name, arguments, tools)
|
||||
|
||||
tool_result = await _dispatch_async_tool(
|
||||
execute_tool_fn,
|
||||
function_name,
|
||||
arguments,
|
||||
tool_call_id,
|
||||
iteration_count,
|
||||
)
|
||||
_call_plan.append(('call', function_name, arguments, tool_call_id))
|
||||
_dispatch_specs.append((function_name, arguments, tool_call_id))
|
||||
|
||||
_batch_results = await _dispatch_tool_batch(_dispatch_specs, iteration_count)
|
||||
_result_iter = iter(_batch_results)
|
||||
for _plan in _call_plan:
|
||||
if _plan[0] == 'error':
|
||||
messages.append(_plan[1])
|
||||
continue
|
||||
_, function_name, arguments, tool_call_id = _plan
|
||||
tool_result = next(_result_iter)
|
||||
tool_call_count += 1 # Increment tool call counter for guardrails
|
||||
tool_results.append(tool_result) # Store the result
|
||||
accumulated_tool_results.append(tool_result) # Accumulate across iterations
|
||||
@@ -5746,10 +5787,39 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""Set the current agent name for token tracking."""
|
||||
self.current_agent_name = agent_name
|
||||
|
||||
def _resolve_openai_compatible_model(self) -> str:
|
||||
"""Route a bare model name through the OpenAI-compatible client.
|
||||
|
||||
When a custom ``base_url`` is set but the model has no ``provider/``
|
||||
prefix (e.g. ``deepseek-v4-flash`` against an OpenAI-compatible host),
|
||||
LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
|
||||
Prefixing ``openai/`` makes LiteLLM use its OpenAI Chat Completions
|
||||
client against the supplied ``base_url``. Anthropic/Gemini/Ollama models
|
||||
are left untouched so their native adapters still apply.
|
||||
|
||||
The effective base_url is either the instance ``base_url`` or one
|
||||
injected by a subscription auth provider (e.g. qwen-cli/codex resolve a
|
||||
Chat-Completions host at request time). Both paths need routing so a
|
||||
bare model under subscription auth does not reach LiteLLM unprefixed.
|
||||
"""
|
||||
model = self.model
|
||||
if not isinstance(model, str) or "/" in model:
|
||||
return model
|
||||
effective_base_url = self.base_url
|
||||
if not effective_base_url:
|
||||
creds = self._resolve_subscription_creds()
|
||||
if creds and getattr(creds, "base_url", None):
|
||||
effective_base_url = creds.base_url
|
||||
if not effective_base_url:
|
||||
return model
|
||||
if self._detect_provider() != "openai":
|
||||
return model
|
||||
return f"openai/{model}"
|
||||
|
||||
def _build_completion_params(self, **override_params) -> Dict[str, Any]:
|
||||
"""Build parameters for litellm completion calls with all necessary config"""
|
||||
params = {
|
||||
"model": self.model,
|
||||
"model": self._resolve_openai_compatible_model(),
|
||||
}
|
||||
|
||||
# Add optional parameters if they exist
|
||||
@@ -6061,7 +6131,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
- tools → ``tools`` (Responses API uses same function schema)
|
||||
- temperature, max_tokens, etc. → direct pass-through
|
||||
"""
|
||||
params: Dict[str, Any] = {"model": self.model}
|
||||
params: Dict[str, Any] = {"model": self._resolve_openai_compatible_model()}
|
||||
|
||||
# ── Extract system instructions ─────────────────────────────────
|
||||
instructions = None
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Model-name → provider-id resolution.
|
||||
|
||||
Maps a bare model name (e.g. ``claude-sonnet-4-6``, ``llama-3.1-70b``) to its
|
||||
canonical provider id (``anthropic``, ``groq``, ...). This is the single source
|
||||
of truth for provider inference so callers no longer hand-code a 3-branch
|
||||
``if/elif`` that silently ignores every other vendor.
|
||||
|
||||
Third parties add a vendor by publishing an entry point under the
|
||||
``praisonaiagents.model_providers`` group. Each entry point loads a callable
|
||||
``(model_name_lowercased: str) -> bool``; the entry-point *name* is the provider
|
||||
id returned on a match. Registered matchers take precedence over the built-ins,
|
||||
so a plugin can also override built-in detection.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, Dict, Optional
|
||||
|
||||
# Built-in model-name → provider matchers. Kept intentionally small and data
|
||||
# driven; extend via the entry-point group rather than editing callers.
|
||||
_BUILTINS: Dict[str, Callable[[str], bool]] = {
|
||||
# ``claude`` is matched as a substring so cloud-vendored forms
|
||||
# (``us.anthropic.claude-3-5-sonnet``, ``anthropic.claude-...``) still
|
||||
# resolve, preserving the pre-refactor behaviour that keyed off
|
||||
# ``'claude' in model``.
|
||||
"anthropic": lambda m: "claude" in m,
|
||||
"openai": lambda m: m.startswith(("gpt-", "o1-", "o3-", "o4-", "chatgpt-")),
|
||||
"google": lambda m: m.startswith(("gemini-", "gemma-")),
|
||||
"groq": lambda m: m.startswith(("llama-", "mixtral-", "gemma2-")) and "groq" in m,
|
||||
"cohere": lambda m: m.startswith(("command-", "c4ai-")),
|
||||
"mistral": lambda m: m.startswith(("mistral-", "codestral-", "open-mistral-", "open-mixtral-")),
|
||||
"ollama": lambda m: m.startswith("ollama/"),
|
||||
"deepseek": lambda m: m.startswith("deepseek-"),
|
||||
"xai": lambda m: m.startswith("grok-"),
|
||||
"perplexity": lambda m: m.startswith(("pplx-", "sonar-")),
|
||||
}
|
||||
|
||||
|
||||
def _entry_point_matchers():
|
||||
"""Yield (provider_id, matcher) pairs registered by third-party plugins.
|
||||
|
||||
Isolated so a broken plugin cannot take down provider resolution: any
|
||||
import/metadata failure yields nothing and falls back to built-ins.
|
||||
"""
|
||||
try:
|
||||
from importlib.metadata import entry_points
|
||||
except Exception: # pragma: no cover - importlib.metadata always present on 3.8+
|
||||
return
|
||||
|
||||
try:
|
||||
eps = entry_points(group="praisonaiagents.model_providers")
|
||||
except TypeError:
|
||||
# Python 3.9 returns a dict-like object without the group kwarg.
|
||||
eps = entry_points().get("praisonaiagents.model_providers", [])
|
||||
except Exception:
|
||||
return
|
||||
|
||||
for ep in eps:
|
||||
try:
|
||||
matcher = ep.load()
|
||||
except Exception:
|
||||
continue
|
||||
yield ep.name, matcher
|
||||
|
||||
|
||||
def resolve_provider(model_name: str) -> Optional[str]:
|
||||
"""Return the canonical provider id for ``model_name``, or None.
|
||||
|
||||
Precedence:
|
||||
1. Explicit litellm-style prefix wins (``openai/gpt-4o`` -> ``openai``).
|
||||
2. Entry-point registered matchers (third-party vendors / overrides).
|
||||
3. Built-in matchers.
|
||||
"""
|
||||
if not model_name:
|
||||
return None
|
||||
|
||||
# 1. Explicit provider prefix.
|
||||
if "/" in model_name:
|
||||
return model_name.split("/", 1)[0].lower()
|
||||
|
||||
lower = model_name.lower()
|
||||
|
||||
# 2. Third-party / override matchers.
|
||||
for provider, matcher in _entry_point_matchers():
|
||||
try:
|
||||
if matcher(lower):
|
||||
return provider
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
# 3. Built-ins.
|
||||
for provider, matcher in _BUILTINS.items():
|
||||
if matcher(lower):
|
||||
return provider
|
||||
|
||||
return None
|
||||
@@ -29,7 +29,7 @@ Memory Providers:
|
||||
- RulesManager: Rules/instructions file management
|
||||
- AutoMemory: Automatic memory extraction from conversations
|
||||
- WorkflowManager: Multi-step workflow execution
|
||||
- HooksManager: Pre/post operation hooks
|
||||
- HooksManager: Standalone, opt-in pre/post operation hooks (call .execute() yourself; not auto-wired into the agent pipeline)
|
||||
- DocsManager: Documentation context management
|
||||
- MCPConfigManager: MCP server configuration management
|
||||
"""
|
||||
|
||||
@@ -4,6 +4,17 @@ Cascade Hooks System for PraisonAI Agents.
|
||||
Provides event hooks similar to Windsurf's Cascade Hooks,
|
||||
allowing custom actions before/after agent operations.
|
||||
|
||||
.. note::
|
||||
``HooksManager`` is a **standalone, opt-in utility**: it is NOT auto-wired
|
||||
into the agent/tool execution pipeline. You must construct it and call
|
||||
:meth:`HooksManager.execute` yourself at the point where you want a hook to
|
||||
run (e.g. in a custom tool or wrapper). For hooks that fire automatically
|
||||
around agent tool/LLM calls, use the live ``praisonaiagents.hooks`` package
|
||||
instead: register handlers on a ``HookRegistry`` (e.g. via
|
||||
``@registry.on(HookEvent.BEFORE_TOOL)``) and pass that registry to the
|
||||
``Agent`` through its ``hooks`` argument (the ``Agent`` builds the
|
||||
``HookRunner`` internally).
|
||||
|
||||
Features:
|
||||
- Pre/post hooks for read, write, command, prompt operations
|
||||
- JSON configuration (.praisonai/hooks.json)
|
||||
@@ -87,6 +98,12 @@ class HooksManager:
|
||||
Hooks are configured in .praisonai/hooks.json and can execute
|
||||
scripts before/after agent operations.
|
||||
|
||||
This is a standalone, opt-in utility. It does not automatically
|
||||
intercept agent file/command/prompt operations; the caller must
|
||||
invoke :meth:`execute` at the desired point. For hooks that fire
|
||||
automatically around agent tool/LLM calls, use the
|
||||
``praisonaiagents.hooks`` package instead.
|
||||
|
||||
Example:
|
||||
```python
|
||||
hooks = HooksManager(workspace_path="/path/to/project")
|
||||
|
||||
@@ -591,7 +591,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
|
||||
for improvement in extracted.get("improvements", []):
|
||||
if improvement and "improvements" in self._stores:
|
||||
self.capture_improvement(improvement, source="auto_extraction")
|
||||
self.capture_improvement(improvement, area="auto_extraction")
|
||||
stored["improvements"] += 1
|
||||
|
||||
return {
|
||||
@@ -623,7 +623,7 @@ Return empty arrays if nothing is found for a category."""
|
||||
Dictionary with extracted learnings
|
||||
"""
|
||||
if not messages:
|
||||
return {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
return {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}}
|
||||
|
||||
# Build conversation text
|
||||
conversation_text = "\n".join([
|
||||
@@ -640,12 +640,14 @@ Extract the following (if present):
|
||||
1. USER PREFERENCES: Things the user likes, dislikes, prefers, or their style
|
||||
2. INSIGHTS: Observations about the user's domain, work, or context
|
||||
3. PATTERNS: Recurring behaviors or request patterns
|
||||
4. IMPROVEMENTS: Concrete proposals to improve future responses
|
||||
|
||||
Return JSON:
|
||||
{{
|
||||
"persona": ["preference 1", "preference 2"],
|
||||
"insights": ["insight 1", "insight 2"],
|
||||
"patterns": ["pattern 1", "pattern 2"]
|
||||
"patterns": ["pattern 1", "pattern 2"],
|
||||
"improvements": ["improvement 1", "improvement 2"]
|
||||
}}
|
||||
|
||||
Only include items that are clearly evident from the conversation.
|
||||
@@ -676,9 +678,9 @@ Return empty arrays if nothing is found for a category."""
|
||||
elif isinstance(response, dict):
|
||||
extracted = response
|
||||
else:
|
||||
extracted = {"persona": [], "insights": [], "patterns": []}
|
||||
extracted = {"persona": [], "insights": [], "patterns": [], "improvements": []}
|
||||
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0}
|
||||
stored = {"persona": 0, "insights": 0, "patterns": 0, "improvements": 0}
|
||||
|
||||
for preference in extracted.get("persona", []):
|
||||
if preference and "persona" in self._stores:
|
||||
@@ -695,10 +697,16 @@ Return empty arrays if nothing is found for a category."""
|
||||
self.capture_pattern(pattern, pattern_type="auto_extracted")
|
||||
stored["patterns"] += 1
|
||||
|
||||
for improvement in extracted.get("improvements", []):
|
||||
if improvement and "improvements" in self._stores:
|
||||
self.capture_improvement(improvement, area="auto_extraction")
|
||||
stored["improvements"] += 1
|
||||
|
||||
return {
|
||||
"persona": extracted.get("persona", []),
|
||||
"insights": extracted.get("insights", []),
|
||||
"patterns": extracted.get("patterns", []),
|
||||
"improvements": extracted.get("improvements", []),
|
||||
"stored": stored,
|
||||
}
|
||||
|
||||
|
||||
@@ -79,11 +79,17 @@ def is_due(
|
||||
try:
|
||||
target = datetime.fromisoformat(sched.at)
|
||||
if target.tzinfo is None:
|
||||
target = target.replace(
|
||||
tzinfo=resolve_schedule_timezone(
|
||||
sched.tz or default_timezone
|
||||
)
|
||||
)
|
||||
# A naive ``at`` reflects the user's wall clock (they typed it
|
||||
# from local time), so interpret it in the schedule's timezone:
|
||||
# the explicit tz / PRAISONAI_SCHEDULE_TIMEZONE when set,
|
||||
# otherwise the local zone rather than UTC — a naive string in a
|
||||
# non-UTC zone would otherwise fire an hour (or more) off.
|
||||
tz = sched.tz or default_timezone
|
||||
if tz or os.environ.get("PRAISONAI_SCHEDULE_TIMEZONE"):
|
||||
tzinfo = resolve_schedule_timezone(tz)
|
||||
else:
|
||||
tzinfo = datetime.now().astimezone().tzinfo
|
||||
target = target.replace(tzinfo=tzinfo)
|
||||
# Evaluate against the caller-supplied ``now`` (not wall-clock) so
|
||||
# one-shot jobs are deterministic and consistent with every/cron.
|
||||
return now >= target.timestamp()
|
||||
|
||||
@@ -15,6 +15,10 @@ Supported formats:
|
||||
"cron:0 7 * * *" → cron expression
|
||||
"at:2026-03-01T09:00:00" → one-shot ISO timestamp
|
||||
"in 20 minutes" → one-shot ~20min from now
|
||||
"at 9am" → one-shot next 09:00
|
||||
"every day at 9am" → daily at 09:00 (cron)
|
||||
"weekdays at 9am" → Mon-Fri at 09:00 (cron)
|
||||
"every monday 9am" → day-of-week at clock time (cron)
|
||||
"""
|
||||
|
||||
import re
|
||||
@@ -44,6 +48,138 @@ _UNIT_MULTIPLIER = {
|
||||
"h": 3600, "hr": 3600, "hour": 3600, "hours": 3600,
|
||||
}
|
||||
|
||||
# Day-of-week names → cron day field (0 or 7 = Sunday). Aliases map to ranges.
|
||||
_NL_DOW = {
|
||||
"sunday": "0", "sun": "0",
|
||||
"monday": "1", "mon": "1",
|
||||
"tuesday": "2", "tue": "2", "tues": "2",
|
||||
"wednesday": "3", "wed": "3",
|
||||
"thursday": "4", "thu": "4", "thur": "4", "thurs": "4",
|
||||
"friday": "5", "fri": "5",
|
||||
"saturday": "6", "sat": "6",
|
||||
"weekday": "1-5", "weekdays": "1-5",
|
||||
"weekend": "0,6", "weekends": "0,6",
|
||||
}
|
||||
|
||||
# Clock time, e.g. "9am", "9:30pm", "17:00", "9 am".
|
||||
_CLOCK_RE = re.compile(
|
||||
r"(?P<hour>\d{1,2})(?::(?P<minute>\d{2}))?\s*(?P<ampm>am|pm)?",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _parse_clock(text: str):
|
||||
"""Parse a clock-time fragment into ``(hour, minute)`` or ``None``."""
|
||||
m = _CLOCK_RE.fullmatch(text.strip())
|
||||
if not m:
|
||||
return None
|
||||
hour = int(m.group("hour"))
|
||||
minute = int(m.group("minute") or 0)
|
||||
ampm = (m.group("ampm") or "").lower()
|
||||
if ampm:
|
||||
if hour < 1 or hour > 12:
|
||||
return None
|
||||
if ampm == "pm" and hour != 12:
|
||||
hour += 12
|
||||
elif ampm == "am" and hour == 12:
|
||||
hour = 0
|
||||
if hour > 23 or minute > 59:
|
||||
return None
|
||||
return hour, minute
|
||||
|
||||
|
||||
def _natural_to_cron(expr: str):
|
||||
"""Convert a natural-language schedule to a 5-field cron string.
|
||||
|
||||
Returns the cron string for recurring day-of-week / daily clock times,
|
||||
or ``None`` when the expression is not a recurring NL form (callers then
|
||||
fall through to the one-shot / interval / cron paths).
|
||||
"""
|
||||
lower = expr.lower().strip()
|
||||
lower = re.sub(r"^every\s+", "", lower)
|
||||
lower = re.sub(r"(?:^|\s)(?:at|on)\s+", " ", lower)
|
||||
lower = re.sub(r"\s+", " ", lower).strip()
|
||||
if not lower:
|
||||
return None
|
||||
|
||||
# Drop filler words that carry no scheduling meaning once "every"/"at"/"on"
|
||||
# have been stripped ("every day at 9am" -> daily; "each morning" unsupported).
|
||||
_FILLER = {"day", "daily", "the", "of", "a"}
|
||||
tokens = [t for t in lower.split(" ") if t and t not in _FILLER]
|
||||
dow_parts = []
|
||||
clock = None
|
||||
i = 0
|
||||
while i < len(tokens):
|
||||
tok = tokens[i]
|
||||
# A comma-joined list of day names, e.g. "mon,wed,fri".
|
||||
if "," in tok and all(d in _NL_DOW for d in tok.split(",") if d):
|
||||
for d in tok.split(","):
|
||||
if d:
|
||||
dow_parts.append(_NL_DOW[d])
|
||||
i += 1
|
||||
continue
|
||||
if tok in _NL_DOW:
|
||||
dow_parts.append(_NL_DOW[tok])
|
||||
i += 1
|
||||
continue
|
||||
# Otherwise attempt a clock time, possibly split across two tokens
|
||||
# ("9 am", "9:30 pm").
|
||||
joined = tok
|
||||
if i + 1 < len(tokens) and tokens[i + 1] in ("am", "pm"):
|
||||
joined = tok + tokens[i + 1]
|
||||
i += 1
|
||||
parsed = _parse_clock(joined)
|
||||
if parsed is None:
|
||||
return None
|
||||
if clock is not None:
|
||||
return None
|
||||
clock = parsed
|
||||
i += 1
|
||||
|
||||
if clock is None:
|
||||
return None
|
||||
hour, minute = clock
|
||||
dow = ",".join(dow_parts) if dow_parts else "*"
|
||||
return f"{minute} {hour} * * {dow}"
|
||||
|
||||
|
||||
def _clock_to_next_at(hour: int, minute: int, tz: str | None) -> str:
|
||||
"""Return the next ISO timestamp for the given clock time (one-shot).
|
||||
|
||||
The clock time denotes local time in the schedule's timezone (``tz`` or
|
||||
the process default), so ``at 9am`` fires at 09:00 in that zone rather
|
||||
than 09:00 UTC. Falls back to UTC when the timezone cannot be resolved.
|
||||
"""
|
||||
try:
|
||||
from .due import resolve_schedule_timezone
|
||||
tzinfo = resolve_schedule_timezone(tz)
|
||||
except Exception:
|
||||
tzinfo = timezone.utc
|
||||
now = datetime.now(tzinfo)
|
||||
target = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
if target <= now:
|
||||
target += timedelta(days=1)
|
||||
return target.isoformat()
|
||||
|
||||
|
||||
def _cron_schedule(cron_expr: str, tz: str | None) -> Schedule:
|
||||
"""Build a cron Schedule, refusing if nothing can evaluate it.
|
||||
|
||||
``is_due()`` returns False for a cron job when ``croniter`` is missing --
|
||||
a log line, no error, a job that never runs. A schedule the runtime
|
||||
cannot evaluate must be rejected here, where the user can still see the
|
||||
answer, not accepted and ignored. Every natural-language recurring form
|
||||
("every day at 9am") arrives through this path.
|
||||
"""
|
||||
try:
|
||||
import croniter # noqa: F401 # type: ignore[import-untyped]
|
||||
except ImportError as exc:
|
||||
raise ValueError(
|
||||
f"Schedule {cron_expr!r} needs the cron engine, which is not "
|
||||
"installed. Install with: pip install croniter"
|
||||
) from exc
|
||||
return Schedule(kind="cron", cron_expr=cron_expr, tz=tz)
|
||||
|
||||
|
||||
def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
|
||||
"""Parse a schedule expression string into a ``Schedule`` object.
|
||||
@@ -68,7 +204,7 @@ def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
|
||||
cron_expr = expr[5:].strip()
|
||||
if not cron_expr:
|
||||
raise ValueError("Empty cron expression after 'cron:' prefix")
|
||||
return Schedule(kind="cron", cron_expr=cron_expr, tz=tz)
|
||||
return _cron_schedule(cron_expr, tz)
|
||||
|
||||
# At prefix (ISO timestamp)
|
||||
if lower.startswith("at:"):
|
||||
@@ -102,7 +238,25 @@ def parse_schedule(expr: str, tz: str | None = None) -> Schedule:
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Natural-language clock-time / day-of-week expressions:
|
||||
# "at 9am" → one-shot next 09:00
|
||||
# "every day at 9am" → daily at 09:00 (cron)
|
||||
# "weekdays at 9am" → Mon-Fri at 09:00 (cron)
|
||||
# "every monday 9am" → day-of-week at clock time (cron)
|
||||
# A bare clock time with no day / "every" is treated as a one-shot; any
|
||||
# recurring form ("every ...", "daily ...", a day-of-week) maps to cron.
|
||||
cron_expr = _natural_to_cron(expr)
|
||||
if cron_expr is not None:
|
||||
has_dow = cron_expr.rsplit(" ", 1)[-1] != "*"
|
||||
recurring = has_dow or bool(re.match(r"(?i)^\s*(every|daily|each)\b", expr))
|
||||
if recurring:
|
||||
return _cron_schedule(cron_expr, tz)
|
||||
minute, hour = cron_expr.split(" ")[:2]
|
||||
at = _clock_to_next_at(int(hour), int(minute), tz)
|
||||
return Schedule(kind="at", at=at, tz=tz)
|
||||
|
||||
raise ValueError(
|
||||
f"Unrecognised schedule expression: {expr!r}. "
|
||||
"Use 'hourly', 'daily', '*/30m', 'cron:EXPR', 'at:ISO', or 'in N minutes'."
|
||||
"Use 'hourly', 'daily', '*/30m', 'cron:EXPR', 'at:ISO', 'in N minutes', "
|
||||
"'at 9am', 'every day at 9am', 'weekdays at 9am', or 'every monday 9am'."
|
||||
)
|
||||
@@ -85,7 +85,19 @@ class Session:
|
||||
timeout: HTTP timeout for remote agent calls (default: 30 seconds)
|
||||
session_ttl: Time-to-live in seconds after which session expires
|
||||
"""
|
||||
self.session_id = session_id or str(uuid.uuid4())[:8]
|
||||
# An explicit session_id is adopted as-is (needed to resume a prior
|
||||
# session). An auto-generated one uses 16 hex chars / 64 bits (vs the
|
||||
# old 8 chars / 32 bits, which hit a 50% birthday collision at ~77k IDs)
|
||||
# and, since its directory is a security boundary between sessions, is
|
||||
# regenerated if a directory already exists for it - mirroring the
|
||||
# retry-on-collision loop the endpoint-path generator already uses.
|
||||
if session_id is not None:
|
||||
self.session_id = session_id
|
||||
else:
|
||||
self.session_id = uuid.uuid4().hex[:16]
|
||||
if agent_url is None:
|
||||
while self._get_session_dir().exists():
|
||||
self.session_id = uuid.uuid4().hex[:16]
|
||||
self.user_id = user_id or "default_user"
|
||||
self.agent_url = agent_url
|
||||
self.timeout = timeout
|
||||
|
||||
@@ -969,9 +969,16 @@ class EditTools:
|
||||
|
||||
*** Delete File: path/to/file
|
||||
|
||||
``Update File`` may contain multiple ``@@`` hunks. Returns a list of
|
||||
dicts: ``{"op", "path", ...}``. Raises ``ValueError`` on malformed
|
||||
input.
|
||||
*** Move File: old/path -> new/path
|
||||
@@ (optional hunks; rename + edit in one op)
|
||||
<old block>
|
||||
===
|
||||
<new block>
|
||||
|
||||
``Update File`` and ``Move File`` may contain multiple ``@@`` hunks.
|
||||
Returns a list of dicts: ``{"op", "path", ...}`` (a move carries
|
||||
``{"op": "move", "src", "dst", "hunks"}``). Raises ``ValueError`` on
|
||||
malformed input.
|
||||
"""
|
||||
lines = patch.splitlines()
|
||||
ops = []
|
||||
@@ -983,7 +990,8 @@ class EditTools:
|
||||
def _header(line):
|
||||
for op, prefix in (("add", "*** Add File:"),
|
||||
("update", "*** Update File:"),
|
||||
("delete", "*** Delete File:")):
|
||||
("delete", "*** Delete File:"),
|
||||
("move", "*** Move File:")):
|
||||
if line.strip().startswith(prefix):
|
||||
return op, line.split(":", 1)[1].strip()
|
||||
return None, None
|
||||
@@ -992,6 +1000,26 @@ class EditTools:
|
||||
"""A line that terminates a body/hunk: a header or a sentinel."""
|
||||
return _header(line)[0] is not None or line.strip() in _SENTINELS
|
||||
|
||||
def _parse_hunks(idx):
|
||||
"""Consume ``@@`` hunks starting at ``idx``; return (hunks, idx)."""
|
||||
hunks = []
|
||||
while idx < n and not _is_boundary(lines[idx]):
|
||||
if lines[idx].strip().startswith("@@"):
|
||||
idx += 1
|
||||
old_block, new_block = [], []
|
||||
in_new = False
|
||||
while idx < n and not lines[idx].strip().startswith("@@") and not _is_boundary(lines[idx]):
|
||||
if lines[idx].strip() == "===":
|
||||
in_new = True
|
||||
idx += 1
|
||||
continue
|
||||
(new_block if in_new else old_block).append(lines[idx])
|
||||
idx += 1
|
||||
hunks.append(("\n".join(old_block), "\n".join(new_block)))
|
||||
else:
|
||||
idx += 1
|
||||
return hunks, idx
|
||||
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
if not line.strip() or line.strip() in _SENTINELS:
|
||||
@@ -1010,35 +1038,37 @@ class EditTools:
|
||||
i += 1
|
||||
ops.append({"op": "add", "path": path, "content": "\n".join(body)})
|
||||
elif op == "update":
|
||||
hunks = []
|
||||
while i < n and not _is_boundary(lines[i]):
|
||||
if lines[i].strip().startswith("@@"):
|
||||
i += 1
|
||||
old_block, new_block = [], []
|
||||
in_new = False
|
||||
while i < n and not lines[i].strip().startswith("@@") and not _is_boundary(lines[i]):
|
||||
if lines[i].strip() == "===":
|
||||
in_new = True
|
||||
i += 1
|
||||
continue
|
||||
(new_block if in_new else old_block).append(lines[i])
|
||||
i += 1
|
||||
hunks.append(("\n".join(old_block), "\n".join(new_block)))
|
||||
else:
|
||||
i += 1
|
||||
hunks, i = _parse_hunks(i)
|
||||
ops.append({"op": "update", "path": path, "hunks": hunks})
|
||||
elif op == "move":
|
||||
# Payload is "<old> -> <new>"; the separator is a literal
|
||||
# " -> " so paths containing "->" without surrounding spaces
|
||||
# are unaffected.
|
||||
if "->" not in path:
|
||||
raise ValueError(
|
||||
f"Malformed Move File header (expected '<old> -> <new>'): {path!r}")
|
||||
src, dst = path.split("->", 1)
|
||||
src, dst = src.strip(), dst.strip()
|
||||
if not src or not dst:
|
||||
raise ValueError(
|
||||
f"Malformed Move File header (empty source or destination): {path!r}")
|
||||
hunks, i = _parse_hunks(i)
|
||||
ops.append({"op": "move", "src": src, "dst": dst, "hunks": hunks})
|
||||
return ops
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def apply_patch(self, patch: str, force: bool = False) -> str:
|
||||
"""Apply a structured multi-file patch atomically.
|
||||
|
||||
The patch may Add, Update, and Delete multiple files in a single
|
||||
call. All operations are validated first; only if every operation
|
||||
is valid are the changes committed to disk. The commit phase stages
|
||||
writes/deletes and rolls them back if any step fails, so on any error
|
||||
the filesystem is left in its original state. BOM and CRLF line
|
||||
endings are preserved on Update, matching ``edit_file``.
|
||||
The patch may Add, Update, Delete, and Move/rename multiple files in a
|
||||
single call. A ``*** Move File: <old> -> <new>`` operation renames a
|
||||
file (optionally applying ``@@`` hunks so it is renamed *and* edited in
|
||||
one atomic step) without re-transmitting the whole body. All
|
||||
operations are validated first; only if every operation is valid are
|
||||
the changes committed to disk. The commit phase stages
|
||||
writes/deletes/moves and rolls them back if any step fails, so on any
|
||||
error the filesystem is left in its original state. BOM and CRLF line
|
||||
endings are preserved on Update and Move, matching ``edit_file``.
|
||||
|
||||
Every affected path is locked for the duration of validate+commit, so
|
||||
a concurrent edit/write to any of those files serialises rather than
|
||||
@@ -1066,8 +1096,16 @@ class EditTools:
|
||||
# Acquire per-path locks for every affected file up front, in a
|
||||
# stable (sorted) order to avoid deadlock when two patches share
|
||||
# files. Held across validate+commit so no concurrent edit/write
|
||||
# can interleave with this atomic patch.
|
||||
lock_paths = sorted({self._validate_path(op["path"]) for op in ops})
|
||||
# can interleave with this atomic patch. A move locks both its
|
||||
# source and destination so the rename is race-safe on both ends.
|
||||
affected = set()
|
||||
for op in ops:
|
||||
if op["op"] == "move":
|
||||
affected.add(self._validate_path(op["src"]))
|
||||
affected.add(self._validate_path(op["dst"]))
|
||||
else:
|
||||
affected.add(self._validate_path(op["path"]))
|
||||
lock_paths = sorted(affected)
|
||||
with contextlib.ExitStack() as stack:
|
||||
for lp in lock_paths:
|
||||
stack.enter_context(_file_locks.get_lock(lp))
|
||||
@@ -1083,18 +1121,97 @@ class EditTools:
|
||||
try:
|
||||
# Phase 1: validate every operation and compute new content.
|
||||
# Each planned entry: (kind, safe_path, display, encoded_bytes,
|
||||
# diff, content_hash). encoded_bytes is None for deletes.
|
||||
# diff, content_hash, src_path). encoded_bytes is None for deletes;
|
||||
# src_path is the source path for a move (None otherwise), so the
|
||||
# commit phase can remove the origin after writing the destination.
|
||||
planned = []
|
||||
# Track output paths produced within this transaction so two
|
||||
# operations cannot silently target the same destination (the
|
||||
# later commit would clobber the earlier result while reporting
|
||||
# success). Each op validates against the *initial* filesystem, so
|
||||
# this cross-op guard is what prevents intra-patch conflicts.
|
||||
produced = {}
|
||||
consumed = {} # paths removed by a move/delete within this patch
|
||||
|
||||
def _claim_output(path, display):
|
||||
prior = produced.get(path)
|
||||
if prior is not None:
|
||||
return (f"Error: Conflicting operations target the same path "
|
||||
f"'{display}' (also produced by '{prior}')")
|
||||
produced[path] = display
|
||||
return None
|
||||
|
||||
for op in ops:
|
||||
if op["op"] == "move":
|
||||
src_path = self._validate_path(op["src"])
|
||||
dst_path = self._validate_path(op["dst"])
|
||||
if src_path == dst_path:
|
||||
return (f"Error: Cannot move '{op['src']}' to '{op['dst']}': "
|
||||
f"source and destination are the same path")
|
||||
if not os.path.exists(src_path):
|
||||
return f"Error: Cannot move '{op['src']}': file not found"
|
||||
# The destination must be free both on disk and within this
|
||||
# transaction (unless a prior op in this patch removed it).
|
||||
if os.path.exists(dst_path) and dst_path not in consumed:
|
||||
return (f"Error: Cannot move '{op['src']}' to '{op['dst']}': "
|
||||
f"destination already exists")
|
||||
conflict = _claim_output(dst_path, f"{op['src']} -> {op['dst']}")
|
||||
if conflict:
|
||||
return conflict
|
||||
# Read in binary to preserve BOM/CRLF like update does.
|
||||
with open(src_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
try:
|
||||
original, bom = self._decode_with_bom(raw_bytes)
|
||||
except ValueError as e:
|
||||
return f"Error: Cannot move '{op['src']}': {e}"
|
||||
original_hash = self._compute_content_hash(original)
|
||||
# Automatic staleness guard on the source, mirroring update.
|
||||
if not force:
|
||||
recorded = _file_locks.get_read_hash(src_path)
|
||||
if recorded is not None and recorded != original_hash:
|
||||
return (f"Error: Cannot move '{op['src']}': file changed "
|
||||
f"since it was read - re-read before editing "
|
||||
f"(or pass force=True to override).")
|
||||
line_ending = self._detect_line_ending(original)
|
||||
updated = original
|
||||
for old_block, new_block in op["hunks"]:
|
||||
if not old_block:
|
||||
return f"Error: Empty hunk in move for '{op['src']}'"
|
||||
spans = self._find_spans(updated, old_block)
|
||||
if not spans:
|
||||
preview = old_block[:50] + ("..." if len(old_block) > 50 else "")
|
||||
return (f"Error: Hunk not found in '{op['src']}': '{preview}'")
|
||||
if len(spans) > 1:
|
||||
preview = old_block[:30] + ("..." if len(old_block) > 30 else "")
|
||||
return (f"Error: Ambiguous hunk in '{op['src']}': '{preview}' "
|
||||
f"matches {len(spans)} locations")
|
||||
start, end = spans[0]
|
||||
updated = updated[:start] + new_block + updated[end:]
|
||||
encoded = self._encode_preserving(updated, line_ending, bom)
|
||||
on_disk_form = encoded[len(bom):].decode('utf-8')
|
||||
display = f"{op['src']} -> {op['dst']}"
|
||||
planned.append(("move", dst_path, display, encoded,
|
||||
self._render_diff(original, updated, display),
|
||||
self._compute_content_hash(on_disk_form),
|
||||
src_path))
|
||||
# The source path is now free for a later op in this patch.
|
||||
consumed[src_path] = display
|
||||
produced.pop(src_path, None)
|
||||
continue
|
||||
|
||||
safe_path = self._validate_path(op["path"])
|
||||
|
||||
if op["op"] == "add":
|
||||
if os.path.exists(safe_path):
|
||||
if os.path.exists(safe_path) and safe_path not in consumed:
|
||||
return f"Error: Cannot add '{op['path']}': file already exists"
|
||||
conflict = _claim_output(safe_path, op["path"])
|
||||
if conflict:
|
||||
return conflict
|
||||
encoded = op["content"].encode('utf-8')
|
||||
planned.append(("add", safe_path, op["path"], encoded,
|
||||
self._render_diff("", op["content"], op["path"]),
|
||||
self._compute_content_hash(op["content"])))
|
||||
self._compute_content_hash(op["content"]), None))
|
||||
|
||||
elif op["op"] == "delete":
|
||||
if not os.path.exists(safe_path):
|
||||
@@ -1114,11 +1231,16 @@ class EditTools:
|
||||
return (f"Error: Cannot delete '{op['path']}': file changed "
|
||||
f"since it was read - re-read before editing "
|
||||
f"(or pass force=True to override).")
|
||||
planned.append(("delete", safe_path, op["path"], None, "", None))
|
||||
planned.append(("delete", safe_path, op["path"], None, "", None, None))
|
||||
consumed[safe_path] = op["path"]
|
||||
produced.pop(safe_path, None)
|
||||
|
||||
elif op["op"] == "update":
|
||||
if not os.path.exists(safe_path):
|
||||
return f"Error: Cannot update '{op['path']}': file not found"
|
||||
conflict = _claim_output(safe_path, op["path"])
|
||||
if conflict:
|
||||
return conflict
|
||||
# Read in binary to preserve BOM/CRLF like edit_file does.
|
||||
with open(safe_path, 'rb') as f:
|
||||
raw_bytes = f.read()
|
||||
@@ -1157,7 +1279,7 @@ class EditTools:
|
||||
on_disk_form = encoded[len(bom):].decode('utf-8')
|
||||
planned.append(("update", safe_path, op["path"], encoded,
|
||||
self._render_diff(original, updated, op["path"]),
|
||||
self._compute_content_hash(on_disk_form)))
|
||||
self._compute_content_hash(on_disk_form), None))
|
||||
|
||||
# Phase 2: commit all operations atomically. Writes are staged to
|
||||
# temp files in the same directory and swapped in with os.replace;
|
||||
@@ -1167,20 +1289,77 @@ class EditTools:
|
||||
messages = []
|
||||
applied = [] # rollback log: (action, *paths)
|
||||
tmp_suffix = ".praison_patch_tmp"
|
||||
|
||||
def _free_temp_path(base: str) -> str:
|
||||
"""Return a staging path derived from ``base`` that does not
|
||||
collide with an existing file, so move/write staging never
|
||||
truncates or later deletes an unrelated user file that happens
|
||||
to sit at the default ``<path>.praison_patch_tmp`` name."""
|
||||
candidate = base + tmp_suffix
|
||||
counter = 0
|
||||
while os.path.lexists(candidate):
|
||||
counter += 1
|
||||
candidate = f"{base}{tmp_suffix}.{counter}"
|
||||
return candidate
|
||||
|
||||
try:
|
||||
for kind, safe_path, display, encoded, diff, content_hash in planned:
|
||||
for kind, safe_path, display, encoded, diff, content_hash, src_path in planned:
|
||||
if kind == "delete":
|
||||
backup = safe_path + tmp_suffix
|
||||
backup = _free_temp_path(safe_path)
|
||||
os.replace(safe_path, backup)
|
||||
applied.append(("delete", safe_path, backup))
|
||||
self._file_cache.pop(safe_path, None)
|
||||
_file_locks.clear_read_hash(safe_path)
|
||||
messages.append(f"Deleted {display}")
|
||||
elif kind == "move":
|
||||
# Write the (optionally edited) content to the
|
||||
# destination, then rename the source aside so a
|
||||
# failure can restore it. ``safe_path`` is the
|
||||
# validated destination; the destination is known not
|
||||
# to exist (checked in phase 1).
|
||||
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
||||
tmp_path = _free_temp_path(safe_path)
|
||||
src_backup = _free_temp_path(src_path)
|
||||
try:
|
||||
with open(tmp_path, 'wb') as f:
|
||||
f.write(encoded)
|
||||
os.replace(tmp_path, safe_path)
|
||||
os.replace(src_path, src_backup)
|
||||
except Exception:
|
||||
if os.path.exists(tmp_path):
|
||||
try:
|
||||
os.remove(tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
# If the destination was already written but the
|
||||
# source rename failed, drop the destination so the
|
||||
# partial move is not left behind.
|
||||
if os.path.exists(safe_path):
|
||||
try:
|
||||
os.remove(safe_path)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
# Capture the source's recorded read-hash before we
|
||||
# clear it, so a later rollback can restore it and keep
|
||||
# the staleness guard armed on the restored source.
|
||||
prev_src_hash = _file_locks.get_read_hash(src_path)
|
||||
applied.append(("move", safe_path, src_path, src_backup,
|
||||
prev_src_hash))
|
||||
self._file_cache.pop(src_path, None)
|
||||
_file_locks.clear_read_hash(src_path)
|
||||
self._file_cache[safe_path] = content_hash
|
||||
_file_locks.record_read_hash(safe_path, content_hash)
|
||||
if self._post_edit_format != "off":
|
||||
if self._apply_post_edit_format(safe_path):
|
||||
self._refresh_hash_after_format(safe_path)
|
||||
diagnostics = self._run_diagnostics(safe_path, display)
|
||||
messages.append(f"Renamed {display}\n{diff}{diagnostics}")
|
||||
else:
|
||||
os.makedirs(os.path.dirname(safe_path) or ".", exist_ok=True)
|
||||
tmp_path = safe_path + tmp_suffix
|
||||
tmp_path = _free_temp_path(safe_path)
|
||||
existed = os.path.exists(safe_path)
|
||||
backup = safe_path + ".praison_patch_bak" if existed else None
|
||||
backup = _free_temp_path(safe_path + ".praison_patch_bak") if existed else None
|
||||
try:
|
||||
with open(tmp_path, 'wb') as f:
|
||||
f.write(encoded)
|
||||
@@ -1222,13 +1401,31 @@ class EditTools:
|
||||
os.replace(backup, target)
|
||||
elif os.path.exists(target):
|
||||
os.remove(target)
|
||||
elif action == "move":
|
||||
# entry = ("move", dst, src, src_backup, prev_src_hash):
|
||||
# drop the freshly-written destination, restore the
|
||||
# source, and re-arm its staleness guard so a later
|
||||
# edit still detects external modifications.
|
||||
src, src_backup = entry[2], entry[3]
|
||||
prev_src_hash = entry[4] if len(entry) > 4 else None
|
||||
if os.path.exists(target):
|
||||
os.remove(target)
|
||||
os.replace(src_backup, src)
|
||||
_file_locks.clear_read_hash(target)
|
||||
self._file_cache.pop(target, None)
|
||||
if prev_src_hash is not None:
|
||||
_file_locks.record_read_hash(src, prev_src_hash)
|
||||
self._file_cache[src] = prev_src_hash
|
||||
except OSError:
|
||||
logger.error("Rollback failed for %s", target)
|
||||
raise
|
||||
|
||||
# Success: discard delete backups and write backups.
|
||||
# Success: discard delete/write backups and move source backups.
|
||||
for entry in applied:
|
||||
action, backup = entry[0], entry[2]
|
||||
if entry[0] == "move":
|
||||
backup = entry[3]
|
||||
else:
|
||||
backup = entry[2]
|
||||
if backup is not None and os.path.exists(backup):
|
||||
try:
|
||||
os.remove(backup)
|
||||
@@ -1377,12 +1574,14 @@ def edit_file(filepath: str, old_string: str, new_string: str,
|
||||
|
||||
@require_approval(risk_level="high")
|
||||
def apply_patch(patch: str, force: bool = False) -> str:
|
||||
"""Apply a structured multi-file patch atomically (Add/Update/Delete).
|
||||
"""Apply a structured multi-file patch atomically (Add/Update/Delete/Move).
|
||||
|
||||
Args:
|
||||
patch: Structured patch text with ``*** Add File:``,
|
||||
``*** Update File:`` and ``*** Delete File:`` sections.
|
||||
force: Bypass the automatic staleness guard on Update operations.
|
||||
``*** Update File:``, ``*** Delete File:`` and
|
||||
``*** Move File: <old> -> <new>`` sections. A Move may carry
|
||||
optional ``@@`` hunks to rename and edit a file in one operation.
|
||||
force: Bypass the automatic staleness guard on Update/Move operations.
|
||||
|
||||
Returns:
|
||||
Combined success message with diffs or an error description.
|
||||
|
||||
@@ -63,7 +63,8 @@ class AgentState:
|
||||
learn_manager: Optional[Any] = None
|
||||
previous_tool_results: List[Any] = field(default_factory=list)
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
cancel_event: Optional[Any] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for Injected[dict] usage."""
|
||||
return {
|
||||
@@ -74,6 +75,7 @@ class AgentState:
|
||||
'last_agent_message': self.last_agent_message,
|
||||
'previous_tool_results': self.previous_tool_results,
|
||||
'metadata': self.metadata,
|
||||
'cancel_event': self.cancel_event,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -33,6 +33,12 @@ class _StreamDrainTimeout(Exception):
|
||||
process.
|
||||
"""
|
||||
|
||||
|
||||
class _CommandCancelled(Exception):
|
||||
"""Raised when a cooperative cancellation signal fires while the child
|
||||
process is still running, so the caller kills the process group and reports
|
||||
an ``interrupted`` outcome instead of waiting for the timeout."""
|
||||
|
||||
class ShellTools:
|
||||
"""Tools for executing shell commands safely."""
|
||||
|
||||
@@ -125,11 +131,17 @@ class ShellTools:
|
||||
text=True
|
||||
)
|
||||
|
||||
# Observe the agent's cooperative cancellation signal (when running
|
||||
# inside an agent tool call) so an interrupt (/stop, Ctrl-C) aborts
|
||||
# this subprocess promptly instead of waiting for the timeout. When
|
||||
# run standalone the event is None and behaviour is unchanged.
|
||||
cancel_event = self._current_cancel_event()
|
||||
|
||||
try:
|
||||
# Read stdout/stderr incrementally so a progress channel (when
|
||||
# active) can surface output live while the command runs. Falls
|
||||
# back to fully-buffered behaviour when no sink is listening.
|
||||
stdout, stderr = self._communicate_streaming(process, timeout)
|
||||
stdout, stderr = self._communicate_streaming(process, timeout, cancel_event)
|
||||
|
||||
# Handle over-budget output. Rather than permanently discarding
|
||||
# the middle (often where the real error lives), spill the full
|
||||
@@ -154,6 +166,20 @@ class ShellTools:
|
||||
result['stderr_artifact'] = stderr_path
|
||||
return result
|
||||
|
||||
except _CommandCancelled:
|
||||
# A cooperative interrupt (/stop, Ctrl-C) fired while the child
|
||||
# was still running. Kill the whole process group so descendants
|
||||
# do not keep mutating state, then report an interrupted outcome.
|
||||
self._kill_process_tree(process)
|
||||
return {
|
||||
'stdout': '',
|
||||
'stderr': 'Command interrupted by user',
|
||||
'exit_code': -1,
|
||||
'success': False,
|
||||
'interrupted': True,
|
||||
'execution_time': time.time() - start_time
|
||||
}
|
||||
|
||||
except _StreamDrainTimeout:
|
||||
# The direct child already exited; only an inherited pipe kept a
|
||||
# reader blocked. Report a timeout but do NOT kill — the direct
|
||||
@@ -169,20 +195,7 @@ class ShellTools:
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
# Kill process on timeout
|
||||
try:
|
||||
import psutil
|
||||
parent = psutil.Process(process.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
child.kill()
|
||||
parent.kill()
|
||||
except ImportError:
|
||||
# Fallback: kill without psutil
|
||||
process.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
# Process already gone — nothing to kill.
|
||||
pass
|
||||
|
||||
self._kill_process_tree(process)
|
||||
return {
|
||||
'stdout': '',
|
||||
'stderr': f'Command timed out after {timeout} seconds',
|
||||
@@ -202,7 +215,48 @@ class ShellTools:
|
||||
'execution_time': 0
|
||||
}
|
||||
|
||||
def _communicate_streaming(self, process, timeout):
|
||||
def _current_cancel_event(self):
|
||||
"""Return the agent's cooperative cancellation Event, if any.
|
||||
|
||||
Sourced from the injected AgentState set by the agent tool-execution
|
||||
loop. Returns ``None`` when the tool runs outside an agent (standalone),
|
||||
keeping behaviour and imports lazy with zero overhead.
|
||||
"""
|
||||
try:
|
||||
from .injected import get_current_state
|
||||
state = get_current_state()
|
||||
except Exception:
|
||||
return None
|
||||
if state is None:
|
||||
return None
|
||||
return getattr(state, 'cancel_event', None)
|
||||
|
||||
def _kill_process_tree(self, process):
|
||||
"""Kill a child process and its descendants (best-effort).
|
||||
|
||||
Uses psutil to reach the whole tree when available, falling back to
|
||||
``process.kill()`` otherwise. Safe to call if the process already exited.
|
||||
"""
|
||||
try:
|
||||
import psutil
|
||||
parent = psutil.Process(process.pid)
|
||||
children = parent.children(recursive=True)
|
||||
for child in children:
|
||||
try:
|
||||
child.kill()
|
||||
except psutil.NoSuchProcess:
|
||||
pass
|
||||
parent.kill()
|
||||
except ImportError:
|
||||
try:
|
||||
process.kill()
|
||||
except Exception:
|
||||
pass
|
||||
except psutil.NoSuchProcess:
|
||||
# Process already gone — nothing to kill.
|
||||
pass
|
||||
|
||||
def _communicate_streaming(self, process, timeout, cancel_event=None):
|
||||
"""Read stdout/stderr line-buffered, emitting live progress.
|
||||
|
||||
Reads both streams concurrently in background threads so a line on
|
||||
@@ -273,16 +327,38 @@ class ShellTools:
|
||||
t_err.start()
|
||||
|
||||
deadline = None if timeout is None else time.monotonic() + timeout
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
# The direct child is still running. Disable any further emission and
|
||||
# re-raise TimeoutExpired so the caller's timeout path KILLS the
|
||||
# process before returning — a recorded read error must not short-
|
||||
# circuit that cleanup (otherwise the still-running child leaks with
|
||||
# its pipes open).
|
||||
stop_emitting.set()
|
||||
raise
|
||||
if cancel_event is None:
|
||||
try:
|
||||
process.wait(timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
# The direct child is still running. Disable any further emission
|
||||
# and re-raise TimeoutExpired so the caller's timeout path KILLS
|
||||
# the process before returning — a recorded read error must not
|
||||
# short-circuit that cleanup (otherwise the still-running child
|
||||
# leaks with its pipes open).
|
||||
stop_emitting.set()
|
||||
raise
|
||||
else:
|
||||
# Poll in short slices so a cooperative interrupt aborts the child
|
||||
# promptly (within the poll interval) rather than only on timeout.
|
||||
_POLL = 0.1
|
||||
while True:
|
||||
if cancel_event.is_set():
|
||||
stop_emitting.set()
|
||||
raise _CommandCancelled()
|
||||
if deadline is not None:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
stop_emitting.set()
|
||||
raise subprocess.TimeoutExpired(process.args, timeout)
|
||||
wait_slice = min(_POLL, remaining)
|
||||
else:
|
||||
wait_slice = _POLL
|
||||
try:
|
||||
process.wait(timeout=wait_slice)
|
||||
break
|
||||
except subprocess.TimeoutExpired:
|
||||
continue
|
||||
|
||||
# Wait for the readers to drain the pipe buffers before returning,
|
||||
# preserving subprocess.communicate()'s "all captured output" semantics.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.2"
|
||||
version = "1.7.3"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -17,7 +17,12 @@ dependencies = [
|
||||
"openai>=1.68.2",
|
||||
"posthog>=3.0.0",
|
||||
"PyYAML>=6.0",
|
||||
"aiohttp>=3.8.0"
|
||||
"aiohttp>=3.8.0",
|
||||
# The scheduler converts every natural-language recurring schedule
|
||||
# ("every day at 9am", "weekdays at 9am") to cron; without this engine
|
||||
# those jobs are accepted and silently never fire. Pure Python; pulls
|
||||
# only python-dateutil.
|
||||
"croniter>=6.0"
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -1205,6 +1205,12 @@ steps:
|
||||
researcher = parser._agents.get('researcher')
|
||||
assert researcher is not None
|
||||
assert hasattr(researcher, '_yaml_max_rpm')
|
||||
assert researcher._yaml_max_rpm == 10
|
||||
# YAML max_rpm must wire a live RateLimiter (not just store metadata).
|
||||
from praisonaiagents.llm.rate_limiter import RateLimiter
|
||||
assert isinstance(researcher._rate_limiter, RateLimiter)
|
||||
assert researcher._rate_limiter.requests_per_minute == 10
|
||||
assert researcher.max_rpm == 10
|
||||
|
||||
def test_agent_with_max_execution_time(self):
|
||||
"""Test that agents can have max_execution_time for timeout."""
|
||||
|
||||
@@ -118,6 +118,77 @@ class TestInterruptController:
|
||||
assert not controller.is_set()
|
||||
assert controller.reason is None
|
||||
|
||||
def test_event_cleared_after_interrupted_turn_ends(self):
|
||||
"""A stale interrupt must not carry into the next turn's tools.
|
||||
|
||||
Regression: the ``event`` (exposed to running tool bodies) previously
|
||||
stayed set after an interrupted turn, so a reused controller aborted
|
||||
subprocesses launched by the NEXT turn. Ending the interrupted turn now
|
||||
clears the underlying flag so a fresh turn starts uninterrupted.
|
||||
"""
|
||||
controller = InterruptController()
|
||||
|
||||
turn = controller._begin_turn()
|
||||
controller.request("user_stop")
|
||||
assert controller.event.is_set()
|
||||
assert controller._turn_is_cancelled(turn)
|
||||
|
||||
# The interrupted turn ends without an explicit clear().
|
||||
controller._end_turn(turn)
|
||||
assert not controller.event.is_set()
|
||||
assert controller.reason is None
|
||||
|
||||
# A brand-new turn must NOT be seen as cancelled.
|
||||
next_turn = controller._begin_turn()
|
||||
assert not controller.event.is_set()
|
||||
assert not controller._turn_is_cancelled(next_turn)
|
||||
controller._end_turn(next_turn)
|
||||
|
||||
def test_event_stays_set_while_another_turn_still_cancelled(self):
|
||||
"""Ending one cancelled turn must not release a still-cancelled sibling."""
|
||||
controller = InterruptController()
|
||||
t1 = controller._begin_turn()
|
||||
t2 = controller._begin_turn()
|
||||
controller.request("user_stop") # cancels both active turns
|
||||
|
||||
controller._end_turn(t1)
|
||||
# t2 is still cancelled, so the shared event must remain set.
|
||||
assert controller.event.is_set()
|
||||
assert controller._turn_is_cancelled(t2)
|
||||
|
||||
controller._end_turn(t2)
|
||||
assert not controller.event.is_set()
|
||||
|
||||
def test_event_is_optional_capability_protocol(self):
|
||||
"""The base protocol stays event-free; the cancellable one requires it."""
|
||||
from praisonaiagents.agent.interrupt import (
|
||||
CancellableInterruptControllerProtocol,
|
||||
)
|
||||
|
||||
controller = InterruptController()
|
||||
assert isinstance(controller, CancellableInterruptControllerProtocol)
|
||||
|
||||
class MinimalController:
|
||||
def request(self, reason="user"):
|
||||
pass
|
||||
|
||||
def clear(self):
|
||||
pass
|
||||
|
||||
def is_set(self):
|
||||
return False
|
||||
|
||||
@property
|
||||
def reason(self):
|
||||
return None
|
||||
|
||||
def check(self):
|
||||
pass
|
||||
|
||||
# A controller without ``event`` is NOT cancellable-capable but must not
|
||||
# raise — the runtime probes via getattr(..., 'event', None).
|
||||
assert not isinstance(MinimalController(), CancellableInterruptControllerProtocol)
|
||||
|
||||
|
||||
class TestLLMLoopCancellation:
|
||||
"""The litellm tool loop (llm/llm.py) must honour the InterruptController.
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""The anti-thrashing guard must be able to go stale.
|
||||
|
||||
``_low_savings_streak`` stops a compactor that cannot shrink a conversation
|
||||
from burning a summarisation call every turn. The streak is only cleared
|
||||
*after* a compaction actually runs -- but the guard returns before reaching
|
||||
that code, so once it latched it could never be released. A long-running
|
||||
agent that tripped it stopped compacting for the rest of the session and its
|
||||
context grew without bound, which is the precise opposite of what auto
|
||||
compaction is for.
|
||||
|
||||
These tests pin both halves of the contract: the guard still suppresses
|
||||
repeat passes on an unchanged conversation, and it releases once the
|
||||
conversation has grown enough to be worth re-testing.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.compaction import CompactionConfig, ContextCompactor
|
||||
|
||||
|
||||
def _config(**overrides):
|
||||
"""A tight budget so a handful of turns is enough to trip the guard."""
|
||||
base = dict(
|
||||
max_tokens=200,
|
||||
target_tokens=150,
|
||||
preserve_recent=2,
|
||||
min_savings_pct=10.0,
|
||||
max_consecutive_low_savings=2,
|
||||
)
|
||||
base.update(overrides)
|
||||
return CompactionConfig(**base)
|
||||
|
||||
|
||||
def _conversation(turns):
|
||||
"""A highly compactible conversation: repetitive, summarisable turns."""
|
||||
return [{"role": "system", "content": "sys"}] + [
|
||||
{"role": "user", "content": f"Please refactor module_{i}.py and keep the public API stable."}
|
||||
for i in range(turns)
|
||||
]
|
||||
|
||||
|
||||
def _latched(config, at_tokens):
|
||||
"""A compactor in the state a long session reaches after two low-yield passes."""
|
||||
compactor = ContextCompactor(config=config, strategy="summarize")
|
||||
compactor._low_savings_streak = config.max_consecutive_low_savings
|
||||
compactor._low_savings_tokens = at_tokens
|
||||
return compactor
|
||||
|
||||
|
||||
class TestGuardHolds:
|
||||
"""Anti-thrashing behaviour must be preserved."""
|
||||
|
||||
def test_unchanged_conversation_is_still_skipped(self):
|
||||
"""Re-running on identical input is the thrashing the guard exists to stop."""
|
||||
config = _config()
|
||||
messages = _conversation(200)
|
||||
probe = ContextCompactor(config=config, strategy="summarize")
|
||||
compactor = _latched(config, probe.count_total_tokens(messages))
|
||||
|
||||
_, result = compactor.compact(messages)
|
||||
|
||||
assert result.was_skipped_due_to_low_savings
|
||||
assert result.messages_removed == 0
|
||||
|
||||
def test_marginal_growth_is_still_skipped(self):
|
||||
"""Growth below the retry threshold is not new evidence.
|
||||
|
||||
A couple of extra turns does not justify re-running a summarisation
|
||||
that just failed to pay for itself; without this the release would
|
||||
fire on essentially every turn.
|
||||
"""
|
||||
config = _config()
|
||||
probe = ContextCompactor(config=config, strategy="summarize")
|
||||
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
|
||||
|
||||
_, result = compactor.compact(_conversation(205))
|
||||
|
||||
assert result.was_skipped_due_to_low_savings
|
||||
|
||||
|
||||
class TestGuardReleases:
|
||||
"""A materially larger conversation is new evidence."""
|
||||
|
||||
def test_growth_releases_the_guard(self):
|
||||
"""Past the threshold the verdict is retried, and the retry pays off."""
|
||||
config = _config()
|
||||
probe = ContextCompactor(config=config, strategy="summarize")
|
||||
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
|
||||
|
||||
_, result = compactor.compact(_conversation(300))
|
||||
|
||||
assert not result.was_skipped_due_to_low_savings
|
||||
assert result.messages_removed > 0
|
||||
# And it must actually shrink -- a released guard that yields nothing
|
||||
# would mean the release is cosmetic.
|
||||
assert result.compacted_tokens < result.original_tokens
|
||||
|
||||
def test_release_matches_a_fresh_compactor(self):
|
||||
"""The clearest statement of the bug.
|
||||
|
||||
A latched compactor used to hand back untouched the very input an
|
||||
otherwise-identical fresh one shrinks by ~96%. Once released the two
|
||||
must be indistinguishable.
|
||||
"""
|
||||
config = _config()
|
||||
messages = _conversation(300)
|
||||
probe = ContextCompactor(config=config, strategy="summarize")
|
||||
latched = _latched(config, probe.count_total_tokens(_conversation(200)))
|
||||
fresh = ContextCompactor(config=config, strategy="summarize")
|
||||
|
||||
_, latched_result = latched.compact(messages)
|
||||
_, fresh_result = fresh.compact(messages)
|
||||
|
||||
assert latched_result.compacted_tokens == fresh_result.compacted_tokens
|
||||
|
||||
def test_streak_resets_so_the_full_allowance_returns(self):
|
||||
"""Releasing must clear the streak, not merely skip the check once.
|
||||
|
||||
A release that left the streak at the cap would re-latch on the next
|
||||
call, turning the fix into a one-shot reprieve.
|
||||
"""
|
||||
config = _config()
|
||||
probe = ContextCompactor(config=config, strategy="summarize")
|
||||
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
|
||||
|
||||
compactor.compact(_conversation(300))
|
||||
|
||||
assert compactor._low_savings_streak < config.max_consecutive_low_savings
|
||||
|
||||
def test_needs_compaction_agrees_with_compact(self):
|
||||
"""Both gates must release together.
|
||||
|
||||
``needs_compaction()`` is the public gate callers consult. If it kept
|
||||
reporting False the release would never be reached in the agent's real
|
||||
code path, and fixing ``compact()`` alone would be dead code.
|
||||
"""
|
||||
config = _config()
|
||||
probe = ContextCompactor(config=config, strategy="summarize")
|
||||
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
|
||||
|
||||
assert compactor.needs_compaction(_conversation(300)) is True
|
||||
|
||||
def test_needs_compaction_still_declines_an_unchanged_conversation(self):
|
||||
"""The other half of that agreement: both gates must also hold together."""
|
||||
config = _config()
|
||||
messages = _conversation(200)
|
||||
probe = ContextCompactor(config=config, strategy="summarize")
|
||||
compactor = _latched(config, probe.count_total_tokens(messages))
|
||||
|
||||
assert compactor.needs_compaction(messages) is False
|
||||
|
||||
|
||||
class TestLongSession:
|
||||
"""End-to-end: the failure this guard bug actually caused."""
|
||||
|
||||
def test_compaction_keeps_engaging_across_a_long_session(self):
|
||||
"""No white-box setup: drive a real session and watch compaction die.
|
||||
|
||||
This is the failure a user actually hits, reproduced only through the
|
||||
public API, so it stands even if the guard's internals are reworked.
|
||||
"""
|
||||
config = CompactionConfig(max_tokens=300, target_tokens=200, preserve_recent=2)
|
||||
compactor = ContextCompactor(config=config, strategy="summarize")
|
||||
messages = [{"role": "system", "content": "You are a coding agent."}]
|
||||
events = 0
|
||||
|
||||
for turn in range(1, 41):
|
||||
messages.extend([
|
||||
{"role": "user", "content": f"Step {turn}: refactor module_{turn}.py, keep the API stable."},
|
||||
{"role": "assistant", "content": f"Done: module_{turn}.py refactored, helper_{turn}() extracted."},
|
||||
])
|
||||
compacted, result = compactor.compact(messages)
|
||||
messages[:] = compacted
|
||||
if result.messages_removed:
|
||||
events += 1
|
||||
|
||||
# Before the fix this latched at 4 events and then never fired again,
|
||||
# letting the context grow unbounded for the rest of the session.
|
||||
assert events > 4, f"compaction stopped engaging after {events} events"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("strategy", ["summarize", "truncate", "sliding"])
|
||||
def test_guard_release_is_strategy_independent(strategy):
|
||||
"""The guard lives above strategy selection, so it must not favour one."""
|
||||
config = _config()
|
||||
probe = ContextCompactor(config=config, strategy=strategy)
|
||||
compactor = _latched(config, probe.count_total_tokens(_conversation(200)))
|
||||
|
||||
_, result = compactor.compact(_conversation(300))
|
||||
|
||||
assert not result.was_skipped_due_to_low_savings
|
||||
@@ -158,9 +158,69 @@ class TestExpressionConditionClass:
|
||||
def test_expression_condition_with_previous_output(self):
|
||||
"""Test ExpressionCondition with previous_output in context."""
|
||||
from praisonaiagents.conditions.evaluator import ExpressionCondition
|
||||
|
||||
|
||||
cond = ExpressionCondition("success in {{previous_output}}")
|
||||
|
||||
|
||||
# previous_output is passed via context
|
||||
assert cond.evaluate({"previous_output": "Operation success"}) is True
|
||||
assert cond.evaluate({"previous_output": "Operation failed"}) is False
|
||||
|
||||
|
||||
class TestEvaluateConditionFailSafeFallback:
|
||||
"""A comparison operator that never resolves must fail safe to False,
|
||||
per evaluate_condition's own documented contract, instead of falling
|
||||
through to the final truthy-string check."""
|
||||
|
||||
def test_relational_with_undefined_right_operand(self):
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{score}} > {{threshold}}", {"score": 90}) is False
|
||||
|
||||
def test_equality_with_undefined_right_operand(self):
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{status}} == {{expected}}", {"status": "approved"}) is False
|
||||
|
||||
def test_relational_with_non_numeric_operands(self):
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{a}} > {{b}}", {"a": "apple", "b": "banana"}) is False
|
||||
|
||||
def test_equality_with_undefined_left_operand(self):
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{error_flag}} == true", {}) is False
|
||||
|
||||
def test_plain_flag_truthy_check_still_works(self):
|
||||
"""Guard against over-broadly rejecting values with no comparison
|
||||
operator at all: the existing boolean/flag path is unaffected."""
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{flag}}", {"flag": "yes"}) is True
|
||||
assert evaluate_condition("{{flag}}", {"flag": ""}) is False
|
||||
|
||||
def test_substituted_value_containing_operator_stays_truthy(self):
|
||||
"""A placeholder with no comparison operator in the template must
|
||||
stay on the truthy-string path even if its substituted value
|
||||
happens to contain '>', '<', '==', etc."""
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{flag}}", {"flag": "ready > now"}) is True
|
||||
|
||||
def test_substituted_value_parseable_as_numeric_comparison_stays_truthy(self):
|
||||
"""A plain flag whose value parses as a numeric comparison
|
||||
(e.g. "5 < 3") must not be evaluated as a comparison: the template
|
||||
has no operator, so the non-empty value is truthy."""
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{flag}}", {"flag": "5 < 3"}) is True
|
||||
assert evaluate_condition("{{flag}}", {"flag": "5 > 3"}) is True
|
||||
|
||||
def test_substituted_value_parseable_as_equality_stays_truthy(self):
|
||||
"""A plain flag whose value parses as an equality comparison
|
||||
(e.g. "a == b") must stay on the truthy-string path because the
|
||||
template contains no comparison operator."""
|
||||
from praisonaiagents.conditions.evaluator import evaluate_condition
|
||||
|
||||
assert evaluate_condition("{{flag}}", {"flag": "a == b"}) is True
|
||||
assert evaluate_condition("{{flag}}", {"flag": "a != b"}) is True
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Unit tests for eval/evalport.py - EvalPort framework-neutral adapter.
|
||||
|
||||
Covers the dependency-free round-trip between PraisonAI eval models and the
|
||||
EvalPort spec dicts. A real agentic test (gated by RUN_LIVE_TESTS=1) runs a live
|
||||
Agent and exports its EvalReport as an EvalPort ResultSet.
|
||||
"""
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.eval import (
|
||||
EvalCase,
|
||||
EvalPackage,
|
||||
EvalReport,
|
||||
EvalResult,
|
||||
from_evalport,
|
||||
report_to_evalport,
|
||||
to_evalport,
|
||||
)
|
||||
|
||||
|
||||
class TestEvalPortImports:
|
||||
"""Adapter functions must be importable from praisonaiagents.eval."""
|
||||
|
||||
def test_public_imports(self):
|
||||
assert callable(to_evalport)
|
||||
assert callable(from_evalport)
|
||||
assert callable(report_to_evalport)
|
||||
|
||||
|
||||
class TestSuiteConversion:
|
||||
"""EvalPackage <-> EvalPort Suite."""
|
||||
|
||||
def _pkg(self):
|
||||
return EvalPackage(
|
||||
name="support_agent_eval",
|
||||
description="Support agent suite",
|
||||
version="2.1.0",
|
||||
cases=[
|
||||
EvalCase(
|
||||
name="refund_policy",
|
||||
input="What's your refund policy?",
|
||||
expected="30 days, unused, receipt required",
|
||||
criteria=["answer_correct"],
|
||||
metadata={"tier": "gold"},
|
||||
timeout_seconds=15.0,
|
||||
)
|
||||
],
|
||||
thresholds={"accuracy": 0.9},
|
||||
seed=7,
|
||||
)
|
||||
|
||||
def test_to_evalport_shape(self):
|
||||
suite = to_evalport(self._pkg())
|
||||
assert suite["kind"] == "suite"
|
||||
assert suite["name"] == "support_agent_eval"
|
||||
assert suite["version"] == "2.1.0"
|
||||
assert suite["thresholds"] == {"accuracy": 0.9}
|
||||
assert suite["seed"] == 7
|
||||
assert "evalport_version" in suite
|
||||
|
||||
case = suite["cases"][0]
|
||||
assert case["id"] == "refund_policy"
|
||||
assert case["input"] == "What's your refund policy?"
|
||||
assert case["expected_output"] == "30 days, unused, receipt required"
|
||||
assert case["graders"] == ["answer_correct"]
|
||||
assert case["metadata"]["tier"] == "gold"
|
||||
assert case["timeout_seconds"] == 15.0
|
||||
assert "timeout_seconds" not in case["metadata"]
|
||||
|
||||
def test_from_evalport_shape(self):
|
||||
suite = to_evalport(self._pkg())
|
||||
pkg = from_evalport(suite)
|
||||
assert isinstance(pkg, EvalPackage)
|
||||
assert pkg.name == "support_agent_eval"
|
||||
assert pkg.version == "2.1.0"
|
||||
assert pkg.thresholds == {"accuracy": 0.9}
|
||||
assert pkg.seed == 7
|
||||
|
||||
case = pkg.cases[0]
|
||||
assert isinstance(case, EvalCase)
|
||||
assert case.name == "refund_policy"
|
||||
assert case.input == "What's your refund policy?"
|
||||
assert case.expected == "30 days, unused, receipt required"
|
||||
assert case.criteria == ["answer_correct"]
|
||||
assert case.metadata["tier"] == "gold"
|
||||
assert case.timeout_seconds == 15.0
|
||||
|
||||
def test_round_trip_preserves_dict(self):
|
||||
pkg = self._pkg()
|
||||
round_tripped = from_evalport(to_evalport(pkg))
|
||||
assert round_tripped.to_dict() == pkg.to_dict()
|
||||
|
||||
def test_external_suite_minimal_fields(self):
|
||||
"""A suite from another tool with only required fields still imports."""
|
||||
external = {
|
||||
"name": "hub_suite",
|
||||
"cases": [{"id": "c1", "input": "hi"}],
|
||||
}
|
||||
pkg = from_evalport(external)
|
||||
assert pkg.name == "hub_suite"
|
||||
assert len(pkg.cases) == 1
|
||||
assert pkg.cases[0].name == "c1"
|
||||
assert pkg.cases[0].input == "hi"
|
||||
assert pkg.cases[0].expected is None
|
||||
|
||||
def test_case_without_expected_or_criteria(self):
|
||||
pkg = EvalPackage(name="p", cases=[EvalCase(name="c", input="x")])
|
||||
case = to_evalport(pkg)["cases"][0]
|
||||
assert "expected_output" not in case
|
||||
assert "graders" not in case
|
||||
|
||||
def test_metadata_timeout_key_does_not_corrupt_native_timeout(self):
|
||||
"""A user ``metadata['timeout_seconds']`` must not clobber the native one."""
|
||||
pkg = EvalPackage(
|
||||
name="p",
|
||||
cases=[
|
||||
EvalCase(
|
||||
name="c",
|
||||
input="x",
|
||||
metadata={"timeout_seconds": 999},
|
||||
timeout_seconds=15.0,
|
||||
)
|
||||
],
|
||||
)
|
||||
case = to_evalport(pkg)["cases"][0]
|
||||
assert case["timeout_seconds"] == 15.0
|
||||
assert case["metadata"]["timeout_seconds"] == 999
|
||||
|
||||
round_tripped = from_evalport(to_evalport(pkg)).cases[0]
|
||||
assert round_tripped.timeout_seconds == 15.0
|
||||
assert round_tripped.metadata["timeout_seconds"] == 999
|
||||
|
||||
def test_external_suite_metadata_timeout_fallback(self):
|
||||
"""External suites that only put timeout in metadata still import it."""
|
||||
external = {
|
||||
"name": "hub",
|
||||
"cases": [{"id": "c1", "input": "hi", "metadata": {"timeout_seconds": 45}}],
|
||||
}
|
||||
case = from_evalport(external).cases[0]
|
||||
assert case.timeout_seconds == 45
|
||||
assert case.metadata["timeout_seconds"] == 45
|
||||
|
||||
|
||||
class TestResultSetConversion:
|
||||
"""EvalReport -> EvalPort ResultSet."""
|
||||
|
||||
def _report(self):
|
||||
return EvalReport(
|
||||
package_name="support_agent_eval",
|
||||
total_cases=2,
|
||||
passed_cases=1,
|
||||
failed_cases=1,
|
||||
average_score=0.6,
|
||||
thresholds_met={"accuracy": False},
|
||||
results=[
|
||||
EvalResult(
|
||||
case_name="refund_policy",
|
||||
passed=True,
|
||||
score=0.95,
|
||||
actual_output="30 days.",
|
||||
latency_ms=120.0,
|
||||
criteria_scores={"answer_correct": 0.95},
|
||||
),
|
||||
EvalResult(
|
||||
case_name="shipping",
|
||||
passed=False,
|
||||
score=0.25,
|
||||
error="timeout",
|
||||
latency_ms=300.0,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
def test_result_set_shape(self):
|
||||
rs = report_to_evalport(self._report())
|
||||
assert rs["kind"] == "result_set"
|
||||
assert rs["suite_name"] == "support_agent_eval"
|
||||
assert rs["summary"]["total"] == 2
|
||||
assert rs["summary"]["passed"] == 1
|
||||
assert rs["summary"]["failed"] == 1
|
||||
assert rs["summary"]["pass_rate"] == 0.5
|
||||
assert rs["summary"]["average_score"] == 0.6
|
||||
assert rs["summary"]["thresholds_met"] == {"accuracy": False}
|
||||
assert len(rs["results"]) == 2
|
||||
|
||||
def test_per_case_fields(self):
|
||||
rs = report_to_evalport(self._report())
|
||||
passed, failed = rs["results"]
|
||||
assert passed["case_id"] == "refund_policy"
|
||||
assert passed["passed"] is True
|
||||
assert passed["score"] == 0.95
|
||||
assert passed["latency_ms"] == 120.0
|
||||
assert passed["actual_output"] == "30 days."
|
||||
assert passed["grader_scores"] == {"answer_correct": 0.95}
|
||||
assert "error" not in passed
|
||||
|
||||
assert failed["case_id"] == "shipping"
|
||||
assert failed["passed"] is False
|
||||
assert failed["error"] == "timeout"
|
||||
assert "actual_output" not in failed
|
||||
assert "grader_scores" not in failed
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("RUN_LIVE_TESTS") != "1",
|
||||
reason="Real agentic test requires RUN_LIVE_TESTS=1 and an API key",
|
||||
)
|
||||
class TestEvalPortRealAgent:
|
||||
"""Real agentic test: run a live Agent, export the report as EvalPort."""
|
||||
|
||||
def test_agent_report_exports_to_evalport(self):
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.eval import HarnessEvaluator
|
||||
|
||||
agent = Agent(
|
||||
name="math",
|
||||
instructions="You answer math questions with only the number.",
|
||||
)
|
||||
answer = agent.start("What is 2 + 2?")
|
||||
assert answer is not None
|
||||
|
||||
trace = {
|
||||
"tool_calls": [],
|
||||
"artifacts": [],
|
||||
"judge_score": 10.0 if "4" in str(answer) else 0.0,
|
||||
}
|
||||
result = HarnessEvaluator(trace=trace, name="math_addition").run()
|
||||
|
||||
report = EvalReport(
|
||||
package_name="math_eval",
|
||||
total_cases=1,
|
||||
passed_cases=1 if result.passed else 0,
|
||||
failed_cases=0 if result.passed else 1,
|
||||
average_score=result.score,
|
||||
results=[
|
||||
EvalResult(
|
||||
case_name="math_addition",
|
||||
passed=result.passed,
|
||||
score=result.score,
|
||||
actual_output=str(answer),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result_set = report_to_evalport(report)
|
||||
assert result_set["kind"] == "result_set"
|
||||
assert result_set["suite_name"] == "math_eval"
|
||||
assert result_set["results"][0]["case_id"] == "math_addition"
|
||||
assert result_set["results"][0]["actual_output"] == str(answer)
|
||||
@@ -721,7 +721,31 @@ class TestCommandHooks:
|
||||
assert len(results) == 1
|
||||
assert results[0].exit_code == 2
|
||||
assert results[0].output.decision == "deny"
|
||||
|
||||
|
||||
def test_parse_output_allow_json_unexpected_exit_denies(self):
|
||||
"""Fail closed: allow-JSON must not bypass an unexpected exit code."""
|
||||
runner = HookRunner(HookRegistry(), cwd="/tmp")
|
||||
result = runner._parse_command_output(
|
||||
'{"decision": "allow"}', "not found", 127
|
||||
)
|
||||
assert result.decision == "deny"
|
||||
assert "127" in result.reason
|
||||
|
||||
def test_parse_output_allow_json_clean_exit_allows(self):
|
||||
"""Allow-JSON with a clean exit still allows."""
|
||||
runner = HookRunner(HookRegistry(), cwd="/tmp")
|
||||
result = runner._parse_command_output('{"decision": "allow"}', "", 0)
|
||||
assert result.decision == "allow"
|
||||
|
||||
def test_parse_output_deny_json_unexpected_exit_keeps_reason(self):
|
||||
"""Explicit deny-JSON is respected even on an unexpected exit code."""
|
||||
runner = HookRunner(HookRegistry(), cwd="/tmp")
|
||||
result = runner._parse_command_output(
|
||||
'{"decision": "deny", "reason": "policy"}', "", 127
|
||||
)
|
||||
assert result.decision == "deny"
|
||||
assert result.reason == "policy"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_command_hook_timeout(self):
|
||||
"""Test command hook timeout."""
|
||||
|
||||
@@ -10,6 +10,7 @@ that never raises.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -181,3 +182,96 @@ def test_emit_never_breaks_on_bad_stream_callback():
|
||||
fallback_index=0,
|
||||
stream_callback=boom,
|
||||
)
|
||||
|
||||
|
||||
def test_build_fallback_dispatcher_does_not_touch_shared_state(monkeypatch):
|
||||
"""The async fallback dispatcher is a throwaway bound to the fallback model
|
||||
and must never be written onto the shared ``self._unified_dispatcher`` or
|
||||
change ``self.llm`` — that is what keeps a concurrent turn on the original
|
||||
model with correct attribution.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import llm as _llm
|
||||
|
||||
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini",
|
||||
fallback_models=["gpt-4o"])
|
||||
# Avoid needing a real OpenAI client/key: stub the openai client + factory.
|
||||
agent._Agent__openai_client = object() # backing field of the lazy property
|
||||
built = []
|
||||
monkeypatch.setattr(
|
||||
_llm, "create_llm_dispatcher",
|
||||
lambda **kw: built.append(kw) or SimpleNamespace(model=kw.get("model")),
|
||||
)
|
||||
# Prime the shared dispatcher so we can assert it is left untouched.
|
||||
sentinel = object()
|
||||
agent._unified_dispatcher = sentinel
|
||||
|
||||
disp = agent._build_fallback_dispatcher("gpt-4o")
|
||||
|
||||
assert disp is not None
|
||||
assert disp is not sentinel # a distinct, throwaway dispatcher
|
||||
assert disp.model == "gpt-4o" # bound to the fallback model
|
||||
assert agent._unified_dispatcher is sentinel # shared cache untouched
|
||||
assert agent.llm == "gpt-4o-mini" # shared model untouched
|
||||
|
||||
|
||||
def test_async_fallback_passes_override_and_leaves_shared_state(monkeypatch):
|
||||
"""The async fallback branch must run the retry against a throwaway
|
||||
dispatcher passed via ``_dispatcher=`` and must NOT mutate the shared
|
||||
``self.llm`` / ``self._unified_dispatcher``, so a concurrent turn on the
|
||||
same Agent never observes the fallback model.
|
||||
|
||||
Regression for the shared-Agent async fallback race (PR #4593 review).
|
||||
Exercises the fallback branch directly with a stubbed classifier so no real
|
||||
backoff/network is involved.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents import llm as _llm
|
||||
from praisonaiagents.llm import error_classifier as _ec
|
||||
|
||||
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini",
|
||||
fallback_models=["gpt-4o"])
|
||||
original_dispatcher = object()
|
||||
agent._unified_dispatcher = original_dispatcher
|
||||
# Avoid needing a real OpenAI client/key when the fallback dispatcher builds.
|
||||
agent._Agent__openai_client = object()
|
||||
monkeypatch.setattr(
|
||||
_llm, "create_llm_dispatcher",
|
||||
lambda **kw: SimpleNamespace(model=kw.get("model")),
|
||||
)
|
||||
|
||||
# Force the classifier to request a model fallback with zero backoff.
|
||||
classification = SimpleNamespace(
|
||||
should_compress_context=False,
|
||||
should_rotate_credential=False,
|
||||
should_fallback_model=True,
|
||||
is_retryable=False,
|
||||
backoff_seconds=0,
|
||||
error_category="rate_limit",
|
||||
user_message="fallback",
|
||||
)
|
||||
monkeypatch.setattr(_ec, "classify_llm_error", lambda *a, **k: classification)
|
||||
|
||||
async def _noop_emit(*a, **k):
|
||||
return None
|
||||
agent._aemit_model_fallback = _noop_emit # type: ignore[assignment]
|
||||
|
||||
used = []
|
||||
|
||||
async def fake_execute(*args, _dispatcher=None, **kwargs):
|
||||
used.append(_dispatcher)
|
||||
return "ok-from-fallback"
|
||||
|
||||
agent._execute_unified_achat_completion = fake_execute # type: ignore[assignment]
|
||||
|
||||
result = asyncio.run(agent._handle_async_llm_error(
|
||||
RuntimeError("rate limit exceeded"),
|
||||
[{"role": "user", "content": "hi"}], None, None, True,
|
||||
False, None, None, None, None, None, True, 0, 0,
|
||||
))
|
||||
|
||||
assert result == "ok-from-fallback"
|
||||
assert used and used[0] is not None # retry ran on throwaway dispatcher
|
||||
assert used[0] is not original_dispatcher
|
||||
assert agent.llm == "gpt-4o-mini" # shared model untouched
|
||||
assert agent._unified_dispatcher is original_dispatcher # shared cache untouched
|
||||
@@ -0,0 +1,87 @@
|
||||
"""
|
||||
Tests for OpenAI-compatible routing of a bare model name + custom base_url.
|
||||
|
||||
A Chat Completions host (e.g. https://api.pzero.studio/v1) exposes catalog
|
||||
model ids like ``deepseek-v4-flash`` with no ``provider/`` prefix. Without a
|
||||
prefix LiteLLM cannot infer a provider and raises "LLM Provider NOT provided".
|
||||
The LLM class should route such a bare model through the OpenAI-compatible
|
||||
client by prefixing ``openai/`` at request-build time when a base_url is set.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.llm.llm import LLM
|
||||
|
||||
|
||||
def test_bare_model_with_base_url_routes_openai():
|
||||
llm = LLM(
|
||||
model="deepseek-v4-flash",
|
||||
base_url="https://api.pzero.studio/v1",
|
||||
api_key="test-key",
|
||||
)
|
||||
params = llm._build_completion_params()
|
||||
assert params["model"] == "openai/deepseek-v4-flash"
|
||||
assert params["base_url"] == "https://api.pzero.studio/v1"
|
||||
assert params["api_key"] == "test-key"
|
||||
|
||||
|
||||
def test_prefixed_model_with_base_url_unchanged():
|
||||
llm = LLM(
|
||||
model="openai/gpt-4o",
|
||||
base_url="https://custom.api.com/v1",
|
||||
)
|
||||
params = llm._build_completion_params()
|
||||
assert params["model"] == "openai/gpt-4o"
|
||||
|
||||
|
||||
def test_bare_model_without_base_url_unchanged():
|
||||
llm = LLM(model="gpt-4o")
|
||||
params = llm._build_completion_params()
|
||||
assert params["model"] == "gpt-4o"
|
||||
|
||||
|
||||
def test_anthropic_model_with_base_url_not_reprefixed():
|
||||
llm = LLM(
|
||||
model="claude-3-5-sonnet",
|
||||
base_url="https://proxy.example.com/v1",
|
||||
)
|
||||
params = llm._build_completion_params()
|
||||
assert params["model"] == "claude-3-5-sonnet"
|
||||
|
||||
|
||||
def test_ollama_base_url_not_reprefixed():
|
||||
llm = LLM(
|
||||
model="llama3",
|
||||
base_url="http://localhost:11434",
|
||||
)
|
||||
params = llm._build_completion_params()
|
||||
assert params["model"] == "llama3"
|
||||
|
||||
|
||||
def test_bare_model_with_subscription_base_url_routes_openai():
|
||||
"""A subscription auth provider (e.g. qwen-cli/codex) injects an
|
||||
OpenAI-compatible base_url at request time. A bare model must still be
|
||||
routed through ``openai/`` even though the instance ``base_url`` is empty.
|
||||
"""
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
llm = LLM(model="qwen3-coder-plus")
|
||||
llm._auth_provider_id = "qwen-cli"
|
||||
llm._cached_subscription_creds = SubscriptionCredentials(
|
||||
api_key="test-token",
|
||||
base_url="https://portal.qwen.ai/v1",
|
||||
)
|
||||
params = llm._build_completion_params()
|
||||
assert params["model"] == "openai/qwen3-coder-plus"
|
||||
assert params["base_url"] == "https://portal.qwen.ai/v1"
|
||||
|
||||
|
||||
def test_bare_model_no_subscription_base_url_unchanged():
|
||||
"""Subscription creds without a base_url must not trigger prefixing."""
|
||||
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
|
||||
|
||||
llm = LLM(model="gpt-4o")
|
||||
llm._auth_provider_id = "some-provider"
|
||||
llm._cached_subscription_creds = SubscriptionCredentials(api_key="test-token")
|
||||
params = llm._build_completion_params()
|
||||
assert params["model"] == "gpt-4o"
|
||||
@@ -171,7 +171,7 @@ class TestProcessConversation:
|
||||
manager = LearnManager()
|
||||
result = manager.process_conversation([])
|
||||
|
||||
assert result == {"persona": [], "insights": [], "patterns": [], "stored": {}}
|
||||
assert result == {"persona": [], "insights": [], "patterns": [], "improvements": [], "stored": {}}
|
||||
|
||||
def test_process_conversation_signature(self):
|
||||
"""process_conversation should have correct signature."""
|
||||
|
||||
@@ -384,3 +384,55 @@ Works with internationalization!
|
||||
assert "José María" in props.metadata["author"] # accented characters
|
||||
assert "naïve" in props.metadata["notes"] # accented character
|
||||
assert "émphasis" in props.metadata["notes"] # accented character
|
||||
|
||||
|
||||
class TestReadPropertiesAutomation:
|
||||
"""Tests for parsing the optional automation frontmatter block."""
|
||||
|
||||
def test_read_properties_parses_automation(self):
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "morning-brief"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text("""---
|
||||
name: morning-brief
|
||||
description: Summarise my calendar and unread priorities for the day.
|
||||
metadata:
|
||||
praisonai:
|
||||
automation:
|
||||
schedule: "cron:0 8 * * mon-fri"
|
||||
deliver: "origin"
|
||||
prompt: "Give me today's brief."
|
||||
---
|
||||
|
||||
# Morning Brief
|
||||
""")
|
||||
|
||||
props = read_properties(skill_dir)
|
||||
|
||||
assert props.automation is not None
|
||||
assert props.automation.schedule == "cron:0 8 * * mon-fri"
|
||||
assert props.automation.deliver == "origin"
|
||||
assert props.automation.prompt == "Give me today's brief."
|
||||
|
||||
def test_read_properties_no_automation_defaults_none(self):
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "plain-skill"
|
||||
skill_dir.mkdir()
|
||||
(skill_dir / "SKILL.md").write_text("""---
|
||||
name: plain-skill
|
||||
description: A skill with no automation block.
|
||||
metadata:
|
||||
author: test-org
|
||||
---
|
||||
|
||||
# Plain Skill
|
||||
""")
|
||||
|
||||
props = read_properties(skill_dir)
|
||||
|
||||
assert props.automation is None
|
||||
assert props.metadata["author"] == "test-org"
|
||||
@@ -24,6 +24,25 @@ from unittest.mock import patch, AsyncMock
|
||||
class TestCompletionDetection:
|
||||
"""Tests that completion detection uses word boundaries, not substrings."""
|
||||
|
||||
def test_completion_patterns_are_compiled_once(self, monkeypatch):
|
||||
"""Completion checks should reuse patterns compiled at module import."""
|
||||
import re
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
|
||||
compile_calls = 0
|
||||
original_compile = re.compile
|
||||
|
||||
def counting_compile(*args, **kwargs):
|
||||
nonlocal compile_calls
|
||||
compile_calls += 1
|
||||
return original_compile(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(re, "compile", counting_compile)
|
||||
|
||||
assert Agent._is_completion_signal("Task completed.") is True
|
||||
assert Agent._is_completion_signal("Still working.") is False
|
||||
assert compile_calls == 0
|
||||
|
||||
def _make_agent(self):
|
||||
"""Create a minimal autonomy-enabled agent."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Unit tests for the durable process-lifecycle primitives (Issue #4603).
|
||||
|
||||
Covers the pure, zero-I/O core seam that lets a hosted gateway detect an
|
||||
unclean/OOM death of its *previous* process at boot, classify memory/disk
|
||||
pressure for the health surface, and run a restart-loop breaker whose events
|
||||
survive the restart it guards:
|
||||
|
||||
* :class:`LifecycleRecord` (JSON round-trip),
|
||||
* :func:`classify_unclean_exit`,
|
||||
* :func:`classify_resource_pressure`, and
|
||||
* :class:`PersistentRestartLoopGuard` over a :class:`RestartStoreProtocol`.
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
LifecycleRecord,
|
||||
PersistentRestartLoopGuard,
|
||||
RestartStoreProtocol,
|
||||
classify_resource_pressure,
|
||||
classify_unclean_exit,
|
||||
)
|
||||
|
||||
|
||||
# ── LifecycleRecord round-trip ──
|
||||
|
||||
|
||||
def test_record_round_trips_through_dict():
|
||||
rec = LifecycleRecord(
|
||||
phase="running",
|
||||
exit_kind="unclean",
|
||||
suspected_oom=True,
|
||||
last_mem_fraction=0.92,
|
||||
restart_events=[1.0, 2.0, 3.0],
|
||||
)
|
||||
back = LifecycleRecord.from_dict(rec.to_dict())
|
||||
assert back == rec
|
||||
|
||||
|
||||
def test_from_dict_tolerates_none_and_junk():
|
||||
assert LifecycleRecord.from_dict(None) == LifecycleRecord()
|
||||
junk = LifecycleRecord.from_dict(
|
||||
{"phase": "bogus", "exit_kind": "nope", "restart_events": "x", "last_mem_fraction": "y"}
|
||||
)
|
||||
assert junk.phase == "running"
|
||||
assert junk.exit_kind == "unknown"
|
||||
assert junk.restart_events == []
|
||||
assert junk.last_mem_fraction is None
|
||||
|
||||
|
||||
def test_from_dict_filters_non_numeric_events():
|
||||
rec = LifecycleRecord.from_dict({"restart_events": [1.0, "bad", 3.0, None]})
|
||||
assert rec.restart_events == [1.0, 3.0]
|
||||
|
||||
|
||||
# ── classify_unclean_exit ──
|
||||
|
||||
|
||||
def test_no_prior_record_is_unknown():
|
||||
rec = classify_unclean_exit(None)
|
||||
assert rec.exit_kind == "unknown"
|
||||
assert rec.phase == "running"
|
||||
assert rec.suspected_oom is False
|
||||
|
||||
|
||||
def test_prior_exited_is_clean():
|
||||
prev = LifecycleRecord(phase="exited", exit_kind="clean")
|
||||
rec = classify_unclean_exit(prev)
|
||||
assert rec.exit_kind == "clean"
|
||||
assert rec.phase == "running"
|
||||
|
||||
|
||||
def test_prior_running_is_unclean():
|
||||
prev = LifecycleRecord(phase="running", last_mem_fraction=0.5)
|
||||
rec = classify_unclean_exit(prev)
|
||||
assert rec.exit_kind == "unclean"
|
||||
assert rec.suspected_oom is False
|
||||
|
||||
|
||||
def test_prior_running_high_mem_is_suspected_oom():
|
||||
prev = LifecycleRecord(phase="running", last_mem_fraction=0.95)
|
||||
rec = classify_unclean_exit(prev, oom_fraction=0.9)
|
||||
assert rec.exit_kind == "unclean"
|
||||
assert rec.suspected_oom is True
|
||||
|
||||
|
||||
def test_prior_draining_is_unclean():
|
||||
prev = LifecycleRecord(phase="draining")
|
||||
rec = classify_unclean_exit(prev)
|
||||
assert rec.exit_kind == "unclean"
|
||||
|
||||
|
||||
def test_restart_events_carry_forward():
|
||||
prev = LifecycleRecord(phase="running", restart_events=[1.0, 2.0])
|
||||
rec = classify_unclean_exit(prev)
|
||||
assert rec.restart_events == [1.0, 2.0]
|
||||
|
||||
|
||||
# ── classify_resource_pressure ──
|
||||
|
||||
|
||||
def test_pressure_missing_signals_are_unknown_not_ok():
|
||||
p = classify_resource_pressure()
|
||||
assert p == {"memory": "unknown", "disk": "unknown"}
|
||||
|
||||
|
||||
def test_pressure_memory_ladder():
|
||||
assert classify_resource_pressure(mem_fraction=0.1)["memory"] == "ok"
|
||||
assert classify_resource_pressure(mem_fraction=0.8)["memory"] == "elevated"
|
||||
assert classify_resource_pressure(mem_fraction=0.95)["memory"] == "critical"
|
||||
|
||||
|
||||
def test_pressure_disk_ladder():
|
||||
assert classify_resource_pressure(disk_free_mb=100, disk_min_free_mb=512)["disk"] == "critical"
|
||||
assert classify_resource_pressure(disk_free_mb=700, disk_min_free_mb=512)["disk"] == "elevated"
|
||||
assert classify_resource_pressure(disk_free_mb=5000, disk_min_free_mb=512)["disk"] == "ok"
|
||||
|
||||
|
||||
# ── PersistentRestartLoopGuard ──
|
||||
|
||||
|
||||
class _FakeStore:
|
||||
"""In-memory RestartStoreProtocol double."""
|
||||
|
||||
def __init__(self, initial: List[float] = None):
|
||||
self.data: List[float] = list(initial or [])
|
||||
self.raise_on_save = False
|
||||
|
||||
def load_events(self) -> List[float]:
|
||||
return list(self.data)
|
||||
|
||||
def save_events(self, events: List[float]) -> None:
|
||||
if self.raise_on_save:
|
||||
raise IOError("boom")
|
||||
self.data = list(events)
|
||||
|
||||
|
||||
def test_store_double_satisfies_protocol():
|
||||
assert isinstance(_FakeStore(), RestartStoreProtocol)
|
||||
|
||||
|
||||
def test_guard_persists_events_on_record():
|
||||
store = _FakeStore()
|
||||
guard = PersistentRestartLoopGuard(store, max_restarts=3, window_seconds=3600)
|
||||
assert guard.record(now=1.0) is False
|
||||
assert guard.record(now=2.0) is False
|
||||
assert guard.record(now=3.0) is True
|
||||
assert store.data == [1.0, 2.0, 3.0]
|
||||
|
||||
|
||||
def test_guard_reloads_persisted_events_across_boots():
|
||||
store = _FakeStore()
|
||||
g1 = PersistentRestartLoopGuard(store, max_restarts=3, window_seconds=1e12)
|
||||
g1.record(now=1.0)
|
||||
g1.record(now=2.0)
|
||||
# A fresh guard (new process) reloads history and is one restart from tripping.
|
||||
g2 = PersistentRestartLoopGuard(store, max_restarts=3, window_seconds=1e12)
|
||||
assert g2.tripped(now=3.0) is False
|
||||
assert g2.record(now=3.0) is True
|
||||
|
||||
|
||||
def test_guard_reset_clears_store():
|
||||
store = _FakeStore([1.0, 2.0])
|
||||
guard = PersistentRestartLoopGuard(store, max_restarts=2, window_seconds=1e12)
|
||||
guard.reset()
|
||||
assert store.data == []
|
||||
|
||||
|
||||
def test_guard_survives_store_failure():
|
||||
store = _FakeStore()
|
||||
store.raise_on_save = True
|
||||
guard = PersistentRestartLoopGuard(store, max_restarts=2, window_seconds=1e12)
|
||||
# A failing store must not crash the breaker: it degrades to in-memory.
|
||||
assert guard.record(now=1.0) is False
|
||||
assert guard.record(now=2.0) is True
|
||||
|
||||
|
||||
def test_guard_load_failure_falls_back_to_empty():
|
||||
class _Broken:
|
||||
def load_events(self):
|
||||
raise IOError("nope")
|
||||
|
||||
def save_events(self, events):
|
||||
pass
|
||||
|
||||
guard = PersistentRestartLoopGuard(_Broken(), max_restarts=2, window_seconds=1e12)
|
||||
assert guard.record(now=1.0) is False
|
||||
@@ -380,6 +380,35 @@ class TestMaxRpmAutoWiresRateLimiter:
|
||||
execution=ExecutionConfig(max_rpm=-1, rate_limiter=custom),
|
||||
)
|
||||
|
||||
def test_auto_wired_limiter_actually_throttles(self):
|
||||
"""The auto-built limiter must space requests, not just exist.
|
||||
|
||||
Uses an injectable clock/sleep so no real time passes: with
|
||||
max_rpm=1 and burst=1, the second acquire() must wait ~60s.
|
||||
"""
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.config.feature_configs import ExecutionConfig
|
||||
agent = Agent(
|
||||
name="test",
|
||||
instructions="test",
|
||||
execution=ExecutionConfig(max_rpm=1),
|
||||
)
|
||||
limiter = agent._rate_limiter
|
||||
clock = [0.0]
|
||||
slept = []
|
||||
def fake_sleep(seconds):
|
||||
slept.append(seconds)
|
||||
clock[0] += seconds
|
||||
limiter._get_time = lambda: clock[0]
|
||||
limiter._sleep = fake_sleep
|
||||
limiter.reset()
|
||||
|
||||
limiter.acquire() # consumes the single burst token, no wait
|
||||
assert slept == []
|
||||
limiter.acquire() # must wait ~60s for the next token at 1 rpm
|
||||
assert slept, "second acquire() should have blocked under max_rpm=1"
|
||||
assert abs(sum(slept) - 60.0) < 1e-6
|
||||
|
||||
|
||||
class TestLegacyKwargsCompatibility:
|
||||
"""Signature-reduction backward-compat: keyword-only guard, internal
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Cron schedules must actually fire, and must be refused when they cannot.
|
||||
|
||||
``croniter`` was declared in none of the nine pyproject.toml files, so on a
|
||||
default install every cron-kind schedule -- and every natural-language
|
||||
recurring one, since "every day at 9am" is converted to cron -- was accepted,
|
||||
persisted, and returned False from is_due() forever. No error at creation; a
|
||||
warning in a log nobody reads; a job that never runs.
|
||||
|
||||
Three guards, one per layer of the failure:
|
||||
* the dependency is declared, so a fresh install has the engine;
|
||||
* a cron job genuinely becomes due, proving the runtime path end to end;
|
||||
* creation refuses a cron schedule when the engine is absent, so the
|
||||
failure is loud at the one moment the user can act on it.
|
||||
"""
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.scheduler.due import is_due
|
||||
from praisonaiagents.scheduler.models import ScheduleJob
|
||||
from praisonaiagents.scheduler.parser import parse_schedule
|
||||
|
||||
_PYPROJECT = pathlib.Path(__file__).resolve().parents[2] / "pyproject.toml"
|
||||
EIGHT_DAYS = 8 * 86400 # covers daily AND weekly ("every monday") schedules
|
||||
|
||||
|
||||
def _job(expr: str) -> ScheduleJob:
|
||||
return ScheduleJob(name="t", schedule=parse_schedule(expr), message="hi")
|
||||
|
||||
|
||||
def test_cron_engine_is_a_declared_dependency():
|
||||
"""Packaging, not code, was the defect; pin the packaging."""
|
||||
text = _PYPROJECT.read_text()
|
||||
deps = text[text.index("dependencies = ["):]
|
||||
deps = deps[: deps.index("]")]
|
||||
assert "croniter" in deps, "croniter missing from core dependencies"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("expr", [
|
||||
"every day at 9am",
|
||||
"weekdays at 9am",
|
||||
"every monday 9am",
|
||||
"cron:0 9 * * *",
|
||||
])
|
||||
def test_recurring_schedule_actually_becomes_due(expr):
|
||||
"""The effect: eight days after creation, daily and weekly jobs are due."""
|
||||
job = _job(expr)
|
||||
assert job.schedule.kind == "cron", "these must route through cron"
|
||||
assert is_due(job, job.created_at + EIGHT_DAYS), (
|
||||
f"{expr!r} was accepted but never becomes due"
|
||||
)
|
||||
|
||||
|
||||
def test_interval_and_one_shot_do_not_need_the_engine(monkeypatch):
|
||||
"""Do not over-restrict: 'hourly' and 'in 20 minutes' never used cron."""
|
||||
monkeypatch.setitem(sys.modules, "croniter", None)
|
||||
for expr in ("hourly", "*/30m", "in 20 minutes"):
|
||||
job = _job(expr)
|
||||
assert is_due(job, job.created_at + EIGHT_DAYS)
|
||||
|
||||
|
||||
def test_cron_schedule_is_refused_when_the_engine_is_missing(monkeypatch):
|
||||
"""Loud at creation. Setting the module to None makes import raise."""
|
||||
monkeypatch.setitem(sys.modules, "croniter", None)
|
||||
with pytest.raises(ValueError, match="cron engine"):
|
||||
parse_schedule("every day at 9am")
|
||||
with pytest.raises(ValueError, match="cron engine"):
|
||||
parse_schedule("cron:0 9 * * *")
|
||||
@@ -184,3 +184,101 @@ class TestScheduleToolImportPaths:
|
||||
def test_import_scheduler_runner(self):
|
||||
from praisonaiagents.scheduler import ScheduleRunner
|
||||
assert ScheduleRunner is not None
|
||||
|
||||
|
||||
class TestNaturalLanguageSchedule:
|
||||
"""Natural-language clock-time / day-of-week schedule expressions."""
|
||||
|
||||
def test_bare_clock_time_is_one_shot(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("at 9am")
|
||||
assert s.kind == "at"
|
||||
assert s.at is not None and "09:00" in s.at
|
||||
|
||||
def test_clock_time_with_minutes_pm(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("at 9:30pm")
|
||||
assert s.kind == "at"
|
||||
assert "21:30" in s.at
|
||||
|
||||
def test_every_day_at_clock_time(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("every day at 9am")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 9 * * *"
|
||||
|
||||
def test_daily_at_clock_time(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("daily at 9am")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 9 * * *"
|
||||
|
||||
def test_weekdays_at_clock_time(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("weekdays at 9am")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 9 * * 1-5"
|
||||
|
||||
def test_weekends_at_clock_time(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("weekends at 10:30am")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "30 10 * * 0,6"
|
||||
|
||||
def test_every_single_weekday(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("every monday 9am")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 9 * * 1"
|
||||
|
||||
def test_multiple_days_comma_list(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("every mon,wed,fri at 9am")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 9 * * 1,3,5"
|
||||
|
||||
def test_24h_clock(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
s = parse_schedule("every day at 17:00")
|
||||
assert s.kind == "cron"
|
||||
assert s.cron_expr == "0 17 * * *"
|
||||
|
||||
def test_legacy_forms_unchanged(self):
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
assert parse_schedule("hourly").kind == "every"
|
||||
assert parse_schedule("*/30m").kind == "every"
|
||||
assert parse_schedule("cron:0 7 * * *").kind == "cron"
|
||||
assert parse_schedule("in 20 minutes").kind == "at"
|
||||
|
||||
def test_invalid_still_raises(self):
|
||||
import pytest
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
for bad in ("gibberish", "at bananas", "every florp"):
|
||||
with pytest.raises(ValueError):
|
||||
parse_schedule(bad)
|
||||
|
||||
def test_one_shot_clock_time_honors_timezone(self):
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
|
||||
s = parse_schedule("at 9am", tz="America/New_York")
|
||||
assert s.kind == "at"
|
||||
target = datetime.fromisoformat(s.at)
|
||||
# The clock time denotes 09:00 local (New York), not 09:00 UTC.
|
||||
assert target.tzinfo is not None
|
||||
local = target.astimezone(ZoneInfo("America/New_York"))
|
||||
assert (local.hour, local.minute) == (9, 0)
|
||||
|
||||
def test_one_shot_clock_time_is_due_at_local_hour(self):
|
||||
from datetime import datetime
|
||||
from praisonaiagents.scheduler import parse_schedule
|
||||
from praisonaiagents.scheduler.due import is_due
|
||||
from praisonaiagents.scheduler.models import ScheduleJob
|
||||
|
||||
s = parse_schedule("at 9am", tz="America/New_York")
|
||||
target = datetime.fromisoformat(s.at)
|
||||
job = ScheduleJob(name="j1", schedule=s, message="p")
|
||||
# Just before the target: not due. At/after target: due.
|
||||
assert is_due(job, target.timestamp() - 1) is False
|
||||
assert is_due(job, target.timestamp()) is True
|
||||
@@ -0,0 +1,137 @@
|
||||
"""The streaming entry point must run context compaction (Issue #4714).
|
||||
|
||||
Agent auto-enables a ContextManager whenever tools are present (auto_compact,
|
||||
compact_threshold 0.8). The non-streaming ``chat()`` path runs
|
||||
``_apply_context_management`` between building the messages and issuing the
|
||||
request; ``start(stream=True)`` built the same messages and sent them
|
||||
unmodified, so a long session streamed the entire history to the model every
|
||||
turn while the manager sat idle. The desktop's default agent has tools, so it
|
||||
hit this on every streamed turn.
|
||||
|
||||
These tests stub the transport and assert the EFFECT: the message list that
|
||||
actually reaches the transport is bounded when a ContextManager is configured,
|
||||
and untouched when it is not.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
from praisonaiagents import Agent
|
||||
|
||||
|
||||
SEEDED = 400 # ~160k estimated tokens; well past 80% of gpt-4o-mini's 128k window
|
||||
FILLER = "lorem ipsum dolor sit amet " * 60
|
||||
|
||||
|
||||
def _add(a: int, b: int) -> int:
|
||||
"""Add two numbers."""
|
||||
return a + b
|
||||
|
||||
|
||||
class _RecordingOpenAI:
|
||||
"""A proxy over the real client wrapper: everything (build_messages, tool
|
||||
formatting, ...) is delegated; only the transport call is intercepted and
|
||||
recorded. ``iter([])`` is an empty stream -- no answer, no tool calls."""
|
||||
|
||||
def __init__(self, real):
|
||||
self._real = real
|
||||
self.calls = []
|
||||
outer = self
|
||||
|
||||
class _Completions:
|
||||
def create(self, **kw):
|
||||
outer.calls.append(kw)
|
||||
return iter([])
|
||||
|
||||
class _Chat:
|
||||
completions = _Completions()
|
||||
|
||||
class _Sync:
|
||||
chat = _Chat()
|
||||
|
||||
self.sync_client = _Sync()
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._real, name)
|
||||
|
||||
|
||||
def _seed_history(agent, n=SEEDED):
|
||||
for i in range(n):
|
||||
agent.chat_history.append({
|
||||
"role": "user" if i % 2 == 0 else "assistant",
|
||||
"content": f"msg {i} {FILLER}",
|
||||
})
|
||||
|
||||
|
||||
def _stub_openai(agent):
|
||||
from praisonaiagents.llm import get_openai_client
|
||||
stub = _RecordingOpenAI(get_openai_client(api_key="sk-test"))
|
||||
agent._Agent__openai_client = stub # the lazily-built client, pre-seeded
|
||||
return stub
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _api_key(monkeypatch):
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
|
||||
|
||||
|
||||
def test_openai_stream_compacts_long_history():
|
||||
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[_add])
|
||||
assert agent.context_manager is not None, "tools present must auto-enable a ContextManager"
|
||||
_seed_history(agent)
|
||||
stub = _stub_openai(agent)
|
||||
|
||||
"".join(agent.start("x", stream=True))
|
||||
|
||||
assert stub.calls, "the stub transport was never called"
|
||||
sent = stub.calls[0]["messages"]
|
||||
# Far fewer than seeded: compaction at threshold 0.8 keeps the window
|
||||
# under budget, not merely one message under the seed count.
|
||||
assert len(sent) <= SEEDED * 0.7, (
|
||||
f"streamed request carried {len(sent)} messages for {SEEDED} seeded; "
|
||||
"context compaction did not run on the streaming path"
|
||||
)
|
||||
# Compaction must not lose the request itself.
|
||||
assert sent[0]["role"] == "system"
|
||||
assert sent[-1] == {"role": "user", "content": "x"}
|
||||
|
||||
|
||||
def test_openai_stream_without_context_manager_sends_full_history():
|
||||
"""Zero-overhead contract: context=False leaves the streamed request alone.
|
||||
|
||||
Also pins that the bounded count above is compaction and not some other
|
||||
trimming in the streaming path."""
|
||||
agent = Agent(name="t", instructions="x", llm="gpt-4o-mini", tools=[_add], context=False)
|
||||
assert agent.context_manager is None
|
||||
_seed_history(agent)
|
||||
stub = _stub_openai(agent)
|
||||
|
||||
"".join(agent.start("x", stream=True))
|
||||
|
||||
sent = stub.calls[0]["messages"]
|
||||
assert len(sent) == SEEDED + 2 # system + seeded history + new user turn
|
||||
|
||||
|
||||
def test_custom_llm_stream_compacts_long_history():
|
||||
"""The LiteLLM branch passes chat_history rather than a built messages list;
|
||||
compaction must apply to the history that is actually sent."""
|
||||
agent = Agent(name="t", instructions="x", llm="openai/gpt-4o-mini", tools=[_add])
|
||||
assert agent.context_manager is not None
|
||||
_seed_history(agent)
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_stream(**kw):
|
||||
seen.update(kw)
|
||||
yield "ok"
|
||||
|
||||
agent.llm_instance.get_response_stream = fake_stream
|
||||
|
||||
"".join(agent.start("x", stream=True))
|
||||
|
||||
assert "chat_history" in seen, "the fake transport was never called"
|
||||
history = seen["chat_history"]
|
||||
assert len(history) <= SEEDED * 0.7, (
|
||||
f"streamed custom-LLM request carried {len(history)} history messages for "
|
||||
f"{SEEDED} seeded; context compaction did not run on the streaming path"
|
||||
)
|
||||
# The turn just appended must survive compaction.
|
||||
assert history[-1] == {"role": "user", "content": "x"}
|
||||
@@ -341,6 +341,207 @@ class TestApplyPatch:
|
||||
assert _read(b) == "original b\n"
|
||||
|
||||
|
||||
class TestMoveFile:
|
||||
def test_move_renames_preserving_content(self, tools, tmp_path):
|
||||
src = tmp_path / "old.py"
|
||||
dst = tmp_path / "new.py"
|
||||
_write(src, "print('hi')\n")
|
||||
patch = f"*** Move File: {src} -> {dst}\n"
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Success" in result
|
||||
assert "Renamed" in result
|
||||
assert not os.path.exists(src)
|
||||
assert _read(dst) == "print('hi')\n"
|
||||
|
||||
def test_move_missing_source_fails(self, tools, tmp_path):
|
||||
src = tmp_path / "nope.py"
|
||||
dst = tmp_path / "new.py"
|
||||
patch = f"*** Move File: {src} -> {dst}\n"
|
||||
result = tools.apply_patch(patch)
|
||||
assert "not found" in result
|
||||
assert not os.path.exists(dst)
|
||||
|
||||
def test_move_existing_destination_fails(self, tools, tmp_path):
|
||||
src = tmp_path / "old.py"
|
||||
dst = tmp_path / "new.py"
|
||||
_write(src, "a\n")
|
||||
_write(dst, "b\n")
|
||||
patch = f"*** Move File: {src} -> {dst}\n"
|
||||
result = tools.apply_patch(patch)
|
||||
assert "destination already exists" in result
|
||||
# Both endpoints untouched.
|
||||
assert _read(src) == "a\n"
|
||||
assert _read(dst) == "b\n"
|
||||
|
||||
def test_move_with_hunks_renames_and_edits(self, tools, tmp_path):
|
||||
src = tmp_path / "foo.py"
|
||||
dst = tmp_path / "bar.py"
|
||||
_write(src, "def foo():\n return 1\n")
|
||||
patch = (
|
||||
f"*** Move File: {src} -> {dst}\n"
|
||||
"@@\n"
|
||||
" return 1\n"
|
||||
"===\n"
|
||||
" return 2\n"
|
||||
)
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Success" in result
|
||||
assert not os.path.exists(src)
|
||||
assert "return 2" in _read(dst)
|
||||
|
||||
def test_move_preserves_crlf_and_bom(self, tools, tmp_path):
|
||||
src = tmp_path / "crlf.py"
|
||||
dst = tmp_path / "crlf_new.py"
|
||||
with open(src, "wb") as f:
|
||||
f.write(b"\xef\xbb\xbfdef foo():\r\n return 1\r\n")
|
||||
patch = (
|
||||
f"*** Move File: {src} -> {dst}\n"
|
||||
"@@\n"
|
||||
" return 1\n"
|
||||
"===\n"
|
||||
" return 2\n"
|
||||
)
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Success" in result
|
||||
assert not os.path.exists(src)
|
||||
with open(dst, "rb") as f:
|
||||
data = f.read()
|
||||
assert data.startswith(b"\xef\xbb\xbf") # BOM preserved
|
||||
assert b"\r\n" in data # CRLF preserved
|
||||
assert b"return 2" in data
|
||||
|
||||
def test_move_atomic_rollback_on_later_failure(self, tools, tmp_path):
|
||||
# A move committed before a failing update must be rolled back.
|
||||
src = tmp_path / "old.py"
|
||||
dst = tmp_path / "new.py"
|
||||
missing = tmp_path / "missing.py" # update fails -> whole patch aborts
|
||||
_write(src, "content\n")
|
||||
patch = (
|
||||
f"*** Move File: {src} -> {dst}\n"
|
||||
f"*** Update File: {missing}\n"
|
||||
"@@\n"
|
||||
"x\n"
|
||||
"===\n"
|
||||
"y\n"
|
||||
)
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Error" in result
|
||||
# Source restored, destination not left behind.
|
||||
assert _read(src) == "content\n"
|
||||
assert not os.path.exists(dst)
|
||||
|
||||
def test_move_malformed_header_fails(self, tools, tmp_path):
|
||||
src = tmp_path / "old.py"
|
||||
_write(src, "x\n")
|
||||
patch = f"*** Move File: {src}\n" # no ' -> <new>'
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Invalid patch" in result
|
||||
|
||||
def test_move_stale_source_aborts(self, tools, tmp_path):
|
||||
src = tmp_path / "old.py"
|
||||
dst = tmp_path / "new.py"
|
||||
_write(src, "v1\n")
|
||||
tools.read_file(str(src)) # record read hash
|
||||
_write(src, "v2\n") # external change
|
||||
patch = f"*** Move File: {src} -> {dst}\n"
|
||||
result = tools.apply_patch(patch)
|
||||
assert "changed" in result
|
||||
assert os.path.exists(src)
|
||||
assert not os.path.exists(dst)
|
||||
# force bypasses the guard.
|
||||
assert "Success" in tools.apply_patch(patch, force=True)
|
||||
assert not os.path.exists(src)
|
||||
|
||||
def test_move_does_not_clobber_sibling_staging_file(self, tools, tmp_path):
|
||||
# A pre-existing file at the default '.praison_patch_tmp' staging name
|
||||
# for the destination must not be truncated or deleted by the move.
|
||||
src = tmp_path / "old.py"
|
||||
dst = tmp_path / "new.py"
|
||||
_write(src, "moved\n")
|
||||
sibling = tmp_path / "new.py.praison_patch_tmp"
|
||||
_write(sibling, "PRECIOUS\n")
|
||||
src_sibling = tmp_path / "old.py.praison_patch_tmp"
|
||||
_write(src_sibling, "ALSO_PRECIOUS\n")
|
||||
patch = f"*** Move File: {src} -> {dst}\n"
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Success" in result
|
||||
assert _read(dst) == "moved\n"
|
||||
# The unrelated sibling files survive untouched.
|
||||
assert _read(sibling) == "PRECIOUS\n"
|
||||
assert _read(src_sibling) == "ALSO_PRECIOUS\n"
|
||||
|
||||
def test_two_moves_to_same_destination_conflict(self, tools, tmp_path):
|
||||
# Two operations producing the same destination must be rejected up
|
||||
# front instead of one silently clobbering the other.
|
||||
a = tmp_path / "a.py"
|
||||
b = tmp_path / "b.py"
|
||||
dst = tmp_path / "out.py"
|
||||
_write(a, "AAA\n")
|
||||
_write(b, "BBB\n")
|
||||
patch = (
|
||||
f"*** Move File: {a} -> {dst}\n"
|
||||
f"*** Move File: {b} -> {dst}\n"
|
||||
)
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Conflicting operations" in result
|
||||
# Nothing committed: both sources intact, destination absent.
|
||||
assert _read(a) == "AAA\n"
|
||||
assert _read(b) == "BBB\n"
|
||||
assert not os.path.exists(dst)
|
||||
|
||||
def test_move_then_add_same_destination_conflict(self, tools, tmp_path):
|
||||
src = tmp_path / "src.py"
|
||||
dst = tmp_path / "dst.py"
|
||||
_write(src, "S\n")
|
||||
patch = (
|
||||
f"*** Move File: {src} -> {dst}\n"
|
||||
f"*** Add File: {dst}\n"
|
||||
"added\n"
|
||||
)
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Conflicting operations" in result
|
||||
assert _read(src) == "S\n"
|
||||
assert not os.path.exists(dst)
|
||||
|
||||
def test_move_into_freed_source_path_allowed(self, tools, tmp_path):
|
||||
# Moving A->B then B-content isn't relevant; but a move that frees a
|
||||
# path lets a later add reuse it within the same patch.
|
||||
a = tmp_path / "a.py"
|
||||
b = tmp_path / "b.py"
|
||||
_write(a, "A\n")
|
||||
patch = (
|
||||
f"*** Move File: {a} -> {b}\n"
|
||||
f"*** Add File: {a}\n"
|
||||
"fresh\n"
|
||||
)
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Success" in result
|
||||
assert _read(b) == "A\n"
|
||||
assert _read(a) == "fresh"
|
||||
|
||||
def test_move_rollback_rearms_source_staleness_guard(self, tools, tmp_path):
|
||||
# After a move is rolled back, a later edit of the restored source must
|
||||
# still detect an external change (staleness guard re-armed).
|
||||
src = tmp_path / "old.py"
|
||||
dst = tmp_path / "new.py"
|
||||
missing = tmp_path / "missing.py"
|
||||
_write(src, "v1\n")
|
||||
tools.read_file(str(src)) # record read hash for staleness guard
|
||||
patch = (
|
||||
f"*** Move File: {src} -> {dst}\n"
|
||||
f"*** Update File: {missing}\n"
|
||||
"@@\nx\n===\ny\n"
|
||||
)
|
||||
result = tools.apply_patch(patch)
|
||||
assert "Error" in result
|
||||
assert _read(src) == "v1\n"
|
||||
assert not os.path.exists(dst)
|
||||
# Simulate an external modification of the restored source.
|
||||
_write(src, "v2\n")
|
||||
stale = tools.edit_file(str(src), "v2", "v3")
|
||||
assert "changed since it was read" in stale
|
||||
|
||||
|
||||
class TestPostEditDiagnostics:
|
||||
"""Post-edit diagnostics feedback for the built-in editor."""
|
||||
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Tests for cooperative interrupt aborting an in-flight shell subprocess.
|
||||
|
||||
Verifies that a running shell command is terminated promptly when the agent's
|
||||
cancellation Event fires (issue #4596), instead of running until its timeout.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.tools.shell_tools import ShellTools
|
||||
from praisonaiagents.tools.injected import AgentState, with_injection_context
|
||||
from praisonaiagents.agent.interrupt import InterruptController
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_approve(monkeypatch):
|
||||
# execute_command is approval-gated (critical); auto-approve for the test.
|
||||
monkeypatch.setenv("PRAISONAI_AUTO_APPROVE", "true")
|
||||
|
||||
|
||||
def _state_with(cancel_event):
|
||||
return AgentState(
|
||||
agent_id="test",
|
||||
run_id="run",
|
||||
session_id="sess",
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
|
||||
|
||||
def test_interrupt_aborts_running_command_within_grace():
|
||||
"""A long sleep is interrupted well before its timeout and reports interrupted."""
|
||||
controller = InterruptController()
|
||||
tools = ShellTools()
|
||||
|
||||
result_holder = {}
|
||||
|
||||
def run():
|
||||
state = _state_with(controller.event)
|
||||
with with_injection_context(state):
|
||||
result_holder["result"] = tools.execute_command(
|
||||
f"{sys.executable} -c \"import time; time.sleep(30)\"",
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
worker = threading.Thread(target=run)
|
||||
start = time.time()
|
||||
worker.start()
|
||||
time.sleep(0.5) # let the subprocess start
|
||||
controller.request("user_stop")
|
||||
worker.join(timeout=10)
|
||||
elapsed = time.time() - start
|
||||
|
||||
assert not worker.is_alive(), "shell command was not aborted"
|
||||
assert elapsed < 10, f"interrupt took too long: {elapsed:.1f}s"
|
||||
result = result_holder["result"]
|
||||
assert result.get("interrupted") is True
|
||||
assert result.get("success") is False
|
||||
|
||||
|
||||
def test_no_cancel_event_preserves_normal_behaviour():
|
||||
"""Without a cancel_event a quick command still returns normally."""
|
||||
tools = ShellTools()
|
||||
result = tools.execute_command(f"{sys.executable} -c \"print('hi')\"", timeout=10)
|
||||
assert result.get("success") is True
|
||||
assert "hi" in result.get("stdout", "")
|
||||
Generated
+20
-6
@@ -850,6 +850,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/51/a2eba2f9ea7d40ddd69d533eeacf835b27946f32204705ae3333bb61b118/crawl4ai-0.8.5-py3-none-any.whl", hash = "sha256:058ff53792e238714bad0045303cade859514f4bcaea8887acb0a190c6798654", size = 500303, upload-time = "2026-03-18T03:34:22.117Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "croniter"
|
||||
version = "6.2.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "python-dateutil" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/57/2e2a65aee2a70483cb28e2b7e15a072d00a523207593b44400d4717bb100/croniter-6.2.4.tar.gz", hash = "sha256:fc124f751b1b04805c2a04b061898b436b45ab2320b045e1e052ea895de65189", size = 166267, upload-time = "2026-07-10T09:52:59.955Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/ba/d678e5bd329646ca51d3c92addbc77804e86d21f4b6b6a027218e6abb010/croniter-6.2.4-py3-none-any.whl", hash = "sha256:8ef3d544107a5c05a150a2d78f8bf5a8eb9c5c4d93405a736b824109574e3f4d", size = 46677, upload-time = "2026-07-10T09:52:58.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "46.0.5"
|
||||
@@ -1008,7 +1020,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
wheels = [
|
||||
@@ -3111,10 +3123,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.2"
|
||||
version = "1.7.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "croniter" },
|
||||
{ name = "openai" },
|
||||
{ name = "posthog" },
|
||||
{ name = "pydantic" },
|
||||
@@ -3227,6 +3240,7 @@ requires-dist = [
|
||||
{ name = "chromadb", marker = "extra == 'knowledge'", specifier = ">=1.0.0" },
|
||||
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
|
||||
{ name = "crawl4ai", marker = "extra == 'crawl'", specifier = ">=0.4.0" },
|
||||
{ name = "croniter", specifier = ">=6.0" },
|
||||
{ name = "dakera", marker = "extra == 'dakera'", specifier = ">=0.12.8" },
|
||||
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
|
||||
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
|
||||
@@ -4367,7 +4381,7 @@ resolution-markers = [
|
||||
"python_full_version < '3.11'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "numpy", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/37/6964b830433e654ec7485e45a00fc9a27cf868d622838f6b6d9c5ec0d532/scipy-1.15.3.tar.gz", hash = "sha256:eae3cf522bc7df64b42cad3925c876e1b0b6c35c1337c93e12c0f366f55b0eaf", size = 59419214, upload-time = "2025-05-08T16:13:05.955Z" }
|
||||
wheels = [
|
||||
@@ -4430,7 +4444,7 @@ resolution-markers = [
|
||||
"python_full_version == '3.11.*'",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
wheels = [
|
||||
@@ -4694,8 +4708,8 @@ name = "standard-aifc"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "audioop-lts" },
|
||||
{ name = "standard-chunk" },
|
||||
{ name = "audioop-lts", marker = "python_full_version >= '3.13'" },
|
||||
{ name = "standard-chunk", marker = "python_full_version >= '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/53/6050dc3dde1671eb3db592c13b55a8005e5040131f7509cef0215212cb84/standard_aifc-3.13.0.tar.gz", hash = "sha256:64e249c7cb4b3daf2fdba4e95721f811bde8bdfc43ad9f936589b7bb2fae2e43", size = 15240, upload-time = "2024-10-30T16:01:31.772Z" }
|
||||
wheels = [
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Subprocess execution that cannot hang the caller indefinitely.
|
||||
|
||||
Several gateway paths shell out while an asyncio event loop is waiting on
|
||||
them. ``subprocess.run`` without ``timeout=`` blocks forever if the child
|
||||
waits on stdin -- a git credential prompt, an ``index.lock`` contention, a
|
||||
confirmation prompt -- and the whole gateway stops serving.
|
||||
|
||||
``subprocess.run(timeout=...)`` alone is not enough, because it only kills
|
||||
the *direct* child:
|
||||
|
||||
* On POSIX its timeout path calls ``kill()`` then ``wait()``. The call
|
||||
returns, but any grandchild the command spawned is orphaned and keeps
|
||||
running.
|
||||
* On Windows it calls ``kill()`` then ``communicate()`` with **no timeout**.
|
||||
A surviving grandchild that inherited the captured stdout/stderr pipe holds
|
||||
it open, so that ``communicate()`` blocks until the grandchild exits --
|
||||
reintroducing exactly the indefinite hang this module exists to prevent.
|
||||
|
||||
That is not hypothetical for the calls here: ``git`` routinely spawns
|
||||
credential helpers and transport processes, and a stuck credential helper is
|
||||
the motivating failure.
|
||||
|
||||
:func:`run_guarded` therefore runs the command in its own process group (or
|
||||
Windows process group), kills the whole group on timeout, and then reaps
|
||||
with a *bounded* second wait so a stubborn descendant can never block the
|
||||
caller.
|
||||
|
||||
Note on semantics: this is a total-runtime cap, not an inactivity timer. A
|
||||
command that streams output steadily for longer than the cap is still killed.
|
||||
Callers with legitimately long work should pass an explicit ``timeout``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import signal
|
||||
import subprocess
|
||||
from typing import Mapping, Optional, Sequence
|
||||
|
||||
__all__ = ["run_guarded", "DEFAULT_TIMEOUT", "TIMEOUT_RETURNCODE", "diagnose"]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: Total seconds a guarded command may run before it is killed.
|
||||
#: Override per call, or globally via ``PRAISONAI_SUBPROCESS_TIMEOUT``.
|
||||
DEFAULT_TIMEOUT = 120.0
|
||||
|
||||
#: Returncode reported for a killed command. 124 is the conventional exit
|
||||
#: status used by coreutils ``timeout``.
|
||||
TIMEOUT_RETURNCODE = 124
|
||||
|
||||
#: Seconds allowed for the post-kill reap. Bounded on purpose: if a
|
||||
#: descendant somehow survives the group kill and still holds the pipe, we
|
||||
#: abandon the output rather than block the caller.
|
||||
REAP_TIMEOUT = 5.0
|
||||
|
||||
_ENV_TIMEOUT = "PRAISONAI_SUBPROCESS_TIMEOUT"
|
||||
|
||||
# Substrings that identify why a child process is sitting idle. Ordered most
|
||||
# specific first; the first hit wins.
|
||||
_SIGNATURES: tuple[tuple[tuple[str, ...], str], ...] = (
|
||||
(
|
||||
("username for", "password for", "passphrase", "authentication failed"),
|
||||
"DETECTED: credential prompt. The command is waiting for a login that "
|
||||
"will never arrive. Configure a credential helper, or set "
|
||||
"GIT_TERMINAL_PROMPT=0 so it fails fast instead of blocking.",
|
||||
),
|
||||
(
|
||||
("index.lock", "unable to create", "another git process"),
|
||||
"DETECTED: git lock contention. Another git process holds the "
|
||||
"repository lock. Retry once it exits, or remove a stale .git/index.lock.",
|
||||
),
|
||||
(
|
||||
("[y/n]", "(yes/no)", "are you sure", "continue?", "overwrite?", "proceed?"),
|
||||
"DETECTED: confirmation prompt. Re-run non-interactively with the "
|
||||
"command's own flag (--yes, -y, --force, --no-confirm).",
|
||||
),
|
||||
(
|
||||
("host key", "fingerprint", "known_hosts"),
|
||||
"DETECTED: SSH host-key prompt. Pre-populate known_hosts, or pass "
|
||||
"-o StrictHostKeyChecking=accept-new.",
|
||||
),
|
||||
)
|
||||
|
||||
_GENERIC = (
|
||||
"No interactive prompt was detected. The command may be genuinely slow, "
|
||||
"blocked on network I/O, or deadlocked. Raise timeout= if the work is "
|
||||
"expected to take longer."
|
||||
)
|
||||
|
||||
|
||||
def diagnose(captured: str) -> str:
|
||||
"""Return a human- and model-actionable reason a command stalled.
|
||||
|
||||
``captured`` is whatever the child emitted before it was killed; it may be
|
||||
empty, which is itself common for a process blocked on stdin.
|
||||
"""
|
||||
haystack = (captured or "").lower()
|
||||
for needles, message in _SIGNATURES:
|
||||
if any(needle in haystack for needle in needles):
|
||||
return message
|
||||
return _GENERIC
|
||||
|
||||
|
||||
def _default_timeout() -> float:
|
||||
"""Resolve the global timeout, ignoring an unusable env override.
|
||||
|
||||
``inf`` is rejected alongside zero and negatives: it parses cleanly and is
|
||||
positive, so a bare ``value > 0`` check would accept it and hand back an
|
||||
unbounded wait -- reinstating the exact hang this module prevents. ``nan``
|
||||
fails the comparison on its own, but is covered explicitly for clarity.
|
||||
"""
|
||||
raw = os.environ.get(_ENV_TIMEOUT)
|
||||
if not raw:
|
||||
return DEFAULT_TIMEOUT
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_TIMEOUT
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return DEFAULT_TIMEOUT
|
||||
return value
|
||||
|
||||
|
||||
def _non_interactive_env(env: Optional[Mapping[str, str]]) -> dict:
|
||||
"""Environment that makes common tools fail rather than prompt.
|
||||
|
||||
Suppressing the prompt at source is better than killing the process after
|
||||
it hangs: the command returns an error the caller can act on immediately.
|
||||
"""
|
||||
resolved = dict(env if env is not None else os.environ)
|
||||
resolved.setdefault("GIT_TERMINAL_PROMPT", "0")
|
||||
resolved.setdefault("GCM_INTERACTIVE", "never")
|
||||
resolved.setdefault("DEBIAN_FRONTEND", "noninteractive")
|
||||
return resolved
|
||||
|
||||
|
||||
def _kill_process_group(proc: subprocess.Popen) -> None:
|
||||
"""Terminate the command *and everything it spawned*.
|
||||
|
||||
Killing only the direct child leaves grandchildren holding the inherited
|
||||
stdout/stderr pipe, which is what turns a bounded call back into a hang.
|
||||
"""
|
||||
if os.name == "nt":
|
||||
try:
|
||||
# /T walks the tree, /F forces. Bounded so the cleanup itself
|
||||
# cannot become the new hang.
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/T", "/PID", str(proc.pid)],
|
||||
capture_output=True,
|
||||
timeout=REAP_TIMEOUT,
|
||||
)
|
||||
except Exception: # pragma: no cover - Windows-only path
|
||||
_kill_direct(proc)
|
||||
return
|
||||
|
||||
try:
|
||||
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
# Already gone, or we could not resolve the group; fall back.
|
||||
_kill_direct(proc)
|
||||
|
||||
|
||||
def _kill_direct(proc: subprocess.Popen) -> None:
|
||||
"""Last-resort kill of the direct child only."""
|
||||
try:
|
||||
proc.kill()
|
||||
except Exception: # pragma: no cover - process already reaped
|
||||
pass
|
||||
|
||||
|
||||
def _reap(proc: subprocess.Popen) -> tuple[str, str]:
|
||||
"""Collect whatever output is available without blocking indefinitely."""
|
||||
try:
|
||||
return proc.communicate(timeout=REAP_TIMEOUT)
|
||||
except subprocess.TimeoutExpired:
|
||||
# A descendant survived the group kill and still holds the pipe.
|
||||
# Abandon the output rather than block the caller -- returning late
|
||||
# is the failure mode this module exists to prevent.
|
||||
logger.warning(
|
||||
"Guarded command %s left a descendant holding its output pipe; "
|
||||
"abandoning captured output after %.0fs.",
|
||||
proc.args,
|
||||
REAP_TIMEOUT,
|
||||
)
|
||||
for stream in (proc.stdout, proc.stderr):
|
||||
try:
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
except Exception: # pragma: no cover - best effort
|
||||
pass
|
||||
return "", ""
|
||||
except Exception: # pragma: no cover - defensive
|
||||
return "", ""
|
||||
|
||||
|
||||
def run_guarded(
|
||||
cmd: Sequence[str],
|
||||
*,
|
||||
timeout: Optional[float] = None,
|
||||
cwd: Optional[str] = None,
|
||||
env: Optional[Mapping[str, str]] = None,
|
||||
interactive: bool = False,
|
||||
check: bool = False,
|
||||
text: bool = True,
|
||||
capture_output: bool = True,
|
||||
**kwargs,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run ``cmd`` with a bounded runtime, never raising on timeout.
|
||||
|
||||
Behaves like ``subprocess.run(..., capture_output=True, text=True)``. On
|
||||
timeout the whole process group is killed and a
|
||||
:class:`subprocess.CompletedProcess` is returned with returncode
|
||||
:data:`TIMEOUT_RETURNCODE` and a diagnosis appended to stderr, so existing
|
||||
``returncode == 0`` checks keep working instead of having to grow a new
|
||||
exception path.
|
||||
|
||||
Args:
|
||||
cmd: Command and arguments.
|
||||
timeout: Total seconds allowed. Defaults to
|
||||
``PRAISONAI_SUBPROCESS_TIMEOUT`` or :data:`DEFAULT_TIMEOUT`.
|
||||
cwd: Working directory.
|
||||
env: Environment; prompt-suppressing defaults are layered underneath.
|
||||
interactive: Set True only for a command that must reach a terminal.
|
||||
Skips prompt suppression and stdin redirection; the timeout and
|
||||
the group kill still apply.
|
||||
check: Raise ``CalledProcessError`` on a non-zero exit, matching
|
||||
``subprocess.run``. A timeout still returns rather than raising.
|
||||
text: Decode output as text.
|
||||
capture_output: Capture stdout and stderr.
|
||||
**kwargs: Forwarded to ``subprocess.Popen``.
|
||||
"""
|
||||
resolved_timeout = _default_timeout() if timeout is None else timeout
|
||||
|
||||
if capture_output:
|
||||
kwargs.setdefault("stdout", subprocess.PIPE)
|
||||
kwargs.setdefault("stderr", subprocess.PIPE)
|
||||
|
||||
# stdin at /dev/null turns "wait forever for input" into an immediate EOF
|
||||
# for tools that honour it, so the timeout is a backstop rather than the
|
||||
# primary defence.
|
||||
if not interactive:
|
||||
kwargs.setdefault("stdin", subprocess.DEVNULL)
|
||||
|
||||
# Isolate the command so the whole tree can be killed together.
|
||||
if os.name == "nt":
|
||||
kwargs["creationflags"] = (
|
||||
kwargs.get("creationflags", 0) | subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
)
|
||||
else:
|
||||
kwargs.setdefault("start_new_session", True)
|
||||
|
||||
argv = list(cmd)
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=env if interactive else _non_interactive_env(env),
|
||||
text=text,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = proc.communicate(timeout=resolved_timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
_kill_process_group(proc)
|
||||
stdout, stderr = _reap(proc)
|
||||
stdout = _as_text(stdout)
|
||||
stderr = _as_text(stderr)
|
||||
# Built outside the f-string: a backslash inside an f-string
|
||||
# expression is a syntax error before Python 3.12, and this package
|
||||
# supports 3.10+.
|
||||
captured = stdout + "\n" + stderr
|
||||
detail = (
|
||||
f"Command killed after {resolved_timeout:g}s: {' '.join(argv)}\n"
|
||||
f"{diagnose(captured)}"
|
||||
)
|
||||
return subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=TIMEOUT_RETURNCODE,
|
||||
stdout=stdout,
|
||||
stderr=(stderr + "\n" + detail).strip(),
|
||||
)
|
||||
except BaseException:
|
||||
# Never leave a running child behind on an unexpected error, including
|
||||
# KeyboardInterrupt.
|
||||
_kill_process_group(proc)
|
||||
_reap(proc)
|
||||
raise
|
||||
|
||||
result = subprocess.CompletedProcess(
|
||||
args=argv,
|
||||
returncode=proc.returncode,
|
||||
stdout=_as_text(stdout) if capture_output else stdout,
|
||||
stderr=_as_text(stderr) if capture_output else stderr,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise subprocess.CalledProcessError(
|
||||
result.returncode, argv, result.stdout, result.stderr
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _as_text(value) -> str:
|
||||
"""Normalise stdout/stderr, which may be bytes, None, or already str."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("utf-8", errors="replace")
|
||||
return str(value)
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.1.2"
|
||||
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
|
||||
from .local import LocalBot
|
||||
from .webhook import WebhookBot, WebhookRoute
|
||||
from .bot import Bot
|
||||
from .botos import BotOS
|
||||
from .botos import BotOS, ThreadRef
|
||||
from ._session import BotSessionManager
|
||||
from ._identity import StoreBackedIdentityResolver
|
||||
from ._agent_registry import AgentRegistry, normalize_number
|
||||
@@ -90,6 +90,9 @@ def __getattr__(name: str):
|
||||
if name == "BotOS":
|
||||
from .botos import BotOS
|
||||
return BotOS
|
||||
if name == "ThreadRef":
|
||||
from .botos import ThreadRef
|
||||
return ThreadRef
|
||||
if name == "SlackApproval":
|
||||
from ._slack_approval import SlackApproval
|
||||
return SlackApproval
|
||||
@@ -217,7 +220,7 @@ __all__ = [
|
||||
"SignalBot",
|
||||
"LocalBot",
|
||||
"WebhookBot", "WebhookRoute",
|
||||
"Bot", "BotOS",
|
||||
"Bot", "BotOS", "ThreadRef",
|
||||
"BotSessionManager",
|
||||
"StoreBackedIdentityResolver",
|
||||
"AgentRegistry", "normalize_number",
|
||||
|
||||
@@ -0,0 +1,342 @@
|
||||
"""Distributed per-turn lock for the gateway (Issue #4655).
|
||||
|
||||
The gateway's per-turn serialisation seam (``BotSessionManager._locks``) consumes
|
||||
a ``LockMap``-shaped object: ``.get(key)`` returns something usable in
|
||||
``async with`` that serialises turns for that key. The default in-process
|
||||
``LockMap`` only serialises *within one process*, so a horizontally-scaled
|
||||
gateway (``replicas > 1``) still runs concurrent turns against one resolved
|
||||
session — the failure ``gateway.turn_lock.backend="redis"`` promises to prevent.
|
||||
|
||||
``RedisTurnLock`` is a drop-in, ``LockMap``-compatible replacement that acquires
|
||||
a cluster-wide lease per resolved-session key using the scheduler's proven
|
||||
owner+TTL lease pattern (``SET NX PX`` to claim, compare-and-del to release), so
|
||||
a crashed holder's lease auto-expires after ``ttl`` and can never wedge a healthy
|
||||
session. On any Redis outage it **fails open** (degrades to a local
|
||||
``asyncio.Lock`` and records a ``DegradedCapabilityRegistry`` entry) rather than
|
||||
wedging a live session — mirroring the fail-safe defaults elsewhere in the
|
||||
gateway.
|
||||
|
||||
The heavy Redis dependency and connection lifecycle live here in the wrapper/bot
|
||||
package; core only owns the protocol and config (``TurnLockProtocol`` /
|
||||
``TurnLockConfig``), keeping the protocol-driven core dependency-free.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ``owner_kind`` must be one of the core ``OWNER_KINDS`` (capability fits a
|
||||
# gateway-internal serialisation control); ``owner_id`` names the mechanism.
|
||||
_DEGRADED_OWNER_KIND = "capability"
|
||||
_DEGRADED_OWNER_ID = "turn_lock:redis"
|
||||
|
||||
|
||||
class _RedisLease:
|
||||
"""Async context manager holding one distributed lease for a key.
|
||||
|
||||
Claims ``key`` with ``SET NX PX`` (owner token + TTL). On acquire failure it
|
||||
polls until the current holder's lease is free or expires. On a Redis outage
|
||||
it falls back to the local lock so a live turn is never wedged (fail-open).
|
||||
Release is a compare-and-del on the owner token: an expired/reclaimed lease
|
||||
is a harmless no-op, never another replica's lease.
|
||||
"""
|
||||
|
||||
def __init__(self, lock: "RedisTurnLock", key: str) -> None:
|
||||
self._lock = lock
|
||||
self._redis_key = lock._redis_key(key)
|
||||
self._local_key = key
|
||||
self._token = uuid.uuid4().hex
|
||||
self._local_lock: Optional[asyncio.Lock] = None
|
||||
self._have_remote = False
|
||||
self._renew_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def __aenter__(self) -> "_RedisLease":
|
||||
# Always hold the in-process lock too: it serialises same-process
|
||||
# waiters cheaply and is the fail-open fallback when Redis is down.
|
||||
self._local_lock = self._lock._local.get(self._local_key)
|
||||
await self._local_lock.acquire()
|
||||
# Any failure or cancellation after this point (e.g. BotOS.stop cancels
|
||||
# in-flight bot tasks during the drain window) must release the local
|
||||
# lock — otherwise the held asyncio.Lock is never evicted and every later
|
||||
# turn for this session blocks forever in-process.
|
||||
try:
|
||||
ttl_ms = max(1, int(self._lock._ttl * 1000))
|
||||
poll = self._lock._poll_interval
|
||||
while True:
|
||||
try:
|
||||
ok = await self._lock._client.set(
|
||||
self._redis_key, self._token, nx=True, px=ttl_ms
|
||||
)
|
||||
except Exception as e: # Redis outage -> fail open on local lock.
|
||||
self._lock._mark_degraded(str(e))
|
||||
self._have_remote = False
|
||||
return self
|
||||
if ok:
|
||||
self._lock._clear_degraded()
|
||||
self._have_remote = True
|
||||
# Renew the lease while the turn runs so a turn longer than
|
||||
# ``ttl`` never lets another replica claim the same session.
|
||||
self._renew_task = asyncio.ensure_future(self._renew_loop())
|
||||
return self
|
||||
# Held by another replica; poll until its lease frees or expires
|
||||
# (the holder's ``px`` TTL guarantees this loop is bounded).
|
||||
await asyncio.sleep(poll)
|
||||
except BaseException:
|
||||
if self._local_lock.locked():
|
||||
self._local_lock.release()
|
||||
raise
|
||||
|
||||
async def _renew_loop(self) -> None:
|
||||
"""Extend our lease at ~ttl/3 so a long turn keeps the session (owner-checked)."""
|
||||
interval = max(self._lock._poll_interval, self._lock._ttl / 3.0)
|
||||
ttl_ms = max(1, int(self._lock._ttl * 1000))
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval)
|
||||
try:
|
||||
await self._lock._renew_remote(
|
||||
self._redis_key, self._token, ttl_ms
|
||||
)
|
||||
except Exception as e: # pragma: no cover - best-effort renewal
|
||||
logger.debug("Failed to renew redis turn lease: %s", e)
|
||||
except asyncio.CancelledError: # pragma: no cover - normal teardown
|
||||
pass
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
try:
|
||||
if self._renew_task is not None:
|
||||
self._renew_task.cancel()
|
||||
try:
|
||||
await self._renew_task
|
||||
except (asyncio.CancelledError, Exception): # pragma: no cover
|
||||
pass
|
||||
if self._have_remote:
|
||||
try:
|
||||
await self._lock._release_remote(self._redis_key, self._token)
|
||||
except Exception as e: # pragma: no cover - best-effort release
|
||||
logger.debug("Failed to release redis turn lease: %s", e)
|
||||
finally:
|
||||
if self._local_lock is not None and self._local_lock.locked():
|
||||
self._local_lock.release()
|
||||
|
||||
|
||||
class RedisTurnLock:
|
||||
"""Distributed, ``LockMap``-compatible per-turn lock (Issue #4655).
|
||||
|
||||
Exposes the same ``.get(key)`` / ``.drop(key)`` / ``.size()`` surface as
|
||||
:class:`~praisonai_bot._lockmap.LockMap` so it drops into the existing
|
||||
``BotSessionManager._locks`` seam with no session-layer change. Each
|
||||
``.get(key)`` returns an async context manager that holds a cluster-wide
|
||||
lease for the resolved-session key while a turn runs.
|
||||
"""
|
||||
|
||||
# Lua: delete only if the value still equals our owner token (atomic
|
||||
# compare-and-del), so we never release a lease another replica reclaimed.
|
||||
_RELEASE_LUA = (
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||
"return redis.call('del', KEYS[1]) else return 0 end"
|
||||
)
|
||||
|
||||
# Lua: extend the TTL only if the value still equals our owner token, so a
|
||||
# lease another replica reclaimed after expiry is never extended.
|
||||
_RENEW_LUA = (
|
||||
"if redis.call('get', KEYS[1]) == ARGV[1] then "
|
||||
"return redis.call('pexpire', KEYS[1], ARGV[2]) else return 0 end"
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: Any,
|
||||
*,
|
||||
ttl: float = 60.0,
|
||||
prefix: str = "praison:turnlock:",
|
||||
poll_interval: float = 0.05,
|
||||
degraded_registry: Optional[Any] = None,
|
||||
) -> None:
|
||||
from .._lockmap import LockMap
|
||||
|
||||
self._client = client
|
||||
self._ttl = float(ttl)
|
||||
self._prefix = prefix
|
||||
self._poll_interval = poll_interval
|
||||
self._local = LockMap()
|
||||
self._degraded_registry = degraded_registry
|
||||
self._degraded = False
|
||||
|
||||
def _redis_key(self, key: str) -> str:
|
||||
return f"{self._prefix}{key}"
|
||||
|
||||
def get(self, key: str) -> _RedisLease:
|
||||
"""Return an async context manager holding the lease for ``key``."""
|
||||
return _RedisLease(self, str(key))
|
||||
|
||||
def drop(self, key: str) -> None:
|
||||
"""Best-effort local cleanup; the remote lease expires via TTL."""
|
||||
try:
|
||||
self._local.drop(str(key))
|
||||
except Exception: # pragma: no cover - defensive
|
||||
pass
|
||||
|
||||
def size(self) -> int:
|
||||
return self._local.size()
|
||||
|
||||
async def _release_remote(self, redis_key: str, token: str) -> None:
|
||||
try:
|
||||
await self._client.eval(self._RELEASE_LUA, 1, redis_key, token)
|
||||
except Exception:
|
||||
# Fallback for clients without eval: best-effort compare-and-del.
|
||||
current = await self._client.get(redis_key)
|
||||
if current == token:
|
||||
await self._client.delete(redis_key)
|
||||
|
||||
async def _renew_remote(self, redis_key: str, token: str, ttl_ms: int) -> None:
|
||||
"""Owner-checked lease extension (compare-and-PEXPIRE)."""
|
||||
try:
|
||||
await self._client.eval(self._RENEW_LUA, 1, redis_key, token, ttl_ms)
|
||||
except Exception:
|
||||
# Fallback for clients without eval: renew only if still ours.
|
||||
current = await self._client.get(redis_key)
|
||||
if current == token:
|
||||
# Re-claim (SET without NX) preserving the owner token + TTL.
|
||||
await self._client.set(redis_key, token, px=ttl_ms)
|
||||
|
||||
def _mark_degraded(self, reason: str) -> None:
|
||||
"""Record a degraded turn-lock capability (Issue #4655 fail-open)."""
|
||||
already = self._degraded
|
||||
self._degraded = True
|
||||
if not already:
|
||||
logger.warning(
|
||||
"Distributed turn lock degraded to local-only "
|
||||
"(Redis unavailable): %s", reason
|
||||
)
|
||||
registry = self._degraded_registry
|
||||
if registry is None:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.gateway.degraded_state import DegradedOwner
|
||||
|
||||
registry.mark(
|
||||
DegradedOwner(
|
||||
owner_kind=_DEGRADED_OWNER_KIND,
|
||||
owner_id=_DEGRADED_OWNER_ID,
|
||||
state="stale",
|
||||
reason=f"distributed turn lock unavailable: {reason}",
|
||||
retry_hint=(
|
||||
"check Redis connectivity; turns serialise per-process "
|
||||
"only until it recovers; see `praisonai gateway doctor`"
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e: # pragma: no cover - registry is best-effort
|
||||
logger.debug("Failed to mark turn-lock degraded: %s", e)
|
||||
|
||||
def _clear_degraded(self) -> None:
|
||||
if not self._degraded:
|
||||
return
|
||||
self._degraded = False
|
||||
registry = self._degraded_registry
|
||||
if registry is None:
|
||||
return
|
||||
try:
|
||||
registry.clear(_DEGRADED_OWNER_KIND, _DEGRADED_OWNER_ID)
|
||||
except Exception as e: # pragma: no cover - registry is best-effort
|
||||
logger.debug("Failed to clear turn-lock degraded: %s", e)
|
||||
|
||||
|
||||
def build_turn_lock(
|
||||
turn_lock_config: Optional[Any],
|
||||
redis_config: Optional[Any] = None,
|
||||
*,
|
||||
degraded_registry: Optional[Any] = None,
|
||||
) -> Any:
|
||||
"""Select the per-turn lock backend from ``TurnLockConfig`` (Issue #4655).
|
||||
|
||||
Returns a ``LockMap``-compatible object. For the default ``"local"`` backend
|
||||
(or when no config is given) this is a plain in-process ``LockMap`` — today's
|
||||
behaviour, byte-for-byte. For ``"redis"`` it constructs a
|
||||
:class:`RedisTurnLock` reusing the gateway's configured ``RedisConfig`` (or
|
||||
the ``turn_lock.url`` override), so turns serialise cluster-wide.
|
||||
|
||||
Construction fails *open*: if Redis cannot be reached the caller gets a
|
||||
degraded ``RedisTurnLock`` (local fallback + a ``DegradedCapabilityRegistry``
|
||||
entry) rather than a silent downgrade — the outage surfaces in
|
||||
``health()`` / ``doctor``.
|
||||
"""
|
||||
from .._lockmap import LockMap
|
||||
|
||||
enabled = bool(getattr(turn_lock_config, "enabled", False))
|
||||
if turn_lock_config is None or not enabled:
|
||||
return LockMap()
|
||||
|
||||
url = getattr(turn_lock_config, "url", None)
|
||||
ttl = float(getattr(turn_lock_config, "ttl", 60.0))
|
||||
|
||||
try:
|
||||
import redis.asyncio as aioredis
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
"gateway.turn_lock.backend='redis' selected but redis[asyncio] is "
|
||||
"not installed; turns serialise per-process only. "
|
||||
"Install with: pip install redis"
|
||||
)
|
||||
_record_build_failure(degraded_registry, "redis package not installed")
|
||||
return LockMap()
|
||||
|
||||
try:
|
||||
if url:
|
||||
client = aioredis.from_url(url, decode_responses=True)
|
||||
elif redis_config is not None and getattr(redis_config, "url", None):
|
||||
client = aioredis.from_url(redis_config.url, decode_responses=True)
|
||||
elif redis_config is not None:
|
||||
client = aioredis.Redis(
|
||||
host=getattr(redis_config, "host", "localhost"),
|
||||
port=getattr(redis_config, "port", 6379),
|
||||
db=getattr(redis_config, "db", 0),
|
||||
password=getattr(redis_config, "password", None),
|
||||
decode_responses=True,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"gateway.turn_lock.backend='redis' selected but no Redis URL or "
|
||||
"RedisConfig is available; turns serialise per-process only."
|
||||
)
|
||||
_record_build_failure(degraded_registry, "no Redis connection configured")
|
||||
return LockMap()
|
||||
except Exception as e: # pragma: no cover - construction is defensive
|
||||
logger.warning("Failed to build distributed turn lock: %s", e)
|
||||
_record_build_failure(degraded_registry, str(e))
|
||||
return LockMap()
|
||||
|
||||
prefix = getattr(redis_config, "prefix", "praison:") if redis_config else "praison:"
|
||||
return RedisTurnLock(
|
||||
client,
|
||||
ttl=ttl,
|
||||
prefix=f"{prefix}turnlock:",
|
||||
degraded_registry=degraded_registry,
|
||||
)
|
||||
|
||||
|
||||
def _record_build_failure(registry: Optional[Any], reason: str) -> None:
|
||||
if registry is None:
|
||||
return
|
||||
try:
|
||||
from praisonaiagents.gateway.degraded_state import DegradedOwner
|
||||
|
||||
registry.mark(
|
||||
DegradedOwner(
|
||||
owner_kind=_DEGRADED_OWNER_KIND,
|
||||
owner_id=_DEGRADED_OWNER_ID,
|
||||
state="stale",
|
||||
reason=f"distributed turn lock unavailable: {reason}",
|
||||
retry_hint=(
|
||||
"turns serialise per-process only; see "
|
||||
"`praisonai gateway doctor`"
|
||||
),
|
||||
)
|
||||
)
|
||||
except Exception as e: # pragma: no cover - registry is best-effort
|
||||
logger.debug("Failed to record turn-lock build failure: %s", e)
|
||||
@@ -37,7 +37,7 @@ import asyncio
|
||||
import logging
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Dict, Optional, Any, TYPE_CHECKING
|
||||
from typing import Callable, Dict, Optional, Any, TYPE_CHECKING
|
||||
from .._lockmap import LockMap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -46,6 +46,45 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def make_approval_supersede_callback(
|
||||
manager: Optional[Any] = None,
|
||||
) -> Callable[[str, int], None]:
|
||||
"""Build an ``on_supersede`` callback that cancels a turn's gateway approvals.
|
||||
|
||||
Bridges :class:`SessionRunControl` to the gateway's ``ExecApprovalManager``
|
||||
so a ``/stop`` (or an interrupting message) fail-closes any approval parked
|
||||
on the abandoned turn: the awaiting tool call unwinds promptly and a
|
||||
resolution that arrives after the stop is dropped.
|
||||
|
||||
The returned callback maps ``user_id`` → the approval manager's
|
||||
``session_id`` (a bot user *is* the approval session in the single-agent bot
|
||||
flow) and calls ``cancel_for_generation``. It is intentionally best-effort:
|
||||
any failure (e.g. the gateway package or manager singleton being absent in a
|
||||
lightweight deployment) is logged and swallowed so stop/interrupt handling is
|
||||
never broken.
|
||||
|
||||
Args:
|
||||
manager: Optional explicit ``ExecApprovalManager``. Resolved lazily from
|
||||
the gateway singleton when ``None`` so importing this module never
|
||||
pulls in the gateway.
|
||||
"""
|
||||
|
||||
def _on_supersede(user_id: str, generation: int) -> None:
|
||||
mgr = manager
|
||||
try:
|
||||
if mgr is None:
|
||||
from ..gateway.exec_approval import get_exec_approval_manager
|
||||
mgr = get_exec_approval_manager()
|
||||
mgr.cancel_for_generation(user_id, generation)
|
||||
except Exception: # noqa: BLE001 - callback must not break run control
|
||||
logger.debug(
|
||||
"Approval supersede callback no-op for user=%s generation=%s",
|
||||
user_id, generation, exc_info=True,
|
||||
)
|
||||
|
||||
return _on_supersede
|
||||
|
||||
|
||||
class RunDecision(Enum):
|
||||
"""Decision for how to handle a submitted message."""
|
||||
RUN_NOW = "run_now" # No current run, start immediately
|
||||
@@ -86,13 +125,21 @@ class SessionRunControl:
|
||||
def __init__(
|
||||
self,
|
||||
busy_mode: str = "queue",
|
||||
busy_ack_template: str = "⏳ {action} — will be considered next"
|
||||
busy_ack_template: str = "⏳ {action} — will be considered next",
|
||||
on_supersede: Optional[Callable[[str, int], Any]] = None,
|
||||
):
|
||||
"""Initialize session run control.
|
||||
|
||||
Args:
|
||||
busy_mode: Policy for mid-run messages ("queue", "interrupt", "steer")
|
||||
busy_ack_template: Template for busy acknowledgment messages
|
||||
on_supersede: Optional callback invoked as ``(user_id, generation)``
|
||||
whenever a run is stopped or interrupted, where ``generation`` is
|
||||
the run generation being abandoned. The gateway wires this to
|
||||
cancel pending approvals bound to that turn so a ``/stop`` (or a
|
||||
superseding message) unblocks an approval-parked turn and drops
|
||||
a resolution that arrives after the turn is gone. ``None``
|
||||
(default) keeps today's behaviour (no approval cancellation).
|
||||
"""
|
||||
try:
|
||||
self._busy_mode = BusyMode(busy_mode)
|
||||
@@ -101,9 +148,26 @@ class SessionRunControl:
|
||||
self._busy_mode = BusyMode.QUEUE
|
||||
|
||||
self._busy_ack_template = busy_ack_template
|
||||
self._on_supersede = on_supersede
|
||||
self._sessions: Dict[str, SessionRunState] = {}
|
||||
self._locks = LockMap()
|
||||
|
||||
def _notify_supersede(self, user_id: str, generation: int) -> None:
|
||||
"""Best-effort notify that ``generation`` for ``user_id`` was abandoned.
|
||||
|
||||
Failures in the callback must never break stop/interrupt handling, so
|
||||
exceptions are logged and swallowed.
|
||||
"""
|
||||
if self._on_supersede is None:
|
||||
return
|
||||
try:
|
||||
self._on_supersede(user_id, generation)
|
||||
except Exception: # noqa: BLE001 - callback must not break run control
|
||||
logger.exception(
|
||||
"on_supersede callback failed for user=%s generation=%s",
|
||||
user_id, generation,
|
||||
)
|
||||
|
||||
def _get_lock(self, user_id: str) -> asyncio.Lock:
|
||||
"""Get or create lock for user."""
|
||||
return self._locks.get(user_id)
|
||||
@@ -150,7 +214,12 @@ class SessionRunControl:
|
||||
# Cancel current run and start new one
|
||||
if session.interrupt_controller:
|
||||
session.interrupt_controller.request("user_new_message")
|
||||
|
||||
|
||||
# Cancel any pending approval bound to the turn being superseded
|
||||
# so an approval-parked turn unwinds and a late resolution for it
|
||||
# is dropped, before we advance the generation.
|
||||
self._notify_supersede(user_id, session.run_generation)
|
||||
|
||||
session.pending_message = None # Clear any pending
|
||||
session.run_generation += 1
|
||||
session.start_time = time.time()
|
||||
@@ -247,7 +316,12 @@ class SessionRunControl:
|
||||
# Request interrupt
|
||||
if session.interrupt_controller:
|
||||
session.interrupt_controller.request("user_stop_command")
|
||||
|
||||
|
||||
# Cancel any pending approval bound to this turn so a turn parked on
|
||||
# a blocking approval wait unwinds promptly (making /stop visible)
|
||||
# and a resolution that arrives after the stop is dropped.
|
||||
self._notify_supersede(user_id, session.run_generation)
|
||||
|
||||
# Clear state
|
||||
session.is_running = False
|
||||
session.pending_message = None
|
||||
|
||||
@@ -194,6 +194,7 @@ class BotSessionManager:
|
||||
platform: str = "",
|
||||
dlq: Optional[Any] = None,
|
||||
identity_resolver: Optional[Any] = None,
|
||||
identity_canonicalizer: Optional[Any] = None,
|
||||
ingress_journal: Optional[Any] = None,
|
||||
run_control: Optional[Any] = None,
|
||||
run_timeout: float = 300.0, # 5 minutes default timeout
|
||||
@@ -245,6 +246,16 @@ class BotSessionManager:
|
||||
# session key is the resolver-returned unified user id, so the
|
||||
# same human pinging from multiple platforms shares one history.
|
||||
self._identity_resolver = identity_resolver
|
||||
# Issue #4661: optional identity-canonicalization seam. An
|
||||
# ``IdentityCanonicalizerProtocol`` (``canonicalize(platform,
|
||||
# raw_user_id) -> str`` — typically the active adapter itself, since
|
||||
# ``BasePlatformAdapter`` implements it) consulted on the raw platform
|
||||
# id *before* it enters the session key / resolver lookup, so a
|
||||
# platform-side id change for the *same* human (WhatsApp JID→LID, a
|
||||
# number↔UUID alias flip) collapses to one stable session instead of
|
||||
# silently forking history/memory/run-state. ``None`` (default) is the
|
||||
# identity function — behaviour is byte-for-byte unchanged.
|
||||
self._identity_canonicalizer = identity_canonicalizer
|
||||
# Ingress journal: optional durable message processing with dedup.
|
||||
# When set, messages are journaled before agent processing for
|
||||
# crash recovery and webhook redelivery protection.
|
||||
@@ -409,6 +420,13 @@ class BotSessionManager:
|
||||
identity_resolver = getattr(runtime, "identity_resolver", None)
|
||||
if identity_resolver is not None:
|
||||
self._identity_resolver = identity_resolver
|
||||
# Issue #4661: adopt an identity-canonicalization seam when the runtime
|
||||
# carries one (an ``IdentityCanonicalizerProtocol``, e.g. the active
|
||||
# adapter itself), so a volatile platform id is stabilised before it
|
||||
# keys a session. Absent seam leaves the identity default untouched.
|
||||
identity_canonicalizer = getattr(runtime, "identity_canonicalizer", None)
|
||||
if identity_canonicalizer is not None:
|
||||
self._identity_canonicalizer = identity_canonicalizer
|
||||
delivery_router = getattr(runtime, "delivery_router", None)
|
||||
if delivery_router is not None:
|
||||
self._delivery_router = delivery_router
|
||||
@@ -578,6 +596,29 @@ class BotSessionManager:
|
||||
return "per_user"
|
||||
return "per_chat"
|
||||
|
||||
def _canonical_user_id(self, user_id: str) -> str:
|
||||
"""Stabilise a volatile raw platform id before it keys a session.
|
||||
|
||||
Applies the optional identity-canonicalization seam (Issue #4661): an
|
||||
``IdentityCanonicalizerProtocol`` (``canonicalize(platform,
|
||||
raw_user_id) -> str`` — typically the active adapter, since
|
||||
``BasePlatformAdapter`` implements it) wired in via
|
||||
``__init__``/``attach_gateway_runtime``. Lets a platform-side id change
|
||||
for the same human (WhatsApp JID→LID, a number↔UUID alias flip) collapse
|
||||
to one stable session key instead of silently forking. No-op (identity)
|
||||
when none is wired, and fail-open: any error falls back to the raw id so
|
||||
a buggy canonicalizer can never break session keying.
|
||||
"""
|
||||
canonicalizer = self._identity_canonicalizer
|
||||
if canonicalizer is None:
|
||||
return user_id
|
||||
try:
|
||||
canonical = canonicalizer.canonicalize(self._platform, user_id)
|
||||
except Exception as e: # pragma: no cover — defensive, never break keying
|
||||
logger.warning("identity canonicalizer failed: %s", e)
|
||||
return user_id
|
||||
return canonical if isinstance(canonical, str) and canonical else user_id
|
||||
|
||||
def _storage_key(
|
||||
self,
|
||||
user_id: str,
|
||||
@@ -618,14 +659,23 @@ class BotSessionManager:
|
||||
prefix = self._platform or "bot"
|
||||
account_key = account or "default"
|
||||
base = f"{prefix}:acct:{account_key}:chat:{chat_id}:{thread_id}"
|
||||
elif self._identity_resolver is not None and self._platform:
|
||||
try:
|
||||
base = self._identity_resolver.resolve(self._platform, user_id)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.warning("identity resolver failed: %s", e)
|
||||
base = user_id
|
||||
else:
|
||||
base = user_id
|
||||
# Issue #4661: canonicalise a volatile platform id to a stable one
|
||||
# BEFORE it enters the session key or the link/pairing resolver, so
|
||||
# a platform-side id change for the same human (WhatsApp JID→LID, a
|
||||
# number↔UUID alias flip) resolves to one session rather than
|
||||
# silently forking. No-op (identity) when no normalizer is wired.
|
||||
canonical_user_id = self._canonical_user_id(user_id)
|
||||
if self._identity_resolver is not None and self._platform:
|
||||
try:
|
||||
base = self._identity_resolver.resolve(
|
||||
self._platform, canonical_user_id
|
||||
)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.warning("identity resolver failed: %s", e)
|
||||
base = canonical_user_id
|
||||
else:
|
||||
base = canonical_user_id
|
||||
# Per-tenant memory isolation (Issue #4341): when a route resolved to a
|
||||
# ``profile``, its namespace is bound turn-locally in ``_ACTIVE_PROFILE_NS``
|
||||
# so every storage key is prefixed with the tenant scope. Reading the
|
||||
@@ -2280,6 +2330,61 @@ class BotSessionManager:
|
||||
"""List user IDs with active sessions."""
|
||||
return list(self._histories.keys())
|
||||
|
||||
def export_history(self, storage_key: str) -> List[Dict[str, Any]]:
|
||||
"""Return a copy of the transcript for a resolved *storage_key*.
|
||||
|
||||
Used by the gateway's live-session handoff (Issue #4660) to read the
|
||||
current session's transcript so it can be seeded onto a freshly created
|
||||
thread/DM on another platform. Prefers the durable store (so a
|
||||
soft-evicted but persisted session is still exportable) and falls back
|
||||
to the in-memory cache. Keys are the resolved storage keys reported by
|
||||
:meth:`warm_sessions` / :meth:`get_user_ids` — no user-id resolution is
|
||||
applied here, so the caller controls exactly which session moves.
|
||||
"""
|
||||
if self._store is not None:
|
||||
try:
|
||||
history = self._store.get_chat_history(self._persist_key(storage_key))
|
||||
if history:
|
||||
return list(history)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.warning("export_history store read failed: %s", e)
|
||||
return list(self._histories.get(storage_key, []))
|
||||
|
||||
def seed_history(
|
||||
self, storage_key: str, history: List[Dict[str, Any]]
|
||||
) -> None:
|
||||
"""Seed a resolved *storage_key* session with *history*.
|
||||
|
||||
The counterpart to :meth:`export_history` for live-session handoff
|
||||
(Issue #4660): the destination session on the target platform is
|
||||
primed with the origin transcript so the same conversation continues
|
||||
seamlessly there. Writes both the in-memory cache and the durable
|
||||
store (when configured) under the resolved key, mirroring
|
||||
:meth:`_save_history` but keyed directly (no user-id resolution).
|
||||
"""
|
||||
history = list(history)
|
||||
self._histories[storage_key] = history
|
||||
if self._store is not None:
|
||||
key = self._persist_key(storage_key)
|
||||
self._flushed.discard(storage_key)
|
||||
try:
|
||||
if hasattr(self._store, "set_chat_history"):
|
||||
self._store.set_chat_history(key, history)
|
||||
else:
|
||||
self._store.clear_session(key)
|
||||
for msg in history:
|
||||
self._store.add_message(
|
||||
key,
|
||||
msg.get("role", "user"),
|
||||
msg.get("content", ""),
|
||||
msg.get("metadata"),
|
||||
)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.warning("seed_history store write failed: %s", e)
|
||||
else:
|
||||
self._flushed.add(storage_key)
|
||||
self._last_active[storage_key] = time.monotonic()
|
||||
|
||||
|
||||
def resolve_durable_store_dir(platform: str = ""):
|
||||
"""Resolve the canonical per-platform SQLite store directory for durability.
|
||||
|
||||
@@ -714,6 +714,7 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
content: Union[str, Dict[str, Any]],
|
||||
reply_to: Optional[str] = None,
|
||||
thread_id: Optional[str] = None,
|
||||
idempotency_key: Optional[str] = None,
|
||||
**kwargs,
|
||||
) -> BotMessage:
|
||||
"""Send an email message.
|
||||
@@ -723,6 +724,12 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
content: Message content (str or {"subject": ..., "body": ...})
|
||||
reply_to: Message-ID to reply to
|
||||
thread_id: Thread ID for conversation tracking
|
||||
idempotency_key: Optional stable key for deterministic replay. Passed
|
||||
as AgentMail's provider-level ``Idempotency-Key`` header so a
|
||||
timeout-after-send never duplicates the email. Declared
|
||||
explicitly (not via ``**kwargs``) so the durable-delivery layer
|
||||
can detect and forward its persisted key. 1-256 chars; AgentMail
|
||||
honours the key for 24h. Defaults to a generated UUID.
|
||||
|
||||
Returns:
|
||||
The sent message
|
||||
@@ -753,6 +760,17 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
|
||||
client = self._get_client()
|
||||
|
||||
# Provider-level idempotency: derive ONE stable key per logical send so
|
||||
# deliver_outbound()'s retries (default 3 attempts) never duplicate an
|
||||
# email at the AgentMail boundary after a timeout-after-send. The same
|
||||
# key must be reused for every retry, so it is generated here — outside
|
||||
# _do_send() — and passed through request_options to both send/reply.
|
||||
# Caller may supply idempotency_key to make replay deterministic; it must
|
||||
# obey AgentMail's 1-256 char rule (24h key lifetime). Accept it via the
|
||||
# explicit param or a legacy **kwargs entry, else generate a UUID.
|
||||
idempotency_key = idempotency_key or kwargs.get("idempotency_key") or str(uuid.uuid4())
|
||||
request_options = {"additional_headers": {"Idempotency-Key": idempotency_key}}
|
||||
|
||||
# Send via AgentMail API
|
||||
# SDK v0.4.7: uses text= (not body=); reply_to= is Reply-To header
|
||||
# For replying to a message, use messages.reply() method
|
||||
@@ -764,12 +782,14 @@ class AgentMailBot(OutboundResilienceMixin, ChatCommandMixin, MessageHookMixin):
|
||||
reply_to, # message_id of original message
|
||||
text=body,
|
||||
subject=subject,
|
||||
request_options=request_options,
|
||||
)
|
||||
return client.inboxes.messages.send(
|
||||
self._inbox_id,
|
||||
to=channel_id,
|
||||
subject=subject,
|
||||
text=body,
|
||||
request_options=request_options,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -40,6 +40,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -54,6 +55,37 @@ from .delivery import DeliveryRouter, SessionSource
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThreadRef:
|
||||
"""A handle to a destination thread/DM created by a live-session handoff.
|
||||
|
||||
Returned by :meth:`BotOS.handoff` (Issue #4660). ``status`` is ``"ok"``
|
||||
when the target thread was created and the session re-homed; otherwise it
|
||||
carries the :meth:`DeliveryRouter.create_thread` outcome
|
||||
(``"unsupported"`` / ``"failed"`` / ``"no_route"``) so a caller/chat
|
||||
command can report *why* a handoff could not happen without exceptions.
|
||||
``session_key`` is the resolved destination session key subsequent turns
|
||||
on ``platform`` route to.
|
||||
"""
|
||||
|
||||
platform: str
|
||||
channel: str
|
||||
thread_id: str
|
||||
session_key: str
|
||||
status: str = "ok"
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
"""True when the handoff succeeded and the thread is ready."""
|
||||
return self.status == "ok"
|
||||
|
||||
@property
|
||||
def target(self) -> str:
|
||||
"""The ``platform:channel:thread`` delivery target for the new thread."""
|
||||
base = f"{self.platform}:{self.channel}"
|
||||
return f"{base}:{self.thread_id}" if self.thread_id else base
|
||||
|
||||
|
||||
def _coerce_drain_timeout(value: Optional[Any]) -> Optional[float]:
|
||||
"""Coerce a drain-timeout config value to a finite float (or None).
|
||||
|
||||
@@ -117,6 +149,9 @@ class BotOS:
|
||||
admission_policy: Optional[Any] = None,
|
||||
max_rss_mb: float = 0.0,
|
||||
reliability: Optional[str] = None,
|
||||
turn_lock: Optional[Any] = None,
|
||||
redis_config: Optional[Any] = None,
|
||||
degraded_registry: Optional[Any] = None,
|
||||
):
|
||||
self._bots: Dict[str, Bot] = {}
|
||||
self._is_running = False
|
||||
@@ -192,17 +227,27 @@ class BotOS:
|
||||
# W1: shared identity resolver applied to every managed bot —
|
||||
# gives cross-platform unified-user sessions out of the box.
|
||||
self._identity_resolver = identity_resolver
|
||||
# Issue #3232: a single per-turn ``LockMap`` shared across every managed
|
||||
# bot's session manager. Because each ``BotSessionManager`` keys its lock
|
||||
# on the *resolved* session id (unified user id under an identity
|
||||
# Issue #3232: a single per-turn lock shared across every managed bot's
|
||||
# session manager. Because each ``BotSessionManager`` keys its lock on
|
||||
# the *resolved* session id (unified user id under an identity
|
||||
# resolver), sharing one map means two adapters that resolve to the same
|
||||
# unified session hold the *same* lock and their turns run serially — no
|
||||
# interleaved read-modify-write on one persisted transcript. Wired only
|
||||
# when a resolver is configured (the sole case where distinct adapters
|
||||
# unify to one session), so single-adapter behaviour is untouched.
|
||||
from .._lockmap import LockMap
|
||||
#
|
||||
# Issue #4655: honour ``gateway.turn_lock.backend``. With the default
|
||||
# ``"local"`` backend this stays a plain in-process ``LockMap`` (today's
|
||||
# behaviour, byte-for-byte). With ``"redis"`` it becomes a
|
||||
# ``RedisTurnLock`` so turns serialise cluster-wide across replicas
|
||||
# rather than the config being silently inert.
|
||||
self._turn_lock_config = turn_lock
|
||||
self._degraded_registry = degraded_registry
|
||||
from ._redis_turn_lock import build_turn_lock
|
||||
|
||||
self._turn_lock_map = LockMap()
|
||||
self._turn_lock_map = build_turn_lock(
|
||||
turn_lock, redis_config, degraded_registry=degraded_registry
|
||||
)
|
||||
self._tasks: List[asyncio.Task] = []
|
||||
|
||||
# Initialize delivery router for proactive outbound messaging
|
||||
@@ -1067,7 +1112,13 @@ class BotOS:
|
||||
getattr(bot, "_identity_resolver", None) is not None
|
||||
for bot in self._bots.values()
|
||||
)
|
||||
if not has_resolver:
|
||||
# Issue #4655: a distributed backend must serialise turns cluster-wide
|
||||
# even for a single adapter (two *replicas* of one adapter still resolve
|
||||
# to the same session). Wire it unconditionally in that case; the local
|
||||
# backend keeps the resolver-only gate so single-adapter behaviour is
|
||||
# unchanged.
|
||||
distributed = bool(getattr(self._turn_lock_config, "enabled", False))
|
||||
if not has_resolver and not distributed:
|
||||
return
|
||||
for platform, bot in self._bots.items():
|
||||
try:
|
||||
@@ -1178,6 +1229,128 @@ class BotOS:
|
||||
"""
|
||||
return await self._delivery_router.deliver(target, text, origin)
|
||||
|
||||
async def handoff(
|
||||
self,
|
||||
session_key: str,
|
||||
to: str,
|
||||
*,
|
||||
name: str = "",
|
||||
seed_text: Optional[str] = None,
|
||||
announce: bool = True,
|
||||
origin: Optional[SessionSource] = None,
|
||||
) -> ThreadRef:
|
||||
"""Re-home a live session onto a fresh thread/DM on another platform.
|
||||
|
||||
The gateway's live-session handoff (Issue #4660): "start on one device,
|
||||
continue on another". It orchestrates existing gateway primitives —
|
||||
it does **not** add new per-platform behaviour:
|
||||
|
||||
1. Creates a fresh thread on the ``to`` target via the existing
|
||||
:meth:`DeliveryRouter.create_thread` (capability-gated per adapter).
|
||||
2. Seeds that thread's session with the **current** transcript of
|
||||
``session_key`` via the session store, so the same conversation
|
||||
continues seamlessly there (:meth:`BotSessionManager.export_history`
|
||||
→ :meth:`~BotSessionManager.seed_history`).
|
||||
3. Subsequent turns arriving on the new thread route to the seeded
|
||||
destination session through the normal inbound path.
|
||||
4. Optionally drops a breadcrumb into the origin (``announce``) and a
|
||||
seed message into the new thread (``seed_text``).
|
||||
|
||||
Never raises for an unsupported/unreachable target — the returned
|
||||
:class:`ThreadRef` carries a non-``ok`` ``status`` so a chat command
|
||||
(``/handoff <platform>``) can explain why the move did not happen.
|
||||
|
||||
Args:
|
||||
session_key: The resolved session key to move (as reported by
|
||||
:meth:`BotSessionManager.warm_sessions` / ``get_user_ids``).
|
||||
to: Destination target (``platform`` | ``platform:channel`` |
|
||||
alias), resolved by the delivery router.
|
||||
name: Optional thread name; defaults to a handoff label.
|
||||
seed_text: Optional first message posted into the new thread.
|
||||
announce: When True (default) leave a breadcrumb in ``origin``.
|
||||
origin: Optional source of the original request (for breadcrumb /
|
||||
target resolution).
|
||||
|
||||
Returns:
|
||||
A :class:`ThreadRef` describing the destination thread and the
|
||||
destination session key subsequent turns route to.
|
||||
"""
|
||||
thread_name = name or "Handoff"
|
||||
status, resolved, thread_id = await self._delivery_router.create_thread(
|
||||
to, thread_name, origin
|
||||
)
|
||||
platform, _, channel = resolved.partition(":")
|
||||
if status != "ok":
|
||||
return ThreadRef(
|
||||
platform=platform or to,
|
||||
channel=channel,
|
||||
thread_id="",
|
||||
session_key="",
|
||||
status=status,
|
||||
)
|
||||
|
||||
# Seed the destination session with the origin transcript so the same
|
||||
# conversation continues on the new thread. Both sessions are located
|
||||
# by reusing the existing session-manager discovery; a missing manager
|
||||
# or empty transcript is non-fatal (the thread is still created).
|
||||
dest_session_key = f"{platform}:{channel}:{thread_id}"
|
||||
try:
|
||||
history = self._export_session_history(session_key)
|
||||
if history:
|
||||
dest_bot = self.get_bot(platform)
|
||||
dest_session = (
|
||||
self._find_session_manager(dest_bot) if dest_bot else None
|
||||
)
|
||||
if dest_session is not None and hasattr(dest_session, "seed_history"):
|
||||
dest_session.seed_history(dest_session_key, history)
|
||||
except Exception as e: # pragma: no cover — defensive, never break handoff
|
||||
logger.warning("handoff session seed failed: %s", e)
|
||||
|
||||
# Post an optional first message into the new thread.
|
||||
if seed_text:
|
||||
try:
|
||||
await self._delivery_router.deliver(dest_session_key, seed_text)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("handoff seed_text delivery failed: %s", e)
|
||||
|
||||
# Drop a breadcrumb in the origin channel ("continued on <platform>").
|
||||
if announce and origin is not None:
|
||||
try:
|
||||
await self._delivery_router.deliver(
|
||||
"origin", f"Continued on {platform}.", origin
|
||||
)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("handoff breadcrumb delivery failed: %s", e)
|
||||
|
||||
return ThreadRef(
|
||||
platform=platform,
|
||||
channel=channel,
|
||||
thread_id=thread_id,
|
||||
session_key=dest_session_key,
|
||||
status="ok",
|
||||
)
|
||||
|
||||
def _export_session_history(self, session_key: str) -> List[Dict[str, Any]]:
|
||||
"""Read a session's transcript from whichever bot owns *session_key*.
|
||||
|
||||
Scans registered bots' session managers for the transcript stored under
|
||||
``session_key`` (its resolved storage key). Returns the first non-empty
|
||||
transcript, or an empty list when no session manager holds it — so a
|
||||
handoff of an unknown/empty session still creates the thread cleanly.
|
||||
"""
|
||||
for bot in self._bots.values():
|
||||
session = self._find_session_manager(bot)
|
||||
if session is None or not hasattr(session, "export_history"):
|
||||
continue
|
||||
try:
|
||||
history = session.export_history(session_key)
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
logger.debug("export_history probe failed: %s", e)
|
||||
continue
|
||||
if history:
|
||||
return history
|
||||
return []
|
||||
|
||||
@property
|
||||
def is_draining(self) -> bool:
|
||||
"""Whether a graceful drain is currently in progress (#2375)."""
|
||||
|
||||
@@ -151,10 +151,14 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
run_control = None
|
||||
if hasattr(self.config, 'busy_mode') and self.config.busy_mode != "queue":
|
||||
try:
|
||||
from ._run_control import SessionRunControl
|
||||
from ._run_control import (
|
||||
SessionRunControl,
|
||||
make_approval_supersede_callback,
|
||||
)
|
||||
run_control = SessionRunControl(
|
||||
busy_mode=self.config.busy_mode,
|
||||
busy_ack_template=getattr(self.config, 'busy_ack', "⏳ {action} — will be considered next")
|
||||
busy_ack_template=getattr(self.config, 'busy_ack', "⏳ {action} — will be considered next"),
|
||||
on_supersede=make_approval_supersede_callback(),
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning("Run control not available, falling back to basic session management")
|
||||
|
||||
@@ -110,19 +110,83 @@ class GatewayApiEndpoints:
|
||||
aid = agents[0]
|
||||
return aid, self._gw.get_agent(aid)
|
||||
|
||||
async def _dispatch(self, session: Any, agent: Any, content: str) -> str:
|
||||
"""Run one agent turn through the same admission gate as chat users."""
|
||||
async def _dispatch(self, session: Any, agent: Any, content: str) -> tuple:
|
||||
"""Run one agent turn through the same admission gate as chat users.
|
||||
|
||||
Returns ``(reply, usage)`` where ``usage`` is a snapshot of the agent's
|
||||
per-turn token metrics captured **in the same execution context that
|
||||
produced the turn's result**.
|
||||
|
||||
One ``Agent`` instance can serve overlapping turns for several sessions.
|
||||
The SDK stores per-turn counts on a *shared, mutable*
|
||||
``llm.last_token_metrics`` reference that each turn overwrites, so any
|
||||
read that is not bound to the completing turn's own context can report a
|
||||
different concurrent request's counts. The native async path runs on the
|
||||
single event loop (no cross-turn interleaving without an ``await``), but
|
||||
the sync ``chat`` fallback runs in a worker **thread**, so a bare read
|
||||
after the ``await`` could still observe another thread's overwrite
|
||||
(Greptile/Qodo P1). We therefore hand ``_dispatch_agent_turn`` an
|
||||
``on_complete`` callback that snapshots the metrics *inside that same
|
||||
context* — the worker thread for sync agents, the loop tick for async —
|
||||
before it can be clobbered.
|
||||
"""
|
||||
holder: dict = {}
|
||||
|
||||
def _capture(a: Any) -> None:
|
||||
holder["usage"] = self._usage_for(a)
|
||||
|
||||
gate = getattr(self._gw, "_admission_gate", None)
|
||||
if gate is not None and getattr(gate, "enabled", False):
|
||||
from ..bots._admission import AdmissionRejected
|
||||
try:
|
||||
async with gate.admit(session_id=session.session_id):
|
||||
result = await self._gw._dispatch_agent_turn(agent, content)
|
||||
result = await self._gw._dispatch_agent_turn(
|
||||
agent, content, on_complete=_capture
|
||||
)
|
||||
except AdmissionRejected as rej:
|
||||
return str(rej.message)
|
||||
return str(rej.message), self._zero_usage()
|
||||
else:
|
||||
result = await self._gw._dispatch_agent_turn(agent, content)
|
||||
return "" if result is None else str(result)
|
||||
result = await self._gw._dispatch_agent_turn(
|
||||
agent, content, on_complete=_capture
|
||||
)
|
||||
usage = holder.get("usage") or self._zero_usage()
|
||||
return ("" if result is None else str(result)), usage
|
||||
|
||||
@staticmethod
|
||||
def _zero_usage() -> dict:
|
||||
return {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
@staticmethod
|
||||
def _usage_for(agent: Any) -> dict:
|
||||
"""Snapshot the agent's last-turn token metrics as an OpenAI ``usage`` block.
|
||||
|
||||
The core SDK records true per-turn counts on the LLM instance
|
||||
(``last_token_metrics`` with ``input_tokens``/``output_tokens``). We read
|
||||
them straight off the boundary so cost-accounting clients get real
|
||||
figures instead of the previous hardcoded zeros. When metrics are
|
||||
unavailable (e.g. a custom agent that does not expose an LLM instance)
|
||||
we fall back to zeros so the response stays spec-shaped.
|
||||
|
||||
Call sites MUST invoke this immediately after the dispatched turn
|
||||
completes with no intervening ``await`` (see ``_dispatch``); the value
|
||||
is read into plain ints here so the returned dict is an immutable
|
||||
snapshot that a later concurrent turn cannot mutate.
|
||||
"""
|
||||
zero = GatewayApiEndpoints._zero_usage()
|
||||
# Read the already-created LLM instance without forcing lazy creation.
|
||||
llm = getattr(agent, "_llm_instance", None) or getattr(
|
||||
agent, "llm_instance", None
|
||||
)
|
||||
metrics = getattr(llm, "last_token_metrics", None) if llm else None
|
||||
if metrics is None:
|
||||
return zero
|
||||
prompt = int(getattr(metrics, "input_tokens", 0) or 0)
|
||||
completion = int(getattr(metrics, "output_tokens", 0) or 0)
|
||||
return {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": prompt + completion,
|
||||
}
|
||||
|
||||
def _session_for(self, agent_id: str, key: str) -> Any:
|
||||
"""Get or create a stable session keyed by the API caller."""
|
||||
@@ -210,9 +274,15 @@ class GatewayApiEndpoints:
|
||||
completion_id = "chatcmpl-" + uuid.uuid4().hex
|
||||
|
||||
if stream:
|
||||
return self._sse_chat(agent_id, agent, session, content, completion_id)
|
||||
opts = body.get("stream_options")
|
||||
include_usage = bool(
|
||||
isinstance(opts, dict) and opts.get("include_usage")
|
||||
)
|
||||
return self._sse_chat(
|
||||
agent_id, agent, session, content, completion_id, include_usage
|
||||
)
|
||||
|
||||
reply = await self._dispatch(session, agent, content)
|
||||
reply, usage = await self._dispatch(session, agent, content)
|
||||
return JSONResponse(
|
||||
{
|
||||
"id": completion_id,
|
||||
@@ -226,15 +296,13 @@ class GatewayApiEndpoints:
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 0,
|
||||
"completion_tokens": 0,
|
||||
"total_tokens": 0,
|
||||
},
|
||||
"usage": usage,
|
||||
}
|
||||
)
|
||||
|
||||
def _sse_chat(self, agent_id, agent, session, content, completion_id):
|
||||
def _sse_chat(
|
||||
self, agent_id, agent, session, content, completion_id, include_usage=False
|
||||
):
|
||||
"""Emit a spec-shaped SSE chat-completion stream.
|
||||
|
||||
The frames are correctly ``chat.completion.chunk`` shaped and terminate
|
||||
@@ -245,6 +313,11 @@ class GatewayApiEndpoints:
|
||||
is deliberately out of scope here to avoid a hot-path regression. Latency
|
||||
to first *content* therefore matches non-streaming; response correctness
|
||||
and client compatibility are unaffected.
|
||||
|
||||
When the client sets ``stream_options.include_usage`` (the OpenAI
|
||||
opt-in for streamed usage), a final ``usage``-only chunk carrying the
|
||||
real per-turn token counts is emitted before ``[DONE]``, matching the
|
||||
OpenAI streaming contract.
|
||||
"""
|
||||
from starlette.responses import StreamingResponse
|
||||
|
||||
@@ -261,7 +334,7 @@ class GatewayApiEndpoints:
|
||||
}
|
||||
yield f"data: {json.dumps(first)}\n\n"
|
||||
|
||||
reply = await self._dispatch(session, agent, content)
|
||||
reply, usage = await self._dispatch(session, agent, content)
|
||||
chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
@@ -281,6 +354,18 @@ class GatewayApiEndpoints:
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
}
|
||||
yield f"data: {json.dumps(final)}\n\n"
|
||||
|
||||
if include_usage:
|
||||
usage_chunk = {
|
||||
"id": completion_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": agent_id,
|
||||
"choices": [],
|
||||
"usage": usage,
|
||||
}
|
||||
yield f"data: {json.dumps(usage_chunk)}\n\n"
|
||||
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
@@ -310,7 +395,7 @@ class GatewayApiEndpoints:
|
||||
raw = body.get("input", "")
|
||||
content = raw if isinstance(raw, str) else _extract_text(raw)
|
||||
session = self._session_for(agent_id, self._caller_key(request))
|
||||
reply = await self._dispatch(session, agent, content)
|
||||
reply, usage = await self._dispatch(session, agent, content)
|
||||
|
||||
return JSONResponse(
|
||||
{
|
||||
@@ -328,6 +413,11 @@ class GatewayApiEndpoints:
|
||||
}
|
||||
],
|
||||
"output_text": reply,
|
||||
"usage": {
|
||||
"input_tokens": usage["prompt_tokens"],
|
||||
"output_tokens": usage["completion_tokens"],
|
||||
"total_tokens": usage["total_tokens"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -403,7 +493,7 @@ class GatewayApiEndpoints:
|
||||
return err(-32602, f"Unknown agent/tool: {name}")
|
||||
content = str(arguments.get("message", ""))
|
||||
session = self._session_for(agent_id, self._caller_key(request))
|
||||
reply = await self._dispatch(session, agent, content)
|
||||
reply, _usage = await self._dispatch(session, agent, content)
|
||||
return ok(
|
||||
{
|
||||
"content": [{"type": "text", "text": reply}],
|
||||
|
||||
@@ -106,6 +106,13 @@ class PendingRequest:
|
||||
# this request. ``None`` (default) keeps today's behaviour where any holder
|
||||
# of the APPROVALS scope may resolve any request.
|
||||
authorized_reviewers: Optional[frozenset] = None
|
||||
# Optional turn-liveness binding. ``session_id`` and ``run_generation``
|
||||
# stamp the originating turn so a supersede/stop can cancel this pending
|
||||
# approval, and ``resolve()`` can drop a resolution that arrives after the
|
||||
# turn is gone (fail-closed). ``None`` (default) preserves today's behaviour
|
||||
# where an approval is unbound and always considered live.
|
||||
session_id: Optional[str] = None
|
||||
run_generation: Optional[int] = None
|
||||
# Resolved via the asyncio Future
|
||||
_future: Optional[asyncio.Future] = field(default=None, repr=False)
|
||||
# Store the event loop at registration time for thread-safe resolution
|
||||
@@ -603,6 +610,11 @@ class ExecApprovalManager:
|
||||
# weak task tracking cannot GC them mid-execution (see CPython docs
|
||||
# for asyncio.create_task).
|
||||
self._bg_tasks: Set[asyncio.Task] = set()
|
||||
# Highest superseded run generation seen per session. A pending approval
|
||||
# bound to a generation <= this value is stale (its turn was stopped or
|
||||
# replaced). Sessions with no entry are fully live. Kept tiny — one int
|
||||
# per active session — and pruned lazily as sessions come and go.
|
||||
self._superseded_generation: Dict[str, int] = {}
|
||||
self.allowlist = allowlist or PermissionAllowlist(
|
||||
durable=durable, path=allowlist_path,
|
||||
)
|
||||
@@ -671,6 +683,8 @@ class ExecApprovalManager:
|
||||
agent_name: str = "",
|
||||
risk_level: str = "medium",
|
||||
authorized_reviewers: Optional[Iterable[str]] = None,
|
||||
session_id: Optional[str] = None,
|
||||
run_generation: Optional[int] = None,
|
||||
) -> tuple:
|
||||
"""Create a pending request and return ``(request_id, future)``.
|
||||
|
||||
@@ -681,6 +695,12 @@ class ExecApprovalManager:
|
||||
``authorized_reviewers`` optionally binds the request to a set of
|
||||
reviewer identities; only those principals may resolve it. ``None``
|
||||
(default) leaves resolution open to any holder of the APPROVALS scope.
|
||||
|
||||
``session_id``/``run_generation`` optionally bind the request to the
|
||||
liveness of the originating turn. When supplied, :meth:`resolve` drops a
|
||||
resolution that arrives after the turn was superseded/stopped, and
|
||||
:meth:`cancel_for_generation` can fail-close the pending future when the
|
||||
turn is interrupted. Both ``None`` (default) preserve today's behaviour.
|
||||
"""
|
||||
# Fast path: already permanently allowed (scoped to this agent, its
|
||||
# argument signature, or a legacy global grant).
|
||||
@@ -694,6 +714,24 @@ class ExecApprovalManager:
|
||||
future.set_result(Resolution(approved=True, reason="allow-always"))
|
||||
return ("auto", future)
|
||||
|
||||
# Defensive: a session-bound request must carry an integer generation.
|
||||
# A non-int would raise during the ``<=`` comparison in
|
||||
# cancel_for_generation()/_is_generation_live(), which callers swallow —
|
||||
# leaving the approval silently un-cancellable. Coerce a clean int or
|
||||
# drop the (untrustworthy) binding fail-open to unbound rather than
|
||||
# register something that can never be superseded.
|
||||
if session_id is not None and run_generation is not None:
|
||||
if isinstance(run_generation, bool) or not isinstance(run_generation, int):
|
||||
try:
|
||||
run_generation = int(run_generation)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning(
|
||||
"Ignoring non-integer run_generation %r for session %s; "
|
||||
"registering approval unbound.",
|
||||
run_generation, session_id,
|
||||
)
|
||||
run_generation = None
|
||||
|
||||
request_id = self._make_id()
|
||||
loop = asyncio.get_running_loop()
|
||||
future = loop.create_future()
|
||||
@@ -706,11 +744,32 @@ class ExecApprovalManager:
|
||||
agent_name=agent_name,
|
||||
risk_level=risk_level,
|
||||
authorized_reviewers=reviewers,
|
||||
session_id=session_id,
|
||||
run_generation=run_generation,
|
||||
_future=future,
|
||||
_loop=loop, # capture loop at registration for thread-safe resolve
|
||||
)
|
||||
|
||||
with self._lock:
|
||||
# Fail-closed: if this turn's generation was already superseded (a
|
||||
# /stop or interrupt raced ahead of registration), never park the
|
||||
# caller on a future no cancel batch will ever complete — resolve it
|
||||
# denied immediately and skip insertion.
|
||||
if (
|
||||
session_id is not None
|
||||
and run_generation is not None
|
||||
and not self._is_generation_live_locked(session_id, run_generation)
|
||||
):
|
||||
future.set_result(
|
||||
Resolution(approved=False, reason="superseded")
|
||||
)
|
||||
logger.info(
|
||||
"Approval request %s denied at registration: session=%s "
|
||||
"generation=%s already superseded",
|
||||
request_id, session_id, run_generation,
|
||||
)
|
||||
return (request_id, future)
|
||||
|
||||
self._prune()
|
||||
self._pending[request_id] = req
|
||||
|
||||
@@ -772,6 +831,34 @@ class ExecApprovalManager:
|
||||
)
|
||||
return False
|
||||
|
||||
# Turn-liveness revalidation (fail-closed). If this request was bound to
|
||||
# a turn that has since been superseded/stopped, drop the resolution:
|
||||
# never execute the approved tool or deliver a reply for an abandoned
|
||||
# turn. The pending future is already gone (popped above); complete it
|
||||
# as denied so any awaiting caller unwinds. Requests with no liveness
|
||||
# binding (session_id/run_generation ``None``) are always considered
|
||||
# live, preserving today's behaviour.
|
||||
if req.session_id is not None and not self._is_generation_live(
|
||||
req.session_id, req.run_generation
|
||||
):
|
||||
logger.warning(
|
||||
"Dropping stale approval resolution for %s (tool=%s): turn "
|
||||
"session=%s generation=%s is no longer current",
|
||||
request_id, req.tool_name, req.session_id, req.run_generation,
|
||||
)
|
||||
if req._future and not req._future.done() and req._loop:
|
||||
req._loop.call_soon_threadsafe(
|
||||
req._future.set_result,
|
||||
Resolution(approved=False, reason="superseded"),
|
||||
)
|
||||
self._record_resolution(
|
||||
request_id,
|
||||
Resolution(approved=False, reason="superseded",
|
||||
resolver=resolution.resolver),
|
||||
approver=resolution.resolver or "gateway",
|
||||
)
|
||||
return False
|
||||
|
||||
approver = resolution.resolver or "gateway"
|
||||
|
||||
if resolution.allow_always:
|
||||
@@ -891,6 +978,90 @@ class ExecApprovalManager:
|
||||
with self._lock:
|
||||
return self._pending.pop(request_id, None) is not None
|
||||
|
||||
# ── Turn-liveness binding ─────────────────────────────────────────
|
||||
|
||||
def _is_generation_live_locked(
|
||||
self, session_id: str, run_generation: Optional[int]
|
||||
) -> bool:
|
||||
"""``_is_generation_live`` body, callable while already holding ``_lock``.
|
||||
|
||||
Split out so :meth:`register` can perform the liveness check inside its
|
||||
own critical section (avoiding a lock re-entry / TOCTOU window) while
|
||||
:meth:`resolve` keeps using the lock-acquiring wrapper.
|
||||
"""
|
||||
if run_generation is None:
|
||||
return True
|
||||
superseded = self._superseded_generation.get(session_id)
|
||||
return superseded is None or run_generation > superseded
|
||||
|
||||
def _is_generation_live(
|
||||
self, session_id: str, run_generation: Optional[int]
|
||||
) -> bool:
|
||||
"""Return whether ``run_generation`` for ``session_id`` is still current.
|
||||
|
||||
An approval is live unless its generation was explicitly superseded via
|
||||
:meth:`cancel_for_generation`. Callers pass ``session_id is None`` for
|
||||
unbound requests and never reach here (always live).
|
||||
"""
|
||||
if run_generation is None:
|
||||
return True
|
||||
with self._lock:
|
||||
return self._is_generation_live_locked(session_id, run_generation)
|
||||
|
||||
def cancel_for_generation(
|
||||
self, session_id: str, run_generation: int
|
||||
) -> int:
|
||||
"""Fail-closed cancel every pending approval bound to a superseded turn.
|
||||
|
||||
Marks generations ``<= run_generation`` for ``session_id`` as superseded
|
||||
so any *later*-arriving resolution is dropped by :meth:`resolve`, and
|
||||
completes any currently-pending future as denied so the awaiting tool
|
||||
call unwinds promptly (this is what makes ``/stop`` produce a visible
|
||||
outcome instead of parking indefinitely on the approval wait).
|
||||
|
||||
Returns the number of pending approvals cancelled. A no-op for sessions
|
||||
with no bound pending approvals.
|
||||
"""
|
||||
with self._lock:
|
||||
prev = self._superseded_generation.get(session_id)
|
||||
if prev is None or run_generation > prev:
|
||||
self._superseded_generation[session_id] = run_generation
|
||||
to_cancel = [
|
||||
r for r in self._pending.values()
|
||||
if r.session_id == session_id
|
||||
and r.run_generation is not None
|
||||
and r.run_generation <= run_generation
|
||||
]
|
||||
for r in to_cancel:
|
||||
self._pending.pop(r.request_id, None)
|
||||
|
||||
for r in to_cancel:
|
||||
if r._future and not r._future.done() and r._loop:
|
||||
r._loop.call_soon_threadsafe(
|
||||
r._future.set_result,
|
||||
Resolution(approved=False, reason="cancelled"),
|
||||
)
|
||||
self._record_resolution(
|
||||
r.request_id,
|
||||
Resolution(approved=False, reason="cancelled"),
|
||||
approver="gateway",
|
||||
)
|
||||
if to_cancel:
|
||||
logger.info(
|
||||
"Cancelled %d pending approval(s) for session=%s generation<=%s",
|
||||
len(to_cancel), session_id, run_generation,
|
||||
)
|
||||
return len(to_cancel)
|
||||
|
||||
def forget_session(self, session_id: str) -> None:
|
||||
"""Drop the superseded-generation marker for a finished session.
|
||||
|
||||
Bounds the tiny per-session bookkeeping so it cannot grow unbounded as
|
||||
sessions churn. Safe no-op when the session is unknown.
|
||||
"""
|
||||
with self._lock:
|
||||
self._superseded_generation.pop(session_id, None)
|
||||
|
||||
# ── Query API ─────────────────────────────────────────────────────
|
||||
|
||||
def list_pending(self) -> List[Dict[str, Any]]:
|
||||
|
||||
@@ -84,12 +84,22 @@ class GatewayApprovalBackend:
|
||||
|
||||
Satisfies :class:`praisonaiagents.approval.protocols.ApprovalProtocol`.
|
||||
"""
|
||||
# Bind this pending approval to the originating turn's liveness when the
|
||||
# caller stamped a session id / run generation (the latter travels in
|
||||
# ``context`` to avoid widening the core contract). Both optional and a
|
||||
# no-op when absent, preserving today's unbound behaviour.
|
||||
run_generation = None
|
||||
if isinstance(request.context, dict):
|
||||
run_generation = request.context.get("run_generation")
|
||||
|
||||
request_id, future = await self.manager.register(
|
||||
tool_name=request.tool_name,
|
||||
arguments=request.arguments,
|
||||
agent_name=request.agent_name or "",
|
||||
risk_level=request.risk_level,
|
||||
authorized_reviewers=request.authorized_reviewers,
|
||||
session_id=request.session_id,
|
||||
run_generation=run_generation,
|
||||
)
|
||||
|
||||
# Fire optional notification (fire-and-forget)
|
||||
|
||||
@@ -8,14 +8,42 @@ system and follows Hermes patterns for worker management.
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from .._guarded_subprocess import run_guarded
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Ceiling for a single git invocation. Worktree add, merge and commit on a
|
||||
# large repository are legitimately slow, so this is set well above the
|
||||
# general default: the aim is to bound an indefinite hang, not to fail slow
|
||||
# work. Override with PRAISONAI_KANBAN_GIT_TIMEOUT (seconds).
|
||||
_DEFAULT_GIT_TIMEOUT = 300.0
|
||||
|
||||
|
||||
def _git_timeout() -> float:
|
||||
"""Resolve the per-git-command ceiling, ignoring an unusable override.
|
||||
|
||||
``inf`` is rejected as well as zero and negatives: it is positive and
|
||||
parses cleanly, so accepting it would silently restore the unbounded git
|
||||
call this guard exists to remove.
|
||||
"""
|
||||
raw = os.environ.get("PRAISONAI_KANBAN_GIT_TIMEOUT")
|
||||
if not raw:
|
||||
return _DEFAULT_GIT_TIMEOUT
|
||||
try:
|
||||
value = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return _DEFAULT_GIT_TIMEOUT
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return _DEFAULT_GIT_TIMEOUT
|
||||
return value
|
||||
|
||||
# Global dispatcher state
|
||||
_dispatcher_running = False
|
||||
_dispatcher_task = None
|
||||
@@ -415,12 +443,22 @@ class KanbanDispatcher:
|
||||
|
||||
A thin subprocess wrapper so worktree isolation stays self-contained in
|
||||
the wrapper without pulling in a Tier-2 package dependency.
|
||||
|
||||
Bounded via :func:`run_guarded`: the dispatcher calls this from inside
|
||||
the gateway's event loop, so a git command blocked on a credential
|
||||
prompt or an ``index.lock`` would otherwise stall every WebSocket
|
||||
client indefinitely. On timeout the result carries returncode 124 and
|
||||
a diagnosis in stderr, which the existing ``returncode == 0`` checks
|
||||
already treat as failure.
|
||||
|
||||
The ceiling is deliberately generous — worktree creation and merges on
|
||||
a large repository are legitimately slow, and the goal is to bound a
|
||||
hang, not to fail slow-but-healthy work.
|
||||
"""
|
||||
return subprocess.run(
|
||||
return run_guarded(
|
||||
["git", *args],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=cwd or self._repo_dir(),
|
||||
timeout=_git_timeout(),
|
||||
)
|
||||
|
||||
def _worktree_root(self) -> str:
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Durable, boot-scoped process-lifecycle ledger for the gateway (Issue #4603).
|
||||
|
||||
The core SDK ships only the pure contract and decisions
|
||||
(:class:`praisonaiagents.gateway.LifecycleRecord`,
|
||||
:func:`praisonaiagents.gateway.classify_unclean_exit`,
|
||||
:class:`praisonaiagents.gateway.PersistentRestartLoopGuard`,
|
||||
:func:`praisonaiagents.gateway.classify_resource_pressure`). This wrapper module
|
||||
owns the heavyweight, OS-specific mechanism those helpers deliberately avoid:
|
||||
|
||||
* a small durable sentinel file the runtime restamps on boot / shutdown and
|
||||
folds a memory sample into on every heartbeat, so the *next* boot can detect
|
||||
that the previous process died uncleanly (OOM-kill / ``SIGKILL`` run no
|
||||
handler, so signal-based forensics never fire for them);
|
||||
* ``/proc`` + cgroup memory sampling and ``shutil.disk_usage`` disk headroom so
|
||||
``GET /health`` can classify redaction-safe memory/disk *pressure*; and
|
||||
* a :class:`praisonaiagents.gateway.RestartStoreProtocol` backed by the same
|
||||
sentinel so the restart-loop breaker survives the very restart it guards.
|
||||
|
||||
Everything here is best-effort and defensive: a sample must never raise and a
|
||||
persistence failure must degrade to in-memory behaviour rather than crash the
|
||||
gateway it is meant to protect.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import time
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from praisonaiagents.gateway import (
|
||||
LifecycleRecord,
|
||||
PersistentRestartLoopGuard,
|
||||
classify_resource_pressure,
|
||||
classify_unclean_exit,
|
||||
)
|
||||
|
||||
from .memory_pressure import CgroupMemoryPressure
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LifecycleLedger:
|
||||
"""Durable process-lifecycle record maintained by the gateway runtime.
|
||||
|
||||
On boot the ledger reads the prior sentinel, classifies whether the previous
|
||||
process exited cleanly, and restamps ``phase="running"`` (see
|
||||
:meth:`record_boot`). A cheap :meth:`sample_memory` folds the current
|
||||
memory-budget fraction into the record so a *later* unclean death is flagged
|
||||
``suspected_oom``. :meth:`mark_exited` stamps a clean exit on the graceful
|
||||
stop path only.
|
||||
|
||||
Args:
|
||||
path: Sentinel file path. Its parent directory is created best-effort.
|
||||
memory_budget_fraction: Budget fraction at/above which memory pressure
|
||||
is classified ``critical`` (also the OOM-suspicion threshold).
|
||||
disk_min_free_mb: Free-disk floor (MiB) below which disk is ``critical``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
memory_budget_fraction: float = 0.9,
|
||||
disk_min_free_mb: float = 512.0,
|
||||
):
|
||||
self.path = path
|
||||
self.memory_budget_fraction = float(memory_budget_fraction)
|
||||
self.disk_min_free_mb = float(disk_min_free_mb)
|
||||
self._mem = CgroupMemoryPressure()
|
||||
self._record: LifecycleRecord = LifecycleRecord()
|
||||
# Set on the first boot classification so /health can report the last
|
||||
# exit without re-reading the file; None until record_boot() runs.
|
||||
self._boot_exit_kind: Optional[str] = None
|
||||
self._boot_suspected_oom: bool = False
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.debug("lifecycle: could not prepare sentinel dir: %s", exc)
|
||||
|
||||
# ── persistence (best-effort) ──
|
||||
|
||||
def _read(self) -> Optional[LifecycleRecord]:
|
||||
try:
|
||||
with open(self.path, "r") as fh:
|
||||
data = json.load(fh)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return LifecycleRecord.from_dict(data)
|
||||
|
||||
def _write(self, record: LifecycleRecord) -> None:
|
||||
"""Atomically persist ``record`` to the sentinel; never raises."""
|
||||
tmp = f"{self.path}.tmp.{os.getpid()}"
|
||||
try:
|
||||
with open(tmp, "w") as fh:
|
||||
json.dump(record.to_dict(), fh)
|
||||
os.replace(tmp, self.path)
|
||||
except OSError as exc: # pragma: no cover - defensive
|
||||
logger.debug("lifecycle: could not persist sentinel: %s", exc)
|
||||
try:
|
||||
if os.path.exists(tmp):
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# ── lifecycle transitions ──
|
||||
|
||||
def record_boot(self) -> LifecycleRecord:
|
||||
"""Read the prior sentinel, classify its exit, and restamp ``running``.
|
||||
|
||||
Returns the fresh boot record. When the previous process was still
|
||||
``running`` in its last write, the record's ``exit_kind`` is
|
||||
``"unclean"`` (and ``suspected_oom`` when its last memory sample was
|
||||
high) — the boot-time signal that a signal-less OOM/``SIGKILL`` occurred.
|
||||
"""
|
||||
prev = self._read()
|
||||
record = classify_unclean_exit(
|
||||
prev, oom_fraction=self.memory_budget_fraction
|
||||
)
|
||||
self._boot_exit_kind = record.exit_kind
|
||||
self._boot_suspected_oom = record.suspected_oom
|
||||
# Stamp our own pid-owned boot marker so a graceful stop from *this*
|
||||
# process can be told apart from a stale file another process left.
|
||||
self._record = record
|
||||
self._owner_pid = os.getpid()
|
||||
self._write(record)
|
||||
if record.exit_kind == "unclean":
|
||||
logger.warning(
|
||||
"Gateway previous process exited uncleanly%s "
|
||||
"(no graceful-stop marker; suspected OOM/SIGKILL)",
|
||||
" — suspected OOM" if record.suspected_oom else "",
|
||||
)
|
||||
return record
|
||||
|
||||
def sample_memory(self) -> Optional[float]:
|
||||
"""Fold a fresh memory-budget fraction into the sentinel (<1ms).
|
||||
|
||||
Returns the sampled fraction (anon RSS / cgroup budget) in ``[0, ∞)`` or
|
||||
``None`` when the platform has no cgroup limit to measure against. Cheap
|
||||
enough to call from the heartbeat; persists the running record so a
|
||||
subsequent hard-kill leaves the last sample behind for OOM suspicion.
|
||||
"""
|
||||
frac = self._memory_fraction()
|
||||
record = LifecycleRecord(
|
||||
phase="running",
|
||||
exit_kind=self._record.exit_kind,
|
||||
suspected_oom=self._record.suspected_oom,
|
||||
last_mem_fraction=frac,
|
||||
restart_events=list(self._record.restart_events),
|
||||
)
|
||||
self._record = record
|
||||
self._write(record)
|
||||
return frac
|
||||
|
||||
def mark_exited(self) -> None:
|
||||
"""Stamp a clean exit — graceful-stop path only, ownership-checked.
|
||||
|
||||
Only the process that wrote the boot marker may record a clean exit, so
|
||||
a late call from a forked/stale context cannot mask another process's
|
||||
unclean death.
|
||||
"""
|
||||
if getattr(self, "_owner_pid", None) != os.getpid():
|
||||
return
|
||||
record = LifecycleRecord(
|
||||
phase="exited",
|
||||
exit_kind="clean",
|
||||
suspected_oom=False,
|
||||
last_mem_fraction=self._record.last_mem_fraction,
|
||||
restart_events=list(self._record.restart_events),
|
||||
)
|
||||
self._record = record
|
||||
self._write(record)
|
||||
|
||||
# ── health surface ──
|
||||
|
||||
def _memory_fraction(self) -> Optional[float]:
|
||||
"""Anon RSS / cgroup budget fraction, or ``None`` when unmeasurable."""
|
||||
try:
|
||||
budget = self._mem.cgroup_limit_mb()
|
||||
if not budget or budget <= 0:
|
||||
return None
|
||||
rss = self._mem.anon_rss_mb()
|
||||
return float(rss) / float(budget)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def _disk_free_mb(self) -> Optional[float]:
|
||||
try:
|
||||
usage = shutil.disk_usage(os.path.dirname(self.path) or ".")
|
||||
return float(usage.free) / (1024.0 * 1024.0)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def pressure(self) -> Dict[str, str]:
|
||||
"""Return the coarse ``{"memory", "disk"}`` pressure block for /health."""
|
||||
return classify_resource_pressure(
|
||||
mem_fraction=self._memory_fraction(),
|
||||
disk_free_mb=self._disk_free_mb(),
|
||||
mem_critical=self.memory_budget_fraction,
|
||||
disk_min_free_mb=self.disk_min_free_mb,
|
||||
)
|
||||
|
||||
def last_exit(self) -> Dict[str, Any]:
|
||||
"""Return the ``{"last_exit", "suspected_oom"}`` lifecycle block."""
|
||||
return {
|
||||
"last_exit": self._boot_exit_kind or "unknown",
|
||||
"suspected_oom": bool(self._boot_suspected_oom),
|
||||
}
|
||||
|
||||
|
||||
class SentinelRestartStore:
|
||||
"""A :class:`RestartStoreProtocol` backed by the lifecycle sentinel file.
|
||||
|
||||
Reads / writes only the ``restart_events`` slice of the durable
|
||||
:class:`LifecycleRecord`, so the persistent restart-loop breaker shares one
|
||||
file with the lifecycle ledger instead of introducing a second sentinel.
|
||||
Best-effort on both ends: a read failure yields ``[]`` and a write failure
|
||||
is swallowed, degrading the breaker to in-memory behaviour.
|
||||
"""
|
||||
|
||||
def __init__(self, ledger: LifecycleLedger):
|
||||
self._ledger = ledger
|
||||
|
||||
def load_events(self) -> List[float]:
|
||||
record = self._ledger._read()
|
||||
if record is None:
|
||||
return list(self._ledger._record.restart_events)
|
||||
return list(record.restart_events)
|
||||
|
||||
def save_events(self, events: List[float]) -> None:
|
||||
rec = self._ledger._record
|
||||
merged = LifecycleRecord(
|
||||
phase=rec.phase,
|
||||
exit_kind=rec.exit_kind,
|
||||
suspected_oom=rec.suspected_oom,
|
||||
last_mem_fraction=rec.last_mem_fraction,
|
||||
restart_events=[float(t) for t in events],
|
||||
)
|
||||
self._ledger._record = merged
|
||||
self._ledger._write(merged)
|
||||
|
||||
|
||||
def default_sentinel_path() -> str:
|
||||
"""Return the default lifecycle sentinel path under ``~/.praisonai``."""
|
||||
return os.path.join(
|
||||
os.path.expanduser("~"), ".praisonai", "gateway", "lifecycle.json"
|
||||
)
|
||||
|
||||
|
||||
def build_persistent_restart_guard(
|
||||
ledger: LifecycleLedger,
|
||||
*,
|
||||
max_restarts: int = 3,
|
||||
window_seconds: float = 3600.0,
|
||||
) -> PersistentRestartLoopGuard:
|
||||
"""Construct a durable restart-loop guard sharing the ledger's sentinel."""
|
||||
return PersistentRestartLoopGuard(
|
||||
SentinelRestartStore(ledger),
|
||||
max_restarts=max_restarts,
|
||||
window_seconds=window_seconds,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LifecycleLedger",
|
||||
"SentinelRestartStore",
|
||||
"default_sentinel_path",
|
||||
"build_persistent_restart_guard",
|
||||
]
|
||||
@@ -111,7 +111,8 @@ def _restrict_windows_acl(secret_path: str) -> bool:
|
||||
"""
|
||||
try:
|
||||
import getpass
|
||||
import subprocess
|
||||
|
||||
from .._guarded_subprocess import run_guarded
|
||||
|
||||
user = os.environ.get("USERNAME") or getpass.getuser()
|
||||
# Resolve icacls from the trusted system directory rather than PATH
|
||||
@@ -123,7 +124,9 @@ def _restrict_windows_acl(secret_path: str) -> bool:
|
||||
)
|
||||
# Quote the username so principals containing spaces (e.g. "John Doe")
|
||||
# are parsed as a single principal by icacls.
|
||||
result = subprocess.run(
|
||||
# Bounded: this runs while hardening the pairing secret on the gateway
|
||||
# startup path, so a stalled icacls must not block it forever.
|
||||
result = run_guarded(
|
||||
[
|
||||
icacls,
|
||||
secret_path,
|
||||
@@ -131,7 +134,7 @@ def _restrict_windows_acl(secret_path: str) -> bool:
|
||||
"/grant:r",
|
||||
f'"{user}":F',
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
@@ -879,7 +879,8 @@ def _read_env_file_tokens(path: str) -> Dict[str, str]:
|
||||
def _scan_launch_agent(label: str) -> DuplicateService:
|
||||
"""Inspect a macOS LaunchAgent plist by label."""
|
||||
import plistlib
|
||||
import subprocess
|
||||
|
||||
from .._guarded_subprocess import run_guarded
|
||||
|
||||
plist_path = os.path.expanduser(f"~/Library/LaunchAgents/{label}.plist")
|
||||
service = DuplicateService(label=label, plist_path=plist_path)
|
||||
@@ -899,11 +900,9 @@ def _scan_launch_agent(label: str) -> DuplicateService:
|
||||
pass
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", label],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Bounded: preflight runs on the gateway's startup path, and a
|
||||
# launchctl that stalls would hold up service discovery indefinitely.
|
||||
result = run_guarded(["launchctl", "list", label], timeout=15)
|
||||
service.running = result.returncode == 0
|
||||
if service.running and result.stdout:
|
||||
parts = result.stdout.strip().split("\t")
|
||||
|
||||
@@ -1121,6 +1121,16 @@ class WebSocketGateway:
|
||||
self._drain_marker_task: Optional[asyncio.Task] = None
|
||||
self._lifecycle_drain_timeout: Optional[float] = None
|
||||
|
||||
# Issue #4603: durable, boot-scoped process-lifecycle ledger. Detects an
|
||||
# unclean/OOM death of the *previous* process (which runs no signal
|
||||
# handler, so forensics never fire), surfaces memory/disk pressure on
|
||||
# ``/health``, and backs a restart-loop breaker that survives the very
|
||||
# restart it guards. Off unless a ``lifecycle.ledger``/``health`` block
|
||||
# enables it, so always-on gateways are unchanged.
|
||||
self._lifecycle_ledger: Optional[Any] = None
|
||||
self._ledger_heartbeat_task: Optional[asyncio.Task] = None
|
||||
self._ledger_heartbeat_interval: float = 15.0
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
return self._is_running
|
||||
@@ -2163,13 +2173,34 @@ class WebSocketGateway:
|
||||
if self._session_store:
|
||||
await self._start_session_cleanup()
|
||||
|
||||
# Issue #3379: re-drive any turn that was in-flight when a previous
|
||||
# process restarted, and proactively notify the originating channel.
|
||||
# Mirrors the durable approval rehydrate above; a no-op without a store.
|
||||
try:
|
||||
await self._resume_interrupted_turns()
|
||||
except Exception:
|
||||
logger.exception("Failed to resume interrupted turns on boot")
|
||||
# Issue #4603: record this boot in the durable lifecycle ledger before
|
||||
# anything else. This reads the prior sentinel, classifies whether the
|
||||
# previous process exited cleanly (an unclean/OOM death runs no signal
|
||||
# handler, so this is the only place it becomes visible), and restamps
|
||||
# ``phase="running"``. No-op when the ledger is unconfigured.
|
||||
if self._lifecycle_ledger is not None:
|
||||
try:
|
||||
self._lifecycle_ledger.record_boot()
|
||||
except Exception:
|
||||
logger.exception("Failed to record boot in lifecycle ledger")
|
||||
|
||||
# Issue #4603: if the persistent restart-loop breaker has already
|
||||
# tripped across prior boots, skip the boot-time auto-resume re-drive
|
||||
# (which is what keeps crashing the process) while still serving fresh
|
||||
# inbound — instead of the supervisor respawning into the same crash.
|
||||
if self._auto_resume_suspended():
|
||||
logger.error(
|
||||
"Persistent restart-loop breaker tripped; suspending boot-time "
|
||||
"auto-resume of interrupted turns (still serving fresh inbound)"
|
||||
)
|
||||
else:
|
||||
# Issue #3379: re-drive any turn that was in-flight when a previous
|
||||
# process restarted, and proactively notify the originating channel.
|
||||
# Mirrors the durable approval rehydrate above; a no-op without a store.
|
||||
try:
|
||||
await self._resume_interrupted_turns()
|
||||
except Exception:
|
||||
logger.exception("Failed to resume interrupted turns on boot")
|
||||
|
||||
# Issue #3881: drive recovery from the durable run ledger too. The
|
||||
# session-blob scan above is heuristic; the ledger is the source of
|
||||
@@ -2197,6 +2228,15 @@ class WebSocketGateway:
|
||||
self._liveness_task = asyncio.create_task(
|
||||
self._liveness_loop(), name="gateway-liveness"
|
||||
)
|
||||
|
||||
# Issue #4603: fold a cheap ``/proc``+cgroup memory sample into the
|
||||
# durable lifecycle sentinel on a slow cadence, so a later signal-less
|
||||
# OOM/SIGKILL leaves the last memory reading behind for the next boot to
|
||||
# flag ``suspected_oom``. No-op when the ledger is unconfigured.
|
||||
if self._lifecycle_ledger is not None and self._ledger_heartbeat_task is None:
|
||||
self._ledger_heartbeat_task = asyncio.create_task(
|
||||
self._ledger_heartbeat_loop(), name="gateway-lifecycle-ledger"
|
||||
)
|
||||
try:
|
||||
await self._server.serve()
|
||||
except Exception as e:
|
||||
@@ -2209,6 +2249,22 @@ class WebSocketGateway:
|
||||
finally:
|
||||
self._disarm_watchdog()
|
||||
await self._cancel_liveness_task()
|
||||
# Issue #4603: ``serve()`` returns through here on every graceful
|
||||
# termination — the SIGINT/SIGTERM handler only flips
|
||||
# ``should_exit`` and uvicorn unwinds, so ``stop()`` is not
|
||||
# guaranteed to run. Stamp the clean-exit marker here (after
|
||||
# cancelling the sampler so it cannot re-stamp ``running``) so a
|
||||
# normal shutdown is not misreported as an unclean/OOM death on the
|
||||
# next boot. ``mark_exited`` is pid-owned and idempotent, so a later
|
||||
# explicit ``stop()`` is harmless.
|
||||
await self._cancel_ledger_heartbeat()
|
||||
if self._lifecycle_ledger is not None:
|
||||
try:
|
||||
self._lifecycle_ledger.mark_exited()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to mark clean exit in lifecycle ledger (serve)"
|
||||
)
|
||||
|
||||
async def _cancel_liveness_task(self) -> None:
|
||||
"""Stop the connection-liveness heartbeat/reaper task (idempotent)."""
|
||||
@@ -2223,6 +2279,95 @@ class WebSocketGateway:
|
||||
except Exception:
|
||||
logger.debug("Liveness task exited with error on cancel")
|
||||
|
||||
async def _cancel_ledger_heartbeat(self) -> None:
|
||||
"""Stop the lifecycle-ledger memory-sample heartbeat (idempotent)."""
|
||||
task = self._ledger_heartbeat_task
|
||||
self._ledger_heartbeat_task = None
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
logger.debug("Lifecycle-ledger heartbeat exited with error")
|
||||
|
||||
async def _ledger_heartbeat_loop(self) -> None:
|
||||
"""Periodically fold a memory sample into the durable lifecycle sentinel.
|
||||
|
||||
Best-effort and cheap (<1ms of ``/proc``+cgroup I/O per tick). Runs only
|
||||
while the ledger is configured; any error is swallowed so it never
|
||||
crashes the gateway it is meant to make observable.
|
||||
"""
|
||||
ledger = self._lifecycle_ledger
|
||||
if ledger is None:
|
||||
return
|
||||
interval = self._ledger_heartbeat_interval
|
||||
while self._is_running:
|
||||
try:
|
||||
await asyncio.sleep(interval)
|
||||
ledger.sample_memory()
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.debug("Lifecycle-ledger sample failed", exc_info=True)
|
||||
|
||||
def _auto_resume_suspended(self) -> bool:
|
||||
"""Whether the persistent restart-loop breaker has tripped this boot.
|
||||
|
||||
Only meaningful for a :class:`PersistentRestartLoopGuard` whose events
|
||||
survive process boots. When there are interrupted turns to re-drive, a
|
||||
boot is restart-worthy: record it and, once ``max_restarts`` such boots
|
||||
fall within the window, report tripped so the caller skips the poison
|
||||
re-drive that keeps crashing the process. Returns ``False`` for the
|
||||
in-memory guard (which cannot see prior boots) and when unconfigured.
|
||||
"""
|
||||
guard = self._restart_loop_guard
|
||||
if guard is None:
|
||||
return False
|
||||
# Only the persistent guard carries cross-boot state; the in-memory one
|
||||
# is rebuilt fresh each boot and must not gate the resume path.
|
||||
if not hasattr(guard, "_store"):
|
||||
return False
|
||||
# Nothing to re-drive ⇒ this boot is not restart-interrupted; don't
|
||||
# count it against the breaker and let it re-arm on a clean boot.
|
||||
if not self._has_interrupted_turns():
|
||||
try:
|
||||
guard.reset()
|
||||
except Exception:
|
||||
pass
|
||||
return False
|
||||
try:
|
||||
return guard.record(now=time.time())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _has_interrupted_turns(self) -> bool:
|
||||
"""Best-effort check for a persisted in-flight turn left by a restart."""
|
||||
store = self._session_store
|
||||
if not store:
|
||||
return False
|
||||
lister = getattr(store, "list_sessions", None)
|
||||
if not callable(lister):
|
||||
return False
|
||||
try:
|
||||
try:
|
||||
summaries = lister(limit=1_000_000)
|
||||
except TypeError:
|
||||
summaries = lister()
|
||||
except Exception:
|
||||
return False
|
||||
for summary in summaries or []:
|
||||
sid = summary.get("session_id") if isinstance(summary, dict) else summary
|
||||
if not sid:
|
||||
continue
|
||||
data = self._load_persisted_session_data(sid)
|
||||
if not data:
|
||||
continue
|
||||
if bool(data.get("is_executing")) or bool(data.get("pending_inbox")):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ── Event-loop liveness watchdog (Issue #3410) ──
|
||||
|
||||
def _configure_watchdog(self, watchdog_cfg: Optional[Dict[str, Any]]) -> None:
|
||||
@@ -2383,6 +2528,58 @@ class WebSocketGateway:
|
||||
logger.warning("Invalid restart_loop_guard config; disabling: %s", e)
|
||||
self._restart_loop_guard = None
|
||||
|
||||
# Issue #4603: durable process-lifecycle ledger. Enabled via
|
||||
# ``lifecycle.ledger.enabled`` (or implicitly when
|
||||
# ``restart_loop_guard.persist`` is set) so an unclean/OOM prior death
|
||||
# is detected at boot, pressure is surfaced on ``/health``, and — when
|
||||
# ``restart_loop_guard.persist`` is truthy — the crash-loop breaker
|
||||
# persists across boots and covers the auto-resume path. Off by default.
|
||||
ledger_cfg = lifecycle_cfg.get("ledger")
|
||||
if not isinstance(ledger_cfg, dict):
|
||||
ledger_cfg = {}
|
||||
persist_guard = isinstance(rlg, dict) and _as_bool(rlg.get("persist"))
|
||||
ledger_enabled = _as_bool(ledger_cfg.get("enabled")) or persist_guard
|
||||
if ledger_enabled:
|
||||
try:
|
||||
from .lifecycle_ledger import (
|
||||
LifecycleLedger,
|
||||
build_persistent_restart_guard,
|
||||
default_sentinel_path,
|
||||
)
|
||||
|
||||
health_cfg = lifecycle_cfg.get("health")
|
||||
if not isinstance(health_cfg, dict):
|
||||
health_cfg = {}
|
||||
path = ledger_cfg.get("path") or default_sentinel_path()
|
||||
self._lifecycle_ledger = LifecycleLedger(
|
||||
str(path),
|
||||
memory_budget_fraction=float(
|
||||
health_cfg.get("memory_budget_fraction", 0.9)
|
||||
),
|
||||
disk_min_free_mb=float(
|
||||
health_cfg.get("disk_min_free_mb", 512.0)
|
||||
),
|
||||
)
|
||||
# When persistence is requested, upgrade the in-memory guard to
|
||||
# one whose events survive the restart it guards and share the
|
||||
# ledger's sentinel file.
|
||||
if persist_guard:
|
||||
rlg = rlg if isinstance(rlg, dict) else {}
|
||||
self._restart_loop_guard = build_persistent_restart_guard(
|
||||
self._lifecycle_ledger,
|
||||
max_restarts=int(rlg.get("max_restarts", 3)),
|
||||
window_seconds=float(
|
||||
rlg.get("window_seconds", 3600.0)
|
||||
),
|
||||
)
|
||||
logger.info(
|
||||
"Gateway lifecycle ledger enabled (path=%s, persist_guard=%s)",
|
||||
path, persist_guard,
|
||||
)
|
||||
except (ImportError, ValueError, OSError) as e:
|
||||
logger.warning("Invalid lifecycle ledger config; disabling: %s", e)
|
||||
self._lifecycle_ledger = None
|
||||
|
||||
def _merge_lifecycle_overrides(
|
||||
self,
|
||||
lifecycle_cfg: Optional[Dict[str, Any]],
|
||||
@@ -2791,6 +2988,18 @@ class WebSocketGateway:
|
||||
# Issue #2798/#3911: stop the connection-liveness heartbeat/reaper.
|
||||
await self._cancel_liveness_task()
|
||||
|
||||
# Issue #4603: this is the graceful-stop path, so stamp a clean exit in
|
||||
# the durable lifecycle sentinel before we go. Only the process that
|
||||
# wrote the boot marker can do so (ownership-checked in the ledger), so
|
||||
# a later unclean death of *another* boot is never masked. Cancel the
|
||||
# sampler first so it cannot re-stamp ``phase="running"`` after this.
|
||||
await self._cancel_ledger_heartbeat()
|
||||
if self._lifecycle_ledger is not None:
|
||||
try:
|
||||
self._lifecycle_ledger.mark_exited()
|
||||
except Exception:
|
||||
logger.debug("Failed to mark clean exit in lifecycle ledger")
|
||||
|
||||
# Release PID lock
|
||||
if hasattr(self, '_pid_lock') and self._pid_lock:
|
||||
self._pid_lock.release_lock()
|
||||
@@ -3302,7 +3511,7 @@ class WebSocketGateway:
|
||||
|
||||
@staticmethod
|
||||
async def _dispatch_agent_turn(
|
||||
agent: Any, content: str, interrupt: Any = None
|
||||
agent: Any, content: str, interrupt: Any = None, on_complete: Any = None
|
||||
) -> Any:
|
||||
"""Execute a single agent turn.
|
||||
|
||||
@@ -3321,12 +3530,31 @@ class WebSocketGateway:
|
||||
controller would let one session's abort/timeout interrupt another. A
|
||||
legacy fallback stamps ``agent.interrupt_controller`` only for agents
|
||||
whose entry point does not accept ``cancel_token``.
|
||||
|
||||
Issue #4656: ``on_complete(agent)`` — when supplied — is invoked
|
||||
**synchronously in the same execution context that produced the turn's
|
||||
result, before that context is handed back to the loop**. For a
|
||||
sync-only agent this runs *inside the worker thread* right after
|
||||
``agent.chat`` returns, so per-turn state the agent stores on shared,
|
||||
mutable attributes (e.g. ``llm.last_token_metrics``) is snapshotted for
|
||||
*this* turn before any concurrent turn — on another worker thread or a
|
||||
later event-loop tick — can overwrite it. This closes the cross-request
|
||||
usage-misattribution race for the sync ``chat`` fallback (Greptile/Qodo
|
||||
P1). The callback must be non-blocking and must not raise; its return
|
||||
value is ignored.
|
||||
"""
|
||||
_kw = {"cancel_token": interrupt} if interrupt is not None else {}
|
||||
|
||||
def _snapshot() -> None:
|
||||
if on_complete is not None:
|
||||
try:
|
||||
on_complete(agent)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def _call_async(fn: Any) -> Any:
|
||||
try:
|
||||
return await fn(content, **_kw)
|
||||
result = await fn(content, **_kw)
|
||||
except TypeError:
|
||||
if not _kw:
|
||||
raise
|
||||
@@ -3334,7 +3562,11 @@ class WebSocketGateway:
|
||||
# attribute for this turn (best-effort, non-isolated).
|
||||
if hasattr(agent, "interrupt_controller"):
|
||||
agent.interrupt_controller = interrupt
|
||||
return await fn(content)
|
||||
result = await fn(content)
|
||||
# No ``await`` between the turn completing and the snapshot: the
|
||||
# single-threaded loop cannot run another turn in between.
|
||||
_snapshot()
|
||||
return result
|
||||
|
||||
for _name in ("arun", "achat"):
|
||||
_fn = getattr(agent, _name, None)
|
||||
@@ -3345,13 +3577,18 @@ class WebSocketGateway:
|
||||
|
||||
def _call_sync() -> Any:
|
||||
try:
|
||||
return agent.chat(content, **_kw)
|
||||
result = agent.chat(content, **_kw)
|
||||
except TypeError:
|
||||
if not _kw:
|
||||
raise
|
||||
if hasattr(agent, "interrupt_controller"):
|
||||
agent.interrupt_controller = interrupt
|
||||
return agent.chat(content)
|
||||
result = agent.chat(content)
|
||||
# Snapshot in *this* worker thread, immediately after the turn
|
||||
# produced its result, before the thread returns to the pool and a
|
||||
# concurrent turn can overwrite the agent's shared metrics.
|
||||
_snapshot()
|
||||
return result
|
||||
|
||||
return await loop.run_in_executor(None, _call_sync)
|
||||
|
||||
@@ -5247,12 +5484,58 @@ class WebSocketGateway:
|
||||
# whether scale-to-zero is armed / the gateway is dormant / an external
|
||||
# drain watcher is active — without scraping logs. Only included when a
|
||||
# lifecycle feature is configured, so always-on gateways are unchanged.
|
||||
lifecycle_block: Dict[str, Any] = {}
|
||||
if self._idle_policy is not None or self._drain_marker_policy is not None:
|
||||
result["lifecycle"] = {
|
||||
lifecycle_block.update({
|
||||
"scale_to_zero": self._idle_policy is not None,
|
||||
"dormant": self._is_dormant,
|
||||
"drain_marker_watch": self._drain_marker_policy is not None,
|
||||
}
|
||||
})
|
||||
|
||||
# Issue #4603: surface the durable process-lifecycle record — whether the
|
||||
# *previous* process exited uncleanly (OOM/SIGKILL, which runs no signal
|
||||
# handler), any suspected OOM, the cross-boot restart count, and whether
|
||||
# boot-time auto-resume is suspended by the persistent breaker — plus a
|
||||
# coarse, redaction-safe memory/disk pressure block. Only included when
|
||||
# the ledger is configured, computed defensively so health() never raises.
|
||||
ledger = self._lifecycle_ledger
|
||||
if ledger is not None:
|
||||
try:
|
||||
lifecycle_block.update(ledger.last_exit())
|
||||
except Exception:
|
||||
pass
|
||||
guard = self._restart_loop_guard
|
||||
if guard is not None and hasattr(guard, "_store"):
|
||||
try:
|
||||
lifecycle_block["restarts_last_hour"] = len(
|
||||
[t for t in getattr(guard, "_events", [])
|
||||
if t >= time.time() - 3600.0]
|
||||
)
|
||||
lifecycle_block["auto_resume_suspended"] = bool(
|
||||
guard.tripped(time.time())
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
pressure = ledger.pressure()
|
||||
result["pressure"] = pressure
|
||||
# Mark the surface degraded on *critical* memory/disk so a
|
||||
# gateway being OOM-killed between kills does not report plain
|
||||
# healthy. ``unknown`` is surfaced verbatim in the pressure
|
||||
# block (never asserted healthy) but does not flip status, so a
|
||||
# host with no cgroup limit to measure keeps today's behaviour.
|
||||
if self._is_running and any(
|
||||
pressure.get(k) == "critical" for k in ("memory", "disk")
|
||||
):
|
||||
result["status"] = "degraded"
|
||||
except Exception:
|
||||
pass
|
||||
# An unclean prior exit also degrades the status surface.
|
||||
if lifecycle_block.get("last_exit") == "unclean" and self._is_running:
|
||||
result["status"] = "degraded"
|
||||
|
||||
if lifecycle_block:
|
||||
result["lifecycle"] = lifecycle_block
|
||||
|
||||
# Issue #3410: surface opt-in event-loop watchdog state so an operator
|
||||
# can confirm the liveness backstop is armed. Only included when
|
||||
@@ -5326,7 +5609,20 @@ class WebSocketGateway:
|
||||
try:
|
||||
pressure = self._compute_pressure()
|
||||
if pressure is not None:
|
||||
result["pressure"] = pressure.to_dict()
|
||||
# Issue #4603: the lifecycle ledger may already have populated
|
||||
# ``result["pressure"]`` with a coarse ``{"memory", "disk"}``
|
||||
# block. Merge the saturation/back-pressure fields in rather
|
||||
# than replacing it, so enabling both features surfaces the OS
|
||||
# resource pressure *and* the admission/backlog signals on one
|
||||
# ``pressure`` object instead of one silently clobbering the
|
||||
# other. Saturation keys ("admission_*", "outbox_*", …) are
|
||||
# disjoint from the ledger's, so the merge is non-destructive.
|
||||
existing = result.get("pressure")
|
||||
merged = pressure.to_dict()
|
||||
if isinstance(existing, dict):
|
||||
for key, value in existing.items():
|
||||
merged.setdefault(key, value)
|
||||
result["pressure"] = merged
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -7250,6 +7546,33 @@ class WebSocketGateway:
|
||||
elif hasattr(bot, "_identity_resolver"):
|
||||
bot._identity_resolver = resolver
|
||||
|
||||
def _build_turn_lock_map(self) -> Any:
|
||||
"""Build the gateway's per-turn lock backend from config (Issue #4655).
|
||||
|
||||
Honours ``gateway.turn_lock.backend``: the default ``"local"`` returns a
|
||||
plain in-process ``LockMap`` (today's behaviour, byte-for-byte), while
|
||||
``"redis"`` returns a ``RedisTurnLock`` reusing the push ``RedisConfig``
|
||||
so turns serialise cluster-wide across replicas. Construction fails open
|
||||
(local fallback + a degraded record) so an outage never wedges startup.
|
||||
"""
|
||||
cfg = getattr(self, "config", None)
|
||||
turn_lock = getattr(cfg, "turn_lock", None)
|
||||
push = getattr(cfg, "push", None)
|
||||
redis_config = getattr(push, "redis", None)
|
||||
try:
|
||||
from ..bots._redis_turn_lock import build_turn_lock
|
||||
|
||||
return build_turn_lock(
|
||||
turn_lock,
|
||||
redis_config,
|
||||
degraded_registry=getattr(self, "_degraded_registry", None),
|
||||
)
|
||||
except Exception as e: # pragma: no cover - defensive: never block startup
|
||||
logger.warning("Failed to build turn lock backend: %s", e)
|
||||
from .._lockmap import LockMap
|
||||
|
||||
return LockMap()
|
||||
|
||||
def _stamp_turn_lock_map(self, bot: Any) -> None:
|
||||
"""Share one per-turn ``LockMap`` with a channel bot (Issue #3232).
|
||||
|
||||
@@ -7270,12 +7593,19 @@ class WebSocketGateway:
|
||||
``start_channels`` and ``_start_single_channel`` (hot-reload) so a
|
||||
restarted channel keeps sharing the same lock map.
|
||||
"""
|
||||
if getattr(self, "_identity_resolver", None) is None:
|
||||
# A local backend only unifies distinct channels onto one session when an
|
||||
# identity resolver is configured (Issue #3232), so without a resolver it
|
||||
# stays a no-op and each channel keeps its own map (today's behaviour). A
|
||||
# distributed (``redis``) backend, however, must serialise across replicas
|
||||
# of even a *single* channel, so it is wired unconditionally (Issue #4655).
|
||||
cfg = getattr(self, "config", None)
|
||||
turn_lock_cfg = getattr(cfg, "turn_lock", None)
|
||||
distributed = bool(getattr(turn_lock_cfg, "enabled", False))
|
||||
if getattr(self, "_identity_resolver", None) is None and not distributed:
|
||||
return
|
||||
lock_map = getattr(self, "_turn_lock_map", None)
|
||||
if lock_map is None:
|
||||
from .._lockmap import LockMap
|
||||
lock_map = LockMap()
|
||||
lock_map = self._build_turn_lock_map()
|
||||
self._turn_lock_map = lock_map
|
||||
sess = (
|
||||
getattr(bot, "_session", None)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""SQLite kanban store implementation."""
|
||||
import logging
|
||||
import sqlite3
|
||||
import json
|
||||
import uuid
|
||||
@@ -13,6 +14,8 @@ from .models import (
|
||||
)
|
||||
from .paths import get_kanban_db_path
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Board-wide default for the per-task retry/circuit-breaker.
|
||||
DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
@@ -223,11 +226,29 @@ class SQLiteKanbanStore:
|
||||
if not path or not isinstance(path, str):
|
||||
return False
|
||||
try:
|
||||
import subprocess
|
||||
result = subprocess.run(
|
||||
from .._guarded_subprocess import TIMEOUT_RETURNCODE, run_guarded
|
||||
|
||||
# Bounded: this sits on the create_task path, which the gateway
|
||||
# dispatcher calls from its event loop. A git command that stalls
|
||||
# on a network-mounted or lock-contended path would otherwise
|
||||
# block every card creation.
|
||||
result = run_guarded(
|
||||
["git", "-C", path, "rev-parse", "--is-inside-work-tree"],
|
||||
capture_output=True, text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if result.returncode == TIMEOUT_RETURNCODE:
|
||||
# A timeout is "unknown", not "not a repository". We still
|
||||
# answer False so a slow path can never fail the create, but
|
||||
# say so: the caller downgrades the task to the shared
|
||||
# workspace, and silently losing worktree isolation is the
|
||||
# kind of thing an operator needs to see.
|
||||
_logger.warning(
|
||||
"Timed out probing %s for a git worktree; treating it as "
|
||||
"not a repository, so this task will run in the shared "
|
||||
"workspace rather than an isolated worktree. %s",
|
||||
path,
|
||||
result.stderr.strip(),
|
||||
)
|
||||
return result.returncode == 0 and result.stdout.strip() == "true"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Bots, gateway, and channel CLI for PraisonAI — messaging platforms and WebSocket control plane extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
|
||||
@@ -323,6 +323,138 @@ class TestAgentMailBotProbe:
|
||||
assert "not installed" in result.error
|
||||
|
||||
|
||||
class TestAgentMailBotIdempotency:
|
||||
"""Test AgentMail send/reply pass a stable Idempotency-Key across retries."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_same_key_across_retries(self):
|
||||
"""Every retry of one logical send must reuse the same Idempotency-Key."""
|
||||
from praisonai_bot.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
bot._inbox_id = "[email protected]"
|
||||
|
||||
seen_keys = []
|
||||
call_count = {"n": 0}
|
||||
|
||||
def send_side_effect(*args, **kwargs):
|
||||
opts = kwargs.get("request_options", {})
|
||||
seen_keys.append(opts.get("additional_headers", {}).get("Idempotency-Key"))
|
||||
call_count["n"] += 1
|
||||
# Fail the first attempt with a recoverable timeout (the exact
|
||||
# timeout-after-send case) so deliver_outbound() retries.
|
||||
if call_count["n"] == 1:
|
||||
raise TimeoutError("simulated timeout after send")
|
||||
return MagicMock(message_id="sent-123")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.inboxes.messages.send.side_effect = send_side_effect
|
||||
|
||||
with patch.object(bot, "_get_client", return_value=mock_client):
|
||||
await bot.send_message(
|
||||
channel_id="[email protected]",
|
||||
content={"subject": "Hi", "body": "hello"},
|
||||
)
|
||||
|
||||
assert len(seen_keys) >= 2
|
||||
assert all(k is not None for k in seen_keys)
|
||||
assert len(set(seen_keys)) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reply_passes_idempotency_key(self):
|
||||
"""messages.reply() must also receive an Idempotency-Key header."""
|
||||
from praisonai_bot.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
bot._inbox_id = "[email protected]"
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.inboxes.messages.reply.return_value = MagicMock(message_id="r-1")
|
||||
|
||||
with patch.object(bot, "_get_client", return_value=mock_client):
|
||||
await bot.send_message(
|
||||
channel_id="[email protected]",
|
||||
content={"subject": "Re: Hi", "body": "reply"},
|
||||
reply_to="orig-msg-id",
|
||||
)
|
||||
|
||||
_, kwargs = mock_client.inboxes.messages.reply.call_args
|
||||
key = kwargs["request_options"]["additional_headers"]["Idempotency-Key"]
|
||||
assert key
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_distinct_sends_get_distinct_keys(self):
|
||||
"""Two distinct logical sends must receive different keys."""
|
||||
from praisonai_bot.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
bot._inbox_id = "[email protected]"
|
||||
|
||||
keys = []
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
keys.append(
|
||||
kwargs["request_options"]["additional_headers"]["Idempotency-Key"]
|
||||
)
|
||||
return MagicMock(message_id="s")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.inboxes.messages.send.side_effect = capture
|
||||
|
||||
with patch.object(bot, "_get_client", return_value=mock_client):
|
||||
await bot.send_message(
|
||||
channel_id="[email protected]", content={"body": "one"}
|
||||
)
|
||||
await bot.send_message(
|
||||
channel_id="[email protected]", content={"body": "two"}
|
||||
)
|
||||
|
||||
assert len(keys) == 2
|
||||
assert keys[0] != keys[1]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_explicit_key_is_used(self):
|
||||
"""A caller-supplied idempotency_key must be forwarded verbatim."""
|
||||
from praisonai_bot.bots import AgentMailBot
|
||||
|
||||
bot = AgentMailBot(token="test_token")
|
||||
bot._inbox_id = "[email protected]"
|
||||
|
||||
keys = []
|
||||
|
||||
def capture(*args, **kwargs):
|
||||
keys.append(
|
||||
kwargs["request_options"]["additional_headers"]["Idempotency-Key"]
|
||||
)
|
||||
return MagicMock(message_id="s")
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.inboxes.messages.send.side_effect = capture
|
||||
|
||||
with patch.object(bot, "_get_client", return_value=mock_client):
|
||||
await bot.send_message(
|
||||
channel_id="[email protected]",
|
||||
content={"body": "one"},
|
||||
idempotency_key="stable-deterministic-key",
|
||||
)
|
||||
|
||||
assert keys == ["stable-deterministic-key"]
|
||||
|
||||
def test_idempotency_key_is_explicit_param(self):
|
||||
"""The param must be declared explicitly so DurableDelivery detects it.
|
||||
|
||||
``DurableDelivery._send_accepts_idempotency_key()`` only forwards its
|
||||
persisted key to adapters whose ``send_message`` signature contains an
|
||||
explicit ``idempotency_key`` parameter (a ``**kwargs`` fallback is
|
||||
deliberately excluded). This guards that contract for AgentMail.
|
||||
"""
|
||||
import inspect
|
||||
from praisonai_bot.bots import AgentMailBot
|
||||
|
||||
params = inspect.signature(AgentMailBot.send_message).parameters
|
||||
assert "idempotency_key" in params
|
||||
|
||||
|
||||
class TestAgentMailBotHealth:
|
||||
"""Test AgentMailBot health functionality."""
|
||||
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Issue #4655 — the distributed turn lock honours ``turn_lock.backend``.
|
||||
|
||||
``gateway.turn_lock.backend="redis"`` must actually serialise turns for a
|
||||
resolved session across replicas, not be silently inert. These tests drive the
|
||||
wrapper-side ``RedisTurnLock`` / ``build_turn_lock`` with an in-memory fake Redis
|
||||
(so no server is needed) and verify:
|
||||
|
||||
* the factory returns a plain ``LockMap`` for the default ``local`` backend,
|
||||
* it returns a ``RedisTurnLock`` for ``redis`` and serialises across two
|
||||
*independent* lock instances sharing one Redis (the cross-replica case),
|
||||
* a Redis outage fails *open* (local serialisation + a degraded record) rather
|
||||
than wedging a live turn.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot._lockmap import LockMap
|
||||
from praisonai_bot.bots._redis_turn_lock import RedisTurnLock, build_turn_lock
|
||||
from praisonaiagents.gateway import TurnLockConfig
|
||||
|
||||
|
||||
class FakeRedis:
|
||||
"""Minimal async Redis supporting SET NX PX, GET, DEL, PEXPIRE and EVAL.
|
||||
|
||||
Models per-key TTL expiry (``px``) so a lease that is never renewed becomes
|
||||
reclaimable, and shared by multiple ``RedisTurnLock`` instances to model
|
||||
separate replicas talking to one Redis.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.store: dict = {}
|
||||
self._expiry: dict = {}
|
||||
self.eval_calls: list = []
|
||||
|
||||
def _expire_if_stale(self, key):
|
||||
exp = self._expiry.get(key)
|
||||
if exp is not None and time.monotonic() >= exp:
|
||||
self.store.pop(key, None)
|
||||
self._expiry.pop(key, None)
|
||||
|
||||
async def set(self, key, value, nx=False, px=None):
|
||||
self._expire_if_stale(key)
|
||||
if nx and key in self.store:
|
||||
return None
|
||||
self.store[key] = value
|
||||
if px is not None:
|
||||
self._expiry[key] = time.monotonic() + (px / 1000.0)
|
||||
else:
|
||||
self._expiry.pop(key, None)
|
||||
return True
|
||||
|
||||
async def get(self, key):
|
||||
self._expire_if_stale(key)
|
||||
return self.store.get(key)
|
||||
|
||||
async def delete(self, key):
|
||||
self.store.pop(key, None)
|
||||
self._expiry.pop(key, None)
|
||||
return 1
|
||||
|
||||
async def pexpire(self, key, px):
|
||||
self._expire_if_stale(key)
|
||||
if key not in self.store:
|
||||
return 0
|
||||
self._expiry[key] = time.monotonic() + (px / 1000.0)
|
||||
return 1
|
||||
|
||||
async def eval(self, script, numkeys, key, *args):
|
||||
self.eval_calls.append((script, key, args))
|
||||
self._expire_if_stale(key)
|
||||
arg = args[0]
|
||||
if "pexpire" in script:
|
||||
if self.store.get(key) == arg:
|
||||
self._expiry[key] = time.monotonic() + (int(args[1]) / 1000.0)
|
||||
return 1
|
||||
return 0
|
||||
# compare-and-del
|
||||
if self.store.get(key) == arg:
|
||||
self.store.pop(key, None)
|
||||
self._expiry.pop(key, None)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
class BoomRedis:
|
||||
async def set(self, *a, **k):
|
||||
raise RuntimeError("redis down")
|
||||
|
||||
|
||||
def test_build_turn_lock_local_returns_lockmap():
|
||||
lock = build_turn_lock(TurnLockConfig()) # default local
|
||||
assert isinstance(lock, LockMap)
|
||||
|
||||
|
||||
def test_build_turn_lock_none_returns_lockmap():
|
||||
assert isinstance(build_turn_lock(None), LockMap)
|
||||
|
||||
|
||||
def test_build_turn_lock_redis_without_config_degrades_to_local():
|
||||
registry_marks = []
|
||||
|
||||
class Reg:
|
||||
def mark(self, owner):
|
||||
registry_marks.append(owner)
|
||||
|
||||
def clear(self, *a):
|
||||
pass
|
||||
|
||||
lock = build_turn_lock(
|
||||
TurnLockConfig(backend="redis"), None, degraded_registry=Reg()
|
||||
)
|
||||
# No Redis available -> fail open to a local map, and record the degradation.
|
||||
assert isinstance(lock, LockMap)
|
||||
assert registry_marks # a degraded entry was recorded
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_turn_lock_serialises_across_instances():
|
||||
"""Two independent RedisTurnLocks over one Redis serialise a shared key."""
|
||||
redis = FakeRedis()
|
||||
a = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
|
||||
b = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
|
||||
|
||||
order = []
|
||||
|
||||
async def worker(lock, label):
|
||||
async with lock.get("session-1"):
|
||||
order.append(f"{label}-enter")
|
||||
await asyncio.sleep(0.05)
|
||||
order.append(f"{label}-exit")
|
||||
|
||||
await asyncio.gather(worker(a, "A"), worker(b, "B"))
|
||||
|
||||
# Whoever ran first must fully finish before the other enters.
|
||||
assert order[0].endswith("enter")
|
||||
assert order[1].endswith("exit")
|
||||
first = order[0].split("-")[0]
|
||||
second = order[2].split("-")[0]
|
||||
assert first != second
|
||||
# Lease is released back to Redis after both turns.
|
||||
assert redis.store == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_turn_lock_release_is_compare_and_del():
|
||||
redis = FakeRedis()
|
||||
lock = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
|
||||
async with lock.get("k"):
|
||||
# While held, the key exists under our namespaced prefix.
|
||||
assert any("turnlock" in key or key.endswith("k") for key in redis.store)
|
||||
assert redis.store == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_turn_lock_release_leaves_other_owner_key():
|
||||
"""Release is owner-checked: a lease reclaimed by another replica survives."""
|
||||
redis = FakeRedis()
|
||||
lock = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
|
||||
async with lock.get("k"):
|
||||
# Simulate another replica reclaiming the expired lease mid-turn.
|
||||
(redis_key,) = list(redis.store)
|
||||
redis.store[redis_key] = "other-owner-token"
|
||||
# Our compare-and-del must NOT delete a key we no longer own.
|
||||
assert redis.store.get(redis_key) == "other-owner-token"
|
||||
# And the release went through the atomic EVAL path.
|
||||
assert redis.eval_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_turn_lock_renews_lease_during_long_turn():
|
||||
"""A turn longer than ttl keeps the lease via owner-checked renewal."""
|
||||
redis = FakeRedis()
|
||||
lock = RedisTurnLock(redis, ttl=0.06, poll_interval=0.01)
|
||||
async with lock.get("k"):
|
||||
(redis_key,) = list(redis.store)
|
||||
first = redis.store[redis_key]
|
||||
# Run well past ttl; without renewal the key would expire and vanish.
|
||||
await asyncio.sleep(0.2)
|
||||
assert redis.store.get(redis_key) == first # still ours
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_turn_lock_reclaims_expired_lease():
|
||||
"""An unrenewed, expired lease becomes reclaimable (no permanent wedge)."""
|
||||
redis = FakeRedis()
|
||||
a = RedisTurnLock(redis, ttl=0.05, poll_interval=0.01)
|
||||
# Manually plant an expiring lease from a "crashed" holder.
|
||||
redis_key = a._redis_key("k")
|
||||
await redis.set(redis_key, "dead-holder", nx=True, px=50)
|
||||
b = RedisTurnLock(redis, ttl=5.0, poll_interval=0.01)
|
||||
# b must be able to acquire once the dead holder's lease expires.
|
||||
async with b.get("k"):
|
||||
assert redis.store.get(redis_key) != "dead-holder"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_redis_turn_lock_fails_open_and_serialises_on_outage():
|
||||
"""A Redis outage must not wedge a turn and must still serialise in-process."""
|
||||
marks = []
|
||||
|
||||
class Reg:
|
||||
def mark(self, owner):
|
||||
marks.append(owner)
|
||||
|
||||
def clear(self, *a):
|
||||
pass
|
||||
|
||||
lock = RedisTurnLock(BoomRedis(), ttl=5.0, poll_interval=0.01, degraded_registry=Reg())
|
||||
|
||||
events = []
|
||||
|
||||
async def worker(label):
|
||||
async with lock.get("k"):
|
||||
events.append(f"{label}-enter")
|
||||
await asyncio.sleep(0.03)
|
||||
events.append(f"{label}-exit")
|
||||
|
||||
await asyncio.gather(worker("A"), worker("B"))
|
||||
|
||||
# Even with Redis down, the local lock serialises the two turns.
|
||||
assert events[0].endswith("enter")
|
||||
assert events[1].endswith("exit")
|
||||
assert events[0].split("-")[0] != events[2].split("-")[0]
|
||||
assert marks # degradation surfaced
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for gateway live-session handoff (Issue #4660).
|
||||
|
||||
``BotOS.handoff`` re-homes an in-progress conversation onto a freshly created
|
||||
thread/DM on another platform: it creates the destination thread via the
|
||||
existing ``DeliveryRouter.create_thread`` primitive, seeds that thread's
|
||||
session with the origin transcript, optionally posts a seed message and drops a
|
||||
breadcrumb in the origin — routing subsequent turns to the new channel.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[5]
|
||||
sys.path.insert(0, str(REPO_ROOT / "src" / "praisonai"))
|
||||
sys.path.insert(0, str(REPO_ROOT / "src" / "praisonai-agents"))
|
||||
|
||||
from praisonai_bot.bots import BotOS, ThreadRef # noqa: E402
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Minimal BotSessionManager stand-in for handoff export/seed."""
|
||||
|
||||
def __init__(self, histories=None):
|
||||
self._by_key = dict(histories or {})
|
||||
self.seeded = {}
|
||||
|
||||
def export_history(self, storage_key):
|
||||
return list(self._by_key.get(storage_key, []))
|
||||
|
||||
def seed_history(self, storage_key, history):
|
||||
self.seeded[storage_key] = list(history)
|
||||
|
||||
|
||||
class _FakeAdapter:
|
||||
"""Adapter exposing the native thread primitive + capability flag."""
|
||||
|
||||
def __init__(self, *, threads=True, thread_id="T-99"):
|
||||
self.capabilities = {"threads": threads}
|
||||
self._thread_id = thread_id
|
||||
self.created = []
|
||||
|
||||
async def create_thread(self, channel_id, name):
|
||||
self.created.append((channel_id, name))
|
||||
return self._thread_id
|
||||
|
||||
|
||||
class _FakeBot:
|
||||
"""Channel bot stub: platform + adapter + session manager + home channel."""
|
||||
|
||||
def __init__(self, platform, *, adapter, session, home="C-home"):
|
||||
self.platform = platform
|
||||
self.adapter = adapter
|
||||
self._session = session
|
||||
self._home_channel = home
|
||||
self.sends = []
|
||||
|
||||
async def send_message(self, channel_id, text, thread_id=None):
|
||||
self.sends.append((channel_id, text, thread_id))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def _make_botos(bot):
|
||||
botos = BotOS(bots=[])
|
||||
botos._bots[bot.platform] = bot
|
||||
# Point the router's home channel so a bare "<platform>" target resolves.
|
||||
botos.delivery_router.directory.set_home_channel(bot.platform, bot._home_channel)
|
||||
return botos
|
||||
|
||||
|
||||
def test_handoff_creates_thread_and_seeds_session():
|
||||
transcript = [
|
||||
{"role": "user", "content": "start on laptop"},
|
||||
{"role": "assistant", "content": "working..."},
|
||||
]
|
||||
session = _FakeSession({"cli:alice": transcript})
|
||||
adapter = _FakeAdapter(threads=True, thread_id="T-42")
|
||||
bot = _FakeBot("telegram", adapter=adapter, session=session)
|
||||
botos = _make_botos(bot)
|
||||
|
||||
ref = asyncio.run(botos.handoff("cli:alice", "telegram"))
|
||||
|
||||
assert isinstance(ref, ThreadRef)
|
||||
assert ref.ok
|
||||
assert ref.platform == "telegram"
|
||||
assert ref.thread_id == "T-42"
|
||||
assert ref.session_key == "telegram:C-home:T-42"
|
||||
assert ref.target == "telegram:C-home:T-42"
|
||||
# Thread was created and the origin transcript was seeded onto it.
|
||||
assert adapter.created == [("C-home", "Handoff")]
|
||||
assert session.seeded["telegram:C-home:T-42"] == transcript
|
||||
|
||||
|
||||
def test_handoff_posts_seed_text_into_new_thread():
|
||||
session = _FakeSession({"cli:alice": [{"role": "user", "content": "hi"}]})
|
||||
adapter = _FakeAdapter(thread_id="T-7")
|
||||
bot = _FakeBot("telegram", adapter=adapter, session=session)
|
||||
botos = _make_botos(bot)
|
||||
|
||||
ref = asyncio.run(
|
||||
botos.handoff("cli:alice", "telegram", seed_text="Continuing here")
|
||||
)
|
||||
|
||||
assert ref.ok
|
||||
# The seed message was delivered to the new thread.
|
||||
assert any(
|
||||
text == "Continuing here" and thread == "T-7"
|
||||
for (_chan, text, thread) in bot.sends
|
||||
)
|
||||
|
||||
|
||||
def test_handoff_unsupported_when_adapter_cannot_thread():
|
||||
session = _FakeSession({"cli:alice": [{"role": "user", "content": "hi"}]})
|
||||
adapter = _FakeAdapter(threads=False)
|
||||
bot = _FakeBot("telegram", adapter=adapter, session=session)
|
||||
botos = _make_botos(bot)
|
||||
|
||||
ref = asyncio.run(botos.handoff("cli:alice", "telegram"))
|
||||
|
||||
assert not ref.ok
|
||||
assert ref.status == "unsupported"
|
||||
# No session was seeded for a handoff that could not create a thread.
|
||||
assert session.seeded == {}
|
||||
|
||||
|
||||
def test_handoff_no_route_for_unknown_platform():
|
||||
session = _FakeSession({"cli:alice": [{"role": "user", "content": "hi"}]})
|
||||
adapter = _FakeAdapter()
|
||||
bot = _FakeBot("telegram", adapter=adapter, session=session)
|
||||
botos = _make_botos(bot)
|
||||
|
||||
ref = asyncio.run(botos.handoff("cli:alice", "whatsapp"))
|
||||
|
||||
assert not ref.ok
|
||||
assert ref.status in ("no_route", "unsupported")
|
||||
assert session.seeded == {}
|
||||
|
||||
|
||||
def test_handoff_of_empty_session_still_creates_thread():
|
||||
session = _FakeSession({}) # no transcript for this key
|
||||
adapter = _FakeAdapter(thread_id="T-1")
|
||||
bot = _FakeBot("telegram", adapter=adapter, session=session)
|
||||
botos = _make_botos(bot)
|
||||
|
||||
ref = asyncio.run(botos.handoff("cli:ghost", "telegram"))
|
||||
|
||||
assert ref.ok
|
||||
assert adapter.created == [("C-home", "Handoff")]
|
||||
# Nothing to seed, but the thread was still opened cleanly.
|
||||
assert session.seeded == {}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_handoff_creates_thread_and_seeds_session()
|
||||
test_handoff_posts_seed_text_into_new_thread()
|
||||
test_handoff_unsupported_when_adapter_cannot_thread()
|
||||
test_handoff_no_route_for_unknown_platform()
|
||||
test_handoff_of_empty_session_still_creates_thread()
|
||||
print("all handoff tests passed")
|
||||
@@ -156,3 +156,100 @@ class TestSessionKeyDerivation:
|
||||
key = mgr._session_key("12345")
|
||||
assert "alice-global" in key
|
||||
assert "telegram" not in key # unified, not platform-prefixed
|
||||
|
||||
|
||||
class TestIdentityCanonicalization:
|
||||
"""Issue #4661: a volatile platform id is canonicalised before it keys
|
||||
the session (via an ``IdentityCanonicalizerProtocol``), so an alias/format
|
||||
change for the same human collapses to one session instead of silently
|
||||
forking."""
|
||||
|
||||
@staticmethod
|
||||
def _canon(mapping):
|
||||
class _Canon:
|
||||
def canonicalize(self, platform, raw_user_id):
|
||||
return mapping.get(raw_user_id, raw_user_id)
|
||||
|
||||
return _Canon()
|
||||
|
||||
def test_no_canonicalizer_is_identity_default(self):
|
||||
# Default: raw id keys the session, byte-for-byte unchanged.
|
||||
mgr = BotSessionManager(platform="whatsapp")
|
||||
assert mgr._storage_key("<JID>") == "<JID>"
|
||||
assert mgr._canonical_user_id("<JID>") == "<JID>"
|
||||
|
||||
def test_canonicalizer_collapses_alias_before_key(self):
|
||||
# Simulate WhatsApp JID/LID for one person collapsing to a stable id.
|
||||
canon = self._canon({"jid-A": "person-1", "lid-B": "person-1"})
|
||||
mgr = BotSessionManager(platform="whatsapp", identity_canonicalizer=canon)
|
||||
assert mgr._storage_key("jid-A") == mgr._storage_key("lid-B")
|
||||
assert mgr._storage_key("jid-A") == "person-1"
|
||||
|
||||
def test_canonicalizer_runs_before_resolver(self):
|
||||
from praisonaiagents.session.identity import InMemoryIdentityResolver
|
||||
|
||||
# Resolver only knows the canonical id, not the raw volatile ones.
|
||||
resolver = InMemoryIdentityResolver()
|
||||
resolver.link("whatsapp", "person-1", "alice-global")
|
||||
canon = self._canon({"jid-A": "person-1", "lid-B": "person-1"})
|
||||
|
||||
mgr = BotSessionManager(
|
||||
platform="whatsapp",
|
||||
identity_resolver=resolver,
|
||||
identity_canonicalizer=canon,
|
||||
)
|
||||
# Both raw forms canonicalise to person-1, which the resolver unifies.
|
||||
assert mgr._storage_key("jid-A") == "alice-global"
|
||||
assert mgr._storage_key("lid-B") == "alice-global"
|
||||
|
||||
def test_canonicalizer_failure_falls_back_to_raw(self):
|
||||
class Boom:
|
||||
def canonicalize(self, platform, raw_user_id):
|
||||
raise RuntimeError("canonicalizer bug")
|
||||
|
||||
mgr = BotSessionManager(platform="whatsapp", identity_canonicalizer=Boom())
|
||||
# Fail-open: a buggy canonicalizer never breaks session keying.
|
||||
assert mgr._storage_key("jid-A") == "jid-A"
|
||||
|
||||
def test_empty_result_falls_back_to_raw(self):
|
||||
mgr = BotSessionManager(
|
||||
platform="whatsapp", identity_canonicalizer=self._canon({"jid-A": ""})
|
||||
)
|
||||
assert mgr._storage_key("jid-A") == "jid-A"
|
||||
|
||||
def test_base_adapter_is_a_canonicalizer_and_identity_by_default(self):
|
||||
from praisonaiagents.bots import BasePlatformAdapter
|
||||
from praisonaiagents.gateway.protocols import IdentityCanonicalizerProtocol
|
||||
|
||||
class MinimalAdapter(BasePlatformAdapter):
|
||||
async def connect(self, *, is_reconnect=False):
|
||||
return True
|
||||
|
||||
async def disconnect(self):
|
||||
return None
|
||||
|
||||
async def send(self, chat_id, content, *, reply_to=None, metadata=None):
|
||||
from praisonaiagents.bots import SendResult
|
||||
|
||||
return SendResult(ok=True, chat_id=chat_id)
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {"id": chat_id}
|
||||
|
||||
adapter = MinimalAdapter()
|
||||
# The adapter structurally *is* an IdentityCanonicalizerProtocol.
|
||||
assert isinstance(adapter, IdentityCanonicalizerProtocol)
|
||||
assert adapter.canonicalize("whatsapp", "raw-id") == "raw-id"
|
||||
# Wired as the session's canonicalizer, it is a no-op by default.
|
||||
mgr = BotSessionManager(platform="whatsapp", identity_canonicalizer=adapter)
|
||||
assert mgr._storage_key("raw-id") == "raw-id"
|
||||
|
||||
def test_attach_gateway_runtime_adopts_canonicalizer(self):
|
||||
from praisonaiagents.bots import GatewayRuntimeSeams
|
||||
|
||||
canon = self._canon({"jid-A": "person-1"})
|
||||
mgr = BotSessionManager(platform="whatsapp")
|
||||
mgr.attach_gateway_runtime(
|
||||
GatewayRuntimeSeams(identity_canonicalizer=canon)
|
||||
)
|
||||
assert mgr._storage_key("jid-A") == "person-1"
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Tests for turn-liveness binding of gateway approvals (issue #4598).
|
||||
|
||||
Verifies that a pending approval bound to a session/run-generation is:
|
||||
- cancelled (fail-closed) when its turn is superseded/stopped, unblocking the
|
||||
awaiting caller promptly (so ``/stop`` produces a visible outcome); and
|
||||
- dropped at the resolution boundary if a resolution arrives after the turn
|
||||
was superseded (no stale tool execution / reply); while
|
||||
- unbound requests (no session/generation) keep today's behaviour.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
from praisonai_bot.gateway.exec_approval import (
|
||||
ExecApprovalManager,
|
||||
Resolution,
|
||||
)
|
||||
|
||||
|
||||
def test_unbound_request_is_always_live(tmp_path):
|
||||
"""A request with no session/generation resolves normally (unchanged)."""
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
|
||||
async def go():
|
||||
rid, future = await mgr.register(
|
||||
tool_name="shell_exec", arguments={"cmd": "ls"}, agent_name="a"
|
||||
)
|
||||
assert mgr.resolve(rid, Resolution(approved=True)) is True
|
||||
return await future
|
||||
|
||||
res = asyncio.run(go())
|
||||
assert res.approved is True
|
||||
|
||||
|
||||
def test_cancel_for_generation_unblocks_pending(tmp_path):
|
||||
"""A /stop-style cancel completes the pending future as denied promptly."""
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
|
||||
async def go():
|
||||
rid, future = await mgr.register(
|
||||
tool_name="shell_exec",
|
||||
arguments={"cmd": "rm -rf x"},
|
||||
agent_name="a",
|
||||
session_id="s1",
|
||||
run_generation=1,
|
||||
)
|
||||
n = mgr.cancel_for_generation("s1", 1)
|
||||
assert n == 1
|
||||
return rid, await future
|
||||
|
||||
rid, res = asyncio.run(go())
|
||||
assert res.approved is False
|
||||
assert res.reason == "cancelled"
|
||||
# The request is gone; a later resolve is a no-op.
|
||||
assert mgr.resolve(rid, Resolution(approved=True)) is False
|
||||
|
||||
|
||||
def test_stale_resolution_is_dropped(tmp_path):
|
||||
"""A resolution arriving after supersede is dropped (fail-closed)."""
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
|
||||
async def go():
|
||||
_rid, _future = await mgr.register(
|
||||
tool_name="shell_exec",
|
||||
arguments={"cmd": "deploy"},
|
||||
agent_name="a",
|
||||
session_id="s1",
|
||||
run_generation=1,
|
||||
)
|
||||
# Turn superseded by a newer generation, but this old pending was not
|
||||
# in the cancel batch (e.g. registered independently); mark superseded.
|
||||
mgr.cancel_for_generation("s1", 1)
|
||||
# Re-register a fresh pending for the SAME old generation to simulate a
|
||||
# late resolution hitting a stale turn. With fail-closed registration
|
||||
# this future is denied at register() time; either way the reviewer's
|
||||
# late approval must be dropped and the future resolves "superseded".
|
||||
rid2, future2 = await mgr.register(
|
||||
tool_name="shell_exec",
|
||||
arguments={"cmd": "deploy"},
|
||||
agent_name="a",
|
||||
session_id="s1",
|
||||
run_generation=1,
|
||||
)
|
||||
# Reviewer approves the stale turn -> must be dropped.
|
||||
assert mgr.resolve(rid2, Resolution(approved=True)) is False
|
||||
return await future2
|
||||
|
||||
res = asyncio.run(go())
|
||||
assert res.approved is False
|
||||
assert res.reason == "superseded"
|
||||
|
||||
|
||||
def test_newer_generation_still_live(tmp_path):
|
||||
"""Superseding an old generation does not affect a newer one."""
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
|
||||
async def go():
|
||||
mgr.cancel_for_generation("s1", 1) # supersede gen 1
|
||||
rid, future = await mgr.register(
|
||||
tool_name="shell_exec",
|
||||
arguments={"cmd": "ls"},
|
||||
agent_name="a",
|
||||
session_id="s1",
|
||||
run_generation=2, # newer, still live
|
||||
)
|
||||
assert mgr.resolve(rid, Resolution(approved=True)) is True
|
||||
return await future
|
||||
|
||||
res = asyncio.run(go())
|
||||
assert res.approved is True
|
||||
|
||||
|
||||
def test_register_after_supersede_is_denied_immediately(tmp_path):
|
||||
"""Registering for an already-superseded generation fail-closes at once.
|
||||
|
||||
Guards the TOCTOU window: if /stop marks the generation before the tool's
|
||||
register() call lands, the caller must not park on a future no cancel batch
|
||||
will ever complete.
|
||||
"""
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
|
||||
async def go():
|
||||
mgr.cancel_for_generation("s1", 5) # supersede up to gen 5
|
||||
rid, future = await mgr.register(
|
||||
tool_name="shell_exec",
|
||||
arguments={"cmd": "rm -rf x"},
|
||||
agent_name="a",
|
||||
session_id="s1",
|
||||
run_generation=5, # <= superseded -> denied at registration
|
||||
)
|
||||
# Not inserted: a resolve finds nothing.
|
||||
assert mgr.resolve(rid, Resolution(approved=True)) is False
|
||||
assert future.done()
|
||||
return await future
|
||||
|
||||
res = asyncio.run(go())
|
||||
assert res.approved is False
|
||||
assert res.reason == "superseded"
|
||||
|
||||
|
||||
def test_non_integer_run_generation_registers_unbound(tmp_path):
|
||||
"""A non-integer run_generation must not break cancellation.
|
||||
|
||||
An un-coercible generation drops the (untrustworthy) binding to unbound so
|
||||
the request still resolves normally instead of silently un-cancellable.
|
||||
"""
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
|
||||
async def go():
|
||||
rid, future = await mgr.register(
|
||||
tool_name="shell_exec",
|
||||
arguments={"cmd": "ls"},
|
||||
agent_name="a",
|
||||
session_id="s1",
|
||||
run_generation="not-an-int", # type: ignore[arg-type]
|
||||
)
|
||||
# Unbound -> a matching cancel does not touch it and resolve works.
|
||||
assert mgr.cancel_for_generation("s1", 1) == 0
|
||||
assert mgr.resolve(rid, Resolution(approved=True)) is True
|
||||
return await future
|
||||
|
||||
res = asyncio.run(go())
|
||||
assert res.approved is True
|
||||
|
||||
|
||||
def test_supersede_callback_wires_stop_to_cancel(tmp_path):
|
||||
"""End-to-end: SessionRunControl.stop() cancels the turn's pending approval.
|
||||
|
||||
Exercises the production wiring (``make_approval_supersede_callback`` +
|
||||
``on_supersede``) rather than calling the manager in isolation, so /stop
|
||||
genuinely unblocks an approval-parked turn.
|
||||
"""
|
||||
from praisonai_bot.bots._run_control import (
|
||||
SessionRunControl,
|
||||
make_approval_supersede_callback,
|
||||
)
|
||||
|
||||
mgr = ExecApprovalManager(ttl=300, allowlist_path=tmp_path / "allow.sqlite")
|
||||
run_control = SessionRunControl(
|
||||
busy_mode="interrupt",
|
||||
on_supersede=make_approval_supersede_callback(mgr),
|
||||
)
|
||||
|
||||
async def go():
|
||||
# Start a run so the session has generation 1, then park an approval
|
||||
# bound to (user, gen) exactly as the gateway would.
|
||||
assert (await run_control.submit("user1", "deploy")).value == "run_now"
|
||||
gen = run_control.get_run_status("user1")["run_generation"]
|
||||
_rid, future = await mgr.register(
|
||||
tool_name="shell_exec",
|
||||
arguments={"cmd": "deploy"},
|
||||
agent_name="a",
|
||||
session_id="user1",
|
||||
run_generation=gen,
|
||||
)
|
||||
# /stop must fire on_supersede -> cancel_for_generation -> unblock.
|
||||
assert await run_control.stop("user1") is True
|
||||
return await future
|
||||
|
||||
res = asyncio.run(go())
|
||||
assert res.approved is False
|
||||
assert res.reason == "cancelled"
|
||||
@@ -19,7 +19,19 @@ from praisonaiagents.gateway import GatewayConfig, ApiConfig
|
||||
from praisonai_bot.gateway.api_endpoints import GatewayApiEndpoints, _extract_text
|
||||
|
||||
|
||||
class _FakeMetrics:
|
||||
input_tokens = 11
|
||||
output_tokens = 7
|
||||
|
||||
|
||||
class _FakeLLM:
|
||||
last_token_metrics = _FakeMetrics()
|
||||
|
||||
|
||||
class _FakeAgent:
|
||||
def __init__(self):
|
||||
self._llm_instance = _FakeLLM()
|
||||
|
||||
async def achat(self, content):
|
||||
return f"echo:{content}"
|
||||
|
||||
@@ -47,8 +59,13 @@ class _FakeGateway:
|
||||
return _FakeSession()
|
||||
|
||||
@staticmethod
|
||||
async def _dispatch_agent_turn(agent, content):
|
||||
return await agent.achat(content)
|
||||
async def _dispatch_agent_turn(agent, content, on_complete=None):
|
||||
result = await agent.achat(content)
|
||||
# Mirror the real gateway: snapshot per-turn state in the same context
|
||||
# that produced the result, before returning to the caller.
|
||||
if on_complete is not None:
|
||||
on_complete(agent)
|
||||
return result
|
||||
|
||||
|
||||
class _FakeReq:
|
||||
@@ -132,6 +149,260 @@ def test_openai_chat_dispatches_to_registered_agent():
|
||||
assert data["choices"][0]["message"]["content"] == "echo:hi"
|
||||
|
||||
|
||||
def test_openai_chat_reports_real_usage():
|
||||
ep = GatewayApiEndpoints(_FakeGateway())
|
||||
resp = asyncio.run(
|
||||
ep.openai_chat(
|
||||
_FakeReq(
|
||||
{"model": "assistant", "messages": [{"role": "user", "content": "hi"}]}
|
||||
)
|
||||
)
|
||||
)
|
||||
usage = _body(resp)["usage"]
|
||||
assert usage["prompt_tokens"] == 11
|
||||
assert usage["completion_tokens"] == 7
|
||||
assert usage["total_tokens"] == 18
|
||||
|
||||
|
||||
def test_openai_chat_usage_zero_when_no_metrics():
|
||||
class _NoMetricsAgent:
|
||||
async def achat(self, content):
|
||||
return f"echo:{content}"
|
||||
|
||||
class _Gw(_FakeGateway):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._agent = _NoMetricsAgent()
|
||||
|
||||
ep = GatewayApiEndpoints(_Gw())
|
||||
resp = asyncio.run(
|
||||
ep.openai_chat(
|
||||
_FakeReq(
|
||||
{"model": "assistant", "messages": [{"role": "user", "content": "hi"}]}
|
||||
)
|
||||
)
|
||||
)
|
||||
usage = _body(resp)["usage"]
|
||||
assert usage == {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}
|
||||
|
||||
|
||||
def _collect_sse(resp):
|
||||
async def _run():
|
||||
chunks = []
|
||||
async for part in resp.body_iterator:
|
||||
chunks.append(part if isinstance(part, str) else part.decode())
|
||||
return chunks
|
||||
|
||||
return asyncio.run(_run())
|
||||
|
||||
|
||||
def test_openai_chat_stream_emits_usage_chunk_when_opted_in():
|
||||
ep = GatewayApiEndpoints(_FakeGateway())
|
||||
resp = asyncio.run(
|
||||
ep.openai_chat(
|
||||
_FakeReq(
|
||||
{
|
||||
"model": "assistant",
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
parts = _collect_sse(resp)
|
||||
usage_payloads = [
|
||||
json.loads(p[len("data: "):])
|
||||
for p in parts
|
||||
if p.startswith("data: ") and '"usage"' in p
|
||||
]
|
||||
assert usage_payloads, "expected a usage-bearing chunk"
|
||||
usage = usage_payloads[-1]["usage"]
|
||||
assert usage["prompt_tokens"] == 11
|
||||
assert usage["completion_tokens"] == 7
|
||||
assert usage["total_tokens"] == 18
|
||||
assert parts[-1] == "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def test_openai_chat_stream_omits_usage_by_default():
|
||||
ep = GatewayApiEndpoints(_FakeGateway())
|
||||
resp = asyncio.run(
|
||||
ep.openai_chat(
|
||||
_FakeReq(
|
||||
{
|
||||
"model": "assistant",
|
||||
"stream": True,
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
parts = _collect_sse(resp)
|
||||
assert not any('"usage"' in p for p in parts)
|
||||
assert parts[-1] == "data: [DONE]\n\n"
|
||||
|
||||
|
||||
def test_dispatch_binds_usage_snapshot_to_its_turn():
|
||||
# ``_dispatch`` must return the token usage snapshotted atomically with the
|
||||
# turn result. A later overwrite of the shared, mutable ``last_token_metrics``
|
||||
# (as a concurrent turn on the same Agent would do) must not change the
|
||||
# already-returned snapshot.
|
||||
class _MutableMetrics:
|
||||
def __init__(self, i, o):
|
||||
self.input_tokens = i
|
||||
self.output_tokens = o
|
||||
|
||||
class _SharedLLM:
|
||||
def __init__(self):
|
||||
self.last_token_metrics = _MutableMetrics(11, 7)
|
||||
|
||||
class _SharedAgent:
|
||||
def __init__(self):
|
||||
self._llm_instance = _SharedLLM()
|
||||
|
||||
async def achat(self, content):
|
||||
return f"echo:{content}"
|
||||
|
||||
class _Gw(_FakeGateway):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._agent = _SharedAgent()
|
||||
|
||||
gw = _Gw()
|
||||
ep = GatewayApiEndpoints(gw)
|
||||
reply, usage = asyncio.run(
|
||||
ep._dispatch(_FakeSession(), gw._agent, "hi")
|
||||
)
|
||||
# Mutate the shared metric AFTER dispatch returned its snapshot.
|
||||
gw._agent._llm_instance.last_token_metrics = _MutableMetrics(999, 999)
|
||||
assert reply == "echo:hi"
|
||||
assert usage == {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}
|
||||
|
||||
|
||||
def test_stream_usage_snapshot_survives_concurrent_overwrite():
|
||||
# The SSE generator yields (suspends) several frames after the turn before
|
||||
# emitting the usage chunk. While suspended, a concurrent turn on the same
|
||||
# shared Agent can overwrite ``last_token_metrics``. The streamed usage must
|
||||
# still reflect THIS turn (snapshotted at dispatch), not the overwrite.
|
||||
class _MutableMetrics:
|
||||
def __init__(self, i, o):
|
||||
self.input_tokens = i
|
||||
self.output_tokens = o
|
||||
|
||||
class _SharedLLM:
|
||||
def __init__(self):
|
||||
self.last_token_metrics = _MutableMetrics(11, 7)
|
||||
|
||||
class _SharedAgent:
|
||||
def __init__(self):
|
||||
self._llm_instance = _SharedLLM()
|
||||
|
||||
async def achat(self, content):
|
||||
return f"echo:{content}"
|
||||
|
||||
class _Gw(_FakeGateway):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._agent = _SharedAgent()
|
||||
|
||||
gw = _Gw()
|
||||
ep = GatewayApiEndpoints(gw)
|
||||
resp = asyncio.run(
|
||||
ep.openai_chat(
|
||||
_FakeReq(
|
||||
{
|
||||
"model": "assistant",
|
||||
"stream": True,
|
||||
"stream_options": {"include_usage": True},
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
}
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def _drain_with_overwrite():
|
||||
parts = []
|
||||
async for part in resp.body_iterator:
|
||||
text = part if isinstance(part, str) else part.decode()
|
||||
parts.append(text)
|
||||
# Once the assistant content frame has streamed (i.e. the turn has
|
||||
# already completed and been snapshotted), simulate a *concurrent*
|
||||
# turn overwriting the shared, mutable metric before the usage frame.
|
||||
if '"content"' in text:
|
||||
gw._agent._llm_instance.last_token_metrics = _MutableMetrics(
|
||||
999, 999
|
||||
)
|
||||
return parts
|
||||
|
||||
parts = asyncio.run(_drain_with_overwrite())
|
||||
usage_payloads = [
|
||||
json.loads(p[len("data: "):])
|
||||
for p in parts
|
||||
if p.startswith("data: ") and '"usage"' in p
|
||||
]
|
||||
assert usage_payloads, "expected a usage-bearing chunk"
|
||||
usage = usage_payloads[-1]["usage"]
|
||||
# Snapshotted at dispatch -> original counts, not the mid-stream overwrite.
|
||||
assert usage == {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}
|
||||
|
||||
|
||||
def test_sync_agent_usage_snapshot_captured_before_thread_returns():
|
||||
# Greptile/Qodo P1 (sync fallback): a sync-only agent's ``chat`` runs in a
|
||||
# worker THREAD. A bare read AFTER the executor ``await`` resolves is NOT
|
||||
# atomic with the turn — the "no intervening await" guarantee only orders
|
||||
# event-loop coroutines, not worker threads — so a concurrent turn can
|
||||
# overwrite the shared, mutable ``last_token_metrics`` in the window between
|
||||
# the turn's ``chat`` returning and the handler reading it. The real
|
||||
# gateway's ``_dispatch_agent_turn`` must therefore snapshot INSIDE the
|
||||
# worker thread, before it returns, via ``on_complete``. We assert exactly
|
||||
# that ordering against the real server method.
|
||||
from praisonai_bot.gateway.server import WebSocketGateway
|
||||
|
||||
events: list = []
|
||||
|
||||
class _Metrics:
|
||||
input_tokens = 11
|
||||
output_tokens = 7
|
||||
|
||||
class _LLM:
|
||||
last_token_metrics = _Metrics()
|
||||
|
||||
class _SyncAgent:
|
||||
# No ``arun``/``achat`` -> forces the sync ``chat`` executor path.
|
||||
_llm_instance = _LLM()
|
||||
|
||||
def chat(self, content):
|
||||
events.append("chat_returned")
|
||||
return f"echo:{content}"
|
||||
|
||||
def _capture(a):
|
||||
# Records that the snapshot ran, and its ordering vs ``chat`` return.
|
||||
events.append("snapshot")
|
||||
|
||||
reply = asyncio.run(
|
||||
WebSocketGateway._dispatch_agent_turn(
|
||||
_SyncAgent(), "hi", on_complete=_capture
|
||||
)
|
||||
)
|
||||
assert reply == "echo:hi"
|
||||
# The snapshot must fire in the SAME worker-thread context, immediately
|
||||
# after ``chat`` returns and before the future resolves back on the loop —
|
||||
# so ``chat_returned`` is immediately followed by ``snapshot`` with no gap
|
||||
# a concurrent turn could exploit.
|
||||
assert events == ["chat_returned", "snapshot"]
|
||||
|
||||
|
||||
def test_openai_responses_reports_usage():
|
||||
ep = GatewayApiEndpoints(_FakeGateway())
|
||||
resp = asyncio.run(
|
||||
ep.openai_responses(_FakeReq({"model": "assistant", "input": "ping"}))
|
||||
)
|
||||
usage = _body(resp)["usage"]
|
||||
assert usage["input_tokens"] == 11
|
||||
assert usage["output_tokens"] == 7
|
||||
assert usage["total_tokens"] == 18
|
||||
|
||||
|
||||
def test_openai_chat_no_agents_returns_503():
|
||||
class _Empty(_FakeGateway):
|
||||
def list_agents(self):
|
||||
|
||||
@@ -222,6 +222,27 @@ def test_turn_lock_stamp_is_noop_without_resolver():
|
||||
assert getattr(gateway, "_turn_lock_map", None) is None
|
||||
|
||||
|
||||
def test_turn_lock_stamp_wires_redis_backend_without_resolver():
|
||||
"""Issue #4655: ``turn_lock.backend='redis'`` is wired even with no resolver.
|
||||
|
||||
Previously the gateway built a process-local ``LockMap`` and only stamped it
|
||||
when a resolver was configured, so ``backend='redis'`` was silently inert and
|
||||
replicas of a single channel ran concurrent turns on one session.
|
||||
"""
|
||||
from praisonaiagents.gateway import TurnLockConfig
|
||||
|
||||
gateway = _gateway_with_agent()
|
||||
assert gateway._identity_resolver is None
|
||||
# Force a redis backend but with no reachable Redis -> build_turn_lock fails
|
||||
# open to a LockMap while recording a degraded entry; the important part is
|
||||
# that the map IS stamped onto the session (the wiring path is reached).
|
||||
gateway.config.turn_lock = TurnLockConfig(backend="redis")
|
||||
bot = _FakeBot()
|
||||
gateway._stamp_turn_lock_map(bot)
|
||||
assert getattr(gateway, "_turn_lock_map", None) is not None
|
||||
assert bot._session._locks is gateway._turn_lock_map
|
||||
|
||||
|
||||
def test_turn_lock_stamp_shares_one_map_with_session():
|
||||
"""A configured resolver stamps the gateway's shared LockMap onto the session."""
|
||||
resolver = _StubResolver()
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Tests for the durable gateway lifecycle ledger wiring (Issue #4603).
|
||||
|
||||
The core primitives (``LifecycleRecord`` / ``classify_unclean_exit`` /
|
||||
``PersistentRestartLoopGuard`` / ``classify_resource_pressure``) are unit-tested
|
||||
in the core SDK; here we verify the *wrapper mechanism* — the durable sentinel,
|
||||
the boot classification, the graceful-exit marker, the memory sample, and the
|
||||
sentinel-backed restart store.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot.gateway.lifecycle_ledger import (
|
||||
LifecycleLedger,
|
||||
SentinelRestartStore,
|
||||
build_persistent_restart_guard,
|
||||
default_sentinel_path,
|
||||
)
|
||||
|
||||
|
||||
def test_fresh_boot_is_unknown(tmp_path):
|
||||
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
|
||||
rec = ledger.record_boot()
|
||||
assert rec.exit_kind == "unknown"
|
||||
assert ledger.last_exit() == {"last_exit": "unknown", "suspected_oom": False}
|
||||
|
||||
|
||||
def test_graceful_stop_marks_clean_and_next_boot_sees_clean(tmp_path):
|
||||
path = str(tmp_path / "lifecycle.json")
|
||||
ledger = LifecycleLedger(path)
|
||||
ledger.record_boot()
|
||||
ledger.mark_exited()
|
||||
|
||||
data = json.loads((tmp_path / "lifecycle.json").read_text())
|
||||
assert data["phase"] == "exited"
|
||||
assert data["exit_kind"] == "clean"
|
||||
|
||||
ledger2 = LifecycleLedger(path)
|
||||
rec = ledger2.record_boot()
|
||||
assert rec.exit_kind == "clean"
|
||||
|
||||
|
||||
def test_unclean_death_detected_on_next_boot(tmp_path):
|
||||
path = str(tmp_path / "lifecycle.json")
|
||||
# Process A boots and never marks exited (simulated OOM/SIGKILL).
|
||||
ledger_a = LifecycleLedger(path, memory_budget_fraction=0.9)
|
||||
ledger_a.record_boot()
|
||||
# A high memory sample was folded in by the heartbeat before death.
|
||||
from praisonaiagents.gateway import LifecycleRecord
|
||||
|
||||
ledger_a._record = LifecycleRecord(
|
||||
phase="running", exit_kind="unknown", last_mem_fraction=0.97,
|
||||
)
|
||||
ledger_a._write(ledger_a._record)
|
||||
|
||||
# Process B boots and detects the unclean prior exit.
|
||||
ledger_b = LifecycleLedger(path, memory_budget_fraction=0.9)
|
||||
rec = ledger_b.record_boot()
|
||||
assert rec.exit_kind == "unclean"
|
||||
assert rec.suspected_oom is True
|
||||
assert ledger_b.last_exit() == {"last_exit": "unclean", "suspected_oom": True}
|
||||
|
||||
|
||||
def test_mark_exited_is_ownership_checked(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / "lifecycle.json")
|
||||
ledger = LifecycleLedger(path)
|
||||
ledger.record_boot()
|
||||
# Pretend a different pid owns the boot marker: mark_exited must be a no-op.
|
||||
ledger._owner_pid = os.getpid() + 1
|
||||
ledger.mark_exited()
|
||||
data = json.loads((tmp_path / "lifecycle.json").read_text())
|
||||
assert data["phase"] == "running"
|
||||
|
||||
|
||||
def test_sample_memory_never_raises(tmp_path):
|
||||
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
|
||||
ledger.record_boot()
|
||||
# Returns a float fraction or None; must not raise on any host.
|
||||
frac = ledger.sample_memory()
|
||||
assert frac is None or isinstance(frac, float)
|
||||
|
||||
|
||||
def test_pressure_returns_closed_vocabulary(tmp_path):
|
||||
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
|
||||
p = ledger.pressure()
|
||||
assert set(p.keys()) == {"memory", "disk"}
|
||||
for v in p.values():
|
||||
assert v in ("ok", "elevated", "critical", "unknown")
|
||||
|
||||
|
||||
def test_pressure_disk_ok_on_tmp(tmp_path):
|
||||
# A tmp dir on CI has ample headroom; disk should classify ok (not unknown).
|
||||
ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"), disk_min_free_mb=1)
|
||||
assert ledger.pressure()["disk"] == "ok"
|
||||
|
||||
|
||||
def test_restart_store_shares_sentinel(tmp_path):
|
||||
path = str(tmp_path / "lifecycle.json")
|
||||
ledger = LifecycleLedger(path)
|
||||
ledger.record_boot()
|
||||
guard = build_persistent_restart_guard(ledger, max_restarts=3, window_seconds=1e12)
|
||||
assert guard.record(now=1.0) is False
|
||||
assert guard.record(now=2.0) is False
|
||||
assert guard.record(now=3.0) is True
|
||||
|
||||
# The events landed in the shared sentinel and reload in a fresh process.
|
||||
ledger2 = LifecycleLedger(path)
|
||||
ledger2.record_boot()
|
||||
guard2 = build_persistent_restart_guard(ledger2, max_restarts=3, window_seconds=1e12)
|
||||
assert guard2.tripped(now=4.0) is True
|
||||
|
||||
|
||||
def test_default_sentinel_path_under_praisonai():
|
||||
p = default_sentinel_path()
|
||||
assert p.endswith(os.path.join(".praisonai", "gateway", "lifecycle.json"))
|
||||
|
||||
|
||||
def test_corrupt_sentinel_is_tolerated(tmp_path):
|
||||
path = tmp_path / "lifecycle.json"
|
||||
path.write_text("{not valid json")
|
||||
ledger = LifecycleLedger(str(path))
|
||||
# A corrupt prior file reads as "no record" -> unknown, never raises.
|
||||
rec = ledger.record_boot()
|
||||
assert rec.exit_kind == "unknown"
|
||||
|
||||
|
||||
# ── server integration: the two P1s (graceful clean-exit + pressure merge) ──
|
||||
|
||||
|
||||
def _make_gateway_with_ledger(tmp_path):
|
||||
"""A bare WebSocketGateway with only a lifecycle ledger wired (no server)."""
|
||||
from praisonai_bot.gateway import WebSocketGateway
|
||||
|
||||
gw = WebSocketGateway()
|
||||
gw._lifecycle_ledger = LifecycleLedger(str(tmp_path / "lifecycle.json"))
|
||||
return gw
|
||||
|
||||
|
||||
def test_graceful_serve_teardown_marks_clean_exit(tmp_path):
|
||||
"""Issue #4603 P1: ``serve()`` returning (the SIGTERM path, which only flips
|
||||
``should_exit`` and never calls ``stop()``) must still stamp a clean exit so
|
||||
the next boot does not misreport a graceful shutdown as unclean/OOM."""
|
||||
path = str(tmp_path / "lifecycle.json")
|
||||
gw = _make_gateway_with_ledger(tmp_path)
|
||||
gw._lifecycle_ledger.path = path
|
||||
gw._lifecycle_ledger.record_boot()
|
||||
|
||||
# Simulate ``serve()``'s finally block running on graceful shutdown.
|
||||
asyncio.run(gw._cancel_ledger_heartbeat())
|
||||
gw._lifecycle_ledger.mark_exited()
|
||||
|
||||
data = json.loads((tmp_path / "lifecycle.json").read_text())
|
||||
assert data["phase"] == "exited"
|
||||
assert data["exit_kind"] == "clean"
|
||||
|
||||
# A fresh process now sees a *clean* prior exit, not an unclean one.
|
||||
ledger2 = LifecycleLedger(path)
|
||||
assert ledger2.record_boot().exit_kind == "clean"
|
||||
|
||||
|
||||
def test_health_merges_ledger_and_saturation_pressure(tmp_path):
|
||||
"""Issue #4603 P1: when both the lifecycle ledger and the saturation
|
||||
back-pressure machinery are enabled, ``/health`` must surface *both* the
|
||||
memory/disk block and the admission/backlog block on one ``pressure``
|
||||
object — neither may silently clobber the other."""
|
||||
from praisonaiagents.gateway import HealthPressure
|
||||
|
||||
gw = _make_gateway_with_ledger(tmp_path)
|
||||
gw._is_running = True
|
||||
# Force a known ledger pressure block and a known saturation snapshot.
|
||||
gw._lifecycle_ledger.pressure = lambda: {"memory": "ok", "disk": "ok"}
|
||||
gw._lifecycle_ledger.last_exit = lambda: {
|
||||
"last_exit": "clean", "suspected_oom": False,
|
||||
}
|
||||
gw._compute_pressure = lambda: HealthPressure(
|
||||
admission_in_flight=2, outbox_pending=5, pressure="elevated"
|
||||
)
|
||||
|
||||
result = gw.health()
|
||||
pressure = result["pressure"]
|
||||
# Ledger's OS resource keys survive...
|
||||
assert pressure["memory"] == "ok"
|
||||
assert pressure["disk"] == "ok"
|
||||
# ...alongside the saturation keys.
|
||||
assert pressure["admission_in_flight"] == 2
|
||||
assert pressure["outbox_pending"] == 5
|
||||
assert pressure["pressure"] == "elevated"
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Tests for the bounded subprocess wrapper.
|
||||
|
||||
The behaviour that matters: a command that would block forever returns, the
|
||||
caller can still branch on returncode, and the stderr it gets back says *why*
|
||||
the command stalled.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai_bot._guarded_subprocess import (
|
||||
DEFAULT_TIMEOUT,
|
||||
TIMEOUT_RETURNCODE,
|
||||
diagnose,
|
||||
run_guarded,
|
||||
)
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
"""A bounded call must stay a faithful subprocess.run replacement."""
|
||||
def test_returns_completed_process(self):
|
||||
result = run_guarded([sys.executable, "-c", "print('ok')"])
|
||||
assert result.returncode == 0
|
||||
assert result.stdout.strip() == "ok"
|
||||
|
||||
def test_propagates_nonzero_exit(self):
|
||||
result = run_guarded([sys.executable, "-c", "import sys; sys.exit(3)"])
|
||||
assert result.returncode == 3
|
||||
|
||||
def test_honours_cwd(self, tmp_path):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.getcwd())"],
|
||||
cwd=str(tmp_path),
|
||||
)
|
||||
assert os.path.realpath(result.stdout.strip()) == os.path.realpath(str(tmp_path))
|
||||
|
||||
|
||||
class TestTimeout:
|
||||
"""A stalled command returns a value; it never raises and never blocks."""
|
||||
def test_hanging_command_returns_instead_of_blocking(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
timeout=0.5,
|
||||
)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
def test_timeout_does_not_raise(self):
|
||||
# The whole point: existing `returncode == 0` call sites keep working
|
||||
# without growing an exception handler.
|
||||
run_guarded([sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3)
|
||||
|
||||
def test_stderr_names_the_command_and_the_limit(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
timeout=0.4,
|
||||
)
|
||||
assert "killed after 0.4s" in result.stderr
|
||||
assert "time.sleep(30)" in result.stderr
|
||||
|
||||
def test_reading_stdin_is_not_a_hang(self):
|
||||
# stdin is /dev/null, so a command that reads it gets EOF immediately
|
||||
# rather than waiting for the timeout to save it.
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import sys; sys.stdin.read(); print('done')"],
|
||||
timeout=5,
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "done" in result.stdout
|
||||
|
||||
|
||||
class TestDiagnosis:
|
||||
"""Stall output is turned into guidance a caller can act on."""
|
||||
def test_credential_prompt(self):
|
||||
assert "credential prompt" in diagnose("Username for 'https://github.com':")
|
||||
|
||||
def test_git_lock(self):
|
||||
msg = diagnose("fatal: Unable to create '/repo/.git/index.lock': File exists")
|
||||
assert "lock contention" in msg
|
||||
|
||||
def test_confirmation_prompt(self):
|
||||
assert "confirmation prompt" in diagnose("Overwrite existing file? [y/N]")
|
||||
|
||||
def test_host_key_prompt(self):
|
||||
assert "host-key" in diagnose("The authenticity of host ... key fingerprint is")
|
||||
|
||||
def test_unknown_output_gets_generic_guidance(self):
|
||||
msg = diagnose("compiling module 41 of 900")
|
||||
assert "No interactive prompt was detected" in msg
|
||||
|
||||
def test_empty_output_is_safe(self):
|
||||
# A process blocked on stdin often emits nothing at all.
|
||||
assert diagnose("") == diagnose(None)
|
||||
|
||||
def test_diagnosis_is_attached_on_timeout(self):
|
||||
script = (
|
||||
"import sys, time; "
|
||||
"sys.stderr.write(\"Username for 'https://github.com':\"); "
|
||||
"sys.stderr.flush(); time.sleep(30)"
|
||||
)
|
||||
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
assert "credential prompt" in result.stderr
|
||||
|
||||
|
||||
class TestEnvironment:
|
||||
"""Prompt suppression is layered under the caller, never over it."""
|
||||
def test_suppresses_git_prompting_by_default(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
|
||||
)
|
||||
assert result.stdout.strip() == "0"
|
||||
|
||||
def test_caller_env_wins(self):
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"],
|
||||
env={"GIT_TERMINAL_PROMPT": "1", "PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
assert result.stdout.strip() == "1"
|
||||
|
||||
def test_interactive_mode_adds_no_suppression(self, monkeypatch):
|
||||
# Clear the ambient value first -- CI images commonly export
|
||||
# GIT_TERMINAL_PROMPT=0 already, which would mask the behaviour under
|
||||
# test. Interactive mode must not *introduce* the variable itself.
|
||||
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
|
||||
result = run_guarded(
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import os; print(os.environ.get('GIT_TERMINAL_PROMPT', 'unset'))",
|
||||
],
|
||||
interactive=True,
|
||||
)
|
||||
assert result.stdout.strip() == "unset"
|
||||
|
||||
def test_default_mode_suppresses_even_when_ambient_is_unset(self, monkeypatch):
|
||||
monkeypatch.delenv("GIT_TERMINAL_PROMPT", raising=False)
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import os; print(os.environ['GIT_TERMINAL_PROMPT'])"]
|
||||
)
|
||||
assert result.stdout.strip() == "0"
|
||||
|
||||
|
||||
class TestTimeoutResolution:
|
||||
"""Explicit argument beats env var beats default; junk never wins."""
|
||||
def test_env_override(self, monkeypatch):
|
||||
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "0.3")
|
||||
result = run_guarded([sys.executable, "-c", "import time; time.sleep(30)"])
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "abc", "0", "-5"])
|
||||
def test_unusable_override_falls_back_to_default(self, monkeypatch, bad):
|
||||
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", bad)
|
||||
# Must not raise, and must not adopt a non-positive timeout that would
|
||||
# kill every command instantly.
|
||||
result = run_guarded([sys.executable, "-c", "print('fine')"])
|
||||
assert result.returncode == 0
|
||||
assert DEFAULT_TIMEOUT > 0
|
||||
|
||||
def test_explicit_timeout_beats_env(self, monkeypatch):
|
||||
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", "30")
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"], timeout=0.3
|
||||
)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
|
||||
class TestCompatibility:
|
||||
"""Call sites read .returncode/.stdout/.stderr unconditionally."""
|
||||
def test_result_is_a_real_completed_process(self):
|
||||
result = run_guarded([sys.executable, "-c", "import time; time.sleep(5)"], timeout=0.3)
|
||||
assert isinstance(result, subprocess.CompletedProcess)
|
||||
assert result.args == [sys.executable, "-c", "import time; time.sleep(5)"]
|
||||
# Call sites read .stdout/.stderr unconditionally; both must be str.
|
||||
assert isinstance(result.stdout, str)
|
||||
assert isinstance(result.stderr, str)
|
||||
|
||||
|
||||
class TestKanbanGitTimeout:
|
||||
"""The dispatcher's git ceiling must bound hangs without failing slow work."""
|
||||
|
||||
def test_default_is_generous(self):
|
||||
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
|
||||
|
||||
# Worktree add / merge on a large repo can legitimately run for
|
||||
# minutes; the bound exists to stop an indefinite hang, not slow work.
|
||||
assert _git_timeout() >= 300.0
|
||||
|
||||
def test_env_override(self, monkeypatch):
|
||||
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", "42")
|
||||
assert _git_timeout() == 42.0
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "abc", "0", "-1"])
|
||||
def test_unusable_override_falls_back(self, monkeypatch, bad):
|
||||
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", bad)
|
||||
assert _git_timeout() >= 300.0
|
||||
|
||||
def test_run_git_is_bounded(self, monkeypatch):
|
||||
"""_run_git must route through run_guarded, not raw subprocess.run."""
|
||||
from praisonai_bot.gateway import kanban_dispatcher as kd
|
||||
|
||||
seen = {}
|
||||
|
||||
def fake_run_guarded(cmd, **kwargs):
|
||||
seen["cmd"] = cmd
|
||||
seen["timeout"] = kwargs.get("timeout")
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(kd, "run_guarded", fake_run_guarded)
|
||||
dispatcher = kd.KanbanDispatcher.__new__(kd.KanbanDispatcher)
|
||||
dispatcher._run_git("status")
|
||||
|
||||
assert seen["cmd"][0] == "git"
|
||||
assert seen["timeout"] is not None and seen["timeout"] > 0
|
||||
|
||||
|
||||
class TestDescendantTermination:
|
||||
"""A grandchild must not outlive the kill, nor hold the caller open.
|
||||
|
||||
subprocess.run's own timeout path kills only the direct child: on POSIX it
|
||||
then wait()s (orphaning the grandchild), and on Windows it communicate()s
|
||||
with no timeout, which blocks until the grandchild releases the inherited
|
||||
pipe. Both are why run_guarded kills the whole process group.
|
||||
"""
|
||||
|
||||
def test_returns_promptly_despite_a_surviving_descendant(self):
|
||||
import time
|
||||
|
||||
script = (
|
||||
"import subprocess, sys, time; "
|
||||
"subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)']); "
|
||||
"time.sleep(60)"
|
||||
)
|
||||
started = time.monotonic()
|
||||
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
|
||||
elapsed = time.monotonic() - started
|
||||
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
# Generous bound: the point is that it does not wait out the 60s
|
||||
# sleeps, on any platform.
|
||||
assert elapsed < 20, f"took {elapsed:.1f}s; descendant blocked the reap"
|
||||
|
||||
def test_grandchild_is_actually_killed(self, tmp_path):
|
||||
marker = tmp_path / "grandchild-survived.txt"
|
||||
grandchild = (
|
||||
f"import time; time.sleep(4); "
|
||||
f"open({str(marker)!r}, 'w').write('alive')"
|
||||
)
|
||||
script = (
|
||||
"import subprocess, sys, time; "
|
||||
f"subprocess.Popen([sys.executable, '-c', {grandchild!r}]); "
|
||||
"time.sleep(60)"
|
||||
)
|
||||
result = run_guarded([sys.executable, "-c", script], timeout=1.0)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
# Outlive the grandchild's sleep. If the group kill worked it never
|
||||
# gets to write; if only the direct child died, the marker appears.
|
||||
import time
|
||||
|
||||
time.sleep(6)
|
||||
assert not marker.exists(), "grandchild survived the group kill"
|
||||
|
||||
|
||||
class TestCheck:
|
||||
"""check= matches subprocess.run, except a timeout still returns."""
|
||||
def test_check_raises_on_nonzero(self):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
run_guarded([sys.executable, "-c", "import sys; sys.exit(2)"], check=True)
|
||||
|
||||
def test_check_is_off_by_default(self):
|
||||
result = run_guarded([sys.executable, "-c", "import sys; sys.exit(2)"])
|
||||
assert result.returncode == 2
|
||||
|
||||
def test_timeout_returns_even_with_check(self):
|
||||
# A timeout is reported as a value, never an exception, so callers
|
||||
# branching on returncode keep working.
|
||||
result = run_guarded(
|
||||
[sys.executable, "-c", "import time; time.sleep(30)"],
|
||||
timeout=0.4,
|
||||
check=True,
|
||||
)
|
||||
assert result.returncode == TIMEOUT_RETURNCODE
|
||||
|
||||
|
||||
class TestNonFiniteTimeoutOverrides:
|
||||
"""An override that parses as positive can still be unbounded.
|
||||
|
||||
float("inf") passes a bare `value > 0` check, which would hand back an
|
||||
infinite timeout and silently reinstate the hang this module prevents.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize("bad", ["inf", "Infinity", "+inf", "1e400", "nan"])
|
||||
def test_subprocess_timeout_rejects_non_finite(self, monkeypatch, bad):
|
||||
import math
|
||||
|
||||
from praisonai_bot._guarded_subprocess import DEFAULT_TIMEOUT, _default_timeout
|
||||
|
||||
# Assert on the resolver, not on run_guarded with an explicit
|
||||
# timeout= -- an explicit argument bypasses the env var entirely, so
|
||||
# that spelling would pass even with the bug present.
|
||||
monkeypatch.setenv("PRAISONAI_SUBPROCESS_TIMEOUT", bad)
|
||||
resolved = _default_timeout()
|
||||
assert math.isfinite(resolved)
|
||||
assert resolved == DEFAULT_TIMEOUT
|
||||
|
||||
@pytest.mark.parametrize("bad", ["inf", "Infinity", "+inf", "1e400", "nan"])
|
||||
def test_git_timeout_rejects_non_finite(self, monkeypatch, bad):
|
||||
import math
|
||||
|
||||
from praisonai_bot.gateway.kanban_dispatcher import _git_timeout
|
||||
|
||||
monkeypatch.setenv("PRAISONAI_KANBAN_GIT_TIMEOUT", bad)
|
||||
resolved = _git_timeout()
|
||||
assert math.isfinite(resolved)
|
||||
assert resolved >= 300.0
|
||||
|
||||
def test_default_resolver_stays_finite_without_override(self, monkeypatch):
|
||||
import math
|
||||
|
||||
from praisonai_bot._guarded_subprocess import _default_timeout
|
||||
|
||||
monkeypatch.delenv("PRAISONAI_SUBPROCESS_TIMEOUT", raising=False)
|
||||
assert math.isfinite(_default_timeout())
|
||||
|
||||
Generated
+2
-2
@@ -1416,7 +1416,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-bot"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -1498,7 +1498,7 @@ provides-extras = ["gateway", "bot", "bot-whatsapp-web", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.1"
|
||||
version = "1.7.3"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.1.2"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
description = "Browser automation for PraisonAI — Chrome extension bridge, CDP, and Playwright agents extracted from the praisonai wrapper."
|
||||
readme = "README.md"
|
||||
license = {text = "MIT"}
|
||||
|
||||
Generated
+2
-2
@@ -920,7 +920,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonai-browser"
|
||||
version = "0.1.1"
|
||||
version = "0.1.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -964,7 +964,7 @@ provides-extras = ["server", "playwright", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.7.1"
|
||||
version = "1.7.3"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -12,6 +12,6 @@ the remaining terminal-agent modules, with PEP 562 shims left behind at the
|
||||
old ``praisonai.*`` paths for backward compatibility.
|
||||
"""
|
||||
|
||||
__version__ = "0.1.1"
|
||||
__version__ = "0.1.2"
|
||||
|
||||
__all__ = ["__version__"]
|
||||
@@ -290,6 +290,18 @@ class PluginRegistry(Generic[T]):
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
def has(self, name: str) -> bool:
|
||||
"""Return True if ``name`` is registered, without loading it.
|
||||
|
||||
Unlike :meth:`is_available`, this never materialises the plugin — it
|
||||
only checks the registered names/aliases — so it is safe for cheap
|
||||
membership tests (e.g. cross-registry ``__getattr__`` dispatch).
|
||||
"""
|
||||
normalized_name = name.lower()
|
||||
with self._lock:
|
||||
canonical_name = self._aliases.get(normalized_name, normalized_name)
|
||||
return canonical_name in self._items or canonical_name in self._loaders
|
||||
|
||||
def list_aliases(self) -> Dict[str, str]:
|
||||
"""List all aliases and their target plugins.
|
||||
|
||||
|
||||
@@ -791,7 +791,7 @@ class AsyncTUI:
|
||||
)
|
||||
|
||||
self._registry = create_default_registry(
|
||||
self._BUILTIN_COMMANDS, include_custom=True
|
||||
self._BUILTIN_COMMANDS, include_custom=True, include_skills=True
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Command registry unavailable: %s", exc)
|
||||
@@ -1883,10 +1883,20 @@ Example: /handoff code "refactor the auth module" """
|
||||
from prompt_toolkit.history import FileHistory
|
||||
from prompt_toolkit.completion import Completer, Completion
|
||||
|
||||
# Create completer for commands and files. Derive the list from the
|
||||
# single source of truth (_BUILTIN_COMMANDS) so registration and
|
||||
# autocomplete never drift (e.g. code-review/security-review).
|
||||
# Create completer for commands and files. Start from the built-ins
|
||||
# (single source of truth so registration and autocomplete never drift,
|
||||
# e.g. code-review/security-review) and extend with discovered registry
|
||||
# commands — custom ``.praisonai/commands/*.md`` and installed skills —
|
||||
# so typing ``/`` offers them too. Degrades to built-ins on any failure.
|
||||
commands = list(self._BUILTIN_COMMANDS.keys())
|
||||
registry = self._get_registry()
|
||||
if registry is not None:
|
||||
try:
|
||||
for c in registry.list_commands():
|
||||
if c.name not in commands:
|
||||
commands.append(c.name)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Skipping registry completions: %s", exc)
|
||||
workspace_files = self._workspace_files
|
||||
|
||||
class PraisonCompleter(Completer):
|
||||
|
||||
@@ -148,13 +148,56 @@ class CustomCommandSource:
|
||||
return commands
|
||||
|
||||
|
||||
def _make_skill_handler(skill: Any) -> Callable[[str], Optional[str]]:
|
||||
"""Build a handler that turns a discovered skill into a runnable prompt.
|
||||
|
||||
The interactive surfaces dispatch any registry command by calling its
|
||||
``handler`` and feeding the returned text to the agent. A skill therefore
|
||||
needs a handler that renders its ``SKILL.md`` body (plus the user's ``args``)
|
||||
into a prompt, matching how :class:`CustomCommandSource` renders custom
|
||||
commands. Without this, ``/my-skill`` would appear in the menu but fall
|
||||
through to "Unknown command" because both dispatchers require a handler.
|
||||
"""
|
||||
|
||||
def handler(args: str) -> Optional[str]:
|
||||
name = str(getattr(skill, "name", "") or "")
|
||||
description = getattr(skill, "description", "") or ""
|
||||
body = ""
|
||||
path = getattr(skill, "path", None)
|
||||
if path is not None:
|
||||
try:
|
||||
from praisonaiagents.skills.parser import (
|
||||
find_skill_md,
|
||||
parse_frontmatter,
|
||||
)
|
||||
|
||||
skill_md = find_skill_md(path)
|
||||
if skill_md is not None:
|
||||
_, body = parse_frontmatter(skill_md.read_text(encoding="utf-8"))
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Skill body unavailable for %s: %s", name, exc)
|
||||
|
||||
sections = [f"Use the '{name}' skill."]
|
||||
if description:
|
||||
sections.append(description)
|
||||
if body:
|
||||
sections.append(body)
|
||||
if args:
|
||||
sections.append(args)
|
||||
return "\n\n".join(section for section in sections if section)
|
||||
|
||||
return handler
|
||||
|
||||
|
||||
class SkillCommandSource:
|
||||
"""Optional source exposing discovered skills as invocable commands.
|
||||
|
||||
Skills live in the core SDK (``praisonaiagents.skills``); this source only
|
||||
*consumes* them. It fails silently (returns ``[]``) when the skills module
|
||||
or any project skills are unavailable, so it never adds import cost or
|
||||
breaks surfaces that do not use skills.
|
||||
breaks surfaces that do not use skills. Each skill carries a handler that
|
||||
renders its ``SKILL.md`` body into a prompt so ``/name`` invocation works in
|
||||
every interactive surface, not just the completion menu.
|
||||
"""
|
||||
|
||||
def discover(self) -> List[Command]:
|
||||
@@ -180,6 +223,7 @@ class SkillCommandSource:
|
||||
description=getattr(skill, "description", "") or "Skill",
|
||||
kind=CommandKind.SKILL,
|
||||
source="skill",
|
||||
handler=_make_skill_handler(skill),
|
||||
)
|
||||
)
|
||||
return commands
|
||||
|
||||
@@ -271,7 +271,18 @@ class InteractiveREPL:
|
||||
from .command_registry import create_default_registry
|
||||
|
||||
self._registry = create_default_registry(
|
||||
DEFAULT_COMMANDS, include_custom=True
|
||||
DEFAULT_COMMANDS, include_custom=True, include_skills=True
|
||||
)
|
||||
# Surface custom/skill/mcp commands in the ``/`` completion menu
|
||||
# too — the built-ins are registered at init, but discovered
|
||||
# commands (e.g. installed skills) must be added here so typing
|
||||
# ``/`` offers them alongside the built-ins.
|
||||
self.io.add_commands(
|
||||
{
|
||||
c.name: c.description
|
||||
for c in self._registry.list_commands()
|
||||
if c.kind.value != "builtin"
|
||||
}
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.debug("Command registry unavailable: %s", exc)
|
||||
@@ -627,7 +638,12 @@ class InteractiveREPL:
|
||||
tools_count=tools_count,
|
||||
session_id=self._session_id
|
||||
)
|
||||
|
||||
|
||||
# Warm the command registry so discovered commands (custom + installed
|
||||
# skills) populate the ``/`` completion menu before the first slash is
|
||||
# typed, not only after the first custom/skill invocation.
|
||||
self._get_registry()
|
||||
|
||||
while self._running:
|
||||
try:
|
||||
# Get input (SYNC - this is the key!)
|
||||
|
||||
@@ -1265,36 +1265,83 @@ class ToolResolver:
|
||||
|
||||
|
||||
# Context-local resolver for multi-project safety
|
||||
import contextlib
|
||||
import contextvars
|
||||
from collections import OrderedDict
|
||||
from typing import Iterator
|
||||
|
||||
_resolver_var: contextvars.ContextVar[Optional[ToolResolver]] = contextvars.ContextVar(
|
||||
"tool_resolver", default=None,
|
||||
)
|
||||
|
||||
# CWD-anchored cache for the *unscoped* default. Keyed by the resolved
|
||||
# ``tools.py`` path (which encodes the current working directory), so a caller in
|
||||
# ``/tenants/A`` and one in ``/tenants/B`` get distinct resolvers instead of the
|
||||
# first caller pinning its CWD into the root context for the whole process.
|
||||
_UNSCOPED_CACHE: "OrderedDict[str, ToolResolver]" = OrderedDict()
|
||||
_UNSCOPED_LOCK = threading.Lock()
|
||||
_UNSCOPED_MAX = 32
|
||||
|
||||
|
||||
def _get_default_resolver() -> ToolResolver:
|
||||
"""Per-context resolver. Falls back to a fresh ToolResolver per context,
|
||||
so changing CWD between agents / requests is honoured.
|
||||
|
||||
Each context (agent/task/request) gets its own resolver anchored to the
|
||||
working directory at the time of first use in that context. This prevents
|
||||
the singleton bug where the first caller locks in their CWD for the entire process.
|
||||
|
||||
For test isolation or multi-project CLIs, create explicit resolver
|
||||
instances instead of using this cached default.
|
||||
"""Return the scoped resolver, else a CWD-anchored resolver for THIS caller.
|
||||
|
||||
Never writes to ``_resolver_var``: ``ContextVar.set()`` from an outermost
|
||||
context leaks into the root context and is inherited by every task/thread
|
||||
that lacks its own value, which would pin the first caller's CWD
|
||||
process-wide (the exact singleton bug this indirection was meant to avoid).
|
||||
|
||||
Callers who want a fixed binding for the duration of a request/task open an
|
||||
explicit scope with :func:`scoped_resolver`.
|
||||
|
||||
For test isolation or multi-project CLIs, create explicit ``ToolResolver``
|
||||
instances instead of relying on this default.
|
||||
"""
|
||||
resolver = _resolver_var.get()
|
||||
if resolver is None:
|
||||
scoped = _resolver_var.get()
|
||||
if scoped is not None:
|
||||
return scoped
|
||||
|
||||
key = str(Path("tools.py").resolve())
|
||||
with _UNSCOPED_LOCK:
|
||||
cached = _UNSCOPED_CACHE.get(key)
|
||||
if cached is not None:
|
||||
_UNSCOPED_CACHE.move_to_end(key)
|
||||
return cached
|
||||
resolver = ToolResolver()
|
||||
_resolver_var.set(resolver)
|
||||
return resolver
|
||||
_UNSCOPED_CACHE[key] = resolver
|
||||
_UNSCOPED_CACHE.move_to_end(key)
|
||||
while len(_UNSCOPED_CACHE) > _UNSCOPED_MAX:
|
||||
_UNSCOPED_CACHE.popitem(last=False)
|
||||
return resolver
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def scoped_resolver(resolver: ToolResolver) -> Iterator[ToolResolver]:
|
||||
"""Bind ``resolver`` for the duration of the ``with`` block.
|
||||
|
||||
Server request handlers, ``RunContext``, and per-tenant middleware wrap their
|
||||
work with this so tool resolution honours the caller's CWD/project without
|
||||
leaking into sibling contexts::
|
||||
|
||||
with scoped_resolver(ToolResolver()):
|
||||
...
|
||||
"""
|
||||
token = _resolver_var.set(resolver)
|
||||
try:
|
||||
yield resolver
|
||||
finally:
|
||||
_resolver_var.reset(token)
|
||||
|
||||
|
||||
def reset_default_resolver() -> None:
|
||||
"""Explicit invalidation hook for daemons / IDE plugins switching projects.
|
||||
|
||||
Call this when changing working directories or switching between projects
|
||||
to ensure the next tool resolution uses the new CWD.
|
||||
|
||||
Clears any scoped binding in the current context and drops the CWD-anchored
|
||||
default cache so the next resolution rebuilds against the current CWD.
|
||||
"""
|
||||
_resolver_var.set(None)
|
||||
with _UNSCOPED_LOCK:
|
||||
_UNSCOPED_CACHE.clear()
|
||||
|
||||
|
||||
# Convenience functions that use cached default resolver for performance
|
||||
|
||||
Loaded 100 of 252 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user