mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
fix-confli
...
linear-int
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1cd8dd41f | ||
|
|
2a7fbcd519 | ||
|
|
6ef82e1501 | ||
|
|
3302c31c60 | ||
|
|
116ba199d1 | ||
|
|
803bdced9c |
@@ -67,24 +67,27 @@ description: Complete guide for setting up Linear integration with OpenHands Clo
|
||||
- Sign in with your Git provider (GitHub, GitLab, or BitBucket)
|
||||
- **Important:** Make sure you're signing in with the same Git provider account that contains the repositories you want the OpenHands agent to work on.
|
||||
|
||||
### Step 2: Configure Linear Integration
|
||||
### Step 2: View Available Workspaces
|
||||
|
||||
1. **Access Integration Settings**
|
||||
- Navigate to **Settings** > **Integrations**
|
||||
- Locate **Linear** section
|
||||
|
||||
2. **Configure Workspace**
|
||||
- Click **Configure** button
|
||||
- Enter your workspace name and click **Connect**
|
||||
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
|
||||
- **Webhook Secret**: The webhook secret from Step 3 above
|
||||
- **Service Account Email**: The service account email from Step 1 above
|
||||
- **Service Account API Key**: The API key from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
2. **Install Linear App**
|
||||
- Click the **Install Linear App** button
|
||||
- You'll be redirected to a page showing available Linear workspaces
|
||||
|
||||
3. **Complete OAuth Flow**
|
||||
3. **Select Workspace**
|
||||
- Choose the workspace you want to connect to
|
||||
- If no integration exists, you'll be prompted to enter additional credentials required for the workspace integration:
|
||||
- **Webhook Secret**: The webhook secret from Step 3 above
|
||||
- **Service Account Email**: The service account email from Step 1 above
|
||||
- **Service Account API Key**: The API key from Step 2 above
|
||||
- Ensure **Active** toggle is enabled
|
||||
|
||||
4. **Complete OAuth Flow**
|
||||
- You'll be redirected to Linear to complete OAuth verification
|
||||
- Grant the necessary permissions to verify your workspace access. If you have access to multiple workspaces, select the correct one that you initially provided
|
||||
- Grant the necessary permissions to verify your workspace access
|
||||
- If successful, you will be redirected back to the **Integrations** settings in the OpenHands Cloud UI
|
||||
|
||||
### Managing Your Integration
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
ConfigureButton,
|
||||
ConfigureModal,
|
||||
} from "#/components/features/settings/project-management/configure-modal";
|
||||
import { LinearInstallButton } from "#/components/features/settings/project-management/linear-install-button";
|
||||
|
||||
interface IntegrationRowProps {
|
||||
platform: "jira" | "jira-dc" | "linear";
|
||||
@@ -88,23 +89,29 @@ export function IntegrationRow({
|
||||
<div className="flex items-center justify-between" data-testid={dataTestId}>
|
||||
<span className="font-medium">{platformName}</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<ConfigureButton
|
||||
onClick={handleConfigure}
|
||||
isDisabled={isLoading}
|
||||
text={buttonText}
|
||||
data-testid={`${platform}-configure-button`}
|
||||
/>
|
||||
{platform === "linear" ? (
|
||||
<LinearInstallButton data-testid={`${platform}-install-button`} />
|
||||
) : (
|
||||
<ConfigureButton
|
||||
onClick={handleConfigure}
|
||||
isDisabled={isLoading}
|
||||
text={buttonText}
|
||||
data-testid={`${platform}-configure-button`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => setConfigureModalOpen(false)}
|
||||
onConfirm={handleConfigureConfirm}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
platformName={platformName}
|
||||
platform={platform}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
{platform !== "linear" && (
|
||||
<ConfigureModal
|
||||
isOpen={isConfigureModalOpen}
|
||||
onClose={() => setConfigureModalOpen(false)}
|
||||
onConfirm={handleConfigureConfirm}
|
||||
onLink={handleLink}
|
||||
onUnlink={handleUnlink}
|
||||
platformName={platformName}
|
||||
platform={platform}
|
||||
integrationData={integrationData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { useLinearInstall } from "#/hooks/mutation/use-linear-install";
|
||||
|
||||
interface LinearInstallButtonProps {
|
||||
"data-testid"?: string;
|
||||
}
|
||||
|
||||
export function LinearInstallButton({
|
||||
"data-testid": dataTestId,
|
||||
}: LinearInstallButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const linearInstallMutation = useLinearInstall();
|
||||
|
||||
const handleInstallClick = () => {
|
||||
linearInstallMutation.mutate();
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInstallClick}
|
||||
disabled={linearInstallMutation.isPending}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{linearInstallMutation.isPending
|
||||
? "Installing..."
|
||||
: t(I18nKey.PROJECT_MANAGEMENT$INSTALL_LINEAR_APP)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
42
frontend/src/hooks/mutation/use-linear-install.ts
Normal file
42
frontend/src/hooks/mutation/use-linear-install.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { openHands } from "#/api/open-hands-axios";
|
||||
import { I18nKey } from "#/i18n/declaration";
|
||||
import { displayErrorToast } from "#/utils/custom-toast-handlers";
|
||||
import { retrieveAxiosErrorMessage } from "#/utils/retrieve-axios-error-message";
|
||||
|
||||
export function useLinearInstall() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const response = await openHands.post(
|
||||
"/integration/linear/workspaces/link",
|
||||
{},
|
||||
);
|
||||
|
||||
const { success, redirect, authorizationUrl } = response.data;
|
||||
|
||||
if (success) {
|
||||
if (redirect) {
|
||||
if (authorizationUrl) {
|
||||
window.location.href = authorizationUrl;
|
||||
} else {
|
||||
throw new Error("Could not get authorization URL from the server.");
|
||||
}
|
||||
} else {
|
||||
window.location.reload();
|
||||
}
|
||||
} else {
|
||||
throw new Error("Linear installation failed");
|
||||
}
|
||||
|
||||
return response.data;
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = retrieveAxiosErrorMessage(error);
|
||||
displayErrorToast(errorMessage || t(I18nKey.ERROR$GENERIC));
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -747,6 +747,7 @@ export enum I18nKey {
|
||||
PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL = "PROJECT_MANAGEMENT$UNLINK_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE = "PROJECT_MANAGEMENT$LINK_CONFIRMATION_TITLE",
|
||||
PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL = "PROJECT_MANAGEMENT$CONFIGURE_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$INSTALL_LINEAR_APP = "PROJECT_MANAGEMENT$INSTALL_LINEAR_APP",
|
||||
PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL = "PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL = "PROJECT_MANAGEMENT$WORKSPACE_NAME_LABEL",
|
||||
PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER = "PROJECT_MANAGEMENT$WORKSPACE_NAME_PLACEHOLDER",
|
||||
|
||||
@@ -11951,6 +11951,13 @@
|
||||
"de": "Konfigurieren",
|
||||
"uk": "Налаштувати"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$INSTALL_LINEAR_APP": {
|
||||
"en": "Install Linear App",
|
||||
"ja": "Linear アプリをインストール",
|
||||
"zh-CN": "安装 Linear 应用",
|
||||
"zh-TW": "安裝 Linear 應用",
|
||||
"ko-KR": "Linear 앱 설치"
|
||||
},
|
||||
"PROJECT_MANAGEMENT$EDIT_BUTTON_LABEL": {
|
||||
"en": "Edit",
|
||||
"ja": "編集",
|
||||
|
||||
@@ -106,10 +106,15 @@ class CodeActAgent(Agent):
|
||||
def _get_tools(self) -> list['ChatCompletionToolParam']:
|
||||
# For these models, we use short tool descriptions ( < 1024 tokens)
|
||||
# to avoid hitting the OpenAI token limit for tool descriptions.
|
||||
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-', 'o3', 'o1', 'o4']
|
||||
SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS = ['gpt-4', 'o3', 'o1', 'o4']
|
||||
|
||||
use_short_tool_desc = False
|
||||
if self.llm is not None:
|
||||
# For historical reasons, previously OpenAI enforces max function description length of 1k characters
|
||||
# https://community.openai.com/t/function-call-description-max-length/529902
|
||||
# But it no longer seems to be an issue recently
|
||||
# https://community.openai.com/t/was-the-character-limit-for-schema-descriptions-upgraded/1225975
|
||||
# Tested on GPT-5 and longer description still works. But we still keep the logic to be safe for older models.
|
||||
use_short_tool_desc = any(
|
||||
model_substr in self.llm.config.model
|
||||
for model_substr in SHORT_TOOL_DESCRIPTION_LLM_SUBSTRS
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import re
|
||||
import sys
|
||||
|
||||
from litellm import ChatCompletionToolParam, ChatCompletionToolParamFunctionChunk
|
||||
@@ -37,7 +38,16 @@ _SHORT_BASH_DESCRIPTION = """Execute a bash command in the terminal.
|
||||
|
||||
def refine_prompt(prompt: str):
|
||||
if sys.platform == 'win32':
|
||||
return prompt.replace('bash', 'powershell')
|
||||
# Replace 'bash' with 'powershell' including tool names like 'execute_bash'
|
||||
# First replace 'execute_bash' with 'execute_powershell' to handle tool names
|
||||
result = re.sub(
|
||||
r'\bexecute_bash\b', 'execute_powershell', prompt, flags=re.IGNORECASE
|
||||
)
|
||||
# Then replace standalone 'bash' with 'powershell'
|
||||
result = re.sub(
|
||||
r'(?<!execute_)(?<!_)\bbash\b', 'powershell', result, flags=re.IGNORECASE
|
||||
)
|
||||
return result
|
||||
return prompt
|
||||
|
||||
|
||||
|
||||
@@ -739,19 +739,3 @@ def run_cli_command(args):
|
||||
except Exception as e:
|
||||
print_formatted_text(f'Error during cleanup: {e}')
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for OpenHands CLI."""
|
||||
from openhands.core.config import get_cli_parser
|
||||
|
||||
parser = get_cli_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
if hasattr(args, 'version') and args.version:
|
||||
import openhands
|
||||
|
||||
print(f'OpenHands CLI version: {openhands.get_version()}')
|
||||
sys.exit(0)
|
||||
|
||||
run_cli_command(args)
|
||||
|
||||
@@ -383,7 +383,7 @@ Do NOT assume the environment is the same as in the example above.
|
||||
"""
|
||||
example = example.lstrip()
|
||||
|
||||
return example
|
||||
return refine_prompt(example)
|
||||
|
||||
|
||||
IN_CONTEXT_LEARNING_EXAMPLE_PREFIX = get_example_for_tools
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
"""
|
||||
LiteLLM currently have an issue where HttpHandlers are being created but not
|
||||
closed. We have submitted a PR to them, (https://github.com/BerriAI/litellm/pull/8711)
|
||||
and their dev team say they are in the process of a refactor that will fix this, but
|
||||
in the meantime, we need to manage the lifecycle of the httpx.Client manually.
|
||||
|
||||
We can't simply pass in our own client object, because all the different implementations use
|
||||
different types of client object.
|
||||
|
||||
So we monkey patch the httpx.Client class to track newly created instances and close these
|
||||
when the operations complete. (Since some paths create a single shared client and reuse these,
|
||||
we actually need to create a proxy object that allows these clients to be reusable.)
|
||||
|
||||
Hopefully, this will be fixed soon and we can remove this abomination.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
from typing import Callable
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def ensure_httpx_close():
|
||||
wrapped_class = httpx.Client
|
||||
proxys = []
|
||||
|
||||
class ClientProxy:
|
||||
"""
|
||||
Sometimes LiteLLM opens a new httpx client for each connection, and does not close them.
|
||||
Sometimes it does close them. Sometimes, it reuses a client between connections. For cases
|
||||
where a client is reused, we need to be able to reuse the client even after closing it.
|
||||
"""
|
||||
|
||||
client_constructor: Callable
|
||||
args: tuple
|
||||
kwargs: dict
|
||||
client: httpx.Client
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.args = args
|
||||
self.kwargs = kwargs
|
||||
self.client = wrapped_class(*self.args, **self.kwargs)
|
||||
proxys.append(self)
|
||||
|
||||
def __getattr__(self, name):
|
||||
# Invoke a method on the proxied client - create one if required
|
||||
if self.client is None:
|
||||
self.client = wrapped_class(*self.args, **self.kwargs)
|
||||
return getattr(self.client, name)
|
||||
|
||||
def close(self):
|
||||
# Close the client if it is open
|
||||
if self.client:
|
||||
self.client.close()
|
||||
self.client = None
|
||||
|
||||
def __iter__(self, *args, **kwargs):
|
||||
# We have to override this as debuggers invoke it causing the client to reopen
|
||||
if self.client:
|
||||
return self.client.iter(*args, **kwargs)
|
||||
return object.__getattribute__(self, 'iter')(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def is_closed(self):
|
||||
# Check if closed
|
||||
if self.client is None:
|
||||
return True
|
||||
return self.client.is_closed
|
||||
|
||||
httpx.Client = ClientProxy
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
httpx.Client = wrapped_class
|
||||
while proxys:
|
||||
proxy = proxys.pop()
|
||||
proxy.close()
|
||||
@@ -4,6 +4,7 @@ from itertools import islice
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
|
||||
from openhands.controller.state.state import State
|
||||
from openhands.core.message import Message, TextContent
|
||||
from openhands.events.observation.agent import MicroagentKnowledge
|
||||
@@ -91,7 +92,8 @@ class PromptManager:
|
||||
return Template(file.read())
|
||||
|
||||
def get_system_message(self) -> str:
|
||||
return self.system_template.render().strip()
|
||||
system_message = self.system_template.render().strip()
|
||||
return refine_prompt(system_message)
|
||||
|
||||
def get_example_user_message(self) -> str:
|
||||
"""This is an initial user message that can be provided to the agent
|
||||
|
||||
21
poetry.lock
generated
21
poetry.lock
generated
@@ -3770,6 +3770,22 @@ http2 = ["h2 (>=3,<5)"]
|
||||
socks = ["socksio (==1.*)"]
|
||||
zstd = ["zstandard (>=0.18.0)"]
|
||||
|
||||
[[package]]
|
||||
name = "httpx-aiohttp"
|
||||
version = "0.1.8"
|
||||
description = "Aiohttp transport for HTTPX"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
groups = ["main"]
|
||||
files = [
|
||||
{file = "httpx_aiohttp-0.1.8-py3-none-any.whl", hash = "sha256:b7bd958d1331f3759a38a0ba22ad29832cb63ca69498c17735228055bf78fa7e"},
|
||||
{file = "httpx_aiohttp-0.1.8.tar.gz", hash = "sha256:756c5e74cdb568c3248ba63fe82bfe8bbe64b928728720f7eaac64b3cf46f308"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
aiohttp = ">=3.10.0,<4"
|
||||
httpx = ">=0.27.0"
|
||||
|
||||
[[package]]
|
||||
name = "httpx-sse"
|
||||
version = "0.4.0"
|
||||
@@ -5136,11 +5152,8 @@ files = [
|
||||
{file = "lxml-5.4.0-cp36-cp36m-win_amd64.whl", hash = "sha256:7ce1a171ec325192c6a636b64c94418e71a1964f56d002cc28122fceff0b6121"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:795f61bcaf8770e1b37eec24edf9771b307df3af74d1d6f27d812e15a9ff3872"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:29f451a4b614a7b5b6c2e043d7b64a15bd8304d7e767055e8ab68387a8cacf4e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:891f7f991a68d20c75cb13c5c9142b2a3f9eb161f1f12a9489c82172d1f133c0"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4aa412a82e460571fad592d0f93ce9935a20090029ba08eca05c614f99b0cc92"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:ac7ba71f9561cd7d7b55e1ea5511543c0282e2b6450f122672a2694621d63b7e"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:c5d32f5284012deaccd37da1e2cd42f081feaa76981f0eaa474351b68df813c5"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:ce31158630a6ac85bddd6b830cffd46085ff90498b397bd0a259f59d27a12188"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:31e63621e073e04697c1b2d23fcb89991790eef370ec37ce4d5d469f40924ed6"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win32.whl", hash = "sha256:be2ba4c3c5b7900246a8f866580700ef0d538f2ca32535e991027bdaba944063"},
|
||||
{file = "lxml-5.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:09846782b1ef650b321484ad429217f5154da4d6e786636c38e434fa32e94e49"},
|
||||
@@ -11753,4 +11766,4 @@ third-party-runtimes = ["daytona", "e2b", "modal", "runloop-api-client"]
|
||||
[metadata]
|
||||
lock-version = "2.1"
|
||||
python-versions = "^3.12,<3.14"
|
||||
content-hash = "4640c66849d6436eed73826154e2d8cf88b456a4d1b71efb9438531245845826"
|
||||
content-hash = "8568c6ec2e11d4fcb23e206a24896b4d2d50e694c04011b668148f484e95b406"
|
||||
|
||||
@@ -20,6 +20,7 @@ packages = [
|
||||
]
|
||||
include = [
|
||||
"openhands/integrations/vscode/openhands-vscode-0.0.1.vsix",
|
||||
"microagents/**/*",
|
||||
]
|
||||
build = "build_vscode.py" # Build VSCode extension during Poetry build
|
||||
|
||||
@@ -41,6 +42,7 @@ numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
deprecated = "*"
|
||||
pexpect = "*"
|
||||
jinja2 = "^3.1.3"
|
||||
python-multipart = "*"
|
||||
@@ -97,6 +99,7 @@ e2b = { version = ">=1.0.5,<1.8.0", optional = true }
|
||||
modal = { version = ">=0.66.26,<1.2.0", optional = true }
|
||||
runloop-api-client = { version = "0.50.0", optional = true }
|
||||
daytona = { version = "0.24.2", optional = true }
|
||||
httpx-aiohttp = "^0.1.8"
|
||||
|
||||
[tool.poetry.extras]
|
||||
third_party_runtimes = [ "e2b", "modal", "runloop-api-client", "daytona" ]
|
||||
@@ -163,7 +166,7 @@ joblib = "*"
|
||||
swebench = { git = "https://github.com/ryanhoangt/SWE-bench.git", rev = "fix-modal-patch-eval" }
|
||||
|
||||
[tool.poetry.scripts]
|
||||
openhands = "openhands.cli.main:main"
|
||||
openhands = "openhands.cli.entry:main"
|
||||
|
||||
[tool.poetry.group.testgeneval.dependencies]
|
||||
fuzzywuzzy = "^0.18.0"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import httpx
|
||||
|
||||
from openhands.utils.ensure_httpx_close import ensure_httpx_close
|
||||
|
||||
|
||||
def test_ensure_httpx_close_basic():
|
||||
"""Test basic functionality of ensure_httpx_close."""
|
||||
ctx = ensure_httpx_close()
|
||||
with ctx:
|
||||
# Create a client - should be tracked
|
||||
client = httpx.Client()
|
||||
|
||||
# After context exit, client should be closed
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_multiple_clients():
|
||||
"""Test ensure_httpx_close with multiple clients."""
|
||||
ctx = ensure_httpx_close()
|
||||
with ctx:
|
||||
client1 = httpx.Client()
|
||||
client2 = httpx.Client()
|
||||
|
||||
assert client1.is_closed
|
||||
assert client2.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_nested():
|
||||
"""Test nested usage of ensure_httpx_close."""
|
||||
with ensure_httpx_close():
|
||||
client1 = httpx.Client()
|
||||
|
||||
with ensure_httpx_close():
|
||||
client2 = httpx.Client()
|
||||
assert not client2.is_closed
|
||||
|
||||
# After inner context, client2 should be closed
|
||||
assert client2.is_closed
|
||||
# client1 should still be open since outer context is still active
|
||||
assert not client1.is_closed
|
||||
|
||||
# After outer context, both clients should be closed
|
||||
assert client1.is_closed
|
||||
assert client2.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_exception():
|
||||
"""Test ensure_httpx_close when an exception occurs."""
|
||||
client = None
|
||||
try:
|
||||
with ensure_httpx_close():
|
||||
client = httpx.Client()
|
||||
raise ValueError('Test exception')
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Client should be closed even if an exception occurred
|
||||
assert client is not None
|
||||
assert client.is_closed
|
||||
|
||||
|
||||
def test_ensure_httpx_close_restore_client():
|
||||
"""Test that the original client is restored after context exit."""
|
||||
original_client = httpx.Client
|
||||
with ensure_httpx_close():
|
||||
assert httpx.Client != original_client
|
||||
|
||||
# Original __init__ should be restored
|
||||
assert httpx.Client == original_client
|
||||
179
tests/unit/test_windows_prompt_refinement.py
Normal file
179
tests/unit/test_windows_prompt_refinement.py
Normal file
@@ -0,0 +1,179 @@
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.llm.llm import LLM
|
||||
|
||||
# Skip all tests in this module if not running on Windows
|
||||
pytestmark = pytest.mark.skipif(
|
||||
sys.platform != 'win32', reason='Windows prompt refinement tests require Windows'
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_llm():
|
||||
"""Create a mock LLM for testing."""
|
||||
llm = LLM(config={'model': 'gpt-4', 'api_key': 'test'})
|
||||
return llm
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def agent_config():
|
||||
"""Create a basic agent config for testing."""
|
||||
return AgentConfig()
|
||||
|
||||
|
||||
def test_codeact_agent_system_prompt_no_bash_on_windows(mock_llm, agent_config):
|
||||
"""Test that CodeActAgent's system prompt doesn't contain 'bash' on Windows."""
|
||||
# Create a CodeActAgent instance
|
||||
agent = CodeActAgent(llm=mock_llm, config=agent_config)
|
||||
|
||||
# Get the system prompt
|
||||
system_prompt = agent.prompt_manager.get_system_message()
|
||||
|
||||
# Assert that 'bash' doesn't exist in the system prompt (case-insensitive)
|
||||
assert 'bash' not in system_prompt.lower(), (
|
||||
f"System prompt contains 'bash' on Windows platform. "
|
||||
f"It should be replaced with 'powershell'. "
|
||||
f'System prompt: {system_prompt}'
|
||||
)
|
||||
|
||||
# Verify that 'powershell' exists instead (case-insensitive)
|
||||
assert 'powershell' in system_prompt.lower(), (
|
||||
f"System prompt should contain 'powershell' on Windows platform. "
|
||||
f'System prompt: {system_prompt}'
|
||||
)
|
||||
|
||||
|
||||
def test_codeact_agent_tool_descriptions_no_bash_on_windows(mock_llm, agent_config):
|
||||
"""Test that CodeActAgent's tool descriptions don't contain 'bash' on Windows."""
|
||||
# Create a CodeActAgent instance
|
||||
agent = CodeActAgent(llm=mock_llm, config=agent_config)
|
||||
|
||||
# Get the tools
|
||||
tools = agent.tools
|
||||
|
||||
# Check each tool's description and parameters
|
||||
for tool in tools:
|
||||
if tool['type'] == 'function':
|
||||
function_info = tool['function']
|
||||
|
||||
# Check function description
|
||||
description = function_info.get('description', '')
|
||||
assert 'bash' not in description.lower(), (
|
||||
f"Tool '{function_info['name']}' description contains 'bash' on Windows. "
|
||||
f'Description: {description}'
|
||||
)
|
||||
|
||||
# Check parameter descriptions
|
||||
parameters = function_info.get('parameters', {})
|
||||
properties = parameters.get('properties', {})
|
||||
|
||||
for param_name, param_info in properties.items():
|
||||
param_description = param_info.get('description', '')
|
||||
assert 'bash' not in param_description.lower(), (
|
||||
f"Tool '{function_info['name']}' parameter '{param_name}' "
|
||||
f"description contains 'bash' on Windows. "
|
||||
f'Parameter description: {param_description}'
|
||||
)
|
||||
|
||||
|
||||
def test_in_context_learning_example_no_bash_on_windows():
|
||||
"""Test that in-context learning examples don't contain 'bash' on Windows."""
|
||||
from openhands.agenthub.codeact_agent.tools.bash import create_cmd_run_tool
|
||||
from openhands.agenthub.codeact_agent.tools.finish import FinishTool
|
||||
from openhands.agenthub.codeact_agent.tools.str_replace_editor import (
|
||||
create_str_replace_editor_tool,
|
||||
)
|
||||
from openhands.llm.fn_call_converter import get_example_for_tools
|
||||
|
||||
# Create a sample set of tools
|
||||
tools = [
|
||||
create_cmd_run_tool(),
|
||||
create_str_replace_editor_tool(),
|
||||
FinishTool,
|
||||
]
|
||||
|
||||
# Get the in-context learning example
|
||||
example = get_example_for_tools(tools)
|
||||
|
||||
# Assert that 'bash' doesn't exist in the example (case-insensitive)
|
||||
assert 'bash' not in example.lower(), (
|
||||
f"In-context learning example contains 'bash' on Windows platform. "
|
||||
f"It should be replaced with 'powershell'. "
|
||||
f'Example: {example}'
|
||||
)
|
||||
|
||||
# Verify that 'powershell' exists instead (case-insensitive)
|
||||
if example: # Only check if example is not empty
|
||||
assert 'powershell' in example.lower(), (
|
||||
f"In-context learning example should contain 'powershell' on Windows platform. "
|
||||
f'Example: {example}'
|
||||
)
|
||||
|
||||
|
||||
def test_refine_prompt_function_works():
|
||||
"""Test that the refine_prompt function correctly replaces 'bash' with 'powershell'."""
|
||||
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
|
||||
|
||||
# Test basic replacement
|
||||
test_prompt = 'Execute a bash command to list files'
|
||||
refined_prompt = refine_prompt(test_prompt)
|
||||
|
||||
assert 'bash' not in refined_prompt.lower()
|
||||
assert 'powershell' in refined_prompt.lower()
|
||||
assert refined_prompt == 'Execute a powershell command to list files'
|
||||
|
||||
# Test multiple occurrences
|
||||
test_prompt = 'Use bash to run bash commands in the bash shell'
|
||||
refined_prompt = refine_prompt(test_prompt)
|
||||
|
||||
assert 'bash' not in refined_prompt.lower()
|
||||
assert (
|
||||
refined_prompt
|
||||
== 'Use powershell to run powershell commands in the powershell shell'
|
||||
)
|
||||
|
||||
# Test case sensitivity
|
||||
test_prompt = 'BASH and Bash and bash should all be replaced'
|
||||
refined_prompt = refine_prompt(test_prompt)
|
||||
|
||||
assert 'bash' not in refined_prompt.lower()
|
||||
assert (
|
||||
refined_prompt
|
||||
== 'powershell and powershell and powershell should all be replaced'
|
||||
)
|
||||
|
||||
# Test execute_bash tool name replacement
|
||||
test_prompt = 'Use the execute_bash tool to run commands'
|
||||
refined_prompt = refine_prompt(test_prompt)
|
||||
|
||||
assert 'execute_bash' not in refined_prompt.lower()
|
||||
assert 'execute_powershell' in refined_prompt.lower()
|
||||
assert refined_prompt == 'Use the execute_powershell tool to run commands'
|
||||
|
||||
# Test that words containing 'bash' but not equal to 'bash' are preserved
|
||||
test_prompt = 'The bashful person likes bash-like syntax'
|
||||
refined_prompt = refine_prompt(test_prompt)
|
||||
|
||||
# 'bashful' should be preserved, 'bash-like' should become 'powershell-like'
|
||||
assert 'bashful' in refined_prompt
|
||||
assert 'powershell-like' in refined_prompt
|
||||
assert refined_prompt == 'The bashful person likes powershell-like syntax'
|
||||
|
||||
|
||||
def test_refine_prompt_function_on_non_windows():
|
||||
"""Test that the refine_prompt function doesn't change anything on non-Windows platforms."""
|
||||
from openhands.agenthub.codeact_agent.tools.bash import refine_prompt
|
||||
|
||||
# Mock sys.platform to simulate non-Windows
|
||||
with patch('openhands.agenthub.codeact_agent.tools.bash.sys.platform', 'linux'):
|
||||
test_prompt = 'Execute a bash command to list files'
|
||||
refined_prompt = refine_prompt(test_prompt)
|
||||
|
||||
# On non-Windows, the prompt should remain unchanged
|
||||
assert refined_prompt == test_prompt
|
||||
assert 'bash' in refined_prompt.lower()
|
||||
Reference in New Issue
Block a user