mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb42efa5b8 | |||
| ff1d472473 | |||
| c69318f2c3 | |||
| dbdfd978bb | |||
| e92704a04a | |||
| 7c9ee87b47 | |||
| 2dfee00e8d | |||
| 2939f1d520 | |||
| 18cf56ddb6 | |||
| 8834d166a1 | |||
| 82ef032a38 | |||
| 3d40056941 | |||
| b8cf0de2ac | |||
| e17c455e87 | |||
| 395272e8b0 |
@@ -46,12 +46,34 @@ These files contain image tags that **must** be updated whenever the SDK version
|
||||
### `openhands/version.py`
|
||||
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
|
||||
|
||||
### `openhands/resolver/issue_resolver.py`
|
||||
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
|
||||
|
||||
### `openhands/runtime/utils/runtime_build.py`
|
||||
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
|
||||
|
||||
### `.github/scripts/update_pr_description.sh`
|
||||
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
|
||||
|
||||
### `enterprise/Dockerfile`
|
||||
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
|
||||
|
||||
## V0 Legacy Files (separate update cadence)
|
||||
|
||||
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
|
||||
|
||||
### `Development.md`
|
||||
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
|
||||
|
||||
### `openhands/runtime/impl/kubernetes/README.md`
|
||||
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
|
||||
|
||||
### `enterprise/enterprise_local/README.md`
|
||||
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
|
||||
|
||||
### `third_party/runtime/impl/daytona/README.md`
|
||||
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
|
||||
|
||||
## Image Registries
|
||||
|
||||
| Registry | Usage |
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
GITHUB_REPO_NAME: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend/package-lock.json'
|
||||
|
||||
- name: Setup environment for end-to-end tests
|
||||
run: |
|
||||
# Create test results directory
|
||||
mkdir -p test-results
|
||||
|
||||
# Create downloads directory for OpenHands (use a directory in the home folder)
|
||||
mkdir -p $HOME/downloads
|
||||
sudo chown -R $USER:$USER $HOME/downloads
|
||||
sudo chmod -R 755 $HOME/downloads
|
||||
|
||||
- name: Build OpenHands
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
INSTALL_DOCKER: 1
|
||||
RUNTIME: docker
|
||||
FRONTEND_PORT: 12000
|
||||
FRONTEND_HOST: 0.0.0.0
|
||||
BACKEND_HOST: 0.0.0.0
|
||||
BACKEND_PORT: 3000
|
||||
ENABLE_BROWSER: true
|
||||
INSTALL_PLAYWRIGHT: 1
|
||||
run: |
|
||||
# Fix poetry.lock file if needed
|
||||
echo "Fixing poetry.lock file if needed..."
|
||||
poetry lock
|
||||
|
||||
# Build OpenHands using make build
|
||||
echo "Running make build..."
|
||||
make build
|
||||
|
||||
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
|
||||
echo "Installing Chromium Headless Shell for Playwright..."
|
||||
poetry run playwright install chromium-headless-shell
|
||||
|
||||
# Verify Playwright browsers are installed (for e2e tests only)
|
||||
echo "Verifying Playwright browsers installation for e2e tests..."
|
||||
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
|
||||
|
||||
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
|
||||
echo "ERROR: Chromium browser not found or not working for e2e tests"
|
||||
echo "$BROWSER_CHECK"
|
||||
exit 1
|
||||
else
|
||||
echo "Playwright browsers are properly installed for e2e tests."
|
||||
fi
|
||||
|
||||
# Docker runtime will handle workspace directory creation
|
||||
|
||||
# Start the application using make run with custom parameters and reduced logging
|
||||
echo "Starting OpenHands using make run..."
|
||||
# Set environment variables to reduce logging verbosity
|
||||
export PYTHONUNBUFFERED=1
|
||||
export LOG_LEVEL=WARNING
|
||||
export UVICORN_LOG_LEVEL=warning
|
||||
export OPENHANDS_LOG_LEVEL=WARNING
|
||||
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
|
||||
|
||||
# Store the PID of the make run process
|
||||
MAKE_PID=$!
|
||||
echo "OpenHands started with PID: $MAKE_PID"
|
||||
|
||||
# Wait for the application to start
|
||||
echo "Waiting for OpenHands to start..."
|
||||
max_attempts=15
|
||||
attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
|
||||
|
||||
# Check if the process is still running
|
||||
if ! ps -p $MAKE_PID > /dev/null; then
|
||||
echo "ERROR: OpenHands process has terminated unexpectedly"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if frontend port is open
|
||||
if nc -z localhost 12000; then
|
||||
# Verify we can get HTML content
|
||||
if curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
|
||||
break
|
||||
else
|
||||
echo "Port 12000 is open but not serving HTML content yet"
|
||||
fi
|
||||
else
|
||||
echo "Frontend port 12000 is not open yet"
|
||||
fi
|
||||
|
||||
# Show log output on each attempt
|
||||
echo "Recent log output:"
|
||||
tail -n 20 /tmp/openhands-e2e-test.log
|
||||
|
||||
# Wait before next attempt
|
||||
echo "Waiting 10 seconds before next check..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# Exit if we've reached the maximum number of attempts
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Final verification that the app is running
|
||||
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "ERROR: OpenHands is not running properly on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print success message
|
||||
echo "OpenHands is running successfully on port 12000"
|
||||
|
||||
- name: Run end-to-end tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
run: |
|
||||
# Check if the application is running
|
||||
if ! nc -z localhost 12000; then
|
||||
echo "ERROR: OpenHands is not running on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the tests with detailed output
|
||||
cd tests/e2e
|
||||
poetry run python -m pytest \
|
||||
test_settings.py::test_github_token_configuration \
|
||||
test_conversation.py::test_conversation_start \
|
||||
test_browsing_catchphrase.py::test_browsing_catchphrase \
|
||||
test_multi_conversation_resume.py::test_multi_conversation_resume \
|
||||
-v --no-header --capture=no --timeout=900
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
/tmp/openhands-e2e-test.log
|
||||
/tmp/openhands-e2e-build.log
|
||||
/tmp/openhands-backend.log
|
||||
/tmp/openhands-frontend.log
|
||||
/tmp/backend-health-check.log
|
||||
/tmp/frontend-check.log
|
||||
/tmp/vite-config.log
|
||||
/tmp/makefile-contents.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
# Stop OpenHands processes
|
||||
echo "Stopping OpenHands processes..."
|
||||
pkill -f "python -m openhands.server" || true
|
||||
pkill -f "npm run dev" || true
|
||||
pkill -f "make run" || true
|
||||
|
||||
# Print process status for debugging
|
||||
echo "Checking if any OpenHands processes are still running:"
|
||||
ps aux | grep -E "openhands|npm run dev" || true
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
name: Auto-Fix Tagged Issue with OpenHands
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
max_iterations:
|
||||
required: false
|
||||
type: number
|
||||
default: 50
|
||||
macro:
|
||||
required: false
|
||||
type: string
|
||||
default: "@openhands-agent"
|
||||
target_branch:
|
||||
required: false
|
||||
type: string
|
||||
default: "main"
|
||||
description: "Target branch to pull and create PR against"
|
||||
pr_type:
|
||||
required: false
|
||||
type: string
|
||||
default: "draft"
|
||||
description: "The PR type that is going to be created (draft, ready)"
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
type: string
|
||||
default: "anthropic/claude-sonnet-4-20250514"
|
||||
LLM_API_VERSION:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
base_container_image:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
description: "Custom sandbox env"
|
||||
runner:
|
||||
required: false
|
||||
type: string
|
||||
default: "ubuntu-latest"
|
||||
secrets:
|
||||
LLM_MODEL:
|
||||
required: false
|
||||
LLM_API_KEY:
|
||||
required: true
|
||||
LLM_BASE_URL:
|
||||
required: false
|
||||
PAT_TOKEN:
|
||||
required: false
|
||||
PAT_USERNAME:
|
||||
required: false
|
||||
|
||||
issues:
|
||||
types: [labeled]
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
auto-fix:
|
||||
if: |
|
||||
github.event_name == 'workflow_call' ||
|
||||
github.event.label.name == 'fix-me' ||
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
((github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
contains(github.event.comment.body, inputs.macro || '@openhands-agent') &&
|
||||
(github.event.comment.author_association == 'OWNER' || github.event.comment.author_association == 'COLLABORATOR' || github.event.comment.author_association == 'MEMBER')
|
||||
) ||
|
||||
|
||||
(github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, inputs.macro || '@openhands-agent') &&
|
||||
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
|
||||
)
|
||||
)
|
||||
runs-on: "${{ inputs.runner || 'ubuntu-latest' }}"
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Upgrade pip
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
- name: Get latest versions and create requirements.txt
|
||||
run: |
|
||||
python -m pip index versions openhands-ai > openhands_versions.txt
|
||||
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
|
||||
|
||||
# Create a new requirements.txt locally within the workflow, ensuring no reference to the repo's file
|
||||
echo "openhands-ai==${OPENHANDS_VERSION}" > /tmp/requirements.txt
|
||||
cat /tmp/requirements.txt
|
||||
|
||||
- name: Cache pip dependencies
|
||||
if: |
|
||||
!(
|
||||
github.event.label.name == 'fix-me-experimental' ||
|
||||
(
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
contains(github.event.comment.body, '@openhands-agent-exp')
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_review' &&
|
||||
contains(github.event.review.body, '@openhands-agent-exp')
|
||||
)
|
||||
)
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
|
||||
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
|
||||
|
||||
- name: Check required environment variables
|
||||
env:
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PAT_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC }}
|
||||
PAT_USERNAME: ${{ secrets.PAT_USERNAME }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
required_vars=("LLM_API_KEY")
|
||||
for var in "${required_vars[@]}"; do
|
||||
if [ -z "${!var}" ]; then
|
||||
echo "Error: Required environment variable $var is not set."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Check optional variables and warn about fallbacks
|
||||
if [ -z "$LLM_BASE_URL" ]; then
|
||||
echo "Warning: LLM_BASE_URL is not set, will use default API endpoint"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_TOKEN" ]; then
|
||||
echo "Warning: PAT_TOKEN is not set, falling back to GITHUB_TOKEN"
|
||||
fi
|
||||
|
||||
if [ -z "$PAT_USERNAME" ]; then
|
||||
echo "Warning: PAT_USERNAME is not set, will use openhands-agent"
|
||||
fi
|
||||
|
||||
- name: Set environment variables
|
||||
env:
|
||||
REVIEW_BODY: ${{ github.event.review.body || '' }}
|
||||
run: |
|
||||
# Handle pull request events first
|
||||
if [ -n "${{ github.event.pull_request.number }}" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle pull request review events
|
||||
elif [ -n "$REVIEW_BODY" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.pull_request.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle issue comment events that reference a PR
|
||||
elif [ -n "${{ github.event.issue.pull_request }}" ]; then
|
||||
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=pr" >> $GITHUB_ENV
|
||||
# Handle regular issue events
|
||||
else
|
||||
echo "ISSUE_NUMBER=${{ github.event.issue.number }}" >> $GITHUB_ENV
|
||||
echo "ISSUE_TYPE=issue" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
if [ -n "$REVIEW_BODY" ]; then
|
||||
echo "COMMENT_ID=${{ github.event.review.id || 'None' }}" >> $GITHUB_ENV
|
||||
else
|
||||
echo "COMMENT_ID=${{ github.event.comment.id || 'None' }}" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
echo "MAX_ITERATIONS=${{ inputs.max_iterations || 50 }}" >> $GITHUB_ENV
|
||||
echo "SANDBOX_ENV_GITHUB_TOKEN=${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}" >> $GITHUB_ENV
|
||||
echo "SANDBOX_BASE_CONTAINER_IMAGE=${{ inputs.base_container_image }}" >> $GITHUB_ENV
|
||||
|
||||
# Set branch variables
|
||||
echo "TARGET_BRANCH=${{ inputs.target_branch || 'main' }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Comment on issue with start message
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const issueType = process.env.ISSUE_TYPE;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: ${{ env.ISSUE_NUMBER }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `[OpenHands](https://github.com/OpenHands/OpenHands) started fixing the ${issueType}! You can monitor the progress [here](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`
|
||||
});
|
||||
|
||||
- name: Install OpenHands
|
||||
id: install_openhands
|
||||
uses: actions/github-script@v9
|
||||
env:
|
||||
COMMENT_BODY: ${{ github.event.comment.body || '' }}
|
||||
REVIEW_BODY: ${{ github.event.review.body || '' }}
|
||||
LABEL_NAME: ${{ github.event.label.name || '' }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
with:
|
||||
script: |
|
||||
const commentBody = process.env.COMMENT_BODY.trim();
|
||||
const reviewBody = process.env.REVIEW_BODY.trim();
|
||||
const labelName = process.env.LABEL_NAME.trim();
|
||||
const eventName = process.env.EVENT_NAME.trim();
|
||||
// Check conditions
|
||||
const isExperimentalLabel = labelName === "fix-me-experimental";
|
||||
const isIssueCommentExperimental =
|
||||
(eventName === "issue_comment" || eventName === "pull_request_review_comment") &&
|
||||
commentBody.includes("@openhands-agent-exp");
|
||||
const isReviewCommentExperimental =
|
||||
eventName === "pull_request_review" && reviewBody.includes("@openhands-agent-exp");
|
||||
|
||||
// Set output variable
|
||||
core.setOutput('isExperimental', isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental);
|
||||
|
||||
// Perform package installation
|
||||
if (isExperimentalLabel || isIssueCommentExperimental || isReviewCommentExperimental) {
|
||||
console.log("Installing experimental OpenHands...");
|
||||
|
||||
await exec.exec("pip install git+https://github.com/openhands/openhands.git");
|
||||
} else {
|
||||
console.log("Installing from requirements.txt...");
|
||||
|
||||
await exec.exec("pip install -r /tmp/requirements.txt");
|
||||
}
|
||||
|
||||
- name: Attempt to resolve issue
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PYTHONPATH: ""
|
||||
run: |
|
||||
cd /tmp && python -m openhands.resolver.resolve_issue \
|
||||
--selected-repo ${{ github.repository }} \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--issue-type ${{ env.ISSUE_TYPE }} \
|
||||
--max-iterations ${{ env.MAX_ITERATIONS }} \
|
||||
--comment-id ${{ env.COMMENT_ID }} \
|
||||
--is-experimental ${{ steps.install_openhands.outputs.isExperimental }}
|
||||
|
||||
- name: Check resolution result
|
||||
id: check_result
|
||||
run: |
|
||||
if cd /tmp && grep -q '"success":true' output/output.jsonl; then
|
||||
echo "RESOLUTION_SUCCESS=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "RESOLUTION_SUCCESS=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Upload output.jsonl as artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() # Upload even if the previous steps fail
|
||||
with:
|
||||
name: resolver-output
|
||||
path: /tmp/output/output.jsonl
|
||||
retention-days: 30 # Keep the artifact for 30 days
|
||||
|
||||
- name: Create draft PR or push branch
|
||||
if: always() # Create PR or branch even if the previous steps fail
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
GITHUB_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
GIT_USERNAME: ${{ secrets.PAT_USERNAME || 'openhands-agent' }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || inputs.LLM_MODEL }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
LLM_API_VERSION: ${{ inputs.LLM_API_VERSION }}
|
||||
PYTHONPATH: ""
|
||||
run: |
|
||||
if [ "${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}" == "true" ]; then
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--target-branch ${{ env.TARGET_BRANCH }} \
|
||||
--pr-type ${{ inputs.pr_type || 'draft' }} \
|
||||
--reviewer ${{ github.actor }} | tee pr_result.txt && \
|
||||
grep "PR created" pr_result.txt | sed 's/.*\///g' > pr_number.txt
|
||||
else
|
||||
cd /tmp && python -m openhands.resolver.send_pull_request \
|
||||
--issue-number ${{ env.ISSUE_NUMBER }} \
|
||||
--pr-type branch \
|
||||
--send-on-failure | tee branch_result.txt && \
|
||||
grep "branch created" branch_result.txt | sed 's/.*\///g; s/.expand=1//g' > branch_name.txt
|
||||
fi
|
||||
|
||||
# Step leaves comment for when agent is invoked on PR
|
||||
- name: Analyze Push Logs (Updated PR or No Changes) # Skip comment if PR update was successful OR leave comment if the agent made no code changes
|
||||
uses: actions/github-script@v9
|
||||
if: always()
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
let logContent = '';
|
||||
|
||||
try {
|
||||
logContent = fs.readFileSync('/tmp/pr_result.txt', 'utf8').trim();
|
||||
} catch (error) {
|
||||
console.error('Error reading pr_result.txt file:', error);
|
||||
}
|
||||
|
||||
const noChangesMessage = `No changes to commit for issue #${issueNumber}. Skipping commit.`;
|
||||
|
||||
// Check logs from send_pull_request.py (pushes code to GitHub)
|
||||
if (logContent.includes("Updated pull request")) {
|
||||
console.log("Updated pull request found. Skipping comment.");
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (logContent.includes(noChangesMessage)) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Openhands failed to create any code changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Step leaves comment for when agent is invoked on issue
|
||||
- name: Comment on issue # Comment link to either PR or branch created by agent
|
||||
uses: actions/github-script@v9
|
||||
if: always() # Comment on issue even if the previous steps fail
|
||||
env:
|
||||
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
const success = process.env.RESOLUTION_SUCCESS === 'true';
|
||||
|
||||
let prNumber = '';
|
||||
let branchName = '';
|
||||
let resultExplanation = '';
|
||||
|
||||
try {
|
||||
if (success) {
|
||||
prNumber = fs.readFileSync('/tmp/pr_number.txt', 'utf8').trim();
|
||||
} else {
|
||||
branchName = fs.readFileSync('/tmp/branch_name.txt', 'utf8').trim();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
if (!success){
|
||||
// Read result_explanation from JSON file for failed resolution
|
||||
const outputFilePath = path.resolve('/tmp/output/output.jsonl');
|
||||
if (fs.existsSync(outputFilePath)) {
|
||||
const outputContent = fs.readFileSync(outputFilePath, 'utf8');
|
||||
const jsonLines = outputContent.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
if (jsonLines.length > 0) {
|
||||
// First entry in JSON lines has the key 'result_explanation'
|
||||
const firstEntry = JSON.parse(jsonLines[0]);
|
||||
resultExplanation = firstEntry.result_explanation || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error){
|
||||
console.error('Error reading file:', error);
|
||||
}
|
||||
|
||||
// Check "success" log from resolver output
|
||||
if (success && prNumber) {
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `A potential fix has been generated and a draft PR #${prNumber} has been created. Please review the changes.`
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
} else if (!success && branchName) {
|
||||
let commentBody = `An attempt was made to automatically fix this issue, but it was unsuccessful. A branch named '${branchName}' has been created with the attempted changes. You can view the branch [here](https://github.com/${context.repo.owner}/${context.repo.repo}/tree/${branchName}). Manual intervention may be required.`;
|
||||
|
||||
if (resultExplanation) {
|
||||
commentBody += `\n\nAdditional details about the failure:\n${resultExplanation}`;
|
||||
}
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: commentBody
|
||||
});
|
||||
process.env.AGENT_RESPONDED = 'true';
|
||||
}
|
||||
|
||||
# Leave error comment when both PR/Issue comment handling fail
|
||||
- name: Fallback Error Comment
|
||||
uses: actions/github-script@v9
|
||||
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
|
||||
env:
|
||||
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
|
||||
with:
|
||||
github-token: ${{ secrets.OPENHANDS_BOT_GITHUB_PAT_PUBLIC || github.token }}
|
||||
script: |
|
||||
const issueNumber = process.env.ISSUE_NUMBER;
|
||||
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issueNumber,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `The workflow to fix this issue encountered an error. Please check the [workflow logs](https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}) for more information.`
|
||||
});
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -60,6 +60,10 @@ jobs:
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -80,7 +84,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
|| (github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') && !contains(github.ref, '-cli') && !startsWith(github.ref, 'refs/tags/cloud-'))
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-python@v6
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install Poetry
|
||||
|
||||
@@ -36,6 +36,7 @@ Full details in our [Development Guide](./Development.md).
|
||||
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
## What Can You Build?
|
||||
|
||||
+1
-1
@@ -16,7 +16,7 @@ open source community:
|
||||
|
||||
#### [Aider](https://github.com/paul-gauthier/aider)
|
||||
- License: Apache License 2.0
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
|
||||
|
||||
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
|
||||
- License: Apache License 2.0
|
||||
|
||||
@@ -309,6 +309,16 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
|
||||
---
|
||||
|
||||
## Using Existing Docker Images
|
||||
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Help
|
||||
|
||||
```bash
|
||||
@@ -329,3 +339,4 @@ make help
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -88,6 +88,7 @@ USER openhands
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
|
||||
@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
|
||||
unset WORKSPACE_BASE
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
|
||||
echo "Downloading and installing third_party_runtimes..."
|
||||
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
|
||||
|
||||
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
|
||||
echo "third_party_runtimes installed successfully."
|
||||
else
|
||||
echo "Failed to install third_party_runtimes." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
|
||||
echo "Running OpenHands as root"
|
||||
export RUN_AS_OPENHANDS=false
|
||||
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -37,12 +37,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
|
||||
@@ -10,7 +10,10 @@ strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (enterprise/)
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openhands.memory.condenser.impl.*]
|
||||
disable_error_code = override
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["enterprise/"]
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -61,6 +61,13 @@ export LITE_LLM_API_KEY=<your LLM API key>
|
||||
python enterprise_local/convert_to_env.py
|
||||
```
|
||||
|
||||
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
|
||||
|
||||
```
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
|
||||
```
|
||||
|
||||
By default the application will log in json, you can override.
|
||||
|
||||
```
|
||||
@@ -196,6 +203,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
@@ -229,6 +237,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
|
||||
@@ -6,8 +6,7 @@ Create Date: 2026-03-22 00:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, Sequence, Union
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
@@ -22,187 +21,6 @@ depends_on: Union[str, Sequence[str], None] = None
|
||||
_EMPTY_JSON = sa.text("'{}'::json")
|
||||
|
||||
|
||||
def _deep_merge(
|
||||
base: dict[str, Any], overrides: Mapping[str, Any] | None
|
||||
) -> dict[str, Any]:
|
||||
merged = dict(base)
|
||||
for key, value in (overrides or {}).items():
|
||||
existing = merged.get(key)
|
||||
if isinstance(existing, dict) and isinstance(value, Mapping):
|
||||
merged[key] = _deep_merge(existing, value)
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
def _strip_none_and_empty(value: Any) -> Any:
|
||||
if isinstance(value, Mapping):
|
||||
cleaned: dict[str, Any] = {}
|
||||
for key, item in value.items():
|
||||
cleaned_item = _strip_none_and_empty(item)
|
||||
if cleaned_item is None:
|
||||
continue
|
||||
if isinstance(cleaned_item, dict) and not cleaned_item:
|
||||
continue
|
||||
cleaned[key] = cleaned_item
|
||||
return cleaned
|
||||
return value
|
||||
|
||||
|
||||
def _build_user_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'schema_version': 1,
|
||||
'agent': row['agent'],
|
||||
'llm': {
|
||||
'model': row['llm_model'],
|
||||
'base_url': row['llm_base_url'],
|
||||
},
|
||||
'condenser': {
|
||||
'enabled': row['enable_default_condenser'],
|
||||
'max_size': row['condenser_max_size'],
|
||||
},
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('agent_settings') or {})
|
||||
|
||||
|
||||
def _build_user_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'max_iterations': row['max_iterations'],
|
||||
'confirmation_mode': row['confirmation_mode'],
|
||||
'security_analyzer': row['security_analyzer'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('conversation_settings') or {})
|
||||
|
||||
|
||||
def _build_org_member_agent_settings_diff(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'schema_version': 1,
|
||||
'llm': {
|
||||
'model': row['llm_model'],
|
||||
'base_url': row['llm_base_url'],
|
||||
},
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('agent_settings_diff') or {})
|
||||
|
||||
|
||||
def _build_org_member_conversation_settings_diff(
|
||||
row: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty({'max_iterations': row['max_iterations']})
|
||||
return _deep_merge(generated, row.get('conversation_settings_diff') or {})
|
||||
|
||||
|
||||
def _build_org_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'schema_version': 1,
|
||||
'agent': row['agent'],
|
||||
'llm': {
|
||||
'model': row['default_llm_model'],
|
||||
'base_url': row['default_llm_base_url'],
|
||||
},
|
||||
'condenser': {
|
||||
'enabled': row['enable_default_condenser'],
|
||||
'max_size': row['condenser_max_size'],
|
||||
},
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('agent_settings') or {})
|
||||
|
||||
|
||||
def _build_org_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
'max_iterations': row['default_max_iterations'],
|
||||
'confirmation_mode': row['confirmation_mode'],
|
||||
'security_analyzer': row['security_analyzer'],
|
||||
}
|
||||
)
|
||||
return _deep_merge(generated, row.get('conversation_settings') or {})
|
||||
|
||||
|
||||
def _get_nested_value(data: Mapping[str, Any] | None, *path: str) -> Any:
|
||||
current: Any = data or {}
|
||||
for key in path:
|
||||
if not isinstance(current, Mapping) or key not in current:
|
||||
return None
|
||||
current = current[key]
|
||||
return current
|
||||
|
||||
|
||||
def _legacy_user_settings_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent_settings = row.get('agent_settings') or {}
|
||||
conversation_settings = row.get('conversation_settings') or {}
|
||||
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
|
||||
return {
|
||||
'agent': _get_nested_value(agent_settings, 'agent'),
|
||||
'max_iterations': _get_nested_value(conversation_settings, 'max_iterations'),
|
||||
'security_analyzer': _get_nested_value(
|
||||
conversation_settings, 'security_analyzer'
|
||||
),
|
||||
'confirmation_mode': _get_nested_value(
|
||||
conversation_settings, 'confirmation_mode'
|
||||
),
|
||||
'llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
|
||||
'llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
|
||||
'enable_default_condenser': (
|
||||
True if condenser_enabled is None else condenser_enabled
|
||||
),
|
||||
'condenser_max_size': _get_nested_value(
|
||||
agent_settings, 'condenser', 'max_size'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _legacy_org_member_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent_settings_diff = row.get('agent_settings_diff') or {}
|
||||
conversation_settings_diff = row.get('conversation_settings_diff') or {}
|
||||
return {
|
||||
'llm_model': _get_nested_value(agent_settings_diff, 'llm', 'model'),
|
||||
'llm_base_url': _get_nested_value(agent_settings_diff, 'llm', 'base_url'),
|
||||
'max_iterations': _get_nested_value(
|
||||
conversation_settings_diff, 'max_iterations'
|
||||
),
|
||||
'mcp_config': _get_nested_value(agent_settings_diff, 'mcp_config'),
|
||||
}
|
||||
|
||||
|
||||
def _legacy_org_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
agent_settings = row.get('agent_settings') or {}
|
||||
conversation_settings = row.get('conversation_settings') or {}
|
||||
condenser_enabled = _get_nested_value(agent_settings, 'condenser', 'enabled')
|
||||
return {
|
||||
'agent': _get_nested_value(agent_settings, 'agent'),
|
||||
'default_max_iterations': _get_nested_value(
|
||||
conversation_settings, 'max_iterations'
|
||||
),
|
||||
'security_analyzer': _get_nested_value(
|
||||
conversation_settings, 'security_analyzer'
|
||||
),
|
||||
'confirmation_mode': _get_nested_value(
|
||||
conversation_settings, 'confirmation_mode'
|
||||
),
|
||||
'default_llm_model': _get_nested_value(agent_settings, 'llm', 'model'),
|
||||
'default_llm_base_url': _get_nested_value(agent_settings, 'llm', 'base_url'),
|
||||
'enable_default_condenser': (
|
||||
True if condenser_enabled is None else condenser_enabled
|
||||
),
|
||||
'mcp_config': _get_nested_value(agent_settings, 'mcp_config'),
|
||||
'condenser_max_size': _get_nested_value(
|
||||
agent_settings, 'condenser', 'max_size'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'user_settings',
|
||||
@@ -264,125 +82,63 @@ def upgrade() -> None:
|
||||
),
|
||||
)
|
||||
|
||||
bind = op.get_bind()
|
||||
|
||||
user_settings_table = sa.table(
|
||||
'user_settings',
|
||||
sa.column('id', sa.Integer()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET agent_settings = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'agent', agent,
|
||||
'llm.model', llm_model,
|
||||
'llm.base_url', llm_base_url,
|
||||
'verification.confirmation_mode', confirmation_mode,
|
||||
'verification.security_analyzer', security_analyzer,
|
||||
'condenser.enabled', enable_default_condenser,
|
||||
'condenser.max_size', condenser_max_size,
|
||||
'max_iterations', max_iterations
|
||||
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
)
|
||||
)
|
||||
user_settings_rows = bind.execute(
|
||||
sa.select(
|
||||
user_settings_table.c.id,
|
||||
user_settings_table.c.agent,
|
||||
user_settings_table.c.max_iterations,
|
||||
user_settings_table.c.security_analyzer,
|
||||
user_settings_table.c.confirmation_mode,
|
||||
user_settings_table.c.llm_model,
|
||||
user_settings_table.c.llm_base_url,
|
||||
user_settings_table.c.enable_default_condenser,
|
||||
user_settings_table.c.condenser_max_size,
|
||||
user_settings_table.c.mcp_config,
|
||||
user_settings_table.c.agent_settings,
|
||||
user_settings_table.c.conversation_settings,
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org_member
|
||||
SET agent_settings_diff = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'llm.model', llm_model,
|
||||
'llm.base_url', llm_base_url,
|
||||
'max_iterations', max_iterations,
|
||||
'mcp_config', mcp_config
|
||||
) || COALESCE(agent_settings_diff::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
)
|
||||
).mappings()
|
||||
for row in user_settings_rows:
|
||||
bind.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.id == row['id'])
|
||||
.values(
|
||||
agent_settings=_build_user_agent_settings(row),
|
||||
conversation_settings=_build_user_conversation_settings(row),
|
||||
)
|
||||
)
|
||||
|
||||
org_member_table = sa.table(
|
||||
'org_member',
|
||||
sa.column('org_id', sa.Uuid()),
|
||||
sa.column('user_id', sa.Uuid()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('agent_settings_diff', sa.JSON()),
|
||||
sa.column('conversation_settings_diff', sa.JSON()),
|
||||
)
|
||||
org_member_rows = bind.execute(
|
||||
sa.select(
|
||||
org_member_table.c.org_id,
|
||||
org_member_table.c.user_id,
|
||||
org_member_table.c.max_iterations,
|
||||
org_member_table.c.llm_model,
|
||||
org_member_table.c.llm_base_url,
|
||||
org_member_table.c.mcp_config,
|
||||
org_member_table.c.agent_settings_diff,
|
||||
org_member_table.c.conversation_settings_diff,
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org
|
||||
SET agent_settings = jsonb_strip_nulls(
|
||||
jsonb_build_object(
|
||||
'schema_version', 1,
|
||||
'agent', agent,
|
||||
'llm.model', default_llm_model,
|
||||
'llm.base_url', default_llm_base_url,
|
||||
'verification.confirmation_mode', confirmation_mode,
|
||||
'verification.security_analyzer', security_analyzer,
|
||||
'condenser.enabled', enable_default_condenser,
|
||||
'condenser.max_size', condenser_max_size,
|
||||
'max_iterations', default_max_iterations,
|
||||
'mcp_config', mcp_config
|
||||
) || COALESCE(agent_settings::jsonb, '{}'::jsonb)
|
||||
)::json
|
||||
"""
|
||||
)
|
||||
).mappings()
|
||||
for row in org_member_rows:
|
||||
bind.execute(
|
||||
org_member_table.update()
|
||||
.where(org_member_table.c.org_id == row['org_id'])
|
||||
.where(org_member_table.c.user_id == row['user_id'])
|
||||
.values(
|
||||
agent_settings_diff=_build_org_member_agent_settings_diff(row),
|
||||
conversation_settings_diff=_build_org_member_conversation_settings_diff(
|
||||
row
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
org_table = sa.table(
|
||||
'org',
|
||||
sa.column('id', sa.Uuid()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('default_max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('default_llm_model', sa.String()),
|
||||
sa.column('default_llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
)
|
||||
org_rows = bind.execute(
|
||||
sa.select(
|
||||
org_table.c.id,
|
||||
org_table.c.agent,
|
||||
org_table.c.default_max_iterations,
|
||||
org_table.c.security_analyzer,
|
||||
org_table.c.confirmation_mode,
|
||||
org_table.c.default_llm_model,
|
||||
org_table.c.default_llm_base_url,
|
||||
org_table.c.enable_default_condenser,
|
||||
org_table.c.mcp_config,
|
||||
org_table.c.condenser_max_size,
|
||||
org_table.c.agent_settings,
|
||||
org_table.c.conversation_settings,
|
||||
)
|
||||
).mappings()
|
||||
for row in org_rows:
|
||||
bind.execute(
|
||||
org_table.update()
|
||||
.where(org_table.c.id == row['id'])
|
||||
.values(
|
||||
agent_settings=_build_org_agent_settings(row),
|
||||
conversation_settings=_build_org_conversation_settings(row),
|
||||
)
|
||||
)
|
||||
|
||||
op.alter_column('user_settings', 'agent_settings', server_default=None)
|
||||
op.alter_column('user_settings', 'conversation_settings', server_default=None)
|
||||
@@ -467,92 +223,73 @@ def downgrade() -> None:
|
||||
op.add_column('org', sa.Column('mcp_config', sa.JSON(), nullable=True))
|
||||
op.add_column('org', sa.Column('condenser_max_size', sa.Integer(), nullable=True))
|
||||
|
||||
bind = op.get_bind()
|
||||
|
||||
user_settings_table = sa.table(
|
||||
'user_settings',
|
||||
sa.column('id', sa.Integer()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE user_settings
|
||||
SET
|
||||
agent = agent_settings ->> 'agent',
|
||||
max_iterations = NULLIF(agent_settings ->> 'max_iterations', '')::integer,
|
||||
security_analyzer =
|
||||
agent_settings ->> 'verification.security_analyzer',
|
||||
confirmation_mode = CASE
|
||||
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
|
||||
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
|
||||
ELSE NULL
|
||||
END,
|
||||
llm_model = agent_settings ->> 'llm.model',
|
||||
llm_base_url = agent_settings ->> 'llm.base_url',
|
||||
enable_default_condenser = CASE
|
||||
WHEN agent_settings::jsonb ? 'condenser.enabled'
|
||||
THEN (agent_settings ->> 'condenser.enabled')::boolean
|
||||
ELSE TRUE
|
||||
END,
|
||||
condenser_max_size =
|
||||
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
|
||||
"""
|
||||
)
|
||||
)
|
||||
user_settings_rows = bind.execute(
|
||||
sa.select(
|
||||
user_settings_table.c.id,
|
||||
user_settings_table.c.agent_settings,
|
||||
user_settings_table.c.conversation_settings,
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org_member
|
||||
SET
|
||||
llm_model = agent_settings_diff ->> 'llm.model',
|
||||
llm_base_url = agent_settings_diff ->> 'llm.base_url',
|
||||
max_iterations =
|
||||
NULLIF(agent_settings_diff ->> 'max_iterations', '')::integer,
|
||||
mcp_config = agent_settings_diff -> 'mcp_config'
|
||||
"""
|
||||
)
|
||||
).mappings()
|
||||
for row in user_settings_rows:
|
||||
bind.execute(
|
||||
user_settings_table.update()
|
||||
.where(user_settings_table.c.id == row['id'])
|
||||
.values(**_legacy_user_settings_values(row))
|
||||
)
|
||||
|
||||
org_member_table = sa.table(
|
||||
'org_member',
|
||||
sa.column('org_id', sa.Uuid()),
|
||||
sa.column('user_id', sa.Uuid()),
|
||||
sa.column('agent_settings_diff', sa.JSON()),
|
||||
sa.column('conversation_settings_diff', sa.JSON()),
|
||||
sa.column('llm_model', sa.String()),
|
||||
sa.column('llm_base_url', sa.String()),
|
||||
sa.column('max_iterations', sa.Integer()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
)
|
||||
org_member_rows = bind.execute(
|
||||
sa.select(
|
||||
org_member_table.c.org_id,
|
||||
org_member_table.c.user_id,
|
||||
org_member_table.c.agent_settings_diff,
|
||||
org_member_table.c.conversation_settings_diff,
|
||||
op.execute(
|
||||
sa.text(
|
||||
"""
|
||||
UPDATE org
|
||||
SET
|
||||
agent = agent_settings ->> 'agent',
|
||||
default_max_iterations =
|
||||
NULLIF(agent_settings ->> 'max_iterations', '')::integer,
|
||||
security_analyzer =
|
||||
agent_settings ->> 'verification.security_analyzer',
|
||||
confirmation_mode = CASE
|
||||
WHEN agent_settings::jsonb ? 'verification.confirmation_mode'
|
||||
THEN (agent_settings ->> 'verification.confirmation_mode')::boolean
|
||||
ELSE NULL
|
||||
END,
|
||||
default_llm_model = agent_settings ->> 'llm.model',
|
||||
default_llm_base_url = agent_settings ->> 'llm.base_url',
|
||||
enable_default_condenser = CASE
|
||||
WHEN agent_settings::jsonb ? 'condenser.enabled'
|
||||
THEN (agent_settings ->> 'condenser.enabled')::boolean
|
||||
ELSE TRUE
|
||||
END,
|
||||
mcp_config = agent_settings -> 'mcp_config',
|
||||
condenser_max_size =
|
||||
NULLIF(agent_settings ->> 'condenser.max_size', '')::integer
|
||||
"""
|
||||
)
|
||||
).mappings()
|
||||
for row in org_member_rows:
|
||||
bind.execute(
|
||||
org_member_table.update()
|
||||
.where(org_member_table.c.org_id == row['org_id'])
|
||||
.where(org_member_table.c.user_id == row['user_id'])
|
||||
.values(**_legacy_org_member_values(row))
|
||||
)
|
||||
|
||||
org_table = sa.table(
|
||||
'org',
|
||||
sa.column('id', sa.Uuid()),
|
||||
sa.column('agent_settings', sa.JSON()),
|
||||
sa.column('conversation_settings', sa.JSON()),
|
||||
sa.column('agent', sa.String()),
|
||||
sa.column('default_max_iterations', sa.Integer()),
|
||||
sa.column('security_analyzer', sa.String()),
|
||||
sa.column('confirmation_mode', sa.Boolean()),
|
||||
sa.column('default_llm_model', sa.String()),
|
||||
sa.column('default_llm_base_url', sa.String()),
|
||||
sa.column('enable_default_condenser', sa.Boolean()),
|
||||
sa.column('mcp_config', sa.JSON()),
|
||||
sa.column('condenser_max_size', sa.Integer()),
|
||||
)
|
||||
org_rows = bind.execute(
|
||||
sa.select(
|
||||
org_table.c.id,
|
||||
org_table.c.agent_settings,
|
||||
org_table.c.conversation_settings,
|
||||
)
|
||||
).mappings()
|
||||
for row in org_rows:
|
||||
bind.execute(
|
||||
org_table.update()
|
||||
.where(org_table.c.id == row['id'])
|
||||
.values(**_legacy_org_values(row))
|
||||
)
|
||||
|
||||
op.drop_column('org', 'agent_settings')
|
||||
op.drop_column('org', 'conversation_settings')
|
||||
op.drop_column('org', '_llm_api_key')
|
||||
|
||||
Generated
+4
-1
@@ -6547,7 +6547,7 @@ python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
python-json-logger = ">=3.2.1"
|
||||
python-multipart = ">=0.0.26"
|
||||
python-multipart = ">=0.0.22"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
@@ -6571,6 +6571,9 @@ uvicorn = "*"
|
||||
whatthepatch = ">=1.0.6"
|
||||
zope-interface = "7.2"
|
||||
|
||||
[package.extras]
|
||||
third-party-runtimes = ["daytona (==0.24.2)", "e2b-code-interpreter (>=2)", "modal (>=0.66.26,<1.2)", "runloop-api-client (==0.50)"]
|
||||
|
||||
[package.source]
|
||||
type = "directory"
|
||||
url = ".."
|
||||
|
||||
@@ -106,15 +106,8 @@ if GITHUB_APP_CLIENT_ID:
|
||||
|
||||
# Add GitLab integration router only if GITLAB_APP_CLIENT_ID is set
|
||||
if GITLAB_APP_CLIENT_ID:
|
||||
# Make sure that the callback processor is loaded here so we don't get an error when deserializing
|
||||
from integrations.gitlab.gitlab_v1_callback_processor import ( # noqa: E402
|
||||
GitlabV1CallbackProcessor,
|
||||
)
|
||||
from server.routes.integration.gitlab import gitlab_integration_router # noqa: E402
|
||||
|
||||
# Bludgeon mypy into not deleting my import
|
||||
logger.debug(f'Loaded {GitlabV1CallbackProcessor.__name__}')
|
||||
|
||||
base_app.include_router(gitlab_integration_router)
|
||||
|
||||
base_app.include_router(api_keys_router) # Add routes for API key management
|
||||
|
||||
@@ -230,10 +230,19 @@ class UserStore:
|
||||
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
org_kwargs = UserStore._get_org_kwargs_for_migration(
|
||||
decrypted_user_settings,
|
||||
custom_settings=custom_settings,
|
||||
)
|
||||
org_kwargs = OrgStore.get_kwargs_from_user_settings(decrypted_user_settings)
|
||||
org_kwargs.pop('id', None)
|
||||
|
||||
# If the user has custom settings, keep the org defaults minimal.
|
||||
if custom_settings:
|
||||
org_kwargs['agent_settings'] = {
|
||||
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
}
|
||||
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
|
||||
|
||||
for key, value in org_kwargs.items():
|
||||
if hasattr(org, key):
|
||||
@@ -1057,27 +1066,6 @@ class UserStore:
|
||||
already_migrated=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_org_kwargs_for_migration(
|
||||
user_settings: UserSettings, *, custom_settings: bool
|
||||
) -> dict:
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
org_kwargs = OrgStore.get_kwargs_from_user_settings(user_settings)
|
||||
org_kwargs.pop('id', None)
|
||||
org_kwargs['org_version'] = ORG_SETTINGS_VERSION
|
||||
|
||||
if custom_settings:
|
||||
org_kwargs['agent_settings'] = {
|
||||
'schema_version': AGENT_SETTINGS_SCHEMA_VERSION,
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
}
|
||||
|
||||
return org_kwargs
|
||||
|
||||
@staticmethod
|
||||
def _has_custom_settings(
|
||||
user_settings: UserSettings, old_user_version: int | None
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
from importlib.util import module_from_spec, spec_from_file_location
|
||||
from pathlib import Path
|
||||
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
MIGRATION_PATH = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ 'migrations'
|
||||
/ 'versions'
|
||||
/ '108_add_agent_settings_to_enterprise_settings.py'
|
||||
)
|
||||
spec = spec_from_file_location('migration_108', MIGRATION_PATH)
|
||||
assert spec is not None and spec.loader is not None
|
||||
migration_108 = module_from_spec(spec)
|
||||
spec.loader.exec_module(migration_108)
|
||||
|
||||
|
||||
def test_user_settings_are_split_into_agent_and_conversation_buckets():
|
||||
row = {
|
||||
'agent': 'CodeActAgent',
|
||||
'max_iterations': 42,
|
||||
'security_analyzer': 'llm',
|
||||
'confirmation_mode': True,
|
||||
'llm_model': 'anthropic/claude-sonnet-4-5-20250929',
|
||||
'llm_base_url': 'https://api.example.com',
|
||||
'enable_default_condenser': False,
|
||||
'condenser_max_size': 128,
|
||||
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
|
||||
'agent_settings': {},
|
||||
'conversation_settings': {},
|
||||
}
|
||||
|
||||
agent_settings = migration_108._build_user_agent_settings(row)
|
||||
conversation_settings = migration_108._build_user_conversation_settings(row)
|
||||
|
||||
assert agent_settings == {
|
||||
'schema_version': 1,
|
||||
'agent': 'CodeActAgent',
|
||||
'llm': {
|
||||
'model': 'anthropic/claude-sonnet-4-5-20250929',
|
||||
'base_url': 'https://api.example.com',
|
||||
},
|
||||
'condenser': {'enabled': False, 'max_size': 128},
|
||||
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
|
||||
}
|
||||
assert conversation_settings == {
|
||||
'max_iterations': 42,
|
||||
'confirmation_mode': True,
|
||||
'security_analyzer': 'llm',
|
||||
}
|
||||
|
||||
|
||||
def test_org_member_diffs_use_nested_llm_and_conversation_settings():
|
||||
row = {
|
||||
'max_iterations': 50,
|
||||
'llm_model': 'openhands/claude-3',
|
||||
'llm_base_url': 'https://proxy.example.com',
|
||||
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
|
||||
'agent_settings_diff': {},
|
||||
'conversation_settings_diff': {},
|
||||
}
|
||||
|
||||
agent_settings_diff = migration_108._build_org_member_agent_settings_diff(row)
|
||||
conversation_settings_diff = (
|
||||
migration_108._build_org_member_conversation_settings_diff(row)
|
||||
)
|
||||
|
||||
assert agent_settings_diff == {
|
||||
'schema_version': 1,
|
||||
'llm': {
|
||||
'model': 'openhands/claude-3',
|
||||
'base_url': 'https://proxy.example.com',
|
||||
},
|
||||
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
|
||||
}
|
||||
assert conversation_settings_diff == {'max_iterations': 50}
|
||||
|
||||
|
||||
def test_org_settings_are_split_into_agent_and_conversation_buckets():
|
||||
row = {
|
||||
'agent': 'CodeActAgent',
|
||||
'default_max_iterations': 99,
|
||||
'security_analyzer': 'auto',
|
||||
'confirmation_mode': False,
|
||||
'default_llm_model': 'anthropic/claude-3-7-sonnet',
|
||||
'default_llm_base_url': 'https://api.example.com',
|
||||
'enable_default_condenser': True,
|
||||
'condenser_max_size': 256,
|
||||
'mcp_config': {'mcpServers': {'org': {'url': 'https://org-mcp.example.com'}}},
|
||||
'agent_settings': {},
|
||||
'conversation_settings': {},
|
||||
}
|
||||
|
||||
agent_settings = migration_108._build_org_agent_settings(row)
|
||||
conversation_settings = migration_108._build_org_conversation_settings(row)
|
||||
|
||||
assert agent_settings == {
|
||||
'schema_version': 1,
|
||||
'agent': 'CodeActAgent',
|
||||
'llm': {
|
||||
'model': 'anthropic/claude-3-7-sonnet',
|
||||
'base_url': 'https://api.example.com',
|
||||
},
|
||||
'condenser': {'enabled': True, 'max_size': 256},
|
||||
'mcp_config': {'mcpServers': {'org': {'url': 'https://org-mcp.example.com'}}},
|
||||
}
|
||||
assert conversation_settings == {
|
||||
'max_iterations': 99,
|
||||
'confirmation_mode': False,
|
||||
'security_analyzer': 'auto',
|
||||
}
|
||||
|
||||
|
||||
def test_downgrade_extracts_legacy_values_from_nested_settings():
|
||||
row = {
|
||||
'agent_settings': {
|
||||
'schema_version': 1,
|
||||
'agent': 'CodeActAgent',
|
||||
'llm': {
|
||||
'model': 'anthropic/claude-sonnet-4-5-20250929',
|
||||
'base_url': 'https://api.example.com',
|
||||
},
|
||||
'condenser': {'enabled': False, 'max_size': 128},
|
||||
},
|
||||
'conversation_settings': {
|
||||
'max_iterations': 42,
|
||||
'confirmation_mode': True,
|
||||
'security_analyzer': 'llm',
|
||||
},
|
||||
}
|
||||
|
||||
assert migration_108._legacy_user_settings_values(row) == {
|
||||
'agent': 'CodeActAgent',
|
||||
'max_iterations': 42,
|
||||
'security_analyzer': 'llm',
|
||||
'confirmation_mode': True,
|
||||
'llm_model': 'anthropic/claude-sonnet-4-5-20250929',
|
||||
'llm_base_url': 'https://api.example.com',
|
||||
'enable_default_condenser': False,
|
||||
'condenser_max_size': 128,
|
||||
}
|
||||
|
||||
|
||||
def test_migrated_payload_loads_via_user_settings_to_settings():
|
||||
row = {
|
||||
'agent': 'CodeActAgent',
|
||||
'max_iterations': 42,
|
||||
'security_analyzer': 'llm',
|
||||
'confirmation_mode': True,
|
||||
'llm_model': 'anthropic/claude-sonnet-4-5-20250929',
|
||||
'llm_base_url': 'https://api.example.com',
|
||||
'enable_default_condenser': False,
|
||||
'condenser_max_size': 128,
|
||||
'mcp_config': {'mcpServers': {'admin': {'url': 'https://mcp.example.com'}}},
|
||||
'agent_settings': {},
|
||||
'conversation_settings': {},
|
||||
}
|
||||
|
||||
user_settings = UserSettings(
|
||||
agent_settings=migration_108._build_user_agent_settings(row),
|
||||
conversation_settings=migration_108._build_user_conversation_settings(row),
|
||||
)
|
||||
|
||||
settings = user_settings.to_settings()
|
||||
|
||||
assert settings.agent_settings.agent == 'CodeActAgent'
|
||||
assert settings.agent_settings.llm.model == 'anthropic/claude-sonnet-4-5-20250929'
|
||||
assert settings.agent_settings.llm.base_url == 'https://api.example.com'
|
||||
assert settings.agent_settings.condenser.enabled is False
|
||||
assert settings.agent_settings.condenser.max_size == 128
|
||||
assert settings.agent_settings.mcp_config is not None
|
||||
assert (
|
||||
settings.agent_settings.mcp_config.mcpServers['admin'].url
|
||||
== 'https://mcp.example.com'
|
||||
)
|
||||
assert settings.conversation_settings.max_iterations == 42
|
||||
assert settings.conversation_settings.confirmation_mode is True
|
||||
assert settings.conversation_settings.security_analyzer == 'llm'
|
||||
@@ -695,66 +695,6 @@ async def test_list_users(async_session_maker):
|
||||
assert user_id2 in user_ids
|
||||
|
||||
|
||||
def test_get_org_kwargs_for_migration_preserves_existing_llm_when_not_custom():
|
||||
from server.constants import ORG_SETTINGS_VERSION
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
user_settings = UserSettings(
|
||||
keycloak_user_id='test',
|
||||
user_version=3,
|
||||
agent_settings={
|
||||
'schema_version': 1,
|
||||
'llm': {
|
||||
'model': 'anthropic/claude-sonnet-4-5-20250929',
|
||||
'base_url': 'https://api.anthropic.com/v1',
|
||||
},
|
||||
},
|
||||
conversation_settings={'max_iterations': 42},
|
||||
)
|
||||
|
||||
org_kwargs = UserStore._get_org_kwargs_for_migration(
|
||||
user_settings, custom_settings=False
|
||||
)
|
||||
|
||||
assert org_kwargs['org_version'] == ORG_SETTINGS_VERSION
|
||||
assert org_kwargs['agent_settings'] == user_settings.agent_settings
|
||||
assert org_kwargs['conversation_settings'] == user_settings.conversation_settings
|
||||
|
||||
|
||||
def test_get_org_kwargs_for_migration_uses_minimal_org_defaults_for_custom_llm():
|
||||
from server.constants import (
|
||||
LITE_LLM_API_URL,
|
||||
ORG_SETTINGS_VERSION,
|
||||
get_default_litellm_model,
|
||||
)
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
user_settings = UserSettings(
|
||||
keycloak_user_id='test',
|
||||
user_version=3,
|
||||
agent_settings={
|
||||
'schema_version': 1,
|
||||
'llm': {
|
||||
'model': 'anthropic/claude-sonnet-4-5-20250929',
|
||||
'base_url': 'https://api.anthropic.com/v1',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
org_kwargs = UserStore._get_org_kwargs_for_migration(
|
||||
user_settings, custom_settings=True
|
||||
)
|
||||
|
||||
assert org_kwargs['org_version'] == ORG_SETTINGS_VERSION
|
||||
assert org_kwargs['agent_settings'] == {
|
||||
'schema_version': 1,
|
||||
'llm': {
|
||||
'model': get_default_litellm_model(),
|
||||
'base_url': LITE_LLM_API_URL,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# --- Tests for _has_custom_settings ---
|
||||
|
||||
|
||||
|
||||
@@ -22,17 +22,4 @@ describe("OrgWideSettingsBadge", () => {
|
||||
const icon = badge.querySelector("svg");
|
||||
expect(icon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the managed-by-admin i18n key when variant is set", () => {
|
||||
// Arrange & Act
|
||||
render(<OrgWideSettingsBadge variant="managed-by-admin" />);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
screen.getByText("SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("SETTINGS$ORG_WIDE_SETTING_BADGE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -196,16 +196,23 @@ describe("useWebSocket", () => {
|
||||
const onCloseSpy = vi.fn();
|
||||
const options = { onClose: onCloseSpy };
|
||||
|
||||
const closeLink = ws.link("ws://close-test.com/ws");
|
||||
mswServer.use(
|
||||
closeLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.close(1000, "Normal closure");
|
||||
}),
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
);
|
||||
|
||||
renderHook(() => useWebSocket("ws://close-test.com/ws", options));
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
// Reset spy after connection is established to ignore any spurious
|
||||
// close events fired by the MSW mock during the handshake.
|
||||
onCloseSpy.mockClear();
|
||||
|
||||
// Unmount to trigger close
|
||||
unmount();
|
||||
|
||||
// Wait for onClose handler to be called
|
||||
await waitFor(() => {
|
||||
expect(onCloseSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
@@ -35,8 +35,6 @@ const VALID_OSS_CONFIG: WebClientConfig = {
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
};
|
||||
|
||||
const VALID_SAAS_CONFIG: WebClientConfig = {
|
||||
@@ -60,8 +58,6 @@ const VALID_SAAS_CONFIG: WebClientConfig = {
|
||||
error_message: null,
|
||||
updated_at: "2024-01-14T10:00:00Z",
|
||||
github_app_slug: null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
};
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
@@ -272,10 +268,7 @@ describe("Content", () => {
|
||||
|
||||
it("should render the 'Configure GitHub Repositories' button if SaaS mode and github_app_slug exists", async () => {
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_OSS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue(MOCK_DEFAULT_USER_SETTINGS);
|
||||
|
||||
const { rerender } = renderGitSettingsScreen();
|
||||
|
||||
@@ -290,24 +283,15 @@ describe("Content", () => {
|
||||
rerender();
|
||||
|
||||
await waitFor(() => {
|
||||
// wait until queries are resolved
|
||||
expect(queryClient.isFetching()).toBe(0);
|
||||
button = screen.queryByTestId("configure-github-repositories-button");
|
||||
expect(button).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("gitlab-status-text")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("install-slack-app-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("disconnect-tokens-button"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
providers_configured: ["gitlab"],
|
||||
github_app_slug: "test-slug",
|
||||
gitlab_enabled: true,
|
||||
slack_enabled: true,
|
||||
});
|
||||
queryClient.invalidateQueries();
|
||||
rerender();
|
||||
@@ -315,8 +299,6 @@ describe("Content", () => {
|
||||
await waitFor(() => {
|
||||
button = screen.getByTestId("configure-github-repositories-button");
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("install-slack-app-button")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("submit-button")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("disconnect-tokens-button"),
|
||||
@@ -632,49 +614,10 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should render configured GitLab and Slack sections in SaaS mode without APP_SLUG", async () => {
|
||||
it("should not render GitLab webhook manager in SaaS mode without APP_SLUG", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
providers_configured: ["gitlab"],
|
||||
gitlab_enabled: true,
|
||||
slack_enabled: true,
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId("configure-github-repositories-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("install-slack-app-button")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab or Slack sections when the backend does not enable them", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
@@ -682,25 +625,19 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId("gitlab-status-text")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId("install-slack-app-button"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab webhook manager when the token is not set", async () => {
|
||||
it("should not render GitLab webhook manager when token is not set", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
const getSettingsSpy = vi.spyOn(SettingsService, "getSettings");
|
||||
|
||||
getConfigSpy.mockResolvedValue({
|
||||
...VALID_SAAS_CONFIG,
|
||||
providers_configured: ["gitlab"],
|
||||
gitlab_enabled: true,
|
||||
});
|
||||
getSettingsSpy.mockResolvedValue({
|
||||
...MOCK_DEFAULT_USER_SETTINGS,
|
||||
@@ -713,7 +650,6 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -49,10 +49,6 @@ vi.mock("react-i18next", async () => {
|
||||
SETTINGS$NAV_BILLING: "Billing",
|
||||
SETTINGS$TITLE: "Settings",
|
||||
COMMON$LANGUAGE_MODEL_LLM: "LLM",
|
||||
SETTINGS$ORG_WIDE_SETTING_BADGE:
|
||||
"This setting affects the whole organization",
|
||||
SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE:
|
||||
"This setting is managed by your organization administrator",
|
||||
};
|
||||
return translations[key] || key;
|
||||
},
|
||||
@@ -655,10 +651,7 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
// Pre-populate user data in cache so useMe() returns admin role immediately
|
||||
mockQueryClient.setQueryData(
|
||||
["organizations", "1", "me"],
|
||||
createMockUser({ role: "admin", org_id: "1" }),
|
||||
);
|
||||
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -736,10 +729,7 @@ describe("Settings Screen", () => {
|
||||
});
|
||||
useSelectedOrganizationStore.setState({ organizationId: "1" });
|
||||
// Pre-populate user data in cache so useMe() returns admin role immediately
|
||||
mockQueryClient.setQueryData(
|
||||
["organizations", "1", "me"],
|
||||
createMockUser({ role: "admin", org_id: "1" }),
|
||||
);
|
||||
mockQueryClient.setQueryData(["organizations", "1", "me"], createMockUser({ role: "admin", org_id: "1" }));
|
||||
|
||||
renderSettingsScreen();
|
||||
|
||||
@@ -871,33 +861,22 @@ describe("Settings Screen", () => {
|
||||
|
||||
renderSettingsScreen(path);
|
||||
|
||||
const badge = await screen.findByTestId("org-wide-settings-badge");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge).toHaveTextContent(
|
||||
"This setting affects the whole organization",
|
||||
);
|
||||
expect(
|
||||
await screen.findByTestId("org-wide-settings-badge"),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"/settings/org-defaults",
|
||||
"/settings/org-defaults/condenser",
|
||||
"/settings/org-defaults/verification",
|
||||
])(
|
||||
"renders the managed-by-admin badge on %s for a member of a team org (read-only view)",
|
||||
async (path) => {
|
||||
seedSaasOrgContext(MOCK_TEAM_ORG_ACME, { role: "member" });
|
||||
it("renders the badge on /settings/org-defaults for a non-admin member of a team org (read-only view)", async () => {
|
||||
seedSaasOrgContext(MOCK_TEAM_ORG_ACME, { role: "member" });
|
||||
|
||||
renderSettingsScreen(path);
|
||||
renderSettingsScreen("/settings/org-defaults");
|
||||
|
||||
const badge = await screen.findByTestId("org-wide-settings-badge");
|
||||
await waitFor(() => {
|
||||
expect(badge).toHaveTextContent(
|
||||
"This setting is managed by your organization administrator",
|
||||
);
|
||||
});
|
||||
},
|
||||
);
|
||||
await screen.findByTestId("org-default-llm-settings-screen");
|
||||
expect(
|
||||
await screen.findByTestId("org-wide-settings-badge"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not render the badge on /settings/org-defaults when the selected organization is a personal org", async () => {
|
||||
seedSaasOrgContext(MOCK_PERSONAL_ORG, { role: "admin" });
|
||||
|
||||
@@ -131,6 +131,13 @@ export interface IOption<T> {
|
||||
value: T;
|
||||
}
|
||||
|
||||
export interface MicroagentContentResponse {
|
||||
content: string;
|
||||
path: string;
|
||||
git_provider: Provider;
|
||||
triggers: string[];
|
||||
}
|
||||
|
||||
export type GetFilesResponse = string[];
|
||||
|
||||
export interface GetFileResponse {
|
||||
|
||||
@@ -43,6 +43,4 @@ export interface WebClientConfig {
|
||||
error_message: string | null;
|
||||
updated_at: string;
|
||||
github_app_slug: string | null;
|
||||
gitlab_enabled?: boolean;
|
||||
slack_enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -3,22 +3,9 @@ import { Typography } from "#/ui/typography";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import InfoCircleIcon from "#/icons/info-circle.svg?react";
|
||||
|
||||
export type OrgWideSettingsBadgeVariant = "org-wide" | "managed-by-admin";
|
||||
|
||||
interface OrgWideSettingsBadgeProps {
|
||||
variant?: OrgWideSettingsBadgeVariant;
|
||||
}
|
||||
|
||||
export function OrgWideSettingsBadge({
|
||||
variant = "org-wide",
|
||||
}: OrgWideSettingsBadgeProps) {
|
||||
export function OrgWideSettingsBadge() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const i18nKey =
|
||||
variant === "managed-by-admin"
|
||||
? I18nKey.SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE
|
||||
: I18nKey.SETTINGS$ORG_WIDE_SETTING_BADGE;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="org-wide-settings-badge"
|
||||
@@ -26,7 +13,7 @@ export function OrgWideSettingsBadge({
|
||||
>
|
||||
<InfoCircleIcon width={12} height={12} className="text-[#8c8c8c]" />
|
||||
<Typography.Text className="text-[11px] font-medium text-[#8c8c8c] leading-5">
|
||||
{t(i18nKey)}
|
||||
{t(I18nKey.SETTINGS$ORG_WIDE_SETTING_BADGE)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -288,7 +288,6 @@ export enum I18nKey {
|
||||
SETTINGS$ORG_SETTINGS_HEADER = "SETTINGS$ORG_SETTINGS_HEADER",
|
||||
SETTINGS$PERSONAL_SETTINGS_HEADER = "SETTINGS$PERSONAL_SETTINGS_HEADER",
|
||||
SETTINGS$ORG_WIDE_SETTING_BADGE = "SETTINGS$ORG_WIDE_SETTING_BADGE",
|
||||
SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE = "SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE",
|
||||
SETTINGS$ORG_DEFAULTS_INFO = "SETTINGS$ORG_DEFAULTS_INFO",
|
||||
SETTINGS$PERSONAL_AGENT_INFO = "SETTINGS$PERSONAL_AGENT_INFO",
|
||||
SETTINGS$GITHUB = "SETTINGS$GITHUB",
|
||||
|
||||
@@ -4895,23 +4895,6 @@
|
||||
"uk": "Це налаштування впливає на всю організацію",
|
||||
"ca": "Aquesta configuració afecta tota l'organització"
|
||||
},
|
||||
"SETTINGS$ORG_MANAGED_BY_ADMIN_BADGE": {
|
||||
"en": "This setting is managed by your organization administrator",
|
||||
"ja": "この設定は組織の管理者によって管理されています",
|
||||
"zh-CN": "此设置由您的组织管理员管理",
|
||||
"zh-TW": "此設定由您的組織管理員管理",
|
||||
"ko-KR": "이 설정은 조직 관리자가 관리합니다",
|
||||
"no": "Denne innstillingen administreres av organisasjonsadministratoren din",
|
||||
"it": "Questa impostazione è gestita dall’amministratore della tua organizzazione",
|
||||
"pt": "Esta configuração é gerida pelo administrador da sua organização",
|
||||
"es": "Esta configuración está gestionada por el administrador de tu organización",
|
||||
"ar": "يتم إدارة هذا الإعداد من قبل مسؤول مؤسستك",
|
||||
"fr": "Ce paramètre est géré par l’administrateur de votre organisation",
|
||||
"tr": "Bu ayar kuruluş yöneticiniz tarafından yönetilmektedir",
|
||||
"de": "Diese Einstellung wird von Ihrem Organisationsadministrator verwaltet",
|
||||
"uk": "Це налаштування керується адміністратором вашої організації",
|
||||
"ca": "Aquesta configuració està gestionada per l'administrador de la teva organització"
|
||||
},
|
||||
"SETTINGS$ORG_DEFAULTS_INFO": {
|
||||
"en": "These organization defaults are applied first. Members can still add their own credentials or personal overrides where allowed.",
|
||||
"ja": "これらの組織のデフォルト設定が最初に適用されます。メンバーは許可されている範囲で、自分の認証情報や個人設定を追加できます。",
|
||||
|
||||
@@ -62,8 +62,6 @@ export const createMockWebClientConfig = (
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -427,8 +425,6 @@ export const SETTINGS_HANDLERS = [
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: mockSaas ? "openhands" : null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
};
|
||||
|
||||
return HttpResponse.json(config);
|
||||
|
||||
@@ -181,9 +181,8 @@ function GitSettingsScreen() {
|
||||
!bitbucketDCHostInputHasValue &&
|
||||
!azureDevOpsHostInputHasValue &&
|
||||
!forgejoHostInputHasValue;
|
||||
const shouldRenderGitHubConfigureButton = isSaas && config?.github_app_slug;
|
||||
const shouldRenderGitLabSection = isSaas && Boolean(config?.gitlab_enabled);
|
||||
const shouldRenderSlackSection = isSaas && Boolean(config?.slack_enabled);
|
||||
const shouldRenderExternalConfigureButtons =
|
||||
isSaas && config?.github_app_slug;
|
||||
const shouldRenderProjectManagementIntegrations =
|
||||
config?.feature_flags?.enable_jira ||
|
||||
config?.feature_flags?.enable_jira_dc ||
|
||||
@@ -197,7 +196,7 @@ function GitSettingsScreen() {
|
||||
>
|
||||
{!isLoading && (
|
||||
<div className="flex flex-col">
|
||||
{shouldRenderGitHubConfigureButton && (
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="pb-1 flex flex-col">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
@@ -211,7 +210,7 @@ function GitSettingsScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderGitLabSection && (
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="mt-6 flex flex-col gap-4 pb-8">
|
||||
<Typography.H3 className="text-xl">
|
||||
@@ -238,7 +237,7 @@ function GitSettingsScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderSlackSection && (
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
<>
|
||||
<div className="pb-1 mt-6 flex flex-col">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
@@ -347,7 +346,7 @@ function GitSettingsScreen() {
|
||||
{isLoading && <GitSettingInputsSkeleton />}
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
{!isSaas && (
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
<>
|
||||
<BrandButton
|
||||
testId="disconnect-tokens-button"
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
} from "#/utils/settings-utils";
|
||||
import { useOrgTypeAndAccess } from "#/hooks/use-org-type-and-access";
|
||||
import { useConfig } from "#/hooks/query/use-config";
|
||||
import { useMe } from "#/hooks/query/use-me";
|
||||
import { OrgWideSettingsBadge } from "#/components/features/settings/org-wide-settings-badge";
|
||||
|
||||
const SAAS_ONLY_PATHS = [
|
||||
@@ -132,15 +131,12 @@ function SettingsScreen() {
|
||||
const navItems = useSettingsNavItems();
|
||||
const { data: config } = useConfig();
|
||||
const { isTeamOrg } = useOrgTypeAndAccess();
|
||||
const { data: me } = useMe();
|
||||
|
||||
// Determine if we should show the org-wide settings badge
|
||||
// Only show for Admin/Owner roles on LLM/org-defaults pages in team orgs
|
||||
const isOrgWideBadgePath = ORG_WIDE_BADGE_PATHS.has(location.pathname);
|
||||
const isSaasMode = config?.app_mode === "saas";
|
||||
const shouldShowOrgWideBadge = isOrgWideBadgePath && isTeamOrg && isSaasMode;
|
||||
// Members see a read-only message; Admins/Owners see the org-wide notice.
|
||||
const orgWideBadgeVariant =
|
||||
me?.role === "member" ? "managed-by-admin" : "org-wide";
|
||||
|
||||
// Current section title for the main content area
|
||||
const currentSectionTitle = useMemo(() => {
|
||||
@@ -169,9 +165,7 @@ function SettingsScreen() {
|
||||
{!shouldHideTitle && (
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Typography.H2>{t(currentSectionTitle)}</Typography.H2>
|
||||
{shouldShowOrgWideBadge && (
|
||||
<OrgWideSettingsBadge variant={orgWideBadgeVariant} />
|
||||
)}
|
||||
{shouldShowOrgWideBadge && <OrgWideSettingsBadge />}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto custom-scrollbar-always">
|
||||
|
||||
@@ -5,7 +5,6 @@ import os
|
||||
import tempfile
|
||||
import zipfile
|
||||
from collections import defaultdict
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, AsyncGenerator, Sequence, cast
|
||||
@@ -1256,21 +1255,10 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
|
||||
# Merge API-provided secrets (they take precedence over existing ones)
|
||||
if api_secrets:
|
||||
from openhands.app_server.constants import (
|
||||
validate_secret_name,
|
||||
validate_secrets_dict,
|
||||
)
|
||||
|
||||
# Validate overall dict size limits first
|
||||
# Cast to Mapping for mypy compatibility (Mapping is covariant in value type)
|
||||
validate_secrets_dict(cast('Mapping[str, object]', api_secrets))
|
||||
from openhands.app_server.constants import validate_secret_name
|
||||
|
||||
for name, value in api_secrets.items():
|
||||
validate_secret_name(name)
|
||||
if name in secrets:
|
||||
_logger.warning(
|
||||
'API-provided secret %r overrides existing secret', name
|
||||
)
|
||||
secrets[name] = StaticSecret(value=value)
|
||||
|
||||
# --- LLM + MCP -----------------------------------------------------
|
||||
|
||||
@@ -4,31 +4,6 @@ This module contains constants that are used across the app server,
|
||||
including security-related configurations for secret name validation.
|
||||
"""
|
||||
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
|
||||
# =============================================================================
|
||||
# SECRET LIMITS (configurable via environment variables)
|
||||
# =============================================================================
|
||||
|
||||
# Maximum number of secrets that can be passed via API in a single request.
|
||||
# Prevents abuse by limiting the size of the secrets dictionary.
|
||||
# Override with: OH_MAX_API_SECRETS_COUNT
|
||||
MAX_API_SECRETS_COUNT: int = int(os.getenv('OH_MAX_API_SECRETS_COUNT', '50'))
|
||||
|
||||
# Maximum length of a secret name in characters.
|
||||
# Environment variable names should be concise; this prevents excessively long names.
|
||||
# Override with: OH_MAX_API_SECRET_NAME_LENGTH
|
||||
MAX_API_SECRET_NAME_LENGTH: int = int(os.getenv('OH_MAX_API_SECRET_NAME_LENGTH', '256'))
|
||||
|
||||
# Maximum length of a secret value in bytes.
|
||||
# 64KB is generous for API keys/tokens while preventing massive payloads.
|
||||
# Override with: OH_MAX_API_SECRET_VALUE_LENGTH
|
||||
MAX_API_SECRET_VALUE_LENGTH: int = int(
|
||||
os.getenv('OH_MAX_API_SECRET_VALUE_LENGTH', '65536')
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# SECRET NAME VALIDATION
|
||||
# =============================================================================
|
||||
@@ -41,24 +16,24 @@ MAX_API_SECRET_VALUE_LENGTH: int = int(
|
||||
# when exported in bash commands, potentially breaking the sandbox or
|
||||
# creating security vulnerabilities.
|
||||
# -----------------------------------------------------------------------------
|
||||
BLOCKED_SECRET_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
# Agent-server container configuration (from initial_env)
|
||||
'OPENVSCODE_SERVER_ROOT',
|
||||
'OH_ENABLE_VNC',
|
||||
'LOG_JSON',
|
||||
'OH_CONVERSATIONS_PATH',
|
||||
'OH_BASH_EVENTS_DIR',
|
||||
'PYTHONUNBUFFERED',
|
||||
'ENV_LOG_LEVEL',
|
||||
# Webhook and CORS - overriding could redirect callbacks to malicious endpoints
|
||||
'OH_WEBHOOKS_0_BASE_URL',
|
||||
'OH_ALLOW_CORS_ORIGINS_0',
|
||||
# Worker ports - could break web application functionality
|
||||
'WORKER_1',
|
||||
'WORKER_2',
|
||||
}
|
||||
)
|
||||
BLOCKED_SECRET_NAMES: frozenset[str] = frozenset({
|
||||
# Agent-server container configuration (from initial_env)
|
||||
'OPENVSCODE_SERVER_ROOT',
|
||||
'OH_ENABLE_VNC',
|
||||
'LOG_JSON',
|
||||
'OH_CONVERSATIONS_PATH',
|
||||
'OH_BASH_EVENTS_DIR',
|
||||
'PYTHONUNBUFFERED',
|
||||
'ENV_LOG_LEVEL',
|
||||
|
||||
# Webhook and CORS - overriding could redirect callbacks to malicious endpoints
|
||||
'OH_WEBHOOKS_0_BASE_URL',
|
||||
'OH_ALLOW_CORS_ORIGINS_0',
|
||||
|
||||
# Worker ports - could break web application functionality
|
||||
'WORKER_1',
|
||||
'WORKER_2',
|
||||
})
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# BLOCKED PREFIXES: Secret names starting with these prefixes are blocked.
|
||||
@@ -67,7 +42,9 @@ BLOCKED_SECRET_NAMES: frozenset[str] = frozenset(
|
||||
# LLM controls (timeouts, retries, model restrictions, etc.). Allowing users
|
||||
# to override these would let them escape app-server LLM controls.
|
||||
# -----------------------------------------------------------------------------
|
||||
BLOCKED_SECRET_PREFIXES: tuple[str, ...] = ('LLM_',)
|
||||
BLOCKED_SECRET_PREFIXES: tuple[str, ...] = (
|
||||
'LLM_',
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OVERRIDABLE: These are system-provided but users MAY override them.
|
||||
@@ -76,22 +53,21 @@ BLOCKED_SECRET_PREFIXES: tuple[str, ...] = ('LLM_',)
|
||||
# Use case: User wants to use their own credentials instead of the
|
||||
# organization-level credentials provided by the system.
|
||||
# -----------------------------------------------------------------------------
|
||||
OVERRIDABLE_SYSTEM_SECRETS: frozenset[str] = frozenset(
|
||||
{
|
||||
# Git Provider Tokens - users may provide their own credentials
|
||||
# Note: Provider tokens are fetched via app-server API, not container env
|
||||
'GITHUB_TOKEN',
|
||||
'GITLAB_TOKEN',
|
||||
'BITBUCKET_TOKEN',
|
||||
'AZURE_DEVOPS_TOKEN',
|
||||
'FORGEJO_TOKEN',
|
||||
# AWS Credentials - used for Bedrock LLM access
|
||||
# Users may want to use their own AWS account for Bedrock models
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_REGION_NAME',
|
||||
}
|
||||
)
|
||||
OVERRIDABLE_SYSTEM_SECRETS: frozenset[str] = frozenset({
|
||||
# Git Provider Tokens - users may provide their own credentials
|
||||
# Note: Provider tokens are fetched via app-server API, not container env
|
||||
'GITHUB_TOKEN',
|
||||
'GITLAB_TOKEN',
|
||||
'BITBUCKET_TOKEN',
|
||||
'AZURE_DEVOPS_TOKEN',
|
||||
'FORGEJO_TOKEN',
|
||||
|
||||
# AWS Credentials - used for Bedrock LLM access
|
||||
# Users may want to use their own AWS account for Bedrock models
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_REGION_NAME',
|
||||
})
|
||||
|
||||
|
||||
def validate_secret_name(name: str) -> None:
|
||||
@@ -101,69 +77,23 @@ def validate_secret_name(name: str) -> None:
|
||||
name: The secret name to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If the name is blocked (exact match or prefix match),
|
||||
or exceeds the maximum length
|
||||
ValueError: If the name is blocked (exact match or prefix match)
|
||||
"""
|
||||
# Check name length
|
||||
if len(name) > MAX_API_SECRET_NAME_LENGTH:
|
||||
raise ValueError(
|
||||
f'Secret name exceeds maximum length of {MAX_API_SECRET_NAME_LENGTH} characters '
|
||||
f'(got {len(name)}). Configure via OH_MAX_API_SECRET_NAME_LENGTH.'
|
||||
)
|
||||
|
||||
upper_name = name.upper()
|
||||
|
||||
|
||||
# Check exact matches
|
||||
if upper_name in BLOCKED_SECRET_NAMES:
|
||||
raise ValueError(
|
||||
f"Secret name '{name}' is reserved for internal use and cannot be overridden. "
|
||||
f'See openhands.app_server.constants for the list of blocked names.'
|
||||
f"See openhands.app_server.constants for the list of blocked names."
|
||||
)
|
||||
|
||||
|
||||
# Check prefix matches
|
||||
for prefix in BLOCKED_SECRET_PREFIXES:
|
||||
if upper_name.startswith(prefix):
|
||||
raise ValueError(
|
||||
f"Secret name '{name}' starts with reserved prefix '{prefix}' and cannot be used. "
|
||||
f'These variables are used for LLM configuration controls.'
|
||||
f"These variables are used for LLM configuration controls."
|
||||
)
|
||||
|
||||
|
||||
# Note: OVERRIDABLE_SYSTEM_SECRETS are intentionally allowed
|
||||
|
||||
|
||||
def validate_secrets_dict(secrets: Mapping[str, object] | None) -> None:
|
||||
"""Validate the entire secrets dictionary for size limits.
|
||||
|
||||
This should be called before iterating over individual secrets.
|
||||
|
||||
Args:
|
||||
secrets: The secrets dictionary to validate (can be None).
|
||||
Values can be str or SecretStr (uses get_secret_value()).
|
||||
|
||||
Raises:
|
||||
ValueError: If the dictionary exceeds size limits
|
||||
"""
|
||||
if secrets is None:
|
||||
return
|
||||
|
||||
# Check number of secrets
|
||||
if len(secrets) > MAX_API_SECRETS_COUNT:
|
||||
raise ValueError(
|
||||
f'Too many secrets provided: {len(secrets)} exceeds maximum of '
|
||||
f'{MAX_API_SECRETS_COUNT}. Configure via OH_MAX_API_SECRETS_COUNT.'
|
||||
)
|
||||
|
||||
# Check individual value lengths
|
||||
for name, value in secrets.items():
|
||||
# Handle both str and SecretStr (Pydantic's SecretStr has get_secret_value())
|
||||
if hasattr(value, 'get_secret_value'):
|
||||
value_str = value.get_secret_value() # type: ignore[union-attr]
|
||||
else:
|
||||
value_str = str(value)
|
||||
value_bytes = len(value_str.encode('utf-8'))
|
||||
if value_bytes > MAX_API_SECRET_VALUE_LENGTH:
|
||||
raise ValueError(
|
||||
f"Secret '{name}' value exceeds maximum length of "
|
||||
f'{MAX_API_SECRET_VALUE_LENGTH} bytes (got {value_bytes}). '
|
||||
f'Configure via OH_MAX_API_SECRET_VALUE_LENGTH.'
|
||||
)
|
||||
|
||||
@@ -29,10 +29,6 @@ from openhands.app_server.config import (
|
||||
)
|
||||
from openhands.app_server.errors import AuthError
|
||||
from openhands.app_server.event.event_service import EventService
|
||||
from openhands.app_server.event_callback.event_callback_models import EventCallback
|
||||
from openhands.app_server.event_callback.set_title_callback_processor import (
|
||||
SetTitleCallbackProcessor,
|
||||
)
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxInfo
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.services.jwt_service import JwtService
|
||||
@@ -207,9 +203,6 @@ async def on_conversation_update(
|
||||
if conversation_info.execution_status == ConversationExecutionStatus.DELETING:
|
||||
return Success()
|
||||
|
||||
# Detect if this is a new conversation (stub has title=None)
|
||||
is_new_conversation = existing.title is None
|
||||
|
||||
# Merge tags from incoming conversation info
|
||||
# SDK can set tags via Conversation(tags=...) which includes automation context
|
||||
merged_tags = merge_conversation_tags(existing.tags, conversation_info.tags)
|
||||
@@ -244,24 +237,6 @@ async def on_conversation_update(
|
||||
app_conversation_info
|
||||
)
|
||||
|
||||
# Register SetTitleCallbackProcessor for new conversations created via webhook.
|
||||
# This enables auto-titling for conversations created directly on the agent-server
|
||||
# (e.g., automation runs) that notify the app-server via webhook.
|
||||
if is_new_conversation:
|
||||
state = InjectorState()
|
||||
setattr(
|
||||
state,
|
||||
USER_CONTEXT_ATTR,
|
||||
SpecifyUserContext(sandbox_info.created_by_user_id),
|
||||
)
|
||||
async with get_event_callback_service(state) as event_callback_service:
|
||||
await event_callback_service.save_event_callback(
|
||||
EventCallback(
|
||||
conversation_id=conversation_info.id,
|
||||
processor=SetTitleCallbackProcessor(),
|
||||
)
|
||||
)
|
||||
|
||||
return Success()
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from openhands.sdk.utils.models import DiscriminatedUnionMixin
|
||||
|
||||
# The version of the agent server to use for deployments.
|
||||
# Typically this will be the same as the values from the pyproject.toml
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.18.1-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:2ecddab-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -6,14 +5,15 @@ import yaml
|
||||
from fastapi import APIRouter, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
import openhands
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.memory.memory import GLOBAL_MICROAGENTS_DIR, USER_MICROAGENTS_DIR
|
||||
|
||||
router = APIRouter(prefix='/skills', tags=['Skills'], dependencies=get_dependencies())
|
||||
|
||||
GLOBAL_SKILLS_DIR = Path(os.path.dirname(openhands.__file__)) / 'skills'
|
||||
USER_SKILLS_DIR = Path.home() / '.openhands' / 'microagents'
|
||||
# Re-use V0 path constants (single source of truth)
|
||||
GLOBAL_SKILLS_DIR = Path(GLOBAL_MICROAGENTS_DIR)
|
||||
USER_SKILLS_DIR = Path(USER_MICROAGENTS_DIR)
|
||||
|
||||
|
||||
class SkillInfo(BaseModel):
|
||||
|
||||
@@ -58,11 +58,6 @@ def _get_maintenance_start_time() -> datetime | None:
|
||||
return None
|
||||
|
||||
|
||||
def _is_gitlab_enabled() -> bool:
|
||||
"""Return whether GitLab OAuth is configured for the web client."""
|
||||
return bool(os.getenv('GITLAB_APP_CLIENT_ID', '').strip())
|
||||
|
||||
|
||||
def _get_providers_configured() -> list[ProviderType]:
|
||||
"""Get configured OAuth providers from environment variables.
|
||||
|
||||
@@ -74,7 +69,7 @@ def _get_providers_configured() -> list[ProviderType]:
|
||||
if os.getenv('GITHUB_APP_CLIENT_ID', '').strip():
|
||||
providers.append(ProviderType.GITHUB)
|
||||
|
||||
if _is_gitlab_enabled():
|
||||
if os.getenv('GITLAB_APP_CLIENT_ID', '').strip():
|
||||
providers.append(ProviderType.GITLAB)
|
||||
|
||||
if os.getenv('BITBUCKET_APP_CLIENT_ID', '').strip():
|
||||
@@ -96,16 +91,6 @@ def _get_github_app_slug() -> str | None:
|
||||
return slug if slug else None
|
||||
|
||||
|
||||
def _get_slack_enabled() -> bool:
|
||||
"""Return whether Slack integration is fully configured for the web client."""
|
||||
return (
|
||||
os.getenv('SLACK_WEBHOOKS_ENABLED', 'false').lower() == 'true'
|
||||
and bool(os.getenv('SLACK_CLIENT_ID', '').strip())
|
||||
and bool(os.getenv('SLACK_CLIENT_SECRET', '').strip())
|
||||
and bool(os.getenv('SLACK_SIGNING_SECRET', '').strip())
|
||||
)
|
||||
|
||||
|
||||
def _get_feature_flags() -> WebClientFeatureFlags:
|
||||
"""Get feature flags from environment variables.
|
||||
|
||||
@@ -148,8 +133,6 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
|
||||
),
|
||||
)
|
||||
github_app_slug: str | None = Field(default_factory=_get_github_app_slug)
|
||||
gitlab_enabled: bool = Field(default_factory=_is_gitlab_enabled)
|
||||
slack_enabled: bool = Field(default_factory=_get_slack_enabled)
|
||||
|
||||
async def get_web_client_config(self) -> WebClientConfig:
|
||||
from openhands.app_server.config import get_global_config
|
||||
@@ -167,7 +150,5 @@ class DefaultWebClientConfigInjector(WebClientConfigInjector):
|
||||
error_message=self.error_message,
|
||||
updated_at=self.updated_at,
|
||||
github_app_slug=self.github_app_slug,
|
||||
gitlab_enabled=self.gitlab_enabled,
|
||||
slack_enabled=self.slack_enabled,
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -42,5 +42,3 @@ class WebClientConfig(DiscriminatedUnionMixin):
|
||||
error_message: str | None
|
||||
updated_at: datetime
|
||||
github_app_slug: str | None
|
||||
gitlab_enabled: bool = False
|
||||
slack_enabled: bool = False
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
# OpenHands Architecture
|
||||
|
||||
Architecture diagrams and explanations for the OpenHands system.
|
||||
|
||||
## Documentation Sections
|
||||
|
||||
- [System Architecture Overview](./system-architecture.md) - Multi-tier architecture and component responsibilities
|
||||
- [Conversation Startup & WebSocket Flow](./conversation-startup.md) - Runtime provisioning and real-time communication
|
||||
- [Agent Execution & LLM Flow](./agent-execution.md) - LLM integration and action execution loop
|
||||
- [Observability](./observability.md) - Logging, metrics, and monitoring
|
||||
@@ -0,0 +1,92 @@
|
||||
# Agent Execution & LLM Flow
|
||||
|
||||
When the agent executes inside the sandbox, it makes LLM calls through LiteLLM:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant AS as Agent Server
|
||||
participant Agent as Agent<br/>(CodeAct)
|
||||
participant LLM as LLM Class
|
||||
participant Lite as LiteLLM
|
||||
participant Proxy as LLM Proxy<br/>(llm-proxy.app.all-hands.dev)
|
||||
participant Provider as LLM Provider<br/>(OpenAI, Anthropic, etc.)
|
||||
participant AES as Action Execution Server
|
||||
|
||||
Note over User,AES: Agent Loop - LLM Call Flow
|
||||
|
||||
User->>AS: WebSocket: User message
|
||||
AS->>Agent: Process message
|
||||
Note over Agent: Build prompt from state
|
||||
|
||||
Agent->>LLM: completion(messages, tools)
|
||||
Note over LLM: Apply config (model, temp, etc.)
|
||||
|
||||
alt Using OpenHands Provider
|
||||
LLM->>Lite: litellm_proxy/{model}
|
||||
Lite->>Proxy: POST /chat/completions
|
||||
Note over Proxy: Auth, rate limit, routing
|
||||
Proxy->>Provider: Forward request
|
||||
Provider-->>Proxy: Response
|
||||
Proxy-->>Lite: Response
|
||||
else Using Direct Provider
|
||||
LLM->>Lite: {provider}/{model}
|
||||
Lite->>Provider: Direct API call
|
||||
Provider-->>Lite: Response
|
||||
end
|
||||
|
||||
Lite-->>LLM: ModelResponse
|
||||
Note over LLM: Track metrics (cost, tokens)
|
||||
LLM-->>Agent: Parsed response
|
||||
|
||||
Note over Agent: Parse action from response
|
||||
AS->>User: WebSocket: Action event
|
||||
|
||||
Note over User,AES: Action Execution
|
||||
|
||||
AS->>AES: HTTP: Execute action
|
||||
Note over AES: Run command/edit file
|
||||
AES-->>AS: Observation
|
||||
AS->>User: WebSocket: Observation event
|
||||
|
||||
Note over Agent: Update state
|
||||
Note over Agent: Loop continues...
|
||||
```
|
||||
|
||||
### LLM Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **LLM Class** | Wrapper with retries, metrics, config | `openhands/llm/llm.py` |
|
||||
| **LiteLLM** | Universal LLM API adapter | External library |
|
||||
| **LLM Proxy** | OpenHands managed proxy for billing/routing | `llm-proxy.app.all-hands.dev` |
|
||||
| **LLM Registry** | Manages multiple LLM instances | `openhands/llm/llm_registry.py` |
|
||||
|
||||
### Model Routing
|
||||
|
||||
```
|
||||
User selects model
|
||||
│
|
||||
▼
|
||||
┌───────────────────┐
|
||||
│ Model prefix? │
|
||||
└───────────────────┘
|
||||
│
|
||||
├── openhands/claude-3-5 ──► Rewrite to litellm_proxy/claude-3-5
|
||||
│ Base URL: llm-proxy.app.all-hands.dev
|
||||
│
|
||||
├── anthropic/claude-3-5 ──► Direct to Anthropic API
|
||||
│ (User's API key)
|
||||
│
|
||||
├── openai/gpt-4 ──► Direct to OpenAI API
|
||||
│ (User's API key)
|
||||
│
|
||||
└── azure/gpt-4 ──► Direct to Azure OpenAI
|
||||
(User's API key + endpoint)
|
||||
```
|
||||
|
||||
### LLM Proxy
|
||||
|
||||
When using `openhands/` prefixed models, requests are routed through a managed proxy.
|
||||
See the [OpenHands documentation](https://docs.openhands.dev/) for details on supported models.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Conversation Startup & WebSocket Flow
|
||||
|
||||
When a user starts a conversation, this sequence occurs:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
autonumber
|
||||
participant User as User (Browser)
|
||||
participant App as App Server
|
||||
participant SS as Sandbox Service
|
||||
participant RAPI as Runtime API
|
||||
participant Pool as Warm Pool
|
||||
participant Sandbox as Sandbox (Container)
|
||||
participant AS as Agent Server
|
||||
participant AES as Action Execution Server
|
||||
|
||||
Note over User,AES: Phase 1: Conversation Creation
|
||||
User->>App: POST /api/conversations
|
||||
Note over App: Authenticate user
|
||||
App->>SS: Create sandbox
|
||||
|
||||
Note over SS,Pool: Phase 2: Runtime Provisioning
|
||||
SS->>RAPI: POST /start (image, env, config)
|
||||
RAPI->>Pool: Check for warm runtime
|
||||
alt Warm runtime available
|
||||
Pool-->>RAPI: Return warm runtime
|
||||
Note over RAPI: Assign to session
|
||||
else No warm runtime
|
||||
RAPI->>Sandbox: Create new container
|
||||
Sandbox->>AS: Start Agent Server
|
||||
Sandbox->>AES: Start Action Execution Server
|
||||
AES-->>AS: Ready
|
||||
end
|
||||
RAPI-->>SS: Runtime URL + session API key
|
||||
SS-->>App: Sandbox info
|
||||
App-->>User: Conversation ID + Sandbox URL
|
||||
|
||||
Note over User,AES: Phase 3: Direct WebSocket Connection
|
||||
User->>AS: WebSocket: /sockets/events/{id}
|
||||
AS-->>User: Connection accepted
|
||||
AS->>User: Replay historical events
|
||||
|
||||
Note over User,AES: Phase 4: User Sends Message
|
||||
User->>AS: WebSocket: SendMessageRequest
|
||||
Note over AS: Agent processes message
|
||||
Note over AS: LLM call → generate action
|
||||
|
||||
Note over User,AES: Phase 5: Action Execution Loop
|
||||
loop Agent Loop
|
||||
AS->>AES: HTTP: Execute action
|
||||
Note over AES: Run in sandbox
|
||||
AES-->>AS: Observation result
|
||||
AS->>User: WebSocket: Event update
|
||||
Note over AS: Update state, next action
|
||||
end
|
||||
|
||||
Note over User,AES: Phase 6: Task Complete
|
||||
AS->>User: WebSocket: AgentStateChanged (FINISHED)
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
1. **Initial Setup via App Server**: The App Server handles authentication and coordinates with the Sandbox Service
|
||||
2. **Runtime API Provisioning**: The Sandbox Service calls the Runtime API, which checks for warm runtimes before creating new containers
|
||||
3. **Warm Pool Optimization**: Pre-warmed runtimes reduce startup latency significantly
|
||||
4. **Direct WebSocket to Sandbox**: Once created, the user's browser connects **directly** to the Agent Server inside the sandbox
|
||||
5. **App Server Not in Hot Path**: After connection, all real-time communication bypasses the App Server entirely
|
||||
6. **Agent Server Orchestrates**: The Agent Server manages the AI loop, calling the Action Execution Server for actual command execution
|
||||
@@ -0,0 +1,85 @@
|
||||
# Observability
|
||||
|
||||
OpenHands provides structured logging and metrics collection for monitoring and debugging.
|
||||
|
||||
> **SDK Documentation**: For detailed guidance on observability and metrics in agent development, see:
|
||||
> - [SDK Observability Guide](https://docs.openhands.dev/sdk/guides/observability)
|
||||
> - [SDK Metrics Guide](https://docs.openhands.dev/sdk/guides/metrics)
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
subgraph Sources["Sources"]
|
||||
Agent["Agent Server"]
|
||||
App["App Server"]
|
||||
Frontend["Frontend"]
|
||||
end
|
||||
|
||||
subgraph Collection["Collection"]
|
||||
JSONLog["JSON Logs<br/>(stdout)"]
|
||||
Metrics["Metrics<br/>(Internal)"]
|
||||
end
|
||||
|
||||
subgraph External["External (Optional)"]
|
||||
LogAgg["Log Aggregator"]
|
||||
Analytics["Analytics Service"]
|
||||
end
|
||||
|
||||
Agent --> JSONLog
|
||||
App --> JSONLog
|
||||
App --> Metrics
|
||||
|
||||
JSONLog --> LogAgg
|
||||
Frontend --> Analytics
|
||||
```
|
||||
|
||||
### Structured Logging
|
||||
|
||||
OpenHands uses Python's standard logging library with structured JSON output support.
|
||||
|
||||
| Component | Format | Destination | Purpose |
|
||||
|-----------|--------|-------------|---------|
|
||||
| **Application Logs** | JSON (when `LOG_JSON=1`) | stdout | Debugging, error tracking |
|
||||
| **Access Logs** | JSON (Uvicorn) | stdout | Request tracing |
|
||||
| **LLM Debug Logs** | Plain text | File (optional) | LLM call debugging |
|
||||
|
||||
### JSON Log Format
|
||||
|
||||
When `LOG_JSON=1` is set, logs are emitted as single-line JSON for ingestion by log aggregators:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Conversation started",
|
||||
"severity": "INFO",
|
||||
"conversation_id": "abc-123",
|
||||
"user_id": "user-456",
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Additional context can be added using Python's logger `extra=` parameter (see [Python logging docs](https://docs.python.org/3/library/logging.html)).
|
||||
|
||||
### Metrics
|
||||
|
||||
| Metric | Tracked By | Storage | Purpose |
|
||||
|--------|------------|---------|---------|
|
||||
| **LLM Cost** | `Metrics` class | Conversation stats file | Billing, budget limits |
|
||||
| **Token Usage** | `Metrics` class | Conversation stats file | Usage analytics |
|
||||
| **Response Latency** | `Metrics` class | Conversation stats file | Performance monitoring |
|
||||
|
||||
### Conversation Stats Persistence
|
||||
|
||||
Per-conversation metrics are persisted for analytics:
|
||||
|
||||
```python
|
||||
# Location: openhands/server/services/conversation_stats.py
|
||||
ConversationStats:
|
||||
- service_to_metrics: Dict[str, Metrics]
|
||||
- accumulated_cost: float
|
||||
- token_usage: TokenUsage
|
||||
|
||||
# Stored at: {file_store}/conversation_stats/{conversation_id}.pkl
|
||||
```
|
||||
|
||||
### Integration with External Services
|
||||
|
||||
Structured JSON logging allows integration with any log aggregation service (e.g., ELK Stack, Loki, Splunk). Configure your log collector to ingest from container stdout/stderr.
|
||||
@@ -0,0 +1,88 @@
|
||||
# System Architecture Overview
|
||||
|
||||
OpenHands supports multiple deployment configurations. This document describes the core components and how they interact.
|
||||
|
||||
## Local/Docker Deployment
|
||||
|
||||
The simplest deployment runs everything locally or in Docker containers:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph Server["OpenHands Server"]
|
||||
API["REST API<br/>(FastAPI)"]
|
||||
ConvMgr["Conversation<br/>Manager"]
|
||||
Runtime["Runtime<br/>Manager"]
|
||||
end
|
||||
|
||||
subgraph Sandbox["Sandbox (Docker Container)"]
|
||||
AES["Action Execution<br/>Server"]
|
||||
Browser["Browser<br/>Environment"]
|
||||
FS["File System"]
|
||||
end
|
||||
|
||||
User["User"] -->|"HTTP/WebSocket"| API
|
||||
API --> ConvMgr
|
||||
ConvMgr --> Runtime
|
||||
Runtime -->|"Provision"| Sandbox
|
||||
|
||||
Server -->|"Execute actions"| AES
|
||||
AES --> Browser
|
||||
AES --> FS
|
||||
```
|
||||
|
||||
### Core Components
|
||||
|
||||
| Component | Purpose | Location |
|
||||
|-----------|---------|----------|
|
||||
| **Server** | REST API, conversation management, runtime orchestration | `openhands/server/` |
|
||||
| **Runtime** | Abstract interface for sandbox execution | `openhands/runtime/` |
|
||||
| **Action Execution Server** | Execute bash, file ops, browser actions | Inside sandbox |
|
||||
| **EventStream** | Central event bus for all communication | `openhands/events/` |
|
||||
|
||||
## Scalable Deployment
|
||||
|
||||
For production deployments, OpenHands can be configured with a separate Runtime API service:
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph AppServer["App Server"]
|
||||
API["REST API"]
|
||||
ConvMgr["Conversation<br/>Manager"]
|
||||
end
|
||||
|
||||
subgraph RuntimeAPI["Runtime API (Optional)"]
|
||||
RuntimeMgr["Runtime<br/>Manager"]
|
||||
WarmPool["Warm Pool"]
|
||||
end
|
||||
|
||||
subgraph Sandbox["Sandbox"]
|
||||
AS["Agent Server"]
|
||||
AES["Action Execution<br/>Server"]
|
||||
end
|
||||
|
||||
User["User"] -->|"HTTP"| API
|
||||
API --> ConvMgr
|
||||
ConvMgr -->|"Provision"| RuntimeMgr
|
||||
RuntimeMgr --> WarmPool
|
||||
RuntimeMgr --> Sandbox
|
||||
|
||||
User -.->|"WebSocket"| AS
|
||||
AS -->|"HTTP"| AES
|
||||
```
|
||||
|
||||
This configuration enables:
|
||||
- **Warm pool**: Pre-provisioned runtimes for faster startup
|
||||
- **Direct WebSocket**: Users connect directly to their sandbox, bypassing the App Server
|
||||
- **Horizontal scaling**: App Server and Runtime API can scale independently
|
||||
|
||||
### Runtime Options
|
||||
|
||||
OpenHands supports multiple runtime implementations:
|
||||
|
||||
| Runtime | Use Case |
|
||||
|---------|----------|
|
||||
| **DockerRuntime** | Local development, single-machine deployments |
|
||||
| **RemoteRuntime** | Connect to externally managed sandboxes |
|
||||
| **ModalRuntime** | Serverless execution via Modal |
|
||||
|
||||
See the [Runtime documentation](https://docs.openhands.dev/usage/architecture/runtime) for details.
|
||||
@@ -0,0 +1,5 @@
|
||||
from openhands.controller.agent_controller import AgentController
|
||||
|
||||
__all__ = [
|
||||
'AgentController',
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from openhands.events.action import Action
|
||||
|
||||
|
||||
class ActionParseError(Exception):
|
||||
"""Exception raised when the response from the LLM cannot be parsed into an action."""
|
||||
|
||||
def __init__(self, error: str):
|
||||
self.error = error
|
||||
|
||||
def __str__(self) -> str:
|
||||
return self.error
|
||||
|
||||
|
||||
class ResponseParser(ABC):
|
||||
"""This abstract base class is a general interface for an response parser dedicated to
|
||||
parsing the action from the response from the LLM.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
) -> None:
|
||||
# Need pay attention to the item order in self.action_parsers
|
||||
self.action_parsers: list[ActionParser] = []
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, response: Any) -> Action:
|
||||
"""Parses the action from the response from the LLM.
|
||||
|
||||
Parameters:
|
||||
- response: The response from the LLM, which can be a string or a dictionary.
|
||||
|
||||
Returns:
|
||||
- action (Action): The action parsed from the response.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_response(self, response: Any) -> str:
|
||||
"""Parses the action from the response from the LLM.
|
||||
|
||||
Parameters:
|
||||
- response: The response from the LLM, which can be a string or a dictionary.
|
||||
|
||||
Returns:
|
||||
- action_str (str): The action str parsed from the response.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse_action(self, action_str: str) -> Action:
|
||||
"""Parses the action from the response from the LLM.
|
||||
|
||||
Parameters:
|
||||
- action_str (str): The response from the LLM.
|
||||
|
||||
Returns:
|
||||
- action (Action): The action parsed from the response.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class ActionParser(ABC):
|
||||
"""This abstract base class is a general interface for an action parser dedicated to
|
||||
parsing the action from the action str from the LLM.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def check_condition(self, action_str: str) -> bool:
|
||||
"""Check if the action string can be parsed by this parser."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, action_str: str) -> Action:
|
||||
"""Parses the action from the action string from the LLM response."""
|
||||
pass
|
||||
@@ -0,0 +1,191 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
# V1 replacement for this module lives in the Software Agent SDK.
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.events.action import Action
|
||||
from openhands.events.action.message import SystemMessageAction
|
||||
from openhands.utils.prompt import PromptManager
|
||||
from litellm import ChatCompletionToolParam
|
||||
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.exceptions import (
|
||||
AgentAlreadyRegisteredError,
|
||||
AgentNotRegisteredError,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
|
||||
|
||||
class Agent(ABC):
|
||||
DEPRECATED = False
|
||||
"""
|
||||
This abstract base class is an general interface for an agent dedicated to
|
||||
executing a specific instruction and allowing human interaction with the
|
||||
agent during execution.
|
||||
It tracks the execution status and maintains a history of interactions.
|
||||
"""
|
||||
|
||||
_registry: dict[str, type['Agent']] = {}
|
||||
sandbox_plugins: list[PluginRequirement] = []
|
||||
|
||||
config_model: type[AgentConfig] = AgentConfig
|
||||
"""Class field that specifies the config model to use for the agent. Subclasses may override with a derived config model if needed."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: AgentConfig,
|
||||
llm_registry: LLMRegistry,
|
||||
):
|
||||
self.llm = llm_registry.get_llm_from_agent_config('agent', config)
|
||||
self.llm_registry = llm_registry
|
||||
self.config = config
|
||||
self._complete = False
|
||||
self._prompt_manager: 'PromptManager' | None = None
|
||||
self.mcp_tools: dict[str, ChatCompletionToolParam] = {}
|
||||
self.tools: list = []
|
||||
|
||||
@property
|
||||
def prompt_manager(self) -> 'PromptManager':
|
||||
if self._prompt_manager is None:
|
||||
raise ValueError(f'Prompt manager not initialized for agent {self.name}')
|
||||
return self._prompt_manager
|
||||
|
||||
def get_system_message(self) -> 'SystemMessageAction | None':
|
||||
"""Returns a SystemMessageAction containing the system message and tools.
|
||||
This will be added to the event stream as the first message.
|
||||
|
||||
Returns:
|
||||
SystemMessageAction: The system message action with content and tools
|
||||
None: If there was an error generating the system message
|
||||
"""
|
||||
# Import here to avoid circular imports
|
||||
from openhands.events.action.message import SystemMessageAction
|
||||
|
||||
try:
|
||||
if not self.prompt_manager:
|
||||
logger.warning(
|
||||
f'[{self.name}] Prompt manager not initialized before getting system message'
|
||||
)
|
||||
return None
|
||||
|
||||
system_message = self.prompt_manager.get_system_message(
|
||||
cli_mode=self.config.cli_mode
|
||||
)
|
||||
|
||||
# Get tools if available
|
||||
tools = getattr(self, 'tools', None)
|
||||
|
||||
system_message_action = SystemMessageAction(
|
||||
content=system_message, tools=tools, agent_class=self.name
|
||||
)
|
||||
# Set the source attribute
|
||||
system_message_action._source = EventSource.AGENT # type: ignore
|
||||
|
||||
return system_message_action
|
||||
except Exception as e:
|
||||
logger.warning(f'[{self.name}] Failed to generate system message: {e}')
|
||||
return None
|
||||
|
||||
@property
|
||||
def complete(self) -> bool:
|
||||
"""Indicates whether the current instruction execution is complete.
|
||||
|
||||
Returns:
|
||||
- complete (bool): True if execution is complete; False otherwise.
|
||||
"""
|
||||
return self._complete
|
||||
|
||||
@abstractmethod
|
||||
def step(self, state: 'State') -> 'Action':
|
||||
"""Starts the execution of the assigned instruction. This method should
|
||||
be implemented by subclasses to define the specific execution logic.
|
||||
"""
|
||||
pass
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Resets the agent's execution status."""
|
||||
# Only reset the completion status, not the LLM metrics
|
||||
self._complete = False
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.__class__.__name__
|
||||
|
||||
@classmethod
|
||||
def register(cls, name: str, agent_cls: type['Agent']) -> None:
|
||||
"""Registers an agent class in the registry.
|
||||
|
||||
Parameters:
|
||||
- name (str): The name to register the class under.
|
||||
- agent_cls (Type['Agent']): The class to register.
|
||||
|
||||
Raises:
|
||||
- AgentAlreadyRegisteredError: If name already registered
|
||||
"""
|
||||
if name in cls._registry:
|
||||
raise AgentAlreadyRegisteredError(name)
|
||||
cls._registry[name] = agent_cls
|
||||
|
||||
@classmethod
|
||||
def get_cls(cls, name: str) -> type['Agent']:
|
||||
"""Retrieves an agent class from the registry.
|
||||
|
||||
Parameters:
|
||||
- name (str): The name of the class to retrieve
|
||||
|
||||
Returns:
|
||||
- agent_cls (Type['Agent']): The class registered under the specified name.
|
||||
|
||||
Raises:
|
||||
- AgentNotRegisteredError: If name not registered
|
||||
"""
|
||||
if name not in cls._registry:
|
||||
raise AgentNotRegisteredError(name)
|
||||
return cls._registry[name]
|
||||
|
||||
@classmethod
|
||||
def list_agents(cls) -> list[str]:
|
||||
"""Retrieves the list of all agent names from the registry.
|
||||
|
||||
Raises:
|
||||
- AgentNotRegisteredError: If no agent is registered
|
||||
"""
|
||||
if not bool(cls._registry):
|
||||
raise AgentNotRegisteredError()
|
||||
return list(cls._registry.keys())
|
||||
|
||||
def set_mcp_tools(self, mcp_tools: list[dict]) -> None:
|
||||
"""Sets the list of MCP tools for the agent.
|
||||
|
||||
Args:
|
||||
- mcp_tools (list[dict]): The list of MCP tools.
|
||||
"""
|
||||
logger.info(
|
||||
f'Setting {len(mcp_tools)} MCP tools for agent {self.name}: {[tool["function"]["name"] for tool in mcp_tools]}'
|
||||
)
|
||||
for tool in mcp_tools:
|
||||
_tool = ChatCompletionToolParam(**tool)
|
||||
if _tool['function']['name'] in self.mcp_tools:
|
||||
logger.warning(
|
||||
f'Tool {_tool["function"]["name"]} already exists, skipping'
|
||||
)
|
||||
continue
|
||||
self.mcp_tools[_tool['function']['name']] = _tool
|
||||
self.tools.append(_tool)
|
||||
logger.info(
|
||||
f'Tools updated for agent {self.name}, total {len(self.tools)}: {[tool["function"]["name"] for tool in self.tools]}'
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.event import Event, EventSource
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.serialization.event import event_from_dict
|
||||
|
||||
|
||||
class ReplayManager:
|
||||
"""ReplayManager manages the lifecycle of a replay session of a given trajectory.
|
||||
|
||||
Replay manager keeps track of a list of events, replays actions, and ignore
|
||||
messages and observations.
|
||||
|
||||
Note that unexpected or even errorneous results could happen if
|
||||
1) any action is non-deterministic, OR
|
||||
2) if the initial state before the replay session is different from the
|
||||
initial state of the trajectory.
|
||||
"""
|
||||
|
||||
def __init__(self, events: list[Event] | None):
|
||||
replay_events = []
|
||||
for event in events or []:
|
||||
if event.source == EventSource.ENVIRONMENT:
|
||||
# ignore ENVIRONMENT events as they are not issued by
|
||||
# the user or agent, and should not be replayed
|
||||
continue
|
||||
if isinstance(event, NullObservation):
|
||||
# ignore NullObservation
|
||||
continue
|
||||
replay_events.append(event)
|
||||
|
||||
if replay_events:
|
||||
logger.info(f'Replay events loaded, events length = {len(replay_events)}')
|
||||
for index in range(len(replay_events) - 1):
|
||||
event = replay_events[index]
|
||||
if isinstance(event, MessageAction) and event.wait_for_response:
|
||||
# For any message waiting for response that is not the last
|
||||
# event, we override wait_for_response to False, as a response
|
||||
# would have been included in the next event, and we don't
|
||||
# want the user to interfere with the replay process
|
||||
logger.info(
|
||||
'Replay events contains wait_for_response message action, ignoring wait_for_response'
|
||||
)
|
||||
event.wait_for_response = False
|
||||
self.replay_events = replay_events
|
||||
self.replay_mode = bool(replay_events)
|
||||
self.replay_index = 0
|
||||
|
||||
def _replayable(self) -> bool:
|
||||
return (
|
||||
self.replay_events is not None
|
||||
and self.replay_index < len(self.replay_events)
|
||||
and isinstance(self.replay_events[self.replay_index], Action)
|
||||
)
|
||||
|
||||
def should_replay(self) -> bool:
|
||||
"""Whether the controller is in trajectory replay mode, and the replay
|
||||
hasn't finished. Note: after the replay is finished, the user and
|
||||
the agent could continue to message/act.
|
||||
|
||||
This method also moves "replay_index" to the next action, if applicable.
|
||||
"""
|
||||
if not self.replay_mode:
|
||||
return False
|
||||
|
||||
assert self.replay_events is not None
|
||||
while self.replay_index < len(self.replay_events) and not self._replayable():
|
||||
self.replay_index += 1
|
||||
|
||||
return self._replayable()
|
||||
|
||||
def step(self) -> Action:
|
||||
assert self.replay_events is not None
|
||||
event = self.replay_events[self.replay_index]
|
||||
assert isinstance(event, Action)
|
||||
self.replay_index += 1
|
||||
return event
|
||||
|
||||
@staticmethod
|
||||
def get_replay_events(trajectory: list[dict]) -> list[Event]:
|
||||
if not isinstance(trajectory, list):
|
||||
raise ValueError(
|
||||
f'Expected a list in {trajectory}, got {type(trajectory).__name__}'
|
||||
)
|
||||
replay_events = []
|
||||
for item in trajectory:
|
||||
event = event_from_dict(item)
|
||||
if event.source == EventSource.ENVIRONMENT:
|
||||
# ignore ENVIRONMENT events as they are not issued by
|
||||
# the user or agent, and should not be replayed
|
||||
continue
|
||||
# cannot add an event with _id to event stream
|
||||
event._id = None # type: ignore[attr-defined]
|
||||
replay_events.append(event)
|
||||
return replay_events
|
||||
@@ -0,0 +1,102 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Generic, TypeVar
|
||||
|
||||
T = TypeVar(
|
||||
'T', int, float
|
||||
) # Type for the value (int for iterations, float for budget)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ControlFlag(Generic[T]):
|
||||
"""Base class for control flags that manage limits and state transitions."""
|
||||
|
||||
limit_increase_amount: T
|
||||
current_value: T
|
||||
max_value: T
|
||||
headless_mode: bool = False
|
||||
_hit_limit: bool = False
|
||||
|
||||
def reached_limit(self) -> bool:
|
||||
"""Check if the limit has been reached.
|
||||
|
||||
Returns:
|
||||
bool: True if the limit has been reached, False otherwise.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def increase_limit(self, headless_mode: bool) -> None:
|
||||
"""Expand the limit when needed."""
|
||||
raise NotImplementedError
|
||||
|
||||
def step(self):
|
||||
"""Determine the next state based on the current state and mode.
|
||||
|
||||
Returns:
|
||||
ControlFlagState: The next state.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@dataclass
|
||||
class IterationControlFlag(ControlFlag[int]):
|
||||
"""Control flag for managing iteration limits."""
|
||||
|
||||
def reached_limit(self) -> bool:
|
||||
"""Check if the iteration limit has been reached."""
|
||||
self._hit_limit = self.current_value >= self.max_value
|
||||
return self._hit_limit
|
||||
|
||||
def increase_limit(self, headless_mode: bool) -> None:
|
||||
"""Expand the iteration limit by adding the initial value."""
|
||||
if not headless_mode and self._hit_limit:
|
||||
self.max_value += self.limit_increase_amount
|
||||
self._hit_limit = False
|
||||
|
||||
def step(self):
|
||||
if self.reached_limit():
|
||||
raise RuntimeError(
|
||||
f'Agent reached maximum iteration. '
|
||||
f'Current iteration: {self.current_value}, max iteration: {self.max_value}'
|
||||
)
|
||||
|
||||
# Increment the current value
|
||||
self.current_value += 1
|
||||
|
||||
|
||||
@dataclass
|
||||
class BudgetControlFlag(ControlFlag[float]):
|
||||
"""Control flag for managing budget limits."""
|
||||
|
||||
def reached_limit(self) -> bool:
|
||||
"""Check if the budget limit has been reached."""
|
||||
self._hit_limit = self.current_value >= self.max_value
|
||||
return self._hit_limit
|
||||
|
||||
def increase_limit(self, headless_mode) -> None:
|
||||
"""Expand the budget limit by adding the initial value to the current value."""
|
||||
if self._hit_limit:
|
||||
self.max_value = self.current_value + self.limit_increase_amount
|
||||
self._hit_limit = False
|
||||
|
||||
def step(self):
|
||||
"""Check if we've reached the limit and update state accordingly.
|
||||
|
||||
Note: Unlike IterationControlFlag, this doesn't increment the value
|
||||
as the budget is updated externally.
|
||||
"""
|
||||
if self.reached_limit():
|
||||
current_str = f'{self.current_value:.2f}'
|
||||
max_str = f'{self.max_value:.2f}'
|
||||
raise RuntimeError(
|
||||
f'Agent reached maximum budget for conversation.'
|
||||
f'Current budget: {current_str}, max budget: {max_str}'
|
||||
)
|
||||
@@ -0,0 +1,318 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
import pickle
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
import openhands
|
||||
from openhands.controller.state.control_flags import (
|
||||
BudgetControlFlag,
|
||||
IterationControlFlag,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.events.action import (
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.action.agent import AgentFinishAction
|
||||
from openhands.events.event import Event, EventSource
|
||||
from openhands.llm.metrics import Metrics
|
||||
from openhands.memory.view import View
|
||||
from openhands.server.services.conversation_stats import ConversationStats
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import get_conversation_agent_state_filename
|
||||
|
||||
RESUMABLE_STATES = [
|
||||
AgentState.RUNNING,
|
||||
AgentState.PAUSED,
|
||||
AgentState.AWAITING_USER_INPUT,
|
||||
AgentState.FINISHED,
|
||||
]
|
||||
|
||||
|
||||
# NOTE: this is deprecated
|
||||
class TrafficControlState(str, Enum):
|
||||
# default state, no rate limiting
|
||||
NORMAL = 'normal'
|
||||
|
||||
# task paused due to traffic control
|
||||
THROTTLING = 'throttling'
|
||||
|
||||
# traffic control is temporarily paused
|
||||
PAUSED = 'paused'
|
||||
|
||||
|
||||
@dataclass
|
||||
class State:
|
||||
"""Represents the running state of an agent in the OpenHands system, saving data of its operation and memory.
|
||||
|
||||
- Multi-agent/delegate state:
|
||||
- store the task (conversation between the agent and the user)
|
||||
- the subtask (conversation between an agent and the user or another agent)
|
||||
- global and local iterations
|
||||
- delegate levels for multi-agent interactions
|
||||
- almost stuck state
|
||||
|
||||
- Running state of an agent:
|
||||
- current agent state (e.g., LOADING, RUNNING, PAUSED)
|
||||
- traffic control state for rate limiting
|
||||
- confirmation mode
|
||||
- the last error encountered
|
||||
|
||||
- Data for saving and restoring the agent:
|
||||
- save to and restore from a session
|
||||
- serialize with pickle and base64
|
||||
|
||||
- Save / restore data about message history
|
||||
- start and end IDs for events in agent's history
|
||||
- summaries and delegate summaries
|
||||
|
||||
- Metrics:
|
||||
- global metrics for the current task
|
||||
- local metrics for the current subtask
|
||||
|
||||
- Extra data:
|
||||
- additional task-specific data
|
||||
"""
|
||||
|
||||
session_id: str = ''
|
||||
user_id: str | None = None
|
||||
iteration_flag: IterationControlFlag = field(
|
||||
default_factory=lambda: IterationControlFlag(
|
||||
limit_increase_amount=100, current_value=0, max_value=100
|
||||
)
|
||||
)
|
||||
conversation_stats: ConversationStats | None = None
|
||||
budget_flag: BudgetControlFlag | None = None
|
||||
confirmation_mode: bool = False
|
||||
history: list[Event] = field(default_factory=list)
|
||||
inputs: dict = field(default_factory=dict)
|
||||
outputs: dict = field(default_factory=dict)
|
||||
agent_state: AgentState = AgentState.LOADING
|
||||
resume_state: AgentState | None = None
|
||||
|
||||
# root agent has level 0, and every delegate increases the level by one
|
||||
delegate_level: int = 0
|
||||
# start_id and end_id track the range of events in history
|
||||
start_id: int = -1
|
||||
end_id: int = -1
|
||||
|
||||
parent_metrics_snapshot: Metrics | None = None
|
||||
parent_iteration: int = 100
|
||||
|
||||
# NOTE: this is used by the controller to track parent's metrics snapshot before delegation
|
||||
# evaluation tasks to store extra data needed to track the progress/state of the task.
|
||||
extra_data: dict[str, Any] = field(default_factory=dict)
|
||||
last_error: str = ''
|
||||
|
||||
# NOTE: deprecated args, kept here temporarily for backwards compatability
|
||||
# Will be remove in 30 days
|
||||
iteration: int | None = None
|
||||
local_iteration: int | None = None
|
||||
max_iterations: int | None = None
|
||||
traffic_control_state: TrafficControlState | None = None
|
||||
local_metrics: Metrics | None = None
|
||||
delegates: dict[tuple[int, int], tuple[str, str]] | None = None
|
||||
|
||||
metrics: Metrics = field(default_factory=Metrics)
|
||||
|
||||
def save_to_session(
|
||||
self, sid: str, file_store: FileStore, user_id: str | None
|
||||
) -> None:
|
||||
conversation_stats = self.conversation_stats
|
||||
self.conversation_stats = None # Don't save conversation stats, handles itself
|
||||
|
||||
pickled = pickle.dumps(self)
|
||||
logger.debug(f'Saving state to session {sid}:{self.agent_state}')
|
||||
encoded = base64.b64encode(pickled).decode('utf-8')
|
||||
try:
|
||||
file_store.write(
|
||||
get_conversation_agent_state_filename(sid, user_id), encoded
|
||||
)
|
||||
|
||||
# see if state is in the old directory on saas/remote use cases and delete it.
|
||||
if user_id:
|
||||
filename = get_conversation_agent_state_filename(sid)
|
||||
try:
|
||||
file_store.delete(filename)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to save state to session: {e}')
|
||||
raise e
|
||||
|
||||
self.conversation_stats = conversation_stats # restore reference
|
||||
|
||||
@staticmethod
|
||||
def restore_from_session(
|
||||
sid: str, file_store: FileStore, user_id: str | None = None
|
||||
) -> 'State':
|
||||
"""Restores the state from the previously saved session."""
|
||||
state: State
|
||||
try:
|
||||
encoded = file_store.read(
|
||||
get_conversation_agent_state_filename(sid, user_id)
|
||||
)
|
||||
pickled = base64.b64decode(encoded)
|
||||
state = pickle.loads(pickled)
|
||||
except FileNotFoundError:
|
||||
# if user_id is provided, we are in a saas/remote use case
|
||||
# and we need to check if the state is in the old directory.
|
||||
if user_id:
|
||||
filename = get_conversation_agent_state_filename(sid)
|
||||
encoded = file_store.read(filename)
|
||||
pickled = base64.b64decode(encoded)
|
||||
state = pickle.loads(pickled)
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f'Could not restore state from session file for sid: {sid}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not restore state from session: {e}')
|
||||
raise e
|
||||
|
||||
# update state
|
||||
if state.agent_state in RESUMABLE_STATES:
|
||||
state.resume_state = state.agent_state
|
||||
else:
|
||||
state.resume_state = None
|
||||
|
||||
# first state after restore
|
||||
state.agent_state = AgentState.LOADING
|
||||
|
||||
# We don't need to clean up deprecated fields here
|
||||
# They will be handled by __getstate__ when the state is saved again
|
||||
|
||||
return state
|
||||
|
||||
def __getstate__(self) -> dict:
|
||||
# don't pickle history, it will be restored from the event stream
|
||||
state = self.__dict__.copy()
|
||||
state['history'] = []
|
||||
|
||||
# Remove any view caching attributes. They'll be rebuilt frmo the
|
||||
# history after that gets reloaded.
|
||||
state.pop('_history_checksum', None)
|
||||
state.pop('_view', None)
|
||||
|
||||
# Remove deprecated fields before pickling
|
||||
state.pop('iteration', None)
|
||||
state.pop('local_iteration', None)
|
||||
state.pop('max_iterations', None)
|
||||
state.pop('traffic_control_state', None)
|
||||
state.pop('local_metrics', None)
|
||||
state.pop('delegates', None)
|
||||
|
||||
return state
|
||||
|
||||
def __setstate__(self, state: dict) -> None:
|
||||
# Check if we're restoring from an older version (before control flags)
|
||||
is_old_version = 'iteration' in state
|
||||
|
||||
# Convert old iteration tracking to new iteration_flag if needed
|
||||
if is_old_version:
|
||||
# Create iteration_flag from old values
|
||||
max_iterations = state.get('max_iterations', 100)
|
||||
current_iteration = state.get('iteration', 0)
|
||||
|
||||
# Add the iteration_flag to the state
|
||||
state['iteration_flag'] = IterationControlFlag(
|
||||
limit_increase_amount=max_iterations,
|
||||
current_value=current_iteration,
|
||||
max_value=max_iterations,
|
||||
)
|
||||
|
||||
# Update the state
|
||||
self.__dict__.update(state)
|
||||
|
||||
# We keep the deprecated fields for backward compatibility
|
||||
# They will be removed by __getstate__ when the state is saved again
|
||||
|
||||
# make sure we always have the attribute history
|
||||
if not hasattr(self, 'history'):
|
||||
self.history = []
|
||||
|
||||
# Ensure we have default values for new fields if they're missing
|
||||
if not hasattr(self, 'iteration_flag'):
|
||||
self.iteration_flag = IterationControlFlag(
|
||||
limit_increase_amount=100, current_value=0, max_value=100
|
||||
)
|
||||
|
||||
if not hasattr(self, 'budget_flag'):
|
||||
self.budget_flag = None
|
||||
|
||||
def get_current_user_intent(self) -> tuple[str | None, list[str] | None]:
|
||||
"""Returns the latest user message and image(if provided) that appears after a FinishAction, or the first (the task) if nothing was finished yet."""
|
||||
last_user_message = None
|
||||
last_user_message_image_urls: list[str] | None = []
|
||||
for event in reversed(self.view):
|
||||
if isinstance(event, MessageAction) and event.source == 'user':
|
||||
last_user_message = event.content
|
||||
last_user_message_image_urls = event.image_urls
|
||||
elif isinstance(event, AgentFinishAction):
|
||||
if last_user_message is not None:
|
||||
return last_user_message, None
|
||||
|
||||
return last_user_message, last_user_message_image_urls
|
||||
|
||||
def get_last_agent_message(self) -> MessageAction | None:
|
||||
for event in reversed(self.view):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.AGENT:
|
||||
return event
|
||||
return None
|
||||
|
||||
def get_last_user_message(self) -> MessageAction | None:
|
||||
for event in reversed(self.view):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
return event
|
||||
return None
|
||||
|
||||
def to_llm_metadata(self, model_name: str, agent_name: str) -> dict:
|
||||
metadata = {
|
||||
'session_id': self.session_id,
|
||||
'trace_version': openhands.__version__,
|
||||
'trace_user_id': self.user_id,
|
||||
'tags': [
|
||||
f'model:{model_name}',
|
||||
f'agent:{agent_name}',
|
||||
f'web_host:{os.environ.get("WEB_HOST", "unspecified")}',
|
||||
f'openhands_version:{openhands.__version__}',
|
||||
],
|
||||
}
|
||||
return metadata
|
||||
|
||||
def get_local_step(self):
|
||||
if not self.parent_iteration:
|
||||
return self.iteration_flag.current_value
|
||||
|
||||
return self.iteration_flag.current_value - self.parent_iteration
|
||||
|
||||
def get_local_metrics(self):
|
||||
if not self.parent_metrics_snapshot:
|
||||
return self.metrics
|
||||
return self.metrics.diff(self.parent_metrics_snapshot)
|
||||
|
||||
@property
|
||||
def view(self) -> View:
|
||||
# Compute a simple checksum from the history to see if we can re-use any
|
||||
# cached view.
|
||||
history_checksum = len(self.history)
|
||||
old_history_checksum = getattr(self, '_history_checksum', -1)
|
||||
|
||||
# If the history has changed, we need to re-create the view and update
|
||||
# the caching.
|
||||
if history_checksum != old_history_checksum:
|
||||
self._history_checksum = history_checksum
|
||||
self._view = View.from_events(self.history)
|
||||
|
||||
return self._view
|
||||
@@ -0,0 +1,275 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from openhands.controller.state.control_flags import (
|
||||
BudgetControlFlag,
|
||||
IterationControlFlag,
|
||||
)
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.agent import AgentDelegateAction, ChangeAgentStateAction
|
||||
from openhands.events.action.empty import NullAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.observation.delegate import AgentDelegateObservation
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.serialization.event import event_to_trajectory
|
||||
from openhands.events.stream import EventStream
|
||||
from openhands.server.services.conversation_stats import ConversationStats
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
|
||||
class StateTracker:
|
||||
"""Manages and synchronizes the state of an agent throughout its lifecycle.
|
||||
|
||||
It is responsible for:
|
||||
1. Maintaining agent state persistence across sessions
|
||||
2. Managing agent history by filtering and tracking relevant events (previously done in the agent controller)
|
||||
3. Synchronizing metrics between the controller and LLM components
|
||||
4. Updating control flags for budget and iteration limits
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, sid: str | None, file_store: FileStore | None, user_id: str | None
|
||||
):
|
||||
self.sid = sid
|
||||
self.file_store = file_store
|
||||
self.user_id = user_id
|
||||
|
||||
# filter out events that are not relevant to the agent
|
||||
# so they will not be included in the agent history
|
||||
self.agent_history_filter = EventFilter(
|
||||
exclude_types=(
|
||||
NullAction,
|
||||
NullObservation,
|
||||
ChangeAgentStateAction,
|
||||
AgentStateChangedObservation,
|
||||
),
|
||||
exclude_hidden=True,
|
||||
)
|
||||
|
||||
def set_initial_state(
|
||||
self,
|
||||
id: str,
|
||||
state: State | None,
|
||||
conversation_stats: ConversationStats,
|
||||
max_iterations: int,
|
||||
max_budget_per_task: float | None,
|
||||
confirmation_mode: bool = False,
|
||||
) -> None:
|
||||
"""Sets the initial state for the agent, either from the previous session, or from a parent agent, or by creating a new one.
|
||||
|
||||
Args:
|
||||
state: The state to initialize with, or None to create a new state.
|
||||
max_iterations: The maximum number of iterations allowed for the task.
|
||||
confirmation_mode: Whether to enable confirmation mode.
|
||||
"""
|
||||
# state can come from:
|
||||
# - the previous session, in which case it has history
|
||||
# - from a parent agent, in which case it has no history
|
||||
# - None / a new state
|
||||
|
||||
# If state is None, we create a brand new state and still load the event stream so we can restore the history
|
||||
if state is None:
|
||||
self.state = State(
|
||||
session_id=id.removesuffix('-delegate'),
|
||||
user_id=self.user_id,
|
||||
inputs={},
|
||||
conversation_stats=conversation_stats,
|
||||
iteration_flag=IterationControlFlag(
|
||||
limit_increase_amount=max_iterations,
|
||||
current_value=0,
|
||||
max_value=max_iterations,
|
||||
),
|
||||
budget_flag=None
|
||||
if not max_budget_per_task
|
||||
else BudgetControlFlag(
|
||||
limit_increase_amount=max_budget_per_task,
|
||||
current_value=0,
|
||||
max_value=max_budget_per_task,
|
||||
),
|
||||
confirmation_mode=confirmation_mode,
|
||||
)
|
||||
self.state.start_id = 0
|
||||
|
||||
logger.info(
|
||||
f'AgentController {id} - created new state. start_id: {self.state.start_id}'
|
||||
)
|
||||
else:
|
||||
self.state = state
|
||||
if self.state.start_id <= -1:
|
||||
self.state.start_id = 0
|
||||
|
||||
state.conversation_stats = conversation_stats
|
||||
|
||||
def _init_history(self, event_stream: EventStream) -> None:
|
||||
"""Initializes the agent's history from the event stream.
|
||||
|
||||
The history is a list of events that:
|
||||
- Excludes events of types listed in self.filter_out
|
||||
- Excludes events with hidden=True attribute
|
||||
- For delegate events (between AgentDelegateAction and AgentDelegateObservation):
|
||||
- Excludes all events between the action and observation
|
||||
- Includes the delegate action and observation themselves
|
||||
"""
|
||||
# define range of events to fetch
|
||||
# delegates start with a start_id and initially won't find any events
|
||||
# otherwise we're restoring a previous session
|
||||
start_id = self.state.start_id if self.state.start_id >= 0 else 0
|
||||
end_id = (
|
||||
self.state.end_id
|
||||
if self.state.end_id >= 0
|
||||
else event_stream.get_latest_event_id()
|
||||
)
|
||||
|
||||
# sanity check
|
||||
if start_id > end_id + 1:
|
||||
logger.warning(
|
||||
f'start_id {start_id} is greater than end_id + 1 ({end_id + 1}). History will be empty.',
|
||||
)
|
||||
self.state.history = []
|
||||
return
|
||||
|
||||
events: list[Event] = []
|
||||
|
||||
# Get rest of history
|
||||
events_to_add = list(
|
||||
event_stream.search_events(
|
||||
start_id=start_id,
|
||||
end_id=end_id,
|
||||
reverse=False,
|
||||
filter=self.agent_history_filter,
|
||||
)
|
||||
)
|
||||
events.extend(events_to_add)
|
||||
|
||||
# Find all delegate action/observation pairs
|
||||
delegate_ranges: list[tuple[int, int]] = []
|
||||
delegate_action_ids: list[int] = [] # stack of unmatched delegate action IDs
|
||||
|
||||
for event in events:
|
||||
if isinstance(event, AgentDelegateAction):
|
||||
delegate_action_ids.append(event.id)
|
||||
# Note: we can get agent=event.agent and task=event.inputs.get('task','')
|
||||
# if we need to track these in the future
|
||||
|
||||
elif isinstance(event, AgentDelegateObservation):
|
||||
# Match with most recent unmatched delegate action
|
||||
if not delegate_action_ids:
|
||||
logger.warning(
|
||||
f'Found AgentDelegateObservation without matching action at id={event.id}',
|
||||
)
|
||||
continue
|
||||
|
||||
action_id = delegate_action_ids.pop()
|
||||
delegate_ranges.append((action_id, event.id))
|
||||
|
||||
# Filter out events between delegate action/observation pairs
|
||||
if delegate_ranges:
|
||||
filtered_events: list[Event] = []
|
||||
current_idx = 0
|
||||
|
||||
for start_id, end_id in sorted(delegate_ranges):
|
||||
# Add events before delegate range
|
||||
filtered_events.extend(
|
||||
event for event in events[current_idx:] if event.id < start_id
|
||||
)
|
||||
|
||||
# Add delegate action and observation
|
||||
filtered_events.extend(
|
||||
event for event in events if event.id in (start_id, end_id)
|
||||
)
|
||||
|
||||
# Update index to after delegate range
|
||||
current_idx = next(
|
||||
(i for i, e in enumerate(events) if e.id > end_id), len(events)
|
||||
)
|
||||
|
||||
# Add any remaining events after last delegate range
|
||||
filtered_events.extend(events[current_idx:])
|
||||
|
||||
self.state.history = filtered_events
|
||||
else:
|
||||
self.state.history = events
|
||||
|
||||
# make sure history is in sync
|
||||
self.state.start_id = start_id
|
||||
|
||||
def close(self, event_stream: EventStream):
|
||||
# we made history, now is the time to rewrite it!
|
||||
# the final state.history will be used by external scripts like evals, tests, etc.
|
||||
# history will need to be complete WITH delegates events
|
||||
# like the regular agent history, it does not include:
|
||||
# - 'hidden' events, events with hidden=True
|
||||
# - backend events (the default 'filtered out' types, types in self.filter_out)
|
||||
start_id = self.state.start_id if self.state.start_id >= 0 else 0
|
||||
end_id = (
|
||||
self.state.end_id
|
||||
if self.state.end_id >= 0
|
||||
else event_stream.get_latest_event_id()
|
||||
)
|
||||
|
||||
self.state.history = list(
|
||||
event_stream.search_events(
|
||||
start_id=start_id,
|
||||
end_id=end_id,
|
||||
reverse=False,
|
||||
filter=self.agent_history_filter,
|
||||
)
|
||||
)
|
||||
|
||||
def add_history(self, event: Event):
|
||||
# if the event is not filtered out, add it to the history
|
||||
if self.agent_history_filter.include(event):
|
||||
self.state.history.append(event)
|
||||
|
||||
def get_trajectory(self, include_screenshots: bool = False) -> list[dict]:
|
||||
return [
|
||||
event_to_trajectory(event, include_screenshots)
|
||||
for event in self.state.history
|
||||
]
|
||||
|
||||
def maybe_increase_control_flags_limits(self, headless_mode: bool):
|
||||
# Iteration and budget extensions are independent of each other
|
||||
# An error will be thrown if any one of the control flags have reached or exceeded its limit
|
||||
self.state.iteration_flag.increase_limit(headless_mode)
|
||||
if self.state.budget_flag:
|
||||
self.state.budget_flag.increase_limit(headless_mode)
|
||||
|
||||
def get_metrics_snapshot(self):
|
||||
"""Deep copy of metrics
|
||||
This serves as a snapshot for the parent's metrics at the time a delegate is created
|
||||
It will be stored and used to compute local metrics for the delegate
|
||||
(since delegates now accumulate metrics from where its parent left off)
|
||||
"""
|
||||
return self.state.metrics.copy()
|
||||
|
||||
def save_state(self):
|
||||
"""Save's current state to persistent store"""
|
||||
if self.sid and self.file_store:
|
||||
self.state.save_to_session(self.sid, self.file_store, self.user_id)
|
||||
|
||||
if self.state.conversation_stats:
|
||||
self.state.conversation_stats.save_metrics()
|
||||
|
||||
def run_control_flags(self):
|
||||
"""Performs one step of the control flags"""
|
||||
self.state.iteration_flag.step()
|
||||
if self.state.budget_flag:
|
||||
self.state.budget_flag.step()
|
||||
|
||||
def sync_budget_flag_with_metrics(self):
|
||||
"""Ensures that budget flag is up to date with accumulated costs from llm completions
|
||||
Budget flag will monitor for when budget is exceeded
|
||||
"""
|
||||
# Sync cost across all llm services from llm registry
|
||||
if self.state.budget_flag and self.state.conversation_stats:
|
||||
self.state.budget_flag.current_value = (
|
||||
self.state.conversation_stats.get_combined_metrics().accumulated_cost
|
||||
)
|
||||
@@ -0,0 +1,488 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import Event, EventSource
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.commands import IPythonRunCellAction
|
||||
from openhands.events.action.empty import NullAction
|
||||
from openhands.events.action.message import MessageAction
|
||||
from openhands.events.observation import (
|
||||
CmdOutputObservation,
|
||||
IPythonRunCellObservation,
|
||||
)
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.events.observation.empty import NullObservation
|
||||
from openhands.events.observation.error import ErrorObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
|
||||
|
||||
class StuckDetector:
|
||||
SYNTAX_ERROR_MESSAGES = [
|
||||
'SyntaxError: unterminated string literal (detected at line',
|
||||
'SyntaxError: invalid syntax. Perhaps you forgot a comma?',
|
||||
'SyntaxError: incomplete input',
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class StuckAnalysis:
|
||||
loop_type: str
|
||||
loop_repeat_times: int
|
||||
loop_start_idx: int # in filtered_history
|
||||
|
||||
def __init__(self, state: State):
|
||||
self.state = state
|
||||
self.stuck_analysis: Optional[StuckDetector.StuckAnalysis] = None
|
||||
|
||||
def is_stuck(self, headless_mode: bool = True) -> bool:
|
||||
"""Checks if the agent is stuck in a loop.
|
||||
|
||||
Args:
|
||||
headless_mode: Matches AgentController's headless_mode.
|
||||
If True: Consider all history (automated/testing)
|
||||
If False: Consider only history after last user message (interactive)
|
||||
|
||||
Returns:
|
||||
bool: True if the agent is stuck in a loop, False otherwise.
|
||||
"""
|
||||
filtered_history_offset = 0
|
||||
if not headless_mode:
|
||||
# In interactive mode, only look at history after the last user message
|
||||
last_user_msg_idx = -1
|
||||
for i, event in enumerate(reversed(self.state.history)):
|
||||
if (
|
||||
isinstance(event, MessageAction)
|
||||
and event.source == EventSource.USER
|
||||
):
|
||||
last_user_msg_idx = len(self.state.history) - i - 1
|
||||
break
|
||||
filtered_history_offset = last_user_msg_idx + 1
|
||||
history_to_check = self.state.history[last_user_msg_idx + 1 :]
|
||||
else:
|
||||
# In headless mode, look at all history
|
||||
history_to_check = self.state.history
|
||||
|
||||
# Filter out user messages and null events
|
||||
filtered_history = [
|
||||
event
|
||||
for event in history_to_check
|
||||
if not (
|
||||
# Filter works elegantly in both modes:
|
||||
# - In headless: actively filters out user messages from full history
|
||||
# - In non-headless: no-op since we already sliced after last user message
|
||||
(isinstance(event, MessageAction) and event.source == EventSource.USER)
|
||||
# there might be some NullAction or NullObservation in the history at least for now
|
||||
or isinstance(event, (NullAction, NullObservation))
|
||||
)
|
||||
]
|
||||
|
||||
# it takes 3 actions minimum to detect a loop, otherwise nothing to do here
|
||||
if len(filtered_history) < 3:
|
||||
return False
|
||||
|
||||
# the first few scenarios detect 3 or 4 repeated steps
|
||||
# prepare the last 4 actions and observations, to check them out
|
||||
last_actions: list[Event] = []
|
||||
last_observations: list[Event] = []
|
||||
|
||||
# retrieve the last four actions and observations starting from the end of history, wherever they are
|
||||
for event in reversed(filtered_history):
|
||||
if isinstance(event, Action) and len(last_actions) < 4:
|
||||
last_actions.append(event)
|
||||
elif isinstance(event, Observation) and len(last_observations) < 4:
|
||||
last_observations.append(event)
|
||||
|
||||
if len(last_actions) == 4 and len(last_observations) == 4:
|
||||
break
|
||||
|
||||
# scenario 1: same action, same observation
|
||||
if self._is_stuck_repeating_action_observation(
|
||||
last_actions, last_observations, filtered_history, filtered_history_offset
|
||||
):
|
||||
return True
|
||||
|
||||
# scenario 2: same action, errors
|
||||
if self._is_stuck_repeating_action_error(
|
||||
last_actions, last_observations, filtered_history, filtered_history_offset
|
||||
):
|
||||
return True
|
||||
|
||||
# scenario 3: monologue
|
||||
if self._is_stuck_monologue(filtered_history, filtered_history_offset):
|
||||
return True
|
||||
|
||||
# scenario 4: action, observation pattern on the last six steps
|
||||
if len(filtered_history) >= 6:
|
||||
if self._is_stuck_action_observation_pattern(
|
||||
filtered_history, filtered_history_offset
|
||||
):
|
||||
return True
|
||||
|
||||
# scenario 5: context window error loop
|
||||
if len(filtered_history) >= 10:
|
||||
if self._is_stuck_context_window_error(
|
||||
filtered_history, filtered_history_offset
|
||||
):
|
||||
return True
|
||||
|
||||
# Empty stuck_analysis when not stuck
|
||||
self.stuck_analysis = None
|
||||
return False
|
||||
|
||||
def _is_stuck_repeating_action_observation(
|
||||
self,
|
||||
last_actions: list[Event],
|
||||
last_observations: list[Event],
|
||||
filtered_history: list[Event],
|
||||
filtered_history_offset: int = 0,
|
||||
) -> bool:
|
||||
# scenario 1: same action, same observation
|
||||
# it takes 4 actions and 4 observations to detect a loop
|
||||
# assert len(last_actions) == 4 and len(last_observations) == 4
|
||||
|
||||
# Check for a loop of 4 identical action-observation pairs
|
||||
if len(last_actions) == 4 and len(last_observations) == 4:
|
||||
actions_equal = all(
|
||||
self._eq_no_pid(last_actions[0], action) for action in last_actions
|
||||
)
|
||||
observations_equal = all(
|
||||
self._eq_no_pid(last_observations[0], observation)
|
||||
for observation in last_observations
|
||||
)
|
||||
|
||||
if actions_equal and observations_equal:
|
||||
logger.warning('Action, Observation loop detected')
|
||||
self.stuck_analysis = StuckDetector.StuckAnalysis(
|
||||
loop_type='repeating_action_observation',
|
||||
loop_repeat_times=4,
|
||||
loop_start_idx=filtered_history.index(last_actions[-1])
|
||||
+ filtered_history_offset,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _is_stuck_repeating_action_error(
|
||||
self,
|
||||
last_actions: list[Event],
|
||||
last_observations: list[Event],
|
||||
filtered_history: list[Event],
|
||||
filtered_history_offset: int = 0,
|
||||
) -> bool:
|
||||
# scenario 2: same action, errors
|
||||
# it takes 3 actions and 3 observations to detect a loop
|
||||
# check if the last three actions are the same and result in errors
|
||||
|
||||
if len(last_actions) < 3 or len(last_observations) < 3:
|
||||
return False
|
||||
|
||||
# are the last three actions the "same"?
|
||||
if all(self._eq_no_pid(last_actions[0], action) for action in last_actions[:3]):
|
||||
# and the last three observations are all errors?
|
||||
if all(isinstance(obs, ErrorObservation) for obs in last_observations[:3]):
|
||||
logger.warning('Action, ErrorObservation loop detected')
|
||||
self.stuck_analysis = StuckDetector.StuckAnalysis(
|
||||
loop_type='repeating_action_error',
|
||||
loop_repeat_times=3,
|
||||
loop_start_idx=filtered_history.index(last_actions[-1])
|
||||
+ filtered_history_offset,
|
||||
)
|
||||
return True
|
||||
# or, are the last three observations all IPythonRunCellObservation with SyntaxError?
|
||||
elif all(
|
||||
isinstance(obs, IPythonRunCellObservation)
|
||||
for obs in last_observations[:3]
|
||||
):
|
||||
warning = 'Action, IPythonRunCellObservation loop detected'
|
||||
for error_message in self.SYNTAX_ERROR_MESSAGES:
|
||||
if error_message.startswith(
|
||||
'SyntaxError: unterminated string literal (detected at line'
|
||||
):
|
||||
if self._check_for_consistent_line_error(
|
||||
[
|
||||
obs
|
||||
for obs in last_observations[:3]
|
||||
if isinstance(obs, IPythonRunCellObservation)
|
||||
],
|
||||
error_message,
|
||||
):
|
||||
logger.warning(warning)
|
||||
self.stuck_analysis = StuckDetector.StuckAnalysis(
|
||||
loop_type='repeating_action_error',
|
||||
loop_repeat_times=3,
|
||||
loop_start_idx=filtered_history.index(last_actions[-1])
|
||||
+ filtered_history_offset,
|
||||
)
|
||||
return True
|
||||
elif error_message in (
|
||||
'SyntaxError: invalid syntax. Perhaps you forgot a comma?',
|
||||
'SyntaxError: incomplete input',
|
||||
) and self._check_for_consistent_invalid_syntax(
|
||||
[
|
||||
obs
|
||||
for obs in last_observations[:3]
|
||||
if isinstance(obs, IPythonRunCellObservation)
|
||||
],
|
||||
error_message,
|
||||
):
|
||||
logger.warning(warning)
|
||||
self.stuck_analysis = StuckDetector.StuckAnalysis(
|
||||
loop_type='repeating_action_error',
|
||||
loop_repeat_times=3,
|
||||
loop_start_idx=filtered_history.index(last_actions[-1])
|
||||
+ filtered_history_offset,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_for_consistent_invalid_syntax(
|
||||
self, observations: list[IPythonRunCellObservation], error_message: str
|
||||
) -> bool:
|
||||
first_lines = []
|
||||
valid_observations = []
|
||||
|
||||
for obs in observations:
|
||||
content = obs.content
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
if len(lines) < 6: # 6 because a real syntax error has at least 6 lines
|
||||
return False
|
||||
|
||||
line1 = lines[0].strip()
|
||||
if not line1.startswith('Cell In[1], line'):
|
||||
return False
|
||||
|
||||
first_lines.append(line1) # Store the first line of each observation
|
||||
|
||||
# Check last three lines
|
||||
if (
|
||||
lines[-1].startswith('[Jupyter Python interpreter:')
|
||||
and lines[-2].startswith('[Jupyter current working directory:')
|
||||
and error_message in lines[-3]
|
||||
):
|
||||
valid_observations.append(obs)
|
||||
|
||||
# Check if:
|
||||
# 1. All first lines are identical
|
||||
# 2. We have exactly 3 valid observations
|
||||
# 3. The error message line is identical in all valid observations
|
||||
return (
|
||||
len(set(first_lines)) == 1
|
||||
and len(valid_observations) == 3
|
||||
and len(
|
||||
set(
|
||||
obs.content.strip().split('\n')[:-2][-1]
|
||||
for obs in valid_observations
|
||||
)
|
||||
)
|
||||
== 1
|
||||
)
|
||||
|
||||
def _check_for_consistent_line_error(
|
||||
self, observations: list[IPythonRunCellObservation], error_message: str
|
||||
) -> bool:
|
||||
error_lines = []
|
||||
|
||||
for obs in observations:
|
||||
content = obs.content
|
||||
lines = content.strip().split('\n')
|
||||
|
||||
if len(lines) < 3:
|
||||
return False
|
||||
|
||||
last_lines = lines[-3:]
|
||||
|
||||
# Check if the last two lines are our own
|
||||
if not (
|
||||
last_lines[-2].startswith('[Jupyter current working directory:')
|
||||
and last_lines[-1].startswith('[Jupyter Python interpreter:')
|
||||
):
|
||||
return False
|
||||
|
||||
# Check for the error message in the 3rd-to-last line
|
||||
if error_message in last_lines[-3]:
|
||||
error_lines.append(last_lines[-3])
|
||||
|
||||
# Check if we found the error message in all 3 observations
|
||||
# and the 3rd-to-last line is identical across all occurrences
|
||||
return len(error_lines) == 3 and len(set(error_lines)) == 1
|
||||
|
||||
def _is_stuck_monologue(
|
||||
self, filtered_history: list[Event], filtered_history_offset: int = 0
|
||||
) -> bool:
|
||||
# scenario 3: monologue
|
||||
# check for repeated MessageActions with source=AGENT
|
||||
# see if the agent is engaged in a good old monologue, telling itself the same thing over and over
|
||||
agent_message_actions = [
|
||||
(i, event)
|
||||
for i, event in enumerate(filtered_history)
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.AGENT
|
||||
]
|
||||
|
||||
# last three message actions will do for this check
|
||||
if len(agent_message_actions) >= 3:
|
||||
last_agent_message_actions = agent_message_actions[-3:]
|
||||
|
||||
if all(
|
||||
(last_agent_message_actions[0][1] == action[1])
|
||||
for action in last_agent_message_actions
|
||||
):
|
||||
# check if there are any observations between the repeated MessageActions
|
||||
# then it's not yet a loop, maybe it can recover
|
||||
start_index = last_agent_message_actions[0][0]
|
||||
end_index = last_agent_message_actions[-1][0]
|
||||
|
||||
has_observation_between = False
|
||||
for event in filtered_history[start_index + 1 : end_index]:
|
||||
if isinstance(event, Observation):
|
||||
has_observation_between = True
|
||||
break
|
||||
|
||||
if not has_observation_between:
|
||||
logger.warning('Repeated MessageAction with source=AGENT detected')
|
||||
self.stuck_analysis = StuckDetector.StuckAnalysis(
|
||||
loop_type='monologue',
|
||||
loop_repeat_times=3,
|
||||
loop_start_idx=start_index + filtered_history_offset,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_stuck_action_observation_pattern(
|
||||
self, filtered_history: list[Event], filtered_history_offset: int = 0
|
||||
) -> bool:
|
||||
# scenario 4: action, observation pattern on the last six steps
|
||||
# check if the agent repeats the same (Action, Observation)
|
||||
# every other step in the last six steps
|
||||
last_six_actions: list[Event] = []
|
||||
last_six_observations: list[Event] = []
|
||||
|
||||
# the end of history is most interesting
|
||||
for event in reversed(filtered_history):
|
||||
if isinstance(event, Action) and len(last_six_actions) < 6:
|
||||
last_six_actions.append(event)
|
||||
elif isinstance(event, Observation) and len(last_six_observations) < 6:
|
||||
last_six_observations.append(event)
|
||||
|
||||
if len(last_six_actions) == 6 and len(last_six_observations) == 6:
|
||||
break
|
||||
|
||||
# this pattern is every other step, like:
|
||||
# (action_1, obs_1), (action_2, obs_2), (action_1, obs_1), (action_2, obs_2),...
|
||||
if len(last_six_actions) == 6 and len(last_six_observations) == 6:
|
||||
actions_equal = (
|
||||
# action_0 == action_2 == action_4
|
||||
self._eq_no_pid(last_six_actions[0], last_six_actions[2])
|
||||
and self._eq_no_pid(last_six_actions[0], last_six_actions[4])
|
||||
# action_1 == action_3 == action_5
|
||||
and self._eq_no_pid(last_six_actions[1], last_six_actions[3])
|
||||
and self._eq_no_pid(last_six_actions[1], last_six_actions[5])
|
||||
)
|
||||
observations_equal = (
|
||||
# obs_0 == obs_2 == obs_4
|
||||
self._eq_no_pid(last_six_observations[0], last_six_observations[2])
|
||||
and self._eq_no_pid(last_six_observations[0], last_six_observations[4])
|
||||
# obs_1 == obs_3 == obs_5
|
||||
and self._eq_no_pid(last_six_observations[1], last_six_observations[3])
|
||||
and self._eq_no_pid(last_six_observations[1], last_six_observations[5])
|
||||
)
|
||||
|
||||
if actions_equal and observations_equal:
|
||||
logger.warning('Action, Observation pattern detected')
|
||||
self.stuck_analysis = StuckDetector.StuckAnalysis(
|
||||
loop_type='repeating_action_observation_pattern',
|
||||
loop_repeat_times=3,
|
||||
loop_start_idx=filtered_history.index(last_six_actions[-1])
|
||||
+ filtered_history_offset,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def _is_stuck_context_window_error(
|
||||
self, filtered_history: list[Event], filtered_history_offset: int = 0
|
||||
) -> bool:
|
||||
"""Detects if we're stuck in a loop of context window errors.
|
||||
|
||||
This happens when we repeatedly get context window errors and try to trim,
|
||||
but the trimming doesn't work, causing us to get more context window errors.
|
||||
The pattern is repeated AgentCondensationObservation events without any other
|
||||
events between them.
|
||||
|
||||
Args:
|
||||
filtered_history: List of filtered events to check
|
||||
|
||||
Returns:
|
||||
bool: True if we detect a context window error loop
|
||||
"""
|
||||
# Look for AgentCondensationObservation events
|
||||
condensation_events = [
|
||||
(i, event)
|
||||
for i, event in enumerate(filtered_history)
|
||||
if isinstance(event, AgentCondensationObservation)
|
||||
]
|
||||
|
||||
# Need at least 10 condensation events to detect a loop
|
||||
if len(condensation_events) < 10:
|
||||
return False
|
||||
|
||||
# Get the last 10 condensation events
|
||||
last_condensation_events = condensation_events[-10:]
|
||||
|
||||
# Check if there are any non-condensation events between them
|
||||
for i in range(len(last_condensation_events) - 1):
|
||||
start_idx = last_condensation_events[i][0]
|
||||
end_idx = last_condensation_events[i + 1][0]
|
||||
|
||||
# Look for any non-condensation events between these two
|
||||
has_other_events = False
|
||||
for event in filtered_history[start_idx + 1 : end_idx]:
|
||||
if not isinstance(event, AgentCondensationObservation):
|
||||
has_other_events = True
|
||||
break
|
||||
|
||||
if not has_other_events:
|
||||
logger.warning(
|
||||
'Context window error loop detected - repeated condensation events'
|
||||
)
|
||||
self.stuck_analysis = StuckDetector.StuckAnalysis(
|
||||
loop_type='context_window_error',
|
||||
loop_repeat_times=2,
|
||||
loop_start_idx=start_idx + filtered_history_offset,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _eq_no_pid(self, obj1: Event, obj2: Event) -> bool:
|
||||
if isinstance(obj1, IPythonRunCellAction) and isinstance(
|
||||
obj2, IPythonRunCellAction
|
||||
):
|
||||
# for loop detection on edit actions, ignore the thought, compare some code
|
||||
# the code should have at least 3 lines, to avoid simple one-liners
|
||||
if (
|
||||
'edit_file_by_replace(' in obj1.code
|
||||
and 'edit_file_by_replace(' in obj2.code
|
||||
):
|
||||
return (
|
||||
len(obj1.code.split('\n')) > 2
|
||||
and obj1.code.split('\n')[:3] == obj2.code.split('\n')[:3]
|
||||
)
|
||||
else:
|
||||
# default comparison
|
||||
return obj1 == obj2
|
||||
elif isinstance(obj1, CmdOutputObservation) and isinstance(
|
||||
obj2, CmdOutputObservation
|
||||
):
|
||||
# for loop detection, ignore command_id, which is the pid
|
||||
return obj1.command == obj2.command and obj1.exit_code == obj2.exit_code
|
||||
else:
|
||||
# this is the default comparison
|
||||
return obj1 == obj2
|
||||
@@ -16,6 +16,7 @@ from openhands.core.config.condenser_config import (
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.model_routing_config import ModelRoutingConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
@@ -129,4 +130,38 @@ class AgentConfig(BaseModel):
|
||||
# Still add it to the mapping
|
||||
agent_mapping['agent'] = base_config
|
||||
|
||||
# Process each custom section independently
|
||||
for name, overrides in custom_sections.items():
|
||||
try:
|
||||
# Merge base config with overrides
|
||||
merged = {**base_config.model_dump(), **overrides}
|
||||
if merged.get('classpath'):
|
||||
# if an explicit classpath is given, try to load it and look up its config model class
|
||||
from openhands.controller.agent import Agent
|
||||
|
||||
try:
|
||||
agent_cls = get_impl(Agent, merged.get('classpath'))
|
||||
custom_config = agent_cls.config_model.model_validate(merged)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed to load custom agent class [{merged.get("classpath")}]: {e}. Using default config model.'
|
||||
)
|
||||
custom_config = cls.model_validate(merged)
|
||||
else:
|
||||
# otherwise, try to look up the agent class by name (i.e. if it's a built-in)
|
||||
# if that fails, just use the default AgentConfig class.
|
||||
try:
|
||||
agent_cls = Agent.get_cls(name)
|
||||
custom_config = agent_cls.config_model.model_validate(merged)
|
||||
except Exception:
|
||||
# otherwise, just fall back to the default config model
|
||||
custom_config = cls.model_validate(merged)
|
||||
agent_mapping[name] = custom_config
|
||||
except ValidationError as e:
|
||||
logger.warning(
|
||||
f'Invalid agent configuration for [{name}]: {e}. This section will be skipped.'
|
||||
)
|
||||
# Skip this custom section but continue with others
|
||||
continue
|
||||
|
||||
return agent_mapping
|
||||
|
||||
@@ -8,6 +8,15 @@
|
||||
"""Centralized command line argument configuration for OpenHands CLI and headless modes."""
|
||||
|
||||
import argparse
|
||||
from argparse import ArgumentParser, _SubParsersAction
|
||||
|
||||
|
||||
def get_subparser(parser: ArgumentParser, name: str) -> ArgumentParser:
|
||||
for action in parser._actions:
|
||||
if isinstance(action, _SubParsersAction):
|
||||
if name in action.choices:
|
||||
return action.choices[name]
|
||||
raise ValueError(f"Subparser '{name}' not found")
|
||||
|
||||
|
||||
def add_common_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
@@ -140,6 +149,71 @@ def add_headless_specific_arguments(parser: argparse.ArgumentParser) -> None:
|
||||
)
|
||||
|
||||
|
||||
def get_cli_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for CLI mode with simplified argument set."""
|
||||
# Create a description with welcome message explaining available commands
|
||||
description = (
|
||||
'Welcome to OpenHands: Code Less, Make More\n\n'
|
||||
'OpenHands supports two main commands:\n'
|
||||
' serve - Launch the OpenHands GUI server (web interface)\n'
|
||||
' cli - Run OpenHands in CLI mode (terminal interface)\n\n'
|
||||
'Running "openhands" without a command is the same as "openhands cli"'
|
||||
)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description=description,
|
||||
prog='openhands',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter, # Preserve formatting in description
|
||||
epilog='For more information about a command, run: openhands COMMAND --help',
|
||||
)
|
||||
|
||||
# Create subparsers
|
||||
subparsers = parser.add_subparsers(
|
||||
dest='command',
|
||||
title='commands',
|
||||
description='OpenHands supports two main commands:',
|
||||
metavar='COMMAND',
|
||||
)
|
||||
|
||||
# Add 'serve' subcommand
|
||||
serve_parser = subparsers.add_parser(
|
||||
'serve', help='Launch the OpenHands GUI server using Docker (web interface)'
|
||||
)
|
||||
serve_parser.add_argument(
|
||||
'--mount-cwd',
|
||||
help='Mount the current working directory into the GUI server container',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
serve_parser.add_argument(
|
||||
'--gpu',
|
||||
help='Enable GPU support by mounting all GPUs into the Docker container via nvidia-docker',
|
||||
action='store_true',
|
||||
default=False,
|
||||
)
|
||||
|
||||
# Add 'cli' subcommand - import all the existing CLI arguments
|
||||
cli_parser = subparsers.add_parser(
|
||||
'cli', help='Run OpenHands in CLI mode (terminal interface)'
|
||||
)
|
||||
add_common_arguments(cli_parser)
|
||||
|
||||
cli_parser.add_argument(
|
||||
'--override-cli-mode',
|
||||
help='Override the default settings for CLI mode',
|
||||
type=bool,
|
||||
default=False,
|
||||
)
|
||||
parser.add_argument(
|
||||
'--conversation',
|
||||
help='The conversation id to continue',
|
||||
type=str,
|
||||
default=None,
|
||||
)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def get_headless_parser() -> argparse.ArgumentParser:
|
||||
"""Create argument parser for headless mode with full argument set."""
|
||||
parser = argparse.ArgumentParser(description='Run the agent via CLI')
|
||||
|
||||
@@ -23,7 +23,9 @@ from openhands.core import logger
|
||||
from openhands.core.config.agent_config import AgentConfig
|
||||
from openhands.core.config.arg_utils import get_headless_parser
|
||||
from openhands.core.config.condenser_config import (
|
||||
CondenserConfig,
|
||||
condenser_config_from_toml_section,
|
||||
create_condenser_config,
|
||||
)
|
||||
from openhands.core.config.extended_config import ExtendedConfig
|
||||
from openhands.core.config.kubernetes_config import KubernetesConfig
|
||||
@@ -35,6 +37,7 @@ from openhands.core.config.sandbox_config import SandboxConfig
|
||||
from openhands.core.config.security_config import SecurityConfig
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
JWT_SECRET = '.jwt_secret'
|
||||
load_dotenv()
|
||||
@@ -625,6 +628,118 @@ def get_llms_for_routing_config(toml_file: str = 'config.toml') -> dict[str, LLM
|
||||
return llms_for_routing
|
||||
|
||||
|
||||
def get_condenser_config_arg(
|
||||
condenser_config_arg: str, toml_file: str = 'config.toml'
|
||||
) -> CondenserConfig | None:
|
||||
"""Get a group of condenser settings from the config file by name.
|
||||
|
||||
A group in config.toml can look like this:
|
||||
|
||||
```
|
||||
[condenser.my_summarizer]
|
||||
type = 'llm'
|
||||
llm_config = 'gpt-4o' # References [llm.gpt-4o]
|
||||
max_size = 50
|
||||
...
|
||||
```
|
||||
|
||||
The user-defined group name, like "my_summarizer", is the argument to this function.
|
||||
The function will load the CondenserConfig object with the settings of this group,
|
||||
from the config file.
|
||||
|
||||
Note that the group must be under the "condenser" group, or in other words,
|
||||
the group name must start with "condenser.".
|
||||
|
||||
Args:
|
||||
condenser_config_arg: The group of condenser settings to get from the config.toml file.
|
||||
toml_file: Path to the configuration file to read from. Defaults to 'config.toml'.
|
||||
|
||||
Returns:
|
||||
CondenserConfig: The CondenserConfig object with the settings from the config file, or None if not found/error.
|
||||
"""
|
||||
# keep only the name, just in case
|
||||
condenser_config_arg = condenser_config_arg.strip('[]')
|
||||
|
||||
# truncate the prefix, just in case
|
||||
if condenser_config_arg.startswith('condenser.'):
|
||||
condenser_config_arg = condenser_config_arg[10:]
|
||||
|
||||
logger.openhands_logger.debug(
|
||||
f'Loading condenser config [{condenser_config_arg}] from {toml_file}'
|
||||
)
|
||||
|
||||
# load the toml file
|
||||
try:
|
||||
with open(toml_file, 'r', encoding='utf-8') as toml_contents:
|
||||
toml_config = toml.load(toml_contents)
|
||||
except FileNotFoundError as e:
|
||||
logger.openhands_logger.info(f'Config file not found: {toml_file}. Error: {e}')
|
||||
return None
|
||||
except toml.TomlDecodeError as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Cannot parse condenser group [{condenser_config_arg}] from {toml_file}. Exception: {e}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Check if the condenser section and the specific config exist
|
||||
if (
|
||||
'condenser' not in toml_config
|
||||
or condenser_config_arg not in toml_config['condenser']
|
||||
):
|
||||
logger.openhands_logger.error(
|
||||
f'Condenser config section [condenser.{condenser_config_arg}] not found in {toml_file}'
|
||||
)
|
||||
return None
|
||||
|
||||
condenser_data = toml_config['condenser'][
|
||||
condenser_config_arg
|
||||
].copy() # Use copy to modify
|
||||
|
||||
# Determine the type and handle potential LLM dependency
|
||||
condenser_type = condenser_data.get('type')
|
||||
if not condenser_type:
|
||||
logger.openhands_logger.error(
|
||||
f'Missing "type" field in [condenser.{condenser_config_arg}] section of {toml_file}'
|
||||
)
|
||||
return None
|
||||
|
||||
# Handle LLM config reference if needed, using get_llm_config_arg
|
||||
if (
|
||||
condenser_type in ('llm', 'llm_attention', 'structured')
|
||||
and 'llm_config' in condenser_data
|
||||
and isinstance(condenser_data['llm_config'], str)
|
||||
):
|
||||
llm_config_name = condenser_data['llm_config']
|
||||
logger.openhands_logger.debug(
|
||||
f'Condenser [{condenser_config_arg}] requires LLM config [{llm_config_name}]. Loading it...'
|
||||
)
|
||||
# Use the existing function to load the specific LLM config
|
||||
referenced_llm_config = get_llm_config_arg(llm_config_name, toml_file=toml_file)
|
||||
|
||||
if referenced_llm_config:
|
||||
# Replace the string reference with the actual LLMConfig object
|
||||
condenser_data['llm_config'] = referenced_llm_config
|
||||
else:
|
||||
# get_llm_config_arg already logs the error if not found
|
||||
logger.openhands_logger.error(
|
||||
f"Failed to load required LLM config '{llm_config_name}' for condenser '{condenser_config_arg}'."
|
||||
)
|
||||
return None
|
||||
|
||||
# Create the condenser config instance
|
||||
try:
|
||||
config = create_condenser_config(condenser_type, condenser_data)
|
||||
logger.openhands_logger.info(
|
||||
f'Successfully loaded condenser config [{condenser_config_arg}] from {toml_file}'
|
||||
)
|
||||
return config
|
||||
except (ValidationError, ValueError) as e:
|
||||
logger.openhands_logger.error(
|
||||
f'Invalid condenser configuration for [{condenser_config_arg}]: {e}.'
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def get_model_routing_config_arg(toml_file: str = 'config.toml') -> ModelRoutingConfig:
|
||||
"""Get the model routing settings from the config file. We only support the default model routing config [model_routing].
|
||||
|
||||
@@ -682,6 +797,29 @@ def parse_arguments() -> argparse.Namespace:
|
||||
return args
|
||||
|
||||
|
||||
def register_custom_agents(config: OpenHandsConfig) -> None:
|
||||
"""Register custom agents from configuration.
|
||||
|
||||
This function is called after configuration is loaded to ensure all custom agents
|
||||
specified in the config are properly imported and registered.
|
||||
"""
|
||||
# Import here to avoid circular dependency
|
||||
from openhands.controller.agent import Agent
|
||||
|
||||
for agent_name, agent_config in config.agents.items():
|
||||
if agent_config.classpath:
|
||||
try:
|
||||
agent_cls = get_impl(Agent, agent_config.classpath)
|
||||
Agent.register(agent_name, agent_cls)
|
||||
logger.openhands_logger.info(
|
||||
f"Registered custom agent '{agent_name}' from {agent_config.classpath}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.openhands_logger.error(
|
||||
f"Failed to register agent '{agent_name}': {e}"
|
||||
)
|
||||
|
||||
|
||||
def load_openhands_config(
|
||||
set_logging_levels: bool = True, config_file: str = 'config.toml'
|
||||
) -> OpenHandsConfig:
|
||||
@@ -695,6 +833,7 @@ def load_openhands_config(
|
||||
load_from_toml(config, config_file)
|
||||
load_from_env(config, os.environ)
|
||||
finalize_config(config)
|
||||
register_custom_agents(config)
|
||||
if set_logging_levels:
|
||||
logger.DEBUG = config.debug
|
||||
logger.DISABLE_COLOR_PRINTING = config.disable_color
|
||||
|
||||
@@ -16,6 +16,16 @@ class AgentError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class AgentNoInstructionError(AgentError):
|
||||
def __init__(self, message: str = 'Instruction must be provided') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AgentEventTypeError(AgentError):
|
||||
def __init__(self, message: str = 'Event must be a dictionary') -> None:
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class AgentAlreadyRegisteredError(AgentError):
|
||||
def __init__(self, name: str | None = None) -> None:
|
||||
if name is not None:
|
||||
@@ -39,6 +49,20 @@ class AgentStuckInLoopError(AgentError):
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ============================================
|
||||
# Agent Controller Exceptions
|
||||
# ============================================
|
||||
|
||||
|
||||
class TaskInvalidStateError(Exception):
|
||||
def __init__(self, state: str | None = None) -> None:
|
||||
if state is not None:
|
||||
message = f'Invalid state {state}'
|
||||
else:
|
||||
message = 'Invalid state'
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
# ============================================
|
||||
# LLM Exceptions
|
||||
# ============================================
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import asyncio
|
||||
|
||||
from openhands.controller import AgentController
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
|
||||
|
||||
async def run_agent_until_done(
|
||||
controller: AgentController,
|
||||
runtime: Runtime,
|
||||
memory: Memory,
|
||||
end_states: list[AgentState],
|
||||
skip_set_callback: bool = False,
|
||||
) -> None:
|
||||
"""run_agent_until_done takes a controller and a runtime, and will run
|
||||
the agent until it reaches a terminal state.
|
||||
Note that runtime must be connected before being passed in here.
|
||||
"""
|
||||
|
||||
def status_callback(msg_type: str, runtime_status: RuntimeStatus, msg: str) -> None:
|
||||
if msg_type == 'error':
|
||||
logger.error(msg)
|
||||
if controller:
|
||||
controller.state.last_error = msg
|
||||
asyncio.create_task(controller.set_agent_state_to(AgentState.ERROR))
|
||||
else:
|
||||
logger.info(msg)
|
||||
|
||||
if not skip_set_callback:
|
||||
if hasattr(runtime, 'status_callback') and runtime.status_callback:
|
||||
raise ValueError(
|
||||
'Runtime status_callback was set, but run_agent_until_done will override it'
|
||||
)
|
||||
if hasattr(controller, 'status_callback') and controller.status_callback:
|
||||
raise ValueError(
|
||||
'Controller status_callback was set, but run_agent_until_done will override it'
|
||||
)
|
||||
|
||||
runtime.status_callback = status_callback
|
||||
controller.status_callback = status_callback
|
||||
memory.status_callback = status_callback
|
||||
|
||||
while controller.state.agent_state not in end_states:
|
||||
await asyncio.sleep(1)
|
||||
@@ -0,0 +1,393 @@
|
||||
# IMPORTANT: LEGACY V0 CODE - Deprecated since version 1.0.0, scheduled for removal April 1, 2026
|
||||
# This file is part of the legacy (V0) implementation of OpenHands and will be removed soon as we complete the migration to V1.
|
||||
# OpenHands V1 uses the Software Agent SDK for the agentic core and runs a new application server. Please refer to:
|
||||
# - V1 agentic core (SDK): https://github.com/OpenHands/software-agent-sdk
|
||||
# - V1 application server (in this repo): openhands/app_server/
|
||||
# Unless you are working on deprecation, please avoid extending this legacy file and consult the V1 codepaths above.
|
||||
# Tag: Legacy-V0
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Callable, Protocol
|
||||
|
||||
from openhands.controller.replay import ReplayManager
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
parse_arguments,
|
||||
setup_config_from_args,
|
||||
)
|
||||
from openhands.core.config.mcp_config import MCPConfig, OpenHandsMCPConfigImpl
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.loop import run_agent_until_done
|
||||
from openhands.core.schema import AgentState
|
||||
from openhands.core.setup import (
|
||||
create_agent,
|
||||
create_controller,
|
||||
create_memory,
|
||||
create_runtime,
|
||||
generate_sid,
|
||||
get_provider_tokens,
|
||||
initialize_repository_for_runtime,
|
||||
)
|
||||
from openhands.events import EventSource, EventStreamSubscriber
|
||||
from openhands.events.action import MessageAction, NullAction
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
from openhands.io import read_input, read_task
|
||||
from openhands.mcp import add_mcp_tools_to_agent
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
|
||||
|
||||
class FakeUserResponseFunc(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
state: State,
|
||||
encapsulate_solution: bool = False,
|
||||
try_parse: Callable[[Action | None], str] | None = None,
|
||||
) -> str: ...
|
||||
|
||||
|
||||
async def run_controller(
|
||||
config: OpenHandsConfig,
|
||||
initial_user_action: Action,
|
||||
sid: str | None = None,
|
||||
runtime: Runtime | None = None,
|
||||
exit_on_message: bool = False,
|
||||
fake_user_response_fn: FakeUserResponseFunc | None = None,
|
||||
headless_mode: bool = True,
|
||||
memory: Memory | None = None,
|
||||
conversation_instructions: str | None = None,
|
||||
) -> State | None:
|
||||
"""Main coroutine to run the agent controller with task input flexibility.
|
||||
|
||||
It's only used when you launch openhands backend directly via cmdline.
|
||||
|
||||
Args:
|
||||
config: The app config.
|
||||
initial_user_action: An Action object containing initial user input
|
||||
sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
Set it to incompatible value will cause unexpected behavior on RemoteRuntime.
|
||||
runtime: (optional) A runtime for the agent to run on.
|
||||
exit_on_message: quit if agent asks for a message from user (optional)
|
||||
fake_user_response_fn: An optional function that receives the current state
|
||||
(could be None) and returns a fake user response.
|
||||
headless_mode: Whether the agent is run in headless mode.
|
||||
|
||||
Returns:
|
||||
The final state of the agent, or None if an error occurred.
|
||||
|
||||
Raises:
|
||||
AssertionError: If initial_user_action is not an Action instance.
|
||||
Exception: Various exceptions may be raised during execution and will be logged.
|
||||
|
||||
Notes:
|
||||
- State persistence: If config.file_store is set, the agent's state will be
|
||||
saved between sessions.
|
||||
- Trajectories: If config.trajectories_path is set, execution history will be
|
||||
saved as JSON for analysis.
|
||||
- Budget control: Execution is limited by config.max_iterations and
|
||||
config.max_budget_per_task.
|
||||
|
||||
Example:
|
||||
>>> config = load_openhands_config()
|
||||
>>> action = MessageAction(content="Write a hello world program")
|
||||
>>> state = await run_controller(config=config, initial_user_action=action)
|
||||
"""
|
||||
sid = sid or generate_sid(config)
|
||||
|
||||
llm_registry, conversation_stats, config = create_registry_and_conversation_stats(
|
||||
config,
|
||||
sid,
|
||||
None,
|
||||
)
|
||||
|
||||
agent = create_agent(config, llm_registry)
|
||||
|
||||
# when the runtime is created, it will be connected and clone the selected repository
|
||||
repo_directory = None
|
||||
if runtime is None:
|
||||
# In itialize repository if needed
|
||||
repo_tokens = get_provider_tokens()
|
||||
runtime = create_runtime(
|
||||
config,
|
||||
llm_registry,
|
||||
sid=sid,
|
||||
headless_mode=headless_mode,
|
||||
agent=agent,
|
||||
git_provider_tokens=repo_tokens,
|
||||
)
|
||||
# Connect to the runtime
|
||||
call_async_from_sync(runtime.connect)
|
||||
|
||||
# Initialize repository if needed
|
||||
if config.sandbox.selected_repo:
|
||||
repo_directory = initialize_repository_for_runtime(
|
||||
runtime,
|
||||
immutable_provider_tokens=repo_tokens,
|
||||
selected_repository=config.sandbox.selected_repo,
|
||||
)
|
||||
|
||||
event_stream = runtime.event_stream
|
||||
|
||||
# when memory is created, it will load the microagents from the selected repository
|
||||
if memory is None:
|
||||
memory = create_memory(
|
||||
runtime=runtime,
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
selected_repository=config.sandbox.selected_repo,
|
||||
repo_directory=repo_directory,
|
||||
conversation_instructions=conversation_instructions,
|
||||
working_dir=str(runtime.workspace_root),
|
||||
)
|
||||
|
||||
# Add MCP tools to the agent
|
||||
if agent.config.enable_mcp:
|
||||
# Add OpenHands' MCP server by default
|
||||
default_servers = await OpenHandsMCPConfigImpl.create_default_mcp_server_config(
|
||||
config.mcp_host, config, None
|
||||
)
|
||||
runtime.config.mcp = MCPConfig(
|
||||
mcpServers={**runtime.config.mcp.mcpServers, **default_servers}
|
||||
)
|
||||
|
||||
await add_mcp_tools_to_agent(agent, runtime, memory)
|
||||
|
||||
replay_events: list[Event] | None = None
|
||||
if config.replay_trajectory_path:
|
||||
logger.info('Trajectory replay is enabled')
|
||||
assert isinstance(initial_user_action, NullAction)
|
||||
replay_events, initial_user_action = load_replay_log(
|
||||
config.replay_trajectory_path
|
||||
)
|
||||
|
||||
controller, initial_state = create_controller(
|
||||
agent, runtime, config, conversation_stats, replay_events=replay_events
|
||||
)
|
||||
|
||||
assert isinstance(initial_user_action, Action), (
|
||||
f'initial user actions must be an Action, got {type(initial_user_action)}'
|
||||
)
|
||||
logger.debug(
|
||||
f'Agent Controller Initialized: Running agent {agent.name}, model '
|
||||
f'{agent.llm.config.model}, with actions: {initial_user_action}'
|
||||
)
|
||||
|
||||
# Set up asyncio-safe signal handler for graceful shutdown
|
||||
sigint_count = 0
|
||||
shutdown_event = asyncio.Event()
|
||||
|
||||
def signal_handler():
|
||||
"""Handle SIGINT signals for graceful shutdown."""
|
||||
nonlocal sigint_count
|
||||
sigint_count += 1
|
||||
|
||||
if sigint_count == 1:
|
||||
logger.info('Received SIGINT (Ctrl+C). Initiating graceful shutdown...')
|
||||
logger.info('Press Ctrl+C again to force immediate exit.')
|
||||
shutdown_event.set()
|
||||
else:
|
||||
logger.info('Received second SIGINT. Forcing immediate exit...')
|
||||
sys.exit(1)
|
||||
|
||||
# Register the asyncio signal handler (safer for async contexts)
|
||||
loop = asyncio.get_running_loop()
|
||||
loop.add_signal_handler(signal.SIGINT, signal_handler)
|
||||
|
||||
# start event is a MessageAction with the task, either resumed or new
|
||||
if initial_state is not None and initial_state.last_error:
|
||||
# we're resuming the previous session
|
||||
event_stream.add_event(
|
||||
MessageAction(
|
||||
content=(
|
||||
"Let's get back on track. If you experienced errors before, do "
|
||||
'NOT resume your task. Ask me about it.'
|
||||
),
|
||||
),
|
||||
EventSource.USER,
|
||||
)
|
||||
else:
|
||||
# init with the provided actions
|
||||
event_stream.add_event(initial_user_action, EventSource.USER)
|
||||
|
||||
def on_event(event: Event) -> None:
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
if event.agent_state == AgentState.AWAITING_USER_INPUT:
|
||||
if exit_on_message:
|
||||
message = '/exit'
|
||||
elif fake_user_response_fn is None:
|
||||
message = read_input(config.cli_multiline_input)
|
||||
else:
|
||||
message = fake_user_response_fn(controller.get_state())
|
||||
action = MessageAction(content=message)
|
||||
event_stream.add_event(action, EventSource.USER)
|
||||
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
|
||||
|
||||
end_states = [
|
||||
AgentState.FINISHED,
|
||||
AgentState.REJECTED,
|
||||
AgentState.ERROR,
|
||||
AgentState.PAUSED,
|
||||
AgentState.STOPPED,
|
||||
]
|
||||
|
||||
try:
|
||||
# Create a task for the main agent loop
|
||||
agent_task = asyncio.create_task(
|
||||
run_agent_until_done(controller, runtime, memory, end_states)
|
||||
)
|
||||
|
||||
# Wait for either the agent to complete or shutdown signal
|
||||
done, pending = await asyncio.wait(
|
||||
[agent_task, asyncio.create_task(shutdown_event.wait())],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
|
||||
# Cancel any pending tasks
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
|
||||
# Wait for all cancelled tasks to complete in parallel
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
# Check if shutdown was requested
|
||||
if shutdown_event.is_set():
|
||||
logger.info('Graceful shutdown requested.')
|
||||
|
||||
# Perform graceful cleanup sequence
|
||||
try:
|
||||
# 1. Stop the agent controller first to prevent new LLM calls
|
||||
logger.debug('Stopping agent controller...')
|
||||
await controller.close()
|
||||
|
||||
# 2. Stop the EventStream to prevent new events from being processed
|
||||
logger.debug('Stopping EventStream...')
|
||||
event_stream.close()
|
||||
|
||||
# 3. Give time for in-flight operations to complete before closing runtime
|
||||
logger.debug('Waiting for in-flight operations to complete...')
|
||||
await asyncio.sleep(0.3)
|
||||
|
||||
# 4. Close the runtime to avoid bash session interruption errors
|
||||
logger.debug('Closing runtime...')
|
||||
runtime.close()
|
||||
|
||||
# 5. Give a brief moment for final cleanup to complete
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Error during graceful cleanup: {e}')
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Exception in main loop: {e}')
|
||||
|
||||
# save session when we're about to close
|
||||
if config.file_store is not None and config.file_store != 'memory':
|
||||
end_state = controller.get_state()
|
||||
# NOTE: the saved state does not include delegates events
|
||||
end_state.save_to_session(
|
||||
event_stream.sid, event_stream.file_store, event_stream.user_id
|
||||
)
|
||||
|
||||
await controller.close(set_stop_state=False)
|
||||
|
||||
state = controller.get_state()
|
||||
|
||||
# save trajectories if applicable
|
||||
if config.save_trajectory_path is not None:
|
||||
# if save_trajectory_path is a folder, use session id as file name
|
||||
if os.path.isdir(config.save_trajectory_path):
|
||||
file_path = os.path.join(config.save_trajectory_path, sid + '.json')
|
||||
else:
|
||||
file_path = config.save_trajectory_path
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
histories = controller.get_trajectory(config.save_screenshots_in_trajectory)
|
||||
with open(file_path, 'w') as f:
|
||||
json.dump(histories, f, indent=4)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
def auto_continue_response(
|
||||
state: State,
|
||||
encapsulate_solution: bool = False,
|
||||
try_parse: Callable[[Action | None], str] | None = None,
|
||||
) -> str:
|
||||
"""Default function to generate user responses.
|
||||
Tell the agent to proceed without asking for more input, or finish the interaction.
|
||||
"""
|
||||
message = (
|
||||
'Please continue on whatever approach you think is suitable.\n'
|
||||
'If you think you have solved the task, please finish the interaction.\n'
|
||||
'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN RESPONSE.\n'
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
def load_replay_log(trajectory_path: str) -> tuple[list[Event] | None, Action]:
|
||||
"""Load trajectory from given path, serialize it to a list of events, and return
|
||||
two things:
|
||||
1) A list of events except the first action
|
||||
2) First action (user message, a.k.a. initial task)
|
||||
"""
|
||||
try:
|
||||
path = Path(trajectory_path).resolve()
|
||||
|
||||
if not path.exists():
|
||||
raise ValueError(f'Trajectory file not found: {path}')
|
||||
|
||||
if not path.is_file():
|
||||
raise ValueError(f'Trajectory path is a directory, not a file: {path}')
|
||||
|
||||
with open(path, 'r', encoding='utf-8') as file:
|
||||
events = ReplayManager.get_replay_events(json.load(file))
|
||||
assert isinstance(events[0], MessageAction)
|
||||
return events[1:], events[0]
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f'Invalid JSON format in {trajectory_path}: {e}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
args = parse_arguments()
|
||||
|
||||
config: OpenHandsConfig = setup_config_from_args(args)
|
||||
|
||||
# Read task from file, CLI args, or stdin
|
||||
task_str = read_task(args, config.cli_multiline_input)
|
||||
|
||||
initial_user_action: Action = NullAction()
|
||||
if config.replay_trajectory_path:
|
||||
if task_str:
|
||||
raise ValueError(
|
||||
'User-specified task is not supported under trajectory replay mode'
|
||||
)
|
||||
else:
|
||||
if not task_str:
|
||||
raise ValueError('No task provided. Please specify a task through -t, -f.')
|
||||
|
||||
# Create actual initial user action
|
||||
initial_user_action = MessageAction(content=task_str)
|
||||
|
||||
# Set session name
|
||||
session_name = args.name
|
||||
sid = generate_sid(config, session_name)
|
||||
|
||||
asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=initial_user_action,
|
||||
sid=sid,
|
||||
fake_user_response_fn=None
|
||||
if args.no_auto_continue
|
||||
else auto_continue_response,
|
||||
)
|
||||
)
|
||||
@@ -8,17 +8,91 @@
|
||||
import hashlib
|
||||
import os
|
||||
import uuid
|
||||
from typing import Callable
|
||||
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.controller import AgentController
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
)
|
||||
from openhands.core.config.config_utils import DEFAULT_WORKSPACE_MOUNT_PATH_IN_SANDBOX
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events import EventStream
|
||||
from openhands.events.event import Event
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.memory import Memory
|
||||
from openhands.microagent.microagent import BaseMicroagent
|
||||
from openhands.runtime import get_runtime_cls
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.services.conversation_stats import ConversationStats
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
|
||||
def create_runtime(
|
||||
config: OpenHandsConfig,
|
||||
llm_registry: LLMRegistry | None = None,
|
||||
sid: str | None = None,
|
||||
headless_mode: bool = True,
|
||||
agent: Agent | None = None,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
) -> Runtime:
|
||||
"""Create a runtime for the agent to run on.
|
||||
|
||||
Args:
|
||||
config: The app config.
|
||||
sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
Set it to incompatible value will cause unexpected behavior on RemoteRuntime.
|
||||
headless_mode: Whether the agent is run in headless mode. `create_runtime` is typically called within evaluation scripts,
|
||||
where we don't want to have the VSCode UI open, so it defaults to True.
|
||||
agent: (optional) The agent instance to use for configuring the runtime.
|
||||
|
||||
Returns:
|
||||
The created Runtime instance (not yet connected or initialized).
|
||||
"""
|
||||
# if sid is provided on the command line, use it as the name of the event stream
|
||||
# otherwise generate it on the basis of the configured jwt_secret
|
||||
# we can do this better, this is just so that the sid is retrieved when we want to restore the session
|
||||
session_id = sid or generate_sid(config)
|
||||
|
||||
# set up the event stream
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
event_stream = EventStream(session_id, file_store)
|
||||
|
||||
# agent class
|
||||
if agent:
|
||||
agent_cls = type(agent)
|
||||
else:
|
||||
agent_cls = Agent.get_cls(config.default_agent)
|
||||
|
||||
# runtime and tools
|
||||
runtime_cls = get_runtime_cls(config.runtime)
|
||||
logger.debug(f'Initializing runtime: {runtime_cls.__name__}')
|
||||
runtime: Runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=event_stream,
|
||||
sid=session_id,
|
||||
plugins=agent_cls.sandbox_plugins,
|
||||
headless_mode=headless_mode,
|
||||
llm_registry=llm_registry or LLMRegistry(config),
|
||||
git_provider_tokens=git_provider_tokens,
|
||||
)
|
||||
|
||||
# Log the plugins that have been registered with the runtime for debugging purposes
|
||||
logger.debug(
|
||||
f'Runtime created with plugins: {[plugin.name for plugin in runtime.plugins]}'
|
||||
)
|
||||
|
||||
return runtime
|
||||
|
||||
|
||||
def get_provider_tokens():
|
||||
@@ -72,6 +146,134 @@ def get_provider_tokens():
|
||||
return secret_store.provider_tokens if secret_store else None
|
||||
|
||||
|
||||
def initialize_repository_for_runtime(
|
||||
runtime: Runtime,
|
||||
immutable_provider_tokens: PROVIDER_TOKEN_TYPE | None = None,
|
||||
selected_repository: str | None = None,
|
||||
) -> str | None:
|
||||
"""Initialize the repository for the runtime by cloning or initializing it,
|
||||
running setup scripts, and setting up git hooks if present.
|
||||
|
||||
Args:
|
||||
runtime: The runtime to initialize the repository for.
|
||||
immutable_provider_tokens: (optional) Provider tokens to use for authentication.
|
||||
selected_repository: (optional) The repository to use.
|
||||
|
||||
Returns:
|
||||
The repository directory path if a repository was cloned, None otherwise.
|
||||
"""
|
||||
# If provider tokens are not provided, attempt to retrieve them from the environment
|
||||
if not immutable_provider_tokens:
|
||||
immutable_provider_tokens = get_provider_tokens()
|
||||
|
||||
logger.debug(f'Selected repository {selected_repository}.')
|
||||
|
||||
# Clone or initialize the repository using the runtime
|
||||
repo_directory = call_async_from_sync(
|
||||
runtime.clone_or_init_repo,
|
||||
GENERAL_TIMEOUT,
|
||||
immutable_provider_tokens,
|
||||
selected_repository,
|
||||
None,
|
||||
)
|
||||
# Run setup script if it exists in the repository
|
||||
runtime.maybe_run_setup_script()
|
||||
# Set up git hooks if pre-commit.sh exists in the repository
|
||||
runtime.maybe_setup_git_hooks()
|
||||
|
||||
return repo_directory
|
||||
|
||||
|
||||
def create_memory(
|
||||
runtime: Runtime,
|
||||
event_stream: EventStream,
|
||||
sid: str,
|
||||
selected_repository: str | None = None,
|
||||
repo_directory: str | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
conversation_instructions: str | None = None,
|
||||
working_dir: str = DEFAULT_WORKSPACE_MOUNT_PATH_IN_SANDBOX,
|
||||
) -> Memory:
|
||||
"""Create a memory for the agent to use.
|
||||
|
||||
Args:
|
||||
runtime: The runtime to use.
|
||||
event_stream: The event stream it will subscribe to.
|
||||
sid: The session id.
|
||||
selected_repository: The repository to clone and start with, if any.
|
||||
repo_directory: The repository directory, if any.
|
||||
status_callback: Optional callback function to handle status updates.
|
||||
conversation_instructions: Optional instructions that are passed to the agent
|
||||
"""
|
||||
memory = Memory(
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
memory.set_conversation_instructions(conversation_instructions)
|
||||
|
||||
if runtime:
|
||||
# sets available hosts
|
||||
memory.set_runtime_info(runtime, {}, working_dir)
|
||||
|
||||
# loads microagents from repo/.openhands/microagents
|
||||
microagents: list[BaseMicroagent] = runtime.get_microagents_from_selected_repo(
|
||||
selected_repository
|
||||
)
|
||||
memory.load_user_workspace_microagents(microagents)
|
||||
|
||||
if selected_repository and repo_directory:
|
||||
memory.set_repository_info(selected_repository, repo_directory)
|
||||
|
||||
return memory
|
||||
|
||||
|
||||
def create_agent(config: OpenHandsConfig, llm_registry: LLMRegistry) -> Agent:
|
||||
agent_cls: type[Agent] = Agent.get_cls(config.default_agent)
|
||||
agent_config = config.get_agent_config(config.default_agent)
|
||||
# Pass the runtime information from the main config to the agent config
|
||||
agent_config.runtime = config.runtime
|
||||
config.get_llm_config_from_agent(config.default_agent)
|
||||
agent = agent_cls(config=agent_config, llm_registry=llm_registry)
|
||||
return agent
|
||||
|
||||
|
||||
def create_controller(
|
||||
agent: Agent,
|
||||
runtime: Runtime,
|
||||
config: OpenHandsConfig,
|
||||
conversation_stats: ConversationStats,
|
||||
headless_mode: bool = True,
|
||||
replay_events: list[Event] | None = None,
|
||||
) -> tuple[AgentController, State | None]:
|
||||
event_stream = runtime.event_stream
|
||||
initial_state = None
|
||||
try:
|
||||
logger.debug(
|
||||
f'Trying to restore agent state from session {event_stream.sid} if available'
|
||||
)
|
||||
initial_state = State.restore_from_session(
|
||||
event_stream.sid, event_stream.file_store
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Cannot restore agent state: {e}')
|
||||
|
||||
controller = AgentController(
|
||||
agent=agent,
|
||||
conversation_stats=conversation_stats,
|
||||
iteration_delta=config.max_iterations,
|
||||
budget_per_task_delta=config.max_budget_per_task,
|
||||
agent_to_llm_config=config.get_agent_to_llm_config_map(),
|
||||
event_stream=event_stream,
|
||||
initial_state=initial_state,
|
||||
headless_mode=headless_mode,
|
||||
confirmation_mode=config.security.confirmation_mode,
|
||||
replay_events=replay_events,
|
||||
)
|
||||
return (controller, initial_state)
|
||||
|
||||
|
||||
def generate_sid(config: OpenHandsConfig, session_name: str | None = None) -> str:
|
||||
"""Generate a session id based on the session name and the jwt secret.
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import asyncio
|
||||
from typing import Any, AsyncIterator
|
||||
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.event_store import EventStore
|
||||
|
||||
|
||||
class AsyncEventStoreWrapper:
|
||||
def __init__(self, event_store: EventStore, *args: Any, **kwargs: Any) -> None:
|
||||
self.event_store = event_store
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
|
||||
async def __aiter__(self) -> AsyncIterator[Event]:
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
# Create an async generator that yields events
|
||||
for event in self.event_store.search_events(*self.args, **self.kwargs):
|
||||
# Run the blocking search_events() in a thread pool
|
||||
def get_event(e: Event = event) -> Event:
|
||||
return e
|
||||
|
||||
yield await loop.run_in_executor(None, get_event)
|
||||
@@ -1,5 +1,4 @@
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -12,6 +11,7 @@ from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.event import Event, EventSource
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.serialization.event import event_from_dict, event_to_dict
|
||||
from openhands.io import json
|
||||
from openhands.storage import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_dir,
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.action import Action
|
||||
from openhands.events.action.empty import NullAction
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
CmdOutputObservation,
|
||||
NullObservation,
|
||||
Observation,
|
||||
)
|
||||
|
||||
|
||||
def get_pairs_from_events(events: list[Event]) -> list[tuple[Action, Observation]]:
|
||||
"""Return the history as a list of tuples (action, observation).
|
||||
|
||||
This function is a compatibility function for evals reading and visualization working with old histories.
|
||||
"""
|
||||
tuples: list[tuple[Action, Observation]] = []
|
||||
action_map: dict[int, Action] = {}
|
||||
observation_map: dict[int, Observation] = {}
|
||||
|
||||
# runnable actions are set as cause of observations
|
||||
# (MessageAction, NullObservation) for source=USER
|
||||
# (MessageAction, NullObservation) for source=AGENT
|
||||
# (other_action?, NullObservation)
|
||||
# (NullAction, CmdOutputObservation) background CmdOutputObservations
|
||||
|
||||
for event in events:
|
||||
if event.id is None or event.id == -1:
|
||||
logger.debug(f'Event {event} has no ID')
|
||||
|
||||
if isinstance(event, Action):
|
||||
action_map[event.id] = event
|
||||
|
||||
if isinstance(event, Observation):
|
||||
if event.cause is None or event.cause == -1:
|
||||
logger.debug(f'Observation {event} has no cause')
|
||||
|
||||
if event.cause is None:
|
||||
# runnable actions are set as cause of observations
|
||||
# NullObservations have no cause
|
||||
continue
|
||||
|
||||
observation_map[event.cause] = event
|
||||
|
||||
for action_id, action in action_map.items():
|
||||
observation = observation_map.get(action_id)
|
||||
if observation:
|
||||
# observation with a cause
|
||||
tuples.append((action, observation))
|
||||
else:
|
||||
tuples.append((action, NullObservation('')))
|
||||
|
||||
for cause_id, observation in observation_map.items():
|
||||
if cause_id not in action_map:
|
||||
if isinstance(observation, NullObservation):
|
||||
continue
|
||||
if not isinstance(observation, CmdOutputObservation):
|
||||
logger.debug(f'Observation {observation} has no cause')
|
||||
tuples.append((NullAction(), observation))
|
||||
|
||||
return tuples.copy()
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Feature operations for Azure DevOps integration (microagents, suggested tasks, user)."""
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.azure_devops.service.base import AzureDevOpsMixinBase
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
ProviderType,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
@@ -137,3 +139,85 @@ class AzureDevOpsFeaturesMixin(AzureDevOpsMixinBase):
|
||||
continue
|
||||
|
||||
return tasks
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file in Azure DevOps."""
|
||||
org, project, repo = self._parse_repository(repository)
|
||||
# URL-encode components to handle spaces and special characters
|
||||
org_enc = self._encode_url_component(org)
|
||||
project_enc = self._encode_url_component(project)
|
||||
repo_enc = self._encode_url_component(repo)
|
||||
return f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/items?path=/.cursorrules&api-version=7.1'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory in Azure DevOps.
|
||||
|
||||
Note: For org-level microagents (e.g., 'org/.openhands'), Azure DevOps doesn't support
|
||||
this concept, so we raise ValueError to let the caller fall back to other providers.
|
||||
"""
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 3:
|
||||
# Azure DevOps doesn't support org-level configs, only full repo paths
|
||||
raise ValueError(
|
||||
f'Invalid repository format: {repository}. Expected format: organization/project/repo'
|
||||
)
|
||||
org, project, repo = parts[0], parts[1], parts[2]
|
||||
# URL-encode components to handle spaces and special characters
|
||||
org_enc = self._encode_url_component(org)
|
||||
project_enc = self._encode_url_component(project)
|
||||
repo_enc = self._encode_url_component(repo)
|
||||
return f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/items?path=/{microagents_path}&recursionLevel=OneLevel&api-version=7.1'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file in Azure DevOps."""
|
||||
return (
|
||||
not item.get('isFolder', False)
|
||||
and item.get('path', '').endswith('.md')
|
||||
and not item.get('path', '').endswith('README.md')
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item in Azure DevOps."""
|
||||
path = item.get('path', '')
|
||||
return path.split('/')[-1] if path else ''
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item in Azure DevOps."""
|
||||
return item.get('path', '').lstrip('/')
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Get content of a specific microagent file.
|
||||
|
||||
Args:
|
||||
repository: Repository name in Azure DevOps format 'org/project/repo'
|
||||
file_path: Path to the microagent file
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
"""
|
||||
org, project, repo = self._parse_repository(repository)
|
||||
# URL-encode components to handle spaces and special characters
|
||||
org_enc = self._encode_url_component(org)
|
||||
project_enc = self._encode_url_component(project)
|
||||
repo_enc = self._encode_url_component(repo)
|
||||
url = f'{self.base_url}/{org_enc}/{project_enc}/_apis/git/repositories/{repo_enc}/items?path={file_path}&api-version=7.1'
|
||||
|
||||
try:
|
||||
response, _ = await self._make_request(url)
|
||||
content = (
|
||||
response if isinstance(response, str) else response.get('content', '')
|
||||
)
|
||||
|
||||
# Parse the content using the base class method
|
||||
return self._parse_microagent_content(content, file_path)
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to fetch microagent content from {file_path}: {e}')
|
||||
raise
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket.service import (
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketReposMixin,
|
||||
)
|
||||
@@ -19,6 +20,7 @@ class BitBucketService(
|
||||
BitBucketReposMixin,
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
):
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from .base import BitBucketMixinBase
|
||||
from .branches import BitBucketBranchesMixin
|
||||
from .features import BitBucketFeaturesMixin
|
||||
from .prs import BitBucketPRsMixin
|
||||
from .repos import BitBucketReposMixin
|
||||
|
||||
__all__ = [
|
||||
'BitBucketMixinBase',
|
||||
'BitBucketBranchesMixin',
|
||||
'BitBucketFeaturesMixin',
|
||||
'BitBucketPRsMixin',
|
||||
'BitBucketReposMixin',
|
||||
]
|
||||
|
||||
@@ -12,6 +12,7 @@ from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
User,
|
||||
)
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
@@ -235,3 +236,47 @@ class BitBucketMixinBase(BaseGitService, HTTPClient):
|
||||
url = f'{self.BASE_URL}/repositories/{repository}'
|
||||
data, _ = await self._make_request(url)
|
||||
return self._parse_repository(data)
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
return f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{microagents_path}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'commit_file'
|
||||
and item['path'].endswith('.md')
|
||||
and not item['path'].endswith('README.md')
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['path'].split('/')[-1]
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.service.base import BitBucketMixinBase
|
||||
from openhands.integrations.service_types import ResourceNotFoundError
|
||||
from openhands.microagent.types import MicroagentContentResponse
|
||||
|
||||
|
||||
class BitBucketFeaturesMixin(BitBucketMixinBase):
|
||||
"""
|
||||
Mixin for BitBucket feature operations (microagents, cursor rules, etc.)
|
||||
"""
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from Bitbucket repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Step 1: Get repository details using existing method
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
|
||||
if not repo_details.main_branch:
|
||||
logger.warning(
|
||||
f'No main branch found in repository info for {repository}. '
|
||||
f'Repository response: mainbranch field missing'
|
||||
)
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
|
||||
# Step 2: Get file content using the main branch
|
||||
file_url = f'{self.BASE_URL}/repositories/{repository}/src/{repo_details.main_branch}/{file_path}'
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
@@ -4,6 +4,7 @@ from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket_data_center.service import (
|
||||
BitbucketDCBranchesMixin,
|
||||
BitbucketDCFeaturesMixin,
|
||||
BitbucketDCPRsMixin,
|
||||
BitbucketDCReposMixin,
|
||||
BitbucketDCResolverMixin,
|
||||
@@ -19,6 +20,7 @@ from openhands.utils.import_utils import get_impl
|
||||
class BitbucketDCService(
|
||||
BitbucketDCResolverMixin,
|
||||
BitbucketDCBranchesMixin,
|
||||
BitbucketDCFeaturesMixin,
|
||||
BitbucketDCPRsMixin,
|
||||
BitbucketDCReposMixin,
|
||||
GitService,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from .base import BitbucketDCMixinBase
|
||||
from .branches import BitbucketDCBranchesMixin
|
||||
from .features import BitbucketDCFeaturesMixin
|
||||
from .prs import BitbucketDCPRsMixin
|
||||
from .repos import BitbucketDCReposMixin
|
||||
from .resolver import BitbucketDCResolverMixin
|
||||
@@ -7,6 +8,7 @@ from .resolver import BitbucketDCResolverMixin
|
||||
__all__ = [
|
||||
'BitbucketDCMixinBase',
|
||||
'BitbucketDCBranchesMixin',
|
||||
'BitbucketDCFeaturesMixin',
|
||||
'BitbucketDCPRsMixin',
|
||||
'BitbucketDCReposMixin',
|
||||
'BitbucketDCResolverMixin',
|
||||
|
||||
@@ -13,6 +13,7 @@ from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
User,
|
||||
)
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
@@ -281,3 +282,58 @@ class BitbucketDCMixinBase(BaseGitService, HTTPClient):
|
||||
url = self._repo_api_base(owner, repo)
|
||||
data, _ = await self._make_request(url)
|
||||
return await self._parse_repository(data, fetch_default_branch=True)
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
return (
|
||||
f'{self.BASE_URL}/projects/{owner}/repos/{repo}/browse/.cursorrules'
|
||||
f'?at=refs/heads/{repo_details.main_branch}'
|
||||
)
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
# Get repository details to get the main branch
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
if not repo_details.main_branch:
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
return (
|
||||
f'{self.BASE_URL}/projects/{owner}/repos/{repo}/browse/{microagents_path}'
|
||||
f'?at=refs/heads/{repo_details.main_branch}'
|
||||
)
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
file_name = item.get('path', {}).get('name', '')
|
||||
return (
|
||||
item.get('type') == 'FILE'
|
||||
and file_name.endswith('.md')
|
||||
and file_name != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item.get('path', {}).get('name', '')
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
file_name = self._get_file_name_from_item(item)
|
||||
return f'{microagents_path}/{file_name}'
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket_data_center.service.base import (
|
||||
BitbucketDCMixinBase,
|
||||
)
|
||||
from openhands.integrations.service_types import ResourceNotFoundError
|
||||
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
|
||||
|
||||
|
||||
class BitbucketDCFeaturesMixin(BitbucketDCMixinBase):
|
||||
"""
|
||||
Mixin for BitBucket data center feature operations (microagents, cursor rules, etc.)
|
||||
"""
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from Bitbucket data center repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'project/repo_slug'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Step 1: Get repository details using existing method
|
||||
repo_details = await self.get_repository_details_from_repo_name(repository)
|
||||
|
||||
if not repo_details.main_branch:
|
||||
logger.warning(
|
||||
f'No main branch found in repository info for {repository}. '
|
||||
f'Repository response: mainbranch field missing'
|
||||
)
|
||||
raise ResourceNotFoundError(
|
||||
f'Main branch not found for repository {repository}. '
|
||||
f'This repository may be empty or have no default branch configured.'
|
||||
)
|
||||
|
||||
# Step 2: Get file content using the main branch
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
repo_base = self._repo_api_base(owner, repo)
|
||||
|
||||
file_url = f'{repo_base}/browse/{file_path}'
|
||||
params = {'at': f'refs/heads/{repo_details.main_branch}'}
|
||||
response, _ = await self._make_request(file_url, params=params)
|
||||
if isinstance(response, dict):
|
||||
lines = response.get('lines')
|
||||
if isinstance(lines, list):
|
||||
content = '\n'.join(
|
||||
line.get('text', '') for line in lines if isinstance(line, dict)
|
||||
)
|
||||
else:
|
||||
content = response.get('content', '')
|
||||
else:
|
||||
content = str(response)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(content, file_path)
|
||||
|
||||
async def _process_microagents_directory(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> list[MicroagentResponse]:
|
||||
microagents = []
|
||||
try:
|
||||
directory_url = await self._get_microagents_directory_url(
|
||||
repository, microagents_path
|
||||
)
|
||||
directory_params = self._get_microagents_directory_params(microagents_path)
|
||||
response, _ = await self._make_request(directory_url, directory_params)
|
||||
|
||||
# Bitbucket DC browse endpoint nests items under response['children']['values']
|
||||
items = response.get('children', {}).get('values', [])
|
||||
|
||||
for item in items:
|
||||
if self._is_valid_microagent_file(item):
|
||||
try:
|
||||
file_name = self._get_file_name_from_item(item)
|
||||
file_path = self._get_file_path_from_item(
|
||||
item, microagents_path
|
||||
)
|
||||
microagents.append(
|
||||
self._create_microagent_response(file_name, file_path)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error processing microagent {item}: {str(e)}')
|
||||
except ResourceNotFoundError:
|
||||
logger.info(
|
||||
f'No microagents directory found in {repository} at {microagents_path}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching microagents directory: {str(e)}')
|
||||
|
||||
return microagents
|
||||
@@ -1,12 +1,123 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.forgejo.service.base import ForgejoMixinBase
|
||||
from openhands.integrations.service_types import SuggestedTask
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
ProviderType,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
)
|
||||
|
||||
|
||||
class ForgejoFeaturesMixin(ForgejoMixinBase):
|
||||
"""Microagent and feature helpers for Forgejo."""
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
owner, repo = self._split_repo(repository)
|
||||
return self._build_repo_api_url(owner, repo, 'contents', '.cursorrules')
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
owner, repo = self._split_repo(repository)
|
||||
normalized_path = microagents_path.strip('/')
|
||||
return self._build_repo_api_url(owner, repo, 'contents', normalized_path)
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
return None
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict[str, Any] | None) -> bool:
|
||||
if not isinstance(item, dict):
|
||||
return False
|
||||
if item.get('type') != 'file':
|
||||
return False
|
||||
name = item.get('name', '')
|
||||
return isinstance(name, str) and (
|
||||
name.endswith('.md') or name.endswith('.cursorrules')
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict[str, Any] | None) -> str:
|
||||
if not isinstance(item, dict):
|
||||
return ''
|
||||
name = item.get('name')
|
||||
return name if isinstance(name, str) else ''
|
||||
|
||||
def _get_file_path_from_item(
|
||||
self, item: dict[str, Any] | None, microagents_path: str
|
||||
) -> str:
|
||||
file_name = self._get_file_name_from_item(item)
|
||||
if not microagents_path:
|
||||
return file_name
|
||||
return f'{microagents_path.strip("/")}/{file_name}'
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]: # type: ignore[override]
|
||||
microagents_path = self._determine_microagents_path(repository)
|
||||
microagents: list[MicroagentResponse] = []
|
||||
|
||||
try:
|
||||
directory_url = await self._get_microagents_directory_url(
|
||||
repository, microagents_path
|
||||
)
|
||||
items, _ = await self._make_request(directory_url)
|
||||
except ResourceNotFoundError:
|
||||
items = []
|
||||
except Exception as exc:
|
||||
# Fail gracefully if the directory cannot be inspected
|
||||
self._log_microagent_warning(repository, str(exc))
|
||||
items = []
|
||||
|
||||
if isinstance(items, list):
|
||||
for item in items:
|
||||
if self._is_valid_microagent_file(item):
|
||||
file_name = self._get_file_name_from_item(item)
|
||||
file_path = self._get_file_path_from_item(item, microagents_path)
|
||||
microagents.append(
|
||||
self._create_microagent_response(file_name, file_path)
|
||||
)
|
||||
|
||||
cursorrules = await self._check_cursorrules_file(repository)
|
||||
if cursorrules:
|
||||
microagents.append(cursorrules)
|
||||
|
||||
return microagents
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse: # type: ignore[override]
|
||||
owner, repo = self._split_repo(repository)
|
||||
normalized_path = file_path.lstrip('/')
|
||||
url = self._build_repo_api_url(owner, repo, 'contents', normalized_path)
|
||||
|
||||
response, _ = await self._make_request(url)
|
||||
content = response.get('content') or ''
|
||||
encoding = (response.get('encoding') or 'base64').lower()
|
||||
|
||||
if encoding == 'base64':
|
||||
try:
|
||||
decoded = base64.b64decode(content).decode('utf-8')
|
||||
except Exception:
|
||||
decoded = ''
|
||||
else:
|
||||
decoded = content
|
||||
|
||||
try:
|
||||
return self._parse_microagent_content(decoded, file_path)
|
||||
except Exception:
|
||||
return MicroagentContentResponse(
|
||||
content=decoded,
|
||||
path=file_path,
|
||||
triggers=[],
|
||||
git_provider=ProviderType.FORGEJO.value,
|
||||
)
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]: # type: ignore[override]
|
||||
# Suggested tasks are not yet implemented for Forgejo.
|
||||
return []
|
||||
|
||||
def _log_microagent_warning(self, repository: str, message: str) -> None:
|
||||
logger.debug(f'Forgejo microagent scan warning for {repository}: {message}')
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import base64
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.queries import (
|
||||
suggested_task_issue_graphql_query,
|
||||
@@ -5,6 +7,7 @@ from openhands.integrations.github.queries import (
|
||||
)
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
ProviderType,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
@@ -115,3 +118,60 @@ class GitHubFeaturesMixin(GitHubMixinBase):
|
||||
)
|
||||
|
||||
return tasks
|
||||
|
||||
"""
|
||||
Methods specifically for microagent management page
|
||||
"""
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/.cursorrules'
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
return f'{self.BASE_URL}/repos/{repository}/contents/{microagents_path}'
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'file'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return f'{microagents_path}/{item["name"]}'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
return None
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from GitHub repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
file_url = f'{self.BASE_URL}/repos/{repository}/contents/{file_path}'
|
||||
|
||||
file_data, _ = await self._make_request(file_url)
|
||||
file_content = base64.b64decode(file_data['content']).decode('utf-8')
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(file_content, file_path)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from openhands.integrations.gitlab.service.base import GitLabMixinBase
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
ProviderType,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
@@ -12,6 +13,40 @@ class GitLabFeaturesMixin(GitLabMixinBase):
|
||||
Methods used for custom features in UI driven via GitLab integration
|
||||
"""
|
||||
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return (
|
||||
f'{self.BASE_URL}/projects/{project_id}/repository/files/.cursorrules/raw'
|
||||
)
|
||||
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
project_id = self._extract_project_id(repository)
|
||||
return f'{self.BASE_URL}/projects/{project_id}/repository/tree'
|
||||
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict:
|
||||
"""Get parameters for the microagents directory request."""
|
||||
return {'path': microagents_path, 'recursive': 'true'}
|
||||
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
return (
|
||||
item['type'] == 'blob'
|
||||
and item['name'].endswith('.md')
|
||||
and item['name'] != 'README.md'
|
||||
)
|
||||
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
return item['name']
|
||||
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
return item['path']
|
||||
|
||||
async def get_suggested_tasks(self) -> list[SuggestedTask]:
|
||||
"""Get suggested tasks for the authenticated user across all repositories.
|
||||
|
||||
@@ -143,3 +178,30 @@ class GitLabFeaturesMixin(GitLabMixinBase):
|
||||
return tasks
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Fetch individual file content from GitLab repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo' or 'domain/owner/repo'
|
||||
file_path: Path to the file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
RuntimeError: If file cannot be fetched or doesn't exist
|
||||
"""
|
||||
# Extract project_id from repository name
|
||||
project_id = self._extract_project_id(repository)
|
||||
|
||||
encoded_file_path = file_path.replace('/', '%2F')
|
||||
base_url = f'{self.BASE_URL}/projects/{project_id}'
|
||||
file_url = f'{base_url}/repository/files/{encoded_file_path}/raw'
|
||||
|
||||
response, _ = await self._make_request(file_url)
|
||||
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
@@ -33,14 +33,17 @@ from openhands.integrations.service_types import (
|
||||
Branch,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
MicroagentParseError,
|
||||
PaginatedBranchesResponse,
|
||||
ProviderTimeoutError,
|
||||
ProviderType,
|
||||
Repository,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
TokenResponse,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
|
||||
from openhands.server.types import AppMode
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -596,6 +599,104 @@ class ProviderHandler:
|
||||
total_count=0,
|
||||
)
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Get microagents from a repository using the appropriate service.
|
||||
|
||||
Args:
|
||||
repository: Repository name in the format 'owner/repo'
|
||||
|
||||
Returns:
|
||||
List of microagents found in the repository
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails
|
||||
"""
|
||||
# Try all available providers in order
|
||||
errors = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self.get_service(provider)
|
||||
result = await service.get_microagents(repository)
|
||||
# Only return early if we got a non-empty result
|
||||
if result:
|
||||
return result
|
||||
# If we got an empty array, continue checking other providers
|
||||
logger.debug(
|
||||
f'No microagents found on {provider} for {repository}, trying other providers'
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f'{provider.value}: {str(e)}')
|
||||
logger.warning(
|
||||
f'Error fetching microagents from {provider} for {repository}: {e}'
|
||||
)
|
||||
|
||||
# If all providers failed or returned empty results, return empty array
|
||||
if errors:
|
||||
logger.error(
|
||||
f'Failed to fetch microagents for {repository} with all available providers. Errors: {"; ".join(errors)}'
|
||||
)
|
||||
raise AuthenticationError(f'Unable to fetch microagents for {repository}')
|
||||
|
||||
# All providers returned empty arrays
|
||||
return []
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Get content of a specific microagent file from a repository.
|
||||
|
||||
Args:
|
||||
repository: Repository name in the format 'owner/repo'
|
||||
file_path: Path to the microagent file within the repository
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
AuthenticationError: If authentication fails
|
||||
"""
|
||||
# Try all available providers in order
|
||||
errors = []
|
||||
for provider in self.provider_tokens:
|
||||
try:
|
||||
service = self.get_service(provider)
|
||||
result = await service.get_microagent_content(repository, file_path)
|
||||
# If we got content, return it immediately
|
||||
if result:
|
||||
return result
|
||||
# If we got empty content, continue checking other providers
|
||||
logger.debug(
|
||||
f'No content found on {provider} for {repository}/{file_path}, trying other providers'
|
||||
)
|
||||
except ResourceNotFoundError:
|
||||
logger.debug(
|
||||
f'File not found on {provider} for {repository}/{file_path}, trying other providers'
|
||||
)
|
||||
continue
|
||||
except MicroagentParseError as e:
|
||||
# Parsing errors are specific to the provider, add to errors list
|
||||
errors.append(f'{provider.value}: {str(e)}')
|
||||
logger.warning(
|
||||
f'Error parsing microagent content from {provider} for {repository}: {e}'
|
||||
)
|
||||
except Exception as e:
|
||||
# For other errors (auth, rate limit, etc.), add to errors list
|
||||
errors.append(f'{provider.value}: {str(e)}')
|
||||
logger.warning(
|
||||
f'Error fetching microagent content from {provider} for {repository}: {e}'
|
||||
)
|
||||
|
||||
# If all providers failed or returned empty results, raise an error
|
||||
if errors:
|
||||
logger.error(
|
||||
f'Failed to fetch microagent content for {repository} with all available providers. Errors: {"; ".join(errors)}'
|
||||
)
|
||||
|
||||
# All providers returned empty content or file not found
|
||||
raise AuthenticationError(
|
||||
f'Microagent file {file_path} not found in {repository}'
|
||||
)
|
||||
|
||||
async def get_authenticated_git_url(
|
||||
self, repo_name: str, is_optional: bool = False
|
||||
) -> str:
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Protocol
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import BaseModel, SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.microagent.microagent import BaseMicroagent
|
||||
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
@@ -29,6 +33,7 @@ class TaskType(str, Enum):
|
||||
UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS'
|
||||
OPEN_ISSUE = 'OPEN_ISSUE'
|
||||
OPEN_PR = 'OPEN_PR'
|
||||
CREATE_MICROAGENT = 'CREATE_MICROAGENT'
|
||||
|
||||
|
||||
class OwnerType(str, Enum):
|
||||
@@ -115,6 +120,12 @@ class SuggestedTask(BaseModel):
|
||||
return template.render(issue_number=issue_number, repo=repo, **terms)
|
||||
|
||||
|
||||
class CreateMicroagent(BaseModel):
|
||||
repo: str
|
||||
git_provider: ProviderType | None = None
|
||||
title: str | None = None
|
||||
|
||||
|
||||
class UserGitInfo(BaseModel):
|
||||
id: str
|
||||
login: str
|
||||
@@ -196,6 +207,12 @@ class ResourceNotFoundError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class MicroagentParseError(ValueError):
|
||||
"""Raised when there is an error parsing a microagent file."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RequestMethod(Enum):
|
||||
POST = 'post'
|
||||
GET = 'get'
|
||||
@@ -215,6 +232,216 @@ class BaseGitService(ABC):
|
||||
method: RequestMethod = RequestMethod.GET,
|
||||
) -> tuple[Any, dict]: ...
|
||||
|
||||
@abstractmethod
|
||||
async def _get_cursorrules_url(self, repository: str) -> str:
|
||||
"""Get the URL for checking .cursorrules file."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def _get_microagents_directory_url(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> str:
|
||||
"""Get the URL for checking microagents directory."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _get_microagents_directory_params(self, microagents_path: str) -> dict | None:
|
||||
"""Get parameters for the microagents directory request. Return None if no parameters needed."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _is_valid_microagent_file(self, item: dict) -> bool:
|
||||
"""Check if an item represents a valid microagent file."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _get_file_name_from_item(self, item: dict) -> str:
|
||||
"""Extract file name from directory item."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
def _get_file_path_from_item(self, item: dict, microagents_path: str) -> str:
|
||||
"""Extract file path from directory item."""
|
||||
...
|
||||
|
||||
def _determine_microagents_path(self, repository_name: str) -> str:
|
||||
"""Determine the microagents directory path based on repository name."""
|
||||
actual_repo_name = repository_name.split('/')[-1]
|
||||
|
||||
# Check for special repository names that use a different structure
|
||||
if actual_repo_name == '.openhands' or actual_repo_name == 'openhands-config':
|
||||
# For repository name ".openhands", scan "microagents" folder
|
||||
return 'microagents'
|
||||
else:
|
||||
# Default behavior: look for .openhands/microagents directory
|
||||
return '.openhands/microagents'
|
||||
|
||||
def _create_microagent_response(
|
||||
self, file_name: str, path: str
|
||||
) -> MicroagentResponse:
|
||||
"""Create a microagent response from basic file information."""
|
||||
# Extract name without extension
|
||||
name = file_name.replace('.md', '').replace('.cursorrules', 'cursorrules')
|
||||
|
||||
return MicroagentResponse(
|
||||
name=name,
|
||||
path=path,
|
||||
created_at=datetime.now(),
|
||||
)
|
||||
|
||||
def _parse_microagent_content(
|
||||
self, content: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Parse microagent content and extract triggers using BaseMicroagent.load.
|
||||
|
||||
Args:
|
||||
content: Raw microagent file content
|
||||
file_path: Path to the file (used for microagent loading)
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
|
||||
Raises:
|
||||
MicroagentParseError: If the microagent file cannot be parsed
|
||||
"""
|
||||
try:
|
||||
# Use BaseMicroagent.load to properly parse the content
|
||||
# Create a temporary path object for the file
|
||||
temp_path = Path(file_path)
|
||||
|
||||
# Load the microagent using the existing infrastructure
|
||||
microagent = BaseMicroagent.load(path=temp_path, file_content=content)
|
||||
|
||||
# Extract triggers from the microagent's metadata
|
||||
triggers = microagent.metadata.triggers
|
||||
|
||||
# Return the MicroagentContentResponse
|
||||
return MicroagentContentResponse(
|
||||
content=microagent.content,
|
||||
path=file_path,
|
||||
triggers=triggers,
|
||||
git_provider=self.provider,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error parsing microagent content for {file_path}: {str(e)}')
|
||||
raise MicroagentParseError(
|
||||
f'Failed to parse microagent file {file_path}: {str(e)}'
|
||||
)
|
||||
|
||||
async def _fetch_cursorrules_content(self, repository: str) -> Any | None:
|
||||
"""Fetch .cursorrules file content from the repository via API.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
|
||||
Returns:
|
||||
Raw API response content if .cursorrules file exists, None otherwise
|
||||
"""
|
||||
cursorrules_url = await self._get_cursorrules_url(repository)
|
||||
cursorrules_response, _ = await self._make_request(cursorrules_url)
|
||||
return cursorrules_response
|
||||
|
||||
async def _check_cursorrules_file(
|
||||
self, repository: str
|
||||
) -> MicroagentResponse | None:
|
||||
"""Check for .cursorrules file in the repository and return microagent response if found.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
|
||||
Returns:
|
||||
MicroagentResponse for .cursorrules file if found, None otherwise
|
||||
"""
|
||||
try:
|
||||
cursorrules_content = await self._fetch_cursorrules_content(repository)
|
||||
if cursorrules_content:
|
||||
return self._create_microagent_response('.cursorrules', '.cursorrules')
|
||||
except ResourceNotFoundError:
|
||||
logger.debug(f'No .cursorrules file found in {repository}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Error checking .cursorrules file in {repository}: {e}')
|
||||
|
||||
return None
|
||||
|
||||
async def _process_microagents_directory(
|
||||
self, repository: str, microagents_path: str
|
||||
) -> list[MicroagentResponse]:
|
||||
"""Process microagents directory and return list of microagent responses.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
microagents_path: Path to the microagents directory
|
||||
|
||||
Returns:
|
||||
List of MicroagentResponse objects found in the directory
|
||||
"""
|
||||
microagents = []
|
||||
|
||||
try:
|
||||
directory_url = await self._get_microagents_directory_url(
|
||||
repository, microagents_path
|
||||
)
|
||||
directory_params = self._get_microagents_directory_params(microagents_path)
|
||||
response, _ = await self._make_request(directory_url, directory_params)
|
||||
|
||||
# Handle different response structures
|
||||
items = response
|
||||
if isinstance(response, dict) and 'values' in response:
|
||||
# Bitbucket format
|
||||
items = response['values']
|
||||
elif isinstance(response, dict) and 'nodes' in response:
|
||||
# GraphQL format (if used)
|
||||
items = response['nodes']
|
||||
|
||||
for item in items:
|
||||
if self._is_valid_microagent_file(item):
|
||||
try:
|
||||
file_name = self._get_file_name_from_item(item)
|
||||
file_path = self._get_file_path_from_item(
|
||||
item, microagents_path
|
||||
)
|
||||
microagents.append(
|
||||
self._create_microagent_response(file_name, file_path)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Error processing microagent {item.get("name", "unknown")}: {str(e)}'
|
||||
)
|
||||
except ResourceNotFoundError:
|
||||
logger.info(
|
||||
f'No microagents directory found in {repository} at {microagents_path}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f'Error fetching microagents directory: {str(e)}')
|
||||
|
||||
return microagents
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Generic implementation of get_microagents that works across all providers.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
|
||||
Returns:
|
||||
List of microagents found in the repository (without content for performance)
|
||||
"""
|
||||
microagents_path = self._determine_microagents_path(repository)
|
||||
microagents = []
|
||||
|
||||
# Step 1: Check for .cursorrules file
|
||||
cursorrules_microagent = await self._check_cursorrules_file(repository)
|
||||
if cursorrules_microagent:
|
||||
microagents.append(cursorrules_microagent)
|
||||
|
||||
# Step 2: Check for microagents directory and process .md files
|
||||
directory_microagents = await self._process_microagents_directory(
|
||||
repository, microagents_path
|
||||
)
|
||||
microagents.extend(directory_microagents)
|
||||
|
||||
return microagents
|
||||
|
||||
def _truncate_comment(
|
||||
self, comment_body: str, max_comment_length: int = 500
|
||||
) -> str:
|
||||
@@ -304,6 +531,20 @@ class GitService(Protocol):
|
||||
) -> list[Branch]:
|
||||
"""Search for branches within a repository"""
|
||||
|
||||
async def get_microagents(self, repository: str) -> list[MicroagentResponse]:
|
||||
"""Get microagents from a repository"""
|
||||
...
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
"""Get content of a specific microagent file
|
||||
|
||||
Returns:
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request/merge request
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
from openhands.io.io import read_input, read_task, read_task_from_file
|
||||
from openhands.io.json import dumps, loads
|
||||
|
||||
__all__ = [
|
||||
'read_input',
|
||||
'read_task_from_file',
|
||||
'read_task',
|
||||
'dumps',
|
||||
'loads',
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
|
||||
def read_input(cli_multiline_input: bool = False) -> str:
|
||||
"""Read input from user based on config settings."""
|
||||
if cli_multiline_input:
|
||||
print('Enter your message (enter "/exit" on a new line to finish):')
|
||||
lines = []
|
||||
while True:
|
||||
line = input('>> ').rstrip()
|
||||
if line == '/exit': # finish input
|
||||
break
|
||||
lines.append(line)
|
||||
return '\n'.join(lines)
|
||||
else:
|
||||
return input('>> ').rstrip()
|
||||
|
||||
|
||||
def read_task_from_file(file_path: str) -> str:
|
||||
"""Read task from the specified file."""
|
||||
with open(file_path, 'r', encoding='utf-8') as file:
|
||||
return file.read()
|
||||
|
||||
|
||||
def read_task(args: argparse.Namespace, cli_multiline_input: bool) -> str:
|
||||
"""Read the task from the CLI args, file, or stdin."""
|
||||
# Determine the task
|
||||
task_str = ''
|
||||
if args.file:
|
||||
task_str = read_task_from_file(args.file)
|
||||
elif args.task:
|
||||
task_str = args.task
|
||||
elif not sys.stdin.isatty():
|
||||
task_str = read_input(cli_multiline_input)
|
||||
|
||||
return task_str
|
||||
@@ -0,0 +1,75 @@
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from json_repair import repair_json
|
||||
from litellm.types.utils import ModelResponse
|
||||
|
||||
from openhands.core.exceptions import LLMResponseError
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import CmdOutputMetadata
|
||||
from openhands.events.serialization import event_to_dict
|
||||
from openhands.llm.metrics import Metrics
|
||||
|
||||
|
||||
class OpenHandsJSONEncoder(json.JSONEncoder):
|
||||
"""Custom JSON encoder that handles datetime and event objects"""
|
||||
|
||||
def default(self, obj):
|
||||
if isinstance(obj, datetime):
|
||||
return obj.isoformat()
|
||||
if isinstance(obj, Event):
|
||||
return event_to_dict(obj)
|
||||
if isinstance(obj, Metrics):
|
||||
return obj.get()
|
||||
if isinstance(obj, ModelResponse):
|
||||
return obj.model_dump()
|
||||
if isinstance(obj, CmdOutputMetadata):
|
||||
return obj.model_dump()
|
||||
return super().default(obj)
|
||||
|
||||
|
||||
# Create a single reusable encoder instance
|
||||
_json_encoder = OpenHandsJSONEncoder()
|
||||
|
||||
|
||||
def dumps(obj, **kwargs) -> str:
|
||||
"""Serialize an object to str format"""
|
||||
if not kwargs:
|
||||
return _json_encoder.encode(obj)
|
||||
|
||||
# Create a copy of the kwargs to avoid modifying the original
|
||||
encoder_kwargs = kwargs.copy()
|
||||
|
||||
# If cls is specified, use it; otherwise use our custom encoder
|
||||
if 'cls' not in encoder_kwargs:
|
||||
encoder_kwargs['cls'] = OpenHandsJSONEncoder
|
||||
|
||||
return json.dumps(obj, **encoder_kwargs)
|
||||
|
||||
|
||||
def loads(json_str: str, **kwargs) -> Any:
|
||||
"""Create a JSON object from str"""
|
||||
try:
|
||||
return json.loads(json_str, **kwargs)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
depth = 0
|
||||
start = -1
|
||||
for i, char in enumerate(json_str):
|
||||
if char == '{':
|
||||
if depth == 0:
|
||||
start = i
|
||||
depth += 1
|
||||
elif char == '}':
|
||||
depth -= 1
|
||||
if depth == 0 and start != -1:
|
||||
response = json_str[start : i + 1]
|
||||
try:
|
||||
json_str = repair_json(response)
|
||||
return json.loads(json_str, **kwargs)
|
||||
except (json.JSONDecodeError, ValueError, TypeError) as e:
|
||||
raise LLMResponseError(
|
||||
'Invalid JSON in response. Please make sure the response is a valid JSON object.'
|
||||
) from e
|
||||
raise LLMResponseError('No valid JSON object found in response.')
|
||||
@@ -7,7 +7,6 @@
|
||||
# Tag: Legacy-V0
|
||||
# V1 replacement for this module lives in the Software Agent SDK.
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import warnings
|
||||
@@ -246,6 +245,8 @@ class LLM(RetryMixin, DebugMixin):
|
||||
)
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Wrapper for the litellm completion function. Logs the input and output of the completion function."""
|
||||
from openhands.io import json
|
||||
|
||||
messages_kwarg: (
|
||||
dict[str, Any] | Message | list[dict[str, Any]] | list[Message]
|
||||
) = []
|
||||
@@ -504,6 +505,7 @@ class LLM(RetryMixin, DebugMixin):
|
||||
# noinspection PyBroadException
|
||||
except Exception:
|
||||
pass
|
||||
from openhands.io import json
|
||||
|
||||
logger.debug(
|
||||
f'Model info: {json.dumps({"model": self.config.model, "base_url": self.config.base_url}, indent=2)}'
|
||||
|
||||
@@ -2,6 +2,7 @@ from openhands.mcp.client import MCPClient
|
||||
from openhands.mcp.error_collector import mcp_error_collector
|
||||
from openhands.mcp.tool import MCPClientTool
|
||||
from openhands.mcp.utils import (
|
||||
add_mcp_tools_to_agent,
|
||||
call_tool_mcp,
|
||||
convert_mcp_clients_to_tools,
|
||||
create_mcp_clients,
|
||||
@@ -15,5 +16,6 @@ __all__ = [
|
||||
'MCPClientTool',
|
||||
'fetch_mcp_tools_from_config',
|
||||
'call_tool_mcp',
|
||||
'add_mcp_tools_to_agent',
|
||||
'mcp_error_collector',
|
||||
]
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.controller.agent import Agent
|
||||
from openhands.memory.memory import Memory
|
||||
|
||||
|
||||
from mcp import McpError
|
||||
|
||||
@@ -15,6 +21,7 @@ from openhands.events.observation.mcp import MCPObservation
|
||||
from openhands.events.observation.observation import Observation
|
||||
from openhands.mcp.client import MCPClient
|
||||
from openhands.mcp.error_collector import mcp_error_collector
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.utils._redact_compat import (
|
||||
redact_text_secrets,
|
||||
redact_url_params,
|
||||
@@ -262,3 +269,49 @@ async def call_tool_mcp(mcp_clients: list[MCPClient], action: MCPAction) -> Obse
|
||||
name=action.name,
|
||||
arguments=action.arguments,
|
||||
)
|
||||
|
||||
|
||||
async def add_mcp_tools_to_agent(
|
||||
agent: 'Agent', runtime: Runtime, memory: 'Memory'
|
||||
) -> MCPConfig:
|
||||
"""Add MCP tools to an agent."""
|
||||
import sys
|
||||
|
||||
# Skip MCP tools on Windows
|
||||
if sys.platform == 'win32':
|
||||
logger.info('MCP functionality is disabled on Windows, skipping MCP tools')
|
||||
agent.set_mcp_tools([])
|
||||
return
|
||||
|
||||
assert runtime.runtime_initialized, (
|
||||
'Runtime must be initialized before adding MCP tools'
|
||||
)
|
||||
|
||||
extra_stdio_servers: dict[str, StdioMCPServer] = {}
|
||||
|
||||
# Add microagent MCP tools if available
|
||||
microagent_mcp_configs = memory.get_microagent_mcp_tools()
|
||||
for mcp_cfg in microagent_mcp_configs:
|
||||
for name, server in mcp_cfg.mcpServers.items():
|
||||
if isinstance(server, StdioMCPServer):
|
||||
if name not in extra_stdio_servers:
|
||||
extra_stdio_servers[name] = server
|
||||
logger.warning(f'Added microagent stdio server: {name}')
|
||||
else:
|
||||
logger.warning(
|
||||
f'Microagent MCP config contains non-stdio server {name}, not yet supported.'
|
||||
)
|
||||
|
||||
# Add the runtime as another MCP server
|
||||
updated_mcp_config = runtime.get_mcp_config(extra_stdio_servers or None)
|
||||
|
||||
# Fetch the MCP tools
|
||||
mcp_tools = await fetch_mcp_tools_from_config(updated_mcp_config)
|
||||
|
||||
tool_names = [tool['function']['name'] for tool in mcp_tools]
|
||||
logger.info(f'Loaded {len(mcp_tools)} MCP tools: {tool_names}')
|
||||
|
||||
# Set the MCP tools on the agent
|
||||
agent.set_mcp_tools(mcp_tools)
|
||||
|
||||
return updated_mcp_config
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Memory Component
|
||||
|
||||
- Short Term History
|
||||
- Memory Condenser
|
||||
|
||||
## Short Term History
|
||||
- Short term history filters the event stream and computes the messages that are injected into the context
|
||||
- It filters out certain events of no interest for the Agent, such as AgentChangeStateObservation or NullAction/NullObservation
|
||||
- When the context window or the token limit set by the user is exceeded, history starts condensing: chunks of messages into summaries.
|
||||
- Each summary is then injected into the context, in the place of the respective chunk it summarizes
|
||||
|
||||
## Memory Condenser
|
||||
- Memory condenser is responsible for summarizing the chunks of events
|
||||
- It summarizes the earlier events first
|
||||
- It starts with the earliest agent actions and observations between two user messages
|
||||
- Then it does the same for later chunks of events between user messages
|
||||
- If there are no more agent events, it summarizes the user messages, this time one by one, if they're large enough and not immediately after an AgentFinishAction event (we assume those are tasks, potentially important)
|
||||
- Summaries are retrieved from the LLM as AgentSummarizeAction, and are saved in State.
|
||||
@@ -0,0 +1,15 @@
|
||||
import openhands.memory.condenser.impl # noqa F401 (we import this to get the condensers registered)
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condenser,
|
||||
get_condensation_metadata,
|
||||
View,
|
||||
Condensation,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'Condenser',
|
||||
'get_condensation_metadata',
|
||||
'CONDENSER_REGISTRY',
|
||||
'View',
|
||||
'Condensation',
|
||||
]
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config.condenser_config import CondenserConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.view import View
|
||||
|
||||
CONDENSER_METADATA_KEY = 'condenser_meta'
|
||||
"""Key identifying where metadata is stored in a `State` object's `extra_data` field."""
|
||||
|
||||
|
||||
def get_condensation_metadata(state: State) -> list[dict[str, Any]]:
|
||||
"""Utility function to retrieve a list of metadata batches from a `State`.
|
||||
|
||||
Args:
|
||||
state: The state to retrieve metadata from.
|
||||
|
||||
Returns:
|
||||
list[dict[str, Any]]: A list of metadata batches, each representing a condensation.
|
||||
"""
|
||||
if CONDENSER_METADATA_KEY in state.extra_data:
|
||||
return state.extra_data[CONDENSER_METADATA_KEY]
|
||||
return []
|
||||
|
||||
|
||||
CONDENSER_REGISTRY: dict[type[CondenserConfig], type[Condenser]] = {}
|
||||
"""Registry of condenser configurations to their corresponding condenser classes."""
|
||||
|
||||
|
||||
class Condensation(BaseModel):
|
||||
"""Produced by a condenser to indicate the history has been condensed."""
|
||||
|
||||
action: CondensationAction
|
||||
|
||||
|
||||
class Condenser(ABC):
|
||||
"""Abstract condenser interface.
|
||||
|
||||
Condensers take a list of `Event` objects and reduce them into a potentially smaller list.
|
||||
|
||||
Agents can use condensers to reduce the amount of events they need to consider when deciding which action to take. To use a condenser, agents can call the `condensed_history` method on the current `State` being considered and use the results instead of the full history.
|
||||
|
||||
If the condenser returns a `Condensation` instead of a `View`, the agent should return `Condensation.action` instead of producing its own action. On the next agent step the condenser will use that condensation event to produce a new `View`.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._metadata_batch: dict[str, Any] = {}
|
||||
self._llm_metadata: dict[str, Any] = {}
|
||||
|
||||
def add_metadata(self, key: str, value: Any) -> None:
|
||||
"""Add information to the current metadata batch.
|
||||
|
||||
Any key/value pairs added to the metadata batch will be recorded in the `State` at the end of the current condensation.
|
||||
|
||||
Args:
|
||||
key: The key to store the metadata under.
|
||||
|
||||
value: The metadata to store.
|
||||
"""
|
||||
self._metadata_batch[key] = value
|
||||
|
||||
def write_metadata(self, state: State) -> None:
|
||||
"""Write the current batch of metadata to the `State`.
|
||||
|
||||
Resets the current metadata batch: any metadata added after this call will be stored in a new batch and written to the `State` at the end of the next condensation.
|
||||
"""
|
||||
if CONDENSER_METADATA_KEY not in state.extra_data:
|
||||
state.extra_data[CONDENSER_METADATA_KEY] = []
|
||||
if self._metadata_batch:
|
||||
state.extra_data[CONDENSER_METADATA_KEY].append(self._metadata_batch)
|
||||
|
||||
# Since the batch has been written, clear it for the next condensation
|
||||
self._metadata_batch = {}
|
||||
|
||||
@contextmanager
|
||||
def metadata_batch(self, state: State):
|
||||
"""Context manager to ensure batched metadata is always written to the `State`."""
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.write_metadata(state)
|
||||
|
||||
@abstractmethod
|
||||
def condense(self, View) -> View | Condensation:
|
||||
"""Condense a sequence of events into a potentially smaller list.
|
||||
|
||||
New condenser strategies should override this method to implement their own condensation logic. Call `self.add_metadata` in the implementation to record any relevant per-condensation diagnostic information.
|
||||
|
||||
Args:
|
||||
View: A view of the history containing all events that should be condensed.
|
||||
|
||||
Returns:
|
||||
View | Condensation: A condensed view of the events or an event indicating the history has been condensed.
|
||||
"""
|
||||
|
||||
def condensed_history(self, state: State) -> View | Condensation:
|
||||
"""Condense the state's history."""
|
||||
if hasattr(self, 'llm'):
|
||||
model_name = self.llm.config.model
|
||||
else:
|
||||
model_name = 'unknown'
|
||||
|
||||
self._llm_metadata = state.to_llm_metadata(
|
||||
model_name=model_name, agent_name='condenser'
|
||||
)
|
||||
with self.metadata_batch(state):
|
||||
return self.condense(state.view)
|
||||
|
||||
@property
|
||||
def llm_metadata(self) -> dict[str, Any]:
|
||||
"""Metadata to be passed to the LLM when using this condenser.
|
||||
|
||||
This metadata is used to provide context about the condensation process and can be used by the LLM to understand how the history was condensed.
|
||||
"""
|
||||
if not self._llm_metadata:
|
||||
logger.warning(
|
||||
'LLM metadata is empty. Ensure to set it in the condenser implementation.'
|
||||
)
|
||||
return self._llm_metadata
|
||||
|
||||
@classmethod
|
||||
def register_config(cls, configuration_type: type[CondenserConfig]) -> None:
|
||||
"""Register a new condenser configuration type.
|
||||
|
||||
Instances of registered configuration types can be passed to `from_config` to create instances of the corresponding condenser.
|
||||
|
||||
Args:
|
||||
configuration_type: The type of configuration used to create instances of the condenser.
|
||||
|
||||
Raises:
|
||||
ValueError: If the configuration type is already registered.
|
||||
"""
|
||||
if configuration_type in CONDENSER_REGISTRY:
|
||||
raise ValueError(
|
||||
f'Condenser configuration {configuration_type} is already registered'
|
||||
)
|
||||
CONDENSER_REGISTRY[configuration_type] = cls
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: CondenserConfig, llm_registry: LLMRegistry
|
||||
) -> Condenser:
|
||||
"""Create a condenser from a configuration object.
|
||||
|
||||
Args:
|
||||
config: Configuration for the condenser.
|
||||
|
||||
Returns:
|
||||
Condenser: A condenser instance.
|
||||
|
||||
Raises:
|
||||
ValueError: If the condenser type is not recognized.
|
||||
"""
|
||||
try:
|
||||
condenser_class = CONDENSER_REGISTRY[type(config)]
|
||||
return condenser_class.from_config(config, llm_registry)
|
||||
except KeyError:
|
||||
raise ValueError(f'Unknown condenser config: {config}')
|
||||
|
||||
|
||||
class RollingCondenser(Condenser, ABC):
|
||||
"""Base class for a specialized condenser strategy that applies condensation to a rolling history.
|
||||
|
||||
The rolling history is generated by `View.from_events`, which analyzes all events in the history and produces a `View` object representing what will be sent to the LLM.
|
||||
|
||||
If `should_condense` says so, the condenser is then responsible for generating a `Condensation` object from the `View` object. This will be added to the event history which should -- when given to `get_view` -- produce the condensed `View` to be passed to the LLM.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def should_condense(self, view: View) -> bool:
|
||||
"""Determine if a view should be condensed."""
|
||||
|
||||
@abstractmethod
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
"""Get the condensation from a view."""
|
||||
|
||||
def condense(self, view: View) -> View | Condensation:
|
||||
# If we trigger the condenser-specific condensation threshold, compute and return
|
||||
# the condensation.
|
||||
if self.should_condense(view):
|
||||
return self.get_condensation(view)
|
||||
|
||||
# Otherwise we're safe to just return the view.
|
||||
else:
|
||||
return view
|
||||
@@ -0,0 +1,41 @@
|
||||
from openhands.memory.condenser.impl.amortized_forgetting_condenser import (
|
||||
AmortizedForgettingCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.browser_output_condenser import (
|
||||
BrowserOutputCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.conversation_window_condenser import (
|
||||
ConversationWindowCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.llm_attention_condenser import (
|
||||
ImportantEventSelection,
|
||||
LLMAttentionCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.llm_summarizing_condenser import (
|
||||
LLMSummarizingCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.no_op_condenser import NoOpCondenser
|
||||
from openhands.memory.condenser.impl.observation_masking_condenser import (
|
||||
ObservationMaskingCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.pipeline import CondenserPipeline
|
||||
from openhands.memory.condenser.impl.recent_events_condenser import (
|
||||
RecentEventsCondenser,
|
||||
)
|
||||
from openhands.memory.condenser.impl.structured_summary_condenser import (
|
||||
StructuredSummaryCondenser,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'AmortizedForgettingCondenser',
|
||||
'LLMAttentionCondenser',
|
||||
'ImportantEventSelection',
|
||||
'LLMSummarizingCondenser',
|
||||
'NoOpCondenser',
|
||||
'ObservationMaskingCondenser',
|
||||
'BrowserOutputCondenser',
|
||||
'RecentEventsCondenser',
|
||||
'StructuredSummaryCondenser',
|
||||
'CondenserPipeline',
|
||||
'ConversationWindowCondenser',
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import AmortizedForgettingCondenserConfig
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class AmortizedForgettingCondenser(RollingCondenser):
|
||||
"""A condenser that maintains a condensed history and forgets old events when it grows too large."""
|
||||
|
||||
def __init__(self, max_size: int = 100, keep_first: int = 0):
|
||||
"""Initialize the condenser.
|
||||
|
||||
Args:
|
||||
max_size: Maximum size of history before forgetting.
|
||||
keep_first: Number of initial events to always keep.
|
||||
|
||||
Raises:
|
||||
ValueError: If keep_first is greater than max_size, keep_first is negative, or max_size is non-positive.
|
||||
"""
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
)
|
||||
if keep_first < 0:
|
||||
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
|
||||
if max_size < 1:
|
||||
raise ValueError(f'max_size ({max_size}) cannot be non-positive')
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
|
||||
super().__init__()
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
target_size = self.max_size // 2
|
||||
head = view[: self.keep_first]
|
||||
|
||||
events_from_tail = target_size - len(head)
|
||||
tail = view[-events_from_tail:]
|
||||
|
||||
event_ids_to_keep = {event.id for event in head + tail}
|
||||
event_ids_to_forget = {event.id for event in view} - event_ids_to_keep
|
||||
|
||||
event = CondensationAction(
|
||||
forgotten_events_start_id=min(event_ids_to_forget),
|
||||
forgotten_events_end_id=max(event_ids_to_forget),
|
||||
)
|
||||
|
||||
return Condensation(action=event)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size or view.unhandled_condensation_request
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
config: AmortizedForgettingCondenserConfig,
|
||||
llm_registry: LLMRegistry,
|
||||
) -> AmortizedForgettingCondenser:
|
||||
return AmortizedForgettingCondenser(**config.model_dump(exclude={'type'}))
|
||||
|
||||
|
||||
AmortizedForgettingCondenser.register_config(AmortizedForgettingCondenserConfig)
|
||||
@@ -0,0 +1,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import BrowserOutputCondenserConfig
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import BrowserOutputObservation
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import Condensation, Condenser, View
|
||||
|
||||
|
||||
class BrowserOutputCondenser(Condenser):
|
||||
"""A condenser that masks the observations from browser outputs outside of a recent attention window.
|
||||
|
||||
The intent here is to mask just the browser outputs and leave everything else untouched. This is important because currently we provide screenshots and accessibility trees as input to the model for browser observations. These are really large and consume a lot of tokens without any benefits in performance. So we want to mask all such observations from all previous timesteps, and leave only the most recent one in context.
|
||||
"""
|
||||
|
||||
def __init__(self, attention_window: int = 1):
|
||||
self.attention_window = attention_window
|
||||
super().__init__()
|
||||
|
||||
def condense(self, view: View) -> View | Condensation:
|
||||
"""Replace the content of browser observations outside of the attention window with a placeholder."""
|
||||
results: list[Event] = []
|
||||
cnt: int = 0
|
||||
for event in reversed(view):
|
||||
if (
|
||||
isinstance(event, BrowserOutputObservation)
|
||||
and cnt >= self.attention_window
|
||||
):
|
||||
results.append(
|
||||
AgentCondensationObservation(
|
||||
f'Visited URL {event.url}\nContent omitted'
|
||||
)
|
||||
)
|
||||
else:
|
||||
results.append(event)
|
||||
if isinstance(event, BrowserOutputObservation):
|
||||
cnt += 1
|
||||
|
||||
return View(events=list(reversed(results)))
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: BrowserOutputCondenserConfig, llm_registry: LLMRegistry
|
||||
) -> BrowserOutputCondenser:
|
||||
return BrowserOutputCondenser(**config.model_dump(exclude={'type'}))
|
||||
|
||||
|
||||
BrowserOutputCondenser.register_config(BrowserOutputCondenserConfig)
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import ConversationWindowCondenserConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.events.action.agent import (
|
||||
CondensationAction,
|
||||
RecallAction,
|
||||
)
|
||||
from openhands.events.action.message import MessageAction, SystemMessageAction
|
||||
from openhands.events.event import EventSource
|
||||
from openhands.events.observation import Observation
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import Condensation, RollingCondenser, View
|
||||
|
||||
|
||||
class ConversationWindowCondenser(RollingCondenser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
"""Apply conversation window truncation similar to _apply_conversation_window.
|
||||
|
||||
This method:
|
||||
1. Identifies essential initial events (System Message, First User Message, Recall Observation)
|
||||
2. Keeps roughly half of the history
|
||||
3. Ensures action-observation pairs are preserved
|
||||
4. Returns a CondensationAction specifying which events to forget
|
||||
"""
|
||||
events = view.events
|
||||
|
||||
# Handle empty history
|
||||
if not events:
|
||||
# No events to condense
|
||||
action = CondensationAction(forgotten_event_ids=[])
|
||||
return Condensation(action=action)
|
||||
|
||||
# 1. Identify essential initial events
|
||||
system_message: SystemMessageAction | None = None
|
||||
first_user_msg: MessageAction | None = None
|
||||
recall_action: RecallAction | None = None
|
||||
recall_observation: Observation | None = None
|
||||
|
||||
# Find System Message (should be the first event, if it exists)
|
||||
system_message = next(
|
||||
(e for e in events if isinstance(e, SystemMessageAction)), None
|
||||
)
|
||||
|
||||
# Find First User Message
|
||||
first_user_msg = next(
|
||||
(
|
||||
e
|
||||
for e in events
|
||||
if isinstance(e, MessageAction) and e.source == EventSource.USER
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if first_user_msg is None:
|
||||
logger.warning(
|
||||
'No first user message found in history during condensation.'
|
||||
)
|
||||
# Return empty condensation if no user message
|
||||
action = CondensationAction(forgotten_event_ids=[])
|
||||
return Condensation(action=action)
|
||||
|
||||
# Find the first user message index
|
||||
first_user_msg_index = -1
|
||||
for i, event in enumerate(events):
|
||||
if isinstance(event, MessageAction) and event.source == EventSource.USER:
|
||||
first_user_msg_index = i
|
||||
break
|
||||
|
||||
# Find Recall Action and Observation related to the First User Message
|
||||
for i in range(first_user_msg_index + 1, len(events)):
|
||||
event = events[i]
|
||||
if (
|
||||
isinstance(event, RecallAction)
|
||||
and event.query == first_user_msg.content
|
||||
):
|
||||
recall_action = event
|
||||
# Look for its observation
|
||||
for j in range(i + 1, len(events)):
|
||||
obs_event = events[j]
|
||||
if (
|
||||
isinstance(obs_event, Observation)
|
||||
and obs_event.cause == recall_action.id
|
||||
):
|
||||
recall_observation = obs_event
|
||||
break
|
||||
break
|
||||
|
||||
# Collect essential events
|
||||
essential_events: list[int] = [] # Store event IDs
|
||||
if system_message:
|
||||
essential_events.append(system_message.id)
|
||||
essential_events.append(first_user_msg.id)
|
||||
if recall_action:
|
||||
essential_events.append(recall_action.id)
|
||||
if recall_observation:
|
||||
essential_events.append(recall_observation.id)
|
||||
|
||||
# 2. Determine which events to keep
|
||||
num_essential_events = len(essential_events)
|
||||
total_events = len(events)
|
||||
num_non_essential_events = total_events - num_essential_events
|
||||
|
||||
# Keep roughly half of the non-essential events
|
||||
num_recent_to_keep = max(1, num_non_essential_events // 2)
|
||||
|
||||
# Calculate the starting index for recent events to keep
|
||||
slice_start_index = total_events - num_recent_to_keep
|
||||
slice_start_index = max(0, slice_start_index)
|
||||
|
||||
# 3. Handle dangling observations at the start of the slice
|
||||
# Find the first non-observation event in the slice
|
||||
recent_events_slice = events[slice_start_index:]
|
||||
first_valid_event_index_in_slice = 0
|
||||
for i, event in enumerate(recent_events_slice):
|
||||
if not isinstance(event, Observation):
|
||||
first_valid_event_index_in_slice = i
|
||||
break
|
||||
else:
|
||||
# All events in the slice are observations
|
||||
first_valid_event_index_in_slice = len(recent_events_slice)
|
||||
|
||||
# Check if all events in the recent slice are dangling observations
|
||||
if first_valid_event_index_in_slice == len(recent_events_slice):
|
||||
logger.warning(
|
||||
'All recent events are dangling observations, which we truncate. This means the agent has only the essential first events. This should not happen.'
|
||||
)
|
||||
|
||||
# Calculate the actual index in the full events list
|
||||
first_valid_event_index = slice_start_index + first_valid_event_index_in_slice
|
||||
|
||||
if first_valid_event_index_in_slice > 0:
|
||||
logger.debug(
|
||||
f'Removed {first_valid_event_index_in_slice} dangling observation(s) '
|
||||
f'from the start of recent event slice.'
|
||||
)
|
||||
|
||||
# 4. Determine which events to keep and which to forget
|
||||
events_to_keep: set[int] = set(essential_events)
|
||||
|
||||
# Add recent events starting from first_valid_event_index
|
||||
for i in range(first_valid_event_index, total_events):
|
||||
events_to_keep.add(events[i].id)
|
||||
|
||||
# Calculate which events to forget
|
||||
all_event_ids = {e.id for e in events}
|
||||
forgotten_event_ids = sorted(all_event_ids - events_to_keep)
|
||||
|
||||
logger.info(
|
||||
f'ConversationWindowCondenser: Keeping {len(events_to_keep)} events, '
|
||||
f'forgetting {len(forgotten_event_ids)} events.'
|
||||
)
|
||||
|
||||
# Create the condensation action
|
||||
if forgotten_event_ids:
|
||||
# Use range if the forgotten events are contiguous
|
||||
if (
|
||||
len(forgotten_event_ids) > 1
|
||||
and forgotten_event_ids[-1] - forgotten_event_ids[0]
|
||||
== len(forgotten_event_ids) - 1
|
||||
):
|
||||
action = CondensationAction(
|
||||
forgotten_events_start_id=forgotten_event_ids[0],
|
||||
forgotten_events_end_id=forgotten_event_ids[-1],
|
||||
)
|
||||
else:
|
||||
action = CondensationAction(forgotten_event_ids=forgotten_event_ids)
|
||||
else:
|
||||
action = CondensationAction(forgotten_event_ids=[])
|
||||
|
||||
return Condensation(action=action)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return view.unhandled_condensation_request
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
_config: ConversationWindowCondenserConfig,
|
||||
llm_registry: LLMRegistry,
|
||||
) -> ConversationWindowCondenser:
|
||||
return ConversationWindowCondenser()
|
||||
|
||||
|
||||
ConversationWindowCondenser.register_config(ConversationWindowCondenserConfig)
|
||||
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from litellm import supports_response_schema
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.config.condenser_config import LLMAttentionCondenserConfig
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class ImportantEventSelection(BaseModel):
|
||||
"""Utility class for the `LLMAttentionCondenser` that forces the LLM to return a list of integers."""
|
||||
|
||||
ids: list[int]
|
||||
|
||||
|
||||
class LLMAttentionCondenser(RollingCondenser):
|
||||
"""Rolling condenser strategy that uses an LLM to select the most important events when condensing the history."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLM,
|
||||
max_size: int = 100,
|
||||
keep_first: int = 1,
|
||||
):
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
)
|
||||
if keep_first < 0:
|
||||
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
|
||||
if max_size < 1:
|
||||
raise ValueError(f'max_size ({max_size}) cannot be non-positive')
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
self.llm = llm
|
||||
|
||||
# This condenser relies on the `response_schema` feature, which is not supported by all LLMs
|
||||
if not supports_response_schema(
|
||||
model=self.llm.config.model,
|
||||
custom_llm_provider=self.llm.config.custom_llm_provider,
|
||||
):
|
||||
raise ValueError(
|
||||
"The LLM model must support the 'response_schema' parameter to use the LLMAttentionCondenser."
|
||||
)
|
||||
|
||||
super().__init__()
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
target_size = self.max_size // 2
|
||||
head_event_ids = [event.id for event in view.events[: self.keep_first]]
|
||||
|
||||
events_from_tail = target_size - len(head_event_ids)
|
||||
|
||||
message: str = """You will be given a list of actions, observations, and thoughts from a coding agent.
|
||||
Each item in the list has an identifier. Please sort the identifiers in order of how important the
|
||||
contents of the item are for the next step of the coding agent's task, from most important to least
|
||||
important."""
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=[
|
||||
{'content': message, 'role': 'user'},
|
||||
*[
|
||||
{
|
||||
'content': f'<ID>{e.id}</ID>\n<CONTENT>{e.message}</CONTENT>',
|
||||
'role': 'user',
|
||||
}
|
||||
for e in view
|
||||
],
|
||||
],
|
||||
response_format={
|
||||
'type': 'json_schema',
|
||||
'json_schema': {
|
||||
'name': 'ImportantEventSelection',
|
||||
'schema': ImportantEventSelection.model_json_schema(),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
response_ids = ImportantEventSelection.model_validate_json(
|
||||
response.choices[0].message.content
|
||||
).ids
|
||||
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
# Filter out any IDs from the head and trim the results down
|
||||
response_ids = [
|
||||
response_id
|
||||
for response_id in response_ids
|
||||
if response_id not in head_event_ids
|
||||
][:events_from_tail]
|
||||
|
||||
# If the response IDs aren't _long_ enough, iterate backwards through the events and add any unfound IDs to the list.
|
||||
for event in reversed(view):
|
||||
if len(response_ids) >= events_from_tail:
|
||||
break
|
||||
if event.id not in response_ids:
|
||||
response_ids.append(event.id)
|
||||
|
||||
# Now that we've found the right number of events to keep, convert this into a list of events to forget.
|
||||
event = CondensationAction(
|
||||
forgotten_event_ids=[
|
||||
event.id
|
||||
for event in view
|
||||
if event.id not in response_ids and event.id not in head_event_ids
|
||||
],
|
||||
)
|
||||
|
||||
return Condensation(action=event)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size or view.unhandled_condensation_request
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: LLMAttentionCondenserConfig, llm_registry: LLMRegistry
|
||||
) -> LLMAttentionCondenser:
|
||||
# This condenser cannot take advantage of prompt caching. If it happens
|
||||
# to be set, we'll pay for the cache writes but never get a chance to
|
||||
# save on a read.
|
||||
llm_config = config.llm_config.model_copy()
|
||||
llm_config.caching_prompt = False
|
||||
|
||||
llm = llm_registry.get_llm('condenser', llm_config)
|
||||
|
||||
return LLMAttentionCondenser(
|
||||
llm=llm,
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
)
|
||||
|
||||
|
||||
LLMAttentionCondenser.register_config(LLMAttentionCondenserConfig)
|
||||
@@ -0,0 +1,182 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import LLMSummarizingCondenserConfig
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class LLMSummarizingCondenser(RollingCondenser):
|
||||
"""A condenser that summarizes forgotten events.
|
||||
|
||||
Maintains a condensed history and forgets old events when it grows too large,
|
||||
keeping a special summarization event after the prefix that summarizes all previous summarizations
|
||||
and newly forgotten events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLM,
|
||||
max_size: int = 100,
|
||||
keep_first: int = 1,
|
||||
max_event_length: int = 10_000,
|
||||
):
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
)
|
||||
if keep_first < 0:
|
||||
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
|
||||
if max_size < 1:
|
||||
raise ValueError(f'max_size ({max_size}) cannot be non-positive')
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
self.max_event_length = max_event_length
|
||||
self.llm = llm
|
||||
|
||||
super().__init__()
|
||||
|
||||
def _truncate(self, content: str) -> str:
|
||||
"""Truncate the content to fit within the specified maximum event length."""
|
||||
return truncate_content(content, max_chars=self.max_event_length)
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
head = view[: self.keep_first]
|
||||
target_size = self.max_size // 2
|
||||
# Number of events to keep from the tail -- target size, minus however many
|
||||
# prefix events from the head, minus one for the summarization event
|
||||
events_from_tail = target_size - len(head) - 1
|
||||
|
||||
summary_event = (
|
||||
view[self.keep_first]
|
||||
if isinstance(view[self.keep_first], AgentCondensationObservation)
|
||||
else AgentCondensationObservation('No events summarized')
|
||||
)
|
||||
|
||||
# Identify events to be forgotten (those not in head or tail)
|
||||
forgotten_events = []
|
||||
for event in view[self.keep_first : -events_from_tail]:
|
||||
if not isinstance(event, AgentCondensationObservation):
|
||||
forgotten_events.append(event)
|
||||
|
||||
# Construct prompt for summarization
|
||||
prompt = """You are maintaining a context-aware state summary for an interactive agent.
|
||||
You will be given a list of events corresponding to actions taken by the agent, and the most recent previous summary if one exists.
|
||||
If the events being summarized contain ANY task-tracking, you MUST include a TASK_TRACKING section to maintain continuity.
|
||||
When referencing tasks make sure to preserve exact task IDs and statuses.
|
||||
|
||||
Track:
|
||||
|
||||
USER_CONTEXT: (Preserve essential user requirements, goals, and clarifications in concise form)
|
||||
|
||||
TASK_TRACKING: {Active tasks, their IDs and statuses - PRESERVE TASK IDs}
|
||||
|
||||
COMPLETED: (Tasks completed so far, with brief results)
|
||||
PENDING: (Tasks that still need to be done)
|
||||
CURRENT_STATE: (Current variables, data structures, or relevant state)
|
||||
|
||||
For code-specific tasks, also include:
|
||||
CODE_STATE: {File paths, function signatures, data structures}
|
||||
TESTS: {Failing cases, error messages, outputs}
|
||||
CHANGES: {Code edits, variable updates}
|
||||
DEPS: {Dependencies, imports, external calls}
|
||||
VERSION_CONTROL_STATUS: {Repository state, current branch, PR status, commit history}
|
||||
|
||||
PRIORITIZE:
|
||||
1. Adapt tracking format to match the actual task type
|
||||
2. Capture key user requirements and goals
|
||||
3. Distinguish between completed and pending tasks
|
||||
4. Keep all sections concise and relevant
|
||||
|
||||
SKIP: Tracking irrelevant details for the current task type
|
||||
|
||||
Example formats:
|
||||
|
||||
For code tasks:
|
||||
USER_CONTEXT: Fix FITS card float representation issue
|
||||
COMPLETED: Modified mod_float() in card.py, all tests passing
|
||||
PENDING: Create PR, update documentation
|
||||
CODE_STATE: mod_float() in card.py updated
|
||||
TESTS: test_format() passed
|
||||
CHANGES: str(val) replaces f"{val:.16G}"
|
||||
DEPS: None modified
|
||||
VERSION_CONTROL_STATUS: Branch: fix-float-precision, Latest commit: a1b2c3d
|
||||
|
||||
For other tasks:
|
||||
USER_CONTEXT: Write 20 haikus based on coin flip results
|
||||
COMPLETED: 15 haikus written for results [T,H,T,H,T,H,T,T,H,T,H,T,H,T,H]
|
||||
PENDING: 5 more haikus needed
|
||||
CURRENT_STATE: Last flip: Heads, Haiku count: 15/20"""
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
# Add the previous summary if it exists. We'll always have a summary
|
||||
# event, but the types aren't precise enought to guarantee that it has a
|
||||
# message attribute.
|
||||
summary_event_content = self._truncate(
|
||||
summary_event.message if summary_event.message else ''
|
||||
)
|
||||
prompt += f'<PREVIOUS SUMMARY>\n{summary_event_content}\n</PREVIOUS SUMMARY>\n'
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
# Add all events that are being forgotten. We use the string
|
||||
# representation defined by the event, and truncate it if necessary.
|
||||
for forgotten_event in forgotten_events:
|
||||
event_content = self._truncate(str(forgotten_event))
|
||||
prompt += f'<EVENT id={forgotten_event.id}>\n{event_content}\n</EVENT>\n'
|
||||
|
||||
prompt += 'Now summarize the events using the rules above.'
|
||||
|
||||
messages = [Message(role='user', content=[TextContent(text=prompt)])]
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=self.llm.format_messages_for_llm(messages),
|
||||
extra_body={'metadata': self.llm_metadata},
|
||||
)
|
||||
summary = response.choices[0].message.content
|
||||
|
||||
self.add_metadata('response', response.model_dump())
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
return Condensation(
|
||||
action=CondensationAction(
|
||||
forgotten_events_start_id=min(event.id for event in forgotten_events),
|
||||
forgotten_events_end_id=max(event.id for event in forgotten_events),
|
||||
summary=summary,
|
||||
summary_offset=self.keep_first,
|
||||
)
|
||||
)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size or view.unhandled_condensation_request
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: LLMSummarizingCondenserConfig, llm_registry: LLMRegistry
|
||||
) -> LLMSummarizingCondenser:
|
||||
# This condenser cannot take advantage of prompt caching. If it happens
|
||||
# to be set, we'll pay for the cache writes but never get a chance to
|
||||
# save on a read.
|
||||
llm_config = config.llm_config.model_copy()
|
||||
llm_config.caching_prompt = False
|
||||
llm = llm_registry.get_llm('condenser', llm_config)
|
||||
|
||||
return LLMSummarizingCondenser(
|
||||
llm=llm,
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
max_event_length=config.max_event_length,
|
||||
)
|
||||
|
||||
|
||||
LLMSummarizingCondenser.register_config(LLMSummarizingCondenserConfig)
|
||||
@@ -0,0 +1,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import NoOpCondenserConfig
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import Condensation, Condenser, View
|
||||
|
||||
|
||||
class NoOpCondenser(Condenser):
|
||||
"""A condenser that does nothing to the event sequence."""
|
||||
|
||||
def condense(self, view: View) -> View | Condensation:
|
||||
"""Returns the list of events unchanged."""
|
||||
return view
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: NoOpCondenserConfig, llm_registry: LLMRegistry
|
||||
) -> NoOpCondenser:
|
||||
return NoOpCondenser()
|
||||
|
||||
|
||||
NoOpCondenser.register_config(NoOpCondenserConfig)
|
||||
@@ -0,0 +1,39 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import ObservationMaskingCondenserConfig
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import Observation
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import Condensation, Condenser, View
|
||||
|
||||
|
||||
class ObservationMaskingCondenser(Condenser):
|
||||
"""A condenser that masks the values of observations outside of a recent attention window."""
|
||||
|
||||
def __init__(self, attention_window: int = 5):
|
||||
self.attention_window = attention_window
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, view: View) -> View | Condensation:
|
||||
"""Replace the content of observations outside of the attention window with a placeholder."""
|
||||
results: list[Event] = []
|
||||
for i, event in enumerate(view):
|
||||
if isinstance(event, Observation) and i < len(view) - self.attention_window:
|
||||
results.append(AgentCondensationObservation('<MASKED>'))
|
||||
else:
|
||||
results.append(event)
|
||||
|
||||
return View(events=results)
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
config: ObservationMaskingCondenserConfig,
|
||||
llm_registry: LLMRegistry,
|
||||
) -> ObservationMaskingCondenser:
|
||||
return ObservationMaskingCondenser(**config.model_dump(exclude={'type'}))
|
||||
|
||||
|
||||
ObservationMaskingCondenser.register_config(ObservationMaskingCondenserConfig)
|
||||
@@ -0,0 +1,50 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.config.condenser_config import CondenserPipelineConfig
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import Condensation, Condenser
|
||||
from openhands.memory.view import View
|
||||
|
||||
|
||||
class CondenserPipeline(Condenser):
|
||||
"""Combines multiple condensers into a single condenser.
|
||||
|
||||
This is useful for creating a pipeline of condensers that can be chained together to achieve very specific condensation aims. Each condenser is run in sequence, passing the output view of one to the next, until we reach the end or a `CondensationAction` is returned instead.
|
||||
"""
|
||||
|
||||
def __init__(self, *condenser: Condenser) -> None:
|
||||
self.condensers = list(condenser)
|
||||
super().__init__()
|
||||
|
||||
@contextmanager
|
||||
def metadata_batch(self, state: State):
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
# The parent class assumes the metadata is stored in the "calling
|
||||
# condenser" -- since we're not threading a State through to each
|
||||
# step in the pipeline, we need to walk back through the pipeline
|
||||
# and manually collect the relevant metadata.
|
||||
for condenser in self.condensers:
|
||||
condenser.write_metadata(state)
|
||||
|
||||
def condense(self, view: View) -> View | Condensation:
|
||||
result: View | Condensation = view
|
||||
for condenser in self.condensers:
|
||||
result = condenser.condense(result)
|
||||
if isinstance(result, Condensation):
|
||||
break
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: CondenserPipelineConfig, llm_registry: LLMRegistry
|
||||
) -> CondenserPipeline:
|
||||
condensers = [Condenser.from_config(c, llm_registry) for c in config.condensers]
|
||||
return CondenserPipeline(*condensers)
|
||||
|
||||
|
||||
CondenserPipeline.register_config(CondenserPipelineConfig)
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from openhands.core.config.condenser_config import RecentEventsCondenserConfig
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import Condensation, Condenser, View
|
||||
|
||||
|
||||
class RecentEventsCondenser(Condenser):
|
||||
"""A condenser that only keeps a certain number of the most recent events."""
|
||||
|
||||
def __init__(self, keep_first: int = 1, max_events: int = 10):
|
||||
self.keep_first = keep_first
|
||||
self.max_events = max_events
|
||||
|
||||
super().__init__()
|
||||
|
||||
def condense(self, view: View) -> View | Condensation:
|
||||
"""Keep only the most recent events (up to `max_events`)."""
|
||||
head = view[: self.keep_first]
|
||||
tail_length = max(0, self.max_events - len(head))
|
||||
tail = view[-tail_length:] if tail_length > 0 else []
|
||||
return View(events=head + tail)
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: RecentEventsCondenserConfig, llm_registry: LLMRegistry
|
||||
) -> RecentEventsCondenser:
|
||||
return RecentEventsCondenser(**config.model_dump(exclude={'type'}))
|
||||
|
||||
|
||||
RecentEventsCondenser.register_config(RecentEventsCondenserConfig)
|
||||
@@ -0,0 +1,329 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.core.config.condenser_config import (
|
||||
StructuredSummaryCondenserConfig,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.action.agent import CondensationAction
|
||||
from openhands.events.observation.agent import AgentCondensationObservation
|
||||
from openhands.events.serialization.event import truncate_content
|
||||
from openhands.llm.llm import LLM
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.memory.condenser.condenser import (
|
||||
Condensation,
|
||||
RollingCondenser,
|
||||
View,
|
||||
)
|
||||
|
||||
|
||||
class StateSummary(BaseModel):
|
||||
"""A structured representation summarizing the state of the agent and the task."""
|
||||
|
||||
# Required core fields
|
||||
user_context: str = Field(
|
||||
default='',
|
||||
description='Essential user requirements, goals, and clarifications in concise form.',
|
||||
)
|
||||
completed_tasks: str = Field(
|
||||
default='', description='List of tasks completed so far with brief results.'
|
||||
)
|
||||
pending_tasks: str = Field(
|
||||
default='', description='List of tasks that still need to be done.'
|
||||
)
|
||||
current_state: str = Field(
|
||||
default='',
|
||||
description='Current variables, data structures, or other relevant state information.',
|
||||
)
|
||||
|
||||
# Code state fields
|
||||
files_modified: str = Field(
|
||||
default='', description='List of files that have been created or modified.'
|
||||
)
|
||||
function_changes: str = Field(
|
||||
default='', description='List of functions that have been created or modified.'
|
||||
)
|
||||
data_structures: str = Field(
|
||||
default='', description='List of key data structures in use or modified.'
|
||||
)
|
||||
|
||||
# Test status fields
|
||||
tests_written: str = Field(
|
||||
default='',
|
||||
description='Whether tests have been written for the changes. True, false, or unknown.',
|
||||
)
|
||||
tests_passing: str = Field(
|
||||
default='',
|
||||
description='Whether all tests are currently passing. True, false, or unknown.',
|
||||
)
|
||||
failing_tests: str = Field(
|
||||
default='', description='List of names or descriptions of any failing tests.'
|
||||
)
|
||||
error_messages: str = Field(
|
||||
default='', description='List of key error messages encountered.'
|
||||
)
|
||||
|
||||
# Version control fields
|
||||
branch_created: str = Field(
|
||||
default='',
|
||||
description='Whether a branch has been created for this work. True, false, or unknown.',
|
||||
)
|
||||
branch_name: str = Field(
|
||||
default='', description='Name of the current working branch if known.'
|
||||
)
|
||||
commits_made: str = Field(
|
||||
default='',
|
||||
description='Whether any commits have been made. True, false, or unknown.',
|
||||
)
|
||||
pr_created: str = Field(
|
||||
default='',
|
||||
description='Whether a pull request has been created. True, false, or unknown.',
|
||||
)
|
||||
pr_status: str = Field(
|
||||
default='',
|
||||
description="Status of any pull request: 'draft', 'open', 'merged', 'closed', or 'unknown'.",
|
||||
)
|
||||
|
||||
# Other fields
|
||||
dependencies: str = Field(
|
||||
default='',
|
||||
description='List of dependencies or imports that have been added or modified.',
|
||||
)
|
||||
other_relevant_context: str = Field(
|
||||
default='',
|
||||
description="Any other important information that doesn't fit into the categories above.",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def tool_description(cls) -> dict[str, Any]:
|
||||
"""Description of a tool whose arguments are the fields of this class.
|
||||
|
||||
Can be given to an LLM to force structured generation.
|
||||
"""
|
||||
properties = {}
|
||||
|
||||
# Build properties dictionary from field information
|
||||
for field_name, field in cls.model_fields.items():
|
||||
description = field.description or ''
|
||||
|
||||
properties[field_name] = {'type': 'string', 'description': description}
|
||||
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'create_state_summary',
|
||||
'description': 'Creates a comprehensive summary of the current state of the interaction to preserve context when history grows too large. You must include non-empty values for user_context, completed_tasks, and pending_tasks.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': properties,
|
||||
'required': ['user_context', 'completed_tasks', 'pending_tasks'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Format the state summary in a clear way for Claude 3.7 Sonnet."""
|
||||
sections = [
|
||||
'# State Summary',
|
||||
'## Core Information',
|
||||
f'**User Context**: {self.user_context}',
|
||||
f'**Completed Tasks**: {self.completed_tasks}',
|
||||
f'**Pending Tasks**: {self.pending_tasks}',
|
||||
f'**Current State**: {self.current_state}',
|
||||
'## Code Changes',
|
||||
f'**Files Modified**: {self.files_modified}',
|
||||
f'**Function Changes**: {self.function_changes}',
|
||||
f'**Data Structures**: {self.data_structures}',
|
||||
f'**Dependencies**: {self.dependencies}',
|
||||
'## Testing Status',
|
||||
f'**Tests Written**: {self.tests_written}',
|
||||
f'**Tests Passing**: {self.tests_passing}',
|
||||
f'**Failing Tests**: {self.failing_tests}',
|
||||
f'**Error Messages**: {self.error_messages}',
|
||||
'## Version Control',
|
||||
f'**Branch Created**: {self.branch_created}',
|
||||
f'**Branch Name**: {self.branch_name}',
|
||||
f'**Commits Made**: {self.commits_made}',
|
||||
f'**PR Created**: {self.pr_created}',
|
||||
f'**PR Status**: {self.pr_status}',
|
||||
'## Additional Context',
|
||||
f'**Other Relevant Context**: {self.other_relevant_context}',
|
||||
]
|
||||
|
||||
# Join all sections with double newlines
|
||||
return '\n\n'.join(sections)
|
||||
|
||||
|
||||
class StructuredSummaryCondenser(RollingCondenser):
|
||||
"""A condenser that summarizes forgotten events.
|
||||
|
||||
Maintains a condensed history and forgets old events when it grows too large. Uses structured generation via function-calling to produce summaries that replace forgotten events.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
llm: LLM,
|
||||
max_size: int = 100,
|
||||
keep_first: int = 1,
|
||||
max_event_length: int = 10_000,
|
||||
):
|
||||
if keep_first >= max_size // 2:
|
||||
raise ValueError(
|
||||
f'keep_first ({keep_first}) must be less than half of max_size ({max_size})'
|
||||
)
|
||||
if keep_first < 0:
|
||||
raise ValueError(f'keep_first ({keep_first}) cannot be negative')
|
||||
if max_size < 1:
|
||||
raise ValueError(f'max_size ({max_size}) cannot be non-positive')
|
||||
|
||||
self.max_size = max_size
|
||||
self.keep_first = keep_first
|
||||
self.max_event_length = max_event_length
|
||||
self.llm = llm
|
||||
if not self.llm.is_function_calling_active():
|
||||
raise ValueError(
|
||||
'LLM must support function calling to use StructuredSummaryCondenser'
|
||||
)
|
||||
|
||||
super().__init__()
|
||||
|
||||
def _truncate(self, content: str) -> str:
|
||||
"""Truncate the content to fit within the specified maximum event length."""
|
||||
return truncate_content(content, max_chars=self.max_event_length)
|
||||
|
||||
def get_condensation(self, view: View) -> Condensation:
|
||||
head = view[: self.keep_first]
|
||||
target_size = self.max_size // 2
|
||||
# Number of events to keep from the tail -- target size, minus however many
|
||||
# prefix events from the head, minus one for the summarization event
|
||||
events_from_tail = target_size - len(head) - 1
|
||||
|
||||
summary_event = (
|
||||
view[self.keep_first]
|
||||
if isinstance(view[self.keep_first], AgentCondensationObservation)
|
||||
else AgentCondensationObservation('No events summarized')
|
||||
)
|
||||
|
||||
# Identify events to be forgotten (those not in head or tail)
|
||||
forgotten_events = []
|
||||
for event in view[self.keep_first : -events_from_tail]:
|
||||
if not isinstance(event, AgentCondensationObservation):
|
||||
forgotten_events.append(event)
|
||||
|
||||
# Construct prompt for summarization
|
||||
prompt = """You are maintaining a context-aware state summary for an interactive software agent. This summary is critical because it:
|
||||
1. Preserves essential context when conversation history grows too large
|
||||
2. Prevents lost work when the session length exceeds token limits
|
||||
3. Helps maintain continuity across multiple interactions
|
||||
|
||||
You will be given:
|
||||
- A list of events (actions taken by the agent)
|
||||
- The most recent previous summary (if one exists)
|
||||
|
||||
Capture all relevant information, especially:
|
||||
- User requirements that were explicitly stated
|
||||
- Work that has been completed
|
||||
- Tasks that remain pending
|
||||
- Current state of code, variables, and data structures
|
||||
- The status of any version control operations"""
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
# Add the previous summary if it exists. We'll always have a summary
|
||||
# event, but the types aren't precise enought to guarantee that it has a
|
||||
# message attribute.
|
||||
summary_event_content = self._truncate(
|
||||
summary_event.message if summary_event.message else ''
|
||||
)
|
||||
prompt += f'<PREVIOUS SUMMARY>\n{summary_event_content}\n</PREVIOUS SUMMARY>\n'
|
||||
|
||||
prompt += '\n\n'
|
||||
|
||||
# Add all events that are being forgotten. We use the string
|
||||
# representation defined by the event, and truncate it if necessary.
|
||||
for forgotten_event in forgotten_events:
|
||||
event_content = self._truncate(str(forgotten_event))
|
||||
prompt += f'<EVENT id={forgotten_event.id}>\n{event_content}\n</EVENT>\n'
|
||||
|
||||
messages = [Message(role='user', content=[TextContent(text=prompt)])]
|
||||
|
||||
response = self.llm.completion(
|
||||
messages=self.llm.format_messages_for_llm(messages),
|
||||
tools=[StateSummary.tool_description()],
|
||||
tool_choice={
|
||||
'type': 'function',
|
||||
'function': {'name': 'create_state_summary'},
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
# Extract the message containing tool calls
|
||||
message = response.choices[0].message
|
||||
|
||||
# Check if there are tool calls
|
||||
if not hasattr(message, 'tool_calls') or not message.tool_calls:
|
||||
raise ValueError('No tool calls found in response')
|
||||
|
||||
# Find the create_state_summary tool call
|
||||
summary_tool_call = None
|
||||
for tool_call in message.tool_calls:
|
||||
if tool_call.function.name == 'create_state_summary':
|
||||
summary_tool_call = tool_call
|
||||
break
|
||||
|
||||
if not summary_tool_call:
|
||||
raise ValueError('create_state_summary tool call not found')
|
||||
|
||||
# Parse the arguments
|
||||
args_json = summary_tool_call.function.arguments
|
||||
args_dict = json.loads(args_json)
|
||||
|
||||
# Create a StateSummary object
|
||||
summary = StateSummary.model_validate(args_dict)
|
||||
|
||||
except (ValueError, AttributeError, KeyError, json.JSONDecodeError) as e:
|
||||
logger.warning(
|
||||
f'Failed to parse summary tool call: {e}. Using empty summary.'
|
||||
)
|
||||
summary = StateSummary()
|
||||
|
||||
self.add_metadata('response', response.model_dump())
|
||||
self.add_metadata('metrics', self.llm.metrics.get())
|
||||
|
||||
return Condensation(
|
||||
action=CondensationAction(
|
||||
forgotten_events_start_id=min(event.id for event in forgotten_events),
|
||||
forgotten_events_end_id=max(event.id for event in forgotten_events),
|
||||
summary=str(summary),
|
||||
summary_offset=self.keep_first,
|
||||
)
|
||||
)
|
||||
|
||||
def should_condense(self, view: View) -> bool:
|
||||
return len(view) > self.max_size or view.unhandled_condensation_request
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls, config: StructuredSummaryCondenserConfig, llm_registry: LLMRegistry
|
||||
) -> StructuredSummaryCondenser:
|
||||
# This condenser cannot take advantage of prompt caching. If it happens
|
||||
# to be set, we'll pay for the cache writes but never get a chance to
|
||||
# save on a read.
|
||||
llm_config = config.llm_config.model_copy()
|
||||
llm_config.caching_prompt = False
|
||||
llm = llm_registry.get_llm('condenser', llm_config)
|
||||
|
||||
return StructuredSummaryCondenser(
|
||||
llm=llm,
|
||||
max_size=config.max_size,
|
||||
keep_first=config.keep_first,
|
||||
max_event_length=config.max_event_length,
|
||||
)
|
||||
|
||||
|
||||
StructuredSummaryCondenser.register_config(StructuredSummaryCondenserConfig)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user