mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
19 Commits
fix-blacks
...
concurrent
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4fcd51b3a | ||
|
|
9bd1992738 | ||
|
|
3856a896ea | ||
|
|
b0030d3a2b | ||
|
|
d76477099c | ||
|
|
3e3b2aaa5c | ||
|
|
1f8aa93843 | ||
|
|
34920ea04e | ||
|
|
f5aeb47a72 | ||
|
|
c830177207 | ||
|
|
e4ccd4057d | ||
|
|
c3d60b31d1 | ||
|
|
35b70ca915 | ||
|
|
a8d65c11e0 | ||
|
|
a4746a53d8 | ||
|
|
13bb474623 | ||
|
|
09aa62f1c3 | ||
|
|
cbc26a5e40 | ||
|
|
6824d14ed8 |
2
.github/workflows/deploy-docs.yml
vendored
2
.github/workflows/deploy-docs.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/dummy-agent-test.yml
vendored
2
.github/workflows/dummy-agent-test.yml
vendored
@@ -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
|
||||
|
||||
72
.github/workflows/integration-runner.yml
vendored
72
.github/workflows/integration-runner.yml
vendored
@@ -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 }}
|
||||
|
||||
19
.github/workflows/openhands-resolver.yml
vendored
19
.github/workflows/openhands-resolver.yml
vendored
@@ -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,
|
||||
|
||||
4
.github/workflows/py-unit-tests.yml
vendored
4
.github/workflows/py-unit-tests.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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连接到您的文件系统"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openhands-frontend",
|
||||
"version": "0.29.0",
|
||||
"version": "0.29.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
30
frontend/src/utils/download-trajectory.ts
Normal file
30
frontend/src/utils/download-trajectory.ts
Normal 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();
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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");
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
@@ -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.')
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user