Compare commits

..

19 Commits

Author SHA1 Message Date
openhands
f4fcd51b3a Implement concurrent event fetching with batch size 2025-03-20 20:16:46 +00:00
Xingyao Wang
9bd1992738 Remove download workspace and download files buttons (#7333)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-21 03:26:30 +08:00
diwu-sf
3856a896ea fix file chunking corruption (#7338) 2025-03-20 15:21:36 -04:00
Rohit Malhotra
b0030d3a2b [Bug]: Use json dumps instead of str repr to prevent escape character mismatches (#7369) 2025-03-20 10:33:15 -04:00
sp.wack
d76477099c chore(frontend): Hardcode feature flag values (#7360)
Co-authored-by: Robert Brennan <accounts@rbren.io>
2025-03-20 13:36:49 +00:00
tawago
3e3b2aaa5c Rename --repo argument to --selected-repo to avoid confusion in the resolver workflow (#7287)
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
2025-03-20 05:01:00 +00:00
Robert Brennan
1f8aa93843 revert runtime for resolver (#7365) 2025-03-20 04:52:43 +00:00
Engel Nyst
34920ea04e Save agent state (#7372) 2025-03-20 05:16:49 +01:00
Graham Neubig
f5aeb47a72 Fix homepage internationalization (Issue #7355) (#7359)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-19 17:48:30 -04:00
Engel Nyst
c830177207 Move security.md to the global microagents (#7361) 2025-03-20 05:40:05 +08:00
Xingyao Wang
e4ccd4057d misc: tweak frontend prompt to prevent agent push to a different branch & update app prompt (#7357) 2025-03-20 05:09:51 +08:00
chuckbutkus
c3d60b31d1 All-1465 Move user conversations (#7340) 2025-03-19 16:03:09 -04:00
mamoodi
35b70ca915 Release 0.29.1 (#7350) 2025-03-19 16:01:16 -04:00
Ivan Dagelic
a8d65c11e0 fix: daytona runtime action execution handling (#7100)
Signed-off-by: Ivan Dagelic <dagelic.ivan@gmail.com>
2025-03-19 15:27:41 -04:00
Xingyao Wang
a4746a53d8 Update prompt for runtime additional info (#7349) 2025-03-19 16:35:20 +00:00
Zaid Sheikh
13bb474623 feat(Session): add sandbox base, runtime container image to session settings (#7329) 2025-03-19 16:08:43 +00:00
blacksmith-sh[bot]
09aa62f1c3 blacksmith.sh: Migrate workflows to Blacksmith (#7148)
Co-authored-by: blacksmith-sh[bot] <157653362+blacksmith-sh[bot]@users.noreply.github.com>
2025-03-19 15:10:17 +00:00
Robert Brennan
cbc26a5e40 Pass litellm error types to user and update error message (#7344)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-19 14:44:30 +00:00
Graham Neubig
6824d14ed8 Update config.template.toml to match current codebase (#7314)
Co-authored-by: openhands <openhands@all-hands.dev>
2025-03-19 15:37:49 +01:00
49 changed files with 517 additions and 914 deletions

View File

@@ -38,8 +38,6 @@ jobs:
uses: useblacksmith/setup-python@v6
with:
python-version: '3.12'
- name: Generate Python Docs
run: rm -rf docs/modules/python && pip install pydoc-markdown && pydoc-markdown
- name: Install dependencies
run: cd docs && npm ci
- name: Build website

View File

@@ -36,7 +36,7 @@ jobs:
python-version: '3.12'
cache: 'poetry'
- name: Install Python dependencies using Poetry
run: poetry install --without evaluation,llama-index
run: poetry install --without evaluation
- name: Build Environment
run: make build
- name: Run tests

View File

@@ -54,7 +54,7 @@ jobs:
Hi! I started running the integration tests on your PR. You will receive a comment with the results shortly.
- name: Install Python dependencies using Poetry
run: poetry install --without evaluation,llama-index
run: poetry install --without evaluation
- name: Configure config.toml for testing with Haiku
env:
@@ -117,68 +117,6 @@ jobs:
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run DelegatorAgent tests for Haiku, limited to t01 and t02
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing DelegatorAgent (Haiku)
env:
LLM_MODEL: "litellm_proxy/claude-3-5-haiku-20241022"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 30
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DelegatorAgent (Haiku)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD DelegatorAgent '' 30 $N_PROCESSES "t01_fix_simple_typo,t02_add_bash_hello" 'delegator_haiku_run'
# Find and export the delegator test results
REPORT_FILE_DELEGATOR_HAIKU=$(find evaluation/evaluation_outputs/outputs/integration_tests/DelegatorAgent/*haiku*_maxiter_30_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_DELEGATOR_HAIKU: $REPORT_FILE_DELEGATOR_HAIKU"
echo "INTEGRATION_TEST_REPORT_DELEGATOR_HAIKU<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_DELEGATOR_HAIKU >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run DelegatorAgent tests for DeepSeek, limited to t01 and t02
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing DelegatorAgent (DeepSeek)
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 30
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DelegatorAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD DelegatorAgent '' 30 $N_PROCESSES "t01_fix_simple_typo,t02_add_bash_hello" 'delegator_deepseek_run'
# Find and export the delegator test results
REPORT_FILE_DELEGATOR_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/DelegatorAgent/deepseek*_maxiter_30_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_DELEGATOR_DEEPSEEK: $REPORT_FILE_DELEGATOR_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_DELEGATOR_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_DELEGATOR_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
- name: Wait a little bit (again)
run: sleep 5
@@ -213,7 +151,7 @@ jobs:
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
@@ -254,12 +192,6 @@ jobs:
**Integration Tests Report (DeepSeek)**
DeepSeek LLM Test Results:
${{ env.INTEGRATION_TEST_REPORT_DEEPSEEK }}
---
**Integration Tests Report Delegator (Haiku)**
${{ env.INTEGRATION_TEST_REPORT_DELEGATOR_HAIKU }}
---
**Integration Tests Report Delegator (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_DELEGATOR_DEEPSEEK }}
---
**Integration Tests Report VisualBrowsing (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}

View File

@@ -74,13 +74,13 @@ jobs:
(github.event.review.author_association == 'OWNER' || github.event.review.author_association == 'COLLABORATOR' || github.event.review.author_association == 'MEMBER')
)
)
runs-on: blacksmith-4vcpu-ubuntu-2204
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Python
uses: useblacksmith/setup-python@v6
uses: actions/setup-python@v5
with:
python-version: "3.12"
@@ -106,7 +106,7 @@ jobs:
contains(github.event.review.body, '@openhands-agent-exp')
)
)
uses: useblacksmith/cache@v5
uses: actions/cache@v4
with:
path: ${{ env.pythonLocation }}/lib/python3.12/site-packages/*
key: ${{ runner.os }}-pip-openhands-resolver-${{ hashFiles('/tmp/requirements.txt') }}
@@ -295,11 +295,12 @@ jobs:
if: always()
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const fs = require('fs');
const issueNumber = ${{ env.ISSUE_NUMBER }};
const issueNumber = process.env.ISSUE_NUMBER;
let logContent = '';
try {
@@ -330,13 +331,15 @@ jobs:
if: always() # Comment on issue even if the previous steps fail
env:
AGENT_RESPONDED: ${{ env.AGENT_RESPONDED || 'false' }}
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
RESOLUTION_SUCCESS: ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const fs = require('fs');
const path = require('path');
const issueNumber = ${{ env.ISSUE_NUMBER }};
const success = ${{ steps.check_result.outputs.RESOLUTION_SUCCESS }};
const issueNumber = process.env.ISSUE_NUMBER;
const success = process.env.RESOLUTION_SUCCESS === 'true';
let prNumber = '';
let branchName = '';
@@ -401,10 +404,12 @@ jobs:
- name: Fallback Error Comment
uses: actions/github-script@v7
if: ${{ env.AGENT_RESPONDED == 'false' }} # Only run if no conditions were met in previous steps
env:
ISSUE_NUMBER: ${{ env.ISSUE_NUMBER }}
with:
github-token: ${{ secrets.PAT_TOKEN || github.token }}
script: |
const issueNumber = ${{ env.ISSUE_NUMBER }};
const issueNumber = process.env.ISSUE_NUMBER;
github.rest.issues.createComment({
issue_number: issueNumber,

View File

@@ -44,11 +44,11 @@ jobs:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- name: Install Python dependencies using Poetry
run: poetry install --without evaluation,llama-index
run: poetry install --without evaluation
- name: Build Environment
run: make build
- name: Run Tests
run: poetry run pytest --forked -n auto --cov=openhands --cov-report=xml -svv ./tests/unit --ignore=tests/unit/test_long_term_memory.py
run: poetry run pytest --forked -n auto --cov=openhands --cov-report=xml -svv ./tests/unit
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v5
env:

View File

@@ -38,9 +38,6 @@ workspace_base = "./workspace"
# Disable color in terminal output
#disable_color = false
# Enable saving and restoring the session when run from CLI
#enable_cli_session = false
# Path to store trajectories, can be a folder or a file
# If it's a folder, the session id will be used as the file name
#save_trajectory_path="./trajectories"
@@ -56,9 +53,6 @@ workspace_base = "./workspace"
# File store type
#file_store = "memory"
# List of allowed file extensions for uploads
#file_uploads_allowed_extensions = [".*"]
# Maximum file size for uploads, in megabytes
#file_uploads_max_file_size_mb = 0
@@ -100,6 +94,12 @@ workspace_base = "./workspace"
# When false, a NoOpCondenserConfig (no summarization) will be used
#enable_default_condenser = true
# Maximum number of concurrent conversations per user
#max_concurrent_conversations = 3
# Maximum age of conversations in seconds before they are automatically closed
#conversation_max_age_seconds = 864000 # 10 days
#################################### LLM #####################################
# Configuration for LLM models (group name starts with 'llm')
# use 'llm' for the default LLM config
@@ -196,6 +196,8 @@ model = "gpt-4o"
# https://github.com/All-Hands-AI/OpenHands/pull/4711
#native_tool_calling = None
[llm.gpt4o-mini]
api_key = ""
model = "gpt-4o"
@@ -209,21 +211,15 @@ model = "gpt-4o"
##############################################################################
[agent]
# whether the browsing tool is enabled
# Whether the browsing tool is enabled
codeact_enable_browsing = true
# whether the LLM draft editor is enabled
# Whether the LLM draft editor is enabled
codeact_enable_llm_editor = false
# whether the IPython tool is enabled
# Whether the IPython tool is enabled
codeact_enable_jupyter = true
# Memory enabled
#memory_enabled = false
# Memory maximum threads
#memory_max_threads = 3
# LLM config group to use
#llm_config = 'your-llm-config-group'
@@ -258,7 +254,7 @@ llm_config = 'gpt3'
# Use host network
#use_host_network = false
# runtime extra build args
# Runtime extra build args
#runtime_extra_build_args = ["--network=host", "--add-host=host.docker.internal:host-gateway"]
# Enable auto linting after editing
@@ -276,6 +272,33 @@ llm_config = 'gpt3'
# BrowserGym environment to use for evaluation
#browsergym_eval_env = ""
# Platform to use for building the runtime image (e.g., "linux/amd64")
#platform = ""
# Force rebuild of runtime image even if it exists
#force_rebuild_runtime = false
# Runtime container image to use (if not provided, will be built from base_container_image)
#runtime_container_image = ""
# Keep runtime alive after session ends
#keep_runtime_alive = false
# Pause closed runtimes instead of stopping them
#pause_closed_runtimes = false
# Delay in seconds before closing idle runtimes
#close_delay = 300
# Remove all containers when stopping the runtime
#rm_all_containers = false
# Enable GPU support in the runtime
#enable_gpu = false
# Additional Docker runtime kwargs
#docker_runtime_kwargs = {}
#################################### Security ###################################
# Configuration for security features
##############################################################################
@@ -287,6 +310,9 @@ llm_config = 'gpt3'
# The security analyzer to use (For Headless / CLI only - In Web this is overridden by Session Init)
#security_analyzer = ""
# Whether to enable security analyzer
#enable_security_analyzer = false
#################################### Condenser #################################
# Condensers control how conversation history is managed and compressed when
# the context grows too large. Each agent uses one condenser configuration.

View File

@@ -402,5 +402,26 @@
"theme.unlistedContent.message": {
"message": "Cette page n'est pas répertoriée. Les moteurs de recherche ne l'indexeront pas, et seuls les utilisateurs ayant un lien direct peuvent y accéder.",
"description": "The unlisted content banner message"
},
"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.": {
"message": "Utilisez l'IA pour gérer les tâches répétitives de votre backlog. Nos agents disposent des mêmes outils qu'un développeur humain : ils peuvent modifier du code, exécuter des commandes, naviguer sur le web, appeler des API et même copier des extraits de code depuis StackOverflow."
},
"Get started with OpenHands.": {
"message": "Commencer avec OpenHands"
},
"Most Popular Links": {
"message": "Liens Populaires"
},
"Customizing OpenHands to a repository": {
"message": "Personnaliser OpenHands pour un dépôt"
},
"Integrating OpenHands with Github": {
"message": "Intégrer OpenHands avec Github"
},
"Recommended models to use": {
"message": "Modèles recommandés"
},
"Connecting OpenHands to your filesystem": {
"message": "Connecter OpenHands à votre système de fichiers"
}
}

View File

@@ -402,5 +402,26 @@
"theme.unlistedContent.message": {
"message": "此页面未列出。搜索引擎不会对其索引,只有拥有直接链接的用户才能访问。",
"description": "The unlisted content banner message"
},
"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.": {
"message": "使用AI处理您积压的工作。我们的代理拥有与人类开发者相同的工具它们可以修改代码、运行命令、浏览网页、调用API甚至从StackOverflow复制代码片段。"
},
"Get started with OpenHands.": {
"message": "开始使用OpenHands"
},
"Most Popular Links": {
"message": "热门链接"
},
"Customizing OpenHands to a repository": {
"message": "为仓库定制OpenHands"
},
"Integrating OpenHands with Github": {
"message": "将OpenHands与Github集成"
},
"Recommended models to use": {
"message": "推荐使用的模型"
},
"Connecting OpenHands to your filesystem": {
"message": "将OpenHands连接到您的文件系统"
}
}

View File

@@ -25,17 +25,19 @@ export function HomepageHeader() {
padding: '0rem 0rem 1rem'
}}>
<p style={{ margin: '0' }}>
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.
<Translate>
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.
</Translate>
<br/>
<Link to="https://docs.all-hands.dev/modules/usage/installation"
<Link to="/modules/usage/installation"
style={{
textDecoration: 'underline',
display: 'inline-block',
marginTop: '0.5rem'
}}
>
Get started with OpenHands.
<Translate>Get started with OpenHands.</Translate>
</Link>
</p>
</div>

View File

@@ -2,6 +2,8 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import Layout from '@theme/Layout';
import { HomepageHeader } from '../components/HomepageHeader/HomepageHeader';
import { translate } from '@docusaurus/Translate';
import Translate from '@docusaurus/Translate';
import Link from '@docusaurus/Link';
import { Demo } from "../components/Demo/Demo";
export default function Home(): JSX.Element {
@@ -21,12 +23,28 @@ export default function Home(): JSX.Element {
</div>
<div style={{ textAlign: 'center', padding: '0.5rem 2rem 1.5rem' }}>
<h2>Most Popular Links</h2>
<h2><Translate>Most Popular Links</Translate></h2>
<ul style={{ listStyleType: 'none'}}>
<li><a href="/modules/usage/prompting/microagents-repo">Customizing OpenHands to a repository</a></li>
<li><a href="/modules/usage/how-to/github-action">Integrating OpenHands with Github</a></li>
<li><a href="/modules/usage/llms#model-recommendations">Recommended models to use</a></li>
<li><a href="/modules/usage/runtimes#connecting-to-your-filesystem">Connecting OpenHands to your filesystem</a></li>
<li>
<Link to="/modules/usage/prompting/microagents-repo">
<Translate>Customizing OpenHands to a repository</Translate>
</Link>
</li>
<li>
<Link to="/modules/usage/how-to/github-action">
<Translate>Integrating OpenHands with Github</Translate>
</Link>
</li>
<li>
<Link to="/modules/usage/llms#model-recommendations">
<Translate>Recommended models to use</Translate>
</Link>
</li>
<li>
<Link to="/modules/usage/runtimes#connecting-to-your-filesystem">
<Translate>Connecting OpenHands to your filesystem</Translate>
</Link>
</li>
</ul>
</div>
</Layout>

View File

@@ -18,7 +18,6 @@ describe("ConversationCard", () => {
const onClick = vi.fn();
const onDelete = vi.fn();
const onChangeTitle = vi.fn();
const onDownloadWorkspace = vi.fn();
beforeAll(() => {
vi.stubGlobal("window", { open: vi.fn() });
@@ -269,30 +268,7 @@ describe("ConversationCard", () => {
expect(onClick).not.toHaveBeenCalled();
});
it("should call onDownloadWorkspace when the download button is clicked", async () => {
const user = userEvent.setup();
render(
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
const ellipsisButton = screen.getByTestId("ellipsis-button");
await user.click(ellipsisButton);
const menu = screen.getByTestId("context-menu");
const downloadButton = within(menu).getByTestId("download-button");
await user.click(downloadButton);
expect(onDownloadWorkspace).toHaveBeenCalled();
});
it("should not display the edit or delete options if the handler is not provided", async () => {
const user = userEvent.setup();
@@ -337,7 +313,6 @@ describe("ConversationCard", () => {
onClick={onClick}
onDelete={onDelete}
onChangeTitle={onChangeTitle}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -350,7 +325,6 @@ describe("ConversationCard", () => {
<ConversationCard
onClick={onClick}
onDelete={onDelete}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
@@ -359,18 +333,6 @@ describe("ConversationCard", () => {
expect(screen.getByTestId("ellipsis-button")).toBeInTheDocument();
rerender(
<ConversationCard
onClick={onClick}
onDownloadWorkspace={onDownloadWorkspace}
title="Conversation 1"
selectedRepository={null}
lastUpdatedAt="2021-10-01T12:00:00Z"
/>,
);
expect(screen.queryByTestId("ellipsis-button")).toBeInTheDocument();
rerender(
<ConversationCard
onClick={onClick}

View File

@@ -59,7 +59,7 @@ describe("TrajectoryActions", () => {
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when negative feedback is clicked", async () => {
it("should call onExportTrajectory when export button is clicked", async () => {
renderWithProviders(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}

View File

@@ -1,12 +1,12 @@
{
"name": "openhands-frontend",
"version": "0.29.0",
"version": "0.29.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openhands-frontend",
"version": "0.29.0",
"version": "0.29.1",
"dependencies": {
"@heroui/react": "2.7.4",
"@monaco-editor/react": "^4.7.0-rc.0",

View File

@@ -1,6 +1,6 @@
{
"name": "openhands-frontend",
"version": "0.29.0",
"version": "0.29.1",
"private": true,
"type": "module",
"engines": {

View File

@@ -2,7 +2,6 @@ import posthog from "posthog-js";
import React from "react";
import { useSelector } from "react-redux";
import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
import { DownloadModal } from "#/components/shared/download-modal";
import type { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
@@ -18,21 +17,11 @@ export function ActionSuggestions({
(state: RootState) => state.initialQuery,
);
const [isDownloading, setIsDownloading] = React.useState(false);
const [hasPullRequest, setHasPullRequest] = React.useState(false);
const handleDownloadClose = () => {
setIsDownloading(false);
};
return (
<div className="flex flex-col gap-2 mb-2">
<DownloadModal
initialPath=""
onClose={handleDownloadClose}
isOpen={isDownloading}
/>
{githubTokenIsSet && selectedRepository ? (
{githubTokenIsSet && selectedRepository && (
<div className="flex flex-row gap-2 justify-center w-full">
{!hasPullRequest ? (
<>
@@ -40,7 +29,7 @@ export function ActionSuggestions({
suggestion={{
label: "Push to Branch",
value:
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request.",
"Please push the changes to a remote branch on GitHub, but do NOT create a pull request. Please use the exact SAME branch name as the one you are currently on.",
}}
onClick={(value) => {
posthog.capture("push_to_branch_button_clicked");
@@ -51,7 +40,7 @@ export function ActionSuggestions({
suggestion={{
label: "Push & Create PR",
value:
"Please push the changes to GitHub and open a pull request.",
"Please push the changes to GitHub and open a pull request. Please use the exact SAME branch name as the one you are currently on.",
}}
onClick={(value) => {
posthog.capture("create_pr_button_clicked");
@@ -74,21 +63,6 @@ export function ActionSuggestions({
/>
)}
</div>
) : (
<SuggestionItem
suggestion={{
label: !isDownloading
? "Download files"
: "Downloading, please wait...",
value: "Download files",
}}
onClick={() => {
posthog.capture("download_workspace_button_clicked");
if (!isDownloading) {
setIsDownloading(true);
}
}}
/>
)}
</div>
);

View File

@@ -21,7 +21,7 @@ import { ContinueButton } from "#/components/shared/buttons/continue-button";
import { ScrollToBottomButton } from "#/components/shared/buttons/scroll-to-bottom-button";
import { LoadingSpinner } from "#/components/shared/loading-spinner";
import { useGetTrajectory } from "#/hooks/mutation/use-get-trajectory";
import { downloadTrajectory } from "#/utils/download-files";
import { downloadTrajectory } from "#/utils/download-trajectory";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
function getEntryPoint(

View File

@@ -1,12 +1,10 @@
import { useParams } from "react-router";
import React from "react";
import posthog from "posthog-js";
import { AgentControlBar } from "./agent-control-bar";
import { AgentStatusBar } from "./agent-status-bar";
import { SecurityLock } from "./security-lock";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { ConversationCard } from "../conversation-panel/conversation-card";
import { DownloadModal } from "#/components/shared/download-modal";
interface ControlsProps {
setSecurityOpen: (isOpen: boolean) => void;
@@ -19,13 +17,6 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
params.conversationId ?? null,
);
const [downloading, setDownloading] = React.useState(false);
const handleDownloadWorkspace = () => {
posthog.capture("download_workspace_button_clicked");
setDownloading(true);
};
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
@@ -39,17 +30,11 @@ export function Controls({ setSecurityOpen, showSecurityLock }: ControlsProps) {
<ConversationCard
variant="compact"
onDownloadWorkspace={handleDownloadWorkspace}
title={conversation?.title ?? ""}
lastUpdatedAt={conversation?.created_at ?? ""}
selectedRepository={conversation?.selected_repository ?? null}
status={conversation?.status}
/>
<DownloadModal
initialPath=""
onClose={() => setDownloading(false)}
isOpen={downloading}
conversationId={conversation?.conversation_id}
/>
</div>
);

View File

@@ -7,7 +7,7 @@ interface ConversationCardContextMenuProps {
onClose: () => void;
onDelete?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onEdit?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownload?: (event: React.MouseEvent<HTMLButtonElement>) => void;
onDownloadViaVSCode?: (event: React.MouseEvent<HTMLButtonElement>) => void;
position?: "top" | "bottom";
}
@@ -15,7 +15,7 @@ export function ConversationCardContextMenu({
onClose,
onDelete,
onEdit,
onDownload,
onDownloadViaVSCode,
position = "bottom",
}: ConversationCardContextMenuProps) {
const ref = useClickOutsideElement<HTMLUListElement>(onClose);
@@ -40,9 +40,12 @@ export function ConversationCardContextMenu({
Edit Title
</ContextMenuListItem>
)}
{onDownload && (
<ContextMenuListItem testId="download-button" onClick={onDownload}>
Download Workspace
{onDownloadViaVSCode && (
<ContextMenuListItem
testId="download-vscode-button"
onClick={onDownloadViaVSCode}
>
Download via VS Code
</ContextMenuListItem>
)}
</ContextMenu>

View File

@@ -1,4 +1,5 @@
import React from "react";
import posthog from "posthog-js";
import { formatTimeDelta } from "#/utils/format-time-delta";
import { ConversationRepoLink } from "./conversation-repo-link";
import {
@@ -13,31 +14,34 @@ interface ConversationCardProps {
onClick?: () => void;
onDelete?: () => void;
onChangeTitle?: (title: string) => void;
onDownloadWorkspace?: () => void;
isActive?: boolean;
title: string;
selectedRepository: string | null;
lastUpdatedAt: string; // ISO 8601
status?: ProjectStatus;
variant?: "compact" | "default";
conversationId?: string; // Optional conversation ID for VS Code URL
}
export function ConversationCard({
onClick,
onDelete,
onChangeTitle,
onDownloadWorkspace,
isActive,
title,
selectedRepository,
lastUpdatedAt,
status = "STOPPED",
variant = "default",
conversationId,
}: ConversationCardProps) {
const [contextMenuVisible, setContextMenuVisible] = React.useState(false);
const [titleMode, setTitleMode] = React.useState<"view" | "edit">("view");
const inputRef = React.useRef<HTMLInputElement>(null);
// We don't use the VS Code URL hook directly here to avoid test failures
// Instead, we'll add the download button conditionally
const handleBlur = () => {
if (inputRef.current?.value) {
const trimmed = inputRef.current.value.trim();
@@ -78,9 +82,32 @@ export function ConversationCard({
setContextMenuVisible(false);
};
const handleDownload = (event: React.MouseEvent<HTMLButtonElement>) => {
const handleDownloadViaVSCode = async (
event: React.MouseEvent<HTMLButtonElement>,
) => {
event.preventDefault();
event.stopPropagation();
onDownloadWorkspace?.();
posthog.capture("download_via_vscode_button_clicked");
// Fetch the VS Code URL from the API
if (conversationId) {
try {
const response = await fetch(
`/api/conversations/${conversationId}/vscode-url`,
);
const data = await response.json();
if (data.vscode_url) {
window.open(data.vscode_url, "_blank");
} else {
console.error("VS Code URL not available", data.error);
}
} catch (error) {
console.error("Failed to fetch VS Code URL", error);
}
}
setContextMenuVisible(false);
};
React.useEffect(() => {
@@ -89,7 +116,11 @@ export function ConversationCard({
}
}, [titleMode]);
const hasContextMenu = !!(onDelete || onChangeTitle || onDownloadWorkspace);
const hasContextMenu = !!(
onDelete ||
onChangeTitle ||
conversationId // If we have a conversation ID, we can show the download button
);
return (
<div
@@ -145,7 +176,9 @@ export function ConversationCard({
onClose={() => setContextMenuVisible(false)}
onDelete={onDelete && handleDelete}
onEdit={onChangeTitle && handleEdit}
onDownload={onDownloadWorkspace && handleDownload}
onDownloadViaVSCode={
conversationId ? handleDownloadViaVSCode : undefined
}
position={variant === "compact" ? "top" : "bottom"}
/>
)}

View File

@@ -1,33 +0,0 @@
import { useDownloadProgress } from "#/hooks/use-download-progress";
import { DownloadProgress } from "./download-progress";
interface DownloadModalProps {
initialPath: string;
onClose: () => void;
isOpen: boolean;
}
function ActiveDownload({
initialPath,
onClose,
}: {
initialPath: string;
onClose: () => void;
}) {
const { progress, cancelDownload } = useDownloadProgress(
initialPath,
onClose,
);
return <DownloadProgress progress={progress} onCancel={cancelDownload} />;
}
export function DownloadModal({
initialPath,
onClose,
isOpen,
}: DownloadModalProps) {
if (!isOpen) return null;
return <ActiveDownload initialPath={initialPath} onClose={onClose} />;
}

View File

@@ -1,94 +0,0 @@
import { useTranslation } from "react-i18next";
import { I18nKey } from "#/i18n/declaration";
export interface DownloadProgressState {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
isDiscoveringFiles: boolean;
}
interface DownloadProgressProps {
progress: DownloadProgressState;
onCancel: () => void;
}
export function DownloadProgress({
progress,
onCancel,
}: DownloadProgressProps) {
const { t } = useTranslation();
const formatBytes = (bytes: number) => {
const units = ["B", "KB", "MB", "GB"];
let size = bytes;
let unitIndex = 0;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex += 1;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-20">
<div className="bg-[#1C1C1C] rounded-lg p-6 max-w-md w-full mx-4 border border-[#525252]">
<div className="mb-4">
<h3 className="text-lg font-semibold mb-2 text-white">
{progress.isDiscoveringFiles
? t(I18nKey.DOWNLOAD$PREPARING)
: t(I18nKey.DOWNLOAD$DOWNLOADING)}
</h3>
<p className="text-sm text-gray-400 truncate">
{progress.isDiscoveringFiles
? t(I18nKey.DOWNLOAD$FOUND_FILES, { count: progress.filesTotal })
: progress.currentFile}
</p>
</div>
<div className="mb-4">
<div className="h-2 bg-[#2C2C2C] rounded-full overflow-hidden">
{progress.isDiscoveringFiles ? (
<div
className="h-full bg-blue-500 animate-pulse"
style={{ width: "100%" }}
/>
) : (
<div
className="h-full bg-blue-500 transition-all duration-300"
style={{
width: `${(progress.filesDownloaded / progress.filesTotal) * 100}%`,
}}
/>
)}
</div>
</div>
<div className="flex justify-between text-sm text-gray-400">
<span>
{progress.isDiscoveringFiles
? t(I18nKey.DOWNLOAD$SCANNING)
: t(I18nKey.DOWNLOAD$FILES_PROGRESS, {
downloaded: progress.filesDownloaded,
total: progress.filesTotal,
})}
</span>
{!progress.isDiscoveringFiles && (
<span>{formatBytes(progress.bytesDownloadedPerSecond)}/s</span>
)}
</div>
<div className="mt-4 flex justify-end">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-400 hover:text-white transition-colors"
>
{t(I18nKey.DOWNLOAD$CANCEL)}
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,80 +0,0 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { downloadFiles } from "#/utils/download-files";
import { DownloadProgressState } from "#/components/shared/download-progress";
import { useConversation } from "#/context/conversation-context";
export const INITIAL_PROGRESS: DownloadProgressState = {
filesTotal: 0,
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0,
isDiscoveringFiles: true,
};
export function useDownloadProgress(
initialPath: string | undefined,
onClose: () => void,
) {
const [isStarted, setIsStarted] = useState(false);
const [progress, setProgress] =
useState<DownloadProgressState>(INITIAL_PROGRESS);
const progressRef = useRef<DownloadProgressState>(INITIAL_PROGRESS);
const abortController = useRef<AbortController>(null);
const { conversationId } = useConversation();
// Create AbortController on mount
useEffect(() => {
const controller = new AbortController();
abortController.current = controller;
// Initialize progress ref with initial state
progressRef.current = INITIAL_PROGRESS;
return () => {
controller.abort();
abortController.current = null;
};
}, []); // Empty deps array - only run on mount/unmount
// Start download when isStarted becomes true
useEffect(() => {
if (!isStarted) {
setIsStarted(true);
return;
}
if (!abortController.current) return;
// Start download
const download = async () => {
try {
await downloadFiles(conversationId, initialPath, {
onProgress: (p) => {
// Update both the ref and state
progressRef.current = { ...p };
setProgress((prev: DownloadProgressState) => ({ ...prev, ...p }));
},
signal: abortController.current!.signal,
});
onClose();
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
onClose();
} else {
throw error;
}
}
};
download();
}, [initialPath, onClose, isStarted]);
// No longer need startDownload as it's handled in useEffect
const cancelDownload = useCallback(() => {
abortController.current?.abort();
}, []);
return {
progress,
cancelDownload,
};
}

View File

@@ -1,356 +0,0 @@
import OpenHands from "#/api/open-hands";
import { downloadWorkspace } from "./download-workspace";
interface DownloadProgress {
filesTotal: number;
filesDownloaded: number;
currentFile: string;
totalBytesDownloaded: number;
bytesDownloadedPerSecond: number;
isDiscoveringFiles: boolean;
}
interface DownloadOptions {
onProgress?: (progress: DownloadProgress) => void;
signal?: AbortSignal;
}
/**
* Checks if the File System Access API is supported
*/
function isFileSystemAccessSupported(): boolean {
return "showDirectoryPicker" in window;
}
/**
* Checks if the Save File Picker API is supported
*/
function isSaveFilePickerSupported(): boolean {
return "showSaveFilePicker" in window;
}
/**
* Creates subdirectories and returns the final directory handle
*/
async function createSubdirectories(
baseHandle: FileSystemDirectoryHandle,
pathParts: string[],
): Promise<FileSystemDirectoryHandle> {
return pathParts.reduce(async (promise, part) => {
const handle = await promise;
return handle.getDirectoryHandle(part, { create: true });
}, Promise.resolve(baseHandle));
}
/**
* Recursively gets all files in a directory
*/
async function getAllFiles(
conversationID: string,
path: string,
progress: DownloadProgress,
options?: DownloadOptions,
): Promise<string[]> {
const entries = await OpenHands.getFiles(conversationID, path);
const processEntry = async (entry: string): Promise<string[]> => {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}
const fullPath = path + entry;
if (entry.endsWith("/")) {
const subEntries = await OpenHands.getFiles(conversationID, fullPath);
const subFilesPromises = subEntries.map((subEntry) =>
processEntry(subEntry),
);
const subFilesArrays = await Promise.all(subFilesPromises);
return subFilesArrays.flat();
}
const updatedProgress = {
...progress,
filesTotal: progress.filesTotal + 1,
currentFile: fullPath,
};
options?.onProgress?.(updatedProgress);
return [fullPath];
};
const filePromises = entries.map((entry) => processEntry(entry));
const fileArrays = await Promise.all(filePromises);
const updatedProgress = {
...progress,
isDiscoveringFiles: false,
};
options?.onProgress?.(updatedProgress);
return fileArrays.flat();
}
/**
* Process a batch of files
*/
async function processBatch(
conversationID: string,
batch: string[],
directoryHandle: FileSystemDirectoryHandle,
progress: DownloadProgress,
startTime: number,
completedFiles: number,
totalBytes: number,
options?: DownloadOptions,
): Promise<{ newCompleted: number; newBytes: number }> {
if (options?.signal?.aborted) {
throw new Error("Download cancelled");
}
// Process files in the batch in parallel
const results = await Promise.all(
batch.map(async (path) => {
try {
const newProgress = {
...progress,
currentFile: path,
isDiscoveringFiles: false,
filesDownloaded: completedFiles,
totalBytesDownloaded: totalBytes,
bytesDownloadedPerSecond:
totalBytes / ((Date.now() - startTime) / 1000),
};
options?.onProgress?.(newProgress);
const content = await OpenHands.getFile(conversationID, path);
// Save to the selected directory preserving structure
const pathParts = path.split("/").filter(Boolean);
const fileName = pathParts.pop() || "file";
const dirHandle =
pathParts.length > 0
? await createSubdirectories(directoryHandle, pathParts)
: directoryHandle;
// Create and write the file
const fileHandle = await dirHandle.getFileHandle(fileName, {
create: true,
});
const writable = await fileHandle.createWritable();
await writable.write(content);
await writable.close();
// Return the size of this file
return new Blob([content]).size;
} catch (error) {
// Silently handle file processing errors and return 0 bytes
return 0;
}
}),
);
// Calculate batch totals
const batchBytes = results.reduce((sum, size) => sum + size, 0);
const newTotalBytes = totalBytes + batchBytes;
const newCompleted =
completedFiles + results.filter((size) => size > 0).length;
// Update progress with batch results
const updatedProgress = {
...progress,
filesDownloaded: newCompleted,
totalBytesDownloaded: newTotalBytes,
bytesDownloadedPerSecond: newTotalBytes / ((Date.now() - startTime) / 1000),
isDiscoveringFiles: false,
};
options?.onProgress?.(updatedProgress);
return {
newCompleted,
newBytes: newTotalBytes,
};
}
export async function downloadTrajectory(
conversationId: string,
data: unknown[] | null,
): Promise<void> {
try {
if (!isSaveFilePickerSupported()) {
throw new Error(
"Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.",
);
}
const options = {
suggestedName: `trajectory-${conversationId}.json`,
types: [
{
description: "JSON File",
accept: {
"application/json": [".json"],
},
},
],
};
const handle = await window.showSaveFilePicker(options);
const writable = await handle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
} catch (error) {
throw new Error(
`Failed to download file: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
/**
* Downloads files from the workspace one by one
* @param initialPath Initial path to start downloading from. If not provided, downloads from root
* @param options Download options including progress callback and abort signal
*/
export async function downloadFiles(
conversationID: string,
initialPath?: string,
options?: DownloadOptions,
): Promise<void> {
const startTime = Date.now();
const progress: DownloadProgress = {
filesTotal: 0, // Will be updated during file discovery
filesDownloaded: 0,
currentFile: "",
totalBytesDownloaded: 0,
bytesDownloadedPerSecond: 0,
isDiscoveringFiles: true,
};
try {
// Check if File System Access API is supported
if (!isFileSystemAccessSupported()) {
throw new Error(
"Your browser doesn't support downloading folders. Please use Chrome, Edge, or another browser that supports the File System Access API.",
);
}
// Show directory picker first
let directoryHandle: FileSystemDirectoryHandle;
try {
directoryHandle = await window.showDirectoryPicker();
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
throw new Error("Download cancelled");
}
if (error instanceof Error && error.name === "SecurityError") {
throw new Error(
"Permission denied. Please allow access to the download location when prompted.",
);
}
throw new Error("Failed to select download location. Please try again.");
}
// Then recursively get all files
const files = await getAllFiles(
conversationID,
initialPath || "",
progress,
options,
);
// Set isDiscoveringFiles to false now that we have the full list and preserve filesTotal
const finalTotal = progress.filesTotal;
options?.onProgress?.({
...progress,
filesTotal: finalTotal,
isDiscoveringFiles: false,
});
// Verify we still have permission after the potentially long file scan
try {
// Try to create and write to a test file to verify permissions
const testHandle = await directoryHandle.getFileHandle(
".openhands-test",
{ create: true },
);
const writable = await testHandle.createWritable();
await writable.close();
} catch (error) {
if (
error instanceof Error &&
error.message.includes("User activation is required")
) {
// Ask for permission again
try {
directoryHandle = await window.showDirectoryPicker();
} catch (permissionError) {
if (
permissionError instanceof Error &&
permissionError.name === "AbortError"
) {
throw new Error("Download cancelled");
}
if (
permissionError instanceof Error &&
permissionError.name === "SecurityError"
) {
throw new Error(
"Permission denied. Please allow access to the download location when prompted.",
);
}
throw new Error(
"Failed to select download location. Please try again.",
);
}
} else {
throw error;
}
}
// Process files in parallel batches to avoid overwhelming the browser
const BATCH_SIZE = 5;
const batches = Array.from(
{ length: Math.ceil(files.length / BATCH_SIZE) },
(_, i) => files.slice(i * BATCH_SIZE, (i + 1) * BATCH_SIZE),
);
// Keep track of completed files across all batches
let completedFiles = 0;
let totalBytesDownloaded = 0;
// Process batches sequentially to maintain order and avoid overwhelming the browser
await batches.reduce(
(promise, batch) =>
promise.then(async () => {
const { newCompleted, newBytes } = await processBatch(
conversationID,
batch,
directoryHandle,
progress,
startTime,
completedFiles,
totalBytesDownloaded,
options,
);
completedFiles = newCompleted;
totalBytesDownloaded = newBytes;
}),
Promise.resolve(),
);
} catch (error) {
if (error instanceof Error && error.message === "Download cancelled") {
throw error;
}
// Fallback to old style download
if (
error instanceof Error &&
(error.message.includes("browser doesn't support") ||
error.message.includes("Failed to select") ||
error.message.includes("Permission denied"))
) {
await downloadWorkspace(conversationID);
return;
}
// Otherwise, wrap it with a generic message
throw new Error(
`Failed to download files: ${error instanceof Error ? error.message : String(error)}`,
);
}
}

View File

@@ -0,0 +1,30 @@
function isSaveFilePickerSupported(): boolean {
return typeof window !== "undefined" && "showSaveFilePicker" in window;
}
export async function downloadTrajectory(
conversationId: string,
data: unknown[] | null,
): Promise<void> {
if (!isSaveFilePickerSupported()) {
throw new Error(
"Your browser doesn't support downloading files. Please use Chrome, Edge, or another browser that supports the File System Access API.",
);
}
const options: SaveFilePickerOptions = {
suggestedName: `trajectory-${conversationId}.json`,
types: [
{
description: "JSON File",
accept: {
"application/json": [".json"],
},
},
],
};
const fileHandle = await window.showSaveFilePicker(options);
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
}

View File

@@ -1,16 +0,0 @@
import OpenHands from "#/api/open-hands";
/**
* Downloads the current workspace as a .zip file.
*/
export const downloadWorkspace = async (conversationId: string) => {
const blob = await OpenHands.getWorkspaceZip(conversationId);
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "workspace.zip");
document.body.appendChild(link);
link.click();
link.parentNode?.removeChild(link);
};

View File

@@ -12,5 +12,7 @@ function loadFeatureFlag(
}
}
export const BILLING_SETTINGS = () => loadFeatureFlag("BILLING_SETTINGS");
export const HIDE_LLM_SETTINGS = () => loadFeatureFlag("HIDE_LLM_SETTINGS");
export const BILLING_SETTINGS = () =>
true || loadFeatureFlag("BILLING_SETTINGS");
export const HIDE_LLM_SETTINGS = () =>
true || loadFeatureFlag("HIDE_LLM_SETTINGS");

View File

@@ -19,6 +19,7 @@ each of which has a corresponding port:
When starting a web server, use the corresponding ports. You should also
set any options to allow iframes and CORS requests, and allow the server to
be accessed from any host (e.g. 0.0.0.0).
For example, if you are using vite.config.js, you should set server.host to 0.0.0.0, server.port to the port assigned to you, and allowedHosts to the host assigned to you.
{% endif %}
{% if runtime_info.additional_agent_instructions %}
{{ runtime_info.additional_agent_instructions }}

View File

@@ -4,12 +4,19 @@ import os
import traceback
from typing import Callable, ClassVar, Type
import litellm
from litellm.exceptions import (
import litellm # noqa
from litellm.exceptions import ( # noqa
APIConnectionError,
APIError,
AuthenticationError,
BadRequestError,
ContextWindowExceededError,
InternalServerError,
NotFoundError,
OpenAIError,
RateLimitError,
ServiceUnavailableError,
Timeout,
)
from openhands.controller.agent import Agent
@@ -223,20 +230,20 @@ class AgentController:
await self.set_agent_state_to(AgentState.ERROR)
if self.status_callback is not None:
err_id = ''
if isinstance(e, litellm.AuthenticationError):
if isinstance(e, AuthenticationError):
err_id = 'STATUS$ERROR_LLM_AUTHENTICATION'
elif isinstance(
e,
(
litellm.ServiceUnavailableError,
litellm.APIConnectionError,
litellm.APIError,
ServiceUnavailableError,
APIConnectionError,
APIError,
),
):
err_id = 'STATUS$ERROR_LLM_SERVICE_UNAVAILABLE'
elif isinstance(e, litellm.InternalServerError):
elif isinstance(e, InternalServerError):
err_id = 'STATUS$ERROR_LLM_INTERNAL_SERVER_ERROR'
elif isinstance(e, litellm.BadRequestError) and 'ExceededBudget' in str(e):
elif isinstance(e, BadRequestError) and 'ExceededBudget' in str(e):
err_id = 'STATUS$ERROR_LLM_OUT_OF_CREDITS'
elif isinstance(e, RateLimitError):
await self.set_agent_state_to(AgentState.RATE_LIMITED)
@@ -256,18 +263,24 @@ class AgentController:
f'Traceback: {traceback.format_exc()}',
)
reported = RuntimeError(
'There was an unexpected error while running the agent. Please '
'report this error to the developers by opening an issue at '
'https://github.com/All-Hands-AI/OpenHands. Your session ID is '
f' {self.id}. Error type: {e.__class__.__name__}'
f'There was an unexpected error while running the agent: {e.__class__.__name__}. You can refresh the page or ask the agent to try again.'
)
if (
isinstance(e, litellm.AuthenticationError)
or isinstance(e, litellm.BadRequestError)
isinstance(e, Timeout)
or isinstance(e, APIError)
or isinstance(e, BadRequestError)
or isinstance(e, NotFoundError)
or isinstance(e, InternalServerError)
or isinstance(e, AuthenticationError)
or isinstance(e, RateLimitError)
or isinstance(e, LLMContextWindowExceedError)
):
reported = e
else:
self.log(
'warning',
f'Unknown exception type while running the agent: {type(e).__name__}.',
)
await self._react_to_exception(reported)
def should_step(self, event: Event) -> bool:

View File

@@ -102,22 +102,53 @@ class State:
extra_data: dict[str, Any] = field(default_factory=dict)
last_error: str = ''
def save_to_session(self, sid: str, file_store: FileStore):
def save_to_session(self, sid: str, file_store: FileStore, user_id: str | None):
pickled = pickle.dumps(self)
logger.debug(f'Saving state to session {sid}:{self.agent_state}')
encoded = base64.b64encode(pickled).decode('utf-8')
try:
file_store.write(get_conversation_agent_state_filename(sid), encoded)
file_store.write(
get_conversation_agent_state_filename(sid, user_id), encoded
)
# see if state is in the old directory on saas/remote use cases and delete it.
if user_id:
filename = get_conversation_agent_state_filename(sid)
try:
file_store.delete(filename)
except Exception:
pass
except Exception as e:
logger.error(f'Failed to save state to session: {e}')
raise e
@staticmethod
def restore_from_session(sid: str, file_store: FileStore) -> 'State':
def restore_from_session(
sid: str, file_store: FileStore, user_id: str | None = None
) -> 'State':
"""
Restores the state from the previously saved session.
"""
state: State
try:
encoded = file_store.read(get_conversation_agent_state_filename(sid))
encoded = file_store.read(
get_conversation_agent_state_filename(sid, user_id)
)
pickled = base64.b64decode(encoded)
state = pickle.loads(pickled)
except FileNotFoundError:
# if user_id is provided, we are in a saas/remote use case
# and we need to check if the state is in the old directory.
if user_id:
filename = get_conversation_agent_state_filename(sid)
encoded = file_store.read(filename)
pickled = base64.b64decode(encoded)
state = pickle.loads(pickled)
else:
raise FileNotFoundError(
f'Could not restore state from session file for sid: {sid}'
)
except Exception as e:
logger.debug(f'Could not restore state from session: {e}')
raise e

View File

@@ -79,7 +79,7 @@ class AppConfig(BaseModel):
runloop_api_key: SecretStr | None = Field(default=None)
daytona_api_key: SecretStr | None = Field(default=None)
daytona_api_url: str = Field(default='https://app.daytona.io/api')
daytona_target: str = Field(default='us')
daytona_target: str = Field(default='eu')
cli_multiline_input: bool = Field(default=False)
conversation_max_age_seconds: int = Field(default=864000) # 10 days in seconds
enable_default_condenser: bool = Field(default=True)

View File

@@ -194,7 +194,9 @@ async def run_controller(
if config.file_store is not None and config.file_store != 'memory':
end_state = controller.get_state()
# NOTE: the saved state does not include delegates events
end_state.save_to_session(event_stream.sid, event_stream.file_store)
end_state.save_to_session(
event_stream.sid, event_stream.file_store, event_stream.user_id
)
await controller.close(set_stop_state=False)

View File

@@ -20,6 +20,9 @@ from openhands.storage.locations import (
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.shutdown_listener import should_continue
# Number of events to fetch concurrently in each batch
EVENT_BATCH_SIZE = 20
class EventStreamSubscriber(str, Enum):
AGENT_CONTROLLER = 'agent_controller'
@@ -32,9 +35,11 @@ class EventStreamSubscriber(str, Enum):
TEST = 'test'
async def session_exists(sid: str, file_store: FileStore) -> bool:
async def session_exists(
sid: str, file_store: FileStore, user_id: str | None = None
) -> bool:
try:
await call_sync_from_async(file_store.list, get_conversation_dir(sid))
await call_sync_from_async(file_store.list, get_conversation_dir(sid, user_id))
return True
except FileNotFoundError:
return False
@@ -57,6 +62,7 @@ class AsyncEventStreamWrapper:
class EventStream:
sid: str
user_id: str | None
file_store: FileStore
secrets: dict[str, str]
# For each subscriber ID, there is a map of callback functions - useful
@@ -70,9 +76,10 @@ class EventStream:
_thread_pools: dict[str, dict[str, ThreadPoolExecutor]]
_thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
def __init__(self, sid: str, file_store: FileStore):
def __init__(self, sid: str, file_store: FileStore, user_id: str | None = None):
self.sid = sid
self.file_store = file_store
self.user_id = user_id
self._stop_flag = threading.Event()
self._queue: queue.Queue[Event] = queue.Queue()
self._thread_pools = {}
@@ -90,10 +97,24 @@ class EventStream:
self.__post_init__()
def __post_init__(self) -> None:
events = []
try:
events = self.file_store.list(get_conversation_events_dir(self.sid))
events_dir = get_conversation_events_dir(self.sid, self.user_id)
events += self.file_store.list(events_dir)
except FileNotFoundError:
logger.debug(f'No events found for session {self.sid}')
logger.debug(f'No events found for session {self.sid} at {events_dir}')
if self.user_id:
# During transition to new location, try old location if user_id is set
# TODO: remove this code after 5/1/2025
try:
events_dir = get_conversation_events_dir(self.sid)
events += self.file_store.list(events_dir)
except FileNotFoundError:
logger.debug(f'No events found for session {self.sid} at {events_dir}')
if not events:
self._cur_id = 0
return
@@ -156,8 +177,8 @@ class EventStream:
del self._subscribers[subscriber_id][callback_id]
def _get_filename_for_id(self, id: int) -> str:
return get_conversation_event_filename(self.sid, id)
def _get_filename_for_id(self, id: int, user_id: str | None) -> str:
return get_conversation_event_filename(self.sid, id, user_id)
@staticmethod
def _get_id_from_filename(filename: str) -> int:
@@ -197,36 +218,96 @@ class EventStream:
return True
return False
# Helper function to fetch a single event and handle exceptions
def fetch_single_event(event_id: int) -> tuple[int, Event | None]:
try:
event = self.get_event(event_id)
return (event_id, event)
except FileNotFoundError:
logger.debug(f'No event found for ID {event_id}')
return (event_id, None)
if reverse:
if end_id is None:
end_id = self._cur_id - 1
event_id = end_id
while event_id >= start_id:
try:
event = self.get_event(event_id)
if not should_filter(event):
current_id = end_id
while current_id >= start_id:
# Determine batch size for this iteration
batch_end = current_id
batch_start = max(start_id, current_id - EVENT_BATCH_SIZE + 1)
# Create list of IDs to fetch in this batch (in reverse order)
batch_ids = list(range(batch_end, batch_start - 1, -1))
# Fetch events concurrently
with ThreadPoolExecutor() as executor:
batch_results = list(executor.map(fetch_single_event, batch_ids))
# Sort results by ID in reverse order to maintain order
batch_results.sort(key=lambda x: x[0], reverse=True)
# Yield events in order
for event_id, event in batch_results:
if event is not None and not should_filter(event):
yield event
except FileNotFoundError:
logger.debug(f'No event found for ID {event_id}')
event_id -= 1
# Move to next batch
current_id = batch_start - 1
else:
event_id = start_id
current_id = start_id
while should_continue():
if end_id is not None and event_id > end_id:
if end_id is not None and current_id > end_id:
break
try:
event = self.get_event(event_id)
if not should_filter(event):
# Determine batch size for this iteration
batch_start = current_id
batch_end = (
end_id if end_id is not None else batch_start + EVENT_BATCH_SIZE - 1
)
batch_end = min(batch_end, batch_start + EVENT_BATCH_SIZE - 1)
# Create list of IDs to fetch in this batch
batch_ids = list(range(batch_start, batch_end + 1))
# Fetch events concurrently
with ThreadPoolExecutor() as executor:
batch_results = list(executor.map(fetch_single_event, batch_ids))
# Sort results by ID to maintain order
batch_results.sort(key=lambda x: x[0])
# Check if we've reached the end of available events
all_not_found = all(event is None for _, event in batch_results)
if all_not_found:
break
# Yield events in order
for event_id, event in batch_results:
if event is not None and not should_filter(event):
yield event
except FileNotFoundError:
break
event_id += 1
elif event is None and event_id == batch_start:
# If the first event in the batch is missing, we've reached the end
return
# Move to next batch
current_id = batch_end + 1
def get_event(self, id: int) -> Event:
filename = self._get_filename_for_id(id)
content = self.file_store.read(filename)
data = json.loads(content)
return event_from_dict(data)
filename = self._get_filename_for_id(id, self.user_id)
try:
content = self.file_store.read(filename)
data = json.loads(content)
return event_from_dict(data)
except FileNotFoundError:
logger.debug(f'File {filename} not found')
# TODO remove this block after 5/1/2025
if self.user_id:
filename = self._get_filename_for_id(id, None)
content = self.file_store.read(filename)
data = json.loads(content)
return event_from_dict(data)
raise
def get_latest_event(self) -> Event:
return self.get_event(self._cur_id - 1)
@@ -277,7 +358,9 @@ class EventStream:
data = self._replace_secrets(data)
event = event_from_dict(data)
if event.id is not None:
self.file_store.write(self._get_filename_for_id(event.id), json.dumps(data))
self.file_store.write(
self._get_filename_for_id(event.id, self.user_id), json.dumps(data)
)
self._queue.put(event)
def set_secrets(self, secrets: dict[str, str]):
@@ -376,7 +459,7 @@ class EventStream:
# Text search in event content if query provided
if query:
event_dict = event_to_dict(event)
event_str = str(event_dict).lower()
event_str = json.dumps(event_dict).lower()
if query.lower() not in event_str:
return True

View File

@@ -121,13 +121,13 @@ Note: OpenHands works best with powerful models like Anthropic's Claude or OpenA
The resolver can automatically attempt to fix a single issue in your repository using the following command:
```bash
python -m openhands.resolver.resolve_issue --repo [OWNER]/[REPO] --issue-number [NUMBER]
python -m openhands.resolver.resolve_issue --selected-repo [OWNER]/[REPO] --issue-number [NUMBER]
```
For instance, if you want to resolve issue #100 in this repo, you would run:
```bash
python -m openhands.resolver.resolve_issue --repo all-hands-ai/openhands --issue-number 100
python -m openhands.resolver.resolve_issue --selected-repo all-hands-ai/openhands --issue-number 100
```
The output will be written to the `output/` directory.
@@ -135,19 +135,19 @@ The output will be written to the `output/` directory.
If you've installed the package from source using poetry, you can use:
```bash
poetry run python openhands/resolver/resolve_issue.py --repo all-hands-ai/openhands --issue-number 100
poetry run python openhands/resolver/resolve_issue.py --selected-repo all-hands-ai/openhands --issue-number 100
```
For resolving multiple issues at once (e.g., in a batch process), you can use the `resolve_all_issues` command:
```bash
python -m openhands.resolver.resolve_all_issues --repo [OWNER]/[REPO] --issue-numbers [NUMBERS]
python -m openhands.resolver.resolve_all_issues --selected-repo [OWNER]/[REPO] --issue-numbers [NUMBERS]
```
For example:
```bash
python -m openhands.resolver.resolve_all_issues --repo all-hands-ai/openhands --issue-numbers 100,101,102
python -m openhands.resolver.resolve_all_issues --selected-repo all-hands-ai/openhands --issue-numbers 100,101,102
```
## Responding to PR Comments

View File

@@ -234,7 +234,7 @@ def main() -> None:
description='Resolve multiple issues from Github or Gitlab.'
)
parser.add_argument(
'--repo',
'--selected-repo',
type=str,
required=True,
help='Github or Gitlab repository to resolve issues in form of `owner/repo`.',
@@ -333,7 +333,7 @@ def main() -> None:
f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik'
)
owner, repo = my_args.repo.split('/')
owner, repo = my_args.selected_repo.split('/')
token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
username = my_args.username if my_args.username else os.getenv('GIT_USERNAME')
if not username:
@@ -342,7 +342,7 @@ def main() -> None:
if not token:
raise ValueError('Token is required.')
platform = identify_token(token)
platform = identify_token(token, my_args.selected_repo)
if platform == Platform.INVALID:
raise ValueError('Token is invalid.')

View File

@@ -539,7 +539,7 @@ def main() -> None:
parser = argparse.ArgumentParser(description='Resolve a single issue.')
parser.add_argument(
'--repo',
'--selected-repo',
type=str,
required=True,
help='repository to resolve issues in form of `owner/repo`.',
@@ -638,9 +638,9 @@ def main() -> None:
f'ghcr.io/all-hands-ai/runtime:{openhands.__version__}-nikolaik'
)
parts = my_args.repo.rsplit('/', 1)
parts = my_args.selected_repo.rsplit('/', 1)
if len(parts) < 2:
raise ValueError('Invalid repo name')
raise ValueError('Invalid repository format. Expected owner/repo')
owner, repo = parts
token = my_args.token or os.getenv('GITHUB_TOKEN') or os.getenv('GITLAB_TOKEN')
@@ -651,7 +651,7 @@ def main() -> None:
if not token:
raise ValueError('Token is required.')
platform = identify_token(token, repo)
platform = identify_token(token, my_args.selected_repo)
if platform == Platform.INVALID:
raise ValueError('Token is invalid.')

View File

@@ -22,13 +22,13 @@ class Platform(Enum):
GITLAB = 2
def identify_token(token: str, repo: str | None = None) -> Platform:
def identify_token(token: str, selected_repo: str | None = None) -> Platform:
"""
Identifies whether a token belongs to GitHub or GitLab.
Parameters:
token (str): The personal access token to check.
repo (str): Repository in format "owner/repo" for GitHub Actions token validation.
selected_repo (str): Repository in format "owner/repo" for GitHub Actions token validation.
Returns:
Platform: "GitHub" if the token is valid for GitHub,
@@ -36,8 +36,8 @@ def identify_token(token: str, repo: str | None = None) -> Platform:
"Invalid" if the token is not recognized by either.
"""
# Try GitHub Actions token format (Bearer) with repo endpoint if repo is provided
if repo:
github_repo_url = f'https://api.github.com/repos/{repo}'
if selected_repo:
github_repo_url = f'https://api.github.com/repos/{selected_repo}'
github_bearer_headers = {
'Authorization': f'Bearer {token}',
'Accept': 'application/vnd.github+json',
@@ -50,7 +50,7 @@ def identify_token(token: str, repo: str | None = None) -> Platform:
if github_repo_response.status_code == 200:
return Platform.GITHUB
except requests.RequestException as e:
print(f'Error connecting to GitHub API (repo check): {e}')
print(f'Error connecting to GitHub API (selected_repo check): {e}')
# Try GitHub PAT format (token)
github_url = 'https://api.github.com/user'

View File

@@ -1,5 +1,4 @@
import os
import shutil
import tempfile
import threading
from abc import abstractmethod
@@ -149,7 +148,10 @@ class ActionExecutionClient(Runtime):
with tempfile.NamedTemporaryFile(
suffix='.zip', delete=False
) as temp_file:
shutil.copyfileobj(response.raw, temp_file, length=16 * 1024)
for chunk in response.iter_content(chunk_size=16 * 1024):
if chunk: # filter out keep-alive new chunks
temp_file.write(chunk)
temp_file.flush()
return Path(temp_file.name)
except requests.Timeout:
raise TimeoutError('Copy operation timed out')

View File

@@ -67,7 +67,7 @@ docker run -it --rm --pull=always \
docker.all-hands.dev/all-hands-ai/openhands:${OPENHANDS_VERSION}
```
> **Tip:** If you don't want your sandboxes to default to the US region, you can set the `DAYTONA_TARGET` environment variable to `eu`
> **Tip:** If you don't want your sandboxes to default to the EU region, you can set the `DAYTONA_TARGET` environment variable to `us`
### Running OpenHands Locally Without Docker

View File

@@ -1,6 +1,7 @@
import json
from typing import Callable
import requests
import tenacity
from daytona_sdk import (
CreateWorkspaceParams,
@@ -17,6 +18,7 @@ from openhands.runtime.impl.action_execution.action_execution_client import (
)
from openhands.runtime.plugins.requirement import PluginRequirement
from openhands.runtime.utils.command import get_action_execution_server_startup_command
from openhands.runtime.utils.request import RequestHTTPError
from openhands.utils.async_utils import call_sync_from_async
from openhands.utils.tenacity_stop import stop_if_should_exit
@@ -111,18 +113,6 @@ class DaytonaRuntime(ActionExecutionClient):
workspace = self.daytona.create(workspace_params)
return workspace
def _get_workspace_status(self) -> str:
assert self.workspace is not None, 'Workspace is not initialized'
assert (
self.workspace.instance.info is not None
), 'Workspace info is not available'
assert (
self.workspace.instance.info.provider_metadata is not None
), 'Provider metadata is not available'
provider_metadata = json.loads(self.workspace.instance.info.provider_metadata)
return provider_metadata.get('status', 'unknown')
def _construct_api_url(self, port: int) -> str:
assert self.workspace is not None, 'Workspace is not initialized'
assert (
@@ -143,10 +133,6 @@ class DaytonaRuntime(ActionExecutionClient):
def _start_action_execution_server(self) -> None:
assert self.workspace is not None, 'Workspace is not initialized'
self.workspace.process.exec(
f'mkdir -p {self.config.workspace_mount_path_in_sandbox}'
)
start_command: list[str] = get_action_execution_server_startup_command(
server_port=self._sandbox_port,
plugins=self.plugins,
@@ -154,7 +140,10 @@ class DaytonaRuntime(ActionExecutionClient):
override_user_id=1000,
override_username='openhands',
)
start_command_str: str = ' '.join(start_command)
start_command_str: str = (
f'mkdir -p {self.config.workspace_mount_path_in_sandbox} && cd /openhands/code && '
+ ' '.join(start_command)
)
self.log(
'debug',
@@ -163,10 +152,6 @@ class DaytonaRuntime(ActionExecutionClient):
exec_session_id = 'action-execution-server'
self.workspace.process.create_session(exec_session_id)
self.workspace.process.execute_session_command(
exec_session_id,
SessionExecuteRequest(command='cd /openhands/code', var_async=True),
)
exec_command = self.workspace.process.execute_session_command(
exec_session_id,
@@ -185,24 +170,33 @@ class DaytonaRuntime(ActionExecutionClient):
async def connect(self):
self.send_status_message('STATUS$STARTING_RUNTIME')
should_start_action_execution_server = False
if self.attach_to_existing:
self.workspace = await call_sync_from_async(self._get_workspace)
else:
should_start_action_execution_server = True
if self.workspace is None:
self.send_status_message('STATUS$PREPARING_CONTAINER')
self.workspace = await call_sync_from_async(self._create_workspace)
self.log('info', f'Created new workspace with id: {self.workspace_id}')
if self._get_workspace_status() == 'stopped':
self.api_url = self._construct_api_url(self._sandbox_port)
state = self.workspace.instance.state
if state == 'stopping':
self.log('info', 'Waiting for Daytona workspace to stop...')
await call_sync_from_async(self.workspace.wait_for_workspace_stop)
state = 'stopped'
if state == 'stopped':
self.log('info', 'Starting Daytona workspace...')
await call_sync_from_async(self.workspace.start)
should_start_action_execution_server = True
self.api_url = await call_sync_from_async(
self._construct_api_url, self._sandbox_port
)
if not self.attach_to_existing:
if should_start_action_execution_server:
await call_sync_from_async(self._start_action_execution_server)
self.log(
'info',
@@ -213,7 +207,7 @@ class DaytonaRuntime(ActionExecutionClient):
self.send_status_message('STATUS$WAITING_FOR_CLIENT')
await call_sync_from_async(self._wait_until_alive)
if not self.attach_to_existing:
if should_start_action_execution_server:
await call_sync_from_async(self.setup_initial_env)
self.log(
@@ -221,10 +215,25 @@ class DaytonaRuntime(ActionExecutionClient):
f'Container initialized with plugins: {[plugin.name for plugin in self.plugins]}',
)
if not self.attach_to_existing:
if should_start_action_execution_server:
self.send_status_message(' ')
self._runtime_initialized = True
@tenacity.retry(
retry=tenacity.retry_if_exception(
lambda e: (
isinstance(e, requests.HTTPError) or isinstance(e, RequestHTTPError)
)
and hasattr(e, 'response')
and e.response.status_code == 502
),
stop=tenacity.stop_after_delay(120) | stop_if_should_exit(),
wait=tenacity.wait_fixed(1),
reraise=True,
)
def _send_action_server_request(self, method, url, **kwargs):
return super()._send_action_server_request(method, url, **kwargs)
def close(self):
super().close()

View File

@@ -37,7 +37,9 @@ class ConversationManager(ABC):
"""Clean up the conversation manager."""
@abstractmethod
async def attach_to_conversation(self, sid: str) -> Conversation | None:
async def attach_to_conversation(
self, sid: str, user_id: str | None = None
) -> Conversation | None:
"""Attach to an existing conversation or create a new one."""
@abstractmethod

View File

@@ -63,9 +63,11 @@ class StandaloneConversationManager(ConversationManager):
self._cleanup_task.cancel()
self._cleanup_task = None
async def attach_to_conversation(self, sid: str) -> Conversation | None:
async def attach_to_conversation(
self, sid: str, user_id: str | None = None
) -> Conversation | None:
start_time = time.time()
if not await session_exists(sid, self.file_store):
if not await session_exists(sid, self.file_store, user_id=user_id):
return None
async with self._conversations_lock:
@@ -88,7 +90,9 @@ class StandaloneConversationManager(ConversationManager):
return conversation
# Create new conversation if none exists
c = Conversation(sid, file_store=self.file_store, config=self.config)
c = Conversation(
sid, file_store=self.file_store, config=self.config, user_id=user_id
)
try:
await c.connect()
except AgentRuntimeUnavailableError as e:
@@ -119,7 +123,7 @@ class StandaloneConversationManager(ConversationManager):
)
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self._local_connection_id_to_session_id[connection_id] = sid
event_stream = await self._get_event_stream(sid)
event_stream = await self._get_event_stream(sid, user_id)
if not event_stream:
return await self.maybe_start_agent_loop(
sid, settings, user_id, github_user_id=github_user_id
@@ -299,7 +303,7 @@ class StandaloneConversationManager(ConversationManager):
except ValueError:
pass # Already subscribed - take no action
event_stream = await self._get_event_stream(sid)
event_stream = await self._get_event_stream(sid, user_id)
if not event_stream:
logger.error(
f'No event stream after starting agent loop: {sid}',
@@ -308,7 +312,9 @@ class StandaloneConversationManager(ConversationManager):
raise RuntimeError(f'no_event_stream:{sid}')
return event_stream
async def _get_event_stream(self, sid: str) -> EventStream | None:
async def _get_event_stream(
self, sid: str, user_id: str | None
) -> EventStream | None:
logger.info(f'_get_event_stream:{sid}', extra={'session_id': sid})
session = self._local_agent_loops_by_sid.get(sid)
if session:

View File

@@ -148,7 +148,9 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
Attach the user's session based on the provided authentication token.
"""
request.state.conversation = (
await shared.conversation_manager.attach_to_conversation(request.state.sid)
await shared.conversation_manager.attach_to_conversation(
request.state.sid, get_user_id(request)
)
)
if not request.state.conversation:
return JSONResponse(

View File

@@ -37,6 +37,7 @@ class AgentSession:
"""
sid: str
user_id: str | None
event_stream: EventStream
file_store: FileStore
controller: AgentController | None = None
@@ -63,7 +64,7 @@ class AgentSession:
"""
self.sid = sid
self.event_stream = EventStream(sid, file_store)
self.event_stream = EventStream(sid, file_store, user_id)
self.file_store = file_store
self._status_callback = status_callback
self.user_id = user_id
@@ -186,7 +187,7 @@ class AgentSession:
self.event_stream.close()
if self.controller is not None:
end_state = self.controller.get_state()
end_state.save_to_session(self.sid, self.file_store)
end_state.save_to_session(self.sid, self.file_store, self.user_id)
await self.controller.close()
if self.runtime is not None:
self.runtime.close()
@@ -371,7 +372,9 @@ class AgentSession:
# Use a heuristic to figure out if we should have a state:
# if we have events in the stream.
try:
restored_state = State.restore_from_session(self.sid, self.file_store)
restored_state = State.restore_from_session(
self.sid, self.file_store, self.user_id
)
self.logger.debug(f'Restored state from session, sid: {self.sid}')
except Exception as e:
if self.event_stream.get_latest_event_id() > 0:

View File

@@ -14,17 +14,16 @@ class Conversation:
file_store: FileStore
event_stream: EventStream
runtime: Runtime
user_id: str | None
def __init__(
self,
sid: str,
file_store: FileStore,
config: AppConfig,
self, sid: str, file_store: FileStore, config: AppConfig, user_id: str | None
):
self.sid = sid
self.config = config
self.file_store = file_store
self.event_stream = EventStream(sid, file_store)
self.user_id = user_id
self.event_stream = EventStream(sid, file_store, user_id)
if config.security.security_analyzer:
self.security_analyzer = options.SecurityAnalyzers.get(
config.security.security_analyzer, SecurityAnalyzer

View File

@@ -99,6 +99,16 @@ class Session:
self.config.security.security_analyzer = (
settings.security_analyzer or self.config.security.security_analyzer
)
self.config.sandbox.base_container_image = (
settings.sandbox_base_container_image
or self.config.sandbox.base_container_image
)
self.config.sandbox.runtime_container_image = (
settings.sandbox_runtime_container_image
if settings.sandbox_base_container_image
or settings.sandbox_runtime_container_image
else self.config.sandbox.runtime_container_image
)
max_iterations = settings.max_iterations or self.config.max_iterations
# This is a shallow copy of the default LLM config, so changes here will

View File

@@ -33,6 +33,8 @@ class Settings(BaseModel):
enable_default_condenser: bool = False
enable_sound_notifications: bool = False
user_consents_to_analytics: bool | None = None
sandbox_base_container_image: str | None = None
sandbox_runtime_container_image: str | None = None
model_config = {
'validate_assignment': True,

View File

@@ -1,25 +1,30 @@
CONVERSATION_BASE_DIR = 'sessions'
def get_conversation_dir(sid: str) -> str:
return f'{CONVERSATION_BASE_DIR}/{sid}/'
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
if user_id:
return f'users/{user_id}/conversations/{sid}/'
else:
return f'{CONVERSATION_BASE_DIR}/{sid}/'
def get_conversation_events_dir(sid: str) -> str:
return f'{get_conversation_dir(sid)}events/'
def get_conversation_events_dir(sid: str, user_id: str | None = None) -> str:
return f'{get_conversation_dir(sid, user_id)}events/'
def get_conversation_event_filename(sid: str, id: int) -> str:
return f'{get_conversation_events_dir(sid)}{id}.json'
def get_conversation_event_filename(
sid: str, id: int, user_id: str | None = None
) -> str:
return f'{get_conversation_events_dir(sid, user_id)}{id}.json'
def get_conversation_metadata_filename(sid: str) -> str:
return f'{get_conversation_dir(sid)}metadata.json'
def get_conversation_metadata_filename(sid: str, user_id: str | None = None) -> str:
return f'{get_conversation_dir(sid, user_id)}metadata.json'
def get_conversation_init_data_filename(sid: str) -> str:
return f'{get_conversation_dir(sid)}init.json'
def get_conversation_init_data_filename(sid: str, user_id: str | None = None) -> str:
return f'{get_conversation_dir(sid, user_id)}init.json'
def get_conversation_agent_state_filename(sid: str) -> str:
return f'{get_conversation_dir(sid)}agent_state.pkl'
def get_conversation_agent_state_filename(sid: str, user_id: str | None = None) -> str:
return f'{get_conversation_dir(sid, user_id)}agent_state.pkl'

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "openhands-ai"
version = "0.29.0"
version = "0.29.1"
description = "OpenHands: Code Less, Make More"
authors = ["OpenHands"]
license = "MIT"