Compare commits

..

5 Commits

Author SHA1 Message Date
mamoodi
a4ee454453 Merge branch 'main' into mh/rel0210 2025-01-21 17:40:41 -05:00
mamoodi
932db86b6e Merge branch 'main' into mh/rel0210 2025-01-21 14:25:49 -05:00
Xingyao Wang
d9bbe2a60b fix(remote_runtime): define runtime_id first to fix attrbute error (#6393) 2025-01-21 13:15:12 -05:00
Calvin Smith
0aa927dc88 fix: Settings modal properly tracks if an API key is set (#6394)
Co-authored-by: Calvin Smith <calvin@all-hands.dev>
2025-01-21 13:07:13 -05:00
mamoodi
e034e31ce1 Release branch for 0.21.0 2025-01-21 12:18:33 -05:00
94 changed files with 810 additions and 3721 deletions

View File

@@ -160,6 +160,7 @@ jobs:
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for DelegatorAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
@@ -173,42 +174,12 @@ jobs:
cat $REPORT_FILE_DELEGATOR_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
# -------------------------------------------------------------
# Run VisualBrowsingAgent tests for DeepSeek, limited to t05 and t06
- name: Wait a little bit (again)
run: sleep 5
- name: Configure config.toml for testing VisualBrowsingAgent (DeepSeek)
env:
LLM_MODEL: "litellm_proxy/deepseek-chat"
LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
MAX_ITERATIONS: 15
run: |
echo "[llm.eval]" > config.toml
echo "model = \"$LLM_MODEL\"" >> config.toml
echo "api_key = \"$LLM_API_KEY\"" >> config.toml
echo "base_url = \"$LLM_BASE_URL\"" >> config.toml
echo "temperature = 0.0" >> config.toml
- name: Run integration test evaluation for VisualBrowsingAgent (DeepSeek)
env:
SANDBOX_FORCE_REBUILD_RUNTIME: True
run: |
poetry run ./evaluation/integration_tests/scripts/run_infer.sh llm.eval HEAD VisualBrowsingAgent '' 15 $N_PROCESSES "t05_simple_browsing,t06_github_pr_browsing.py" 'visualbrowsing_deepseek_run'
# Find and export the visual browsing agent test results
REPORT_FILE_VISUALBROWSING_DEEPSEEK=$(find evaluation/evaluation_outputs/outputs/integration_tests/VisualBrowsingAgent/deepseek*_maxiter_15_N* -name "report.md" -type f | head -n 1)
echo "REPORT_FILE_VISUALBROWSING_DEEPSEEK: $REPORT_FILE_VISUALBROWSING_DEEPSEEK"
echo "INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK<<EOF" >> $GITHUB_ENV
cat $REPORT_FILE_VISUALBROWSING_DEEPSEEK >> $GITHUB_ENV
echo >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
- name: Create archive of evaluation outputs
run: |
TIMESTAMP=$(date +'%y-%m-%d-%H-%M')
cd evaluation/evaluation_outputs/outputs # Change to the outputs directory
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* integration_tests/VisualBrowsingAgent/* # Only include the actual result directories
tar -czvf ../../../integration_tests_${TIMESTAMP}.tar.gz integration_tests/CodeActAgent/* integration_tests/DelegatorAgent/* # Only include the actual result directories
- name: Upload evaluation results as artifact
uses: actions/upload-artifact@v4
@@ -256,7 +227,4 @@ jobs:
**Integration Tests Report Delegator (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_DELEGATOR_DEEPSEEK }}
---
**Integration Tests Report VisualBrowsing (DeepSeek)**
${{ env.INTEGRATION_TEST_REPORT_VISUALBROWSING_DEEPSEEK }}
---
Download testing outputs (includes both Haiku and DeepSeek results): [Download](${{ steps.upload_results_artifact.outputs.artifact-url }})

View File

@@ -84,10 +84,6 @@ jobs:
run: |
python -m pip index versions openhands-ai > openhands_versions.txt
OPENHANDS_VERSION=$(head -n 1 openhands_versions.txt | awk '{print $2}' | tr -d '()')
# Ensure requirements.txt ends with newline before appending
if [ -f requirements.txt ] && [ -s requirements.txt ]; then
sed -i -e '$a\' requirements.txt
fi
echo "openhands-ai==${OPENHANDS_VERSION}" >> requirements.txt
cat requirements.txt

1
.gitignore vendored
View File

@@ -176,7 +176,6 @@ evaluation/gorilla/data
evaluation/toolqa/data
evaluation/scienceagentbench/benchmark
evaluation/commit0_bench/repos
evaluation/visualcodebench/
# openhands resolver
output/

View File

@@ -75,7 +75,7 @@ workspace_base = "./workspace"
#run_as_openhands = true
# Runtime environment
#runtime = "docker"
#runtime = "eventstream"
# Name of the default agent
#default_agent = "CodeActAgent"

View File

@@ -1,674 +0,0 @@
from collections import Counter
from copy import deepcopy
from difflib import SequenceMatcher
from io import BytesIO
from bs4 import BeautifulSoup, Comment, NavigableString, Tag
import cv2
import numpy as np
import torch
from colormath.color_conversions import convert_color
from colormath.color_diff import delta_e_cie2000
from colormath.color_objects import LabColor, sRGBColor
from PIL import Image, ImageChops, ImageColor
from scipy.optimize import linear_sum_assignment
from transformers import CLIPModel, CLIPProcessor
from openhands.core.logger import openhands_logger as logger
def calculate_similarity(block1, block2):
"""Calculate text similarity between two blocks using SequenceMatcher."""
text_similarity = SequenceMatcher(None, block1['text'], block2['text']).ratio()
return text_similarity
def adjust_cost_for_context(cost_matrix, consecutive_bonus=1.0, window_size=20):
"""Adjust cost matrix by considering context similarity."""
if window_size <= 0:
return cost_matrix
n, m = cost_matrix.shape
adjusted_cost_matrix = np.copy(cost_matrix)
for i in range(n):
for j in range(m):
if adjusted_cost_matrix[i][j] >= -0.5:
continue
nearby_matrix = cost_matrix[
max(0, i - window_size) : min(n, i + window_size + 1),
max(0, j - window_size) : min(m, j + window_size + 1),
]
flattened_array = nearby_matrix.flatten()
sorted_array = np.sort(flattened_array)[::-1]
sorted_array = np.delete(
sorted_array, np.where(sorted_array == cost_matrix[i, j])[0][0]
)
top_k_elements = sorted_array[-window_size * 2 :]
bonus = consecutive_bonus * np.sum(top_k_elements)
adjusted_cost_matrix[i][j] += bonus
return adjusted_cost_matrix
def create_cost_matrix(A, B):
"""Create cost matrix for block matching."""
n = len(A)
m = len(B)
cost_matrix = np.zeros((n, m))
for i in range(n):
for j in range(m):
cost_matrix[i, j] = -calculate_similarity(A[i], B[j])
return cost_matrix
def calculate_distance_max_1d(x1, y1, x2, y2):
"""Calculate maximum 1D distance between points."""
return max(abs(x2 - x1), abs(y2 - y1))
def calculate_ratio(h1, h2):
"""Calculate ratio between two heights."""
return max(h1, h2) / min(h1, h2)
def rgb_to_lab(rgb):
"""Convert RGB color to Lab color space."""
rgb_color = sRGBColor(rgb[0], rgb[1], rgb[2], is_upscaled=True)
lab_color = convert_color(rgb_color, LabColor)
return lab_color
def color_similarity_ciede2000(rgb1, rgb2):
"""Calculate color similarity using CIEDE2000 formula."""
lab1 = rgb_to_lab(rgb1)
lab2 = rgb_to_lab(rgb2)
delta_e = delta_e_cie2000(lab1, lab2)
similarity = max(0, 1 - (delta_e / 100))
return similarity
def merge_blocks_wo_check(block1, block2):
"""Merge two blocks without additional checks."""
merged_text = block1['text'] + ' ' + block2['text']
x_min = min(block1['bbox'][0], block2['bbox'][0])
y_min = min(block1['bbox'][1], block2['bbox'][1])
x_max = max(
block1['bbox'][0] + block1['bbox'][2], block2['bbox'][0] + block2['bbox'][2]
)
y_max = max(
block1['bbox'][1] + block1['bbox'][3], block2['bbox'][1] + block2['bbox'][3]
)
merged_bbox = (x_min, y_min, x_max - x_min, y_max - y_min)
merged_color = tuple(
(color1 + color2) // 2
for color1, color2 in zip(block1['color'], block2['color'])
)
return {'text': merged_text, 'bbox': merged_bbox, 'color': merged_color}
def find_maximum_matching(A, B, consecutive_bonus, window_size):
"""Find maximum matching between two sets of blocks."""
cost_matrix = create_cost_matrix(A, B)
cost_matrix = adjust_cost_for_context(cost_matrix, consecutive_bonus, window_size)
row_ind, col_ind = linear_sum_assignment(cost_matrix)
current_cost = cost_matrix[row_ind, col_ind].tolist()
return list(zip(row_ind, col_ind)), current_cost, cost_matrix
def remove_indices(lst, indices):
"""Remove indices from list in reverse order."""
for index in sorted(indices, reverse=True):
if index < len(lst):
lst.pop(index)
return lst
def merge_blocks_by_list(blocks, merge_list):
"""Merge blocks according to merge list."""
pop_list = []
while merge_list:
i = merge_list[0][0]
j = merge_list[0][1]
blocks[i] = merge_blocks_wo_check(blocks[i], blocks[j])
pop_list.append(j)
merge_list.pop(0)
if merge_list:
new_merge_list = []
for k in range(len(merge_list)):
if (
merge_list[k][0] != i
and merge_list[k][1] != i
and merge_list[k][0] != j
and merge_list[k][1] != j
):
new_merge_list.append(merge_list[k])
merge_list = new_merge_list
remove_indices(blocks, pop_list)
return blocks
def difference_of_means(list1, list2):
"""Calculate difference of means between two lists."""
counter1 = Counter(list1)
counter2 = Counter(list2)
for element in set(list1) & set(list2):
common_count = min(counter1[element], counter2[element])
counter1[element] -= common_count
counter2[element] -= common_count
unique_list1 = [item for item in counter1.elements()]
unique_list2 = [item for item in counter2.elements()]
mean_list1 = sum(unique_list1) / len(unique_list1) if unique_list1 else 0
mean_list2 = sum(unique_list2) / len(unique_list2) if unique_list2 else 0
if mean_list1 - mean_list2 > 0:
if min(unique_list1) > min(unique_list2):
return mean_list1 - mean_list2
return 0.0
return mean_list1 - mean_list2
def find_possible_merge(A, B, consecutive_bonus, window_size, debug=False):
"""Find possible merges between blocks."""
merge_bonus = 0.0
merge_windows = 1
def sortFn(value):
return value[2]
while True:
A_changed = False
B_changed = False
matching, current_cost, cost_matrix = find_maximum_matching(
A, B, merge_bonus, merge_windows
)
if len(A) >= 2:
merge_list = []
for i in range(len(A) - 1):
new_A = deepcopy(A)
new_A[i] = merge_blocks_wo_check(new_A[i], new_A[i + 1])
new_A.pop(i + 1)
updated_matching, updated_cost, _ = find_maximum_matching(
new_A, B, merge_bonus, merge_windows
)
diff = difference_of_means(current_cost, updated_cost)
if diff > 0.05:
merge_list.append([i, i + 1, diff])
merge_list.sort(key=sortFn, reverse=True)
if merge_list:
A_changed = True
A = merge_blocks_by_list(A, merge_list)
matching, current_cost, cost_matrix = find_maximum_matching(
A, B, merge_bonus, merge_windows
)
if len(B) >= 2:
merge_list = []
for i in range(len(B) - 1):
new_B = deepcopy(B)
new_B[i] = merge_blocks_wo_check(new_B[i], new_B[i + 1])
new_B.pop(i + 1)
updated_matching, updated_cost, _ = find_maximum_matching(
A, new_B, merge_bonus, merge_windows
)
diff = difference_of_means(current_cost, updated_cost)
if diff > 0.05:
merge_list.append([i, i + 1, diff])
merge_list.sort(key=sortFn, reverse=True)
if merge_list:
B_changed = True
B = merge_blocks_by_list(B, merge_list)
matching, current_cost, cost_matrix = find_maximum_matching(
A, B, merge_bonus, merge_windows
)
if not A_changed and not B_changed:
break
matching, _, _ = find_maximum_matching(A, B, consecutive_bonus, window_size)
return A, B, matching
def merge_blocks_by_bbox(blocks):
"""Merge blocks with same bounding box."""
merged_blocks = {}
for block in blocks:
bbox = tuple(block['bbox'])
if bbox in merged_blocks:
existing_block = merged_blocks[bbox]
existing_block['text'] += ' ' + block['text']
existing_block['color'] = [
(ec + c) / 2 for ec, c in zip(existing_block['color'], block['color'])
]
else:
merged_blocks[bbox] = block
return list(merged_blocks.values())
def mask_bounding_boxes_with_inpainting(image, bounding_boxes):
"""Mask bounding boxes in image using inpainting."""
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
mask = np.zeros(image_cv.shape[:2], dtype=np.uint8)
height, width = image_cv.shape[:2]
for bbox in bounding_boxes:
x_ratio, y_ratio, w_ratio, h_ratio = bbox
x = int(x_ratio * width)
y = int(y_ratio * height)
w = int(w_ratio * width)
h = int(h_ratio * height)
mask[y : y + h, x : x + w] = 255
inpainted_image = cv2.inpaint(image_cv, mask, 3, cv2.INPAINT_TELEA)
return Image.fromarray(cv2.cvtColor(inpainted_image, cv2.COLOR_BGR2RGB))
def rescale_and_mask(image, blocks):
"""Rescale image and mask blocks."""
if blocks:
image = mask_bounding_boxes_with_inpainting(image, blocks)
width, height = image.size
if width < height:
new_size = (width, width)
else:
new_size = (height, height)
return image.resize(new_size, Image.LANCZOS)
def calculate_clip_similarity(image1, image2, blocks1, blocks2):
"""Calculate CLIP similarity between two images."""
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
device = 'cuda' if torch.cuda.is_available() else 'cpu'
model = model.to(device)
# Mask and preprocess images
image1_masked = rescale_and_mask(image1, [block['bbox'] for block in blocks1])
image2_masked = rescale_and_mask(image2, [block['bbox'] for block in blocks2])
inputs = processor(
images=[image1_masked, image2_masked], return_tensors='pt', padding=True
)
inputs = {k: v.to(device) for k, v in inputs.items()}
# Calculate features and similarity
with torch.no_grad():
image_features = model.get_image_features(**inputs)
image_features1 = image_features[0].unsqueeze(0)
image_features2 = image_features[1].unsqueeze(0)
image_features1 /= image_features1.norm(dim=-1, keepdim=True)
image_features2 /= image_features2.norm(dim=-1, keepdim=True)
similarity = (image_features1 @ image_features2.T).item()
return similarity
def rgb_to_hex(rgb):
"""Convert an RGB tuple to hexadecimal format."""
return '{:02X}{:02X}{:02X}'.format(*rgb)
class ColorPool:
def __init__(self, offset=0):
color_values = list(range(10, 251, 16))
color_list = [((r + offset) % 256, (g + offset) % 256, (b + offset) % 256)
for r in color_values for g in color_values for b in color_values]
self.color_pool = [rgb_to_hex(color) for color in color_list]
def pop_color(self):
if self.color_pool:
return self.color_pool.pop()
else:
raise NotImplementedError
def process_html_str(html_str, offset=0):
"""Process HTML string to assign unique colors to text elements."""
soup = BeautifulSoup(html_str, 'html.parser')
def update_style(element, property_name, value):
important_value = f"{value} !important"
styles = element.attrs.get('style', '').split(';')
updated_styles = [s for s in styles if not s.strip().startswith(property_name) and len(s.strip()) > 0]
updated_styles.append(f"{property_name}: {important_value}")
element['style'] = '; '.join(updated_styles).strip()
# Set background color of all elements to transparent white
for element in soup.find_all(True):
update_style(element, 'background-color', 'rgba(255, 255, 255, 0.0)')
color_pool = ColorPool(offset)
text_tags = ['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'div', 'span', 'a', 'b', 'li',
'table', 'td', 'th', 'button', 'footer', 'header', 'figcaption']
for tag in soup.find_all(text_tags):
color = f"#{color_pool.pop_color()}"
update_style(tag, 'color', color)
update_style(tag, 'opacity', '1.0')
return str(soup)
def similar(n1, n2):
"""Check if two numbers are similar within a threshold."""
return abs(n1 - n2) <= 8
def find_different_pixels(image1, image2):
"""Find pixels that differ between two images."""
if image1.size != image2.size:
logger.warning("Images are not the same size")
return None
image1 = image1.convert('RGB')
image2 = image2.convert('RGB')
pixels1 = image1.load()
pixels2 = image2.load()
different_pixels = []
for x in range(image1.size[0]):
for y in range(image1.size[1]):
r1, g1, b1 = pixels1[x, y]
r2, g2, b2 = pixels2[x, y]
if similar((r1 + 50) % 256, r2) and similar((g1 + 50) % 256, g2) and similar((b1 + 50) % 256, b2):
different_pixels.append((y, x))
return np.stack(different_pixels) if different_pixels else None
def extract_text_with_color(html_str):
"""Extract text and color information from HTML string."""
def get_color(tag):
if 'style' in tag.attrs:
styles = tag['style'].split(';')
color_style = [s for s in styles if 'color' in s and 'background-color' not in s]
if color_style:
color = color_style[-1].split(':')[1].strip().replace(" !important", "")
if color[0] == "#":
return color
else:
try:
if color.startswith('rgb'):
color = tuple(map(int, color[4:-1].split(',')))
else:
color = ImageColor.getrgb(color)
return '#{:02x}{:02x}{:02x}'.format(*color)
except ValueError:
logger.warning(f"Unable to identify or convert color: {color}")
return None
return None
def extract_text_recursive(element, parent_color='#000000'):
if isinstance(element, Comment):
return None
elif isinstance(element, NavigableString):
text = element.strip()
return (text, parent_color) if text else None
elif isinstance(element, Tag):
current_color = get_color(element) or parent_color
children_texts = filter(None, [extract_text_recursive(child, current_color)
for child in element.children])
return list(children_texts)
soup = BeautifulSoup(html_str, 'html.parser')
body = soup.body
return extract_text_recursive(body) if body else []
def flatten_tree(tree):
"""Flatten a nested tree structure into a list."""
flat_list = []
def flatten(node):
if isinstance(node, list):
for item in node:
flatten(item)
else:
flat_list.append(node)
flatten(tree)
return flat_list
def get_blocks_from_image_diff_pixels(image, html_text_color_tree, different_pixels):
"""Extract text blocks from image using color differences."""
image_cv = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2BGR)
x_w = image_cv.shape[0]
y_w = image_cv.shape[1]
def hex_to_bgr(hex_color):
hex_color = hex_color.lstrip('#')
rgb = tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
return rgb[::-1]
def get_intersect(arr1, arr2):
arr1_reshaped = arr1.view([('', arr1.dtype)] * arr1.shape[1])
arr2_reshaped = arr2.view([('', arr2.dtype)] * arr2.shape[1])
common_rows = np.intersect1d(arr1_reshaped, arr2_reshaped)
return common_rows.view(arr1.dtype).reshape(-1, arr1.shape[1])
blocks = []
for item in html_text_color_tree:
try:
color = np.array(hex_to_bgr(item[1]), dtype="uint8")
except:
continue
lower = color - 4
upper = color + 4
mask = cv2.inRange(image_cv, lower, upper)
coords = np.column_stack(np.where(mask > 0))
coords = get_intersect(coords, different_pixels)
if coords.size == 0:
continue
x_min, y_min = np.min(coords, axis=0)
x_max, y_max = np.max(coords, axis=0)
# Get average color from original image
color_coords = coords.copy()
color_coords = color_coords[color_coords[:, 0] <= x_max]
color_coords = color_coords[color_coords[:, 1] <= y_max]
colors = [image_cv[x, y] for x, y in color_coords]
avg_color = tuple(map(int, np.mean(colors, axis=0)))[::-1] # Convert BGR to RGB
blocks.append({
'text': item[0].lower(),
'bbox': (y_min / y_w, x_min / x_w, (y_max - y_min + 1) / y_w, (x_max - x_min + 1) / x_w),
'color': avg_color
})
return blocks
def get_blocks_from_html(html_str, image1):
"""Extract text blocks from HTML and image."""
# Process HTML with two different color offsets
html_str_1 = process_html_str(html_str, offset=0)
html_str_2 = process_html_str(html_str, offset=50)
# Render both HTML versions to images
# TODO: Screenshot html_str_2
filter_color = (255, 0, 0)
image2 = Image.new("RGB", image1.size, filter_color)
# Find pixels that differ between the two rendered images
different_pixels = find_different_pixels(image1, image2)
if different_pixels is None:
logger.warning("Unable to get pixels with different colors")
return []
# Extract text and color information from HTML
html_text_color_tree = flatten_tree(extract_text_with_color(html_str_1))
try:
blocks = get_blocks_from_image_diff_pixels(image1, html_text_color_tree, different_pixels)
except Exception as e:
logger.warning(f"Unable to get blocks: {e}")
return []
return blocks
def evaluate(task, generated_img):
"""Evaluate generated image against reference image using multiple metrics."""
# Load reference image
post_image = task['post_image']
# Extract blocks from HTML and images
post_blocks = get_blocks_from_html(task['post_html'], post_image)
gen_blocks = get_blocks_from_html(task['gen_html'], generated_img)
print("block details", post_blocks, gen_blocks)
if not post_blocks or not gen_blocks:
# Fallback to basic CLIP and pixel comparison if no blocks available
clip_score = calculate_clip_similarity(post_image, generated_img, [], [])
logger.info(f'CLIP similarity score: {clip_score}')
# Pixel comparison
diff = ImageChops.difference(generated_img, post_image)
pixel_match = not diff.getbbox()
logger.info(
f"Pixel difference analysis: {'No difference' if pixel_match else 'Differences found'}"
)
return clip_score > 0.95 or pixel_match
# Merge blocks with same bounding boxes
post_blocks = merge_blocks_by_bbox(post_blocks)
gen_blocks = merge_blocks_by_bbox(gen_blocks)
# Find optimal block matching
consecutive_bonus, window_size = 0.1, 1
gen_blocks_m, post_blocks_m, matching = find_possible_merge(
gen_blocks, deepcopy(post_blocks), consecutive_bonus, window_size
)
# Filter matches with low similarity
filtered_matching = []
for i, j in matching:
text_similarity = calculate_similarity(gen_blocks_m[i], post_blocks_m[j])
if text_similarity >= 0.5:
filtered_matching.append([i, j, text_similarity])
matching = filtered_matching
if not matching:
logger.warning('No matching blocks found')
clip_score = calculate_clip_similarity(
post_image, generated_img, gen_blocks, post_blocks
)
return clip_score > 0.95
# Calculate metrics for matched blocks
indices1 = [item[0] for item in matching]
indices2 = [item[1] for item in matching]
# Calculate unmatched areas
unmatched_area_1 = sum(
block['bbox'][2] * block['bbox'][3]
for i, block in enumerate(gen_blocks_m)
if i not in indices1
)
unmatched_area_2 = sum(
block['bbox'][2] * block['bbox'][3]
for j, block in enumerate(post_blocks_m)
if j not in indices2
)
total_unmatched_area = unmatched_area_1 + unmatched_area_2
# Calculate metrics for matched blocks
matched_areas = []
text_scores = []
position_scores = []
color_scores = []
for i, j, text_similarity in matching:
# Area
block_area = (
gen_blocks_m[i]['bbox'][2] * gen_blocks_m[i]['bbox'][3]
+ post_blocks_m[j]['bbox'][2] * post_blocks_m[j]['bbox'][3]
)
matched_areas.append(block_area)
# Position similarity
position_similarity = 1 - calculate_distance_max_1d(
gen_blocks_m[i]['bbox'][0] + gen_blocks_m[i]['bbox'][2] / 2,
gen_blocks_m[i]['bbox'][1] + gen_blocks_m[i]['bbox'][3] / 2,
post_blocks_m[j]['bbox'][0] + post_blocks_m[j]['bbox'][2] / 2,
post_blocks_m[j]['bbox'][1] + post_blocks_m[j]['bbox'][3] / 2,
)
# Color similarity
color_similarity = color_similarity_ciede2000(
gen_blocks_m[i]['color'], post_blocks_m[j]['color']
)
text_scores.append(text_similarity)
position_scores.append(position_similarity)
color_scores.append(color_similarity)
# Calculate final scores
total_area = sum(matched_areas) + total_unmatched_area
size_score = sum(matched_areas) / total_area if total_area > 0 else 0
text_score = np.mean(text_scores) if text_scores else 0
position_score = np.mean(position_scores) if position_scores else 0
color_score = np.mean(color_scores) if color_scores else 0
clip_score = calculate_clip_similarity(
post_image, generated_img, gen_blocks, post_blocks
)
# Combine scores with equal weights
final_score = 0.2 * (
size_score + text_score + position_score + color_score + clip_score
)
logger.info('Evaluation scores:')
logger.info(f'- Size score: {size_score:.3f}')
logger.info(f'- Text score: {text_score:.3f}')
logger.info(f'- Position score: {position_score:.3f}')
logger.info(f'- Color score: {color_score:.3f}')
logger.info(f'- CLIP score: {clip_score:.3f}')
logger.info(f'- Final score: {final_score:.3f}')
return final_score > 0.8 # Consider it a match if final score > 80%
def png_to_bytes(png):
buffer = BytesIO()
png.save(buffer, format='PNG')
image_bytes = buffer.getvalue()
return image_bytes
def bytes_to_image(image_bytes):
"""Convert bytes to a Pillow Image object."""
return Image.open(BytesIO(image_bytes))
if __name__ == '__main__':
first_image = Image.open('./evaluation/visualcodebench/data/1/post.png')
image = Image.open('./evaluation/visualcodebench/data/1/prev.png')
html_file = open('./evaluation/visualcodebench/data/1/post/index.html', 'r')
first_html = html_file.read()
html_file.close()
html_file = open('./evaluation/visualcodebench/data/1/prev/index.html', 'r')
gen_html = html_file.read()
html_file.close()
sample = {'post_image': first_image, "post_html": first_html, "gen_html": gen_html}
evaluate(sample, image)

View File

@@ -1,97 +0,0 @@
import base64
import os
from io import BytesIO
import pandas as pd
from huggingface_hub import snapshot_download
from PIL import PngImagePlugin
from tqdm import tqdm
from openhands.core.logger import openhands_logger as logger
REPO_DOWNLOAD_DIR = (
'./evaluation/visualcodebench/' # Directory to store the downloaded repository
)
def download_repository():
"""
Download the entire repository from Hugging Face Hub.
This function clones the repository into REPO_DOWNLOAD_DIR.
"""
repo_id = 'rvmalhot/VisualCodeBench'
try:
logger.info(f"Downloading repository '{repo_id}'...")
snapshot_download(
repo_id=repo_id,
local_dir=REPO_DOWNLOAD_DIR,
repo_type='dataset',
ignore_patterns=None, # Download all files
)
logger.info(f"Repository downloaded to '{REPO_DOWNLOAD_DIR}'.")
except Exception as e:
logger.error(f"Error downloading repository '{repo_id}': {e}")
raise e
def format_task_dict(example):
instance_id = example['id']
prev_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/prev')
post_remote_path = os.path.join(REPO_DOWNLOAD_DIR, f'data/{instance_id}/post')
# Check if 'prev' and 'post' directories exist
prev_exists = os.path.exists(prev_remote_path)
post_exists = os.path.exists(post_remote_path)
if prev_exists and post_exists:
skip = False
else:
skip = True
task = {
'instance_id': instance_id,
'prev_image': example['prev_image'],
'post_image': example['post_image'],
'changes': example['changes'],
'prev_code_files': example['prev_code_files'],
'post_code_files': example['post_code_files'],
'skip': skip,
}
return task
def prepare_visualcodebench(dataset):
logger.info('Processing dataset')
dataset_processed = []
for example in tqdm(dataset['train']):
formatted_example = format_task_dict(example)
if formatted_example['skip']:
continue
del formatted_example['skip']
dataset_processed.append(formatted_example)
return pd.DataFrame(dataset_processed)
def pil_image_to_base64(image: PngImagePlugin.PngImageFile) -> str:
"""
Converts a PIL image to a Base64-encoded string.
Parameters:
- image (PngImagePlugin.PngImageFile): The PIL image to convert.
Returns:
- str: The Base64-encoded string of the image.
"""
if not isinstance(image, PngImagePlugin.PngImageFile):
raise ValueError(
'The provided image is not a PIL.PngImagePlugin.PngImageFile instance.'
)
buffered = BytesIO()
image.save(buffered, format='PNG')
img_bytes = buffered.getvalue()
img_base64 = base64.b64encode(img_bytes).decode('utf-8')
base64_with_prefix = f'data:image/png;base64,{img_base64}'
return [base64_with_prefix]

View File

@@ -1,247 +0,0 @@
# FILE: run_infer.py
import asyncio
import os
import shutil
import tempfile
from functools import partial
import pandas as pd
from datasets import load_dataset
# from evaluation.benchmarks.visualcodebench.eval import capture_screenshot
from evaluation.benchmarks.visualcodebench.prepare import (
REPO_DOWNLOAD_DIR,
download_repository,
pil_image_to_base64,
prepare_visualcodebench,
)
from evaluation.utils.shared import (
EvalMetadata,
assert_and_raise,
codeact_user_response,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
)
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
)
from openhands.core.config.utils import parse_arguments
from openhands.core.logger import openhands_logger as logger # Import OpenHands logger
from openhands.core.main import create_runtime, run_controller
from openhands.events.action.commands import CmdRunAction
from openhands.events.action.message import MessageAction
from openhands.events.observation.commands import CmdOutputObservation
from openhands.runtime.base import Runtime
from openhands.utils.async_utils import call_async_from_sync
# Define workspace and output directories
WORKSPACE_DIR = './workspace'
FAKE_RESPONSES = {
'CodeActAgent': partial(codeact_user_response, encapsulate_solution=True),
}
def get_config(
metadata: EvalMetadata,
) -> AppConfig:
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='eventstream',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
),
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
)
config.set_llm_config(metadata.llm_config)
return config
def initialize_runtime(
runtime: Runtime,
instance: pd.Series, # this argument is not required
):
"""Initialize the runtime for the agent.
This function is called before the runtime is used to run the agent.
"""
logger.info('-' * 30)
logger.info('BEGIN Runtime Initialization Fn')
logger.info('-' * 30)
workspace_dir_name = instance['instance_id']
obs: CmdOutputObservation
action = CmdRunAction(command='mkdir -p /workspace/{workspace_dir_name}')
action.timeout = 600
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0,
f'Failed to create /workspace/{workspace_dir_name}: {str(obs)}',
)
file_path = REPO_DOWNLOAD_DIR + f'data/{workspace_dir_name}/prev/index.html'
runtime.copy_to(file_path, f'/workspace/{workspace_dir_name}')
logger.info(f'Copied code file for instance {workspace_dir_name}')
action = CmdRunAction(command=f'cd /workspace/{workspace_dir_name}')
action.timeout = 600
logger.info(action, extra={'msg_type': 'ACTION'})
obs = runtime.run_action(action)
logger.info(obs, extra={'msg_type': 'OBSERVATION'})
assert_and_raise(
obs.exit_code == 0,
f'Failed to cd to /workspace/{workspace_dir_name}: {str(obs)}',
)
logger.info('-' * 30)
logger.info('END Runtime Initialization Fn')
logger.info('-' * 30)
def complete_runtime(
runtime: Runtime,
instance: pd.Series, # this argument is not required, but it is used to get the workspace_dir_name
) -> str:
# TODO: extract edited HTML file from agent workspace
# temp_zip = runtime.copy_from(f'/workspace/{instance.instance_id}')
# file_name = f'/workspace/{instance.instance_id}/index.html'
# with zipfile.ZipFile(temp_zip, 'r') as zip_ref:
# if file_name in zip_ref.namelist():
# with zip_ref.open(file_name) as file:
# file_content = file.read().decode('utf-8') # Decode bytes to string
# else:
# raise FileNotFoundError(f"'{file_name}' not found in the ZIP archive.")
with tempfile.TemporaryDirectory() as tmpdir:
src_folder = REPO_DOWNLOAD_DIR + f'data/{instance.instance_id}/post/'
shutil.copytree(src_folder, tmpdir, dirs_exist_ok=True)
# image = capture_screenshot(tmpdir)
# if image is not None:
# shutil.copy(os.path.join(tmpdir, 'final_screenshot.png'), REPO_DOWNLOAD_DIR)
def process_instance(
instance: pd.Series, metadata: EvalMetadata, reset_logger: bool = True
):
config = get_config(metadata)
# 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, instance.instance_id, log_dir)
else:
logger.info(f'Starting evaluation for instance {instance.instance_id}.')
# =============================================
# build instruction
# =============================================
# Prepare instruction
instruction = (
f"Modify the HTML/CSS according to the following instruction:\n\n"
f"{instance['changes']}\n\n"
)
instruction += (
'IMPORTANT: You should ONLY interact with the environment provided '
'to you AND NEVER ASK FOR HUMAN HELP.\n'
)
# =============================================
# create sandbox and run the agent
# =============================================
runtime: Runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
try:
initialize_runtime(runtime, instance=instance)
image_urls = pil_image_to_base64(instance['prev_image'])
action = MessageAction(content=instruction, image_urls=image_urls)
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=action,
runtime=runtime,
fake_user_response_fn=FAKE_RESPONSES[metadata.agent_class],
)
)
if state is None:
raise ValueError('State should not be None.')
# =============================================
# result evaluation
# =============================================
return_val = complete_runtime(runtime, instance)
logger.info(f'Return value {return_val}')
finally:
runtime.close()
# TODO: return EVAL output
def main():
"""Main function to run the evaluation."""
# args = parse_args()
args = parse_arguments()
logger.info(f"\n{'='*80}\nStarting VisualCodeBench Evaluation\n{'='*80}")
logger.info(f'Agent: {args.agent_cls}')
logger.info(f'Model: {args.llm_config}')
logger.info(f'Max iterations: {args.max_iterations}')
logger.info(f'Eval limit: {args.eval_n_limit}')
logger.info(f'Num workers: {args.eval_num_workers}\n')
logger.info(f'Eval output: {args.eval_output_dir}\n')
# Step 1: Download the entire repository once
logger.info('Downloading repository...')
download_repository()
# Step 2: Load Dataset
logger.info('Loading dataset...')
dataset = load_dataset(REPO_DOWNLOAD_DIR)
# Step 3: Prepare dataset
llm_config = get_llm_config_arg(args.llm_config)
if llm_config is None:
logger.error(f'Could not find LLM config: {args.llm_config}')
raise ValueError(f'Could not find LLM config: {args.llm_config}')
metadata = make_metadata(
llm_config,
'VisualCodeBench',
args.agent_cls,
args.max_iterations,
args.eval_note,
'evaluation/output/',
)
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
dataset = prepare_visualcodebench(dataset)
instances = prepare_dataset(dataset, output_file, eval_n_limit=args.eval_n_limit)
# Step 4: Run eval
run_evaluation(
instances, metadata, output_file, args.eval_num_workers, process_instance
)
if __name__ == '__main__':
main()

View File

@@ -1,46 +0,0 @@
#!/bin/bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# Check if required arguments are provided
if [ "$#" -lt 4 ]; then
echo "Usage: $0 [model_config] [commit_hash] [agent_cls] [eval_limit] [num_workers]"
echo "Example: $0 llm.eval_gpt_4o_mini HEAD CodeActAgent 5 1"
exit 1
fi
MODEL_CONFIG=$1
COMMIT_HASH=$2
AGENT_CLS=$3
EVAL_LIMIT=$4
NUM_WORKERS=${5:-1} # Default to 1 worker if not specified
# Checkout the specified commit
checkout_eval_branch
if [ -z "$AGENT" ]; then
echo "Agent not specified, use default CodeActAgent"
AGENT="CodeActAgent"
fi
get_openhands_version
echo "AGENT: $AGENT"
echo "OPENHANDS_VERSION: $OPENHANDS_VERSION"
echo "MODEL_CONFIG: $MODEL_CONFIG"
COMMAND="export PYTHONPATH=evaluation/benchmarks/visualcodebench:\$PYTHONPATH && poetry run python evaluation/benchmarks/visualcodebench/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations 5 \
--eval-num-workers $NUM_WORKERS \
--eval-note $OPENHANDS_VERSION" \
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
# Run the command
eval $COMMAND

View File

@@ -1,167 +0,0 @@
import http
import os
import socket
import socketserver
import threading
import time
from io import BytesIO
import requests
from PIL import Image, ImageChops
from playwright.sync_api import sync_playwright
from openhands.core.logger import openhands_logger as logger
def get_free_port():
"""Find a free port to run the HTTP server."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.bind(('', 0))
return s.getsockname()[1]
def start_http_server(tmpdir):
port = get_free_port()
class CustomHTTPRequestHandler(http.server.SimpleHTTPRequestHandler):
def translate_path(self, path):
# Serve files from the specified directory instead of the current working directory
path = super().translate_path(path)
relative_path = os.path.relpath(path, os.getcwd())
return os.path.join(tmpdir, relative_path)
handler = CustomHTTPRequestHandler
server = socketserver.TCPServer(('', port), handler)
return server, port
def capture_screenshot(tmpdir):
server, port = start_http_server(tmpdir)
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
time.sleep(10)
image = None
try:
server_url = f'http://localhost:{port}/'
if not is_server_reachable(server_url):
raise RuntimeError(f'Server not reachable at {server_url}')
screenshot_path = os.path.join(tmpdir, 'final_screenshot.png')
capture_screenshot_playwright(server_url, screenshot_path)
image = Image.open(screenshot_path)
image.load()
finally:
# Shut down the server and clean up
server.shutdown()
server.server_close()
return image
def is_server_reachable(url):
"""
Check if the local server is reachable.
"""
try:
response = requests.get(url, timeout=5) # Set a 5-second timeout
if response.status_code == 200:
logger.info(f'Server is reachable at {url}')
return True
else:
logger.warning(
f'Server responded with status code {response.status_code} at {url}'
)
return False
except requests.ConnectionError as e:
logger.error(f'Failed to connect to server at {url}: {e}')
return False
def capture_screenshot_playwright(url, screenshot_path):
"""Capture a screenshot of the given URL using Playwright."""
try:
with sync_playwright() as p:
logger.info('Launching browser...')
browser = p.chromium.launch(timeout=10000) # 10 seconds for browser launch
logger.info('Creating a new page...')
page = browser.new_page()
logger.info(f'Navigating to URL: {url}')
try:
page.goto(url, timeout=60 * 1000) # Set timeout to 5 seconds
logger.info('Page navigation completed.')
except Exception as e:
logger.warning(f'Page navigation timed out. {e}. Continuing...')
logger.info('Waiting for network to be idle...')
try:
page.wait_for_load_state(
'networkidle', timeout=60 * 1000
) # Set timeout to 5 seconds
logger.info('Page load state reached.')
except Exception as e:
logger.warning(f'Page load state timed out. {e}. Continuing...')
logger.info('Capturing screenshot...')
page.screenshot(
path=screenshot_path, full_page=True
) # Capture full page screenshot
logger.info(f'Screenshot saved to {screenshot_path}')
browser.close()
return True
except Exception as e:
logger.error(f'Error capturing screenshot with Playwright: {e}')
return False
def evaluate(task, screenshot_path):
"""Compare generated screenshot with post_image using CLIP score."""
try:
import torch
from transformers import CLIPModel, CLIPProcessor
# Load CLIP model and processor
model = CLIPModel.from_pretrained('openai/clip-vit-base-patch32')
processor = CLIPProcessor.from_pretrained('openai/clip-vit-base-patch32')
# Load images
post_image = Image.open(BytesIO(task['post_image']))
generated_img = Image.open(screenshot_path)
# Process images
inputs = processor(
images=[post_image, generated_img], return_tensors='pt', padding=True
)
# Get image features
image_features = model.get_image_features(**inputs)
# Calculate cosine similarity
similarity = torch.nn.functional.cosine_similarity(
image_features[0].unsqueeze(0), image_features[1].unsqueeze(0)
).item()
logger.info(f'CLIP similarity score: {similarity}')
return similarity > 0.95 # Consider it a match if similarity > 95%
except Exception as e:
logger.error(f'Error in CLIP evaluation: {e}')
# Fallback to pixel comparison if CLIP fails
try:
post_image = Image.open(BytesIO(task['post_image']))
generated_img = Image.open(screenshot_path)
# Compare images directly without converting to bytes
diff = ImageChops.difference(generated_img, post_image)
logger.info(
f"Pixel difference analysis: {'No difference' if not diff.getbbox() else 'Differences found'}"
)
return not diff.getbbox()
except Exception as ex:
logger.error(f'Error in fallback evaluation: {ex}')
return False

View File

@@ -1,50 +0,0 @@
# VisualWebArena Evaluation with OpenHands Browsing Agents
This folder contains evaluation for [VisualWebArena](https://github.com/web-arena-x/visualwebarena) benchmark, powered by [BrowserGym](https://github.com/ServiceNow/BrowserGym) for easy evaluation of how well an agent capable of browsing can perform on realistic web browsing tasks.
## Setup Environment and LLM Configuration
Please follow instruction [here](../../README.md#setup) to setup your local development environment and LLM.
## Setup VisualWebArena Environment
VisualWebArena 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/visualwebarena/blob/main/environment_docker/README.md) to set up your own VisualWebArena environment through local servers or AWS EC2 instances.
Take note of the base URL (`$VISUALWEBARENA_BASE_URL`) of the machine where the environment is installed.
## Test if your environment works
Access with browser the above VisualWebArena website URLs and see if they load correctly.
If you cannot access the website, make sure the firewall allows public access of the aforementioned ports on your server
Check the network security policy if you are using an AWS machine.
Follow the VisualWebArena environment setup guide carefully, and make sure the URL fields are populated with the correct base URL of your server.
## Run Evaluation
```bash
export VISUALWEBARENA_BASE_URL=<YOUR_SERVER_URL_HERE>
export OPENAI_API_KEY="yourkey" # this OpenAI API key is required for some visualWebArena validators that utilize LLMs
export OPENAI_BASE_URL="https://api.openai.com/v1/" # base URL for OpenAI model used for VisualWebArena evaluation
bash evaluation/benchmarks/visualwebarena/scripts/run_infer.sh llm.claude HEAD VisualBrowsingAgent
```
Results will be in `evaluation/evaluation_outputs/outputs/visualwebarena/`
To calculate the success rate, run:
```sh
poetry run python evaluation/benchmarks/visualwebarena/get_success_rate.py evaluation/evaluation_outputs/outputs/visualwebarena/SOME_AGENT/EXP_NAME/output.jsonl
```
## 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).
## VisualBrowsingAgent V1.0 result
Tested on VisualBrowsingAgent V1.0
VisualWebArena, 910 tasks (high cost, single run due to fixed task), max step 15. Resolve rates are:
- GPT4o: 26.15%
- Claude-3.5 Sonnet: 25.27%

View File

@@ -1,40 +0,0 @@
import argparse
import json
import browsergym.visualwebarena # noqa F401 register visualwebarena 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/visualwebarena')
]
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']
reward = data['test_result']['reward']
if reward >= 0:
total_reward += data['test_result']['reward']
else:
actual_num -= 1
avg_reward = total_reward / total_num
print('Total reward: ', total_reward)
print('Success Rate: ', avg_reward)
avg_cost = total_cost / actual_num
print('Avg Cost: ', avg_cost)
print('Total Cost: ', total_cost)
print('Actual number of tasks finished: ', actual_num)

View File

@@ -1,254 +0,0 @@
import asyncio
import json
import os
from typing import Any
import browsergym.visualwebarena # noqa F401 register visualwebarena tasks as gym environments
import gymnasium as gym
import pandas as pd
from evaluation.utils.shared import (
EvalMetadata,
EvalOutput,
compatibility_for_eval_history_pairs,
make_metadata,
prepare_dataset,
reset_logger_for_multiprocessing,
run_evaluation,
update_llm_config_for_completions_logging,
)
from openhands.controller.state.state import State
from openhands.core.config import (
AppConfig,
SandboxConfig,
get_llm_config_arg,
parse_arguments,
)
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 = {'VisualBrowsingAgent'}
AGENT_CLS_TO_FAKE_USER_RESPONSE_FN = {
'VisualBrowsingAgent': 'Continue the task. IMPORTANT: do not talk to the user until you have finished the task',
}
def get_config(
metadata: EvalMetadata,
env_id: str,
) -> AppConfig:
base_url = os.environ.get('VISUALWEBARENA_BASE_URL', None)
openai_api_key = os.environ.get('OPENAI_API_KEY', None)
openai_base_url = os.environ.get('OPENAI_BASE_URL', None)
assert base_url is not None, 'VISUALWEBARENA_BASE_URL must be set'
assert openai_api_key is not None, 'OPENAI_API_KEY must be set'
assert openai_base_url is not None, 'OPENAI_BASE_URL must be set'
config = AppConfig(
default_agent=metadata.agent_class,
run_as_openhands=False,
runtime='docker',
max_iterations=metadata.max_iterations,
sandbox=SandboxConfig(
base_container_image='python:3.12-bookworm',
enable_auto_lint=True,
use_host_network=False,
browsergym_eval_env=env_id,
runtime_startup_env_vars={
'BASE_URL': base_url,
'OPENAI_API_KEY': openai_api_key,
'OPENAI_BASE_URL': openai_base_url,
'VWA_CLASSIFIEDS': f'{base_url}:9980',
'VWA_CLASSIFIEDS_RESET_TOKEN': '4b61655535e7ed388f0d40a93600254c',
'VWA_SHOPPING': f'{base_url}:7770',
'VWA_SHOPPING_ADMIN': f'{base_url}:7780/admin',
'VWA_REDDIT': f'{base_url}:9999',
'VWA_GITLAB': f'{base_url}:8023',
'VWA_WIKIPEDIA': f'{base_url}:8888',
'VWA_HOMEPAGE': f'{base_url}:4399',
},
timeout=300,
),
# do not mount workspace
workspace_base=None,
workspace_mount_path=None,
attach_to_existing=True,
)
config.set_llm_config(
update_llm_config_for_completions_logging(
metadata.llm_config,
metadata.eval_output_dir,
env_id,
)
)
return config
def initialize_runtime(
runtime: Runtime,
) -> tuple[str, list]:
"""Initialize the runtime for the agent.
This function is called before the runtime is used to run the agent.
"""
logger.info(f"{'-' * 50} BEGIN Runtime Initialization Fn {'-' * 50}")
obs: CmdOutputObservation
# 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
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
goal_image_urls = []
if hasattr(obs, 'goal_image_urls'):
goal_image_urls = obs.goal_image_urls
logger.info(f"{'-' * 50} END Runtime Initialization Fn {'-' * 50}")
return goal, goal_image_urls
def complete_runtime(
runtime: Runtime,
) -> dict[str, Any]:
"""Complete the runtime for the agent.
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
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'})
logger.info(f"{'-' * 50} END Runtime Completion Fn {'-' * 50}")
return {
'rewards': json.loads(obs.content),
}
def process_instance(
instance: pd.Series,
metadata: EvalMetadata,
reset_logger: bool = True,
):
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, env_id, log_dir)
else:
logger.info(f'Starting evaluation for instance {env_id}.')
runtime = create_runtime(config)
call_async_from_sync(runtime.connect)
task_str, goal_image_urls = initialize_runtime(runtime)
initial_user_action = MessageAction(content=task_str, image_urls=goal_image_urls)
state: State | None = asyncio.run(
run_controller(
config=config,
initial_user_action=initial_user_action,
runtime=runtime,
)
)
# ======= 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.')
metrics = state.metrics.get() if state.metrics else None
# Instruction obtained from the first message from the USER
instruction = ''
for event in state.history:
if isinstance(event, MessageAction):
instruction = event.content
break
try:
return_val = complete_runtime(runtime)
logger.info(f'Return value from complete_runtime: {return_val}')
reward = max(return_val['rewards'])
except Exception:
reward = -1.0 # kept -1 to identify instances for which evaluation failed.
# 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
# remove when it becomes unnecessary
histories = compatibility_for_eval_history_pairs(state.history)
# Save the output
output = EvalOutput(
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={
'reward': reward,
},
)
runtime.close()
return output
if __name__ == '__main__':
args = parse_arguments()
dataset = pd.DataFrame(
{
'instance_id': [
id
for id in gym.envs.registry.keys()
if id.startswith('browsergym/visualwebarena')
]
}
)
llm_config = None
if args.llm_config:
llm_config = get_llm_config_arg(args.llm_config)
if llm_config is None:
raise ValueError(f'Could not find LLM config: --llm_config {args.llm_config}')
metadata = make_metadata(
llm_config,
'visualwebarena',
args.agent_cls,
args.max_iterations,
args.eval_note,
args.eval_output_dir,
)
output_file = os.path.join(metadata.eval_output_dir, 'output.jsonl')
instances = prepare_dataset(dataset, output_file, args.eval_n_limit)
run_evaluation(
instances,
metadata,
output_file,
args.eval_num_workers,
process_instance,
)

View File

@@ -1,48 +0,0 @@
#!/bin/bash
set -eo pipefail
source "evaluation/utils/version_control.sh"
# configure browsing agent
export USE_NAV="true"
export USE_CONCISE_ANSWER="true"
MODEL_CONFIG=$1
COMMIT_HASH=$2
AGENT=$3
EVAL_LIMIT=$4
NUM_WORKERS=$5
if [ -z "$NUM_WORKERS" ]; then
NUM_WORKERS=1
echo "Number of workers not specified, use default $NUM_WORKERS"
fi
checkout_eval_branch
if [ -z "$AGENT" ]; then
echo "Agent not specified, use default VisualBrowsingAgent"
AGENT="VisualBrowsingAgent"
fi
get_openhands_version
echo "AGENT: $AGENT"
echo "AGENT_VERSION: $OPENHANDS_VERSION"
echo "MODEL_CONFIG: $MODEL_CONFIG"
EVAL_NOTE="${OPENHANDS_VERSION}"
COMMAND="poetry run python evaluation/benchmarks/visualwebarena/run_infer.py \
--agent-cls $AGENT \
--llm-config $MODEL_CONFIG \
--max-iterations 15 \
--eval-num-workers $NUM_WORKERS \
--eval-note $EVAL_NOTE"
if [ -n "$EVAL_LIMIT" ]; then
echo "EVAL_LIMIT: $EVAL_LIMIT"
COMMAND="$COMMAND --eval-n-limit $EVAL_LIMIT"
fi
# Run the command
eval $COMMAND

View File

@@ -35,7 +35,6 @@ from openhands.utils.async_utils import call_async_from_sync
FAKE_RESPONSES = {
'CodeActAgent': fake_user_response,
'DelegatorAgent': fake_user_response,
'VisualBrowsingAgent': fake_user_response,
}

View File

@@ -1,4 +1,4 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import { render, screen, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
QueryClientProvider,
@@ -7,12 +7,10 @@ import {
} from "@tanstack/react-query";
import userEvent from "@testing-library/user-event";
import { createRoutesStub } from "react-router";
import React from "react";
import { ConversationPanel } from "#/components/features/conversation-panel/conversation-panel";
import OpenHands from "#/api/open-hands";
import { AuthProvider } from "#/context/auth-context";
import { clickOnEditButton } from "./utils";
import { queryClientConfig } from "#/query-client-config";
describe("ConversationPanel", () => {
const onCloseMock = vi.fn();
@@ -233,47 +231,4 @@ describe("ConversationPanel", () => {
expect(onCloseMock).toHaveBeenCalledOnce();
});
it("should refetch data on rerenders", async () => {
// We need to simulate the toggling of the component to test the refetching
function PanelWithToggle() {
const [isOpen, setIsOpen] = React.useState(true);
return (
<>
<button type="button" onClick={() => setIsOpen((prev) => !prev)}>
Toggle
</button>
{isOpen && <ConversationPanel onClose={onCloseMock} />}
</>
);
}
const MyRouterStub = createRoutesStub([
{
Component: PanelWithToggle,
path: "/",
},
]);
const getUserConversationsSpy = vi.spyOn(OpenHands, "getUserConversations");
render(<MyRouterStub />, {
wrapper: ({ children }) => (
<AuthProvider>
<QueryClientProvider client={new QueryClient(queryClientConfig)}>
{children}
</QueryClientProvider>
</AuthProvider>
),
});
await waitFor(() => expect(getUserConversationsSpy).toHaveBeenCalledOnce());
const button = screen.getByText("Toggle");
await userEvent.click(button);
await userEvent.click(button);
await waitFor(() =>
expect(getUserConversationsSpy).toHaveBeenCalledTimes(2),
);
});
});

View File

@@ -8,9 +8,6 @@ import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import OpenHands from "#/api/open-hands";
import { MOCK_USER_PREFERENCES } from "#/mocks/handlers";
// These tests will now fail because the conversation panel is rendered through a portal
// and technically not a child of the Sidebar component.
const renderSidebar = () => {
const RouterStub = createRoutesStub([
{
@@ -155,9 +152,7 @@ describe("Sidebar", () => {
const settingsModal = screen.getByTestId("ai-config-modal");
// Click the advanced options switch to show the API key input
const advancedOptionsSwitch = within(settingsModal).getByTestId(
"advanced-option-switch",
);
const advancedOptionsSwitch = within(settingsModal).getByTestId("advanced-option-switch");
await user.click(advancedOptionsSwitch);
const apiKeyInput = within(settingsModal).getByLabelText(/API\$KEY/i);

View File

@@ -1,13 +1,12 @@
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, it, vi } from "vitest";
import { TrajectoryActions } from "#/components/features/trajectory/trajectory-actions";
import { FeedbackActions } from "#/components/features/feedback/feedback-actions";
describe("TrajectoryActions", () => {
describe("FeedbackActions", () => {
const user = userEvent.setup();
const onPositiveFeedback = vi.fn();
const onNegativeFeedback = vi.fn();
const onExportTrajectory = vi.fn();
afterEach(() => {
vi.clearAllMocks();
@@ -15,10 +14,9 @@ describe("TrajectoryActions", () => {
it("should render correctly", () => {
render(
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -29,10 +27,9 @@ describe("TrajectoryActions", () => {
it("should call onPositiveFeedback when positive feedback is clicked", async () => {
render(
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -44,10 +41,9 @@ describe("TrajectoryActions", () => {
it("should call onNegativeFeedback when negative feedback is clicked", async () => {
render(
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
@@ -56,19 +52,4 @@ describe("TrajectoryActions", () => {
expect(onNegativeFeedback).toHaveBeenCalled();
});
it("should call onExportTrajectory when negative feedback is clicked", async () => {
render(
<TrajectoryActions
onPositiveFeedback={onPositiveFeedback}
onNegativeFeedback={onNegativeFeedback}
onExportTrajectory={onExportTrajectory}
/>,
);
const exportButton = screen.getByTestId("export-trajectory");
await user.click(exportButton);
expect(onExportTrajectory).toHaveBeenCalled();
});
});

View File

@@ -1,20 +1,20 @@
import { describe, it, expect } from "vitest";
import store from "../src/store";
import {
setInitialPrompt,
clearInitialPrompt,
setInitialQuery,
clearInitialQuery,
} from "../src/state/initial-query-slice";
describe("Initial Query Behavior", () => {
it("should clear initial query when clearInitialPrompt is dispatched", () => {
it("should clear initial query when clearInitialQuery is dispatched", () => {
// Set up initial query in the store
store.dispatch(setInitialPrompt("test query"));
expect(store.getState().initialQuery.initialPrompt).toBe("test query");
store.dispatch(setInitialQuery("test query"));
expect(store.getState().initialQuery.initialQuery).toBe("test query");
// Clear the initial query
store.dispatch(clearInitialPrompt());
store.dispatch(clearInitialQuery());
// Verify initial query is cleared
expect(store.getState().initialQuery.initialPrompt).toBeNull();
expect(store.getState().initialQuery.initialQuery).toBeNull();
});
});

View File

@@ -21,7 +21,6 @@
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.0.1",
"i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
@@ -54,7 +53,6 @@
"@react-router/dev": "^7.1.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.64.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.0",
@@ -105,7 +103,6 @@
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
"integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
@@ -1594,7 +1591,6 @@
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
@@ -1612,7 +1608,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -1625,7 +1620,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -3408,7 +3402,6 @@
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "2.0.5",
@@ -3422,7 +3415,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -3432,7 +3424,6 @@
"version": "1.2.8",
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.scandir": "2.1.5",
@@ -3533,7 +3524,6 @@
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
@@ -5542,6 +5532,7 @@
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -5643,7 +5634,8 @@
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
@@ -5764,7 +5756,7 @@
"version": "22.10.7",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.10.7.tgz",
"integrity": "sha512-V09KvXxFiutGp6B7XkpaDXlNadZxrzajcY50EuoLIpQ6WWYCSvf19lVIazzfIzQvhUN2HjX12spLojTnhuKlGg==",
"dev": true,
"devOptional": true,
"dependencies": {
"undici-types": "~6.20.0"
}
@@ -5773,7 +5765,6 @@
"version": "19.0.7",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.0.7.tgz",
"integrity": "sha512-MoFsEJKkAtZCrC1r6CM8U22GzhG7u2Wir8ons/aCKH6MBdD1ibV24zOSSkdZVUKqN5i396zG5VKLYZ3yaUZdLA==",
"dev": true,
"dependencies": {
"csstype": "^3.0.2"
}
@@ -6462,7 +6453,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6472,7 +6462,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -6488,14 +6477,12 @@
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
"integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
"dev": true,
"license": "MIT"
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
@@ -6509,7 +6496,6 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -6835,7 +6821,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/basic-auth": {
@@ -6860,7 +6845,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6909,7 +6893,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -6919,7 +6902,6 @@
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
@@ -7070,7 +7052,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
"integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -7646,7 +7627,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -7661,7 +7641,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -7684,7 +7663,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"dev": true,
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
@@ -7951,7 +7929,6 @@
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
"integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/dir-glob": {
@@ -7971,7 +7948,6 @@
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
"integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
"dev": true,
"license": "MIT"
},
"node_modules/doctrine": {
@@ -7992,7 +7968,8 @@
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/dot-case": {
"version": "3.0.4",
@@ -8036,7 +8013,6 @@
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/ee-first": {
@@ -8055,7 +8031,6 @@
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -9212,7 +9187,6 @@
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz",
"integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==",
"dev": true,
"license": "MIT",
"dependencies": {
"@nodelib/fs.stat": "^2.0.2",
@@ -9229,7 +9203,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -9256,7 +9229,6 @@
"version": "1.18.0",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.18.0.tgz",
"integrity": "sha512-QKHXPW0hD8g4UET03SdOdunzSouc9N4AuHdsX8XNcTsuz+yYFILVNIX4l9yHABMhiEI9Db0JTTIpu0wB+Y1QQw==",
"dev": true,
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
@@ -9298,7 +9270,6 @@
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
@@ -9418,7 +9389,6 @@
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
"integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.0",
@@ -9476,13 +9446,13 @@
}
},
"node_modules/framer-motion": {
"version": "12.0.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.0.1.tgz",
"integrity": "sha512-u6p0Qc4cY/AEQAtrC7qiYlXla39qnWoI4JXY7OCNBDXwJ5yRBD8HU+RhaOqqziw2m/b0BDh32f44W94+wXonMQ==",
"license": "MIT",
"version": "11.16.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.16.1.tgz",
"integrity": "sha512-xsjhEUSWHn39g334PpBTH+QissgEJVJkpRGS/4QUyMSmoJSNxA+7FTuq61s+OXPMS4muu5k9Y6r7GpcNKhd1xA==",
"peer": true,
"dependencies": {
"motion-dom": "^12.0.0",
"motion-utils": "^12.0.0",
"motion-dom": "^11.16.1",
"motion-utils": "^11.16.0",
"tslib": "^2.4.0"
},
"peerDependencies": {
@@ -9689,7 +9659,6 @@
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
@@ -9710,7 +9679,6 @@
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
"integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.3"
@@ -10462,7 +10430,6 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
@@ -10505,7 +10472,6 @@
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"dev": true,
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
@@ -10573,7 +10539,6 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -10628,7 +10593,6 @@
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
@@ -10681,7 +10645,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
@@ -10910,7 +10873,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -10989,7 +10951,6 @@
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
@@ -11005,7 +10966,6 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -11220,7 +11180,6 @@
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
"integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14"
@@ -11233,7 +11192,6 @@
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
"integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
"dev": true,
"license": "MIT"
},
"node_modules/lint-staged": {
@@ -11638,6 +11596,7 @@
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
@@ -12008,7 +11967,6 @@
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 8"
@@ -12589,7 +12547,6 @@
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
@@ -12680,7 +12637,6 @@
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
@@ -12706,7 +12662,6 @@
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -12762,19 +12717,19 @@
}
},
"node_modules/motion-dom": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.0.0.tgz",
"integrity": "sha512-CvYd15OeIR6kHgMdonCc1ihsaUG4MYh/wrkz8gZ3hBX/uamyZCXN9S9qJoYF03GqfTt7thTV/dxnHYX4+55vDg==",
"license": "MIT",
"version": "11.16.1",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-11.16.1.tgz",
"integrity": "sha512-XVNf3iCfZn9OHPZYJQy5YXXLn0NuPNvtT3YCat89oAnr4D88Cr52KqFgKa8dWElBK8uIoQhpJMJEG+dyniYycQ==",
"peer": true,
"dependencies": {
"motion-utils": "^12.0.0"
"motion-utils": "^11.16.0"
}
},
"node_modules/motion-utils": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.0.0.tgz",
"integrity": "sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==",
"license": "MIT"
"version": "11.16.0",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-11.16.0.tgz",
"integrity": "sha512-ngdWPjg31rD4WGXFi0eZ00DQQqKKu04QExyv/ymlC+3k+WIgYVFbt6gS5JsFPbJODTF/r8XiE/X+SsoT9c0ocw==",
"peer": true
},
"node_modules/mri": {
"version": "1.2.0",
@@ -12866,7 +12821,6 @@
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
"integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0",
@@ -12987,7 +12941,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13098,7 +13051,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13108,7 +13060,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -13350,7 +13301,6 @@
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/pako": {
@@ -13469,7 +13419,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -13479,14 +13428,12 @@
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"dev": true,
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
@@ -13503,7 +13450,6 @@
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/path-to-regexp": {
@@ -13559,7 +13505,6 @@
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
@@ -13585,7 +13530,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
"integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
@@ -13595,7 +13539,6 @@
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz",
"integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -13674,7 +13617,6 @@
"version": "15.1.0",
"resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
"integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
"dev": true,
"license": "MIT",
"dependencies": {
"postcss-value-parser": "^4.0.0",
@@ -13692,7 +13634,6 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz",
"integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==",
"dev": true,
"license": "MIT",
"dependencies": {
"camelcase-css": "^2.0.1"
@@ -13712,7 +13653,6 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz",
"integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -13748,7 +13688,6 @@
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
"integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
@@ -13774,7 +13713,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -13802,7 +13740,6 @@
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
"dev": true,
"license": "MIT"
},
"node_modules/posthog-js": {
@@ -13877,6 +13814,7 @@
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
@@ -13892,6 +13830,7 @@
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
@@ -14064,7 +14003,6 @@
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
"dev": true,
"funding": [
{
"type": "github",
@@ -14185,7 +14123,8 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/react-markdown": {
"version": "9.0.3",
@@ -14313,7 +14252,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
"integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"pify": "^2.3.0"
@@ -14642,7 +14580,6 @@
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
"integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-core-module": "^2.16.0",
@@ -14716,7 +14653,6 @@
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
"integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==",
"dev": true,
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
@@ -14841,7 +14777,6 @@
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
"dev": true,
"funding": [
{
"type": "github",
@@ -15124,7 +15059,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -15137,7 +15071,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15226,7 +15159,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
@@ -15546,7 +15478,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
@@ -15565,7 +15496,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -15580,14 +15510,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -15597,7 +15525,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -15610,7 +15537,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -15753,7 +15679,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -15767,7 +15692,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -15838,7 +15762,6 @@
"version": "3.35.0",
"resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz",
"integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.2",
@@ -15861,7 +15784,6 @@
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
"integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 6"
@@ -15884,7 +15806,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
@@ -15962,7 +15883,6 @@
"version": "3.4.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz",
"integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==",
"dev": true,
"license": "MIT",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
@@ -16000,7 +15920,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
@@ -16025,7 +15944,6 @@
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
@@ -16038,7 +15956,6 @@
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"dev": true,
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
@@ -16052,7 +15969,6 @@
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
@@ -16086,7 +16002,6 @@
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
"integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
"dev": true,
"license": "MIT",
"dependencies": {
"any-promise": "^1.0.0"
@@ -16096,7 +16011,6 @@
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
"integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"thenify": ">= 3.1.0 < 4"
@@ -16189,7 +16103,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
@@ -16278,7 +16191,6 @@
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/tsconfck": {
@@ -16460,7 +16372,7 @@
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"devOptional": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -16501,7 +16413,7 @@
"version": "6.20.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz",
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -16718,7 +16630,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
@@ -17256,7 +17167,6 @@
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
@@ -17275,7 +17185,6 @@
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
@@ -17293,14 +17202,12 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -17310,7 +17217,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -17325,7 +17231,6 @@
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -17338,7 +17243,6 @@
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
@@ -17351,7 +17255,6 @@
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
@@ -17445,7 +17348,6 @@
"version": "2.6.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz",
"integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==",
"dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"

View File

@@ -20,7 +20,6 @@
"axios": "^1.7.9",
"clsx": "^2.1.1",
"eslint-config-airbnb-typescript": "^18.0.0",
"framer-motion": "^12.0.1",
"i18next": "^24.2.1",
"i18next-browser-languagedetector": "^8.0.2",
"i18next-http-backend": "^3.0.1",
@@ -81,7 +80,6 @@
"@react-router/dev": "^7.1.2",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/eslint-plugin-query": "^5.64.2",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.1",
"@testing-library/react": "^16.2.0",
"@testing-library/user-event": "^14.6.0",

View File

@@ -244,14 +244,10 @@ class OpenHands {
static async createConversation(
githubToken?: string,
selectedRepository?: string,
initialUserMsg?: string,
imageUrls?: string[],
): Promise<Conversation> {
const body = {
github_token: githubToken,
selected_repository: selectedRepository,
initial_user_msg: initialUserMsg,
image_urls: imageUrls,
};
const { data } = await openHands.post<Conversation>(

View File

@@ -23,7 +23,7 @@ export const AGENT_STATUS_MAP: {
},
[AgentState.AWAITING_USER_INPUT]: {
message: I18nKey.CHAT_INTERFACE$AGENT_AWAITING_USER_INPUT_MESSAGE,
indicator: IndicatorColor.BLUE,
indicator: IndicatorColor.ORANGE,
},
[AgentState.PAUSED]: {
message: I18nKey.CHAT_INTERFACE$AGENT_PAUSED_MESSAGE,

View File

@@ -4,7 +4,8 @@ import React from "react";
import posthog from "posthog-js";
import { useParams } from "react-router";
import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
import { TrajectoryActions } from "../trajectory/trajectory-actions";
import { FeedbackActions } from "../feedback/feedback-actions";
import { ExportActions } from "../export/export-actions";
import { createChatMessage } from "#/services/chat-service";
import { InteractiveChatBox } from "./interactive-chat-box";
import { addUserMessage } from "#/state/chat-slice";
@@ -154,13 +155,15 @@ export function ChatInterface() {
<div className="flex flex-col gap-[6px] px-4 pb-4">
<div className="flex justify-between relative">
<TrajectoryActions
<FeedbackActions
onPositiveFeedback={() =>
onClickShareFeedbackActionButton("positive")
}
onNegativeFeedback={() =>
onClickShareFeedbackActionButton("negative")
}
/>
<ExportActions
onExportTrajectory={() => onClickExportTrajectoryButton()}
/>

View File

@@ -12,22 +12,15 @@ interface MessagesProps {
export const Messages: React.FC<MessagesProps> = React.memo(
({ messages, isAwaitingUserConfirmation }) =>
messages.map((message, index) => {
const shouldShowConfirmationButtons =
messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation;
if (message.type === "error" || message.type === "action") {
return (
<div key={index}>
<ExpandableMessage
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
/>
{shouldShowConfirmationButtons && <ConfirmationButtons />}
</div>
<ExpandableMessage
key={index}
type={message.type}
id={message.translationID}
message={message.content}
success={message.success}
/>
);
}
@@ -40,7 +33,9 @@ export const Messages: React.FC<MessagesProps> = React.memo(
{message.imageUrls && message.imageUrls.length > 0 && (
<ImageCarousel size="small" images={message.imageUrls} />
)}
{shouldShowConfirmationButtons && <ConfirmationButtons />}
{messages.length - 1 === index &&
message.sender === "assistant" &&
isAwaitingUserConfirmation && <ConfirmationButtons />}
</ChatMessage>
);
}),

View File

@@ -43,7 +43,7 @@ export function AgentStatusBar() {
React.useEffect(() => {
if (status === WsClientProviderStatus.DISCONNECTED) {
setStatusMessage("Connecting...");
setStatusMessage("Trying to reconnect...");
} else {
setStatusMessage(AGENT_STATUS_MAP[curAgentState].message);
}

View File

@@ -0,0 +1,17 @@
import ExportIcon from "#/icons/export.svg?react";
import { ExportActionButton } from "#/components/shared/buttons/export-action-button";
interface ExportActionsProps {
onExportTrajectory: () => void;
}
export function ExportActions({ onExportTrajectory }: ExportActionsProps) {
return (
<div data-testid="export-actions" className="flex gap-1">
<ExportActionButton
onClick={onExportTrajectory}
icon={<ExportIcon width={15} height={15} />}
/>
</div>
);
}

View File

@@ -1,36 +1,28 @@
import ThumbsUpIcon from "#/icons/thumbs-up.svg?react";
import ThumbDownIcon from "#/icons/thumbs-down.svg?react";
import ExportIcon from "#/icons/export.svg?react";
import { TrajectoryActionButton } from "#/components/shared/buttons/trajectory-action-button";
import { FeedbackActionButton } from "#/components/shared/buttons/feedback-action-button";
interface TrajectoryActionsProps {
interface FeedbackActionsProps {
onPositiveFeedback: () => void;
onNegativeFeedback: () => void;
onExportTrajectory: () => void;
}
export function TrajectoryActions({
export function FeedbackActions({
onPositiveFeedback,
onNegativeFeedback,
onExportTrajectory,
}: TrajectoryActionsProps) {
}: FeedbackActionsProps) {
return (
<div data-testid="feedback-actions" className="flex gap-1">
<TrajectoryActionButton
<FeedbackActionButton
testId="positive-feedback"
onClick={onPositiveFeedback}
icon={<ThumbsUpIcon width={15} height={15} />}
/>
<TrajectoryActionButton
<FeedbackActionButton
testId="negative-feedback"
onClick={onNegativeFeedback}
icon={<ThumbDownIcon width={15} height={15} />}
/>
<TrajectoryActionButton
testId="export-trajectory"
onClick={onExportTrajectory}
icon={<ExportIcon width={15} height={15} />}
/>
</div>
);
}

View File

@@ -0,0 +1,17 @@
interface ExportActionButtonProps {
onClick: () => void;
icon: React.ReactNode;
}
export function ExportActionButton({ onClick, icon }: ExportActionButtonProps) {
return (
<button
type="button"
onClick={onClick}
className="button-base p-1 hover:bg-neutral-500"
title="Export trajectory"
>
{icon}
</button>
);
}

View File

@@ -1,14 +1,14 @@
interface TrajectoryActionButtonProps {
interface FeedbackActionButtonProps {
testId?: string;
onClick: () => void;
icon: React.ReactNode;
}
export function TrajectoryActionButton({
export function FeedbackActionButton({
testId,
onClick,
icon,
}: TrajectoryActionButtonProps) {
}: FeedbackActionButtonProps) {
return (
<button
type="button"

View File

@@ -11,11 +11,15 @@ import { hydrateRoot } from "react-dom/client";
import { Provider } from "react-redux";
import posthog from "posthog-js";
import "./i18n";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
QueryCache,
QueryClient,
QueryClientProvider,
} from "@tanstack/react-query";
import toast from "react-hot-toast";
import store from "./store";
import { useConfig } from "./hooks/query/use-config";
import { AuthProvider } from "./context/auth-context";
import { queryClientConfig } from "./query-client-config";
import { SettingsProvider } from "./context/settings-context";
function PosthogInit() {
@@ -46,7 +50,27 @@ async function prepareApp() {
}
}
const queryClient = new QueryClient(queryClientConfig);
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: (error, query) => {
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
},
mutations: {
onError: (error) => {
toast.error(error.message);
},
},
},
});
prepareApp().then(() =>
startTransition(() => {

View File

@@ -3,7 +3,7 @@ import { useNavigate } from "react-router";
import posthog from "posthog-js";
import { useDispatch, useSelector } from "react-redux";
import OpenHands from "#/api/open-hands";
import { setInitialPrompt } from "#/state/initial-query-slice";
import { setInitialQuery } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { useAuth } from "#/context/auth-context";
@@ -18,7 +18,7 @@ export const useCreateConversation = () => {
);
return useMutation({
mutationFn: async (variables: { q?: string }) => {
mutationFn: (variables: { q?: string }) => {
if (
!variables.q?.trim() &&
!selectedRepository &&
@@ -28,13 +28,10 @@ export const useCreateConversation = () => {
throw new Error("No query provided");
}
if (variables.q) dispatch(setInitialPrompt(variables.q));
if (variables.q) dispatch(setInitialQuery(variables.q));
return OpenHands.createConversation(
gitHubToken || undefined,
selectedRepository || undefined,
variables.q,
files,
);
},
onSuccess: async ({ conversation_id: conversationId }, { q }) => {

View File

@@ -6,7 +6,7 @@ import { useConfig } from "./use-config";
import OpenHands from "#/api/open-hands";
export const useGitHubUser = () => {
const { gitHubToken, setUserId, logout } = useAuth();
const { gitHubToken, setUserId } = useAuth();
const { data: config } = useConfig();
const user = useQuery({
@@ -29,11 +29,5 @@ export const useGitHubUser = () => {
}
}, [user.data]);
React.useEffect(() => {
if (user.isError) {
logout();
}
}, [user.isError]);
return user;
};

View File

@@ -9,6 +9,5 @@ export const useUserConversations = () => {
queryKey: ["user", "conversations"],
queryFn: OpenHands.getUserConversations,
enabled: !!userIsAuthenticated,
staleTime: 0,
});
};

View File

@@ -1 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-download"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 15 16" fill="none">
<path
d="M11.875 9.5h-2.5V3.25c0-.16576-.0658-.32473-.1831-.44194-.1172-.11721-.276-.18306-.4419-.18306h-2.5c-.16576 0-.32473.06585-.44194.18306C5.68585 2.92527 5.62 3.08424 5.62 3.25V9.5h-2.5c-.13855 0-.27293.0483-.38002.1367-.10708.0883-.18294.2124-.21493.3508-.03199.1385-.01839.2839.03873.4142.05712.1304.15543.2397.27872.3108l4.375 2.5c.09664.0552.20607.0842.3175.0842.11144 0 .22087-.029.3175-.0842l4.375-2.5c.1233-.0711.2216-.1804.2787-.3108.0571-.1303.0707-.2757.0387-.4142-.032-.1384-.1078-.2625-.2149-.3508-.1071-.0884-.2415-.1367-.38-.1367zM3.75 13.375v1.25c0 .1658.06585.3247.18306.4419.11721.1172.27618.1831.44194.1831h6.25c.1657 0 .3247-.0659.4419-.1831.1172-.1172.1831-.2761.1831-.4419v-1.25c0-.1657-.0659-.3247-.1831-.4419-.1172-.1172-.2762-.1831-.4419-.1831h-6.25c-.16576 0-.32473.0659-.44194.1831C3.81585 13.0503 3.75 13.2093 3.75 13.375z"
fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 347 B

After

Width:  |  Height:  |  Size: 963 B

View File

@@ -141,7 +141,7 @@ export const handlers = [
{ id: 2, full_name: "octocat/earth" },
]),
),
http.get("/api/github/user", () => {
http.get("https://api.github.com/user", () => {
const user: GitHubUser = {
id: 1,
login: "octocat",

View File

@@ -1,25 +0,0 @@
import { QueryClientConfig, QueryCache } from "@tanstack/react-query";
import toast from "react-hot-toast";
const QUERY_KEYS_TO_IGNORE = ["authenticated", "hosts"];
export const queryClientConfig: QueryClientConfig = {
queryCache: new QueryCache({
onError: (error, query) => {
if (!QUERY_KEYS_TO_IGNORE.some((key) => query.queryKey.includes(key))) {
toast.error(error.message);
}
},
}),
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 15, // 15 minutes
},
mutations: {
onError: (error) => {
toast.error(error.message);
},
},
},
};

View File

@@ -1,8 +1,10 @@
import React from "react";
import { useWSStatusChange } from "./hooks/use-ws-status-change";
import { useHandleWSEvents } from "./hooks/use-handle-ws-events";
import { useHandleRuntimeActive } from "./hooks/use-handle-runtime-active";
export function EventHandler({ children }: React.PropsWithChildren) {
useWSStatusChange();
useHandleWSEvents();
useHandleRuntimeActive();

View File

@@ -0,0 +1,68 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import {
useWsClient,
WsClientProviderStatus,
} from "#/context/ws-client-provider";
import { createChatMessage } from "#/services/chat-service";
import { setCurrentAgentState } from "#/state/agent-slice";
import { addUserMessage } from "#/state/chat-slice";
import { clearFiles, clearInitialQuery } from "#/state/initial-query-slice";
import { RootState } from "#/store";
import { AgentState } from "#/types/agent-state";
export const useWSStatusChange = () => {
const { send, status } = useWsClient();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const dispatch = useDispatch();
const statusRef = React.useRef<WsClientProviderStatus | null>(null);
const { files, initialQuery } = useSelector(
(state: RootState) => state.initialQuery,
);
const sendInitialQuery = (query: string, base64Files: string[]) => {
const timestamp = new Date().toISOString();
send(createChatMessage(query, base64Files, timestamp));
};
const dispatchInitialQuery = (query: string) => {
sendInitialQuery(query, files);
dispatch(clearFiles()); // reset selected files
dispatch(clearInitialQuery()); // reset initial query
};
const handleAgentInit = () => {
if (initialQuery) {
dispatchInitialQuery(initialQuery);
}
};
React.useEffect(() => {
if (curAgentState === AgentState.INIT) {
handleAgentInit();
}
}, [curAgentState]);
React.useEffect(() => {
if (statusRef.current === status) {
return; // This is a check because of strict mode - if the status did not change, don't do anything
}
statusRef.current = status;
if (status !== WsClientProviderStatus.DISCONNECTED && initialQuery) {
dispatch(
addUserMessage({
content: initialQuery,
imageUrls: files,
timestamp: new Date().toISOString(),
pending: true,
}),
);
}
if (status === WsClientProviderStatus.DISCONNECTED) {
dispatch(setCurrentAgentState(AgentState.STOPPED));
}
}, [status]);
};

View File

@@ -1,7 +1,7 @@
import { useDisclosure } from "@nextui-org/react";
import React from "react";
import { Outlet } from "react-router";
import { useDispatch, useSelector } from "react-redux";
import { useDispatch } from "react-redux";
import { FaServer } from "react-icons/fa";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
@@ -11,7 +11,7 @@ import {
useConversation,
} from "#/context/conversation-context";
import { Controls } from "#/components/features/controls/controls";
import { clearMessages, addUserMessage } from "#/state/chat-slice";
import { clearMessages } from "#/state/chat-slice";
import { clearTerminal } from "#/state/command-slice";
import { useEffectOnce } from "#/hooks/use-effect-once";
import CodeIcon from "#/icons/code.svg?react";
@@ -36,8 +36,6 @@ import { ServedAppLabel } from "#/components/layout/served-app-label";
import { TerminalStatusLabel } from "#/components/features/terminal/terminal-status-label";
import { useSettings } from "#/hooks/query/use-settings";
import { MULTI_CONVERSATION_UI } from "#/utils/feature-flags";
import { clearFiles, clearInitialPrompt } from "#/state/initial-query-slice";
import { RootState } from "#/store";
function AppContent() {
useConversationConfig();
@@ -48,9 +46,6 @@ function AppContent() {
const { data: conversation, isFetched } = useUserConversation(
conversationId || null,
);
const { initialPrompt, files } = useSelector(
(state: RootState) => state.initialQuery,
);
const dispatch = useDispatch();
const endSession = useEndSession();
@@ -79,18 +74,6 @@ function AppContent() {
dispatch(clearMessages());
dispatch(clearTerminal());
dispatch(clearJupyter());
if (conversationId && (initialPrompt || files.length > 0)) {
dispatch(
addUserMessage({
content: initialPrompt || "",
imageUrls: files || [],
timestamp: new Date().toISOString(),
pending: true,
}),
);
dispatch(clearInitialPrompt());
dispatch(clearFiles());
}
}, [conversationId]);
useEffectOnce(() => {

View File

@@ -2,14 +2,14 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
type SliceState = {
files: string[]; // base64 encoded images
initialPrompt: string | null;
initialQuery: string | null;
selectedRepository: string | null;
importedProjectZip: string | null; // base64 encoded zip
};
const initialState: SliceState = {
files: [],
initialPrompt: null,
initialQuery: null,
selectedRepository: null,
importedProjectZip: null,
};
@@ -27,11 +27,11 @@ export const selectedFilesSlice = createSlice({
clearFiles(state) {
state.files = [];
},
setInitialPrompt(state, action: PayloadAction<string>) {
state.initialPrompt = action.payload;
setInitialQuery(state, action: PayloadAction<string>) {
state.initialQuery = action.payload;
},
clearInitialPrompt(state) {
state.initialPrompt = null;
clearInitialQuery(state) {
state.initialQuery = null;
},
setSelectedRepository(state, action: PayloadAction<string | null>) {
state.selectedRepository = action.payload;
@@ -49,8 +49,8 @@ export const {
addFile,
removeFile,
clearFiles,
setInitialPrompt,
clearInitialPrompt,
setInitialQuery,
clearInitialQuery,
setSelectedRepository,
clearSelectedRepository,
setImportedProjectZip,

View File

@@ -20,7 +20,7 @@ The key classes in OpenHands are:
* Sandbox: the part of the runtime responsible for running commands, e.g. inside of Docker
* Server: brokers OpenHands sessions over HTTP, e.g. to drive the frontend
* Session: holds a single EventStream, a single AgentController, and a single Runtime. Generally represents a single task (but potentially including several user prompts)
* ConversationManager: keeps a list of active sessions, and ensures requests are routed to the correct Session
* SessionManager: keeps a list of active sessions, and ensures requests are routed to the correct Session
## Control Flow
Here's the basic loop (in pseudocode) that drives agents.

View File

@@ -12,7 +12,6 @@ from openhands.agenthub import ( # noqa: E402
codeact_agent,
delegator_agent,
dummy_agent,
visualbrowsing_agent,
)
__all__ = [
@@ -20,7 +19,6 @@ __all__ = [
'delegator_agent',
'dummy_agent',
'browsing_agent',
'visualbrowsing_agent',
]
for agent in all_microagents.values():

View File

@@ -32,7 +32,6 @@ from openhands.events.tool import ToolCallMetadata
_BASH_DESCRIPTION = """Execute a bash command in the terminal.
* Long running commands: For commands that may run indefinitely, it should be run in the background and the output should be redirected to a file, e.g. command = `python3 app.py > server.log 2>&1 &`.
* Interact with running process: If a bash command returns exit code `-1`, this means the process is not yet finished. By setting `is_input` to `true`, the assistant can interact with the running process and send empty `command` to retrieve any additional logs, or send additional text (set `command` to the text) to STDIN of the running process, or send command like `C-c` (Ctrl+C), `C-d` (Ctrl+D), `C-z` (Ctrl+Z) to interrupt the process.
* One command at a time: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.
"""
CmdRunTool = ChatCompletionToolParam(
@@ -45,7 +44,7 @@ CmdRunTool = ChatCompletionToolParam(
'properties': {
'command': {
'type': 'string',
'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process. Note: You can only execute one bash command at a time. If you need to run multiple commands sequentially, you can use `&&` or `;` to chain them together.',
'description': 'The bash command to execute. Can be empty string to view additional logs when previous exit code is `-1`. Can be `C-c` (Ctrl+C) to interrupt the currently running process.',
},
'is_input': {
'type': 'string',
@@ -81,7 +80,7 @@ IPythonTool = ChatCompletionToolParam(
),
)
_FILE_EDIT_DESCRIPTION = """Edit a file in plain-text format.
_FILE_EDIT_DESCRIPTION = """Edit a file.
* The assistant can edit files by specifying the file path and providing a draft of the new file content.
* The draft content doesn't need to be exactly the same as the existing file; the assistant may skip unchanged lines using comments like `# unchanged` to indicate unchanged sections.
* IMPORTANT: For large files (e.g., > 300 lines), specify the range of lines to edit using `start` and `end` (1-indexed, inclusive). The range should be smaller than 300 lines.
@@ -217,7 +216,7 @@ LLMBasedFileEditTool = ChatCompletionToolParam(
),
)
_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files in plain-text format
_STR_REPLACE_EDITOR_DESCRIPTION = """Custom editing tool for viewing, creating and editing files
* State is persistent across command calls and discussions with the user
* If `path` is a file, `view` displays the result of applying `cat -n`. If `path` is a directory, `view` lists non-hidden files and directories up to 2 levels deep
* The `create` command cannot be used if the specified `path` already exists as a file

View File

@@ -1,7 +1,6 @@
You are OpenHands agent, a helpful AI assistant that can interact with a computer to solve tasks.
<IMPORTANT>
* If user provides a path, you should NOT assume it's relative to the current working directory. Instead, you should explore the file system to find the file before working on it.
* You should start exploring the file system with your view command, unless you need to explore more deeply.
* When configuring git credentials, use "openhands" as the user.name and "openhands@all-hands.dev" as the user.email by default, unless explicitly instructed otherwise.
* You MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
* The assistant MUST NOT include comments in the code unless they are necessary to describe non-obvious behavior.
</IMPORTANT>

View File

@@ -1,7 +0,0 @@
# Browsing Agent Framework
This folder implements the AgentLab [generic agent](https://github.com/ServiceNow/AgentLab/tree/main/src/agentlab/agents/generic_agent) that enables full-featured web browsing. The observations given to the agent include set-of-marks annotated web-page screenshot, accessibility tree of the web-page and all the thoughts and actions from previous steps.
## Test run
Note that for browsing tasks, GPT-4/Claude is usually a requirement to get reasonable results, due to the complexity of the web page structures. This agent has been evaluated on the VisualWebArena benchmark and the CodeAct agent does not call this VisualBrowsingAgent. CodeAct agent uses has in-built support for browsing (e.g., via browse_url and browser tool).

View File

@@ -1,6 +0,0 @@
from openhands.agenthub.visualbrowsing_agent.visualbrowsing_agent import (
VisualBrowsingAgent,
)
from openhands.controller.agent import Agent
Agent.register('VisualBrowsingAgent', VisualBrowsingAgent)

View File

@@ -1,306 +0,0 @@
from browsergym.core.action.highlevel import HighLevelActionSet
from browsergym.utils.obs import flatten_axtree_to_str
from openhands.agenthub.browsing_agent.response_parser import BrowsingResponseParser
from openhands.controller.agent import Agent
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig
from openhands.core.logger import openhands_logger as logger
from openhands.core.message import ImageContent, Message, TextContent
from openhands.events.action import (
Action,
AgentFinishAction,
BrowseInteractiveAction,
MessageAction,
)
from openhands.events.event import EventSource
from openhands.events.observation import BrowserOutputObservation
from openhands.events.observation.observation import Observation
from openhands.llm.llm import LLM
from openhands.runtime.plugins import (
PluginRequirement,
)
def get_error_prefix(obs: BrowserOutputObservation) -> str:
# temporary fix for OneStopMarket to ignore timeout errors
if 'timeout' in obs.last_browser_action_error:
return ''
return f'## Error from previous action:\n{obs.last_browser_action_error}\n'
def create_goal_prompt(goal: str, image_urls: list[str] | None):
goal_txt: str = f"""\
# Instructions
Review the current state of the page and all other information to find the best possible next action to accomplish your goal. Your answer will be interpreted and executed by a program, make sure to follow the formatting instructions.
## Goal:
{goal}
"""
goal_image_urls = []
if image_urls is not None:
for idx, url in enumerate(image_urls):
goal_txt = goal_txt + f'Images: Goal input image ({idx+1})\n'
goal_image_urls.append(url)
goal_txt += '\n'
return goal_txt, goal_image_urls
def create_observation_prompt(
axtree_txt: str,
tabs: str,
focused_element: str,
error_prefix: str,
som_screenshot: str | None,
):
txt_observation = f"""
# Observation of current step:
{tabs}{axtree_txt}{focused_element}{error_prefix}
"""
# screenshot + som: will be a non-empty string if present in observation
screenshot_url = None
if (som_screenshot is not None) and (len(som_screenshot) > 0):
txt_observation += 'Image: Current page screenshot (Note that only visible portion of webpage is present in the screenshot. You may need to scroll to view the remaining portion of the web-page.\n'
screenshot_url = som_screenshot
else:
logger.info('SOM Screenshot not present in observation!')
txt_observation += '\n'
return txt_observation, screenshot_url
def get_tabs(obs: BrowserOutputObservation) -> str:
prompt_pieces = ['\n## Currently open tabs:']
for page_index, page_url in enumerate(obs.open_pages_urls):
active_or_not = ' (active tab)' if page_index == obs.active_page_index else ''
prompt_piece = f"""\
Tab {page_index}{active_or_not}:
URL: {page_url}
"""
prompt_pieces.append(prompt_piece)
return '\n'.join(prompt_pieces) + '\n'
def get_axtree(axtree_txt: str) -> str:
bid_info = """\
Note: [bid] is the unique alpha-numeric identifier at the beginning of lines for each element in the AXTree. Always use bid to refer to elements in your actions.
"""
visible_tag_info = """\
Note: You can only interact with visible elements. If the "visible" tag is not present, the element is not visible on the page.
"""
return f'\n## AXTree:\n{bid_info}{visible_tag_info}{axtree_txt}\n'
def get_action_prompt(action_set: HighLevelActionSet) -> str:
action_set_generic_info = """\
Note: This action set allows you to interact with your environment. Most of them are python function executing playwright code. The primary way of referring to elements in the page is through bid which are specified in your observations.
"""
action_description = action_set.describe(
with_long_description=False,
with_examples=False,
)
action_prompt = f'# Action space:\n{action_set_generic_info}{action_description}\n'
return action_prompt
def get_history_prompt(prev_actions: list[BrowseInteractiveAction]) -> str:
history_prompt = ['# History of all previous interactions with the task:\n']
for i in range(len(prev_actions)):
history_prompt.append(f'## step {i+1}')
history_prompt.append(
f'\nOuput thought and action: {prev_actions[i].thought} ```{prev_actions[i].browser_actions}```\n'
)
return '\n'.join(history_prompt) + '\n'
class VisualBrowsingAgent(Agent):
VERSION = '1.0'
"""
VisualBrowsing Agent that can uses webpage screenshots during browsing.
"""
sandbox_plugins: list[PluginRequirement] = []
response_parser = BrowsingResponseParser()
def __init__(
self,
llm: LLM,
config: AgentConfig,
) -> None:
"""Initializes a new instance of the VisualBrowsingAgent class.
Parameters:
- llm (LLM): The llm to be used by this agent
"""
super().__init__(llm, config)
# define a configurable action space, with chat functionality, web navigation, and webpage grounding using accessibility tree and HTML.
# see https://github.com/ServiceNow/BrowserGym/blob/main/core/src/browsergym/core/action/highlevel.py for more details
action_subsets = [
'chat',
'bid',
'nav',
'tab',
'infeas',
]
self.action_space = HighLevelActionSet(
subsets=action_subsets,
strict=False, # less strict on the parsing of the actions
multiaction=False,
)
self.action_prompt = get_action_prompt(self.action_space)
self.abstract_example = f"""
# Abstract Example
Here is an abstract version of the answer with description of the content of each tag. Make sure you follow this structure, but replace the content with your answer:
You must mandatorily think step by step. If you need to make calculations such as coordinates, write them here. Describe the effect that your previous action had on the current content of the page. In summary the next action I will perform is ```{self.action_space.example_action(abstract=True)}```
"""
self.concrete_example = """
# Concrete Example
Here is a concrete example of how to format your answer. Make sure to generate the action in the correct format ensuring that the action is present inside ``````:
Let's think step-by-step. From previous action I tried to set the value of year to "2022", using select_option, but it doesn't appear to be in the form. It may be a dynamic dropdown, I will try using click with the bid "324" and look at the response from the page. In summary the next action I will perform is ```click('324')```
"""
self.hints = """
Note:
* Make sure to use bid to identify elements when using commands.
* Interacting with combobox, dropdowns and auto-complete fields can be tricky, sometimes you need to use select_option, while other times you need to use fill or click and wait for the reaction of the page.
"""
self.reset()
def reset(self) -> None:
"""Resets the VisualBrowsingAgent."""
super().reset()
self.cost_accumulator = 0
self.error_accumulator = 0
def step(self, state: State) -> Action:
"""Performs one step using the VisualBrowsingAgent.
This includes gathering information on previous steps and prompting the model to make a browsing command to execute.
Parameters:
- state (State): used to get updated info
Returns:
- BrowseInteractiveAction(browsergym_command) - BrowserGym commands to run
- MessageAction(content) - Message action to run (e.g. ask for clarification)
- AgentFinishAction() - end the interaction
"""
messages: list[Message] = []
prev_actions = []
cur_axtree_txt = ''
error_prefix = ''
focused_element = ''
tabs = ''
last_obs = None
last_action = None
if len(state.history) == 1:
# for visualwebarena, 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(1000)')
for event in state.history:
if isinstance(event, BrowseInteractiveAction):
prev_actions.append(event)
last_action = event
elif isinstance(event, MessageAction) and event.source == EventSource.AGENT:
# agent has responded, task finished.
return AgentFinishAction(outputs={'content': event.content})
elif isinstance(event, Observation):
last_obs = event
if len(prev_actions) >= 1: # ignore noop()
prev_actions = prev_actions[1:] # remove the first noop action
# if the final BrowserInteractiveAction exec BrowserGym's send_msg_to_user,
# we should also send a message back to the user in OpenHands and call it a day
if (
isinstance(last_action, BrowseInteractiveAction)
and last_action.browsergym_send_msg_to_user
):
return MessageAction(last_action.browsergym_send_msg_to_user)
history_prompt = get_history_prompt(prev_actions)
if isinstance(last_obs, BrowserOutputObservation):
if last_obs.error:
# add error recovery prompt prefix
error_prefix = get_error_prefix(last_obs)
if len(error_prefix) > 0:
self.error_accumulator += 1
if self.error_accumulator > 5:
return MessageAction(
'Too many errors encountered. Task failed.'
)
focused_element = '## Focused element:\nNone\n'
if last_obs.focused_element_bid is not None:
focused_element = (
f"## Focused element:\nbid='{last_obs.focused_element_bid}'\n"
)
tabs = get_tabs(last_obs)
try:
# 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,
)
cur_axtree_txt = get_axtree(axtree_txt=cur_axtree_txt)
except Exception as e:
logger.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()
if goal is None:
goal = state.inputs['task']
goal_txt, goal_images = create_goal_prompt(goal, image_urls)
observation_txt, som_screenshot = create_observation_prompt(
cur_axtree_txt, tabs, focused_element, error_prefix, set_of_marks
)
human_prompt = [TextContent(type='text', text=goal_txt)]
if len(goal_images) > 0:
human_prompt.append(ImageContent(image_urls=goal_images))
human_prompt.append(TextContent(type='text', text=observation_txt))
if som_screenshot is not None:
human_prompt.append(ImageContent(image_urls=[som_screenshot]))
remaining_content = f"""
{history_prompt}\
{self.action_prompt}\
{self.hints}\
{self.abstract_example}\
{self.concrete_example}\
"""
human_prompt.append(TextContent(type='text', text=remaining_content))
system_msg = """\
You are an agent trying to solve a web task based on the content of the page and user instructions. You can interact with the page and explore, and send messages to the user when you finish the task. Each time you submit an action it will be sent to the browser and you will receive a new page.
""".strip()
messages.append(Message(role='system', content=[TextContent(text=system_msg)]))
messages.append(Message(role='user', content=human_prompt))
flat_messages = self.llm.format_messages_for_llm(messages)
response = self.llm.completion(
messages=flat_messages,
temperature=0.0,
stop=[')```', ')\n```'],
)
return self.response_parser.parse(response)

View File

@@ -501,6 +501,10 @@ class AgentController:
EventSource.ENVIRONMENT,
)
if new_state == AgentState.INIT and self.state.resume_state:
await self.set_agent_state_to(self.state.resume_state)
self.state.resume_state = None
def get_agent_state(self) -> AgentState:
"""Returns the current state of the agent.

View File

@@ -39,7 +39,7 @@ class SandboxConfig(BaseModel):
remote_runtime_api_url: str = Field(default='http://localhost:8000')
local_runtime_url: str = Field(default='http://localhost')
keep_runtime_alive: bool = Field(default=False)
keep_runtime_alive: bool = Field(default=True)
rm_all_containers: bool = Field(default=False)
api_key: str | None = Field(default=None)
base_container_image: str = Field(

View File

@@ -9,7 +9,7 @@ from uuid import uuid4
import toml
from dotenv import load_dotenv
from pydantic import BaseModel, SecretStr, ValidationError
from pydantic import BaseModel, ValidationError
from openhands.core import logger
from openhands.core.config.agent_config import AgentConfig
@@ -192,7 +192,7 @@ def load_from_toml(cfg: AppConfig, toml_file: str = 'config.toml'):
custom_fields[k] = v
merged_llm_dict = generic_llm_fields.copy()
merged_llm_dict.update(custom_fields)
custom_llm_config = LLMConfig(**merged_llm_dict)
cfg.set_llm_config(custom_llm_config, nested_key)
@@ -287,10 +287,8 @@ def finalize_config(cfg: AppConfig):
pathlib.Path(cfg.cache_dir).mkdir(parents=True, exist_ok=True)
if not cfg.jwt_secret:
cfg.jwt_secret = SecretStr(
get_or_create_jwt_secret(
get_file_store(cfg.file_store, cfg.file_store_path)
)
cfg.jwt_secret = get_or_create_jwt_secret(
get_file_store(cfg.file_store, cfg.file_store_path)
)

View File

@@ -4,6 +4,10 @@ __all__ = ['ActionType']
class ActionTypeSchema(BaseModel):
INIT: str = Field(default='initialize')
"""Initializes the agent. Only sent by client.
"""
MESSAGE: str = Field(default='message')
"""Represents a message.
"""

View File

@@ -6,6 +6,10 @@ class AgentState(str, Enum):
"""The agent is loading.
"""
INIT = 'init'
"""The agent is initialized.
"""
RUNNING = 'running'
"""The agent is running.
"""

View File

@@ -13,10 +13,8 @@ class BrowserOutputObservation(Observation):
url: str
trigger_by_action: str
screenshot: str = field(repr=False, default='') # don't show in repr
set_of_marks: str = field(default='', repr=False) # don't show in repr
error: bool = False
observation: str = ObservationType.BROWSE
goal_image_urls: list = field(default_factory=list)
# do not include in the memory
open_pages_urls: list = field(default_factory=list)
active_page_index: int = -1

View File

@@ -18,4 +18,3 @@ class GithubIssue(BaseModel):
review_threads: list[ReviewThread] | None = None
thread_ids: list[str] | None = None
head_branch: str | None = None
base_branch: str | None = None

View File

@@ -331,10 +331,9 @@ def main():
if not token:
raise ValueError('Github token is required.')
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
llm_config = LLMConfig(
model=my_args.llm_model or os.environ['LLM_MODEL'],
api_key=str(api_key) if api_key else None,
api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'],
base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None),
)

View File

@@ -307,6 +307,7 @@ async def resolve_issue(
repo_instruction: str | None,
issue_number: int,
comment_id: int | None,
target_branch: str | None = None,
reset_logger: bool = False,
) -> None:
"""Resolve a single github issue.
@@ -325,7 +326,7 @@ async def resolve_issue(
repo_instruction: Repository instruction to use.
issue_number: Issue number to resolve.
comment_id: Optional ID of a specific comment to focus on.
target_branch: Optional target branch to create PR against (for PRs).
reset_logger: Whether to reset the logger for multiprocessing.
"""
issue_handler = issue_handler_factory(issue_type, owner, repo, token, llm_config)
@@ -423,9 +424,9 @@ async def resolve_issue(
try:
# checkout to pr branch if needed
if issue_type == 'pr':
branch_to_use = issue.head_branch
branch_to_use = target_branch if target_branch else issue.head_branch
logger.info(
f'Checking out to PR branch {branch_to_use} for issue {issue.number}'
f'Checking out to PR branch {target_branch} for issue {issue.number}'
)
if not branch_to_use:
@@ -445,6 +446,10 @@ async def resolve_issue(
cwd=repo_dir,
)
# Update issue's base_branch if using custom target branch
if target_branch:
issue.base_branch = target_branch
base_commit = (
subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=repo_dir)
.decode('utf-8')
@@ -567,6 +572,12 @@ def main():
choices=['issue', 'pr'],
help='Type of issue to resolve, either open issue or pr comments.',
)
parser.add_argument(
'--target-branch',
type=str,
default=None,
help="Target branch to pull and create PR against (for PRs). If not specified, uses the PR's base branch.",
)
parser.add_argument(
'--is-experimental',
type=lambda x: x.lower() == 'true',
@@ -590,10 +601,9 @@ def main():
if not token:
raise ValueError('Github token is required.')
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
llm_config = LLMConfig(
model=my_args.llm_model or os.environ['LLM_MODEL'],
api_key=str(api_key) if api_key else None,
api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'],
base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None),
)
@@ -633,6 +643,7 @@ def main():
repo_instruction=repo_instruction,
issue_number=my_args.issue_number,
comment_id=my_args.comment_id,
target_branch=my_args.target_branch,
)
)

View File

@@ -719,10 +719,9 @@ def main():
else os.getenv('GITHUB_USERNAME')
)
api_key = my_args.llm_api_key or os.environ['LLM_API_KEY']
llm_config = LLMConfig(
model=my_args.llm_model or os.environ['LLM_MODEL'],
api_key=str(api_key) if api_key else None,
api_key=my_args.llm_api_key or os.environ['LLM_API_KEY'],
base_url=my_args.llm_base_url or os.environ.get('LLM_BASE_URL', None),
)

View File

@@ -136,10 +136,6 @@ class Runtime(FileEditRuntimeMixin):
def close(self) -> None:
pass
@classmethod
async def delete(cls, conversation_id: str) -> None:
pass
def log(self, level: str, message: str) -> None:
message = f'[runtime {self.sid}] {message}'
getattr(logger, level)(message, stacklevel=2)

View File

@@ -11,7 +11,7 @@ import gymnasium as gym
import html2text
import numpy as np
import tenacity
from browsergym.utils.obs import flatten_dom_to_str, overlay_som
from browsergym.utils.obs import flatten_dom_to_str
from PIL import Image
from openhands.core.exceptions import BrowserInitException
@@ -65,22 +65,15 @@ class BrowserEnv:
logger.error(f'Failed to start browser process: {e}')
raise
if not self.check_alive(timeout=200):
if not self.check_alive():
self.close()
raise BrowserInitException('Failed to start browser environment.')
def browser_process(self):
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:
logger.debug('Initializing browser env for web browsing evaluation.')
if '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
@@ -88,7 +81,10 @@ class BrowserEnv:
raise ValueError(
f'Unsupported browsergym eval env: {self.browsergym_eval_env}'
)
env = gym.make(self.browsergym_eval_env, tags_to_mark='all', timeout=100000)
env = gym.make(
self.browsergym_eval_env,
tags_to_mark='all',
)
else:
env = gym.make(
'browsergym/openended',
@@ -98,27 +94,17 @@ class BrowserEnv:
disable_env_checker=True,
tags_to_mark='all',
)
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:
logger.debug(f"Browsing goal: {obs['goal']}")
self.eval_goal = obs['goal']
if 'goal_object' in obs:
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.')
logger.debug('Browser env started.')
while should_continue():
try:
if self.browser_side.poll(timeout=0.01):
@@ -136,13 +122,7 @@ class BrowserEnv:
# 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,
},
)
(unique_request_id, {'text_content': self.eval_goal})
)
continue
elif action_data['action'] == BROWSER_EVAL_GET_REWARDS_ACTION:
@@ -165,15 +145,7 @@ class BrowserEnv:
html_str = flatten_dom_to_str(obs['dom_object'])
obs['text_content'] = self.html_text_converter.handle(html_str)
# make observation serializable
obs['set_of_marks'] = self.image_to_png_base64_url(
overlay_som(
obs['screenshot'], obs.get('extra_element_properties', {})
),
add_data_prefix=True,
)
obs['screenshot'] = self.image_to_png_base64_url(
obs['screenshot'], add_data_prefix=True
)
obs['screenshot'] = self.image_to_png_base64_url(obs['screenshot'])
obs['active_page_index'] = obs['active_page_index'].item()
obs['elapsed_time'] = obs['elapsed_time'].item()
self.browser_side.send((unique_request_id, obs))
@@ -185,7 +157,7 @@ class BrowserEnv:
pass
return
def step(self, action_str: str, timeout: float = 100) -> dict:
def step(self, action_str: str, timeout: float = 30) -> dict:
"""Execute an action in the browser environment and return the observation."""
unique_request_id = str(uuid.uuid4())
self.agent_side.send((unique_request_id, {'action': action_str}))

View File

@@ -35,10 +35,6 @@ async def browse(
content=obs['text_content'], # text content of the page
url=obs.get('url', ''), # URL of the page
screenshot=obs.get('screenshot', None), # base64-encoded screenshot, png
set_of_marks=obs.get(
'set_of_marks', None
), # base64-encoded Set-of-Marks annotated screenshot, png,
goal_image_urls=obs.get('image_content', []),
open_pages_urls=obs.get('open_pages_urls', []), # list of open pages
active_page_index=obs.get(
'active_page_index', -1

View File

@@ -1,19 +1,18 @@
import docker
def stop_all_containers(prefix: str):
def remove_all_containers(prefix: str):
docker_client = docker.from_env()
try:
containers = docker_client.containers.list(all=True)
for container in containers:
try:
if container.name.startswith(prefix):
container.stop()
container.remove(force=True)
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
except docker.errors.NotFound: # yes, this can happen!
pass
finally:
docker_client.close()

View File

@@ -5,7 +5,6 @@ from typing import Callable
import docker
import requests
import tenacity
from docker.models.containers import Container
from openhands.core.config import AppConfig
from openhands.core.exceptions import (
@@ -19,7 +18,7 @@ from openhands.runtime.builder import DockerRuntimeBuilder
from openhands.runtime.impl.action_execution.action_execution_client import (
ActionExecutionClient,
)
from openhands.runtime.impl.docker.containers import stop_all_containers
from openhands.runtime.impl.docker.containers import remove_all_containers
from openhands.runtime.plugins import PluginRequirement
from openhands.runtime.utils import find_available_tcp_port
from openhands.runtime.utils.command import get_action_execution_server_startup_command
@@ -36,8 +35,8 @@ APP_PORT_RANGE_1 = (50000, 54999)
APP_PORT_RANGE_2 = (55000, 59999)
def stop_all_runtime_containers():
stop_all_containers(CONTAINER_NAME_PREFIX)
def remove_all_runtime_containers():
remove_all_containers(CONTAINER_NAME_PREFIX)
_atexit_registered = False
@@ -67,9 +66,9 @@ class DockerRuntime(ActionExecutionClient):
headless_mode: bool = True,
):
global _atexit_registered
if not _atexit_registered:
if not _atexit_registered and not config.sandbox.keep_runtime_alive:
_atexit_registered = True
atexit.register(stop_all_runtime_containers)
atexit.register(remove_all_runtime_containers)
self.config = config
self._runtime_initialized: bool = False
@@ -86,7 +85,7 @@ class DockerRuntime(ActionExecutionClient):
self.base_container_image = self.config.sandbox.base_container_image
self.runtime_container_image = self.config.sandbox.runtime_container_image
self.container_name = CONTAINER_NAME_PREFIX + sid
self.container: Container | None = None
self.container = None
self.runtime_builder = DockerRuntimeBuilder(self.docker_client)
@@ -188,6 +187,7 @@ class DockerRuntime(ActionExecutionClient):
def _init_container(self):
self.log('debug', 'Preparing to start container...')
self.send_status_message('STATUS$PREPARING_CONTAINER')
self._host_port = self._find_available_port(EXECUTION_SERVER_PORT_RANGE)
self._container_port = self._host_port
self._vscode_port = self._find_available_port(VSCODE_PORT_RANGE)
@@ -287,7 +287,7 @@ class DockerRuntime(ActionExecutionClient):
'warning',
f'Container {self.container_name} already exists. Removing...',
)
stop_all_containers(self.container_name)
remove_all_containers(self.container_name)
return self._init_container()
else:
@@ -308,20 +308,20 @@ class DockerRuntime(ActionExecutionClient):
def _attach_to_container(self):
self.container = self.docker_client.containers.get(self.container_name)
if self.container.status == 'exited':
self.container.start()
config = self.container.attrs['Config']
for env_var in config['Env']:
if env_var.startswith('port='):
self._host_port = int(env_var.split('port=')[1])
self._container_port = self._host_port
elif env_var.startswith('VSCODE_PORT='):
self._vscode_port = int(env_var.split('VSCODE_PORT=')[1])
self._app_ports = []
for exposed_port in config['ExposedPorts'].keys():
exposed_port = int(exposed_port.split('/tcp')[0])
if exposed_port != self._host_port and exposed_port != self._vscode_port:
self._app_ports.append(exposed_port)
for port in self.container.attrs['NetworkSettings']['Ports']: # type: ignore
port = int(port.split('/')[0])
if (
port >= EXECUTION_SERVER_PORT_RANGE[0]
and port <= EXECUTION_SERVER_PORT_RANGE[1]
):
self._container_port = port
if port >= VSCODE_PORT_RANGE[0] and port <= VSCODE_PORT_RANGE[1]:
self._vscode_port = port
elif port >= APP_PORT_RANGE_1[0] and port <= APP_PORT_RANGE_1[1]:
self._app_ports.append(port)
elif port >= APP_PORT_RANGE_2[0] and port <= APP_PORT_RANGE_2[1]:
self._app_ports.append(port)
self._host_port = self._container_port
self.api_url = f'{self.config.sandbox.local_runtime_url}:{self._container_port}'
self.log(
'debug',
@@ -368,7 +368,7 @@ class DockerRuntime(ActionExecutionClient):
close_prefix = (
CONTAINER_NAME_PREFIX if rm_all_containers else self.container_name
)
stop_all_containers(close_prefix)
remove_all_containers(close_prefix)
def _is_port_in_use_docker(self, port):
containers = self.docker_client.containers.list()
@@ -404,17 +404,3 @@ class DockerRuntime(ActionExecutionClient):
hosts[f'http://localhost:{port}'] = port
return hosts
@classmethod
async def delete(cls, conversation_id: str):
docker_client = cls._init_docker_client()
try:
container_name = CONTAINER_NAME_PREFIX + conversation_id
container = docker_client.containers.get(container_name)
container.remove(force=True)
except docker.errors.APIError:
pass
except docker.errors.NotFound:
pass
finally:
docker_client.close()

View File

@@ -40,7 +40,6 @@ class ModalRuntime(ActionExecutionClient):
container_name_prefix = 'openhands-sandbox-'
sandbox: modal.Sandbox | None
sid: str
def __init__(
self,
@@ -58,7 +57,6 @@ class ModalRuntime(ActionExecutionClient):
self.config = config
self.sandbox = None
self.sid = sid
self.modal_client = modal.Client.from_credentials(
config.modal_api_token_id.get_secret_value(),
@@ -77,8 +75,6 @@ class ModalRuntime(ActionExecutionClient):
# This value is arbitrary as it's private to the container
self.container_port = 3000
self._vscode_port = 4445
self._vscode_url: str | None = None
self.status_callback = status_callback
self.base_container_image_id = self.config.sandbox.base_container_image
@@ -144,7 +140,6 @@ class ModalRuntime(ActionExecutionClient):
if not self.attach_to_existing:
self.send_status_message(' ')
self._runtime_initialized = True
def _get_action_execution_server_host(self):
return self.api_url
@@ -213,7 +208,6 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
environment: dict[str, str | None] = {
'port': str(self.container_port),
'PYTHONUNBUFFERED': '1',
'VSCODE_PORT': str(self._vscode_port),
}
if self.config.debug:
environment['DEBUG'] = 'true'
@@ -231,7 +225,7 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
*sandbox_start_cmd,
secrets=[env_secret],
workdir='/openhands/code',
encrypted_ports=[self.container_port, self._vscode_port],
encrypted_ports=[self.container_port],
image=self.image,
app=self.app,
client=self.modal_client,
@@ -254,27 +248,3 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
if not self.attach_to_existing and self.sandbox:
self.sandbox.terminate()
@property
def vscode_url(self) -> str | None:
if self._vscode_url is not None: # cached value
self.log('debug', f'VSCode URL: {self._vscode_url}')
return self._vscode_url
token = super().get_vscode_token()
if not token:
self.log('error', 'VSCode token not found')
return None
if not self.sandbox:
self.log('error', 'Sandbox not initialized')
return None
tunnel = self.sandbox.tunnels()[self._vscode_port]
tunnel_url = tunnel.url
self._vscode_url = tunnel_url + f'/?tkn={token}&folder={self.config.workspace_mount_path_in_sandbox}'
self.log(
'debug',
f'VSCode URL: {self._vscode_url}',
)
return self._vscode_url

View File

@@ -306,12 +306,6 @@ class RemoteRuntime(ActionExecutionClient):
assert 'pod_status' in runtime_data
pod_status = runtime_data['pod_status'].lower()
self.log('debug', f'Pod status: {pod_status}')
restart_count = runtime_data.get('restart_count', 0)
if restart_count != 0:
restart_reasons = runtime_data.get('restart_reasons')
self.log(
'debug', f'Pod restarts: {restart_count}, reasons: {restart_reasons}'
)
# FIXME: We should fix it at the backend of /start endpoint, make sure
# the pod is created before returning the response.

View File

@@ -125,13 +125,13 @@ The `agent_session.py` file contains the `AgentSession` class, which manages the
- Handling security analysis
- Managing the event stream
### 3. session/conversation_manager/conversation_manager.py
### 3. session/manager.py
The `conversation_manager.py` file defines the `ConversationManager` class, which is responsible for managing multiple client conversations. Key features include:
The `manager.py` file defines the `SessionManager` class, which is responsible for managing multiple client sessions. Key features include:
- Adding and restarting conversations
- Sending messages to specific conversations
- Cleaning up inactive conversations
- Adding and restarting sessions
- Sending messages to specific sessions
- Cleaning up inactive sessions
### 4. listen.py
@@ -148,7 +148,7 @@ The `listen.py` file is the main server file that sets up the FastAPI applicatio
1. **Server Initialization**:
- The FastAPI application is created and configured in `listen.py`.
- CORS middleware and static file serving are set up.
- The `ConversationManager` is initialized.
- The `SessionManager` is initialized.
2. **Client Connection**:
- When a client connects via WebSocket, a new `Session` is created or an existing one is restarted.
@@ -173,7 +173,7 @@ The `listen.py` file is the main server file that sets up the FastAPI applicatio
- Security-related API requests are forwarded to the security analyzer.
7. **Session Management**:
- The `ConversationManager` periodically cleans up inactive sessions.
- The `SessionManager` periodically cleans up inactive sessions.
- It also handles sending messages to specific sessions when needed.
8. **API Endpoints**:

View File

@@ -10,6 +10,13 @@ from fastapi import (
import openhands.agenthub # noqa F401 (we import this to get the agents registered)
from openhands import __version__
from openhands.server.middleware import (
AttachConversationMiddleware,
CacheControlMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
RateLimitMiddleware,
)
from openhands.server.routes.conversation import app as conversation_api_router
from openhands.server.routes.feedback import app as feedback_api_router
from openhands.server.routes.files import app as files_api_router
@@ -21,12 +28,13 @@ from openhands.server.routes.public import app as public_api_router
from openhands.server.routes.security import app as security_api_router
from openhands.server.routes.settings import app as settings_router
from openhands.server.routes.trajectory import app as trajectory_router
from openhands.server.shared import conversation_manager, openhands_config
from openhands.server.shared import openhands_config, session_manager
from openhands.utils.import_utils import get_impl
@asynccontextmanager
async def _lifespan(app: FastAPI):
async with conversation_manager:
async with session_manager:
yield
@@ -36,7 +44,17 @@ app = FastAPI(
version=__version__,
lifespan=_lifespan,
)
openhands_config.attach_middleware(app)
app.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
app.add_middleware(CacheControlMiddleware)
app.add_middleware(
RateLimitMiddleware, rate_limiter=InMemoryRateLimiter(requests=10, seconds=1)
)
@app.get('/health')
@@ -53,3 +71,8 @@ app.include_router(manage_conversation_api_router)
app.include_router(settings_router)
app.include_router(github_api_router)
app.include_router(trajectory_router)
AttachConversationMiddlewareImpl = get_impl(
AttachConversationMiddleware, openhands_config.attach_conversation_middleware_path
)
app.middleware('http')(AttachConversationMiddlewareImpl(app))

View File

@@ -1,5 +1,44 @@
import jwt
from fastapi import Request
from jwt.exceptions import InvalidTokenError
from openhands.core.logger import openhands_logger as logger
def get_user_id(request: Request) -> str | None:
return getattr(request.state, 'github_user_id', None)
def get_sid_from_token(token: str, jwt_secret: str) -> str:
"""Retrieves the session id from a JWT token.
Parameters:
token (str): The JWT token from which the session id is to be extracted.
Returns:
str: The session id if found and valid, otherwise an empty string.
"""
try:
# Decode the JWT using the specified secret and algorithm
payload = jwt.decode(token, jwt_secret, algorithms=['HS256'])
# Ensure the payload contains 'sid'
if 'sid' in payload:
return payload['sid']
else:
logger.error('SID not found in token')
return ''
except InvalidTokenError:
logger.error('Invalid token')
except Exception as e:
logger.exception('Unexpected error decoding token: %s', e)
return ''
def sign_token(payload: dict[str, object], jwt_secret: str, algorithm='HS256') -> str:
"""Signs a JWT token."""
# payload = {
# "sid": sid,
# # "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
# }
return jwt.encode(payload, jwt_secret, algorithm=algorithm)

View File

@@ -1,15 +1,8 @@
import os
from fastapi import FastAPI, HTTPException
from fastapi import HTTPException
from openhands.core.logger import openhands_logger as logger
from openhands.server.middleware import (
AttachConversationMiddleware,
CacheControlMiddleware,
InMemoryRateLimiter,
LocalhostCORSMiddleware,
RateLimitMiddleware,
)
from openhands.server.types import AppMode, OpenhandsConfigInterface
from openhands.utils.import_utils import get_impl
@@ -19,13 +12,15 @@ class OpenhandsConfig(OpenhandsConfigInterface):
app_mode = AppMode.OSS
posthog_client_key = 'phc_3ESMmY9SgqEAGBB6sMGK5ayYHkeUuknH2vP6FmWH9RA'
github_client_id = os.environ.get('GITHUB_APP_CLIENT_ID', '')
attach_conversation_middleware_path = (
'openhands.server.middleware.AttachConversationMiddleware'
)
settings_store_class: str = (
'openhands.storage.settings.file_settings_store.FileSettingsStore'
)
conversation_store_class: str = (
'openhands.storage.conversation.file_conversation_store.FileConversationStore'
)
conversation_manager_class: str = 'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager'
def verify_config(self):
if self.config_cls:
@@ -47,21 +42,6 @@ class OpenhandsConfig(OpenhandsConfigInterface):
return config
def attach_middleware(self, api: FastAPI) -> None:
api.add_middleware(
LocalhostCORSMiddleware,
allow_credentials=True,
allow_methods=['*'],
allow_headers=['*'],
)
api.add_middleware(CacheControlMiddleware)
api.add_middleware(
RateLimitMiddleware,
rate_limiter=InMemoryRateLimiter(requests=10, seconds=1),
)
api.middleware('http')(AttachConversationMiddleware(api))
def load_openhands_config():
config_cls = os.environ.get('OPENHANDS_CONFIG_CLS', None)

View File

@@ -1,96 +0,0 @@
from __future__ import annotations
from abc import ABC, abstractmethod
import socketio
from openhands.core.config import AppConfig
from openhands.events.action import MessageAction
from openhands.events.stream import EventStream
from openhands.server.session.conversation import Conversation
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
class ConversationManager(ABC):
"""Abstract base class for managing conversations in OpenHands.
This class defines the interface for managing conversations, whether in standalone
or clustered mode. It handles the lifecycle of conversations, including creation,
attachment, detachment, and cleanup.
"""
sio: socketio.AsyncServer
config: AppConfig
file_store: FileStore
@abstractmethod
async def __aenter__(self):
"""Initialize the conversation manager."""
@abstractmethod
async def __aexit__(self, exc_type, exc_value, traceback):
"""Clean up the conversation manager."""
@abstractmethod
async def attach_to_conversation(self, sid: str) -> Conversation | None:
"""Attach to an existing conversation or create a new one."""
@abstractmethod
async def detach_from_conversation(self, conversation: Conversation):
"""Detach from a conversation."""
@abstractmethod
async def join_conversation(
self, sid: str, connection_id: str, settings: Settings, user_id: str | None
) -> EventStream | None:
"""Join a conversation and return its event stream."""
async def is_agent_loop_running(self, sid: str) -> bool:
"""Check if an agent loop is running for the given session ID."""
sids = await self.get_running_agent_loops(filter_to_sids={sid})
return bool(sids)
@abstractmethod
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
"""Get all running agent loops, optionally filtered by user ID and session IDs."""
@abstractmethod
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
"""Get all connections, optionally filtered by user ID and session IDs."""
@abstractmethod
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
) -> EventStream:
"""Start an event loop if one is not already running"""
@abstractmethod
async def send_to_event_stream(self, connection_id: str, data: dict):
"""Send data to an event stream."""
@abstractmethod
async def disconnect_from_session(self, connection_id: str):
"""Disconnect from a session."""
@abstractmethod
async def close_session(self, sid: str):
"""Close a session."""
@classmethod
@abstractmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: AppConfig,
file_store: FileStore,
) -> ConversationManager:
"""Get a store for the user represented by the token given"""

View File

@@ -1,284 +0,0 @@
import asyncio
import time
from dataclasses import dataclass, field
from typing import Iterable
import socketio
from openhands.core.config.app_config import AppConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.stream import EventStream, session_exists
from openhands.server.session.conversation import Conversation
from openhands.server.session.session import ROOM_KEY, Session
from openhands.server.settings import Settings
from openhands.storage.files import FileStore
from openhands.utils.async_utils import wait_all
from openhands.utils.shutdown_listener import should_continue
from .conversation_manager import ConversationManager
_CLEANUP_INTERVAL = 15
MAX_RUNNING_CONVERSATIONS = 3
@dataclass
class StandaloneConversationManager(ConversationManager):
"""Manages conversations in standalone mode (single server instance)."""
sio: socketio.AsyncServer
config: AppConfig
file_store: FileStore
_local_agent_loops_by_sid: dict[str, Session] = field(default_factory=dict)
_local_connection_id_to_session_id: dict[str, str] = field(default_factory=dict)
_active_conversations: dict[str, tuple[Conversation, int]] = field(
default_factory=dict
)
_detached_conversations: dict[str, tuple[Conversation, float]] = field(
default_factory=dict
)
_conversations_lock: asyncio.Lock = field(default_factory=asyncio.Lock)
_cleanup_task: asyncio.Task | None = None
async def __aenter__(self):
self._cleanup_task = asyncio.create_task(self._cleanup_stale())
return self
async def __aexit__(self, exc_type, exc_value, traceback):
if self._cleanup_task:
self._cleanup_task.cancel()
self._cleanup_task = None
async def attach_to_conversation(self, sid: str) -> Conversation | None:
start_time = time.time()
if not await session_exists(sid, self.file_store):
return None
async with self._conversations_lock:
# Check if we have an active conversation we can reuse
if sid in self._active_conversations:
conversation, count = self._active_conversations[sid]
self._active_conversations[sid] = (conversation, count + 1)
logger.info(f'Reusing active conversation {sid}')
return conversation
# Check if we have a detached conversation we can reuse
if sid in self._detached_conversations:
conversation, _ = self._detached_conversations.pop(sid)
self._active_conversations[sid] = (conversation, 1)
logger.info(f'Reusing detached conversation {sid}')
return conversation
# Create new conversation if none exists
c = Conversation(sid, file_store=self.file_store, config=self.config)
try:
await c.connect()
except AgentRuntimeUnavailableError as e:
logger.error(f'Error connecting to conversation {c.sid}: {e}')
await c.disconnect()
return None
end_time = time.time()
logger.info(
f'Conversation {c.sid} connected in {end_time - start_time} seconds'
)
self._active_conversations[sid] = (c, 1)
return c
async def join_conversation(
self, sid: str, connection_id: str, settings: Settings, user_id: str | None
):
logger.info(f'join_conversation:{sid}:{connection_id}')
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
self._local_connection_id_to_session_id[connection_id] = sid
event_stream = await self._get_event_stream(sid)
if not event_stream:
return await self.maybe_start_agent_loop(sid, settings, user_id)
return event_stream
async def detach_from_conversation(self, conversation: Conversation):
sid = conversation.sid
async with self._conversations_lock:
if sid in self._active_conversations:
conv, count = self._active_conversations[sid]
if count > 1:
self._active_conversations[sid] = (conv, count - 1)
return
else:
self._active_conversations.pop(sid)
self._detached_conversations[sid] = (conversation, time.time())
async def _cleanup_stale(self):
while should_continue():
try:
async with self._conversations_lock:
# Create a list of items to process to avoid modifying dict during iteration
items = list(self._detached_conversations.items())
for sid, (conversation, detach_time) in items:
await conversation.disconnect()
self._detached_conversations.pop(sid, None)
close_threshold = time.time() - self.config.sandbox.close_delay
running_loops = list(self._local_agent_loops_by_sid.items())
running_loops.sort(key=lambda item: item[1].last_active_ts)
sid_to_close: list[str] = []
for sid, session in running_loops:
state = session.agent_session.get_state()
if session.last_active_ts < close_threshold and state not in [
AgentState.RUNNING,
None,
]:
sid_to_close.append(sid)
connections = await self.get_connections(
filter_to_sids=set(sid_to_close)
)
connected_sids = {sid for _, sid in connections.items()}
sid_to_close = [
sid for sid in sid_to_close if sid not in connected_sids
]
await wait_all(self._close_session(sid) for sid in sid_to_close)
await asyncio.sleep(_CLEANUP_INTERVAL)
except asyncio.CancelledError:
async with self._conversations_lock:
for conversation, _ in self._detached_conversations.values():
await conversation.disconnect()
self._detached_conversations.clear()
await wait_all(
self._close_session(sid) for sid in self._local_agent_loops_by_sid
)
return
except Exception as e:
logger.warning(f'error_cleaning_stale: {str(e)}')
await asyncio.sleep(_CLEANUP_INTERVAL)
async def get_running_agent_loops(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> set[str]:
"""Get the running session ids. If a user is supplied, then the results are limited to session ids for that user. If a set of filter_to_sids is supplied, then results are limited to these ids of interest."""
items: Iterable[tuple[str, Session]] = self._local_agent_loops_by_sid.items()
if filter_to_sids is not None:
items = (item for item in items if item[0] in filter_to_sids)
if user_id:
items = (item for item in items if item[1].user_id == user_id)
sids = {sid for sid, _ in items}
return sids
async def get_connections(
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
) -> dict[str, str]:
connections = dict(**self._local_connection_id_to_session_id)
if filter_to_sids is not None:
connections = {
connection_id: sid
for connection_id, sid in connections.items()
if sid in filter_to_sids
}
if user_id:
for connection_id, sid in list(connections.items()):
session = self._local_agent_loops_by_sid.get(sid)
if not session or session.user_id != user_id:
connections.pop(connection_id)
return connections
async def maybe_start_agent_loop(
self,
sid: str,
settings: Settings,
user_id: str | None,
initial_user_msg: MessageAction | None = None,
) -> EventStream:
logger.info(f'maybe_start_agent_loop:{sid}')
session: Session | None = None
if not await self.is_agent_loop_running(sid):
logger.info(f'start_agent_loop:{sid}')
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) >= MAX_RUNNING_CONVERSATIONS:
logger.info('too_many_sessions_for:{user_id}')
# Order is not guaranteed, but response_ids tend to be in descending chronological order
# By reversing, we are likely to pick the oldest (or at least an older) conversation
session_id = next(iter(reversed(list(response_ids))))
await self.close_session(session_id)
session = Session(
sid=sid,
file_store=self.file_store,
config=self.config,
sio=self.sio,
user_id=user_id,
)
self._local_agent_loops_by_sid[sid] = session
asyncio.create_task(session.initialize_agent(settings, initial_user_msg))
event_stream = await self._get_event_stream(sid)
if not event_stream:
logger.error(f'No event stream after starting agent loop: {sid}')
raise RuntimeError(f'no_event_stream:{sid}')
return event_stream
async def _get_event_stream(self, sid: str) -> EventStream | None:
logger.info(f'_get_event_stream:{sid}')
session = self._local_agent_loops_by_sid.get(sid)
if session:
logger.info(f'found_local_agent_loop:{sid}')
return session.agent_session.event_stream
return None
async def send_to_event_stream(self, connection_id: str, data: dict):
# If there is a local session running, send to that
sid = self._local_connection_id_to_session_id.get(connection_id)
if not sid:
raise RuntimeError(f'no_connected_session:{connection_id}')
session = self._local_agent_loops_by_sid.get(sid)
if session:
await session.dispatch(data)
return
raise RuntimeError(f'no_connected_session:{connection_id}:{sid}')
async def disconnect_from_session(self, connection_id: str):
sid = self._local_connection_id_to_session_id.pop(connection_id, None)
logger.info(f'disconnect_from_session:{connection_id}:{sid}')
if not sid:
# This can occur if the init action was never run.
logger.warning(f'disconnect_from_uninitialized_session:{connection_id}')
return
async def close_session(self, sid: str):
session = self._local_agent_loops_by_sid.get(sid)
if session:
await self._close_session(sid)
async def _close_session(self, sid: str):
logger.info(f'_close_session:{sid}')
# Clear up local variables
connection_ids_to_remove = list(
connection_id
for connection_id, conn_sid in self._local_connection_id_to_session_id.items()
if sid == conn_sid
)
logger.info(f'removing connections: {connection_ids_to_remove}')
for connnnection_id in connection_ids_to_remove:
self._local_connection_id_to_session_id.pop(connnnection_id, None)
session = self._local_agent_loops_by_sid.pop(sid, None)
if not session:
logger.warning(f'no_session_to_close:{sid}')
return
logger.info(f'closing_session:{session.sid}')
await session.close()
logger.info(f'closed_session:{session.sid}')
@classmethod
def get_instance(
cls,
sio: socketio.AsyncServer,
config: AppConfig,
file_store: FileStore,
) -> ConversationManager:
return StandaloneConversationManager(sio, config, file_store)

View File

@@ -1,10 +1,10 @@
from urllib.parse import parse_qs
import jwt
from pydantic import SecretStr
from socketio.exceptions import ConnectionRefusedError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import (
NullAction,
)
@@ -15,7 +15,7 @@ from openhands.events.observation.agent import AgentStateChangedObservation
from openhands.events.serialization import event_to_dict
from openhands.events.stream import AsyncEventStreamWrapper
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
from openhands.server.shared import config, conversation_manager, openhands_config, sio
from openhands.server.shared import config, openhands_config, session_manager, sio
from openhands.server.types import AppMode
@@ -39,13 +39,9 @@ async def connect(connection_id: str, environ, auth):
raise ConnectionRefusedError('No github_auth cookie')
if not config.jwt_secret:
raise RuntimeError('JWT secret not found')
jwt_secret = (
config.jwt_secret.get_secret_value()
if isinstance(config.jwt_secret, SecretStr)
else config.jwt_secret
decoded = jwt.decode(
signed_token, config.jwt_secret.get_secret_value(), algorithms=['HS256']
)
decoded = jwt.decode(signed_token, jwt_secret, algorithms=['HS256'])
user_id = decoded['github_user_id']
logger.info(f'User {user_id} is connecting to conversation {conversation_id}')
@@ -69,7 +65,7 @@ async def connect(connection_id: str, environ, auth):
'Settings not found', {'msg_id': 'CONFIGURATION$SETTINGS_NOT_FOUND'}
)
event_stream = await conversation_manager.join_conversation(
event_stream = await session_manager.join_conversation(
conversation_id, connection_id, settings, user_id
)
@@ -85,6 +81,8 @@ async def connect(connection_id: str, environ, auth):
):
continue
elif isinstance(event, AgentStateChangedObservation):
if event.agent_state == AgentState.INIT:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
agent_state_changed = event
else:
await sio.emit('oh_event', event_to_dict(event), to=connection_id)
@@ -94,10 +92,10 @@ async def connect(connection_id: str, environ, auth):
@sio.event
async def oh_action(connection_id: str, data: dict):
await conversation_manager.send_to_event_stream(connection_id, data)
await session_manager.send_to_event_stream(connection_id, data)
@sio.event
async def disconnect(connection_id: str):
logger.info(f'sio:disconnect:{connection_id}')
await conversation_manager.disconnect_from_session(connection_id)
await session_manager.disconnect_from_session(connection_id)

View File

@@ -11,7 +11,7 @@ from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request as StarletteRequest
from starlette.types import ASGIApp
from openhands.server import shared
from openhands.server.shared import session_manager
from openhands.server.types import SessionMiddlewareInterface
@@ -146,8 +146,8 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
"""
Attach the user's session based on the provided authentication token.
"""
request.state.conversation = (
await shared.conversation_manager.attach_to_conversation(request.state.sid)
request.state.conversation = await session_manager.attach_to_conversation(
request.state.sid
)
if not request.state.conversation:
return JSONResponse(
@@ -160,9 +160,7 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
"""
Detach the user's session.
"""
await shared.conversation_manager.detach_from_conversation(
request.state.conversation
)
await session_manager.detach_from_conversation(request.state.conversation)
async def __call__(self, request: Request, call_next: Callable):
if not self._should_attach(request):

View File

@@ -2,6 +2,7 @@ import uvicorn
from fastapi import FastAPI, WebSocket
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema import ActionType
from openhands.utils.shutdown_listener import should_continue
app = FastAPI()
@@ -10,6 +11,10 @@ app = FastAPI()
@app.websocket('/ws')
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
# send message to mock connection
await websocket.send_json(
{'action': ActionType.INIT, 'message': 'Control loop started.'}
)
try:
while should_continue():

View File

@@ -1,4 +1,3 @@
import httpx
import requests
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import JSONResponse
@@ -48,41 +47,46 @@ async def get_github_repositories(
# Fetch repositories from GitHub
try:
async with httpx.AsyncClient() as client:
response = await client.get(github_api_url, headers=headers, params=params)
response.raise_for_status() # Raise an error for HTTP codes >= 400
json_response = JSONResponse(content=response.json())
# Forward the Link header if it exists
if 'Link' in response.headers:
json_response.headers['Link'] = response.headers['Link']
return json_response
response = await call_sync_from_async(
requests.get, github_api_url, headers=headers, params=params
)
response.raise_for_status() # Raise an error for HTTP codes >= 400
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching repositories: {str(e)}',
)
# Create response with the JSON content
json_response = JSONResponse(content=response.json())
response.close()
# Forward the Link header if it exists
if 'Link' in response.headers:
json_response.headers['Link'] = response.headers['Link']
return json_response
@app.get('/user')
async def get_github_user(github_token: str = Depends(require_github_token)):
headers = generate_github_headers(github_token)
try:
async with httpx.AsyncClient() as client:
response = await client.get('https://api.github.com/user', headers=headers)
response.raise_for_status() # Raise an error for HTTP codes >= 400
json_response = JSONResponse(content=response.json())
return json_response
response = await call_sync_from_async(
requests.get, 'https://api.github.com/user', headers=headers
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
raise HTTPException(
status_code=response.status_code if response else 500,
detail=f'Error fetching user: {str(e)}',
)
json_response = JSONResponse(content=response.json())
response.close()
return json_response
@app.get('/installations')
async def get_github_installation_ids(

View File

@@ -7,13 +7,11 @@ from fastapi.responses import JSONResponse
from pydantic import BaseModel
from openhands.core.logger import openhands_logger as logger
from openhands.events.action.message import MessageAction
from openhands.events.stream import EventStreamSubscriber
from openhands.runtime import get_runtime_cls
from openhands.server.auth import get_user_id
from openhands.server.routes.settings import ConversationStoreImpl, SettingsStoreImpl
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.shared import config, conversation_manager
from openhands.server.shared import config, session_manager
from openhands.server.types import LLMAuthenticationError, MissingSettingsError
from openhands.storage.data_models.conversation_info import ConversationInfo
from openhands.storage.data_models.conversation_info_result_set import (
@@ -35,7 +33,6 @@ class InitSessionRequest(BaseModel):
github_token: str | None = None
selected_repository: str | None = None
initial_user_msg: str | None = None
image_urls: list[str] | None = None
async def _create_new_conversation(
@@ -43,7 +40,6 @@ async def _create_new_conversation(
token: str | None,
selected_repository: str | None,
initial_user_msg: str | None,
image_urls: list[str] | None,
):
logger.info('Loading settings')
settings_store = await SettingsStoreImpl.get_instance(config, user_id)
@@ -97,14 +93,8 @@ async def _create_new_conversation(
)
logger.info(f'Starting agent loop for conversation {conversation_id}')
initial_message_action = None
if initial_user_msg or image_urls:
initial_message_action = MessageAction(
content=initial_user_msg or '',
image_urls=image_urls or [],
)
event_stream = await conversation_manager.maybe_start_agent_loop(
conversation_id, conversation_init_data, user_id, initial_message_action
event_stream = await session_manager.maybe_start_agent_loop(
conversation_id, conversation_init_data, user_id, initial_user_msg
)
try:
event_stream.subscribe(
@@ -130,16 +120,10 @@ async def new_conversation(request: Request, data: InitSessionRequest):
github_token = getattr(request.state, 'github_token', '') or data.github_token
selected_repository = data.selected_repository
initial_user_msg = data.initial_user_msg
image_urls = data.image_urls or []
try:
# Create conversation with initial message
conversation_id = await _create_new_conversation(
user_id,
github_token,
selected_repository,
initial_user_msg,
image_urls,
user_id, github_token, selected_repository, initial_user_msg
)
return JSONResponse(
@@ -181,7 +165,7 @@ async def search_conversations(
for conversation in conversation_metadata_result_set.results
if hasattr(conversation, 'created_at')
)
running_conversations = await conversation_manager.get_running_agent_loops(
running_conversations = await session_manager.get_running_agent_loops(
get_user_id(request), set(conversation_ids)
)
result = ConversationInfoResultSet(
@@ -206,7 +190,7 @@ async def get_conversation(
)
try:
metadata = await conversation_store.get_metadata(conversation_id)
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
is_running = await session_manager.is_agent_loop_running(conversation_id)
conversation_info = await _get_conversation_info(metadata, is_running)
return conversation_info
except FileNotFoundError:
@@ -240,11 +224,9 @@ async def delete_conversation(
await conversation_store.get_metadata(conversation_id)
except FileNotFoundError:
return False
is_running = await conversation_manager.is_agent_loop_running(conversation_id)
is_running = await session_manager.is_agent_loop_running(conversation_id)
if is_running:
await conversation_manager.close_session(conversation_id)
runtime_cls = get_runtime_cls(config.runtime)
await runtime_cls.delete(conversation_id)
await session_manager.close_session(conversation_id)
await conversation_store.delete_metadata(conversation_id)
return True

View File

@@ -8,12 +8,19 @@ interruptions are recoverable.
There are 3 main server side event handlers:
* `connect` - Invoked when a new connection to the server is established. (This may be via http or WebSocket)
* `oh_action` - Invoked when a connected client sends an event (such as a prompt for the Agent) -
* `oh_action` - Invoked when a connected client sends an event (Such as `INIT` or a prompt for the Agent) -
this is distinct from the `oh_event` sent from the server to the client.
* `disconnect` - Invoked when a connected client disconnects from the server.
## Init
Each connection has a unique id, and when initially established, is not associated with any session. An
`INIT` event must be sent to the server in order to attach a connection to a session. The `INIT` event
may optionally include a GitHub token and a token to connect to an existing session. (Which may be running
locally or may need to be hydrated). If no token is received as part of the init event, it is assumed a
new session should be started.
## Disconnect
The (manager)[manager.py] manages connections and sessions. Each session may have zero or more connections
associated with it. When a session no longer has any
associated with it, managed by invocations of `INIT` and disconnect. When a session no longer has any
connections associated with it, after a set amount of time (determined by `config.sandbox.close_delay`),
the session and runtime are passivated (So will need to be rehydrated to continue.)

View File

@@ -1,3 +1,4 @@
from openhands.server.session.manager import SessionManager
from openhands.server.session.session import Session
__all__ = ['Session']
__all__ = ['Session', 'SessionManager']

View File

@@ -9,7 +9,8 @@ from openhands.core.config import AgentConfig, AppConfig, LLMConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import ChangeAgentStateAction, MessageAction
from openhands.events.action import ChangeAgentStateAction
from openhands.events.action.message import MessageAction
from openhands.events.event import EventSource
from openhands.events.stream import EventStream
from openhands.microagent import BaseMicroAgent
@@ -71,7 +72,7 @@ class AgentSession:
agent_configs: dict[str, AgentConfig] | None = None,
github_token: str | None = None,
selected_repository: str | None = None,
initial_message: MessageAction | None = None,
initial_user_msg: str | None = None,
):
"""Starts the Agent session
Parameters:
@@ -110,17 +111,15 @@ class AgentSession:
agent_to_llm_config=agent_to_llm_config,
agent_configs=agent_configs,
)
if initial_message:
self.event_stream.add_event(initial_message, EventSource.USER)
self.event_stream.add_event(
ChangeAgentStateAction(AgentState.RUNNING), EventSource.ENVIRONMENT
)
else:
self.event_stream.add_event(
ChangeAgentStateAction(AgentState.AWAITING_USER_INPUT),
EventSource.ENVIRONMENT,
)
self.event_stream.add_event(
ChangeAgentStateAction(AgentState.INIT), EventSource.ENVIRONMENT
)
if initial_user_msg:
self.event_stream.add_event(
MessageAction(content=initial_user_msg), EventSource.USER
)
self._starting = False
async def close(self):

View File

@@ -11,7 +11,6 @@ from openhands.core.config import AppConfig
from openhands.core.exceptions import AgentRuntimeUnavailableError
from openhands.core.logger import openhands_logger as logger
from openhands.core.schema.agent import AgentState
from openhands.events.action import MessageAction
from openhands.events.stream import EventStream, session_exists
from openhands.server.session.agent_session import WAIT_TIME_BEFORE_CLOSE
from openhands.server.session.conversation import Conversation
@@ -447,7 +446,7 @@ class SessionManager:
sid: str,
settings: Settings,
user_id: str | None,
initial_message: MessageAction | None = None,
initial_user_msg: str | None = None,
) -> EventStream:
logger.info(f'maybe_start_agent_loop:{sid}')
session: Session | None = None
@@ -457,10 +456,7 @@ class SessionManager:
response_ids = await self.get_running_agent_loops(user_id)
if len(response_ids) >= MAX_RUNNING_CONVERSATIONS:
logger.info('too_many_sessions_for:{user_id}')
# Order is not guaranteed, but response_ids tend to be in descending chronological order
# By reversing, we are likely to pick the oldest (or at least an older) conversation
session_id = next(iter(reversed(list(response_ids))))
await self.close_session(session_id)
await self.close_session(next(iter(response_ids)))
session = Session(
sid=sid,
@@ -470,7 +466,7 @@ class SessionManager:
user_id=user_id,
)
self._local_agent_loops_by_sid[sid] = session
asyncio.create_task(session.initialize_agent(settings, initial_message))
asyncio.create_task(session.initialize_agent(settings, initial_user_msg))
event_stream = await self._get_event_stream(sid)
if not event_stream:

View File

@@ -74,9 +74,7 @@ class Session:
self.is_alive = False
await self.agent_session.close()
async def initialize_agent(
self, settings: Settings, initial_message: MessageAction | None
):
async def initialize_agent(self, settings: Settings, initial_user_msg: str | None):
self.agent_session.event_stream.add_event(
AgentStateChangedObservation('', AgentState.LOADING),
EventSource.ENVIRONMENT,
@@ -124,7 +122,7 @@ class Session:
agent_configs=self.config.get_agent_configs(),
github_token=github_token,
selected_repository=selected_repository,
initial_message=initial_message,
initial_user_msg=initial_user_msg,
)
except Exception as e:
logger.exception(f'Error creating agent_session: {e}')

View File

@@ -5,11 +5,8 @@ from dotenv import load_dotenv
from openhands.core.config import load_app_config
from openhands.server.config.openhands_config import load_openhands_config
from openhands.server.conversation_manager.conversation_manager import (
ConversationManager,
)
from openhands.server.session import SessionManager
from openhands.storage import get_file_store
from openhands.utils.import_utils import get_impl
load_dotenv()
@@ -30,8 +27,4 @@ sio = socketio.AsyncServer(
async_mode='asgi', cors_allowed_origins='*', client_manager=client_manager
)
ConversationManagerImpl = get_impl(
ConversationManager, # type: ignore
openhands_config.conversation_manager_class,
)
conversation_manager = ConversationManagerImpl.get_instance(sio, config, file_store)
session_manager = SessionManager(sio, config, file_store)

View File

@@ -2,8 +2,6 @@ from abc import ABC, abstractmethod
from enum import Enum
from typing import ClassVar, Protocol
from fastapi import FastAPI
class AppMode(Enum):
OSS = 'oss'
@@ -38,11 +36,6 @@ class OpenhandsConfigInterface(ABC):
"""Configure attributes for frontend"""
raise NotImplementedError
@abstractmethod
def attach_middleware(self, api: FastAPI) -> None:
"""Attach required middleware for the current environment"""
raise NotImplementedError
class MissingSettingsError(ValueError):
"""Raised when settings are missing or not found."""

View File

@@ -11,7 +11,7 @@ def get_file_store(file_store: str, file_store_path: str | None = None) -> FileS
raise ValueError('file_store_path is required for local file store')
return LocalFileStore(file_store_path)
elif file_store == 's3':
return S3FileStore(file_store_path)
return S3FileStore()
elif file_store == 'google_cloud':
return GoogleCloudFileStore(file_store_path)
return InMemoryFileStore()

View File

@@ -40,8 +40,6 @@ class FileConversationStore(ConversationStore):
# Temp: force int to str to stop pydandic being, well... pedantic
json_obj = json.loads(json_str)
if 'created_at' not in json_obj:
raise FileNotFoundError(path)
if isinstance(json_obj.get('github_user_id'), int):
json_obj['github_user_id'] = str(json_obj.get('github_user_id'))

View File

@@ -1,130 +1,50 @@
import io
import os
import boto3
import botocore
from minio import Minio
from openhands.storage.files import FileStore
class S3FileStore(FileStore):
def __init__(self, bucket_name: str | None) -> None:
def __init__(self) -> None:
access_key = os.getenv('AWS_ACCESS_KEY_ID')
secret_key = os.getenv('AWS_SECRET_ACCESS_KEY')
endpoint = os.getenv('AWS_S3_ENDPOINT', 's3.amazonaws.com')
secure = os.getenv('AWS_S3_SECURE', 'true').lower() == 'true'
endpoint = self._ensure_url_scheme(secure, os.getenv('AWS_S3_ENDPOINT'))
if bucket_name is None:
bucket_name = os.environ['AWS_S3_BUCKET']
self.bucket = bucket_name
self.client = boto3.client(
's3',
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
endpoint_url=endpoint,
use_ssl=secure,
)
self.bucket = os.getenv('AWS_S3_BUCKET')
self.client = Minio(endpoint, access_key, secret_key, secure=secure)
def write(self, path: str, contents: str | bytes) -> None:
as_bytes = contents.encode('utf-8') if isinstance(contents, str) else contents
stream = io.BytesIO(as_bytes)
try:
as_bytes = (
contents.encode('utf-8') if isinstance(contents, str) else contents
)
self.client.put_object(Bucket=self.bucket, Key=path, Body=as_bytes)
except botocore.exceptions.ClientError as e:
if e.response['Error']['Code'] == 'AccessDenied':
raise FileNotFoundError(
f"Error: Access denied to bucket '{self.bucket}'."
)
elif e.response['Error']['Code'] == 'NoSuchBucket':
raise FileNotFoundError(
f"Error: The bucket '{self.bucket}' does not exist."
)
raise FileNotFoundError(
f"Error: Failed to write to bucket '{self.bucket}' at path {path}: {e}"
)
self.client.put_object(self.bucket, path, stream, len(as_bytes))
except Exception as e:
raise FileNotFoundError(f'Failed to write to S3 at path {path}: {e}')
def read(self, path: str) -> str:
try:
response = self.client.get_object(Bucket=self.bucket, Key=path)
return response['Body'].read().decode('utf-8')
except botocore.exceptions.ClientError as e:
# Catch all S3-related errors
if e.response['Error']['Code'] == 'NoSuchBucket':
raise FileNotFoundError(
f"Error: The bucket '{self.bucket}' does not exist."
)
elif e.response['Error']['Code'] == 'NoSuchKey':
raise FileNotFoundError(
f"Error: The object key '{path}' does not exist in bucket '{self.bucket}'."
)
else:
raise FileNotFoundError(
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
)
return self.client.get_object(self.bucket, path).data.decode('utf-8')
except Exception as e:
raise FileNotFoundError(
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
)
raise FileNotFoundError(f'Failed to read from S3 at path {path}: {e}')
def list(self, path: str) -> list[str]:
if path and path != '/' and not path.endswith('/'):
path += '/'
try:
response = self.client.list_objects_v2(Bucket=self.bucket, Prefix=path)
# Check if 'Contents' exists in the response
if 'Contents' in response:
objects = [obj['Key'] for obj in response['Contents']]
return objects
else:
return list()
except botocore.exceptions.ClientError as e:
# Catch all S3-related errors
if e.response['Error']['Code'] == 'NoSuchBucket':
raise FileNotFoundError(
f"Error: The bucket '{self.bucket}' does not exist."
)
elif e.response['Error']['Code'] == 'AccessDenied':
raise FileNotFoundError(
f"Error: Access denied to bucket '{self.bucket}'."
)
else:
raise FileNotFoundError(f"Error: {e.response['Error']['Message']}")
return [
obj.object_name for obj in self.client.list_objects(self.bucket, path)
]
except Exception as e:
raise FileNotFoundError(
f"Error: Failed to read from bucket '{self.bucket}' at path {path}: {e}"
)
raise FileNotFoundError(f'Failed to list S3 objects at path {path}: {e}')
def delete(self, path: str) -> None:
try:
self.client.delete_object(Bucket=self.bucket, Key=path)
except botocore.exceptions.ClientError as e:
if e.response['Error']['Code'] == 'NoSuchBucket':
raise FileNotFoundError(
f"Error: The bucket '{self.bucket}' does not exist."
)
elif e.response['Error']['Code'] == 'AccessDenied':
raise FileNotFoundError(
f"Error: Access denied to bucket '{self.bucket}'."
)
elif e.response['Error']['Code'] == 'NoSuchKey':
raise FileNotFoundError(
f"Error: The object key '{path}' does not exist in bucket '{self.bucket}'."
)
else:
raise FileNotFoundError(
f"Error: Failed to delete key '{path}' from bucket '{self.bucket}': {e}"
)
client = self.client
bucket = self.bucket
objects_to_delete = client.list_objects(bucket, prefix=path, recursive=True)
for obj in objects_to_delete:
client.remove_object(bucket, obj.object_name)
except Exception as e:
raise FileNotFoundError(
f"Error: Failed to delete key '{path}' from bucket '{self.bucket}: {e}"
)
def _ensure_url_scheme(self, secure: bool, url: str | None) -> str | None:
if not url:
return None
if secure:
if not url.startswith('https://'):
url = 'https://' + url.removeprefix('http://')
else:
if not url.startswith('http://'):
url = 'http://' + url.removeprefix('https://')
return url
raise FileNotFoundError(f'Failed to delete S3 object at path {path}: {e}')

319
poetry.lock generated
View File

@@ -588,43 +588,6 @@ urllib3 = {version = ">=1.25.4,<2.2.0 || >2.2.0,<3", markers = "python_version >
[package.extras]
crt = ["awscrt (==0.23.4)"]
[[package]]
name = "browsergym"
version = "0.10.2"
description = "BrowserGym: a gym environment for web task automation in the Chromium browser"
optional = false
python-versions = ">3.7"
files = [
{file = "browsergym-0.10.2-py3-none-any.whl", hash = "sha256:9581d1d1f1fcd1cf35266cf30c881d60c147a0d374b3491eeaebb07d9690f868"},
{file = "browsergym-0.10.2.tar.gz", hash = "sha256:3cdd7520cca857421aa7ec0a965968df4bcef721299a424397f86d7cad078ab0"},
]
[package.dependencies]
browsergym-assistantbench = "0.10.2"
browsergym-core = "0.10.2"
browsergym-experiments = "0.10.2"
browsergym-miniwob = "0.10.2"
browsergym-visualwebarena = "0.10.2"
browsergym-webarena = "0.10.2"
browsergym-workarena = ">=0.4.1"
[[package]]
name = "browsergym-assistantbench"
version = "0.10.2"
description = "AssistantBench benchmark for BrowserGym"
optional = false
python-versions = ">3.7"
files = [
{file = "browsergym_assistantbench-0.10.2-py3-none-any.whl", hash = "sha256:af0d3a3e23686066b070feca38f8740262bed6d65ccf9098f393334a005987c0"},
{file = "browsergym_assistantbench-0.10.2.tar.gz", hash = "sha256:de18eb7c010403d5d467b927b4713b56f6e97a59493bee4c42599d4d7cb54dce"},
]
[package.dependencies]
browsergym-core = "0.10.2"
datasets = "*"
numpy = "*"
scipy = "*"
[[package]]
name = "browsergym-core"
version = "0.10.2"
@@ -645,22 +608,6 @@ pillow = ">=10.1"
playwright = ">=1.39,<2.0"
pyparsing = ">=3"
[[package]]
name = "browsergym-experiments"
version = "0.10.2"
description = "Experimentation tools for BrowserGym"
optional = false
python-versions = ">3.7"
files = [
{file = "browsergym_experiments-0.10.2-py3-none-any.whl", hash = "sha256:60a626b3159ef63b5ff72a6c8156c8f3cf82a9278dfc5a9d3ece39c2b1913595"},
{file = "browsergym_experiments-0.10.2.tar.gz", hash = "sha256:b49bc27f315ad12014ff21580c7c7aca6489ca4106e7ab46502f716674efa236"},
]
[package.dependencies]
browsergym-core = "0.10.2"
dataclasses-json = "*"
tiktoken = ">=0.4"
[[package]]
name = "browsergym-miniwob"
version = "0.10.2"
@@ -675,22 +622,6 @@ files = [
[package.dependencies]
browsergym-core = "0.10.2"
[[package]]
name = "browsergym-visualwebarena"
version = "0.10.2"
description = "VisualWebArena benchmark for BrowserGym"
optional = false
python-versions = ">3.7"
files = [
{file = "browsergym_visualwebarena-0.10.2-py3-none-any.whl", hash = "sha256:87c913ccd4d12a79c625b5c4d9ead7e0bc50b298d19e413204bb586a67736d83"},
{file = "browsergym_visualwebarena-0.10.2.tar.gz", hash = "sha256:5f84a4f33a21106c9b650cecb0362b78af2546d9927255828c273fe800d776a1"},
]
[package.dependencies]
browsergym-core = "0.10.2"
libvisualwebarena = "0.0.14"
requests = "*"
[[package]]
name = "browsergym-webarena"
version = "0.10.2"
@@ -706,26 +637,6 @@ files = [
browsergym-core = "0.10.2"
libwebarena = "0.0.3"
[[package]]
name = "browsergym-workarena"
version = "0.4.1"
description = "WorkArena benchmark for BrowserGym"
optional = false
python-versions = ">3.7"
files = [
{file = "browsergym_workarena-0.4.1-py3-none-any.whl", hash = "sha256:b8f04b2e3801fd32962b7d99f0685c507b258841e2b4bfdb46d041091d2f1b89"},
{file = "browsergym_workarena-0.4.1.tar.gz", hash = "sha256:ba2958d804b80836c7f81360d66b99c6c655c5070eddc5fae9c1c88306a23403"},
]
[package.dependencies]
browsergym-core = ">=0.2"
english-words = ">=2.0.1"
faker = ">=24.8.0"
numpy = ">=1.14"
requests = ">=2.31"
tenacity = ">=8.2.3"
tqdm = ">=4.66.2"
[[package]]
name = "build"
version = "1.2.2.post1"
@@ -1108,20 +1019,6 @@ humanfriendly = ">=9.1"
[package.extras]
cron = ["capturer (>=2.4)"]
[[package]]
name = "colormath"
version = "3.0.0"
description = "Color math and conversion library."
optional = false
python-versions = "*"
files = [
{file = "colormath-3.0.0.tar.gz", hash = "sha256:3d4605af344527da0e4f9f504fad7ddbebda35322c566a6c72e28edb1ff31217"},
]
[package.dependencies]
networkx = ">=2.0"
numpy = "*"
[[package]]
name = "comm"
version = "0.2.2"
@@ -1645,16 +1542,6 @@ protobuf = ">=3.20.0,<6.0.0"
python-dateutil = ">=2.8.2"
typing-extensions = ">=4.1.0"
[[package]]
name = "english-words"
version = "2.0.1"
description = "Generate sets of english words by combining different word lists"
optional = false
python-versions = "*"
files = [
{file = "english-words-2.0.1.tar.gz", hash = "sha256:a4105c57493bb757a3d8973fcf8e1dc05e7ca09c836dff467c3fb445f84bc43d"},
]
[[package]]
name = "evaluate"
version = "0.4.3"
@@ -1718,21 +1605,6 @@ files = [
[package.extras]
tests = ["asttokens (>=2.1.0)", "coverage", "coverage-enable-subprocess", "ipython", "littleutils", "pytest", "rich"]
[[package]]
name = "faker"
version = "33.1.0"
description = "Faker is a Python package that generates fake data for you."
optional = false
python-versions = ">=3.8"
files = [
{file = "Faker-33.1.0-py3-none-any.whl", hash = "sha256:d30c5f0e2796b8970de68978365247657486eb0311c5abe88d0b895b68dff05d"},
{file = "faker-33.1.0.tar.gz", hash = "sha256:1c925fc0e86a51fc46648b504078c88d0cd48da1da2595c4e712841cab43a1e4"},
]
[package.dependencies]
python-dateutil = ">=2.4"
typing-extensions = "*"
[[package]]
name = "farama-notifications"
version = "0.0.4"
@@ -3087,39 +2959,6 @@ files = [
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "imageio"
version = "2.36.1"
description = "Library for reading and writing a wide range of image, video, scientific, and volumetric data formats."
optional = false
python-versions = ">=3.9"
files = [
{file = "imageio-2.36.1-py3-none-any.whl", hash = "sha256:20abd2cae58e55ca1af8a8dcf43293336a59adf0391f1917bf8518633cfc2cdf"},
{file = "imageio-2.36.1.tar.gz", hash = "sha256:e4e1d231f47f9a9e16100b0f7ce1a86e8856fb4d1c0fa2c4365a316f1746be62"},
]
[package.dependencies]
numpy = "*"
pillow = ">=8.3.2"
[package.extras]
all-plugins = ["astropy", "av", "imageio-ffmpeg", "numpy (>2)", "pillow-heif", "psutil", "rawpy", "tifffile"]
all-plugins-pypy = ["av", "imageio-ffmpeg", "pillow-heif", "psutil", "tifffile"]
build = ["wheel"]
dev = ["black", "flake8", "fsspec[github]", "pytest", "pytest-cov"]
docs = ["numpydoc", "pydata-sphinx-theme", "sphinx (<6)"]
ffmpeg = ["imageio-ffmpeg", "psutil"]
fits = ["astropy"]
full = ["astropy", "av", "black", "flake8", "fsspec[github]", "gdal", "imageio-ffmpeg", "itk", "numpy (>2)", "numpydoc", "pillow-heif", "psutil", "pydata-sphinx-theme", "pytest", "pytest-cov", "rawpy", "sphinx (<6)", "tifffile", "wheel"]
gdal = ["gdal"]
itk = ["itk"]
linting = ["black", "flake8"]
pillow-heif = ["pillow-heif"]
pyav = ["av"]
rawpy = ["numpy (>2)", "rawpy"]
test = ["fsspec[github]", "pytest", "pytest-cov"]
tifffile = ["tifffile"]
[[package]]
name = "importlib-metadata"
version = "7.1.0"
@@ -3829,25 +3668,6 @@ websocket-client = ">=0.32.0,<0.40.0 || >0.40.0,<0.41.dev0 || >=0.43.dev0"
[package.extras]
adal = ["adal (>=1.0.2)"]
[[package]]
name = "lazy-loader"
version = "0.4"
description = "Makes it easy to load subpackages and functions on demand."
optional = false
python-versions = ">=3.7"
files = [
{file = "lazy_loader-0.4-py3-none-any.whl", hash = "sha256:342aa8e14d543a154047afb4ba8ef17f5563baad3fc610d7b15b213b0f119efc"},
{file = "lazy_loader-0.4.tar.gz", hash = "sha256:47c75182589b91a4e1a85a136c074285a5ad4d9f39c63e0d7fb76391c4574cd1"},
]
[package.dependencies]
packaging = "*"
[package.extras]
dev = ["changelist (==0.5)"]
lint = ["pre-commit (==3.7.0)"]
test = ["pytest (>=7.4)", "pytest-cov (>=4.1)"]
[[package]]
name = "libtmux"
version = "0.39.0"
@@ -3859,33 +3679,6 @@ files = [
{file = "libtmux-0.39.0.tar.gz", hash = "sha256:59346aeef3c0d6017f3bc5e23248d43cdf50f32b775b9cb5d9ff5e2e5f3059f4"},
]
[[package]]
name = "libvisualwebarena"
version = "0.0.14"
description = "This is an unofficial, use-at-your-own risks port of the visualwebarena benchmark, for use as a standalone library package."
optional = false
python-versions = "<4,>=3.7"
files = [
{file = "libvisualwebarena-0.0.14-py3-none-any.whl", hash = "sha256:636b06ca1d52f1a363503b5b563492e83f2482efaf85bb26b69744565a499f0f"},
{file = "libvisualwebarena-0.0.14.tar.gz", hash = "sha256:7e660179f60f1df8d884204f2b742a2117e7fe050823d839ca5744ea1c0709a7"},
]
[package.dependencies]
aiolimiter = "*"
beartype = "0.12.0"
evaluate = "*"
flask = "*"
gymnasium = "*"
nltk = "*"
openai = ">=1"
Pillow = "*"
playwright = ">=1.32,<1.40"
scikit-image = ">=0.16"
text-generation = "*"
tiktoken = "*"
transformers = "*"
types-tqdm = "*"
[[package]]
name = "libwebarena"
version = "0.0.3"
@@ -3958,19 +3751,19 @@ pydantic = ">=1.10"
[[package]]
name = "llama-index"
version = "0.12.13"
version = "0.12.11"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index-0.12.13-py3-none-any.whl", hash = "sha256:0b285aa451ced6bd8da40df99068ac96badf8b5725c4edc29f2bce4da2ffd8bc"},
{file = "llama_index-0.12.13.tar.gz", hash = "sha256:1e39a397dcc51dabe280c121fd8d5451a6a84595233a8b26caa54d9b7ecf9ffc"},
{file = "llama_index-0.12.11-py3-none-any.whl", hash = "sha256:007361c35e1981a1656cef287b7bcdf22aa88e7d41b8e3a8ee261bb5a10519a9"},
{file = "llama_index-0.12.11.tar.gz", hash = "sha256:b1116946a2414aec104a6c417b847da5b4f077a0966c50ebd2fc445cd713adce"},
]
[package.dependencies]
llama-index-agent-openai = ">=0.4.0,<0.5.0"
llama-index-cli = ">=0.4.0,<0.5.0"
llama-index-core = ">=0.12.13,<0.13.0"
llama-index-core = ">=0.12.11,<0.13.0"
llama-index-embeddings-openai = ">=0.3.0,<0.4.0"
llama-index-indices-managed-llama-cloud = ">=0.4.0"
llama-index-llms-openai = ">=0.3.0,<0.4.0"
@@ -4015,13 +3808,13 @@ llama-index-llms-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-core"
version = "0.12.13"
version = "0.12.11"
description = "Interface between LLMs and your data"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_core-0.12.13-py3-none-any.whl", hash = "sha256:9708bb594bbddffd6ff0767242e49d8978d1ba60a2e62e071d9d123ad2f17e6f"},
{file = "llama_index_core-0.12.13.tar.gz", hash = "sha256:77af0161246ce1de38efc17cb6438dfff9e9558af00bcfac7dd4d0b7325efa4b"},
{file = "llama_index_core-0.12.11-py3-none-any.whl", hash = "sha256:3b1e019c899e9e011dfa01c96b7e3f666e0c161035fbca6cb787b4c61e0c94db"},
{file = "llama_index_core-0.12.11.tar.gz", hash = "sha256:9a41ca91167ea5eec9ebaac7f5e958b7feddbd8af3bfbf7c393a5edfb994d566"},
]
[package.dependencies]
@@ -4066,13 +3859,13 @@ llama-index-llms-azure-openai = ">=0.3.0,<0.4.0"
[[package]]
name = "llama-index-embeddings-huggingface"
version = "0.5.1"
version = "0.5.0"
description = "llama-index embeddings huggingface integration"
optional = false
python-versions = "<4.0,>=3.9"
files = [
{file = "llama_index_embeddings_huggingface-0.5.1-py3-none-any.whl", hash = "sha256:cff600538e9616829d379ced09f08fc6d237e5599975d781ca52b599a419394e"},
{file = "llama_index_embeddings_huggingface-0.5.1.tar.gz", hash = "sha256:def1639bab8511e3ac0284520104b0c6dce9bc053b4dce38c127bd62bc28f7fc"},
{file = "llama_index_embeddings_huggingface-0.5.0-py3-none-any.whl", hash = "sha256:70634b2cfaad28103b5125971fc98118f1bc404cb6145744b55de4ed54b0ad99"},
{file = "llama_index_embeddings_huggingface-0.5.0.tar.gz", hash = "sha256:bb75924bd52631364bd3b1a4b0ab78753a0bef00210f2762b425cbd05f4ea60e"},
]
[package.dependencies]
@@ -5596,17 +5389,15 @@ realtime = ["websockets (>=13,<15)"]
[[package]]
name = "openhands-aci"
version = "0.1.9"
version = "0.1.8"
description = "An Agent-Computer Interface (ACI) designed for software development agents OpenHands."
optional = false
python-versions = "<4.0,>=3.12"
files = [
{file = "openhands_aci-0.1.9-py3-none-any.whl", hash = "sha256:62af189878db046aa98475a41fa01200efd5ddf1db8a435c38da3d4ad32cb11a"},
{file = "openhands_aci-0.1.9.tar.gz", hash = "sha256:690d33d355a3e4111f52861dbb96ff766b5a268202324a87c94ba67b628a63b1"},
]
python-versions = "^3.12"
files = []
develop = false
[package.dependencies]
diskcache = ">=5.6.3,<6.0.0"
diskcache = "^5.6.3"
flake8 = "*"
gitpython = "*"
grep-ast = "0.3.3"
@@ -5616,7 +5407,13 @@ numpy = "*"
pandas = "*"
scipy = "*"
tree-sitter = "0.21.3"
whatthepatch = ">=1.0.6,<2.0.0"
whatthepatch = "^1.0.6"
[package.source]
type = "git"
url = "https://github.com/All-Hands-AI/openhands-aci.git"
reference = "fix-find-show-only-hidden-subpaths"
resolved_reference = "910e8c470aff0e496bf262bc673c7ee7b4531159"
[[package]]
name = "opentelemetry-api"
@@ -7943,54 +7740,6 @@ files = [
attrs = ">=18.0.0"
pathspec = ">=0.10.1"
[[package]]
name = "scikit-image"
version = "0.24.0"
description = "Image processing in Python"
optional = false
python-versions = ">=3.9"
files = [
{file = "scikit_image-0.24.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:cb3bc0264b6ab30b43c4179ee6156bc18b4861e78bb329dd8d16537b7bbf827a"},
{file = "scikit_image-0.24.0-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:9c7a52e20cdd760738da38564ba1fed7942b623c0317489af1a598a8dedf088b"},
{file = "scikit_image-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93f46e6ce42e5409f4d09ce1b0c7f80dd7e4373bcec635b6348b63e3c886eac8"},
{file = "scikit_image-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39ee0af13435c57351a3397eb379e72164ff85161923eec0c38849fecf1b4764"},
{file = "scikit_image-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ac7913b028b8aa780ffae85922894a69e33d1c0bf270ea1774f382fe8bf95e7"},
{file = "scikit_image-0.24.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:272909e02a59cea3ed4aa03739bb88df2625daa809f633f40b5053cf09241831"},
{file = "scikit_image-0.24.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:190ebde80b4470fe8838764b9b15f232a964f1a20391663e31008d76f0c696f7"},
{file = "scikit_image-0.24.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59c98cc695005faf2b79904e4663796c977af22586ddf1b12d6af2fa22842dc2"},
{file = "scikit_image-0.24.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa27b3a0dbad807b966b8db2d78da734cb812ca4787f7fbb143764800ce2fa9c"},
{file = "scikit_image-0.24.0-cp311-cp311-win_amd64.whl", hash = "sha256:dacf591ac0c272a111181afad4b788a27fe70d213cfddd631d151cbc34f8ca2c"},
{file = "scikit_image-0.24.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:6fccceb54c9574590abcddc8caf6cefa57c13b5b8b4260ab3ff88ad8f3c252b3"},
{file = "scikit_image-0.24.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ccc01e4760d655aab7601c1ba7aa4ddd8b46f494ac46ec9c268df6f33ccddf4c"},
{file = "scikit_image-0.24.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18836a18d3a7b6aca5376a2d805f0045826bc6c9fc85331659c33b4813e0b563"},
{file = "scikit_image-0.24.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8579bda9c3f78cb3b3ed8b9425213c53a25fa7e994b7ac01f2440b395babf660"},
{file = "scikit_image-0.24.0-cp312-cp312-win_amd64.whl", hash = "sha256:82ab903afa60b2da1da2e6f0c8c65e7c8868c60a869464c41971da929b3e82bc"},
{file = "scikit_image-0.24.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ef04360eda372ee5cd60aebe9be91258639c86ae2ea24093fb9182118008d009"},
{file = "scikit_image-0.24.0-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:e9aadb442360a7e76f0c5c9d105f79a83d6df0e01e431bd1d5757e2c5871a1f3"},
{file = "scikit_image-0.24.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5e37de6f4c1abcf794e13c258dc9b7d385d5be868441de11c180363824192ff7"},
{file = "scikit_image-0.24.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4688c18bd7ec33c08d7bf0fd19549be246d90d5f2c1d795a89986629af0a1e83"},
{file = "scikit_image-0.24.0-cp39-cp39-win_amd64.whl", hash = "sha256:56dab751d20b25d5d3985e95c9b4e975f55573554bd76b0aedf5875217c93e69"},
{file = "scikit_image-0.24.0.tar.gz", hash = "sha256:5d16efe95da8edbeb363e0c4157b99becbd650a60b77f6e3af5768b66cf007ab"},
]
[package.dependencies]
imageio = ">=2.33"
lazy-loader = ">=0.4"
networkx = ">=2.8"
numpy = ">=1.23"
packaging = ">=21"
pillow = ">=9.1"
scipy = ">=1.9"
tifffile = ">=2022.8.12"
[package.extras]
build = ["Cython (>=3.0.4)", "build", "meson-python (>=0.15)", "ninja", "numpy (>=2.0.0rc1)", "packaging (>=21)", "pythran", "setuptools (>=67)", "spin (==0.8)", "wheel"]
data = ["pooch (>=1.6.0)"]
developer = ["ipython", "pre-commit", "tomli"]
docs = ["PyWavelets (>=1.1.1)", "dask[array] (>=2022.9.2)", "ipykernel", "ipywidgets", "kaleido", "matplotlib (>=3.6)", "myst-parser", "numpydoc (>=1.7)", "pandas (>=1.5)", "plotly (>=5.10)", "pooch (>=1.6)", "pydata-sphinx-theme (>=0.15.2)", "pytest-doctestplus", "pytest-runner", "scikit-learn (>=1.1)", "seaborn (>=0.11)", "sphinx (>=7.3)", "sphinx-copybutton", "sphinx-gallery (>=0.14)", "sphinx_design (>=0.5)", "tifffile (>=2022.8.12)"]
optional = ["PyWavelets (>=1.1.1)", "SimpleITK", "astropy (>=5.0)", "cloudpickle (>=0.2.1)", "dask[array] (>=2021.1.0)", "matplotlib (>=3.6)", "pooch (>=1.6.0)", "pyamg", "scikit-learn (>=1.1)"]
test = ["asv", "numpydoc (>=1.7)", "pooch (>=1.6.0)", "pytest (>=7.0)", "pytest-cov (>=2.11.0)", "pytest-doctestplus", "pytest-faulthandler", "pytest-localserver"]
[[package]]
name = "scikit-learn"
version = "1.6.0"
@@ -8702,28 +8451,6 @@ files = [
{file = "threadpoolctl-3.5.0.tar.gz", hash = "sha256:082433502dd922bf738de0d8bcc4fdcbf0979ff44c42bd40f5af8a282f6fa107"},
]
[[package]]
name = "tifffile"
version = "2024.9.20"
description = "Read and write TIFF files"
optional = false
python-versions = ">=3.10"
files = [
{file = "tifffile-2024.9.20-py3-none-any.whl", hash = "sha256:c54dc85bc1065d972cb8a6ffb3181389d597876aa80177933459733e4ed243dd"},
{file = "tifffile-2024.9.20.tar.gz", hash = "sha256:3fbf3be2f995a7051a8ae05a4be70c96fc0789f22ed6f1c4104c973cf68a640b"},
]
[package.dependencies]
numpy = "*"
[package.extras]
all = ["defusedxml", "fsspec", "imagecodecs (>=2023.8.12)", "lxml", "matplotlib", "zarr"]
codecs = ["imagecodecs (>=2023.8.12)"]
plot = ["matplotlib"]
test = ["cmapfile", "czifile", "dask", "defusedxml", "fsspec", "imagecodecs", "lfdfiles", "lxml", "ndtiff", "oiffile", "psdtags", "pytest", "roifile", "xarray", "zarr"]
xml = ["defusedxml", "lxml"]
zarr = ["fsspec", "zarr"]
[[package]]
name = "tiktoken"
version = "0.8.0"
@@ -10130,4 +9857,4 @@ testing = ["coverage[toml]", "zope.event", "zope.testing"]
[metadata]
lock-version = "2.0"
python-versions = "^3.12"
content-hash = "fbca4b2ca0fe2d1d3cac46164c0c1eb9e468dc6f6bc7165e9a3d62ea9f25d801"
content-hash = "f0fdb1fa00337a3fdda425cbfb9af7020d7460fdca8eb9dcfbe4817cf60d0a05"

View File

@@ -65,7 +65,7 @@ runloop-api-client = "0.13.0"
libtmux = ">=0.37,<0.40"
pygithub = "^2.5.0"
joblib = "*"
openhands-aci = "0.1.9"
openhands-aci = "0.1.8"
python-socketio = "^5.11.4"
redis = "^5.2.0"
sse-starlette = "^2.1.3"
@@ -101,7 +101,6 @@ reportlab = "*"
[tool.coverage.run]
concurrency = ["gevent"]
[tool.poetry.group.runtime.dependencies]
jupyterlab = "*"
notebook = "*"
@@ -130,7 +129,6 @@ ignore = ["D1"]
[tool.ruff.lint.pydocstyle]
convention = "google"
[tool.poetry.group.evaluation.dependencies]
streamlit = "*"
whatthepatch = "*"
@@ -144,10 +142,8 @@ gdown = "*"
matplotlib = "*"
seaborn = "*"
tabulate = "*"
browsergym = "0.10.2"
browsergym-webarena = "0.10.2"
browsergym-miniwob = "0.10.2"
browsergym-visualwebarena = "0.10.2"
[tool.poetry-dynamic-versioning]
enable = true

View File

@@ -500,111 +500,6 @@ def test_send_pull_request_with_reviewer(
assert result == 'https://github.com/test-owner/test-repo/pull/1'
@patch('subprocess.run')
@patch('requests.post')
@patch('requests.get')
def test_send_pull_request_target_branch_with_fork(
mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir
):
"""Test that target_branch works correctly when using a fork."""
repo_path = os.path.join(mock_output_dir, 'repo')
fork_owner = 'fork-owner'
target_branch = 'custom-target'
# Mock API responses
mock_get.side_effect = [
MagicMock(status_code=404), # Branch doesn't exist
MagicMock(status_code=200), # Target branch exists
]
mock_post.return_value.json.return_value = {
'html_url': 'https://github.com/test-owner/test-repo/pull/1'
}
# Mock subprocess.run calls
mock_run.side_effect = [
MagicMock(returncode=0), # git checkout -b
MagicMock(returncode=0), # git push
]
# Call the function with fork_owner and target_branch
result = send_pull_request(
github_issue=mock_github_issue,
github_token='test-token',
github_username='test-user',
patch_dir=repo_path,
pr_type='ready',
fork_owner=fork_owner,
target_branch=target_branch,
)
# Assert API calls
assert mock_get.call_count == 2
# Verify target branch was checked in original repo, not fork
target_branch_check = mock_get.call_args_list[1]
assert target_branch_check[0][0] == f'https://api.github.com/repos/test-owner/test-repo/branches/{target_branch}'
# Check PR creation
mock_post.assert_called_once()
post_data = mock_post.call_args[1]['json']
assert post_data['base'] == target_branch # PR should target the specified branch
assert post_data['head'] == 'openhands-fix-issue-42' # Branch name should be standard
# Check that push was to fork
push_call = mock_run.call_args_list[1]
assert f'https://test-user:test-token@github.com/{fork_owner}/test-repo.git' in str(push_call)
@patch('subprocess.run')
@patch('requests.post')
@patch('requests.get')
def test_send_pull_request_target_branch_with_additional_message(
mock_get, mock_post, mock_run, mock_github_issue, mock_output_dir
):
"""Test that target_branch works correctly with additional PR message."""
repo_path = os.path.join(mock_output_dir, 'repo')
target_branch = 'feature-branch'
additional_message = 'Additional PR context'
# Mock API responses
mock_get.side_effect = [
MagicMock(status_code=404), # Branch doesn't exist
MagicMock(status_code=200), # Target branch exists
]
mock_post.return_value.json.return_value = {
'html_url': 'https://github.com/test-owner/test-repo/pull/1'
}
# Mock subprocess.run calls
mock_run.side_effect = [
MagicMock(returncode=0), # git checkout -b
MagicMock(returncode=0), # git push
]
# Call the function with target_branch and additional_message
result = send_pull_request(
github_issue=mock_github_issue,
github_token='test-token',
github_username='test-user',
patch_dir=repo_path,
pr_type='ready',
target_branch=target_branch,
additional_message=additional_message,
)
# Assert API calls
assert mock_get.call_count == 2
# Check PR creation
mock_post.assert_called_once()
post_data = mock_post.call_args[1]['json']
assert post_data['base'] == target_branch
assert additional_message in post_data['body']
assert 'This pull request fixes #42' in post_data['body']
@patch('requests.get')
def test_send_pull_request_invalid_target_branch(
mock_get, mock_github_issue, mock_output_dir

View File

@@ -40,7 +40,7 @@ def _patch_store():
MagicMock(return_value=file_store),
):
with patch(
'openhands.server.routes.manage_conversations.conversation_manager.file_store',
'openhands.server.routes.manage_conversations.session_manager.file_store',
file_store,
):
yield

View File

@@ -1,68 +0,0 @@
from unittest.mock import MagicMock, patch
import pytest
from openhands.core.config import AppConfig
from openhands.events import EventStream
from openhands.runtime.impl.docker.docker_runtime import DockerRuntime
@pytest.fixture
def mock_docker_client():
with patch('docker.from_env') as mock_client:
container_mock = MagicMock()
container_mock.status = 'running'
container_mock.attrs = {
'Config': {
'Env': ['port=12345', 'VSCODE_PORT=54321'],
'ExposedPorts': {'12345/tcp': {}, '54321/tcp': {}},
}
}
mock_client.return_value.containers.get.return_value = container_mock
mock_client.return_value.containers.run.return_value = container_mock
# Mock version info for BuildKit check
mock_client.return_value.version.return_value = {'Version': '20.10.0'}
yield mock_client.return_value
@pytest.fixture
def config():
config = AppConfig()
config.sandbox.keep_runtime_alive = False
return config
@pytest.fixture
def event_stream():
return MagicMock(spec=EventStream)
@patch('openhands.runtime.impl.docker.docker_runtime.stop_all_containers')
def test_container_stopped_when_keep_runtime_alive_false(
mock_stop_containers, mock_docker_client, config, event_stream
):
# Arrange
runtime = DockerRuntime(config, event_stream, sid='test-sid')
runtime.container = mock_docker_client.containers.get.return_value
# Act
runtime.close()
# Assert
mock_stop_containers.assert_called_once_with('openhands-runtime-test-sid')
@patch('openhands.runtime.impl.docker.docker_runtime.stop_all_containers')
def test_container_not_stopped_when_keep_runtime_alive_true(
mock_stop_containers, mock_docker_client, config, event_stream
):
# Arrange
config.sandbox.keep_runtime_alive = True
runtime = DockerRuntime(config, event_stream, sid='test-sid')
runtime.container = mock_docker_client.containers.get.return_value
# Act
runtime.close()
# Assert
mock_stop_containers.assert_not_called()

View File

@@ -3,6 +3,12 @@ from unittest.mock import patch
from openhands.core.config import AppConfig
# Mock the SessionManager to avoid asyncio issues
class MockSessionManager:
def __init__(self, *args, **kwargs):
pass
# Mock StaticFiles
class MockStaticFiles:
def __init__(self, *args, **kwargs):
@@ -11,6 +17,7 @@ class MockStaticFiles:
# Patch necessary components before importing from listen
with (
patch('openhands.server.session.SessionManager', MockSessionManager),
patch('fastapi.staticfiles.StaticFiles', MockStaticFiles),
):
from openhands.server.file_config import (

297
tests/unit/test_manager.py Normal file
View File

@@ -0,0 +1,297 @@
import asyncio
import json
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
from openhands.core.config.app_config import AppConfig
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.server.session.manager import SessionManager
from openhands.storage.memory import InMemoryFileStore
@dataclass
class GetMessageMock:
message: dict | None
sleep_time: int = 0.01
async def get_message(self, **kwargs):
await asyncio.sleep(self.sleep_time)
return {'data': json.dumps(self.message)}
def get_mock_sio(get_message: GetMessageMock | None = None):
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.manager.redis = MagicMock()
sio.manager.redis.publish = AsyncMock()
pubsub = AsyncMock()
pubsub.get_message = (get_message or GetMessageMock(None)).get_message
sio.manager.redis.pubsub.return_value = pubsub
return sio
@pytest.mark.asyncio
async def test_session_not_running_in_cluster():
sio = get_mock_sio()
id = uuid4()
with (
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
result = await session_manager._get_running_agent_loops_remotely(
filter_to_sids={'non-existant-session'}
)
assert result == set()
assert sio.manager.redis.publish.await_count == 1
sio.manager.redis.publish.assert_called_once_with(
'session_msg',
'{"query_id": "'
+ str(id)
+ '", "message_type": "running_agent_loops_query", "filter_to_sids": ["non-existant-session"]}',
)
@pytest.mark.asyncio
async def test_get_running_agent_loops_remotely():
id = uuid4()
sio = get_mock_sio(
GetMessageMock(
{
'query_id': str(id),
'sids': ['existing-session'],
'message_type': 'running_agent_loops_response',
}
)
)
with (
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.1),
patch('openhands.server.session.manager.uuid4', MagicMock(return_value=id)),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
result = await session_manager._get_running_agent_loops_remotely(
1, {'existing-session'}
)
assert result == {'existing-session'}
assert sio.manager.redis.publish.await_count == 1
sio.manager.redis.publish.assert_called_once_with(
'session_msg',
'{"query_id": "'
+ str(id)
+ '", "message_type": "running_agent_loops_query", "user_id": 1, "filter_to_sids": ["existing-session"]}',
)
@pytest.mark.asyncio
async def test_init_new_local_session():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = set()
with (
patch('openhands.server.session.manager.Session', mock_session),
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.1),
patch(
'openhands.server.session.manager.SessionManager._redis_subscribe',
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager.get_running_agent_loops',
get_running_agent_loops_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData(), 1
)
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData(), 1
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 1
@pytest.mark.asyncio
async def test_join_local_session():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = set()
with (
patch('openhands.server.session.manager.Session', mock_session),
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch(
'openhands.server.session.manager.SessionManager._redis_subscribe',
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager.get_running_agent_loops',
get_running_agent_loops_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData(), None
)
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData(), None
)
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData(), None
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 2
@pytest.mark.asyncio
async def test_join_cluster_session():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = {'new-session-id'}
with (
patch('openhands.server.session.manager.Session', mock_session),
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch(
'openhands.server.session.manager.SessionManager._redis_subscribe',
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._get_running_agent_loops_remotely',
get_running_agent_loops_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData(), 1
)
assert session_instance.initialize_agent.call_count == 0
assert sio.enter_room.await_count == 1
@pytest.mark.asyncio
async def test_add_to_local_event_stream():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = set()
with (
patch('openhands.server.session.manager.Session', mock_session),
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch(
'openhands.server.session.manager.SessionManager._redis_subscribe',
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager.get_running_agent_loops',
get_running_agent_loops_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData(), 1
)
await session_manager.join_conversation(
'new-session-id', 'connection-id', ConversationInitData(), 1
)
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)
session_instance.dispatch.assert_called_once_with({'event_type': 'some_event'})
@pytest.mark.asyncio
async def test_add_to_cluster_event_stream():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = {'new-session-id'}
with (
patch('openhands.server.session.manager.Session', mock_session),
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch(
'openhands.server.session.manager.SessionManager._redis_subscribe',
AsyncMock(),
),
patch(
'openhands.server.session.manager.SessionManager._get_running_agent_loops_remotely',
get_running_agent_loops_mock,
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
await session_manager.join_conversation(
'new-session-id', 'connection-id', ConversationInitData(), 1
)
await session_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)
assert sio.manager.redis.publish.await_count == 1
sio.manager.redis.publish.assert_called_once_with(
'session_msg',
'{"sid": "new-session-id", "message_type": "event", "data": {"event_type": "some_event"}}',
)
@pytest.mark.asyncio
async def test_cleanup_session_connections():
sio = get_mock_sio()
with (
patch('openhands.server.session.manager._REDIS_POLL_TIMEOUT', 0.01),
patch(
'openhands.server.session.manager.SessionManager._redis_subscribe',
AsyncMock(),
),
):
async with SessionManager(
sio, AppConfig(), InMemoryFileStore()
) as session_manager:
session_manager._local_connection_id_to_session_id.update(
{
'conn1': 'session1',
'conn2': 'session1',
'conn3': 'session2',
'conn4': 'session2',
}
)
await session_manager._close_session('session1')
remaining_connections = session_manager._local_connection_id_to_session_id
assert 'conn1' not in remaining_connections
assert 'conn2' not in remaining_connections
assert 'conn3' in remaining_connections
assert 'conn4' in remaining_connections
assert remaining_connections['conn3'] == 'session2'
assert remaining_connections['conn4'] == 'session2'

View File

@@ -1,161 +0,0 @@
import asyncio
import json
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from openhands.core.config.app_config import AppConfig
from openhands.server.conversation_manager.standalone_conversation_manager import (
StandaloneConversationManager,
)
from openhands.server.session.conversation_init_data import ConversationInitData
from openhands.storage.memory import InMemoryFileStore
@dataclass
class GetMessageMock:
message: dict | None
sleep_time: int = 0.01
async def get_message(self, **kwargs):
await asyncio.sleep(self.sleep_time)
return {'data': json.dumps(self.message)}
def get_mock_sio(get_message: GetMessageMock | None = None):
sio = MagicMock()
sio.enter_room = AsyncMock()
sio.manager.redis = MagicMock()
sio.manager.redis.publish = AsyncMock()
pubsub = AsyncMock()
pubsub.get_message = (get_message or GetMessageMock(None)).get_message
sio.manager.redis.pubsub.return_value = pubsub
return sio
@pytest.mark.asyncio
async def test_init_new_local_session():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = set()
with (
patch(
'openhands.server.conversation_manager.standalone_conversation_manager.Session',
mock_session,
),
patch(
'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager.get_running_agent_loops',
get_running_agent_loops_mock,
),
):
async with StandaloneConversationManager(
sio, AppConfig(), InMemoryFileStore()
) as conversation_manager:
await conversation_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData(), 1
)
await conversation_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData(), 1
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 1
@pytest.mark.asyncio
async def test_join_local_session():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = set()
with (
patch(
'openhands.server.conversation_manager.standalone_conversation_manager.Session',
mock_session,
),
patch(
'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager.get_running_agent_loops',
get_running_agent_loops_mock,
),
):
async with StandaloneConversationManager(
sio, AppConfig(), InMemoryFileStore()
) as conversation_manager:
await conversation_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData(), None
)
await conversation_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData(), None
)
await conversation_manager.join_conversation(
'new-session-id', 'new-session-id', ConversationInitData(), None
)
assert session_instance.initialize_agent.call_count == 1
assert sio.enter_room.await_count == 2
@pytest.mark.asyncio
async def test_add_to_local_event_stream():
session_instance = AsyncMock()
session_instance.agent_session = MagicMock()
mock_session = MagicMock()
mock_session.return_value = session_instance
sio = get_mock_sio()
get_running_agent_loops_mock = AsyncMock()
get_running_agent_loops_mock.return_value = set()
with (
patch(
'openhands.server.conversation_manager.standalone_conversation_manager.Session',
mock_session,
),
patch(
'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager.get_running_agent_loops',
get_running_agent_loops_mock,
),
):
async with StandaloneConversationManager(
sio, AppConfig(), InMemoryFileStore()
) as conversation_manager:
await conversation_manager.maybe_start_agent_loop(
'new-session-id', ConversationInitData(), 1
)
await conversation_manager.join_conversation(
'new-session-id', 'connection-id', ConversationInitData(), 1
)
await conversation_manager.send_to_event_stream(
'connection-id', {'event_type': 'some_event'}
)
session_instance.dispatch.assert_called_once_with({'event_type': 'some_event'})
@pytest.mark.asyncio
async def test_cleanup_session_connections():
sio = get_mock_sio()
async with StandaloneConversationManager(
sio, AppConfig(), InMemoryFileStore()
) as conversation_manager:
conversation_manager._local_connection_id_to_session_id.update(
{
'conn1': 'session1',
'conn2': 'session1',
'conn3': 'session2',
'conn4': 'session2',
}
)
await conversation_manager._close_session('session1')
remaining_connections = conversation_manager._local_connection_id_to_session_id
assert 'conn1' not in remaining_connections
assert 'conn2' not in remaining_connections
assert 'conn3' in remaining_connections
assert 'conn4' in remaining_connections
assert remaining_connections['conn3'] == 'session2'
assert remaining_connections['conn4'] == 'session2'