mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
2 Commits
main
...
gitlab-eve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9314052e89 | ||
|
|
08890f189f |
@@ -46,12 +46,34 @@ These files contain image tags that **must** be updated whenever the SDK version
|
||||
### `openhands/version.py`
|
||||
- Reads version from `pyproject.toml` at runtime → `openhands.__version__`
|
||||
|
||||
### `openhands/resolver/issue_resolver.py`
|
||||
- Builds `ghcr.io/openhands/runtime:{openhands.__version__}-nikolaik` dynamically
|
||||
|
||||
### `openhands/runtime/utils/runtime_build.py`
|
||||
- Base repo URL `ghcr.io/openhands/runtime` is a constant; version comes from elsewhere
|
||||
|
||||
### `.github/scripts/update_pr_description.sh`
|
||||
- Uses `${SHORT_SHA}` variable at CI runtime, not hardcoded
|
||||
|
||||
### `enterprise/Dockerfile`
|
||||
- `ARG BASE="ghcr.io/openhands/openhands"` — base image, version supplied at build time
|
||||
|
||||
## V0 Legacy Files (separate update cadence)
|
||||
|
||||
These reference the V0 runtime image (`ghcr.io/openhands/runtime:X.Y-nikolaik`) for local Docker/Kubernetes paths. They are **not** updated as part of a V1 release but may be updated independently.
|
||||
|
||||
### `Development.md`
|
||||
- `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:X.Y-nikolaik`
|
||||
|
||||
### `openhands/runtime/impl/kubernetes/README.md`
|
||||
- `runtime_container_image = "docker.openhands.dev/openhands/runtime:X.Y-nikolaik"`
|
||||
|
||||
### `enterprise/enterprise_local/README.md`
|
||||
- Uses `ghcr.io/openhands/runtime:main-nikolaik` (points to `main`, not versioned)
|
||||
|
||||
### `third_party/runtime/impl/daytona/README.md`
|
||||
- Uses `${OPENHANDS_VERSION}` variable, not hardcoded
|
||||
|
||||
## Image Registries
|
||||
|
||||
| Registry | Usage |
|
||||
|
||||
228
.github/workflows/e2e-tests.yml
vendored
Normal file
228
.github/workflows/e2e-tests.yml
vendored
Normal file
@@ -0,0 +1,228 @@
|
||||
name: End-to-End Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
branches:
|
||||
- main
|
||||
- develop
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e-tests:
|
||||
if: contains(github.event.pull_request.labels.*.name, 'end-to-end') || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
GITHUB_REPO_NAME: ${{ github.repository }}
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install poetry via pipx
|
||||
uses: abatilo/actions-poetry@v4
|
||||
with:
|
||||
poetry-version: 2.1.3
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: '3.12'
|
||||
cache: 'poetry'
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 xauth xvfb libgbm1 libasound2t64 netcat-openbsd
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'npm'
|
||||
cache-dependency-path: 'frontend/package-lock.json'
|
||||
|
||||
- name: Setup environment for end-to-end tests
|
||||
run: |
|
||||
# Create test results directory
|
||||
mkdir -p test-results
|
||||
|
||||
# Create downloads directory for OpenHands (use a directory in the home folder)
|
||||
mkdir -p $HOME/downloads
|
||||
sudo chown -R $USER:$USER $HOME/downloads
|
||||
sudo chmod -R 755 $HOME/downloads
|
||||
|
||||
- name: Build OpenHands
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
INSTALL_DOCKER: 1
|
||||
RUNTIME: docker
|
||||
FRONTEND_PORT: 12000
|
||||
FRONTEND_HOST: 0.0.0.0
|
||||
BACKEND_HOST: 0.0.0.0
|
||||
BACKEND_PORT: 3000
|
||||
ENABLE_BROWSER: true
|
||||
INSTALL_PLAYWRIGHT: 1
|
||||
run: |
|
||||
# Fix poetry.lock file if needed
|
||||
echo "Fixing poetry.lock file if needed..."
|
||||
poetry lock
|
||||
|
||||
# Build OpenHands using make build
|
||||
echo "Running make build..."
|
||||
make build
|
||||
|
||||
# Install Chromium Headless Shell for Playwright (needed for pytest-playwright)
|
||||
echo "Installing Chromium Headless Shell for Playwright..."
|
||||
poetry run playwright install chromium-headless-shell
|
||||
|
||||
# Verify Playwright browsers are installed (for e2e tests only)
|
||||
echo "Verifying Playwright browsers installation for e2e tests..."
|
||||
BROWSER_CHECK=$(poetry run python tests/e2e/check_playwright.py 2>/dev/null)
|
||||
|
||||
if [ "$BROWSER_CHECK" != "chromium_found" ]; then
|
||||
echo "ERROR: Chromium browser not found or not working for e2e tests"
|
||||
echo "$BROWSER_CHECK"
|
||||
exit 1
|
||||
else
|
||||
echo "Playwright browsers are properly installed for e2e tests."
|
||||
fi
|
||||
|
||||
# Docker runtime will handle workspace directory creation
|
||||
|
||||
# Start the application using make run with custom parameters and reduced logging
|
||||
echo "Starting OpenHands using make run..."
|
||||
# Set environment variables to reduce logging verbosity
|
||||
export PYTHONUNBUFFERED=1
|
||||
export LOG_LEVEL=WARNING
|
||||
export UVICORN_LOG_LEVEL=warning
|
||||
export OPENHANDS_LOG_LEVEL=WARNING
|
||||
FRONTEND_PORT=12000 FRONTEND_HOST=0.0.0.0 BACKEND_HOST=0.0.0.0 make run > /tmp/openhands-e2e-test.log 2>&1 &
|
||||
|
||||
# Store the PID of the make run process
|
||||
MAKE_PID=$!
|
||||
echo "OpenHands started with PID: $MAKE_PID"
|
||||
|
||||
# Wait for the application to start
|
||||
echo "Waiting for OpenHands to start..."
|
||||
max_attempts=15
|
||||
attempt=1
|
||||
|
||||
while [ $attempt -le $max_attempts ]; do
|
||||
echo "Checking if OpenHands is running (attempt $attempt of $max_attempts)..."
|
||||
|
||||
# Check if the process is still running
|
||||
if ! ps -p $MAKE_PID > /dev/null; then
|
||||
echo "ERROR: OpenHands process has terminated unexpectedly"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if frontend port is open
|
||||
if nc -z localhost 12000; then
|
||||
# Verify we can get HTML content
|
||||
if curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "SUCCESS: OpenHands is running and serving HTML content on port 12000"
|
||||
break
|
||||
else
|
||||
echo "Port 12000 is open but not serving HTML content yet"
|
||||
fi
|
||||
else
|
||||
echo "Frontend port 12000 is not open yet"
|
||||
fi
|
||||
|
||||
# Show log output on each attempt
|
||||
echo "Recent log output:"
|
||||
tail -n 20 /tmp/openhands-e2e-test.log
|
||||
|
||||
# Wait before next attempt
|
||||
echo "Waiting 10 seconds before next check..."
|
||||
sleep 10
|
||||
attempt=$((attempt + 1))
|
||||
|
||||
# Exit if we've reached the maximum number of attempts
|
||||
if [ $attempt -gt $max_attempts ]; then
|
||||
echo "ERROR: OpenHands failed to start after $max_attempts attempts"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
# Final verification that the app is running
|
||||
if ! nc -z localhost 12000 || ! curl -s http://localhost:12000 | grep -q "<html"; then
|
||||
echo "ERROR: OpenHands is not running properly on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Print success message
|
||||
echo "OpenHands is running successfully on port 12000"
|
||||
|
||||
- name: Run end-to-end tests
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.E2E_TEST_GITHUB_TOKEN }}
|
||||
LLM_MODEL: ${{ secrets.LLM_MODEL || 'gpt-4o' }}
|
||||
LLM_API_KEY: ${{ secrets.LLM_API_KEY || 'test-key' }}
|
||||
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
|
||||
run: |
|
||||
# Check if the application is running
|
||||
if ! nc -z localhost 12000; then
|
||||
echo "ERROR: OpenHands is not running on port 12000"
|
||||
echo "Last 50 lines of the log:"
|
||||
tail -n 50 /tmp/openhands-e2e-test.log
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Run the tests with detailed output
|
||||
cd tests/e2e
|
||||
poetry run python -m pytest \
|
||||
test_settings.py::test_github_token_configuration \
|
||||
test_conversation.py::test_conversation_start \
|
||||
test_browsing_catchphrase.py::test_browsing_catchphrase \
|
||||
test_multi_conversation_resume.py::test_multi_conversation_resume \
|
||||
-v --no-header --capture=no --timeout=900
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: tests/e2e/test-results/
|
||||
retention-days: 30
|
||||
|
||||
- name: Upload OpenHands logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: openhands-logs
|
||||
path: |
|
||||
/tmp/openhands-e2e-test.log
|
||||
/tmp/openhands-e2e-build.log
|
||||
/tmp/openhands-backend.log
|
||||
/tmp/openhands-frontend.log
|
||||
/tmp/backend-health-check.log
|
||||
/tmp/frontend-check.log
|
||||
/tmp/vite-config.log
|
||||
/tmp/makefile-contents.log
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
# Stop OpenHands processes
|
||||
echo "Stopping OpenHands processes..."
|
||||
pkill -f "python -m openhands.server" || true
|
||||
pkill -f "npm run dev" || true
|
||||
pkill -f "make run" || true
|
||||
|
||||
# Print process status for debugging
|
||||
echo "Checking if any OpenHands processes are still running:"
|
||||
ps aux | grep -E "openhands|npm run dev" || true
|
||||
40
.github/workflows/pr-review-by-openhands.yml
vendored
40
.github/workflows/pr-review-by-openhands.yml
vendored
@@ -2,14 +2,12 @@
|
||||
name: PR Review by OpenHands
|
||||
|
||||
on:
|
||||
# Use pull_request for same-repo PRs so workflow changes can self-verify in PRs.
|
||||
# TEMPORARY MITIGATION (Clinejection hardening)
|
||||
#
|
||||
# We temporarily avoid `pull_request_target` here. We'll restore it after the PR review
|
||||
# workflow is fully hardened for untrusted execution.
|
||||
pull_request:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
# Use pull_request_target for fork PRs.
|
||||
# The bot token used here is intentionally scoped to PR review operations,
|
||||
# so the remaining blast radius is bounded even though PR content is untrusted.
|
||||
pull_request_target:
|
||||
types: [opened, ready_for_review, labeled, review_requested]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -18,33 +16,13 @@ permissions:
|
||||
|
||||
jobs:
|
||||
pr-review:
|
||||
# Run on same-repo PRs via pull_request and on fork PRs via pull_request_target.
|
||||
# Trigger when one of the following conditions is met:
|
||||
# 1. A new non-draft PR is opened by a non-first-time contributor, OR
|
||||
# 2. A draft PR is converted to ready for review by a non-first-time contributor, OR
|
||||
# 3. The 'review-this' label is added, OR
|
||||
# 4. openhands-agent or all-hands-bot is requested as a reviewer
|
||||
# Note: FIRST_TIME_CONTRIBUTOR and NONE PRs require manual trigger via label/reviewer request.
|
||||
# Trigger logic:
|
||||
# 1. Route same-repo PRs through `pull_request` and fork PRs through `pull_request_target`
|
||||
# 2. Auto-trigger on `opened` / `ready_for_review` for non-first-time contributors
|
||||
# 3. Always allow manual triggers via `review-this` or reviewer request
|
||||
# The author association check is duplicated intentionally for both
|
||||
# auto-triggered actions (`opened` and `ready_for_review`).
|
||||
# Note: fork PRs will not have access to repository secrets under `pull_request`.
|
||||
# Skip forks to avoid noisy failures until we restore a hardened `pull_request_target` flow.
|
||||
if: |
|
||||
github.event.pull_request.head.repo.full_name == github.repository &&
|
||||
(
|
||||
(
|
||||
github.event_name == 'pull_request' &&
|
||||
github.event.pull_request.head.repo.full_name == github.repository
|
||||
) ||
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.head.repo.full_name != github.repository
|
||||
)
|
||||
) &&
|
||||
(
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'ready_for_review' && github.event.pull_request.author_association != 'FIRST_TIME_CONTRIBUTOR' && github.event.pull_request.author_association != 'NONE') ||
|
||||
(github.event.action == 'opened' && github.event.pull_request.draft == false) ||
|
||||
github.event.action == 'ready_for_review' ||
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'review-this') ||
|
||||
(
|
||||
github.event.action == 'review_requested' &&
|
||||
|
||||
4
.github/workflows/py-tests.yml
vendored
4
.github/workflows/py-tests.yml
vendored
@@ -60,6 +60,10 @@ jobs:
|
||||
run: PYTHONPATH=".:$PYTHONPATH" poetry run pytest --forked -n auto -s ./tests/unit --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.${{ matrix.python_version }}"
|
||||
- name: Run Runtime Tests with CLIRuntime
|
||||
run: PYTHONPATH=".:$PYTHONPATH" TEST_RUNTIME=cli poetry run pytest -n 5 --reruns 2 --reruns-delay 3 -s tests/runtime/test_bash.py --cov=openhands --cov-branch
|
||||
env:
|
||||
COVERAGE_FILE: ".coverage.runtime.${{ matrix.python_version }}"
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
|
||||
31
AGENTS.md
31
AGENTS.md
@@ -27,7 +27,7 @@ Before pushing any changes, you MUST ensure that any lint errors or simple test
|
||||
|
||||
* If you've made changes to the backend, you should run `pre-commit run --config ./dev_config/python/.pre-commit-config.yaml` (this will run on staged files).
|
||||
* If you've made changes to the frontend, you should run `cd frontend && npm run lint:fix && npm run build ; cd ..`
|
||||
* If you've made changes to the VSCode extension, you should run `cd openhands/app_server/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
|
||||
* If you've made changes to the VSCode extension, you should run `cd openhands/integrations/vscode && npm run lint:fix && npm run compile ; cd ../../..`
|
||||
|
||||
The pre-commit hooks MUST pass successfully before pushing any changes to the repository. This is a mandatory requirement to maintain code quality and consistency.
|
||||
|
||||
@@ -150,7 +150,7 @@ Frontend:
|
||||
|
||||
|
||||
VSCode Extension:
|
||||
- Located in the `openhands/app_server/integrations/vscode` directory
|
||||
- Located in the `openhands/integrations/vscode` directory
|
||||
- Setup: Run `npm install` in the extension directory
|
||||
- Linting:
|
||||
- Run linting with fixes: `npm run lint:fix`
|
||||
@@ -284,32 +284,6 @@ If you are starting a pull request (PR), please follow the template in `.github/
|
||||
|
||||
These details may or may not be useful for your current task.
|
||||
|
||||
### Conversation State Management
|
||||
|
||||
#### Agent State and Sandbox Status:
|
||||
The frontend uses `useAgentState` hook (`frontend/src/hooks/use-agent-state.ts`) to determine the current conversation state. This hook:
|
||||
- Returns `curAgentState` (AgentState enum) for UI state determination
|
||||
- Returns `isArchived` flag when `sandbox_status === "MISSING"` (archived conversations)
|
||||
- Prioritizes live WebSocket execution status over cached API data
|
||||
|
||||
#### Archived Conversations (sandbox_status === "MISSING"):
|
||||
When a conversation's sandbox is no longer available (archived):
|
||||
- `useAgentState` returns `AgentState.STOPPED` and `isArchived: true`
|
||||
- Chat input is replaced with an archived banner (`ArchivedBanner` component)
|
||||
- VS Code tab, Terminal, and Planner show read-only messages instead of loading states
|
||||
- All interactive elements that require a running sandbox are disabled
|
||||
|
||||
#### Testing useAgentState:
|
||||
When mocking `useAgentState` in tests, always include the `isArchived` property:
|
||||
```typescript
|
||||
vi.mock("#/hooks/use-agent-state", () => ({
|
||||
useAgentState: () => ({
|
||||
curAgentState: AgentState.AWAITING_USER_INPUT,
|
||||
isArchived: false,
|
||||
}),
|
||||
}));
|
||||
```
|
||||
|
||||
### Microagents
|
||||
|
||||
Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge and task-specific workflows. They are Markdown files that can include frontmatter for configuration.
|
||||
@@ -389,7 +363,6 @@ There are two main patterns for saving settings in the OpenHands frontend:
|
||||
**When to use each pattern:**
|
||||
- Use Pattern 1 (Immediate Save) for entity management where each item is independent
|
||||
- Use Pattern 2 (Manual Save) for configuration forms where settings are interdependent or need validation
|
||||
- Git provider tokens in the local/OSS integrations settings are managed through the V1 secrets endpoints (`POST`/`DELETE /api/v1/secrets/git-providers`). Do not reuse the logout flow for disconnecting tokens; `useLogout` is for actual app logout and still targets legacy OSS logout behavior.
|
||||
|
||||
### Adding New LLM Models
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ Full details in our [Development Guide](./Development.md).
|
||||
|
||||
- **[Frontend](./frontend/README.md)** - React application
|
||||
- **[App Server (V1)](./openhands/app_server/README.md)** - Current FastAPI application server and REST API modules
|
||||
- **[Runtime](./openhands/runtime/README.md)** - Execution environments
|
||||
- **[Evaluation](https://github.com/OpenHands/benchmarks)** - Testing and benchmarks
|
||||
|
||||
## What Can You Build?
|
||||
|
||||
@@ -16,7 +16,7 @@ open source community:
|
||||
|
||||
#### [Aider](https://github.com/paul-gauthier/aider)
|
||||
- License: Apache License 2.0
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks.
|
||||
- Description: AI pair programming tool. OpenHands has adapted and integrated its linter module for code-related tasks in [`agentskills utilities`](https://github.com/OpenHands/OpenHands/tree/main/openhands/runtime/plugins/agent_skills/utils/aider)
|
||||
|
||||
#### [BrowserGym](https://github.com/ServiceNow/BrowserGym)
|
||||
- License: Apache License 2.0
|
||||
|
||||
@@ -309,6 +309,16 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
|
||||
---
|
||||
|
||||
## Using Existing Docker Images
|
||||
|
||||
To reduce build time, you can use an existing runtime image:
|
||||
|
||||
```bash
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:1.2-nikolaik
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Help
|
||||
|
||||
```bash
|
||||
@@ -329,3 +339,4 @@ make help
|
||||
- [/tests/unit/README.md](./tests/unit/README.md): Guide to writing and running unit tests
|
||||
- [OpenHands/benchmarks](https://github.com/OpenHands/benchmarks): Documentation for the evaluation framework and benchmarks
|
||||
- [/skills/README.md](./skills/README.md): Information about the skills architecture and implementation
|
||||
- [/openhands/runtime/README.md](./openhands/runtime/README.md): Documentation for the runtime environment and execution model
|
||||
|
||||
@@ -88,6 +88,7 @@ USER openhands
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./skills ./skills
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
|
||||
@@ -23,6 +23,18 @@ if [ -z "$WORKSPACE_MOUNT_PATH" ]; then
|
||||
unset WORKSPACE_BASE
|
||||
fi
|
||||
|
||||
if [[ "$INSTALL_THIRD_PARTY_RUNTIMES" == "true" ]]; then
|
||||
echo "Downloading and installing third_party_runtimes..."
|
||||
echo "Warning: Third-party runtimes are provided as-is, not actively supported and may be removed in future releases."
|
||||
|
||||
if pip install 'openhands-ai[third_party_runtimes]' -qqq 2> >(tee /dev/stderr); then
|
||||
echo "third_party_runtimes installed successfully."
|
||||
else
|
||||
echo "Failed to install third_party_runtimes." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ "$SANDBOX_USER_ID" -eq 0 ]]; then
|
||||
echo "Running OpenHands as root"
|
||||
export RUN_AS_OPENHANDS=false
|
||||
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -37,12 +37,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(enterprise/)
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
|
||||
@@ -10,7 +10,7 @@ strict_optional = True
|
||||
disable_error_code = type-abstract
|
||||
|
||||
# Exclude third-party runtime directory from type checking
|
||||
exclude = (enterprise/)
|
||||
exclude = (third_party/|enterprise/)
|
||||
|
||||
[mypy-openai.*]
|
||||
follow_imports = skip
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Exclude third-party runtime directory from linting
|
||||
exclude = ["enterprise/"]
|
||||
exclude = ["third_party/", "enterprise/"]
|
||||
|
||||
[lint]
|
||||
select = [
|
||||
|
||||
@@ -61,6 +61,13 @@ export LITE_LLM_API_KEY=<your LLM API key>
|
||||
python enterprise_local/convert_to_env.py
|
||||
```
|
||||
|
||||
You'll also need to set up the runtime image, so that the dev server doesn't try to rebuild it.
|
||||
|
||||
```
|
||||
export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/openhands/runtime:main-nikolaik
|
||||
docker pull $SANDBOX_RUNTIME_CONTAINER_IMAGE
|
||||
```
|
||||
|
||||
By default the application will log in json, you can override.
|
||||
|
||||
```
|
||||
@@ -196,6 +203,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
@@ -229,6 +237,7 @@ And then invoking `printenv`. NOTE: _DO NOT DO THIS WITH PROD!!!_ (Hopefully by
|
||||
"REDIS_HOST": "localhost:6379",
|
||||
"OPENHANDS": "<YOUR LOCAL OPENHANDS DIR>",
|
||||
"FRONTEND_DIRECTORY": "<YOUR LOCAL OPENHANDS DIR>/frontend/build",
|
||||
"SANDBOX_RUNTIME_CONTAINER_IMAGE": "ghcr.io/openhands/runtime:main-nikolaik",
|
||||
"FILE_STORE_PATH": "<YOUR HOME DIRECTORY>>/.openhands-state",
|
||||
"OPENHANDS_CONFIG_CLS": "server.config.SaaSServerConfig",
|
||||
"GITHUB_APP_ID": "1062351",
|
||||
|
||||
@@ -112,6 +112,9 @@ lines.append(
|
||||
lines.append(
|
||||
'OPENHANDS_BITBUCKET_DATA_CENTER_SERVICE_CLS=integrations.bitbucket_data_center.bitbucket_dc_service.SaaSBitbucketDCService'
|
||||
)
|
||||
lines.append(
|
||||
'OPENHANDS_CONVERSATION_VALIDATOR_CLS=storage.saas_conversation_validator.SaasConversationValidator'
|
||||
)
|
||||
lines.append('POSTHOG_CLIENT_KEY=test')
|
||||
lines.append('ENABLE_PROACTIVE_CONVERSATION_STARTERS=true')
|
||||
lines.append('MAX_CONCURRENT_CONVERSATIONS=10')
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.bitbucket.bitbucket_service import (
|
||||
BitBucketService,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket.bitbucket_service import BitBucketService
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class SaaSBitBucketService(BitBucketService):
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.bitbucket_data_center.bitbucket_dc_service import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.bitbucket_data_center.bitbucket_dc_service import (
|
||||
BitbucketDCService,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class SaaSBitbucketDCService(BitbucketDCService):
|
||||
|
||||
@@ -19,12 +19,12 @@ from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from storage.openhands_pr import OpenhandsPR
|
||||
from storage.openhands_pr_store import OpenhandsPRStore
|
||||
|
||||
from openhands.app_server.conversation_paths import get_conversation_dir
|
||||
from openhands.app_server.file_store import get_file_store
|
||||
from openhands.app_server.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.locations import get_conversation_dir
|
||||
|
||||
config = load_openhands_config()
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
@@ -112,7 +112,7 @@ class GitHubDataCollector:
|
||||
suffix = path.format(repo_id, number)
|
||||
|
||||
if conversation_id:
|
||||
return f'{get_conversation_dir(conversation_id)}/{suffix}'
|
||||
return f'{get_conversation_dir(conversation_id)}{suffix}'
|
||||
|
||||
return suffix
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from types import MappingProxyType
|
||||
|
||||
from github import Auth, Github, GithubIntegration
|
||||
from integrations.github.data_collector import GitHubDataCollector
|
||||
from integrations.github.github_solvability import summarize_issue_solvability
|
||||
from integrations.github.github_view import (
|
||||
GithubFactory,
|
||||
GithubFailingAction,
|
||||
@@ -19,6 +20,7 @@ from integrations.models import (
|
||||
from integrations.types import ResolverViewInterface
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_SOLVABILITY_ANALYSIS,
|
||||
HOST_URL,
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR,
|
||||
get_session_expired_message,
|
||||
@@ -31,15 +33,15 @@ from server.auth.auth_error import ExpiredError
|
||||
from server.auth.constants import GITHUB_APP_CLIENT_ID, GITHUB_APP_PRIVATE_KEY
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.app_server.integrations.service_types import AuthenticationError
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.integrations.service_types import AuthenticationError
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class GithubManager(Manager[GithubViewType]):
|
||||
@@ -356,7 +358,26 @@ class GithubManager(Manager[GithubViewType]):
|
||||
)
|
||||
)
|
||||
|
||||
conversation_id = await github_view.initialize_new_conversation()
|
||||
# We first initialize a conversation and generate the solvability report BEFORE starting the conversation runtime
|
||||
# This helps us accumulate llm spend without requiring a running runtime. This setups us up for
|
||||
# 1. If there is a problem starting the runtime we still have accumulated total conversation cost
|
||||
# 2. In the future, based on the report confidence we can conditionally start the conversation
|
||||
# 3. Once the conversation is started, its base cost will include the report's spend as well which allows us to control max budget per resolver task
|
||||
convo_metadata = await github_view.initialize_new_conversation()
|
||||
solvability_summary = None
|
||||
if not ENABLE_SOLVABILITY_ANALYSIS:
|
||||
logger.info(
|
||||
'[Github]: Solvability report feature is disabled, skipping'
|
||||
)
|
||||
else:
|
||||
try:
|
||||
solvability_summary = await summarize_issue_solvability(
|
||||
github_view, user_token
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'[Github]: Error summarizing issue solvability: {str(e)}'
|
||||
)
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
github_view.user_info.keycloak_user_id, self.token_manager
|
||||
@@ -365,21 +386,26 @@ class GithubManager(Manager[GithubViewType]):
|
||||
await github_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
conversation_id,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id_hex = github_view.conversation_id
|
||||
conversation_id = github_view.conversation_id
|
||||
|
||||
logger.info(
|
||||
f'[GitHub] Created conversation {conversation_id_hex} for user {user_info.username}'
|
||||
f'[GitHub] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
|
||||
# Send message with conversation link
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id_hex)
|
||||
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
base_msg = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
# Combine messages: include solvability report with "I'm on it!" if successful
|
||||
if solvability_summary:
|
||||
msg_info = f'{base_msg}\n\n{solvability_summary}'
|
||||
else:
|
||||
msg_info = base_msg
|
||||
|
||||
except MissingSettingsError as e:
|
||||
logger.warning(
|
||||
|
||||
@@ -4,9 +4,9 @@ from integrations.store_repo_utils import store_repositories_in_db
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.github.github_service import GitHubService
|
||||
from openhands.app_server.integrations.service_types import ProviderType, Repository
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GitHubService
|
||||
from openhands.integrations.service_types import ProviderType, Repository
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
|
||||
188
enterprise/integrations/github/github_solvability.py
Normal file
188
enterprise/integrations/github/github_solvability.py
Normal file
@@ -0,0 +1,188 @@
|
||||
import asyncio
|
||||
import time
|
||||
|
||||
from github import Auth, Github
|
||||
from integrations.github.github_view import (
|
||||
GithubInlinePRComment,
|
||||
GithubIssueComment,
|
||||
GithubPRComment,
|
||||
GithubViewType,
|
||||
)
|
||||
from integrations.solvability.data import load_classifier
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from integrations.solvability.models.summary import SolvabilitySummary
|
||||
from integrations.utils import ENABLE_SOLVABILITY_ANALYSIS
|
||||
from pydantic import ValidationError
|
||||
from server.config import get_config
|
||||
from storage.saas_settings_store import SaasSettingsStore
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
|
||||
|
||||
def fetch_github_issue_context(
|
||||
github_view: GithubViewType,
|
||||
user_token: str,
|
||||
) -> str:
|
||||
"""Fetch full GitHub issue/PR context including title, body, and comments.
|
||||
|
||||
Args:
|
||||
full_repo_name: Full repository name in the format 'owner/repo'
|
||||
issue_number: The issue or PR number
|
||||
user_token: GitHub user access token
|
||||
max_comments: Maximum number of comments to fetch (default: 10)
|
||||
max_comment_length: Maximum length of each comment to include in the context (default: 500)
|
||||
|
||||
Returns:
|
||||
A comprehensive string containing the issue/PR context
|
||||
"""
|
||||
|
||||
# Build context string
|
||||
context_parts = []
|
||||
|
||||
# Add title and body
|
||||
context_parts.append(f'Title: {github_view.title}')
|
||||
context_parts.append(f'Description:\n{github_view.description}')
|
||||
|
||||
with Github(auth=Auth.Token(user_token)) as github_client:
|
||||
repo = github_client.get_repo(github_view.full_repo_name)
|
||||
issue = repo.get_issue(github_view.issue_number)
|
||||
if issue.labels:
|
||||
labels = [label.name for label in issue.labels]
|
||||
context_parts.append(f"Labels: {', '.join(labels)}")
|
||||
|
||||
for comment in github_view.previous_comments:
|
||||
context_parts.append(f'- {comment.author}: {comment.body}')
|
||||
|
||||
return '\n\n'.join(context_parts)
|
||||
|
||||
|
||||
async def summarize_issue_solvability(
|
||||
github_view: GithubViewType,
|
||||
user_token: str,
|
||||
timeout: float = 60.0 * 5,
|
||||
) -> str:
|
||||
"""Generate a solvability summary for an issue using the resolver view interface.
|
||||
|
||||
Args:
|
||||
resolver_view: A resolver view interface instance (e.g., GithubIssue, GithubPRComment)
|
||||
user_token: GitHub user access token for API access
|
||||
timeout: Maximum time in seconds to wait for the result (default: 60.0)
|
||||
|
||||
Returns:
|
||||
The solvability summary as a string
|
||||
|
||||
Raises:
|
||||
ValueError: If LLM settings cannot be found for the user
|
||||
asyncio.TimeoutError: If the operation exceeds the specified timeout
|
||||
"""
|
||||
if not ENABLE_SOLVABILITY_ANALYSIS:
|
||||
raise ValueError('Solvability report feature is disabled')
|
||||
|
||||
if github_view.user_info.keycloak_user_id is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No user ID found for user {github_view.user_info.username}'
|
||||
)
|
||||
|
||||
# Grab the user's information so we can load their LLM configuration
|
||||
store = SaasSettingsStore(
|
||||
user_id=github_view.user_info.keycloak_user_id,
|
||||
config=get_config(),
|
||||
)
|
||||
|
||||
user_settings = await store.load()
|
||||
|
||||
if user_settings is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No user settings found for user ID {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
# Check if solvability analysis is enabled for this user, exit early if
|
||||
# needed
|
||||
if not getattr(user_settings, 'enable_solvability_analysis', False):
|
||||
raise ValueError(
|
||||
f'Solvability analysis disabled for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
agent_settings = user_settings.agent_settings
|
||||
llm_settings = agent_settings.llm
|
||||
if llm_settings.api_key is None:
|
||||
raise ValueError(
|
||||
f'[Solvability] No LLM API key found for user {github_view.user_info.user_id}'
|
||||
)
|
||||
|
||||
try:
|
||||
llm_config = LLMConfig(
|
||||
model=llm_settings.model,
|
||||
api_key=llm_settings.api_key.get_secret_value(),
|
||||
base_url=llm_settings.base_url,
|
||||
)
|
||||
except ValidationError as e:
|
||||
raise ValueError(
|
||||
f'[Solvability] Invalid LLM configuration for user {github_view.user_info.user_id}: {str(e)}'
|
||||
)
|
||||
|
||||
# Fetch the full GitHub issue/PR context using the GitHub API
|
||||
start_time = time.time()
|
||||
issue_context = fetch_github_issue_context(github_view, user_token)
|
||||
logger.info(
|
||||
f'[Solvability] Grabbed issue context for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'response_latency': time.time() - start_time,
|
||||
'full_repo_name': github_view.full_repo_name,
|
||||
'issue_number': github_view.issue_number,
|
||||
},
|
||||
)
|
||||
|
||||
# For comment-based triggers, also include the specific comment that triggered the action
|
||||
if isinstance(
|
||||
github_view, (GithubIssueComment, GithubPRComment, GithubInlinePRComment)
|
||||
):
|
||||
issue_context += f'\n\nTriggering Comment:\n{github_view.comment_body}'
|
||||
|
||||
solvability_classifier = load_classifier('default-classifier')
|
||||
|
||||
async with asyncio.timeout(timeout):
|
||||
solvability_report: SolvabilityReport = await call_sync_from_async(
|
||||
lambda: solvability_classifier.solvability_report(
|
||||
issue_context, llm_config=llm_config
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f'[Solvability] Generated report for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'report': solvability_report.model_dump(exclude=['issue']),
|
||||
},
|
||||
)
|
||||
|
||||
llm_registry, conversation_stats, _ = create_registry_and_conversation_stats(
|
||||
get_config(),
|
||||
github_view.conversation_id,
|
||||
github_view.user_info.keycloak_user_id,
|
||||
None,
|
||||
)
|
||||
|
||||
solvability_summary = await call_sync_from_async(
|
||||
lambda: SolvabilitySummary.from_report(
|
||||
solvability_report,
|
||||
llm=llm_registry.get_llm(
|
||||
service_id='solvability_analysis', config=llm_config
|
||||
),
|
||||
)
|
||||
)
|
||||
conversation_stats.save_metrics()
|
||||
|
||||
logger.info(
|
||||
f'[Solvability] Generated summary for {github_view.conversation_id}',
|
||||
extra={
|
||||
'conversation_id': github_view.conversation_id,
|
||||
'summary': solvability_summary.model_dump(exclude=['content']),
|
||||
},
|
||||
)
|
||||
|
||||
return solvability_summary.format_as_markdown()
|
||||
@@ -14,9 +14,11 @@ from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS,
|
||||
ENABLE_V1_GITHUB_RESOLVER,
|
||||
HOST,
|
||||
HOST_URL,
|
||||
get_oh_labels,
|
||||
get_user_v1_enabled_setting,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
@@ -25,28 +27,37 @@ from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.org_store import OrgStore
|
||||
from storage.proactive_conversation_store import ProactiveConversationStore
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.app_server.integrations.service_types import Comment
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_service import GithubServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
|
||||
|
||||
async def is_v1_enabled_for_github_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITHUB_RESOLVER
|
||||
|
||||
|
||||
async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's proactive conversation setting.
|
||||
|
||||
@@ -94,6 +105,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
title: str
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return getattr(self, 'branch_name', None)
|
||||
@@ -140,7 +152,11 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
self.v1_enabled = await is_v1_enabled_for_github_resolver(
|
||||
self.user_info.keycloak_user_id
|
||||
)
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='github',
|
||||
@@ -148,20 +164,54 @@ class GithubIssue(ResolverViewInterface):
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
# All conversations use V1 app conversation service
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
return conversation_id
|
||||
logger.info(
|
||||
f'[GitHub V1]: User flag found for {self.user_info.keycloak_user_id} is {self.v1_enabled}'
|
||||
)
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversationm metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
self.conversation_id = uuid4().hex
|
||||
return ConversationMetadata(
|
||||
conversation_id=self.conversation_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITHUB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja_env, saas_user_auth, conversation_id)
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
|
||||
async def _get_v1_initial_user_message(self, jinja_env: Environment) -> str:
|
||||
"""Build the initial user message for V1 resolver conversations.
|
||||
@@ -189,7 +239,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitHub V1]: Creating V1 conversation')
|
||||
@@ -209,7 +259,7 @@ class GithubIssue(ResolverViewInterface):
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=conversation_id,
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
# NOTE: Resolver instructions are intended to be lower priority than the
|
||||
# system prompt, so we inject them into the initial user message.
|
||||
system_message_suffix=None,
|
||||
@@ -763,6 +813,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_issue_comment(message):
|
||||
@@ -788,6 +839,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_pr_comment(message):
|
||||
@@ -829,6 +881,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif GithubFactory.is_inline_pr_comment(message):
|
||||
@@ -862,6 +915,7 @@ class GithubFactory:
|
||||
title='',
|
||||
description='',
|
||||
previous_comments=[],
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
|
||||
@@ -25,15 +25,15 @@ from jinja2 import Environment, FileSystemLoader
|
||||
from pydantic import SecretStr
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.app_server.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderToken, ProviderType
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
|
||||
|
||||
class GitlabManager(Manager[GitlabViewType]):
|
||||
@@ -208,8 +208,8 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
)
|
||||
)
|
||||
|
||||
# Initialize conversation and get UUID
|
||||
conversation_id = await gitlab_view.initialize_new_conversation()
|
||||
# Initialize conversation and get metadata (following GitHub pattern)
|
||||
convo_metadata = await gitlab_view.initialize_new_conversation()
|
||||
|
||||
saas_user_auth = await get_saas_user_auth(
|
||||
gitlab_view.user_info.keycloak_user_id, self.token_manager
|
||||
@@ -218,19 +218,19 @@ class GitlabManager(Manager[GitlabViewType]):
|
||||
await gitlab_view.create_new_conversation(
|
||||
self.jinja_env,
|
||||
secret_store.provider_tokens,
|
||||
conversation_id,
|
||||
convo_metadata,
|
||||
saas_user_auth,
|
||||
)
|
||||
|
||||
conversation_id_hex = gitlab_view.conversation_id
|
||||
conversation_id = gitlab_view.conversation_id
|
||||
|
||||
logger.info(
|
||||
f'[GitLab] Created conversation {conversation_id_hex} for user {user_info.username}'
|
||||
f'[GitLab] Created conversation {conversation_id} for user {user_info.username}'
|
||||
)
|
||||
|
||||
# V1 callback processors are registered by the view during conversation creation
|
||||
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id_hex)
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
msg_info = f"I'm on it! {user_info.username} can [track my progress at all-hands.dev]({conversation_link})"
|
||||
|
||||
except MissingSettingsError as e:
|
||||
|
||||
@@ -7,14 +7,14 @@ from server.auth.token_manager import TokenManager
|
||||
from storage.gitlab_webhook import GitlabWebhook, WebhookStatus
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabService
|
||||
from openhands.app_server.integrations.service_types import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabService
|
||||
from openhands.integrations.service_types import (
|
||||
ProviderType,
|
||||
RateLimitError,
|
||||
Repository,
|
||||
RequestMethod,
|
||||
)
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
|
||||
@@ -6,36 +6,47 @@ from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.types import ResolverViewInterface, UserData
|
||||
from integrations.utils import (
|
||||
ENABLE_V1_GITLAB_RESOLVER,
|
||||
HOST,
|
||||
get_oh_labels,
|
||||
get_user_v1_enabled_setting,
|
||||
has_exact_mention,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.config import get_config
|
||||
from storage.saas_conversation_store import SaasConversationStore
|
||||
from storage.saas_secrets_store import SaasSecretsStore
|
||||
|
||||
from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.app_server.integrations.service_types import Comment
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderType
|
||||
from openhands.integrations.service_types import Comment
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
|
||||
OH_LABEL, INLINE_OH_LABEL = get_oh_labels(HOST)
|
||||
CONFIDENTIAL_NOTE = 'confidential_note'
|
||||
NOTE_TYPES = ['note', CONFIDENTIAL_NOTE]
|
||||
|
||||
|
||||
async def is_v1_enabled_for_gitlab_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_GITLAB_RESOLVER
|
||||
|
||||
|
||||
# =================================================
|
||||
# SECTION: Factory to create appriorate Gitlab view
|
||||
# =================================================
|
||||
@@ -57,6 +68,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
description: str
|
||||
previous_comments: list[Comment]
|
||||
is_mr: bool
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_branch_name(self) -> str | None:
|
||||
return getattr(self, 'branch_name', None)
|
||||
@@ -102,7 +114,10 @@ class GitlabIssue(ResolverViewInterface):
|
||||
|
||||
return user_secrets.custom_secrets if user_secrets else None
|
||||
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
async def initialize_new_conversation(self) -> ConversationMetadata:
|
||||
# v1_enabled is already set at construction time in the factory method
|
||||
# This is the source of truth for the conversation type
|
||||
|
||||
# Resolve target org based on claimed git organizations
|
||||
self.resolved_org_id = await resolve_org_for_repo(
|
||||
provider='gitlab',
|
||||
@@ -110,26 +125,57 @@ class GitlabIssue(ResolverViewInterface):
|
||||
keycloak_user_id=self.user_info.keycloak_user_id,
|
||||
)
|
||||
|
||||
# All conversations use V1 app conversation service
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
return conversation_id
|
||||
if self.v1_enabled:
|
||||
# Create dummy conversation metadata
|
||||
# Don't save to conversation store
|
||||
# V1 conversations are stored in a separate table
|
||||
self.conversation_id = uuid4().hex
|
||||
return ConversationMetadata(
|
||||
conversation_id=self.conversation_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
)
|
||||
|
||||
# Create the conversation store with resolver org routing
|
||||
# (bypasses initialize_conversation to avoid threading enterprise-only
|
||||
# resolver_org_id through the generic OSS interface)
|
||||
store = await SaasConversationStore.get_resolver_instance(
|
||||
get_config(),
|
||||
self.user_info.keycloak_user_id,
|
||||
self.resolved_org_id,
|
||||
)
|
||||
|
||||
conversation_id = uuid4().hex
|
||||
conversation_metadata = ConversationMetadata(
|
||||
trigger=ConversationTrigger.RESOLVER,
|
||||
conversation_id=conversation_id,
|
||||
title=get_default_conversation_title(conversation_id),
|
||||
user_id=self.user_info.keycloak_user_id,
|
||||
selected_repository=self.full_repo_name,
|
||||
selected_branch=self._get_branch_name(),
|
||||
git_provider=ProviderType.GITLAB,
|
||||
)
|
||||
await store.save_metadata(conversation_metadata)
|
||||
|
||||
self.conversation_id = conversation_id
|
||||
return conversation_metadata
|
||||
|
||||
async def create_new_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: PROVIDER_TOKEN_TYPE,
|
||||
conversation_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
saas_user_auth: UserAuth,
|
||||
):
|
||||
# V0 conversation path has been removed - all conversations use V1 app conversation service
|
||||
await self._create_v1_conversation(jinja_env, saas_user_auth, conversation_id)
|
||||
await self._create_v1_conversation(
|
||||
jinja_env, saas_user_auth, conversation_metadata
|
||||
)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
saas_user_auth: UserAuth,
|
||||
conversation_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[GitLab V1]: Creating V1 conversation')
|
||||
@@ -155,7 +201,7 @@ class GitlabIssue(ResolverViewInterface):
|
||||
|
||||
# Create the V1 conversation start request with the callback processor
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=conversation_id,
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
system_message_suffix=conversation_instructions,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.full_repo_name,
|
||||
@@ -404,6 +450,16 @@ class GitlabFactory:
|
||||
user_id=user_id, username=username, keycloak_user_id=keycloak_user_id
|
||||
)
|
||||
|
||||
# Check v1_enabled at construction time - this is the source of truth
|
||||
v1_enabled = (
|
||||
await is_v1_enabled_for_gitlab_resolver(keycloak_user_id)
|
||||
if keycloak_user_id
|
||||
else False
|
||||
)
|
||||
logger.info(
|
||||
f'[GitLab V1]: User flag found for {keycloak_user_id} is {v1_enabled}'
|
||||
)
|
||||
|
||||
if GitlabFactory.is_labeled_issue(message):
|
||||
issue_iid = payload['object_attributes']['iid']
|
||||
|
||||
@@ -425,6 +481,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_issue_comment(message):
|
||||
@@ -455,6 +512,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=False,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message):
|
||||
@@ -487,6 +545,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
elif GitlabFactory.is_mr_comment(message, inline=True):
|
||||
@@ -527,6 +586,7 @@ class GitlabFactory:
|
||||
description='',
|
||||
previous_comments=[],
|
||||
is_mr=True,
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
|
||||
raise ValueError(f'Unhandled GitLab webhook event: {message}')
|
||||
|
||||
@@ -42,13 +42,13 @@ from storage.jira_integration_store import JiraIntegrationStore
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
|
||||
@@ -7,7 +7,7 @@ from jinja2 import Environment
|
||||
from storage.jira_user import JiraUser
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from integrations.jira.jira_payload import JiraWebhookPayload
|
||||
|
||||
@@ -35,15 +35,18 @@ from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
JIRA_CLOUD_API_URL = 'https://api.atlassian.com/ex/jira'
|
||||
@@ -189,30 +192,32 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
)
|
||||
await integration_store.create_conversation(jira_conversation)
|
||||
|
||||
conversation_id = await self._initialize_conversation()
|
||||
await self._create_v1_conversation(jinja_env, conversation_id)
|
||||
conversation_metadata = await self._create_v1_metadata()
|
||||
await self._create_v1_conversation(jinja_env, conversation_metadata)
|
||||
return self.conversation_id
|
||||
|
||||
async def _initialize_conversation(self) -> UUID:
|
||||
"""Initialize conversation and return the conversation ID.
|
||||
async def _create_v1_metadata(self) -> ConversationMetadata:
|
||||
"""Create conversation metadata for V1 conversations.
|
||||
|
||||
The JiraConversation mapping is saved to the integration store (above), but
|
||||
V1 conversation metadata is managed by the app conversation system, not
|
||||
the legacy conversation store.
|
||||
"""
|
||||
logger.info('[Jira]: Initializing V1 conversation')
|
||||
logger.info('[Jira]: Creating V1 metadata')
|
||||
|
||||
# Generate a conversation ID for V1
|
||||
conversation_id = uuid4()
|
||||
self.conversation_id = conversation_id.hex
|
||||
# Generate a dummy conversation for V1 (not saved to store)
|
||||
self.conversation_id = uuid4().hex
|
||||
self.resolved_org_id = await self._get_resolved_org_id()
|
||||
|
||||
return conversation_id
|
||||
return ConversationMetadata(
|
||||
conversation_id=self.conversation_id,
|
||||
selected_repository=self.selected_repo,
|
||||
)
|
||||
|
||||
async def _create_v1_conversation(
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
conversation_id: UUID,
|
||||
conversation_metadata: ConversationMetadata,
|
||||
):
|
||||
"""Create conversation using the new V1 app conversation system."""
|
||||
logger.info('[Jira]: Creating V1 conversation')
|
||||
@@ -231,7 +236,7 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
|
||||
# Create the V1 conversation start request
|
||||
start_request = AppConversationStartRequest(
|
||||
conversation_id=conversation_id,
|
||||
conversation_id=UUID(conversation_metadata.conversation_id),
|
||||
system_message_suffix=None,
|
||||
initial_message=initial_message,
|
||||
selected_repository=self.selected_repo,
|
||||
|
||||
@@ -29,16 +29,16 @@ from storage.jira_dc_integration_store import JiraDcIntegrationStore
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.integrations.service_types import Repository
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import Repository
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ from jinja2 import Environment
|
||||
from storage.jira_dc_user import JiraDcUser
|
||||
from storage.jira_dc_workspace import JiraDcWorkspace
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class JiraDcViewInterface(ABC):
|
||||
|
||||
@@ -27,15 +27,17 @@ from openhands.agent_server.models import SendMessageRequest
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler, ProviderType
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
|
||||
integration_store = JiraDcIntegrationStore.get_instance()
|
||||
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
from uuid import UUID
|
||||
|
||||
from openhands.app_server.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType, UserGitInfo
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE, ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType, UserGitInfo
|
||||
from openhands.sdk.secret import SecretSource, StaticSecret
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
class ResolverUserContext(UserContext):
|
||||
|
||||
@@ -26,7 +26,6 @@ class SlackErrorCode(Enum):
|
||||
PROVIDER_AUTH_FAILED = 'SLACK_ERR_006'
|
||||
LLM_AUTH_FAILED = 'SLACK_ERR_007'
|
||||
MISSING_SETTINGS = 'SLACK_ERR_008'
|
||||
MISSING_SLACK_SCOPES = 'SLACK_ERR_009'
|
||||
UNEXPECTED_ERROR = 'SLACK_ERR_999'
|
||||
|
||||
|
||||
@@ -99,11 +98,6 @@ _USER_MESSAGES: dict[SlackErrorCode, str] = {
|
||||
'{username} please re-login into '
|
||||
f'[OpenHands Cloud]({HOST_URL}) before starting a job.'
|
||||
),
|
||||
SlackErrorCode.MISSING_SLACK_SCOPES: (
|
||||
'⚠️ The Slack app is missing required permissions. '
|
||||
f'Please ask your workspace admin to re-install the OpenHands Slack App at {HOST_URL}/slack/install '
|
||||
'to authorize the updated permissions.'
|
||||
),
|
||||
SlackErrorCode.UNEXPECTED_ERROR: (
|
||||
'Uh oh! There was an unexpected error (ref: {code}). Please try again later.'
|
||||
),
|
||||
|
||||
@@ -30,20 +30,20 @@ from sqlalchemy import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.integrations.service_types import (
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import (
|
||||
AuthenticationError,
|
||||
ProviderTimeoutError,
|
||||
Repository,
|
||||
)
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import config, server_config, sio
|
||||
from openhands.server.types import (
|
||||
LLMAuthenticationError,
|
||||
MissingSettingsError,
|
||||
SessionExpiredError,
|
||||
)
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
authorize_url_generator = AuthorizeUrlGenerator(
|
||||
client_id=SLACK_CLIENT_ID,
|
||||
|
||||
@@ -5,7 +5,7 @@ from integrations.types import SummaryExtractionTracker
|
||||
from jinja2 import Environment
|
||||
from storage.slack_user import SlackUser
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -112,6 +112,7 @@ class SlackViewInterface(SlackMessageView, SummaryExtractionTracker, ABC):
|
||||
should_extract: bool
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
@abstractmethod
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
|
||||
@@ -5,7 +5,6 @@ from uuid import UUID, uuid4
|
||||
from integrations.models import Message
|
||||
from integrations.resolver_context import ResolverUserContext
|
||||
from integrations.resolver_org_router import resolve_org_for_repo
|
||||
from integrations.slack.slack_errors import SlackError, SlackErrorCode
|
||||
from integrations.slack.slack_types import (
|
||||
SlackMessageView,
|
||||
SlackViewInterface,
|
||||
@@ -14,10 +13,11 @@ from integrations.slack.slack_types import (
|
||||
from integrations.slack.slack_v1_callback_processor import SlackV1CallbackProcessor
|
||||
from integrations.utils import (
|
||||
CONVERSATION_URL,
|
||||
ENABLE_V1_SLACK_RESOLVER,
|
||||
get_user_v1_enabled_setting,
|
||||
)
|
||||
from jinja2 import Environment
|
||||
from slack_sdk import WebClient
|
||||
from slack_sdk.errors import SlackApiError
|
||||
from storage.slack_conversation import SlackConversation
|
||||
from storage.slack_conversation_store import SlackConversationStore
|
||||
from storage.slack_team_store import SlackTeamStore
|
||||
@@ -26,17 +26,19 @@ from storage.slack_user import SlackUser
|
||||
from openhands.app_server.app_conversation.app_conversation_models import (
|
||||
AppConversationStartRequest,
|
||||
AppConversationStartTaskStatus,
|
||||
ConversationTrigger,
|
||||
SendMessageRequest,
|
||||
)
|
||||
from openhands.app_server.config import get_app_conversation_service
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.sandbox.sandbox_models import SandboxStatus
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.app_server.user.specifiy_user_context import USER_CONTEXT_ATTR
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.sdk import TextContent
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationTrigger,
|
||||
)
|
||||
from openhands.utils.async_utils import GENERAL_TIMEOUT
|
||||
|
||||
# =================================================
|
||||
@@ -49,6 +51,10 @@ slack_conversation_store = SlackConversationStore.get_instance()
|
||||
slack_team_store = SlackTeamStore.get_instance()
|
||||
|
||||
|
||||
async def is_v1_enabled_for_slack_resolver(user_id: str) -> bool:
|
||||
return await get_user_v1_enabled_setting(user_id) and ENABLE_V1_SLACK_RESOLVER
|
||||
|
||||
|
||||
@dataclass
|
||||
class SlackNewConversationView(SlackViewInterface):
|
||||
bot_access_token: str
|
||||
@@ -64,6 +70,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
send_summary_instruction: bool
|
||||
conversation_id: str
|
||||
team_id: str
|
||||
v1_enabled: bool
|
||||
|
||||
def _get_initial_prompt(self, text: str, blocks: list[dict]):
|
||||
bot_id = self._get_bot_id(blocks)
|
||||
@@ -88,34 +95,24 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
messages = []
|
||||
if self.thread_ts:
|
||||
client = WebClient(token=self.bot_access_token)
|
||||
try:
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.thread_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT, # We can be smarter about getting more context/condensing it even in the future
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get('error') == 'missing_scope':
|
||||
raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e
|
||||
raise
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.thread_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT, # We can be smarter about getting more context/condensing it even in the future
|
||||
)
|
||||
|
||||
messages = result['messages']
|
||||
|
||||
else:
|
||||
client = WebClient(token=self.bot_access_token)
|
||||
try:
|
||||
result = client.conversations_history(
|
||||
channel=self.channel_id,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT,
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get('error') == 'missing_scope':
|
||||
raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e
|
||||
raise
|
||||
result = client.conversations_history(
|
||||
channel=self.channel_id,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=CONTEXT_LIMIT,
|
||||
)
|
||||
|
||||
messages = result['messages']
|
||||
messages.reverse()
|
||||
@@ -152,7 +149,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'Attempting to start conversation without confirming selected repo from user'
|
||||
)
|
||||
|
||||
async def save_slack_convo(self):
|
||||
async def save_slack_convo(self, v1_enabled: bool = False):
|
||||
if self.slack_to_openhands_user:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
@@ -164,6 +161,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
'keycloak_user_id': user_info.keycloak_user_id,
|
||||
'org_id': user_info.org_id,
|
||||
'parent_id': self.thread_ts or self.message_ts,
|
||||
'v1_enabled': v1_enabled,
|
||||
},
|
||||
)
|
||||
slack_conversation = SlackConversation(
|
||||
@@ -173,7 +171,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
org_id=user_info.org_id,
|
||||
parent_id=self.thread_ts
|
||||
or self.message_ts, # conversations can start in a thread reply as well; we should always references the parent's (root level msg's) message ID
|
||||
v1_enabled=True, # All conversations are V1
|
||||
v1_enabled=v1_enabled,
|
||||
)
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
@@ -270,7 +268,7 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
)
|
||||
|
||||
logger.info(f'[Slack V1]: Created new conversation: {self.conversation_id}')
|
||||
await self.save_slack_convo()
|
||||
await self.save_slack_convo(v1_enabled=True)
|
||||
|
||||
def get_response_msg(self) -> str:
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
@@ -292,18 +290,13 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
|
||||
async def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
client = WebClient(token=self.bot_access_token)
|
||||
try:
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.message_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=1, # Get exact user message, in future we can be smarter with collecting additional context
|
||||
)
|
||||
except SlackApiError as e:
|
||||
if e.response.get('error') == 'missing_scope':
|
||||
raise SlackError(SlackErrorCode.MISSING_SLACK_SCOPES) from e
|
||||
raise
|
||||
result = client.conversations_replies(
|
||||
channel=self.channel_id,
|
||||
ts=self.message_ts,
|
||||
inclusive=True,
|
||||
latest=self.message_ts,
|
||||
limit=1, # Get exact user message, in future we can be smarter with collecting additional context
|
||||
)
|
||||
|
||||
user_message = result['messages'][0]
|
||||
user_message = self._get_initial_prompt(
|
||||
@@ -382,7 +375,7 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
)
|
||||
|
||||
# 6. Send the message to the agent server
|
||||
url = f'{agent_server_url.rstrip("/")}/api/conversations/{UUID(self.conversation_id)}/events'
|
||||
url = f"{agent_server_url.rstrip('/')}/api/conversations/{UUID(self.conversation_id)}/events"
|
||||
|
||||
headers = {'X-Session-API-Key': running_sandbox.session_api_key}
|
||||
payload = send_message_request.model_dump()
|
||||
@@ -523,6 +516,7 @@ class SlackFactory:
|
||||
conversation_id=conversation.conversation_id,
|
||||
slack_conversation=conversation,
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
elif SlackFactory.did_user_select_repo_from_form(message):
|
||||
@@ -540,6 +534,7 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -557,6 +552,7 @@ class SlackFactory:
|
||||
send_summary_instruction=True,
|
||||
conversation_id='',
|
||||
team_id=team_id,
|
||||
v1_enabled=False,
|
||||
)
|
||||
|
||||
|
||||
|
||||
41
enterprise/integrations/solvability/data/__init__.py
Normal file
41
enterprise/integrations/solvability/data/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
Utilities for loading and managing pre-trained classifiers.
|
||||
|
||||
Assumes that classifiers are stored adjacent to this file in the `solvability/data` directory, using a simple
|
||||
`name + .json` pattern.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from integrations.solvability.models.classifier import SolvabilityClassifier
|
||||
|
||||
|
||||
def load_classifier(name: str) -> SolvabilityClassifier:
|
||||
"""
|
||||
Load a classifier by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the classifier to load.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: The loaded classifier instance.
|
||||
"""
|
||||
data_dir = Path(__file__).parent
|
||||
classifier_path = data_dir / f'{name}.json'
|
||||
|
||||
if not classifier_path.exists():
|
||||
raise FileNotFoundError(f"Classifier '{name}' not found at {classifier_path}")
|
||||
|
||||
with classifier_path.open('r') as f:
|
||||
return SolvabilityClassifier.model_validate_json(f.read())
|
||||
|
||||
|
||||
def available_classifiers() -> list[str]:
|
||||
"""
|
||||
List all available classifiers in the data directory.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of classifier names (without the .json extension).
|
||||
"""
|
||||
data_dir = Path(__file__).parent
|
||||
return [f.stem for f in data_dir.glob('*.json') if f.is_file()]
|
||||
File diff suppressed because one or more lines are too long
38
enterprise/integrations/solvability/models/__init__.py
Normal file
38
enterprise/integrations/solvability/models/__init__.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""
|
||||
Solvability Models Package
|
||||
|
||||
This package contains the core machine learning models and components for predicting
|
||||
the solvability of GitHub issues and similar technical problems.
|
||||
|
||||
The solvability prediction system works by:
|
||||
1. Using a Featurizer to extract semantic features from issue descriptions via LLM calls
|
||||
2. Training a RandomForestClassifier on these features to predict solvability
|
||||
3. Generating detailed reports with feature importance analysis
|
||||
|
||||
Key Components:
|
||||
- Feature: Defines individual features that can be extracted from issues
|
||||
- Featurizer: Orchestrates LLM-based feature extraction with sampling and batching
|
||||
- SolvabilityClassifier: Main ML pipeline combining featurization and classification
|
||||
- SolvabilityReport: Comprehensive output with predictions, feature analysis, and metadata
|
||||
- ImportanceStrategy: Configurable methods for calculating feature importance (SHAP, permutation, impurity)
|
||||
"""
|
||||
|
||||
from integrations.solvability.models.classifier import SolvabilityClassifier
|
||||
from integrations.solvability.models.featurizer import (
|
||||
EmbeddingDimension,
|
||||
Feature,
|
||||
FeatureEmbedding,
|
||||
Featurizer,
|
||||
)
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
|
||||
__all__ = [
|
||||
'Feature',
|
||||
'EmbeddingDimension',
|
||||
'FeatureEmbedding',
|
||||
'Featurizer',
|
||||
'ImportanceStrategy',
|
||||
'SolvabilityClassifier',
|
||||
'SolvabilityReport',
|
||||
]
|
||||
433
enterprise/integrations/solvability/models/classifier.py
Normal file
433
enterprise/integrations/solvability/models/classifier.py
Normal file
@@ -0,0 +1,433 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import pickle
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import shap
|
||||
from integrations.solvability.models.featurizer import Feature, Featurizer
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
PrivateAttr,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from sklearn.ensemble import RandomForestClassifier
|
||||
from sklearn.exceptions import NotFittedError
|
||||
from sklearn.inspection import permutation_importance
|
||||
from sklearn.utils.validation import check_is_fitted
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
|
||||
|
||||
class SolvabilityClassifier(BaseModel):
|
||||
"""
|
||||
Machine learning pipeline for predicting the solvability of GitHub issues and similar problems.
|
||||
|
||||
This classifier combines LLM-based feature extraction with traditional ML classification:
|
||||
1. Uses a Featurizer to extract semantic boolean features from issue descriptions via LLM calls
|
||||
2. Trains a RandomForestClassifier on these features to predict solvability scores
|
||||
3. Provides feature importance analysis using configurable strategies (SHAP, permutation, impurity)
|
||||
4. Generates comprehensive reports with predictions, feature analysis, and cost metrics
|
||||
|
||||
The classifier supports both training on labeled data and inference on new issues, with built-in
|
||||
support for batch processing and concurrent feature extraction.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""
|
||||
The identifier for the classifier.
|
||||
"""
|
||||
|
||||
featurizer: Featurizer
|
||||
"""
|
||||
The featurizer to use for transforming the input data.
|
||||
"""
|
||||
|
||||
classifier: RandomForestClassifier
|
||||
"""
|
||||
The RandomForestClassifier used for predicting solvability from extracted features.
|
||||
|
||||
This ensemble model provides robust predictions and built-in feature importance metrics.
|
||||
"""
|
||||
|
||||
importance_strategy: ImportanceStrategy = ImportanceStrategy.IMPURITY
|
||||
"""
|
||||
Strategy to use for calculating feature importance.
|
||||
"""
|
||||
|
||||
samples: int = 10
|
||||
"""
|
||||
Number of samples to use for calculating feature embedding coefficients.
|
||||
"""
|
||||
|
||||
random_state: int | None = None
|
||||
"""
|
||||
Random state for reproducibility.
|
||||
"""
|
||||
|
||||
_classifier_attrs: dict[str, Any] = PrivateAttr(default_factory=dict)
|
||||
"""
|
||||
Private dictionary storing cached results from feature extraction and importance calculations.
|
||||
|
||||
Contains keys like 'features_', 'cost_', 'feature_importances_', and 'labels_' that are populated
|
||||
during transform(), fit(), and predict() operations. Access these via the corresponding properties.
|
||||
|
||||
This field is never serialized, so cached values will not persist across model save/load cycles.
|
||||
"""
|
||||
|
||||
model_config = {
|
||||
'arbitrary_types_allowed': True,
|
||||
}
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_random_state(self) -> SolvabilityClassifier:
|
||||
"""
|
||||
Validate the random state configuration between this object and the classifier.
|
||||
"""
|
||||
# If both random states are set, they definitely need to agree.
|
||||
if self.random_state is not None and self.classifier.random_state is not None:
|
||||
if self.random_state != self.classifier.random_state:
|
||||
raise ValueError(
|
||||
'The random state of the classifier and the top-level classifier must agree.'
|
||||
)
|
||||
|
||||
# Otherwise, we'll always set the classifier's random state to the top-level one.
|
||||
self.classifier.random_state = self.random_state
|
||||
|
||||
return self
|
||||
|
||||
@property
|
||||
def features_(self) -> pd.DataFrame:
|
||||
"""
|
||||
Get the features used by the classifier for the most recent inputs.
|
||||
"""
|
||||
if 'features_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'SolvabilityClassifier.transform() has not yet been called.'
|
||||
)
|
||||
return self._classifier_attrs['features_']
|
||||
|
||||
@property
|
||||
def cost_(self) -> pd.DataFrame:
|
||||
"""
|
||||
Get the cost of the classifier for the most recent inputs.
|
||||
"""
|
||||
if 'cost_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'SolvabilityClassifier.transform() has not yet been called.'
|
||||
)
|
||||
return self._classifier_attrs['cost_']
|
||||
|
||||
@property
|
||||
def feature_importances_(self) -> np.ndarray:
|
||||
"""
|
||||
Get the feature importances for the most recent inputs.
|
||||
"""
|
||||
if 'feature_importances_' not in self._classifier_attrs:
|
||||
raise ValueError(
|
||||
'No SolvabilityClassifier methods that produce feature importances (.fit(), .predict_proba(), and '
|
||||
'.predict()) have been called.'
|
||||
)
|
||||
return self._classifier_attrs['feature_importances_'] # type: ignore[no-any-return]
|
||||
|
||||
@property
|
||||
def is_fitted(self) -> bool:
|
||||
"""
|
||||
Check if the classifier is fitted.
|
||||
"""
|
||||
try:
|
||||
check_is_fitted(self.classifier)
|
||||
return True
|
||||
except NotFittedError:
|
||||
return False
|
||||
|
||||
def transform(self, issues: pd.Series, llm_config: LLMConfig) -> pd.DataFrame:
|
||||
"""
|
||||
Transform the input issues using the featurizer to extract features.
|
||||
|
||||
This method orchestrates the feature extraction pipeline:
|
||||
1. Uses the featurizer to generate embeddings for all issues
|
||||
2. Converts embeddings to a structured DataFrame
|
||||
3. Separates feature columns from metadata columns
|
||||
4. Stores results for later access via properties
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
pd.DataFrame: A DataFrame containing only the feature columns (no metadata).
|
||||
"""
|
||||
# Generate feature embeddings for all issues using batch processing
|
||||
feature_embeddings = self.featurizer.embed_batch(
|
||||
issues, samples=self.samples, llm_config=llm_config
|
||||
)
|
||||
df = pd.DataFrame(embedding.to_row() for embedding in feature_embeddings)
|
||||
|
||||
# Split into feature columns (used by classifier) and cost columns (metadata)
|
||||
feature_columns = [feature.identifier for feature in self.featurizer.features]
|
||||
cost_columns = [col for col in df.columns if col not in feature_columns]
|
||||
|
||||
# Store both sets for access via properties
|
||||
self._classifier_attrs['features_'] = df[feature_columns]
|
||||
self._classifier_attrs['cost_'] = df[cost_columns]
|
||||
|
||||
return self.features_
|
||||
|
||||
def fit(
|
||||
self, issues: pd.Series, labels: pd.Series, llm_config: LLMConfig
|
||||
) -> SolvabilityClassifier:
|
||||
"""
|
||||
Fit the classifier to the input issues and labels.
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
|
||||
labels: A pandas Series containing the labels (0 or 1) for each issue.
|
||||
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: The fitted classifier.
|
||||
"""
|
||||
features = self.transform(issues, llm_config=llm_config)
|
||||
self.classifier.fit(features, labels)
|
||||
|
||||
# Store labels for permutation importance calculation
|
||||
self._classifier_attrs['labels_'] = labels
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, self.classifier.predict_proba(features), labels
|
||||
)
|
||||
|
||||
return self
|
||||
|
||||
def predict_proba(self, issues: pd.Series, llm_config: LLMConfig) -> np.ndarray:
|
||||
"""
|
||||
Predict the solvability probabilities for the input issues.
|
||||
|
||||
Returns class probabilities where the second column represents the probability
|
||||
of the issue being solvable (positive class).
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Array of shape (n_samples, 2) with probabilities for each class.
|
||||
Column 0: probability of not solvable, Column 1: probability of solvable.
|
||||
"""
|
||||
features = self.transform(issues, llm_config=llm_config)
|
||||
scores = self.classifier.predict_proba(features)
|
||||
|
||||
# Calculate feature importances based on the configured strategy
|
||||
# For permutation importance, we need ground truth labels if available
|
||||
labels = self._classifier_attrs.get('labels_')
|
||||
if (
|
||||
self.importance_strategy == ImportanceStrategy.PERMUTATION
|
||||
and labels is not None
|
||||
):
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, scores, labels
|
||||
)
|
||||
else:
|
||||
self._classifier_attrs['feature_importances_'] = self._importance(
|
||||
features, scores
|
||||
)
|
||||
|
||||
return scores # type: ignore[no-any-return]
|
||||
|
||||
def predict(self, issues: pd.Series, llm_config: LLMConfig) -> np.ndarray:
|
||||
"""
|
||||
Predict the solvability of the input issues by returning binary labels.
|
||||
|
||||
Uses a 0.5 probability threshold to convert probabilities to binary predictions.
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
llm_config: LLM configuration to use for feature extraction.
|
||||
|
||||
Returns:
|
||||
np.ndarray: Boolean array where True indicates the issue is predicted as solvable.
|
||||
"""
|
||||
probabilities = self.predict_proba(issues, llm_config=llm_config)
|
||||
# Apply 0.5 threshold to convert probabilities to binary predictions
|
||||
labels = probabilities[:, 1] >= 0.5
|
||||
return labels
|
||||
|
||||
def _importance(
|
||||
self,
|
||||
features: pd.DataFrame,
|
||||
scores: np.ndarray,
|
||||
labels: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Calculate feature importance scores using the configured strategy.
|
||||
|
||||
Different strategies provide different interpretations:
|
||||
- SHAP: Shapley values indicating contribution to individual predictions
|
||||
- PERMUTATION: Decrease in model performance when feature is shuffled
|
||||
- IMPURITY: Gini impurity decrease from splits on each feature
|
||||
|
||||
Args:
|
||||
features: Feature matrix used for predictions.
|
||||
scores: Model prediction scores (unused for some strategies).
|
||||
labels: Ground truth labels (required for permutation importance).
|
||||
|
||||
Returns:
|
||||
np.ndarray: Feature importance scores, one per feature.
|
||||
"""
|
||||
match self.importance_strategy:
|
||||
case ImportanceStrategy.SHAP:
|
||||
# Use SHAP TreeExplainer for tree-based models
|
||||
explainer = shap.TreeExplainer(self.classifier)
|
||||
shap_values = explainer.shap_values(features)
|
||||
# Return mean SHAP values for the positive class (solvable)
|
||||
return shap_values.mean(axis=0)[:, 1] # type: ignore[no-any-return]
|
||||
|
||||
case ImportanceStrategy.PERMUTATION:
|
||||
# Permutation importance requires ground truth labels
|
||||
if labels is None:
|
||||
raise ValueError('Labels are required for permutation importance')
|
||||
result = permutation_importance(
|
||||
self.classifier,
|
||||
features,
|
||||
labels,
|
||||
n_repeats=10, # Number of permutation rounds for stability
|
||||
random_state=self.random_state,
|
||||
)
|
||||
return result.importances_mean # type: ignore[no-any-return]
|
||||
|
||||
case ImportanceStrategy.IMPURITY:
|
||||
# Use built-in feature importances from RandomForest
|
||||
return self.classifier.feature_importances_ # type: ignore[no-any-return]
|
||||
|
||||
case _:
|
||||
raise ValueError(
|
||||
f'Unknown importance strategy: {self.importance_strategy}'
|
||||
)
|
||||
|
||||
def add_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""
|
||||
Add new features to the classifier's featurizer.
|
||||
|
||||
Note: Adding features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
|
||||
Args:
|
||||
features: List of Feature objects to add.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: Self for method chaining.
|
||||
"""
|
||||
for feature in features:
|
||||
if feature not in self.featurizer.features:
|
||||
self.featurizer.features.append(feature)
|
||||
return self
|
||||
|
||||
def forget_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""
|
||||
Remove features from the classifier's featurizer.
|
||||
|
||||
Note: Removing features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
|
||||
Args:
|
||||
features: List of Feature objects to remove.
|
||||
|
||||
Returns:
|
||||
SolvabilityClassifier: Self for method chaining.
|
||||
"""
|
||||
for feature in features:
|
||||
try:
|
||||
self.featurizer.features.remove(feature)
|
||||
except ValueError:
|
||||
# Feature not in list, continue with others
|
||||
continue
|
||||
return self
|
||||
|
||||
@field_serializer('classifier')
|
||||
@staticmethod
|
||||
def _rfc_to_json(rfc: RandomForestClassifier) -> str:
|
||||
"""
|
||||
Convert a RandomForestClassifier to a JSON-compatible value (a string).
|
||||
"""
|
||||
return base64.b64encode(pickle.dumps(rfc)).decode('utf-8')
|
||||
|
||||
@field_validator('classifier', mode='before')
|
||||
@staticmethod
|
||||
def _json_to_rfc(value: str | RandomForestClassifier) -> RandomForestClassifier:
|
||||
"""
|
||||
Convert a JSON-compatible value (a string) back to a RandomForestClassifier.
|
||||
"""
|
||||
if isinstance(value, RandomForestClassifier):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
model = pickle.loads(base64.b64decode(value))
|
||||
if isinstance(model, RandomForestClassifier):
|
||||
return model
|
||||
except Exception as e:
|
||||
raise ValueError(f'Failed to decode the classifier: {e}')
|
||||
|
||||
raise ValueError(
|
||||
'The classifier must be a RandomForestClassifier or a JSON-compatible dictionary.'
|
||||
)
|
||||
|
||||
def solvability_report(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
|
||||
Args:
|
||||
issue: The issue description for which to generate the report.
|
||||
llm_config: Optional LLM configuration to use for feature extraction.
|
||||
kwargs: Additional metadata to include in the report.
|
||||
|
||||
Returns:
|
||||
SolvabilityReport: The generated solvability report.
|
||||
"""
|
||||
if not self.is_fitted:
|
||||
raise ValueError(
|
||||
'The classifier must be fitted before generating a report.'
|
||||
)
|
||||
|
||||
scores = self.predict_proba(pd.Series([issue]), llm_config=llm_config)
|
||||
|
||||
return SolvabilityReport(
|
||||
identifier=self.identifier,
|
||||
issue=issue,
|
||||
score=scores[0, 1],
|
||||
features=self.features_.iloc[0].to_dict(),
|
||||
samples=self.samples,
|
||||
importance_strategy=self.importance_strategy,
|
||||
# Unlike the features, the importances are just a series with no link
|
||||
# to the actual feature names. For that we have to recombine with the
|
||||
# feature identifiers.
|
||||
feature_importances=dict(
|
||||
zip(
|
||||
self.featurizer.feature_identifiers(),
|
||||
self.feature_importances_.tolist(),
|
||||
)
|
||||
),
|
||||
random_state=self.random_state,
|
||||
metadata=dict(kwargs) if kwargs else None,
|
||||
# Both cost and response_latency are columns in the cost_ DataFrame,
|
||||
# so we can get both by just unpacking the first row.
|
||||
**self.cost_.iloc[0].to_dict(),
|
||||
)
|
||||
|
||||
def __call__(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
"""
|
||||
return self.solvability_report(issue, llm_config=llm_config, **kwargs)
|
||||
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class DifficultyLevel(Enum):
|
||||
"""Enum representing the difficulty level based on solvability score."""
|
||||
|
||||
EASY = ('EASY', 0.7, '🟢')
|
||||
MEDIUM = ('MEDIUM', 0.4, '🟡')
|
||||
HARD = ('HARD', 0.0, '🔴')
|
||||
|
||||
def __init__(self, label: str, threshold: float, emoji: str):
|
||||
self.label = label
|
||||
self.threshold = threshold
|
||||
self.emoji = emoji
|
||||
|
||||
@classmethod
|
||||
def from_score(cls, score: float) -> DifficultyLevel:
|
||||
"""Get difficulty level from a solvability score.
|
||||
|
||||
Returns the difficulty level with the highest threshold that is less than or equal to the given score.
|
||||
"""
|
||||
# Sort enum values by threshold in descending order
|
||||
sorted_levels = sorted(cls, key=lambda x: x.threshold, reverse=True)
|
||||
|
||||
# Find the first level where score meets the threshold
|
||||
for level in sorted_levels:
|
||||
if score >= level.threshold:
|
||||
return level
|
||||
|
||||
# This should never happen if thresholds are set correctly,
|
||||
# but return the lowest threshold level as fallback
|
||||
return sorted_levels[-1]
|
||||
|
||||
def format_display(self) -> str:
|
||||
"""Format the difficulty level for display."""
|
||||
return f'{self.emoji} **Solvability: {self.label}**'
|
||||
368
enterprise/integrations/solvability/models/featurizer.py
Normal file
368
enterprise/integrations/solvability/models/featurizer.py
Normal file
@@ -0,0 +1,368 @@
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.core.config import LLMConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
class Feature(BaseModel):
|
||||
"""
|
||||
Represents a single boolean feature that can be extracted from issue descriptions.
|
||||
|
||||
Features are semantic properties of issues (e.g., "has_code_example", "requires_debugging")
|
||||
that are evaluated by LLMs and used as input to the solvability classifier.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""Unique identifier for the feature, used as column name in feature matrices."""
|
||||
|
||||
description: str
|
||||
"""Human-readable description of what the feature represents, used in LLM prompts."""
|
||||
|
||||
@property
|
||||
def to_tool_description_field(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert this feature to a JSON schema field for LLM tool calling.
|
||||
|
||||
Returns:
|
||||
dict: JSON schema field definition for this feature.
|
||||
"""
|
||||
return {
|
||||
'type': 'boolean',
|
||||
'description': self.description,
|
||||
}
|
||||
|
||||
|
||||
class EmbeddingDimension(BaseModel):
|
||||
"""
|
||||
Represents a single dimension (feature evaluation) within a feature embedding sample.
|
||||
|
||||
Each dimension corresponds to one feature being evaluated as true/false for a given issue.
|
||||
"""
|
||||
|
||||
feature_id: str
|
||||
"""Identifier of the feature being evaluated."""
|
||||
|
||||
result: bool
|
||||
"""Boolean result of the feature evaluation for this sample."""
|
||||
|
||||
|
||||
# Type alias for a single embedding sample - maps feature identifiers to boolean values
|
||||
EmbeddingSample = dict[str, bool]
|
||||
"""
|
||||
A single sample from the LLM evaluation of features for an issue.
|
||||
Maps feature identifiers to their boolean evaluations.
|
||||
"""
|
||||
|
||||
|
||||
class FeatureEmbedding(BaseModel):
|
||||
"""
|
||||
Represents the complete feature embedding for a single issue, including multiple samples
|
||||
and associated metadata about the LLM calls used to generate it.
|
||||
|
||||
Multiple samples are collected to account for LLM variability and provide more robust
|
||||
feature estimates through averaging.
|
||||
"""
|
||||
|
||||
samples: list[EmbeddingSample]
|
||||
"""List of individual feature evaluation samples from the LLM."""
|
||||
|
||||
prompt_tokens: int | None = None
|
||||
"""Total prompt tokens consumed across all LLM calls for this embedding."""
|
||||
|
||||
completion_tokens: int | None = None
|
||||
"""Total completion tokens generated across all LLM calls for this embedding."""
|
||||
|
||||
response_latency: float | None = None
|
||||
"""Total response latency (seconds) across all LLM calls for this embedding."""
|
||||
|
||||
@property
|
||||
def dimensions(self) -> list[str]:
|
||||
"""
|
||||
Get all unique feature identifiers present across all samples.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers that appear in at least one sample.
|
||||
"""
|
||||
dims: set[str] = set()
|
||||
for sample in self.samples:
|
||||
dims.update(sample.keys())
|
||||
return list(dims)
|
||||
|
||||
def coefficient(self, dimension: str) -> float | None:
|
||||
"""
|
||||
Calculate the average coefficient (0-1) for a specific feature dimension.
|
||||
|
||||
This computes the proportion of samples where the feature was evaluated as True,
|
||||
providing a continuous feature value for the classifier.
|
||||
|
||||
Args:
|
||||
dimension: Feature identifier to calculate coefficient for.
|
||||
|
||||
Returns:
|
||||
float | None: Average coefficient (0.0-1.0), or None if dimension not found.
|
||||
"""
|
||||
# Extract boolean values for this dimension, converting to 0/1
|
||||
values = [
|
||||
1 if v else 0
|
||||
for v in [sample.get(dimension) for sample in self.samples]
|
||||
if v is not None
|
||||
]
|
||||
if values:
|
||||
return sum(values) / len(values)
|
||||
return None
|
||||
|
||||
def to_row(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert the embedding to a flat dictionary suitable for DataFrame construction.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Dictionary with metadata fields and feature coefficients.
|
||||
"""
|
||||
return {
|
||||
'response_latency': self.response_latency,
|
||||
'prompt_tokens': self.prompt_tokens,
|
||||
'completion_tokens': self.completion_tokens,
|
||||
**{dimension: self.coefficient(dimension) for dimension in self.dimensions},
|
||||
}
|
||||
|
||||
def sample_entropy(self) -> dict[str, float]:
|
||||
"""
|
||||
Calculate the Shannon entropy of feature evaluations across samples.
|
||||
|
||||
Higher entropy indicates more variability in LLM responses for a feature,
|
||||
which may suggest ambiguity in the feature definition or issue description.
|
||||
|
||||
Returns:
|
||||
dict[str, float]: Mapping of feature identifiers to their entropy values (0-1).
|
||||
"""
|
||||
from collections import Counter
|
||||
from math import log2
|
||||
|
||||
entropy = {}
|
||||
for dimension in self.dimensions:
|
||||
# Count True/False occurrences for this feature across samples
|
||||
counts = Counter(sample.get(dimension, False) for sample in self.samples)
|
||||
total = sum(counts.values())
|
||||
if total == 0:
|
||||
entropy[dimension] = 0.0
|
||||
continue
|
||||
# Calculate Shannon entropy: -Σ(p * log2(p))
|
||||
entropy_value = -sum(
|
||||
(count / total) * log2(count / total)
|
||||
for count in counts.values()
|
||||
if count > 0
|
||||
)
|
||||
entropy[dimension] = entropy_value
|
||||
return entropy
|
||||
|
||||
|
||||
class Featurizer(BaseModel):
|
||||
"""
|
||||
Orchestrates LLM-based feature extraction from issue descriptions.
|
||||
|
||||
The Featurizer uses structured LLM tool calling to evaluate boolean features
|
||||
for issue descriptions. It handles prompt construction, tool schema generation,
|
||||
and batch processing with concurrency.
|
||||
"""
|
||||
|
||||
system_prompt: str
|
||||
"""System prompt that provides context and instructions to the LLM."""
|
||||
|
||||
message_prefix: str
|
||||
"""Prefix added to user messages before the issue description."""
|
||||
|
||||
features: list[Feature]
|
||||
"""List of features to extract from each issue description."""
|
||||
|
||||
def system_message(self) -> dict[str, Any]:
|
||||
"""
|
||||
Construct the system message for LLM conversations.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: System message dictionary for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'role': 'system',
|
||||
'content': self.system_prompt,
|
||||
}
|
||||
|
||||
def user_message(
|
||||
self, issue_description: str, set_cache: bool = True
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Construct the user message containing the issue description.
|
||||
|
||||
Args:
|
||||
issue_description: The description of the issue to analyze.
|
||||
set_cache: Whether to enable ephemeral caching for this message.
|
||||
Should be False for single samples to avoid cache overhead.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: User message dictionary for LLM API calls.
|
||||
"""
|
||||
message: dict[str, Any] = {
|
||||
'role': 'user',
|
||||
'content': f'{self.message_prefix}{issue_description}',
|
||||
}
|
||||
if set_cache:
|
||||
message['cache_control'] = {'type': 'ephemeral'}
|
||||
return message
|
||||
|
||||
@property
|
||||
def tool_choice(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get the tool choice configuration for forcing LLM to use the featurizer tool.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Tool choice configuration for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {'name': 'call_featurizer'},
|
||||
}
|
||||
|
||||
@property
|
||||
def tool_description(self) -> dict[str, Any]:
|
||||
"""
|
||||
Generate the tool schema for the featurizer function.
|
||||
|
||||
Creates a JSON schema that describes the featurizer tool with all configured
|
||||
features as boolean parameters.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Complete tool description for LLM API calls.
|
||||
"""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'call_featurizer',
|
||||
'description': 'Record the features present in the issue.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
feature.identifier: feature.to_tool_description_field
|
||||
for feature in self.features
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def embed(
|
||||
self,
|
||||
issue_description: str,
|
||||
llm_config: LLMConfig,
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> FeatureEmbedding:
|
||||
"""
|
||||
Generate a feature embedding for a single issue description.
|
||||
|
||||
Makes multiple LLM calls to collect samples and reduce variance in feature evaluations.
|
||||
Each call uses tool calling to extract structured boolean feature values.
|
||||
|
||||
Args:
|
||||
issue_description: The description of the issue to analyze.
|
||||
llm_config: Configuration for the LLM to use.
|
||||
temperature: Sampling temperature for the model. Higher values increase randomness.
|
||||
samples: Number of samples to generate for averaging.
|
||||
|
||||
Returns:
|
||||
FeatureEmbedding: Complete embedding with samples and metadata.
|
||||
"""
|
||||
embedding_samples: list[dict[str, Any]] = []
|
||||
response_latency: float = 0.0
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
|
||||
# TODO: use llm registry
|
||||
llm = LLM(llm_config, service_id='solvability')
|
||||
|
||||
# Generate multiple samples to account for LLM variability
|
||||
for _ in range(samples):
|
||||
start_time = time.time()
|
||||
response = llm.completion(
|
||||
messages=[
|
||||
self.system_message(),
|
||||
self.user_message(issue_description, set_cache=(samples > 1)),
|
||||
],
|
||||
tools=[self.tool_description],
|
||||
tool_choice=self.tool_choice,
|
||||
temperature=temperature,
|
||||
)
|
||||
stop_time = time.time()
|
||||
|
||||
# Extract timing and token usage metrics
|
||||
latency = stop_time - start_time
|
||||
# Parse the structured tool call response containing feature evaluations
|
||||
features = response.choices[0].message.tool_calls[0].function.arguments # type: ignore[index, union-attr]
|
||||
embedding = json.loads(features)
|
||||
|
||||
# Accumulate results and metrics
|
||||
embedding_samples.append(embedding)
|
||||
prompt_tokens += response.usage.prompt_tokens # type: ignore[union-attr, attr-defined]
|
||||
completion_tokens += response.usage.completion_tokens # type: ignore[union-attr, attr-defined]
|
||||
response_latency += latency
|
||||
|
||||
return FeatureEmbedding(
|
||||
samples=embedding_samples,
|
||||
response_latency=response_latency,
|
||||
prompt_tokens=prompt_tokens,
|
||||
completion_tokens=completion_tokens,
|
||||
)
|
||||
|
||||
def embed_batch(
|
||||
self,
|
||||
issue_descriptions: list[str],
|
||||
llm_config: LLMConfig,
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> list[FeatureEmbedding]:
|
||||
"""
|
||||
Generate embeddings for a batch of issue descriptions using concurrent processing.
|
||||
|
||||
Processes multiple issues in parallel to improve throughput while maintaining
|
||||
result ordering.
|
||||
|
||||
Args:
|
||||
issue_descriptions: List of issue descriptions to analyze.
|
||||
llm_config: Configuration for the LLM to use.
|
||||
temperature: Sampling temperature for the model.
|
||||
samples: Number of samples to generate per issue.
|
||||
|
||||
Returns:
|
||||
list[FeatureEmbedding]: List of embeddings in the same order as input.
|
||||
"""
|
||||
with ThreadPoolExecutor() as executor:
|
||||
# Submit all embedding tasks concurrently
|
||||
future_to_desc = {
|
||||
executor.submit(
|
||||
self.embed,
|
||||
desc,
|
||||
llm_config,
|
||||
temperature=temperature,
|
||||
samples=samples,
|
||||
): i
|
||||
for i, desc in enumerate(issue_descriptions)
|
||||
}
|
||||
|
||||
# Collect results in original order to maintain consistency
|
||||
results: list[FeatureEmbedding] = [None] * len(issue_descriptions) # type: ignore[list-item]
|
||||
for future in as_completed(future_to_desc):
|
||||
index = future_to_desc[future]
|
||||
results[index] = future.result()
|
||||
|
||||
return results
|
||||
|
||||
def feature_identifiers(self) -> list[str]:
|
||||
"""
|
||||
Get the identifiers of all configured features.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers in the order they were defined.
|
||||
"""
|
||||
return [feature.identifier for feature in self.features]
|
||||
@@ -0,0 +1,23 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ImportanceStrategy(str, Enum):
|
||||
"""
|
||||
Strategy to use for calculating feature importances, which are used to estimate the predictive power of each feature
|
||||
in training loops and explanations.
|
||||
"""
|
||||
|
||||
SHAP = 'shap'
|
||||
"""
|
||||
Use SHAP (SHapley Additive exPlanations) to calculate feature importances.
|
||||
"""
|
||||
|
||||
PERMUTATION = 'permutation'
|
||||
"""
|
||||
Use the permutation-based feature importances.
|
||||
"""
|
||||
|
||||
IMPURITY = 'impurity'
|
||||
"""
|
||||
Use the impurity-based feature importances from the RandomForestClassifier.
|
||||
"""
|
||||
87
enterprise/integrations/solvability/models/report.py
Normal file
87
enterprise/integrations/solvability/models/report.py
Normal file
@@ -0,0 +1,87 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from integrations.solvability.models.importance_strategy import ImportanceStrategy
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SolvabilityReport(BaseModel):
|
||||
"""
|
||||
Comprehensive report containing solvability predictions and analysis for a single issue.
|
||||
|
||||
This report includes the solvability score, extracted feature values, feature importance analysis,
|
||||
cost metrics (tokens and latency), and metadata about the prediction process. It serves as the
|
||||
primary output format for solvability analysis and can be used for logging, debugging, and
|
||||
generating human-readable summaries.
|
||||
"""
|
||||
|
||||
identifier: str
|
||||
"""
|
||||
The identifier of the solvability model used to generate the report.
|
||||
"""
|
||||
|
||||
issue: str
|
||||
"""
|
||||
The issue description for which the solvability is predicted.
|
||||
|
||||
This field is exactly the input to the solvability model.
|
||||
"""
|
||||
|
||||
score: float
|
||||
"""
|
||||
[0, 1]-valued score indicating the likelihood of the issue being solvable.
|
||||
"""
|
||||
|
||||
prompt_tokens: int
|
||||
"""
|
||||
Total number of prompt tokens used in API calls made to generate the features.
|
||||
"""
|
||||
|
||||
completion_tokens: int
|
||||
"""
|
||||
Total number of completion tokens used in API calls made to generate the features.
|
||||
"""
|
||||
|
||||
response_latency: float
|
||||
"""
|
||||
Total response latency of API calls made to generate the features.
|
||||
"""
|
||||
|
||||
features: dict[str, float]
|
||||
"""
|
||||
[0, 1]-valued scores for each feature in the model.
|
||||
|
||||
These are the values fed to the random forest classifier to generate the solvability score.
|
||||
"""
|
||||
|
||||
samples: int
|
||||
"""
|
||||
Number of samples used to compute the feature embedding coefficients.
|
||||
"""
|
||||
|
||||
importance_strategy: ImportanceStrategy
|
||||
"""
|
||||
Strategy used to calculate feature importances.
|
||||
"""
|
||||
|
||||
feature_importances: dict[str, float]
|
||||
"""
|
||||
Importance scores for each feature in the model.
|
||||
|
||||
Interpretation of these scores depends on the importance strategy used.
|
||||
"""
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
"""
|
||||
Datetime when the report was created.
|
||||
"""
|
||||
|
||||
random_state: int | None = None
|
||||
"""
|
||||
Classifier random state used when generating this report.
|
||||
"""
|
||||
|
||||
metadata: dict[str, Any] | None = None
|
||||
"""
|
||||
Metadata for logging and debugging purposes.
|
||||
"""
|
||||
172
enterprise/integrations/solvability/models/summary.py
Normal file
172
enterprise/integrations/solvability/models/summary.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from integrations.solvability.models.difficulty_level import DifficultyLevel
|
||||
from integrations.solvability.models.report import SolvabilityReport
|
||||
from integrations.solvability.prompts import load_prompt
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from openhands.llm import LLM
|
||||
|
||||
|
||||
class SolvabilitySummary(BaseModel):
|
||||
"""Summary of the solvability analysis in human-readable format."""
|
||||
|
||||
score: float
|
||||
"""
|
||||
Solvability score indicating the likelihood of the issue being solvable.
|
||||
"""
|
||||
|
||||
summary: str
|
||||
"""
|
||||
The executive summary content generated by the LLM.
|
||||
"""
|
||||
|
||||
actionable_feedback: str
|
||||
"""
|
||||
Actionable feedback content generated by the LLM.
|
||||
"""
|
||||
|
||||
positive_feedback: str
|
||||
"""
|
||||
Positive feedback content generated by the LLM, highlighting what is good about the issue.
|
||||
"""
|
||||
|
||||
prompt_tokens: int
|
||||
"""
|
||||
Number of prompt tokens used in the API call to generate the summary.
|
||||
"""
|
||||
|
||||
completion_tokens: int
|
||||
"""
|
||||
Number of completion tokens used in the API call to generate the summary.
|
||||
"""
|
||||
|
||||
response_latency: float
|
||||
"""
|
||||
Response latency of the API call to generate the summary.
|
||||
"""
|
||||
|
||||
created_at: datetime = Field(default_factory=datetime.now)
|
||||
"""
|
||||
Datetime when the summary was created.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def tool_description() -> dict[str, Any]:
|
||||
"""Get the tool description for the LLM."""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'solvability_summary',
|
||||
'description': 'Generate a human-readable summary of the solvability analysis.',
|
||||
'parameters': {
|
||||
'type': 'object',
|
||||
'properties': {
|
||||
'summary': {
|
||||
'type': 'string',
|
||||
'description': 'A high-level (at most two sentences) summary of the solvability report.',
|
||||
},
|
||||
'actionable_feedback': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Bullet list of 1-3 pieces of actionable feedback on how the user can address the lowest scoring relevant features.'
|
||||
),
|
||||
},
|
||||
'positive_feedback': {
|
||||
'type': 'string',
|
||||
'description': (
|
||||
'Bullet list of 1-3 pieces of positive feedback on the issue, highlighting what is good about it.'
|
||||
),
|
||||
},
|
||||
},
|
||||
'required': ['summary', 'actionable_feedback'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def tool_choice() -> dict[str, Any]:
|
||||
"""Get the tool choice for the LLM."""
|
||||
return {
|
||||
'type': 'function',
|
||||
'function': {
|
||||
'name': 'solvability_summary',
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def system_message() -> dict[str, Any]:
|
||||
"""Get the system message for the LLM."""
|
||||
return {
|
||||
'role': 'system',
|
||||
'content': load_prompt('summary_system_message'),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def user_message(report: SolvabilityReport) -> dict[str, Any]:
|
||||
"""Get the user message for the LLM."""
|
||||
return {
|
||||
'role': 'user',
|
||||
'content': load_prompt(
|
||||
'summary_user_message',
|
||||
report=report.model_dump(),
|
||||
difficulty_level=DifficultyLevel.from_score(report.score).value[0],
|
||||
),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def from_report(report: SolvabilityReport, llm: LLM) -> SolvabilitySummary:
|
||||
"""Create a SolvabilitySummary from a SolvabilityReport."""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
response = llm.completion(
|
||||
messages=[
|
||||
SolvabilitySummary.system_message(),
|
||||
SolvabilitySummary.user_message(report),
|
||||
],
|
||||
tools=[SolvabilitySummary.tool_description()],
|
||||
tool_choice=SolvabilitySummary.tool_choice(),
|
||||
)
|
||||
response_latency = time.time() - start_time
|
||||
|
||||
# Grab the arguments from the forced function call
|
||||
arguments = json.loads(
|
||||
response.choices[0].message.tool_calls[0].function.arguments
|
||||
)
|
||||
|
||||
return SolvabilitySummary(
|
||||
# The score is copied directly from the report
|
||||
score=report.score,
|
||||
# Performance and usage metrics are pulled from the response
|
||||
prompt_tokens=response.usage.prompt_tokens,
|
||||
completion_tokens=response.usage.completion_tokens,
|
||||
response_latency=response_latency,
|
||||
# Every other field should be taken from the forced function call
|
||||
**arguments,
|
||||
)
|
||||
|
||||
def format_as_markdown(self) -> str:
|
||||
"""Format the summary content as Markdown."""
|
||||
# Convert score to difficulty level enum
|
||||
difficulty_level = DifficultyLevel.from_score(self.score)
|
||||
|
||||
# Create the main difficulty display
|
||||
result = f'{difficulty_level.format_display()}\n\n{self.summary}'
|
||||
|
||||
# If not easy, show the three features with lowest importance scores
|
||||
if difficulty_level != DifficultyLevel.EASY:
|
||||
# Add dropdown with lowest importance features
|
||||
result += '\n\nYou can make the issue easier to resolve by addressing these concerns in the conversation:\n\n'
|
||||
result += self.actionable_feedback
|
||||
|
||||
# If the difficulty isn't hard, add some positive feedback
|
||||
if difficulty_level != DifficultyLevel.HARD:
|
||||
result += '\n\nPositive feedback:\n\n'
|
||||
result += self.positive_feedback
|
||||
|
||||
return result
|
||||
13
enterprise/integrations/solvability/prompts/__init__.py
Normal file
13
enterprise/integrations/solvability/prompts/__init__.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from pathlib import Path
|
||||
|
||||
import jinja2
|
||||
|
||||
|
||||
def load_prompt(prompt: str, **kwargs) -> str:
|
||||
"""Load a prompt by name. Passes all the keyword arguments to the prompt template."""
|
||||
env = jinja2.Environment(loader=jinja2.FileSystemLoader(Path(__file__).parent))
|
||||
template = env.get_template(f'{prompt}.j2')
|
||||
return template.render(**kwargs)
|
||||
|
||||
|
||||
__all__ = ['load_prompt']
|
||||
@@ -0,0 +1,10 @@
|
||||
You are a helpful assistant that generates human-readable summaries of solvability reports.
|
||||
The report predicts how likely it is that the issue can be resolved, and is produced purely based on the information provided in the issue description and comments.
|
||||
The report explains which features are present in the issue and how impactful they are to the solvability score (using SHAP values).
|
||||
Your task is to create a concise, high-level summary of the solvability analysis,
|
||||
with an emphasis on the key factors that make the issue easy or hard to resolve.
|
||||
Focus on the features with extreme scores, BUT ONLY if they are related to the issue at hand after careful consideration.
|
||||
You should NEVER mention: SHAP, scores, feature names, or technical metrics.
|
||||
You will also be given the expected difficulty of the issue, as EASY/MEDIUM/HARD.
|
||||
Be sure to frame your responses with that difficulty in mind.
|
||||
For example, if the issue is HARD you should not describe it as "straightforward".
|
||||
@@ -0,0 +1,9 @@
|
||||
Generate a high-level summary of the solvability report:
|
||||
|
||||
{{ report }}
|
||||
|
||||
We estimate the issue is {{ difficulty_level }}.
|
||||
The summary should be concise (at most two sentences) and describe the primary characteristics of this issue.
|
||||
Focus on what information is present and what factors are most relevant to resolution.
|
||||
Actionable feedback should be something that can be addressed by the user purely by providing more information.
|
||||
Positive feedback should explain the features that are positively contributing to the solvability score.
|
||||
@@ -3,9 +3,9 @@ from storage.stored_repository import StoredRepository
|
||||
from storage.user_repo_map import UserRepositoryMap
|
||||
from storage.user_repo_map_store import UserRepositoryMapStore
|
||||
|
||||
from openhands.app_server.integrations.service_types import Repository
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import Repository
|
||||
|
||||
|
||||
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from jinja2 import Environment
|
||||
from pydantic import BaseModel
|
||||
@@ -9,8 +8,9 @@ from pydantic import BaseModel
|
||||
if TYPE_CHECKING:
|
||||
from integrations.models import Message
|
||||
|
||||
from openhands.app_server.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
|
||||
|
||||
class GitLabResourceType(Enum):
|
||||
@@ -53,11 +53,11 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
"""Instructions passed when conversation is first initialized."""
|
||||
raise NotImplementedError()
|
||||
|
||||
async def initialize_new_conversation(self) -> UUID:
|
||||
"""Initialize a new conversation and return the conversation ID.
|
||||
async def initialize_new_conversation(self) -> 'ConversationMetadata':
|
||||
"""Initialize a new conversation and return metadata.
|
||||
|
||||
This method resolves the target organization and generates a new
|
||||
conversation ID.
|
||||
For V1 conversations, creates a dummy ConversationMetadata.
|
||||
For V0 conversations, initializes through the conversation store.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -65,7 +65,7 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
self,
|
||||
jinja_env: Environment,
|
||||
git_provider_tokens: 'PROVIDER_TOKEN_TYPE',
|
||||
conversation_id: UUID,
|
||||
conversation_metadata: 'ConversationMetadata',
|
||||
saas_user_auth: 'UserAuth',
|
||||
) -> None:
|
||||
"""Create a new conversation.
|
||||
@@ -73,7 +73,7 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
Args:
|
||||
jinja_env: Jinja2 environment for template rendering
|
||||
git_provider_tokens: Token mapping for git providers
|
||||
conversation_id: The UUID of the conversation to create
|
||||
conversation_metadata: Metadata for the conversation
|
||||
saas_user_auth: User authentication for SaaS
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from jinja2 import Environment, FileSystemLoader
|
||||
from server.constants import WEB_HOST
|
||||
from storage.org_store import OrgStore
|
||||
|
||||
from openhands.app_server.integrations.service_types import Repository
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events import Event, EventSource
|
||||
from openhands.events.action import (
|
||||
AgentFinishAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.event_filter import EventFilter
|
||||
from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.integrations.service_types import Repository
|
||||
|
||||
# ---- DO NOT REMOVE ----
|
||||
# WARNING: Langfuse depends on the WEB_HOST environment variable being set to track events.
|
||||
@@ -15,8 +27,10 @@ HOST = WEB_HOST
|
||||
|
||||
IS_LOCAL_DEPLOYMENT = 'localhost' in HOST
|
||||
HOST_URL = f'https://{HOST}' if not IS_LOCAL_DEPLOYMENT else f'http://{HOST}'
|
||||
GITHUB_WEBHOOK_URL = f'{HOST_URL}/integration/github/events'
|
||||
GITLAB_WEBHOOK_URL = f'{HOST_URL}/integration/gitlab/events'
|
||||
CONVERSATION_URL = f'{HOST_URL}/conversations/{{}}'
|
||||
conversation_prefix = 'conversations/{}'
|
||||
CONVERSATION_URL = f'{HOST_URL}/{conversation_prefix}'
|
||||
|
||||
# Toggle for auto-response feature that proactively starts conversations with users when workflow tests fail
|
||||
ENABLE_PROACTIVE_CONVERSATION_STARTERS = (
|
||||
@@ -63,11 +77,30 @@ def get_user_not_found_message(username: str | None = None) -> str:
|
||||
return f"It looks like you haven't created an OpenHands account yet. Please sign up at [OpenHands Cloud]({HOST_URL}) and try again."
|
||||
|
||||
|
||||
# Toggle for solvability report feature
|
||||
ENABLE_SOLVABILITY_ANALYSIS = (
|
||||
os.getenv('ENABLE_SOLVABILITY_ANALYSIS', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Toggle for V1 GitHub resolver feature
|
||||
ENABLE_V1_GITHUB_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_GITHUB_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
ENABLE_V1_SLACK_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_SLACK_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
# Toggle for V1 GitLab resolver feature
|
||||
ENABLE_V1_GITLAB_RESOLVER = (
|
||||
os.getenv('ENABLE_V1_GITLAB_RESOLVER', 'false').lower() == 'true'
|
||||
)
|
||||
|
||||
OPENHANDS_RESOLVER_TEMPLATES_DIR = (
|
||||
os.getenv('OPENHANDS_RESOLVER_TEMPLATES_DIR')
|
||||
or 'openhands/app_server/integrations/templates/resolver/'
|
||||
or 'openhands/integrations/templates/resolver/'
|
||||
)
|
||||
_jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
jinja_env = Environment(loader=FileSystemLoader(OPENHANDS_RESOLVER_TEMPLATES_DIR))
|
||||
|
||||
|
||||
def get_oh_labels(web_host: str) -> tuple[str, str]:
|
||||
@@ -89,11 +122,31 @@ def get_oh_labels(web_host: str) -> tuple[str, str]:
|
||||
|
||||
|
||||
def get_summary_instruction():
|
||||
summary_instruction_template = _jinja_env.get_template('summary_prompt.j2')
|
||||
summary_instruction_template = jinja_env.get_template('summary_prompt.j2')
|
||||
summary_instruction = summary_instruction_template.render()
|
||||
return summary_instruction
|
||||
|
||||
|
||||
async def get_user_v1_enabled_setting(user_id: str | None) -> bool:
|
||||
"""Get the user's V1 conversation API setting.
|
||||
|
||||
Args:
|
||||
user_id: The keycloak user ID
|
||||
|
||||
Returns:
|
||||
True if V1 conversations are enabled for this user, False otherwise
|
||||
"""
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
org = await OrgStore.get_current_org_from_keycloak_user_id(user_id)
|
||||
|
||||
if not org or org.v1_enabled is None:
|
||||
return False
|
||||
|
||||
return org.v1_enabled
|
||||
|
||||
|
||||
def has_exact_mention(text: str, mention: str) -> bool:
|
||||
"""Check if the text contains an exact mention (not part of a larger word).
|
||||
|
||||
@@ -120,6 +173,205 @@ def has_exact_mention(text: str, mention: str) -> bool:
|
||||
return bool(re.search(rf'(?:^|[^\w@]){pattern}(?![\w-])', text_lower))
|
||||
|
||||
|
||||
def confirm_event_type(event: Event):
|
||||
return isinstance(event, AgentStateChangedObservation) and not (
|
||||
event.agent_state == AgentState.REJECTED
|
||||
or event.agent_state == AgentState.USER_CONFIRMED
|
||||
or event.agent_state == AgentState.USER_REJECTED
|
||||
or event.agent_state == AgentState.LOADING
|
||||
or event.agent_state == AgentState.RUNNING
|
||||
)
|
||||
|
||||
|
||||
def get_readable_error_reason(reason: str):
|
||||
if reason == 'STATUS$ERROR_LLM_AUTHENTICATION':
|
||||
reason = 'Authentication with the LLM provider failed. Please check your API key or credentials'
|
||||
elif reason == 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE':
|
||||
reason = 'The LLM service is temporarily unavailable. Please try again later'
|
||||
elif reason == 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR':
|
||||
reason = 'The LLM provider encountered an internal error. Please try again soon'
|
||||
elif reason == 'STATUS$ERROR_LLM_OUT_OF_CREDITS':
|
||||
reason = "You've run out of credits. Please top up to continue"
|
||||
elif reason == 'STATUS$ERROR_LLM_CONTENT_POLICY_VIOLATION':
|
||||
reason = 'Content policy violation. The output was blocked by content filtering policy'
|
||||
return reason
|
||||
|
||||
|
||||
def get_summary_for_agent_state(
|
||||
observations: list[AgentStateChangedObservation], conversation_link: str
|
||||
) -> str:
|
||||
unknown_error_msg = f'OpenHands encountered an unknown error. [See the conversation]({conversation_link}) for more information, or try again'
|
||||
|
||||
if len(observations) == 0:
|
||||
logger.error(
|
||||
'Unknown error: No agent state observations found',
|
||||
extra={'conversation_link': conversation_link},
|
||||
)
|
||||
return unknown_error_msg
|
||||
|
||||
observation: AgentStateChangedObservation = observations[0]
|
||||
state = observation.agent_state
|
||||
|
||||
if state == AgentState.RATE_LIMITED:
|
||||
logger.warning(
|
||||
'Agent was rate limited',
|
||||
extra={
|
||||
'agent_state': state.value,
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': getattr(observation, 'reason', None),
|
||||
},
|
||||
)
|
||||
return 'OpenHands was rate limited by the LLM provider. Please try again later.'
|
||||
|
||||
if state == AgentState.ERROR:
|
||||
reason = observation.reason
|
||||
reason = get_readable_error_reason(reason)
|
||||
|
||||
logger.error(
|
||||
'Agent encountered an error',
|
||||
extra={
|
||||
'agent_state': state.value,
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': observation.reason,
|
||||
'readable_reason': reason,
|
||||
},
|
||||
)
|
||||
|
||||
return f'OpenHands encountered an error: **{reason}**.\n\n[See the conversation]({conversation_link}) for more information.'
|
||||
|
||||
if state == AgentState.AWAITING_USER_INPUT:
|
||||
logger.info(
|
||||
'Agent is awaiting user input',
|
||||
extra={
|
||||
'agent_state': state.value,
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': getattr(observation, 'reason', None),
|
||||
},
|
||||
)
|
||||
return f'OpenHands is waiting for your input. [Continue the conversation]({conversation_link}) to provide additional instructions.'
|
||||
|
||||
# Log unknown agent state as error
|
||||
logger.error(
|
||||
'Unknown error: Unhandled agent state',
|
||||
extra={
|
||||
'agent_state': state.value if hasattr(state, 'value') else str(state),
|
||||
'conversation_link': conversation_link,
|
||||
'observation_reason': getattr(observation, 'reason', None),
|
||||
},
|
||||
)
|
||||
return unknown_error_msg
|
||||
|
||||
|
||||
def get_final_agent_observation(
|
||||
event_store: EventStoreABC,
|
||||
) -> list[AgentStateChangedObservation]:
|
||||
events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.ENVIRONMENT,
|
||||
include_types=(AgentStateChangedObservation,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
result = [e for e in events if isinstance(e, AgentStateChangedObservation)]
|
||||
assert len(result) == len(events)
|
||||
return result
|
||||
|
||||
|
||||
def get_last_user_msg(event_store: EventStoreABC) -> list[MessageAction]:
|
||||
events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.USER,
|
||||
include_types=(MessageAction,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
result = [e for e in events if isinstance(e, MessageAction)]
|
||||
assert len(result) == len(events)
|
||||
return result
|
||||
|
||||
|
||||
def extract_summary_from_event_store(
|
||||
event_store: EventStoreABC, conversation_id: str
|
||||
) -> str:
|
||||
"""
|
||||
Get agent summary or alternative message depending on current AgentState
|
||||
"""
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
summary_instruction = get_summary_instruction()
|
||||
|
||||
instruction_events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
query=json.dumps(summary_instruction),
|
||||
source=EventSource.USER,
|
||||
include_types=(MessageAction,),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
)
|
||||
)
|
||||
|
||||
final_agent_observation = get_final_agent_observation(event_store)
|
||||
|
||||
# Find summary instruction event ID
|
||||
if not instruction_events:
|
||||
logger.warning(
|
||||
'no_instruction_event_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
return get_summary_for_agent_state(
|
||||
final_agent_observation, conversation_link
|
||||
) # Agent did not receive summary instruction
|
||||
|
||||
summary_events = list(
|
||||
event_store.search_events(
|
||||
filter=EventFilter(
|
||||
source=EventSource.AGENT,
|
||||
include_types=(MessageAction, AgentFinishAction),
|
||||
),
|
||||
limit=1,
|
||||
reverse=True,
|
||||
start_id=instruction_events[0].id,
|
||||
)
|
||||
)
|
||||
|
||||
if not summary_events:
|
||||
logger.warning(
|
||||
'no_agent_messages_found', extra={'conversation_id': conversation_id}
|
||||
)
|
||||
return get_summary_for_agent_state(
|
||||
final_agent_observation, conversation_link
|
||||
) # Agent failed to generate summary
|
||||
|
||||
summary_event = summary_events[0]
|
||||
if isinstance(summary_event, MessageAction):
|
||||
return summary_event.content
|
||||
|
||||
assert isinstance(summary_event, AgentFinishAction)
|
||||
return summary_event.final_thought
|
||||
|
||||
|
||||
def append_conversation_footer(message: str, conversation_id: str) -> str:
|
||||
"""
|
||||
Append a small footer with the conversation URL to a message.
|
||||
|
||||
Args:
|
||||
message: The original message content
|
||||
conversation_id: The conversation ID to link to
|
||||
|
||||
Returns:
|
||||
The message with the conversation footer appended
|
||||
"""
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
footer = f'\n\n[View full conversation]({conversation_link})'
|
||||
return message + footer
|
||||
|
||||
|
||||
def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
"""
|
||||
Extract all repository names in the format 'owner/repo' from various Git provider URLs
|
||||
|
||||
@@ -7,8 +7,8 @@ from pydantic import SecretStr
|
||||
from server.auth.saas_user_auth import SaasUserAuth
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import UserAuth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import UserAuth
|
||||
|
||||
|
||||
def is_budget_exceeded_error(error_message: str) -> bool:
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
"""Add llm_profiles column to user table.
|
||||
|
||||
The Settings model exposes ``llm_profiles`` (saved LLM configurations plus
|
||||
the active profile name), but the SaaS path persists a flattened Settings
|
||||
dump onto the User/Org rows. Without a column here the field is silently
|
||||
dropped on store() and always defaults to empty on load(), so saved
|
||||
profiles disappear after any settings update or page refresh.
|
||||
|
||||
The column is plain ``String`` because the ORM-level ``EncryptedJSON``
|
||||
TypeDecorator stores JSON-serialized profiles as a JWE-encrypted string —
|
||||
profiles can carry per-profile ``api_key`` values, so the at-rest
|
||||
representation must match the existing org/member encrypted-secret pattern.
|
||||
|
||||
Revision ID: 109
|
||||
Revises: 108
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '109'
|
||||
down_revision: Union[str, None] = '108'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('user', sa.Column('llm_profiles', sa.String(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('user', 'llm_profiles')
|
||||
@@ -1,31 +0,0 @@
|
||||
"""Add agent_kind column to conversation_metadata table.
|
||||
|
||||
Stores the agent type ('llm' or 'acp') for each conversation so the
|
||||
correct agent-server endpoint can be used when routing requests.
|
||||
|
||||
Revision ID: 110
|
||||
Revises: 109
|
||||
Create Date: 2026-04-28
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '110'
|
||||
down_revision: Union[str, None] = '109'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
'conversation_metadata',
|
||||
sa.Column('agent_kind', sa.String(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('conversation_metadata', 'agent_kind')
|
||||
49
enterprise/poetry.lock
generated
49
enterprise/poetry.lock
generated
@@ -4961,14 +4961,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "lmnr"
|
||||
version = "0.7.49"
|
||||
version = "0.7.46"
|
||||
description = "Python SDK for Laminar"
|
||||
optional = false
|
||||
python-versions = "<4,>=3.10"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "lmnr-0.7.49-py3-none-any.whl", hash = "sha256:510113b02bac3e639fa80244c67ff0be5948234275b0ef04cd310d66c7d720bf"},
|
||||
{file = "lmnr-0.7.49.tar.gz", hash = "sha256:0b6da7d1707ce4e248c15083835a70723be9e6cc652b77ddc95c12e27dc87ef3"},
|
||||
{file = "lmnr-0.7.46-py3-none-any.whl", hash = "sha256:596599af3eb999c5fb253640967fa893d34998b78c577b8773c214d89efa81c9"},
|
||||
{file = "lmnr-0.7.46.tar.gz", hash = "sha256:082c9d17a1962b559651eea843eff49c1ec54729654ba37388c4a360e862af78"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -4984,11 +4984,11 @@ opentelemetry-sdk = ">=1.39.0,<2.0.0"
|
||||
opentelemetry-semantic-conventions = "0.60b1"
|
||||
opentelemetry-semantic-conventions-ai = "0.4.13"
|
||||
orjson = ">=3.0.0,<4.0.0"
|
||||
packaging = ">=22.0,<27.0"
|
||||
packaging = ">=22.0"
|
||||
pydantic = ">=2.0.3,<3.0.0"
|
||||
python-dotenv = ">=1.0,<2.0"
|
||||
tenacity = ">=8.0,<10.0"
|
||||
tqdm = ">=4.0,<5.0"
|
||||
tqdm = ">=4.0"
|
||||
|
||||
[package.extras]
|
||||
alephalpha = ["opentelemetry-instrumentation-alephalpha (==0.52.4)"]
|
||||
@@ -6454,14 +6454,14 @@ llama = ["llama-index (>=0.12.29,<0.13.0)", "llama-index-core (>=0.12.29,<0.13.0
|
||||
|
||||
[[package]]
|
||||
name = "openhands-agent-server"
|
||||
version = "1.19.0"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands Agent Server - REST/WebSocket interface for OpenHands AI Agent"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_agent_server-1.19.0-py3-none-any.whl", hash = "sha256:132902dc918f446e3b0f5cda9f4da36a4881fc73fe509eb177959afe988c38bb"},
|
||||
{file = "openhands_agent_server-1.19.0.tar.gz", hash = "sha256:4f81b5ec550881706b361c51a422b6daad2a33c73b94d2f3088c84ed32ce049e"},
|
||||
{file = "openhands_agent_server-1.17.0-py3-none-any.whl", hash = "sha256:44336cad001c31caeb516481a5a7aea6dd9b5ab4798461f147b5231668d8fb74"},
|
||||
{file = "openhands_agent_server-1.17.0.tar.gz", hash = "sha256:3a88449a3b9ded653dcd2a8c518810c75602873cf9f7d4e8f9b90fd8fd225652"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6523,9 +6523,9 @@ memory-profiler = ">=0.61"
|
||||
numpy = "*"
|
||||
openai = "2.8"
|
||||
openhands-aci = "0.3.3"
|
||||
openhands-agent-server = "1.19"
|
||||
openhands-sdk = "1.19"
|
||||
openhands-tools = "1.19"
|
||||
openhands-agent-server = "1.17"
|
||||
openhands-sdk = "1.17"
|
||||
openhands-tools = "1.17"
|
||||
opentelemetry-api = ">=1.33.1"
|
||||
opentelemetry-exporter-otlp-proto-grpc = ">=1.33.1"
|
||||
orjson = ">=3.11.6"
|
||||
@@ -6547,7 +6547,7 @@ python-docx = "*"
|
||||
python-dotenv = "*"
|
||||
python-frontmatter = ">=1.1"
|
||||
python-json-logger = ">=3.2.1"
|
||||
python-multipart = ">=0.0.26"
|
||||
python-multipart = ">=0.0.22"
|
||||
python-pptx = "*"
|
||||
python-socketio = "5.14"
|
||||
pythonnet = {version = "*", markers = "sys_platform == \"win32\""}
|
||||
@@ -6571,20 +6571,23 @@ 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 = ".."
|
||||
|
||||
[[package]]
|
||||
name = "openhands-sdk"
|
||||
version = "1.19.0"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands SDK - Core functionality for building AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_sdk-1.19.0-py3-none-any.whl", hash = "sha256:704906533da50f2d0e93bf28609b1a36a4aa4ce578bfac13a3d1a76609d87db8"},
|
||||
{file = "openhands_sdk-1.19.0.tar.gz", hash = "sha256:5611d877e6495a712725569f6bca3de8fabefd9e44c61dc30bd39f8883371508"},
|
||||
{file = "openhands_sdk-1.17.0-py3-none-any.whl", hash = "sha256:3b771e72209453871c3036a562cf33e9ad9642a54bd48edb44f89915ac54709d"},
|
||||
{file = "openhands_sdk-1.17.0.tar.gz", hash = "sha256:3c69df6590f023a514137272d413658848e0d5bc9aecf941b946c8662862779a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6595,7 +6598,7 @@ fastmcp = ">=3.0.0"
|
||||
filelock = ">=3.20.1"
|
||||
httpx = {version = ">=0.27.0", extras = ["socks"]}
|
||||
litellm = ">=1.82.6,<1.82.7 || >1.82.7,<1.82.8 || >1.82.8"
|
||||
lmnr = ">=0.7.47"
|
||||
lmnr = ">=0.7.24"
|
||||
pydantic = ">=2.12.5"
|
||||
python-frontmatter = ">=1.1.0"
|
||||
python-json-logger = ">=3.3.0"
|
||||
@@ -6607,14 +6610,14 @@ boto3 = ["boto3 (>=1.35.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "openhands-tools"
|
||||
version = "1.19.0"
|
||||
version = "1.17.0"
|
||||
description = "OpenHands Tools - Runtime tools for AI agents"
|
||||
optional = false
|
||||
python-versions = ">=3.12"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "openhands_tools-1.19.0-py3-none-any.whl", hash = "sha256:ff5ddb40d628a468eda4488b2c0045470c88e396bab43330b6b468f3ada47b9e"},
|
||||
{file = "openhands_tools-1.19.0.tar.gz", hash = "sha256:b4dc59a813fe1fe7bda519979498a7bdf07dd8f83ea3f0aad78c154f5fcb9a32"},
|
||||
{file = "openhands_tools-1.17.0-py3-none-any.whl", hash = "sha256:76cd30fcc153627444f18638bcd926c9190989f80a3492381e84a181c021d815"},
|
||||
{file = "openhands_tools-1.17.0.tar.gz", hash = "sha256:4a9d6c1aec00d366d0feb1ac2e9ee9988ad9806a0ef89f7dbe4655644e639d4a"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -14144,14 +14147,6 @@ optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:e87be7572991552606a3155d2f6c2045ded8bce94bfd9f74bf521d949c219a1c"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:86c2fdf178c66474a1be2965602818d30780e4e3ed890e3c206931f65d9a154c"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:035d259e64c41d02cc45afc3b8b46388b232e7d16d84734d851cca7334761da5"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa472cb9de7e14fee9408e144f29f68384cd8e9c677dff0002da19f361a59bdf"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1a0ea86eccff74e85ab4a2cf77c813fad7c84162962ce242dff0c51601028832"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8ab26dc998bbd4b4287b129f67c10ca715deb402ed77d0645674490ea509097e"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-win_amd64.whl", hash = "sha256:d4486653feaff3314ef45534dcb6f9ea8ab3aa160896287c6473788f88eb38be"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:e7a14b76ec23cc8386cf662d5ea602d81331376c93ca6299a97b174047790345"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2b612a6e5bd17bb7fa2aab4bb6fc1fba45c94f09cb034ab332e45603b86e32fd"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a8b98f62bc53efcd4d971151950c9b9cd5cbe3bacdb0cd69fdccac63350d83e"},
|
||||
{file = "tree_sitter_c_sharp-0.23.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:986e93d845a438ec3c4416401aa98e6a6f6631d644bbbc2e43fcb915c51d255d"},
|
||||
|
||||
@@ -28,6 +28,7 @@ from server.routes.api_keys import api_router as api_keys_router # noqa: E402
|
||||
from server.routes.auth import api_router, oauth_router # noqa: E402
|
||||
from server.routes.billing import billing_router # noqa: E402
|
||||
from server.routes.email import api_router as email_router # noqa: E402
|
||||
from server.routes.feedback import router as feedback_router # noqa: E402
|
||||
from server.routes.github_proxy import add_github_proxy_routes # noqa: E402
|
||||
from server.routes.integration.jira import jira_integration_router # noqa: E402
|
||||
from server.routes.integration.jira_dc import jira_dc_integration_router # noqa: E402
|
||||
@@ -146,6 +147,7 @@ if BITBUCKET_DATA_CENTER_HOST:
|
||||
|
||||
base_app.include_router(bitbucket_dc_proxy_router)
|
||||
base_app.include_router(email_router) # Add routes for email management
|
||||
base_app.include_router(feedback_router) # Add routes for conversation feedback
|
||||
|
||||
|
||||
base_app.add_middleware(
|
||||
|
||||
@@ -40,8 +40,8 @@ from storage.org_member_store import OrgMemberStore
|
||||
from storage.role import Role
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
|
||||
|
||||
class Permission(str, Enum):
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import os
|
||||
|
||||
from openhands.app_server.integrations.gitlab.constants import GITLAB_HOST
|
||||
|
||||
GITHUB_APP_CLIENT_ID = os.getenv('GITHUB_APP_CLIENT_ID', '').strip()
|
||||
GITHUB_APP_CLIENT_SECRET = os.getenv('GITHUB_APP_CLIENT_SECRET', '').strip()
|
||||
GITHUB_APP_WEBHOOK_SECRET = os.getenv('GITHUB_APP_WEBHOOK_SECRET', '')
|
||||
@@ -16,7 +14,6 @@ KEYCLOAK_SERVER_URL_EXT = os.getenv(
|
||||
KEYCLOAK_ADMIN_PASSWORD = os.getenv('KEYCLOAK_ADMIN_PASSWORD', '')
|
||||
GITLAB_APP_CLIENT_ID = os.getenv('GITLAB_APP_CLIENT_ID', '').strip()
|
||||
GITLAB_APP_CLIENT_SECRET = os.getenv('GITLAB_APP_CLIENT_SECRET', '').strip()
|
||||
GITLAB_TOKEN_URL = f'https://{GITLAB_HOST}/oauth/token'
|
||||
BITBUCKET_APP_CLIENT_ID = os.getenv('BITBUCKET_APP_CLIENT_ID', '').strip()
|
||||
BITBUCKET_APP_CLIENT_SECRET = os.getenv('BITBUCKET_APP_CLIENT_SECRET', '').strip()
|
||||
ENABLE_ENTERPRISE_SSO = os.getenv('ENABLE_ENTERPRISE_SSO', '').strip()
|
||||
|
||||
@@ -2,8 +2,8 @@ from integrations.github.github_service import SaaSGitHubService
|
||||
from pydantic import SecretStr
|
||||
from server.auth.auth_utils import user_verifier
|
||||
|
||||
from openhands.app_server.integrations.github.github_types import GitHubUser
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.github_types import GitHubUser
|
||||
|
||||
|
||||
def is_user_allowed(user_login: str):
|
||||
|
||||
@@ -3,8 +3,8 @@ import asyncio
|
||||
from pydantic import SecretStr
|
||||
from sqlalchemy import select
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ def schedule_gitlab_repo_sync(
|
||||
|
||||
# Lazy import to avoid circular dependency:
|
||||
# middleware -> gitlab_sync -> integrations.gitlab.gitlab_service
|
||||
# -> openhands.app_server.integrations.gitlab.gitlab_service -> get_impl
|
||||
# -> openhands.integrations.gitlab.gitlab_service -> get_impl
|
||||
# -> integrations.gitlab.gitlab_service (circular)
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
|
||||
|
||||
@@ -35,15 +35,15 @@ from storage.user_authorization_store import UserAuthorizationStore
|
||||
from storage.user_store import UserStore
|
||||
from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed
|
||||
|
||||
from openhands.app_server.integrations.provider import (
|
||||
from openhands.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderToken,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.app_server.secrets.secrets_models import Secrets
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.app_server.settings.settings_store import SettingsStore
|
||||
from openhands.app_server.user_auth.user_auth import AuthType, UserAuth
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth
|
||||
from openhands.storage.data_models.secrets import Secrets
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ from server.auth.constants import (
|
||||
GITHUB_APP_CLIENT_SECRET,
|
||||
GITLAB_APP_CLIENT_ID,
|
||||
GITLAB_APP_CLIENT_SECRET,
|
||||
GITLAB_TOKEN_URL,
|
||||
KEYCLOAK_REALM_NAME,
|
||||
KEYCLOAK_SERVER_URL,
|
||||
KEYCLOAK_SERVER_URL_EXT,
|
||||
@@ -51,7 +50,7 @@ from storage.github_app_installation import GithubAppInstallation
|
||||
from storage.offline_token_store import OfflineTokenStore
|
||||
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.types import SessionExpiredError
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
@@ -418,7 +417,7 @@ class TokenManager:
|
||||
return await self._parse_refresh_response(data)
|
||||
|
||||
async def _refresh_gitlab_token(self, refresh_token: str) -> dict[str, str | int]:
|
||||
url = GITLAB_TOKEN_URL
|
||||
url = 'https://gitlab.com/oauth/token'
|
||||
logger.info(f'Refreshing GitLab token with URL: {url}')
|
||||
|
||||
payload = {
|
||||
|
||||
@@ -22,8 +22,8 @@ from server.auth.constants import (
|
||||
)
|
||||
from server.constants import DEPLOYMENT_MODE
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.config.utils import load_openhands_config
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
@@ -72,6 +72,12 @@ class SaaSServerConfig(ServerConfig):
|
||||
auth_url: str | None = os.environ.get('AUTH_URL')
|
||||
settings_store_class: str = 'storage.saas_settings_store.SaasSettingsStore'
|
||||
secret_store_class: str = 'storage.saas_secrets_store.SaasSecretsStore'
|
||||
conversation_store_class: str = (
|
||||
'storage.saas_conversation_store.SaasConversationStore'
|
||||
)
|
||||
monitoring_listener_class: str = (
|
||||
'server.saas_monitoring_listener.SaaSMonitoringListener'
|
||||
)
|
||||
user_auth_class: str = 'server.auth.saas_user_auth.SaasUserAuth'
|
||||
# Maintenance window configuration
|
||||
maintenance_start_time: str = os.environ.get(
|
||||
|
||||
@@ -4,8 +4,8 @@ Email domain validation utilities for enterprise endpoints.
|
||||
|
||||
from fastapi import Depends, HTTPException, Request, status
|
||||
|
||||
from openhands.app_server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
|
||||
|
||||
async def get_admin_user_id(
|
||||
|
||||
@@ -15,9 +15,9 @@ from server.auth.saas_user_auth import SaasUserAuth, token_manager
|
||||
from server.routes.auth import set_response_cookie
|
||||
from server.utils.url_utils import get_cookie_domain, get_cookie_samesite
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth.user_auth import AuthType, UserAuth, get_user_auth
|
||||
from openhands.server.utils import config
|
||||
|
||||
|
||||
class SetAuthCookieMiddleware:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.app_server.user.user_models import UserInfo
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class SaasUserInfo(UserInfo):
|
||||
|
||||
@@ -12,9 +12,9 @@ from storage.org_member_store import OrgMemberStore
|
||||
from storage.org_service import OrgService
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.app_server.user_auth.user_auth import AuthType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_auth, get_user_id
|
||||
from openhands.server.user_auth.user_auth import AuthType
|
||||
|
||||
|
||||
# Helper functions for BYOR API key management
|
||||
|
||||
@@ -3,7 +3,6 @@ import json
|
||||
import uuid
|
||||
import warnings
|
||||
from datetime import datetime, timezone
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Optional, cast
|
||||
from urllib.parse import quote, urlencode
|
||||
from uuid import UUID as parse_uuid
|
||||
@@ -47,16 +46,13 @@ from storage.database import a_session_maker
|
||||
from storage.user import User
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.integrations.provider import (
|
||||
PROVIDER_TOKEN_TYPE,
|
||||
ProviderHandler,
|
||||
ProviderToken,
|
||||
)
|
||||
from openhands.app_server.integrations.service_types import ProviderType, TokenResponse
|
||||
from openhands.app_server.user_auth import get_access_token
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType, TokenResponse
|
||||
from openhands.server.services.conversation_service import create_provider_tokens_object
|
||||
from openhands.server.shared import config
|
||||
from openhands.server.user_auth import get_access_token
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter('ignore')
|
||||
@@ -67,18 +63,6 @@ oauth_router = APIRouter(prefix='/oauth')
|
||||
token_manager = TokenManager()
|
||||
|
||||
|
||||
def create_provider_tokens_object(
|
||||
providers_set: list[ProviderType],
|
||||
) -> PROVIDER_TOKEN_TYPE:
|
||||
"""Create provider tokens object for the given providers."""
|
||||
provider_information: dict[ProviderType, ProviderToken] = {}
|
||||
|
||||
for provider in providers_set:
|
||||
provider_information[provider] = ProviderToken(token=None, user_id=None)
|
||||
|
||||
return MappingProxyType(provider_information)
|
||||
|
||||
|
||||
def set_response_cookie(
|
||||
request: Request,
|
||||
response: Response,
|
||||
@@ -719,41 +703,6 @@ async def accept_tos(request: Request):
|
||||
return response
|
||||
|
||||
|
||||
@api_router.get('/onboarding_status')
|
||||
async def onboarding_status(request: Request):
|
||||
"""Return whether the current user must still complete onboarding.
|
||||
|
||||
Kept as a dedicated endpoint instead of riding on ``GET /api/v1/settings``
|
||||
(the natural home for fields like ``email_verified``) because the settings
|
||||
response is heavyweight: ``SaasSettingsStore.load`` joins User, Org, and
|
||||
OrgMember rows and deep-merges the org-level and member-level
|
||||
``agent_settings`` before returning. Onboarding gating runs on every
|
||||
protected-route navigation, so we need a lightweight read of a single
|
||||
boolean rather than paying for the full settings aggregation.
|
||||
"""
|
||||
user_auth = cast(SaasUserAuth, await get_user_auth(request))
|
||||
user_id = await user_auth.get_user_id()
|
||||
|
||||
if not user_id:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
content={'error': 'User is not authenticated'},
|
||||
)
|
||||
|
||||
user = await UserStore.get_user_by_id(user_id)
|
||||
if not user:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
content={'error': 'User not found'},
|
||||
)
|
||||
|
||||
should_complete = await _should_redirect_to_onboarding(user_id, user)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_200_OK,
|
||||
content={'should_complete_onboarding': should_complete},
|
||||
)
|
||||
|
||||
|
||||
@api_router.post('/complete_onboarding')
|
||||
async def complete_onboarding(request: Request):
|
||||
"""Mark onboarding as completed for the current user."""
|
||||
|
||||
@@ -21,7 +21,7 @@ from storage.subscription_access import SubscriptionAccess
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.config import get_global_config
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
stripe.api_key = STRIPE_API_KEY
|
||||
billing_router = APIRouter(prefix='/api/billing', tags=['Billing'])
|
||||
|
||||
@@ -13,9 +13,9 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from server.utils.url_utils import get_web_url
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Email validation regex pattern
|
||||
EMAIL_REGEX = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
|
||||
|
||||
145
enterprise/server/routes/feedback.py
Normal file
145
enterprise/server/routes/feedback.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.future import select
|
||||
from storage.database import a_session_maker
|
||||
from storage.feedback import ConversationFeedback
|
||||
from storage.stored_conversation_metadata_saas import StoredConversationMetadataSaas
|
||||
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.server.shared import file_store
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# We use the get_dependencies method here to signal to the OpenAPI docs that this endpoint
|
||||
# is protected. The actual protection is provided by SetAuthCookieMiddleware
|
||||
# TODO: It may be an error by you can actually post feedback to a conversation you don't
|
||||
# own right now - maybe this is useful in the context of public shared conversations?
|
||||
router = APIRouter(
|
||||
prefix='/feedback', tags=['feedback'], dependencies=get_dependencies()
|
||||
)
|
||||
|
||||
|
||||
async def get_event_ids(conversation_id: str, user_id: str) -> List[int]:
|
||||
"""Get all event IDs for a given conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to get events for
|
||||
user_id: The ID of the user who owns the conversation
|
||||
|
||||
Returns:
|
||||
List of event IDs in the conversation
|
||||
|
||||
Raises:
|
||||
HTTPException: If conversation metadata not found
|
||||
"""
|
||||
|
||||
# Verify the conversation belongs to the user
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(StoredConversationMetadataSaas).where(
|
||||
StoredConversationMetadataSaas.conversation_id == conversation_id,
|
||||
StoredConversationMetadataSaas.user_id == user_id,
|
||||
)
|
||||
)
|
||||
metadata = result.scalars().first()
|
||||
if not metadata:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f'Conversation {conversation_id} not found',
|
||||
)
|
||||
|
||||
# Create an event store to access the events directly
|
||||
# This works even when the conversation is not running
|
||||
event_store = EventStore(
|
||||
sid=conversation_id,
|
||||
file_store=file_store,
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Get events from the event store
|
||||
events = event_store.search_events(start_id=0)
|
||||
|
||||
# Return list of event IDs
|
||||
return [event.id for event in events]
|
||||
|
||||
|
||||
class FeedbackRequest(BaseModel):
|
||||
conversation_id: str
|
||||
event_id: Optional[int] = None
|
||||
rating: int = Field(..., ge=1, le=5)
|
||||
reason: Optional[str] = None
|
||||
metadata: Optional[Dict[str, Any]] = None
|
||||
|
||||
|
||||
@router.post('/conversation', status_code=status.HTTP_201_CREATED)
|
||||
async def submit_conversation_feedback(feedback: FeedbackRequest):
|
||||
"""
|
||||
Submit feedback for a conversation.
|
||||
|
||||
This endpoint accepts a rating (1-5) and optional reason for the feedback.
|
||||
The feedback is associated with a specific conversation and optionally a specific event.
|
||||
"""
|
||||
# Validate rating is between 1 and 5
|
||||
if feedback.rating < 1 or feedback.rating > 5:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail='Rating must be between 1 and 5',
|
||||
)
|
||||
|
||||
# Create new feedback record
|
||||
new_feedback = ConversationFeedback(
|
||||
conversation_id=feedback.conversation_id,
|
||||
event_id=feedback.event_id,
|
||||
rating=feedback.rating,
|
||||
reason=feedback.reason,
|
||||
metadata=feedback.metadata,
|
||||
)
|
||||
|
||||
# Add to database
|
||||
async with a_session_maker() as session:
|
||||
session.add(new_feedback)
|
||||
await session.commit()
|
||||
|
||||
return {'status': 'success', 'message': 'Feedback submitted successfully'}
|
||||
|
||||
|
||||
@router.get('/conversation/{conversation_id}/batch')
|
||||
async def get_batch_feedback(conversation_id: str, user_id: str = Depends(get_user_id)):
|
||||
"""
|
||||
Get feedback for all events in a conversation.
|
||||
|
||||
Returns feedback status for each event, including whether feedback exists
|
||||
and if so, the rating and reason.
|
||||
"""
|
||||
# Get all event IDs for the conversation
|
||||
event_ids = await get_event_ids(conversation_id, user_id)
|
||||
if not event_ids:
|
||||
return {}
|
||||
|
||||
# Query for existing feedback for all events
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ConversationFeedback).where(
|
||||
ConversationFeedback.conversation_id == conversation_id,
|
||||
ConversationFeedback.event_id.in_(event_ids),
|
||||
)
|
||||
)
|
||||
|
||||
# Create a mapping of event_id to feedback
|
||||
feedback_map = {
|
||||
feedback.event_id: {
|
||||
'exists': True,
|
||||
'rating': feedback.rating,
|
||||
'reason': feedback.reason,
|
||||
}
|
||||
for feedback in result.scalars()
|
||||
}
|
||||
|
||||
# Build response including all events
|
||||
response = {}
|
||||
for event_id in event_ids:
|
||||
response[str(event_id)] = feedback_map.get(event_id, {'exists': False})
|
||||
|
||||
return response
|
||||
@@ -15,8 +15,8 @@ from server.auth.constants import (
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.services.automation_event_service import AutomationEventService
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderType
|
||||
|
||||
# Environment variable to disable GitHub webhooks
|
||||
GITHUB_WEBHOOKS_ENABLED = os.environ.get('GITHUB_WEBHOOKS_ENABLED', '1') in (
|
||||
|
||||
@@ -2,7 +2,15 @@ import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request, status
|
||||
from fastapi import (
|
||||
APIRouter,
|
||||
BackgroundTasks,
|
||||
Depends,
|
||||
Header,
|
||||
HTTPException,
|
||||
Request,
|
||||
status,
|
||||
)
|
||||
from fastapi.responses import JSONResponse
|
||||
from integrations.gitlab.gitlab_manager import GitlabManager
|
||||
from integrations.gitlab.gitlab_service import SaaSGitLabService
|
||||
@@ -15,20 +23,24 @@ from integrations.models import Message, SourceType
|
||||
from integrations.types import GitLabResourceType
|
||||
from integrations.utils import GITLAB_WEBHOOK_URL, IS_LOCAL_DEPLOYMENT
|
||||
from pydantic import BaseModel
|
||||
from server.auth.constants import AUTOMATION_EVENT_FORWARDING_ENABLED
|
||||
from server.auth.token_manager import TokenManager
|
||||
from server.services.automation_event_service import AutomationEventService
|
||||
from storage.gitlab_webhook import GitlabWebhook
|
||||
from storage.gitlab_webhook_store import GitlabWebhookStore
|
||||
|
||||
from openhands.app_server.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.gitlab.gitlab_service import GitLabServiceImpl
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.server.shared import sio
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
gitlab_integration_router = APIRouter(prefix='/integration')
|
||||
webhook_store = GitlabWebhookStore()
|
||||
|
||||
token_manager = TokenManager()
|
||||
gitlab_manager = GitlabManager(token_manager)
|
||||
automation_event_service = AutomationEventService(token_manager)
|
||||
|
||||
|
||||
# Request/Response models
|
||||
@@ -82,6 +94,7 @@ async def verify_gitlab_signature(
|
||||
@gitlab_integration_router.post('/gitlab/events')
|
||||
async def gitlab_events(
|
||||
request: Request,
|
||||
background_tasks: BackgroundTasks,
|
||||
x_gitlab_token: str = Header(None),
|
||||
x_openhands_webhook_id: str = Header(None),
|
||||
x_openhands_user_id: str = Header(None),
|
||||
@@ -112,6 +125,16 @@ async def gitlab_events(
|
||||
content={'message': 'Duplicate GitLab event ignored.'},
|
||||
)
|
||||
|
||||
# Forward to automation service (fire-and-forget background task)
|
||||
if AUTOMATION_EVENT_FORWARDING_ENABLED:
|
||||
background_tasks.add_task(
|
||||
automation_event_service.forward_event,
|
||||
provider=ProviderType.GITLAB,
|
||||
payload=payload_data,
|
||||
installation_id=x_openhands_webhook_id,
|
||||
)
|
||||
|
||||
# Existing resolver bot processing
|
||||
message = Message(
|
||||
source=SourceType.GITLAB,
|
||||
message={
|
||||
|
||||
@@ -20,8 +20,8 @@ from server.auth.token_manager import TokenManager
|
||||
from storage.jira_workspace import JiraWorkspace
|
||||
from storage.redis import create_redis_client
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Environment variable to disable Jira webhooks
|
||||
JIRA_WEBHOOKS_ENABLED = os.environ.get('JIRA_WEBHOOKS_ENABLED', '0') in (
|
||||
|
||||
@@ -28,8 +28,8 @@ from server.auth.token_manager import TokenManager
|
||||
from server.constants import WEB_HOST
|
||||
from storage.redis import create_redis_client
|
||||
|
||||
from openhands.app_server.user_auth.user_auth import get_user_auth
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import get_user_auth
|
||||
|
||||
# Environment variable to disable Jira DC webhooks
|
||||
JIRA_DC_WEBHOOKS_ENABLED = os.environ.get('JIRA_DC_WEBHOOKS_ENABLED', '0') in (
|
||||
|
||||
@@ -29,7 +29,6 @@ from server.constants import (
|
||||
SLACK_WEBHOOKS_ENABLED,
|
||||
)
|
||||
from server.logger import logger
|
||||
from slack_sdk.errors import SlackApiError
|
||||
from slack_sdk.oauth import AuthorizeUrlGenerator
|
||||
from slack_sdk.signature import SignatureVerifier
|
||||
from slack_sdk.web.async_client import AsyncWebClient
|
||||
@@ -39,10 +38,7 @@ from storage.slack_team_store import SlackTeamStore
|
||||
from storage.slack_user import SlackUser
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.integrations.service_types import (
|
||||
ProviderTimeoutError,
|
||||
ProviderType,
|
||||
)
|
||||
from openhands.integrations.service_types import ProviderTimeoutError, ProviderType
|
||||
from openhands.server.shared import config, sio
|
||||
|
||||
signature_verifier = SignatureVerifier(signing_secret=SLACK_SIGNING_SECRET)
|
||||
@@ -50,16 +46,7 @@ slack_router = APIRouter(prefix='/slack')
|
||||
|
||||
# Build https://slack.com/oauth/v2/authorize with sufficient query parameters
|
||||
authorize_url_generator = AuthorizeUrlGenerator(
|
||||
client_id=SLACK_CLIENT_ID,
|
||||
scopes=[
|
||||
'app_mentions:read',
|
||||
'chat:write',
|
||||
'users:read',
|
||||
'channels:history',
|
||||
'groups:history',
|
||||
'mpim:history',
|
||||
'im:history',
|
||||
],
|
||||
client_id=SLACK_CLIENT_ID, scopes=['app_mentions:read', 'chat:write']
|
||||
)
|
||||
token_manager = TokenManager()
|
||||
|
||||
@@ -245,24 +232,7 @@ async def keycloak_callback(
|
||||
|
||||
# Retrieve the display_name from slack
|
||||
client = AsyncWebClient(token=bot_access_token)
|
||||
try:
|
||||
slack_user_info = await client.users_info(user=slack_user_id)
|
||||
except SlackApiError as e:
|
||||
if e.response.get('error') == 'missing_scope':
|
||||
logger.warning(
|
||||
'slack_missing_scope_during_install',
|
||||
extra={'slack_user_id': slack_user_id, 'team_id': team_id},
|
||||
)
|
||||
return _html_response(
|
||||
title='Re-installation Required',
|
||||
description=(
|
||||
'The Slack app is missing required permissions. '
|
||||
f'Please <a href="{HOST_URL}/slack/install" style="color:#ecedee;text-decoration:underline;">re-install the OpenHands Slack App</a> '
|
||||
'to authorize the updated permissions.'
|
||||
),
|
||||
status_code=400,
|
||||
)
|
||||
raise
|
||||
slack_user_info = await client.users_info(user=slack_user_id)
|
||||
slack_display_name = slack_user_info.data['user']['profile']['display_name']
|
||||
slack_user = SlackUser(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
@@ -396,7 +366,7 @@ async def on_options_load(request: Request, background_tasks: BackgroundTasks):
|
||||
# Verify this is a block_suggestion payload
|
||||
if payload.get('type') != 'block_suggestion':
|
||||
logger.warning(
|
||||
f'slack_on_options_load: Unexpected payload type: {payload.get("type")}'
|
||||
f"slack_on_options_load: Unexpected payload type: {payload.get('type')}"
|
||||
)
|
||||
return JSONResponse({'options': []})
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ import os
|
||||
from fastmcp import Client, FastMCP
|
||||
from fastmcp.client.transports import NpxStdioTransport
|
||||
|
||||
from openhands.app_server.mcp.mcp_router import mcp_server
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.routes.mcp import mcp_server
|
||||
|
||||
ENABLE_MCP_SEARCH_ENGINE = (
|
||||
os.getenv('ENABLE_MCP_SEARCH_ENGINE', 'false').lower() == 'true'
|
||||
|
||||
@@ -10,8 +10,8 @@ from server.utils.url_utils import get_web_url
|
||||
from storage.api_key_store import ApiKeyStore
|
||||
from storage.device_code_store import DeviceCodeStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
@@ -22,8 +22,8 @@ from server.utils.rate_limit_utils import check_rate_limit_by_user_id
|
||||
from storage.org_store import OrgStore
|
||||
from storage.role_store import RoleStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# Router for invitation operations on an organization (requires org_id)
|
||||
invitation_router = APIRouter(prefix='/api/organizations/{org_id}/members')
|
||||
|
||||
@@ -162,6 +162,7 @@ class OrgResponse(BaseModel):
|
||||
search_api_key: str | None = None
|
||||
sandbox_api_key: str | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
enable_solvability_analysis: bool | None = None
|
||||
v1_enabled: bool | None = None
|
||||
credits: float | None = None
|
||||
is_personal: bool = False
|
||||
@@ -194,6 +195,7 @@ class OrgResponse(BaseModel):
|
||||
search_api_key=None,
|
||||
sandbox_api_key=None,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
v1_enabled=org.v1_enabled,
|
||||
credits=credits,
|
||||
is_personal=str(org.id) == user_id if user_id else False,
|
||||
@@ -230,6 +232,7 @@ class OrgUpdate(BaseModel):
|
||||
sandbox_runtime_container_image: str | None = None
|
||||
sandbox_api_key: str | None = None
|
||||
max_budget_per_task: float | None = Field(default=None, gt=0)
|
||||
enable_solvability_analysis: bool | None = None
|
||||
v1_enabled: bool | None = None
|
||||
search_api_key: str | None = None
|
||||
llm_api_key: str | None = None
|
||||
@@ -550,6 +553,7 @@ class OrgAppSettingsResponse(BaseModel):
|
||||
"""Response model for organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool = True
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@classmethod
|
||||
@@ -566,6 +570,7 @@ class OrgAppSettingsResponse(BaseModel):
|
||||
enable_proactive_conversation_starters=org.enable_proactive_conversation_starters
|
||||
if org.enable_proactive_conversation_starters is not None
|
||||
else True,
|
||||
enable_solvability_analysis=org.enable_solvability_analysis,
|
||||
max_budget_per_task=org.max_budget_per_task,
|
||||
)
|
||||
|
||||
@@ -574,6 +579,7 @@ class OrgAppSettingsUpdate(BaseModel):
|
||||
"""Request model for updating organization app settings."""
|
||||
|
||||
enable_proactive_conversation_starters: bool | None = None
|
||||
enable_solvability_analysis: bool | None = None
|
||||
max_budget_per_task: float | None = None
|
||||
|
||||
@field_validator('max_budget_per_task')
|
||||
|
||||
@@ -50,8 +50,8 @@ from storage.org_service import OrgService
|
||||
from storage.org_store import OrgStore
|
||||
from storage.user_store import UserStore
|
||||
|
||||
from openhands.app_server.user_auth import get_user_id
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth import get_user_id
|
||||
|
||||
# Initialize API router
|
||||
org_router = APIRouter(prefix='/api/organizations', tags=['Orgs'])
|
||||
|
||||
@@ -17,12 +17,12 @@ from openhands.app_server.config import (
|
||||
depends_user_context,
|
||||
resolve_provider_llm_base_url,
|
||||
)
|
||||
from openhands.app_server.integrations.provider import ProviderHandler
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.app_server.sandbox.session_auth import validate_session_key_ownership
|
||||
from openhands.app_server.user.auth_user_context import AuthUserContext
|
||||
from openhands.app_server.user.user_context import UserContext
|
||||
from openhands.app_server.utils.dependencies import get_dependencies
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
55
enterprise/server/saas_monitoring_listener.py
Normal file
55
enterprise/server/saas_monitoring_listener.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from server.logger import logger
|
||||
|
||||
from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.event import Event
|
||||
from openhands.events.observation import (
|
||||
AgentStateChangedObservation,
|
||||
)
|
||||
from openhands.server.monitoring import MonitoringListener
|
||||
|
||||
|
||||
class SaaSMonitoringListener(MonitoringListener):
|
||||
"""Forward app signals to structured logging for GCP native monitoring."""
|
||||
|
||||
def on_session_event(self, event: Event) -> None:
|
||||
"""Track metrics about events being added to a Session's EventStream."""
|
||||
if (
|
||||
isinstance(event, AgentStateChangedObservation)
|
||||
and event.agent_state == AgentState.ERROR
|
||||
):
|
||||
logger.info(
|
||||
'Tracking agent status error',
|
||||
extra={'signal': 'saas_agent_status_errors'},
|
||||
)
|
||||
|
||||
def on_agent_session_start(self, success: bool, duration: float) -> None:
|
||||
"""Track an agent session start.
|
||||
|
||||
Success is true if startup completed without error.
|
||||
Duration is start time in seconds observed by AgentSession.
|
||||
"""
|
||||
logger.info(
|
||||
'Tracking agent session start',
|
||||
extra={
|
||||
'signal': 'saas_agent_session_start',
|
||||
'success': success,
|
||||
'duration': duration,
|
||||
},
|
||||
)
|
||||
|
||||
def on_create_conversation(self) -> None:
|
||||
"""Track the beginning of conversation creation.
|
||||
|
||||
Does not currently capture whether it succeed.
|
||||
"""
|
||||
logger.info(
|
||||
'Tracking create conversation', extra={'signal': 'saas_create_conversation'}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_instance(
|
||||
cls,
|
||||
config: OpenHandsConfig,
|
||||
) -> 'SaaSMonitoringListener':
|
||||
return cls()
|
||||
@@ -35,8 +35,8 @@ from server.auth.constants import (
|
||||
)
|
||||
from server.auth.token_manager import TokenManager
|
||||
|
||||
from openhands.app_server.integrations.provider import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.server.shared import sio
|
||||
|
||||
# Cache TTL constants
|
||||
@@ -163,7 +163,8 @@ class AutomationEventService:
|
||||
org_id = await self._resolve_git_org(provider, git_org_name)
|
||||
|
||||
# Fallback for personal repos (owner_type indicates individual user)
|
||||
if not org_id and owner_type == 'User':
|
||||
# GitHub uses 'User', GitLab uses 'user'
|
||||
if not org_id and owner_type and owner_type.lower() == 'user':
|
||||
org_id = await self._resolve_personal_org(provider, owner_id)
|
||||
if org_id:
|
||||
logger.info(
|
||||
@@ -206,6 +207,18 @@ class AutomationEventService:
|
||||
owner = repo.get('owner', {})
|
||||
return owner.get('login'), owner.get('type'), owner.get('id')
|
||||
|
||||
if provider == ProviderType.GITLAB:
|
||||
# GitLab uses 'project' instead of 'repository'
|
||||
# path_with_namespace is like "org-name/repo-name" or "user-name/repo-name"
|
||||
project = payload.get('project', {})
|
||||
path_with_namespace = project.get('path_with_namespace', '')
|
||||
git_org = path_with_namespace.split('/')[0] if path_with_namespace else None
|
||||
namespace = project.get('namespace', {})
|
||||
# GitLab uses 'group' for organizations and 'user' for personal projects
|
||||
owner_type = namespace.get('kind')
|
||||
owner_id = namespace.get('id')
|
||||
return git_org, owner_type, owner_id
|
||||
|
||||
logger.warning(f'Unsupported provider ({provider.value})')
|
||||
return None, None, None
|
||||
|
||||
|
||||
@@ -313,22 +313,11 @@ class OrgInvitationService:
|
||||
raise InvitationInvalidError('User not found')
|
||||
|
||||
user_email = user.email
|
||||
# Fallback: fetch email from Keycloak if not in database (for existing users).
|
||||
# When found, persist it back to User.email so the members list shows it
|
||||
# without requiring the user to log out and log back in.
|
||||
# Fallback: fetch email from Keycloak if not in database (for existing users)
|
||||
if not user_email:
|
||||
token_manager = TokenManager()
|
||||
user_info = await token_manager.get_user_info_from_user_id(str(user_id))
|
||||
if user_info:
|
||||
user_email = user_info.get('email')
|
||||
if user_email:
|
||||
await UserStore.backfill_user_email(
|
||||
str(user_id),
|
||||
{
|
||||
'email': user_email,
|
||||
'email_verified': user_info.get('emailVerified', False),
|
||||
},
|
||||
)
|
||||
user_email = user_info.get('email') if user_info else None
|
||||
|
||||
if not user_email:
|
||||
raise EmailMismatchError('Your account does not have an email address')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
from datetime import datetime
|
||||
|
||||
# Simplified imports to avoid dependency chain issues
|
||||
# from openhands.app_server.integrations.service_types import ProviderType
|
||||
# from openhands.integrations.service_types import ProviderType
|
||||
# from openhands.sdk.llm import MetricsSnapshot
|
||||
# from openhands.app_server.app_conversation.app_conversation_models import ConversationTrigger
|
||||
# from openhands.storage.data_models.conversation_metadata import ConversationTrigger
|
||||
# For now, use Any to avoid import issues
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -30,8 +30,8 @@ from openhands.agent_server.utils import utc_now
|
||||
from openhands.app_server.app_conversation.sql_app_conversation_info_service import (
|
||||
StoredConversationMetadata,
|
||||
)
|
||||
from openhands.app_server.integrations.provider import ProviderType
|
||||
from openhands.app_server.services.injector import InjectorState
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.sdk.llm import MetricsSnapshot, TokenUsage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
295
enterprise/server/utils/conversation_callback_utils.py
Normal file
295
enterprise/server/utils/conversation_callback_utils.py
Normal file
@@ -0,0 +1,295 @@
|
||||
import base64
|
||||
import json
|
||||
import pickle
|
||||
from datetime import datetime
|
||||
|
||||
from server.logger import logger
|
||||
from sqlalchemy import and_, select
|
||||
from storage.conversation_callback import (
|
||||
CallbackStatus,
|
||||
ConversationCallback,
|
||||
ConversationCallbackProcessor,
|
||||
)
|
||||
from storage.conversation_work import ConversationWork
|
||||
from storage.database import a_session_maker, session_maker
|
||||
from storage.stored_conversation_metadata import StoredConversationMetadata
|
||||
|
||||
from openhands.core.config import load_openhands_config
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.events.event_store import EventStore
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
from openhands.events.serialization.event import event_from_dict
|
||||
from openhands.server.services.conversation_stats import ConversationStats
|
||||
from openhands.storage import get_file_store
|
||||
from openhands.storage.files import FileStore
|
||||
from openhands.storage.locations import (
|
||||
get_conversation_agent_state_filename,
|
||||
get_conversation_dir,
|
||||
)
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
config = load_openhands_config()
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
|
||||
|
||||
async def process_event(
|
||||
user_id: str, conversation_id: str, subpath: str, content: dict
|
||||
):
|
||||
"""
|
||||
Process a conversation event and invoke any registered callbacks.
|
||||
|
||||
Args:
|
||||
user_id: The user ID associated with the conversation
|
||||
conversation_id: The conversation ID
|
||||
subpath: The event subpath
|
||||
content: The event content
|
||||
"""
|
||||
logger.debug(
|
||||
'process_event',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'conversation_id': conversation_id,
|
||||
'content': content,
|
||||
},
|
||||
)
|
||||
write_path = get_conversation_dir(conversation_id, user_id) + subpath
|
||||
|
||||
# This writes to the google cloud storage, so we do this in a background thread to not block the main runloop...
|
||||
await call_sync_from_async(file_store.write, write_path, json.dumps(content))
|
||||
|
||||
event = event_from_dict(content)
|
||||
if isinstance(event, AgentStateChangedObservation):
|
||||
# Load and invoke all active callbacks for this conversation
|
||||
await invoke_conversation_callbacks(conversation_id, event)
|
||||
|
||||
# Update active working seconds if agent state is not Running
|
||||
if event.agent_state != AgentState.RUNNING:
|
||||
event_store = EventStore(conversation_id, file_store, user_id)
|
||||
update_active_working_seconds(
|
||||
event_store, conversation_id, user_id, file_store
|
||||
)
|
||||
|
||||
|
||||
async def invoke_conversation_callbacks(
|
||||
conversation_id: str, observation: AgentStateChangedObservation
|
||||
):
|
||||
"""
|
||||
Load and invoke all active callbacks for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to process callbacks for
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
"""
|
||||
async with a_session_maker() as session:
|
||||
result = await session.execute(
|
||||
select(ConversationCallback).filter(
|
||||
and_(
|
||||
ConversationCallback.conversation_id == conversation_id,
|
||||
ConversationCallback.status == CallbackStatus.ACTIVE,
|
||||
)
|
||||
)
|
||||
)
|
||||
callbacks = result.scalars().all()
|
||||
|
||||
for callback in callbacks:
|
||||
try:
|
||||
processor = callback.get_processor()
|
||||
await processor.__call__(callback, observation)
|
||||
logger.info(
|
||||
'callback_invoked_successfully',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'callback_id': callback.id,
|
||||
'processor_type': callback.processor_type,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'callback_invocation_failed',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'callback_id': callback.id,
|
||||
'processor_type': callback.processor_type,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
# Mark callback as error status
|
||||
callback.status = CallbackStatus.ERROR
|
||||
callback.updated_at = datetime.now()
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
def update_conversation_metadata(conversation_id: str, content: dict):
|
||||
"""
|
||||
Update conversation metadata with new content.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to update
|
||||
content: The metadata content to update
|
||||
"""
|
||||
logger.debug(
|
||||
'update_conversation_metadata',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'content': content,
|
||||
},
|
||||
)
|
||||
with session_maker() as session:
|
||||
conversation = (
|
||||
session.query(StoredConversationMetadata)
|
||||
.filter(StoredConversationMetadata.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
conversation.title = content.get('title') or conversation.title
|
||||
conversation.last_updated_at = datetime.now()
|
||||
conversation.accumulated_cost = (
|
||||
content.get('accumulated_cost') or conversation.accumulated_cost
|
||||
)
|
||||
conversation.prompt_tokens = (
|
||||
content.get('prompt_tokens') or conversation.prompt_tokens
|
||||
)
|
||||
conversation.completion_tokens = (
|
||||
content.get('completion_tokens') or conversation.completion_tokens
|
||||
)
|
||||
conversation.total_tokens = (
|
||||
content.get('total_tokens') or conversation.total_tokens
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def register_callback_processor(
|
||||
conversation_id: str, processor: ConversationCallbackProcessor
|
||||
) -> int:
|
||||
"""
|
||||
Register a callback processor for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to register the callback for
|
||||
processor: The ConversationCallbackProcessor instance to register
|
||||
|
||||
Returns:
|
||||
int: The ID of the created callback
|
||||
"""
|
||||
with session_maker() as session:
|
||||
callback = ConversationCallback(
|
||||
conversation_id=conversation_id, status=CallbackStatus.ACTIVE
|
||||
)
|
||||
callback.set_processor(processor)
|
||||
session.add(callback)
|
||||
session.commit()
|
||||
return callback.id
|
||||
|
||||
|
||||
def update_active_working_seconds(
|
||||
event_store: EventStore, conversation_id: str, user_id: str, file_store: FileStore
|
||||
):
|
||||
"""
|
||||
Calculate and update the total active working seconds for a conversation.
|
||||
|
||||
This function reads all events for the conversation, looks for AgentStateChanged
|
||||
observations, and calculates the total time spent in a running state.
|
||||
|
||||
Args:
|
||||
event_store: The EventStore instance for reading events
|
||||
conversation_id: The conversation ID to process
|
||||
user_id: The user ID associated with the conversation
|
||||
file_store: The FileStore instance for accessing conversation data
|
||||
"""
|
||||
try:
|
||||
# Track agent state changes and calculate running time
|
||||
running_start_time = None
|
||||
total_running_seconds = 0.0
|
||||
|
||||
for event in event_store.search_events():
|
||||
if isinstance(event, AgentStateChangedObservation) and event.timestamp:
|
||||
event_timestamp = datetime.fromisoformat(event.timestamp).timestamp()
|
||||
|
||||
if event.agent_state == AgentState.RUNNING:
|
||||
# Agent started running
|
||||
if running_start_time is None:
|
||||
running_start_time = event_timestamp
|
||||
elif running_start_time is not None:
|
||||
# Agent stopped running, calculate duration
|
||||
duration = event_timestamp - running_start_time
|
||||
total_running_seconds += duration
|
||||
running_start_time = None
|
||||
|
||||
# If agent is still running at the end, don't count that time yet
|
||||
# (it will be counted when the agent stops)
|
||||
|
||||
# Create or update the conversation_work record
|
||||
with session_maker() as session:
|
||||
conversation_work = (
|
||||
session.query(ConversationWork)
|
||||
.filter(ConversationWork.conversation_id == conversation_id)
|
||||
.first()
|
||||
)
|
||||
|
||||
if conversation_work:
|
||||
# Update existing record
|
||||
conversation_work.seconds = total_running_seconds
|
||||
conversation_work.updated_at = datetime.now().isoformat()
|
||||
else:
|
||||
# Create new record
|
||||
conversation_work = ConversationWork(
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
seconds=total_running_seconds,
|
||||
)
|
||||
session.add(conversation_work)
|
||||
|
||||
session.commit()
|
||||
|
||||
logger.info(
|
||||
'updated_active_working_seconds',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'total_seconds': total_running_seconds,
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'failed_to_update_active_working_seconds',
|
||||
extra={
|
||||
'conversation_id': conversation_id,
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def update_agent_state(user_id: str, conversation_id: str, content: bytes):
|
||||
"""
|
||||
Update agent state file for a conversation.
|
||||
|
||||
Args:
|
||||
user_id: The user ID associated with the conversation
|
||||
conversation_id: The conversation ID
|
||||
content: The agent state content as bytes
|
||||
"""
|
||||
logger.debug(
|
||||
'update_agent_state',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'conversation_id': conversation_id,
|
||||
'content_size': len(content),
|
||||
},
|
||||
)
|
||||
write_path = get_conversation_agent_state_filename(conversation_id, user_id)
|
||||
file_store.write(write_path, content)
|
||||
|
||||
|
||||
def update_conversation_stats(user_id: str, conversation_id: str, content: bytes):
|
||||
existing_convo_stats = ConversationStats(
|
||||
file_store=file_store, conversation_id=conversation_id, user_id=user_id
|
||||
)
|
||||
|
||||
incoming_convo_stats = ConversationStats(None, conversation_id, None)
|
||||
pickled = base64.b64decode(content)
|
||||
incoming_convo_stats.restored_metrics = pickle.loads(pickled)
|
||||
|
||||
# Merging automatically saves to file store
|
||||
existing_convo_stats.merge_and_save(incoming_convo_stats)
|
||||
@@ -16,7 +16,7 @@ from server.verified_models.verified_model_service import (
|
||||
)
|
||||
|
||||
from openhands.app_server.config import get_db_session
|
||||
from openhands.app_server.config_api.config_router import get_llm_models_dependency
|
||||
from openhands.server.routes import public
|
||||
from openhands.utils.llm import ModelsResponse, get_supported_llm_models
|
||||
|
||||
api_router = APIRouter(prefix='/api/admin/verified-models', tags=['Verified Models'])
|
||||
@@ -138,4 +138,6 @@ async def get_saas_llm_models_dependency(request: Request) -> ModelsResponse:
|
||||
# This must be called after the app is created in saas_server.py
|
||||
def override_llm_models_dependency(app):
|
||||
"""Override the default LLM models implementation with SaaS version."""
|
||||
app.dependency_overrides[get_llm_models_dependency] = get_saas_llm_models_dependency
|
||||
app.dependency_overrides[public.get_llm_models_dependency] = (
|
||||
get_saas_llm_models_dependency
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ from storage.api_key import ApiKey
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.billing_session import BillingSession
|
||||
from storage.billing_session_type import BillingSessionType
|
||||
from storage.conversation_callback import CallbackStatus, ConversationCallback
|
||||
from storage.conversation_work import ConversationWork
|
||||
from storage.feedback import ConversationFeedback, Feedback
|
||||
from storage.github_app_installation import GithubAppInstallation
|
||||
@@ -44,6 +45,8 @@ __all__ = [
|
||||
'AuthTokens',
|
||||
'BillingSession',
|
||||
'BillingSessionType',
|
||||
'CallbackStatus',
|
||||
'ConversationCallback',
|
||||
'ConversationFeedback',
|
||||
'StoredConversationMetadataSaas',
|
||||
'ConversationWork',
|
||||
|
||||
@@ -10,8 +10,8 @@ from sqlalchemy.exc import OperationalError
|
||||
from storage.auth_tokens import AuthTokens
|
||||
from storage.database import a_session_maker
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
# Time buffer (in seconds) before actual expiration to consider token expired
|
||||
# This ensures tokens are refreshed before they actually expire. The
|
||||
|
||||
@@ -3,7 +3,7 @@ Unified SQLAlchemy declarative base for all models.
|
||||
|
||||
Re-exports the core Base to ensure enterprise and core models share the same
|
||||
metadata registry. This allows foreign key relationships between enterprise
|
||||
models and core models (e.g., StoredConversationMetadata).
|
||||
models (e.g., ConversationCallback) and core models (e.g., StoredConversationMetadata).
|
||||
|
||||
The core Base now uses SQLAlchemy 2.0 DeclarativeBase for proper type inference
|
||||
with Mapped types, while remaining backward compatible with existing Column()
|
||||
|
||||
115
enterprise/storage/conversation_callback.py
Normal file
115
enterprise/storage/conversation_callback.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from openhands.events.observation.agent import AgentStateChangedObservation
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, text
|
||||
from sqlalchemy import Enum as SQLEnum
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from storage.base import Base
|
||||
|
||||
from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class ConversationCallbackProcessor(BaseModel, ABC):
|
||||
"""
|
||||
Abstract base class for conversation callback processors.
|
||||
|
||||
Conversation processors are invoked when events occur in a conversation
|
||||
to perform additional processing, notifications, or integrations.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(
|
||||
# Allow extra fields for flexibility
|
||||
extra='allow',
|
||||
# Allow arbitrary types
|
||||
arbitrary_types_allowed=True,
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def __call__(
|
||||
self,
|
||||
callback: ConversationCallback,
|
||||
observation: 'AgentStateChangedObservation',
|
||||
) -> None:
|
||||
"""
|
||||
Process a conversation event.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to process
|
||||
observation: The AgentStateChangedObservation that triggered the callback
|
||||
callback: The conversation callback
|
||||
"""
|
||||
|
||||
|
||||
class CallbackStatus(Enum):
|
||||
"""Status of a conversation callback."""
|
||||
|
||||
ACTIVE = 'ACTIVE'
|
||||
COMPLETED = 'COMPLETED'
|
||||
ERROR = 'ERROR'
|
||||
|
||||
|
||||
class ConversationCallback(Base):
|
||||
"""
|
||||
Model for storing conversation callbacks that process conversation events.
|
||||
"""
|
||||
|
||||
__tablename__ = 'conversation_callbacks'
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True)
|
||||
conversation_id: Mapped[str] = mapped_column(
|
||||
String,
|
||||
ForeignKey('conversation_metadata.conversation_id'),
|
||||
nullable=False,
|
||||
index=True,
|
||||
)
|
||||
status: Mapped[CallbackStatus] = mapped_column(
|
||||
SQLEnum(CallbackStatus), nullable=False, default=CallbackStatus.ACTIVE
|
||||
)
|
||||
processor_type: Mapped[str] = mapped_column(String, nullable=False)
|
||||
processor_json: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
nullable=False,
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
server_default=text('CURRENT_TIMESTAMP'),
|
||||
onupdate=datetime.now,
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def get_processor(self) -> ConversationCallbackProcessor:
|
||||
"""
|
||||
Get the processor instance from the stored processor type and JSON data.
|
||||
|
||||
Returns:
|
||||
ConversationCallbackProcessor: The processor instance
|
||||
"""
|
||||
# Import the processor class dynamically
|
||||
processor_class: type[ConversationCallbackProcessor] = get_impl(
|
||||
ConversationCallbackProcessor, self.processor_type
|
||||
)
|
||||
processor = processor_class.model_validate_json(self.processor_json)
|
||||
return processor
|
||||
|
||||
def set_processor(self, processor: ConversationCallbackProcessor) -> None:
|
||||
"""
|
||||
Set the processor instance, storing its type and JSON representation.
|
||||
|
||||
Args:
|
||||
processor: The ConversationCallbackProcessor instance to store
|
||||
"""
|
||||
self.processor_type = (
|
||||
f'{processor.__class__.__module__}.{processor.__class__.__name__}'
|
||||
)
|
||||
self.processor_json = processor.model_dump_json()
|
||||
@@ -1,14 +1,10 @@
|
||||
import binascii
|
||||
import hashlib
|
||||
import json
|
||||
from base64 import b64decode, b64encode
|
||||
from typing import Any
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
from pydantic import BaseModel, SecretStr
|
||||
from pydantic import SecretStr
|
||||
from server.config import get_config
|
||||
from sqlalchemy import String, TypeDecorator
|
||||
from sqlalchemy.engine.interfaces import Dialect
|
||||
|
||||
_jwt_service = None
|
||||
_fernet = None
|
||||
@@ -139,39 +135,3 @@ def model_to_kwargs(model_instance):
|
||||
column.name: getattr(model_instance, column.name)
|
||||
for column in model_instance.__table__.columns
|
||||
}
|
||||
|
||||
|
||||
class EncryptedJSON(TypeDecorator[dict[str, Any]]):
|
||||
"""JSON column whose serialized payload is encrypted at rest.
|
||||
|
||||
Accepts either a plain ``dict`` or a pydantic ``BaseModel``. Pydantic
|
||||
models are dumped via ``model_dump(mode='json', context={'expose_secrets': True})``
|
||||
so nested ``SecretStr`` values keep their real payload — the column
|
||||
itself is the encryption boundary, so masking on the way in would
|
||||
corrupt round-trips.
|
||||
|
||||
Use for JSON payloads that may contain secrets (e.g. nested ``api_key``
|
||||
fields) where the existing ``_<field>`` String + property pattern is
|
||||
awkward — this keeps the column accessible as a normal ORM attribute
|
||||
while encrypting the entire JSON blob via the same JWE service used
|
||||
by ``encrypt_value``/``decrypt_value``.
|
||||
"""
|
||||
|
||||
impl = String
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(
|
||||
self, value: BaseModel | dict[str, Any] | None, dialect: Dialect
|
||||
) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, BaseModel):
|
||||
value = value.model_dump(mode='json', context={'expose_secrets': True})
|
||||
return encrypt_value(json.dumps(value))
|
||||
|
||||
def process_result_value(
|
||||
self, value: str | None, dialect: Dialect
|
||||
) -> dict[str, Any] | None:
|
||||
if value is None:
|
||||
return None
|
||||
return json.loads(decrypt_value(value))
|
||||
|
||||
@@ -19,7 +19,7 @@ from server.constants import (
|
||||
from server.logger import logger
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.utils.http_session import httpx_verify_option
|
||||
|
||||
# Timeout in seconds for key verification requests to LiteLLM
|
||||
|
||||
@@ -6,8 +6,8 @@ from sqlalchemy import and_, desc, select
|
||||
from storage.database import a_session_maker
|
||||
from storage.openhands_pr import OpenhandsPR
|
||||
|
||||
from openhands.app_server.integrations.service_types import ProviderType
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import ProviderType
|
||||
|
||||
|
||||
class OpenhandsPRStore:
|
||||
|
||||
@@ -62,6 +62,9 @@ class Org(Base):
|
||||
# encrypted column, don't set directly, set without the underscore
|
||||
_sandbox_api_key: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
max_budget_per_task: Mapped[float | None] = mapped_column(nullable=True)
|
||||
enable_solvability_analysis: Mapped[bool | None] = mapped_column(
|
||||
nullable=True, default=False
|
||||
)
|
||||
v1_enabled: Mapped[bool | None] = mapped_column(nullable=True)
|
||||
conversation_expiration: Mapped[int | None] = mapped_column(nullable=True)
|
||||
byor_export_enabled: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
|
||||
@@ -14,7 +14,7 @@ from storage.org_member import OrgMember
|
||||
from storage.user import User
|
||||
from storage.user_settings import UserSettings
|
||||
|
||||
from openhands.app_server.settings.settings_models import Settings
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.utils.jsonpatch_compat import deep_merge
|
||||
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user