mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4fd2402c2 | ||
|
|
d1f8877600 | ||
|
|
08118d742b | ||
|
|
c62a6616db | ||
|
|
fbf0429434 | ||
|
|
0e2edb63f5 | ||
|
|
50b38e9081 | ||
|
|
e9c3335656 | ||
|
|
3bf038ed7c | ||
|
|
408f8aa50f | ||
|
|
025ac7672f | ||
|
|
aab6f4127c | ||
|
|
c932cd0815 | ||
|
|
d3395172f8 | ||
|
|
1e8851b244 | ||
|
|
167fb3f429 | ||
|
|
df4d30addf | ||
|
|
37daf068c5 | ||
|
|
5452abe513 | ||
|
|
a8b6406dac | ||
|
|
509d4a9513 | ||
|
|
d099c21f5d |
2
.github/scripts/update_pr_description.sh
vendored
2
.github/scripts/update_pr_description.sh
vendored
@@ -18,7 +18,7 @@ DOCKER_RUN_COMMAND="docker run -it --rm \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:${SHORT_SHA}"
|
||||
|
||||
# Define the uvx command
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@${BRANCH_NAME} openhands"
|
||||
UVX_RUN_COMMAND="uvx --python 3.12 --from git+https://github.com/All-Hands-AI/OpenHands@${BRANCH_NAME}#subdirectory=openhands-cli openhands"
|
||||
|
||||
# Get the current PR body
|
||||
PR_BODY=$(gh pr view "$PR_NUMBER" --json body --jq .body)
|
||||
|
||||
58
.github/workflows/cli-build-test.yml
vendored
Normal file
58
.github/workflows/cli-build-test.yml
vendored
Normal file
@@ -0,0 +1,58 @@
|
||||
# Workflow that builds and tests the CLI binary executable
|
||||
name: CLI - Build and Test Binary
|
||||
|
||||
# Run on pushes to main branch and all pull requests, but only when CLI files change
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "openhands-cli/**"
|
||||
|
||||
# Cancel previous runs if a new commit is pushed
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ (github.head_ref && github.ref) || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-and-test-binary:
|
||||
name: Build and test binary executable
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.12
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
uv sync
|
||||
|
||||
- name: Build binary executable
|
||||
working-directory: openhands-cli
|
||||
run: |
|
||||
./build.sh --install-pyinstaller | tee output.log
|
||||
echo "Full output:"
|
||||
cat output.log
|
||||
|
||||
if grep -q "❌" output.log; then
|
||||
echo "❌ Found failure marker in output"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
@@ -73,6 +73,24 @@ jobs:
|
||||
working-directory: ./enterprise
|
||||
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
lint-cli-python:
|
||||
name: Lint CLI python
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2204
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: 3.12
|
||||
cache: "pip"
|
||||
- name: Install pre-commit
|
||||
run: pip install pre-commit==4.2.0
|
||||
- name: Run pre-commit hooks
|
||||
working-directory: ./openhands-cli
|
||||
run: pre-commit run --all-files --config ./dev_config/python/.pre-commit-config.yaml
|
||||
|
||||
# Check version consistency across documentation
|
||||
check-version-consistency:
|
||||
name: Check version consistency
|
||||
|
||||
52
.github/workflows/py-tests.yml
vendored
52
.github/workflows/py-tests.yml
vendored
@@ -127,11 +127,58 @@ jobs:
|
||||
name: coverage-enterprise
|
||||
path: ".coverage.enterprise.${{ matrix.python_version }}"
|
||||
include-hidden-files: true
|
||||
|
||||
# Run CLI unit tests
|
||||
test-cli-python:
|
||||
name: CLI Unit Tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: ["3.12"]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: useblacksmith/setup-python@v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v3
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./openhands-cli
|
||||
run: |
|
||||
uv sync --group dev
|
||||
|
||||
- name: Run CLI unit tests
|
||||
working-directory: ./openhands-cli
|
||||
env:
|
||||
# write coverage to repo root so the merge step finds it
|
||||
COVERAGE_FILE: "${{ github.workspace }}/.coverage.openhands-cli.${{ matrix.python-version }}"
|
||||
run: |
|
||||
uv run pytest --forked -n auto -s \
|
||||
-p no:ddtrace -p no:ddtrace.pytest_bdd -p no:ddtrace.pytest_benchmark \
|
||||
tests --cov=openhands_cli --cov-branch
|
||||
|
||||
- name: Store coverage file
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: coverage-openhands-cli
|
||||
path: ".coverage.openhands-cli.${{ matrix.python-version }}"
|
||||
include-hidden-files: true
|
||||
|
||||
|
||||
coverage-comment:
|
||||
name: Coverage Comment
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [test-on-linux, test-enterprise]
|
||||
needs: [test-on-linux, test-enterprise, test-cli-python]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
@@ -145,6 +192,9 @@ jobs:
|
||||
pattern: coverage-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create symlink for CLI source files
|
||||
run: ln -sf openhands-cli/openhands_cli openhands_cli
|
||||
|
||||
- name: Coverage comment
|
||||
id: coverage_comment
|
||||
uses: py-cov-action/python-coverage-comment-action@v3
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -19,4 +19,4 @@ jobs:
|
||||
close-issue-message: 'This issue was automatically closed due to 50 days of inactivity. We do this to help keep the issues somewhat manageable and focus on active issues.'
|
||||
close-pr-message: 'This PR was closed because it had no activity for 50 days. If you feel this was closed in error, and you would like to continue the PR, please resubmit or let us know.'
|
||||
days-before-close: 10
|
||||
operations-per-run: 150
|
||||
operations-per-run: 300
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
"This issue has been labeled as **good first issue**, which means it's a great place to get started with the OpenHands project.\n\n" +
|
||||
"If you're interested in working on it, feel free to! No need to ask for permission.\n\n" +
|
||||
"Be sure to check out our [development setup guide](" + repoUrl + "/blob/main/Development.md) to get your environment set up, and follow our [contribution guidelines](" + repoUrl + "/blob/main/CONTRIBUTING.md) when you're ready to submit a fix.\n\n" +
|
||||
"Feel free to join our developer community on [Slack](dub.sh/openhands). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"Feel free to join our developer community on [Slack](https://all-hands.dev/joinslack). You can ask for [help](https://openhands-ai.slack.com/archives/C078L0FUGUX), [feedback](https://openhands-ai.slack.com/archives/C086ARSNMGA), and even ask for a [PR review](https://openhands-ai.slack.com/archives/C08D8FJ5771).\n\n" +
|
||||
"🙌 Happy hacking! 🙌\n\n" +
|
||||
"<!-- auto-comment:good-first-issue -->"
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ poetry run pytest ./tests/unit/test_*.py
|
||||
To reduce build time (e.g., if no changes were made to the client-runtime component), you can use an existing Docker
|
||||
container image by setting the SANDBOX_RUNTIME_CONTAINER_IMAGE environment variable to the desired Docker image.
|
||||
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.57-nikolaik`
|
||||
Example: `export SANDBOX_RUNTIME_CONTAINER_IMAGE=ghcr.io/all-hands-ai/runtime:0.58-nikolaik`
|
||||
|
||||
## Develop inside Docker container
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -11,7 +11,7 @@
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/stargazers"><img src="https://img.shields.io/github/stars/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="Stargazers"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/LICENSE"><img src="https://img.shields.io/github/license/All-Hands-AI/OpenHands?style=for-the-badge&color=blue" alt="MIT License"></a>
|
||||
<br/>
|
||||
<a href="https://dub.sh/openhands"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://all-hands.dev/joinslack"><img src="https://img.shields.io/badge/Slack-Join%20Us-red?logo=slack&logoColor=white&style=for-the-badge" alt="Join our Slack community"></a>
|
||||
<a href="https://github.com/All-Hands-AI/OpenHands/blob/main/CREDITS.md"><img src="https://img.shields.io/badge/Project-Credits-blue?style=for-the-badge&color=FFE165&logo=github&logoColor=white" alt="Credits"></a>
|
||||
<br/>
|
||||
<a href="https://docs.all-hands.dev/usage/getting-started"><img src="https://img.shields.io/badge/Documentation-000?logo=googledocs&logoColor=FFE165&style=for-the-badge" alt="Check out the documentation"></a>
|
||||
@@ -76,17 +76,17 @@ You'll find OpenHands running at [http://localhost:3000](http://localhost:3000)
|
||||
You can also run OpenHands directly with Docker:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
```
|
||||
|
||||
</details>
|
||||
@@ -139,7 +139,7 @@ troubleshooting resources, and advanced configuration options.
|
||||
OpenHands is a community-driven project, and we welcome contributions from everyone. We do most of our communication
|
||||
through Slack, so this is the best place to start, but we also are happy to have you contact us on Github:
|
||||
|
||||
- [Join our Slack workspace](https://dub.sh/openhands) - Here we talk about research, architecture, and future development.
|
||||
- [Join our Slack workspace](https://all-hands.dev/joinslack) - Here we talk about research, architecture, and future development.
|
||||
- [Read or post Github Issues](https://github.com/All-Hands-AI/OpenHands/issues) - Check out the issues we're working on, or add your own ideas.
|
||||
|
||||
See more about the community in [COMMUNITY.md](./COMMUNITY.md) or find details on contributing in [CONTRIBUTING.md](./CONTRIBUTING.md).
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
- SANDBOX_API_HOSTNAME=host.docker.internal
|
||||
- DOCKER_HOST_ADDR=host.docker.internal
|
||||
#
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.57-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-ghcr.io/all-hands-ai/runtime:0.58-nikolaik}
|
||||
- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234}
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -3,9 +3,9 @@ repos:
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/)
|
||||
exclude: ^(docs/|modules/|python/|openhands-ui/|third_party/|enterprise/|openhands-cli/)
|
||||
- id: check-yaml
|
||||
args: ["--allow-multiple-documents"]
|
||||
- id: debug-statements
|
||||
@@ -28,12 +28,12 @@ repos:
|
||||
entry: ruff check --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(third_party/|enterprise/|openhands-cli/)
|
||||
# Run the formatter.
|
||||
- id: ruff-format
|
||||
entry: ruff format --config dev_config/python/ruff.toml
|
||||
types_or: [python, pyi, jupyter]
|
||||
exclude: ^(third_party/|enterprise/)
|
||||
exclude: ^(third_party/|enterprise/|openhands-cli/)
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.15.0
|
||||
|
||||
@@ -7,7 +7,7 @@ services:
|
||||
image: openhands:latest
|
||||
container_name: openhands-app-${DATE:-}
|
||||
environment:
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik}
|
||||
- SANDBOX_RUNTIME_CONTAINER_IMAGE=${SANDBOX_RUNTIME_CONTAINER_IMAGE:-docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik}
|
||||
#- SANDBOX_USER_ID=${SANDBOX_USER_ID:-1234} # enable this only if you want a specific non-root sandbox user but you will have to manually adjust permissions of ~/.openhands for this user
|
||||
- WORKSPACE_MOUNT_PATH=${WORKSPACE_BASE:-$PWD/workspace}
|
||||
ports:
|
||||
|
||||
@@ -3,18 +3,16 @@
|
||||
"theme": "mint",
|
||||
"name": "All Hands Docs",
|
||||
"colors": {
|
||||
"primary": "#99873c",
|
||||
"light": "#ffe165",
|
||||
"dark": "#ffe165"
|
||||
"primary": "#99873c"
|
||||
},
|
||||
"background": {
|
||||
"color": {
|
||||
"light": "#f7f3ee",
|
||||
"dark": "#0B0D0E"
|
||||
"light": "#f7f3ee"
|
||||
}
|
||||
},
|
||||
"appearance": {
|
||||
"default": "light"
|
||||
"default": "light",
|
||||
"strict": true
|
||||
},
|
||||
"favicon": "/logo-square.png",
|
||||
"navigation": {
|
||||
@@ -214,9 +212,8 @@
|
||||
},
|
||||
"footer": {
|
||||
"socials": {
|
||||
"slack": "https://dub.sh/openhands",
|
||||
"github": "https://github.com/All-Hands-AI/OpenHands",
|
||||
"discord": "https://discord.gg/ESHStjSjD4"
|
||||
"slack": "https://all-hands.dev/joinslack",
|
||||
"github": "https://github.com/All-Hands-AI/OpenHands"
|
||||
}
|
||||
},
|
||||
"contextual": {
|
||||
|
||||
@@ -4,7 +4,8 @@ description: OpenHands - Code Less, Make More
|
||||
icon: book-open
|
||||
mode: wide
|
||||
---
|
||||
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer: they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.
|
||||
Use AI to tackle the toil in your backlog. Our agents have all the same tools as a human developer:
|
||||
they can modify code, run commands, browse the web, call APIs, and yes-even copy code snippets from StackOverflow.
|
||||
|
||||
<iframe
|
||||
className="w-full aspect-video"
|
||||
|
||||
14
docs/reo-init.js
Normal file
14
docs/reo-init.js
Normal file
@@ -0,0 +1,14 @@
|
||||
// Reo.dev tracking initialization
|
||||
(function() {
|
||||
var e, t, n;
|
||||
e = "6bac7145b4ee6ec";
|
||||
t = function() {
|
||||
Reo.init({clientID: "6bac7145b4ee6ec"});
|
||||
};
|
||||
n = document.createElement("script");
|
||||
n.src = "https://static.reo.dev/" + e + "/reo.js";
|
||||
n.defer = true;
|
||||
n.onload = t;
|
||||
document.head.appendChild(n);
|
||||
})();
|
||||
|
||||
@@ -17,96 +17,87 @@ To use the OpenHands Cloud API, you'll need to generate an API key:
|
||||
4. Give your key a descriptive name (Example: "Development" or "Production") and select `Create`.
|
||||
5. Copy the generated API key and store it securely. It will only be shown once.
|
||||
|
||||
## API Usage
|
||||
## API Usage Example
|
||||
|
||||
### Starting a New Conversation
|
||||
|
||||
To start a new conversation with OpenHands to perform a task, you'll need to make a POST request to the conversation endpoint.
|
||||
To start a new conversation with OpenHands to perform a task,
|
||||
[you'll need to make a POST request to the conversation endpoint](/api-reference/new-conversation).
|
||||
|
||||
#### Request Parameters
|
||||
<Tabs>
|
||||
<Tab title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://app.all-hands.dev/api/conversations" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}'
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Python (with requests)">
|
||||
```python
|
||||
import requests
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
|--------------------|----------|----------|------------------------------------------------------------------------------------------------------|
|
||||
| `initial_user_msg` | string | Yes | The initial message to start the conversation. |
|
||||
| `repository` | string | No | Git repository name to provide context in the format `owner/repo`. You must have access to the repo. |
|
||||
api_key = "YOUR_API_KEY"
|
||||
url = "https://app.all-hands.dev/api/conversations"
|
||||
|
||||
#### Examples
|
||||
|
||||
|
||||
<Accordion title="cURL">
|
||||
```bash
|
||||
curl -X POST "https://app.all-hands.dev/api/conversations" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}'
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Python (with requests)">
|
||||
```python
|
||||
import requests
|
||||
|
||||
api_key = "YOUR_API_KEY"
|
||||
url = "https://app.all-hands.dev/api/conversations"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
data = {
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
conversation = response.json()
|
||||
|
||||
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
|
||||
print(f"Status: {conversation['status']}")
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="TypeScript/JavaScript (with fetch)">
|
||||
```typescript
|
||||
const apiKey = "YOUR_API_KEY";
|
||||
const url = "https://app.all-hands.dev/api/conversations";
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
const data = {
|
||||
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
repository: "yourusername/your-repo"
|
||||
};
|
||||
|
||||
async function startConversation() {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const conversation = await response.json();
|
||||
|
||||
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
|
||||
console.log(`Status: ${conversation.status}`);
|
||||
|
||||
return conversation;
|
||||
} catch (error) {
|
||||
console.error("Error starting conversation:", error);
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
}
|
||||
|
||||
startConversation();
|
||||
```
|
||||
</Accordion>
|
||||
data = {
|
||||
"initial_user_msg": "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
"repository": "yourusername/your-repo"
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=data)
|
||||
conversation = response.json()
|
||||
|
||||
print(f"Conversation Link: https://app.all-hands.dev/conversations/{conversation['conversation_id']}")
|
||||
print(f"Status: {conversation['status']}")
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="TypeScript/JavaScript (with fetch)">
|
||||
```typescript
|
||||
const apiKey = "YOUR_API_KEY";
|
||||
const url = "https://app.all-hands.dev/api/conversations";
|
||||
|
||||
const headers = {
|
||||
"Authorization": `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json"
|
||||
};
|
||||
|
||||
const data = {
|
||||
initial_user_msg: "Check whether there is any incorrect information in the README.md file and send a PR to fix it if so.",
|
||||
repository: "yourusername/your-repo"
|
||||
};
|
||||
|
||||
async function startConversation() {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: headers,
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const conversation = await response.json();
|
||||
|
||||
console.log(`Conversation Link: https://app.all-hands.dev/conversations/${conversation.id}`);
|
||||
console.log(`Status: ${conversation.status}`);
|
||||
|
||||
return conversation;
|
||||
} catch (error) {
|
||||
console.error("Error starting conversation:", error);
|
||||
}
|
||||
}
|
||||
|
||||
startConversation();
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
#### Response
|
||||
|
||||
@@ -125,42 +116,6 @@ You may receive an `AuthenticationError` if:
|
||||
- You provided the wrong repository name.
|
||||
- You don't have access to the repository.
|
||||
|
||||
|
||||
### Retrieving Conversation Status
|
||||
|
||||
You can check the status of a conversation by making a GET request to the conversation endpoint.
|
||||
|
||||
#### Endpoint
|
||||
|
||||
```
|
||||
GET https://app.all-hands.dev/api/conversations/{conversation_id}
|
||||
```
|
||||
|
||||
#### Example
|
||||
|
||||
<Accordion title="cURL">
|
||||
```bash
|
||||
curl -X GET "https://app.all-hands.dev/api/conversations/{conversation_id}" \
|
||||
-H "Authorization: Bearer YOUR_API_KEY"
|
||||
```
|
||||
</Accordion>
|
||||
|
||||
#### Response
|
||||
|
||||
The response is formatted as follows:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id":"abc1234",
|
||||
"title":"Update README.md",
|
||||
"created_at":"2025-04-29T15:13:51.370706Z",
|
||||
"last_updated_at":"2025-04-29T15:13:57.199210Z",
|
||||
"status":"RUNNING",
|
||||
"selected_repository":"yourusername/your-repo",
|
||||
"trigger":"gui"
|
||||
}
|
||||
```
|
||||
|
||||
## Rate Limits
|
||||
|
||||
If you have too many conversations running at once, older conversations will be paused to limit the number of concurrent conversations.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
title: "Pro Subscription"
|
||||
description: "Learn about OpenHands Cloud Pro Subscription features and pricing"
|
||||
description: "Learn about OpenHands Cloud Pro Subscription features and pricing."
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
The OpenHands Pro Subscription unlocks additional features and better pricing when you run OpenHands conversations in
|
||||
OpenHands Cloud.
|
||||
|
||||
@@ -11,13 +13,13 @@ OpenHands Cloud.
|
||||
All users start on the Pay-as-you-go plan and have access to these base features when they sign up:
|
||||
|
||||
* **Run multiple OpenHands conversations on OpenHands Cloud runtimes.**
|
||||
* **API keys to the OpenHands LLM provider for use in OpenHands CLI or when running OpenHands on your own**
|
||||
* **API keys to the OpenHands LLM provider for use in OpenHands CLI or when running OpenHands on your own.**
|
||||
* **$20 in initial OpenHands Cloud credits to get started.**
|
||||
* **Support for GitHub, GitLab, Bitbucket, Slack, and more.**
|
||||
|
||||
## What you get with a Pro Subscription
|
||||
|
||||
The $20/month Pro Subscription covers the cost of runtime compute in OpenHands Cloud, plus enables the following
|
||||
The $20/month Pro subscription covers the cost of runtime compute in OpenHands Cloud, plus enables the following
|
||||
features:
|
||||
|
||||
* **Bring Your Own LLM Keys:** Bring your own API keys from OpenAI, Anthropic, Mistral, and other providers.
|
||||
|
||||
@@ -90,7 +90,6 @@ If you would like to set things up more systematically, you can:
|
||||
others have encountered the same problem.
|
||||
2. **Join our community**: Get help from other users and developers:
|
||||
- [Slack community](https://dub.sh/openhands)
|
||||
- [Discord server](https://discord.gg/ESHStjSjD4)
|
||||
3. **Check our troubleshooting guide**: Common issues and solutions are documented in
|
||||
[Troubleshooting](/usage/troubleshooting/troubleshooting).
|
||||
4. **Report bugs**: If you've found a bug, please [create an issue](https://github.com/All-Hands-AI/OpenHands/issues/new)
|
||||
|
||||
@@ -113,7 +113,7 @@ The conversation history will be saved in `~/.openhands/sessions`.
|
||||
```bash
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -122,7 +122,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
|
||||
python -m openhands.cli.entry --override-cli-mode true
|
||||
```
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export GITHUB_TOKEN="your-token" # Required for repository operations
|
||||
# Run OpenHands
|
||||
docker run -it \
|
||||
--pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e SANDBOX_USER_ID=$(id -u) \
|
||||
-e SANDBOX_VOLUMES=$SANDBOX_VOLUMES \
|
||||
-e LLM_API_KEY=$LLM_API_KEY \
|
||||
@@ -73,7 +73,7 @@ docker run -it \
|
||||
-v ~/.openhands:/.openhands \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app-$(date +%Y%m%d%H%M%S) \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57 \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58 \
|
||||
python -m openhands.core.main -t "write a bash script that prints hi"
|
||||
```
|
||||
|
||||
|
||||
@@ -4,16 +4,17 @@ description: Running OpenHands Cloud or running on your own.
|
||||
icon: rocket
|
||||
---
|
||||
|
||||
## OpenHands Cloud
|
||||
<Tabs>
|
||||
<Tab title="OpenHands Cloud">
|
||||
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $20 in free credits for new users.
|
||||
|
||||
The easiest way to get started with OpenHands is on OpenHands Cloud, which comes with $20 in free credits for new users.
|
||||
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
|
||||
To get started with OpenHands Cloud, visit [app.all-hands.dev](https://app.all-hands.dev).
|
||||
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
|
||||
</Tab>
|
||||
<Tab title="Running OpenHands on Your Own">
|
||||
Run OpenHands on your local system and bring your own LLM and API key.
|
||||
|
||||
For more information see [getting started with OpenHands Cloud.](/usage/cloud/openhands-cloud)
|
||||
|
||||
## Running OpenHands on Your Own
|
||||
|
||||
Run OpenHands on your local system and bring your own LLM and API key.
|
||||
|
||||
For more information see [running OpenHands on your own.](/usage/local-setup)
|
||||
For more information see [running OpenHands on your own.](/usage/local-setup)
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
@@ -68,23 +68,23 @@ Download and install the LM Studio desktop app from [lmstudio.ai](https://lmstud
|
||||
1. Check [the installation guide](/usage/local-setup) and ensure all prerequisites are met before running OpenHands, then run:
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
```
|
||||
|
||||
2. Wait until the server is running (see log below):
|
||||
```
|
||||
Digest: sha256:e72f9baecb458aedb9afc2cd5bc935118d1868719e55d50da73190d3a85c674f
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
Status: Image is up to date for docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
Starting OpenHands...
|
||||
Running OpenHands as root
|
||||
14:22:13 - openhands:INFO: server_config.py:50 - Using config class None
|
||||
@@ -119,7 +119,7 @@ When started for the first time, OpenHands will prompt you to set up the LLM pro
|
||||
|
||||
That's it! You can now start using OpenHands with the local LLM server.
|
||||
|
||||
If you encounter any issues, let us know on [Slack](https://dub.sh/openhands) or [Discord](https://discord.gg/ESHStjSjD4).
|
||||
If you encounter any issues, let us know on [Slack](https://dub.sh/openhands).
|
||||
|
||||
## Advanced: Alternative LLM Backends
|
||||
|
||||
|
||||
@@ -116,17 +116,17 @@ Note that you'll still need `uv` installed for the default MCP servers to work p
|
||||
<Accordion title="Docker Command (Click to expand)">
|
||||
|
||||
```bash
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik
|
||||
docker pull docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik
|
||||
|
||||
docker run -it --rm --pull=always \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.57-nikolaik \
|
||||
-e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.58-nikolaik \
|
||||
-e LOG_ALL_EVENTS=true \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-v ~/.openhands:/.openhands \
|
||||
-p 3000:3000 \
|
||||
--add-host host.docker.internal:host-gateway \
|
||||
--name openhands-app \
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.57
|
||||
docker.all-hands.dev/all-hands-ai/openhands:0.58
|
||||
```
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -41,71 +41,71 @@ MCP configuration can be defined in:
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### SSE Servers
|
||||
<Tabs>
|
||||
<Tab title="SSE Servers">
|
||||
SSE servers are configured using either a string URL or an object with the following properties:
|
||||
|
||||
SSE servers are configured using either a string URL or an object with the following properties:
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SSE server.
|
||||
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SSE server.
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
</Tab>
|
||||
<Tab title="SHTTP Servers">
|
||||
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
|
||||
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SHTTP server.
|
||||
|
||||
#### SHTTP Servers
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
|
||||
SHTTP (Streamable HTTP) servers are configured using either a string URL or an object with the following properties:
|
||||
- `timeout` (optional)
|
||||
- Type: `int`
|
||||
- Default: `60`
|
||||
- Range: `1-3600` seconds (1 hour maximum)
|
||||
- Description: Timeout in seconds for tool execution. This prevents tool calls from hanging indefinitely.
|
||||
- **Use Cases:**
|
||||
- **Short timeout (1-30s)**: For lightweight operations like status checks or simple queries.
|
||||
- **Medium timeout (30-300s)**: For standard processing tasks like data analysis or API calls.
|
||||
- **Long timeout (300-3600s)**: For heavy operations like file processing, complex calculations, or batch operations.
|
||||
<Note>
|
||||
This timeout only applies to individual tool calls, not server connection establishment.
|
||||
</Note>
|
||||
</Tab>
|
||||
<Tab title="Stdio Servers">
|
||||
<Note>
|
||||
While stdio servers are supported, [we recommend using MCP proxies](/usage/settings/mcp-settings#configuration-examples) for
|
||||
better reliability and performance.
|
||||
</Note>
|
||||
|
||||
- `url` (required)
|
||||
- Type: `str`
|
||||
- Description: The URL of the SHTTP server.
|
||||
Stdio servers are configured using an object with the following properties:
|
||||
|
||||
- `api_key` (optional)
|
||||
- Type: `str`
|
||||
- Description: API key for authentication.
|
||||
- `name` (required)
|
||||
- Type: `str`
|
||||
- Description: A unique name for the server.
|
||||
|
||||
- `timeout` (optional)
|
||||
- Type: `int`
|
||||
- Default: `60`
|
||||
- Range: `1-3600` seconds (1 hour maximum)
|
||||
- Description: Timeout in seconds for tool execution. This prevents tool calls from hanging indefinitely.
|
||||
- **Use Cases:**
|
||||
- **Short timeout (1-30s)**: For lightweight operations like status checks or simple queries.
|
||||
- **Medium timeout (30-300s)**: For standard processing tasks like data analysis or API calls.
|
||||
- **Long timeout (300-3600s)**: For heavy operations like file processing, complex calculations, or batch operations.
|
||||
<Note>
|
||||
This timeout only applies to individual tool calls, not server connection establishment.
|
||||
</Note>
|
||||
- `command` (required)
|
||||
- Type: `str`
|
||||
- Description: The command to run the server.
|
||||
|
||||
#### Stdio Servers
|
||||
- `args` (optional)
|
||||
- Type: `list of str`
|
||||
- Default: `[]`
|
||||
- Description: Command-line arguments to pass to the server.
|
||||
|
||||
<Note>
|
||||
While stdio servers are supported, [we recommend using MCP proxies](/usage/settings/mcp-settings#configuration-examples) for
|
||||
better reliability and performance.
|
||||
</Note>
|
||||
- `env` (optional)
|
||||
- Type: `dict of str to str`
|
||||
- Default: `{}`
|
||||
- Description: Environment variables to set for the server process.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Stdio servers are configured using an object with the following properties:
|
||||
|
||||
- `name` (required)
|
||||
- Type: `str`
|
||||
- Description: A unique name for the server.
|
||||
|
||||
- `command` (required)
|
||||
- Type: `str`
|
||||
- Description: The command to run the server.
|
||||
|
||||
- `args` (optional)
|
||||
- Type: `list of str`
|
||||
- Default: `[]`
|
||||
- Description: Command-line arguments to pass to the server.
|
||||
|
||||
- `env` (optional)
|
||||
- Type: `dict of str to str`
|
||||
- Default: `{}`
|
||||
- Description: Environment variables to set for the server process.
|
||||
|
||||
##### When to Use Direct Stdio
|
||||
#### When to Use Direct Stdio
|
||||
|
||||
Direct stdio connections may still be appropriate in these scenarios:
|
||||
- **Development and testing**: Quick prototyping of MCP servers.
|
||||
@@ -114,76 +114,78 @@ Direct stdio connections may still be appropriate in these scenarios:
|
||||
|
||||
### Configuration Examples
|
||||
|
||||
#### Recommended: Using Proxy Servers (SSE/HTTP)
|
||||
<Tabs>
|
||||
<Tab title="Proxy Servers (SSE/HTTP) - Recommended">
|
||||
For stdio-based MCP servers, we recommend using MCP proxy tools like
|
||||
[`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
|
||||
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to
|
||||
HTTP/SSE endpoints.
|
||||
|
||||
For stdio-based MCP servers, we recommend using MCP proxy tools like
|
||||
[`supergateway`](https://github.com/supercorp-ai/supergateway) instead of direct stdio connections.
|
||||
[SuperGateway](https://github.com/supercorp-ai/supergateway) is a popular MCP proxy that converts stdio MCP servers to
|
||||
HTTP/SSE endpoints.
|
||||
Start the proxy servers separately:
|
||||
```bash
|
||||
# Terminal 1: Filesystem server proxy
|
||||
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
|
||||
|
||||
Start the proxy servers separately:
|
||||
```bash
|
||||
# Terminal 1: Filesystem server proxy
|
||||
supergateway --stdio "npx @modelcontextprotocol/server-filesystem /" --port 8080
|
||||
# Terminal 2: Fetch server proxy
|
||||
supergateway --stdio "uvx mcp-server-fetch" --port 8081
|
||||
```
|
||||
|
||||
# Terminal 2: Fetch server proxy
|
||||
supergateway --stdio "uvx mcp-server-fetch" --port 8081
|
||||
```
|
||||
Then configure OpenHands to use the HTTP endpoint:
|
||||
|
||||
Then configure OpenHands to use the HTTP endpoint:
|
||||
```toml
|
||||
[mcp]
|
||||
# SSE Servers - Recommended approach using proxy tools
|
||||
sse_servers = [
|
||||
# Basic SSE server with just a URL
|
||||
"http://example.com:8080/mcp",
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
# SSE Servers - Recommended approach using proxy tools
|
||||
sse_servers = [
|
||||
# Basic SSE server with just a URL
|
||||
"http://example.com:8080/mcp",
|
||||
# SuperGateway proxy for fetch server
|
||||
"http://localhost:8081/sse",
|
||||
|
||||
# SuperGateway proxy for fetch server
|
||||
"http://localhost:8081/sse",
|
||||
# External MCP service with authentication
|
||||
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
|
||||
]
|
||||
|
||||
# External MCP service with authentication
|
||||
{url="https://api.example.com/mcp/sse", api_key="your-api-key"}
|
||||
]
|
||||
# SHTTP Servers - Modern streamable HTTP transport (recommended)
|
||||
shttp_servers = [
|
||||
# Basic SHTTP server with default 60s timeout
|
||||
"https://api.example.com/mcp/shttp",
|
||||
|
||||
# SHTTP Servers - Modern streamable HTTP transport (recommended)
|
||||
shttp_servers = [
|
||||
# Basic SHTTP server with default 60s timeout
|
||||
"https://api.example.com/mcp/shttp",
|
||||
|
||||
# Server with custom timeout for heavy operations
|
||||
{
|
||||
url = "https://files.example.com/mcp/shttp",
|
||||
api_key = "your-api-key",
|
||||
timeout = 1800 # 30 minutes for large file processing
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
#### Alternative: Direct Stdio Servers (Not Recommended for Production)
|
||||
|
||||
```toml
|
||||
[mcp]
|
||||
# Direct stdio servers - use only for development/testing
|
||||
stdio_servers = [
|
||||
# Basic stdio server
|
||||
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
|
||||
|
||||
# Stdio server with environment variables
|
||||
{
|
||||
name="filesystem",
|
||||
command="npx",
|
||||
args=["@modelcontextprotocol/server-filesystem", "/"],
|
||||
env={
|
||||
"DEBUG": "true"
|
||||
# Server with custom timeout for heavy operations
|
||||
{
|
||||
url = "https://files.example.com/mcp/shttp",
|
||||
api_key = "your-api-key",
|
||||
timeout = 1800 # 30 minutes for large file processing
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
]
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Direct Stdio Servers">
|
||||
<Note>
|
||||
This setup is not Recommended for production.
|
||||
</Note>
|
||||
```toml
|
||||
[mcp]
|
||||
# Direct stdio servers - use only for development/testing
|
||||
stdio_servers = [
|
||||
# Basic stdio server
|
||||
{name="fetch", command="uvx", args=["mcp-server-fetch"]},
|
||||
|
||||
For production use, we recommend using proxy tools like SuperGateway.
|
||||
# Stdio server with environment variables
|
||||
{
|
||||
name="filesystem",
|
||||
command="npx",
|
||||
args=["@modelcontextprotocol/server-filesystem", "/"],
|
||||
env={
|
||||
"DEBUG": "true"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Other Proxy Tools
|
||||
For production use, we recommend using proxy tools like SuperGateway.
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
Other options include:
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ RUN apt-get update && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install Python packages with security fixes
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace posthog "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
RUN pip install alembic psycopg2-binary cloud-sql-python-connector pg8000 gspread stripe python-keycloak asyncpg sqlalchemy[asyncio] resend tenacity slack-sdk ddtrace "posthog>=6.0.0" "limits==5.2.0" coredis prometheus-client shap scikit-learn pandas numpy && \
|
||||
# Update packages with known CVE fixes
|
||||
pip install --upgrade \
|
||||
"mcp>=1.10.0" \
|
||||
|
||||
@@ -17,7 +17,8 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
def run_conversation_variant_test(
|
||||
user_id, conversation_id, conversation_settings
|
||||
) -> ConversationInitData:
|
||||
"""Run conversation variant test and potentially modify the conversation settings
|
||||
"""
|
||||
Run conversation variant test and potentially modify the conversation settings
|
||||
based on the PostHog feature flags.
|
||||
|
||||
Args:
|
||||
@@ -52,7 +53,8 @@ class SaaSExperimentManager(ExperimentManager):
|
||||
def run_config_variant_test(
|
||||
user_id: str | None, conversation_id: str, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
"""Run agent config variant test and potentially modify the OpenHands config
|
||||
"""
|
||||
Run agent config variant test and potentially modify the OpenHands config
|
||||
based on the current experiment type and PostHog feature flags.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""LiteLLM model experiment handler.
|
||||
"""
|
||||
LiteLLM model experiment handler.
|
||||
|
||||
This module contains the handler for the LiteLLM model experiment.
|
||||
"""
|
||||
@@ -17,7 +18,8 @@ from openhands.core.logger import openhands_logger as logger
|
||||
def handle_litellm_default_model_experiment(
|
||||
user_id, conversation_id, conversation_settings
|
||||
):
|
||||
"""Handle the LiteLLM model experiment.
|
||||
"""
|
||||
Handle the LiteLLM model experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""System prompt experiment handler.
|
||||
"""
|
||||
System prompt experiment handler.
|
||||
|
||||
This module contains the handler for the system prompt experiment that uses
|
||||
the PostHog variant as the system prompt filename.
|
||||
@@ -16,7 +17,8 @@ from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def _get_system_prompt_variant(user_id, conversation_id):
|
||||
"""Get the system prompt variant for the experiment.
|
||||
"""
|
||||
Get the system prompt variant for the experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
@@ -117,7 +119,8 @@ def _get_system_prompt_variant(user_id, conversation_id):
|
||||
def handle_system_prompt_experiment(
|
||||
user_id, conversation_id, config: OpenHandsConfig
|
||||
) -> OpenHandsConfig:
|
||||
"""Handle the system prompt experiment for OpenHands config.
|
||||
"""
|
||||
Handle the system prompt experiment for OpenHands config.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""LiteLLM model experiment handler.
|
||||
"""
|
||||
LiteLLM model experiment handler.
|
||||
|
||||
This module contains the handler for the LiteLLM model experiment.
|
||||
"""
|
||||
@@ -109,7 +110,8 @@ def handle_claude4_vs_gpt5_experiment(
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
"""Handle the LiteLLM model experiment.
|
||||
"""
|
||||
Handle the LiteLLM model experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
@@ -119,6 +121,7 @@ def handle_claude4_vs_gpt5_experiment(
|
||||
Returns:
|
||||
Modified conversation settings
|
||||
"""
|
||||
|
||||
enabled_variant = _get_model_variant(user_id, conversation_id)
|
||||
|
||||
if not enabled_variant:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Condenser max step experiment handler.
|
||||
"""
|
||||
Condenser max step experiment handler.
|
||||
|
||||
This module contains the handler for the condenser max step experiment that tests
|
||||
different max_size values for the condenser configuration.
|
||||
@@ -14,7 +15,8 @@ from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
|
||||
|
||||
def _get_condenser_max_step_variant(user_id, conversation_id):
|
||||
"""Get the condenser max step variant for the experiment.
|
||||
"""
|
||||
Get the condenser max step variant for the experiment.
|
||||
|
||||
Args:
|
||||
user_id: The user ID
|
||||
@@ -117,7 +119,8 @@ def handle_condenser_max_step_experiment(
|
||||
conversation_id: str,
|
||||
conversation_settings: ConversationInitData,
|
||||
) -> ConversationInitData:
|
||||
"""Handle the condenser max step experiment for conversation settings.
|
||||
"""
|
||||
Handle the condenser max step experiment for conversation settings.
|
||||
|
||||
We should not modify persistent user settings. Instead, apply the experiment
|
||||
variant to the conversation's in-memory settings object for this session only.
|
||||
@@ -128,6 +131,7 @@ def handle_condenser_max_step_experiment(
|
||||
|
||||
Returns the (potentially) modified conversation_settings.
|
||||
"""
|
||||
|
||||
enabled_variant = _get_condenser_max_step_variant(user_id, conversation_id)
|
||||
|
||||
if enabled_variant is None:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Experiment versions package.
|
||||
"""
|
||||
Experiment versions package.
|
||||
|
||||
This package contains handlers for different experiment versions.
|
||||
"""
|
||||
|
||||
@@ -43,7 +43,8 @@ class TriggerType(str, Enum):
|
||||
|
||||
|
||||
class GitHubDataCollector:
|
||||
"""Saves data on Cloud Resolver Interactions
|
||||
"""
|
||||
Saves data on Cloud Resolver Interactions
|
||||
|
||||
1. We always save
|
||||
- Resolver trigger (comment or label)
|
||||
@@ -88,7 +89,8 @@ class GitHubDataCollector:
|
||||
self.conversation_id = None
|
||||
|
||||
async def _get_repo_node_id(self, repo_id: str, gh_client) -> str:
|
||||
"""Get the new GitHub GraphQL node ID for a repository using the GitHub client.
|
||||
"""
|
||||
Get the new GitHub GraphQL node ID for a repository using the GitHub client.
|
||||
|
||||
Args:
|
||||
repo_id: Numeric repository ID as string (e.g., "123456789")
|
||||
@@ -134,7 +136,10 @@ class GitHubDataCollector:
|
||||
def _get_issue_comments(
|
||||
self, installation_id: str, repo_name: str, issue_number: int, conversation_id
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Retrieve all comments from an issue until a comment with conversation_id is found"""
|
||||
"""
|
||||
Retrieve all comments from an issue until a comment with conversation_id is found
|
||||
"""
|
||||
|
||||
try:
|
||||
installation_token = self._get_installation_access_token(installation_id)
|
||||
|
||||
@@ -170,16 +175,18 @@ class GitHubDataCollector:
|
||||
github_view: GithubIssue,
|
||||
trigger_type: TriggerType,
|
||||
) -> None:
|
||||
"""Save issue data when it's labeled with openhands
|
||||
|
||||
1. Save under {conversation_dir}/{conversation_id}/github_data/issue_{issue_number}.json
|
||||
2. Save issue snapshot (title, body, comments)
|
||||
3. Save trigger type (label)
|
||||
4. Save PR opened (if exists, this information comes later when agent has finished its task)
|
||||
- Save commit shas
|
||||
- Save author info
|
||||
5. Was PR merged or closed
|
||||
"""
|
||||
Save issue data when it's labeled with openhands
|
||||
|
||||
1. Save under {conversation_dir}/{conversation_id}/github_data/issue_{issue_number}.json
|
||||
2. Save issue snapshot (title, body, comments)
|
||||
3. Save trigger type (label)
|
||||
4. Save PR opened (if exists, this information comes later when agent has finished its task)
|
||||
- Save commit shas
|
||||
- Save author info
|
||||
5. Was PR merged or closed
|
||||
"""
|
||||
|
||||
conversation_id = github_view.conversation_id
|
||||
|
||||
if not conversation_id:
|
||||
@@ -378,6 +385,7 @@ class GitHubDataCollector:
|
||||
openhands_general_comment_count: int = 0,
|
||||
) -> dict:
|
||||
"""Build the final data structure for JSON storage"""
|
||||
|
||||
is_merged = pr_data['merged']
|
||||
merged_by = None
|
||||
merge_commit_sha = None
|
||||
@@ -411,7 +419,8 @@ class GitHubDataCollector:
|
||||
}
|
||||
|
||||
async def save_full_pr(self, openhands_pr: OpenhandsPR) -> None:
|
||||
"""Save PR information including metadata and commit details using GraphQL
|
||||
"""
|
||||
Save PR information including metadata and commit details using GraphQL
|
||||
|
||||
Saves:
|
||||
- Repo metadata (repo name, languages, contributors)
|
||||
@@ -597,12 +606,17 @@ class GitHubDataCollector:
|
||||
return None
|
||||
|
||||
def _is_pr_closed_or_merged(self, payload):
|
||||
"""Check if PR was closed (regardless of conversation URL)"""
|
||||
"""
|
||||
Check if PR was closed (regardless of conversation URL)
|
||||
"""
|
||||
action = payload.get('action', '')
|
||||
return action == 'closed' and 'pull_request' in payload
|
||||
|
||||
def _track_closed_or_merged_pr(self, payload):
|
||||
"""Track PR closed/merged event"""
|
||||
"""
|
||||
Track PR closed/merged event
|
||||
"""
|
||||
|
||||
repo_id = str(payload['repository']['id'])
|
||||
pr_number = payload['number']
|
||||
installation_id = str(payload['installation']['id'])
|
||||
|
||||
@@ -103,7 +103,8 @@ class SaaSGitHubService(GitHubService):
|
||||
}
|
||||
|
||||
async def get_repository_node_id(self, repo_id: str) -> str:
|
||||
"""Get the new GitHub GraphQL node ID for a repository using REST API.
|
||||
"""
|
||||
Get the new GitHub GraphQL node ID for a repository using REST API.
|
||||
|
||||
Args:
|
||||
repo_id: Numeric repository ID as string (e.g., "123456789")
|
||||
|
||||
@@ -39,6 +39,7 @@ def fetch_github_issue_context(
|
||||
Returns:
|
||||
A comprehensive string containing the issue/PR context
|
||||
"""
|
||||
|
||||
# Build context string
|
||||
context_parts = []
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ async def get_user_proactive_conversation_setting(user_id: str | None) -> bool:
|
||||
This function checks both the global environment variable kill switch AND
|
||||
the user's individual setting. Both must be true for the function to return true.
|
||||
"""
|
||||
|
||||
# If no user ID is provided, we can't check user settings
|
||||
if not user_id:
|
||||
return False
|
||||
|
||||
@@ -43,7 +43,8 @@ class GitlabManager(Manager):
|
||||
async def _user_has_write_access_to_repo(
|
||||
self, project_id: str, user_id: str
|
||||
) -> bool:
|
||||
"""Check if the user has write access to the repository (can pull/push changes and open merge requests).
|
||||
"""
|
||||
Check if the user has write access to the repository (can pull/push changes and open merge requests).
|
||||
|
||||
Args:
|
||||
project_id: The ID of the GitLab project
|
||||
@@ -53,6 +54,7 @@ class GitlabManager(Manager):
|
||||
Returns:
|
||||
bool: True if the user has write access to the repository, False otherwise
|
||||
"""
|
||||
|
||||
keycloak_user_id = await self.token_manager.get_user_id_from_idp_user_id(
|
||||
user_id, ProviderType.GITLAB
|
||||
)
|
||||
@@ -115,7 +117,8 @@ class GitlabManager(Manager):
|
||||
return has_write_access
|
||||
|
||||
async def send_message(self, message: Message, gitlab_view: ResolverViewInterface):
|
||||
"""Send a message to GitLab based on the view type.
|
||||
"""
|
||||
Send a message to GitLab based on the view type.
|
||||
|
||||
Args:
|
||||
message: The message to send
|
||||
@@ -162,7 +165,8 @@ class GitlabManager(Manager):
|
||||
)
|
||||
|
||||
async def start_job(self, gitlab_view: GitlabViewType):
|
||||
"""Start a job for the GitLab view.
|
||||
"""
|
||||
Start a job for the GitLab view.
|
||||
|
||||
Args:
|
||||
gitlab_view: The GitLab view object containing issue/PR/comment info
|
||||
|
||||
@@ -81,7 +81,8 @@ class SaaSGitLabService(GitLabService):
|
||||
return gitlab_token
|
||||
|
||||
async def get_owned_groups(self) -> list[dict]:
|
||||
"""Get all groups for which the current user is the owner.
|
||||
"""
|
||||
Get all groups for which the current user is the owner.
|
||||
|
||||
Returns:
|
||||
list[dict]: A list of groups owned by the current user.
|
||||
@@ -97,7 +98,8 @@ class SaaSGitLabService(GitLabService):
|
||||
return []
|
||||
|
||||
async def add_owned_projects_and_groups_to_db(self, owned_personal_projects):
|
||||
"""Add owned projects and groups to the database for webhook tracking.
|
||||
"""
|
||||
Add owned projects and groups to the database for webhook tracking.
|
||||
|
||||
Args:
|
||||
owned_personal_projects: List of personal projects owned by the user
|
||||
@@ -145,7 +147,8 @@ class SaaSGitLabService(GitLabService):
|
||||
async def store_repository_data(
|
||||
self, users_personal_projects: list[dict], repositories: list[Repository]
|
||||
) -> None:
|
||||
"""Store repository data in the database.
|
||||
"""
|
||||
Store repository data in the database.
|
||||
This function combines the functionality of add_owned_projects_and_groups_to_db and store_repositories_in_db.
|
||||
|
||||
Args:
|
||||
@@ -168,7 +171,8 @@ class SaaSGitLabService(GitLabService):
|
||||
async def get_all_repositories(
|
||||
self, sort: str, app_mode: AppMode, store_in_background: bool = True
|
||||
) -> list[Repository]:
|
||||
"""Get repositories for the authenticated user, including information about the kind of project.
|
||||
"""
|
||||
Get repositories for the authenticated user, including information about the kind of project.
|
||||
Also collects repositories where the kind is "user" and the user is the owner.
|
||||
|
||||
Args:
|
||||
@@ -266,7 +270,8 @@ class SaaSGitLabService(GitLabService):
|
||||
async def check_resource_exists(
|
||||
self, resource_type: GitLabResourceType, resource_id: str
|
||||
) -> tuple[bool, WebhookStatus | None]:
|
||||
"""Check if resource exists and the user has access to it.
|
||||
"""
|
||||
Check if resource exists and the user has access to it.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource
|
||||
@@ -277,6 +282,7 @@ class SaaSGitLabService(GitLabService):
|
||||
- bool: True if the resource exists and the user has access to it, False otherwise
|
||||
- str: A reason message explaining the result
|
||||
"""
|
||||
|
||||
if resource_type == GitLabResourceType.GROUP:
|
||||
url = f'{self.BASE_URL}/groups/{resource_id}'
|
||||
else:
|
||||
@@ -295,7 +301,8 @@ class SaaSGitLabService(GitLabService):
|
||||
async def check_webhook_exists_on_resource(
|
||||
self, resource_type: GitLabResourceType, resource_id: str, webhook_url: str
|
||||
) -> tuple[bool, WebhookStatus | None]:
|
||||
"""Check if a webhook already exists for resource with a specific URL.
|
||||
"""
|
||||
Check if a webhook already exists for resource with a specific URL.
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource
|
||||
@@ -307,6 +314,7 @@ class SaaSGitLabService(GitLabService):
|
||||
- bool: True if the webhook exists, False otherwise
|
||||
- str: A reason message explaining the result
|
||||
"""
|
||||
|
||||
# Construct the URL based on the resource type
|
||||
if resource_type == GitLabResourceType.GROUP:
|
||||
url = f'{self.BASE_URL}/groups/{resource_id}/hooks'
|
||||
@@ -335,7 +343,8 @@ class SaaSGitLabService(GitLabService):
|
||||
async def check_user_has_admin_access_to_resource(
|
||||
self, resource_type: GitLabResourceType, resource_id: str
|
||||
) -> tuple[bool, WebhookStatus | None]:
|
||||
"""Check if the user has admin access to resource (is either an owner or maintainer)
|
||||
"""
|
||||
Check if the user has admin access to resource (is either an owner or maintainer)
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource
|
||||
@@ -346,6 +355,7 @@ class SaaSGitLabService(GitLabService):
|
||||
- bool: True if the user has admin access to the resource (owner or maintainer), False otherwise
|
||||
- str: A reason message explaining the result
|
||||
"""
|
||||
|
||||
# For groups, we need to check if the user is an owner or maintainer
|
||||
if resource_type == GitLabResourceType.GROUP:
|
||||
url = f'{self.BASE_URL}/groups/{resource_id}/members/all'
|
||||
@@ -402,7 +412,8 @@ class SaaSGitLabService(GitLabService):
|
||||
webhook_uuid: str,
|
||||
scopes: list[str],
|
||||
) -> tuple[str | None, WebhookStatus | None]:
|
||||
"""Install webhook for user's group or project
|
||||
"""
|
||||
Install webhook for user's group or project
|
||||
|
||||
Args:
|
||||
resource_type: The type of resource
|
||||
@@ -417,6 +428,7 @@ class SaaSGitLabService(GitLabService):
|
||||
- bool: True if installation was successful, False otherwise
|
||||
- str: A reason message explaining the result
|
||||
"""
|
||||
|
||||
description = 'Cloud OpenHands Resolver'
|
||||
|
||||
# Set up webhook parameters
|
||||
@@ -488,7 +500,9 @@ class SaaSGitLabService(GitLabService):
|
||||
async def reply_to_issue(
|
||||
self, project_id: str, issue_number: str, discussion_id: str | None, body: str
|
||||
):
|
||||
"""Either create new comment thread, or reply to comment thread (depending on discussion_id param)"""
|
||||
"""
|
||||
Either create new comment thread, or reply to comment thread (depending on discussion_id param)
|
||||
"""
|
||||
try:
|
||||
if discussion_id:
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/issues/{issue_number}/discussions/{discussion_id}/notes'
|
||||
@@ -503,7 +517,9 @@ class SaaSGitLabService(GitLabService):
|
||||
async def reply_to_mr(
|
||||
self, project_id: str, merge_request_iid: str, discussion_id: str, body: str
|
||||
):
|
||||
"""Reply to comment thread on MR"""
|
||||
"""
|
||||
Reply to comment thread on MR
|
||||
"""
|
||||
try:
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{merge_request_iid}/discussions/{discussion_id}/notes'
|
||||
params = {'body': body}
|
||||
|
||||
@@ -48,6 +48,7 @@ class JiraManager(Manager):
|
||||
self, jira_user_id: str, workspace_id: int
|
||||
) -> tuple[JiraUser | None, UserAuth | None]:
|
||||
"""Authenticate Jira user and get their OpenHands user auth."""
|
||||
|
||||
# Find active Jira user by Keycloak user ID and workspace ID
|
||||
jira_user = await self.integration_store.get_active_user(
|
||||
jira_user_id, workspace_id
|
||||
@@ -205,6 +206,7 @@ class JiraManager(Manager):
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process incoming Jira webhook message."""
|
||||
|
||||
payload = message.message.get('payload', {})
|
||||
job_context = self.parse_webhook(payload)
|
||||
|
||||
@@ -297,7 +299,10 @@ class JiraManager(Manager):
|
||||
async def is_job_requested(
|
||||
self, message: Message, jira_view: JiraViewInterface
|
||||
) -> bool:
|
||||
"""Check if a job is requested and handle repository selection."""
|
||||
"""
|
||||
Check if a job is requested and handle repository selection.
|
||||
"""
|
||||
|
||||
if isinstance(jira_view, JiraExistingConversationView):
|
||||
return True
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
instructions_template = jinja_env.get_template('jira_instructions.j2')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
@@ -51,6 +52,7 @@ class JiraNewConversationView(JiraViewInterface):
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Jira conversation"""
|
||||
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
@@ -110,6 +112,7 @@ class JiraExistingConversationView(JiraViewInterface):
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_existing_conversation.j2')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
@@ -122,6 +125,7 @@ class JiraExistingConversationView(JiraViewInterface):
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Update an existing Jira conversation"""
|
||||
|
||||
user_id = self.jira_user.keycloak_user_id
|
||||
|
||||
try:
|
||||
@@ -187,6 +191,7 @@ class JiraFactory:
|
||||
jira_workspace: JiraWorkspace,
|
||||
) -> JiraViewInterface:
|
||||
"""Create appropriate Jira view based on the message and user state"""
|
||||
|
||||
if not jira_user or not saas_user_auth or not jira_workspace:
|
||||
raise StartingConvoException('User not authenticated with Jira integration')
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ class JiraDcManager(Manager):
|
||||
self, user_email: str, jira_dc_user_id: str, workspace_id: int
|
||||
) -> tuple[JiraDcUser | None, UserAuth | None]:
|
||||
"""Authenticate Jira DC user and get their OpenHands user auth."""
|
||||
|
||||
if not jira_dc_user_id or jira_dc_user_id == 'none':
|
||||
# Get Keycloak user ID from email
|
||||
keycloak_user_id = await self.token_manager.get_user_id_from_user_email(
|
||||
@@ -220,6 +221,7 @@ class JiraDcManager(Manager):
|
||||
|
||||
async def receive_message(self, message: Message):
|
||||
"""Process incoming Jira DC webhook message."""
|
||||
|
||||
payload = message.message.get('payload', {})
|
||||
job_context = self.parse_webhook(payload)
|
||||
|
||||
@@ -313,7 +315,10 @@ class JiraDcManager(Manager):
|
||||
async def is_job_requested(
|
||||
self, message: Message, jira_dc_view: JiraDcViewInterface
|
||||
) -> bool:
|
||||
"""Check if a job is requested and handle repository selection."""
|
||||
"""
|
||||
Check if a job is requested and handle repository selection.
|
||||
"""
|
||||
|
||||
if isinstance(jira_dc_view, JiraDcExistingConversationView):
|
||||
return True
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
instructions_template = jinja_env.get_template('jira_dc_instructions.j2')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
@@ -54,6 +55,7 @@ class JiraDcNewConversationView(JiraDcViewInterface):
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Jira DC conversation"""
|
||||
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
@@ -113,6 +115,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
user_msg_template = jinja_env.get_template('jira_dc_existing_conversation.j2')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
@@ -125,6 +128,7 @@ class JiraDcExistingConversationView(JiraDcViewInterface):
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Update an existing Jira conversation"""
|
||||
|
||||
user_id = self.jira_dc_user.keycloak_user_id
|
||||
|
||||
try:
|
||||
@@ -191,6 +195,7 @@ class JiraDcFactory:
|
||||
jira_dc_workspace: JiraDcWorkspace,
|
||||
) -> JiraDcViewInterface:
|
||||
"""Create appropriate Jira DC view based on the payload."""
|
||||
|
||||
if not jira_dc_user or not saas_user_auth or not jira_dc_workspace:
|
||||
raise StartingConvoException('User not authenticated with Jira integration')
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ class LinearManager(Manager):
|
||||
self, linear_user_id: str, workspace_id: int
|
||||
) -> tuple[LinearUser | None, UserAuth | None]:
|
||||
"""Authenticate Linear user and get their OpenHands user auth."""
|
||||
|
||||
# Find active Linear user by Linear user ID and workspace ID
|
||||
linear_user = await self.integration_store.get_active_user(
|
||||
linear_user_id, workspace_id
|
||||
@@ -304,7 +305,10 @@ class LinearManager(Manager):
|
||||
async def is_job_requested(
|
||||
self, message: Message, linear_view: LinearViewInterface
|
||||
) -> bool:
|
||||
"""Check if a job is requested and handle repository selection."""
|
||||
"""
|
||||
Check if a job is requested and handle repository selection.
|
||||
"""
|
||||
|
||||
if isinstance(linear_view, LinearExistingConversationView):
|
||||
return True
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ class LinearNewConversationView(LinearViewInterface):
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
instructions_template = jinja_env.get_template('linear_instructions.j2')
|
||||
instructions = instructions_template.render()
|
||||
|
||||
@@ -51,6 +52,7 @@ class LinearNewConversationView(LinearViewInterface):
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Create a new Linear conversation"""
|
||||
|
||||
if not self.selected_repo:
|
||||
raise StartingConvoException('No repository selected for this conversation')
|
||||
|
||||
@@ -110,6 +112,7 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
|
||||
user_msg_template = jinja_env.get_template('linear_existing_conversation.j2')
|
||||
user_msg = user_msg_template.render(
|
||||
issue_key=self.job_context.issue_key,
|
||||
@@ -122,6 +125,7 @@ class LinearExistingConversationView(LinearViewInterface):
|
||||
|
||||
async def create_or_update_conversation(self, jinja_env: Environment) -> str:
|
||||
"""Update an existing Linear conversation"""
|
||||
|
||||
user_id = self.linear_user.keycloak_user_id
|
||||
|
||||
try:
|
||||
@@ -188,6 +192,7 @@ class LinearFactory:
|
||||
linear_workspace: LinearWorkspace,
|
||||
) -> LinearViewInterface:
|
||||
"""Create appropriate Linear view based on the message and user state"""
|
||||
|
||||
if not linear_user or not saas_user_auth or not linear_workspace:
|
||||
raise StartingConvoException(
|
||||
'User not authenticated with Linear integration'
|
||||
|
||||
@@ -8,22 +8,22 @@ class Manager(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def receive_message(self, message: Message):
|
||||
"""Receive message from integration"""
|
||||
"Receive message from integration"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def send_message(self, message: Message):
|
||||
"""Send message to integration from Openhands server"""
|
||||
"Send message to integration from Openhands server"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def is_job_requested(self, message: Message) -> bool:
|
||||
"""Confirm that a job is being requested"""
|
||||
"Confirm that a job is being requested"
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def start_job(self):
|
||||
"""Kick off a job with openhands agent"""
|
||||
"Kick off a job with openhands agent"
|
||||
raise NotImplementedError
|
||||
|
||||
def create_outgoing_message(self, msg: str | dict, ephemeral: bool = False):
|
||||
|
||||
@@ -244,11 +244,13 @@ class SlackManager(Manager):
|
||||
async def is_job_requested(
|
||||
self, message: Message, slack_view: SlackViewInterface
|
||||
) -> bool:
|
||||
"""A job is always request we only receive webhooks for events associated with the slack bot
|
||||
"""
|
||||
A job is always request we only receive webhooks for events associated with the slack bot
|
||||
This method really just checks
|
||||
1. Is the user is authenticated
|
||||
2. Do we have the necessary information to start a job (either by inferring the selected repo, otherwise asking the user)
|
||||
"""
|
||||
|
||||
# Infer repo from user message is not needed; user selected repo from the form or is updating existing convo
|
||||
if isinstance(slack_view, SlackUpdateExistingConversationView):
|
||||
return True
|
||||
|
||||
@@ -24,17 +24,17 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
|
||||
|
||||
@abstractmethod
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
"Instructions passed when conversation is first initialized"
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def create_or_update_conversation(self, jinja_env: Environment):
|
||||
"""Create a new conversation"""
|
||||
"Create a new conversation"
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_callback_id(self) -> str:
|
||||
"""Unique callback id for subscribription made to EventStream for fetching agent summary"""
|
||||
"Unique callback id for subscribription made to EventStream for fetching agent summary"
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
@@ -43,4 +43,6 @@ class SlackViewInterface(SummaryExtractionTracker, ABC):
|
||||
|
||||
|
||||
class StartingConvoException(Exception):
|
||||
"""Raised when trying to send message to a conversation that's is still starting up"""
|
||||
"""
|
||||
Raised when trying to send message to a conversation that's is still starting up
|
||||
"""
|
||||
|
||||
@@ -95,7 +95,8 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
return ''
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
"Instructions passed when conversation is first initialized"
|
||||
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
|
||||
messages = []
|
||||
@@ -178,7 +179,9 @@ class SlackNewConversationView(SlackViewInterface):
|
||||
await slack_conversation_store.create_slack_conversation(slack_conversation)
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""Only creates a new conversation"""
|
||||
"""
|
||||
Only creates a new conversation
|
||||
"""
|
||||
self._verify_necessary_values_are_set()
|
||||
|
||||
provider_tokens = await self.saas_user_auth.get_provider_tokens()
|
||||
@@ -243,7 +246,9 @@ class SlackUpdateExistingConversationView(SlackNewConversationView):
|
||||
return user_message, ''
|
||||
|
||||
async def create_or_update_conversation(self, jinja: Environment) -> str:
|
||||
"""Send new user message to converation"""
|
||||
"""
|
||||
Send new user message to converation
|
||||
"""
|
||||
user_info: SlackUser = self.slack_to_openhands_user
|
||||
saas_user_auth: UserAuth = self.saas_user_auth
|
||||
user_id = user_info.keycloak_user_id
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Utilities for loading and managing pre-trained classifiers.
|
||||
"""
|
||||
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.
|
||||
@@ -10,7 +11,8 @@ from integrations.solvability.models.classifier import SolvabilityClassifier
|
||||
|
||||
|
||||
def load_classifier(name: str) -> SolvabilityClassifier:
|
||||
"""Load a classifier by name.
|
||||
"""
|
||||
Load a classifier by name.
|
||||
|
||||
Args:
|
||||
name (str): The name of the classifier to load.
|
||||
@@ -29,7 +31,8 @@ def load_classifier(name: str) -> SolvabilityClassifier:
|
||||
|
||||
|
||||
def available_classifiers() -> list[str]:
|
||||
"""List all available classifiers in the data directory.
|
||||
"""
|
||||
List all available classifiers in the data directory.
|
||||
|
||||
Returns:
|
||||
list[str]: A list of classifier names (without the .json extension).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Solvability Models Package
|
||||
"""
|
||||
Solvability Models Package
|
||||
|
||||
This package contains the core machine learning models and components for predicting
|
||||
the solvability of GitHub issues and similar technical problems.
|
||||
|
||||
@@ -26,7 +26,8 @@ from openhands.core.config import LLMConfig
|
||||
|
||||
|
||||
class SolvabilityClassifier(BaseModel):
|
||||
"""Machine learning pipeline for predicting the solvability of GitHub issues and similar problems.
|
||||
"""
|
||||
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
|
||||
@@ -86,7 +87,9 @@ class SolvabilityClassifier(BaseModel):
|
||||
|
||||
@model_validator(mode='after')
|
||||
def validate_random_state(self) -> SolvabilityClassifier:
|
||||
"""Validate the random state configuration between this object and the classifier."""
|
||||
"""
|
||||
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:
|
||||
@@ -101,7 +104,9 @@ class SolvabilityClassifier(BaseModel):
|
||||
|
||||
@property
|
||||
def features_(self) -> pd.DataFrame:
|
||||
"""Get the features used by the classifier for the most recent inputs."""
|
||||
"""
|
||||
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.'
|
||||
@@ -110,7 +115,9 @@ class SolvabilityClassifier(BaseModel):
|
||||
|
||||
@property
|
||||
def cost_(self) -> pd.DataFrame:
|
||||
"""Get the cost of the classifier for the most recent inputs."""
|
||||
"""
|
||||
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.'
|
||||
@@ -119,7 +126,9 @@ class SolvabilityClassifier(BaseModel):
|
||||
|
||||
@property
|
||||
def feature_importances_(self) -> np.ndarray:
|
||||
"""Get the feature importances for the most recent inputs."""
|
||||
"""
|
||||
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 '
|
||||
@@ -129,7 +138,9 @@ class SolvabilityClassifier(BaseModel):
|
||||
|
||||
@property
|
||||
def is_fitted(self) -> bool:
|
||||
"""Check if the classifier is fitted."""
|
||||
"""
|
||||
Check if the classifier is fitted.
|
||||
"""
|
||||
try:
|
||||
check_is_fitted(self.classifier)
|
||||
return True
|
||||
@@ -137,7 +148,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
return False
|
||||
|
||||
def transform(self, issues: pd.Series, llm_config: LLMConfig) -> pd.DataFrame:
|
||||
"""Transform the input issues using the featurizer to extract features.
|
||||
"""
|
||||
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
|
||||
@@ -171,7 +183,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
def fit(
|
||||
self, issues: pd.Series, labels: pd.Series, llm_config: LLMConfig
|
||||
) -> SolvabilityClassifier:
|
||||
"""Fit the classifier to the input issues and labels.
|
||||
"""
|
||||
Fit the classifier to the input issues and labels.
|
||||
|
||||
Args:
|
||||
issues: A pandas Series containing the issue descriptions.
|
||||
@@ -195,7 +208,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
return self
|
||||
|
||||
def predict_proba(self, issues: pd.Series, llm_config: LLMConfig) -> np.ndarray:
|
||||
"""Predict the solvability probabilities for the input issues.
|
||||
"""
|
||||
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).
|
||||
@@ -229,7 +243,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
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.
|
||||
"""
|
||||
Predict the solvability of the input issues by returning binary labels.
|
||||
|
||||
Uses a 0.5 probability threshold to convert probabilities to binary predictions.
|
||||
|
||||
@@ -251,7 +266,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
scores: np.ndarray,
|
||||
labels: np.ndarray | None = None,
|
||||
) -> np.ndarray:
|
||||
"""Calculate feature importance scores using the configured strategy.
|
||||
"""
|
||||
Calculate feature importance scores using the configured strategy.
|
||||
|
||||
Different strategies provide different interpretations:
|
||||
- SHAP: Shapley values indicating contribution to individual predictions
|
||||
@@ -297,7 +313,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
)
|
||||
|
||||
def add_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""Add new features to the classifier's featurizer.
|
||||
"""
|
||||
Add new features to the classifier's featurizer.
|
||||
|
||||
Note: Adding features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
@@ -314,7 +331,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
return self
|
||||
|
||||
def forget_features(self, features: list[Feature]) -> SolvabilityClassifier:
|
||||
"""Remove features from the classifier's featurizer.
|
||||
"""
|
||||
Remove features from the classifier's featurizer.
|
||||
|
||||
Note: Removing features after training requires retraining the classifier
|
||||
since the feature space will have changed.
|
||||
@@ -336,13 +354,17 @@ class SolvabilityClassifier(BaseModel):
|
||||
@field_serializer('classifier')
|
||||
@staticmethod
|
||||
def _rfc_to_json(rfc: RandomForestClassifier) -> str:
|
||||
"""Convert a RandomForestClassifier to a JSON-compatible value (a string)."""
|
||||
"""
|
||||
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."""
|
||||
"""
|
||||
Convert a JSON-compatible value (a string) back to a RandomForestClassifier.
|
||||
"""
|
||||
if isinstance(value, RandomForestClassifier):
|
||||
return value
|
||||
|
||||
@@ -361,7 +383,8 @@ class SolvabilityClassifier(BaseModel):
|
||||
def solvability_report(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""Generate a solvability report for the given issue.
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
|
||||
Args:
|
||||
issue: The issue description for which to generate the report.
|
||||
@@ -404,5 +427,7 @@ class SolvabilityClassifier(BaseModel):
|
||||
def __call__(
|
||||
self, issue: str, llm_config: LLMConfig, **kwargs: Any
|
||||
) -> SolvabilityReport:
|
||||
"""Generate a solvability report for the given issue."""
|
||||
"""
|
||||
Generate a solvability report for the given issue.
|
||||
"""
|
||||
return self.solvability_report(issue, llm_config=llm_config, **kwargs)
|
||||
|
||||
@@ -10,7 +10,8 @@ from openhands.llm.llm import LLM
|
||||
|
||||
|
||||
class Feature(BaseModel):
|
||||
"""Represents a single boolean feature that can be extracted from issue descriptions.
|
||||
"""
|
||||
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.
|
||||
@@ -24,7 +25,8 @@ class Feature(BaseModel):
|
||||
|
||||
@property
|
||||
def to_tool_description_field(self) -> dict[str, Any]:
|
||||
"""Convert this feature to a JSON schema field for LLM tool calling.
|
||||
"""
|
||||
Convert this feature to a JSON schema field for LLM tool calling.
|
||||
|
||||
Returns:
|
||||
dict: JSON schema field definition for this feature.
|
||||
@@ -36,7 +38,8 @@ class Feature(BaseModel):
|
||||
|
||||
|
||||
class EmbeddingDimension(BaseModel):
|
||||
"""Represents a single dimension (feature evaluation) within a feature embedding sample.
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
@@ -57,7 +60,8 @@ Maps feature identifiers to their boolean evaluations.
|
||||
|
||||
|
||||
class FeatureEmbedding(BaseModel):
|
||||
"""Represents the complete feature embedding for a single issue, including multiple samples
|
||||
"""
|
||||
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
|
||||
@@ -78,7 +82,8 @@ class FeatureEmbedding(BaseModel):
|
||||
|
||||
@property
|
||||
def dimensions(self) -> list[str]:
|
||||
"""Get all unique feature identifiers present across all samples.
|
||||
"""
|
||||
Get all unique feature identifiers present across all samples.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers that appear in at least one sample.
|
||||
@@ -89,7 +94,8 @@ class FeatureEmbedding(BaseModel):
|
||||
return list(dims)
|
||||
|
||||
def coefficient(self, dimension: str) -> float | None:
|
||||
"""Calculate the average coefficient (0-1) for a specific feature dimension.
|
||||
"""
|
||||
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.
|
||||
@@ -111,7 +117,8 @@ class FeatureEmbedding(BaseModel):
|
||||
return None
|
||||
|
||||
def to_row(self) -> dict[str, Any]:
|
||||
"""Convert the embedding to a flat dictionary suitable for DataFrame construction.
|
||||
"""
|
||||
Convert the embedding to a flat dictionary suitable for DataFrame construction.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Dictionary with metadata fields and feature coefficients.
|
||||
@@ -124,7 +131,8 @@ class FeatureEmbedding(BaseModel):
|
||||
}
|
||||
|
||||
def sample_entropy(self) -> dict[str, float]:
|
||||
"""Calculate the Shannon entropy of feature evaluations across samples.
|
||||
"""
|
||||
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.
|
||||
@@ -154,7 +162,8 @@ class FeatureEmbedding(BaseModel):
|
||||
|
||||
|
||||
class Featurizer(BaseModel):
|
||||
"""Orchestrates LLM-based feature extraction from issue descriptions.
|
||||
"""
|
||||
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,
|
||||
@@ -171,7 +180,8 @@ class Featurizer(BaseModel):
|
||||
"""List of features to extract from each issue description."""
|
||||
|
||||
def system_message(self) -> dict[str, Any]:
|
||||
"""Construct the system message for LLM conversations.
|
||||
"""
|
||||
Construct the system message for LLM conversations.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: System message dictionary for LLM API calls.
|
||||
@@ -184,7 +194,8 @@ class Featurizer(BaseModel):
|
||||
def user_message(
|
||||
self, issue_description: str, set_cache: bool = True
|
||||
) -> dict[str, Any]:
|
||||
"""Construct the user message containing the issue description.
|
||||
"""
|
||||
Construct the user message containing the issue description.
|
||||
|
||||
Args:
|
||||
issue_description: The description of the issue to analyze.
|
||||
@@ -204,7 +215,8 @@ class Featurizer(BaseModel):
|
||||
|
||||
@property
|
||||
def tool_choice(self) -> dict[str, Any]:
|
||||
"""Get the tool choice configuration for forcing LLM to use the featurizer tool.
|
||||
"""
|
||||
Get the tool choice configuration for forcing LLM to use the featurizer tool.
|
||||
|
||||
Returns:
|
||||
dict[str, Any]: Tool choice configuration for LLM API calls.
|
||||
@@ -216,7 +228,8 @@ class Featurizer(BaseModel):
|
||||
|
||||
@property
|
||||
def tool_description(self) -> dict[str, Any]:
|
||||
"""Generate the tool schema for the featurizer function.
|
||||
"""
|
||||
Generate the tool schema for the featurizer function.
|
||||
|
||||
Creates a JSON schema that describes the featurizer tool with all configured
|
||||
features as boolean parameters.
|
||||
@@ -246,7 +259,8 @@ class Featurizer(BaseModel):
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> FeatureEmbedding:
|
||||
"""Generate a feature embedding for a single issue description.
|
||||
"""
|
||||
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.
|
||||
@@ -308,7 +322,8 @@ class Featurizer(BaseModel):
|
||||
temperature: float = 1.0,
|
||||
samples: int = 10,
|
||||
) -> list[FeatureEmbedding]:
|
||||
"""Generate embeddings for a batch of issue descriptions using concurrent processing.
|
||||
"""
|
||||
Generate embeddings for a batch of issue descriptions using concurrent processing.
|
||||
|
||||
Processes multiple issues in parallel to improve throughput while maintaining
|
||||
result ordering.
|
||||
@@ -344,7 +359,8 @@ class Featurizer(BaseModel):
|
||||
return results
|
||||
|
||||
def feature_identifiers(self) -> list[str]:
|
||||
"""Get the identifiers of all configured features.
|
||||
"""
|
||||
Get the identifiers of all configured features.
|
||||
|
||||
Returns:
|
||||
list[str]: List of feature identifiers in the order they were defined.
|
||||
|
||||
@@ -2,7 +2,8 @@ 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
|
||||
"""
|
||||
Strategy to use for calculating feature importances, which are used to estimate the predictive power of each feature
|
||||
in training loops and explanations.
|
||||
"""
|
||||
|
||||
|
||||
@@ -6,7 +6,8 @@ from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SolvabilityReport(BaseModel):
|
||||
"""Comprehensive report containing solvability predictions and analysis for a single issue.
|
||||
"""
|
||||
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
|
||||
|
||||
@@ -39,13 +39,13 @@ class ResolverViewInterface(SummaryExtractionTracker):
|
||||
raw_payload: dict
|
||||
|
||||
def _get_instructions(self, jinja_env: Environment) -> tuple[str, str]:
|
||||
"""Instructions passed when conversation is first initialized"""
|
||||
"Instructions passed when conversation is first initialized"
|
||||
raise NotImplementedError()
|
||||
|
||||
async def create_new_conversation(self, jinja_env: Environment, token: str):
|
||||
"""Create a new conversation"""
|
||||
"Create a new conversation"
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_callback_id(self) -> str:
|
||||
"""Unique callback id for subscribription made to EventStream for fetching agent summary"""
|
||||
"Unique callback id for subscribription made to EventStream for fetching agent summary"
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -215,7 +215,9 @@ def get_last_user_msg(event_store: EventStoreABC) -> list[MessageAction]:
|
||||
def extract_summary_from_event_store(
|
||||
event_store: EventStoreABC, conversation_id: str
|
||||
) -> str:
|
||||
"""Get agent summary or alternative message depending on current AgentState"""
|
||||
"""
|
||||
Get agent summary or alternative message depending on current AgentState
|
||||
"""
|
||||
conversation_link = CONVERSATION_URL.format(conversation_id)
|
||||
summary_instruction = get_summary_instruction()
|
||||
|
||||
@@ -291,7 +293,10 @@ async def get_last_user_msg_from_conversation_manager(
|
||||
async def extract_summary_from_conversation_manager(
|
||||
conversation_manager: ConversationManager, conversation_id: str
|
||||
) -> str:
|
||||
"""Get agent summary or alternative message depending on current AgentState"""
|
||||
"""
|
||||
Get agent summary or alternative message depending on current AgentState
|
||||
"""
|
||||
|
||||
event_store = await get_event_store_from_conversation_manager(
|
||||
conversation_manager, conversation_id
|
||||
)
|
||||
@@ -300,7 +305,8 @@ async def extract_summary_from_conversation_manager(
|
||||
|
||||
|
||||
def append_conversation_footer(message: str, conversation_id: str) -> str:
|
||||
"""Append a small footer with the conversation URL to a message.
|
||||
"""
|
||||
Append a small footer with the conversation URL to a message.
|
||||
|
||||
Args:
|
||||
message: The original message content
|
||||
@@ -315,12 +321,14 @@ def append_conversation_footer(message: str, conversation_id: str) -> str:
|
||||
|
||||
|
||||
async def store_repositories_in_db(repos: list[Repository], user_id: str) -> None:
|
||||
"""Store repositories in DB and create user-repository mappings
|
||||
"""
|
||||
Store repositories in DB and create user-repository mappings
|
||||
|
||||
Args:
|
||||
repos: List of Repository objects to store
|
||||
user_id: User ID associated with these repositories
|
||||
"""
|
||||
|
||||
# Convert Repository objects to StoredRepository objects
|
||||
# Convert Repository objects to UserRepositoryMap objects
|
||||
stored_repos = []
|
||||
@@ -358,9 +366,9 @@ async def store_repositories_in_db(repos: list[Repository], user_id: str) -> Non
|
||||
|
||||
|
||||
def infer_repo_from_message(user_msg: str) -> list[str]:
|
||||
"""Extract all repository names in the format 'owner/repo' from various Git provider URLs
|
||||
"""
|
||||
Extract all repository names in the format 'owner/repo' from various Git provider URLs
|
||||
and direct mentions in text. Supports GitHub, GitLab, and BitBucket.
|
||||
|
||||
Args:
|
||||
user_msg: Input message that may contain repository references
|
||||
Returns:
|
||||
@@ -443,10 +451,10 @@ def filter_potential_repos_by_user_msg(
|
||||
|
||||
|
||||
def markdown_to_jira_markup(markdown_text: str) -> str:
|
||||
"""Convert markdown text to Jira Wiki Markup format.
|
||||
"""
|
||||
Convert markdown text to Jira Wiki Markup format.
|
||||
This function handles common markdown elements and converts them to their
|
||||
Jira Wiki Markup equivalents. It's designed to be exception-safe.
|
||||
|
||||
Args:
|
||||
markdown_text: The markdown text to convert
|
||||
Returns:
|
||||
|
||||
@@ -22,7 +22,8 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create maintenance tasks for all users whose user_version is less than
|
||||
"""
|
||||
Create maintenance tasks for all users whose user_version is less than
|
||||
the current version.
|
||||
|
||||
This replaces the functionality of the removed admin maintenance endpoint.
|
||||
@@ -88,7 +89,8 @@ def upgrade():
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""No downgrade operation needed as we're just creating tasks.
|
||||
"""
|
||||
No downgrade operation needed as we're just creating tasks.
|
||||
The tasks themselves will be processed and completed.
|
||||
|
||||
If needed, we could delete tasks with this processor type, but that's not necessary
|
||||
|
||||
15
enterprise/poetry.lock
generated
15
enterprise/poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -1061,7 +1061,7 @@ files = [
|
||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||
]
|
||||
markers = {main = "platform_system == \"Windows\" or os_name == \"nt\" or sys_platform == \"win32\"", dev = "os_name == \"nt\"", test = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
markers = {main = "platform_system == \"Windows\" or sys_platform == \"win32\" or os_name == \"nt\"", dev = "os_name == \"nt\"", test = "platform_system == \"Windows\" or sys_platform == \"win32\""}
|
||||
|
||||
[[package]]
|
||||
name = "comm"
|
||||
@@ -1990,6 +1990,7 @@ files = [
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9b31dd488d0778c36f8279b306dc92a42f16904cba54acca71e107d65b60b0c"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:b19361ee649365eefc717ec08005972d3d1eb9ee39908022d98e3bfa9da59e37"},
|
||||
{file = "fastuuid-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:8fc66b11423e6f3e1937385f655bedd67aebe56a3dcec0cb835351cfe7d358c9"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:2925f67b88d47cb16aa3eb1ab20fdcf21b94d74490e0818c91ea41434b987493"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7b15c54d300279ab20a9cc0579ada9c9f80d1bc92997fc61fb7bf3103d7cb26b"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:458f1bc3ebbd76fdb89ad83e6b81ccd3b2a99fa6707cd3650b27606745cfb170"},
|
||||
{file = "fastuuid-0.12.0-cp38-cp38-manylinux_2_34_x86_64.whl", hash = "sha256:a8f0f83fbba6dc44271a11b22e15838641b8c45612cdf541b4822a5930f6893c"},
|
||||
@@ -6112,14 +6113,14 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "posthog"
|
||||
version = "4.10.0"
|
||||
version = "6.7.6"
|
||||
description = "Integrate PostHog into any python application."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "posthog-4.10.0-py3-none-any.whl", hash = "sha256:b693d3d8209d000d8c5f4d6ea19096bfdfb83047fa8a14c937ae50a3394809a1"},
|
||||
{file = "posthog-4.10.0.tar.gz", hash = "sha256:513bfbb21344013294abc046b1142173189c5422a3906cf2280d1389b0c2e28b"},
|
||||
{file = "posthog-6.7.6-py3-none-any.whl", hash = "sha256:b09a7e65a042ec416c28874b397d3accae412a80a8b0ef3fa686fbffc99e4d4b"},
|
||||
{file = "posthog-6.7.6.tar.gz", hash = "sha256:ee5c5ad04b857d96d9b7a4f715e23916a2f206bfcf25e5a9d328a3d27664b0d3"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
@@ -6128,11 +6129,11 @@ distro = ">=1.5.0"
|
||||
python-dateutil = ">=2.2"
|
||||
requests = ">=2.7,<3.0"
|
||||
six = ">=1.5"
|
||||
typing-extensions = ">=4.2.0"
|
||||
|
||||
[package.extras]
|
||||
dev = ["django-stubs", "lxml", "mypy", "mypy-baseline", "packaging", "pre-commit", "pydantic", "ruff", "setuptools", "tomli", "tomli_w", "twine", "types-mock", "types-python-dateutil", "types-requests", "types-setuptools", "types-six", "wheel"]
|
||||
langchain = ["langchain (>=0.2.0)"]
|
||||
sentry = ["django", "sentry-sdk"]
|
||||
test = ["anthropic", "coverage", "django", "freezegun (==1.5.1)", "google-genai", "langchain-anthropic (>=0.3.15)", "langchain-community (>=0.3.25)", "langchain-core (>=0.3.65)", "langchain-openai (>=0.3.22)", "langgraph (>=0.4.8)", "mock (>=2.0.0)", "openai", "parameterized (>=0.8.1)", "pydantic", "pytest", "pytest-asyncio", "pytest-timeout"]
|
||||
|
||||
[[package]]
|
||||
@@ -10102,4 +10103,4 @@ cffi = ["cffi (>=1.17) ; python_version >= \"3.13\" and platform_python_implemen
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "8c460070dce6bdec5ee0ee7bc0c2246fcf2602d1e64a0867b4f5e3a0e334fe93"
|
||||
content-hash = "fac67a8991a3e2c840a23702dc90f99e98d381f3537ad50b4c4739cdbde941ca"
|
||||
|
||||
@@ -38,7 +38,7 @@ resend = "^2.7.0"
|
||||
tenacity = "^9.1.2"
|
||||
slack-sdk = "^3.35.0"
|
||||
ddtrace = "3.13.0" #pin to avoid yanked version 3.12.4
|
||||
posthog = "^4.2.0"
|
||||
posthog = "^6.0.0"
|
||||
limits = "^5.2.0"
|
||||
coredis = "^4.22.0"
|
||||
httpx = "*"
|
||||
|
||||
@@ -19,10 +19,7 @@ from server.auth.constants import ( # noqa: E402
|
||||
from server.constants import PERMITTED_CORS_ORIGINS # noqa: E402
|
||||
from server.logger import logger # noqa: E402
|
||||
from server.metrics import metrics_app # noqa: E402
|
||||
from server.middleware import ( # noqa: E402
|
||||
LLMSettingsMiddleware,
|
||||
SetAuthCookieMiddleware,
|
||||
)
|
||||
from server.middleware import SetAuthCookieMiddleware # noqa: E402
|
||||
from server.rate_limit import setup_rate_limit_handler # noqa: E402
|
||||
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
|
||||
@@ -108,7 +105,6 @@ base_app.add_middleware(
|
||||
allow_headers=['*'],
|
||||
)
|
||||
base_app.add_middleware(CacheControlMiddleware)
|
||||
base_app.middleware('http')(LLMSettingsMiddleware())
|
||||
base_app.middleware('http')(SetAuthCookieMiddleware())
|
||||
|
||||
base_app.mount('/', SPAStaticFiles(directory=directory, html=True), name='dist')
|
||||
|
||||
@@ -31,7 +31,6 @@ class GoogleSheetsClient:
|
||||
self, spreadsheet_id: str, range_name: str
|
||||
) -> Optional[List[str]]:
|
||||
"""Get usernames from cache if available and not expired.
|
||||
|
||||
Args:
|
||||
spreadsheet_id: The ID of the Google Sheet
|
||||
range_name: The A1 notation of the range to fetch
|
||||
@@ -57,7 +56,6 @@ class GoogleSheetsClient:
|
||||
self, spreadsheet_id: str, range_name: str, usernames: List[str]
|
||||
) -> None:
|
||||
"""Update cache with new usernames and current timestamp.
|
||||
|
||||
Args:
|
||||
spreadsheet_id: The ID of the Google Sheet
|
||||
range_name: The A1 notation of the range to fetch
|
||||
@@ -69,7 +67,6 @@ class GoogleSheetsClient:
|
||||
def get_usernames(self, spreadsheet_id: str, range_name: str = 'A:A') -> List[str]:
|
||||
"""Get list of usernames from specified Google Sheet.
|
||||
Uses cached data if available and less than 15 seconds old.
|
||||
|
||||
Args:
|
||||
spreadsheet_id: The ID of the Google Sheet
|
||||
range_name: The A1 notation of the range to fetch
|
||||
|
||||
@@ -21,6 +21,7 @@ from openhands.events.event_store_abc import EventStoreABC
|
||||
from openhands.events.observation import AgentStateChangedObservation
|
||||
from openhands.events.stream import EventStreamSubscriber
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime.runtime_status import RuntimeStatus
|
||||
from openhands.server.config.server_config import ServerConfig
|
||||
from openhands.server.conversation_manager.conversation_manager import (
|
||||
ConversationManager,
|
||||
@@ -483,7 +484,8 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
await pipe.execute()
|
||||
|
||||
async def _disconnect_from_stopped(self):
|
||||
"""Handle connections to conversations that have stopped unexpectedly.
|
||||
"""
|
||||
Handle connections to conversations that have stopped unexpectedly.
|
||||
|
||||
This method detects when a local connection is pointing to a conversation
|
||||
that was running on another server that has crashed or been terminated
|
||||
@@ -685,6 +687,7 @@ class ClusteredConversationManager(StandaloneConversationManager):
|
||||
url=self._get_conversation_url(conversation_id),
|
||||
session_api_key=None,
|
||||
event_store=EventStore(conversation_id, self.file_store, uid),
|
||||
runtime_status=RuntimeStatus.READY,
|
||||
)
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -70,7 +70,8 @@ PERMITTED_CORS_ORIGINS = [
|
||||
|
||||
|
||||
def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
"""Build the LiteLLM proxy model path based on environment and model name.
|
||||
"""
|
||||
Build the LiteLLM proxy model path based on environment and model name.
|
||||
|
||||
This utility constructs the full model path for LiteLLM proxy based on:
|
||||
- Environment type (staging vs prod)
|
||||
@@ -82,6 +83,7 @@ def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
Returns:
|
||||
The full LiteLLM proxy model path (e.g., 'litellm_proxy/prod/claude-3-7-sonnet-20250219')
|
||||
"""
|
||||
|
||||
if 'prod' in model_name or 'litellm' in model_name or 'proxy' in model_name:
|
||||
raise ValueError("Only include model name, don't include prefix")
|
||||
|
||||
@@ -94,7 +96,8 @@ def build_litellm_proxy_model_path(model_name: str) -> str:
|
||||
|
||||
|
||||
def get_default_litellm_model():
|
||||
"""Construct proxy for litellm model based on user settings and environment type (staging vs prod)
|
||||
"""
|
||||
Construct proxy for litellm model based on user settings and environment type (staging vs prod)
|
||||
if not set explicitly
|
||||
"""
|
||||
if LITELLM_DEFAULT_MODEL:
|
||||
|
||||
@@ -25,7 +25,8 @@ from openhands.server.shared import conversation_manager
|
||||
|
||||
|
||||
class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to GitHub.
|
||||
"""
|
||||
Processor for sending conversation summaries to GitHub.
|
||||
|
||||
This processor is used to send summaries of conversations to GitHub issues/PRs
|
||||
when agent state changes occur.
|
||||
@@ -35,7 +36,8 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
send_summary_instruction: bool = True
|
||||
|
||||
async def _send_message_to_github(self, message: str) -> None:
|
||||
"""Send a message to GitHub.
|
||||
"""
|
||||
Send a message to GitHub.
|
||||
|
||||
Args:
|
||||
message: The message content to send to GitHub
|
||||
@@ -66,7 +68,8 @@ class GithubCallbackProcessor(ConversationCallbackProcessor):
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""Process a conversation event by sending a summary to GitHub.
|
||||
"""
|
||||
Process a conversation event by sending a summary to GitHub.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
|
||||
@@ -28,7 +28,8 @@ gitlab_manager = GitlabManager(token_manager)
|
||||
|
||||
|
||||
class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to GitLab.
|
||||
"""
|
||||
Processor for sending conversation summaries to GitLab.
|
||||
|
||||
This processor is used to send summaries of conversations to GitLab
|
||||
when agent state changes occur.
|
||||
@@ -38,7 +39,8 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
send_summary_instruction: bool = True
|
||||
|
||||
async def _send_message_to_gitlab(self, message: str) -> None:
|
||||
"""Send a message to GitLab.
|
||||
"""
|
||||
Send a message to GitLab.
|
||||
|
||||
Args:
|
||||
message: The message content to send to GitLab
|
||||
@@ -65,7 +67,8 @@ class GitlabCallbackProcessor(ConversationCallbackProcessor):
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""Process a conversation event by sending a summary to GitLab.
|
||||
"""
|
||||
Process a conversation event by sending a summary to GitLab.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
|
||||
@@ -26,7 +26,8 @@ integration_store = jira_manager.integration_store
|
||||
|
||||
|
||||
class JiraCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to Jira.
|
||||
"""
|
||||
Processor for sending conversation summaries to Jira.
|
||||
|
||||
This processor is used to send summaries of conversations to Jira issues
|
||||
when agent state changes occur.
|
||||
@@ -36,7 +37,8 @@ class JiraCallbackProcessor(ConversationCallbackProcessor):
|
||||
workspace_name: str
|
||||
|
||||
async def _send_comment_to_jira(self, message: str) -> None:
|
||||
"""Send a comment to Jira issue.
|
||||
"""
|
||||
Send a comment to Jira issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Jira
|
||||
@@ -77,7 +79,8 @@ class JiraCallbackProcessor(ConversationCallbackProcessor):
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""Process a conversation event by sending a summary to Jira.
|
||||
"""
|
||||
Process a conversation event by sending a summary to Jira.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
|
||||
@@ -25,7 +25,8 @@ jira_dc_manager = JiraDcManager(token_manager)
|
||||
|
||||
|
||||
class JiraDcCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to Jira DC.
|
||||
"""
|
||||
Processor for sending conversation summaries to Jira DC.
|
||||
|
||||
This processor is used to send summaries of conversations to Jira DC issues
|
||||
when agent state changes occur.
|
||||
@@ -36,7 +37,8 @@ class JiraDcCallbackProcessor(ConversationCallbackProcessor):
|
||||
base_api_url: str
|
||||
|
||||
async def _send_comment_to_jira_dc(self, message: str) -> None:
|
||||
"""Send a comment to Jira DC issue.
|
||||
"""
|
||||
Send a comment to Jira DC issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Jira DC
|
||||
@@ -78,7 +80,8 @@ class JiraDcCallbackProcessor(ConversationCallbackProcessor):
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""Process a conversation event by sending a summary to Jira DC.
|
||||
"""
|
||||
Process a conversation event by sending a summary to Jira DC.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
|
||||
@@ -24,7 +24,8 @@ linear_manager = LinearManager(token_manager)
|
||||
|
||||
|
||||
class LinearCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to Linear.
|
||||
"""
|
||||
Processor for sending conversation summaries to Linear.
|
||||
|
||||
This processor is used to send summaries of conversations to Linear issues
|
||||
when agent state changes occur.
|
||||
@@ -35,7 +36,8 @@ class LinearCallbackProcessor(ConversationCallbackProcessor):
|
||||
workspace_name: str
|
||||
|
||||
async def _send_comment_to_linear(self, message: str) -> None:
|
||||
"""Send a comment to Linear issue.
|
||||
"""
|
||||
Send a comment to Linear issue.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Linear
|
||||
@@ -77,7 +79,8 @@ class LinearCallbackProcessor(ConversationCallbackProcessor):
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""Process a conversation event by sending a summary to Linear.
|
||||
"""
|
||||
Process a conversation event by sending a summary to Linear.
|
||||
|
||||
Args:
|
||||
callback: The conversation callback
|
||||
|
||||
@@ -26,7 +26,8 @@ slack_manager = SlackManager(token_manager)
|
||||
|
||||
|
||||
class SlackCallbackProcessor(ConversationCallbackProcessor):
|
||||
"""Processor for sending conversation summaries to Slack.
|
||||
"""
|
||||
Processor for sending conversation summaries to Slack.
|
||||
|
||||
This processor is used to send summaries of conversations to Slack channels
|
||||
when agent state changes occur.
|
||||
@@ -40,7 +41,8 @@ class SlackCallbackProcessor(ConversationCallbackProcessor):
|
||||
last_user_msg_id: int | None = None
|
||||
|
||||
async def _send_message_to_slack(self, message: str) -> None:
|
||||
"""Send a message to Slack using the conversation_manager's send_to_event_stream method.
|
||||
"""
|
||||
Send a message to Slack using the conversation_manager's send_to_event_stream method.
|
||||
|
||||
Args:
|
||||
message: The message content to send to Slack
|
||||
@@ -81,7 +83,8 @@ class SlackCallbackProcessor(ConversationCallbackProcessor):
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""Process a conversation event by sending a summary to Slack.
|
||||
"""
|
||||
Process a conversation event by sending a summary to Slack.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to process
|
||||
|
||||
@@ -33,7 +33,8 @@ class LegacyCacheEntry:
|
||||
|
||||
@dataclass
|
||||
class LegacyConversationManager(ConversationManager):
|
||||
"""Conversation manager for use while migrating - since existing conversations are not nested!
|
||||
"""
|
||||
Conversation manager for use while migrating - since existing conversations are not nested!
|
||||
Separate class from SaasNestedConversationManager so it can be easliy removed in a few weeks.
|
||||
(As of 2025-07-23)
|
||||
"""
|
||||
@@ -269,7 +270,8 @@ class LegacyConversationManager(ConversationManager):
|
||||
del self._legacy_cache[key]
|
||||
|
||||
async def should_start_in_legacy_mode(self, conversation_id: str) -> bool:
|
||||
"""Check if a conversation should run in legacy mode by directly checking the runtime.
|
||||
"""
|
||||
Check if a conversation should run in legacy mode by directly checking the runtime.
|
||||
The /list method does not include stopped conversations even though the PVC for these
|
||||
may not yet have been deleted, so we need to check /sessions/{session_id} directly.
|
||||
"""
|
||||
@@ -293,7 +295,8 @@ class LegacyConversationManager(ConversationManager):
|
||||
return is_legacy
|
||||
|
||||
def is_legacy_runtime(self, runtime: dict | None) -> bool:
|
||||
"""Determine if a runtime is a legacy runtime based on its command.
|
||||
"""
|
||||
Determine if a runtime is a legacy runtime based on its command.
|
||||
|
||||
Args:
|
||||
runtime: The runtime dictionary or None if not found
|
||||
|
||||
@@ -59,9 +59,11 @@ def setup_json_logger(
|
||||
level: str = LOG_LEVEL,
|
||||
_out: TextIO = sys.stdout,
|
||||
) -> None:
|
||||
"""Configure logger instance to output json for Google Cloud.
|
||||
"""
|
||||
Configure logger instance to output json for Google Cloud.
|
||||
Existing filters should stay in place for sensitive content.
|
||||
"""
|
||||
|
||||
# Remove existing handlers to avoid duplicate logs
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
@@ -82,7 +84,8 @@ def setup_json_logger(
|
||||
|
||||
|
||||
def setup_all_loggers():
|
||||
"""Setup JSON logging for all libraries that may be logging.
|
||||
"""
|
||||
Setup JSON logging for all libraries that may be logging.
|
||||
Leave OpenHands alone since it's already configured.
|
||||
"""
|
||||
if LOG_JSON:
|
||||
|
||||
@@ -13,7 +13,8 @@ from openhands.core.config import load_openhands_config
|
||||
|
||||
|
||||
class UserVersionUpgradeProcessor(MaintenanceTaskProcessor):
|
||||
"""Processor for upgrading user settings to the current version.
|
||||
"""
|
||||
Processor for upgrading user settings to the current version.
|
||||
|
||||
This processor takes a list of user IDs and upgrades any users
|
||||
whose user_version is less than CURRENT_USER_SETTINGS_VERSION.
|
||||
@@ -22,7 +23,8 @@ class UserVersionUpgradeProcessor(MaintenanceTaskProcessor):
|
||||
user_ids: List[str]
|
||||
|
||||
async def __call__(self, task: MaintenanceTask) -> dict:
|
||||
"""Process user version upgrades for the specified user IDs.
|
||||
"""
|
||||
Process user version upgrades for the specified user IDs.
|
||||
|
||||
Args:
|
||||
task: The maintenance task being processed
|
||||
|
||||
@@ -27,7 +27,8 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
def create_default_mcp_server_config(
|
||||
host: str, config: 'OpenHandsConfig', user_id: str | None = None
|
||||
) -> tuple[MCPSHTTPServerConfig | None, list[MCPStdioServerConfig]]:
|
||||
"""Create a default MCP server configuration.
|
||||
"""
|
||||
Create a default MCP server configuration.
|
||||
|
||||
Args:
|
||||
host: Host string
|
||||
@@ -35,6 +36,7 @@ class SaaSOpenHandsMCPConfig(OpenHandsMCPConfig):
|
||||
Returns:
|
||||
A tuple containing the default SSE server configuration and a list of MCP stdio server configurations
|
||||
"""
|
||||
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
if user_id:
|
||||
api_key = api_key_store.retrieve_mcp_api_key(user_id)
|
||||
|
||||
@@ -33,7 +33,8 @@ def metrics_app() -> Callable:
|
||||
metrics_callable = make_asgi_app()
|
||||
|
||||
async def wrapped_handler(scope, receive, send):
|
||||
"""Call _update_metrics before serving Prometheus metrics endpoint.
|
||||
"""
|
||||
Call _update_metrics before serving Prometheus metrics endpoint.
|
||||
Not wrapped in a `try`, failing would make metrics endpoint unavailable.
|
||||
"""
|
||||
await _update_metrics()
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from datetime import UTC, datetime
|
||||
from typing import Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import HTTPException, Request, Response, status
|
||||
from fastapi import Request, Response, status
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import SecretStr
|
||||
from server.auth.auth_error import (
|
||||
@@ -13,25 +12,21 @@ from server.auth.auth_error import (
|
||||
)
|
||||
from server.auth.gitlab_sync import schedule_gitlab_repo_sync
|
||||
from server.auth.saas_user_auth import SaasUserAuth, token_manager
|
||||
from server.constants import get_default_litellm_model
|
||||
from server.routes.auth import (
|
||||
get_cookie_domain,
|
||||
get_cookie_samesite,
|
||||
set_response_cookie,
|
||||
)
|
||||
from storage.database import session_maker
|
||||
from storage.subscription_access import SubscriptionAccess
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.server.user_auth.user_auth import AuthType, get_user_auth
|
||||
from openhands.server.utils import config
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
|
||||
|
||||
class SetAuthCookieMiddleware:
|
||||
"""Update the auth cookie with the current authentication state if it was refreshed before sending response to user.
|
||||
|
||||
Deleting invalid cookies is handled by CookieError using FastAPIs standard error handling mechanism.
|
||||
"""
|
||||
Update the auth cookie with the current authentication state if it was refreshed before sending response to user.
|
||||
Deleting invalid cookies is handled by CookieError using FastAPIs standard error handling mechanism
|
||||
"""
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
@@ -177,247 +172,3 @@ class SetAuthCookieMiddleware:
|
||||
await token_manager.logout(user_auth.refresh_token.get_secret_value())
|
||||
except Exception:
|
||||
logger.debug('Error logging out')
|
||||
|
||||
|
||||
class LLMSettingsMiddleware:
|
||||
"""Middleware to validate LLM settings access for enterprise users.
|
||||
|
||||
Intercepts POST requests to /api/settings and validates that non-pro users
|
||||
cannot modify LLM-related settings.
|
||||
"""
|
||||
|
||||
async def __call__(self, request: Request, call_next: Callable):
|
||||
try:
|
||||
logger.warning(
|
||||
f'LLM middleware called for {request.method} {request.url.path}'
|
||||
)
|
||||
|
||||
# Check if this is a POST request to /api/settings
|
||||
if request.method == 'POST' and request.url.path == '/api/settings':
|
||||
logger.warning('LLM middleware intercepting POST /api/settings request')
|
||||
await self._validate_llm_settings_request(request)
|
||||
|
||||
# Continue with the request
|
||||
response: Response = await call_next(request)
|
||||
return response
|
||||
|
||||
except HTTPException:
|
||||
# Re-raise HTTPException (our 403 response)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f'Error in LLM settings middleware: {e}')
|
||||
# Let other errors pass through to be handled by the route
|
||||
fallback_response: Response = await call_next(request)
|
||||
return fallback_response
|
||||
|
||||
async def _validate_llm_settings_request(self, request: Request) -> None:
|
||||
"""Validate LLM settings access for the current request."""
|
||||
try:
|
||||
logger.info(
|
||||
f"LLM settings middleware intercepting POST /api/settings from {request.client.host if request.client else 'unknown'}"
|
||||
)
|
||||
|
||||
# Get user authentication - this will trigger authentication if not already done
|
||||
try:
|
||||
user_auth = await get_user_auth(request)
|
||||
except Exception as e:
|
||||
logger.info(f'No valid user auth found ({e}), letting route handle request')
|
||||
return # No user auth, let the route handle it
|
||||
|
||||
user_id = await user_auth.get_user_id()
|
||||
if not user_id:
|
||||
logger.info('No user ID found, letting route handle request')
|
||||
return # No user ID, let the route handle it
|
||||
|
||||
logger.info(f'Processing settings request for user: {user_id}')
|
||||
|
||||
# Parse the request JSON to get new settings
|
||||
try:
|
||||
settings_data = await request.json()
|
||||
logger.info(f'Parsed settings data keys: {list(settings_data.keys())}')
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid JSON in request body: {e}')
|
||||
return # Invalid JSON, let the route handle it
|
||||
|
||||
# Convert to Settings object for validation
|
||||
try:
|
||||
new_settings = Settings(**settings_data)
|
||||
logger.info('Successfully created Settings object from request data')
|
||||
except Exception as e:
|
||||
logger.warning(f'Invalid settings format: {e}')
|
||||
return # Invalid settings format, let the route handle it
|
||||
|
||||
# Validate LLM settings access by comparing new settings against SaaS defaults
|
||||
await validate_llm_settings_access(user_id, new_settings)
|
||||
logger.info(f'LLM settings validation passed for user {user_id}')
|
||||
|
||||
except HTTPException as e:
|
||||
logger.warning(
|
||||
f'LLM settings validation failed: HTTP {e.status_code} - {e.detail}'
|
||||
)
|
||||
# Re-raise our 403 response
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.warning(f'Unexpected error validating LLM settings request: {e}')
|
||||
# Let other errors pass through
|
||||
|
||||
|
||||
def _get_saas_default_settings() -> Settings:
|
||||
"""Get the default SaaS settings for comparison."""
|
||||
return Settings(
|
||||
language='en',
|
||||
agent='CodeActAgent',
|
||||
enable_proactive_conversation_starters=True,
|
||||
enable_default_condenser=True,
|
||||
condenser_max_size=120,
|
||||
llm_model=get_default_litellm_model(), # litellm_proxy/prod/claude-sonnet-4-20250514
|
||||
confirmation_mode=False,
|
||||
security_analyzer='llm',
|
||||
# Note: llm_api_key and llm_base_url are auto-provisioned for SaaS users,
|
||||
# so we don't include them in defaults - any custom values are changes
|
||||
)
|
||||
|
||||
|
||||
def has_llm_settings_changes(user_settings: Settings, saas_defaults: Settings) -> bool:
|
||||
"""Check if user settings contain changes to LLM-related settings from SaaS defaults."""
|
||||
logger.info(
|
||||
f"Checking LLM settings changes - User settings: {user_settings.model_dump(exclude={'secrets_store'})}"
|
||||
)
|
||||
logger.info(
|
||||
f"Checking LLM settings changes - SaaS defaults: {saas_defaults.model_dump(exclude={'secrets_store'})}"
|
||||
)
|
||||
|
||||
# Core LLM settings - any custom values are changes since SaaS auto-provisions these
|
||||
if (
|
||||
user_settings.llm_model is not None
|
||||
and user_settings.llm_model != saas_defaults.llm_model
|
||||
):
|
||||
logger.warning(
|
||||
f"LLM model change detected: user='{user_settings.llm_model}' vs default='{saas_defaults.llm_model}'"
|
||||
)
|
||||
return True
|
||||
if user_settings.llm_api_key is not None:
|
||||
# Any custom API key is a change (SaaS users get auto-provisioned keys)
|
||||
logger.warning(
|
||||
f'LLM API key change detected: user has custom key (length={len(user_settings.llm_api_key.get_secret_value()) if user_settings.llm_api_key else 0})'
|
||||
)
|
||||
return True
|
||||
if user_settings.llm_base_url is not None and user_settings.llm_base_url != '':
|
||||
# Any non-empty base URL is a change (SaaS users get auto-provisioned URL)
|
||||
logger.warning(
|
||||
f"LLM base URL change detected: user='{user_settings.llm_base_url}' (non-empty)"
|
||||
)
|
||||
return True
|
||||
|
||||
# LLM-related configuration settings
|
||||
if user_settings.agent is not None and user_settings.agent != saas_defaults.agent:
|
||||
logger.warning(
|
||||
f"Agent change detected: user='{user_settings.agent}' vs default='{saas_defaults.agent}'"
|
||||
)
|
||||
return True
|
||||
if (
|
||||
user_settings.confirmation_mode is not None
|
||||
and user_settings.confirmation_mode != saas_defaults.confirmation_mode
|
||||
):
|
||||
logger.warning(
|
||||
f'Confirmation mode change detected: user={user_settings.confirmation_mode} vs default={saas_defaults.confirmation_mode}'
|
||||
)
|
||||
return True
|
||||
if (
|
||||
user_settings.security_analyzer is not None
|
||||
and user_settings.security_analyzer != saas_defaults.security_analyzer
|
||||
and user_settings.security_analyzer != ''
|
||||
): # Handle empty string as None
|
||||
logger.warning(
|
||||
f"Security analyzer change detected: user='{user_settings.security_analyzer}' vs default='{saas_defaults.security_analyzer}'"
|
||||
)
|
||||
return True
|
||||
if user_settings.max_budget_per_task is not None:
|
||||
logger.warning(
|
||||
f'Max budget per task change detected: user={user_settings.max_budget_per_task}'
|
||||
)
|
||||
return True
|
||||
if user_settings.max_iterations is not None:
|
||||
logger.warning(
|
||||
f'Max iterations change detected: user={user_settings.max_iterations}'
|
||||
)
|
||||
return True
|
||||
|
||||
# Memory/context management settings
|
||||
if user_settings.enable_default_condenser != saas_defaults.enable_default_condenser:
|
||||
logger.warning(
|
||||
f'Enable default condenser change detected: user={user_settings.enable_default_condenser} vs default={saas_defaults.enable_default_condenser}'
|
||||
)
|
||||
return True
|
||||
if (
|
||||
user_settings.condenser_max_size is not None
|
||||
and user_settings.condenser_max_size != saas_defaults.condenser_max_size
|
||||
):
|
||||
logger.warning(
|
||||
f'Condenser max size change detected: user={user_settings.condenser_max_size} vs default={saas_defaults.condenser_max_size}'
|
||||
)
|
||||
return True
|
||||
|
||||
logger.info('No LLM settings changes detected')
|
||||
return False
|
||||
|
||||
|
||||
def _has_active_subscription(user_id: str) -> bool:
|
||||
"""Check if user has an active subscription (pro user)."""
|
||||
with session_maker() as session:
|
||||
now = datetime.now(UTC)
|
||||
logger.info(f'Checking subscription for user {user_id} at time {now}')
|
||||
|
||||
subscription_access = (
|
||||
session.query(SubscriptionAccess)
|
||||
.filter(SubscriptionAccess.status == 'ACTIVE')
|
||||
.filter(SubscriptionAccess.user_id == user_id)
|
||||
.filter(SubscriptionAccess.start_at <= now)
|
||||
.filter(SubscriptionAccess.end_at >= now)
|
||||
.first()
|
||||
)
|
||||
|
||||
if subscription_access:
|
||||
logger.info(
|
||||
f'Found active subscription for user {user_id}: starts={subscription_access.start_at}, ends={subscription_access.end_at}'
|
||||
)
|
||||
else:
|
||||
logger.info(f'No active subscription found for user {user_id}')
|
||||
|
||||
return subscription_access is not None
|
||||
|
||||
|
||||
async def validate_llm_settings_access(
|
||||
user_id: str, user_settings: Settings, saas_defaults: Settings | None = None
|
||||
) -> None:
|
||||
"""Validate that user has permission to change LLM settings.
|
||||
|
||||
Raises HTTPException with 403 status if non-pro user tries to change LLM settings.
|
||||
"""
|
||||
if saas_defaults is None:
|
||||
saas_defaults = _get_saas_default_settings()
|
||||
|
||||
logger.info(f'Validating LLM settings access for user: {user_id}')
|
||||
|
||||
# Check if user is trying to change LLM settings
|
||||
if has_llm_settings_changes(user_settings, saas_defaults):
|
||||
logger.warning(f'User {user_id} attempting to change LLM settings')
|
||||
|
||||
# Check if user has active subscription (is pro user)
|
||||
has_subscription = _has_active_subscription(user_id)
|
||||
logger.info(
|
||||
f"User {user_id} subscription status: {'active' if has_subscription else 'none'}"
|
||||
)
|
||||
|
||||
if not has_subscription:
|
||||
logger.warning(
|
||||
f'Blocking non-pro user {user_id} from changing LLM settings'
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail='LLM settings can only be modified by pro users',
|
||||
)
|
||||
else:
|
||||
logger.info(f'Allowing pro user {user_id} to change LLM settings')
|
||||
else:
|
||||
logger.info(f'User {user_id} making non-LLM settings changes only - allowing')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Usage:
|
||||
"""
|
||||
Usage:
|
||||
|
||||
Call setup_rate_limit_handler on your FastAPI app to add the exception handler
|
||||
|
||||
@@ -22,7 +23,9 @@ from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
def setup_rate_limit_handler(app: Starlette):
|
||||
"""Add exception handler that"""
|
||||
"""
|
||||
Add exception handler that
|
||||
"""
|
||||
app.add_exception_handler(RateLimitException, _rate_limit_exceeded_handler)
|
||||
|
||||
|
||||
@@ -53,7 +56,8 @@ class RateLimiter:
|
||||
self.limit_items = limits.parse_many(windows)
|
||||
|
||||
async def hit(self, namespace: str, key: str):
|
||||
"""Raises RateLimitException when limit is hit.
|
||||
"""
|
||||
Raises RateLimitException when limit is hit.
|
||||
Logs and swallows exceptions and logs if lookup fails.
|
||||
"""
|
||||
for lim in self.limit_items:
|
||||
@@ -76,7 +80,9 @@ class RateLimiter:
|
||||
async def _get_stats_as_result(
|
||||
self, lim: limits.RateLimitItem, namespace: str, key: str
|
||||
) -> RateLimitResult:
|
||||
"""Lookup rate limit window stats and return a RateLimitResult with the data needed for response headers."""
|
||||
"""
|
||||
Lookup rate limit window stats and return a RateLimitResult with the data needed for response headers.
|
||||
"""
|
||||
stats: limits.WindowStats = await self.strategy.get_window_stats(
|
||||
lim, namespace, key
|
||||
)
|
||||
@@ -91,7 +97,8 @@ class RateLimiter:
|
||||
|
||||
|
||||
def create_redis_rate_limiter(windows: str) -> RateLimiter:
|
||||
"""Create a RateLimiter with the Redis backend and "Fixed Window" strategy.
|
||||
"""
|
||||
Create a RateLimiter with the Redis backend and "Fixed Window" strategy.
|
||||
windows arg example: "10/second; 100/minute"
|
||||
"""
|
||||
backend = limits.aio.storage.RedisStorage(f'async+{get_redis_authed_url()}')
|
||||
@@ -100,7 +107,9 @@ def create_redis_rate_limiter(windows: str) -> RateLimiter:
|
||||
|
||||
|
||||
class RateLimitException(HTTPException):
|
||||
"""exception raised when a rate limit is hit."""
|
||||
"""
|
||||
exception raised when a rate limit is hit.
|
||||
"""
|
||||
|
||||
result: RateLimitResult
|
||||
|
||||
@@ -112,7 +121,9 @@ class RateLimitException(HTTPException):
|
||||
|
||||
|
||||
def _rate_limit_exceeded_handler(request: Request, exc: Exception) -> Response:
|
||||
"""Build a simple JSON response that includes the details of the rate limit that was hit."""
|
||||
"""
|
||||
Build a simple JSON response that includes the details of the rate limit that was hit.
|
||||
"""
|
||||
logger.info(exc.__class__.__name__)
|
||||
if isinstance(exc, RateLimitException):
|
||||
response = JSONResponse(
|
||||
|
||||
@@ -174,19 +174,17 @@ async def keycloak_callback(
|
||||
posthog_user_id = f'FEATURE_{user_id}' if IS_FEATURE_ENV else user_id
|
||||
|
||||
try:
|
||||
posthog.identify(
|
||||
posthog_user_id,
|
||||
{
|
||||
'$set': {
|
||||
'user_id': posthog_user_id, # Explicitly set as property
|
||||
'original_user_id': user_id, # Store the original user_id
|
||||
'is_feature_env': IS_FEATURE_ENV, # Track if this is a feature environment
|
||||
}
|
||||
posthog.set(
|
||||
distinct_id=posthog_user_id,
|
||||
properties={
|
||||
'user_id': posthog_user_id,
|
||||
'original_user_id': user_id,
|
||||
'is_feature_env': IS_FEATURE_ENV,
|
||||
},
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'auth:posthog_identify:failed',
|
||||
'auth:posthog_set:failed',
|
||||
extra={
|
||||
'user_id': user_id,
|
||||
'error': str(e),
|
||||
|
||||
@@ -17,7 +17,8 @@ ADD_DEBUGGING_ROUTES = os.environ.get('ADD_DEBUGGING_ROUTES') in ('1', 'true')
|
||||
|
||||
|
||||
def add_debugging_routes(api: FastAPI):
|
||||
"""# HERE BE DRAGONS!
|
||||
"""
|
||||
# HERE BE DRAGONS!
|
||||
Chaos scripts for debugging and stress testing the system.
|
||||
|
||||
This module contains endpoints that deliberately stress test and potentially break
|
||||
@@ -30,6 +31,7 @@ def add_debugging_routes(api: FastAPI):
|
||||
- Testing async vs sync database access patterns
|
||||
- Simulating event loop blocking
|
||||
"""
|
||||
|
||||
if not ADD_DEBUGGING_ROUTES:
|
||||
return
|
||||
|
||||
@@ -37,7 +39,8 @@ def add_debugging_routes(api: FastAPI):
|
||||
|
||||
@chaos_router.get('/pool-stats')
|
||||
def pool_stats() -> dict[str, int]:
|
||||
"""Returns current database connection pool statistics.
|
||||
"""
|
||||
Returns current database connection pool statistics.
|
||||
|
||||
This endpoint provides real-time metrics about the SQLAlchemy connection pool:
|
||||
- checked_in: Number of connections currently available in the pool
|
||||
@@ -52,7 +55,8 @@ def add_debugging_routes(api: FastAPI):
|
||||
|
||||
@chaos_router.get('/test-db')
|
||||
def test_db(num_tests: int = 10, delay: int = 1) -> str:
|
||||
"""Stress tests the database connection pool using multiple threads.
|
||||
"""
|
||||
Stress tests the database connection pool using multiple threads.
|
||||
|
||||
Creates multiple threads that each open a database connection, perform a query,
|
||||
hold the connection for the specified delay, and then release it.
|
||||
@@ -73,7 +77,8 @@ def add_debugging_routes(api: FastAPI):
|
||||
|
||||
@chaos_router.get('/a-test-db')
|
||||
async def a_chaos_monkey(num_tests: int = 10, delay: int = 1) -> str:
|
||||
"""Stress tests the async database connection pool.
|
||||
"""
|
||||
Stress tests the async database connection pool.
|
||||
|
||||
Similar to /test-db but uses async connections and coroutines instead of threads.
|
||||
This endpoint helps compare the behavior of async vs sync connection pools
|
||||
@@ -88,7 +93,8 @@ def add_debugging_routes(api: FastAPI):
|
||||
|
||||
@chaos_router.get('/lock-main-runloop')
|
||||
async def lock_main_runloop(duration: int = 10) -> str:
|
||||
"""Deliberately blocks the main asyncio event loop.
|
||||
"""
|
||||
Deliberately blocks the main asyncio event loop.
|
||||
|
||||
This endpoint uses a synchronous sleep operation in an async function,
|
||||
which blocks the entire FastAPI server's event loop for the specified duration.
|
||||
@@ -107,7 +113,8 @@ def add_debugging_routes(api: FastAPI):
|
||||
|
||||
|
||||
def _db_check(delay: int):
|
||||
"""Executes a single request against the database with an artificial delay.
|
||||
"""
|
||||
Executes a single request against the database with an artificial delay.
|
||||
|
||||
This helper function:
|
||||
1. Opens a database connection from the pool
|
||||
@@ -134,7 +141,8 @@ def _db_check(delay: int):
|
||||
|
||||
|
||||
async def _a_db_check(delay: int):
|
||||
"""Executes a single async request against the database with an artificial delay.
|
||||
"""
|
||||
Executes a single async request against the database with an artificial delay.
|
||||
|
||||
This is the async version of _db_check that:
|
||||
1. Opens an async database connection from the pool
|
||||
|
||||
@@ -73,7 +73,8 @@ class FeedbackRequest(BaseModel):
|
||||
|
||||
@router.post('/conversation', status_code=status.HTTP_201_CREATED)
|
||||
async def submit_conversation_feedback(feedback: FeedbackRequest):
|
||||
"""Submit feedback for a conversation.
|
||||
"""
|
||||
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.
|
||||
@@ -107,7 +108,8 @@ async def submit_conversation_feedback(feedback: FeedbackRequest):
|
||||
|
||||
@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.
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -16,7 +16,8 @@ GITHUB_PROXY_ENDPOINTS = bool(os.environ.get('GITHUB_PROXY_ENDPOINTS'))
|
||||
|
||||
|
||||
def add_github_proxy_routes(app: FastAPI):
|
||||
"""Authentication endpoints for feature branches.
|
||||
"""
|
||||
Authentication endpoints for feature branches.
|
||||
|
||||
# Requirements
|
||||
* This should never be enabled in prod!
|
||||
|
||||
@@ -138,6 +138,7 @@ async def saas_search_repositories(
|
||||
per_page: int = 5,
|
||||
sort: str = 'stars',
|
||||
order: str = 'desc',
|
||||
selected_provider: ProviderType | None = None,
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE | None = Depends(get_provider_tokens),
|
||||
access_token: SecretStr | None = Depends(get_access_token),
|
||||
user_id: str | None = Depends(get_user_id),
|
||||
@@ -155,6 +156,7 @@ async def saas_search_repositories(
|
||||
per_page=per_page,
|
||||
sort=sort,
|
||||
order=order,
|
||||
selected_provider=selected_provider,
|
||||
provider_tokens=provider_tokens,
|
||||
access_token=access_token,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -23,10 +23,14 @@ AGENT_SESSION_START_HISTOGRAM = Histogram(
|
||||
|
||||
|
||||
class SaaSMonitoringListener(MonitoringListener):
|
||||
"""Forward app signals to Prometheus."""
|
||||
"""
|
||||
Forward app signals to Prometheus.
|
||||
"""
|
||||
|
||||
def on_session_event(self, event: Event) -> None:
|
||||
"""Track metrics about events being added to a Session's EventStream."""
|
||||
"""
|
||||
Track metrics about events being added to a Session's EventStream.
|
||||
"""
|
||||
if (
|
||||
isinstance(event, AgentStateChangedObservation)
|
||||
and event.agent_state == AgentState.ERROR
|
||||
@@ -38,7 +42,8 @@ class SaaSMonitoringListener(MonitoringListener):
|
||||
)
|
||||
|
||||
def on_agent_session_start(self, success: bool, duration: float) -> None:
|
||||
"""Track an agent session start.
|
||||
"""
|
||||
Track an agent session start.
|
||||
Success is true if startup completed without error.
|
||||
Duration is start time in seconds observed by AgentSession.
|
||||
"""
|
||||
@@ -53,7 +58,8 @@ class SaaSMonitoringListener(MonitoringListener):
|
||||
)
|
||||
|
||||
def on_create_conversation(self) -> None:
|
||||
"""Track the beginning of conversation creation.
|
||||
"""
|
||||
Track the beginning of conversation creation.
|
||||
Does not currently capture whether it succeed.
|
||||
"""
|
||||
CREATE_CONVERSATION_COUNT.inc()
|
||||
|
||||
@@ -60,9 +60,14 @@ from openhands.utils.utils import create_registry_and_conversation_stats
|
||||
RUNTIME_URL_PATTERN = os.getenv(
|
||||
'RUNTIME_URL_PATTERN', 'https://{runtime_id}.prod-runtime.all-hands.dev'
|
||||
)
|
||||
RUNTIME_ROUTING_MODE = os.getenv('RUNTIME_ROUTING_MODE', 'subdomain').lower()
|
||||
|
||||
# Pattern for base URL for the runtime
|
||||
RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + '/api/conversations/{conversation_id}'
|
||||
RUNTIME_CONVERSATION_URL = RUNTIME_URL_PATTERN + (
|
||||
'/runtime/api/conversations/{conversation_id}'
|
||||
if RUNTIME_ROUTING_MODE == 'path'
|
||||
else '/api/conversations/{conversation_id}'
|
||||
)
|
||||
|
||||
# Time in seconds before a Redis entry is considered expired if not refreshed
|
||||
_REDIS_ENTRY_TIMEOUT_SECONDS = 300
|
||||
@@ -131,7 +136,9 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
async def get_running_agent_loops(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
) -> set[str]:
|
||||
"""Get the running agent loops directly from the remote runtime."""
|
||||
"""
|
||||
Get the running agent loops directly from the remote runtime.
|
||||
"""
|
||||
conversation_ids = await self._get_all_running_conversation_ids()
|
||||
|
||||
if filter_to_sids is not None:
|
||||
@@ -342,18 +349,48 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
api_url: str,
|
||||
custom_secrets: MappingProxyType[str, Any] | None,
|
||||
):
|
||||
"""Setup custom secrets for the nested conversation."""
|
||||
"""Setup custom secrets for the nested conversation.
|
||||
|
||||
Note: When resuming conversations, secrets may already exist in the runtime.
|
||||
We check for specific duplicate error messages to handle this case gracefully.
|
||||
"""
|
||||
if custom_secrets:
|
||||
for key, secret in custom_secrets.items():
|
||||
response = await client.post(
|
||||
f'{api_url}/api/secrets',
|
||||
json={
|
||||
'name': key,
|
||||
'description': secret.description,
|
||||
'value': secret.secret.get_secret_value(),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
try:
|
||||
response = await client.post(
|
||||
f'{api_url}/api/secrets',
|
||||
json={
|
||||
'name': key,
|
||||
'description': secret.description,
|
||||
'value': secret.secret.get_secret_value(),
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
logger.debug(f'Successfully created secret: {key}')
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 400:
|
||||
# Only ignore if it's actually a duplicate error
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_msg = error_data.get('message', '')
|
||||
# The API returns: "Secret {secret_name} already exists"
|
||||
if 'already exists' in error_msg:
|
||||
logger.info(
|
||||
f'Secret "{key}" already exists, continuing - ignoring duplicate',
|
||||
extra={'api_url': api_url},
|
||||
)
|
||||
continue
|
||||
except (KeyError, ValueError, TypeError):
|
||||
pass # If we can't parse JSON, fall through to re-raise
|
||||
# Re-raise all other errors (including non-duplicate 400s)
|
||||
logger.error(
|
||||
f'Failed to setup secret "{key}": HTTP {e.response.status_code}',
|
||||
extra={
|
||||
'api_url': api_url,
|
||||
'response_text': e.response.text[:200],
|
||||
},
|
||||
)
|
||||
raise
|
||||
|
||||
def _get_mcp_config(self, user_id: str) -> MCPConfig | None:
|
||||
api_key_store = ApiKeyStore.get_instance()
|
||||
@@ -480,7 +517,10 @@ class SaasNestedConversationManager(ConversationManager):
|
||||
)
|
||||
|
||||
def _get_user_id_from_conversation(self, conversation_id: str) -> str:
|
||||
"""Get user_id from conversation_id."""
|
||||
"""
|
||||
Get user_id from conversation_id.
|
||||
"""
|
||||
|
||||
with session_maker() as session:
|
||||
conversation_metadata = (
|
||||
session.query(StoredConversationMetadata)
|
||||
|
||||
@@ -34,7 +34,8 @@ 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.
|
||||
"""
|
||||
Process a conversation event and invoke any registered callbacks.
|
||||
|
||||
Args:
|
||||
user_id: The user ID associated with the conversation
|
||||
@@ -71,7 +72,8 @@ async def process_event(
|
||||
async def invoke_conversation_callbacks(
|
||||
conversation_id: str, observation: AgentStateChangedObservation
|
||||
):
|
||||
"""Load and invoke all active callbacks for a conversation.
|
||||
"""
|
||||
Load and invoke all active callbacks for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to process callbacks for
|
||||
@@ -117,7 +119,8 @@ async def invoke_conversation_callbacks(
|
||||
|
||||
|
||||
def update_conversation_metadata(conversation_id: str, content: dict):
|
||||
"""Update conversation metadata with new content.
|
||||
"""
|
||||
Update conversation metadata with new content.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to update
|
||||
@@ -156,7 +159,8 @@ def update_conversation_metadata(conversation_id: str, content: dict):
|
||||
def register_callback_processor(
|
||||
conversation_id: str, processor: ConversationCallbackProcessor
|
||||
) -> int:
|
||||
"""Register a callback processor for a conversation.
|
||||
"""
|
||||
Register a callback processor for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID to register the callback for
|
||||
@@ -178,7 +182,8 @@ def register_callback_processor(
|
||||
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.
|
||||
"""
|
||||
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.
|
||||
@@ -258,7 +263,8 @@ def update_active_working_seconds(
|
||||
|
||||
|
||||
def update_agent_state(user_id: str, conversation_id: str, content: bytes):
|
||||
"""Update agent state file for a conversation.
|
||||
"""
|
||||
Update agent state file for a conversation.
|
||||
|
||||
Args:
|
||||
user_id: The user ID associated with the conversation
|
||||
|
||||
@@ -3,7 +3,9 @@ from storage.base import Base
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""Represents an API key for a user."""
|
||||
"""
|
||||
Represents an API key for a user.
|
||||
"""
|
||||
|
||||
__tablename__ = 'api_keys'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -73,7 +73,8 @@ class AuthTokenStore:
|
||||
]
|
||||
| None = None,
|
||||
) -> Dict[str, str | int] | None:
|
||||
"""Load authentication tokens from the database and refresh them if necessary.
|
||||
"""
|
||||
Load authentication tokens from the database and refresh them if necessary.
|
||||
|
||||
This method retrieves the current authentication tokens for the user and checks if they have expired.
|
||||
It uses the provided `check_expiration_and_refresh` function to determine if the tokens need
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Unified SQLAlchemy declarative base for all models."""
|
||||
"""
|
||||
Unified SQLAlchemy declarative base for all models.
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ from storage.base import Base
|
||||
|
||||
|
||||
class BillingSession(Base): # type: ignore
|
||||
"""Represents a Stripe billing session for credit purchases.
|
||||
"""
|
||||
Represents a Stripe billing session for credit purchases.
|
||||
Tracks the status of payment transactions and associated user information.
|
||||
"""
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class ConversationCallbackProcessor(BaseModel, ABC):
|
||||
"""Abstract base class for conversation callback processors.
|
||||
"""
|
||||
Abstract base class for conversation callback processors.
|
||||
|
||||
Conversation processors are invoked when events occur in a conversation
|
||||
to perform additional processing, notifications, or integrations.
|
||||
@@ -34,7 +35,8 @@ class ConversationCallbackProcessor(BaseModel, ABC):
|
||||
callback: ConversationCallback,
|
||||
observation: AgentStateChangedObservation,
|
||||
) -> None:
|
||||
"""Process a conversation event.
|
||||
"""
|
||||
Process a conversation event.
|
||||
|
||||
Args:
|
||||
conversation_id: The ID of the conversation to process
|
||||
@@ -52,7 +54,9 @@ class CallbackStatus(Enum):
|
||||
|
||||
|
||||
class ConversationCallback(Base): # type: ignore
|
||||
"""Model for storing conversation callbacks that process conversation events."""
|
||||
"""
|
||||
Model for storing conversation callbacks that process conversation events.
|
||||
"""
|
||||
|
||||
__tablename__ = 'conversation_callbacks'
|
||||
|
||||
@@ -81,7 +85,8 @@ class ConversationCallback(Base): # type: ignore
|
||||
)
|
||||
|
||||
def get_processor(self) -> ConversationCallbackProcessor:
|
||||
"""Get the processor instance from the stored processor type and JSON data.
|
||||
"""
|
||||
Get the processor instance from the stored processor type and JSON data.
|
||||
|
||||
Returns:
|
||||
ConversationCallbackProcessor: The processor instance
|
||||
@@ -94,7 +99,8 @@ class ConversationCallback(Base): # type: ignore
|
||||
return processor
|
||||
|
||||
def set_processor(self, processor: ConversationCallbackProcessor) -> None:
|
||||
"""Set the processor instance, storing its type and JSON representation.
|
||||
"""
|
||||
Set the processor instance, storing its type and JSON representation.
|
||||
|
||||
Args:
|
||||
processor: The ConversationCallbackProcessor instance to store
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Database model for experiment assignments.
|
||||
"""
|
||||
Database model for experiment assignments.
|
||||
|
||||
This model tracks which experiments a conversation is assigned to and what variant
|
||||
they received from PostHog feature flags.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Store for managing experiment assignments.
|
||||
"""
|
||||
Store for managing experiment assignments.
|
||||
|
||||
This store handles creating and updating experiment assignments for conversations.
|
||||
"""
|
||||
@@ -19,7 +20,8 @@ class ExperimentAssignmentStore:
|
||||
experiment_name: str,
|
||||
variant: str,
|
||||
) -> None:
|
||||
"""Update the variant for a specific experiment.
|
||||
"""
|
||||
Update the variant for a specific experiment.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
|
||||
@@ -3,7 +3,9 @@ from storage.base import Base
|
||||
|
||||
|
||||
class GithubAppInstallation(Base): # type: ignore
|
||||
"""Represents a Github App Installation with associated token."""
|
||||
"""
|
||||
Represents a Github App Installation with associated token.
|
||||
"""
|
||||
|
||||
__tablename__ = 'github_app_installations'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -13,7 +13,9 @@ class WebhookStatus(IntEnum):
|
||||
|
||||
|
||||
class GitlabWebhook(Base): # type: ignore
|
||||
"""Represents a Gitlab webhook configuration for a repository or group."""
|
||||
"""
|
||||
Represents a Gitlab webhook configuration for a repository or group.
|
||||
"""
|
||||
|
||||
__tablename__ = 'gitlab_webhook'
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
@@ -86,6 +86,7 @@ class GitlabWebhookStore:
|
||||
Raises:
|
||||
ValueError: If neither project_id nor group_id is provided, or if both are provided.
|
||||
"""
|
||||
|
||||
resource_type, resource_id = GitlabWebhookStore.determine_resource_type(webhook)
|
||||
async with self.a_session_maker() as session:
|
||||
async with session.begin():
|
||||
@@ -109,6 +110,7 @@ class GitlabWebhookStore:
|
||||
Raises:
|
||||
ValueError: If neither project_id nor group_id is provided, or if both are provided.
|
||||
"""
|
||||
|
||||
resource_type, resource_id = GitlabWebhookStore.determine_resource_type(webhook)
|
||||
|
||||
logger.info(
|
||||
@@ -182,6 +184,7 @@ class GitlabWebhookStore:
|
||||
Returns:
|
||||
List of GitlabWebhook objects that need processing
|
||||
"""
|
||||
|
||||
async with self.a_session_maker() as session:
|
||||
query = (
|
||||
select(GitlabWebhook)
|
||||
@@ -195,7 +198,9 @@ class GitlabWebhookStore:
|
||||
return list(webhooks)
|
||||
|
||||
async def get_webhook_secret(self, webhook_uuid: str, user_id: str) -> str | None:
|
||||
"""Get's webhook secret given the webhook uuid and admin keycloak user id"""
|
||||
"""
|
||||
Get's webhook secret given the webhook uuid and admin keycloak user id
|
||||
"""
|
||||
async with self.a_session_maker() as session:
|
||||
query = (
|
||||
select(GitlabWebhook)
|
||||
|
||||
@@ -23,6 +23,7 @@ class JiraDcIntegrationStore:
|
||||
status: str = 'active',
|
||||
) -> JiraDcWorkspace:
|
||||
"""Create a new Jira DC workspace with encrypted sensitive data."""
|
||||
|
||||
with session_maker() as session:
|
||||
workspace = JiraDcWorkspace(
|
||||
name=name.lower(),
|
||||
@@ -82,6 +83,7 @@ class JiraDcIntegrationStore:
|
||||
status: str = 'active',
|
||||
) -> JiraDcUser:
|
||||
"""Create a new Jira DC workspace link."""
|
||||
|
||||
jira_dc_user = JiraDcUser(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
jira_dc_user_id=jira_dc_user_id,
|
||||
@@ -123,6 +125,7 @@ class JiraDcIntegrationStore:
|
||||
self, keycloak_user_id: str
|
||||
) -> Optional[JiraDcUser]:
|
||||
"""Retrieve user by Keycloak user ID."""
|
||||
|
||||
with session_maker() as session:
|
||||
return (
|
||||
session.query(JiraDcUser)
|
||||
@@ -181,6 +184,7 @@ class JiraDcIntegrationStore:
|
||||
self, keycloak_user_id: str, status: str
|
||||
) -> JiraDcUser:
|
||||
"""Update the status of a Jira DC user mapping."""
|
||||
|
||||
with session_maker() as session:
|
||||
user = (
|
||||
session.query(JiraDcUser)
|
||||
|
||||
@@ -24,6 +24,7 @@ class JiraIntegrationStore:
|
||||
status: str = 'active',
|
||||
) -> JiraWorkspace:
|
||||
"""Create a new Jira workspace with encrypted sensitive data."""
|
||||
|
||||
workspace = JiraWorkspace(
|
||||
name=name.lower(),
|
||||
jira_cloud_id=jira_cloud_id,
|
||||
@@ -90,6 +91,7 @@ class JiraIntegrationStore:
|
||||
status: str = 'active',
|
||||
) -> JiraUser:
|
||||
"""Create a new Jira workspace link."""
|
||||
|
||||
jira_user = JiraUser(
|
||||
keycloak_user_id=keycloak_user_id,
|
||||
jira_user_id=jira_user_id,
|
||||
|
||||
@@ -24,6 +24,7 @@ class LinearIntegrationStore:
|
||||
status: str = 'active',
|
||||
) -> LinearWorkspace:
|
||||
"""Create a new Linear workspace with encrypted sensitive data."""
|
||||
|
||||
workspace = LinearWorkspace(
|
||||
name=name.lower(),
|
||||
linear_org_id=linear_org_id,
|
||||
|
||||
@@ -15,7 +15,8 @@ from openhands.utils.import_utils import get_impl
|
||||
|
||||
|
||||
class MaintenanceTaskProcessor(BaseModel, ABC):
|
||||
"""Abstract base class for maintenance task processors.
|
||||
"""
|
||||
Abstract base class for maintenance task processors.
|
||||
|
||||
Maintenance processors are invoked to perform background maintenance
|
||||
tasks such as upgrading user settings, cleaning up data, etc.
|
||||
@@ -30,7 +31,8 @@ class MaintenanceTaskProcessor(BaseModel, ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def __call__(self, task: MaintenanceTask) -> dict:
|
||||
"""Process a maintenance task.
|
||||
"""
|
||||
Process a maintenance task.
|
||||
|
||||
Args:
|
||||
task: The maintenance task to process
|
||||
@@ -51,7 +53,9 @@ class MaintenanceTaskStatus(Enum):
|
||||
|
||||
|
||||
class MaintenanceTask(Base): # type: ignore
|
||||
"""Model for storing maintenance tasks that perform background operations."""
|
||||
"""
|
||||
Model for storing maintenance tasks that perform background operations.
|
||||
"""
|
||||
|
||||
__tablename__ = 'maintenance_tasks'
|
||||
|
||||
@@ -79,7 +83,8 @@ class MaintenanceTask(Base): # type: ignore
|
||||
)
|
||||
|
||||
def get_processor(self) -> MaintenanceTaskProcessor:
|
||||
"""Get the processor instance from the stored processor type and JSON data.
|
||||
"""
|
||||
Get the processor instance from the stored processor type and JSON data.
|
||||
|
||||
Returns:
|
||||
MaintenanceTaskProcessor: The processor instance
|
||||
@@ -92,7 +97,8 @@ class MaintenanceTask(Base): # type: ignore
|
||||
return processor
|
||||
|
||||
def set_processor(self, processor: MaintenanceTaskProcessor) -> None:
|
||||
"""Set the processor instance, storing its type and JSON representation.
|
||||
"""
|
||||
Set the processor instance, storing its type and JSON representation.
|
||||
|
||||
Args:
|
||||
processor: The MaintenanceTaskProcessor instance to store
|
||||
|
||||
@@ -13,7 +13,9 @@ from storage.base import Base
|
||||
|
||||
|
||||
class OpenhandsPR(Base): # type: ignore
|
||||
"""Represents a pull request created by OpenHands."""
|
||||
"""
|
||||
Represents a pull request created by OpenHands.
|
||||
"""
|
||||
|
||||
__tablename__ = 'openhands_prs'
|
||||
id = Column(Integer, Identity(), primary_key=True)
|
||||
|
||||
@@ -15,7 +15,9 @@ class OpenhandsPRStore:
|
||||
session_maker: sessionmaker
|
||||
|
||||
def insert_pr(self, pr: OpenhandsPR) -> None:
|
||||
"""Insert a new PR or delete and recreate if repo_id and pr_number already exist."""
|
||||
"""
|
||||
Insert a new PR or delete and recreate if repo_id and pr_number already exist.
|
||||
"""
|
||||
with self.session_maker() as session:
|
||||
# Check if PR already exists
|
||||
existing_pr = (
|
||||
@@ -37,7 +39,8 @@ class OpenhandsPRStore:
|
||||
session.commit()
|
||||
|
||||
def increment_process_attempts(self, repo_id: str, pr_number: int) -> bool:
|
||||
"""Increment the process attempts counter for a PR.
|
||||
"""
|
||||
Increment the process attempts counter for a PR.
|
||||
|
||||
Args:
|
||||
repo_id: Repository identifier
|
||||
@@ -72,7 +75,8 @@ class OpenhandsPRStore:
|
||||
num_openhands_review_comments: int,
|
||||
num_openhands_general_comments: int,
|
||||
) -> bool:
|
||||
"""Update OpenHands statistics for a PR with row-level locking and timestamp validation.
|
||||
"""
|
||||
Update OpenHands statistics for a PR with row-level locking and timestamp validation.
|
||||
|
||||
Args:
|
||||
repo_id: Repository identifier
|
||||
@@ -122,7 +126,8 @@ class OpenhandsPRStore:
|
||||
def get_unprocessed_prs(
|
||||
self, limit: int = 50, max_retries: int = 3
|
||||
) -> list[OpenhandsPR]:
|
||||
"""Get unprocessed PR entries from the OpenhandsPR table.
|
||||
"""
|
||||
Get unprocessed PR entries from the OpenhandsPR table.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of PRs to retrieve (default: 50)
|
||||
|
||||
@@ -34,7 +34,8 @@ class ProactiveConversationStore:
|
||||
pr_number: int,
|
||||
get_all_workflows: Callable,
|
||||
) -> WorkflowRunGroup | None:
|
||||
"""1. Get the workflow based on repo_id, pr_number, commit
|
||||
"""
|
||||
1. Get the workflow based on repo_id, pr_number, commit
|
||||
2. If the field doesn't exist
|
||||
- Fetch the workflow statuses and store them
|
||||
- Create a new record
|
||||
@@ -44,6 +45,7 @@ class ProactiveConversationStore:
|
||||
This method uses an explicit transaction with row-level locking to ensure
|
||||
thread safety when multiple processes access the same database rows.
|
||||
"""
|
||||
|
||||
should_send = False
|
||||
provider_repo_id = self.get_repo_id(provider, repo_id)
|
||||
|
||||
@@ -129,12 +131,14 @@ class ProactiveConversationStore:
|
||||
return final_workflow_group
|
||||
|
||||
async def clean_old_convos(self, older_than_minutes=30):
|
||||
"""Clean up proactive conversation records that are older than the specified time.
|
||||
"""
|
||||
Clean up proactive conversation records that are older than the specified time.
|
||||
|
||||
Args:
|
||||
older_than_minutes: Number of minutes. Records older than this will be deleted.
|
||||
Defaults to 30 minutes.
|
||||
"""
|
||||
|
||||
# Calculate the cutoff time (current time - older_than_minutes)
|
||||
cutoff_time = datetime.now(UTC) - timedelta(minutes=older_than_minutes)
|
||||
|
||||
|
||||
@@ -15,7 +15,8 @@ class RepositoryStore:
|
||||
config: OpenHandsConfig
|
||||
|
||||
def store_projects(self, repositories: list[StoredRepository]) -> None:
|
||||
"""Store repositories in database
|
||||
"""
|
||||
Store repositories in database
|
||||
|
||||
1. Make sure to store repositories if its ID doesn't exist
|
||||
2. If repository ID already exists, make sure to only update the repo is_public and repo_name fields
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user