mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
5 Commits
fix-webare
...
openhands/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de78d493ea | ||
|
|
a1ea40eddf | ||
|
|
abe969846e | ||
|
|
695a353bd5 | ||
|
|
ebdc5f9aea |
@@ -58,34 +58,34 @@ RUN sed -i 's/^UID_MIN.*/UID_MIN 499/' /etc/login.defs
|
||||
# Default is 60000, but we've seen up to 200000
|
||||
RUN sed -i 's/^UID_MAX.*/UID_MAX 1000000/' /etc/login.defs
|
||||
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID openhands
|
||||
RUN groupadd --gid $OPENHANDS_USER_ID app
|
||||
RUN useradd -l -m -u $OPENHANDS_USER_ID --gid $OPENHANDS_USER_ID -s /bin/bash openhands && \
|
||||
usermod -aG openhands openhands && \
|
||||
usermod -aG app openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers
|
||||
RUN chown -R openhands:openhands /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:openhands $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
RUN chown -R openhands:app /app && chmod -R 770 /app
|
||||
RUN sudo chown -R openhands:app $WORKSPACE_BASE && sudo chmod -R 770 $WORKSPACE_BASE
|
||||
USER openhands
|
||||
|
||||
ENV VIRTUAL_ENV=/app/.venv \
|
||||
PATH="/app/.venv/bin:$PATH" \
|
||||
PYTHONPATH='/app'
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
COPY --chown=openhands:app --chmod=770 --from=backend-builder ${VIRTUAL_ENV} ${VIRTUAL_ENV}
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:openhands --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:openhands --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:openhands pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
COPY --chown=openhands:app --chmod=770 ./microagents ./microagents
|
||||
COPY --chown=openhands:app --chmod=770 ./openhands ./openhands
|
||||
COPY --chown=openhands:app --chmod=777 ./openhands/runtime/plugins ./openhands/runtime/plugins
|
||||
COPY --chown=openhands:app pyproject.toml poetry.lock README.md MANIFEST.in LICENSE ./
|
||||
|
||||
# This is run as "openhands" user, and will create __pycache__ with openhands:openhands ownership
|
||||
RUN python openhands/core/download.py # No-op to download assets
|
||||
# Add this line to set group ownership of all files/directories not already in "app" group
|
||||
# openhands:openhands -> openhands:openhands
|
||||
RUN find /app \! -group openhands -exec chgrp openhands {} +
|
||||
# openhands:openhands -> openhands:app
|
||||
RUN find /app \! -group app -exec chgrp app {} +
|
||||
|
||||
COPY --chown=openhands:openhands --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:openhands --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
COPY --chown=openhands:app --chmod=770 --from=frontend-builder /app/build ./frontend/build
|
||||
COPY --chown=openhands:app --chmod=770 ./containers/app/entrypoint.sh /app/entrypoint.sh
|
||||
|
||||
USER root
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ else
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
usermod -aG openhands enduser
|
||||
usermod -aG app enduser
|
||||
# get the user group of /var/run/docker.sock and set openhands to that group
|
||||
DOCKER_SOCKET_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
echo "Docker socket group id: $DOCKER_SOCKET_GID"
|
||||
|
||||
@@ -87,13 +87,19 @@ source ~/.bashrc # or source ~/.zshrc
|
||||
|
||||
</AccordionGroup>
|
||||
|
||||
3. Launch an interactive OpenHands conversation from the command line:
|
||||
```bash
|
||||
# If using uvx (recommended)
|
||||
uvx --python 3.12 --from openhands-ai openhands
|
||||
```
|
||||
|
||||
<Note>
|
||||
If you have cloned the repository, you can also run the CLI directly using Poetry:
|
||||
|
||||
poetry run openhands
|
||||
</Note>
|
||||
|
||||
3. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
4. Set your model, API key, and other preferences using the UI (or alternatively environment variables, below).
|
||||
|
||||
This command opens an interactive prompt where you can type tasks or commands and get responses from OpenHands.
|
||||
The first time you run the CLI, it will take you through configuring the required LLM
|
||||
|
||||
@@ -22,7 +22,7 @@ SDK to spawn and control these sandboxes.
|
||||
|
||||
You can use the E2B CLI to create a custom sandbox with a Dockerfile. Read the full guide
|
||||
[here](https://e2b.dev/docs/guide/custom-sandbox). The premade OpenHands sandbox for E2B is set up in the `containers`
|
||||
directory, and it's called `openhands`.
|
||||
directory. and it's called `openhands`.
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ N_RUNS=${4:-1}
|
||||
export EXP_NAME=$EXP_NAME
|
||||
# use 2x resources for rollout since some codebases are pretty resource-intensive
|
||||
export DEFAULT_RUNTIME_RESOURCE_FACTOR=2
|
||||
export ITERATIVE_EVAL_MODE=false
|
||||
echo "MODEL: $MODEL"
|
||||
echo "EXP_NAME: $EXP_NAME"
|
||||
DATASET="SWE-Gym/SWE-Gym" # change this to the "/SWE-Gym-Lite" if you want to rollout the lite subset
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
# WebArena CDP Integration Implementation Plan
|
||||
|
||||
## Overview
|
||||
|
||||
This document outlines the proper solution for integrating OpenHands with the official WebArena evaluation harness using Chrome DevTools Protocol (CDP) session logging.
|
||||
|
||||
## The Problem
|
||||
|
||||
WebArena evaluators require:
|
||||
1. Live browser state (DOM, cookies, localStorage, etc.)
|
||||
2. CDPSession object for making CDP calls
|
||||
3. Page object for accessing current URL, title, content
|
||||
|
||||
OpenHands only provides:
|
||||
1. Action/observation pairs in text format
|
||||
2. No live browser state
|
||||
3. No CDP access during evaluation
|
||||
|
||||
## The Solution: CDP Session Logging
|
||||
|
||||
### Phase 1: Capture Browser State During Inference
|
||||
|
||||
**Modify `openhands/runtime/browser/browser_env.py`:**
|
||||
|
||||
```python
|
||||
class BrowserEnv:
|
||||
def __init__(self, ...):
|
||||
# ... existing code ...
|
||||
self.cdp_logger = CDPSessionLogger() if should_log_cdp() else None
|
||||
|
||||
def step(self, action):
|
||||
# ... existing action execution ...
|
||||
|
||||
# Log CDP state after each action
|
||||
if self.cdp_logger:
|
||||
self.cdp_logger.capture_state_snapshot(f"after_action_{action.action}")
|
||||
|
||||
# ... return observation ...
|
||||
|
||||
def close(self):
|
||||
# Save final CDP session
|
||||
if self.cdp_logger:
|
||||
instance_id = get_current_instance_id() # from evaluation context
|
||||
self.cdp_logger.save_session(instance_id)
|
||||
```
|
||||
|
||||
**Add CDP Logger Integration:**
|
||||
|
||||
```python
|
||||
class CDPSessionLogger:
|
||||
def attach_to_browsergym_env(self, env):
|
||||
"""Attach to BrowserGym environment's Playwright page."""
|
||||
# Access the underlying Playwright page from BrowserGym
|
||||
playwright_page = env.page # or however BrowserGym exposes it
|
||||
self.attach_to_page(playwright_page)
|
||||
|
||||
def capture_state_snapshot(self, trigger: str):
|
||||
"""Capture complete browser state using CDP."""
|
||||
# DOM snapshot (key for WebArena evaluators)
|
||||
dom_snapshot = self.cdp_session.send("DOMSnapshot.captureSnapshot", {
|
||||
"computedStyles": [],
|
||||
"includeDOMRects": True,
|
||||
"includePaintOrder": True,
|
||||
})
|
||||
|
||||
# All other state (cookies, localStorage, etc.)
|
||||
# ... as shown in POC ...
|
||||
```
|
||||
|
||||
### Phase 2: Mock Objects for Evaluation
|
||||
|
||||
**Create Mock Page/CDPSession:**
|
||||
|
||||
```python
|
||||
class MockCDPSession:
|
||||
def __init__(self, saved_state):
|
||||
self.saved_state = saved_state
|
||||
|
||||
def send(self, method: str, params=None):
|
||||
"""Return saved state instead of making live CDP calls."""
|
||||
if method == "DOMSnapshot.captureSnapshot":
|
||||
return self.saved_state["dom_snapshot"]
|
||||
elif method == "Network.getAllCookies":
|
||||
return self.saved_state["cookies"]
|
||||
# ... handle all CDP methods WebArena uses ...
|
||||
|
||||
class MockPage:
|
||||
def __init__(self, saved_state):
|
||||
self.saved_state = saved_state
|
||||
|
||||
def url(self): return self.saved_state["final_url"]
|
||||
def title(self): return self.saved_state["final_title"]
|
||||
def context(self): return MockBrowserContext(self.saved_state)
|
||||
# ... implement all Page methods WebArena uses ...
|
||||
```
|
||||
|
||||
### Phase 3: Updated Evaluation Script
|
||||
|
||||
**Modify `eval_infer.py`:**
|
||||
|
||||
```python
|
||||
def evaluate_with_official_webarena_harness(instance_data, config_file):
|
||||
"""Use official WebArena evaluators with saved CDP state."""
|
||||
|
||||
# Load saved CDP session
|
||||
cdp_integration = WebArenaCDPIntegration()
|
||||
mock_page, mock_client = cdp_integration.create_mock_page_and_client(
|
||||
instance_data["instance_id"]
|
||||
)
|
||||
|
||||
# Convert OpenHands trajectory to WebArena format
|
||||
trajectory = convert_openhands_trajectory_to_webarena_format(instance_data)
|
||||
|
||||
# Use official WebArena evaluator with mock objects
|
||||
evaluator = evaluator_router(config_file)
|
||||
score = evaluator(
|
||||
trajectory=trajectory,
|
||||
config_file=config_file,
|
||||
page=mock_page, # Mock page with saved state
|
||||
client=mock_client, # Mock CDP session with saved state
|
||||
)
|
||||
|
||||
return score
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
### Step 1: Integrate CDP Logger into BrowserEnv
|
||||
|
||||
1. **Add CDP logging to `browser_env.py`:**
|
||||
- Detect when running WebArena evaluation
|
||||
- Attach CDP logger to BrowserGym's Playwright page
|
||||
- Capture state snapshots after each action
|
||||
- Save final session with instance ID
|
||||
|
||||
2. **Environment variable setup:**
|
||||
```bash
|
||||
export WEBARENA_CDP_LOGGING=true
|
||||
export WEBARENA_CDP_SESSION_DIR=/tmp/cdp_sessions
|
||||
```
|
||||
|
||||
### Step 2: Create Mock Objects
|
||||
|
||||
1. **Implement `MockCDPSession`:**
|
||||
- Handle all CDP methods WebArena evaluators use
|
||||
- Return saved state instead of making live calls
|
||||
- Support `DOMSnapshot.captureSnapshot`, `Network.getAllCookies`, etc.
|
||||
|
||||
2. **Implement `MockPage`:**
|
||||
- Provide saved URL, title, content
|
||||
- Mock JavaScript evaluation with saved state
|
||||
- Support element queries using DOM snapshot
|
||||
|
||||
### Step 3: Update Evaluation Pipeline
|
||||
|
||||
1. **Modify `run_infer.py`:**
|
||||
- Enable CDP logging for WebArena tasks
|
||||
- Ensure instance IDs are properly set
|
||||
- Save CDP sessions to accessible location
|
||||
|
||||
2. **Update `eval_infer.py`:**
|
||||
- Load saved CDP sessions
|
||||
- Create mock objects
|
||||
- Use official WebArena evaluators
|
||||
- Remove all heuristic evaluation logic
|
||||
|
||||
### Step 4: Testing and Validation
|
||||
|
||||
1. **Test with known tasks:**
|
||||
- Run inference with CDP logging
|
||||
- Verify CDP sessions are saved correctly
|
||||
- Test evaluation with mock objects
|
||||
- Compare results with expected outcomes
|
||||
|
||||
2. **Validate DOM snapshot format:**
|
||||
- Ensure saved DOM snapshots match WebArena expectations
|
||||
- Test all CDP methods used by evaluators
|
||||
- Verify JavaScript evaluation works correctly
|
||||
|
||||
## Benefits of This Approach
|
||||
|
||||
1. **✅ Uses Official WebArena Evaluation:** No heuristics or approximations
|
||||
2. **✅ Preserves Exact Browser State:** DOM, cookies, localStorage, etc.
|
||||
3. **✅ No Live Browser Needed:** Evaluation works offline with saved state
|
||||
4. **✅ Scalable:** Can evaluate many instances without browser overhead
|
||||
5. **✅ Accurate:** Evaluators get exactly the state they expect
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
/tmp/cdp_sessions/
|
||||
├── webarena.1.json # CDP session for task 1
|
||||
├── webarena.2.json # CDP session for task 2
|
||||
├── webarena.3.json # CDP session for task 3
|
||||
└── webarena.4.json # CDP session for task 4
|
||||
|
||||
evaluation/benchmarks/webarena/
|
||||
├── run_infer.py # Modified to enable CDP logging
|
||||
├── eval_infer.py # Uses mock objects with saved state
|
||||
├── cdp_integration.py # Mock Page/CDPSession implementation
|
||||
└── IMPLEMENTATION_PLAN.md # This document
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Implement CDP logger integration in `browser_env.py`**
|
||||
2. **Create comprehensive mock objects**
|
||||
3. **Update evaluation scripts**
|
||||
4. **Test with actual WebArena tasks**
|
||||
5. **Validate results against expected outcomes**
|
||||
|
||||
This approach solves the fundamental problem: WebArena evaluators need live browser state, but OpenHands only provides action/observation pairs. By capturing and replaying the exact browser state, we can use the official WebArena evaluation harness without any compromises.
|
||||
@@ -6,21 +6,11 @@ This folder contains evaluation for [WebArena](https://github.com/web-arena-x/we
|
||||
|
||||
Please follow instruction [here](../../README.md#setup) to setup your local development environment and LLM.
|
||||
|
||||
Make sure to install the evaluation dependencies:
|
||||
|
||||
```bash
|
||||
poetry install --with evaluation
|
||||
```
|
||||
|
||||
## Setup WebArena Environment
|
||||
|
||||
WebArena requires access to websites containing pre-populated content. You can either:
|
||||
|
||||
1. **Use an existing WebArena environment** (recommended for evaluation): Set the `WEBARENA_BASE_URL` environment variable to point to an existing WebArena server.
|
||||
|
||||
2. **Set up your own environment**: Follow [this document](https://github.com/web-arena-x/webarena/blob/main/environment_docker/README.md) to set up your own WebArena environment through local servers or AWS EC2 instances.
|
||||
|
||||
The WebArena evaluation package is already installed with the evaluation dependencies, so you don't need to clone the WebArena repository separately.
|
||||
WebArena requires you to set up websites containing pre-populated content that is accessible via URL to the machine running the OpenHands agents.
|
||||
Follow [this document](https://github.com/web-arena-x/webarena/blob/main/environment_docker/README.md) to set up your own WebArena environment through local servers or AWS EC2 instances.
|
||||
Take note of the base URL (`$WEBARENA_BASE_URL`) of the machine where the environment is installed.
|
||||
|
||||
## Test if your environment works
|
||||
|
||||
@@ -31,51 +21,20 @@ Follow the WebArena environment setup guide carefully, and make sure the URL fie
|
||||
|
||||
## Run Evaluation
|
||||
|
||||
### Step 1: Run Inference
|
||||
Before running, you must provide an LLM config in a local config.toml and pass its name to run_infer.sh:
|
||||
|
||||
1) Create config.toml in the repo root (this file is gitignored):
|
||||
|
||||
```toml
|
||||
[llm.eval_openai]
|
||||
model = "gpt-4o"
|
||||
api_key = "sk-..." # Your OpenAI API key
|
||||
```
|
||||
|
||||
2) Ensure Docker is installed and running (the first run will build a browser-enabled runtime image).
|
||||
|
||||
|
||||
```bash
|
||||
export WEBARENA_BASE_URL=<YOUR_SERVER_URL_HERE>
|
||||
export OPENAI_API_KEY="yourkey" # this key is required for some WebArena validators that utilize LLMs
|
||||
# args: MODEL_CONFIG COMMIT_HASH AGENT EVAL_LIMIT NUM_WORKERS
|
||||
bash evaluation/benchmarks/webarena/scripts/run_infer.sh llm.eval_openai HEAD BrowsingAgent 3 1
|
||||
bash evaluation/benchmarks/webarena/scripts/run_infer.sh
|
||||
```
|
||||
|
||||
Results will be in `evaluation/evaluation_outputs/outputs/webarena/`
|
||||
|
||||
### Step 2: Evaluate Results
|
||||
To calculate the success rate, run:
|
||||
|
||||
To evaluate the results and calculate success rate using the official WebArena harness, you must have the official WebArena repo and its Python dependencies available locally:
|
||||
|
||||
1) Clone the official repo and install deps (one-time):
|
||||
|
||||
```bash
|
||||
cd /workspace/project
|
||||
git clone https://github.com/web-arena-x/webarena
|
||||
cd webarena && pip install -e .
|
||||
```sh
|
||||
poetry run python evaluation/benchmarks/webarena/get_success_rate.py evaluation/evaluation_outputs/outputs/webarena/SOME_AGENT/EXP_NAME/output.jsonl
|
||||
```
|
||||
|
||||
2) Then run the evaluator:
|
||||
|
||||
```bash
|
||||
poetry run python evaluation/benchmarks/webarena/eval_infer.py evaluation/evaluation_outputs/outputs/webarena/SOME_AGENT/EXP_NAME/output.jsonl
|
||||
```
|
||||
|
||||
Notes:
|
||||
- The evaluator expects WEBARENA_BASE_URL to be set and the WebArena services to be reachable.
|
||||
- If you skip installing the official harness, you can still inspect output.jsonl manually or write your own scorer, but the script above will fail without the harness.
|
||||
|
||||
## Submit your evaluation results
|
||||
|
||||
You can start your own fork of [our huggingface evaluation outputs](https://huggingface.co/spaces/OpenHands/evaluation) and submit a PR of your evaluation results following the guide [here](https://huggingface.co/docs/hub/en/repositories-pull-requests-discussions#pull-requests-and-discussions).
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
BrowserGym State Capture for WebArena Evaluation
|
||||
|
||||
This module leverages BrowserGym's existing state capture capabilities to save
|
||||
browser state for proper WebArena evaluation. BrowserGym already provides:
|
||||
- extract_dom_snapshot() - exactly what WebArena evaluators need
|
||||
- Direct Playwright page access via env.page
|
||||
- CDP session access via page.context.new_cdp_session()
|
||||
|
||||
This is much simpler than our original CDP logging approach because BrowserGym
|
||||
already has all the infrastructure we need.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import browsergym.core.observation as obs
|
||||
|
||||
|
||||
class BrowserGymStateCapture:
|
||||
"""
|
||||
Captures browser state using BrowserGym's existing observation functions.
|
||||
This provides everything WebArena evaluators need without custom CDP logging.
|
||||
"""
|
||||
|
||||
def __init__(self, output_dir: str = '/tmp/webarena_states'):
|
||||
self.output_dir = Path(output_dir)
|
||||
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.current_instance_id: str | None = None
|
||||
|
||||
def set_instance_id(self, instance_id: str) -> None:
|
||||
"""Set the current WebArena instance ID for state saving."""
|
||||
self.current_instance_id = instance_id
|
||||
|
||||
def capture_final_state(self, browsergym_env) -> dict[str, Any]:
|
||||
"""
|
||||
Capture the final browser state using BrowserGym's observation functions.
|
||||
This captures everything WebArena evaluators need.
|
||||
"""
|
||||
if not hasattr(browsergym_env, 'page'):
|
||||
raise RuntimeError('BrowserGym environment does not have page attribute')
|
||||
|
||||
page = browsergym_env.page
|
||||
|
||||
# Use BrowserGym's existing observation extraction functions
|
||||
state = {
|
||||
'instance_id': self.current_instance_id,
|
||||
'final_url': page.url,
|
||||
'final_title': page.title(),
|
||||
# This is the key - BrowserGym's extract_dom_snapshot uses CDP internally
|
||||
# and returns exactly the format WebArena evaluators expect
|
||||
'dom_snapshot': obs.extract_dom_snapshot(page),
|
||||
# Additional state that might be useful
|
||||
'screenshot': obs.extract_screenshot(page),
|
||||
'axtree': obs.extract_merged_axtree(page),
|
||||
'focused_element': obs.extract_focused_element_bid(page),
|
||||
}
|
||||
|
||||
# Get additional browser state via CDP
|
||||
try:
|
||||
cdp_session = page.context.new_cdp_session(page)
|
||||
|
||||
# Get cookies
|
||||
cookies_result = cdp_session.send('Network.getAllCookies')
|
||||
state['cookies'] = cookies_result
|
||||
|
||||
# Get localStorage
|
||||
local_storage = cdp_session.send(
|
||||
'Runtime.evaluate',
|
||||
{'expression': 'JSON.stringify(localStorage)', 'returnByValue': True},
|
||||
)
|
||||
state['local_storage'] = local_storage.get('result', {}).get('value', '{}')
|
||||
|
||||
# Get sessionStorage
|
||||
session_storage = cdp_session.send(
|
||||
'Runtime.evaluate',
|
||||
{'expression': 'JSON.stringify(sessionStorage)', 'returnByValue': True},
|
||||
)
|
||||
state['session_storage'] = session_storage.get('result', {}).get(
|
||||
'value', '{}'
|
||||
)
|
||||
|
||||
cdp_session.detach()
|
||||
|
||||
except Exception as e:
|
||||
print(f'Warning: Could not capture additional state via CDP: {e}')
|
||||
state['cookies'] = {'cookies': []}
|
||||
state['local_storage'] = '{}'
|
||||
state['session_storage'] = '{}'
|
||||
|
||||
return state
|
||||
|
||||
def save_state(self, browsergym_env) -> str:
|
||||
"""Save the current browser state to disk."""
|
||||
if self.current_instance_id is None:
|
||||
raise RuntimeError('Instance ID not set. Call set_instance_id() first.')
|
||||
|
||||
state = self.capture_final_state(browsergym_env)
|
||||
|
||||
# Save to file
|
||||
state_file = self.output_dir / f'{self.current_instance_id}.json'
|
||||
with open(state_file, 'w') as f:
|
||||
json.dump(state, f, indent=2, default=str)
|
||||
|
||||
print(f'✅ Saved browser state to: {state_file}')
|
||||
return str(state_file)
|
||||
|
||||
def load_state(self, instance_id: str) -> dict[str, Any]:
|
||||
"""Load saved browser state from disk."""
|
||||
state_file = self.output_dir / f'{instance_id}.json'
|
||||
|
||||
if not state_file.exists():
|
||||
raise FileNotFoundError(f'State file not found: {state_file}')
|
||||
|
||||
with open(state_file, 'r') as f:
|
||||
state = json.load(f)
|
||||
|
||||
return state
|
||||
|
||||
|
||||
class MockPageForWebArena:
|
||||
"""
|
||||
Mock Page object that provides saved browser state for WebArena evaluation.
|
||||
This uses the exact state captured by BrowserGym's observation functions.
|
||||
"""
|
||||
|
||||
def __init__(self, saved_state: dict[str, Any]):
|
||||
self.saved_state = saved_state
|
||||
self._url = saved_state.get('final_url', '')
|
||||
self._title = saved_state.get('final_title', '')
|
||||
self._context = MockBrowserContextForWebArena(saved_state)
|
||||
|
||||
def url(self) -> str:
|
||||
return self._url
|
||||
|
||||
def title(self) -> str:
|
||||
return self._title
|
||||
|
||||
@property
|
||||
def context(self):
|
||||
return self._context
|
||||
|
||||
def evaluate(self, expression: str) -> Any:
|
||||
"""Mock JavaScript evaluation using saved state."""
|
||||
if 'window.location.href' in expression:
|
||||
return self._url
|
||||
elif 'document.title' in expression:
|
||||
return self._title
|
||||
elif 'localStorage' in expression:
|
||||
return self.saved_state.get('local_storage', '{}')
|
||||
elif 'sessionStorage' in expression:
|
||||
return self.saved_state.get('session_storage', '{}')
|
||||
return None
|
||||
|
||||
|
||||
class MockCDPSessionForWebArena:
|
||||
"""
|
||||
Mock CDPSession that returns saved state from BrowserGym's observations.
|
||||
This is the key component that makes WebArena evaluators work.
|
||||
"""
|
||||
|
||||
def __init__(self, saved_state: dict[str, Any]):
|
||||
self.saved_state = saved_state
|
||||
|
||||
def send(self, method: str, params: Optional[dict] = None) -> dict[str, Any]:
|
||||
"""
|
||||
Mock CDP send method that returns BrowserGym's captured state.
|
||||
The key insight: BrowserGym's extract_dom_snapshot() already returns
|
||||
the exact format that WebArena evaluators expect from CDP calls.
|
||||
"""
|
||||
if method == 'DOMSnapshot.captureSnapshot':
|
||||
# BrowserGym's extract_dom_snapshot already returns the right format!
|
||||
return self.saved_state.get('dom_snapshot', {})
|
||||
|
||||
elif method == 'Network.getAllCookies':
|
||||
return self.saved_state.get('cookies', {'cookies': []})
|
||||
|
||||
elif method == 'Runtime.evaluate':
|
||||
if params and 'expression' in params:
|
||||
expression = params['expression']
|
||||
if 'localStorage' in expression:
|
||||
return {
|
||||
'result': {'value': self.saved_state.get('local_storage', '{}')}
|
||||
}
|
||||
elif 'sessionStorage' in expression:
|
||||
return {
|
||||
'result': {
|
||||
'value': self.saved_state.get('session_storage', '{}')
|
||||
}
|
||||
}
|
||||
elif 'window.location.href' in expression:
|
||||
return {'result': {'value': self.saved_state.get('final_url', '')}}
|
||||
elif 'document.title' in expression:
|
||||
return {
|
||||
'result': {'value': self.saved_state.get('final_title', '')}
|
||||
}
|
||||
|
||||
return {}
|
||||
|
||||
def detach(self):
|
||||
"""Mock detach method."""
|
||||
pass
|
||||
|
||||
|
||||
class MockBrowserContextForWebArena:
|
||||
"""Mock browser context for WebArena evaluation."""
|
||||
|
||||
def __init__(self, saved_state: dict[str, Any]):
|
||||
self.saved_state = saved_state
|
||||
|
||||
def new_cdp_session(self, page) -> MockCDPSessionForWebArena:
|
||||
"""Return mock CDP session with BrowserGym's captured state."""
|
||||
return MockCDPSessionForWebArena(self.saved_state)
|
||||
|
||||
|
||||
def integrate_with_openhands_browser_env():
|
||||
"""
|
||||
Integration point for OpenHands browser_env.py.
|
||||
This shows how to add state capture to the existing BrowserGym usage.
|
||||
"""
|
||||
|
||||
# This would be added to browser_env.py in the browser_process method
|
||||
example_integration = """
|
||||
def browser_process(self) -> None:
|
||||
env = gym.make('browsergym/openended', ...)
|
||||
obs, info = env.reset()
|
||||
|
||||
# Add state capture for WebArena evaluation
|
||||
state_capture = None
|
||||
if os.getenv('WEBARENA_EVALUATION'):
|
||||
state_capture = BrowserGymStateCapture()
|
||||
|
||||
while should_continue():
|
||||
if self.browser_side.poll(timeout=0.01):
|
||||
unique_request_id, action_data = self.browser_side.recv()
|
||||
|
||||
# Handle WebArena instance ID setting
|
||||
if unique_request_id == 'SET_WEBARENA_INSTANCE':
|
||||
if state_capture:
|
||||
state_capture.set_instance_id(action_data['instance_id'])
|
||||
continue
|
||||
|
||||
action = action_data['action']
|
||||
obs, reward, terminated, truncated, info = env.step(action)
|
||||
|
||||
# Capture final state when task completes
|
||||
if terminated and state_capture:
|
||||
state_capture.save_state(env)
|
||||
|
||||
# ... rest of existing code ...
|
||||
"""
|
||||
|
||||
return example_integration
|
||||
|
||||
|
||||
def demonstrate_integration():
|
||||
"""Demonstrate how this integrates with WebArena evaluation."""
|
||||
print('🚀 BrowserGym State Capture for WebArena')
|
||||
print('=' * 50)
|
||||
|
||||
print('✅ Key advantages of this approach:')
|
||||
print(" 1. Uses BrowserGym's existing observation functions")
|
||||
print(' 2. extract_dom_snapshot() already returns WebArena-compatible format')
|
||||
print(' 3. No custom CDP logging needed')
|
||||
print(' 4. Minimal changes to OpenHands browser_env.py')
|
||||
print(' 5. Leverages existing, tested BrowserGym infrastructure')
|
||||
|
||||
print('\n📋 Integration steps:')
|
||||
print(' 1. Add BrowserGymStateCapture to browser_env.py')
|
||||
print(' 2. Capture state when WebArena tasks complete')
|
||||
print(
|
||||
' 3. Use MockPageForWebArena and MockCDPSessionForWebArena in eval_infer.py'
|
||||
)
|
||||
print(' 4. Official WebArena evaluators work with mock objects')
|
||||
|
||||
print('\n🎯 This is much simpler than custom CDP logging because')
|
||||
print(' BrowserGym already provides everything we need!')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
demonstrate_integration()
|
||||
@@ -1,359 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WebArena evaluation script for OpenHands outputs using official WebArena evaluation harness.
|
||||
This script evaluates the results from run_infer.py using the official WebArena evaluation code.
|
||||
|
||||
This script requires:
|
||||
1. Official WebArena repository cloned to /workspace/project/webarena
|
||||
2. WebArena environment variables properly configured
|
||||
3. Authentication files set up for WebArena sites
|
||||
4. Docker containers running for WebArena sites
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
# Set up environment variables for WebArena
|
||||
WEBARENA_BASE_URL = os.environ.get('WEBARENA_BASE_URL', '')
|
||||
if WEBARENA_BASE_URL:
|
||||
os.environ['REDDIT'] = f'{WEBARENA_BASE_URL}:9999'
|
||||
os.environ['SHOPPING'] = f'{WEBARENA_BASE_URL}:7770'
|
||||
os.environ['SHOPPING_ADMIN'] = f'{WEBARENA_BASE_URL}:7780'
|
||||
os.environ['GITLAB'] = f'{WEBARENA_BASE_URL}:8023'
|
||||
os.environ['WIKIPEDIA'] = f'{WEBARENA_BASE_URL}:8888'
|
||||
os.environ['MAP'] = f'{WEBARENA_BASE_URL}:3000'
|
||||
os.environ['HOMEPAGE'] = f'{WEBARENA_BASE_URL}:4399'
|
||||
|
||||
# Add the webarena path to sys.path to import its modules
|
||||
WEBARENA_PATH = '/workspace/project/webarena'
|
||||
sys.path.insert(0, WEBARENA_PATH)
|
||||
|
||||
try:
|
||||
from browser_env import ScriptBrowserEnv, create_stop_action
|
||||
from browser_env.actions import Action
|
||||
from browser_env.utils import StateInfo
|
||||
from evaluation_harness import evaluator_router
|
||||
|
||||
print('✅ WebArena evaluation harness imported successfully')
|
||||
except ImportError as e:
|
||||
print(f'❌ Failed to import WebArena evaluation harness: {e}')
|
||||
print('Make sure the WebArena repository is cloned to /workspace/project/webarena')
|
||||
print('and all dependencies are installed.')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def load_config_file(config_path: str) -> dict[str, Any]:
|
||||
"""Load WebArena config file."""
|
||||
with open(config_path, 'r') as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def convert_openhands_action_to_webarena(action_data: dict[str, Any]) -> Action:
|
||||
"""Convert OpenHands action format to WebArena action format."""
|
||||
action_type = action_data.get('action', '')
|
||||
args = action_data.get('args', {})
|
||||
|
||||
if action_type == 'browse':
|
||||
url = args.get('url', '')
|
||||
if url:
|
||||
return Action(action_type='goto', coordinate=[0, 0], text=url)
|
||||
|
||||
elif action_type == 'click':
|
||||
coordinate = args.get('coordinate', [0, 0])
|
||||
return Action(action_type='click', coordinate=coordinate)
|
||||
|
||||
elif action_type == 'type':
|
||||
text = args.get('text', '')
|
||||
return Action(action_type='type', text=text, coordinate=[0, 0])
|
||||
|
||||
elif action_type == 'key':
|
||||
key = args.get('key', '')
|
||||
return Action(action_type='key', text=key, coordinate=[0, 0])
|
||||
|
||||
elif action_type == 'scroll':
|
||||
coordinate = args.get('coordinate', [0, 0])
|
||||
direction = args.get('direction', 'down')
|
||||
return Action(action_type='scroll', coordinate=coordinate, text=direction)
|
||||
|
||||
elif action_type == 'finish':
|
||||
return create_stop_action('')
|
||||
|
||||
# Default fallback for unknown actions
|
||||
return Action(action_type='none', coordinate=[0, 0])
|
||||
|
||||
|
||||
def convert_openhands_trajectory_to_webarena_format(
|
||||
openhands_output: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Convert OpenHands trajectory format to WebArena trajectory format.
|
||||
|
||||
OpenHands format: history contains pairs of [action, observation]
|
||||
WebArena format: trajectory is a list alternating between StateInfo and Action
|
||||
"""
|
||||
trajectory = []
|
||||
|
||||
# Add initial state
|
||||
initial_state = StateInfo(
|
||||
observation={'text': 'Initial state'}, info={'observation_metadata': {}}
|
||||
)
|
||||
trajectory.append(initial_state)
|
||||
|
||||
# Process the history
|
||||
history = openhands_output.get('history', [])
|
||||
for history_pair in history:
|
||||
if len(history_pair) >= 2:
|
||||
action_data = history_pair[0]
|
||||
observation_data = history_pair[1]
|
||||
|
||||
# Convert action
|
||||
webarena_action = convert_openhands_action_to_webarena(action_data)
|
||||
trajectory.append(webarena_action)
|
||||
|
||||
# Add state info from observation
|
||||
state_info = StateInfo(
|
||||
observation={'text': observation_data.get('content', '')},
|
||||
info={'observation_metadata': observation_data.get('extras', {})},
|
||||
)
|
||||
trajectory.append(state_info)
|
||||
|
||||
return trajectory
|
||||
|
||||
|
||||
def evaluate_with_official_webarena_harness(
|
||||
instance_data: dict[str, Any], config_file_path: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Evaluate a single WebArena instance using the official evaluation harness.
|
||||
|
||||
This function:
|
||||
1. Converts OpenHands trajectory to WebArena format
|
||||
2. Sets up a browser environment
|
||||
3. Replays the trajectory to reach the final state
|
||||
4. Runs the official WebArena evaluator
|
||||
"""
|
||||
|
||||
instance_id = instance_data.get('instance_id', 'unknown')
|
||||
print(f'\n🔍 Evaluating instance: {instance_id}')
|
||||
|
||||
try:
|
||||
# Load config to understand the task
|
||||
config_data = load_config_file(config_file_path)
|
||||
intent = config_data.get('intent', '')
|
||||
start_url = config_data.get('start_url', '')
|
||||
|
||||
print(f' Task: {intent}')
|
||||
print(f' Start URL: {start_url}')
|
||||
|
||||
# Convert OpenHands trajectory to WebArena format
|
||||
trajectory = convert_openhands_trajectory_to_webarena_format(instance_data)
|
||||
print(f' Converted trajectory with {len(trajectory)} steps')
|
||||
|
||||
# Get the evaluator for this config
|
||||
evaluator = evaluator_router(config_file_path)
|
||||
print(f' Using evaluator: {type(evaluator).__name__}')
|
||||
|
||||
# Create browser environment for evaluation
|
||||
env = ScriptBrowserEnv(
|
||||
headless=True,
|
||||
slow_mo=0,
|
||||
observation_type='accessibility_tree',
|
||||
current_viewport_only=True,
|
||||
viewport_size={'width': 1280, 'height': 720},
|
||||
)
|
||||
|
||||
try:
|
||||
# Initialize the environment with the task
|
||||
obs, info = env.reset(options={'config_file': config_file_path})
|
||||
|
||||
# Replay the trajectory to reach the final state
|
||||
# This is necessary because the evaluator needs the actual browser state
|
||||
current_obs = obs
|
||||
for i, step in enumerate(trajectory):
|
||||
if isinstance(step, Action):
|
||||
try:
|
||||
current_obs, reward, done, info = env.step(step)
|
||||
if done:
|
||||
break
|
||||
except Exception as e:
|
||||
print(f' Warning: Error replaying step {i}: {e}')
|
||||
continue
|
||||
|
||||
# Run the official evaluation
|
||||
score = evaluator(
|
||||
trajectory=trajectory,
|
||||
config_file=config_file_path,
|
||||
page=env.page,
|
||||
client=env.page.context.new_cdp_session(env.page),
|
||||
)
|
||||
|
||||
result = {
|
||||
'instance_id': instance_id,
|
||||
'score': score,
|
||||
'success': score == 1.0,
|
||||
'trajectory_length': len(trajectory),
|
||||
'evaluator': type(evaluator).__name__,
|
||||
'evaluation_type': 'official_webarena_harness',
|
||||
'intent': intent,
|
||||
}
|
||||
|
||||
print(
|
||||
f' Result: {"✅ PASS" if score == 1.0 else "❌ FAIL"} (score: {score})'
|
||||
)
|
||||
return result
|
||||
|
||||
finally:
|
||||
env.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f' ❌ Error evaluating {instance_id}: {e}')
|
||||
return {
|
||||
'instance_id': instance_id,
|
||||
'score': 0.0,
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'evaluator': 'error',
|
||||
'evaluation_type': 'error',
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Evaluate WebArena results using ONLY the official WebArena evaluation harness'
|
||||
)
|
||||
parser.add_argument(
|
||||
'output_file', type=str, help='Path to OpenHands output.jsonl file'
|
||||
)
|
||||
parser.add_argument(
|
||||
'--results_file',
|
||||
type=str,
|
||||
default='webarena_official_eval_results.json',
|
||||
help='Path to save evaluation results',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--config_dir',
|
||||
type=str,
|
||||
default='/workspace/project/webarena/config_files/examples',
|
||||
help='Directory containing WebArena config files',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print('🚀 Starting WebArena Evaluation with Official WebArena Harness ONLY')
|
||||
print(f'📁 Output file: {args.output_file}')
|
||||
print(f'📁 Config directory: {args.config_dir}')
|
||||
|
||||
# Verify WebArena environment is properly set up
|
||||
if not WEBARENA_BASE_URL:
|
||||
print('❌ WEBARENA_BASE_URL environment variable not set')
|
||||
print('Please set WEBARENA_BASE_URL to your WebArena server URL')
|
||||
sys.exit(1)
|
||||
|
||||
print(f'🌐 WebArena base URL: {WEBARENA_BASE_URL}')
|
||||
|
||||
# Load OpenHands results
|
||||
results = []
|
||||
with open(args.output_file, 'r') as f:
|
||||
for line in f:
|
||||
if line.strip():
|
||||
results.append(json.loads(line))
|
||||
|
||||
print(f'📊 Found {len(results)} instances to evaluate')
|
||||
|
||||
# Evaluate each instance using ONLY official WebArena evaluation harness
|
||||
evaluation_results = []
|
||||
total_score = 0.0
|
||||
|
||||
for result in results:
|
||||
instance_id = result.get('instance_id', 'unknown')
|
||||
|
||||
# Find corresponding config file
|
||||
config_file = None
|
||||
# Accept either plain numeric id ("8") or legacy prefixed id ("webarena.8")
|
||||
task_num = instance_id.split('.')[-1]
|
||||
config_file = f'{args.config_dir}/{task_num}.json'
|
||||
|
||||
if config_file and os.path.exists(config_file):
|
||||
eval_result = evaluate_with_official_webarena_harness(result, config_file)
|
||||
evaluation_results.append(eval_result)
|
||||
total_score += eval_result.get('score', 0.0)
|
||||
else:
|
||||
print(f'\n🔍 Evaluating instance: {instance_id}')
|
||||
print(f' ⚠️ Config file not found: {config_file}')
|
||||
evaluation_results.append(
|
||||
{
|
||||
'instance_id': instance_id,
|
||||
'score': 0.0,
|
||||
'success': False,
|
||||
'error': f'Config file not found: {config_file}',
|
||||
'evaluation_type': 'config_error',
|
||||
}
|
||||
)
|
||||
|
||||
# Calculate final metrics
|
||||
total_instances = len(evaluation_results)
|
||||
success_count = sum(1 for r in evaluation_results if r.get('success', False))
|
||||
success_rate = success_count / total_instances if total_instances > 0 else 0.0
|
||||
average_score = total_score / total_instances if total_instances > 0 else 0.0
|
||||
|
||||
# Save results
|
||||
final_results = {
|
||||
'evaluation_method': 'official_webarena_harness_only',
|
||||
'webarena_base_url': WEBARENA_BASE_URL,
|
||||
'total_instances': total_instances,
|
||||
'success_count': success_count,
|
||||
'success_rate': success_rate,
|
||||
'average_score': average_score,
|
||||
'individual_results': evaluation_results,
|
||||
}
|
||||
|
||||
with open(args.results_file, 'w') as f:
|
||||
json.dump(final_results, f, indent=2)
|
||||
|
||||
# Print summary
|
||||
print('\n' + '=' * 70)
|
||||
print('🎯 WEBARENA EVALUATION RESULTS (Official Harness ONLY)')
|
||||
print('=' * 70)
|
||||
print(f'📊 Total instances: {total_instances}')
|
||||
print(f'✅ Successful: {success_count}')
|
||||
print(f'❌ Failed: {total_instances - success_count}')
|
||||
print(f'📈 Success rate: {success_rate:.2%}')
|
||||
print(f'📊 Average score: {average_score:.4f}')
|
||||
print(f'💾 Results saved to: {args.results_file}')
|
||||
print('=' * 70)
|
||||
|
||||
# Print individual results
|
||||
print('\n📋 Individual Results:')
|
||||
for result in evaluation_results:
|
||||
status = '✅ PASS' if result.get('success', False) else '❌ FAIL'
|
||||
score = result.get('score', 0.0)
|
||||
instance_id = result.get('instance_id', 'unknown')
|
||||
evaluator = result.get('evaluator', 'unknown')
|
||||
error = result.get('error', '')
|
||||
if error:
|
||||
print(f' {instance_id}: {status} (score: {score:.2f}) - Error: {error}')
|
||||
else:
|
||||
print(
|
||||
f' {instance_id}: {status} (score: {score:.2f}) - Evaluator: {evaluator}'
|
||||
)
|
||||
|
||||
# Print requirements if there were errors
|
||||
error_count = sum(1 for r in evaluation_results if r.get('error'))
|
||||
if error_count > 0:
|
||||
print('\n' + '⚠️' * 20)
|
||||
print('EVALUATION ERRORS DETECTED')
|
||||
print('⚠️' * 20)
|
||||
print('This evaluation requires:')
|
||||
print('1. WebArena Docker containers running and accessible')
|
||||
print('2. Authentication files (.auth/) properly set up')
|
||||
print('3. All WebArena dependencies installed')
|
||||
print('4. Proper network access to WebArena sites')
|
||||
print('\nPlease resolve these issues for accurate evaluation.')
|
||||
print('⚠️' * 20)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -1,211 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WebArena Evaluation Script
|
||||
|
||||
This script evaluates WebArena task results using the official WebArena evaluation harness
|
||||
with BrowserGym state capture. It loads saved browser state and creates mock objects
|
||||
that provide the exact state WebArena evaluators need.
|
||||
|
||||
This approach leverages BrowserGym's existing observation functions (extract_dom_snapshot, etc.)
|
||||
which already provide WebArena-compatible state capture.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Add WebArena to path
|
||||
sys.path.insert(0, '/workspace/project/webarena')
|
||||
|
||||
|
||||
def convert_openhands_trajectory_to_webarena_format(
|
||||
instance_data: dict[str, Any],
|
||||
) -> list[Any]:
|
||||
"""
|
||||
Convert OpenHands trajectory format to WebArena trajectory format.
|
||||
|
||||
WebArena expects a list of alternating Action and StateInfo objects.
|
||||
OpenHands provides action/observation pairs in text format.
|
||||
"""
|
||||
trajectory = []
|
||||
|
||||
# Get the conversation history
|
||||
history = instance_data.get('history', [])
|
||||
|
||||
for entry in history:
|
||||
if entry.get('source') == 'agent':
|
||||
# This is an agent action
|
||||
content = entry.get('message', {}).get('content', '')
|
||||
|
||||
# Create a WebArena-compatible action
|
||||
action = {
|
||||
'action_type': 'browser_action',
|
||||
'content': content,
|
||||
'timestamp': entry.get('timestamp', 0),
|
||||
}
|
||||
trajectory.append(action)
|
||||
|
||||
elif entry.get('source') == 'user':
|
||||
# This might be an observation or state info
|
||||
content = entry.get('message', {}).get('content', '')
|
||||
|
||||
# Create a WebArena-compatible state info
|
||||
state_info = {
|
||||
'observation': content,
|
||||
'timestamp': entry.get('timestamp', 0),
|
||||
}
|
||||
trajectory.append(state_info)
|
||||
|
||||
# Add a final stop action if needed
|
||||
if trajectory and not trajectory[-1].get('action_type'):
|
||||
trajectory.append(
|
||||
{
|
||||
'action_type': 'stop',
|
||||
'content': 'Task completed',
|
||||
'timestamp': trajectory[-1].get('timestamp', 0) + 1,
|
||||
}
|
||||
)
|
||||
|
||||
return trajectory
|
||||
|
||||
|
||||
def evaluate_with_browsergym_state_capture(
|
||||
instance_data: dict[str, Any], config_file: str
|
||||
) -> float:
|
||||
"""
|
||||
Evaluate using official WebArena harness with BrowserGym state capture.
|
||||
|
||||
This loads the saved browser state captured during inference and creates
|
||||
mock Page/CDPSession objects that provide the exact state WebArena evaluators need.
|
||||
"""
|
||||
try:
|
||||
# Import BrowserGym state capture
|
||||
from browsergym_state_capture import (
|
||||
BrowserGymStateCapture,
|
||||
MockCDPSessionForWebArena,
|
||||
MockPageForWebArena,
|
||||
)
|
||||
|
||||
# Import WebArena evaluation components
|
||||
from evaluation_harness import evaluator_router
|
||||
|
||||
# Load saved browser state
|
||||
instance_id = instance_data.get('instance_id', 'unknown')
|
||||
state_capture = BrowserGymStateCapture()
|
||||
|
||||
try:
|
||||
saved_state = state_capture.load_state(instance_id)
|
||||
print(f' ✅ Loaded browser state for {instance_id}')
|
||||
except FileNotFoundError:
|
||||
print(f' ❌ No saved browser state found for {instance_id}')
|
||||
print(' Make sure inference was run with browser_logging_dir enabled')
|
||||
return 0.0
|
||||
|
||||
# Create mock objects with saved state
|
||||
mock_page = MockPageForWebArena(saved_state)
|
||||
mock_client = MockCDPSessionForWebArena(saved_state)
|
||||
|
||||
# Convert trajectory format
|
||||
trajectory = convert_openhands_trajectory_to_webarena_format(instance_data)
|
||||
|
||||
# Get the official evaluator
|
||||
evaluator = evaluator_router(config_file)
|
||||
|
||||
# Run evaluation with mock objects containing saved browser state
|
||||
score = evaluator(
|
||||
trajectory=trajectory,
|
||||
config_file=config_file,
|
||||
page=mock_page, # Mock page with BrowserGym's captured state
|
||||
client=mock_client, # Mock CDP session with BrowserGym's captured state
|
||||
)
|
||||
|
||||
return score
|
||||
|
||||
except ImportError as e:
|
||||
print(f' ❌ Could not import BrowserGym state capture: {e}')
|
||||
print(' Make sure browsergym_state_capture.py is available')
|
||||
return 0.0
|
||||
except Exception as e:
|
||||
print(f' ❌ Evaluation failed: {e}')
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
return 0.0
|
||||
|
||||
|
||||
def main():
|
||||
"""Main evaluation function."""
|
||||
if len(sys.argv) != 2:
|
||||
print('Usage: python eval_infer.py <output_file>')
|
||||
sys.exit(1)
|
||||
|
||||
output_file = sys.argv[1]
|
||||
|
||||
if not os.path.exists(output_file):
|
||||
print(f'❌ Output file not found: {output_file}')
|
||||
sys.exit(1)
|
||||
|
||||
print('🔍 WebArena Evaluation (BrowserGym State Capture)')
|
||||
print('=' * 60)
|
||||
|
||||
# Load results
|
||||
with open(output_file, 'r') as f:
|
||||
results = [json.loads(line) for line in f]
|
||||
|
||||
print(f'📊 Evaluating {len(results)} WebArena tasks...')
|
||||
|
||||
# WebArena config files
|
||||
config_dir = Path('/workspace/project/webarena/config_files/examples')
|
||||
|
||||
total_score = 0
|
||||
evaluated_count = 0
|
||||
|
||||
for result in results:
|
||||
instance_id = result.get('instance_id', 'unknown')
|
||||
|
||||
# Find corresponding config file
|
||||
config_file = config_dir / f'{instance_id}.json'
|
||||
|
||||
if not config_file.exists():
|
||||
print(f'⚠️ Config file not found for {instance_id}')
|
||||
continue
|
||||
|
||||
print(f'\n🧪 Evaluating {instance_id}...')
|
||||
|
||||
try:
|
||||
# Use official WebArena evaluation with BrowserGym state capture
|
||||
score = evaluate_with_browsergym_state_capture(result, str(config_file))
|
||||
|
||||
print(f' Score: {score}')
|
||||
total_score += score
|
||||
evaluated_count += 1
|
||||
|
||||
except Exception as e:
|
||||
print(f' ❌ Evaluation failed: {e}')
|
||||
|
||||
if evaluated_count > 0:
|
||||
average_score = total_score / evaluated_count
|
||||
print('\n📈 Results Summary:')
|
||||
print(f' Tasks evaluated: {evaluated_count}')
|
||||
print(f' Total score: {total_score}')
|
||||
print(f' Average score: {average_score:.3f}')
|
||||
print(
|
||||
f' Pass rate: {total_score}/{evaluated_count} ({100 * total_score / evaluated_count:.1f}%)'
|
||||
)
|
||||
else:
|
||||
print('\n❌ No tasks could be evaluated')
|
||||
|
||||
print('\n🎯 Evaluation Method:')
|
||||
print(' - Uses official WebArena evaluation harness')
|
||||
print(' - Loads browser state captured by BrowserGym during inference')
|
||||
print(' - Creates mock Page/CDPSession objects with exact browser state')
|
||||
print(' - WebArena evaluators get the exact state they need')
|
||||
|
||||
print('\n💡 To enable browser state capture during inference:')
|
||||
print(' export WEBARENA_BROWSER_LOGGING_DIR=/tmp/webarena_states')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
33
evaluation/benchmarks/webarena/get_success_rate.py
Normal file
33
evaluation/benchmarks/webarena/get_success_rate.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import argparse
|
||||
import json
|
||||
|
||||
import browsergym.webarena # noqa F401 register webarena tasks as gym environments
|
||||
import gymnasium as gym
|
||||
|
||||
parser = argparse.ArgumentParser(description='Calculate average reward.')
|
||||
parser.add_argument('output_path', type=str, help='path to output.jsonl')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if __name__ == '__main__':
|
||||
env_ids = [
|
||||
id for id in gym.envs.registry.keys() if id.startswith('browsergym/webarena')
|
||||
]
|
||||
total_num = len(env_ids)
|
||||
print('Total number of tasks: ', total_num)
|
||||
total_reward = 0
|
||||
total_cost = 0
|
||||
actual_num = 0
|
||||
with open(args.output_path, 'r') as f:
|
||||
for line in f:
|
||||
data = json.loads(line)
|
||||
actual_num += 1
|
||||
total_cost += data['metrics']['accumulated_cost']
|
||||
total_reward += data['test_result']
|
||||
|
||||
avg_reward = total_reward / total_num
|
||||
print('Success Rate: ', avg_reward)
|
||||
|
||||
avg_cost = total_cost / actual_num
|
||||
print('Avg Cost: ', avg_cost)
|
||||
print('Actual number of tasks finished: ', actual_num)
|
||||
@@ -1,13 +1,15 @@
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import browsergym.webarena # noqa F401 register webarena tasks as gym environments
|
||||
import gymnasium as gym
|
||||
import pandas as pd
|
||||
|
||||
from evaluation.utils.shared import (
|
||||
EvalMetadata,
|
||||
EvalOutput,
|
||||
codeact_user_response,
|
||||
compatibility_for_eval_history_pairs,
|
||||
get_default_sandbox_config_for_eval,
|
||||
get_metrics,
|
||||
@@ -21,32 +23,29 @@ from openhands.controller.state.state import State
|
||||
from openhands.core.config import (
|
||||
OpenHandsConfig,
|
||||
get_llm_config_arg,
|
||||
parse_arguments,
|
||||
)
|
||||
from openhands.core.config.arg_utils import get_evaluation_parser
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.main import create_runtime, run_controller
|
||||
from openhands.events.action import (
|
||||
BrowseInteractiveAction,
|
||||
CmdRunAction,
|
||||
MessageAction,
|
||||
)
|
||||
from openhands.events.observation import CmdOutputObservation
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.runtime.browser.browser_env import (
|
||||
BROWSER_EVAL_GET_GOAL_ACTION,
|
||||
BROWSER_EVAL_GET_REWARDS_ACTION,
|
||||
)
|
||||
from openhands.utils.async_utils import call_async_from_sync
|
||||
|
||||
SUPPORTED_AGENT_CLS = {'BrowsingAgent', 'CodeActAgent'}
|
||||
|
||||
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
|
||||
'CodeActAgent': codeact_user_response,
|
||||
'BrowsingAgent': codeact_user_response,
|
||||
}
|
||||
|
||||
# Global variable to store task configs
|
||||
TASK_CONFIGS = {}
|
||||
SUPPORTED_AGENT_CLS = {'BrowsingAgent'}
|
||||
|
||||
|
||||
def get_config(
|
||||
metadata: EvalMetadata,
|
||||
task_config: dict,
|
||||
env_id: str,
|
||||
) -> OpenHandsConfig:
|
||||
base_url = os.environ.get('WEBARENA_BASE_URL', None)
|
||||
openai_api_key = os.environ.get('OPENAI_API_KEY', None)
|
||||
@@ -55,7 +54,7 @@ def get_config(
|
||||
|
||||
sandbox_config = get_default_sandbox_config_for_eval()
|
||||
sandbox_config.base_container_image = 'python:3.12-bookworm'
|
||||
# Remove browsergym_eval_env dependency - we'll use regular browser environment
|
||||
sandbox_config.browsergym_eval_env = env_id
|
||||
sandbox_config.runtime_startup_env_vars = {
|
||||
'BASE_URL': base_url,
|
||||
'OPENAI_API_KEY': openai_api_key,
|
||||
@@ -71,7 +70,6 @@ def get_config(
|
||||
metadata=metadata,
|
||||
runtime='docker',
|
||||
sandbox_config=sandbox_config,
|
||||
enable_browser=True,
|
||||
)
|
||||
config.set_llm_config(metadata.llm_config)
|
||||
agent_config = config.get_agent_config(metadata.agent_class)
|
||||
@@ -79,59 +77,30 @@ def get_config(
|
||||
return config
|
||||
|
||||
|
||||
def get_instruction(task_config: dict) -> MessageAction:
|
||||
"""Create the instruction message for the agent based on the task config."""
|
||||
intent = task_config.get('intent', 'Complete the task')
|
||||
start_url = task_config.get('start_url', 'about:blank')
|
||||
|
||||
# BrowserGym WebArena already handles URL substitution, so we can use start_url directly
|
||||
# Create a comprehensive instruction that includes the task and starting point
|
||||
instruction = f"""You are a web browsing agent. Your task is: {intent}
|
||||
|
||||
Please start by navigating to: {start_url}
|
||||
|
||||
Complete the task by interacting with the webpage as needed. Use the browser tool to navigate, click, fill forms, and perform other web interactions to accomplish the goal."""
|
||||
|
||||
return MessageAction(content=instruction)
|
||||
|
||||
|
||||
def initialize_runtime(
|
||||
runtime: Runtime,
|
||||
task_config: dict,
|
||||
) -> None:
|
||||
) -> dict:
|
||||
"""Initialize the runtime for the agent.
|
||||
|
||||
This function is called before the runtime is used to run the agent.
|
||||
Also performs initial navigation to the task's start_url because USE_NAV is disabled during evaluation.
|
||||
"""
|
||||
logger.info(f'{"-" * 50} BEGIN Runtime Initialization Fn {"-" * 50}')
|
||||
obs: CmdOutputObservation
|
||||
|
||||
# Ensure workspace exists
|
||||
# Set instance id
|
||||
action = CmdRunAction(command='mkdir -p /workspace')
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
assert obs.exit_code == 0
|
||||
|
||||
# Navigate to the configured start_url so the page is ready for the agent
|
||||
try:
|
||||
from openhands.events.action import BrowseInteractiveAction
|
||||
|
||||
start_url = task_config.get('start_url')
|
||||
if start_url:
|
||||
browse_action = BrowseInteractiveAction(
|
||||
browser_actions=f'goto("{start_url}")',
|
||||
return_axtree=True,
|
||||
)
|
||||
runtime.browse_interactive(browse_action)
|
||||
else:
|
||||
logger.warning(
|
||||
'No start_url found in task_config; skipping initial navigation'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to perform initial navigation: {e}')
|
||||
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_GOAL_ACTION)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
goal = obs.content
|
||||
|
||||
logger.info(f'{"-" * 50} END Runtime Initialization Fn {"-" * 50}')
|
||||
return goal
|
||||
|
||||
|
||||
def complete_runtime(
|
||||
@@ -139,40 +108,22 @@ def complete_runtime(
|
||||
) -> dict[str, Any]:
|
||||
"""Complete the runtime for the agent.
|
||||
|
||||
This function is called after the agent has run.
|
||||
Since we're using the official webarena evaluation, we don't need to get rewards here.
|
||||
This function is called before the runtime is used to run the agent.
|
||||
If you need to do something in the sandbox to get the correctness metric after
|
||||
the agent has run, modify this function.
|
||||
"""
|
||||
logger.info(f'{"-" * 50} BEGIN Runtime Completion Fn {"-" * 50}')
|
||||
obs: CmdOutputObservation
|
||||
|
||||
# Capture the final accessibility tree for WebArena evaluation
|
||||
try:
|
||||
# Create a browser action to get the current page state with accessibility tree
|
||||
from openhands.events.action import BrowseInteractiveAction
|
||||
action = BrowseInteractiveAction(browser_actions=BROWSER_EVAL_GET_REWARDS_ACTION)
|
||||
logger.info(action, extra={'msg_type': 'ACTION'})
|
||||
obs = runtime.run_action(action)
|
||||
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
|
||||
|
||||
# Use a no-op action that returns the accessibility tree
|
||||
final_browse_action = BrowseInteractiveAction(
|
||||
browser_actions='noop()', # No-op action to just get current state
|
||||
return_axtree=True, # Ensure we get the accessibility tree
|
||||
)
|
||||
|
||||
# Execute the action to get the final observation with accessibility tree
|
||||
final_obs = runtime.browse_interactive(final_browse_action)
|
||||
|
||||
# Extract the accessibility tree from the observation
|
||||
final_axtree = None
|
||||
if hasattr(final_obs, 'axtree_object') and final_obs.axtree_object:
|
||||
final_axtree = final_obs.axtree_object
|
||||
logger.info('Successfully captured final accessibility tree')
|
||||
else:
|
||||
logger.warning('No accessibility tree found in final observation')
|
||||
|
||||
logger.info(f'{"-" * 50} END Runtime Completion Fn {"-" * 50}')
|
||||
return {'final_accessibility_tree': final_axtree}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Error capturing final accessibility tree: {e}')
|
||||
logger.info(f'{"-" * 50} END Runtime Completion Fn {"-" * 50}')
|
||||
return {'final_accessibility_tree': None}
|
||||
logger.info(f'{"-" * 50} END Runtime Completion Fn {"-" * 50}')
|
||||
return {
|
||||
'rewards': json.loads(obs.content),
|
||||
}
|
||||
|
||||
|
||||
def process_instance(
|
||||
@@ -180,34 +131,31 @@ def process_instance(
|
||||
metadata: EvalMetadata,
|
||||
reset_logger: bool = True,
|
||||
):
|
||||
task_id = instance.instance_id
|
||||
task_config = TASK_CONFIGS.get(task_id, {})
|
||||
config = get_config(metadata, task_config)
|
||||
env_id = instance.instance_id
|
||||
config = get_config(metadata, env_id)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
log_dir = os.path.join(metadata.eval_output_dir, 'infer_logs')
|
||||
reset_logger_for_multiprocessing(logger, str(task_id), log_dir)
|
||||
reset_logger_for_multiprocessing(logger, env_id, log_dir)
|
||||
else:
|
||||
logger.info(f'Starting evaluation for task {task_id}.')
|
||||
logger.info(f'Starting evaluation for instance {env_id}.')
|
||||
|
||||
runtime = create_runtime(config)
|
||||
call_async_from_sync(runtime.connect)
|
||||
initialize_runtime(runtime, task_config)
|
||||
|
||||
# Get the proper instruction message
|
||||
message_action = get_instruction(task_config)
|
||||
task_str = initialize_runtime(runtime)
|
||||
|
||||
state: State | None = asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=message_action,
|
||||
initial_user_action=MessageAction(content=task_str),
|
||||
runtime=runtime,
|
||||
fake_user_response_fn=AGENT_CLS_TO_FAKE_USER_RESPONSE_FN[
|
||||
metadata.agent_class
|
||||
],
|
||||
)
|
||||
)
|
||||
# ======= Attempt to evaluate the agent's environment impact =======
|
||||
|
||||
# If you are working on some simpler benchmark that only evaluates the final model output (e.g., in a MessageAction)
|
||||
# You can simply get the LAST `MessageAction` from the returned `state.history` and parse it for evaluation.
|
||||
|
||||
if state is None:
|
||||
raise ValueError('State should not be None.')
|
||||
@@ -223,6 +171,7 @@ def process_instance(
|
||||
|
||||
return_val = complete_runtime(runtime)
|
||||
logger.info(f'Return value from complete_runtime: {return_val}')
|
||||
reward = max(return_val['rewards'])
|
||||
|
||||
# history is now available as a stream of events, rather than list of pairs of (Action, Observation)
|
||||
# for compatibility with the existing output format, we can remake the pairs here
|
||||
@@ -231,90 +180,43 @@ def process_instance(
|
||||
|
||||
# Save the output
|
||||
output = EvalOutput(
|
||||
instance_id=str(task_id),
|
||||
instance_id=env_id,
|
||||
instruction=instruction,
|
||||
metadata=metadata,
|
||||
history=histories,
|
||||
metrics=metrics,
|
||||
error=state.last_error if state and state.last_error else None,
|
||||
test_result={
|
||||
'task_config': task_config, # Store task config for later evaluation
|
||||
'final_accessibility_tree': return_val.get('final_accessibility_tree')
|
||||
if return_val
|
||||
else None,
|
||||
'reward': reward,
|
||||
},
|
||||
)
|
||||
return output
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = get_evaluation_parser()
|
||||
args = parser.parse_args()
|
||||
args = parse_arguments()
|
||||
|
||||
# Set up WebArena environment variables for BrowserGym
|
||||
base_url = os.environ.get('WEBARENA_BASE_URL', None)
|
||||
if not base_url:
|
||||
raise ValueError('WEBARENA_BASE_URL must be set')
|
||||
|
||||
# Set up the WA_ prefixed environment variables that BrowserGym expects
|
||||
os.environ['WA_SHOPPING'] = f'{base_url}:7770/'
|
||||
os.environ['WA_SHOPPING_ADMIN'] = f'{base_url}:7780/admin'
|
||||
os.environ['WA_REDDIT'] = f'{base_url}:9999'
|
||||
os.environ['WA_GITLAB'] = f'{base_url}:8023'
|
||||
os.environ['WA_WIKIPEDIA'] = (
|
||||
f'{base_url}:8888/wikipedia_en_all_maxi_2022-05/A/User:The_other_Kiwix_guy/Landing'
|
||||
)
|
||||
os.environ['WA_MAP'] = f'{base_url}:3000'
|
||||
os.environ['WA_HOMEPAGE'] = f'{base_url}:4399'
|
||||
|
||||
# Load webarena task configs from BrowserGym
|
||||
from browsergym.webarena.config import TASK_IDS
|
||||
from browsergym.webarena.task import GenericWebArenaTask
|
||||
|
||||
task_configs = []
|
||||
|
||||
# Load a subset of tasks for testing (first 10 tasks)
|
||||
test_task_ids = list(TASK_IDS)[:10] # Use first 10 tasks for testing
|
||||
|
||||
for task_id in test_task_ids:
|
||||
try:
|
||||
# Create a temporary task to get the config
|
||||
temp_task = GenericWebArenaTask(seed=42, task_id=task_id)
|
||||
|
||||
# Get the first (and likely only) task config for this task_id
|
||||
if temp_task.task_configs:
|
||||
task_config = temp_task.task_configs[0]
|
||||
task_configs.append({'task_id': task_id, 'task_config': task_config})
|
||||
except Exception as e:
|
||||
print(f'Warning: Could not load task {task_id}: {e}')
|
||||
continue
|
||||
|
||||
if not task_configs:
|
||||
raise ValueError('No task configs could be loaded from BrowserGym WebArena')
|
||||
|
||||
print(f'Found {len(task_configs)} task configs from BrowserGym WebArena')
|
||||
|
||||
# Store task configs globally for process_instance to access
|
||||
for task in task_configs:
|
||||
TASK_CONFIGS[str(task['task_id'])] = task['task_config']
|
||||
|
||||
# Create dataset from task configs
|
||||
dataset = pd.DataFrame(
|
||||
[{'instance_id': str(task['task_id'])} for task in task_configs]
|
||||
{
|
||||
'instance_id': [
|
||||
id
|
||||
for id in gym.envs.registry.keys()
|
||||
if id.startswith('browsergym/webarena')
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
llm_config = None
|
||||
if args.llm_config:
|
||||
llm_config = get_llm_config_arg(args.llm_config, args.config_file)
|
||||
llm_config = get_llm_config_arg(args.llm_config)
|
||||
# modify_params must be False for evaluation purpose, for reproducibility and accuracy of results
|
||||
if llm_config:
|
||||
llm_config.modify_params = False
|
||||
llm_config.modify_params = False
|
||||
if llm_config is None:
|
||||
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
|
||||
|
||||
metadata = make_metadata(
|
||||
llm_config,
|
||||
'webarena',
|
||||
args.dataset_name,
|
||||
args.agent_cls,
|
||||
args.max_iterations,
|
||||
args.eval_note,
|
||||
|
||||
@@ -38,7 +38,7 @@ EVAL_NOTE="$OPENHANDS_VERSION"
|
||||
COMMAND="poetry run python evaluation/benchmarks/webarena/run_infer.py \
|
||||
--agent-cls $AGENT \
|
||||
--llm-config $MODEL_CONFIG \
|
||||
--max-iterations 30 \
|
||||
--max-iterations 15 \
|
||||
--eval-num-workers $NUM_WORKERS \
|
||||
--eval-note $EVAL_NOTE"
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# WebArena environment configuration
|
||||
# This script sets up the environment variables needed for WebArena evaluation
|
||||
|
||||
# Check if WEBARENA_BASE_URL is set
|
||||
if [ -z "$WEBARENA_BASE_URL" ]; then
|
||||
echo "Warning: WEBARENA_BASE_URL is not set. Please set it to the base URL where webarena services are hosted."
|
||||
echo "Example: export WEBARENA_BASE_URL=http://your-webarena-host"
|
||||
fi
|
||||
|
||||
# Check if OPENAI_API_KEY is set
|
||||
if [ -z "$OPENAI_API_KEY" ]; then
|
||||
echo "Warning: OPENAI_API_KEY is not set. Please set it to your OpenAI API key."
|
||||
fi
|
||||
|
||||
echo "WebArena environment configured:"
|
||||
echo " WEBARENA_BASE_URL: $WEBARENA_BASE_URL"
|
||||
echo " OPENAI_API_KEY: ${OPENAI_API_KEY:+[SET]}${OPENAI_API_KEY:-[NOT SET]}"
|
||||
@@ -1,209 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to aggregate token usage metrics from LLM completion files.
|
||||
|
||||
Usage:
|
||||
python aggregate_token_usage.py <directory_path> [--input-cost <cost>] [--output-cost <cost>] [--cached-cost <cost>]
|
||||
|
||||
Arguments:
|
||||
directory_path: Path to the directory containing completion files
|
||||
--input-cost: Cost per input token (default: 0.0)
|
||||
--output-cost: Cost per output token (default: 0.0)
|
||||
--cached-cost: Cost per cached token (default: 0.0)
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def aggregate_token_usage(
|
||||
directory_path, input_cost=0.0, output_cost=0.0, cached_cost=0.0
|
||||
):
|
||||
"""
|
||||
Aggregate token usage metrics from all JSON completion files in the directory.
|
||||
|
||||
Args:
|
||||
directory_path (str): Path to directory containing completion files
|
||||
input_cost (float): Cost per input token
|
||||
output_cost (float): Cost per output token
|
||||
cached_cost (float): Cost per cached token
|
||||
"""
|
||||
|
||||
# Initialize counters
|
||||
totals = {
|
||||
'input_tokens': 0,
|
||||
'output_tokens': 0,
|
||||
'cached_tokens': 0,
|
||||
'total_tokens': 0,
|
||||
'files_processed': 0,
|
||||
'files_with_errors': 0,
|
||||
'cost': 0,
|
||||
}
|
||||
|
||||
# Find all JSON files recursively
|
||||
json_files = list(Path(directory_path).rglob('*.json'))
|
||||
|
||||
print(f'Found {len(json_files)} JSON files to process...')
|
||||
|
||||
for json_file in json_files:
|
||||
try:
|
||||
with open(json_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Look for usage data in response or fncall_response
|
||||
usage_data = None
|
||||
if (
|
||||
'response' in data
|
||||
and isinstance(data['response'], dict)
|
||||
and 'usage' in data['response']
|
||||
):
|
||||
usage_data = data['response']['usage']
|
||||
elif (
|
||||
'fncall_response' in data
|
||||
and isinstance(data['fncall_response'], dict)
|
||||
and 'usage' in data['fncall_response']
|
||||
):
|
||||
usage_data = data['fncall_response']['usage']
|
||||
|
||||
if usage_data:
|
||||
# Extract token counts
|
||||
completion_tokens = usage_data.get('completion_tokens', 0)
|
||||
prompt_tokens = usage_data.get('prompt_tokens', 0)
|
||||
cached_tokens = usage_data.get('cached_tokens', 0)
|
||||
|
||||
# Handle cases where cached_tokens might be in prompt_tokens_details
|
||||
if cached_tokens == 0 and 'prompt_tokens_details' in usage_data:
|
||||
details = usage_data['prompt_tokens_details']
|
||||
if isinstance(details, dict) and 'cached_tokens' in details:
|
||||
cached_tokens = details.get('cached_tokens', 0) or 0
|
||||
|
||||
# Calculate non-cached input tokens
|
||||
non_cached_input = prompt_tokens - cached_tokens
|
||||
|
||||
# Update totals
|
||||
totals['input_tokens'] += non_cached_input
|
||||
totals['output_tokens'] += completion_tokens
|
||||
totals['cached_tokens'] += cached_tokens
|
||||
totals['total_tokens'] += prompt_tokens + completion_tokens
|
||||
|
||||
if 'cost' in data:
|
||||
totals['cost'] += data['cost']
|
||||
totals['files_processed'] += 1
|
||||
|
||||
# Progress indicator
|
||||
if totals['files_processed'] % 1000 == 0:
|
||||
print(f'Processed {totals["files_processed"]} files...')
|
||||
|
||||
except Exception as e:
|
||||
totals['files_with_errors'] += 1
|
||||
if totals['files_with_errors'] <= 5: # Only show first 5 errors
|
||||
print(f'Error processing {json_file}: {e}')
|
||||
|
||||
# Calculate costs
|
||||
input_cost_total = totals['input_tokens'] * input_cost
|
||||
output_cost_total = totals['output_tokens'] * output_cost
|
||||
cached_cost_total = totals['cached_tokens'] * cached_cost
|
||||
total_cost = input_cost_total + output_cost_total + cached_cost_total
|
||||
|
||||
# Print results
|
||||
print('\n' + '=' * 60)
|
||||
print('TOKEN USAGE AGGREGATION RESULTS')
|
||||
print('=' * 60)
|
||||
print(f'Files processed: {totals["files_processed"]:,}')
|
||||
print(f'Files with errors: {totals["files_with_errors"]:,}')
|
||||
print()
|
||||
print('TOKEN COUNTS:')
|
||||
print(f' Input tokens (non-cached): {totals["input_tokens"]:,}')
|
||||
print(f' Output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Cached tokens: {totals["cached_tokens"]:,}')
|
||||
print(f' Total tokens: {totals["total_tokens"]:,}')
|
||||
print(f' Total costs (based on returned value): ${totals["cost"]:.6f}')
|
||||
print()
|
||||
|
||||
if input_cost > 0 or output_cost > 0 or cached_cost > 0:
|
||||
print('COST CALCULATED BASED ON PROVIDED RATE:')
|
||||
print(
|
||||
f' Input cost: ${input_cost_total:.6f} ({totals["input_tokens"]:,} × ${input_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Output cost: ${output_cost_total:.6f} ({totals["output_tokens"]:,} × ${output_cost:.6f})'
|
||||
)
|
||||
print(
|
||||
f' Cached cost: ${cached_cost_total:.6f} ({totals["cached_tokens"]:,} × ${cached_cost:.6f})'
|
||||
)
|
||||
print(f' Total cost: ${total_cost:.6f}')
|
||||
print()
|
||||
|
||||
print('SUMMARY:')
|
||||
print(
|
||||
f' Total input tokens: {totals["input_tokens"] + totals["cached_tokens"]:,}'
|
||||
)
|
||||
print(f' Total output tokens: {totals["output_tokens"]:,}')
|
||||
print(f' Grand total tokens: {totals["total_tokens"]:,}')
|
||||
|
||||
return totals
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Aggregate token usage metrics from LLM completion files',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
python aggregate_token_usage.py /path/to/completions
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002
|
||||
python aggregate_token_usage.py /path/to/completions --input-cost 0.000001 --output-cost 0.000002 --cached-cost 0.0000005
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'directory_path', help='Path to directory containing completion files'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--input-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per input token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--output-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per output token (default: 0.0)',
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--cached-cost',
|
||||
type=float,
|
||||
default=0.0,
|
||||
help='Cost per cached token (default: 0.0)',
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate directory path
|
||||
if not os.path.exists(args.directory_path):
|
||||
print(f"Error: Directory '{args.directory_path}' does not exist.")
|
||||
return 1
|
||||
|
||||
if not os.path.isdir(args.directory_path):
|
||||
print(f"Error: '{args.directory_path}' is not a directory.")
|
||||
return 1
|
||||
|
||||
# Run aggregation
|
||||
try:
|
||||
aggregate_token_usage(
|
||||
args.directory_path, args.input_cost, args.output_cost, args.cached_cost
|
||||
)
|
||||
return 0
|
||||
except Exception as e:
|
||||
print(f'Error during aggregation: {e}')
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
exit(main())
|
||||
@@ -188,14 +188,6 @@ def make_metadata(
|
||||
pathlib.Path(os.path.join(eval_output_path, 'logs')).mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
# Allow overriding the evaluation output directory via env for smoke runs
|
||||
override_output_dir = os.environ.get('EVAL_OUTPUT_DIR')
|
||||
if override_output_dir:
|
||||
eval_output_path = override_output_dir
|
||||
pathlib.Path(eval_output_path).mkdir(parents=True, exist_ok=True)
|
||||
pathlib.Path(os.path.join(eval_output_path, 'logs')).mkdir(
|
||||
parents=True, exist_ok=True
|
||||
)
|
||||
logger.info(f'Using evaluation output directory: {eval_output_path}')
|
||||
|
||||
metadata = EvalMetadata(
|
||||
|
||||
@@ -17,7 +17,7 @@ const mockUseUserProviders = vi.fn();
|
||||
const mockUseGitRepositories = vi.fn();
|
||||
const mockUseConfig = vi.fn();
|
||||
const mockUseRepositoryMicroagents = vi.fn();
|
||||
const mockUseMicroagentManagementConversations = vi.fn();
|
||||
const mockUseSearchConversations = vi.fn();
|
||||
|
||||
vi.mock("#/hooks/use-user-providers", () => ({
|
||||
useUserProviders: () => mockUseUserProviders(),
|
||||
@@ -35,9 +35,8 @@ vi.mock("#/hooks/query/use-repository-microagents", () => ({
|
||||
useRepositoryMicroagents: () => mockUseRepositoryMicroagents(),
|
||||
}));
|
||||
|
||||
vi.mock("#/hooks/query/use-microagent-management-conversations", () => ({
|
||||
useMicroagentManagementConversations: () =>
|
||||
mockUseMicroagentManagementConversations(),
|
||||
vi.mock("#/hooks/query/use-search-conversations", () => ({
|
||||
useSearchConversations: () => mockUseSearchConversations(),
|
||||
}));
|
||||
|
||||
describe("MicroagentManagement", () => {
|
||||
@@ -213,7 +212,7 @@ describe("MicroagentManagement", () => {
|
||||
isError: false,
|
||||
});
|
||||
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: mockConversations,
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -860,8 +859,8 @@ describe("MicroagentManagement", () => {
|
||||
});
|
||||
|
||||
// Search conversations functionality tests
|
||||
describe("Microagent management conversations functionality", () => {
|
||||
it("should call useMicroagentManagementConversations API when repository is expanded", async () => {
|
||||
describe("Search conversations functionality", () => {
|
||||
it("should call searchConversations API when repository is expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -877,7 +876,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both microagents and conversations to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -897,7 +896,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
@@ -922,7 +921,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
});
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
isError: false,
|
||||
@@ -959,7 +958,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that loading spinner is not displayed
|
||||
@@ -984,7 +983,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagent file paths are displayed for microagents
|
||||
@@ -1014,7 +1013,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1034,7 +1033,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed
|
||||
@@ -1051,7 +1050,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: [...mockConversations],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1071,7 +1070,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that conversations are displayed
|
||||
@@ -1094,7 +1093,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -1114,7 +1113,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that microagents are displayed
|
||||
@@ -1132,7 +1131,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
it("should handle error when fetching conversations", async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isError: true,
|
||||
@@ -1151,7 +1150,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
// Wait for the error to be handled
|
||||
await waitFor(() => {
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that the learn this repo component is displayed (since conversations failed)
|
||||
@@ -1196,7 +1195,7 @@ describe("MicroagentManagement", () => {
|
||||
expect(learnThisRepo).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should call useMicroagentManagementConversations with correct parameters", async () => {
|
||||
it("should call searchConversations with correct parameters", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderMicroagentManagement();
|
||||
|
||||
@@ -1209,9 +1208,9 @@ describe("MicroagentManagement", () => {
|
||||
const repoAccordion = screen.getByTestId("repository-name-tooltip");
|
||||
await user.click(repoAccordion);
|
||||
|
||||
// Wait for useMicroagentManagementConversations to be called
|
||||
// Wait for searchConversations to be called
|
||||
await waitFor(() => {
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1231,7 +1230,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to complete
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that conversations display correct information
|
||||
@@ -1258,7 +1257,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for both queries to be called for first repo
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Check that both microagents and conversations are displayed
|
||||
@@ -2392,7 +2391,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2412,7 +2411,7 @@ describe("MicroagentManagement", () => {
|
||||
// Wait for microagents and conversations to be fetched
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Verify the learn this repo trigger is displayed when no microagents exist
|
||||
@@ -2437,7 +2436,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2492,7 +2491,7 @@ describe("MicroagentManagement", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
mockUseMicroagentManagementConversations.mockReturnValue({
|
||||
mockUseSearchConversations.mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
@@ -2509,7 +2508,7 @@ describe("MicroagentManagement", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUseRepositoryMicroagents).toHaveBeenCalled();
|
||||
expect(mockUseMicroagentManagementConversations).toHaveBeenCalled();
|
||||
expect(mockUseSearchConversations).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT show the learn this repo trigger when microagents exist
|
||||
|
||||
257
frontend/package-lock.json
generated
257
frontend/package-lock.json
generated
@@ -14,10 +14,10 @@
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.8.2",
|
||||
"@react-router/serve": "^7.8.2",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@stripe/react-stripe-js": "^3.9.1",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
@@ -35,9 +35,9 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.30",
|
||||
"jose": "^6.0.13",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.541.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.260.3",
|
||||
"posthog-js": "^1.260.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.8.2",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-syntax-highlighter": "^15.6.5",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -74,7 +74,7 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
@@ -1556,14 +1556,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/accordion/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/alert": {
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/alert/-/alert-2.2.24.tgz",
|
||||
@@ -1600,14 +1592,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/aria-utils/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/autocomplete": {
|
||||
"version": "2.3.26",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/autocomplete/-/autocomplete-2.3.26.tgz",
|
||||
@@ -1639,14 +1623,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/autocomplete/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/avatar": {
|
||||
"version": "2.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/avatar/-/avatar-2.2.20.tgz",
|
||||
@@ -1725,14 +1701,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/button/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/calendar": {
|
||||
"version": "2.2.24",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/calendar/-/calendar-2.2.24.tgz",
|
||||
@@ -1767,14 +1735,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/calendar/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/card": {
|
||||
"version": "2.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/card/-/card-2.2.23.tgz",
|
||||
@@ -1797,14 +1757,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/card/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/checkbox": {
|
||||
"version": "2.3.24",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/checkbox/-/checkbox-2.3.24.tgz",
|
||||
@@ -1831,14 +1783,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/checkbox/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/chip": {
|
||||
"version": "2.2.20",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/chip/-/chip-2.2.20.tgz",
|
||||
@@ -1897,14 +1841,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/date-input/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/date-picker": {
|
||||
"version": "2.3.25",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/date-picker/-/date-picker-2.3.25.tgz",
|
||||
@@ -1936,14 +1872,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/date-picker/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/divider": {
|
||||
"version": "2.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/divider/-/divider-2.2.17.tgz",
|
||||
@@ -1960,14 +1888,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/divider/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/dom-animation": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/dom-animation/-/dom-animation-2.1.10.tgz",
|
||||
@@ -2039,14 +1959,6 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/form/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/framer-utils": {
|
||||
"version": "2.1.20",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/framer-utils/-/framer-utils-2.1.20.tgz",
|
||||
@@ -2129,14 +2041,6 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/input/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/kbd": {
|
||||
"version": "2.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/kbd/-/kbd-2.2.19.tgz",
|
||||
@@ -2198,14 +2102,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/listbox/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/menu": {
|
||||
"version": "2.2.23",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/menu/-/menu-2.2.23.tgz",
|
||||
@@ -2231,14 +2127,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/menu/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/modal": {
|
||||
"version": "2.2.21",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/modal/-/modal-2.2.21.tgz",
|
||||
@@ -2323,14 +2211,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/number-input/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/pagination": {
|
||||
"version": "2.2.22",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/pagination/-/pagination-2.2.22.tgz",
|
||||
@@ -2427,14 +2307,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/radio/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/react": {
|
||||
"version": "2.8.2",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/react/-/react-2.8.2.tgz",
|
||||
@@ -2588,14 +2460,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/select/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/shared-icons": {
|
||||
"version": "2.1.10",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/shared-icons/-/shared-icons-2.1.10.tgz",
|
||||
@@ -2758,14 +2622,6 @@
|
||||
"react": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/system-rsc/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/system-rsc/node_modules/clsx": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz",
|
||||
@@ -2828,14 +2684,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/tabs/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/theme": {
|
||||
"version": "2.4.20",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/theme/-/theme-2.4.20.tgz",
|
||||
@@ -2931,14 +2779,6 @@
|
||||
"react": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-accordion/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-button": {
|
||||
"version": "2.2.18",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/use-aria-button/-/use-aria-button-2.2.18.tgz",
|
||||
@@ -2955,14 +2795,6 @@
|
||||
"react": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-button/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-link": {
|
||||
"version": "2.2.19",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/use-aria-link/-/use-aria-link-2.2.19.tgz",
|
||||
@@ -2979,14 +2811,6 @@
|
||||
"react": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-link/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-modal-overlay": {
|
||||
"version": "2.2.17",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/use-aria-modal-overlay/-/use-aria-modal-overlay-2.2.17.tgz",
|
||||
@@ -3028,14 +2852,6 @@
|
||||
"react-dom": ">=18 || >=19.0.0-rc.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-multiselect/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-overlay": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/use-aria-overlay/-/use-aria-overlay-2.0.2.tgz",
|
||||
@@ -3052,14 +2868,6 @@
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-aria-overlay/node_modules/@react-types/shared": {
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@heroui/use-callback-ref": {
|
||||
"version": "2.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@heroui/use-callback-ref/-/use-callback-ref-2.1.8.tgz",
|
||||
@@ -3875,11 +3683,6 @@
|
||||
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@posthog/core": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.0.1.tgz",
|
||||
"integrity": "sha512-bwXUeHe+MLgENm8+/FxEbiNocOw1Vjewmm+HEUaYQe6frq8OhZnrvtnzZU3Q3DF6N0UbAmD/q+iNfNgyx8mozg=="
|
||||
},
|
||||
"node_modules/@react-aria/breadcrumbs": {
|
||||
"version": "3.5.27",
|
||||
"resolved": "https://registry.npmjs.org/@react-aria/breadcrumbs/-/breadcrumbs-3.5.27.tgz",
|
||||
@@ -5315,9 +5118,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@react-types/shared": {
|
||||
"version": "3.32.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.32.0.tgz",
|
||||
"integrity": "sha512-t+cligIJsZYFMSPFMvsJMjzlzde06tZMOIOFa1OV5Z0BcMowrb2g4mB57j/9nP28iJIRYn10xCniQts+qadrqQ==",
|
||||
"version": "3.31.0",
|
||||
"resolved": "https://registry.npmjs.org/@react-types/shared/-/shared-3.31.0.tgz",
|
||||
"integrity": "sha512-ua5U6V66gDcbLZe4P2QeyNgPp4YWD1ymGA6j3n+s8CGExtrCPe64v+g4mvpT8Bnb985R96e4zFT61+m0YCwqMg==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1"
|
||||
}
|
||||
@@ -5756,9 +5560,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@stripe/react-stripe-js": {
|
||||
"version": "3.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.2.tgz",
|
||||
"integrity": "sha512-urAZek4LrnHWfk4WYXItOiX+6xyxjcn0SkhBDoysXphLkUt92UWCd5+NlomhVqaLo98XiUQGZRiRcL8HOHZ8Jw==",
|
||||
"version": "3.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-3.9.1.tgz",
|
||||
"integrity": "sha512-t5KZiu7jkUTHOx0adGSlSj4xPpFSvW6BsgIRQHNXqhHeYBH0mpddVUZsO33WM1m6Vyd1Wl96JoBhwEsw8jMHTQ==",
|
||||
"dependencies": {
|
||||
"prop-types": "^15.7.2"
|
||||
},
|
||||
@@ -5769,9 +5573,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@stripe/stripe-js": {
|
||||
"version": "7.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.9.0.tgz",
|
||||
"integrity": "sha512-ggs5k+/0FUJcIgNY08aZTqpBTtbExkJMYMLSMwyucrhtWexVOEY1KJmhBsxf+E/Q15f5rbwBpj+t0t2AW2oCsQ==",
|
||||
"version": "7.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-7.8.0.tgz",
|
||||
"integrity": "sha512-DNXRfYUgkZlrniQORbA/wH8CdFRhiBSE0R56gYU0V5vvpJ9WZwvGrz9tBAZmfq2aTgw6SK7mNpmTizGzLWVezw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.16"
|
||||
}
|
||||
@@ -6698,10 +6503,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.8.tgz",
|
||||
"integrity": "sha512-xG7xaBMJCpcK0RpN8jDbAACQo54ycO6h4dSSmgv8+fu6ZIAdANkx/WsawASUjVXYfy+J9AbUpRMNNEsXCDfDBQ==",
|
||||
"version": "19.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.7.tgz",
|
||||
"integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.0.0"
|
||||
}
|
||||
@@ -12881,9 +12687,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.542.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.542.0.tgz",
|
||||
"integrity": "sha512-w3hD8/SQB7+lzU2r4VdFyzzOzKnUjTZIF/MQJGSSvni7Llewni4vuViRppfRAa2guOsY5k4jZyxw/i9DQHv+dw==",
|
||||
"version": "0.541.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.541.0.tgz",
|
||||
"integrity": "sha512-s0Vircsu5WaGv2KoJZ5+SoxiAJ3UXV5KqEM3eIFDHaHkcLIFdIWgXtZ412+Gh02UsdS7Was+jvEpBvPCWQISlg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
@@ -14906,11 +14712,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/posthog-js": {
|
||||
"version": "1.260.3",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.260.3.tgz",
|
||||
"integrity": "sha512-FCtksk0GQn22Rk9P7x7dsmAO7a2aBxPeYb2O2KXSraxR8xd2G6lUOOthVDK+qgtmuhpUZuur/mHrXEslMUEtjg==",
|
||||
"version": "1.260.2",
|
||||
"resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.260.2.tgz",
|
||||
"integrity": "sha512-2Q+QUz9j9+uG16wp0WcOEbezVsLZCobZyTX8NvWPMGKyPaf2lOsjbPjznsq5JiIt324B6NAqzpWYZTzvhn9k9Q==",
|
||||
"dependencies": {
|
||||
"@posthog/core": "1.0.1",
|
||||
"core-js": "^3.38.1",
|
||||
"fflate": "^0.4.8",
|
||||
"preact": "^10.19.3",
|
||||
@@ -15430,9 +15235,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "15.6.6",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.6.tgz",
|
||||
"integrity": "sha512-DgXrc+AZF47+HvAPEmn7Ua/1p10jNoVZVI/LoPiYdtY+OM+/nG5yefLHKJwdKqY1adMuHFbeyBaG9j64ML7vTw==",
|
||||
"version": "15.6.5",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.5.tgz",
|
||||
"integrity": "sha512-Sscw/qACcdp3UIuDVN+PhdKkQZTmAv55+RTzwTJZS+UFFpLilogVnKelDqHuc4E//d7lgEAo2dcDY9h4xhEtJw==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.3.1",
|
||||
"highlight.js": "^10.4.1",
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
"@monaco-editor/react": "^4.7.0-rc.0",
|
||||
"@react-router/node": "^7.8.2",
|
||||
"@react-router/serve": "^7.8.2",
|
||||
"@react-types/shared": "^3.32.0",
|
||||
"@react-types/shared": "^3.31.0",
|
||||
"@reduxjs/toolkit": "^2.8.2",
|
||||
"@stripe/react-stripe-js": "^3.9.2",
|
||||
"@stripe/stripe-js": "^7.9.0",
|
||||
"@stripe/react-stripe-js": "^3.9.1",
|
||||
"@stripe/stripe-js": "^7.8.0",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tailwindcss/vite": "^4.1.12",
|
||||
"@tanstack/react-query": "^5.85.5",
|
||||
@@ -34,9 +34,9 @@
|
||||
"i18next-http-backend": "^3.0.2",
|
||||
"isbot": "^5.1.30",
|
||||
"jose": "^6.0.13",
|
||||
"lucide-react": "^0.542.0",
|
||||
"lucide-react": "^0.541.0",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"posthog-js": "^1.260.3",
|
||||
"posthog-js": "^1.260.2",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-highlight": "^0.15.0",
|
||||
@@ -47,7 +47,7 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"react-router": "^7.8.2",
|
||||
"react-select": "^5.10.2",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"react-syntax-highlighter": "^15.6.5",
|
||||
"react-textarea-autosize": "^8.5.9",
|
||||
"remark-breaks": "^4.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -98,7 +98,7 @@
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.11",
|
||||
"@types/react-dom": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@types/react-highlight": "^0.12.8",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/ws": "^8.18.1",
|
||||
|
||||
@@ -726,27 +726,6 @@ class OpenHands {
|
||||
);
|
||||
return data;
|
||||
}
|
||||
|
||||
static async getMicroagentManagementConversations(
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
): Promise<Conversation[]> {
|
||||
const params: Record<string, string | number> = {
|
||||
limit,
|
||||
selected_repository: selectedRepository,
|
||||
};
|
||||
|
||||
if (pageId) {
|
||||
params.page_id = pageId;
|
||||
}
|
||||
|
||||
const { data } = await openHands.get<ResultSet<Conversation>>(
|
||||
"/api/microagent-management/conversations",
|
||||
{ params },
|
||||
);
|
||||
return data.results;
|
||||
}
|
||||
}
|
||||
|
||||
export default OpenHands;
|
||||
|
||||
@@ -9,7 +9,6 @@ import { CopyToClipboardButton } from "#/components/shared/buttons/copy-to-clipb
|
||||
import { anchor } from "../markdown/anchor";
|
||||
import { OpenHandsSourceType } from "#/types/core/base";
|
||||
import { paragraph } from "../markdown/paragraph";
|
||||
import { TooltipButton } from "#/components/shared/buttons/tooltip-button";
|
||||
|
||||
interface ChatMessageProps {
|
||||
type: OpenHandsSourceType;
|
||||
@@ -17,7 +16,6 @@ interface ChatMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@@ -68,35 +66,17 @@ export function ChatMessage({
|
||||
"items-center gap-1",
|
||||
)}
|
||||
>
|
||||
{actions?.map((action, index) =>
|
||||
action.tooltip ? (
|
||||
<TooltipButton
|
||||
key={index}
|
||||
tooltip={action.tooltip}
|
||||
ariaLabel={action.tooltip}
|
||||
placement="top"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
</TooltipButton>
|
||||
) : (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
{actions?.map((action, index) => (
|
||||
<button
|
||||
key={index}
|
||||
type="button"
|
||||
onClick={action.onClick}
|
||||
className="button-base p-1 cursor-pointer"
|
||||
aria-label={`Action ${index + 1}`}
|
||||
>
|
||||
{action.icon}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<CopyToClipboardButton
|
||||
isHidden={!isHovering}
|
||||
|
||||
@@ -72,9 +72,6 @@ const getRecallObservationContent = (event: RecallObservation): string => {
|
||||
if (event.extras.repo_instructions) {
|
||||
content += `\n\n**Repository Instructions:**\n\n${event.extras.repo_instructions}`;
|
||||
}
|
||||
if (event.extras.conversation_instructions) {
|
||||
content += `\n\n**Conversation Instructions:**\n\n${event.extras.conversation_instructions}`;
|
||||
}
|
||||
if (event.extras.additional_agent_instructions) {
|
||||
content += `\n\n**Additional Instructions:**\n\n${event.extras.additional_agent_instructions}`;
|
||||
}
|
||||
|
||||
@@ -46,7 +46,6 @@ interface EventMessageProps {
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
isInLast10Actions: boolean;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
import { OpenHandsAction } from "#/types/core/actions";
|
||||
import { OpenHandsObservation } from "#/types/core/observations";
|
||||
@@ -63,8 +62,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
EventMicroagentStatus[]
|
||||
>([]);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsAction | OpenHandsObservation): boolean => {
|
||||
if (isOpenHandsAction(event)) {
|
||||
@@ -246,7 +243,6 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
setSelectedEventId(message.id);
|
||||
setShowLaunchMicroagentModal(true);
|
||||
},
|
||||
tooltip: t("MICROAGENT$ADD_TO_MEMORY"),
|
||||
},
|
||||
]
|
||||
: undefined
|
||||
|
||||
@@ -76,10 +76,6 @@ export function LaunchMicroagentModal({
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="text-sm text-[#A3A3A3] font-normal leading-5">
|
||||
{t("MICROAGENT$DEFINITION")}
|
||||
</span>
|
||||
|
||||
<form
|
||||
data-testid="launch-microagent-modal"
|
||||
onSubmit={onSubmit}
|
||||
|
||||
@@ -23,9 +23,9 @@ export function ConfirmStopModal({
|
||||
<ModalBackdrop>
|
||||
<ModalBody className="items-start border border-tertiary">
|
||||
<div className="flex flex-col gap-2">
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_PAUSE)} />
|
||||
<BaseModalTitle title={t(I18nKey.CONVERSATION$CONFIRM_STOP)} />
|
||||
<BaseModalDescription
|
||||
description={t(I18nKey.CONVERSATION$PAUSE_WARNING)}
|
||||
description={t(I18nKey.CONVERSATION$STOP_WARNING)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
@@ -129,7 +129,7 @@ export function ConversationCardContextMenu({
|
||||
|
||||
{onStop && (
|
||||
<ContextMenuListItem testId="stop-button" onClick={onStop}>
|
||||
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$PAUSE)} />
|
||||
<ContextMenuIconText icon={Power} text={t(I18nKey.BUTTON$STOP)} />
|
||||
</ContextMenuListItem>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { ConversationStatus } from "#/types/conversation-status";
|
||||
import ArchivedIcon from "./state-indicators/archived.svg?react";
|
||||
import ErrorIcon from "./state-indicators/error.svg?react";
|
||||
import RunningIcon from "./state-indicators/running.svg?react";
|
||||
import StartingIcon from "./state-indicators/starting.svg?react";
|
||||
import StoppedIcon from "./state-indicators/stopped.svg?react";
|
||||
@@ -11,8 +9,6 @@ const CONVERSATION_STATUS_INDICATORS: Record<ConversationStatus, SVGIcon> = {
|
||||
STOPPED: StoppedIcon,
|
||||
RUNNING: RunningIcon,
|
||||
STARTING: StartingIcon,
|
||||
ARCHIVED: ArchivedIcon,
|
||||
ERROR: ErrorIcon,
|
||||
};
|
||||
|
||||
interface ConversationStateIndicatorProps {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#A7A9AC"><path d="M0 0h24v24H0V0z" fill="none"/><path d="M17 7h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1 0 1.43-.98 2.63-2.31 2.98l1.46 1.46C20.88 15.61 22 13.95 22 12c0-2.76-2.24-5-5-5zm-1 4h-2.19l2 2H16zM2 4.27l3.11 3.11C3.29 8.12 2 9.91 2 12c0 2.76 2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1 0-1.59 1.21-2.9 2.76-3.07L8.73 11H8v2h2.73L13 15.27V17h1.73l4.01 4L20 19.74 3.27 3 2 4.27z"/><path d="M0 24V0" fill="none"/></svg>
|
||||
|
Before Width: | Height: | Size: 512 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#e7000b"><path d="M0 0h24v24H0z" fill="none"/><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z"/></svg>
|
||||
|
Before Width: | Height: | Size: 254 B |
@@ -277,12 +277,6 @@ export function MicroagentManagementContent() {
|
||||
const repositoryName = repository.full_name;
|
||||
const gitProvider = repository.git_provider;
|
||||
|
||||
const createMicroagent = {
|
||||
repo: repositoryName,
|
||||
git_provider: gitProvider,
|
||||
title: formData.query,
|
||||
};
|
||||
|
||||
// Launch a new conversation to help the user understand the repo
|
||||
createConversationAndSubscribe({
|
||||
query: formData.query,
|
||||
@@ -292,7 +286,6 @@ export function MicroagentManagementContent() {
|
||||
branch: formData.selectedBranch,
|
||||
gitProvider,
|
||||
},
|
||||
createMicroagent,
|
||||
onSuccessCallback: () => {
|
||||
hideLearnThisRepoModal();
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ import { BrandButton } from "../settings/brand-button";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { RootState } from "#/store";
|
||||
import XIcon from "#/icons/x.svg?react";
|
||||
import { cn, getRepoMdCreatePrompt } from "#/utils/utils";
|
||||
import { cn } from "#/utils/utils";
|
||||
import { LearnThisRepoFormData } from "#/types/microagent-management";
|
||||
import { Branch } from "#/types/git";
|
||||
import { useRepositoryBranches } from "#/hooks/query/use-repository-branches";
|
||||
@@ -76,25 +76,23 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
const onSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
const finalQuery = getRepoMdCreatePrompt(
|
||||
selectedRepository?.git_provider || "github",
|
||||
query.trim(),
|
||||
);
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
query: query.trim(),
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const finalQuery = getRepoMdCreatePrompt(
|
||||
selectedRepository?.git_provider || "github",
|
||||
query.trim(),
|
||||
);
|
||||
if (!query.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
onConfirm({
|
||||
query: finalQuery,
|
||||
query: query.trim(),
|
||||
selectedBranch: selectedBranch?.name || "",
|
||||
});
|
||||
};
|
||||
@@ -246,6 +244,7 @@ export function MicroagentManagementLearnThisRepoModal({
|
||||
onClick={handleConfirm}
|
||||
testId="confirm-button"
|
||||
isDisabled={
|
||||
!query.trim() ||
|
||||
isLoading ||
|
||||
isLoadingBranches ||
|
||||
!selectedBranch ||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Spinner } from "@heroui/react";
|
||||
import { MicroagentManagementMicroagentCard } from "./microagent-management-microagent-card";
|
||||
import { MicroagentManagementLearnThisRepo } from "./microagent-management-learn-this-repo";
|
||||
import { useRepositoryMicroagents } from "#/hooks/query/use-repository-microagents";
|
||||
import { useMicroagentManagementConversations } from "#/hooks/query/use-microagent-management-conversations";
|
||||
import { useSearchConversations } from "#/hooks/query/use-search-conversations";
|
||||
import { GitRepository } from "#/types/git";
|
||||
import { RootState } from "#/store";
|
||||
import { setSelectedMicroagentItem } from "#/state/microagent-management-slice";
|
||||
@@ -42,9 +42,9 @@ export function MicroagentManagementRepoMicroagents({
|
||||
data: conversations,
|
||||
isLoading: isLoadingConversations,
|
||||
isError: isErrorConversations,
|
||||
} = useMicroagentManagementConversations(
|
||||
} = useSearchConversations(
|
||||
repositoryName,
|
||||
undefined,
|
||||
"microagent_management",
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import OpenHands from "#/api/open-hands";
|
||||
|
||||
export const useMicroagentManagementConversations = (
|
||||
selectedRepository: string,
|
||||
pageId?: string,
|
||||
limit: number = 100,
|
||||
cacheDisabled: boolean = false,
|
||||
) =>
|
||||
useQuery({
|
||||
queryKey: [
|
||||
"conversations",
|
||||
"microagent-management",
|
||||
pageId,
|
||||
limit,
|
||||
selectedRepository,
|
||||
],
|
||||
queryFn: () =>
|
||||
OpenHands.getMicroagentManagementConversations(
|
||||
selectedRepository,
|
||||
pageId,
|
||||
limit,
|
||||
),
|
||||
enabled: !!selectedRepository,
|
||||
staleTime: cacheDisabled ? 0 : 1000 * 60 * 5, // 5 minutes
|
||||
gcTime: cacheDisabled ? 0 : 1000 * 60 * 15, // 15 minutes
|
||||
});
|
||||
@@ -131,6 +131,7 @@ export enum I18nKey {
|
||||
CONVERSATION$REPOSITORY = "CONVERSATION$REPOSITORY",
|
||||
CONVERSATION$BRANCH = "CONVERSATION$BRANCH",
|
||||
CONVERSATION$GIT_PROVIDER = "CONVERSATION$GIT_PROVIDER",
|
||||
ACCOUNT_SETTINGS$TITLE = "ACCOUNT_SETTINGS$TITLE",
|
||||
WORKSPACE$TERMINAL_TAB_LABEL = "WORKSPACE$TERMINAL_TAB_LABEL",
|
||||
WORKSPACE$BROWSER_TAB_LABEL = "WORKSPACE$BROWSER_TAB_LABEL",
|
||||
WORKSPACE$JUPYTER_TAB_LABEL = "WORKSPACE$JUPYTER_TAB_LABEL",
|
||||
@@ -327,7 +328,6 @@ export enum I18nKey {
|
||||
USER$ACCOUNT_SETTINGS = "USER$ACCOUNT_SETTINGS",
|
||||
JUPYTER$OUTPUT_LABEL = "JUPYTER$OUTPUT_LABEL",
|
||||
BUTTON$STOP = "BUTTON$STOP",
|
||||
BUTTON$PAUSE = "BUTTON$PAUSE",
|
||||
BUTTON$EDIT_TITLE = "BUTTON$EDIT_TITLE",
|
||||
BUTTON$DOWNLOAD_VIA_VSCODE = "BUTTON$DOWNLOAD_VIA_VSCODE",
|
||||
BUTTON$DISPLAY_COST = "BUTTON$DISPLAY_COST",
|
||||
@@ -339,8 +339,6 @@ export enum I18nKey {
|
||||
LANDING$RECENT_CONVERSATION = "LANDING$RECENT_CONVERSATION",
|
||||
CONVERSATION$CONFIRM_DELETE = "CONVERSATION$CONFIRM_DELETE",
|
||||
CONVERSATION$CONFIRM_STOP = "CONVERSATION$CONFIRM_STOP",
|
||||
CONVERSATION$CONFIRM_PAUSE = "CONVERSATION$CONFIRM_PAUSE",
|
||||
CONVERSATION$PAUSE_WARNING = "CONVERSATION$PAUSE_WARNING",
|
||||
CONVERSATION$STOP_WARNING = "CONVERSATION$STOP_WARNING",
|
||||
CONVERSATION$METRICS_INFO = "CONVERSATION$METRICS_INFO",
|
||||
CONVERSATION$CREATED = "CONVERSATION$CREATED",
|
||||
@@ -478,6 +476,7 @@ export enum I18nKey {
|
||||
PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL = "PROJECT_MENU_CARD_CONTEXT_MENU$DOWNLOAD_FILES_LABEL",
|
||||
PROJECT_MENU_CARD$OPEN = "PROJECT_MENU_CARD$OPEN",
|
||||
ACTION_BUTTON$RESUME = "ACTION_BUTTON$RESUME",
|
||||
ACTION_BUTTON$PAUSE = "ACTION_BUTTON$PAUSE",
|
||||
BROWSER$SCREENSHOT_ALT = "BROWSER$SCREENSHOT_ALT",
|
||||
ERROR_TOAST$CLOSE_BUTTON_LABEL = "ERROR_TOAST$CLOSE_BUTTON_LABEL",
|
||||
FILE_EXPLORER$UPLOAD = "FILE_EXPLORER$UPLOAD",
|
||||
@@ -516,6 +515,7 @@ export enum I18nKey {
|
||||
STATUS$CONNECTED = "STATUS$CONNECTED",
|
||||
BROWSER$NO_PAGE_LOADED = "BROWSER$NO_PAGE_LOADED",
|
||||
USER$AVATAR_PLACEHOLDER = "USER$AVATAR_PLACEHOLDER",
|
||||
ACCOUNT_SETTINGS$SETTINGS = "ACCOUNT_SETTINGS$SETTINGS",
|
||||
ACCOUNT_SETTINGS$LOGOUT = "ACCOUNT_SETTINGS$LOGOUT",
|
||||
SETTINGS_FORM$ADVANCED_OPTIONS_LABEL = "SETTINGS_FORM$ADVANCED_OPTIONS_LABEL",
|
||||
CONVERSATION$NO_CONVERSATIONS = "CONVERSATION$NO_CONVERSATIONS",
|
||||
@@ -575,6 +575,8 @@ export enum I18nKey {
|
||||
ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO = "ENTERPRISE_SSO$CONNECT_TO_ENTERPRISE_SSO",
|
||||
AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER = "AUTH$SIGN_IN_WITH_IDENTITY_PROVIDER",
|
||||
WAITLIST$JOIN_WAITLIST = "WAITLIST$JOIN_WAITLIST",
|
||||
ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS = "ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS",
|
||||
ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB = "ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB",
|
||||
CONVERSATION$DELETE_WARNING = "CONVERSATION$DELETE_WARNING",
|
||||
FEEDBACK$TITLE = "FEEDBACK$TITLE",
|
||||
FEEDBACK$DESCRIPTION = "FEEDBACK$DESCRIPTION",
|
||||
@@ -823,6 +825,4 @@ export enum I18nKey {
|
||||
SETTINGS$SECURITY_ANALYZER_NONE = "SETTINGS$SECURITY_ANALYZER_NONE",
|
||||
SETTINGS$SECURITY_ANALYZER_INVARIANT = "SETTINGS$SECURITY_ANALYZER_INVARIANT",
|
||||
COMMON$HIGH_RISK = "COMMON$HIGH_RISK",
|
||||
MICROAGENT$DEFINITION = "MICROAGENT$DEFINITION",
|
||||
MICROAGENT$ADD_TO_MEMORY = "MICROAGENT$ADD_TO_MEMORY",
|
||||
}
|
||||
|
||||
@@ -1568,20 +1568,20 @@
|
||||
"uk": "Максимальний розмір історії конденсатора пам'яті"
|
||||
},
|
||||
"SETTINGS$CONDENSER_MAX_SIZE_TOOLTIP": {
|
||||
"en": "After this many events, the condenser will summarize history. Minimum 20.",
|
||||
"ja": "このイベント数を超えると、凝縮器が履歴を要約します。最小 20。",
|
||||
"zh-CN": "达到此事件数量后,凝缩器将汇总历史。最小 20。",
|
||||
"zh-TW": "超過此事件數後,凝縮器會摘要歷史。最小 20。",
|
||||
"ko-KR": "이 이벤트 수 이후 응축기가 기록을 요약합니다. 최소 20.",
|
||||
"no": "Etter så mange hendelser vil kondenseren oppsummere historikken. Minimum 20.",
|
||||
"it": "Dopo questo numero di eventi, il condensatore riassumerà la cronologia. Minimo 20.",
|
||||
"pt": "Após esse número de eventos, o condensador irá resumir o histórico. Mínimo 20.",
|
||||
"es": "Después de este número de eventos, el condensador resumirá el historial. Mínimo 20.",
|
||||
"ar": "بعد هذا العدد من الأحداث، سيقوم المكثف بتلخيص السجل. الحد الأدنى 20.",
|
||||
"fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 20.",
|
||||
"tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 20.",
|
||||
"de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 20.",
|
||||
"uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 20."
|
||||
"en": "After this many events, the condenser will summarize history. Minimum 10.",
|
||||
"ja": "このイベント数を超えると、凝縮器が履歴を要約します。最小 10。",
|
||||
"zh-CN": "达到此事件数量后,凝缩器将汇总历史。最小 10。",
|
||||
"zh-TW": "超過此事件數後,凝縮器會摘要歷史。最小 10。",
|
||||
"ko-KR": "이 이벤트 수 이후 응축기가 기록을 요약합니다. 최소 10.",
|
||||
"no": "Etter så mange hendelser vil kondenseren oppsummere historikken. Minimum 10.",
|
||||
"it": "Dopo questo numero di eventi, il condensatore riassumerà la cronologia. Minimo 10.",
|
||||
"pt": "Após esse número de eventos, o condensador irá resumir o histórico. Mínimo 10.",
|
||||
"es": "Después de este número de eventos, el condensador resumirá el historial. Mínimo 10.",
|
||||
"ar": "بعد هذا العدد من الأحداث، سيقوم المكثف بتلخيص السجل. الحد الأدنى 10.",
|
||||
"fr": "Après ce nombre d'événements, le condenseur résumera l'historique. Minimum 10.",
|
||||
"tr": "Bu kadar olaydan sonra yoğunlaştırıcı geçmişi özetler. En az 10.",
|
||||
"de": "Nach so vielen Ereignissen fasst der Kondensator die Historie zusammen. Minimum 10.",
|
||||
"uk": "Після цієї кількості подій конденсатор узагальнить історію. Мінімум 10."
|
||||
},
|
||||
"SETTINGS$LANGUAGE": {
|
||||
"en": "Language",
|
||||
@@ -2095,6 +2095,22 @@
|
||||
"de": "Git-Anbieter",
|
||||
"uk": "Git-провайдер"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$TITLE": {
|
||||
"en": "Account Settings",
|
||||
"ja": "アカウント設定",
|
||||
"zh-CN": "账户设置",
|
||||
"zh-TW": "帳戶設定",
|
||||
"ko-KR": "계정 설정",
|
||||
"no": "Kontoinnstillinger",
|
||||
"it": "Impostazioni account",
|
||||
"pt": "Configurações da conta",
|
||||
"es": "Configuración de la cuenta",
|
||||
"ar": "إعدادات الحساب",
|
||||
"fr": "Paramètres du compte",
|
||||
"tr": "Hesap ayarları",
|
||||
"de": "Kontoeinstellungen",
|
||||
"uk": "Налаштування облікового запису"
|
||||
},
|
||||
"WORKSPACE$TERMINAL_TAB_LABEL": {
|
||||
"en": "Terminal",
|
||||
"zh-CN": "终端",
|
||||
@@ -5231,22 +5247,6 @@
|
||||
"tr": "Durdur",
|
||||
"uk": "Стоп"
|
||||
},
|
||||
"BUTTON$PAUSE": {
|
||||
"en": "Pause",
|
||||
"ja": "一時停止",
|
||||
"zh-CN": "暂停",
|
||||
"zh-TW": "暫停",
|
||||
"ko-KR": "일시정지",
|
||||
"fr": "Mettre en pause",
|
||||
"es": "Pausar",
|
||||
"de": "Pausieren",
|
||||
"it": "Pausa",
|
||||
"pt": "Pausar",
|
||||
"ar": "إيقاف مؤقت",
|
||||
"no": "Pause",
|
||||
"tr": "Duraklat",
|
||||
"uk": "Призупинити"
|
||||
},
|
||||
"BUTTON$EDIT_TITLE": {
|
||||
"en": "Edit Title",
|
||||
"ja": "タイトルを編集",
|
||||
@@ -5423,40 +5423,8 @@
|
||||
"de": "Stopp bestätigen",
|
||||
"uk": "Підтвердити зупинку"
|
||||
},
|
||||
"CONVERSATION$CONFIRM_PAUSE": {
|
||||
"en": "Confirm Pause",
|
||||
"ja": "一時停止の確認",
|
||||
"zh-CN": "确认暂停",
|
||||
"zh-TW": "確認暫停",
|
||||
"ko-KR": "일시정지 확인",
|
||||
"no": "Bekreft pause",
|
||||
"it": "Conferma pausa",
|
||||
"pt": "Confirmar pausa",
|
||||
"es": "Confirmar pausa",
|
||||
"ar": "تأكيد الإيقاف المؤقت",
|
||||
"fr": "Confirmer la mise en pause",
|
||||
"tr": "Duraklatmayı Onayla",
|
||||
"de": "Pause bestätigen",
|
||||
"uk": "Підтвердити призупинення"
|
||||
},
|
||||
"CONVERSATION$PAUSE_WARNING": {
|
||||
"en": "Are you sure you want to pause this conversation?",
|
||||
"ja": "この会話を一時停止してもよろしいですか?",
|
||||
"zh-CN": "您确定要暂停此对话吗?",
|
||||
"zh-TW": "您確定要暫停此對話嗎?",
|
||||
"ko-KR": "이 대화를 일시정지하시겠습니까?",
|
||||
"no": "Er du sikker på at du vil pause denne samtalen?",
|
||||
"it": "Sei sicuro di voler mettere in pausa questa conversazione?",
|
||||
"pt": "Tem certeza de que deseja pausar esta conversa?",
|
||||
"es": "¿Está seguro de que desea pausar esta conversación?",
|
||||
"ar": "هل أنت متأكد أنك تريد إيقاف هذه المحادثة مؤقتًا؟",
|
||||
"fr": "Êtes-vous sûr de vouloir mettre cette conversation en pause ?",
|
||||
"tr": "Bu konuşmayı duraklatmak istediğinizden emin misiniz?",
|
||||
"de": "Sind Sie sicher, dass Sie dieses Gespräch pausieren möchten?",
|
||||
"uk": "Ви впевнені, що хочете призупинити цю розмову?"
|
||||
},
|
||||
"CONVERSATION$STOP_WARNING": {
|
||||
"en": "Are you sure you want to pause this conversation?",
|
||||
"en": "Are you sure you want to stop this conversation?",
|
||||
"ja": "この会話を停止してもよろしいですか?",
|
||||
"zh-CN": "您确定要停止此对话吗?",
|
||||
"zh-TW": "您確定要停止此對話嗎?",
|
||||
@@ -7647,6 +7615,22 @@
|
||||
"tr": "Ajan görevine devam et",
|
||||
"uk": "Відновити завдання агента"
|
||||
},
|
||||
"ACTION_BUTTON$PAUSE": {
|
||||
"en": "Pause the current task",
|
||||
"zh-CN": "暂停",
|
||||
"zh-TW": "暫停",
|
||||
"ko-KR": "일시정지",
|
||||
"ja": "一時停止",
|
||||
"no": "Sett gjeldende oppgave på pause",
|
||||
"ar": "إيقاف المهمة الحالية مؤقتاً",
|
||||
"de": "Aktuelle Aufgabe pausieren",
|
||||
"fr": "Mettre en pause la tâche actuelle",
|
||||
"it": "Metti in pausa il compito corrente",
|
||||
"pt": "Pausar a tarefa atual",
|
||||
"es": "Pausar la tarea actual",
|
||||
"tr": "Mevcut görevi duraklat",
|
||||
"uk": "Призупинити поточне завдання"
|
||||
},
|
||||
"BROWSER$SCREENSHOT_ALT": {
|
||||
"en": "Browser Screenshot",
|
||||
"zh-CN": "截图",
|
||||
@@ -8255,6 +8239,22 @@
|
||||
"tr": "Kullanıcı avatarı yer tutucusu",
|
||||
"uk": "заповнювач аватара користувача"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$SETTINGS": {
|
||||
"en": "Account Settings",
|
||||
"ja": "アカウント設定",
|
||||
"zh-CN": "账户设置",
|
||||
"zh-TW": "帳戶設定",
|
||||
"ko-KR": "계정 설정",
|
||||
"no": "Kontoinnstillinger",
|
||||
"it": "Impostazioni account",
|
||||
"pt": "Configurações da conta",
|
||||
"es": "Configuración de la cuenta",
|
||||
"ar": "إعدادات الحساب",
|
||||
"fr": "Paramètres du compte",
|
||||
"tr": "Hesap ayarları",
|
||||
"de": "Kontoeinstellungen",
|
||||
"uk": "Налаштування облікового запису"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$LOGOUT": {
|
||||
"en": "Logout",
|
||||
"ja": "ログアウト",
|
||||
@@ -9199,6 +9199,38 @@
|
||||
"tr": "Bekleme listesine katıl",
|
||||
"uk": "Приєднатися до списку очікування"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$ADDITIONAL_SETTINGS": {
|
||||
"en": "Additional Settings",
|
||||
"ja": "追加設定",
|
||||
"zh-CN": "附加设置",
|
||||
"zh-TW": "附加設定",
|
||||
"ko-KR": "추가 설정",
|
||||
"de": "Zusätzliche Einstellungen",
|
||||
"no": "Ytterligere innstillinger",
|
||||
"it": "Impostazioni aggiuntive",
|
||||
"pt": "Configurações adicionais",
|
||||
"es": "Configuraciones adicionales",
|
||||
"ar": "إعدادات إضافية",
|
||||
"fr": "Paramètres supplémentaires",
|
||||
"tr": "Ek Ayarlar",
|
||||
"uk": "Додаткові налаштування"
|
||||
},
|
||||
"ACCOUNT_SETTINGS$DISCONNECT_FROM_GITHUB": {
|
||||
"en": "Disconnect from GitHub",
|
||||
"ja": "GitHubから切断",
|
||||
"zh-CN": "断开与GitHub的连接",
|
||||
"zh-TW": "中斷與GitHub的連接",
|
||||
"ko-KR": "GitHub 연결 해제",
|
||||
"de": "Von GitHub trennen",
|
||||
"no": "Koble fra GitHub",
|
||||
"it": "Disconnetti da GitHub",
|
||||
"pt": "Desconectar do GitHub",
|
||||
"es": "Desconectar de GitHub",
|
||||
"ar": "قطع الاتصال من GitHub",
|
||||
"fr": "Se déconnecter de GitHub",
|
||||
"tr": "GitHub'dan bağlantıyı kes",
|
||||
"uk": "Відключитися від GitHub"
|
||||
},
|
||||
"CONVERSATION$DELETE_WARNING": {
|
||||
"en": "Are you sure you want to delete this conversation? This action cannot be undone.",
|
||||
"ja": "この会話を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
@@ -13166,37 +13198,5 @@
|
||||
"tr": "Yüksek Risk",
|
||||
"de": "Hohes Risiko",
|
||||
"uk": "Високий ризик"
|
||||
},
|
||||
"MICROAGENT$DEFINITION": {
|
||||
"en": "Microagents are specialized prompts that enhance OpenHands with domain-specific knowledge. They provide expert guidance, automate common tasks, and ensure consistent practices across projects.",
|
||||
"ja": "マイクロエージェントは、OpenHandsにドメイン固有の知識を追加するための専門的なプロンプトです。専門的なガイダンスを提供し、一般的なタスクを自動化し、プロジェクト全体で一貫した実践を保証します。",
|
||||
"zh-CN": "微代理是增强 OpenHands 领域知识的专用提示。它们提供专家指导,自动化常见任务,并确保项目中的一致实践。",
|
||||
"zh-TW": "微代理是增強 OpenHands 領域知識的專用提示。它們提供專家指導,自動化常見任務,並確保專案中的一致實踐。",
|
||||
"ko-KR": "마이크로에이전트는 OpenHands에 도메인별 지식을 추가하는 특화된 프롬프트입니다. 전문가의 안내를 제공하고, 일반적인 작업을 자동화하며, 프로젝트 전반에 걸쳐 일관된 관행을 보장합니다.",
|
||||
"no": "Mikroagenter er spesialiserte prompt som forbedrer OpenHands med domenespesifikk kunnskap. De gir ekspertråd, automatiserer vanlige oppgaver og sikrer konsistente praksiser på tvers av prosjekter.",
|
||||
"it": "I microagenti sono prompt specializzati che arricchiscono OpenHands con conoscenze specifiche di dominio. Forniscono guida esperta, automatizzano attività comuni e garantiscono pratiche coerenti tra i progetti.",
|
||||
"pt": "Microagentes são prompts especializados que aprimoram o OpenHands com conhecimento específico de domínio. Eles fornecem orientação especializada, automatizam tarefas comuns e garantem práticas consistentes em todos os projetos.",
|
||||
"es": "Los microagentes son prompts especializados que mejoran OpenHands con conocimientos específicos de dominio. Proporcionan orientación experta, automatizan tareas comunes y aseguran prácticas consistentes en los proyectos.",
|
||||
"ar": "الميكرووكلاء هم مطالبات متخصصة تعزز OpenHands بمعرفة متخصصة في المجال. يقدمون إرشادات خبراء، ويؤتمتون المهام الشائعة، ويضمنون ممارسات متسقة عبر المشاريع.",
|
||||
"fr": "Les microagents sont des invites spécialisées qui enrichissent OpenHands avec des connaissances spécifiques au domaine. Ils fournissent des conseils d'experts, automatisent les tâches courantes et garantissent des pratiques cohérentes dans les projets.",
|
||||
"tr": "Mikro ajanlar, OpenHands'i alanına özgü bilgilerle geliştiren özel istemlerdir. Uzman rehberliği sağlar, yaygın görevleri otomatikleştirir ve projeler arasında tutarlı uygulamalar sunar.",
|
||||
"de": "Microagents sind spezialisierte Prompts, die OpenHands mit domänenspezifischem Wissen erweitern. Sie bieten fachkundige Anleitung, automatisieren gängige Aufgaben und sorgen für konsistente Praktiken in Projekten.",
|
||||
"uk": "Мікроагенти — це спеціалізовані підказки, які розширюють OpenHands галузевими знаннями. Вони надають експертні поради, автоматизують типові завдання та забезпечують послідовні практики у проєктах."
|
||||
},
|
||||
"MICROAGENT$ADD_TO_MEMORY": {
|
||||
"en": "Add to Microagent Memory",
|
||||
"ja": "マイクロエージェントメモリに追加",
|
||||
"zh-CN": "添加到微代理记忆",
|
||||
"zh-TW": "加入微代理記憶體",
|
||||
"ko-KR": "마이크로에이전트 메모리에 추가",
|
||||
"no": "Legg til i mikroagentminne",
|
||||
"it": "Aggiungi alla memoria del microagente",
|
||||
"pt": "Adicionar à Memória do Microagente",
|
||||
"es": "Agregar a la memoria del microagente",
|
||||
"ar": "أضف إلى ذاكرة الميكرووكيل",
|
||||
"fr": "Ajouter à la mémoire du microagent",
|
||||
"tr": "Mikroajan Hafızasına Ekle",
|
||||
"de": "Zur Microagent-Speicher hinzufügen",
|
||||
"uk": "Додати до пам'яті мікроагента"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,13 +186,9 @@ function LlmSettingsScreen() {
|
||||
const condenserMaxSizeStr = formData
|
||||
.get("condenser-max-size-input")
|
||||
?.toString();
|
||||
const condenserMaxSizeRaw = condenserMaxSizeStr
|
||||
const condenserMaxSize = condenserMaxSizeStr
|
||||
? Number.parseInt(condenserMaxSizeStr, 10)
|
||||
: undefined;
|
||||
const condenserMaxSize =
|
||||
condenserMaxSizeRaw !== undefined
|
||||
? Math.max(20, condenserMaxSizeRaw)
|
||||
: undefined;
|
||||
|
||||
const securityAnalyzer = formData
|
||||
.get("security-analyzer-input")
|
||||
@@ -326,9 +322,8 @@ function LlmSettingsScreen() {
|
||||
|
||||
const handleCondenserMaxSizeIsDirty = (value: string) => {
|
||||
const parsed = value ? Number.parseInt(value, 10) : undefined;
|
||||
const bounded = parsed !== undefined ? Math.max(20, parsed) : undefined;
|
||||
const condenserMaxSizeIsDirty =
|
||||
(bounded ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
|
||||
(parsed ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE) !==
|
||||
(settings?.CONDENSER_MAX_SIZE ?? DEFAULT_SETTINGS.CONDENSER_MAX_SIZE);
|
||||
setDirtyInputs((prev) => ({
|
||||
...prev,
|
||||
@@ -598,7 +593,7 @@ function LlmSettingsScreen() {
|
||||
testId="condenser-max-size-input"
|
||||
name="condenser-max-size-input"
|
||||
type="number"
|
||||
min={20}
|
||||
min={10}
|
||||
step={1}
|
||||
label={t(I18nKey.SETTINGS$CONDENSER_MAX_SIZE)}
|
||||
defaultValue={(
|
||||
|
||||
@@ -1,6 +1 @@
|
||||
export type ConversationStatus =
|
||||
| "STARTING"
|
||||
| "RUNNING"
|
||||
| "STOPPED"
|
||||
| "ARCHIVED"
|
||||
| "ERROR";
|
||||
export type ConversationStatus = "STARTING" | "RUNNING" | "STOPPED";
|
||||
|
||||
@@ -127,7 +127,6 @@ export interface RecallObservation extends OpenHandsObservationEvent<"recall"> {
|
||||
runtime_hosts?: Record<string, number>;
|
||||
custom_secrets_descriptions?: Record<string, string>;
|
||||
additional_agent_instructions?: string;
|
||||
conversation_instructions?: string;
|
||||
date?: string;
|
||||
microagent_knowledge?: MicroagentKnowledge[];
|
||||
};
|
||||
|
||||
@@ -244,31 +244,3 @@ export const extractRepositoryInfo = (
|
||||
|
||||
return { owner, repo, filePath };
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the repository markdown creation prompt with additional PR creation instructions
|
||||
* @param gitProvider The git provider to use for generating provider-specific text
|
||||
* @param query Optional custom query to use instead of the default prompt
|
||||
* @returns The complete prompt for creating repository markdown and PR instructions
|
||||
*/
|
||||
export const getRepoMdCreatePrompt = (
|
||||
gitProvider: Provider,
|
||||
query?: string,
|
||||
): string => {
|
||||
const providerName = getProviderName(gitProvider);
|
||||
const pr = getPR(gitProvider === "gitlab");
|
||||
const prShort = getPRShort(gitProvider === "gitlab");
|
||||
|
||||
return `Please explore this repository. Create the file .openhands/microagents/repo.md with:
|
||||
${
|
||||
query
|
||||
? `- ${query}`
|
||||
: `- A description of the project
|
||||
- An overview of the file structure
|
||||
- Any information on how to run tests or other relevant commands
|
||||
- Any other information that would be helpful to a brand new developer
|
||||
Keep it short--just a few paragraphs will do.`
|
||||
}
|
||||
|
||||
Please push the changes to your branch on ${providerName} and create a ${pr}. Please create a meaningful branch name that describes the changes. If a ${pr} template exists in the repository, please follow it when creating the ${prShort} description.`;
|
||||
};
|
||||
|
||||
@@ -24,14 +24,12 @@ export const VERIFIED_MODELS = [
|
||||
"kimi-k2-0711-preview",
|
||||
"qwen3-coder-480b",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
];
|
||||
|
||||
// LiteLLM does not return OpenAI models with the provider, so we list them here to set them ourselves for consistency
|
||||
// (e.g., they return `gpt-4o` instead of `openai/gpt-4o`)
|
||||
export const VERIFIED_OPENAI_MODELS = [
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-4.1",
|
||||
@@ -68,7 +66,6 @@ export const VERIFIED_MISTRAL_MODELS = [
|
||||
export const VERIFIED_OPENHANDS_MODELS = [
|
||||
"claude-sonnet-4-20250514",
|
||||
"gpt-5-2025-08-07",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-1-20250805",
|
||||
"gemini-2.5-pro",
|
||||
|
||||
@@ -154,32 +154,15 @@ class BrowsingAgent(Agent):
|
||||
# for webarena and miniwob++ eval, we need to retrieve the initial observation already in browser env
|
||||
# initialize and retrieve the first observation by issuing an noop OP
|
||||
# For non-benchmark browsing, the browser env starts with a blank page, and the agent is expected to first navigate to desired websites
|
||||
return BrowseInteractiveAction(browser_actions='noop()', return_axtree=True)
|
||||
return BrowseInteractiveAction(browser_actions='noop()')
|
||||
|
||||
for event in state.view:
|
||||
if isinstance(event, BrowseInteractiveAction):
|
||||
prev_actions.append(event.browser_actions)
|
||||
last_action = event
|
||||
elif isinstance(event, MessageAction) and event.source == EventSource.AGENT:
|
||||
# agent has responded with a message. Avoid finishing on generic browsing error string.
|
||||
# Check for various forms of the generic browsing error message
|
||||
generic_error_patterns = [
|
||||
'error encountered when browsing',
|
||||
'error encountered while browsing',
|
||||
'error encountered during browsing',
|
||||
'an error encountered when browsing',
|
||||
'an error encountered while browsing',
|
||||
'an error encountered during browsing'
|
||||
]
|
||||
if (
|
||||
event.content
|
||||
and any(pattern in event.content.strip().lower() for pattern in generic_error_patterns)
|
||||
):
|
||||
logger.warning(
|
||||
'Ignoring generic error message from agent; continuing.'
|
||||
)
|
||||
else:
|
||||
return AgentFinishAction(outputs={'content': event.content})
|
||||
# agent has responded, task finished.
|
||||
return AgentFinishAction(outputs={'content': event.content})
|
||||
elif isinstance(event, Observation):
|
||||
last_obs = event
|
||||
|
||||
@@ -193,21 +176,7 @@ class BrowsingAgent(Agent):
|
||||
isinstance(last_action, BrowseInteractiveAction)
|
||||
and last_action.browsergym_send_msg_to_user
|
||||
):
|
||||
# Avoid prematurely finishing on generic error messages
|
||||
msg_content = last_action.browsergym_send_msg_to_user.strip()
|
||||
generic_error_patterns = [
|
||||
'error encountered when browsing',
|
||||
'error encountered while browsing',
|
||||
'error encountered during browsing',
|
||||
'an error encountered when browsing',
|
||||
'an error encountered while browsing',
|
||||
'an error encountered during browsing'
|
||||
]
|
||||
if any(pattern in msg_content.lower() for pattern in generic_error_patterns):
|
||||
logger.warning('Ignoring generic error message from model; continuing.')
|
||||
# Do not finish; proceed to compute next action
|
||||
else:
|
||||
return MessageAction(last_action.browsergym_send_msg_to_user)
|
||||
return MessageAction(last_action.browsergym_send_msg_to_user)
|
||||
|
||||
if isinstance(last_obs, BrowserOutputObservation):
|
||||
if last_obs.error:
|
||||
@@ -220,59 +189,17 @@ class BrowsingAgent(Agent):
|
||||
cur_url = last_obs.url
|
||||
|
||||
try:
|
||||
# Debug logging to understand the structure
|
||||
logger.info(
|
||||
f'DEBUG: axtree_object type: {type(last_obs.axtree_object)}'
|
||||
cur_axtree_txt = flatten_axtree_to_str(
|
||||
last_obs.axtree_object,
|
||||
extra_properties=last_obs.extra_element_properties,
|
||||
with_clickable=True,
|
||||
filter_visible_only=True,
|
||||
)
|
||||
logger.info(
|
||||
f'DEBUG: axtree_object is None: {last_obs.axtree_object is None}'
|
||||
)
|
||||
if isinstance(last_obs.axtree_object, dict):
|
||||
logger.info(
|
||||
f'DEBUG: axtree_object keys: {list(last_obs.axtree_object.keys())}'
|
||||
)
|
||||
if 'nodes' in last_obs.axtree_object:
|
||||
logger.info(
|
||||
f'DEBUG: nodes type: {type(last_obs.axtree_object["nodes"])}'
|
||||
)
|
||||
logger.info(
|
||||
f'DEBUG: nodes length: {len(last_obs.axtree_object["nodes"]) if last_obs.axtree_object["nodes"] else 0}'
|
||||
)
|
||||
|
||||
# Check if axtree_object exists and has the expected structure
|
||||
if not last_obs.axtree_object or not isinstance(
|
||||
last_obs.axtree_object, dict
|
||||
):
|
||||
logger.info('DEBUG: Using fallback - no axtree_object or not dict')
|
||||
cur_axtree_txt = '[No accessibility tree available]'
|
||||
elif (
|
||||
'nodes' not in last_obs.axtree_object
|
||||
or not last_obs.axtree_object['nodes']
|
||||
):
|
||||
# axtree_object exists but is empty or missing nodes - this is the common case
|
||||
logger.info('DEBUG: Using fallback - missing nodes or empty nodes')
|
||||
cur_axtree_txt = '[Accessibility tree not yet loaded]'
|
||||
else:
|
||||
# axtree_object has the expected structure with nodes
|
||||
logger.info('DEBUG: Calling flatten_axtree_to_str')
|
||||
cur_axtree_txt = flatten_axtree_to_str(
|
||||
last_obs.axtree_object,
|
||||
extra_properties=last_obs.extra_element_properties,
|
||||
with_clickable=True,
|
||||
filter_visible_only=True,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'BROWSING AGENT ERROR when trying to process the accessibility tree: %s',
|
||||
e,
|
||||
)
|
||||
logger.error(
|
||||
f'DEBUG: Exception occurred with axtree_object: {last_obs.axtree_object}'
|
||||
)
|
||||
# Fall back gracefully without aborting the task
|
||||
cur_axtree_txt = (
|
||||
'[Accessibility tree unavailable due to processing error]'
|
||||
'Error when trying to process the accessibility tree: %s', e
|
||||
)
|
||||
return MessageAction('Error encountered when browsing.')
|
||||
|
||||
goal, _ = state.get_current_user_intent()
|
||||
|
||||
|
||||
@@ -61,32 +61,11 @@ class BrowsingActionParserMessage(ActionParser):
|
||||
return '```' not in action_str
|
||||
|
||||
def parse(self, action_str: str) -> Action:
|
||||
# If the model emitted a plain message (no code fence). If it is an
|
||||
# error-like message, recover by requesting another observation instead
|
||||
# of finishing immediately.
|
||||
lowered = action_str.strip().lower()
|
||||
# Check for various forms of the generic browsing error message
|
||||
generic_error_patterns = [
|
||||
'error encountered when browsing',
|
||||
'error encountered while browsing',
|
||||
'error encountered during browsing',
|
||||
'an error encountered when browsing',
|
||||
'an error encountered while browsing',
|
||||
'an error encountered during browsing'
|
||||
]
|
||||
if any(pattern in lowered for pattern in generic_error_patterns):
|
||||
return BrowseInteractiveAction(
|
||||
browser_actions='noop()',
|
||||
thought='Recovered from generic browsing error message',
|
||||
browsergym_send_msg_to_user='',
|
||||
return_axtree=True,
|
||||
)
|
||||
msg = f'send_msg_to_user("""{action_str}""")'
|
||||
return BrowseInteractiveAction(
|
||||
browser_actions=msg,
|
||||
thought=action_str,
|
||||
browsergym_send_msg_to_user=action_str,
|
||||
return_axtree=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -122,24 +101,6 @@ class BrowsingActionParserBrowseInteractive(ActionParser):
|
||||
)
|
||||
thought = parts[0].strip() if parts[1].strip() != '' else ''
|
||||
|
||||
# Guard against generic error message leading to premature finish
|
||||
lowered = browser_actions.strip().lower()
|
||||
generic_error_patterns = [
|
||||
'error encountered when browsing',
|
||||
'error encountered while browsing',
|
||||
'error encountered during browsing',
|
||||
'an error encountered when browsing',
|
||||
'an error encountered while browsing',
|
||||
'an error encountered during browsing'
|
||||
]
|
||||
if any(pattern in lowered for pattern in generic_error_patterns):
|
||||
return BrowseInteractiveAction(
|
||||
browser_actions='noop()',
|
||||
thought=thought,
|
||||
browsergym_send_msg_to_user='',
|
||||
return_axtree=True,
|
||||
)
|
||||
|
||||
# if the LLM wants to talk to the user, we extract the message
|
||||
msg_content = ''
|
||||
for sub_action in browser_actions.split('\n'):
|
||||
@@ -152,33 +113,14 @@ class BrowsingActionParserBrowseInteractive(ActionParser):
|
||||
logger.error(f'Error parsing action: {sub_action}')
|
||||
# the syntax was not correct, but we can still try to get the message
|
||||
# e.g. send_msg_to_user("Hello, world!") or send_msg_to_user('Hello, world!'
|
||||
match = re.search(r'send_msg_to_user\((["])(.*?)\1\)', sub_action)
|
||||
match = re.search(r'send_msg_to_user\((["\'])(.*?)\1\)', sub_action)
|
||||
if match:
|
||||
msg_content = match.group(2)
|
||||
else:
|
||||
msg_content = ''
|
||||
|
||||
# Also guard if the extracted message content is the generic error
|
||||
lowered_msg = msg_content.strip().lower()
|
||||
generic_error_patterns = [
|
||||
'error encountered when browsing',
|
||||
'error encountered while browsing',
|
||||
'error encountered during browsing',
|
||||
'an error encountered when browsing',
|
||||
'an error encountered while browsing',
|
||||
'an error encountered during browsing'
|
||||
]
|
||||
if any(pattern in lowered_msg for pattern in generic_error_patterns):
|
||||
return BrowseInteractiveAction(
|
||||
browser_actions='noop()',
|
||||
thought=thought,
|
||||
browsergym_send_msg_to_user='',
|
||||
return_axtree=True,
|
||||
)
|
||||
|
||||
return BrowseInteractiveAction(
|
||||
browser_actions=browser_actions,
|
||||
thought=thought,
|
||||
browsergym_send_msg_to_user=msg_content,
|
||||
return_axtree=True,
|
||||
)
|
||||
|
||||
@@ -247,11 +247,7 @@ def response_to_actions(
|
||||
raise FunctionCallValidationError(
|
||||
f'Missing required argument "code" in tool call {tool_call.function.name}'
|
||||
)
|
||||
# Allow user to specify whether they need accessibility tree
|
||||
return_axtree = arguments.get('return_axtree', False)
|
||||
action = BrowseInteractiveAction(
|
||||
browser_actions=arguments['code'], return_axtree=return_axtree
|
||||
)
|
||||
action = BrowseInteractiveAction(browser_actions=arguments['code'])
|
||||
set_security_risk(action, arguments)
|
||||
|
||||
# ================================================
|
||||
|
||||
@@ -64,7 +64,7 @@ scroll(delta_x: float, delta_y: float)
|
||||
|
||||
scroll(-50.2, -100.5)
|
||||
|
||||
fill(bid: str, value: str, enable_autocomplete_menu: bool = False)
|
||||
fill(bid: str, value: str)
|
||||
Description: Fill out a form field. It focuses the element and triggers an input event with the entered text. It works for <input>, <textarea> and [contenteditable] elements.
|
||||
Examples:
|
||||
fill('237', 'example value')
|
||||
@@ -159,11 +159,6 @@ BrowserTool = ChatCompletionToolParam(
|
||||
+ _BROWSER_TOOL_DESCRIPTION
|
||||
),
|
||||
},
|
||||
'return_axtree': {
|
||||
'type': 'boolean',
|
||||
'description': 'Whether to return the accessibility tree in the observation. Set to true if you need to analyze page structure or find elements by text content. Default is false for performance.',
|
||||
'default': False,
|
||||
},
|
||||
'security_risk': {
|
||||
'type': 'string',
|
||||
'description': SECURITY_RISK_DESC,
|
||||
|
||||
@@ -250,69 +250,24 @@ Note:
|
||||
)
|
||||
tabs = get_tabs(last_obs)
|
||||
try:
|
||||
# Debug logging to understand the structure
|
||||
logger.info(
|
||||
f'VISUAL DEBUG: axtree_object type: {type(last_obs.axtree_object)}'
|
||||
# IMPORTANT: keep AX Tree of full webpage, add visible and clickable tags
|
||||
cur_axtree_txt = flatten_axtree_to_str(
|
||||
last_obs.axtree_object,
|
||||
extra_properties=last_obs.extra_element_properties,
|
||||
with_visible=True,
|
||||
with_clickable=True,
|
||||
with_center_coords=False,
|
||||
with_bounding_box_coords=False,
|
||||
filter_visible_only=False,
|
||||
filter_with_bid_only=False,
|
||||
filter_som_only=False,
|
||||
)
|
||||
logger.info(
|
||||
f'VISUAL DEBUG: axtree_object is None: {last_obs.axtree_object is None}'
|
||||
)
|
||||
if isinstance(last_obs.axtree_object, dict):
|
||||
logger.info(
|
||||
f'VISUAL DEBUG: axtree_object keys: {list(last_obs.axtree_object.keys())}'
|
||||
)
|
||||
if 'nodes' in last_obs.axtree_object:
|
||||
logger.info(
|
||||
f'VISUAL DEBUG: nodes type: {type(last_obs.axtree_object["nodes"])}'
|
||||
)
|
||||
logger.info(
|
||||
f'VISUAL DEBUG: nodes length: {len(last_obs.axtree_object["nodes"]) if last_obs.axtree_object["nodes"] else 0}'
|
||||
)
|
||||
|
||||
# Check if axtree_object exists and has the expected structure
|
||||
if not last_obs.axtree_object or not isinstance(
|
||||
last_obs.axtree_object, dict
|
||||
):
|
||||
logger.info(
|
||||
'VISUAL DEBUG: Using fallback - no axtree_object or not dict'
|
||||
)
|
||||
cur_axtree_txt = '[No accessibility tree available]'
|
||||
elif (
|
||||
'nodes' not in last_obs.axtree_object
|
||||
or not last_obs.axtree_object['nodes']
|
||||
):
|
||||
# axtree_object exists but is empty or missing nodes - this is the common case
|
||||
logger.info(
|
||||
'VISUAL DEBUG: Using fallback - missing nodes or empty nodes'
|
||||
)
|
||||
cur_axtree_txt = '[Accessibility tree not yet loaded]'
|
||||
else:
|
||||
# IMPORTANT: keep AX Tree of full webpage, add visible and clickable tags
|
||||
logger.info('VISUAL DEBUG: Calling flatten_axtree_to_str')
|
||||
cur_axtree_txt = flatten_axtree_to_str(
|
||||
last_obs.axtree_object,
|
||||
extra_properties=last_obs.extra_element_properties,
|
||||
with_visible=True,
|
||||
with_clickable=True,
|
||||
with_center_coords=False,
|
||||
with_bounding_box_coords=False,
|
||||
filter_visible_only=False,
|
||||
filter_with_bid_only=False,
|
||||
filter_som_only=False,
|
||||
)
|
||||
cur_axtree_txt = get_axtree(axtree_txt=cur_axtree_txt)
|
||||
cur_axtree_txt = get_axtree(axtree_txt=cur_axtree_txt)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
'VISUAL BROWSING AGENT ERROR when trying to process the accessibility tree: %s',
|
||||
e,
|
||||
)
|
||||
logger.error(
|
||||
f'VISUAL DEBUG: Exception occurred with axtree_object: {last_obs.axtree_object}'
|
||||
)
|
||||
# Fall back gracefully without aborting the task
|
||||
cur_axtree_txt = (
|
||||
'[Accessibility tree unavailable due to processing error]'
|
||||
'Error when trying to process the accessibility tree: %s', e
|
||||
)
|
||||
return MessageAction('Error encountered when browsing.')
|
||||
set_of_marks = last_obs.set_of_marks
|
||||
goal, image_urls = state.get_current_user_intent()
|
||||
|
||||
|
||||
@@ -151,7 +151,6 @@ VERIFIED_PROVIDERS = ['openhands', 'anthropic', 'openai', 'mistral']
|
||||
|
||||
VERIFIED_OPENAI_MODELS = [
|
||||
'gpt-5-2025-08-07',
|
||||
'gpt-5-mini-2025-08-07',
|
||||
'o4-mini',
|
||||
'gpt-4o',
|
||||
'gpt-4o-mini',
|
||||
@@ -187,7 +186,6 @@ VERIFIED_MISTRAL_MODELS = [
|
||||
VERIFIED_OPENHANDS_MODELS = [
|
||||
'claude-sonnet-4-20250514',
|
||||
'gpt-5-2025-08-07',
|
||||
'gpt-5-mini-2025-08-07',
|
||||
'claude-opus-4-20250514',
|
||||
'claude-opus-4-1-20250805',
|
||||
'devstral-small-2507',
|
||||
|
||||
@@ -2,8 +2,6 @@ import os
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
|
||||
|
||||
class SandboxConfig(BaseModel):
|
||||
"""Configuration for the sandbox.
|
||||
@@ -57,7 +55,6 @@ class SandboxConfig(BaseModel):
|
||||
)
|
||||
runtime_container_image: str | None = Field(default=None)
|
||||
user_id: int = Field(default=os.getuid() if hasattr(os, 'getuid') else 1000)
|
||||
logger.debug(f'SandboxConfig user_id default: {user_id}')
|
||||
timeout: int = Field(default=120)
|
||||
remote_runtime_init_timeout: int = Field(default=180)
|
||||
remote_runtime_api_timeout: int = Field(default=10)
|
||||
|
||||
@@ -144,6 +144,7 @@ class FileEditObservation(Observation):
|
||||
Returns:
|
||||
A string containing the formatted diff visualization.
|
||||
"""
|
||||
|
||||
# Use cached diff if available
|
||||
if self._diff_cache is not None:
|
||||
return self._diff_cache
|
||||
|
||||
@@ -65,24 +65,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
def provider(self) -> str:
|
||||
return ProviderType.BITBUCKET.value
|
||||
|
||||
def _extract_owner_and_repo(self, repository: str) -> tuple[str, str]:
|
||||
"""Extract owner and repo from repository string.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'workspace/repo_slug'
|
||||
|
||||
Returns:
|
||||
Tuple of (owner, repo)
|
||||
|
||||
Raises:
|
||||
ValueError: If repository format is invalid
|
||||
"""
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
return parts[-2], parts[-1]
|
||||
|
||||
async def get_latest_token(self) -> SecretStr | None:
|
||||
"""Get latest working token of the user."""
|
||||
return self.token
|
||||
@@ -513,7 +495,13 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
self, repository: str
|
||||
) -> Repository:
|
||||
"""Gets all repository details from repository name."""
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}'
|
||||
data, _ = await self._make_request(url)
|
||||
@@ -522,7 +510,13 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
|
||||
async def get_branches(self, repository: str) -> list[Branch]:
|
||||
"""Get branches for a repository."""
|
||||
owner, repo = self._extract_owner_and_repo(repository)
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repository.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repository}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/refs/branches'
|
||||
|
||||
@@ -573,7 +567,13 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
Returns:
|
||||
The URL of the created pull request
|
||||
"""
|
||||
owner, repo = self._extract_owner_and_repo(repo_name)
|
||||
# Extract owner and repo from the repository string (e.g., "owner/repo")
|
||||
parts = repo_name.split('/')
|
||||
if len(parts) < 2:
|
||||
raise ValueError(f'Invalid repository name: {repo_name}')
|
||||
|
||||
owner = parts[-2]
|
||||
repo = parts[-1]
|
||||
|
||||
url = f'{self.BASE_URL}/repositories/{owner}/{repo}/pullrequests'
|
||||
|
||||
@@ -593,21 +593,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
# Return the URL to the pull request
|
||||
return data.get('links', {}).get('html', {}).get('href', '')
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The pull request number
|
||||
|
||||
Returns:
|
||||
Raw Bitbucket API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repositories/{repository}/pullrequests/{pr_number}'
|
||||
pr_data, _ = await self._make_request(url)
|
||||
|
||||
return pr_data
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
@@ -643,40 +628,6 @@ class BitBucketService(BaseGitService, GitService, InstallationsService):
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(response, file_path)
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a Bitbucket pull request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (OPEN), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
pr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# Bitbucket API response structure
|
||||
# https://developer.atlassian.com/cloud/bitbucket/rest/api-group-pullrequests/#api-repositories-workspace-repo-slug-pullrequests-pull-request-id-get
|
||||
if 'state' in pr_details:
|
||||
# Bitbucket state values: OPEN, MERGED, DECLINED, SUPERSEDED
|
||||
return pr_details['state'] == 'OPEN'
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine Bitbucket PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
|
||||
bitbucket_service_cls = os.environ.get(
|
||||
'OPENHANDS_BITBUCKET_SERVICE_CLS',
|
||||
|
||||
@@ -9,16 +9,12 @@ from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.github.queries import (
|
||||
get_review_threads_graphql_query,
|
||||
get_thread_comments_graphql_query,
|
||||
get_thread_from_comment_graphql_query,
|
||||
suggested_task_issue_graphql_query,
|
||||
suggested_task_pr_graphql_query,
|
||||
)
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
Comment,
|
||||
GitService,
|
||||
InstallationsService,
|
||||
OwnerType,
|
||||
@@ -676,21 +672,6 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
# Return the HTML URL of the created PR
|
||||
return response['html_url']
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The pull request number
|
||||
|
||||
Returns:
|
||||
Raw GitHub API response for the pull request
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/pulls/{pr_number}'
|
||||
pr_data, _ = await self._make_request(url)
|
||||
|
||||
return pr_data
|
||||
|
||||
async def get_microagent_content(
|
||||
self, repository: str, file_path: str
|
||||
) -> MicroagentContentResponse:
|
||||
@@ -714,258 +695,6 @@ class GitHubService(BaseGitService, GitService, InstallationsService):
|
||||
# Parse the content to extract triggers from frontmatter
|
||||
return self._parse_microagent_content(file_content, file_path)
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a GitHub PR is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (open), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
pr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# GitHub API response structure
|
||||
# https://docs.github.com/en/rest/pulls/pulls#get-a-pull-request
|
||||
if 'state' in pr_details:
|
||||
return pr_details['state'] == 'open'
|
||||
elif 'merged' in pr_details and 'closed_at' in pr_details:
|
||||
# Check if PR is merged or closed
|
||||
return not (pr_details['merged'] or pr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitHub PR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(pr_details.keys())}. Assuming PR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine GitHub PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
async def get_issue_or_pr_comments(
|
||||
self, repository: str, issue_number: int, max_comments: int = 10
|
||||
) -> list[Comment]:
|
||||
"""Get comments for an issue.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
issue_number: The issue number
|
||||
discussion_id: Not used for GitHub (kept for compatibility with GitLab)
|
||||
|
||||
Returns:
|
||||
List of Comment objects ordered by creation date
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}/comments'
|
||||
page = 1
|
||||
all_comments: list[dict] = []
|
||||
|
||||
while len(all_comments) < max_comments:
|
||||
params = {
|
||||
'per_page': 10,
|
||||
'sort': 'created',
|
||||
'direction': 'asc',
|
||||
'page': page,
|
||||
}
|
||||
response, headers = await self._make_request(url, params=params)
|
||||
all_comments.extend(response or [])
|
||||
|
||||
# Parse the Link header for rel="next"
|
||||
link_header = headers.get('Link', '')
|
||||
if 'rel="next"' not in link_header:
|
||||
break
|
||||
|
||||
page += 1
|
||||
|
||||
return self._process_raw_comments(all_comments)
|
||||
|
||||
async def get_issue_or_pr_title_and_body(
|
||||
self, repository: str, issue_number: int
|
||||
) -> tuple[str, str]:
|
||||
"""Get the title and body of an issue.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
issue_number: The issue number
|
||||
|
||||
Returns:
|
||||
A tuple of (title, body)
|
||||
"""
|
||||
url = f'{self.BASE_URL}/repos/{repository}/issues/{issue_number}'
|
||||
response, _ = await self._make_request(url)
|
||||
title = response.get('title') or ''
|
||||
body = response.get('body') or ''
|
||||
return title, body
|
||||
|
||||
async def get_review_thread_comments(
|
||||
self,
|
||||
comment_id: str,
|
||||
repository: str,
|
||||
pr_number: int,
|
||||
) -> list[Comment]:
|
||||
"""Get all comments in a review thread starting from a specific comment.
|
||||
|
||||
Uses GraphQL to traverse the reply chain from the given comment up to the root
|
||||
comment, then finds the review thread and returns all comments in the thread.
|
||||
|
||||
Args:
|
||||
comment_id: The GraphQL node ID of any comment in the thread
|
||||
repo: Repository name
|
||||
pr_number: Pull request number
|
||||
|
||||
Returns:
|
||||
List of Comment objects representing the entire thread
|
||||
"""
|
||||
|
||||
# Step 1: Use existing GraphQL query to get the comment and check for replyTo
|
||||
variables = {'commentId': comment_id}
|
||||
data = await self.execute_graphql_query(
|
||||
get_thread_from_comment_graphql_query, variables
|
||||
)
|
||||
|
||||
comment_node = data.get('data', {}).get('node')
|
||||
if not comment_node:
|
||||
return []
|
||||
|
||||
# Step 2: If replyTo exists, traverse to the root comment
|
||||
root_comment_id = comment_id
|
||||
reply_to = comment_node.get('replyTo')
|
||||
if reply_to:
|
||||
root_comment_id = reply_to['id']
|
||||
|
||||
# Step 3: Get all review threads and find the one containing our root comment
|
||||
owner, repo = repository.split('/')
|
||||
thread_id = None
|
||||
after_cursor = None
|
||||
has_next_page = True
|
||||
|
||||
while has_next_page and not thread_id:
|
||||
threads_variables: dict[str, Any] = {
|
||||
'owner': owner,
|
||||
'repo': repo,
|
||||
'number': pr_number,
|
||||
'first': 50,
|
||||
}
|
||||
if after_cursor:
|
||||
threads_variables['after'] = after_cursor
|
||||
|
||||
threads_data = await self.execute_graphql_query(
|
||||
get_review_threads_graphql_query, threads_variables
|
||||
)
|
||||
|
||||
review_threads_data = (
|
||||
threads_data.get('data', {})
|
||||
.get('repository', {})
|
||||
.get('pullRequest', {})
|
||||
.get('reviewThreads', {})
|
||||
)
|
||||
|
||||
review_threads = review_threads_data.get('nodes', [])
|
||||
page_info = review_threads_data.get('pageInfo', {})
|
||||
|
||||
# Search for the thread containing our root comment
|
||||
for thread in review_threads:
|
||||
first_comments = thread.get('comments', {}).get('nodes', [])
|
||||
for first_comment in first_comments:
|
||||
if first_comment.get('id') == root_comment_id:
|
||||
thread_id = thread.get('id')
|
||||
break
|
||||
if thread_id:
|
||||
break
|
||||
|
||||
# Update pagination variables
|
||||
has_next_page = page_info.get('hasNextPage', False)
|
||||
after_cursor = page_info.get('endCursor')
|
||||
|
||||
if not thread_id:
|
||||
# Fallback: return just the comments we found during traversal
|
||||
logger.warning(
|
||||
f'Could not find review thread for comment {comment_id}, returning traversed comments'
|
||||
)
|
||||
return []
|
||||
|
||||
# Step 4: Get all comments from the review thread using the thread ID
|
||||
all_thread_comments = []
|
||||
after_cursor = None
|
||||
has_next_page = True
|
||||
|
||||
while has_next_page:
|
||||
comments_variables: dict[str, Any] = {}
|
||||
comments_variables['threadId'] = thread_id
|
||||
comments_variables['page'] = 50
|
||||
if after_cursor:
|
||||
comments_variables['after'] = after_cursor
|
||||
|
||||
thread_comments_data = await self.execute_graphql_query(
|
||||
get_thread_comments_graphql_query, comments_variables
|
||||
)
|
||||
|
||||
thread_node = thread_comments_data.get('data', {}).get('node')
|
||||
if not thread_node:
|
||||
break
|
||||
|
||||
comments_data = thread_node.get('comments', {})
|
||||
comments_nodes = comments_data.get('nodes', [])
|
||||
page_info = comments_data.get('pageInfo', {})
|
||||
|
||||
all_thread_comments.extend(comments_nodes)
|
||||
|
||||
has_next_page = page_info.get('hasNextPage', False)
|
||||
after_cursor = page_info.get('endCursor')
|
||||
|
||||
return self._process_raw_comments(all_thread_comments)
|
||||
|
||||
def _truncate_comment(
|
||||
self, comment_body: str, max_comment_length: int = 500
|
||||
) -> str:
|
||||
"""Truncate comment body to a maximum length."""
|
||||
if len(comment_body) > max_comment_length:
|
||||
return comment_body[:max_comment_length] + '...'
|
||||
return comment_body
|
||||
|
||||
def _process_raw_comments(
|
||||
self, comments_data: list, max_comments: int = 10
|
||||
) -> list[Comment]:
|
||||
"""Convert raw comment data to Comment objects."""
|
||||
comments: list[Comment] = []
|
||||
for comment in comments_data:
|
||||
author = 'unknown'
|
||||
|
||||
if comment.get('author'):
|
||||
author = comment.get('author', {}).get('login', 'unknown')
|
||||
elif comment.get('user'):
|
||||
author = comment.get('user', {}).get('login', 'unknown')
|
||||
|
||||
comments.append(
|
||||
Comment(
|
||||
id=str(comment.get('id', 'unknown')),
|
||||
body=self._truncate_comment(comment.get('body', '')),
|
||||
author=author,
|
||||
created_at=datetime.fromisoformat(
|
||||
comment.get('createdAt', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment.get('createdAt')
|
||||
else datetime.fromtimestamp(0),
|
||||
updated_at=datetime.fromisoformat(
|
||||
comment.get('updatedAt', '').replace('Z', '+00:00')
|
||||
)
|
||||
if comment.get('updatedAt')
|
||||
else datetime.fromtimestamp(0),
|
||||
system=False,
|
||||
)
|
||||
)
|
||||
|
||||
# Sort comments by creation date to maintain chronological order
|
||||
comments.sort(key=lambda c: c.created_at)
|
||||
return comments[-max_comments:]
|
||||
|
||||
|
||||
github_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITHUB_SERVICE_CLS',
|
||||
|
||||
@@ -45,80 +45,3 @@ suggested_task_issue_graphql_query = """
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
get_thread_from_comment_graphql_query = """
|
||||
query GetThreadFromComment($commentId: ID!) {
|
||||
node(id: $commentId) {
|
||||
... on PullRequestReviewComment {
|
||||
id
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
replyTo {
|
||||
id
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
createdAt
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
get_review_threads_graphql_query = """
|
||||
query($owner: String!, $repo: String!, $number: Int!, $first: Int = 50, $after: String) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
reviewThreads(first: $first, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
path
|
||||
isResolved
|
||||
comments(first: 1) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
body
|
||||
author {
|
||||
login
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pageInfo {
|
||||
hasNextPage
|
||||
endCursor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
get_thread_comments_graphql_query = """
|
||||
query ($threadId: ID!, $page: Int = 50, $after: String) {
|
||||
node(id: $threadId) {
|
||||
... on PullRequestReviewThread {
|
||||
id
|
||||
path
|
||||
isResolved
|
||||
comments(first: $page, after: $after) {
|
||||
nodes {
|
||||
id
|
||||
databaseId
|
||||
body
|
||||
author { login }
|
||||
createdAt
|
||||
}
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -5,7 +5,6 @@ from typing import Any
|
||||
import httpx
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.integrations.service_types import (
|
||||
BaseGitService,
|
||||
Branch,
|
||||
@@ -627,22 +626,6 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return response['web_url']
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific merge request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
Raw GitLab API response for the merge request
|
||||
"""
|
||||
project_id = self._extract_project_id(repository)
|
||||
url = f'{self.BASE_URL}/projects/{project_id}/merge_requests/{pr_number}'
|
||||
mr_data, _ = await self._make_request(url)
|
||||
|
||||
return mr_data
|
||||
|
||||
def _extract_project_id(self, repository: str) -> str:
|
||||
"""Extract project_id from repository name for GitLab API calls.
|
||||
|
||||
@@ -744,7 +727,7 @@ class GitLabService(BaseGitService, GitService):
|
||||
continue
|
||||
|
||||
comment = Comment(
|
||||
id=str(comment_data['id']),
|
||||
id=comment_data['id'],
|
||||
body=comment_data['body'],
|
||||
author=comment_data.get('author', {}).get('username', 'unknown'),
|
||||
created_at=datetime.fromisoformat(
|
||||
@@ -766,42 +749,6 @@ class GitLabService(BaseGitService, GitService):
|
||||
|
||||
return all_comments
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a GitLab merge request is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The merge request number (iid)
|
||||
|
||||
Returns:
|
||||
True if MR is active (opened), False if closed/merged
|
||||
"""
|
||||
try:
|
||||
mr_details = await self.get_pr_details(repository, pr_number)
|
||||
|
||||
# GitLab API response structure
|
||||
# https://docs.gitlab.com/ee/api/merge_requests.html#get-single-mr
|
||||
if 'state' in mr_details:
|
||||
return mr_details['state'] == 'opened'
|
||||
elif 'merged_at' in mr_details and 'closed_at' in mr_details:
|
||||
# Check if MR is merged or closed
|
||||
return not (mr_details['merged_at'] or mr_details['closed_at'])
|
||||
|
||||
# If we can't determine the state, assume it's active (safer default)
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}. '
|
||||
f'Response keys: {list(mr_details.keys())}. Assuming MR is active.'
|
||||
)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine GitLab MR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the MR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
|
||||
gitlab_service_cls = os.environ.get(
|
||||
'OPENHANDS_GITLAB_SERVICE_CLS',
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from types import MappingProxyType
|
||||
from typing import Annotated, Any, Coroutine, Literal, cast, overload
|
||||
|
||||
import httpx
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
@@ -30,7 +28,6 @@ from openhands.integrations.service_types import (
|
||||
Repository,
|
||||
ResourceNotFoundError,
|
||||
SuggestedTask,
|
||||
TokenResponse,
|
||||
User,
|
||||
)
|
||||
from openhands.microagent.types import MicroagentContentResponse, MicroagentResponse
|
||||
@@ -115,8 +112,6 @@ class ProviderHandler:
|
||||
external_auth_id: str | None = None,
|
||||
external_auth_token: SecretStr | None = None,
|
||||
external_token_manager: bool = False,
|
||||
session_api_key: str | None = None,
|
||||
sid: str | None = None,
|
||||
):
|
||||
if not isinstance(provider_tokens, MappingProxyType):
|
||||
raise TypeError(
|
||||
@@ -132,13 +127,7 @@ class ProviderHandler:
|
||||
self.external_auth_id = external_auth_id
|
||||
self.external_auth_token = external_auth_token
|
||||
self.external_token_manager = external_token_manager
|
||||
self.session_api_key = session_api_key
|
||||
self.sid = sid
|
||||
self._provider_tokens = provider_tokens
|
||||
WEB_HOST = os.getenv('WEB_HOST', '').strip()
|
||||
self.REFRESH_TOKEN_URL = (
|
||||
f'https://{WEB_HOST}/api/refresh-tokens' if WEB_HOST else None
|
||||
)
|
||||
|
||||
@property
|
||||
def provider_tokens(self) -> PROVIDER_TOKEN_TYPE:
|
||||
@@ -172,24 +161,8 @@ class ProviderHandler:
|
||||
self, provider: ProviderType
|
||||
) -> SecretStr | None:
|
||||
"""Get latest token from service"""
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(
|
||||
self.REFRESH_TOKEN_URL,
|
||||
headers={
|
||||
'X-Session-API-Key': self.session_api_key,
|
||||
},
|
||||
params={'provider': provider.value, 'sid': self.sid},
|
||||
)
|
||||
|
||||
resp.raise_for_status()
|
||||
data = TokenResponse.model_validate_json(resp.text)
|
||||
return SecretStr(data.token)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f'Failed to fetch latest token for provider {provider}: {e}')
|
||||
|
||||
return None
|
||||
service = self._get_service(provider)
|
||||
return await service.get_latest_token()
|
||||
|
||||
async def get_github_installations(self) -> list[str]:
|
||||
service = cast(InstallationsService, self._get_service(ProviderType.GITHUB))
|
||||
@@ -383,7 +356,7 @@ class ProviderHandler:
|
||||
else SecretStr('')
|
||||
)
|
||||
|
||||
if get_latest and self.REFRESH_TOKEN_URL and self.sid:
|
||||
if get_latest:
|
||||
token = await self._get_latest_provider_token(provider)
|
||||
|
||||
if token:
|
||||
@@ -640,30 +613,3 @@ class ProviderHandler:
|
||||
remote_url = f'https://{domain}/{repo_name}.git'
|
||||
|
||||
return remote_url
|
||||
|
||||
async def is_pr_open(
|
||||
self, repository: str, pr_number: int, git_provider: ProviderType
|
||||
) -> bool:
|
||||
"""Check if a PR is still active (not closed/merged).
|
||||
|
||||
This method checks the PR status using the provider's service method.
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
git_provider: The Git provider type for this repository
|
||||
|
||||
Returns:
|
||||
True if PR is active (open), False if closed/merged, True if can't determine
|
||||
"""
|
||||
try:
|
||||
service = self._get_service(git_provider)
|
||||
return await service.is_pr_open(repository, pr_number)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Could not determine PR status for {repository}#{pr_number}: {e}. '
|
||||
f'Including conversation to be safe.'
|
||||
)
|
||||
# If we can't determine the PR status, include the conversation to be safe
|
||||
return True
|
||||
|
||||
@@ -14,10 +14,6 @@ from openhands.microagent.types import MicroagentContentResponse, MicroagentResp
|
||||
from openhands.server.types import AppMode
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
class ProviderType(Enum):
|
||||
GITHUB = 'github'
|
||||
GITLAB = 'gitlab'
|
||||
@@ -145,7 +141,7 @@ class Repository(BaseModel):
|
||||
|
||||
|
||||
class Comment(BaseModel):
|
||||
id: str
|
||||
id: int
|
||||
body: str
|
||||
author: str
|
||||
created_at: datetime
|
||||
@@ -524,27 +520,3 @@ class GitService(Protocol):
|
||||
MicroagentContentResponse with parsed content and triggers
|
||||
"""
|
||||
...
|
||||
|
||||
async def get_pr_details(self, repository: str, pr_number: int) -> dict:
|
||||
"""Get detailed information about a specific pull request/merge request
|
||||
|
||||
Args:
|
||||
repository: Repository name in format specific to the provider
|
||||
pr_number: The pull request/merge request number
|
||||
|
||||
Returns:
|
||||
Raw API response from the git provider
|
||||
"""
|
||||
...
|
||||
|
||||
async def is_pr_open(self, repository: str, pr_number: int) -> bool:
|
||||
"""Check if a PR is still active (not closed/merged).
|
||||
|
||||
Args:
|
||||
repository: Repository name in format 'owner/repo'
|
||||
pr_number: The PR number to check
|
||||
|
||||
Returns:
|
||||
True if PR is active (open), False if closed/merged
|
||||
"""
|
||||
...
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
You are requested to fix issue number #{{ issue_number }} in a repository.
|
||||
|
||||
A comment on the issue has been addressed to you.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
1. Address the comment. Use the $GITHUB_TOKEN and GitHub API to read issue title, body, and comments if you need more context
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
When you're done, make sure to
|
||||
|
||||
1. Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements.
|
||||
2. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
|
||||
3. Commit your changes with a clear commit message
|
||||
4. Push the branch to GitHub
|
||||
5. Use the `create_pr` tool to open a new PR
|
||||
6. The PR description should:
|
||||
- Follow the repository's PR template (check `.github/pull_request_template.md` if it exists)
|
||||
- Mention that it "fixes" or "closes" the issue number
|
||||
- Include all required sections from the template
|
||||
@@ -0,0 +1 @@
|
||||
{{ issue_comment }}
|
||||
@@ -1,41 +0,0 @@
|
||||
{% if issue_number %}
|
||||
You are requested to fix issue #{{ issue_number }}: "{{ issue_title }}" in a repository.
|
||||
A comment on the issue has been addressed to you.
|
||||
{% else %}
|
||||
Your task is to fix the issue: "{{ issue_title }}".
|
||||
{% endif %}
|
||||
|
||||
# Issue Body
|
||||
{{ issue_body }}
|
||||
|
||||
{% if previous_comments %}
|
||||
# Previous Comments
|
||||
For reference, here are the previous comments on the issue:
|
||||
|
||||
{% for comment in previous_comments %}
|
||||
- @{{ comment.author }} said:
|
||||
{{ comment.body }}
|
||||
{% if not loop.last %}\n\n{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
# Guidelines
|
||||
|
||||
1. Review the task carefully.
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
# Final Checklist
|
||||
Re-read the issue title, body, and comments and make sure that you have successfully implemented all requirements.
|
||||
|
||||
Use the $GITHUB_TOKEN and GitHub APIs to
|
||||
|
||||
1. Create a new branch using `openhands/` as a prefix (e.g `openhands/update-readme`)
|
||||
2. Commit your changes with a clear commit message
|
||||
3. Push the branch to GitHub
|
||||
4. Use the `create_pr` tool to open a new PR
|
||||
5. The PR description should:
|
||||
- Follow the repository's PR template (check `.github/pull_request_template.md` if it exists)
|
||||
- Mention that it "fixes" or "closes" the issue number
|
||||
- Include all required sections from the template
|
||||
@@ -0,0 +1,17 @@
|
||||
Your tasking is to fix an issue in your repository. Do the following
|
||||
|
||||
1. Read the issue body and comments using the $GITHUB_TOKEN and Github API
|
||||
2. For all changes to actual application code (e.g. in Python or Javascript), add an appropriate test to the testing directory to make sure that the issue has been fixed
|
||||
3. Run the tests, and if they pass you are done!
|
||||
4. You do NOT need to write new tests if there are only changes to documentation or configuration files.
|
||||
|
||||
When you're done, make sure to
|
||||
|
||||
1. Create a new branch with a descriptive name (e.g., `openhands/fix-issue-123`)
|
||||
2. Commit your changes with a clear commit message
|
||||
3. Push the branch to GitHub
|
||||
4. Use the `create_pr` tool to open a new PR
|
||||
5. The PR description should:
|
||||
- Follow the repository's PR template (check `.github/pull_request_template.md` if it exists)
|
||||
- Mention that it "fixes" or "closes" the issue number
|
||||
- Include all required sections from the template
|
||||
@@ -0,0 +1 @@
|
||||
Please fix issue number #{{ issue_number }} in your repository.
|
||||
@@ -1,5 +0,0 @@
|
||||
{% if issue_comment %}
|
||||
{{ issue_comment }}
|
||||
{% else %}
|
||||
Please fix issue number #{{ issue_number }}.
|
||||
{% endif %}
|
||||
@@ -1,23 +1,7 @@
|
||||
You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}: "{{ pr_title }}".
|
||||
A comment on the PR has been addressed to you.
|
||||
You are checked out to branch {{ branch_name }}, which has an open PR #{{ pr_number }}.
|
||||
A comment on the PR has been addressed to you. Do NOT respond to this comment via the GitHub API.
|
||||
|
||||
# PR Description
|
||||
{{ pr_body }}
|
||||
|
||||
{% if comments %}
|
||||
# Previous Comments
|
||||
You may find these other comments relevant:
|
||||
{% for comment in comments %}
|
||||
- @{{ comment.author }} said at {{ comment.created_at }}:
|
||||
{{ comment.body }}
|
||||
{% if not loop.last %}\n\n{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if file_location %}
|
||||
# Comment location
|
||||
The comment is in the file `{{ file_location }}` on line #{{ line_number }}
|
||||
{% endif %}.
|
||||
{% if file_location %} The comment is in the file `{{ file_location }}` on line #{{ line_number }}{% endif %}.
|
||||
|
||||
# Steps to Handle the Comment
|
||||
|
||||
|
||||
@@ -84,7 +84,6 @@ FUNCTION_CALLING_PATTERNS: list[str] = [
|
||||
'kimi-k2-instruct',
|
||||
'qwen3-coder*',
|
||||
'qwen3-coder-480b-a35b-instruct',
|
||||
'deepseek-chat',
|
||||
]
|
||||
|
||||
REASONING_EFFORT_PATTERNS: list[str] = [
|
||||
@@ -99,7 +98,8 @@ REASONING_EFFORT_PATTERNS: list[str] = [
|
||||
'o4-mini-2025-04-16',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-5*',
|
||||
'gpt-5',
|
||||
'gpt-5-2025-08-07',
|
||||
# DeepSeek reasoning family
|
||||
'deepseek-r1-0528*',
|
||||
]
|
||||
|
||||
@@ -302,12 +302,10 @@ class ConversationMemory:
|
||||
elif isinstance(action, MessageAction):
|
||||
role = 'user' if action.source == 'user' else 'assistant'
|
||||
content = [TextContent(text=action.content or '')]
|
||||
if action.image_urls:
|
||||
if vision_is_active and action.image_urls:
|
||||
if role == 'user':
|
||||
for idx, url in enumerate(action.image_urls):
|
||||
# Only add descriptive text if vision is active
|
||||
if vision_is_active:
|
||||
content.append(TextContent(text=f'Image {idx + 1}:'))
|
||||
content.append(TextContent(text=f'Image {idx + 1}:'))
|
||||
content.append(ImageContent(image_urls=[url]))
|
||||
else:
|
||||
content.append(ImageContent(image_urls=action.image_urls))
|
||||
@@ -416,8 +414,8 @@ class ConversationMemory:
|
||||
# Create message content with text
|
||||
content: list[TextContent | ImageContent] = [TextContent(text=text)]
|
||||
|
||||
# Add image URLs if available
|
||||
if obs.image_urls:
|
||||
# Add image URLs if available and vision is active
|
||||
if vision_is_active and obs.image_urls:
|
||||
# Filter out empty or invalid image URLs
|
||||
valid_image_urls = [
|
||||
url for url in obs.image_urls if self._is_valid_image_url(url)
|
||||
@@ -426,8 +424,7 @@ class ConversationMemory:
|
||||
|
||||
if valid_image_urls:
|
||||
content.append(ImageContent(image_urls=valid_image_urls))
|
||||
# Only add explanatory text if vision is active
|
||||
if vision_is_active and invalid_count > 0:
|
||||
if invalid_count > 0:
|
||||
# Add text indicating some images were filtered
|
||||
content[
|
||||
0
|
||||
@@ -436,12 +433,10 @@ class ConversationMemory:
|
||||
logger.debug(
|
||||
'IPython observation has image URLs but none are valid'
|
||||
)
|
||||
# Only add explanatory text if vision is active
|
||||
if vision_is_active:
|
||||
# Add text indicating all images were filtered
|
||||
content[
|
||||
0
|
||||
].text += f'\n\nNote: All {len(obs.image_urls)} image(s) in this output were invalid or empty and have been filtered. The agent should use alternative methods to access visual information.' # type: ignore[union-attr]
|
||||
# Add text indicating all images were filtered
|
||||
content[
|
||||
0
|
||||
].text += f'\n\nNote: All {len(obs.image_urls)} image(s) in this output were invalid or empty and have been filtered. The agent should use alternative methods to access visual information.' # type: ignore[union-attr]
|
||||
|
||||
message = Message(role='user', content=content)
|
||||
elif isinstance(obs, FileEditObservation):
|
||||
@@ -453,21 +448,15 @@ class ConversationMemory:
|
||||
) # Content is already truncated by openhands-aci
|
||||
elif isinstance(obs, BrowserOutputObservation):
|
||||
text = obs.content
|
||||
content = [TextContent(text=text)]
|
||||
if (
|
||||
obs.trigger_by_action == ActionType.BROWSE_INTERACTIVE
|
||||
and enable_som_visual_browsing
|
||||
and vision_is_active
|
||||
):
|
||||
# Only add descriptive text if vision is active
|
||||
if vision_is_active:
|
||||
# We know content[0] is TextContent since we just created it above
|
||||
text_content = content[0]
|
||||
assert isinstance(text_content, TextContent)
|
||||
text_content.text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. However, the Accessibility tree contains information from the entire webpage.)\n'
|
||||
text += 'Image: Current webpage screenshot (Note that only visible portion of webpage is present in the screenshot. However, the Accessibility tree contains information from the entire webpage.)\n'
|
||||
|
||||
# Determine which image to use and validate it
|
||||
image_url = None
|
||||
image_type = None
|
||||
if obs.set_of_marks is not None and len(obs.set_of_marks) > 0:
|
||||
image_url = obs.set_of_marks
|
||||
image_type = 'set of marks'
|
||||
@@ -475,29 +464,38 @@ class ConversationMemory:
|
||||
image_url = obs.screenshot
|
||||
image_type = 'screenshot'
|
||||
|
||||
# Always add ImageContent if we have a valid image URL
|
||||
# Create message content with text
|
||||
content = [TextContent(text=text)]
|
||||
|
||||
# Only add ImageContent if we have a valid image URL
|
||||
if self._is_valid_image_url(image_url):
|
||||
content.append(ImageContent(image_urls=[image_url])) # type: ignore[list-item]
|
||||
logger.debug(f'Adding {image_type} for browsing')
|
||||
logger.debug(f'Vision enabled for browsing, showing {image_type}')
|
||||
else:
|
||||
if vision_is_active and image_url:
|
||||
if image_url:
|
||||
logger.warning(
|
||||
f'Invalid image URL format for {image_type}: {image_url[:50]}...'
|
||||
)
|
||||
# Add text indicating the image was filtered (only if vision is active)
|
||||
# Add text indicating the image was filtered
|
||||
content[
|
||||
0
|
||||
].text += f'\n\nNote: The {image_type} for this webpage was invalid or empty and has been filtered. The agent should use alternative methods to access visual information about the webpage.' # type: ignore[union-attr]
|
||||
elif vision_is_active and not image_url:
|
||||
else:
|
||||
logger.debug(
|
||||
'Vision enabled for browsing, but no valid image available'
|
||||
)
|
||||
# Add text indicating no image was available (only if vision is active)
|
||||
# Add text indicating no image was available
|
||||
content[
|
||||
0
|
||||
].text += '\n\nNote: No visual information (screenshot or set of marks) is available for this webpage. The agent should rely on the text content above.' # type: ignore[union-attr]
|
||||
|
||||
message = Message(role='user', content=content)
|
||||
message = Message(role='user', content=content)
|
||||
else:
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[TextContent(text=text)],
|
||||
)
|
||||
logger.debug('Vision disabled for browsing, showing text')
|
||||
elif isinstance(obs, AgentDelegateObservation):
|
||||
text = truncate_content(
|
||||
obs.outputs.get('content', obs.content),
|
||||
|
||||
@@ -573,8 +573,10 @@ class ActionExecutor:
|
||||
return FileEditObservation(
|
||||
content=result_str,
|
||||
path=action.path,
|
||||
old_content=action.old_str,
|
||||
new_content=action.new_str,
|
||||
# Use actual file contents returned by the editor to avoid
|
||||
# incorrect "no changes detected" messages in visualization.
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
diff=get_diff(
|
||||
old_contents=old_content or '',
|
||||
@@ -647,6 +649,7 @@ class ActionExecutor:
|
||||
|
||||
if __name__ == '__main__':
|
||||
logger.warning('Starting Action Execution Server')
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('port', type=int, help='Port to listen on')
|
||||
parser.add_argument('--working-dir', type=str, help='Working directory')
|
||||
|
||||
@@ -323,6 +323,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
async def _export_latest_git_provider_tokens(self, event: Action) -> None:
|
||||
"""Refresh runtime provider tokens when agent attemps to run action with provider token"""
|
||||
if not self.user_id:
|
||||
return
|
||||
|
||||
providers_called = ProviderHandler.check_cmd_action_for_provider_token_ref(
|
||||
event
|
||||
)
|
||||
@@ -330,17 +333,8 @@ class Runtime(FileEditRuntimeMixin):
|
||||
if not providers_called:
|
||||
return
|
||||
|
||||
provider_handler = ProviderHandler(
|
||||
provider_tokens=self.git_provider_tokens
|
||||
or cast(PROVIDER_TOKEN_TYPE, MappingProxyType({})),
|
||||
external_auth_id=self.user_id,
|
||||
external_token_manager=True,
|
||||
session_api_key=self.session_api_key,
|
||||
sid=self.sid,
|
||||
)
|
||||
|
||||
logger.info(f'Fetching latest provider tokens for runtime: {self.sid}')
|
||||
env_vars = await provider_handler.get_env_vars(
|
||||
env_vars = await self.provider_handler.get_env_vars(
|
||||
providers=providers_called, expose_secrets=False, get_latest=True
|
||||
)
|
||||
|
||||
@@ -349,10 +343,10 @@ class Runtime(FileEditRuntimeMixin):
|
||||
|
||||
try:
|
||||
if self.event_stream:
|
||||
await provider_handler.set_event_stream_secrets(
|
||||
await self.provider_handler.set_event_stream_secrets(
|
||||
self.event_stream, env_vars=env_vars
|
||||
)
|
||||
self.add_env_vars(provider_handler.expose_env_vars(env_vars))
|
||||
self.add_env_vars(self.provider_handler.expose_env_vars(env_vars))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed export latest github token to runtime: {self.sid}, {e}'
|
||||
@@ -1148,27 +1142,6 @@ fi
|
||||
self.git_handler.set_cwd(cwd)
|
||||
return self.git_handler.get_git_diff(file_path)
|
||||
|
||||
def get_workspace_branch(self, primary_repo_path: str | None = None) -> str | None:
|
||||
"""
|
||||
Get the current branch of the workspace.
|
||||
|
||||
Args:
|
||||
primary_repo_path: Path to the primary repository within the workspace.
|
||||
If None, uses the workspace root.
|
||||
|
||||
Returns:
|
||||
str | None: The current branch name, or None if not a git repository or error occurs.
|
||||
"""
|
||||
if primary_repo_path:
|
||||
# Use the primary repository path
|
||||
git_cwd = str(self.workspace_root / primary_repo_path)
|
||||
else:
|
||||
# Use the workspace root
|
||||
git_cwd = str(self.workspace_root)
|
||||
|
||||
self.git_handler.set_cwd(git_cwd)
|
||||
return self.git_handler.get_current_branch()
|
||||
|
||||
@property
|
||||
def additional_agent_instructions(self) -> str:
|
||||
return ''
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import atexit
|
||||
import json
|
||||
import multiprocessing
|
||||
import time
|
||||
import uuid
|
||||
@@ -20,18 +21,14 @@ BROWSER_EVAL_GET_REWARDS_ACTION = 'GET_EVAL_REWARDS'
|
||||
|
||||
|
||||
class BrowserEnv:
|
||||
def __init__(
|
||||
self,
|
||||
browsergym_eval_env: str | None = None,
|
||||
browser_logging_dir: str | None = None,
|
||||
):
|
||||
def __init__(self, browsergym_eval_env: str | None = None):
|
||||
self.html_text_converter = self.get_html_text_converter()
|
||||
self.eval_mode = False
|
||||
self.eval_dir = ''
|
||||
|
||||
# Browser state logging configuration (for WebArena evaluation)
|
||||
self.browser_logging_dir = browser_logging_dir
|
||||
self.enable_state_logging = browser_logging_dir is not None
|
||||
# EVAL only: browsergym_eval_env must be provided for evaluation
|
||||
self.browsergym_eval_env = browsergym_eval_env
|
||||
self.eval_mode = bool(browsergym_eval_env)
|
||||
|
||||
# Initialize browser environment process
|
||||
multiprocessing.set_start_method('spawn', force=True)
|
||||
@@ -70,43 +67,59 @@ class BrowserEnv:
|
||||
raise BrowserInitException('Failed to start browser environment.')
|
||||
|
||||
def browser_process(self) -> None:
|
||||
env = gym.make(
|
||||
'browsergym/openended',
|
||||
task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'},
|
||||
wait_for_user_message=False,
|
||||
headless=True,
|
||||
disable_env_checker=True,
|
||||
tags_to_mark='all',
|
||||
timeout=100000,
|
||||
pw_context_kwargs={'accept_downloads': True},
|
||||
pw_chromium_kwargs={'downloads_path': '/workspace/.downloads/'},
|
||||
pre_observation_delay=2.0, # Increase delay to allow accessibility trees to load
|
||||
)
|
||||
if self.eval_mode:
|
||||
assert self.browsergym_eval_env is not None
|
||||
logger.info('Initializing browser env for web browsing evaluation.')
|
||||
if not self.browsergym_eval_env.startswith('browsergym/'):
|
||||
self.browsergym_eval_env = 'browsergym/' + self.browsergym_eval_env
|
||||
if 'visualwebarena' in self.browsergym_eval_env:
|
||||
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
|
||||
import nltk
|
||||
|
||||
nltk.download('punkt_tab')
|
||||
elif 'webarena' in self.browsergym_eval_env:
|
||||
import browsergym.webarena # noqa F401 register webarena tasks as gym environments
|
||||
elif 'miniwob' in self.browsergym_eval_env:
|
||||
import browsergym.miniwob # noqa F401 register miniwob tasks as gym environments
|
||||
else:
|
||||
raise ValueError(
|
||||
f'Unsupported browsergym eval env: {self.browsergym_eval_env}'
|
||||
)
|
||||
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
|
||||
else:
|
||||
env = gym.make(
|
||||
'browsergym/openended',
|
||||
task_kwargs={'start_url': 'about:blank', 'goal': 'PLACEHOLDER_GOAL'},
|
||||
wait_for_user_message=False,
|
||||
headless=True,
|
||||
disable_env_checker=True,
|
||||
tags_to_mark='all',
|
||||
timeout=100000,
|
||||
pw_context_kwargs={'accept_downloads': True},
|
||||
pw_chromium_kwargs={'downloads_path': '/workspace/.downloads/'},
|
||||
)
|
||||
obs, info = env.reset()
|
||||
|
||||
logger.info('Successfully called env.reset')
|
||||
# EVAL ONLY: save the goal into file for evaluation
|
||||
self.eval_goal = None
|
||||
self.goal_image_urls = []
|
||||
self.eval_rewards: list[float] = []
|
||||
if self.eval_mode:
|
||||
self.eval_goal = obs['goal']
|
||||
if 'goal_object' in obs:
|
||||
obs['goal_object'] = list(obs['goal_object'])
|
||||
if len(obs['goal_object']) > 0:
|
||||
self.eval_goal = obs['goal_object'][0]['text']
|
||||
for message in obs['goal_object']:
|
||||
if message['type'] == 'image_url':
|
||||
image_src = message['image_url']
|
||||
if isinstance(image_src, dict):
|
||||
image_src = image_src['url']
|
||||
self.goal_image_urls.append(image_src)
|
||||
logger.debug(f'Browsing goal: {self.eval_goal}')
|
||||
logger.info('Browser env started.')
|
||||
|
||||
# Initialize browser state capture for WebArena evaluation
|
||||
state_capture = None
|
||||
if self.enable_state_logging:
|
||||
try:
|
||||
from evaluation.benchmarks.webarena.browsergym_state_capture import (
|
||||
BrowserGymStateCapture,
|
||||
)
|
||||
|
||||
state_capture = BrowserGymStateCapture(
|
||||
output_dir=self.browser_logging_dir or '/tmp/webarena_states'
|
||||
)
|
||||
logger.info(
|
||||
f'Browser state logging enabled: {self.browser_logging_dir}'
|
||||
)
|
||||
except ImportError:
|
||||
logger.warning(
|
||||
'Could not import BrowserGymStateCapture, state logging disabled'
|
||||
)
|
||||
state_capture = None
|
||||
|
||||
while should_continue():
|
||||
try:
|
||||
if self.browser_side.poll(timeout=0.01):
|
||||
@@ -120,60 +133,34 @@ class BrowserEnv:
|
||||
elif unique_request_id == 'IS_ALIVE':
|
||||
self.browser_side.send(('ALIVE', None))
|
||||
continue
|
||||
elif unique_request_id == 'SET_WEBARENA_INSTANCE':
|
||||
# Set WebArena instance ID for state capture
|
||||
if state_capture and 'instance_id' in action_data:
|
||||
state_capture.set_instance_id(action_data['instance_id'])
|
||||
logger.info(
|
||||
f'Set WebArena instance ID: {action_data["instance_id"]}'
|
||||
|
||||
# EVAL ONLY: Get evaluation info
|
||||
if action_data['action'] == BROWSER_EVAL_GET_GOAL_ACTION:
|
||||
self.browser_side.send(
|
||||
(
|
||||
unique_request_id,
|
||||
{
|
||||
'text_content': self.eval_goal,
|
||||
'image_content': self.goal_image_urls,
|
||||
},
|
||||
)
|
||||
self.browser_side.send((unique_request_id, {'status': 'ok'}))
|
||||
)
|
||||
continue
|
||||
elif unique_request_id == 'CAPTURE_WEBARENA_STATE':
|
||||
# Capture final browser state for WebArena evaluation
|
||||
if state_capture:
|
||||
try:
|
||||
state_file = state_capture.save_state(env)
|
||||
self.browser_side.send(
|
||||
(
|
||||
unique_request_id,
|
||||
{'status': 'ok', 'state_file': state_file},
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to capture WebArena state: {e}')
|
||||
self.browser_side.send(
|
||||
(
|
||||
unique_request_id,
|
||||
{'status': 'error', 'error': str(e)},
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.browser_side.send(
|
||||
(unique_request_id, {'status': 'disabled'})
|
||||
elif action_data['action'] == BROWSER_EVAL_GET_REWARDS_ACTION:
|
||||
self.browser_side.send(
|
||||
(
|
||||
unique_request_id,
|
||||
{'text_content': json.dumps(self.eval_rewards)},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
action = action_data['action']
|
||||
obs, reward, terminated, truncated, info = env.step(action)
|
||||
|
||||
# DEBUG: Log what's in the BrowserGym observation
|
||||
logger.info(f'DEBUG: BrowserGym obs keys: {list(obs.keys())}')
|
||||
if 'axtree_object' in obs:
|
||||
axtree_obj = obs['axtree_object']
|
||||
logger.info(f'DEBUG: axtree_object type: {type(axtree_obj)}')
|
||||
if isinstance(axtree_obj, dict):
|
||||
logger.info(
|
||||
f'DEBUG: axtree_object keys: {list(axtree_obj.keys())}'
|
||||
)
|
||||
if 'nodes' in axtree_obj:
|
||||
logger.info(
|
||||
f'DEBUG: axtree_object nodes length: {len(axtree_obj["nodes"]) if axtree_obj["nodes"] else 0}'
|
||||
)
|
||||
else:
|
||||
logger.info(f'DEBUG: axtree_object value: {axtree_obj}')
|
||||
else:
|
||||
logger.info('DEBUG: No axtree_object in BrowserGym observation')
|
||||
# EVAL ONLY: Save the rewards into file for evaluation
|
||||
if self.eval_mode:
|
||||
self.eval_rewards.append(reward)
|
||||
|
||||
# add text content of the page
|
||||
html_str = flatten_dom_to_str(obs['dom_object'])
|
||||
@@ -221,48 +208,6 @@ class BrowserEnv:
|
||||
logger.debug(f'Browser env is not alive. Response ID: {response_id}')
|
||||
return False
|
||||
|
||||
def set_webarena_instance_id(self, instance_id: str, timeout: float = 10) -> bool:
|
||||
"""Set the WebArena instance ID for browser state capture."""
|
||||
if not self.enable_state_logging:
|
||||
logger.warning('Browser state logging is not enabled')
|
||||
return False
|
||||
|
||||
unique_request_id = 'SET_WEBARENA_INSTANCE'
|
||||
self.agent_side.send((unique_request_id, {'instance_id': instance_id}))
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if should_exit() or time.time() - start_time > timeout:
|
||||
logger.error('Timeout setting WebArena instance ID')
|
||||
return False
|
||||
if self.agent_side.poll(timeout=0.01):
|
||||
response_id, response = self.agent_side.recv()
|
||||
if response_id == unique_request_id:
|
||||
return response.get('status') == 'ok'
|
||||
|
||||
def capture_webarena_state(self, timeout: float = 30) -> str | None:
|
||||
"""Capture the current browser state for WebArena evaluation."""
|
||||
if not self.enable_state_logging:
|
||||
logger.warning('Browser state logging is not enabled')
|
||||
return None
|
||||
|
||||
unique_request_id = 'CAPTURE_WEBARENA_STATE'
|
||||
self.agent_side.send((unique_request_id, {}))
|
||||
start_time = time.time()
|
||||
while True:
|
||||
if should_exit() or time.time() - start_time > timeout:
|
||||
logger.error('Timeout capturing WebArena state')
|
||||
return None
|
||||
if self.agent_side.poll(timeout=0.01):
|
||||
response_id, response = self.agent_side.recv()
|
||||
if response_id == unique_request_id:
|
||||
if response.get('status') == 'ok':
|
||||
return response.get('state_file')
|
||||
else:
|
||||
logger.error(
|
||||
f'Failed to capture state: {response.get("error", "unknown error")}'
|
||||
)
|
||||
return None
|
||||
|
||||
def close(self) -> None:
|
||||
if not self.process.is_alive():
|
||||
return
|
||||
|
||||
@@ -21,22 +21,14 @@ def get_axtree_str(
|
||||
extra_element_properties: dict[str, Any],
|
||||
filter_visible_only: bool = False,
|
||||
) -> str:
|
||||
# Check if axtree_object exists and has the expected structure
|
||||
if not axtree_object or not isinstance(axtree_object, dict):
|
||||
return '[No accessibility tree available]'
|
||||
elif 'nodes' not in axtree_object or not axtree_object['nodes']:
|
||||
# axtree_object exists but is empty or missing nodes - this is the common case
|
||||
return '[Accessibility tree not yet loaded]'
|
||||
else:
|
||||
# axtree_object has the expected structure with nodes
|
||||
cur_axtree_txt = flatten_axtree_to_str(
|
||||
axtree_object,
|
||||
extra_properties=extra_element_properties,
|
||||
with_clickable=True,
|
||||
skip_generic=False,
|
||||
filter_visible_only=filter_visible_only,
|
||||
)
|
||||
return str(cur_axtree_txt)
|
||||
cur_axtree_txt = flatten_axtree_to_str(
|
||||
axtree_object,
|
||||
extra_properties=extra_element_properties,
|
||||
with_clickable=True,
|
||||
skip_generic=False,
|
||||
filter_visible_only=filter_visible_only,
|
||||
)
|
||||
return str(cur_axtree_txt)
|
||||
|
||||
|
||||
def get_agent_obs_text(obs: BrowserOutputObservation) -> str:
|
||||
|
||||
@@ -674,8 +674,10 @@ class CLIRuntime(Runtime):
|
||||
return FileEditObservation(
|
||||
content=result_str,
|
||||
path=action.path,
|
||||
old_content=action.old_str,
|
||||
new_content=action.new_str,
|
||||
# Use actual file contents returned by the editor to avoid
|
||||
# incorrect "no changes detected" messages in visualization.
|
||||
old_content=old_content,
|
||||
new_content=new_content,
|
||||
impl_source=FileEditSource.OH_ACI,
|
||||
diff=get_diff(
|
||||
old_contents=old_content or '',
|
||||
|
||||
@@ -76,7 +76,6 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
user_id,
|
||||
git_provider_tokens,
|
||||
)
|
||||
logger.debug(f'RemoteRuntime.init user_id {user_id}')
|
||||
if self.config.sandbox.api_key is None:
|
||||
raise ValueError(
|
||||
'API key is required to use the remote runtime. '
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from openhands.core.config import OpenHandsConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.plugins import PluginRequirement
|
||||
|
||||
DEFAULT_PYTHON_PREFIX = [
|
||||
@@ -24,9 +23,6 @@ def get_action_execution_server_startup_command(
|
||||
python_executable: str = 'python',
|
||||
) -> list[str]:
|
||||
sandbox_config = app_config.sandbox
|
||||
logger.debug(f'app_config {vars(app_config)}')
|
||||
logger.debug(f'sandbox_config {vars(sandbox_config)}')
|
||||
logger.debug(f'override_user_id {override_user_id}')
|
||||
|
||||
# Plugin args
|
||||
plugin_args = []
|
||||
@@ -43,7 +39,9 @@ def get_action_execution_server_startup_command(
|
||||
username = override_username or (
|
||||
'openhands' if app_config.run_as_openhands else 'root'
|
||||
)
|
||||
user_id = override_user_id or (1000 if app_config.run_as_openhands else 0)
|
||||
user_id = override_user_id or (
|
||||
sandbox_config.user_id if app_config.run_as_openhands else 0
|
||||
)
|
||||
|
||||
base_cmd = [
|
||||
*python_prefix,
|
||||
@@ -64,6 +62,5 @@ def get_action_execution_server_startup_command(
|
||||
|
||||
if not app_config.enable_browser:
|
||||
base_cmd.append('--no-enable-browser')
|
||||
logger.debug(f'get_action_execution_server_startup_command: {base_cmd}')
|
||||
|
||||
return base_cmd
|
||||
|
||||
@@ -10,7 +10,6 @@ GIT_CHANGES_CMD = 'python3 /openhands/code/openhands/runtime/utils/git_changes.p
|
||||
GIT_DIFF_CMD = (
|
||||
'python3 /openhands/code/openhands/runtime/utils/git_diff.py "{file_path}"'
|
||||
)
|
||||
GIT_BRANCH_CMD = 'git branch --show-current'
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -39,7 +38,6 @@ class GitHandler:
|
||||
self.cwd: str | None = None
|
||||
self.git_changes_cmd = GIT_CHANGES_CMD
|
||||
self.git_diff_cmd = GIT_DIFF_CMD
|
||||
self.git_branch_cmd = GIT_BRANCH_CMD
|
||||
|
||||
def set_cwd(self, cwd: str) -> None:
|
||||
"""Sets the current working directory for Git operations.
|
||||
@@ -57,28 +55,6 @@ class GitHandler:
|
||||
result = self.execute(f'chmod +x "{script_file}"', self.cwd)
|
||||
return script_file
|
||||
|
||||
def get_current_branch(self) -> str | None:
|
||||
"""
|
||||
Retrieves the current branch name of the git repository.
|
||||
|
||||
Returns:
|
||||
str | None: The current branch name, or None if not a git repository or error occurs.
|
||||
"""
|
||||
# If cwd is not set, return None
|
||||
if not self.cwd:
|
||||
return None
|
||||
|
||||
result = self.execute(self.git_branch_cmd, self.cwd)
|
||||
if result.exit_code == 0:
|
||||
branch = result.content.strip()
|
||||
# git branch --show-current returns empty string if not on any branch (detached HEAD)
|
||||
if branch:
|
||||
return branch
|
||||
return None
|
||||
|
||||
# If not a git repository or other error, return None
|
||||
return None
|
||||
|
||||
def get_git_changes(self) -> list[dict[str, str]] | None:
|
||||
"""Retrieves the list of changed files in Git repositories.
|
||||
Examines each direct subdirectory of the workspace directory looking for git repositories
|
||||
|
||||
@@ -49,61 +49,6 @@ def init_user_and_working_directory(
|
||||
if username == os.getenv('USER') and username not in ['root', 'openhands']:
|
||||
return None
|
||||
|
||||
# Skip root since it is already created
|
||||
if username != 'root':
|
||||
# Check if the username already exists
|
||||
logger.debug(f'Attempting to create user `{username}` with UID {user_id}.')
|
||||
existing_user_id = -1
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f'id -u {username}', shell=True, check=True, capture_output=True
|
||||
)
|
||||
existing_user_id = int(result.stdout.decode().strip())
|
||||
|
||||
# The user ID already exists, skip setup
|
||||
if existing_user_id == user_id:
|
||||
logger.debug(
|
||||
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
|
||||
)
|
||||
return existing_user_id
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Returncode 1 indicates, that the user does not exist yet
|
||||
if e.returncode == 1:
|
||||
logger.debug(
|
||||
f'User `{username}` does not exist. Proceeding with user creation.'
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
f'Error checking user `{username}`, skipping setup:\n{e}\n'
|
||||
)
|
||||
raise
|
||||
|
||||
# Add sudoer
|
||||
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
|
||||
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
|
||||
if output.returncode != 0:
|
||||
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
|
||||
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
|
||||
|
||||
command = (
|
||||
f'useradd -rm -d /home/{username} -s /bin/bash '
|
||||
f'-g root -G sudo -u {user_id} {username}'
|
||||
)
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
if output.returncode == 0:
|
||||
logger.debug(
|
||||
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
|
||||
)
|
||||
|
||||
# First create the working directory, independent of the user
|
||||
logger.debug(f'Client working directory: {initial_cwd}')
|
||||
command = f'umask 002; mkdir -p {initial_cwd}'
|
||||
@@ -119,4 +64,57 @@ def init_user_and_working_directory(
|
||||
out_str += output.stdout.decode()
|
||||
logger.debug(f'Created working directory. Output: [{out_str}]')
|
||||
|
||||
# Skip root since it is already created
|
||||
if username == 'root':
|
||||
return None
|
||||
|
||||
# Check if the username already exists
|
||||
existing_user_id = -1
|
||||
try:
|
||||
result = subprocess.run(
|
||||
f'id -u {username}', shell=True, check=True, capture_output=True
|
||||
)
|
||||
existing_user_id = int(result.stdout.decode().strip())
|
||||
|
||||
# The user ID already exists, skip setup
|
||||
if existing_user_id == user_id:
|
||||
logger.debug(
|
||||
f'User `{username}` already has the provided UID {user_id}. Skipping user setup.'
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f'User `{username}` already exists with UID {existing_user_id}. Skipping user setup.'
|
||||
)
|
||||
return existing_user_id
|
||||
return None
|
||||
except subprocess.CalledProcessError as e:
|
||||
# Returncode 1 indicates, that the user does not exist yet
|
||||
if e.returncode == 1:
|
||||
logger.debug(
|
||||
f'User `{username}` does not exist. Proceeding with user creation.'
|
||||
)
|
||||
else:
|
||||
logger.error(f'Error checking user `{username}`, skipping setup:\n{e}\n')
|
||||
raise
|
||||
|
||||
# Add sudoer
|
||||
sudoer_line = r"echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers"
|
||||
output = subprocess.run(sudoer_line, shell=True, capture_output=True)
|
||||
if output.returncode != 0:
|
||||
raise RuntimeError(f'Failed to add sudoer: {output.stderr.decode()}')
|
||||
logger.debug(f'Added sudoer successfully. Output: [{output.stdout.decode()}]')
|
||||
|
||||
command = (
|
||||
f'useradd -rm -d /home/{username} -s /bin/bash '
|
||||
f'-g root -G sudo -u {user_id} {username}'
|
||||
)
|
||||
output = subprocess.run(command, shell=True, capture_output=True)
|
||||
if output.returncode == 0:
|
||||
logger.debug(
|
||||
f'Added user `{username}` successfully with UID {user_id}. Output: [{output.stdout.decode()}]'
|
||||
)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
f'Failed to create user `{username}` with UID {user_id}. Output: [{output.stderr.decode()}]'
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -14,16 +14,12 @@ ENV POETRY_VIRTUALENVS_PATH=/openhands/poetry \
|
||||
|
||||
{% macro setup_base_system() %}
|
||||
|
||||
# Set PATH early to ensure system commands are available
|
||||
ENV PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
|
||||
|
||||
# Install base system dependencies
|
||||
|
||||
{% if (('ubuntu' in base_image) or ('mswebench' in base_image)) %}
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
|
||||
coreutils util-linux procps findutils grep sed \
|
||||
{%- if (base_image.endswith(':latest') or base_image.endswith(':24.04') or ('mswebench' in base_image)) -%}
|
||||
libgl1 \
|
||||
{%- else %}
|
||||
@@ -45,7 +41,6 @@ RUN apt-get update && \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
wget curl ca-certificates sudo apt-utils git jq tmux build-essential ripgrep ffmpeg \
|
||||
coreutils util-linux procps findutils grep sed \
|
||||
libgl1-mesa-glx \
|
||||
libasound2-plugins libatomic1 \
|
||||
# Install Docker dependencies
|
||||
@@ -63,30 +58,15 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR="/openhands/
|
||||
# Add /openhands/bin to PATH
|
||||
ENV PATH="/openhands/bin:${PATH}"
|
||||
|
||||
# Remove UID 1000 and GID 1000 users/groups that might conflict with openhands user
|
||||
# Remove UID 1000 named pn or ubuntu, so the 'openhands' user can be created from ubuntu hosts
|
||||
RUN (if getent passwd 1000 | grep -q pn; then userdel pn; fi) && \
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi) && \
|
||||
(if getent group 1000 | grep -q pn; then groupdel pn; fi) && \
|
||||
(if getent group 1000 | grep -q ubuntu; then groupdel ubuntu; fi)
|
||||
(if getent passwd 1000 | grep -q ubuntu; then userdel ubuntu; fi)
|
||||
|
||||
# Create openhands group and user
|
||||
RUN groupadd -g 1000 openhands && \
|
||||
useradd -u 1000 -g 1000 -m -s /bin/bash openhands && \
|
||||
usermod -aG sudo openhands && \
|
||||
echo 'openhands ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers && \
|
||||
# Set empty password for openhands user to allow passwordless su
|
||||
passwd -d openhands && \
|
||||
# Set empty password for root user as well to ensure su works in both directions
|
||||
passwd -d root && \
|
||||
# Ensure root can su to openhands without password by configuring PAM
|
||||
sed -i '/pam_rootok.so/d' /etc/pam.d/su && \
|
||||
sed -i '1i auth sufficient pam_rootok.so' /etc/pam.d/su
|
||||
|
||||
# Create necessary directories
|
||||
RUN mkdir -p /openhands && \
|
||||
mkdir -p /openhands/logs && \
|
||||
mkdir -p /openhands/poetry && \
|
||||
chown -R openhands:openhands /openhands
|
||||
mkdir -p /openhands/poetry
|
||||
|
||||
|
||||
# ================================================================
|
||||
@@ -167,16 +147,14 @@ RUN if [ -z "${RELEASE_TAG}" ]; then \
|
||||
if [ -d "${OPENVSCODE_SERVER_ROOT}" ]; then rm -rf "${OPENVSCODE_SERVER_ROOT}"; fi && \
|
||||
mv ${RELEASE_TAG}-linux-${arch} ${OPENVSCODE_SERVER_ROOT} && \
|
||||
cp ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/openvscode-server ${OPENVSCODE_SERVER_ROOT}/bin/remote-cli/code && \
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz && \
|
||||
chown -R openhands:openhands ${OPENVSCODE_SERVER_ROOT}
|
||||
rm -f ${RELEASE_TAG}-linux-${arch}.tar.gz
|
||||
|
||||
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_vscode_extensions() %}
|
||||
# Install our custom extensions as openhands user
|
||||
USER openhands
|
||||
# Install our custom extension
|
||||
RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world && \
|
||||
cp -r /openhands/code/openhands/runtime/utils/vscode-extensions/hello-world/* ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-hello-world/
|
||||
|
||||
@@ -187,72 +165,27 @@ RUN mkdir -p ${OPENVSCODE_SERVER_ROOT}/extensions/openhands-memory-monitor && \
|
||||
RUN rm -rf ${OPENVSCODE_SERVER_ROOT}/extensions/{handlebars,pug,json,diff,grunt,ini,npm}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_dependencies_root() %}
|
||||
# Install system-level dependencies that require root
|
||||
USER root
|
||||
RUN \
|
||||
{% if enable_browser %}
|
||||
# Install system dependencies for Playwright (requires root)
|
||||
apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
libnss3 libnspr4 libatk-bridge2.0-0 libdrm2 libxkbcommon0 libxcomposite1 \
|
||||
libxdamage1 libxrandr2 libgbm1 libxss1 && \
|
||||
# Install libasound2 - try new package name first (Ubuntu 24.04+), fallback to old name
|
||||
(apt-get install -y --no-install-recommends libasound2t64 || apt-get install -y --no-install-recommends libasound2) && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* && \
|
||||
# Install Playwright browsers in shared location accessible to all users
|
||||
export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers && \
|
||||
mkdir -p /opt/playwright-browsers && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \
|
||||
# Set proper permissions for shared access
|
||||
chmod -R 755 /opt/playwright-browsers && \
|
||||
# Create cache directories and symlinks for both users
|
||||
mkdir -p /home/openhands/.cache && \
|
||||
mkdir -p /root/.cache && \
|
||||
ln -sf /opt/playwright-browsers /home/openhands/.cache/ms-playwright && \
|
||||
ln -sf /opt/playwright-browsers /root/.cache/ms-playwright && \
|
||||
chown -h openhands:openhands /home/openhands/.cache/ms-playwright && \
|
||||
# Set environment variable for all users
|
||||
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /etc/environment && \
|
||||
{% endif %}
|
||||
# Set environment variables (requires root)
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
# Set permissions for shared read-only access
|
||||
chmod -R 755 /openhands/poetry && \
|
||||
chmod -R 755 /openhands/micromamba && \
|
||||
chown -R openhands:openhands /openhands/poetry && \
|
||||
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace && \
|
||||
chown -R openhands:openhands /openhands/workspace && \
|
||||
chown -R openhands:openhands /openhands/micromamba && \
|
||||
# Ensure PATH includes system binaries early in startup
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"' >> /etc/environment && \
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:$PATH"' >> /etc/bash.bashrc && \
|
||||
# Set up conda environment activation for all users
|
||||
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /etc/bash.bashrc && \
|
||||
echo 'micromamba activate openhands 2>/dev/null || true' >> /etc/bash.bashrc && \
|
||||
# Set up environment for root user
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/openhands/micromamba/bin:$PATH"' >> /root/.bashrc && \
|
||||
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /root/.bashrc && \
|
||||
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /root/.bashrc && \
|
||||
echo 'micromamba activate openhands 2>/dev/null || true' >> /root/.bashrc && \
|
||||
# Clean up system packages (requires root)
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
|
||||
|
||||
{% endmacro %}
|
||||
|
||||
{% macro install_dependencies_user() %}
|
||||
# Install user-level dependencies as openhands user
|
||||
{% macro install_dependencies() %}
|
||||
# Install all dependencies
|
||||
WORKDIR /openhands/code
|
||||
|
||||
USER openhands
|
||||
RUN \
|
||||
/openhands/micromamba/bin/micromamba config set changeps1 False && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry config virtualenvs.path /openhands/poetry && \
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry env use python3.12 && \
|
||||
# Install project dependencies
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry install --only main,runtime --no-interaction --no-root && \
|
||||
# Clean up user caches
|
||||
# Update and install additional tools
|
||||
# (There used to be an "apt-get update" here, hopefully we can skip it.)
|
||||
{% if enable_browser %}/openhands/micromamba/bin/micromamba run -n openhands poetry run playwright install --with-deps chromium && \{% endif %}
|
||||
# Set environment variables
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry run python -c "import sys; print('OH_INTERPRETER_PATH=' + sys.executable)" >> /etc/environment && \
|
||||
# Set permissions
|
||||
chmod -R g+rws /openhands/poetry && \
|
||||
mkdir -p /openhands/workspace && chmod -R g+rws,o+rw /openhands/workspace && \
|
||||
# Clean up
|
||||
/openhands/micromamba/bin/micromamba run -n openhands poetry cache clear --all . -n && \
|
||||
apt-get clean && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* && \
|
||||
/openhands/micromamba/bin/micromamba clean --all
|
||||
|
||||
{% endmacro %}
|
||||
@@ -270,16 +203,7 @@ RUN \
|
||||
RUN mkdir -p /openhands/micromamba/bin && \
|
||||
/bin/bash -c "PREFIX_LOCATION=/openhands/micromamba BIN_FOLDER=/openhands/micromamba/bin INIT_YES=no CONDA_FORGE_YES=yes $(curl -L https://micro.mamba.pm/install.sh)" && \
|
||||
/openhands/micromamba/bin/micromamba config remove channels defaults && \
|
||||
/openhands/micromamba/bin/micromamba config list && \
|
||||
chown -R openhands:openhands /openhands/micromamba && \
|
||||
# Create read-only shared access to micromamba for all users
|
||||
# This allows both root and openhands users to access the same packages
|
||||
# while maintaining security by keeping openhands as the owner
|
||||
chmod -R 755 /openhands/micromamba && \
|
||||
# Create a separate writable location for root's micromamba cache/config
|
||||
mkdir -p /root/.local/share/micromamba && \
|
||||
# Set up environment variables for system-wide access
|
||||
echo 'export PATH="/openhands/micromamba/bin:$PATH"' >> /etc/environment
|
||||
/openhands/micromamba/bin/micromamba config list
|
||||
|
||||
# Create the openhands virtual environment and install poetry and python
|
||||
RUN /openhands/micromamba/bin/micromamba create -n openhands -y && \
|
||||
@@ -290,75 +214,40 @@ RUN \
|
||||
if [ -d /openhands/code ]; then rm -rf /openhands/code; fi && \
|
||||
mkdir -p /openhands/code/openhands && \
|
||||
touch /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code && \
|
||||
# Set global git configuration to ensure proper author/committer information
|
||||
git config --global user.name "openhands" && \
|
||||
git config --global user.email "openhands@all-hands.dev"
|
||||
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
|
||||
{{ install_dependencies_user() }}
|
||||
{{ install_dependencies_root() }}
|
||||
{{ install_dependencies() }}
|
||||
|
||||
# ================================================================
|
||||
# END: Build Runtime Image from Scratch
|
||||
# ================================================================
|
||||
{% endif %}
|
||||
|
||||
# Ensure openhands user/group and base dirs exist even when not building from scratch
|
||||
USER root
|
||||
RUN \
|
||||
# Ensure group exists (prefer GID 1000 if available)
|
||||
if ! getent group openhands >/dev/null 2>&1; then \
|
||||
if getent group 1000 >/dev/null 2>&1; then groupadd openhands; else groupadd -g 1000 openhands; fi; \
|
||||
fi && \
|
||||
# Ensure user exists (prefer UID 1000 if available)
|
||||
if ! id -u openhands >/dev/null 2>&1; then \
|
||||
if getent passwd 1000 >/dev/null 2>&1; then useradd -m -s /bin/bash -g openhands openhands; else useradd -u 1000 -g openhands -m -s /bin/bash openhands; fi; \
|
||||
fi && \
|
||||
# Ensure home and required directories exist before later steps
|
||||
mkdir -p /home/openhands && \
|
||||
mkdir -p /openhands && \
|
||||
mkdir -p $(dirname ${OPENVSCODE_SERVER_ROOT}) && \
|
||||
# Ensure ownership is correct for all OpenHands paths
|
||||
chown -R openhands:openhands /home/openhands || true && \
|
||||
chown -R openhands:openhands /openhands || true
|
||||
|
||||
{{ setup_vscode_server() }}
|
||||
|
||||
# ================================================================
|
||||
# Copy Project source files
|
||||
# ================================================================
|
||||
RUN if [ -d /openhands/code/openhands ]; then rm -rf /openhands/code/openhands; fi
|
||||
COPY --chown=openhands:openhands ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
COPY ./code/pyproject.toml ./code/poetry.lock /openhands/code/
|
||||
RUN if [ -d /openhands/code/microagents ]; then rm -rf /openhands/code/microagents; fi
|
||||
COPY --chown=openhands:openhands ./code/microagents /openhands/code/microagents
|
||||
COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py && \
|
||||
chown -R openhands:openhands /openhands/code
|
||||
COPY ./code/microagents /openhands/code/microagents
|
||||
COPY ./code/openhands /openhands/code/openhands
|
||||
RUN chmod a+rwx /openhands/code/openhands/__init__.py
|
||||
|
||||
|
||||
|
||||
# ================================================================
|
||||
# END: Build from versioned image
|
||||
# ================================================================
|
||||
{% if build_from_versioned %}
|
||||
{{ install_dependencies_user() }}
|
||||
{{ install_dependencies_root() }}
|
||||
{{ install_dependencies() }}
|
||||
{{ install_vscode_extensions() }}
|
||||
{% endif %}
|
||||
|
||||
# Install extra dependencies if specified (as openhands user)
|
||||
{% if extra_deps %}
|
||||
USER openhands
|
||||
RUN {{ extra_deps }}
|
||||
{% endif %}
|
||||
|
||||
# Set up environment for openhands user
|
||||
USER root
|
||||
RUN \
|
||||
# Set up environment for openhands user
|
||||
echo 'export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/openhands/micromamba/bin:$PATH"' >> /home/openhands/.bashrc && \
|
||||
echo 'export PLAYWRIGHT_BROWSERS_PATH=/opt/playwright-browsers' >> /home/openhands/.bashrc && \
|
||||
echo 'eval "$(/openhands/micromamba/bin/micromamba shell hook --shell bash)"' >> /home/openhands/.bashrc && \
|
||||
echo 'micromamba activate openhands 2>/dev/null || true' >> /home/openhands/.bashrc && \
|
||||
chown openhands:openhands /home/openhands/.bashrc
|
||||
# Install extra dependencies if specified
|
||||
{% if extra_deps %}RUN {{ extra_deps }} {% endif %}
|
||||
|
||||
@@ -2,7 +2,7 @@ import asyncio
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Iterable
|
||||
from typing import Callable, Iterable
|
||||
|
||||
import socketio
|
||||
|
||||
@@ -11,9 +11,7 @@ from openhands.core.config.openhands_config import OpenHandsConfig
|
||||
from openhands.core.exceptions import AgentRuntimeUnavailableError
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.core.schema.agent import AgentState
|
||||
from openhands.core.schema.observation import ObservationType
|
||||
from openhands.events.action import MessageAction
|
||||
from openhands.events.observation.commands import CmdOutputObservation
|
||||
from openhands.events.stream import EventStreamSubscriber, session_exists
|
||||
from openhands.llm.llm_registry import LLMRegistry
|
||||
from openhands.runtime import get_runtime_cls
|
||||
@@ -518,18 +516,6 @@ class StandaloneConversationManager(ConversationManager):
|
||||
conversation.total_tokens = (
|
||||
token_usage.prompt_tokens + token_usage.completion_tokens
|
||||
)
|
||||
|
||||
# Check for branch changes if this is a git-related event
|
||||
if event and self._is_git_related_event(event):
|
||||
logger.info(
|
||||
f'Git-related event detected, updating conversation branch for {conversation_id}',
|
||||
extra={
|
||||
'session_id': conversation_id,
|
||||
'command': getattr(event, 'command', 'unknown'),
|
||||
},
|
||||
)
|
||||
await self._update_conversation_branch(conversation)
|
||||
|
||||
default_title = get_default_conversation_title(conversation_id)
|
||||
if (
|
||||
conversation.title == default_title
|
||||
@@ -562,154 +548,6 @@ class StandaloneConversationManager(ConversationManager):
|
||||
|
||||
await conversation_store.save_metadata(conversation)
|
||||
|
||||
def _is_git_related_event(self, event) -> bool:
|
||||
"""
|
||||
Determine if an event is related to git operations that could change the branch.
|
||||
|
||||
Args:
|
||||
event: The event to check
|
||||
|
||||
Returns:
|
||||
True if the event is git-related and could change the branch, False otherwise
|
||||
"""
|
||||
# Early return if event is None or not the correct type
|
||||
if not event or not isinstance(event, CmdOutputObservation):
|
||||
return False
|
||||
|
||||
# Check CmdOutputObservation for git commands that change branches
|
||||
# We check the observation result, not the action request, to ensure the command actually succeeded
|
||||
if (
|
||||
event.observation == ObservationType.RUN
|
||||
and event.metadata.exit_code == 0 # Only consider successful commands
|
||||
):
|
||||
command = event.command.lower()
|
||||
|
||||
# Check if any git command that changes branches is present anywhere in the command
|
||||
# This handles compound commands like "cd workspace && git checkout feature-branch"
|
||||
git_commands = [
|
||||
'git checkout',
|
||||
'git switch',
|
||||
'git merge',
|
||||
'git rebase',
|
||||
'git reset',
|
||||
'git branch',
|
||||
]
|
||||
|
||||
is_git_related = any(git_cmd in command for git_cmd in git_commands)
|
||||
|
||||
if is_git_related:
|
||||
logger.debug(
|
||||
f'Detected git-related command: {command} with exit code {event.metadata.exit_code}',
|
||||
extra={'command': command, 'exit_code': event.metadata.exit_code},
|
||||
)
|
||||
|
||||
return is_git_related
|
||||
|
||||
return False
|
||||
|
||||
async def _update_conversation_branch(self, conversation: ConversationMetadata):
|
||||
"""
|
||||
Update the conversation's current branch if it has changed.
|
||||
|
||||
Args:
|
||||
conversation: The conversation metadata to update
|
||||
"""
|
||||
try:
|
||||
# Get the session and runtime for this conversation
|
||||
session, runtime = self._get_session_and_runtime(
|
||||
conversation.conversation_id
|
||||
)
|
||||
if not session or not runtime:
|
||||
return
|
||||
|
||||
# Get the current branch from the workspace
|
||||
current_branch = self._get_current_workspace_branch(
|
||||
runtime, conversation.selected_repository
|
||||
)
|
||||
|
||||
# Update branch if it has changed
|
||||
if self._should_update_branch(conversation.selected_branch, current_branch):
|
||||
self._update_branch_in_conversation(conversation, current_branch)
|
||||
|
||||
except Exception as e:
|
||||
# Log an error that occurred during branch update
|
||||
logger.warning(
|
||||
f'Failed to update conversation branch: {e}',
|
||||
extra={'session_id': conversation.conversation_id},
|
||||
)
|
||||
|
||||
def _get_session_and_runtime(
|
||||
self, conversation_id: str
|
||||
) -> tuple[Session | None, Any | None]:
|
||||
"""
|
||||
Get the session and runtime for a conversation.
|
||||
|
||||
Args:
|
||||
conversation_id: The conversation ID
|
||||
|
||||
Returns:
|
||||
Tuple of (session, runtime) or (None, None) if not found
|
||||
"""
|
||||
session = self._local_agent_loops_by_sid.get(conversation_id)
|
||||
if not session or not session.agent_session.runtime:
|
||||
return None, None
|
||||
return session, session.agent_session.runtime
|
||||
|
||||
def _get_current_workspace_branch(
|
||||
self, runtime: Any, selected_repository: str | None
|
||||
) -> str | None:
|
||||
"""
|
||||
Get the current branch from the workspace.
|
||||
|
||||
Args:
|
||||
runtime: The runtime instance
|
||||
selected_repository: The selected repository path or None
|
||||
|
||||
Returns:
|
||||
The current branch name or None if not found
|
||||
"""
|
||||
# Extract the repository name from the full repository path
|
||||
if not selected_repository:
|
||||
primary_repo_path = None
|
||||
else:
|
||||
# Extract the repository name from the full path (e.g., "org/repo" -> "repo")
|
||||
primary_repo_path = selected_repository.split('/')[-1]
|
||||
|
||||
return runtime.get_workspace_branch(primary_repo_path)
|
||||
|
||||
def _should_update_branch(
|
||||
self, current_branch: str | None, new_branch: str | None
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if the branch should be updated.
|
||||
|
||||
Args:
|
||||
current_branch: The current branch in conversation metadata
|
||||
new_branch: The new branch from the workspace
|
||||
|
||||
Returns:
|
||||
True if the branch should be updated, False otherwise
|
||||
"""
|
||||
return new_branch is not None and new_branch != current_branch
|
||||
|
||||
def _update_branch_in_conversation(
|
||||
self, conversation: ConversationMetadata, new_branch: str | None
|
||||
):
|
||||
"""
|
||||
Update the branch in the conversation metadata.
|
||||
|
||||
Args:
|
||||
conversation: The conversation metadata to update
|
||||
new_branch: The new branch name
|
||||
"""
|
||||
old_branch = conversation.selected_branch
|
||||
conversation.selected_branch = new_branch
|
||||
|
||||
logger.info(
|
||||
f'Branch changed from {old_branch} to {new_branch}',
|
||||
extra={'session_id': conversation.conversation_id},
|
||||
)
|
||||
|
||||
async def get_agent_loop_info(
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
):
|
||||
|
||||
@@ -79,85 +79,6 @@ from openhands.utils.conversation_summary import get_default_conversation_title
|
||||
app = APIRouter(prefix='/api', dependencies=get_dependencies())
|
||||
|
||||
|
||||
def _filter_conversations_by_age(
|
||||
conversations: list[ConversationMetadata], max_age_seconds: int
|
||||
) -> list:
|
||||
"""Filter conversations by age, removing those older than max_age_seconds.
|
||||
|
||||
Args:
|
||||
conversations: List of conversations to filter
|
||||
max_age_seconds: Maximum age in seconds for conversations to be included
|
||||
|
||||
Returns:
|
||||
List of conversations that meet the age criteria
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
filtered_results = []
|
||||
|
||||
for conversation in conversations:
|
||||
# Skip conversations without created_at or older than max_age
|
||||
if not hasattr(conversation, 'created_at'):
|
||||
continue
|
||||
|
||||
age_seconds = (
|
||||
now - conversation.created_at.replace(tzinfo=timezone.utc)
|
||||
).total_seconds()
|
||||
if age_seconds > max_age_seconds:
|
||||
continue
|
||||
|
||||
filtered_results.append(conversation)
|
||||
|
||||
return filtered_results
|
||||
|
||||
|
||||
async def _build_conversation_result_set(
|
||||
filtered_conversations: list, next_page_id: str | None
|
||||
) -> ConversationInfoResultSet:
|
||||
"""Build a ConversationInfoResultSet from filtered conversations.
|
||||
|
||||
This function handles the common logic of getting conversation IDs, connections,
|
||||
agent loop info, and building the final result set.
|
||||
|
||||
Args:
|
||||
filtered_conversations: List of filtered conversations
|
||||
next_page_id: Next page ID for pagination
|
||||
|
||||
Returns:
|
||||
ConversationInfoResultSet with the processed conversations
|
||||
"""
|
||||
conversation_ids = set(
|
||||
conversation.conversation_id for conversation in filtered_conversations
|
||||
)
|
||||
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info_by_conversation_id = {
|
||||
info.conversation_id: info for info in agent_loop_info
|
||||
}
|
||||
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
_get_conversation_info(
|
||||
conversation=conversation,
|
||||
num_connections=sum(
|
||||
1
|
||||
for conversation_id in connection_ids_to_conversation_ids.values()
|
||||
if conversation_id == conversation.conversation_id
|
||||
),
|
||||
agent_loop_info=agent_loop_info_by_conversation_id.get(
|
||||
conversation.conversation_id
|
||||
),
|
||||
)
|
||||
for conversation in filtered_conversations
|
||||
),
|
||||
next_page_id=next_page_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class InitSessionRequest(BaseModel):
|
||||
repository: str | None = None
|
||||
git_provider: ProviderType | None = None
|
||||
@@ -299,14 +220,22 @@ async def search_conversations(
|
||||
) -> ConversationInfoResultSet:
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Apply age filter first using common function
|
||||
filtered_results = _filter_conversations_by_age(
|
||||
conversation_metadata_result_set.results, config.conversation_max_age_seconds
|
||||
)
|
||||
# Apply filters at API level
|
||||
filtered_results = []
|
||||
now = datetime.now(timezone.utc)
|
||||
max_age = config.conversation_max_age_seconds
|
||||
|
||||
for conversation in conversation_metadata_result_set.results:
|
||||
# Skip conversations without created_at or older than max_age
|
||||
if not hasattr(conversation, 'created_at'):
|
||||
continue
|
||||
|
||||
age_seconds = (
|
||||
now - conversation.created_at.replace(tzinfo=timezone.utc)
|
||||
).total_seconds()
|
||||
if age_seconds > max_age:
|
||||
continue
|
||||
|
||||
# Apply additional filters
|
||||
final_filtered_results = []
|
||||
for conversation in filtered_results:
|
||||
# Apply repository filter
|
||||
if (
|
||||
selected_repository is not None
|
||||
@@ -321,11 +250,38 @@ async def search_conversations(
|
||||
):
|
||||
continue
|
||||
|
||||
final_filtered_results.append(conversation)
|
||||
filtered_results.append(conversation)
|
||||
|
||||
return await _build_conversation_result_set(
|
||||
final_filtered_results, conversation_metadata_result_set.next_page_id
|
||||
conversation_ids = set(
|
||||
conversation.conversation_id for conversation in filtered_results
|
||||
)
|
||||
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info_by_conversation_id = {
|
||||
info.conversation_id: info for info in agent_loop_info
|
||||
}
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
_get_conversation_info(
|
||||
conversation=conversation,
|
||||
num_connections=sum(
|
||||
1
|
||||
for conversation_id in connection_ids_to_conversation_ids.values()
|
||||
if conversation_id == conversation.conversation_id
|
||||
),
|
||||
agent_loop_info=agent_loop_info_by_conversation_id.get(
|
||||
conversation.conversation_id
|
||||
),
|
||||
)
|
||||
for conversation in filtered_results
|
||||
),
|
||||
next_page_id=conversation_metadata_result_set.next_page_id,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get('/conversations/{conversation_id}')
|
||||
@@ -769,65 +725,3 @@ def add_experiment_config_for_conversation(
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@app.get('/microagent-management/conversations')
|
||||
async def get_microagent_management_conversations(
|
||||
selected_repository: str,
|
||||
page_id: str | None = None,
|
||||
limit: int = 20,
|
||||
conversation_store: ConversationStore = Depends(get_conversation_store),
|
||||
provider_tokens: PROVIDER_TOKEN_TYPE = Depends(get_provider_tokens),
|
||||
) -> ConversationInfoResultSet:
|
||||
"""Get conversations for the microagent management page with pagination support.
|
||||
|
||||
This endpoint returns conversations with conversation_trigger = 'microagent_management'
|
||||
and only includes conversations with active PRs. Pagination is supported.
|
||||
|
||||
Args:
|
||||
page_id: Optional page ID for pagination
|
||||
limit: Maximum number of results per page (default: 20)
|
||||
selected_repository: Optional repository filter to limit results to a specific repository
|
||||
conversation_store: Conversation store dependency
|
||||
provider_tokens: Provider tokens for checking PR status
|
||||
"""
|
||||
conversation_metadata_result_set = await conversation_store.search(page_id, limit)
|
||||
|
||||
# Apply age filter first using common function
|
||||
filtered_results = _filter_conversations_by_age(
|
||||
conversation_metadata_result_set.results, config.conversation_max_age_seconds
|
||||
)
|
||||
|
||||
# Check if the last PR is active (not closed/merged)
|
||||
provider_handler = ProviderHandler(provider_tokens)
|
||||
|
||||
# Apply additional filters
|
||||
final_filtered_results = []
|
||||
for conversation in filtered_results:
|
||||
# Only include microagent_management conversations
|
||||
if conversation.trigger != ConversationTrigger.MICROAGENT_MANAGEMENT:
|
||||
continue
|
||||
|
||||
# Apply repository filter if specified
|
||||
if conversation.selected_repository != selected_repository:
|
||||
continue
|
||||
|
||||
if (
|
||||
conversation.pr_number
|
||||
and len(conversation.pr_number) > 0
|
||||
and conversation.selected_repository
|
||||
and conversation.git_provider
|
||||
and not await provider_handler.is_pr_open(
|
||||
conversation.selected_repository,
|
||||
conversation.pr_number[-1], # Get the last PR number
|
||||
conversation.git_provider,
|
||||
)
|
||||
):
|
||||
# Skip this conversation if the PR is closed/merged
|
||||
continue
|
||||
|
||||
final_filtered_results.append(conversation)
|
||||
|
||||
return await _build_conversation_result_set(
|
||||
final_filtered_results, conversation_metadata_result_set.next_page_id
|
||||
)
|
||||
|
||||
@@ -1,23 +1,7 @@
|
||||
"""
|
||||
This class is similar to the RuntimeStatus defined in the runtime api. (When this class was defined
|
||||
a RuntimeStatus class already existed in OpenHands which serves a completely different purpose) Some of
|
||||
the status definitions do not match up:
|
||||
|
||||
STOPPED/paused - the runtime is not running but may be restarted
|
||||
ARCHIVED/stopped - the runtime is not running and will not restart due to deleted files.
|
||||
"""
|
||||
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ConversationStatus(Enum):
|
||||
# The conversation is starting
|
||||
STARTING = 'STARTING'
|
||||
# The conversation is running - the agent may be working or idle
|
||||
RUNNING = 'RUNNING'
|
||||
# The conversation has stopped (This is synonymous with `paused` in the runtime API.)
|
||||
STOPPED = 'STOPPED'
|
||||
# The conversation has been archived and cannot be restarted.
|
||||
ARCHIVED = 'ARCHIVED'
|
||||
# Something has gone wrong with the conversation (The runtime rather than the agent)
|
||||
ERROR = 'ERROR'
|
||||
|
||||
@@ -110,8 +110,8 @@ class Settings(BaseModel):
|
||||
def validate_condenser_max_size(cls, v: int | None) -> int | None:
|
||||
if v is None:
|
||||
return v
|
||||
if v < 20:
|
||||
raise ValueError('condenser_max_size must be at least 20')
|
||||
if v < 10:
|
||||
raise ValueError('condenser_max_size must be at least 10')
|
||||
return v
|
||||
|
||||
@field_serializer('secrets_store')
|
||||
|
||||
@@ -57,7 +57,6 @@ def get_supported_llm_models(config: OpenHandsConfig) -> list[str]:
|
||||
openhands_models = [
|
||||
'openhands/claude-sonnet-4-20250514',
|
||||
'openhands/gpt-5-2025-08-07',
|
||||
'openhands/gpt-5-mini-2025-08-07',
|
||||
'openhands/claude-opus-4-20250514',
|
||||
'openhands/gemini-2.5-pro',
|
||||
'openhands/o3',
|
||||
|
||||
349
poetry.lock
generated
349
poetry.lock
generated
@@ -1,4 +1,4 @@
|
||||
# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand.
|
||||
# This file is automatically @generated by Poetry 2.1.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "aiofiles"
|
||||
@@ -1078,80 +1078,79 @@ botocore = ["botocore"]
|
||||
|
||||
[[package]]
|
||||
name = "browsergym"
|
||||
version = "0.14.2"
|
||||
version = "0.13.3"
|
||||
description = "BrowserGym: a gym environment for web task automation in the Chromium browser"
|
||||
optional = false
|
||||
python-versions = ">3.10"
|
||||
python-versions = ">3.7"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "browsergym-0.14.2-py3-none-any.whl", hash = "sha256:03e8aada75deb3dd3b68673a68b05f0522a83e4de5a63da5aeb2222daffe6df4"},
|
||||
{file = "browsergym-0.14.2.tar.gz", hash = "sha256:f45419ac0a2a050ca728ad2085b59a37ebf7df7d32d8f280b7db7b9bd6564be0"},
|
||||
{file = "browsergym-0.13.3-py3-none-any.whl", hash = "sha256:4f1f8284ca3eb82e5bafb8fa24557ccdd98aaee55971cfa136ad7857011abb20"},
|
||||
{file = "browsergym-0.13.3.tar.gz", hash = "sha256:c3ee2ac41cf7a13abe71e0f9c63c28b37fee348dcc64fa1a6d2b5e513f9929e0"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
browsergym-assistantbench = "0.14.2"
|
||||
browsergym-core = "0.14.2"
|
||||
browsergym-experiments = "0.14.2"
|
||||
browsergym-miniwob = "0.14.2"
|
||||
browsergym-visualwebarena = "0.14.2"
|
||||
browsergym-webarena = "0.14.2"
|
||||
browsergym-assistantbench = "0.13.3"
|
||||
browsergym-core = "0.13.3"
|
||||
browsergym-experiments = "0.13.3"
|
||||
browsergym-miniwob = "0.13.3"
|
||||
browsergym-visualwebarena = "0.13.3"
|
||||
browsergym-webarena = "0.13.3"
|
||||
browsergym-workarena = ">=0.4.1"
|
||||
weblinx-browsergym = ">=0.0.2"
|
||||
weblinx-browsergym = ">=0.0.1dev14"
|
||||
|
||||
[[package]]
|
||||
name = "browsergym-assistantbench"
|
||||
version = "0.14.2"
|
||||
version = "0.13.3"
|
||||
description = "AssistantBench benchmark for BrowserGym"
|
||||
optional = false
|
||||
python-versions = ">3.7"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "browsergym_assistantbench-0.14.2-py3-none-any.whl", hash = "sha256:f137abe167f2d6287d7eb125a68eee0f3d63da365b34a70798993638de41139e"},
|
||||
{file = "browsergym_assistantbench-0.14.2.tar.gz", hash = "sha256:0c76833a1ca0713b2da0b33d62b621677a1b6b8e58733255d052a40f24dbf0ab"},
|
||||
{file = "browsergym_assistantbench-0.13.3-py3-none-any.whl", hash = "sha256:33f40b590f2baa521e05c1b32b063d867e9cd901c40dda5cb30cb203035236b7"},
|
||||
{file = "browsergym_assistantbench-0.13.3.tar.gz", hash = "sha256:46d784c7dcfc7b07836e4378d20275998b185b6c2ca6d0973500ab0333fde981"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
browsergym-core = "0.14.2"
|
||||
browsergym-core = "0.13.3"
|
||||
datasets = "*"
|
||||
numpy = "*"
|
||||
scipy = "*"
|
||||
|
||||
[[package]]
|
||||
name = "browsergym-core"
|
||||
version = "0.14.2"
|
||||
version = "0.13.3"
|
||||
description = "BrowserGym: a gym environment for web task automation in the Chromium browser"
|
||||
optional = false
|
||||
python-versions = ">3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
files = [
|
||||
{file = "browsergym_core-0.14.2-py3-none-any.whl", hash = "sha256:217dfae3d8f6a92e4502b4dfd97dc5ec955a91e5f6b45944f857c182a57168d0"},
|
||||
{file = "browsergym_core-0.14.2.tar.gz", hash = "sha256:aa99a56aa6aae74bb3e1c139ae2fe7d53f0a5bed8707e0ee7520daed531f1f52"},
|
||||
{file = "browsergym_core-0.13.3-py3-none-any.whl", hash = "sha256:db806c64deb819a51501f0466ecb51533fbc7b6edb5f7dbdcb865e7564a86719"},
|
||||
{file = "browsergym_core-0.13.3.tar.gz", hash = "sha256:ac5036b574c8c14ac4a0c09da578a0a00b584d6f5b5ed9bf7a247e24f4d9d2f8"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
beautifulsoup4 = ">=4.12"
|
||||
gymnasium = ">=0.27"
|
||||
lxml = ">=4.9,<6.0.0"
|
||||
mcp = {version = ">=1.6.0", extras = ["cli"]}
|
||||
lxml = ">=4.9"
|
||||
numpy = ">=1.14"
|
||||
pillow = ">=10.1"
|
||||
playwright = "1.44"
|
||||
playwright = ">=1.39,<2.0"
|
||||
pyparsing = ">=3"
|
||||
|
||||
[[package]]
|
||||
name = "browsergym-experiments"
|
||||
version = "0.14.2"
|
||||
version = "0.13.3"
|
||||
description = "Experimentation tools for BrowserGym"
|
||||
optional = false
|
||||
python-versions = ">3.7"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "browsergym_experiments-0.14.2-py3-none-any.whl", hash = "sha256:acb5eee773b7fbba6f3f60e03fa6b7fa66d277181e9bae36bdaf5ddec6d338d5"},
|
||||
{file = "browsergym_experiments-0.14.2.tar.gz", hash = "sha256:d71cee90706026c585ca95165f2bb1363b3607432c0720afcfd3b1d51aa9a637"},
|
||||
{file = "browsergym_experiments-0.13.3-py3-none-any.whl", hash = "sha256:61963e747eb2c3d04f4f0b5bb5a2f61208025fe2f94faf23f1b86b98dfce3218"},
|
||||
{file = "browsergym_experiments-0.13.3.tar.gz", hash = "sha256:96842e7700e27380746ac57ffc647a1dd56d449f925441ed9bc87675cddfff08"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
browsergym-core = "0.14.2"
|
||||
browsergym-core = "0.13.3"
|
||||
dataclasses-json = "*"
|
||||
tiktoken = ">=0.4"
|
||||
|
||||
@@ -1166,33 +1165,33 @@ workarena = ["browsergym-workarena"]
|
||||
|
||||
[[package]]
|
||||
name = "browsergym-miniwob"
|
||||
version = "0.14.2"
|
||||
version = "0.13.3"
|
||||
description = "MiniWoB++ benchmark for BrowserGym"
|
||||
optional = false
|
||||
python-versions = ">3.7"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "browsergym_miniwob-0.14.2-py3-none-any.whl", hash = "sha256:bc99712c11e39d46c11c5431d57a121854f141291ab16d62e329a1dca0cea974"},
|
||||
{file = "browsergym_miniwob-0.14.2.tar.gz", hash = "sha256:00ea1f820124689f086830323ea610fec5207e7f1718c86d1fc69e0eb385d939"},
|
||||
{file = "browsergym_miniwob-0.13.3-py3-none-any.whl", hash = "sha256:353b9f8849b7f637e17a928021a93ce962ca9b828434cfe68cebdbe2f11f4a2f"},
|
||||
{file = "browsergym_miniwob-0.13.3.tar.gz", hash = "sha256:0e22797a83d4664636364b2400c5ea0eca16ddd3f50d3003891b0892da1ff40e"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
browsergym-core = "0.14.2"
|
||||
browsergym-core = "0.13.3"
|
||||
|
||||
[[package]]
|
||||
name = "browsergym-visualwebarena"
|
||||
version = "0.14.2"
|
||||
version = "0.13.3"
|
||||
description = "VisualWebArena benchmark for BrowserGym"
|
||||
optional = false
|
||||
python-versions = ">3.7"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "browsergym_visualwebarena-0.14.2-py3-none-any.whl", hash = "sha256:c86efeb64e97d2b2305af36e460b5e638f328955bf9c5e5c31a0fa5cffaee922"},
|
||||
{file = "browsergym_visualwebarena-0.14.2.tar.gz", hash = "sha256:a926c13b3f244cdb6266106f2b88904af090f3bc16f17524e6b714ac25727f73"},
|
||||
{file = "browsergym_visualwebarena-0.13.3-py3-none-any.whl", hash = "sha256:a42c200023497a4970290fce39b419a93aadfc9e92c02ae602704d2957e5e531"},
|
||||
{file = "browsergym_visualwebarena-0.13.3.tar.gz", hash = "sha256:635b4a71c8ff6bff3e84c0fecc7a10b9e932fe2929d4bf8e2e9a5bf2e29438e4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
browsergym-core = "0.14.2"
|
||||
browsergym-core = "0.13.3"
|
||||
browsergym-webarena = "*"
|
||||
libvisualwebarena = "0.0.15"
|
||||
requests = "*"
|
||||
@@ -1200,18 +1199,18 @@ torch = "*"
|
||||
|
||||
[[package]]
|
||||
name = "browsergym-webarena"
|
||||
version = "0.14.2"
|
||||
version = "0.13.3"
|
||||
description = "WebArena benchmark for BrowserGym"
|
||||
optional = false
|
||||
python-versions = ">3.7"
|
||||
groups = ["evaluation"]
|
||||
files = [
|
||||
{file = "browsergym_webarena-0.14.2-py3-none-any.whl", hash = "sha256:d9bd8fb4e64627a57134fe205497aa36c5e39ffcafd255b8511ba31983478cff"},
|
||||
{file = "browsergym_webarena-0.14.2.tar.gz", hash = "sha256:ccc741ea6a6d4e0d4022fc3c0e7c50d2ee7edc2076a3c50b277005eb572f4c65"},
|
||||
{file = "browsergym_webarena-0.13.3-py3-none-any.whl", hash = "sha256:28098690f7c4a513c06e9da0d95f13e5c7bc70ec4bcfcfb7f83311b4081af0c9"},
|
||||
{file = "browsergym_webarena-0.13.3.tar.gz", hash = "sha256:60347edfd8d16e9b6b34a03b3ccb0e058ff11b83f3308ac5ead60321a9cc6462"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
browsergym-core = "0.14.2"
|
||||
browsergym-core = "0.13.3"
|
||||
libwebarena = "0.0.4"
|
||||
|
||||
[[package]]
|
||||
@@ -2869,58 +2868,56 @@ test = ["build", "mypy", "pytest", "pytest-xdist", "ruff", "twine", "types-reque
|
||||
|
||||
[[package]]
|
||||
name = "gevent"
|
||||
version = "24.2.1"
|
||||
version = "25.5.1"
|
||||
description = "Coroutine-based network library"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["test"]
|
||||
files = [
|
||||
{file = "gevent-24.2.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:6f947a9abc1a129858391b3d9334c45041c08a0f23d14333d5b844b6e5c17a07"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde283313daf0b34a8d1bab30325f5cb0f4e11b5869dbe5bc61f8fe09a8f66f3"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1df555431f5cd5cc189a6ee3544d24f8c52f2529134685f1e878c4972ab026"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:14532a67f7cb29fb055a0e9b39f16b88ed22c66b96641df8c04bdc38c26b9ea5"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd23df885318391856415e20acfd51a985cba6919f0be78ed89f5db9ff3a31cb"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ca80b121bbec76d7794fcb45e65a7eca660a76cc1a104ed439cdbd7df5f0b060"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b9913c45d1be52d7a5db0c63977eebb51f68a2d5e6fd922d1d9b5e5fd758cc98"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:918cdf8751b24986f915d743225ad6b702f83e1106e08a63b736e3a4c6ead789"},
|
||||
{file = "gevent-24.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:3d5325ccfadfd3dcf72ff88a92fb8fc0b56cacc7225f0f4b6dcf186c1a6eeabc"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:03aa5879acd6b7076f6a2a307410fb1e0d288b84b03cdfd8c74db8b4bc882fc5"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8bb35ce57a63c9a6896c71a285818a3922d8ca05d150fd1fe49a7f57287b836"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7f87c2c02e03d99b95cfa6f7a776409083a9e4d468912e18c7680437b29222c"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968581d1717bbcf170758580f5f97a2925854943c45a19be4d47299507db2eb7"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7899a38d0ae7e817e99adb217f586d0a4620e315e4de577444ebeeed2c5729be"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:f5e8e8d60e18d5f7fd49983f0c4696deeddaf6e608fbab33397671e2fcc6cc91"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fbfdce91239fe306772faab57597186710d5699213f4df099d1612da7320d682"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cdf66977a976d6a3cfb006afdf825d1482f84f7b81179db33941f2fc9673bb1d"},
|
||||
{file = "gevent-24.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1dffb395e500613e0452b9503153f8f7ba587c67dd4a85fc7cd7aa7430cb02cc"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:6c47ae7d1174617b3509f5d884935e788f325eb8f1a7efc95d295c68d83cce40"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7cac622e11b4253ac4536a654fe221249065d9a69feb6cdcd4d9af3503602e0"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf5b9c72b884c6f0c4ed26ef204ee1f768b9437330422492c319470954bc4cc7"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5de3c676e57177b38857f6e3cdfbe8f38d1cd754b63200c0615eaa31f514b4f"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d4faf846ed132fd7ebfbbf4fde588a62d21faa0faa06e6f468b7faa6f436b661"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:368a277bd9278ddb0fde308e6a43f544222d76ed0c4166e0d9f6b036586819d9"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:f8a04cf0c5b7139bc6368b461257d4a757ea2fe89b3773e494d235b7dd51119f"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:9d8d0642c63d453179058abc4143e30718b19a85cbf58c2744c9a63f06a1d388"},
|
||||
{file = "gevent-24.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:94138682e68ec197db42ad7442d3cf9b328069c3ad8e4e5022e6b5cd3e7ffae5"},
|
||||
{file = "gevent-24.2.1-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:8f4b8e777d39013595a7740b4463e61b1cfe5f462f1b609b28fbc1e4c4ff01e5"},
|
||||
{file = "gevent-24.2.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141a2b24ad14f7b9576965c0c84927fc85f824a9bb19f6ec1e61e845d87c9cd8"},
|
||||
{file = "gevent-24.2.1-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:9202f22ef811053077d01f43cc02b4aaf4472792f9fd0f5081b0b05c926cca19"},
|
||||
{file = "gevent-24.2.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:2955eea9c44c842c626feebf4459c42ce168685aa99594e049d03bedf53c2800"},
|
||||
{file = "gevent-24.2.1-cp38-cp38-win32.whl", hash = "sha256:44098038d5e2749b0784aabb27f1fcbb3f43edebedf64d0af0d26955611be8d6"},
|
||||
{file = "gevent-24.2.1-cp38-cp38-win_amd64.whl", hash = "sha256:117e5837bc74a1673605fb53f8bfe22feb6e5afa411f524c835b2ddf768db0de"},
|
||||
{file = "gevent-24.2.1-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:2ae3a25ecce0a5b0cd0808ab716bfca180230112bb4bc89b46ae0061d62d4afe"},
|
||||
{file = "gevent-24.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a7ceb59986456ce851160867ce4929edaffbd2f069ae25717150199f8e1548b8"},
|
||||
{file = "gevent-24.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2e9ac06f225b696cdedbb22f9e805e2dd87bf82e8fa5e17756f94e88a9d37cf7"},
|
||||
{file = "gevent-24.2.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:90cbac1ec05b305a1b90ede61ef73126afdeb5a804ae04480d6da12c56378df1"},
|
||||
{file = "gevent-24.2.1-cp39-cp39-win32.whl", hash = "sha256:782a771424fe74bc7e75c228a1da671578c2ba4ddb2ca09b8f959abdf787331e"},
|
||||
{file = "gevent-24.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:3adfb96637f44010be8abd1b5e73b5070f851b817a0b182e601202f20fa06533"},
|
||||
{file = "gevent-24.2.1-pp310-pypy310_pp73-macosx_11_0_universal2.whl", hash = "sha256:7b00f8c9065de3ad226f7979154a7b27f3b9151c8055c162332369262fc025d8"},
|
||||
{file = "gevent-24.2.1.tar.gz", hash = "sha256:432fc76f680acf7cf188c2ee0f5d3ab73b63c1f03114c7cd8a34cebbe5aa2056"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8e5a0fab5e245b15ec1005b3666b0a2e867c26f411c8fe66ae1afe07174a30e9"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c7b80a37f2fb45ee4a8f7e64b77dd8a842d364384046e394227b974a4e9c9a52"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:29ab729d50ae85077a68e0385f129f5b01052d01a0ae6d7fdc1824f5337905e4"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:80d20592aeabcc4e294fd441fd43d45cb537437fd642c374ea9d964622fad229"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8ba0257542ccbb72a8229dc34d00844ccdfba110417e4b7b34599548d0e20e9"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cad0821dff998c7c60dd238f92cd61380342c47fb9e92e1a8705d9b5ac7c16e8"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:017a7384c0cd1a5907751c991535a0699596e89725468a7fc39228312e10efa1"},
|
||||
{file = "gevent-25.5.1-cp310-cp310-win_amd64.whl", hash = "sha256:469c86d02fccad7e2a3d82fe22237e47ecb376fbf4710bc18747b49c50716817"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:12380aba5c316e9ff53cc21d8ab80f4a91c0df3ada58f65d4f5eb2cf693db00e"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f0694daab1a041b69a53f53c2141c12994892b2503870515cabe6a5dbd2a928"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2797885e9aeffdc98e1846723e5aa212e7ce53007dbef40d6fd2add264235c41"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cde6aaac36b54332e10ea2a5bc0de6a8aba6c205c92603fe4396e3777c88e05d"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24484f80f14befb8822bf29554cfb3a26a26cb69cd1e5a8be9e23b4bd7a96e25"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc7446895fa184890d8ca5ea61e502691114f9db55c9b76adc33f3086c4368"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5b6106e2414b1797133786258fa1962a5e836480e4d5e861577f9fc63b673a5a"},
|
||||
{file = "gevent-25.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:bc899212d90f311784c58938a9c09c59802fb6dc287a35fabdc36d180f57f575"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:d87c0a1bd809d8f70f96b9b229779ec6647339830b8888a192beed33ac8d129f"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b87a4b66edb3808d4d07bbdb0deed5a710cf3d3c531e082759afd283758bb649"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f076779050029a82feb0cb1462021d3404d22f80fa76a181b1a7889cd4d6b519"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb673eb291c19370f69295f7a881a536451408481e2e3deec3f41dedb7c281ec"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1325ed44225c8309c0dd188bdbbbee79e1df8c11ceccac226b861c7d52e4837"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:fcd5bcad3102bde686d0adcc341fade6245186050ce14386d547ccab4bd54310"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1a93062609e8fa67ec97cd5fb9206886774b2a09b24887f40148c9c37e6fb71c"},
|
||||
{file = "gevent-25.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:2534c23dc32bed62b659ed4fd9e198906179e68b26c9276a897e04163bdde806"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a022a9de9275ce0b390b7315595454258c525dc8287a03f1a6cacc5878ab7cbc"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3fae8533f9d0ef3348a1f503edcfb531ef7a0236b57da1e24339aceb0ce52922"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c7b32d9c3b5294b39ea9060e20c582e49e1ec81edbfeae6cf05f8ad0829cb13d"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7b95815fe44f318ebbfd733b6428b4cb18cc5e68f1c40e8501dd69cc1f42a83d"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2d316529b70d325b183b2f3f5cde958911ff7be12eb2b532b5c301f915dbbf1e"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f6ba33c13db91ffdbb489a4f3d177a261ea1843923e1d68a5636c53fe98fa5ce"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:37ee34b77c7553777c0b8379915f75934c3f9c8cd32f7cd098ea43c9323c2276"},
|
||||
{file = "gevent-25.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:9fa6aa0da224ed807d3b76cdb4ee8b54d4d4d5e018aed2478098e685baae7896"},
|
||||
{file = "gevent-25.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:0bacf89a65489d26c7087669af89938d5bfd9f7afb12a07b57855b9fad6ccbd0"},
|
||||
{file = "gevent-25.5.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e30169ef9cc0a57930bfd8fe14d86bc9d39fb96d278e3891e85cbe7b46058a97"},
|
||||
{file = "gevent-25.5.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:e72ad5f8d9c92df017fb91a1f6a438cfb63b0eff4b40904ff81b40cb8150078c"},
|
||||
{file = "gevent-25.5.1-cp39-cp39-win32.whl", hash = "sha256:e5f358e81e27b1a7f2fb2f5219794e13ab5f59ce05571aa3877cfac63adb97db"},
|
||||
{file = "gevent-25.5.1-cp39-cp39-win_amd64.whl", hash = "sha256:b83aff2441c7d4ee93e519989713b7c2607d4510abe990cd1d04f641bc6c03af"},
|
||||
{file = "gevent-25.5.1-pp310-pypy310_pp73-macosx_11_0_universal2.whl", hash = "sha256:60ad4ca9ca2c4cc8201b607c229cd17af749831e371d006d8a91303bb5568eb1"},
|
||||
{file = "gevent-25.5.1.tar.gz", hash = "sha256:582c948fa9a23188b890d0bc130734a506d039a2e5ad87dae276a456cc683e61"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cffi = {version = ">=1.12.2", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
|
||||
greenlet = {version = ">=3.0rc3", markers = "platform_python_implementation == \"CPython\" and python_version >= \"3.11\""}
|
||||
cffi = {version = ">=1.17.1", markers = "platform_python_implementation == \"CPython\" and sys_platform == \"win32\""}
|
||||
greenlet = {version = ">=3.2.2", markers = "platform_python_implementation == \"CPython\""}
|
||||
"zope.event" = "*"
|
||||
"zope.interface" = "*"
|
||||
|
||||
@@ -2928,8 +2925,8 @@ greenlet = {version = ">=3.0rc3", markers = "platform_python_implementation == \
|
||||
dnspython = ["dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\""]
|
||||
docs = ["furo", "repoze.sphinx.autointerface", "sphinx", "sphinxcontrib-programoutput", "zope.schema"]
|
||||
monitor = ["psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""]
|
||||
recommended = ["cffi (>=1.12.2) ; platform_python_implementation == \"CPython\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""]
|
||||
test = ["cffi (>=1.12.2) ; platform_python_implementation == \"CPython\"", "coverage (>=5.0) ; sys_platform != \"win32\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "objgraph", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\"", "requests"]
|
||||
recommended = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\""]
|
||||
test = ["cffi (>=1.17.1) ; platform_python_implementation == \"CPython\"", "coverage (>=5.0) ; sys_platform != \"win32\"", "dnspython (>=1.16.0,<2.0) ; python_version < \"3.10\"", "idna ; python_version < \"3.10\"", "objgraph", "psutil (>=5.7.0) ; sys_platform != \"win32\" or platform_python_implementation == \"CPython\"", "requests"]
|
||||
|
||||
[[package]]
|
||||
name = "ghapi"
|
||||
@@ -3403,70 +3400,67 @@ grpc = ["grpcio (>=1.44.0,<2.0.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.0.3"
|
||||
version = "3.2.2"
|
||||
description = "Lightweight in-process concurrent programming"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "greenlet-3.0.3-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9da2bd29ed9e4f15955dd1595ad7bc9320308a3b766ef7f837e23ad4b4aac31a"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d353cadd6083fdb056bb46ed07e4340b0869c305c8ca54ef9da3421acbdf6881"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dca1e2f3ca00b84a396bc1bce13dd21f680f035314d2379c4160c98153b2059b"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ed7fb269f15dc662787f4119ec300ad0702fa1b19d2135a37c2c4de6fadfd4a"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd4f49ae60e10adbc94b45c0b5e6a179acc1736cf7a90160b404076ee283cf83"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:73a411ef564e0e097dbe7e866bb2dda0f027e072b04da387282b02c308807405"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:7f362975f2d179f9e26928c5b517524e89dd48530a0202570d55ad6ca5d8a56f"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:649dde7de1a5eceb258f9cb00bdf50e978c9db1b996964cd80703614c86495eb"},
|
||||
{file = "greenlet-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:68834da854554926fbedd38c76e60c4a2e3198c6fbed520b106a8986445caaf9"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:b1b5667cced97081bf57b8fa1d6bfca67814b0afd38208d52538316e9422fc61"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:52f59dd9c96ad2fc0d5724107444f76eb20aaccb675bf825df6435acb7703559"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afaff6cf5200befd5cec055b07d1c0a5a06c040fe5ad148abcd11ba6ab9b114e"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fe754d231288e1e64323cfad462fcee8f0288654c10bdf4f603a39ed923bef33"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2797aa5aedac23af156bbb5a6aa2cd3427ada2972c828244eb7d1b9255846379"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7f009caad047246ed379e1c4dbcb8b020f0a390667ea74d2387be2998f58a22"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:c5e1536de2aad7bf62e27baf79225d0d64360d4168cf2e6becb91baf1ed074f3"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:894393ce10ceac937e56ec00bb71c4c2f8209ad516e96033e4b3b1de270e200d"},
|
||||
{file = "greenlet-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:1ea188d4f49089fc6fb283845ab18a2518d279c7cd9da1065d7a84e991748728"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:70fb482fdf2c707765ab5f0b6655e9cfcf3780d8d87355a063547b41177599be"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4d1ac74f5c0c0524e4a24335350edad7e5f03b9532da7ea4d3c54d527784f2e"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:149e94a2dd82d19838fe4b2259f1b6b9957d5ba1b25640d2380bea9c5df37676"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15d79dd26056573940fcb8c7413d84118086f2ec1a8acdfa854631084393efcc"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:881b7db1ebff4ba09aaaeae6aa491daeb226c8150fc20e836ad00041bcb11230"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fcd2469d6a2cf298f198f0487e0a5b1a47a42ca0fa4dfd1b6862c999f018ebbf"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1f672519db1796ca0d8753f9e78ec02355e862d0998193038c7073045899f305"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2516a9957eed41dd8f1ec0c604f1cdc86758b587d964668b5b196a9db5bfcde6"},
|
||||
{file = "greenlet-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:bba5387a6975598857d86de9eac14210a49d554a77eb8261cc68b7d082f78ce2"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-macosx_11_0_universal2.whl", hash = "sha256:5b51e85cb5ceda94e79d019ed36b35386e8c37d22f07d6a751cb659b180d5274"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:daf3cb43b7cf2ba96d614252ce1684c1bccee6b2183a01328c98d36fcd7d5cb0"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99bf650dc5d69546e076f413a87481ee1d2d09aaaaaca058c9251b6d8c14783f"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2dd6e660effd852586b6a8478a1d244b8dc90ab5b1321751d2ea15deb49ed414"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3391d1e16e2a5a1507d83e4a8b100f4ee626e8eca43cf2cadb543de69827c4c"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e1f145462f1fa6e4a4ae3c0f782e580ce44d57c8f2c7aae1b6fa88c0b2efdb41"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:1a7191e42732df52cb5f39d3527217e7ab73cae2cb3694d241e18f53d84ea9a7"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:0448abc479fab28b00cb472d278828b3ccca164531daab4e970a0458786055d6"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-win32.whl", hash = "sha256:b542be2440edc2d48547b5923c408cbe0fc94afb9f18741faa6ae970dbcb9b6d"},
|
||||
{file = "greenlet-3.0.3-cp37-cp37m-win_amd64.whl", hash = "sha256:01bc7ea167cf943b4c802068e178bbf70ae2e8c080467070d01bfa02f337ee67"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-macosx_11_0_universal2.whl", hash = "sha256:1996cb9306c8595335bb157d133daf5cf9f693ef413e7673cb07e3e5871379ca"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3ddc0f794e6ad661e321caa8d2f0a55ce01213c74722587256fb6566049a8b04"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c9db1c18f0eaad2f804728c67d6c610778456e3e1cc4ab4bbd5eeb8e6053c6fc"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7170375bcc99f1a2fbd9c306f5be8764eaf3ac6b5cb968862cad4c7057756506"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b66c9c1e7ccabad3a7d037b2bcb740122a7b17a53734b7d72a344ce39882a1b"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:098d86f528c855ead3479afe84b49242e174ed262456c342d70fc7f972bc13c4"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:81bb9c6d52e8321f09c3d165b2a78c680506d9af285bfccbad9fb7ad5a5da3e5"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:fd096eb7ffef17c456cfa587523c5f92321ae02427ff955bebe9e3c63bc9f0da"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-win32.whl", hash = "sha256:d46677c85c5ba00a9cb6f7a00b2bfa6f812192d2c9f7d9c4f6a55b60216712f3"},
|
||||
{file = "greenlet-3.0.3-cp38-cp38-win_amd64.whl", hash = "sha256:419b386f84949bf0e7c73e6032e3457b82a787c1ab4a0e43732898a761cc9dbf"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:da70d4d51c8b306bb7a031d5cff6cc25ad253affe89b70352af5f1cb68e74b53"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:086152f8fbc5955df88382e8a75984e2bb1c892ad2e3c80a2508954e52295257"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d73a9fe764d77f87f8ec26a0c85144d6a951a6c438dfe50487df5595c6373eac"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7dcbe92cc99f08c8dd11f930de4d99ef756c3591a5377d1d9cd7dd5e896da71"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1551a8195c0d4a68fac7a4325efac0d541b48def35feb49d803674ac32582f61"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64d7675ad83578e3fc149b617a444fab8efdafc9385471f868eb5ff83e446b8b"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b37eef18ea55f2ffd8f00ff8fe7c8d3818abd3e25fb73fae2ca3b672e333a7a6"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:77457465d89b8263bca14759d7c1684df840b6811b2499838cc5b040a8b5b113"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-win32.whl", hash = "sha256:57e8974f23e47dac22b83436bdcf23080ade568ce77df33159e019d161ce1d1e"},
|
||||
{file = "greenlet-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:c5ee858cfe08f34712f548c3c363e807e7186f03ad7a5039ebadb29e8c6be067"},
|
||||
{file = "greenlet-3.0.3.tar.gz", hash = "sha256:43374442353259554ce33599da8b692d5aa96f8976d567d4badf263371fbe491"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c49e9f7c6f625507ed83a7485366b46cbe325717c60837f7244fc99ba16ba9d6"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3cc1a3ed00ecfea8932477f729a9f616ad7347a5e55d50929efa50a86cb7be7"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c9896249fbef2c615853b890ee854f22c671560226c9221cfd27c995db97e5c"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7409796591d879425997a518138889d8d17e63ada7c99edc0d7a1c22007d4907"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7791dcb496ec53d60c7f1c78eaa156c21f402dda38542a00afc3e20cae0f480f"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8009ae46259e31bc73dc183e402f548e980c96f33a6ef58cc2e7865db012e13"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:fd9fb7c941280e2c837b603850efc93c999ae58aae2b40765ed682a6907ebbc5"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:00cd814b8959b95a546e47e8d589610534cfb71f19802ea8a2ad99d95d702057"},
|
||||
{file = "greenlet-3.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:d0cb7d47199001de7658c213419358aa8937df767936506db0db7ce1a71f4a2f"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:dcb9cebbf3f62cb1e5afacae90761ccce0effb3adaa32339a0670fe7805d8068"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf3fc9145141250907730886b031681dfcc0de1c158f3cc51c092223c0f381ce"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:efcdfb9df109e8a3b475c016f60438fcd4be68cd13a365d42b35914cdab4bb2b"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4bd139e4943547ce3a56ef4b8b1b9479f9e40bb47e72cc906f0f66b9d0d5cab3"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71566302219b17ca354eb274dfd29b8da3c268e41b646f330e324e3967546a74"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3091bc45e6b0c73f225374fefa1536cd91b1e987377b12ef5b19129b07d93ebe"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:44671c29da26539a5f142257eaba5110f71887c24d40df3ac87f1117df589e0e"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c23ea227847c9dbe0b3910f5c0dd95658b607137614eb821e6cbaecd60d81cc6"},
|
||||
{file = "greenlet-3.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:0a16fb934fcabfdfacf21d79e6fed81809d8cd97bc1be9d9c89f0e4567143d7b"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:df4d1509efd4977e6a844ac96d8be0b9e5aa5d5c77aa27ca9f4d3f92d3fcf330"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da956d534a6d1b9841f95ad0f18ace637668f680b1339ca4dcfb2c1837880a0b"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9c7b15fb9b88d9ee07e076f5a683027bc3befd5bb5d25954bb633c385d8b737e"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:752f0e79785e11180ebd2e726c8a88109ded3e2301d40abced2543aa5d164275"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9ae572c996ae4b5e122331e12bbb971ea49c08cc7c232d1bd43150800a2d6c65"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02f5972ff02c9cf615357c17ab713737cccfd0eaf69b951084a9fd43f39833d3"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fefc7aa68b34b9224490dfda2e70ccf2131368493add64b4ef2d372955c207e"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a31ead8411a027c2c4759113cf2bd473690517494f3d6e4bf67064589afcd3c5"},
|
||||
{file = "greenlet-3.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:b24c7844c0a0afc3ccbeb0b807adeefb7eff2b5599229ecedddcfeb0ef333bec"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3ab7194ee290302ca15449f601036007873028712e92ca15fc76597a0aeb4c59"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2dc5c43bb65ec3669452af0ab10729e8fdc17f87a1f2ad7ec65d4aaaefabf6bf"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:decb0658ec19e5c1f519faa9a160c0fc85a41a7e6654b3ce1b44b939f8bf1325"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6fadd183186db360b61cb34e81117a096bff91c072929cd1b529eb20dd46e6c5"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1919cbdc1c53ef739c94cf2985056bcc0838c1f217b57647cbf4578576c63825"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3885f85b61798f4192d544aac7b25a04ece5fe2704670b4ab73c2d2c14ab740d"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:85f3e248507125bf4af607a26fd6cb8578776197bd4b66e35229cdf5acf1dfbf"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1e76106b6fc55fa3d6fe1c527f95ee65e324a13b62e243f77b48317346559708"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:fe46d4f8e94e637634d54477b0cfabcf93c53f29eedcbdeecaf2af32029b4421"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ba30e88607fb6990544d84caf3c706c4b48f629e18853fc6a646f82db9629418"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:055916fafad3e3388d27dd68517478933a97edc2fc54ae79d3bec827de2c64c4"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2593283bf81ca37d27d110956b79e8723f9aa50c4bcdc29d3c0543d4743d2763"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:89c69e9a10670eb7a66b8cef6354c24671ba241f46152dd3eed447f79c29fb5b"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02a98600899ca1ca5d3a2590974c9e3ec259503b2d6ba6527605fcd74e08e207"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:b50a8c5c162469c3209e5ec92ee4f95c8231b11db6a04db09bbe338176723bb8"},
|
||||
{file = "greenlet-3.2.2-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:45f9f4853fb4cc46783085261c9ec4706628f3b57de3e68bae03e8f8b3c0de51"},
|
||||
{file = "greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-macosx_11_0_universal2.whl", hash = "sha256:1e4747712c4365ef6765708f948acc9c10350719ca0545e362c24ab973017370"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:782743700ab75716650b5238a4759f840bb2dcf7bff56917e9ffdf9f1f23ec59"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:354f67445f5bed6604e493a06a9a49ad65675d3d03477d38a4db4a427e9aad0e"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3aeca9848d08ce5eb653cf16e15bb25beeab36e53eb71cc32569f5f3afb2a3aa"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cb8553ee954536500d88a1a2f58fcb867e45125e600e80f586ade399b3f8819"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1592a615b598643dbfd566bac8467f06c8c8ab6e56f069e573832ed1d5d528cc"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:1f72667cc341c95184f1c68f957cb2d4fc31eef81646e8e59358a10ce6689457"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a8fa80665b1a29faf76800173ff5325095f3e66a78e62999929809907aca5659"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-win32.whl", hash = "sha256:6629311595e3fe7304039c67f00d145cd1d38cf723bb5b99cc987b23c1433d61"},
|
||||
{file = "greenlet-3.2.2-cp39-cp39-win_amd64.whl", hash = "sha256:eeb27bece45c0c2a5842ac4c5a1b5c2ceaefe5711078eed4e8043159fa05c834"},
|
||||
{file = "greenlet-3.2.2.tar.gz", hash = "sha256:ad053d34421a2debba45aa3cc39acf454acbcd025b3fc1a9f8a0dee237abd485"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
@@ -3797,7 +3791,7 @@ version = "0.4.0"
|
||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
|
||||
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
|
||||
@@ -5235,6 +5229,22 @@ files = [
|
||||
[package.dependencies]
|
||||
cobble = ">=0.1.3,<0.2"
|
||||
|
||||
[[package]]
|
||||
name = "markdown"
|
||||
version = "3.8.2"
|
||||
description = "Python implementation of John Gruber's Markdown."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["dev"]
|
||||
files = [
|
||||
{file = "markdown-3.8.2-py3-none-any.whl", hash = "sha256:5c83764dbd4e00bdd94d85a19b8d55ccca20fe35b2e678a1422b380324dd5f24"},
|
||||
{file = "markdown-3.8.2.tar.gz", hash = "sha256:247b9a70dd12e27f67431ce62523e675b866d254f900c4fe75ce3dda62237c45"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["mdx_gh_links (>=0.2)", "mkdocs (>=1.6)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"]
|
||||
testing = ["coverage", "pyyaml"]
|
||||
|
||||
[[package]]
|
||||
name = "markdown-it-py"
|
||||
version = "3.0.0"
|
||||
@@ -5459,7 +5469,7 @@ version = "1.9.2"
|
||||
description = "Model Context Protocol SDK"
|
||||
optional = false
|
||||
python-versions = ">=3.10"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "mcp-1.9.2-py3-none-any.whl", hash = "sha256:bc29f7fd67d157fef378f89a4210384f5fecf1168d0feb12d22929818723f978"},
|
||||
{file = "mcp-1.9.2.tar.gz", hash = "sha256:3c7651c053d635fd235990a12e84509fe32780cd359a5bbef352e20d4d963c05"},
|
||||
@@ -5471,11 +5481,9 @@ httpx = ">=0.27"
|
||||
httpx-sse = ">=0.4"
|
||||
pydantic = ">=2.7.2,<3.0.0"
|
||||
pydantic-settings = ">=2.5.2"
|
||||
python-dotenv = {version = ">=1.0.0", optional = true, markers = "extra == \"cli\""}
|
||||
python-multipart = ">=0.0.9"
|
||||
sse-starlette = ">=1.6.1"
|
||||
starlette = ">=0.27"
|
||||
typer = {version = ">=0.12.4", optional = true, markers = "extra == \"cli\""}
|
||||
uvicorn = {version = ">=0.23.1", markers = "sys_platform != \"emscripten\""}
|
||||
|
||||
[package.extras]
|
||||
@@ -7050,24 +7058,25 @@ type = ["mypy (>=1.14.1)"]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.44.0"
|
||||
version = "1.52.0"
|
||||
description = "A high-level API to automate web browsers"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "playwright-1.44.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:c2317a80896796fdeb03d60f06cc229e775ff2e19b80c64b1bb9b29c8a59d992"},
|
||||
{file = "playwright-1.44.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:54d44fb634d870839301c2326e1e12a178a1be0de76d0caaec230ab075c2e077"},
|
||||
{file = "playwright-1.44.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:64b67194e73b47ae72acf25f1a9cfacfef38ca2b52e4bb8b0abd385c5deeaadf"},
|
||||
{file = "playwright-1.44.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:29161b1fae71f7c402df5b15f0bd3deaeecd8b3d1ecd9ff01271700c66210e7b"},
|
||||
{file = "playwright-1.44.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8c8a3bfea17576d3f94a2363eee195cbda8dbba86975588c7eaac7792b25eee"},
|
||||
{file = "playwright-1.44.0-py3-none-win32.whl", hash = "sha256:235e37832deaa9af8a629d09955396259ab757533cc1922f9b0308b4ee0d9cdf"},
|
||||
{file = "playwright-1.44.0-py3-none-win_amd64.whl", hash = "sha256:5b8a4a1d4d50f4ff99b47965576322a8c4e34631854b862a25c1feb824be22a8"},
|
||||
{file = "playwright-1.52.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:19b2cb9d4794062008a635a99bd135b03ebb782d460f96534a91cb583f549512"},
|
||||
{file = "playwright-1.52.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0797c0479cbdc99607412a3c486a3a2ec9ddc77ac461259fd2878c975bcbb94a"},
|
||||
{file = "playwright-1.52.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:7223960b7dd7ddeec1ba378c302d1d09733b8dac438f492e9854c85d3ca7144f"},
|
||||
{file = "playwright-1.52.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:d010124d24a321e0489a8c0d38a3971a7ca7656becea7656c9376bfea7f916d4"},
|
||||
{file = "playwright-1.52.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4173e453c43180acc60fd77ffe1ebee8d0efbfd9986c03267007b9c3845415af"},
|
||||
{file = "playwright-1.52.0-py3-none-win32.whl", hash = "sha256:cd0bdf92df99db6237a99f828e80a6a50db6180ef8d5352fc9495df2c92f9971"},
|
||||
{file = "playwright-1.52.0-py3-none-win_amd64.whl", hash = "sha256:dcbf75101eba3066b7521c6519de58721ea44379eb17a0dafa94f9f1b17f59e4"},
|
||||
{file = "playwright-1.52.0-py3-none-win_arm64.whl", hash = "sha256:9d0085b8de513de5fb50669f8e6677f0252ef95a9a1d2d23ccee9638e71e65cb"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
greenlet = "3.0.3"
|
||||
pyee = "11.1.0"
|
||||
greenlet = ">=3.1.1,<4.0.0"
|
||||
pyee = ">=13,<14"
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
@@ -7671,7 +7680,7 @@ version = "2.9.1"
|
||||
description = "Settings management using Pydantic"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef"},
|
||||
{file = "pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268"},
|
||||
@@ -7723,21 +7732,21 @@ files = [
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "11.1.0"
|
||||
version = "13.0.0"
|
||||
description = "A rough port of Node.js's EventEmitter to Python with a few tricks of its own"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "evaluation", "test"]
|
||||
files = [
|
||||
{file = "pyee-11.1.0-py3-none-any.whl", hash = "sha256:5d346a7d0f861a4b2e6c47960295bd895f816725b27d656181947346be98d7c1"},
|
||||
{file = "pyee-11.1.0.tar.gz", hash = "sha256:b53af98f6990c810edd9b56b87791021a8f54fd13db4edd1142438d44ba2263f"},
|
||||
{file = "pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498"},
|
||||
{file = "pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
|
||||
dev = ["black", "build", "flake8", "flake8-black", "isort", "jupyter-console", "mkdocs", "mkdocs-include-markdown-plugin", "mkdocstrings[python]", "mypy", "pytest", "pytest-asyncio ; python_version >= \"3.4\"", "pytest-trio ; python_version >= \"3.7\"", "sphinx", "toml", "tox", "trio", "trio ; python_version > \"3.6\"", "trio-typing ; python_version > \"3.6\"", "twine", "twisted", "validate-pyproject[all]"]
|
||||
|
||||
[[package]]
|
||||
name = "pyflakes"
|
||||
@@ -8195,7 +8204,7 @@ version = "0.0.20"
|
||||
description = "A streaming multipart parser for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104"},
|
||||
{file = "python_multipart-0.0.20.tar.gz", hash = "sha256:8dd0cab45b8e23064ae09147625994d090fa46f5b0d1e13af944c331a7fa9d13"},
|
||||
@@ -9630,7 +9639,7 @@ version = "2.4.1"
|
||||
description = "SSE plugin for Starlette"
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "sse_starlette-2.4.1-py3-none-any.whl", hash = "sha256:08b77ea898ab1a13a428b2b6f73cfe6d0e607a7b4e15b9bb23e4a37b087fd39a"},
|
||||
{file = "sse_starlette-2.4.1.tar.gz", hash = "sha256:7c8a800a1ca343e9165fc06bbda45c78e4c6166320707ae30b416c42da070926"},
|
||||
@@ -9701,7 +9710,7 @@ version = "0.46.2"
|
||||
description = "The little ASGI library that shines."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35"},
|
||||
{file = "starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5"},
|
||||
@@ -10733,7 +10742,7 @@ version = "0.35.0"
|
||||
description = "The lightning-fast ASGI server."
|
||||
optional = false
|
||||
python-versions = ">=3.9"
|
||||
groups = ["main", "evaluation"]
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a"},
|
||||
{file = "uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01"},
|
||||
@@ -11857,4 +11866,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "4aabe341a78e439a0cc9dead9f03f49c75bbe7f8b1287269e62961d88af04468"
|
||||
content-hash = "7fb3d77f015b78ea0100583a7ac61b8e9cccd1029db35bebedb8a87ffa8693c3"
|
||||
|
||||
@@ -41,7 +41,7 @@ types-toml = "*"
|
||||
uvicorn = "*"
|
||||
numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.14.2" # integrate browsergym-core as the browsing interface
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
deprecated = "*"
|
||||
pexpect = "*"
|
||||
@@ -115,6 +115,7 @@ pre-commit = "4.2.0"
|
||||
build = "*"
|
||||
types-setuptools = "*"
|
||||
pytest = "^8.4.0"
|
||||
markdown = "^3.8.2"
|
||||
|
||||
[tool.poetry.group.test]
|
||||
optional = true
|
||||
@@ -156,10 +157,10 @@ gdown = "*"
|
||||
matplotlib = "*"
|
||||
seaborn = "*"
|
||||
tabulate = "*"
|
||||
browsergym = "0.14.2"
|
||||
browsergym-webarena = "0.14.2"
|
||||
browsergym-miniwob = "0.14.2"
|
||||
browsergym-visualwebarena = "0.14.2"
|
||||
browsergym = "0.13.3"
|
||||
browsergym-webarena = "0.13.3"
|
||||
browsergym-miniwob = "0.13.3"
|
||||
browsergym-visualwebarena = "0.13.3"
|
||||
boto3-stubs = { extras = [ "s3" ], version = "^1.37.19" }
|
||||
# transitive dependency, pinned here to avoid conflicts
|
||||
pyarrow = "21.0.0"
|
||||
|
||||
@@ -6,10 +6,10 @@ from openhands.cli.tui import (
|
||||
CustomDiffLexer,
|
||||
UsageMetrics,
|
||||
UserCancelledError,
|
||||
_render_basic_markdown,
|
||||
display_banner,
|
||||
display_command,
|
||||
display_event,
|
||||
display_file_edit,
|
||||
display_mcp_action,
|
||||
display_mcp_errors,
|
||||
display_mcp_observation,
|
||||
@@ -264,6 +264,113 @@ class TestDisplayFunctions:
|
||||
container = mock_print_container.call_args[0][0]
|
||||
assert 'echo test' in container.body.text
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
def test_display_file_edit_new_file_creation_shows_no_changes(
|
||||
self, mock_print_container
|
||||
):
|
||||
"""Test that creating a new file from scratch shows '0 changes' message when old_content equals new_content."""
|
||||
# This reproduces the issue where agents creating new files show "0 changes"
|
||||
# when both old_content and new_content are empty/None
|
||||
file_edit_obs = FileEditObservation(
|
||||
path='/home/xingyaow/OpenHands-eval/hello.py',
|
||||
content='File created successfully',
|
||||
old_content='', # Empty for new file
|
||||
new_content='', # Also empty, causing the issue
|
||||
)
|
||||
|
||||
display_file_edit(file_edit_obs)
|
||||
|
||||
mock_print_container.assert_called_once()
|
||||
container = mock_print_container.call_args[0][0]
|
||||
displayed_text = container.body.text
|
||||
|
||||
# This should show the problematic message
|
||||
assert (
|
||||
'(no changes detected. Please make sure your edits change the content of the existing file.)'
|
||||
in displayed_text
|
||||
)
|
||||
# The "0 changes" message is not shown when there are truly no changes detected
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
def test_display_file_edit_shows_zero_changes_message(self, mock_print_container):
|
||||
"""Test that file edit shows '0 changes' message when old_content equals new_content but not empty."""
|
||||
# This reproduces the issue where agents show "0 changes" message
|
||||
# This happens when the file content before and after are the same
|
||||
file_edit_obs = FileEditObservation(
|
||||
path='/home/xingyaow/OpenHands-eval/hello.py',
|
||||
content='File created successfully',
|
||||
old_content='print("hello")', # Same content
|
||||
new_content='print("hello")', # Same content, causing the issue
|
||||
)
|
||||
|
||||
display_file_edit(file_edit_obs)
|
||||
|
||||
mock_print_container.assert_called_once()
|
||||
container = mock_print_container.call_args[0][0]
|
||||
displayed_text = container.body.text
|
||||
|
||||
# This should show the problematic message
|
||||
assert (
|
||||
'(no changes detected. Please make sure your edits change the content of the existing file.)'
|
||||
in displayed_text
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
def test_display_file_edit_shows_zero_changes_with_empty_edit_groups(
|
||||
self, mock_print_container
|
||||
):
|
||||
"""Test that file edit shows '0 changes' message when edit_groups is empty but contents are different."""
|
||||
# This reproduces the issue where agents show "0 changes" message
|
||||
# This happens when old_content != new_content but get_edit_groups returns empty list
|
||||
# For example, when old_content is None and new_content is empty string
|
||||
file_edit_obs = FileEditObservation(
|
||||
path='/home/xingyaow/OpenHands-eval/hello.py',
|
||||
content='File created successfully',
|
||||
old_content=None, # None for new file
|
||||
new_content='', # Empty string, different from None
|
||||
)
|
||||
|
||||
display_file_edit(file_edit_obs)
|
||||
|
||||
mock_print_container.assert_called_once()
|
||||
container = mock_print_container.call_args[0][0]
|
||||
displayed_text = container.body.text
|
||||
|
||||
# This should show the "0 changes" message since get_edit_groups returns empty list
|
||||
# when old_content is None
|
||||
assert (
|
||||
'[Existing file /home/xingyaow/OpenHands-eval/hello.py is edited with 0 changes.]'
|
||||
in displayed_text
|
||||
)
|
||||
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
def test_display_file_edit_new_file_creation_with_content(
|
||||
self, mock_print_container
|
||||
):
|
||||
"""Test that creating a new file with actual content shows proper diff."""
|
||||
# This shows how it should work when new_content has actual content
|
||||
file_edit_obs = FileEditObservation(
|
||||
path='/home/xingyaow/OpenHands-eval/hello.py',
|
||||
content='File created successfully',
|
||||
old_content='', # Empty for new file
|
||||
new_content='print("Hello, World!")\n', # Actual content
|
||||
)
|
||||
|
||||
display_file_edit(file_edit_obs)
|
||||
|
||||
mock_print_container.assert_called_once()
|
||||
container = mock_print_container.call_args[0][0]
|
||||
displayed_text = container.body.text
|
||||
|
||||
# This should NOT show the "no changes detected" message
|
||||
assert (
|
||||
'(no changes detected. Please make sure your edits change the content of the existing file.)'
|
||||
not in displayed_text
|
||||
)
|
||||
# Should show that changes were made
|
||||
assert 'is edited with' in displayed_text
|
||||
assert 'changes.]' in displayed_text
|
||||
|
||||
|
||||
class TestInteractiveCommandFunctions:
|
||||
@patch('openhands.cli.tui.print_container')
|
||||
@@ -399,34 +506,34 @@ class TestReadConfirmationInput:
|
||||
mock_confirm.return_value = 2 # user picked third menu item
|
||||
|
||||
|
||||
class TestMarkdownRendering:
|
||||
def test_empty_string(self):
|
||||
assert _render_basic_markdown('') == ''
|
||||
|
||||
def test_plain_text(self):
|
||||
assert _render_basic_markdown('hello world') == 'hello world'
|
||||
|
||||
def test_bold(self):
|
||||
assert _render_basic_markdown('**bold**') == '<b>bold</b>'
|
||||
|
||||
def test_underline(self):
|
||||
assert _render_basic_markdown('__under__') == '<u>under</u>'
|
||||
|
||||
def test_combined(self):
|
||||
assert (
|
||||
_render_basic_markdown('mix **bold** and __under__ here')
|
||||
== 'mix <b>bold</b> and <u>under</u> here'
|
||||
)
|
||||
|
||||
def test_html_is_escaped(self):
|
||||
assert _render_basic_markdown('<script>alert(1)</script>') == (
|
||||
'<script>alert(1)</script>'
|
||||
)
|
||||
|
||||
def test_bold_with_special_chars(self):
|
||||
assert _render_basic_markdown('**a < b & c > d**') == (
|
||||
'<b>a < b & c > d</b>'
|
||||
)
|
||||
# class TestMarkdownRendering:
|
||||
# def test_empty_string(self):
|
||||
# assert _render_basic_markdown('') == ''
|
||||
#
|
||||
# def test_plain_text(self):
|
||||
# assert _render_basic_markdown('hello world') == 'hello world'
|
||||
#
|
||||
# def test_bold(self):
|
||||
# assert _render_basic_markdown('**bold**') == '<b>bold</b>'
|
||||
#
|
||||
# def test_underline(self):
|
||||
# assert _render_basic_markdown('__under__') == '<u>under</u>'
|
||||
#
|
||||
# def test_combined(self):
|
||||
# assert (
|
||||
# _render_basic_markdown('mix **bold** and __under__ here')
|
||||
# == 'mix <b>bold</b> and <u>under</u> here'
|
||||
# )
|
||||
#
|
||||
# def test_html_is_escaped(self):
|
||||
# assert _render_basic_markdown('<script>alert(1)</script>') == (
|
||||
# '<script>alert(1)</script>'
|
||||
# )
|
||||
#
|
||||
# def test_bold_with_special_chars(self):
|
||||
# assert _render_basic_markdown('**a < b & c > d**') == (
|
||||
# '<b>a < b & c > d</b>'
|
||||
# )
|
||||
|
||||
|
||||
"""Tests for CLI TUI MCP functionality."""
|
||||
|
||||
@@ -86,15 +86,6 @@ def test_model_matches_provider_qualified(name, pattern, expected):
|
||||
supports_stop_words=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'gpt-5-mini-2025-08-07',
|
||||
ModelFeatures(
|
||||
supports_function_calling=True,
|
||||
supports_reasoning_effort=True,
|
||||
supports_prompt_cache=False,
|
||||
supports_stop_words=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
'o3-mini',
|
||||
ModelFeatures(
|
||||
@@ -181,7 +172,6 @@ def test_get_features(model, expect):
|
||||
'gpt-4o',
|
||||
'gpt-4.1',
|
||||
'gpt-5',
|
||||
'gpt-5-mini-2025-08-07',
|
||||
# o-series
|
||||
'o1-2024-12-17',
|
||||
'o3-mini',
|
||||
@@ -209,7 +199,6 @@ def test_function_calling_models(model):
|
||||
'gemini-2.5-flash',
|
||||
'gemini-2.5-pro',
|
||||
'gpt-5',
|
||||
'gpt-5-mini-2025-08-07',
|
||||
],
|
||||
)
|
||||
def test_reasoning_effort_models(model):
|
||||
@@ -263,7 +252,6 @@ def test_prompt_cache_models(model):
|
||||
('gemini-2.5-pro', True),
|
||||
('gpt-5', True),
|
||||
('gpt-5-2025-08-07', True),
|
||||
('gpt-5-mini-2025-08-07', True),
|
||||
('claude-opus-4-1-20250805', False),
|
||||
# DeepSeek
|
||||
('deepseek/DeepSeek-R1-0528:671b-Q4_K_XL', True),
|
||||
|
||||
@@ -489,7 +489,7 @@ def test_amortized_forgetting_condenser_keeps_first_and_last_events():
|
||||
|
||||
events = [create_test_event(f'Event {i}', id=i) for i in range(max_size * 10)]
|
||||
|
||||
# To ensure the most recent event is always recorded, track it in a non-local variable updated
|
||||
# To ensure the most recent event is always recorded, track it in a non-local variable udpated
|
||||
# with a closure we'll pass to the view generator as a callback.
|
||||
most_recent_event: Event | None = None
|
||||
|
||||
@@ -773,7 +773,7 @@ def test_structured_summary_condenser_keeps_first_and_summary_events(
|
||||
i, max_size
|
||||
)
|
||||
|
||||
# Ensure that the prefix is appropriately maintained
|
||||
# Ensure that the prefix is appropiately maintained
|
||||
assert view[:keep_first] == events[: min(keep_first, i + 1)]
|
||||
|
||||
# If we've condensed, ensure that the summary event is present
|
||||
|
||||
@@ -1574,12 +1574,8 @@ def test_process_ipython_observation_with_vision_disabled(
|
||||
vision_is_active=False,
|
||||
)
|
||||
|
||||
# Check that the message contains both text and image content
|
||||
# (ImageContent is always included, filtering happens at Message serialization level)
|
||||
# Check that the message contains only text content
|
||||
assert len(messages) == 1
|
||||
message = messages[0]
|
||||
assert len(message.content) == 2
|
||||
assert len(message.content) == 1
|
||||
assert isinstance(message.content[0], TextContent)
|
||||
assert isinstance(message.content[1], ImageContent)
|
||||
# Check that NO explanatory text about filtered images was added when vision is disabled
|
||||
assert 'invalid or empty image(s) were filtered' not in message.content[0].text
|
||||
|
||||
@@ -166,10 +166,7 @@ def test_generate_dockerfile_build_from_scratch():
|
||||
assert 'python=3.12' in dockerfile_content
|
||||
|
||||
# Check the update command
|
||||
assert (
|
||||
'COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands'
|
||||
in dockerfile_content
|
||||
)
|
||||
assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
|
||||
assert (
|
||||
'/openhands/micromamba/bin/micromamba run -n openhands poetry install'
|
||||
in dockerfile_content
|
||||
@@ -191,10 +188,7 @@ def test_generate_dockerfile_build_from_lock():
|
||||
assert 'poetry install' not in dockerfile_content
|
||||
|
||||
# These update commands SHOULD still in the dockerfile
|
||||
assert (
|
||||
'COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands'
|
||||
in dockerfile_content
|
||||
)
|
||||
assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
|
||||
|
||||
|
||||
def test_generate_dockerfile_build_from_versioned():
|
||||
@@ -212,10 +206,7 @@ def test_generate_dockerfile_build_from_versioned():
|
||||
|
||||
# this SHOULD exist when build from versioned
|
||||
assert 'poetry install' in dockerfile_content
|
||||
assert (
|
||||
'COPY --chown=openhands:openhands ./code/openhands /openhands/code/openhands'
|
||||
in dockerfile_content
|
||||
)
|
||||
assert 'COPY ./code/openhands /openhands/code/openhands' in dockerfile_content
|
||||
|
||||
|
||||
def test_get_runtime_image_repo_and_tag_eventstream():
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from types import MappingProxyType
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
@@ -213,28 +213,22 @@ async def test_export_latest_git_provider_tokens_multiple_refs(temp_dir):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_latest_git_provider_tokens_token_update(runtime, monkeypatch):
|
||||
async def test_export_latest_git_provider_tokens_token_update(runtime):
|
||||
"""Test that token updates are handled correctly"""
|
||||
# First export with initial token
|
||||
cmd = CmdRunAction(command='echo $GITHUB_TOKEN')
|
||||
await runtime._export_latest_git_provider_tokens(cmd)
|
||||
|
||||
# Ensure refresh-token flow is enabled in ProviderHandler
|
||||
monkeypatch.setenv('WEB_HOST', 'example.com')
|
||||
|
||||
# Simulate that provider handler will now fetch a new token from refresh endpoint
|
||||
# Update the token
|
||||
new_token = 'new_test_token'
|
||||
runtime.provider_handler._provider_tokens = MappingProxyType(
|
||||
{ProviderType.GITHUB: ProviderToken(token=SecretStr(new_token))}
|
||||
)
|
||||
|
||||
# Patch ProviderHandler._get_latest_provider_token to return new SecretStr
|
||||
with patch.object(
|
||||
ProviderHandler,
|
||||
'_get_latest_provider_token',
|
||||
new=AsyncMock(return_value=SecretStr(new_token)),
|
||||
):
|
||||
# Export again with updated token – runtime should fetch latest and update EventStream secrets
|
||||
await runtime._export_latest_git_provider_tokens(cmd)
|
||||
# Export again with updated token
|
||||
await runtime._export_latest_git_provider_tokens(cmd)
|
||||
|
||||
# Verify that the new token was exported to the event stream
|
||||
# Verify that the new token was exported
|
||||
assert runtime.event_stream.secrets == {'github_token': new_token}
|
||||
|
||||
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.integrations.provider import ProviderHandler
|
||||
from openhands.server.data_models.conversation_info_result_set import (
|
||||
ConversationInfoResultSet,
|
||||
)
|
||||
from openhands.server.routes.manage_conversations import (
|
||||
get_microagent_management_conversations,
|
||||
)
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import (
|
||||
ConversationMetadata,
|
||||
ConversationTrigger,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_success():
|
||||
"""Test successful retrieval of microagent management conversations."""
|
||||
# Mock data
|
||||
page_id = 'test_page_123'
|
||||
limit = 10
|
||||
selected_repository = 'owner/repo'
|
||||
|
||||
# Create mock conversations
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id='next_page_456')
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id='next_page_456'
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400 # 24 hours
|
||||
|
||||
# Call the function with correct parameter order
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository=selected_repository,
|
||||
page_id=page_id,
|
||||
limit=limit,
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, ConversationInfoResultSet)
|
||||
assert result.next_page_id == 'next_page_456'
|
||||
|
||||
# Verify conversation store was called correctly
|
||||
mock_conversation_store.search.assert_called_once_with(page_id, limit)
|
||||
|
||||
# Verify provider handler was created with correct tokens
|
||||
mock_provider_handler.is_pr_open.assert_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_no_results():
|
||||
"""Test when no conversations match the criteria."""
|
||||
# Mock conversation store with empty results
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=[], next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function with required selected_repository parameter
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify the result
|
||||
assert isinstance(result, ConversationInfoResultSet)
|
||||
assert result.next_page_id is None
|
||||
assert len(result.results) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_filter_by_repository():
|
||||
"""Test filtering conversations by selected repository."""
|
||||
# Create mock conversations with different repositories
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo1',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo2',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only repo1 should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function with repository filter
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo1',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only conversations from the specified repository are returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
assert result.results[0].selected_repository == 'owner/repo1'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_filter_by_trigger():
|
||||
"""Test that only microagent_management conversations are returned."""
|
||||
# Create mock conversations with different triggers
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.GUI, # Different trigger
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only microagent_management should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only microagent_management conversations are returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
assert result.results[0].trigger == ConversationTrigger.MICROAGENT_MANAGEMENT
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_filter_inactive_pr():
|
||||
"""Test filtering out conversations with inactive PRs."""
|
||||
# Create mock conversations
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_2',
|
||||
user_id='user_2',
|
||||
title='Test Conversation 2',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler with one active and one inactive PR
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(side_effect=[True, False])
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only active PR should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[mock_conversations[0]], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only conversations with active PRs are returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
|
||||
# Verify provider handler was called for both PRs
|
||||
assert mock_provider_handler.is_pr_open.call_count == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_no_pr_number():
|
||||
"""Test conversations without PR numbers are included."""
|
||||
# Create mock conversations without PR numbers
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=[], # No PR number
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=mock_conversations, next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify conversation without PR number is included
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_1'
|
||||
|
||||
# Verify provider handler was not called (no PR to check)
|
||||
mock_provider_handler.is_pr_open.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_no_repository():
|
||||
"""Test conversations without selected repository are filtered out for PR checks."""
|
||||
# Create mock conversations without repository
|
||||
mock_conversations = [
|
||||
ConversationMetadata(
|
||||
conversation_id='conv_1',
|
||||
user_id='user_1',
|
||||
title='Test Conversation 1',
|
||||
selected_repository=None, # No repository
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
last_updated_at=datetime.now(timezone.utc),
|
||||
),
|
||||
]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - conversation should be filtered out due to repository mismatch
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify conversation without repository is filtered out
|
||||
assert len(result.results) == 0
|
||||
|
||||
# Verify provider handler was not called (no repository for PR check)
|
||||
mock_provider_handler.is_pr_open.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_age_filter():
|
||||
"""Test that conversations are filtered by age."""
|
||||
# Create mock conversations with different ages
|
||||
now = datetime.now(timezone.utc)
|
||||
old_conversation = ConversationMetadata(
|
||||
conversation_id='conv_old',
|
||||
user_id='user_1',
|
||||
title='Old Conversation',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['123'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=now.replace(year=now.year - 1), # Very old
|
||||
last_updated_at=now.replace(year=now.year - 1),
|
||||
)
|
||||
|
||||
recent_conversation = ConversationMetadata(
|
||||
conversation_id='conv_recent',
|
||||
user_id='user_2',
|
||||
title='Recent Conversation',
|
||||
selected_repository='owner/repo',
|
||||
git_provider='github',
|
||||
pr_number=['456'],
|
||||
trigger=ConversationTrigger.MICROAGENT_MANAGEMENT,
|
||||
created_at=now, # Recent
|
||||
last_updated_at=now,
|
||||
)
|
||||
|
||||
mock_conversations = [old_conversation, recent_conversation]
|
||||
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=mock_conversations, next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
# Mock provider handler
|
||||
mock_provider_handler = MagicMock(spec=ProviderHandler)
|
||||
mock_provider_handler.is_pr_open = AsyncMock(return_value=True)
|
||||
|
||||
with (
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations.ProviderHandler',
|
||||
return_value=mock_provider_handler,
|
||||
),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function - only recent conversation should be included
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[recent_conversation], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config with short max age
|
||||
mock_config.conversation_max_age_seconds = 3600 # 1 hour
|
||||
|
||||
# Call the function
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify only recent conversation is returned
|
||||
assert len(result.results) == 1
|
||||
assert result.results[0].conversation_id == 'conv_recent'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_pagination():
|
||||
"""Test pagination functionality."""
|
||||
# Mock conversation store with pagination
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=[], next_page_id='next_page_789')
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id='next_page_789'
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function with pagination parameters
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
page_id='test_page',
|
||||
limit=5,
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify pagination parameters were passed correctly
|
||||
mock_conversation_store.search.assert_called_once_with('test_page', 5)
|
||||
assert result.next_page_id == 'next_page_789'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_microagent_management_conversations_default_parameters():
|
||||
"""Test default parameter values."""
|
||||
# Mock conversation store
|
||||
mock_conversation_store = MagicMock(spec=ConversationStore)
|
||||
mock_conversation_store.search = AsyncMock(
|
||||
return_value=MagicMock(results=[], next_page_id=None)
|
||||
)
|
||||
|
||||
# Mock provider tokens
|
||||
mock_provider_tokens = {'github': 'token_123'}
|
||||
|
||||
with (
|
||||
patch('openhands.server.routes.manage_conversations.ProviderHandler'),
|
||||
patch(
|
||||
'openhands.server.routes.manage_conversations._build_conversation_result_set'
|
||||
) as mock_build_result,
|
||||
patch('openhands.server.routes.manage_conversations.config') as mock_config,
|
||||
):
|
||||
# Mock the build result function
|
||||
mock_build_result.return_value = ConversationInfoResultSet(
|
||||
results=[], next_page_id=None
|
||||
)
|
||||
|
||||
# Mock config
|
||||
mock_config.conversation_max_age_seconds = 86400
|
||||
|
||||
# Call the function without parameters (selected_repository is required)
|
||||
result = await get_microagent_management_conversations(
|
||||
selected_repository='owner/repo',
|
||||
conversation_store=mock_conversation_store,
|
||||
provider_tokens=mock_provider_tokens,
|
||||
)
|
||||
|
||||
# Verify default values were used
|
||||
mock_conversation_store.search.assert_called_once_with(None, 20)
|
||||
assert isinstance(result, ConversationInfoResultSet)
|
||||
Reference in New Issue
Block a user