mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
26 Commits
cloud-1.23
...
dependabot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a815ad2c10 | ||
|
|
e86067c15b | ||
|
|
137bede1f5 | ||
|
|
8a1d80ac8f | ||
|
|
77043da280 | ||
|
|
180a35f013 | ||
|
|
18365e0323 | ||
|
|
9a743ff51a | ||
|
|
29577935b4 | ||
|
|
7498353ed5 | ||
|
|
b62bdfd143 | ||
|
|
fb98faf4ac | ||
|
|
a8f62aa30c | ||
|
|
1a7449b03a | ||
|
|
1091901be2 | ||
|
|
15160f6733 | ||
|
|
13dba59bb8 | ||
|
|
478c998f04 | ||
|
|
a9fc93ffbf | ||
|
|
cc100c0d10 | ||
|
|
7bc3300981 | ||
|
|
3e0283796e | ||
|
|
cd0175d83e | ||
|
|
f313cfceb9 | ||
|
|
fb0108f946 | ||
|
|
6b29a82de3 |
@@ -46,34 +46,12 @@ 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 |
|
||||
|
||||
228
.github/workflows/e2e-tests.yml
vendored
228
.github/workflows/e2e-tests.yml
vendored
@@ -1,228 +0,0 @@
|
||||
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
|
||||
2
.github/workflows/lint-fix.yml
vendored
2
.github/workflows/lint-fix.yml
vendored
@@ -72,7 +72,7 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
4
.github/workflows/lint.yml
vendored
4
.github/workflows/lint.yml
vendored
@@ -47,7 +47,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
|
||||
433
.github/workflows/openhands-resolver.yml
vendored
433
.github/workflows/openhands-resolver.yml
vendored
@@ -1,433 +0,0 @@
|
||||
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.`
|
||||
});
|
||||
8
.github/workflows/py-tests.yml
vendored
8
.github/workflows/py-tests.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
@@ -60,10 +60,6 @@ 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:
|
||||
@@ -84,7 +80,7 @@ jobs:
|
||||
- name: Install poetry via pipx
|
||||
run: pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: "poetry"
|
||||
|
||||
2
.github/workflows/pypi-release.yml
vendored
2
.github/workflows/pypi-release.yml
vendored
@@ -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@v5
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
- name: Install Poetry
|
||||
|
||||
@@ -36,7 +36,6 @@ 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?
|
||||
|
||||
@@ -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 in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
|
||||
|
||||
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
|
||||
- License: Apache License 2.0
|
||||
|
||||
@@ -309,16 +309,6 @@ 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
|
||||
@@ -339,4 +329,3 @@ 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,7 +88,6 @@ 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,18 +23,6 @@ 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/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|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: ^(third_party/|enterprise/)
|
||||
exclude: ^(enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
@@ -60,6 +60,7 @@ repos:
|
||||
lxml,
|
||||
"openhands-sdk==1.17.0",
|
||||
"openhands-tools==1.17.0",
|
||||
"sqlalchemy>=2.0",
|
||||
]
|
||||
# To see gaps add `--html-report mypy-report/`
|
||||
entry: mypy --config-file dev_config/python/mypy.ini openhands/
|
||||
|
||||
@@ -10,10 +10,7 @@ strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openhands.memory.condenser.impl.*]
|
||||
disable_error_code = override
|
||||
exclude = (enterprise/)
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
exclude = ["enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -50,6 +50,7 @@ repos:
|
||||
- ./
|
||||
- stripe==11.5.0
|
||||
- pygithub==2.6.1
|
||||
- sqlalchemy>=2.0
|
||||
# Use -p (package) to avoid dual module name conflict when using MYPYPATH
|
||||
# MYPYPATH=enterprise allows resolving bare imports like "from integrations.xxx"
|
||||
# Note: tests package excluded to avoid conflict with core openhands tests
|
||||
|
||||
@@ -61,13 +61,6 @@ 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.
|
||||
|
||||
```
|
||||
@@ -203,7 +196,6 @@ 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",
|
||||
@@ -237,7 +229,6 @@ 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",
|
||||
|
||||
@@ -429,6 +429,11 @@ class GitHubDataCollector:
|
||||
- Num openhands review comments
|
||||
"""
|
||||
pr_number = openhands_pr.pr_number
|
||||
if openhands_pr.installation_id is None:
|
||||
logger.warning(
|
||||
f'Skipping PR {openhands_pr.repo_name}#{pr_number}: missing installation_id'
|
||||
)
|
||||
return
|
||||
installation_id = int(openhands_pr.installation_id)
|
||||
repo_id = openhands_pr.repo_id
|
||||
|
||||
|
||||
@@ -59,11 +59,11 @@ async def find_or_create_customer_by_user_id(user_id: str) -> dict | None:
|
||||
extra={'user_id': user_id, 'org_id': str(org.id)},
|
||||
)
|
||||
|
||||
# Create the customer in stripe
|
||||
customer = await stripe.Customer.create_async(
|
||||
email=org.contact_email,
|
||||
metadata={'org_id': str(org.id)},
|
||||
)
|
||||
# Create the customer in stripe (only include email if available)
|
||||
create_params: dict = {'metadata': {'org_id': str(org.id)}}
|
||||
if org.contact_email:
|
||||
create_params['email'] = org.contact_email
|
||||
customer = await stripe.Customer.create_async(**create_params)
|
||||
|
||||
# Save the stripe customer in the local db
|
||||
async with a_session_maker() as session:
|
||||
@@ -108,11 +108,14 @@ async def migrate_customer(session, user_id: str, org: Org):
|
||||
if stripe_customer is None:
|
||||
return
|
||||
stripe_customer.org_id = org.id
|
||||
customer = await stripe.Customer.modify_async(
|
||||
id=stripe_customer.stripe_customer_id,
|
||||
email=org.contact_email,
|
||||
metadata={'user_id': '', 'org_id': str(org.id)},
|
||||
)
|
||||
# Only include email if available to avoid sending empty strings to Stripe
|
||||
modify_params: dict = {
|
||||
'id': stripe_customer.stripe_customer_id,
|
||||
'metadata': {'user_id': '', 'org_id': str(org.id)},
|
||||
}
|
||||
if org.contact_email:
|
||||
modify_params['email'] = org.contact_email
|
||||
customer = await stripe.Customer.modify_async(**modify_params)
|
||||
|
||||
logger.info(
|
||||
'migrated_customer',
|
||||
|
||||
@@ -49,133 +49,6 @@ def _strip_none_and_empty(value: Any) -> Any:
|
||||
return value
|
||||
|
||||
|
||||
def _next_server_name(existing: Mapping[str, Any], base_name: str) -> str:
|
||||
if base_name not in existing:
|
||||
return base_name
|
||||
|
||||
suffix = 1
|
||||
while f'{base_name}_{suffix}' in existing:
|
||||
suffix += 1
|
||||
return f'{base_name}_{suffix}'
|
||||
|
||||
|
||||
def _normalize_mcp_config(value: Any) -> Any:
|
||||
if not isinstance(value, Mapping):
|
||||
return value
|
||||
|
||||
raw_mcp_servers = value.get('mcpServers')
|
||||
if isinstance(raw_mcp_servers, Mapping):
|
||||
mcp_servers = dict(raw_mcp_servers)
|
||||
return {'mcpServers': mcp_servers} if mcp_servers else None
|
||||
|
||||
if not any(
|
||||
key in value for key in ('sse_servers', 'stdio_servers', 'shttp_servers')
|
||||
):
|
||||
return value
|
||||
|
||||
servers: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for entry in value.get('sse_servers', []) or []:
|
||||
if isinstance(entry, str):
|
||||
entry = {'url': entry}
|
||||
if not isinstance(entry, Mapping) or not isinstance(entry.get('url'), str):
|
||||
continue
|
||||
|
||||
server: dict[str, Any] = {'url': entry['url'], 'transport': 'sse'}
|
||||
if entry.get('api_key') is not None:
|
||||
server['auth'] = entry.get('api_key')
|
||||
servers[_next_server_name(servers, 'sse')] = server
|
||||
|
||||
for entry in value.get('shttp_servers', []) or []:
|
||||
if isinstance(entry, str):
|
||||
entry = {'url': entry}
|
||||
if not isinstance(entry, Mapping) or not isinstance(entry.get('url'), str):
|
||||
continue
|
||||
|
||||
server = {'url': entry['url']}
|
||||
if entry.get('api_key') is not None:
|
||||
server['auth'] = entry.get('api_key')
|
||||
if entry.get('timeout') is not None:
|
||||
server['timeout'] = entry.get('timeout')
|
||||
servers[_next_server_name(servers, 'shttp')] = server
|
||||
|
||||
for entry in value.get('stdio_servers', []) or []:
|
||||
if not isinstance(entry, Mapping) or not isinstance(entry.get('command'), str):
|
||||
continue
|
||||
|
||||
server = {'command': entry['command']}
|
||||
if entry.get('args') is not None:
|
||||
server['args'] = entry.get('args')
|
||||
if entry.get('env') is not None:
|
||||
server['env'] = entry.get('env')
|
||||
base_name = entry.get('name') if isinstance(entry.get('name'), str) else 'stdio'
|
||||
servers[_next_server_name(servers, base_name)] = server
|
||||
|
||||
return {'mcpServers': servers} if servers else None
|
||||
|
||||
|
||||
def _legacy_api_key(auth_value: Any) -> str | None:
|
||||
if isinstance(auth_value, str) and auth_value != 'oauth':
|
||||
return auth_value
|
||||
return None
|
||||
|
||||
|
||||
def _to_legacy_mcp_config(value: Any) -> Any:
|
||||
if not isinstance(value, Mapping):
|
||||
return value
|
||||
|
||||
raw_mcp_servers = value.get('mcpServers')
|
||||
if not isinstance(raw_mcp_servers, Mapping):
|
||||
return value
|
||||
|
||||
legacy: dict[str, list[Any]] = {
|
||||
'sse_servers': [],
|
||||
'stdio_servers': [],
|
||||
'shttp_servers': [],
|
||||
}
|
||||
|
||||
for server_name, server_config in raw_mcp_servers.items():
|
||||
if not isinstance(server_config, Mapping):
|
||||
continue
|
||||
|
||||
url = server_config.get('url')
|
||||
if isinstance(url, str):
|
||||
entry: dict[str, Any] = {'url': url}
|
||||
api_key = _legacy_api_key(server_config.get('auth'))
|
||||
if api_key is not None:
|
||||
entry['api_key'] = api_key
|
||||
if server_config.get('transport') == 'sse':
|
||||
legacy['sse_servers'].append(entry)
|
||||
else:
|
||||
if server_config.get('timeout') is not None:
|
||||
entry['timeout'] = server_config.get('timeout')
|
||||
legacy['shttp_servers'].append(entry)
|
||||
continue
|
||||
|
||||
command = server_config.get('command')
|
||||
if not isinstance(command, str):
|
||||
continue
|
||||
|
||||
entry = {'name': server_name, 'command': command}
|
||||
if server_config.get('args') is not None:
|
||||
entry['args'] = server_config.get('args')
|
||||
if server_config.get('env') is not None:
|
||||
entry['env'] = server_config.get('env')
|
||||
legacy['stdio_servers'].append(entry)
|
||||
|
||||
return legacy
|
||||
|
||||
|
||||
def _normalize_nested_mcp_config(settings: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
normalized = dict(settings or {})
|
||||
mcp_config = _normalize_mcp_config(normalized.get('mcp_config'))
|
||||
if mcp_config is None:
|
||||
normalized.pop('mcp_config', None)
|
||||
else:
|
||||
normalized['mcp_config'] = mcp_config
|
||||
return normalized
|
||||
|
||||
|
||||
def _build_user_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
generated = _strip_none_and_empty(
|
||||
{
|
||||
@@ -189,14 +62,10 @@ def _build_user_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
'enabled': row['enable_default_condenser'],
|
||||
'max_size': row['condenser_max_size'],
|
||||
},
|
||||
'mcp_config': _normalize_mcp_config(row['mcp_config']),
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
merged = _deep_merge(
|
||||
generated,
|
||||
_normalize_nested_mcp_config(row.get('agent_settings')),
|
||||
)
|
||||
return _normalize_nested_mcp_config(merged)
|
||||
return _deep_merge(generated, row.get('agent_settings') or {})
|
||||
|
||||
|
||||
def _build_user_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
@@ -218,14 +87,10 @@ def _build_org_member_agent_settings_diff(row: Mapping[str, Any]) -> dict[str, A
|
||||
'model': row['llm_model'],
|
||||
'base_url': row['llm_base_url'],
|
||||
},
|
||||
'mcp_config': _normalize_mcp_config(row['mcp_config']),
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
merged = _deep_merge(
|
||||
generated,
|
||||
_normalize_nested_mcp_config(row.get('agent_settings_diff')),
|
||||
)
|
||||
return _normalize_nested_mcp_config(merged)
|
||||
return _deep_merge(generated, row.get('agent_settings_diff') or {})
|
||||
|
||||
|
||||
def _build_org_member_conversation_settings_diff(
|
||||
@@ -248,14 +113,10 @@ def _build_org_agent_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
'enabled': row['enable_default_condenser'],
|
||||
'max_size': row['condenser_max_size'],
|
||||
},
|
||||
'mcp_config': _normalize_mcp_config(row['mcp_config']),
|
||||
'mcp_config': row['mcp_config'],
|
||||
}
|
||||
)
|
||||
merged = _deep_merge(
|
||||
generated,
|
||||
_normalize_nested_mcp_config(row.get('agent_settings')),
|
||||
)
|
||||
return _normalize_nested_mcp_config(merged)
|
||||
return _deep_merge(generated, row.get('agent_settings') or {})
|
||||
|
||||
|
||||
def _build_org_conversation_settings(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
@@ -311,9 +172,7 @@ def _legacy_org_member_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
'max_iterations': _get_nested_value(
|
||||
conversation_settings_diff, 'max_iterations'
|
||||
),
|
||||
'mcp_config': _to_legacy_mcp_config(
|
||||
_get_nested_value(agent_settings_diff, 'mcp_config')
|
||||
),
|
||||
'mcp_config': _get_nested_value(agent_settings_diff, 'mcp_config'),
|
||||
}
|
||||
|
||||
|
||||
@@ -337,9 +196,7 @@ def _legacy_org_values(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
'enable_default_condenser': (
|
||||
True if condenser_enabled is None else condenser_enabled
|
||||
),
|
||||
'mcp_config': _to_legacy_mcp_config(
|
||||
_get_nested_value(agent_settings, 'mcp_config')
|
||||
),
|
||||
'mcp_config': _get_nested_value(agent_settings, 'mcp_config'),
|
||||
'condenser_max_size': _get_nested_value(
|
||||
agent_settings, 'condenser', 'max_size'
|
||||
),
|
||||
|
||||
5
enterprise/poetry.lock
generated
5
enterprise/poetry.lock
generated
@@ -6547,7 +6547,7 @@ python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
python-json-logger = ">=3.2.1"
|
||||
python-multipart = ">=0.0.22"
|
||||
python-multipart = ">=0.0.26"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
@@ -6571,9 +6571,6 @@ 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,8 +106,15 @@ 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
|
||||
|
||||
@@ -180,6 +180,18 @@ async def device_token(device_code: str = Form(...)):
|
||||
)
|
||||
|
||||
if device_code_entry.status == 'authorized':
|
||||
# Verify user_id is set (should always be true for authorized status)
|
||||
if not device_code_entry.keycloak_user_id:
|
||||
logger.error(
|
||||
'Authorized device code missing user_id',
|
||||
extra={'user_code': device_code_entry.user_code},
|
||||
)
|
||||
return _oauth_error(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
'server_error',
|
||||
'User identification missing',
|
||||
)
|
||||
|
||||
# Retrieve the specific API key for this device using the user_code
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
device_key_name = f'{API_KEY_NAME} ({device_code_entry.user_code})'
|
||||
|
||||
@@ -350,8 +350,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
# Convert string user_id to UUID
|
||||
user_id_uuid = UUID(user_id_str)
|
||||
user_query = select(User).where(User.id == user_id_uuid)
|
||||
result = await self.db_session.execute(user_query)
|
||||
user = result.scalar_one_or_none()
|
||||
user_result = await self.db_session.execute(user_query)
|
||||
user = user_result.scalar_one_or_none()
|
||||
assert user
|
||||
|
||||
# Determine org_id: prefer API key's org_id if authenticated via API key
|
||||
@@ -372,8 +372,8 @@ class SaasSQLAppConversationInfoService(SQLAppConversationInfoService):
|
||||
saas_query = select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == str(info.id)
|
||||
)
|
||||
result = await self.db_session.execute(saas_query)
|
||||
existing_saas_metadata = result.scalar_one_or_none()
|
||||
saas_result = await self.db_session.execute(saas_query)
|
||||
existing_saas_metadata = saas_result.scalar_one_or_none()
|
||||
assert existing_saas_metadata is None or (
|
||||
existing_saas_metadata.user_id == user_id_uuid
|
||||
and existing_saas_metadata.org_id == org_id
|
||||
|
||||
@@ -138,7 +138,8 @@ class VerifiedModelService:
|
||||
)
|
||||
)
|
||||
result = await self.db_session.execute(query)
|
||||
return result.scalars().first()
|
||||
stored = result.scalars().first()
|
||||
return verified_model(stored) if stored else None
|
||||
|
||||
async def create_verified_model(
|
||||
self,
|
||||
|
||||
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
from integrations.types import GitLabResourceType
|
||||
from sqlalchemy import and_, asc, select, text, update
|
||||
from sqlalchemy import and_, asc, delete, select, text, update
|
||||
from sqlalchemy.dialects.postgresql import insert
|
||||
from storage.database import a_session_maker
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
@@ -25,6 +25,8 @@ class GitlabWebhookStore:
|
||||
|
||||
if webhook.group_id:
|
||||
return (GitLabResourceType.GROUP, webhook.group_id)
|
||||
# At this point, project_id must be set (we checked at least one is set above)
|
||||
assert webhook.project_id is not None
|
||||
return (GitLabResourceType.PROJECT, webhook.project_id)
|
||||
|
||||
async def store_webhooks(self, project_details: list[GitlabWebhook]) -> None:
|
||||
@@ -123,11 +125,11 @@ class GitlabWebhookStore:
|
||||
async with session.begin():
|
||||
# Create query based on the identifier provided
|
||||
if resource_type == GitLabResourceType.PROJECT:
|
||||
query = GitlabWebhook.__table__.delete().where(
|
||||
query = delete(GitlabWebhook).where(
|
||||
GitlabWebhook.project_id == resource_id
|
||||
)
|
||||
else: # has_group_id must be True based on validation
|
||||
query = GitlabWebhook.__table__.delete().where(
|
||||
query = delete(GitlabWebhook).where(
|
||||
GitlabWebhook.group_id == resource_id
|
||||
)
|
||||
|
||||
|
||||
@@ -402,9 +402,7 @@ class LiteLlmManager:
|
||||
extra={'org_id': org_id, 'user_id': keycloak_user_id},
|
||||
)
|
||||
# Update user_settings with the new key so it gets stored in org_member
|
||||
# agent_settings is a JSON column (dict) on UserSettings
|
||||
if user_settings.agent_settings is None:
|
||||
user_settings.agent_settings = {}
|
||||
# agent_settings is a non-nullable JSON column (dict) on UserSettings
|
||||
user_settings.agent_settings.setdefault('llm', {})[
|
||||
'api_key'
|
||||
] = new_key
|
||||
|
||||
@@ -35,10 +35,10 @@ class OrgAppSettingsStore:
|
||||
Org: The organization object, or None if not found
|
||||
"""
|
||||
# Get user with their current_org_id
|
||||
result = await self.db_session.execute(
|
||||
user_result = await self.db_session.execute(
|
||||
select(User).filter(User.id == UUID(user_id))
|
||||
)
|
||||
user = result.scalars().first()
|
||||
user = user_result.scalars().first()
|
||||
|
||||
if not user:
|
||||
return None
|
||||
@@ -48,8 +48,8 @@ class OrgAppSettingsStore:
|
||||
return None
|
||||
|
||||
# Get the organization
|
||||
result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = result.scalars().first()
|
||||
org_result = await self.db_session.execute(select(Org).filter(Org.id == org_id))
|
||||
org = org_result.scalars().first()
|
||||
|
||||
if not org:
|
||||
return None
|
||||
|
||||
@@ -4,10 +4,13 @@ import dataclasses
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC
|
||||
from typing import TYPE_CHECKING, Callable, ContextManager
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from storage.database import session_maker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sqlalchemy.orm import Session
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
from storage.user_store import UserStore
|
||||
@@ -31,14 +34,14 @@ logger = logging.getLogger(__name__)
|
||||
@dataclass
|
||||
class SaasConversationStore(ConversationStore):
|
||||
user_id: str
|
||||
session_maker: sessionmaker
|
||||
session_maker: Callable[[], ContextManager[Session]]
|
||||
org_id: UUID | None = None # will be fetched automatically
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
user_id: str,
|
||||
org_id: UUID,
|
||||
session_maker: sessionmaker,
|
||||
org_id: UUID | None,
|
||||
session_maker: Callable[[], ContextManager[Session]],
|
||||
resolver_org_id: UUID | None = None,
|
||||
):
|
||||
self.user_id = user_id
|
||||
@@ -65,7 +68,9 @@ class SaasConversationStore(ConversationStore):
|
||||
|
||||
return query
|
||||
|
||||
def _to_external_model(self, conversation_metadata: StoredConversationMetadata):
|
||||
def _to_external_model(
|
||||
self, conversation_metadata: StoredConversationMetadata
|
||||
) -> ConversationMetadata:
|
||||
kwargs = {
|
||||
c.name: getattr(conversation_metadata, c.name)
|
||||
for c in StoredConversationMetadata.__table__.columns
|
||||
@@ -216,7 +221,7 @@ class SaasConversationStore(ConversationStore):
|
||||
|
||||
def _search():
|
||||
with self.session_maker() as session:
|
||||
conversations = (
|
||||
stored_conversations = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.join(
|
||||
StoredConversationMetadataSaas,
|
||||
@@ -233,13 +238,16 @@ class SaasConversationStore(ConversationStore):
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
)
|
||||
conversations = [self._to_external_model(c) for c in conversations]
|
||||
conversations = [
|
||||
self._to_external_model(c) for c in stored_conversations
|
||||
]
|
||||
current_page_size = len(conversations)
|
||||
next_page_id = offset_to_page_id(
|
||||
offset + limit, current_page_size > limit
|
||||
)
|
||||
conversations = conversations[:limit]
|
||||
return ConversationMetadataResultSet(conversations, next_page_id)
|
||||
return ConversationMetadataResultSet(
|
||||
conversations[:limit], next_page_id
|
||||
)
|
||||
|
||||
return await call_sync_from_async(_search)
|
||||
|
||||
|
||||
@@ -60,13 +60,11 @@ class SaasSecretsStore(SecretsStore):
|
||||
async with a_session_maker() as session:
|
||||
# Incoming secrets are always the most updated ones
|
||||
# Delete existing records for this user AND organization only
|
||||
# Note: user.current_org_id is non-nullable, so org_id is always set
|
||||
delete_query = delete(StoredCustomSecrets).filter(
|
||||
StoredCustomSecrets.keycloak_user_id == self.user_id
|
||||
StoredCustomSecrets.keycloak_user_id == self.user_id,
|
||||
StoredCustomSecrets.org_id == org_id,
|
||||
)
|
||||
if org_id is not None:
|
||||
delete_query = delete_query.filter(StoredCustomSecrets.org_id == org_id)
|
||||
else:
|
||||
delete_query = delete_query.filter(StoredCustomSecrets.org_id.is_(None))
|
||||
await session.execute(delete_query)
|
||||
|
||||
# Prepare the new secrets data
|
||||
|
||||
@@ -50,41 +50,6 @@ def test_user_settings_are_split_into_agent_and_conversation_buckets():
|
||||
}
|
||||
|
||||
|
||||
def test_user_settings_normalize_legacy_mcp_config():
|
||||
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': {
|
||||
'sse_servers': [],
|
||||
'stdio_servers': [],
|
||||
'shttp_servers': [
|
||||
{'url': 'https://mcp.example.com', 'api_key': None, 'timeout': 60}
|
||||
],
|
||||
},
|
||||
'agent_settings': {},
|
||||
'conversation_settings': {},
|
||||
}
|
||||
|
||||
assert migration_108._build_user_agent_settings(row) == {
|
||||
'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': {'shttp': {'url': 'https://mcp.example.com', 'timeout': 60}}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_org_member_diffs_use_nested_llm_and_conversation_settings():
|
||||
row = {
|
||||
'max_iterations': 50,
|
||||
@@ -111,36 +76,6 @@ def test_org_member_diffs_use_nested_llm_and_conversation_settings():
|
||||
assert conversation_settings_diff == {'max_iterations': 50}
|
||||
|
||||
|
||||
def test_org_member_diffs_normalize_legacy_mcp_config():
|
||||
row = {
|
||||
'max_iterations': 50,
|
||||
'llm_model': 'openhands/claude-3',
|
||||
'llm_base_url': 'https://proxy.example.com',
|
||||
'mcp_config': {
|
||||
'sse_servers': [],
|
||||
'stdio_servers': [],
|
||||
'shttp_servers': [
|
||||
{'url': 'https://mcp.deepwiki.com/mcp', 'api_key': None, 'timeout': 60}
|
||||
],
|
||||
},
|
||||
'agent_settings_diff': {},
|
||||
'conversation_settings_diff': {},
|
||||
}
|
||||
|
||||
assert migration_108._build_org_member_agent_settings_diff(row) == {
|
||||
'schema_version': 1,
|
||||
'llm': {
|
||||
'model': 'openhands/claude-3',
|
||||
'base_url': 'https://proxy.example.com',
|
||||
},
|
||||
'mcp_config': {
|
||||
'mcpServers': {
|
||||
'shttp': {'url': 'https://mcp.deepwiki.com/mcp', 'timeout': 60}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_org_settings_are_split_into_agent_and_conversation_buckets():
|
||||
row = {
|
||||
'agent': 'CodeActAgent',
|
||||
@@ -206,42 +141,6 @@ def test_downgrade_extracts_legacy_values_from_nested_settings():
|
||||
}
|
||||
|
||||
|
||||
def test_downgrade_restores_legacy_mcp_config_from_sdk_settings():
|
||||
row = {
|
||||
'agent_settings_diff': {
|
||||
'schema_version': 1,
|
||||
'mcp_config': {
|
||||
'mcpServers': {
|
||||
'sse': {'url': 'https://mcp.example.com', 'transport': 'sse'},
|
||||
'shttp': {
|
||||
'url': 'https://mcp.deepwiki.com/mcp',
|
||||
'timeout': 60,
|
||||
},
|
||||
'deepwiki-stdio': {
|
||||
'command': 'npx',
|
||||
'args': ['-y', 'deepwiki-mcp'],
|
||||
'env': {'A': 'B'},
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
'conversation_settings_diff': {},
|
||||
}
|
||||
|
||||
assert migration_108._legacy_org_member_values(row)['mcp_config'] == {
|
||||
'sse_servers': [{'url': 'https://mcp.example.com'}],
|
||||
'stdio_servers': [
|
||||
{
|
||||
'name': 'deepwiki-stdio',
|
||||
'command': 'npx',
|
||||
'args': ['-y', 'deepwiki-mcp'],
|
||||
'env': {'A': 'B'},
|
||||
}
|
||||
],
|
||||
'shttp_servers': [{'url': 'https://mcp.deepwiki.com/mcp', 'timeout': 60}],
|
||||
}
|
||||
|
||||
|
||||
def test_migrated_payload_loads_via_user_settings_to_settings():
|
||||
row = {
|
||||
'agent': 'CodeActAgent',
|
||||
|
||||
@@ -196,23 +196,16 @@ describe("useWebSocket", () => {
|
||||
const onCloseSpy = vi.fn();
|
||||
const options = { onClose: onCloseSpy };
|
||||
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWebSocket("ws://acme.com/ws", options),
|
||||
const closeLink = ws.link("ws://close-test.com/ws");
|
||||
mswServer.use(
|
||||
closeLink.addEventListener("connection", ({ client, server }) => {
|
||||
server.connect();
|
||||
client.close(1000, "Normal closure");
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for connection to be established
|
||||
await waitFor(() => {
|
||||
expect(result.current.isConnected).toBe(true);
|
||||
});
|
||||
renderHook(() => useWebSocket("ws://close-test.com/ws", options));
|
||||
|
||||
// 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,6 +35,8 @@ 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 = {
|
||||
@@ -58,6 +60,8 @@ 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();
|
||||
@@ -268,7 +272,10 @@ 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();
|
||||
|
||||
@@ -283,15 +290,24 @@ 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();
|
||||
@@ -299,6 +315,8 @@ 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"),
|
||||
@@ -614,30 +632,16 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab webhook manager in SaaS mode without APP_SLUG", async () => {
|
||||
// Arrange
|
||||
const getConfigSpy = vi.spyOn(OptionService, "getConfig");
|
||||
getConfigSpy.mockResolvedValue(VALID_SAAS_CONFIG);
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("should not render GitLab webhook manager when token is not set", async () => {
|
||||
it("should render configured GitLab and Slack sections 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,
|
||||
@@ -650,6 +654,66 @@ describe("GitLab Webhook Manager Integration", () => {
|
||||
|
||||
// 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();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// 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 () => {
|
||||
// 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,
|
||||
provider_tokens_set: {},
|
||||
});
|
||||
|
||||
// Act
|
||||
renderGitSettingsScreen();
|
||||
await screen.findByTestId("git-settings-screen");
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("gitlab-status-text")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("GITLAB$WEBHOOK_MANAGER_TITLE"),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
@@ -131,13 +131,6 @@ 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,4 +43,6 @@ export interface WebClientConfig {
|
||||
error_message: string | null;
|
||||
updated_at: string;
|
||||
github_app_slug: string | null;
|
||||
gitlab_enabled?: boolean;
|
||||
slack_enabled?: boolean;
|
||||
}
|
||||
|
||||
@@ -62,6 +62,8 @@ export const createMockWebClientConfig = (
|
||||
error_message: null,
|
||||
updated_at: new Date().toISOString(),
|
||||
github_app_slug: null,
|
||||
gitlab_enabled: false,
|
||||
slack_enabled: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
@@ -425,6 +427,8 @@ 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,8 +181,9 @@ function GitSettingsScreen() {
|
||||
!bitbucketDCHostInputHasValue &&
|
||||
!azureDevOpsHostInputHasValue &&
|
||||
!forgejoHostInputHasValue;
|
||||
const shouldRenderExternalConfigureButtons =
|
||||
isSaas && config?.github_app_slug;
|
||||
const shouldRenderGitHubConfigureButton = isSaas && config?.github_app_slug;
|
||||
const shouldRenderGitLabSection = isSaas && Boolean(config?.gitlab_enabled);
|
||||
const shouldRenderSlackSection = isSaas && Boolean(config?.slack_enabled);
|
||||
const shouldRenderProjectManagementIntegrations =
|
||||
config?.feature_flags?.enable_jira ||
|
||||
config?.feature_flags?.enable_jira_dc ||
|
||||
@@ -196,7 +197,7 @@ function GitSettingsScreen() {
|
||||
>
|
||||
{!isLoading && (
|
||||
<div className="flex flex-col">
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
{shouldRenderGitHubConfigureButton && (
|
||||
<>
|
||||
<div className="pb-1 flex flex-col">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
@@ -210,7 +211,7 @@ function GitSettingsScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
{shouldRenderGitLabSection && (
|
||||
<>
|
||||
<div className="mt-6 flex flex-col gap-4 pb-8">
|
||||
<Typography.H3 className="text-xl">
|
||||
@@ -237,7 +238,7 @@ function GitSettingsScreen() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{shouldRenderExternalConfigureButtons && !isLoading && (
|
||||
{shouldRenderSlackSection && (
|
||||
<>
|
||||
<div className="pb-1 mt-6 flex flex-col">
|
||||
<h3 className="text-xl font-medium text-white">
|
||||
@@ -346,7 +347,7 @@ function GitSettingsScreen() {
|
||||
{isLoading && <GitSettingInputsSkeleton />}
|
||||
|
||||
<div className="flex gap-6 p-6 justify-end">
|
||||
{!shouldRenderExternalConfigureButtons && (
|
||||
{!isSaas && (
|
||||
<>
|
||||
<BrandButton
|
||||
testId="disconnect-tokens-button"
|
||||
|
||||
@@ -3,7 +3,7 @@ from enum import Enum
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, SecretStr
|
||||
|
||||
from openhands.agent_server.models import OpenHandsModel, SendMessageRequest
|
||||
from openhands.agent_server.utils import OpenHandsUUID, utc_now
|
||||
@@ -175,6 +175,18 @@ class AppConversationStartRequest(OpenHandsModel):
|
||||
),
|
||||
)
|
||||
|
||||
# Secrets passed directly via API at conversation start time
|
||||
secrets: dict[str, SecretStr] | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
'Secrets to pass to the conversation. These are merged with any '
|
||||
'existing secrets (from database or git providers), with API-provided '
|
||||
'secrets taking precedence (overriding any existing secret with the same name). '
|
||||
'Keys are secret names (e.g., "MY_API_KEY"), values are the secret values. '
|
||||
'Warning: Providing a secret that already exists will silently override it.'
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class AppConversationUpdateRequest(BaseModel):
|
||||
"""Request model for updating conversation metadata.
|
||||
|
||||
@@ -5,6 +5,7 @@ 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
|
||||
@@ -309,6 +310,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
remote_workspace=remote_workspace,
|
||||
selected_repository=request.selected_repository,
|
||||
plugins=request.plugins,
|
||||
api_secrets=request.secrets,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1216,6 +1218,7 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
remote_workspace: AsyncRemoteWorkspace | None = None,
|
||||
selected_repository: str | None = None,
|
||||
plugins: list[PluginSpec] | None = None,
|
||||
api_secrets: dict[str, SecretStr] | None = None,
|
||||
) -> StartConversationRequest:
|
||||
"""Build a complete StartConversationRequest for a user.
|
||||
|
||||
@@ -1224,6 +1227,23 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
Server-only overrides (system prompts, LLM tracing metadata,
|
||||
skills, hooks) are applied to the agent after creation.
|
||||
Finally delegates to ``ConversationSettings.create_request()``.
|
||||
|
||||
Args:
|
||||
sandbox: Sandbox information
|
||||
conversation_id: Unique conversation identifier
|
||||
initial_message: Optional initial message to send
|
||||
system_message_suffix: Optional suffix for system message
|
||||
git_provider: Optional git provider type
|
||||
working_dir: Working directory path
|
||||
agent_type: Type of agent (DEFAULT or PLAN)
|
||||
llm_model: Optional specific LLM model to use
|
||||
remote_workspace: Optional remote workspace instance
|
||||
selected_repository: Optional repository name
|
||||
plugins: Optional list of plugins to load
|
||||
api_secrets: Optional secrets passed directly via the API.
|
||||
These are merged with existing secrets (from database
|
||||
and git providers), with API-provided secrets taking
|
||||
precedence.
|
||||
"""
|
||||
user = await self.user_context.get_user_info()
|
||||
|
||||
@@ -1231,8 +1251,28 @@ class LiveStatusAppConversationService(AppConversationServiceBase):
|
||||
workspace = LocalWorkspace(working_dir=project_dir)
|
||||
|
||||
# --- secrets --------------------------------------------------------
|
||||
# Start with secrets from git providers and database
|
||||
secrets = await self._setup_secrets_for_git_providers(user)
|
||||
|
||||
# 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))
|
||||
|
||||
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 -----------------------------------------------------
|
||||
llm, mcp_config = await self._configure_llm_and_mcp(
|
||||
user, llm_model, conversation_id
|
||||
|
||||
@@ -21,7 +21,7 @@ import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from typing import AsyncGenerator
|
||||
from typing import AsyncGenerator, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
@@ -33,6 +33,7 @@ from sqlalchemy import (
|
||||
func,
|
||||
select,
|
||||
)
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -523,19 +524,19 @@ class SQLAppConversationInfoService(AppConversationInfoService):
|
||||
sandbox_id = stored.sandbox_id
|
||||
assert sandbox_id is not None
|
||||
|
||||
# Rebuild token usage
|
||||
# Rebuild token usage (use 0 as default for nullable int columns)
|
||||
token_usage = TokenUsage(
|
||||
prompt_tokens=stored.prompt_tokens,
|
||||
completion_tokens=stored.completion_tokens,
|
||||
cache_read_tokens=stored.cache_read_tokens,
|
||||
cache_write_tokens=stored.cache_write_tokens,
|
||||
context_window=stored.context_window,
|
||||
per_turn_token=stored.per_turn_token,
|
||||
prompt_tokens=stored.prompt_tokens or 0,
|
||||
completion_tokens=stored.completion_tokens or 0,
|
||||
cache_read_tokens=stored.cache_read_tokens or 0,
|
||||
cache_write_tokens=stored.cache_write_tokens or 0,
|
||||
context_window=stored.context_window or 0,
|
||||
per_turn_token=stored.per_turn_token or 0,
|
||||
)
|
||||
|
||||
# Rebuild metrics object
|
||||
# Rebuild metrics object (use 0.0 as default for nullable float columns)
|
||||
metrics = MetricsSnapshot(
|
||||
accumulated_cost=stored.accumulated_cost,
|
||||
accumulated_cost=stored.accumulated_cost or 0.0,
|
||||
max_budget_per_task=stored.max_budget_per_task,
|
||||
accumulated_token_usage=token_usage,
|
||||
)
|
||||
@@ -547,7 +548,7 @@ class SQLAppConversationInfoService(AppConversationInfoService):
|
||||
return AppConversationInfo(
|
||||
id=UUID(stored.conversation_id),
|
||||
created_by_user_id=None, # User ID is now stored in ConversationMetadataSaas
|
||||
sandbox_id=stored.sandbox_id,
|
||||
sandbox_id=sandbox_id, # Use the asserted non-None value
|
||||
selected_repository=stored.selected_repository,
|
||||
selected_branch=stored.selected_branch,
|
||||
git_provider=(
|
||||
@@ -555,7 +556,7 @@ class SQLAppConversationInfoService(AppConversationInfoService):
|
||||
),
|
||||
title=stored.title,
|
||||
trigger=ConversationTrigger(stored.trigger) if stored.trigger else None,
|
||||
pr_number=stored.pr_number,
|
||||
pr_number=stored.pr_number or [],
|
||||
llm_model=stored.llm_model,
|
||||
metrics=metrics,
|
||||
parent_conversation_id=(
|
||||
@@ -599,7 +600,7 @@ class SQLAppConversationInfoService(AppConversationInfoService):
|
||||
)
|
||||
|
||||
# Execute the secure delete query
|
||||
result = await self.db_session.execute(delete_query)
|
||||
result = cast(CursorResult, await self.db_session.execute(delete_query))
|
||||
|
||||
return result.rowcount > 0
|
||||
|
||||
|
||||
@@ -19,11 +19,12 @@ from __future__ import annotations
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import AsyncGenerator
|
||||
from typing import AsyncGenerator, cast
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
from sqlalchemy import Enum, String, func, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -264,7 +265,7 @@ class SQLAppConversationStartTaskService(AppConversationStartTaskService):
|
||||
StoredAppConversationStartTask.created_by_user_id == self.user_id
|
||||
)
|
||||
|
||||
result = await self.session.execute(delete_query)
|
||||
result = cast(CursorResult, await self.session.execute(delete_query))
|
||||
|
||||
# Return True if any rows were affected
|
||||
return result.rowcount > 0
|
||||
|
||||
169
openhands/app_server/constants.py
Normal file
169
openhands/app_server/constants.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""Constants for the OpenHands App Server.
|
||||
|
||||
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
|
||||
# =============================================================================
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# BLOCKED: These names CANNOT be used as user-provided secrets.
|
||||
#
|
||||
# These environment variables are injected into the agent-server container
|
||||
# at startup. User-provided secrets with these names would override them
|
||||
# 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 PREFIXES: Secret names starting with these prefixes are blocked.
|
||||
#
|
||||
# LLM_* variables are auto-forwarded to the agent-server container to enforce
|
||||
# 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_',)
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# OVERRIDABLE: These are system-provided but users MAY override them.
|
||||
# Documented here for clarity - these are explicitly ALLOWED, not blocked.
|
||||
#
|
||||
# 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',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def validate_secret_name(name: str) -> None:
|
||||
"""Validate that a secret name is allowed.
|
||||
|
||||
Args:
|
||||
name: The secret name to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If the name is blocked (exact match or prefix match),
|
||||
or exceeds the maximum length
|
||||
"""
|
||||
# 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.'
|
||||
)
|
||||
|
||||
# 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.'
|
||||
)
|
||||
|
||||
# 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,6 +29,10 @@ 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
|
||||
@@ -203,6 +207,9 @@ 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)
|
||||
@@ -237,6 +244,24 @@ 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.17.0-python'
|
||||
AGENT_SERVER_IMAGE = 'ghcr.io/openhands/agent-server:1.18.1-python'
|
||||
|
||||
|
||||
class SandboxSpecService(ABC):
|
||||
|
||||
@@ -24,7 +24,7 @@ DB_SESSION_ATTR = 'db_session'
|
||||
DB_SESSION_KEEP_OPEN_ATTR = 'db_session_keep_open'
|
||||
|
||||
|
||||
class DbSessionInjector(BaseModel, Injector[async_sessionmaker]):
|
||||
class DbSessionInjector(BaseModel, Injector[AsyncSession]):
|
||||
persistence_dir: Path
|
||||
host: str | None = None
|
||||
port: int | None = None
|
||||
@@ -166,6 +166,7 @@ class DbSessionInjector(BaseModel, Injector[async_sessionmaker]):
|
||||
if self.gcp_db_instance: # GCP environments
|
||||
async_engine = await self._create_async_gcp_engine()
|
||||
else:
|
||||
url: str | URL
|
||||
if self.host:
|
||||
try:
|
||||
import asyncpg # noqa: F401
|
||||
@@ -199,6 +200,7 @@ class DbSessionInjector(BaseModel, Injector[async_sessionmaker]):
|
||||
poolclass=NullPool,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
assert async_engine is not None # Always assigned in either branch above
|
||||
self._async_engine = async_engine
|
||||
return async_engine
|
||||
|
||||
@@ -209,6 +211,7 @@ class DbSessionInjector(BaseModel, Injector[async_sessionmaker]):
|
||||
if self.gcp_db_instance: # GCP environments
|
||||
engine = self._create_gcp_engine()
|
||||
else:
|
||||
url: str | URL
|
||||
if self.host:
|
||||
try:
|
||||
import pg8000 # noqa: F401
|
||||
@@ -234,6 +237,7 @@ class DbSessionInjector(BaseModel, Injector[async_sessionmaker]):
|
||||
pool_recycle=self.pool_recycle,
|
||||
pool_pre_ping=True,
|
||||
)
|
||||
assert engine is not None # Always assigned in either branch above
|
||||
self._engine = engine
|
||||
return engine
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
@@ -5,15 +6,14 @@ 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())
|
||||
|
||||
# Re-use V0 path constants (single source of truth)
|
||||
GLOBAL_SKILLS_DIR = Path(GLOBAL_MICROAGENTS_DIR)
|
||||
USER_SKILLS_DIR = Path(USER_MICROAGENTS_DIR)
|
||||
GLOBAL_SKILLS_DIR = Path(os.path.dirname(openhands.__file__)) / 'skills'
|
||||
USER_SKILLS_DIR = Path.home() / '.openhands' / 'microagents'
|
||||
|
||||
|
||||
class SkillInfo(BaseModel):
|
||||
|
||||
@@ -58,6 +58,11 @@ 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.
|
||||
|
||||
@@ -69,7 +74,7 @@ def _get_providers_configured() -> list[ProviderType]:
|
||||
if os.getenv('GITHUB_APP_CLIENT_ID', '').strip():
|
||||
providers.append(ProviderType.GITHUB)
|
||||
|
||||
if os.getenv('GITLAB_APP_CLIENT_ID', '').strip():
|
||||
if _is_gitlab_enabled():
|
||||
providers.append(ProviderType.GITLAB)
|
||||
|
||||
if os.getenv('BITBUCKET_APP_CLIENT_ID', '').strip():
|
||||
@@ -91,6 +96,16 @@ 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.
|
||||
|
||||
@@ -133,6 +148,8 @@ 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
|
||||
@@ -150,5 +167,7 @@ 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,3 +42,5 @@ class WebClientConfig(DiscriminatedUnionMixin):
|
||||
error_message: str | None
|
||||
updated_at: datetime
|
||||
github_app_slug: str | None
|
||||
gitlab_enabled: bool = False
|
||||
slack_enabled: bool = False
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# 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
|
||||
@@ -1,92 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,68 +0,0 @@
|
||||
# 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
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,88 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,5 +0,0 @@
|
||||
from openhands.controller.agent_controller import AgentController
|
||||
|
||||
__all__ = [
|
||||
'AgentController',
|
||||
]
|
||||
@@ -1,85 +0,0 @@
|
||||
# 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
|
||||
@@ -1,191 +0,0 @@
|
||||
# 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
@@ -1,105 +0,0 @@
|
||||
# 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
|
||||
@@ -1,102 +0,0 @@
|
||||
# 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}'
|
||||
)
|
||||
@@ -1,318 +0,0 @@
|
||||
# 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
|
||||
@@ -1,275 +0,0 @@
|
||||
# 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
|
||||
)
|
||||
@@ -1,488 +0,0 @@
|
||||
# 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,7 +16,6 @@ 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):
|
||||
@@ -130,38 +129,4 @@ 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,15 +8,6 @@
|
||||
"""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:
|
||||
@@ -149,71 +140,6 @@ 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,9 +23,7 @@ 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
|
||||
@@ -37,7 +35,6 @@ 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()
|
||||
@@ -628,118 +625,6 @@ 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].
|
||||
|
||||
@@ -797,29 +682,6 @@ 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:
|
||||
@@ -833,7 +695,6 @@ 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,16 +16,6 @@ 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:
|
||||
@@ -49,20 +39,6 @@ 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
|
||||
# ============================================
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
# 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)
|
||||
@@ -1,393 +0,0 @@
|
||||
# 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,91 +8,17 @@
|
||||
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():
|
||||
@@ -146,134 +72,6 @@ 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.
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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,4 +1,5 @@
|
||||
import asyncio
|
||||
import json
|
||||
import queue
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
@@ -11,7 +12,6 @@ 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,
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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,9 +1,7 @@
|
||||
"""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,
|
||||
@@ -139,85 +137,3 @@ 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,7 +4,6 @@ from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket.service import (
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketReposMixin,
|
||||
)
|
||||
@@ -20,7 +19,6 @@ class BitBucketService(
|
||||
BitBucketReposMixin,
|
||||
BitBucketBranchesMixin,
|
||||
BitBucketPRsMixin,
|
||||
BitBucketFeaturesMixin,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
):
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
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,7 +12,6 @@ from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
User,
|
||||
)
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
@@ -236,47 +235,3 @@ 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']
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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,7 +4,6 @@ from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.bitbucket_data_center.service import (
|
||||
BitbucketDCBranchesMixin,
|
||||
BitbucketDCFeaturesMixin,
|
||||
BitbucketDCPRsMixin,
|
||||
BitbucketDCReposMixin,
|
||||
BitbucketDCResolverMixin,
|
||||
@@ -20,7 +19,6 @@ from openhands.utils.import_utils import get_impl
|
||||
class BitbucketDCService(
|
||||
BitbucketDCResolverMixin,
|
||||
BitbucketDCBranchesMixin,
|
||||
BitbucketDCFeaturesMixin,
|
||||
BitbucketDCPRsMixin,
|
||||
BitbucketDCReposMixin,
|
||||
GitService,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
from .base import BitbucketDCMixinBase
|
||||
from .branches import BitbucketDCBranchesMixin
|
||||
from .features import BitbucketDCFeaturesMixin
|
||||
from .prs import BitbucketDCPRsMixin
|
||||
from .repos import BitbucketDCReposMixin
|
||||
from .resolver import BitbucketDCResolverMixin
|
||||
@@ -8,7 +7,6 @@ from .resolver import BitbucketDCResolverMixin
|
||||
__all__ = [
|
||||
'BitbucketDCMixinBase',
|
||||
'BitbucketDCBranchesMixin',
|
||||
'BitbucketDCFeaturesMixin',
|
||||
'BitbucketDCPRsMixin',
|
||||
'BitbucketDCReposMixin',
|
||||
'BitbucketDCResolverMixin',
|
||||
|
||||
@@ -13,7 +13,6 @@ from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
ResourceNotFoundError,
|
||||
User,
|
||||
)
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
@@ -282,58 +281,3 @@ 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}'
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
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,123 +1,12 @@
|
||||
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 (
|
||||
MicroagentContentResponse,
|
||||
MicroagentResponse,
|
||||
ProviderType,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
)
|
||||
from openhands.integrations.service_types import 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,5 +1,3 @@
|
||||
import base64
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.queries import (
|
||||
suggested_task_issue_graphql_query,
|
||||
@@ -7,7 +5,6 @@ from openhands.integrations.github.queries import (
|
||||
)
|
||||
from openhands.integrations.github.service.base import GitHubMixinBase
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
ProviderType,
|
||||
SuggestedTask,
|
||||
TaskType,
|
||||
@@ -118,60 +115,3 @@ 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,6 +1,5 @@
|
||||
from openhands.integrations.gitlab.service.base import GitLabMixinBase
|
||||
from openhands.integrations.service_types import (
|
||||
MicroagentContentResponse,
|
||||
ProviderType,
|
||||
RequestMethod,
|
||||
SuggestedTask,
|
||||
@@ -13,40 +12,6 @@ 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.
|
||||
|
||||
@@ -178,30 +143,3 @@ 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,17 +33,14 @@ 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
|
||||
|
||||
@@ -599,104 +596,6 @@ 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,15 +1,11 @@
|
||||
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
|
||||
|
||||
|
||||
@@ -33,7 +29,6 @@ class TaskType(str, Enum):
|
||||
UNRESOLVED_COMMENTS = 'UNRESOLVED_COMMENTS'
|
||||
OPEN_ISSUE = 'OPEN_ISSUE'
|
||||
OPEN_PR = 'OPEN_PR'
|
||||
CREATE_MICROAGENT = 'CREATE_MICROAGENT'
|
||||
|
||||
|
||||
class OwnerType(str, Enum):
|
||||
@@ -120,12 +115,6 @@ 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
|
||||
@@ -207,12 +196,6 @@ 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'
|
||||
@@ -232,216 +215,6 @@ 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:
|
||||
@@ -531,20 +304,6 @@ 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
|
||||
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
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',
|
||||
]
|
||||
@@ -1,37 +0,0 @@
|
||||
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
|
||||
@@ -1,75 +0,0 @@
|
||||
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,6 +7,7 @@
|
||||
# Tag: Legacy-V0
|
||||
# V1 replacement for this module lives in the Software Agent SDK.
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import warnings
|
||||
@@ -245,8 +246,6 @@ 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]
|
||||
) = []
|
||||
@@ -505,7 +504,6 @@ 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,7 +2,6 @@ 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,
|
||||
@@ -16,6 +15,5 @@ __all__ = [
|
||||
'MCPClientTool',
|
||||
'fetch_mcp_tools_from_config',
|
||||
'call_tool_mcp',
|
||||
'add_mcp_tools_to_agent',
|
||||
'mcp_error_collector',
|
||||
]
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
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
|
||||
|
||||
@@ -21,7 +15,6 @@ 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,
|
||||
@@ -269,49 +262,3 @@ 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
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,15 +0,0 @@
|
||||
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',
|
||||
]
|
||||
@@ -1,193 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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',
|
||||
]
|
||||
@@ -1,69 +0,0 @@
|
||||
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)
|
||||
@@ -1,49 +0,0 @@
|
||||
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)
|
||||
@@ -1,188 +0,0 @@
|
||||
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)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user