Compare commits

..

26 Commits

Author SHA1 Message Date
dependabot[bot]
a815ad2c10 chore(deps): bump actions/setup-python from 5 to 6
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 5 to 6.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 21:41:44 +00:00
Tim O'Farrell
e86067c15b Removed V0 runtime (#14117) 2026-04-24 15:40:37 -06:00
aivong-openhands
137bede1f5 APP-1325: show GitLab/Slack sections without GitHub App configured (#14097) 2026-04-24 15:10:38 -04:00
Tim O'Farrell
8a1d80ac8f Removed Architecture diagrams (#14120) 2026-04-24 12:45:02 -06:00
Tim O'Farrell
77043da280 Removed V0 third party runtimes (#14119) 2026-04-24 12:23:01 -06:00
Tim O'Farrell
180a35f013 Removed V0 controller (#14060) 2026-04-24 11:05:17 -06:00
Tim O'Farrell
18365e0323 APP-1359 Removed V0 microagent Package (#14053) 2026-04-24 09:28:19 -06:00
aivong-openhands
9a743ff51a APP-1325: register GitlabV1CallbackProcessor for deserialization (#14110) 2026-04-24 11:01:06 -04:00
Graham Neubig
29577935b4 fix: preserve LLM and MCP settings in migration 108 (#14112)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 14:36:12 +00:00
Tim O'Farrell
7498353ed5 APP-1360 Removed V0 memory package (#14057) 2026-04-24 08:22:16 -06:00
Tim O'Farrell
b62bdfd143 chore: delete unused Python code identified by vulture analysis (#14111)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-24 07:36:57 -06:00
Tim O'Farrell
fb98faf4ac refactor: remove external dependencies on V0 packages (controller, memory, microagent) (#14106)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 17:09:46 -06:00
John-Mason P. Shackelford
a8f62aa30c feat: add secrets field to AppConversationStartRequest for direct API secret passing (#14009)
Add the ability for API callers to pass secrets directly when starting
a conversation, without requiring them to be pre-stored in the database.

Changes:
- Add optional `secrets: dict[str, SecretStr]` field to
  AppConversationStartRequest model
- Update `_build_start_conversation_request_for_user()` to merge
  API-provided secrets with existing secrets (from git providers/database)
- API-provided secrets take precedence over existing secrets with same name
- Add new `openhands/app_server/constants.py` with secret validation:
  - Blocked names: container config vars (OH_*, WORKER_*, etc.)
  - Blocked prefixes: LLM_* (to enforce app-server LLM controls)
  - Configurable size limits via environment variables
- Add warning log when API secrets override existing secrets
- Bump agent-server image to 1.18.1-python (SDK v1.18.1 with MCP
  secrets expansion support)

Closes #14007
2026-04-23 18:23:31 -04:00
Tim O'Farrell
1a7449b03a Remove dead code. (#14103) 2026-04-23 13:42:40 -06:00
Rohit Malhotra
1091901be2 Fix: Register SetTitleCallbackProcessor for webhook-created conversations (#14102)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 14:53:37 -04:00
Hiep Le
15160f6733 fix(frontend): show members a read-only badge on org-defaults pages (#14098) 2026-04-23 23:52:43 +07:00
Graham Neubig
13dba59bb8 Fix enterprise migration 108 settings mapping (#14088)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-23 12:47:37 -04:00
Tim O'Farrell
478c998f04 APP-1363 : Remove V0 io Package (#14094) 2026-04-23 09:31:01 -06:00
Tim O'Farrell
a9fc93ffbf More pieces of V0 carved off (#14089) 2026-04-23 08:26:40 -06:00
Tim O'Farrell
cc100c0d10 Removed the V0 resolver (#14062) 2026-04-23 07:48:32 -06:00
Rohit Malhotra
7bc3300981 Add missing SqlAlchemy type stub to mypy (#13413)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:52:27 +00:00
Rohit Malhotra
3e0283796e fix: add return type annotation for ConversationMetadata conversion (SQLAlchemy typing PR7) (#14081)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:37:18 +00:00
Rohit Malhotra
cd0175d83e fix: correct return types and remove unreachable code (SQLAlchemy typing PR6) (#14079)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 20:17:11 +00:00
Rohit Malhotra
f313cfceb9 fix: correct SQLAlchemy type annotations in DbSessionInjector (#14075)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 16:13:39 -04:00
Rohit Malhotra
fb0108f946 fix: handle nullable arguments in enterprise code (#14078)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 16:10:08 -04:00
Rohit Malhotra
6b29a82de3 fix: correct SQLAlchemy Result and Table type annotations (#14076)
Co-authored-by: openhands <openhands@all-hands.dev>
2026-04-22 19:43:14 +00:00
364 changed files with 2659 additions and 70780 deletions

View File

@@ -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 |

View File

@@ -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

View File

@@ -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"

View File

@@ -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"

View File

@@ -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.`
});

View File

@@ -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"

View File

@@ -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

View File

@@ -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?

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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/

View File

@@ -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

View File

@@ -1,5 +1,5 @@
# Exclude third-party runtime directory from linting
exclude = ["third_party/", "enterprise/"]
exclude = ["enterprise/"]
[lint]
select = [

View File

@@ -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

View File

@@ -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",

View File

@@ -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

View File

@@ -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',

View File

@@ -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'
),

View File

@@ -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 = ".."

View File

@@ -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

View File

@@ -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})'

View File

@@ -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

View File

@@ -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,

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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',

View File

@@ -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();
});

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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"

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View 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.'
)

View File

@@ -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()

View File

@@ -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):

View File

@@ -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

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -1,5 +0,0 @@
from openhands.controller.agent_controller import AgentController
__all__ = [
'AgentController',
]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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}'
)

View File

@@ -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

View File

@@ -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
)

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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

View File

@@ -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
# ============================================

View File

@@ -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)

View File

@@ -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,
)
)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

View File

@@ -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,
):

View File

@@ -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',
]

View File

@@ -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']

View File

@@ -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)

View File

@@ -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,

View File

@@ -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',

View File

@@ -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}'

View File

@@ -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

View File

@@ -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}')

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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.')

View File

@@ -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)}'

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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.

View File

@@ -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',
]

View File

@@ -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

View File

@@ -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',
]

View File

@@ -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)

View File

@@ -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)

View File

@@ -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