mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
6 Commits
refactor/b
...
replace-si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da828b8723 | ||
|
|
d3d8010d23 | ||
|
|
d298d99f78 | ||
|
|
550d003566 | ||
|
|
533ac9e988 | ||
|
|
30f0fff9a2 |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -5,7 +5,7 @@
|
||||
/frontend/ @rbren @amanape
|
||||
|
||||
# Evaluation code owners
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
/evaluation/ @xingyaoww @neubig
|
||||
|
||||
# Documentation code owners
|
||||
/docs/ @mamoodi
|
||||
|
||||
@@ -178,4 +178,4 @@ interface OpenHandsEvent {
|
||||
### Event Handling Issues
|
||||
|
||||
- Check that you're correctly parsing the event data
|
||||
- Verify that your event handlers are properly registered
|
||||
- Verify that your event handlers are properly registered
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Azure
|
||||
|
||||
OpenHands uses LiteLLM to make calls to Azure's chat models. You can find their documentation on using Azure as a
|
||||
OpenHands uses LiteLLM to make calls to Azure's chat models. You can find their documentation on using Azure as a
|
||||
provider [here](https://docs.litellm.ai/docs/providers/azure).
|
||||
|
||||
## Azure OpenAI Configuration
|
||||
|
||||
@@ -10,7 +10,7 @@ OpenHands uses LiteLLM to make calls to Google's chat models. You can find their
|
||||
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
|
||||
- `LLM Provider` to `Gemini`
|
||||
- `LLM Model` to the model you will be using.
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
|
||||
(e.g. gemini/<model-name> like `gemini/gemini-2.0-flash`).
|
||||
- `API Key` to your Gemini API key
|
||||
|
||||
@@ -28,5 +28,5 @@ VERTEXAI_LOCATION="<your-gcp-location>"
|
||||
Then set the following in the OpenHands UI through the Settings under the `LLM` tab:
|
||||
- `LLM Provider` to `VertexAI`
|
||||
- `LLM Model` to the model you will be using.
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in `Custom Model`
|
||||
(e.g. vertex_ai/<model-name>).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Groq
|
||||
|
||||
OpenHands uses LiteLLM to make calls to chat models on Groq. You can find their documentation on using Groq as a
|
||||
OpenHands uses LiteLLM to make calls to chat models on Groq. You can find their documentation on using Groq as a
|
||||
provider [here](https://docs.litellm.ai/docs/providers/groq).
|
||||
|
||||
## Configuration
|
||||
@@ -8,7 +8,7 @@ provider [here](https://docs.litellm.ai/docs/providers/groq).
|
||||
When running OpenHands, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
|
||||
- `LLM Provider` to `Groq`
|
||||
- `LLM Model` to the model you will be using. [Visit here to see the list of
|
||||
models that Groq hosts](https://console.groq.com/docs/models). If the model is not in the list,
|
||||
models that Groq hosts](https://console.groq.com/docs/models). If the model is not in the list,
|
||||
enable `Advanced` options, and enter it in `Custom Model` (e.g. groq/<model-name> like `groq/llama3-70b-8192`).
|
||||
- `API key` to your Groq API key. To find or create your Groq API Key, [see here](https://console.groq.com/keys).
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ To use LiteLLM proxy with OpenHands, you need to:
|
||||
|
||||
## Supported Models
|
||||
|
||||
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy
|
||||
The supported models depend on your LiteLLM proxy configuration. OpenHands supports any model that your LiteLLM proxy
|
||||
is configured to handle.
|
||||
|
||||
Refer to your LiteLLM proxy configuration for the list of available models and their names.
|
||||
|
||||
@@ -25,7 +25,7 @@ OpenHands will issue many prompts to the LLM you configure. Most of these LLMs c
|
||||
limits and monitor usage.
|
||||
:::
|
||||
|
||||
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
|
||||
If you have successfully run OpenHands with specific providers, we encourage you to open a PR to share your setup process
|
||||
to help others using the same provider!
|
||||
|
||||
For a full list of the providers and models available, please consult the
|
||||
|
||||
@@ -25,7 +25,7 @@ We recommend using [LMStudio](https://lmstudio.ai/) for serving these models loc
|
||||
- Option 2: Download a LLM in GGUF format. For example, to download [Devstral Small 2505 GGUF](https://huggingface.co/mistralai/Devstral-Small-2505_gguf), using `huggingface-cli download mistralai/Devstral-Small-2505_gguf --local-dir mistralai/Devstral-Small-2505_gguf`. Then in bash terminal, run `lms import {model_name}` in the directory where you've downloaded the model checkpoint (e.g. run `lms import devstralQ4_K_M.gguf` in `mistralai/Devstral-Small-2505_gguf`)
|
||||
|
||||
3. Open LM Studio application, you should first switch to `power user` mode, and then open the developer tab:
|
||||
|
||||
|
||||

|
||||
|
||||
4. Then click `Select a model to load` on top of the application:
|
||||
@@ -154,7 +154,7 @@ Start OpenHands using `make run`.
|
||||
|
||||
### Configure OpenHands
|
||||
|
||||
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
|
||||
Once OpenHands is running, you'll need to set the following in the OpenHands UI through the Settings under the `LLM` tab:
|
||||
1. Enable `Advanced` options.
|
||||
2. Set the following:
|
||||
- `Custom Model` to `openai/<served-model-name>` (e.g. `openai/openhands-lm-32b-v0.1`)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenAI
|
||||
|
||||
OpenHands uses LiteLLM to make calls to OpenAI's chat models. You can find their documentation on using OpenAI as a
|
||||
OpenHands uses LiteLLM to make calls to OpenAI's chat models. You can find their documentation on using OpenAI as a
|
||||
provider [here](https://docs.litellm.ai/docs/providers/openai).
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# OpenRouter
|
||||
|
||||
OpenHands uses LiteLLM to make calls to chat models on OpenRouter. You can find their documentation on using
|
||||
OpenHands uses LiteLLM to make calls to chat models on OpenRouter. You can find their documentation on using
|
||||
OpenRouter as a provider [here](https://docs.litellm.ai/docs/providers/openrouter).
|
||||
|
||||
## Configuration
|
||||
@@ -9,6 +9,6 @@ When running OpenHands, you'll need to set the following in the OpenHands UI thr
|
||||
* `LLM Provider` to `OpenRouter`
|
||||
* `LLM Model` to the model you will be using.
|
||||
[Visit here to see a full list of OpenRouter models](https://openrouter.ai/models).
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in
|
||||
If the model is not in the list, enable `Advanced` options, and enter it in
|
||||
`Custom Model` (e.g. openrouter/<model-name> like `openrouter/anthropic/claude-3.5-sonnet`).
|
||||
* `API Key` to your OpenRouter API key.
|
||||
|
||||
@@ -6,7 +6,7 @@ Organizations and users can define microagents that apply to all repositories be
|
||||
|
||||
## Usage
|
||||
|
||||
These microagents can be [any type of microagent](./microagents-overview#microagent-types) and will be loaded
|
||||
These microagents can be [any type of microagent](./microagents-overview#microagent-types) and will be loaded
|
||||
accordingly. However, they are applied to all repositories belonging to the organization or user.
|
||||
|
||||
Add a `.openhands` repository under the organization or user and create a `microagents` directory and place the
|
||||
|
||||
@@ -15,7 +15,7 @@ Before using the Local Runtime, ensure that:
|
||||
1. You can run OpenHands using the [Development workflow](https://github.com/All-Hands-AI/OpenHands/blob/main/Development.md).
|
||||
2. For Linux and Mac, tmux is available on your system.
|
||||
3. For Windows, PowerShell is available on your system.
|
||||
- Only [CLI mode](../how-to/cli-mode) and [headless mode](../how-to/headless-mode) are supported in Windows with Local Runtime.
|
||||
- Only [CLI mode](../how-to/cli-mode) and [headless mode](../how-to/headless-mode) are supported in Windows with Local Runtime.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
@@ -201,7 +201,7 @@ def process_instance(
|
||||
) -> EvalOutput:
|
||||
config = get_config(metadata)
|
||||
# use a session id for concurrent evaluation
|
||||
sid = _get_instance_id(instance)
|
||||
conversation_id = _get_instance_id(instance)
|
||||
|
||||
# Setup the logger properly, so you can run multi-processing to parallelize the evaluation
|
||||
if reset_logger:
|
||||
@@ -218,7 +218,7 @@ def process_instance(
|
||||
|
||||
# Prepare instruction
|
||||
instruction = (
|
||||
f'Please fix the function in {sid}.py such that all test cases pass.\n'
|
||||
f'Please fix the function in {conversation_id}.py such that all test cases pass.\n'
|
||||
'Environment has been set up for you to start working. You may assume all necessary tools are installed.\n\n'
|
||||
'# Problem Statement\n'
|
||||
f'{problem_statement}\n\n'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
TASK_INSTRUECTION="""
|
||||
TASK_INSTRUECTION = """
|
||||
Given the following GitHub problem description, your objective is to localize the specific files, classes or functions, and lines of code that need modification or contain key information to resolve the issue.
|
||||
|
||||
Follow these steps to localize the issue:
|
||||
@@ -66,4 +66,4 @@ FAKE_USER_MSG_FOR_LOC = (
|
||||
'Verify that you have carefully analyzed the impact of the found locations on the repository, especially their dependencies. '
|
||||
'If you think you have solved the task, please send your final answer (including the former answer and reranking) to user through message and then call `finish` to finish.\n'
|
||||
'IMPORTANT: YOU SHOULD NEVER ASK FOR HUMAN HELP.\n'
|
||||
)
|
||||
)
|
||||
|
||||
@@ -141,7 +141,7 @@ def run_solver(
|
||||
state: State | None = asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
sid=task_name,
|
||||
conversation_id=task_name,
|
||||
initial_user_action=MessageAction(content=instruction),
|
||||
runtime=runtime,
|
||||
fake_user_response_fn=codeact_user_response,
|
||||
|
||||
@@ -16,8 +16,8 @@ vi.mock("react-i18next", async () => {
|
||||
if (i18nKey === "SETTINGS$API_KEYS_DESCRIPTION") {
|
||||
return (
|
||||
<span>
|
||||
API keys allow you to authenticate with the OpenHands API programmatically.
|
||||
Keep your API keys secure; anyone with your API key can access your account.
|
||||
API keys allow you to authenticate with the OpenHands API programmatically.
|
||||
Keep your API keys secure; anyone with your API key can access your account.
|
||||
For more information on how to use the API, see our {components.a}
|
||||
</span>
|
||||
);
|
||||
@@ -48,7 +48,7 @@ describe("ApiKeysManager", () => {
|
||||
|
||||
it("should render the API documentation link", () => {
|
||||
renderComponent();
|
||||
|
||||
|
||||
// Find the link to the API documentation
|
||||
const link = screen.getByRole("link");
|
||||
expect(link).toBeInTheDocument();
|
||||
@@ -56,4 +56,4 @@ describe("ApiKeysManager", () => {
|
||||
expect(link).toHaveAttribute("target", "_blank");
|
||||
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -60,11 +60,11 @@ Object.entries(translationJson).forEach(([key, translations]) => {
|
||||
if (Object.keys(missingTranslations).length > 0) {
|
||||
console.error('\x1b[31m%s\x1b[0m', 'ERROR: Missing translations detected');
|
||||
console.error(`Found ${Object.keys(missingTranslations).length} translation keys with missing languages:`);
|
||||
|
||||
|
||||
Object.entries(missingTranslations).forEach(([key, langs]) => {
|
||||
console.error(`- Key "${key}" is missing translations for: ${langs.join(', ')}`);
|
||||
});
|
||||
|
||||
|
||||
console.error('\nPlease add the missing translations before committing.');
|
||||
}
|
||||
|
||||
@@ -72,11 +72,11 @@ if (Object.keys(missingTranslations).length > 0) {
|
||||
if (Object.keys(extraLanguages).length > 0) {
|
||||
console.error('\x1b[31m%s\x1b[0m', 'ERROR: Extra languages detected');
|
||||
console.error(`Found ${Object.keys(extraLanguages).length} translation keys with extra languages not in AvailableLanguages:`);
|
||||
|
||||
|
||||
Object.entries(extraLanguages).forEach(([key, langs]) => {
|
||||
console.error(`- Key "${key}" has translations for unsupported languages: ${langs.join(', ')}`);
|
||||
});
|
||||
|
||||
|
||||
console.error('\nPlease remove the extra languages before committing.');
|
||||
}
|
||||
|
||||
@@ -85,4 +85,4 @@ if (hasErrors) {
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log('\x1b[32m%s\x1b[0m', 'All translation keys have complete language coverage!');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,10 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
describe("RepositorySelectionForm", () => {
|
||||
const mockOnRepoSelection = vi.fn();
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
|
||||
|
||||
// Mock the hooks with default values
|
||||
(useUserRepositories as any).mockReturnValue({
|
||||
data: [
|
||||
@@ -32,7 +32,7 @@ describe("RepositorySelectionForm", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
|
||||
(useRepositoryBranches as any).mockReturnValue({
|
||||
data: [
|
||||
{ name: "main" },
|
||||
@@ -41,90 +41,90 @@ describe("RepositorySelectionForm", () => {
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
});
|
||||
|
||||
|
||||
(useCreateConversation as any).mockReturnValue({
|
||||
mutate: vi.fn(),
|
||||
isPending: false,
|
||||
isSuccess: false,
|
||||
});
|
||||
|
||||
|
||||
(useIsCreatingConversation as any).mockReturnValue(false);
|
||||
});
|
||||
|
||||
|
||||
it("should clear selected branch when input is empty", async () => {
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
|
||||
|
||||
|
||||
// First select a repository to enable the branch dropdown
|
||||
const repoDropdown = screen.getByTestId("repository-dropdown");
|
||||
fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
|
||||
|
||||
|
||||
// Get the branch dropdown and verify it's enabled
|
||||
const branchDropdown = screen.getByTestId("branch-dropdown");
|
||||
expect(branchDropdown).not.toBeDisabled();
|
||||
|
||||
|
||||
// Simulate deleting all text in the branch input
|
||||
fireEvent.change(branchDropdown, { target: { value: "" } });
|
||||
|
||||
|
||||
// Verify the branch input is cleared (no selected branch)
|
||||
expect(branchDropdown).toHaveValue("");
|
||||
});
|
||||
|
||||
|
||||
it("should clear selected branch when input contains only whitespace", async () => {
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
|
||||
|
||||
|
||||
// First select a repository to enable the branch dropdown
|
||||
const repoDropdown = screen.getByTestId("repository-dropdown");
|
||||
fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
|
||||
|
||||
|
||||
// Get the branch dropdown and verify it's enabled
|
||||
const branchDropdown = screen.getByTestId("branch-dropdown");
|
||||
expect(branchDropdown).not.toBeDisabled();
|
||||
|
||||
|
||||
// Simulate entering only whitespace in the branch input
|
||||
fireEvent.change(branchDropdown, { target: { value: " " } });
|
||||
|
||||
|
||||
// Verify the branch input is cleared (no selected branch)
|
||||
expect(branchDropdown).toHaveValue("");
|
||||
});
|
||||
|
||||
it("should keep branch empty after being cleared even with auto-selection", async () => {
|
||||
render(<RepositorySelectionForm onRepoSelection={mockOnRepoSelection} />);
|
||||
|
||||
|
||||
// First select a repository to enable the branch dropdown
|
||||
const repoDropdown = screen.getByTestId("repository-dropdown");
|
||||
fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
|
||||
|
||||
|
||||
// Get the branch dropdown and verify it's enabled
|
||||
const branchDropdown = screen.getByTestId("branch-dropdown");
|
||||
expect(branchDropdown).not.toBeDisabled();
|
||||
|
||||
|
||||
// The branch should be auto-selected to "main" initially
|
||||
expect(branchDropdown).toHaveValue("main");
|
||||
|
||||
|
||||
// Simulate deleting all text in the branch input
|
||||
fireEvent.change(branchDropdown, { target: { value: "" } });
|
||||
|
||||
|
||||
// Verify the branch input is cleared (no selected branch)
|
||||
expect(branchDropdown).toHaveValue("");
|
||||
|
||||
|
||||
// Trigger a re-render by changing something else
|
||||
fireEvent.change(repoDropdown, { target: { value: "test/repo2" } });
|
||||
fireEvent.change(repoDropdown, { target: { value: "test/repo1" } });
|
||||
|
||||
|
||||
// The branch should be auto-selected to "main" again after repo change
|
||||
expect(branchDropdown).toHaveValue("main");
|
||||
|
||||
|
||||
// Clear it again
|
||||
fireEvent.change(branchDropdown, { target: { value: "" } });
|
||||
|
||||
|
||||
// Verify it stays empty
|
||||
expect(branchDropdown).toHaveValue("");
|
||||
|
||||
|
||||
// Simulate a component update without changing repos
|
||||
// This would normally trigger the useEffect if our fix wasn't working
|
||||
fireEvent.blur(branchDropdown);
|
||||
|
||||
|
||||
// Verify it still stays empty
|
||||
expect(branchDropdown).toHaveValue("");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -266,5 +266,6 @@ class CodeActAgent(Agent):
|
||||
|
||||
def response_to_actions(self, response: 'ModelResponse') -> list['Action']:
|
||||
return codeact_function_calling.response_to_actions(
|
||||
response, mcp_tool_names=list(self.mcp_tools.keys()),
|
||||
response,
|
||||
mcp_tool_names=list(self.mcp_tools.keys()),
|
||||
)
|
||||
|
||||
@@ -5,14 +5,13 @@ This is similar to the functionality of `CodeActResponseParser`.
|
||||
|
||||
import json
|
||||
|
||||
|
||||
from litellm import (
|
||||
ChatCompletionToolParam,
|
||||
ModelResponse,
|
||||
)
|
||||
|
||||
from openhands.agenthub.codeact_agent.tools import FinishTool
|
||||
from openhands.agenthub.codeact_agent.function_calling import combine_thought
|
||||
from openhands.agenthub.codeact_agent.tools import FinishTool
|
||||
from openhands.agenthub.loc_agent.tools import (
|
||||
SearchEntityTool,
|
||||
SearchRepoTool,
|
||||
@@ -32,7 +31,8 @@ from openhands.events.tool import ToolCallMetadata
|
||||
|
||||
|
||||
def response_to_actions(
|
||||
response: ModelResponse, mcp_tool_names: list[str] | None = None,
|
||||
response: ModelResponse,
|
||||
mcp_tool_names: list[str] | None = None,
|
||||
) -> list[Action]:
|
||||
actions: list[Action] = []
|
||||
assert len(response.choices) == 1, 'Only one choice is supported for now'
|
||||
@@ -87,7 +87,7 @@ def response_to_actions(
|
||||
raise FunctionCallNotExistsError(
|
||||
f'Tool {tool_call.function.name} is not registered. (arguments: {arguments}). Please check the tool name and retry with an existing tool.'
|
||||
)
|
||||
|
||||
|
||||
# We only add thought to the first action
|
||||
if i == 0:
|
||||
action = combine_thought(action, thought)
|
||||
@@ -106,7 +106,7 @@ def response_to_actions(
|
||||
wait_for_response=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Add response id to actions
|
||||
# This will ensure we can match both actions without tool calls (e.g. MessageAction)
|
||||
# and actions with tool calls (e.g. CmdRunAction, IPythonRunCellAction, etc.)
|
||||
@@ -116,7 +116,7 @@ def response_to_actions(
|
||||
|
||||
assert len(actions) >= 1
|
||||
return actions
|
||||
|
||||
|
||||
|
||||
def get_tools() -> list[ChatCompletionToolParam]:
|
||||
tools = [FinishTool]
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
from openhands.agenthub.codeact_agent import CodeActAgent
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import openhands.agenthub.loc_agent.function_calling as locagent_function_calling
|
||||
from openhands.agenthub.codeact_agent import CodeActAgent
|
||||
from openhands.core.config import AgentConfig
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.llm.llm import LLM
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
from openhands.events.action import Action
|
||||
from openhands.llm.llm import ModelResponse
|
||||
|
||||
@@ -35,5 +34,6 @@ class LocAgent(CodeActAgent):
|
||||
|
||||
def response_to_actions(self, response: 'ModelResponse') -> list['Action']:
|
||||
return locagent_function_calling.response_to_actions(
|
||||
response, mcp_tool_names=list(self.mcp_tools.keys()),
|
||||
response,
|
||||
mcp_tool_names=list(self.mcp_tools.keys()),
|
||||
)
|
||||
|
||||
@@ -41,7 +41,7 @@ async def handle_commands(
|
||||
command: str,
|
||||
event_stream: EventStream,
|
||||
usage_metrics: UsageMetrics,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
config: OpenHandsConfig,
|
||||
current_dir: str,
|
||||
settings_store: FileSettingsStore,
|
||||
@@ -54,7 +54,7 @@ async def handle_commands(
|
||||
close_repl = handle_exit_command(
|
||||
event_stream,
|
||||
usage_metrics,
|
||||
sid,
|
||||
conversation_id,
|
||||
)
|
||||
elif command == '/help':
|
||||
handle_help_command()
|
||||
@@ -63,10 +63,10 @@ async def handle_commands(
|
||||
config, event_stream, current_dir
|
||||
)
|
||||
elif command == '/status':
|
||||
handle_status_command(usage_metrics, sid)
|
||||
handle_status_command(usage_metrics, conversation_id)
|
||||
elif command == '/new':
|
||||
close_repl, new_session_requested = handle_new_command(
|
||||
event_stream, usage_metrics, sid
|
||||
event_stream, usage_metrics, conversation_id
|
||||
)
|
||||
elif command == '/settings':
|
||||
await handle_settings_command(config, settings_store)
|
||||
@@ -81,7 +81,7 @@ async def handle_commands(
|
||||
|
||||
|
||||
def handle_exit_command(
|
||||
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
|
||||
event_stream: EventStream, usage_metrics: UsageMetrics, conversation_id: str
|
||||
) -> bool:
|
||||
close_repl = False
|
||||
|
||||
@@ -94,7 +94,7 @@ def handle_exit_command(
|
||||
ChangeAgentStateAction(AgentState.STOPPED),
|
||||
EventSource.ENVIRONMENT,
|
||||
)
|
||||
display_shutdown_message(usage_metrics, sid)
|
||||
display_shutdown_message(usage_metrics, conversation_id)
|
||||
close_repl = True
|
||||
|
||||
return close_repl
|
||||
@@ -135,12 +135,12 @@ async def handle_init_command(
|
||||
return close_repl, reload_microagents
|
||||
|
||||
|
||||
def handle_status_command(usage_metrics: UsageMetrics, sid: str) -> None:
|
||||
display_status(usage_metrics, sid)
|
||||
def handle_status_command(usage_metrics: UsageMetrics, conversation_id: str) -> None:
|
||||
display_status(usage_metrics, conversation_id)
|
||||
|
||||
|
||||
def handle_new_command(
|
||||
event_stream: EventStream, usage_metrics: UsageMetrics, sid: str
|
||||
event_stream: EventStream, usage_metrics: UsageMetrics, conversation_id: str
|
||||
) -> tuple[bool, bool]:
|
||||
close_repl = False
|
||||
new_session_requested = False
|
||||
@@ -160,7 +160,7 @@ def handle_new_command(
|
||||
ChangeAgentStateAction(AgentState.STOPPED),
|
||||
EventSource.ENVIRONMENT,
|
||||
)
|
||||
display_shutdown_message(usage_metrics, sid)
|
||||
display_shutdown_message(usage_metrics, conversation_id)
|
||||
|
||||
return close_repl, new_session_requested
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ from openhands.core.setup import (
|
||||
create_controller,
|
||||
create_memory,
|
||||
create_runtime,
|
||||
generate_sid,
|
||||
generate_conversation_id,
|
||||
initialize_repository_for_runtime,
|
||||
)
|
||||
from openhands.events import EventSource, EventStreamSubscriber
|
||||
@@ -77,7 +77,7 @@ async def cleanup_session(
|
||||
event_stream = runtime.event_stream
|
||||
end_state = controller.get_state()
|
||||
end_state.save_to_session(
|
||||
event_stream.sid,
|
||||
event_stream.conversation_id,
|
||||
event_stream.file_store,
|
||||
event_stream.user_id,
|
||||
)
|
||||
@@ -113,7 +113,7 @@ async def run_session(
|
||||
reload_microagents = False
|
||||
new_session_requested = False
|
||||
|
||||
sid = generate_sid(config, session_name)
|
||||
conversation_id = generate_conversation_id(config, session_name)
|
||||
is_loaded = asyncio.Event()
|
||||
is_paused = asyncio.Event() # Event to track agent pause requests
|
||||
always_confirm_mode = False # Flag to enable always confirm mode
|
||||
@@ -129,7 +129,7 @@ async def run_session(
|
||||
agent = create_agent(config)
|
||||
runtime = create_runtime(
|
||||
config,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
headless_mode=True,
|
||||
agent=agent,
|
||||
)
|
||||
@@ -164,7 +164,7 @@ async def run_session(
|
||||
next_message,
|
||||
event_stream,
|
||||
usage_metrics,
|
||||
sid,
|
||||
conversation_id,
|
||||
config,
|
||||
current_dir,
|
||||
settings_store,
|
||||
@@ -238,7 +238,7 @@ async def run_session(
|
||||
def on_event(event: Event) -> None:
|
||||
loop.create_task(on_event_async(event))
|
||||
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, conversation_id)
|
||||
|
||||
await runtime.connect()
|
||||
|
||||
@@ -254,7 +254,7 @@ async def run_session(
|
||||
memory = create_memory(
|
||||
runtime=runtime,
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
selected_repository=config.sandbox.selected_repo,
|
||||
repo_directory=repo_directory,
|
||||
conversation_instructions=conversation_instructions,
|
||||
@@ -282,7 +282,7 @@ async def run_session(
|
||||
clear()
|
||||
|
||||
# Show OpenHands banner and session ID
|
||||
display_banner(session_id=sid)
|
||||
display_banner(session_id=conversation_id)
|
||||
|
||||
welcome_message = 'What do you want to build?' # from the application
|
||||
initial_message = '' # from the user
|
||||
@@ -292,7 +292,7 @@ async def run_session(
|
||||
|
||||
# If we loaded a state, we are resuming a previous session
|
||||
if initial_state is not None:
|
||||
logger.info(f'Resuming session: {sid}')
|
||||
logger.info(f'Resuming session: {conversation_id}')
|
||||
|
||||
if initial_state.last_error:
|
||||
# If the last session ended in an error, provide a message.
|
||||
|
||||
@@ -109,7 +109,7 @@ class AgentController:
|
||||
max_budget_per_task: float | None = None,
|
||||
agent_to_llm_config: dict[str, LLMConfig] | None = None,
|
||||
agent_configs: dict[str, AgentConfig] | None = None,
|
||||
sid: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
confirmation_mode: bool = False,
|
||||
initial_state: State | None = None,
|
||||
is_delegate: bool = False,
|
||||
@@ -128,7 +128,7 @@ class AgentController:
|
||||
we delegate to a different agent.
|
||||
agent_configs: A dictionary mapping agent names to agent configurations in the case that
|
||||
we delegate to a different agent.
|
||||
sid: The session ID of the agent.
|
||||
conversation_id: The session ID of the agent.
|
||||
confirmation_mode: Whether to enable confirmation mode for agent actions.
|
||||
initial_state: The initial state of the controller.
|
||||
is_delegate: Whether this controller is a delegate.
|
||||
@@ -136,7 +136,7 @@ class AgentController:
|
||||
status_callback: Optional callback function to handle status updates.
|
||||
replay_events: A list of logs to replay.
|
||||
"""
|
||||
self.id = sid or event_stream.sid
|
||||
self.id = conversation_id or event_stream.conversation_id
|
||||
self.agent = agent
|
||||
self.headless_mode = headless_mode
|
||||
self.is_delegate = is_delegate
|
||||
@@ -700,7 +700,7 @@ class AgentController:
|
||||
|
||||
# Create the delegate with is_delegate=True so it does NOT subscribe directly
|
||||
self.delegate = AgentController(
|
||||
sid=self.id + '-delegate',
|
||||
conversation_id=self.id + '-delegate',
|
||||
agent=delegate_agent,
|
||||
event_stream=self.event_stream,
|
||||
max_iterations=self.state.max_iterations,
|
||||
|
||||
@@ -105,19 +105,19 @@ class State:
|
||||
last_error: str = ''
|
||||
|
||||
def save_to_session(
|
||||
self, sid: str, file_store: FileStore, user_id: str | None
|
||||
self, conversation_id: str, file_store: FileStore, user_id: str | None
|
||||
) -> None:
|
||||
pickled = pickle.dumps(self)
|
||||
logger.debug(f'Saving state to session {sid}:{self.agent_state}')
|
||||
logger.debug(f'Saving state to session {conversation_id}:{self.agent_state}')
|
||||
encoded = base64.b64encode(pickled).decode('utf-8')
|
||||
try:
|
||||
file_store.write(
|
||||
get_conversation_agent_state_filename(sid, user_id), encoded
|
||||
get_conversation_agent_state_filename(conversation_id, user_id), encoded
|
||||
)
|
||||
|
||||
# see if state is in the old directory on saas/remote use cases and delete it.
|
||||
if user_id:
|
||||
filename = get_conversation_agent_state_filename(sid)
|
||||
filename = get_conversation_agent_state_filename(conversation_id)
|
||||
try:
|
||||
file_store.delete(filename)
|
||||
except Exception:
|
||||
@@ -128,7 +128,7 @@ class State:
|
||||
|
||||
@staticmethod
|
||||
def restore_from_session(
|
||||
sid: str, file_store: FileStore, user_id: str | None = None
|
||||
conversation_id: str, file_store: FileStore, user_id: str | None = None
|
||||
) -> 'State':
|
||||
"""
|
||||
Restores the state from the previously saved session.
|
||||
@@ -137,7 +137,7 @@ class State:
|
||||
state: State
|
||||
try:
|
||||
encoded = file_store.read(
|
||||
get_conversation_agent_state_filename(sid, user_id)
|
||||
get_conversation_agent_state_filename(conversation_id, user_id)
|
||||
)
|
||||
pickled = base64.b64decode(encoded)
|
||||
state = pickle.loads(pickled)
|
||||
@@ -145,13 +145,13 @@ class State:
|
||||
# if user_id is provided, we are in a saas/remote use case
|
||||
# and we need to check if the state is in the old directory.
|
||||
if user_id:
|
||||
filename = get_conversation_agent_state_filename(sid)
|
||||
filename = get_conversation_agent_state_filename(conversation_id)
|
||||
encoded = file_store.read(filename)
|
||||
pickled = base64.b64decode(encoded)
|
||||
state = pickle.loads(pickled)
|
||||
else:
|
||||
raise FileNotFoundError(
|
||||
f'Could not restore state from session file for sid: {sid}'
|
||||
f'Could not restore state from session file for conversation_id: {conversation_id}'
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Could not restore state from session: {e}')
|
||||
|
||||
@@ -22,7 +22,7 @@ from openhands.core.setup import (
|
||||
create_controller,
|
||||
create_memory,
|
||||
create_runtime,
|
||||
generate_sid,
|
||||
generate_conversation_id,
|
||||
initialize_repository_for_runtime,
|
||||
)
|
||||
from openhands.events import EventSource, EventStreamSubscriber
|
||||
@@ -49,7 +49,7 @@ class FakeUserResponseFunc(Protocol):
|
||||
async def run_controller(
|
||||
config: OpenHandsConfig,
|
||||
initial_user_action: Action,
|
||||
sid: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
runtime: Runtime | None = None,
|
||||
agent: Agent | None = None,
|
||||
exit_on_message: bool = False,
|
||||
@@ -65,7 +65,7 @@ async def run_controller(
|
||||
Args:
|
||||
config: The app config.
|
||||
initial_user_action: An Action object containing initial user input
|
||||
sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
conversation_id: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
Set it to incompatible value will cause unexpected behavior on RemoteRuntime.
|
||||
runtime: (optional) A runtime for the agent to run on.
|
||||
agent: (optional) A agent to run.
|
||||
@@ -94,7 +94,7 @@ async def run_controller(
|
||||
>>> action = MessageAction(content="Write a hello world program")
|
||||
>>> state = await run_controller(config=config, initial_user_action=action)
|
||||
"""
|
||||
sid = sid or generate_sid(config)
|
||||
conversation_id = conversation_id or generate_conversation_id(config)
|
||||
|
||||
if agent is None:
|
||||
agent = create_agent(config)
|
||||
@@ -104,7 +104,7 @@ async def run_controller(
|
||||
if runtime is None:
|
||||
runtime = create_runtime(
|
||||
config,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
headless_mode=headless_mode,
|
||||
agent=agent,
|
||||
)
|
||||
@@ -125,7 +125,7 @@ async def run_controller(
|
||||
memory = create_memory(
|
||||
runtime=runtime,
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
selected_repository=config.sandbox.selected_repo,
|
||||
repo_directory=repo_directory,
|
||||
conversation_instructions=conversation_instructions,
|
||||
@@ -194,7 +194,7 @@ async def run_controller(
|
||||
action = MessageAction(content=message)
|
||||
event_stream.add_event(action, EventSource.USER)
|
||||
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, sid)
|
||||
event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, conversation_id)
|
||||
|
||||
end_states = [
|
||||
AgentState.FINISHED,
|
||||
@@ -214,7 +214,7 @@ async def run_controller(
|
||||
end_state = controller.get_state()
|
||||
# NOTE: the saved state does not include delegates events
|
||||
end_state.save_to_session(
|
||||
event_stream.sid, event_stream.file_store, event_stream.user_id
|
||||
event_stream.conversation_id, event_stream.file_store, event_stream.user_id
|
||||
)
|
||||
|
||||
await controller.close(set_stop_state=False)
|
||||
@@ -225,7 +225,9 @@ async def run_controller(
|
||||
if config.save_trajectory_path is not None:
|
||||
# if save_trajectory_path is a folder, use session id as file name
|
||||
if os.path.isdir(config.save_trajectory_path):
|
||||
file_path = os.path.join(config.save_trajectory_path, sid + '.json')
|
||||
file_path = os.path.join(
|
||||
config.save_trajectory_path, conversation_id + '.json'
|
||||
)
|
||||
else:
|
||||
file_path = config.save_trajectory_path
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
@@ -299,13 +301,13 @@ if __name__ == '__main__':
|
||||
|
||||
# Set session name
|
||||
session_name = args.name
|
||||
sid = generate_sid(config, session_name)
|
||||
conversation_id = generate_conversation_id(config, session_name)
|
||||
|
||||
asyncio.run(
|
||||
run_controller(
|
||||
config=config,
|
||||
initial_user_action=initial_user_action,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
fake_user_response_fn=None
|
||||
if args.no_auto_continue
|
||||
else auto_continue_response,
|
||||
|
||||
@@ -29,7 +29,7 @@ from openhands.utils.async_utils import GENERAL_TIMEOUT, call_async_from_sync
|
||||
|
||||
def create_runtime(
|
||||
config: OpenHandsConfig,
|
||||
sid: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
headless_mode: bool = True,
|
||||
agent: Agent | None = None,
|
||||
) -> Runtime:
|
||||
@@ -37,7 +37,7 @@ def create_runtime(
|
||||
|
||||
Args:
|
||||
config: The app config.
|
||||
sid: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
conversation_id: (optional) The session id. IMPORTANT: please don't set this unless you know what you're doing.
|
||||
Set it to incompatible value will cause unexpected behavior on RemoteRuntime.
|
||||
headless_mode: Whether the agent is run in headless mode. `create_runtime` is typically called within evaluation scripts,
|
||||
where we don't want to have the VSCode UI open, so it defaults to True.
|
||||
@@ -46,10 +46,10 @@ def create_runtime(
|
||||
Returns:
|
||||
The created Runtime instance (not yet connected or initialized).
|
||||
"""
|
||||
# if sid is provided on the command line, use it as the name of the event stream
|
||||
# if conversation_id is provided on the command line, use it as the name of the event stream
|
||||
# otherwise generate it on the basis of the configured jwt_secret
|
||||
# we can do this better, this is just so that the sid is retrieved when we want to restore the session
|
||||
session_id = sid or generate_sid(config)
|
||||
# we can do this better, this is just so that the conversation_id is retrieved when we want to restore the session
|
||||
session_id = conversation_id or generate_conversation_id(config)
|
||||
|
||||
# set up the event stream
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
@@ -73,7 +73,7 @@ def create_runtime(
|
||||
runtime: Runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=event_stream,
|
||||
sid=session_id,
|
||||
conversation_id=session_id,
|
||||
plugins=agent_cls.sandbox_plugins,
|
||||
headless_mode=headless_mode,
|
||||
)
|
||||
@@ -131,7 +131,7 @@ def initialize_repository_for_runtime(
|
||||
def create_memory(
|
||||
runtime: Runtime,
|
||||
event_stream: EventStream,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
selected_repository: str | None = None,
|
||||
repo_directory: str | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
@@ -142,7 +142,7 @@ def create_memory(
|
||||
Args:
|
||||
runtime: The runtime to use.
|
||||
event_stream: The event stream it will subscribe to.
|
||||
sid: The session id.
|
||||
conversation_id: The session id.
|
||||
selected_repository: The repository to clone and start with, if any.
|
||||
repo_directory: The repository directory, if any.
|
||||
status_callback: Optional callback function to handle status updates.
|
||||
@@ -150,7 +150,7 @@ def create_memory(
|
||||
"""
|
||||
memory = Memory(
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
@@ -196,10 +196,10 @@ def create_controller(
|
||||
initial_state = None
|
||||
try:
|
||||
logger.debug(
|
||||
f'Trying to restore agent state from session {event_stream.sid} if available'
|
||||
f'Trying to restore agent state from session {event_stream.conversation_id} if available'
|
||||
)
|
||||
initial_state = State.restore_from_session(
|
||||
event_stream.sid, event_stream.file_store
|
||||
event_stream.conversation_id, event_stream.file_store
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f'Cannot restore agent state: {e}')
|
||||
@@ -218,8 +218,10 @@ def create_controller(
|
||||
return (controller, initial_state)
|
||||
|
||||
|
||||
def generate_sid(config: OpenHandsConfig, session_name: str | None = None) -> str:
|
||||
"""Generate a session id based on the session name and the jwt secret."""
|
||||
def generate_conversation_id(
|
||||
config: OpenHandsConfig, session_name: str | None = None
|
||||
) -> str:
|
||||
"""Generate a conversation id based on the session name and the jwt secret."""
|
||||
session_name = session_name or str(uuid.uuid4())
|
||||
jwt_secret = config.jwt_secret
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ class EventStore(EventStoreABC):
|
||||
A stored list of events backing a conversation
|
||||
"""
|
||||
|
||||
sid: str
|
||||
conversation_id: str
|
||||
file_store: FileStore
|
||||
user_id: str | None
|
||||
cur_id: int = -1 # We fix this in post init if it is not specified
|
||||
@@ -57,10 +57,12 @@ class EventStore(EventStoreABC):
|
||||
return
|
||||
events = []
|
||||
try:
|
||||
events_dir = get_conversation_events_dir(self.sid, self.user_id)
|
||||
events_dir = get_conversation_events_dir(self.conversation_id, self.user_id)
|
||||
events = self.file_store.list(events_dir)
|
||||
except FileNotFoundError:
|
||||
logger.debug(f'No events found for session {self.sid} at {events_dir}')
|
||||
logger.debug(
|
||||
f'No events found for session {self.conversation_id} at {events_dir}'
|
||||
)
|
||||
|
||||
if not events:
|
||||
self.cur_id = 0
|
||||
@@ -145,10 +147,10 @@ class EventStore(EventStoreABC):
|
||||
yield event
|
||||
|
||||
def _get_filename_for_id(self, id: int, user_id: str | None) -> str:
|
||||
return get_conversation_event_filename(self.sid, id, user_id)
|
||||
return get_conversation_event_filename(self.conversation_id, id, user_id)
|
||||
|
||||
def _get_filename_for_cache(self, start: int, end: int) -> str:
|
||||
return f'{get_conversation_dir(self.sid, self.user_id)}event_cache/{start}-{end}.json'
|
||||
return f'{get_conversation_dir(self.conversation_id, self.user_id)}event_cache/{start}-{end}.json'
|
||||
|
||||
def _load_cache_page(self, start: int, end: int) -> _CachePage:
|
||||
"""Read a page from the cache. Reading individual events is slow when there are a lot of them, so we use pages."""
|
||||
|
||||
@@ -13,7 +13,7 @@ class EventStoreABC:
|
||||
A stored list of events backing a conversation
|
||||
"""
|
||||
|
||||
sid: str
|
||||
conversation_id: str
|
||||
user_id: str | None
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -17,7 +17,7 @@ class NestedEventStore(EventStoreABC):
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
sid: str
|
||||
conversation_id: str
|
||||
user_id: str | None
|
||||
|
||||
def search_events(
|
||||
|
||||
@@ -11,7 +11,9 @@ class MCPObservation(Observation):
|
||||
|
||||
observation: str = ObservationType.MCP
|
||||
name: str = '' # The name of the MCP tool that was called
|
||||
arguments: dict[str, Any] = field(default_factory=dict) # The arguments passed to the MCP tool
|
||||
arguments: dict[str, Any] = field(
|
||||
default_factory=dict
|
||||
) # The arguments passed to the MCP tool
|
||||
|
||||
@property
|
||||
def message(self) -> str:
|
||||
|
||||
@@ -32,10 +32,12 @@ class EventStreamSubscriber(str, Enum):
|
||||
|
||||
|
||||
async def session_exists(
|
||||
sid: str, file_store: FileStore, user_id: str | None = None
|
||||
conversation_id: str, file_store: FileStore, user_id: str | None = None
|
||||
) -> bool:
|
||||
try:
|
||||
await call_sync_from_async(file_store.list, get_conversation_dir(sid, user_id))
|
||||
await call_sync_from_async(
|
||||
file_store.list, get_conversation_dir(conversation_id, user_id)
|
||||
)
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
@@ -54,8 +56,10 @@ class EventStream(EventStore):
|
||||
_thread_loops: dict[str, dict[str, asyncio.AbstractEventLoop]]
|
||||
_write_page_cache: list[dict]
|
||||
|
||||
def __init__(self, sid: str, file_store: FileStore, user_id: str | None = None):
|
||||
super().__init__(sid, file_store, user_id)
|
||||
def __init__(
|
||||
self, conversation_id: str, file_store: FileStore, user_id: str | None = None
|
||||
):
|
||||
super().__init__(conversation_id, file_store, user_id)
|
||||
self._stop_flag = threading.Event()
|
||||
self._queue: queue.Queue[Event] = queue.Queue()
|
||||
self._thread_pools = {}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{ issue_comment }}
|
||||
{{ issue_comment }}
|
||||
|
||||
@@ -1 +1 @@
|
||||
Please fix issue number #{{ issue_number }} in your repository.
|
||||
Please fix issue number #{{ issue_number }} in your repository.
|
||||
|
||||
@@ -1 +1 @@
|
||||
{{ pr_comment }}
|
||||
{{ pr_comment }}
|
||||
|
||||
@@ -40,7 +40,7 @@ class Memory:
|
||||
(a RecallAction) and publishes observations with the content (such as RecallObservation).
|
||||
"""
|
||||
|
||||
sid: str
|
||||
conversation_id: str
|
||||
event_stream: EventStream
|
||||
status_callback: Callable | None
|
||||
loop: asyncio.AbstractEventLoop | None
|
||||
@@ -48,18 +48,18 @@ class Memory:
|
||||
def __init__(
|
||||
self,
|
||||
event_stream: EventStream,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
status_callback: Callable | None = None,
|
||||
):
|
||||
self.event_stream = event_stream
|
||||
self.sid = sid if sid else str(uuid.uuid4())
|
||||
self.conversation_id = conversation_id if conversation_id else str(uuid.uuid4())
|
||||
self.status_callback = status_callback
|
||||
self.loop = None
|
||||
|
||||
self.event_stream.subscribe(
|
||||
EventStreamSubscriber.MEMORY,
|
||||
self.on_event,
|
||||
self.sid,
|
||||
self.conversation_id,
|
||||
)
|
||||
|
||||
# Additional placeholders to store user workspace microagents
|
||||
|
||||
@@ -4,4 +4,4 @@ You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instructi
|
||||
Some basic information about this repository:
|
||||
{{ repo_instruction }}{% endif %}
|
||||
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
|
||||
@@ -13,4 +13,4 @@ You SHOULD INCLUDE PROPER INDENTATION in your edit commands.{% if repo_instructi
|
||||
Some basic information about this repository:
|
||||
{{ repo_instruction }}{% endif %}
|
||||
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
When you think you have fixed the issue through code changes, please finish the interaction.
|
||||
|
||||
@@ -2,4 +2,4 @@ Please fix the following issue for the repository in /workspace.
|
||||
An environment has been set up for you to start working. You may assume all necessary tools are installed.
|
||||
|
||||
# Problem Statement
|
||||
{{ body }}
|
||||
{{ body }}
|
||||
|
||||
@@ -2,4 +2,4 @@ Please fix the following issue for the repository in /workspace.
|
||||
An environment has been set up for you to start working. You may assume all necessary tools are installed.
|
||||
|
||||
# Problem Statement
|
||||
{{ body }}
|
||||
{{ body }}
|
||||
|
||||
@@ -93,10 +93,10 @@ class Runtime(FileEditRuntimeMixin):
|
||||
"""The runtime is how the agent interacts with the external environment.
|
||||
This includes a bash sandbox, a browser, and filesystem interactions.
|
||||
|
||||
sid is the session id, which is used to identify the current user session.
|
||||
conversation_id is the session id, which is used to identify the current user session.
|
||||
"""
|
||||
|
||||
sid: str
|
||||
conversation_id: str
|
||||
config: OpenHandsConfig
|
||||
initial_env_vars: dict[str, str]
|
||||
attach_to_existing: bool
|
||||
@@ -107,7 +107,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable[[str, str, str], None] | None = None,
|
||||
@@ -119,11 +119,11 @@ class Runtime(FileEditRuntimeMixin):
|
||||
self.git_handler = GitHandler(
|
||||
execute_shell_fn=self._execute_shell_fn_git_handler
|
||||
)
|
||||
self.sid = sid
|
||||
self.conversation_id = conversation_id
|
||||
self.event_stream = event_stream
|
||||
if event_stream:
|
||||
event_stream.subscribe(
|
||||
EventStreamSubscriber.RUNTIME, self.on_event, self.sid
|
||||
EventStreamSubscriber.RUNTIME, self.on_event, self.conversation_id
|
||||
)
|
||||
self.plugins = (
|
||||
copy.deepcopy(plugins) if plugins is not None and len(plugins) > 0 else []
|
||||
@@ -189,7 +189,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
pass
|
||||
|
||||
def log(self, level: str, message: str) -> None:
|
||||
message = f'[runtime {self.sid}] {message}'
|
||||
message = f'[runtime {self.conversation_id}] {message}'
|
||||
getattr(logger, level)(message, stacklevel=2)
|
||||
|
||||
def send_status_message(self, message_id: str):
|
||||
@@ -266,7 +266,9 @@ class Runtime(FileEditRuntimeMixin):
|
||||
if not providers_called:
|
||||
return
|
||||
|
||||
logger.info(f'Fetching latest provider tokens for runtime: {self.sid}')
|
||||
logger.info(
|
||||
f'Fetching latest provider tokens for runtime: {self.conversation_id}'
|
||||
)
|
||||
env_vars = await self.provider_handler.get_env_vars(
|
||||
providers=providers_called, expose_secrets=False, get_latest=True
|
||||
)
|
||||
@@ -282,7 +284,7 @@ class Runtime(FileEditRuntimeMixin):
|
||||
self.add_env_vars(self.provider_handler.expose_env_vars(env_vars))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f'Failed export latest github token to runtime: {self.sid}, {e}'
|
||||
f'Failed export latest github token to runtime: {self.conversation_id}, {e}'
|
||||
)
|
||||
|
||||
async def _handle_action(self, event: Action) -> None:
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import io
|
||||
import base64
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def image_to_png_base64_url(
|
||||
image: np.ndarray | Image.Image, add_data_prefix: bool = False
|
||||
@@ -21,6 +23,7 @@ def image_to_png_base64_url(
|
||||
else f'{image_base64}'
|
||||
)
|
||||
|
||||
|
||||
def png_base64_url_to_image(png_base64_url: str) -> Image.Image:
|
||||
"""Convert a base64 encoded png image url to a PIL Image."""
|
||||
splited = png_base64_url.split(',')
|
||||
|
||||
@@ -12,13 +12,14 @@ from browsergym.utils.obs import flatten_dom_to_str, overlay_som
|
||||
|
||||
from openhands.core.exceptions import BrowserInitException
|
||||
from openhands.core.logger import openhands_logger as logger
|
||||
from openhands.runtime.browser.base64 import image_to_png_base64_url
|
||||
from openhands.utils.shutdown_listener import should_continue, should_exit
|
||||
from openhands.utils.tenacity_stop import stop_if_should_exit
|
||||
from openhands.runtime.browser.base64 import image_to_png_base64_url
|
||||
|
||||
BROWSER_EVAL_GET_GOAL_ACTION = 'GET_EVAL_GOAL'
|
||||
BROWSER_EVAL_GET_REWARDS_ACTION = 'GET_EVAL_REWARDS'
|
||||
|
||||
|
||||
class BrowserEnv:
|
||||
def __init__(self, browsergym_eval_env: str | None = None):
|
||||
self.html_text_converter = self.get_html_text_converter()
|
||||
|
||||
@@ -67,7 +67,7 @@ class ActionExecutionClient(Runtime):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Any | None = None,
|
||||
@@ -84,7 +84,7 @@ class ActionExecutionClient(Runtime):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
@@ -448,7 +448,9 @@ class ActionExecutionClient(Runtime):
|
||||
)
|
||||
|
||||
# Create clients for this specific operation
|
||||
mcp_clients = await create_mcp_clients(updated_mcp_config.sse_servers, self.sid)
|
||||
mcp_clients = await create_mcp_clients(
|
||||
updated_mcp_config.sse_servers, self.conversation_id
|
||||
)
|
||||
|
||||
# Call the tool and return the result
|
||||
# No need for try/finally since disconnect() is now just resetting state
|
||||
|
||||
@@ -59,7 +59,7 @@ class CLIRuntime(Runtime):
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
conversation_id (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
status_callback (Callable | None, optional): Callback for status updates. Defaults to None.
|
||||
@@ -73,7 +73,7 @@ class CLIRuntime(Runtime):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable[[str, str, str], None] | None = None,
|
||||
@@ -85,7 +85,7 @@ class CLIRuntime(Runtime):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
@@ -106,7 +106,7 @@ class CLIRuntime(Runtime):
|
||||
else:
|
||||
# Create a temporary directory for the workspace
|
||||
self._workspace_path = tempfile.mkdtemp(
|
||||
prefix=f'openhands_workspace_{sid}_'
|
||||
prefix=f'openhands_workspace_{conversation_id}_'
|
||||
)
|
||||
logger.info(f'Created temporary workspace at {self._workspace_path}')
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class DaytonaRuntime(ActionExecutionClient):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
@@ -45,8 +45,8 @@ class DaytonaRuntime(ActionExecutionClient):
|
||||
assert config.daytona_api_key, 'Daytona API key is required'
|
||||
|
||||
self.config = config
|
||||
self.sid = sid
|
||||
self.workspace_id = WORKSPACE_PREFIX + sid
|
||||
self.conversation_id = conversation_id
|
||||
self.workspace_id = WORKSPACE_PREFIX + conversation_id
|
||||
self.workspace: Workspace | None = None
|
||||
self._vscode_url: str | None = None
|
||||
|
||||
@@ -67,7 +67,7 @@ class DaytonaRuntime(ActionExecutionClient):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
|
||||
@@ -67,7 +67,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
conversation_id (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
@@ -78,7 +78,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
@@ -112,7 +112,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_name = CONTAINER_NAME_PREFIX + conversation_id
|
||||
self.container: Container | None = None
|
||||
self.main_module = main_module
|
||||
|
||||
@@ -124,7 +124,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
@@ -132,7 +132,7 @@ class DockerRuntime(ActionExecutionClient):
|
||||
headless_mode,
|
||||
)
|
||||
|
||||
# Log runtime_extra_deps after base class initialization so self.sid is available
|
||||
# Log runtime_extra_deps after base class initialization so self.conversation_id is available
|
||||
if self.config.sandbox.runtime_extra_deps:
|
||||
self.log(
|
||||
'debug',
|
||||
|
||||
@@ -24,7 +24,7 @@ class E2BRuntime(Runtime):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
sandbox: E2BSandbox | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
@@ -32,7 +32,7 @@ class E2BRuntime(Runtime):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
status_callback=status_callback,
|
||||
)
|
||||
|
||||
@@ -109,7 +109,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
conversation_id (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): list of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
@@ -118,7 +118,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable[[str, str, str], None] | None = None,
|
||||
@@ -147,7 +147,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
# A temporary directory is created for the agent to run in
|
||||
# This is used for the local runtime only
|
||||
self._temp_workspace = tempfile.mkdtemp(
|
||||
prefix=f'openhands_workspace_{sid}',
|
||||
prefix=f'openhands_workspace_{conversation_id}',
|
||||
)
|
||||
self.config.workspace_mount_path_in_sandbox = self._temp_workspace
|
||||
|
||||
@@ -192,7 +192,7 @@ class LocalRuntime(ActionExecutionClient):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
|
||||
@@ -33,20 +33,20 @@ class ModalRuntime(ActionExecutionClient):
|
||||
Args:
|
||||
config (OpenHandsConfig): The application configuration.
|
||||
event_stream (EventStream): The event stream to subscribe to.
|
||||
sid (str, optional): The session ID. Defaults to 'default'.
|
||||
conversation_id (str, optional): The session ID. Defaults to 'default'.
|
||||
plugins (list[PluginRequirement] | None, optional): List of plugin requirements. Defaults to None.
|
||||
env_vars (dict[str, str] | None, optional): Environment variables to set. Defaults to None.
|
||||
"""
|
||||
|
||||
container_name_prefix = 'openhands-sandbox-'
|
||||
sandbox: modal.Sandbox | None
|
||||
sid: str
|
||||
conversation_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
@@ -58,7 +58,7 @@ class ModalRuntime(ActionExecutionClient):
|
||||
|
||||
self.config = config
|
||||
self.sandbox = None
|
||||
self.sid = sid
|
||||
self.conversation_id = conversation_id
|
||||
|
||||
self.modal_client = modal.Client.from_credentials(
|
||||
config.modal_api_token_id.get_secret_value(),
|
||||
@@ -93,7 +93,7 @@ class ModalRuntime(ActionExecutionClient):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
@@ -104,7 +104,7 @@ class ModalRuntime(ActionExecutionClient):
|
||||
async def connect(self):
|
||||
self.send_status_message('STATUS$STARTING_RUNTIME')
|
||||
|
||||
self.log('debug', f'ModalRuntime `{self.sid}`')
|
||||
self.log('debug', f'ModalRuntime `{self.conversation_id}`')
|
||||
|
||||
self.image = self._get_image_definition(
|
||||
self.base_container_image_id,
|
||||
@@ -113,8 +113,8 @@ class ModalRuntime(ActionExecutionClient):
|
||||
)
|
||||
|
||||
if self.attach_to_existing:
|
||||
if self.sid in MODAL_RUNTIME_IDS:
|
||||
sandbox_id = MODAL_RUNTIME_IDS[self.sid]
|
||||
if self.conversation_id in MODAL_RUNTIME_IDS:
|
||||
sandbox_id = MODAL_RUNTIME_IDS[self.conversation_id]
|
||||
self.log('debug', f'Attaching to existing Modal sandbox: {sandbox_id}')
|
||||
self.sandbox = modal.Sandbox.from_id(
|
||||
sandbox_id, client=self.modal_client
|
||||
@@ -236,12 +236,13 @@ echo 'export INPUTRC=/etc/inputrc' >> /etc/bash.bashrc
|
||||
client=self.modal_client,
|
||||
timeout=60 * 60,
|
||||
)
|
||||
MODAL_RUNTIME_IDS[self.sid] = self.sandbox.object_id
|
||||
MODAL_RUNTIME_IDS[self.conversation_id] = self.sandbox.object_id
|
||||
self.log('debug', 'Container started')
|
||||
|
||||
except Exception as e:
|
||||
self.log(
|
||||
'error', f'Error: Instance {self.sid} FAILED to start container!\n'
|
||||
'error',
|
||||
f'Error: Instance {self.conversation_id} FAILED to start container!\n',
|
||||
)
|
||||
self.log('error', str(e))
|
||||
self.close()
|
||||
|
||||
@@ -50,7 +50,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable[..., None] | None = None,
|
||||
@@ -63,7 +63,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
@@ -100,7 +100,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
self.available_hosts: dict[str, int] = {}
|
||||
|
||||
def log(self, level: str, message: str, exc_info: bool | None = None) -> None:
|
||||
message = f'[runtime session_id={self.sid} runtime_id={self.runtime_id or "unknown"}] {message}'
|
||||
message = f'[runtime session_id={self.conversation_id} runtime_id={self.runtime_id or "unknown"}] {message}'
|
||||
getattr(logger, level)(message, stacklevel=2, exc_info=exc_info)
|
||||
|
||||
@property
|
||||
@@ -125,7 +125,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
self.log('debug', f'Using existing runtime with ID: {self.runtime_id}')
|
||||
elif self.attach_to_existing:
|
||||
raise AgentRuntimeNotFoundError(
|
||||
f'Could not find existing runtime for SID: {self.sid}'
|
||||
f'Could not find existing runtime for SID: {self.conversation_id}'
|
||||
)
|
||||
else:
|
||||
self.send_status_message('STATUS$STARTING_CONTAINER')
|
||||
@@ -160,7 +160,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
try:
|
||||
response = self._send_runtime_api_request(
|
||||
'GET',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}',
|
||||
f'{self.config.sandbox.remote_runtime_api_url}/sessions/{self.conversation_id}',
|
||||
)
|
||||
data = response.json()
|
||||
status = data.get('status')
|
||||
@@ -174,7 +174,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
except json.decoder.JSONDecodeError as e:
|
||||
self.log(
|
||||
'error',
|
||||
f'Invalid JSON response from runtime API: {e}. URL: {self.config.sandbox.remote_runtime_api_url}/sessions/{self.sid}. Response: {response}',
|
||||
f'Invalid JSON response from runtime API: {e}. URL: {self.config.sandbox.remote_runtime_api_url}/sessions/{self.conversation_id}. Response: {response}',
|
||||
)
|
||||
raise
|
||||
|
||||
@@ -247,7 +247,7 @@ class RemoteRuntime(ActionExecutionClient):
|
||||
'command': command,
|
||||
'working_dir': '/openhands/code/',
|
||||
'environment': environment,
|
||||
'session_id': self.sid,
|
||||
'session_id': self.conversation_id,
|
||||
'resource_factor': self.config.sandbox.remote_runtime_resource_factor,
|
||||
}
|
||||
if self.config.sandbox.remote_runtime_class == 'sysbox':
|
||||
|
||||
@@ -29,7 +29,7 @@ class RunloopRuntime(ActionExecutionClient):
|
||||
self,
|
||||
config: OpenHandsConfig,
|
||||
event_stream: EventStream,
|
||||
sid: str = 'default',
|
||||
conversation_id: str = 'default',
|
||||
plugins: list[PluginRequirement] | None = None,
|
||||
env_vars: dict[str, str] | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
@@ -42,11 +42,11 @@ class RunloopRuntime(ActionExecutionClient):
|
||||
self.runloop_api_client = Runloop(
|
||||
bearer_token=config.runloop_api_key.get_secret_value(),
|
||||
)
|
||||
self.container_name = CONTAINER_NAME_PREFIX + sid
|
||||
self.container_name = CONTAINER_NAME_PREFIX + conversation_id
|
||||
super().__init__(
|
||||
config,
|
||||
event_stream,
|
||||
sid,
|
||||
conversation_id,
|
||||
plugins,
|
||||
env_vars,
|
||||
status_callback,
|
||||
@@ -99,7 +99,7 @@ class RunloopRuntime(ActionExecutionClient):
|
||||
|
||||
devbox = self.runloop_api_client.devboxes.create(
|
||||
entrypoint=entrypoint,
|
||||
name=self.sid,
|
||||
name=self.conversation_id,
|
||||
environment_variables={'DEBUG': 'true'} if self.config.debug else {},
|
||||
prebuilt='openhands',
|
||||
launch_parameters=LaunchParameters(
|
||||
@@ -121,7 +121,12 @@ class RunloopRuntime(ActionExecutionClient):
|
||||
status='running'
|
||||
).devboxes
|
||||
self.devbox = next(
|
||||
(devbox for devbox in active_devboxes if devbox.name == self.sid), None
|
||||
(
|
||||
devbox
|
||||
for devbox in active_devboxes
|
||||
if devbox.name == self.conversation_id
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
if self.devbox is None:
|
||||
|
||||
@@ -6,22 +6,22 @@ from contextlib import asynccontextmanager
|
||||
@asynccontextmanager
|
||||
async def capture_logs(logger_name, level=logging.ERROR):
|
||||
logger = logging.getLogger(logger_name)
|
||||
|
||||
|
||||
# Store original handlers and level
|
||||
original_handlers = logger.handlers[:]
|
||||
original_level = logger.level
|
||||
|
||||
|
||||
# Set up capture
|
||||
log_capture = io.StringIO()
|
||||
handler = logging.StreamHandler(log_capture)
|
||||
handler.setLevel(level)
|
||||
|
||||
|
||||
logger.handlers = [handler]
|
||||
logger.setLevel(level)
|
||||
|
||||
|
||||
try:
|
||||
yield log_capture
|
||||
finally:
|
||||
# Restore original configuration
|
||||
logger.handlers = original_handlers
|
||||
logger.setLevel(original_level)
|
||||
logger.setLevel(original_level)
|
||||
|
||||
@@ -46,15 +46,15 @@ class InvariantAnalyzer(SecurityAnalyzer):
|
||||
self,
|
||||
event_stream: EventStream,
|
||||
policy: str | None = None,
|
||||
sid: str | None = None,
|
||||
conversation_id: str | None = None,
|
||||
) -> None:
|
||||
"""Initializes a new instance of the InvariantAnalzyer class."""
|
||||
super().__init__(event_stream)
|
||||
self.trace = []
|
||||
self.input = []
|
||||
self.settings = {}
|
||||
if sid is None:
|
||||
self.sid = str(uuid.uuid4())
|
||||
if conversation_id is None:
|
||||
self.conversation_id = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
self.docker_client = docker.from_env()
|
||||
@@ -101,7 +101,7 @@ class InvariantAnalyzer(SecurityAnalyzer):
|
||||
)
|
||||
|
||||
self.api_server = f'{self.api_host}:{self.api_port}'
|
||||
self.client = InvariantClient(self.api_server, self.sid)
|
||||
self.client = InvariantClient(self.api_server, self.conversation_id)
|
||||
if policy is None:
|
||||
policy, _ = self.client.Policy.get_template()
|
||||
if policy is None:
|
||||
|
||||
@@ -22,7 +22,7 @@ class ServerConfig(ServerConfigInterface):
|
||||
'openhands.storage.conversation.file_conversation_store.FileConversationStore'
|
||||
)
|
||||
conversation_manager_class: str = os.environ.get(
|
||||
"CONVERSATION_MANAGER_CLASS",
|
||||
'CONVERSATION_MANAGER_CLASS',
|
||||
'openhands.server.conversation_manager.standalone_conversation_manager.StandaloneConversationManager',
|
||||
)
|
||||
monitoring_listener_class: str = 'openhands.server.monitoring.MonitoringListener'
|
||||
|
||||
@@ -38,7 +38,7 @@ class ConversationManager(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def attach_to_conversation(
|
||||
self, sid: str, user_id: str | None = None
|
||||
self, conversation_id: str, user_id: str | None = None
|
||||
) -> ServerConversation | None:
|
||||
"""Attach to an existing conversation or create a new one."""
|
||||
|
||||
@@ -49,16 +49,16 @@ class ConversationManager(ABC):
|
||||
@abstractmethod
|
||||
async def join_conversation(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
) -> AgentLoopInfo | None:
|
||||
"""Join a conversation and return its event stream."""
|
||||
|
||||
async def is_agent_loop_running(self, sid: str) -> bool:
|
||||
async def is_agent_loop_running(self, conversation_id: 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})
|
||||
sids = await self.get_running_agent_loops(filter_to_sids={conversation_id})
|
||||
return bool(sids)
|
||||
|
||||
@abstractmethod
|
||||
@@ -76,7 +76,7 @@ class ConversationManager(ABC):
|
||||
@abstractmethod
|
||||
async def maybe_start_agent_loop(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
@@ -93,7 +93,7 @@ class ConversationManager(ABC):
|
||||
"""Disconnect from a session."""
|
||||
|
||||
@abstractmethod
|
||||
async def close_session(self, sid: str):
|
||||
async def close_session(self, conversation_id: str):
|
||||
"""Close a session."""
|
||||
|
||||
@abstractmethod
|
||||
|
||||
@@ -64,7 +64,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
pass
|
||||
|
||||
async def attach_to_conversation(
|
||||
self, sid: str, user_id: str | None = None
|
||||
self, conversation_id: str, user_id: str | None = None
|
||||
) -> ServerConversation | None:
|
||||
# Not supported - clients should connect directly to the nested server!
|
||||
raise ValueError('unsupported_operation')
|
||||
@@ -75,7 +75,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
|
||||
async def join_conversation(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
@@ -112,44 +112,47 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
|
||||
async def maybe_start_agent_loop(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
if not await self.is_agent_loop_running(sid):
|
||||
if not await self.is_agent_loop_running(conversation_id):
|
||||
await self._start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
conversation_id, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
|
||||
nested_url = self._get_nested_url(sid)
|
||||
nested_url = self._get_nested_url(conversation_id)
|
||||
return AgentLoopInfo(
|
||||
conversation_id=sid,
|
||||
conversation_id=conversation_id,
|
||||
url=nested_url,
|
||||
session_api_key=self._get_session_api_key_for_conversation(sid),
|
||||
session_api_key=self._get_session_api_key_for_conversation(conversation_id),
|
||||
event_store=NestedEventStore(
|
||||
base_url=nested_url,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
),
|
||||
status=ConversationStatus.STARTING
|
||||
if sid in self._starting_conversation_ids
|
||||
if conversation_id in self._starting_conversation_ids
|
||||
else ConversationStatus.RUNNING,
|
||||
)
|
||||
|
||||
async def _start_agent_loop(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None,
|
||||
replay_json: str | None,
|
||||
):
|
||||
logger.info(f'starting_agent_loop:{sid}', extra={'session_id': sid})
|
||||
await self.ensure_num_conversations_below_limit(sid, user_id)
|
||||
runtime = await self._create_runtime(sid, user_id, settings)
|
||||
self._starting_conversation_ids.add(sid)
|
||||
logger.info(
|
||||
f'starting_agent_loop:{conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
await self.ensure_num_conversations_below_limit(conversation_id, user_id)
|
||||
runtime = await self._create_runtime(conversation_id, user_id, settings)
|
||||
self._starting_conversation_ids.add(conversation_id)
|
||||
try:
|
||||
# Build the runtime container image if it is missing
|
||||
await call_sync_from_async(runtime.maybe_build_runtime_container_image)
|
||||
@@ -160,7 +163,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
# Start the conversation in a background task.
|
||||
asyncio.create_task(
|
||||
self._start_conversation(
|
||||
sid,
|
||||
conversation_id,
|
||||
settings,
|
||||
runtime,
|
||||
initial_user_msg,
|
||||
@@ -170,12 +173,12 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
|
||||
except Exception:
|
||||
self._starting_conversation_ids.remove(sid)
|
||||
self._starting_conversation_ids.remove(conversation_id)
|
||||
raise
|
||||
|
||||
async def _start_conversation(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
settings: Settings,
|
||||
runtime: DockerRuntime,
|
||||
initial_user_msg: MessageAction | None,
|
||||
@@ -187,7 +190,9 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
await call_sync_from_async(runtime.setup_initial_env)
|
||||
async with httpx.AsyncClient(
|
||||
headers={
|
||||
'X-Session-API-Key': self._get_session_api_key_for_conversation(sid)
|
||||
'X-Session-API-Key': self._get_session_api_key_for_conversation(
|
||||
conversation_id
|
||||
)
|
||||
}
|
||||
) as client:
|
||||
# setup the settings...
|
||||
@@ -239,7 +244,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
'initial_user_msg': initial_user_msg,
|
||||
'image_urls': [],
|
||||
'replay_json': replay_json,
|
||||
'conversation_id': sid,
|
||||
'conversation_id': conversation_id,
|
||||
}
|
||||
|
||||
if isinstance(settings, ConversationInitData):
|
||||
@@ -258,7 +263,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
finally:
|
||||
self._starting_conversation_ids.remove(sid)
|
||||
self._starting_conversation_ids.remove(conversation_id)
|
||||
|
||||
async def send_to_event_stream(self, connection_id: str, data: dict):
|
||||
# Not supported - clients should connect directly to the nested server!
|
||||
@@ -268,8 +273,8 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
# Not supported - clients should connect directly to the nested server!
|
||||
raise ValueError('unsupported_operation')
|
||||
|
||||
async def close_session(self, sid: str):
|
||||
stop_all_containers(f'openhands-runtime-{sid}')
|
||||
async def close_session(self, conversation_id: str):
|
||||
stop_all_containers(f'openhands-runtime-{conversation_id}')
|
||||
|
||||
async def get_agent_loop_info(self, user_id=None, filter_to_sids=None):
|
||||
results = []
|
||||
@@ -295,7 +300,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
),
|
||||
event_store=NestedEventStore(
|
||||
base_url=nested_url,
|
||||
sid=conversation_id,
|
||||
conversation_id=conversation_id,
|
||||
user_id=user_id,
|
||||
),
|
||||
status=ConversationStatus.STARTING
|
||||
@@ -331,8 +336,10 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
store = await conversation_store_class.get_instance(self.config, user_id)
|
||||
return store
|
||||
|
||||
def _get_nested_url(self, sid: str) -> str:
|
||||
container = self.docker_client.containers.get(f'openhands-runtime-{sid}')
|
||||
def _get_nested_url(self, conversation_id: str) -> str:
|
||||
container = self.docker_client.containers.get(
|
||||
f'openhands-runtime-{conversation_id}'
|
||||
)
|
||||
return self.get_nested_url_for_container(container)
|
||||
|
||||
def get_nested_url_for_container(self, container: Container) -> str:
|
||||
@@ -352,12 +359,14 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
return session_api_key
|
||||
|
||||
async def ensure_num_conversations_below_limit(self, sid: str, user_id: str | None):
|
||||
async def ensure_num_conversations_below_limit(
|
||||
self, conversation_id: str, user_id: str | None
|
||||
):
|
||||
response_ids = await self.get_running_agent_loops(user_id)
|
||||
if len(response_ids) >= self.config.max_concurrent_conversations:
|
||||
logger.info(
|
||||
f'too_many_sessions_for:{user_id or ""}',
|
||||
extra={'session_id': sid, 'user_id': user_id},
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
# Get the conversations sorted (oldest first)
|
||||
conversation_store = await self._get_conversation_store(user_id)
|
||||
@@ -380,7 +389,7 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
await self.sio.emit(
|
||||
'oh_event',
|
||||
status_update_dict,
|
||||
to=ROOM_KEY.format(sid=oldest_conversation_id),
|
||||
to=ROOM_KEY.format(conversation_id=oldest_conversation_id),
|
||||
)
|
||||
await self.close_session(oldest_conversation_id)
|
||||
|
||||
@@ -394,11 +403,13 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
)
|
||||
return provider_handler
|
||||
|
||||
async def _create_runtime(self, sid: str, user_id: str | None, settings: Settings):
|
||||
async def _create_runtime(
|
||||
self, conversation_id: str, user_id: str | None, settings: Settings
|
||||
):
|
||||
# This session is created here only because it is the easiest way to get a runtime, which
|
||||
# is the easiest way to create the needed docker container
|
||||
session = Session(
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
file_store=self.file_store,
|
||||
config=self.config,
|
||||
sio=self.sio,
|
||||
@@ -422,7 +433,9 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
env_vars['SERVE_FRONTEND'] = '0'
|
||||
env_vars['RUNTIME'] = 'local'
|
||||
env_vars['USER'] = 'CURRENT_USER'
|
||||
env_vars['SESSION_API_KEY'] = self._get_session_api_key_for_conversation(sid)
|
||||
env_vars['SESSION_API_KEY'] = self._get_session_api_key_for_conversation(
|
||||
conversation_id
|
||||
)
|
||||
|
||||
# Set up mounted volume for conversation directory within workspace
|
||||
# TODO: Check if we are using the standard event store and file store
|
||||
@@ -431,19 +444,19 @@ class DockerNestedConversationManager(ConversationManager):
|
||||
volumes = []
|
||||
else:
|
||||
volumes = [v.strip() for v in config.sandbox.volumes.split(',')]
|
||||
conversation_dir = get_conversation_dir(sid, user_id)
|
||||
conversation_dir = get_conversation_dir(conversation_id, user_id)
|
||||
volumes.append(
|
||||
f'{config.file_store_path}/{conversation_dir}:{OpenHandsConfig.model_fields["file_store_path"].default}/{conversation_dir}:rw'
|
||||
)
|
||||
config.sandbox.volumes = ','.join(volumes)
|
||||
|
||||
# Currently this eventstream is never used and only exists because one is required in order to create a docker runtime
|
||||
event_stream = EventStream(sid, self.file_store, user_id)
|
||||
event_stream = EventStream(conversation_id, self.file_store, user_id)
|
||||
|
||||
runtime = DockerRuntime(
|
||||
config=config,
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
plugins=agent.sandbox_plugins,
|
||||
headless_mode=False,
|
||||
attach_to_existing=False,
|
||||
|
||||
@@ -46,7 +46,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
server_config: ServerConfig
|
||||
# Defaulting monitoring_listener for temp backward compatibility.
|
||||
monitoring_listener: MonitoringListener = MonitoringListener()
|
||||
_local_agent_loops_by_sid: dict[str, Session] = field(default_factory=dict)
|
||||
_local_agent_loops_by_conversation_id: 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[ServerConversation, int]] = field(
|
||||
default_factory=dict
|
||||
@@ -68,78 +70,90 @@ class StandaloneConversationManager(ConversationManager):
|
||||
self._cleanup_task = None
|
||||
|
||||
async def attach_to_conversation(
|
||||
self, sid: str, user_id: str | None = None
|
||||
self, conversation_id: str, user_id: str | None = None
|
||||
) -> ServerConversation | None:
|
||||
start_time = time.time()
|
||||
if not await session_exists(sid, self.file_store, user_id=user_id):
|
||||
if not await session_exists(conversation_id, self.file_store, user_id=user_id):
|
||||
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)
|
||||
if conversation_id in self._active_conversations:
|
||||
conversation, count = self._active_conversations[conversation_id]
|
||||
self._active_conversations[conversation_id] = (conversation, count + 1)
|
||||
logger.info(
|
||||
f'Reusing active conversation {sid}', extra={'session_id': sid}
|
||||
f'Reusing active conversation {conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
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)
|
||||
if conversation_id in self._detached_conversations:
|
||||
conversation, _ = self._detached_conversations.pop(conversation_id)
|
||||
self._active_conversations[conversation_id] = (conversation, 1)
|
||||
logger.info(
|
||||
f'Reusing detached conversation {sid}', extra={'session_id': sid}
|
||||
f'Reusing detached conversation {conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
return conversation
|
||||
|
||||
# Create new conversation if none exists
|
||||
c = ServerConversation(
|
||||
sid, file_store=self.file_store, config=self.config, user_id=user_id
|
||||
conversation_id,
|
||||
file_store=self.file_store,
|
||||
config=self.config,
|
||||
user_id=user_id,
|
||||
)
|
||||
try:
|
||||
await c.connect()
|
||||
except AgentRuntimeUnavailableError as e:
|
||||
logger.error(
|
||||
f'Error connecting to conversation {c.sid}: {e}',
|
||||
extra={'session_id': sid},
|
||||
f'Error connecting to conversation {c.conversation_id}: {e}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
await c.disconnect()
|
||||
return None
|
||||
end_time = time.time()
|
||||
logger.info(
|
||||
f'ServerConversation {c.sid} connected in {end_time - start_time} seconds'
|
||||
f'ServerConversation {c.conversation_id} connected in {end_time - start_time} seconds'
|
||||
)
|
||||
self._active_conversations[sid] = (c, 1)
|
||||
self._active_conversations[conversation_id] = (c, 1)
|
||||
return c
|
||||
|
||||
async def join_conversation(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
connection_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
) -> AgentLoopInfo:
|
||||
logger.info(
|
||||
f'join_conversation:{sid}:{connection_id}',
|
||||
extra={'session_id': sid, 'user_id': user_id},
|
||||
f'join_conversation:{conversation_id}:{connection_id}',
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
await self.sio.enter_room(
|
||||
connection_id, ROOM_KEY.format(conversation_id=conversation_id)
|
||||
)
|
||||
self._local_connection_id_to_session_id[connection_id] = conversation_id
|
||||
agent_loop_info = await self.maybe_start_agent_loop(
|
||||
conversation_id, settings, user_id
|
||||
)
|
||||
await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
|
||||
self._local_connection_id_to_session_id[connection_id] = sid
|
||||
agent_loop_info = await self.maybe_start_agent_loop(sid, settings, user_id)
|
||||
return agent_loop_info
|
||||
|
||||
async def detach_from_conversation(self, conversation: ServerConversation):
|
||||
sid = conversation.sid
|
||||
conversation_id = conversation.conversation_id
|
||||
async with self._conversations_lock:
|
||||
if sid in self._active_conversations:
|
||||
conv, count = self._active_conversations[sid]
|
||||
if conversation_id in self._active_conversations:
|
||||
conv, count = self._active_conversations[conversation_id]
|
||||
if count > 1:
|
||||
self._active_conversations[sid] = (conv, count - 1)
|
||||
self._active_conversations[conversation_id] = (conv, count - 1)
|
||||
return
|
||||
else:
|
||||
self._active_conversations.pop(sid)
|
||||
self._detached_conversations[sid] = (conversation, time.time())
|
||||
self._active_conversations.pop(conversation_id)
|
||||
self._detached_conversations[conversation_id] = (
|
||||
conversation,
|
||||
time.time(),
|
||||
)
|
||||
|
||||
async def _cleanup_stale(self):
|
||||
while should_continue():
|
||||
@@ -147,31 +161,38 @@ class StandaloneConversationManager(ConversationManager):
|
||||
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:
|
||||
for conversation_id, (conversation, detach_time) in items:
|
||||
await conversation.disconnect()
|
||||
self._detached_conversations.pop(sid, None)
|
||||
self._detached_conversations.pop(conversation_id, None)
|
||||
|
||||
close_threshold = time.time() - self.config.sandbox.close_delay
|
||||
running_loops = list(self._local_agent_loops_by_sid.items())
|
||||
running_loops = list(self._local_agent_loops_by_conversation_id.items())
|
||||
running_loops.sort(key=lambda item: item[1].last_active_ts)
|
||||
sid_to_close: list[str] = []
|
||||
for sid, session in running_loops:
|
||||
for conversation_id, 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)
|
||||
sid_to_close.append(conversation_id)
|
||||
|
||||
connections = await self.get_connections(
|
||||
filter_to_sids=set(sid_to_close) # get_connections expects a set
|
||||
)
|
||||
connected_sids = {sid for _, sid in connections.items()}
|
||||
connected_sids = {
|
||||
conversation_id for _, conversation_id in connections.items()
|
||||
}
|
||||
sid_to_close = [
|
||||
sid for sid in sid_to_close if sid not in connected_sids
|
||||
conversation_id
|
||||
for conversation_id in sid_to_close
|
||||
if conversation_id not in connected_sids
|
||||
]
|
||||
await wait_all(
|
||||
(self._close_session(sid) for sid in sid_to_close),
|
||||
(
|
||||
self._close_session(conversation_id)
|
||||
for conversation_id in sid_to_close
|
||||
),
|
||||
timeout=WAIT_TIME_BEFORE_CLOSE,
|
||||
)
|
||||
await asyncio.sleep(_CLEANUP_INTERVAL)
|
||||
@@ -181,7 +202,8 @@ class StandaloneConversationManager(ConversationManager):
|
||||
await conversation.disconnect()
|
||||
self._detached_conversations.clear()
|
||||
await wait_all(
|
||||
self._close_session(sid) for sid in self._local_agent_loops_by_sid
|
||||
self._close_session(conversation_id)
|
||||
for conversation_id in self._local_agent_loops_by_conversation_id
|
||||
)
|
||||
return
|
||||
except Exception:
|
||||
@@ -210,7 +232,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
A set of session IDs
|
||||
"""
|
||||
# Get all items and convert to list for sorting
|
||||
items: Iterable[tuple[str, Session]] = self._local_agent_loops_by_sid.items()
|
||||
items: Iterable[tuple[str, Session]] = (
|
||||
self._local_agent_loops_by_conversation_id.items()
|
||||
)
|
||||
|
||||
# Filter items if needed
|
||||
if filter_to_sids is not None:
|
||||
@@ -218,7 +242,7 @@ class StandaloneConversationManager(ConversationManager):
|
||||
if user_id:
|
||||
items = (item for item in items if item[1].user_id == user_id)
|
||||
|
||||
sids = {sid for sid, _ in items}
|
||||
sids = {conversation_id for conversation_id, _ in items}
|
||||
return sids
|
||||
|
||||
async def get_connections(
|
||||
@@ -227,48 +251,56 @@ class StandaloneConversationManager(ConversationManager):
|
||||
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
|
||||
connection_id: conversation_id
|
||||
for connection_id, conversation_id in connections.items()
|
||||
if conversation_id in filter_to_sids
|
||||
}
|
||||
if user_id:
|
||||
for connection_id, sid in list(connections.items()):
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
for connection_id, conversation_id in list(connections.items()):
|
||||
session = self._local_agent_loops_by_conversation_id.get(
|
||||
conversation_id
|
||||
)
|
||||
if not session or session.user_id != user_id:
|
||||
connections.pop(connection_id)
|
||||
return connections
|
||||
|
||||
async def maybe_start_agent_loop(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
) -> AgentLoopInfo:
|
||||
logger.info(f'maybe_start_agent_loop:{sid}', extra={'session_id': sid})
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
logger.info(
|
||||
f'maybe_start_agent_loop:{conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
session = self._local_agent_loops_by_conversation_id.get(conversation_id)
|
||||
if not session:
|
||||
session = await self._start_agent_loop(
|
||||
sid, settings, user_id, initial_user_msg, replay_json
|
||||
conversation_id, settings, user_id, initial_user_msg, replay_json
|
||||
)
|
||||
return self._agent_loop_info_from_session(session)
|
||||
|
||||
async def _start_agent_loop(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
settings: Settings,
|
||||
user_id: str | None,
|
||||
initial_user_msg: MessageAction | None = None,
|
||||
replay_json: str | None = None,
|
||||
) -> Session:
|
||||
logger.info(f'starting_agent_loop:{sid}', extra={'session_id': sid})
|
||||
logger.info(
|
||||
f'starting_agent_loop:{conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
|
||||
response_ids = await self.get_running_agent_loops(user_id)
|
||||
if len(response_ids) >= self.config.max_concurrent_conversations:
|
||||
logger.info(
|
||||
f'too_many_sessions_for:{user_id or ""}',
|
||||
extra={'session_id': sid, 'user_id': user_id},
|
||||
extra={'session_id': conversation_id, 'user_id': user_id},
|
||||
)
|
||||
# Get the conversations sorted (oldest first)
|
||||
conversation_store = await self._get_conversation_store(user_id)
|
||||
@@ -291,18 +323,18 @@ class StandaloneConversationManager(ConversationManager):
|
||||
await self.sio.emit(
|
||||
'oh_event',
|
||||
status_update_dict,
|
||||
to=ROOM_KEY.format(sid=oldest_conversation_id),
|
||||
to=ROOM_KEY.format(conversation_id=oldest_conversation_id),
|
||||
)
|
||||
await self.close_session(oldest_conversation_id)
|
||||
|
||||
session = Session(
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
file_store=self.file_store,
|
||||
config=self.config,
|
||||
sio=self.sio,
|
||||
user_id=user_id,
|
||||
)
|
||||
self._local_agent_loops_by_sid[sid] = session
|
||||
self._local_agent_loops_by_conversation_id[conversation_id] = session
|
||||
asyncio.create_task(
|
||||
session.initialize_agent(settings, initial_user_msg, replay_json)
|
||||
)
|
||||
@@ -310,7 +342,9 @@ class StandaloneConversationManager(ConversationManager):
|
||||
try:
|
||||
session.agent_session.event_stream.subscribe(
|
||||
EventStreamSubscriber.SERVER,
|
||||
self._create_conversation_update_callback(user_id, sid, settings),
|
||||
self._create_conversation_update_callback(
|
||||
user_id, conversation_id, settings
|
||||
),
|
||||
UPDATED_AT_CALLBACK_ID,
|
||||
)
|
||||
except ValueError:
|
||||
@@ -319,59 +353,73 @@ class StandaloneConversationManager(ConversationManager):
|
||||
|
||||
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:
|
||||
conversation_id = self._local_connection_id_to_session_id.get(connection_id)
|
||||
if not conversation_id:
|
||||
raise RuntimeError(f'no_connected_session:{connection_id}')
|
||||
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
session = self._local_agent_loops_by_conversation_id.get(conversation_id)
|
||||
if session:
|
||||
await session.dispatch(data)
|
||||
return
|
||||
|
||||
raise RuntimeError(f'no_connected_session:{connection_id}:{sid}')
|
||||
raise RuntimeError(f'no_connected_session:{connection_id}:{conversation_id}')
|
||||
|
||||
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}', extra={'session_id': sid}
|
||||
conversation_id = self._local_connection_id_to_session_id.pop(
|
||||
connection_id, None
|
||||
)
|
||||
if not sid:
|
||||
logger.info(
|
||||
f'disconnect_from_session:{connection_id}:{conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
if not conversation_id:
|
||||
# This can occur if the init action was never run.
|
||||
logger.warning(
|
||||
f'disconnect_from_uninitialized_session:{connection_id}',
|
||||
extra={'session_id': sid},
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
return
|
||||
|
||||
async def close_session(self, sid: str):
|
||||
session = self._local_agent_loops_by_sid.get(sid)
|
||||
async def close_session(self, conversation_id: str):
|
||||
session = self._local_agent_loops_by_conversation_id.get(conversation_id)
|
||||
if session:
|
||||
await self._close_session(sid)
|
||||
await self._close_session(conversation_id)
|
||||
|
||||
async def _close_session(self, sid: str):
|
||||
logger.info(f'_close_session:{sid}', extra={'session_id': sid})
|
||||
async def _close_session(self, conversation_id: str):
|
||||
logger.info(
|
||||
f'_close_session:{conversation_id}', extra={'session_id': conversation_id}
|
||||
)
|
||||
|
||||
# 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
|
||||
if conversation_id == conn_sid
|
||||
)
|
||||
logger.info(
|
||||
f'removing connections: {connection_ids_to_remove}',
|
||||
extra={'session_id': sid},
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
for connection_id in connection_ids_to_remove:
|
||||
self._local_connection_id_to_session_id.pop(connection_id, None)
|
||||
|
||||
session = self._local_agent_loops_by_sid.pop(sid, None)
|
||||
session = self._local_agent_loops_by_conversation_id.pop(conversation_id, None)
|
||||
if not session:
|
||||
logger.warning(f'no_session_to_close:{sid}', extra={'session_id': sid})
|
||||
logger.warning(
|
||||
f'no_session_to_close:{conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f'closing_session:{session.sid}', extra={'session_id': sid})
|
||||
logger.info(
|
||||
f'closing_session:{session.conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
await session.close()
|
||||
logger.info(f'closed_session:{session.sid}', extra={'session_id': sid})
|
||||
logger.info(
|
||||
f'closed_session:{session.conversation_id}',
|
||||
extra={'session_id': conversation_id},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_instance(
|
||||
@@ -455,7 +503,7 @@ class StandaloneConversationManager(ConversationManager):
|
||||
await self.sio.emit(
|
||||
'oh_event',
|
||||
status_update_dict,
|
||||
to=ROOM_KEY.format(sid=conversation_id),
|
||||
to=ROOM_KEY.format(conversation_id=conversation_id),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f'Error emitting title update event: {e}')
|
||||
@@ -468,18 +516,18 @@ class StandaloneConversationManager(ConversationManager):
|
||||
self, user_id: str | None = None, filter_to_sids: set[str] | None = None
|
||||
):
|
||||
results = []
|
||||
for session in self._local_agent_loops_by_sid.values():
|
||||
for session in self._local_agent_loops_by_conversation_id.values():
|
||||
if user_id and session.user_id != user_id:
|
||||
continue
|
||||
if filter_to_sids and session.sid not in filter_to_sids:
|
||||
if filter_to_sids and session.conversation_id not in filter_to_sids:
|
||||
continue
|
||||
results.append(self._agent_loop_info_from_session(session))
|
||||
return results
|
||||
|
||||
def _agent_loop_info_from_session(self, session: Session):
|
||||
return AgentLoopInfo(
|
||||
conversation_id=session.sid,
|
||||
url=self._get_conversation_url(session.sid),
|
||||
conversation_id=session.conversation_id,
|
||||
url=self._get_conversation_url(session.conversation_id),
|
||||
session_api_key=None,
|
||||
event_store=session.agent_session.event_stream,
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ class AgentLoopInfo:
|
||||
"""
|
||||
Information about an agent loop - the URL on which to locate it and the event store
|
||||
"""
|
||||
|
||||
conversation_id: str
|
||||
url: str | None
|
||||
session_api_key: str | None
|
||||
|
||||
@@ -159,7 +159,7 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
if not conversation_id:
|
||||
return False
|
||||
|
||||
request.state.sid = conversation_id
|
||||
request.state.conversation_id = conversation_id
|
||||
|
||||
return True
|
||||
|
||||
@@ -170,7 +170,7 @@ class AttachConversationMiddleware(SessionMiddlewareInterface):
|
||||
user_id = await get_user_id(request)
|
||||
request.state.conversation = (
|
||||
await shared.conversation_manager.attach_to_conversation(
|
||||
request.state.sid, user_id
|
||||
request.state.conversation_id, user_id
|
||||
)
|
||||
)
|
||||
if not request.state.conversation:
|
||||
|
||||
@@ -18,7 +18,9 @@ async def get_remote_runtime_config(request: Request) -> JSONResponse:
|
||||
"""
|
||||
runtime = request.state.conversation.runtime
|
||||
runtime_id = runtime.runtime_id if hasattr(runtime, 'runtime_id') else None
|
||||
session_id = runtime.sid if hasattr(runtime, 'sid') else None
|
||||
session_id = (
|
||||
runtime.conversation_id if hasattr(runtime, 'conversation_id') else None
|
||||
)
|
||||
return JSONResponse(
|
||||
content={
|
||||
'runtime_id': runtime_id,
|
||||
@@ -157,5 +159,5 @@ async def search_events(
|
||||
@app.post('/events')
|
||||
async def add_event(request: Request):
|
||||
data = request.json()
|
||||
conversation_manager.send_to_event_stream(request.state.sid, data)
|
||||
conversation_manager.send_to_event_stream(request.state.conversation_id, data)
|
||||
return JSONResponse({'success': True})
|
||||
|
||||
@@ -23,20 +23,16 @@ from openhands.events.observation import (
|
||||
FileReadObservation,
|
||||
)
|
||||
from openhands.runtime.base import Runtime
|
||||
from openhands.server.data_models.conversation_info import ConversationInfo
|
||||
from openhands.server.file_config import (
|
||||
FILES_TO_IGNORE,
|
||||
)
|
||||
from openhands.server.shared import (
|
||||
ConversationStoreImpl,
|
||||
config,
|
||||
conversation_manager,
|
||||
)
|
||||
from openhands.server.user_auth import get_user_id
|
||||
from openhands.server.utils import get_conversation_store
|
||||
from openhands.storage.conversation.conversation_store import ConversationStore
|
||||
from openhands.storage.data_models.conversation_metadata import ConversationMetadata
|
||||
from openhands.storage.data_models.conversation_status import ConversationStatus
|
||||
from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
app = APIRouter(prefix='/api/conversations/{conversation_id}')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
|
||||
from openhands.runtime.utils.system_stats import get_system_stats
|
||||
@@ -6,17 +7,16 @@ from openhands.runtime.utils.system_stats import get_system_stats
|
||||
start_time = time.time()
|
||||
last_execution_time = start_time
|
||||
|
||||
|
||||
def add_health_endpoints(app: FastAPI):
|
||||
@app.get('/alive')
|
||||
async def alive():
|
||||
return {'status': 'ok'}
|
||||
|
||||
|
||||
@app.get('/health')
|
||||
async def health() -> str:
|
||||
return 'OK'
|
||||
|
||||
|
||||
@app.get('/server_info')
|
||||
async def get_server_info():
|
||||
current_time = time.time()
|
||||
@@ -29,9 +29,8 @@ def add_health_endpoints(app: FastAPI):
|
||||
'resources': get_system_stats(),
|
||||
}
|
||||
return response
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@app.middleware('http')
|
||||
async def update_last_execution_time(request: Request, call_next):
|
||||
global last_execution_time
|
||||
response = await call_next(request)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
@@ -106,8 +105,10 @@ async def new_conversation(
|
||||
if auth_type == AuthType.BEARER:
|
||||
conversation_trigger = ConversationTrigger.REMOTE_API_KEY
|
||||
|
||||
|
||||
if conversation_trigger == ConversationTrigger.REMOTE_API_KEY and not initial_user_msg:
|
||||
if (
|
||||
conversation_trigger == ConversationTrigger.REMOTE_API_KEY
|
||||
and not initial_user_msg
|
||||
):
|
||||
return JSONResponse(
|
||||
content={
|
||||
'status': 'error',
|
||||
@@ -196,19 +197,27 @@ async def search_conversations(
|
||||
conversation_ids = set(
|
||||
conversation.conversation_id for conversation in filtered_results
|
||||
)
|
||||
connection_ids_to_conversation_ids = await conversation_manager.get_connections(filter_to_sids=conversation_ids)
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(filter_to_sids=conversation_ids)
|
||||
agent_loop_info_by_conversation_id = {info.conversation_id: info for info in agent_loop_info}
|
||||
connection_ids_to_conversation_ids = await conversation_manager.get_connections(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info = await conversation_manager.get_agent_loop_info(
|
||||
filter_to_sids=conversation_ids
|
||||
)
|
||||
agent_loop_info_by_conversation_id = {
|
||||
info.conversation_id: info for info in agent_loop_info
|
||||
}
|
||||
result = ConversationInfoResultSet(
|
||||
results=await wait_all(
|
||||
_get_conversation_info(
|
||||
conversation=conversation,
|
||||
num_connections=sum(
|
||||
1 for conversation_id in connection_ids_to_conversation_ids.values()
|
||||
1
|
||||
for conversation_id in connection_ids_to_conversation_ids.values()
|
||||
if conversation_id == conversation.conversation_id
|
||||
),
|
||||
agent_loop_info=agent_loop_info_by_conversation_id.get(conversation.conversation_id),
|
||||
|
||||
agent_loop_info=agent_loop_info_by_conversation_id.get(
|
||||
conversation.conversation_id
|
||||
),
|
||||
)
|
||||
for conversation in filtered_results
|
||||
),
|
||||
@@ -224,10 +233,16 @@ async def get_conversation(
|
||||
) -> ConversationInfo | None:
|
||||
try:
|
||||
metadata = await conversation_store.get_metadata(conversation_id)
|
||||
num_connections = len(await conversation_manager.get_connections(filter_to_sids={conversation_id}))
|
||||
agent_loop_infos = await conversation_manager.get_agent_loop_info(filter_to_sids={conversation_id})
|
||||
num_connections = len(
|
||||
await conversation_manager.get_connections(filter_to_sids={conversation_id})
|
||||
)
|
||||
agent_loop_infos = await conversation_manager.get_agent_loop_info(
|
||||
filter_to_sids={conversation_id}
|
||||
)
|
||||
agent_loop_info = agent_loop_infos[0] if agent_loop_infos else None
|
||||
conversation_info = await _get_conversation_info(metadata, num_connections, agent_loop_info)
|
||||
conversation_info = await _get_conversation_info(
|
||||
metadata, num_connections, agent_loop_info
|
||||
)
|
||||
return conversation_info
|
||||
except FileNotFoundError:
|
||||
return None
|
||||
@@ -269,11 +284,15 @@ async def _get_conversation_info(
|
||||
created_at=conversation.created_at,
|
||||
selected_repository=conversation.selected_repository,
|
||||
status=(
|
||||
agent_loop_info.status if agent_loop_info else ConversationStatus.STOPPED
|
||||
agent_loop_info.status
|
||||
if agent_loop_info
|
||||
else ConversationStatus.STOPPED
|
||||
),
|
||||
num_connections=num_connections,
|
||||
url=agent_loop_info.url if agent_loop_info else None,
|
||||
session_api_key=agent_loop_info.session_api_key if agent_loop_info else None,
|
||||
session_api_key=agent_loop_info.session_api_key
|
||||
if agent_loop_info
|
||||
else None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
|
||||
@@ -217,7 +217,9 @@ async def create_custom_secret(
|
||||
) -> JSONResponse:
|
||||
try:
|
||||
existing_secrets = await secrets_store.load()
|
||||
custom_secrets = dict(existing_secrets.custom_secrets) if existing_secrets else {}
|
||||
custom_secrets = (
|
||||
dict(existing_secrets.custom_secrets) if existing_secrets else {}
|
||||
)
|
||||
|
||||
secret_name = incoming_secret.name
|
||||
secret_value = incoming_secret.value
|
||||
@@ -237,7 +239,9 @@ async def create_custom_secret(
|
||||
# Create a new UserSecrets that preserves provider tokens
|
||||
updated_user_secrets = UserSecrets(
|
||||
custom_secrets=custom_secrets,
|
||||
provider_tokens=existing_secrets.provider_tokens if existing_secrets else {},
|
||||
provider_tokens=existing_secrets.provider_tokens
|
||||
if existing_secrets
|
||||
else {},
|
||||
)
|
||||
|
||||
await secrets_store.store(updated_user_secrets)
|
||||
|
||||
@@ -44,7 +44,7 @@ class AgentSession:
|
||||
controller: The AgentController instance for controlling the agent.
|
||||
"""
|
||||
|
||||
sid: str
|
||||
conversation_id: str
|
||||
user_id: str | None
|
||||
event_stream: EventStream
|
||||
file_store: FileStore
|
||||
@@ -59,7 +59,7 @@ class AgentSession:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
file_store: FileStore,
|
||||
status_callback: Callable | None = None,
|
||||
user_id: str | None = None,
|
||||
@@ -67,17 +67,17 @@ class AgentSession:
|
||||
"""Initializes a new instance of the Session class
|
||||
|
||||
Parameters:
|
||||
- sid: The session ID
|
||||
- conversation_id: The session ID
|
||||
- file_store: Instance of the FileStore
|
||||
"""
|
||||
|
||||
self.sid = sid
|
||||
self.event_stream = EventStream(sid, file_store, user_id)
|
||||
self.conversation_id = conversation_id
|
||||
self.event_stream = EventStream(conversation_id, file_store, user_id)
|
||||
self.file_store = file_store
|
||||
self._status_callback = status_callback
|
||||
self.user_id = user_id
|
||||
self.logger = OpenHandsLoggerAdapter(
|
||||
extra={'session_id': sid, 'user_id': user_id}
|
||||
extra={'session_id': conversation_id, 'user_id': user_id}
|
||||
)
|
||||
|
||||
async def start(
|
||||
@@ -213,19 +213,21 @@ class AgentSession:
|
||||
self._closed = True
|
||||
while self._starting and should_continue():
|
||||
self.logger.debug(
|
||||
f'Waiting for initialization to finish before closing session {self.sid}'
|
||||
f'Waiting for initialization to finish before closing session {self.conversation_id}'
|
||||
)
|
||||
await asyncio.sleep(WAIT_TIME_BEFORE_CLOSE_INTERVAL)
|
||||
if time.time() <= self._started_at + WAIT_TIME_BEFORE_CLOSE:
|
||||
self.logger.error(
|
||||
f'Waited too long for initialization to finish before closing session {self.sid}'
|
||||
f'Waited too long for initialization to finish before closing session {self.conversation_id}'
|
||||
)
|
||||
break
|
||||
if self.event_stream is not None:
|
||||
self.event_stream.close()
|
||||
if self.controller is not None:
|
||||
end_state = self.controller.get_state()
|
||||
end_state.save_to_session(self.sid, self.file_store, self.user_id)
|
||||
end_state.save_to_session(
|
||||
self.conversation_id, self.file_store, self.user_id
|
||||
)
|
||||
await self.controller.close()
|
||||
if self.runtime is not None:
|
||||
EXECUTOR.submit(self.runtime.close)
|
||||
@@ -332,7 +334,7 @@ class AgentSession:
|
||||
self.runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=self.event_stream,
|
||||
sid=self.sid,
|
||||
conversation_id=self.conversation_id,
|
||||
plugins=agent.sandbox_plugins,
|
||||
status_callback=self._status_callback,
|
||||
headless_mode=False,
|
||||
@@ -352,7 +354,7 @@ class AgentSession:
|
||||
self.runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=self.event_stream,
|
||||
sid=self.sid,
|
||||
conversation_id=self.conversation_id,
|
||||
plugins=agent.sandbox_plugins,
|
||||
status_callback=self._status_callback,
|
||||
headless_mode=False,
|
||||
@@ -426,7 +428,7 @@ class AgentSession:
|
||||
self.logger.debug(msg)
|
||||
|
||||
controller = AgentController(
|
||||
sid=self.sid,
|
||||
conversation_id=self.conversation_id,
|
||||
event_stream=self.event_stream,
|
||||
agent=agent,
|
||||
max_iterations=int(max_iterations),
|
||||
@@ -451,7 +453,7 @@ class AgentSession:
|
||||
) -> Memory:
|
||||
memory = Memory(
|
||||
event_stream=self.event_stream,
|
||||
sid=self.sid,
|
||||
conversation_id=self.conversation_id,
|
||||
status_callback=self._status_callback,
|
||||
)
|
||||
|
||||
@@ -480,9 +482,11 @@ class AgentSession:
|
||||
# if we have events in the stream.
|
||||
try:
|
||||
restored_state = State.restore_from_session(
|
||||
self.sid, self.file_store, self.user_id
|
||||
self.conversation_id, self.file_store, self.user_id
|
||||
)
|
||||
self.logger.debug(
|
||||
f'Restored state from session, conversation_id: {self.conversation_id}'
|
||||
)
|
||||
self.logger.debug(f'Restored state from session, sid: {self.sid}')
|
||||
except Exception as e:
|
||||
if self.event_stream.get_latest_event_id() > 0:
|
||||
# if we have events, we should have a state
|
||||
|
||||
@@ -10,7 +10,7 @@ from openhands.utils.async_utils import call_sync_from_async
|
||||
|
||||
|
||||
class ServerConversation:
|
||||
sid: str
|
||||
conversation_id: str
|
||||
file_store: FileStore
|
||||
event_stream: EventStream
|
||||
runtime: Runtime
|
||||
@@ -18,16 +18,16 @@ class ServerConversation:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
file_store: FileStore,
|
||||
config: OpenHandsConfig,
|
||||
user_id: str | None,
|
||||
):
|
||||
self.sid = sid
|
||||
self.conversation_id = conversation_id
|
||||
self.config = config
|
||||
self.file_store = file_store
|
||||
self.user_id = user_id
|
||||
self.event_stream = EventStream(sid, file_store, user_id)
|
||||
self.event_stream = EventStream(conversation_id, file_store, user_id)
|
||||
if config.security.security_analyzer:
|
||||
self.security_analyzer = options.SecurityAnalyzers.get(
|
||||
config.security.security_analyzer, SecurityAnalyzer
|
||||
@@ -37,7 +37,7 @@ class ServerConversation:
|
||||
self.runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=self.event_stream,
|
||||
sid=self.sid,
|
||||
conversation_id=self.conversation_id,
|
||||
attach_to_existing=True,
|
||||
headless_mode=False,
|
||||
)
|
||||
|
||||
@@ -33,11 +33,11 @@ from openhands.server.session.conversation_init_data import ConversationInitData
|
||||
from openhands.storage.data_models.settings import Settings
|
||||
from openhands.storage.files import FileStore
|
||||
|
||||
ROOM_KEY = 'room:{sid}'
|
||||
ROOM_KEY = 'room:{conversation_id}'
|
||||
|
||||
|
||||
class Session:
|
||||
sid: str
|
||||
conversation_id: str
|
||||
sio: socketio.AsyncServer | None
|
||||
last_active_ts: int = 0
|
||||
is_alive: bool = True
|
||||
@@ -50,25 +50,25 @@ class Session:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sid: str,
|
||||
conversation_id: str,
|
||||
config: OpenHandsConfig,
|
||||
file_store: FileStore,
|
||||
sio: socketio.AsyncServer | None,
|
||||
user_id: str | None = None,
|
||||
):
|
||||
self.sid = sid
|
||||
self.conversation_id = conversation_id
|
||||
self.sio = sio
|
||||
self.last_active_ts = int(time.time())
|
||||
self.file_store = file_store
|
||||
self.logger = OpenHandsLoggerAdapter(extra={'session_id': sid})
|
||||
self.logger = OpenHandsLoggerAdapter(extra={'session_id': conversation_id})
|
||||
self.agent_session = AgentSession(
|
||||
sid,
|
||||
conversation_id,
|
||||
file_store,
|
||||
status_callback=self.queue_status_message,
|
||||
user_id=user_id,
|
||||
)
|
||||
self.agent_session.event_stream.subscribe(
|
||||
EventStreamSubscriber.SERVER, self.on_event, self.sid
|
||||
EventStreamSubscriber.SERVER, self.on_event, self.conversation_id
|
||||
)
|
||||
# Copying this means that when we update variables they are not applied to the shared global configuration!
|
||||
self.config = deepcopy(config)
|
||||
@@ -82,7 +82,7 @@ class Session:
|
||||
event_to_dict(
|
||||
AgentStateChangedObservation('', AgentState.STOPPED.value)
|
||||
),
|
||||
to=ROOM_KEY.format(sid=self.sid),
|
||||
to=ROOM_KEY.format(conversation_id=self.conversation_id),
|
||||
)
|
||||
self.is_alive = False
|
||||
await self.agent_session.close()
|
||||
@@ -309,7 +309,11 @@ class Session:
|
||||
if not self.is_alive:
|
||||
return False
|
||||
if self.sio:
|
||||
await self.sio.emit('oh_event', data, to=ROOM_KEY.format(sid=self.sid))
|
||||
await self.sio.emit(
|
||||
'oh_event',
|
||||
data,
|
||||
to=ROOM_KEY.format(conversation_id=self.conversation_id),
|
||||
)
|
||||
await asyncio.sleep(0.001) # This flushes the data to the client
|
||||
self.last_active_ts = int(time.time())
|
||||
return True
|
||||
|
||||
@@ -7,8 +7,8 @@ from fastapi import Request
|
||||
from pydantic import SecretStr
|
||||
|
||||
from openhands.integrations.provider import PROVIDER_TOKEN_TYPE
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.server.settings import Settings
|
||||
from openhands.server.shared import server_config
|
||||
from openhands.storage.data_models.user_secrets import UserSecrets
|
||||
from openhands.storage.secrets.secrets_store import SecretsStore
|
||||
from openhands.storage.settings.settings_store import SettingsStore
|
||||
|
||||
@@ -20,7 +20,7 @@ class ConversationMetadata:
|
||||
title: str | None = None
|
||||
last_updated_at: datetime | None = None
|
||||
trigger: ConversationTrigger | None = None
|
||||
pr_number: list[int] = field(default_factory=list)
|
||||
pr_number: list[int] = field(default_factory=list)
|
||||
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
# Cost and token metrics
|
||||
accumulated_cost: float = 0.0
|
||||
|
||||
@@ -137,7 +137,6 @@ class UserSecrets(BaseModel):
|
||||
new_data['custom_secrets'] = secrets
|
||||
|
||||
return new_data
|
||||
|
||||
|
||||
def set_event_stream_secrets(self, event_stream: EventStream) -> None:
|
||||
"""
|
||||
@@ -158,10 +157,9 @@ class UserSecrets(BaseModel):
|
||||
|
||||
return secrets
|
||||
|
||||
|
||||
def get_custom_secrets_descriptions(self) -> dict[str, str]:
|
||||
secrets = {}
|
||||
for secret_name, secret in self.custom_secrets.items():
|
||||
secrets[secret_name] = secret.description
|
||||
|
||||
return secrets
|
||||
return secrets
|
||||
|
||||
@@ -1,30 +1,38 @@
|
||||
CONVERSATION_BASE_DIR = 'sessions'
|
||||
|
||||
|
||||
def get_conversation_dir(sid: str, user_id: str | None = None) -> str:
|
||||
def get_conversation_dir(conversation_id: str, user_id: str | None = None) -> str:
|
||||
if user_id:
|
||||
return f'users/{user_id}/conversations/{sid}/'
|
||||
return f'users/{user_id}/conversations/{conversation_id}/'
|
||||
else:
|
||||
return f'{CONVERSATION_BASE_DIR}/{sid}/'
|
||||
return f'{CONVERSATION_BASE_DIR}/{conversation_id}/'
|
||||
|
||||
|
||||
def get_conversation_events_dir(sid: str, user_id: str | None = None) -> str:
|
||||
return f'{get_conversation_dir(sid, user_id)}events/'
|
||||
def get_conversation_events_dir(
|
||||
conversation_id: str, user_id: str | None = None
|
||||
) -> str:
|
||||
return f'{get_conversation_dir(conversation_id, user_id)}events/'
|
||||
|
||||
|
||||
def get_conversation_event_filename(
|
||||
sid: str, id: int, user_id: str | None = None
|
||||
conversation_id: str, id: int, user_id: str | None = None
|
||||
) -> str:
|
||||
return f'{get_conversation_events_dir(sid, user_id)}{id}.json'
|
||||
return f'{get_conversation_events_dir(conversation_id, user_id)}{id}.json'
|
||||
|
||||
|
||||
def get_conversation_metadata_filename(sid: str, user_id: str | None = None) -> str:
|
||||
return f'{get_conversation_dir(sid, user_id)}metadata.json'
|
||||
def get_conversation_metadata_filename(
|
||||
conversation_id: str, user_id: str | None = None
|
||||
) -> str:
|
||||
return f'{get_conversation_dir(conversation_id, user_id)}metadata.json'
|
||||
|
||||
|
||||
def get_conversation_init_data_filename(sid: str, user_id: str | None = None) -> str:
|
||||
return f'{get_conversation_dir(sid, user_id)}init.json'
|
||||
def get_conversation_init_data_filename(
|
||||
conversation_id: str, user_id: str | None = None
|
||||
) -> str:
|
||||
return f'{get_conversation_dir(conversation_id, user_id)}init.json'
|
||||
|
||||
|
||||
def get_conversation_agent_state_filename(sid: str, user_id: str | None = None) -> str:
|
||||
return f'{get_conversation_dir(sid, user_id)}agent_state.pkl'
|
||||
def get_conversation_agent_state_filename(
|
||||
conversation_id: str, user_id: str | None = None
|
||||
) -> str:
|
||||
return f'{get_conversation_dir(conversation_id, user_id)}agent_state.pkl'
|
||||
|
||||
@@ -20,12 +20,12 @@ packages = [
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.12,<3.14"
|
||||
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
google-auth-oauthlib = "*" # For Google Sheets OAuth
|
||||
litellm = "^1.60.0, !=1.64.4, !=1.67.*" # avoid 1.64.4 (known bug) & 1.67.* (known bug #10272)
|
||||
aiohttp = ">=3.9.0,!=3.11.13" # Pin to avoid yanked version 3.11.13
|
||||
google-generativeai = "*" # To use litellm with Gemini Pro API
|
||||
google-api-python-client = "^2.164.0" # For Google Sheets API
|
||||
google-auth-httplib2 = "*" # For Google Sheets authentication
|
||||
google-auth-oauthlib = "*" # For Google Sheets OAuth
|
||||
termcolor = "*"
|
||||
docker = "*"
|
||||
fastapi = "*"
|
||||
@@ -34,7 +34,7 @@ uvicorn = "*"
|
||||
types-toml = "*"
|
||||
numpy = "*"
|
||||
json-repair = "*"
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
browsergym-core = "0.13.3" # integrate browsergym-core as the browsing interface
|
||||
html2text = "*"
|
||||
e2b = ">=1.0.5,<1.4.0"
|
||||
pexpect = "*"
|
||||
@@ -60,7 +60,7 @@ tornado = "*"
|
||||
python-dotenv = "*"
|
||||
pylcs = "^0.1.1"
|
||||
whatthepatch = "^1.0.6"
|
||||
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
|
||||
protobuf = "^4.21.6,<5.0.0" # chromadb currently fails on 5.0+
|
||||
opentelemetry-api = "1.25.0"
|
||||
opentelemetry-exporter-otlp-proto-grpc = "1.25.0"
|
||||
modal = ">=0.66.26,<0.78.0"
|
||||
|
||||
@@ -32,8 +32,8 @@ sandbox_test_folder = '/workspace'
|
||||
|
||||
|
||||
def _get_runtime_sid(runtime: Runtime) -> str:
|
||||
logger.debug(f'\nruntime.sid: {runtime.sid}')
|
||||
return runtime.sid
|
||||
logger.debug(f'\nruntime.conversation_id: {runtime.conversation_id}')
|
||||
return runtime.conversation_id
|
||||
|
||||
|
||||
def _get_host_folder(runtime: Runtime) -> str:
|
||||
@@ -219,7 +219,7 @@ def _load_runtime(
|
||||
docker_runtime_kwargs: dict[str, str] | None = None,
|
||||
override_mcp_config: MCPConfig | None = None,
|
||||
) -> tuple[Runtime, OpenHandsConfig]:
|
||||
sid = 'rt_' + str(random.randint(100000, 999999))
|
||||
conversation_id = 'rt_' + str(random.randint(100000, 999999))
|
||||
|
||||
# AgentSkills need to be initialized **before** Jupyter
|
||||
# otherwise Jupyter will not access the proper dependencies installed by AgentSkills
|
||||
@@ -264,12 +264,12 @@ def _load_runtime(
|
||||
config.mcp = override_mcp_config
|
||||
|
||||
file_store = get_file_store(config.file_store, config.file_store_path)
|
||||
event_stream = EventStream(sid, file_store)
|
||||
event_stream = EventStream(conversation_id, file_store)
|
||||
|
||||
runtime = runtime_cls(
|
||||
config=config,
|
||||
event_stream=event_stream,
|
||||
sid=sid,
|
||||
conversation_id=conversation_id,
|
||||
plugins=plugins,
|
||||
)
|
||||
|
||||
|
||||
@@ -10,24 +10,36 @@ class TestTranslationCompleteness(unittest.TestCase):
|
||||
|
||||
def test_translation_completeness_check_runs(self):
|
||||
"""Test that the translation completeness check script can be executed."""
|
||||
frontend_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(__file__)))), "frontend")
|
||||
script_path = os.path.join(frontend_dir, "scripts", "check-translation-completeness.cjs")
|
||||
|
||||
frontend_dir = os.path.join(
|
||||
os.path.dirname(
|
||||
os.path.dirname(os.path.dirname(os.path.dirname(__file__)))
|
||||
),
|
||||
'frontend',
|
||||
)
|
||||
script_path = os.path.join(
|
||||
frontend_dir, 'scripts', 'check-translation-completeness.cjs'
|
||||
)
|
||||
|
||||
# Verify the script exists
|
||||
self.assertTrue(os.path.exists(script_path), f"Script not found at {script_path}")
|
||||
|
||||
self.assertTrue(
|
||||
os.path.exists(script_path), f'Script not found at {script_path}'
|
||||
)
|
||||
|
||||
# Verify the script is executable
|
||||
self.assertTrue(os.access(script_path, os.X_OK), f"Script at {script_path} is not executable")
|
||||
|
||||
self.assertTrue(
|
||||
os.access(script_path, os.X_OK),
|
||||
f'Script at {script_path} is not executable',
|
||||
)
|
||||
|
||||
# Run the script (it may fail due to missing translations, but we just want to verify it runs)
|
||||
try:
|
||||
subprocess.run(
|
||||
["node", script_path],
|
||||
cwd=frontend_dir,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True
|
||||
['node', script_path],
|
||||
cwd=frontend_dir,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# We don't assert on the return code because it might fail due to missing translations
|
||||
except Exception as e:
|
||||
self.fail(f"Failed to run translation completeness check: {e}")
|
||||
self.fail(f'Failed to run translation completeness check: {e}')
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_get_converted_issues_initializes_review_comments():
|
||||
|
||||
# Set up the mock to return different responses for different calls
|
||||
# First call is for issues, second call is for comments
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_issues_response,
|
||||
mock_comments_response,
|
||||
mock_comments_response,
|
||||
@@ -64,7 +64,7 @@ def test_get_converted_issues_handles_empty_body():
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = []
|
||||
# Set up the mock to return different responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_issues_response,
|
||||
mock_comments_response,
|
||||
mock_comments_response,
|
||||
@@ -141,7 +141,7 @@ def test_pr_handler_get_converted_issues_with_comments():
|
||||
'body': 'This is additional context from an externally referenced issue.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_comments_response, # Third call for PR comments
|
||||
@@ -271,7 +271,7 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
mock_empty_response = MagicMock()
|
||||
mock_empty_response.json.return_value = []
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_comments_response, # Third call for PR comments
|
||||
@@ -376,7 +376,7 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
mock_empty_response = MagicMock()
|
||||
mock_empty_response.json.return_value = []
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_comments_response, # Third call for PR comments
|
||||
@@ -499,7 +499,7 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
'body': 'External context #2.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_comments_response, # Third call for PR comments
|
||||
@@ -601,7 +601,7 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
'body': 'External context #2.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_comments_response, # Third call for PR comments
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_handle_nonexistent_issue_reference():
|
||||
|
||||
# Mock the requests.get to simulate a 404 error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPError(
|
||||
mock_response.raise_for_status.conversation_ide_effect = httpx.HTTPError(
|
||||
'404 Client Error: Not Found'
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_handle_rate_limit_error():
|
||||
|
||||
# Mock the requests.get to simulate a rate limit error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPError(
|
||||
mock_response.raise_for_status.conversation_ide_effect = httpx.HTTPError(
|
||||
'403 Client Error: Rate Limit Exceeded'
|
||||
)
|
||||
|
||||
@@ -193,7 +193,7 @@ def test_guess_success_rate_limit_wait_time(mock_litellm_completion, default_con
|
||||
|
||||
with patch('time.sleep') as mock_sleep:
|
||||
# Simulate a rate limit error followed by a successful response
|
||||
mock_litellm_completion.side_effect = [
|
||||
mock_litellm_completion.conversation_ide_effect = [
|
||||
RateLimitError(
|
||||
'Rate limit exceeded', llm_provider='test_provider', model='test_model'
|
||||
),
|
||||
@@ -249,7 +249,7 @@ def test_guess_success_rate_limit_wait_time(mock_litellm_completion, default_con
|
||||
def test_guess_success_exhausts_retries(mock_completion, default_config):
|
||||
"""Test the retry mechanism in guess_success exhausts retries and raises an error."""
|
||||
# Simulate persistent rate limit errors by always raising RateLimitError
|
||||
mock_completion.side_effect = RateLimitError(
|
||||
mock_completion.conversation_ide_effect = RateLimitError(
|
||||
'Rate limit exceeded', llm_provider='test_provider', model='test_model'
|
||||
)
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ def mock_conversation_instructions_template():
|
||||
return 'Instructions: {{ repo_instruction }}'
|
||||
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_followup_prompt_template():
|
||||
return 'Issue context: {{ issues }}\n\nReview comments: {{ review_comments }}\n\nReview threads: {{ review_threads }}\n\nFiles: {{ files }}\n\nThread comments: {{ thread_context }}\n\nPlease fix this issue.'
|
||||
@@ -116,7 +115,7 @@ def create_cmd_output(exit_code: int, content: str, command: str):
|
||||
|
||||
def test_initialize_runtime(default_mock_args, mock_github_token):
|
||||
mock_runtime = MagicMock()
|
||||
mock_runtime.run_action.side_effect = [
|
||||
mock_runtime.run_action.conversation_ide_effect = [
|
||||
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
|
||||
create_cmd_output(
|
||||
exit_code=0, content='', command='git config --global core.pager ""'
|
||||
@@ -171,7 +170,7 @@ def test_download_issues_from_github():
|
||||
)
|
||||
|
||||
mock_issues_response = MagicMock()
|
||||
mock_issues_response.json.side_effect = [
|
||||
mock_issues_response.json.conversation_ide_effect = [
|
||||
[
|
||||
{'number': 1, 'title': 'Issue 1', 'body': 'This is an issue'},
|
||||
{
|
||||
@@ -212,7 +211,7 @@ def test_download_pr_from_github():
|
||||
llm_config = LLMConfig(model='test', api_key='test')
|
||||
handler = ServiceContextPR(GithubPRHandler('owner', 'repo', 'token'), llm_config)
|
||||
mock_pr_response = MagicMock()
|
||||
mock_pr_response.json.side_effect = [
|
||||
mock_pr_response.json.conversation_ide_effect = [
|
||||
[
|
||||
{
|
||||
'number': 1,
|
||||
@@ -239,7 +238,7 @@ def test_download_pr_from_github():
|
||||
|
||||
# Mock for GraphQL request (for download_pr_metadata)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.side_effect = lambda: {
|
||||
mock_graphql_response.json.conversation_ide_effect = lambda: {
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
@@ -338,7 +337,7 @@ def test_download_pr_from_github():
|
||||
async def test_complete_runtime(default_mock_args, mock_github_token):
|
||||
"""Test the complete_runtime method."""
|
||||
mock_runtime = MagicMock()
|
||||
mock_runtime.run_action.side_effect = [
|
||||
mock_runtime.run_action.conversation_ide_effect = [
|
||||
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
|
||||
create_cmd_output(
|
||||
exit_code=0, content='', command='git config --global core.pager ""'
|
||||
@@ -483,7 +482,7 @@ async def test_process_issue(
|
||||
# Mock the run_controller function
|
||||
mock_run_controller = AsyncMock()
|
||||
if test_case['run_controller_raises']:
|
||||
mock_run_controller.side_effect = test_case['run_controller_raises']
|
||||
mock_run_controller.conversation_ide_effect = test_case['run_controller_raises']
|
||||
else:
|
||||
mock_run_controller.return_value = test_case['run_controller_return']
|
||||
|
||||
@@ -532,7 +531,11 @@ async def test_process_issue(
|
||||
handler_instance.guess_success.assert_not_called()
|
||||
|
||||
|
||||
def test_get_instruction(mock_user_instructions_template, mock_conversation_instructions_template, mock_followup_prompt_template):
|
||||
def test_get_instruction(
|
||||
mock_user_instructions_template,
|
||||
mock_conversation_instructions_template,
|
||||
mock_followup_prompt_template,
|
||||
):
|
||||
issue = Issue(
|
||||
owner='test_owner',
|
||||
repo='test_repo',
|
||||
@@ -545,7 +548,10 @@ def test_get_instruction(mock_user_instructions_template, mock_conversation_inst
|
||||
GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config
|
||||
)
|
||||
instruction, conversation_instructions, images_urls = issue_handler.get_instruction(
|
||||
issue, mock_user_instructions_template, mock_conversation_instructions_template, None
|
||||
issue,
|
||||
mock_user_instructions_template,
|
||||
mock_conversation_instructions_template,
|
||||
None,
|
||||
)
|
||||
expected_instruction = 'Issue: Test Issue\n\nThis is a test issue refer to image \n\nPlease fix this issue.'
|
||||
|
||||
@@ -576,7 +582,10 @@ def test_get_instruction(mock_user_instructions_template, mock_conversation_inst
|
||||
GithubPRHandler('owner', 'repo', 'token'), mock_llm_config
|
||||
)
|
||||
instruction, conversation_instructions, images_urls = pr_handler.get_instruction(
|
||||
issue, mock_followup_prompt_template, mock_conversation_instructions_template, None
|
||||
issue,
|
||||
mock_followup_prompt_template,
|
||||
mock_conversation_instructions_template,
|
||||
None,
|
||||
)
|
||||
expected_instruction = "Issue context: [\n \"Issue 1 fix the type\"\n]\n\nReview comments: None\n\nReview threads: [\n \"There is still a typo 'pthon' instead of 'python'\"\n]\n\nFiles: []\n\nThread comments: I've left review comments, please address them\n---\nThis is a valid concern.\n\nPlease fix this issue."
|
||||
|
||||
@@ -601,7 +610,9 @@ def test_file_instruction():
|
||||
with open('openhands/resolver/prompts/resolve/basic.jinja', 'r') as f:
|
||||
prompt = f.read()
|
||||
|
||||
with open('openhands/resolver/prompts/resolve/basic-conversation-instructions.jinja', 'r') as f:
|
||||
with open(
|
||||
'openhands/resolver/prompts/resolve/basic-conversation-instructions.jinja', 'r'
|
||||
) as f:
|
||||
conversation_instructions_template = f.read()
|
||||
|
||||
# Test without thread comments
|
||||
@@ -610,7 +621,7 @@ def test_file_instruction():
|
||||
GithubIssueHandler('owner', 'repo', 'token'), mock_llm_config
|
||||
)
|
||||
instruction, conversation_instructions, images_urls = issue_handler.get_instruction(
|
||||
issue, prompt,conversation_instructions_template, None
|
||||
issue, prompt, conversation_instructions_template, None
|
||||
)
|
||||
expected_instruction = """Please fix the following issue for the repository in /workspace.
|
||||
An environment has been set up for you to start working. You may assume all necessary tools are installed.
|
||||
@@ -620,7 +631,6 @@ Test Issue
|
||||
|
||||
This is a test issue """
|
||||
|
||||
|
||||
expected_conversation_instructions = """IMPORTANT: You should ONLY interact with the environment provided to you AND NEVER ASK FOR HUMAN HELP.
|
||||
You SHOULD INCLUDE PROPER INDENTATION in your edit commands.
|
||||
|
||||
@@ -644,7 +654,9 @@ def test_file_instruction_with_repo_instruction():
|
||||
with open('openhands/resolver/prompts/resolve/basic.jinja', 'r') as f:
|
||||
prompt = f.read()
|
||||
|
||||
with open('openhands/resolver/prompts/resolve/basic-conversation-instructions.jinja', 'r') as f:
|
||||
with open(
|
||||
'openhands/resolver/prompts/resolve/basic-conversation-instructions.jinja', 'r'
|
||||
) as f:
|
||||
conversation_instructions_prompt = f.read()
|
||||
|
||||
# load repo instruction from openhands/resolver/prompts/repo_instructions/all-hands-ai___openhands-resolver.txt
|
||||
@@ -662,7 +674,6 @@ def test_file_instruction_with_repo_instruction():
|
||||
issue, prompt, conversation_instructions_prompt, repo_instruction
|
||||
)
|
||||
|
||||
|
||||
expected_instruction = """Please fix the following issue for the repository in /workspace.
|
||||
An environment has been set up for you to start working. You may assume all necessary tools are installed.
|
||||
|
||||
@@ -683,7 +694,6 @@ This is a Python repo for openhands-resolver, a library that attempts to resolve
|
||||
|
||||
When you think you have fixed the issue through code changes, please finish the interaction."""
|
||||
|
||||
|
||||
assert instruction == expected_instruction
|
||||
assert conversation_instructions == expected_conversation_instructions
|
||||
assert conversation_instructions is not None
|
||||
@@ -785,7 +795,9 @@ def test_instruction_with_thread_comments():
|
||||
with open('openhands/resolver/prompts/resolve/basic.jinja', 'r') as f:
|
||||
prompt = f.read()
|
||||
|
||||
with open('openhands/resolver/prompts/resolve/basic-conversation-instructions.jinja', 'r') as f:
|
||||
with open(
|
||||
'openhands/resolver/prompts/resolve/basic-conversation-instructions.jinja', 'r'
|
||||
) as f:
|
||||
conversation_instructions_template = f.read()
|
||||
|
||||
llm_config = LLMConfig(model='test', api_key='test')
|
||||
@@ -917,7 +929,7 @@ def test_download_pr_with_review_comments():
|
||||
llm_config = LLMConfig(model='test', api_key='test')
|
||||
handler = ServiceContextPR(GithubPRHandler('owner', 'repo', 'token'), llm_config)
|
||||
mock_pr_response = MagicMock()
|
||||
mock_pr_response.json.side_effect = [
|
||||
mock_pr_response.json.conversation_ide_effect = [
|
||||
[
|
||||
{
|
||||
'number': 1,
|
||||
@@ -937,7 +949,7 @@ def test_download_pr_with_review_comments():
|
||||
|
||||
# Mock for GraphQL request with review comments but no threads
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.side_effect = lambda: {
|
||||
mock_graphql_response.json.conversation_ide_effect = lambda: {
|
||||
'data': {
|
||||
'repository': {
|
||||
'pullRequest': {
|
||||
@@ -991,7 +1003,7 @@ def test_download_issue_with_specific_comment():
|
||||
|
||||
# Mock issue and comment responses
|
||||
mock_issue_response = MagicMock()
|
||||
mock_issue_response.json.side_effect = [
|
||||
mock_issue_response.json.conversation_ide_effect = [
|
||||
[
|
||||
{'number': 1, 'title': 'Issue 1', 'body': 'This is an issue'},
|
||||
],
|
||||
|
||||
@@ -363,12 +363,12 @@ def test_send_pull_request(
|
||||
|
||||
# Mock API responses based on whether target_branch is specified
|
||||
if target_branch:
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=200), # Target branch exists
|
||||
]
|
||||
else:
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
|
||||
]
|
||||
@@ -378,7 +378,7 @@ def test_send_pull_request(
|
||||
}
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -450,13 +450,13 @@ def test_send_pull_request_with_reviewer(
|
||||
reviewer = 'test-reviewer'
|
||||
|
||||
# Mock API responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
|
||||
]
|
||||
|
||||
# Mock PR creation response
|
||||
mock_post.side_effect = [
|
||||
mock_post.conversation_ide_effect = [
|
||||
MagicMock(
|
||||
status_code=201,
|
||||
json=lambda: {
|
||||
@@ -468,7 +468,7 @@ def test_send_pull_request_with_reviewer(
|
||||
]
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -516,7 +516,7 @@ def test_send_pull_request_target_branch_with_fork(
|
||||
target_branch = 'custom-target'
|
||||
|
||||
# Mock API responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=200), # Target branch exists
|
||||
]
|
||||
@@ -526,7 +526,7 @@ def test_send_pull_request_target_branch_with_fork(
|
||||
}
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -580,7 +580,7 @@ def test_send_pull_request_target_branch_with_additional_message(
|
||||
additional_message = 'Additional PR context'
|
||||
|
||||
# Mock API responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=200), # Target branch exists
|
||||
]
|
||||
@@ -590,7 +590,7 @@ def test_send_pull_request_target_branch_with_additional_message(
|
||||
}
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -626,7 +626,7 @@ def test_send_pull_request_invalid_target_branch(
|
||||
repo_path = os.path.join(mock_output_dir, 'repo')
|
||||
|
||||
# Mock API response for non-existent branch
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=404), # Target branch doesn't exist
|
||||
]
|
||||
@@ -661,7 +661,7 @@ def test_send_pull_request_git_push_failure(
|
||||
mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'})
|
||||
|
||||
# Mock the subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=1, stderr='Error: failed to push some refs'), # git push
|
||||
]
|
||||
@@ -721,7 +721,7 @@ def test_send_pull_request_permission_error(
|
||||
mock_post.return_value.status_code = 403
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -1036,7 +1036,7 @@ def test_send_pull_request_branch_naming(
|
||||
repo_path = os.path.join(mock_output_dir, 'repo')
|
||||
|
||||
# Mock API responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=200), # First branch exists
|
||||
MagicMock(status_code=200), # Second branch exists
|
||||
MagicMock(status_code=404), # Third branch doesn't exist
|
||||
@@ -1044,7 +1044,7 @@ def test_send_pull_request_branch_naming(
|
||||
]
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -1124,7 +1124,7 @@ def test_main(
|
||||
mock_parser.return_value.parse_args.return_value = mock_args
|
||||
|
||||
# Setup environment variables
|
||||
mock_getenv.side_effect = (
|
||||
mock_getenv.conversation_ide_effect = (
|
||||
lambda key, default=None: 'mock_token' if key == 'GITHUB_TOKEN' else default
|
||||
)
|
||||
|
||||
@@ -1178,7 +1178,7 @@ def test_main(
|
||||
|
||||
# Test for invalid token
|
||||
mock_args.issue_number = '42' # Reset to valid issue number
|
||||
mock_getenv.side_effect = (
|
||||
mock_getenv.conversation_ide_effect = (
|
||||
lambda key, default=None: None
|
||||
) # Return None for all env vars
|
||||
with pytest.raises(ValueError, match='token is not set'):
|
||||
@@ -1238,7 +1238,7 @@ def test_make_commit_no_changes(mock_subprocess_run):
|
||||
)
|
||||
|
||||
# Mock subprocess.run to simulate no changes in the repo
|
||||
mock_subprocess_run.side_effect = [
|
||||
mock_subprocess_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=1, stdout=''), # git status --porcelain (no changes)
|
||||
|
||||
@@ -23,7 +23,7 @@ def test_get_converted_issues_initializes_review_comments():
|
||||
|
||||
# Set up the mock to return different responses for different calls
|
||||
# First call is for issues, second call is for comments
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_issues_response,
|
||||
mock_comments_response,
|
||||
mock_comments_response,
|
||||
@@ -64,7 +64,7 @@ def test_get_converted_issues_handles_empty_body():
|
||||
mock_comments_response = MagicMock()
|
||||
mock_comments_response.json.return_value = []
|
||||
# Set up the mock to return different responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_issues_response,
|
||||
mock_comments_response,
|
||||
mock_comments_response,
|
||||
@@ -139,7 +139,7 @@ def test_pr_handler_get_converted_issues_with_comments():
|
||||
'description': 'This is additional context from an externally referenced issue.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_empty_response, # Third call for related issues
|
||||
@@ -273,7 +273,7 @@ def test_pr_handler_get_converted_issues_with_specific_thread_comment():
|
||||
mock_empty_response = MagicMock()
|
||||
mock_empty_response.json.return_value = []
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_empty_response, # Third call for related issues
|
||||
@@ -392,7 +392,7 @@ def test_pr_handler_get_converted_issues_with_specific_review_thread_comment():
|
||||
mock_empty_response = MagicMock()
|
||||
mock_empty_response.json.return_value = []
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_empty_response, # Third call for related issues
|
||||
@@ -529,7 +529,7 @@ def test_pr_handler_get_converted_issues_with_specific_comment_and_issue_refs():
|
||||
'description': 'External context #2.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_empty_response, # Third call for related issues
|
||||
@@ -638,7 +638,7 @@ def test_pr_handler_get_converted_issues_with_duplicate_issue_refs():
|
||||
'description': 'External context #2.'
|
||||
}
|
||||
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
mock_prs_response, # First call for PRs
|
||||
mock_empty_response, # Second call for PRs (empty page)
|
||||
mock_empty_response, # Third call for related issues
|
||||
|
||||
@@ -43,7 +43,7 @@ def test_handle_nonexistent_issue_reference():
|
||||
|
||||
# Mock the requests.get to simulate a 404 error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPError(
|
||||
mock_response.raise_for_status.conversation_ide_effect = httpx.HTTPError(
|
||||
'404 Client Error: Not Found'
|
||||
)
|
||||
|
||||
@@ -70,7 +70,7 @@ def test_handle_rate_limit_error():
|
||||
|
||||
# Mock the requests.get to simulate a rate limit error
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPError(
|
||||
mock_response.raise_for_status.conversation_ide_effect = httpx.HTTPError(
|
||||
'403 Client Error: Rate Limit Exceeded'
|
||||
)
|
||||
|
||||
@@ -195,7 +195,7 @@ def test_guess_success_rate_limit_wait_time(mock_litellm_completion, default_con
|
||||
|
||||
with patch('time.sleep') as mock_sleep:
|
||||
# Simulate a rate limit error followed by a successful response
|
||||
mock_litellm_completion.side_effect = [
|
||||
mock_litellm_completion.conversation_ide_effect = [
|
||||
RateLimitError(
|
||||
'Rate limit exceeded', llm_provider='test_provider', model='test_model'
|
||||
),
|
||||
@@ -251,7 +251,7 @@ def test_guess_success_rate_limit_wait_time(mock_litellm_completion, default_con
|
||||
def test_guess_success_exhausts_retries(mock_completion, default_config):
|
||||
"""Test the retry mechanism in guess_success exhausts retries and raises an error."""
|
||||
# Simulate persistent rate limit errors by always raising RateLimitError
|
||||
mock_completion.side_effect = RateLimitError(
|
||||
mock_completion.conversation_ide_effect = RateLimitError(
|
||||
'Rate limit exceeded', llm_provider='test_provider', model='test_model'
|
||||
)
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ def test_initialize_runtime(default_mock_args, mock_gitlab_token):
|
||||
mock_runtime = MagicMock()
|
||||
|
||||
if os.getenv('GITLAB_CI') == 'true':
|
||||
mock_runtime.run_action.side_effect = [
|
||||
mock_runtime.run_action.conversation_ide_effect = [
|
||||
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
|
||||
create_cmd_output(
|
||||
exit_code=0, content='', command='sudo chown -R 1001:0 /workspace/*'
|
||||
@@ -128,7 +128,7 @@ def test_initialize_runtime(default_mock_args, mock_gitlab_token):
|
||||
),
|
||||
]
|
||||
else:
|
||||
mock_runtime.run_action.side_effect = [
|
||||
mock_runtime.run_action.conversation_ide_effect = [
|
||||
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
|
||||
create_cmd_output(
|
||||
exit_code=0, content='', command='git config --global core.pager ""'
|
||||
@@ -191,7 +191,7 @@ def test_download_issues_from_gitlab():
|
||||
)
|
||||
|
||||
mock_issues_response = MagicMock()
|
||||
mock_issues_response.json.side_effect = [
|
||||
mock_issues_response.json.conversation_ide_effect = [
|
||||
[
|
||||
{'iid': 1, 'title': 'Issue 1', 'description': 'This is an issue'},
|
||||
{
|
||||
@@ -232,7 +232,7 @@ def test_download_pr_from_gitlab():
|
||||
llm_config = LLMConfig(model='test', api_key='test')
|
||||
handler = ServiceContextPR(GitlabPRHandler('owner', 'repo', 'token'), llm_config)
|
||||
mock_pr_response = MagicMock()
|
||||
mock_pr_response.json.side_effect = [
|
||||
mock_pr_response.json.conversation_ide_effect = [
|
||||
[
|
||||
{
|
||||
'iid': 1,
|
||||
@@ -272,7 +272,7 @@ def test_download_pr_from_gitlab():
|
||||
|
||||
# Mock for GraphQL request (for download_pr_metadata)
|
||||
mock_graphql_response = MagicMock()
|
||||
mock_graphql_response.json.side_effect = lambda: {
|
||||
mock_graphql_response.json.conversation_ide_effect = lambda: {
|
||||
'data': {
|
||||
'project': {
|
||||
'mergeRequest': {
|
||||
@@ -377,7 +377,7 @@ def test_download_pr_from_gitlab():
|
||||
@pytest.mark.asyncio
|
||||
async def test_complete_runtime(default_mock_args, mock_gitlab_token):
|
||||
mock_runtime = MagicMock()
|
||||
mock_runtime.run_action.side_effect = [
|
||||
mock_runtime.run_action.conversation_ide_effect = [
|
||||
create_cmd_output(exit_code=0, content='', command='cd /workspace'),
|
||||
create_cmd_output(
|
||||
exit_code=0, content='', command='git config --global core.pager ""'
|
||||
@@ -510,7 +510,7 @@ async def test_process_issue(
|
||||
# Configure run_controller mock based on test case
|
||||
mock_run_controller = AsyncMock()
|
||||
if test_case.get('run_controller_raises'):
|
||||
mock_run_controller.side_effect = test_case['run_controller_raises']
|
||||
mock_run_controller.conversation_ide_effect = test_case['run_controller_raises']
|
||||
else:
|
||||
mock_run_controller.return_value = test_case['run_controller_return']
|
||||
|
||||
@@ -958,7 +958,7 @@ def test_download_issue_with_specific_comment():
|
||||
|
||||
# Mock issue and comment responses
|
||||
mock_issue_response = MagicMock()
|
||||
mock_issue_response.json.side_effect = [
|
||||
mock_issue_response.json.conversation_ide_effect = [
|
||||
[
|
||||
{'iid': 1, 'title': 'Issue 1', 'description': 'This is an issue'},
|
||||
],
|
||||
|
||||
@@ -365,13 +365,13 @@ def test_send_pull_request(
|
||||
|
||||
# Mock API responses based on whether target_branch is specified
|
||||
if target_branch:
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=200), # Target branch exists
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
|
||||
]
|
||||
else:
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
|
||||
@@ -382,7 +382,7 @@ def test_send_pull_request(
|
||||
}
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -466,14 +466,14 @@ def test_send_pull_request_with_reviewer(
|
||||
reviewer = 'test-reviewer'
|
||||
|
||||
# Mock API responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(json=lambda: {'default_branch': 'main'}), # Get default branch
|
||||
MagicMock(json=lambda: [{'id': 123}]), # Get user data
|
||||
]
|
||||
|
||||
# Mock PR creation response
|
||||
mock_post.side_effect = [
|
||||
mock_post.conversation_ide_effect = [
|
||||
MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
@@ -484,12 +484,12 @@ def test_send_pull_request_with_reviewer(
|
||||
]
|
||||
|
||||
# Mock request reviwers response
|
||||
mock_put.side_effect = [
|
||||
mock_put.conversation_ide_effect = [
|
||||
MagicMock(status_code=200), # Reviewer request
|
||||
]
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -534,7 +534,7 @@ def test_send_pull_request_invalid_target_branch(
|
||||
repo_path = os.path.join(mock_output_dir, 'repo')
|
||||
|
||||
# Mock API response for non-existent branch
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=404), # Branch doesn't exist
|
||||
MagicMock(status_code=404), # Target branch doesn't exist
|
||||
]
|
||||
@@ -569,7 +569,7 @@ def test_send_pull_request_git_push_failure(
|
||||
mock_get.return_value = MagicMock(json=lambda: {'default_branch': 'main'})
|
||||
|
||||
# Mock the subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=1, stderr='Error: failed to push some refs'), # git push
|
||||
]
|
||||
@@ -629,7 +629,7 @@ def test_send_pull_request_permission_error(
|
||||
mock_post.return_value.status_code = 403
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -937,7 +937,7 @@ def test_send_pull_request_branch_naming(
|
||||
repo_path = os.path.join(mock_output_dir, 'repo')
|
||||
|
||||
# Mock API responses
|
||||
mock_get.side_effect = [
|
||||
mock_get.conversation_ide_effect = [
|
||||
MagicMock(status_code=200), # First branch exists
|
||||
MagicMock(status_code=200), # Second branch exists
|
||||
MagicMock(status_code=404), # Third branch doesn't exist
|
||||
@@ -946,7 +946,7 @@ def test_send_pull_request_branch_naming(
|
||||
]
|
||||
|
||||
# Mock subprocess.run calls
|
||||
mock_run.side_effect = [
|
||||
mock_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0), # git checkout -b
|
||||
MagicMock(returncode=0), # git push
|
||||
]
|
||||
@@ -1026,7 +1026,7 @@ def test_main(
|
||||
mock_parser.return_value.parse_args.return_value = mock_args
|
||||
|
||||
# Setup environment variables
|
||||
mock_getenv.side_effect = (
|
||||
mock_getenv.conversation_ide_effect = (
|
||||
lambda key, default=None: 'mock_token' if key == 'GITLAB_TOKEN' else default
|
||||
)
|
||||
|
||||
@@ -1080,7 +1080,7 @@ def test_main(
|
||||
|
||||
# Test for invalid token
|
||||
mock_args.issue_number = '42' # Reset to valid issue number
|
||||
mock_getenv.side_effect = (
|
||||
mock_getenv.conversation_ide_effect = (
|
||||
lambda key, default=None: None
|
||||
) # Return None for all env vars
|
||||
with pytest.raises(ValueError, match='token is not set'):
|
||||
@@ -1140,7 +1140,7 @@ def test_make_commit_no_changes(mock_subprocess_run):
|
||||
)
|
||||
|
||||
# Mock subprocess.run to simulate no changes in the repo
|
||||
mock_subprocess_run.side_effect = [
|
||||
mock_subprocess_run.conversation_ide_effect = [
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=0),
|
||||
MagicMock(returncode=1, stdout=''), # git status --porcelain (no changes)
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
from typing import Type
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from pydantic import SecretStr
|
||||
|
||||
@@ -8,11 +5,11 @@ from openhands.core.config import LLMConfig
|
||||
from openhands.integrations.provider import ProviderType
|
||||
from openhands.resolver.interfaces.github import GithubIssueHandler, GithubPRHandler
|
||||
from openhands.resolver.interfaces.gitlab import GitlabIssueHandler, GitlabPRHandler
|
||||
from openhands.resolver.issue_handler_factory import IssueHandlerFactory
|
||||
from openhands.resolver.interfaces.issue_definitions import (
|
||||
ServiceContextIssue,
|
||||
ServiceContextPR,
|
||||
)
|
||||
from openhands.resolver.issue_handler_factory import IssueHandlerFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -45,33 +42,29 @@ test_cases = [
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'platform,issue_type,expected_context_type,expected_handler_type',
|
||||
test_cases
|
||||
'platform,issue_type,expected_context_type,expected_handler_type', test_cases
|
||||
)
|
||||
def test_handler_creation(
|
||||
factory_params,
|
||||
platform: ProviderType,
|
||||
issue_type: str,
|
||||
expected_context_type: Type,
|
||||
expected_handler_type: Type,
|
||||
expected_context_type: type,
|
||||
expected_handler_type: type,
|
||||
):
|
||||
factory = IssueHandlerFactory(
|
||||
**factory_params,
|
||||
platform=platform,
|
||||
issue_type=issue_type
|
||||
**factory_params, platform=platform, issue_type=issue_type
|
||||
)
|
||||
|
||||
|
||||
handler = factory.create()
|
||||
|
||||
|
||||
assert isinstance(handler, expected_context_type)
|
||||
assert isinstance(handler._strategy, expected_handler_type)
|
||||
|
||||
|
||||
def test_invalid_issue_type(factory_params):
|
||||
factory = IssueHandlerFactory(
|
||||
**factory_params,
|
||||
platform=ProviderType.GITHUB,
|
||||
issue_type='invalid'
|
||||
**factory_params, platform=ProviderType.GITHUB, issue_type='invalid'
|
||||
)
|
||||
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid issue type: invalid'):
|
||||
factory.create()
|
||||
factory.create()
|
||||
|
||||
@@ -122,7 +122,7 @@ async def test_async_completion_with_user_cancellation(cancel_delay):
|
||||
with patch.object(
|
||||
AsyncLLM, '_call_acompletion', new_callable=AsyncMock
|
||||
) as mock_call_acompletion:
|
||||
mock_call_acompletion.side_effect = mock_acompletion
|
||||
mock_call_acompletion.conversation_ide_effect = mock_acompletion
|
||||
test_llm = _get_llm(AsyncLLM)
|
||||
|
||||
async def cancel_after_delay():
|
||||
|
||||
@@ -76,7 +76,9 @@ def mock_agent():
|
||||
def mock_event_stream():
|
||||
mock = MagicMock(
|
||||
spec=EventStream,
|
||||
event_stream=EventStream(sid='test', file_store=InMemoryFileStore({})),
|
||||
event_stream=EventStream(
|
||||
conversation_id='test', file_store=InMemoryFileStore({})
|
||||
),
|
||||
)
|
||||
mock.get_latest_event_id.return_value = 0
|
||||
return mock
|
||||
@@ -84,7 +86,7 @@ def mock_event_stream():
|
||||
|
||||
@pytest.fixture
|
||||
def test_event_stream():
|
||||
event_stream = EventStream(sid='test', file_store=InMemoryFileStore({}))
|
||||
event_stream = EventStream(conversation_id='test', file_store=InMemoryFileStore({}))
|
||||
return event_stream
|
||||
|
||||
|
||||
@@ -129,7 +131,7 @@ async def test_set_agent_state(mock_agent, mock_event_stream):
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -147,7 +149,7 @@ async def test_on_event_message_action(mock_agent, mock_event_stream):
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -164,7 +166,7 @@ async def test_on_event_change_agent_state_action(mock_agent, mock_event_stream)
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -182,7 +184,7 @@ async def test_react_to_exception(mock_agent, mock_event_stream, mock_status_cal
|
||||
event_stream=mock_event_stream,
|
||||
status_callback=mock_status_callback,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -202,7 +204,7 @@ async def test_react_to_content_policy_violation(
|
||||
event_stream=mock_event_stream,
|
||||
status_callback=mock_status_callback,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -274,7 +276,7 @@ async def test_run_controller_with_fatal_error(
|
||||
config=config,
|
||||
initial_user_action=MessageAction(content='Test message'),
|
||||
runtime=runtime,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
agent=mock_agent,
|
||||
fake_user_response_fn=lambda _: 'repeat',
|
||||
memory=mock_memory,
|
||||
@@ -341,7 +343,7 @@ async def test_run_controller_stop_with_stuck(
|
||||
config=config,
|
||||
initial_user_action=MessageAction(content='Test message'),
|
||||
runtime=runtime,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
agent=mock_agent,
|
||||
fake_user_response_fn=lambda _: 'repeat',
|
||||
memory=mock_memory,
|
||||
@@ -384,7 +386,7 @@ async def test_max_iterations_extension(mock_agent, mock_event_stream):
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=False,
|
||||
initial_state=initial_state,
|
||||
@@ -419,7 +421,7 @@ async def test_max_iterations_extension(mock_agent, mock_event_stream):
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
initial_state=initial_state,
|
||||
@@ -451,7 +453,7 @@ async def test_step_max_budget(mock_agent, mock_event_stream):
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
max_budget_per_task=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=False,
|
||||
)
|
||||
@@ -471,7 +473,7 @@ async def test_step_max_budget_headless(mock_agent, mock_event_stream):
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
max_budget_per_task=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -492,7 +494,7 @@ async def test_reset_with_pending_action_no_observation(mock_agent, mock_event_s
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -541,7 +543,7 @@ async def test_reset_with_pending_action_existing_observation(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -583,7 +585,7 @@ async def test_reset_without_pending_action(mock_agent, mock_event_stream):
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -614,7 +616,7 @@ async def test_reset_with_pending_action_no_metadata(
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -701,7 +703,7 @@ async def test_run_controller_max_iterations_has_metrics(
|
||||
config=config,
|
||||
initial_user_action=MessageAction(content='Test message'),
|
||||
runtime=runtime,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
agent=mock_agent,
|
||||
fake_user_response_fn=lambda _: 'repeat',
|
||||
memory=mock_memory,
|
||||
@@ -735,7 +737,7 @@ async def test_notify_on_llm_retry(mock_agent, mock_event_stream, mock_status_ca
|
||||
event_stream=mock_event_stream,
|
||||
status_callback=mock_status_callback,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -808,7 +810,7 @@ async def test_context_window_exceeded_error_handling(
|
||||
config=OpenHandsConfig(max_iterations=max_iterations),
|
||||
initial_user_action=MessageAction(content='INITIAL'),
|
||||
runtime=mock_runtime,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
agent=mock_agent,
|
||||
fake_user_response_fn=lambda _: 'repeat',
|
||||
memory=mock_memory,
|
||||
@@ -946,7 +948,7 @@ async def test_run_controller_with_context_window_exceeded_with_truncation(
|
||||
config=OpenHandsConfig(max_iterations=5),
|
||||
initial_user_action=MessageAction(content='INITIAL'),
|
||||
runtime=mock_runtime,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
agent=mock_agent,
|
||||
fake_user_response_fn=lambda _: 'repeat',
|
||||
memory=mock_memory,
|
||||
@@ -1022,7 +1024,7 @@ async def test_run_controller_with_context_window_exceeded_without_truncation(
|
||||
config=OpenHandsConfig(max_iterations=3),
|
||||
initial_user_action=MessageAction(content='INITIAL'),
|
||||
runtime=mock_runtime,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
agent=mock_agent,
|
||||
fake_user_response_fn=lambda _: 'repeat',
|
||||
memory=mock_memory,
|
||||
@@ -1081,7 +1083,7 @@ async def test_run_controller_with_memory_error(test_event_stream, mock_agent):
|
||||
runtime.event_stream = event_stream
|
||||
|
||||
# Create a real Memory instance
|
||||
memory = Memory(event_stream=event_stream, sid='test-memory')
|
||||
memory = Memory(event_stream=event_stream, conversation_id='test-memory')
|
||||
|
||||
# Patch the _find_microagent_knowledge method to raise our test exception
|
||||
def mock_find_microagent_knowledge(*args, **kwargs):
|
||||
@@ -1094,7 +1096,7 @@ async def test_run_controller_with_memory_error(test_event_stream, mock_agent):
|
||||
config=config,
|
||||
initial_user_action=MessageAction(content='Test message'),
|
||||
runtime=runtime,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
agent=mock_agent,
|
||||
fake_user_response_fn=lambda _: 'repeat',
|
||||
memory=memory,
|
||||
@@ -1109,7 +1111,7 @@ async def test_run_controller_with_memory_error(test_event_stream, mock_agent):
|
||||
async def test_action_metrics_copy(mock_agent):
|
||||
# Setup
|
||||
file_store = InMemoryFileStore({})
|
||||
event_stream = EventStream(sid='test', file_store=file_store)
|
||||
event_stream = EventStream(conversation_id='test', file_store=file_store)
|
||||
|
||||
# Create agent with metrics
|
||||
mock_agent.llm = MagicMock(spec=LLM)
|
||||
@@ -1169,7 +1171,7 @@ async def test_action_metrics_copy(mock_agent):
|
||||
agent=mock_agent,
|
||||
event_stream=event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -1278,7 +1280,7 @@ async def test_condenser_metrics_included(mock_agent, test_event_stream):
|
||||
agent=mock_agent,
|
||||
event_stream=test_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -1336,7 +1338,7 @@ async def test_first_user_message_with_identical_content(test_event_stream, mock
|
||||
agent=mock_agent,
|
||||
event_stream=test_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
@@ -1388,10 +1390,10 @@ async def test_agent_controller_processes_null_observation_with_cause():
|
||||
"""
|
||||
# Create an in-memory file store and real event stream
|
||||
file_store = InMemoryFileStore()
|
||||
event_stream = EventStream(sid='test-session', file_store=file_store)
|
||||
event_stream = EventStream(conversation_id='test-session', file_store=file_store)
|
||||
|
||||
# Create a Memory instance - not used directly in this test but needed for setup
|
||||
Memory(event_stream=event_stream, sid='test-session')
|
||||
Memory(event_stream=event_stream, conversation_id='test-session')
|
||||
|
||||
# Create a mock agent with necessary attributes
|
||||
mock_agent = MagicMock(spec=Agent)
|
||||
@@ -1404,7 +1406,7 @@ async def test_agent_controller_processes_null_observation_with_cause():
|
||||
agent=mock_agent,
|
||||
event_stream=event_stream,
|
||||
max_iterations=10,
|
||||
sid='test-session',
|
||||
conversation_id='test-session',
|
||||
)
|
||||
|
||||
# Patch the controller's step method to track calls
|
||||
@@ -1468,14 +1470,14 @@ def test_agent_controller_should_step_with_null_observation_cause_zero(mock_agen
|
||||
"""Test that AgentController's should_step method returns False for NullObservation with cause = 0."""
|
||||
# Create a mock event stream
|
||||
file_store = InMemoryFileStore()
|
||||
event_stream = EventStream(sid='test-session', file_store=file_store)
|
||||
event_stream = EventStream(conversation_id='test-session', file_store=file_store)
|
||||
|
||||
# Create an agent controller
|
||||
controller = AgentController(
|
||||
agent=mock_agent,
|
||||
event_stream=event_stream,
|
||||
max_iterations=10,
|
||||
sid='test-session',
|
||||
conversation_id='test-session',
|
||||
)
|
||||
|
||||
# Create a NullObservation with cause = 0
|
||||
@@ -1548,7 +1550,7 @@ async def test_openrouter_context_window_exceeded_error(
|
||||
agent=mock_agent,
|
||||
event_stream=test_event_stream,
|
||||
max_iterations=max_iterations,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
status_callback=mock_status_callback,
|
||||
|
||||
@@ -30,9 +30,9 @@ from openhands.storage.memory import InMemoryFileStore
|
||||
@pytest.fixture
|
||||
def mock_event_stream():
|
||||
"""Creates an event stream in memory."""
|
||||
sid = f'test-{uuid4()}'
|
||||
conversation_id = f'test-{uuid4()}'
|
||||
file_store = InMemoryFileStore({})
|
||||
return EventStream(sid=sid, file_store=file_store)
|
||||
return EventStream(conversation_id=conversation_id, file_store=file_store)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -92,7 +92,7 @@ async def test_delegation_flow(mock_parent_agent, mock_child_agent, mock_event_s
|
||||
agent=mock_parent_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='parent',
|
||||
conversation_id='parent',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
initial_state=parent_state,
|
||||
@@ -191,7 +191,7 @@ async def test_delegate_step_different_states(
|
||||
agent=mock_parent_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test',
|
||||
conversation_id='test',
|
||||
confirmation_mode=False,
|
||||
headless_mode=True,
|
||||
)
|
||||
|
||||
@@ -94,7 +94,7 @@ def controller_fixture():
|
||||
mock_agent.config = OpenHandsConfig().get_agent_config('CodeActAgent')
|
||||
|
||||
mock_event_stream = MagicMock(spec=EventStream)
|
||||
mock_event_stream.sid = 'test_sid'
|
||||
mock_event_stream.conversation_id = 'test_sid'
|
||||
mock_event_stream.file_store = InMemoryFileStore({})
|
||||
# Ensure get_latest_event_id returns an integer
|
||||
mock_event_stream.get_latest_event_id.return_value = -1
|
||||
@@ -103,7 +103,7 @@ def controller_fixture():
|
||||
agent=mock_agent,
|
||||
event_stream=mock_event_stream,
|
||||
max_iterations=10,
|
||||
sid='test_sid',
|
||||
conversation_id='test_sid',
|
||||
)
|
||||
controller.state = State(session_id='test_sid')
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ async def test_agent_session_start_with_no_state(mock_agent):
|
||||
# Setup
|
||||
file_store = InMemoryFileStore({})
|
||||
session = AgentSession(
|
||||
sid='test-session',
|
||||
conversation_id='test-session',
|
||||
file_store=file_store,
|
||||
)
|
||||
|
||||
@@ -90,7 +90,7 @@ async def test_agent_session_start_with_no_state(mock_agent):
|
||||
super().set_initial_state(*args, state=state, **kwargs)
|
||||
|
||||
# Create a real Memory instance with the mock event stream
|
||||
memory = Memory(event_stream=mock_event_stream, sid='test-session')
|
||||
memory = Memory(event_stream=mock_event_stream, conversation_id='test-session')
|
||||
memory.microagents_dir = 'test-dir'
|
||||
|
||||
# Patch AgentController and State.restore_from_session to fail; patch Memory in AgentSession
|
||||
@@ -144,7 +144,7 @@ async def test_agent_session_start_with_restored_state(mock_agent):
|
||||
# Setup
|
||||
file_store = InMemoryFileStore({})
|
||||
session = AgentSession(
|
||||
sid='test-session',
|
||||
conversation_id='test-session',
|
||||
file_store=file_store,
|
||||
)
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ async def test_auto_generate_title_fallback():
|
||||
# Mock the LLM to raise an exception
|
||||
with patch('openhands.utils.conversation_summary.LLM') as mock_llm_cls:
|
||||
mock_llm = mock_llm_cls.return_value
|
||||
mock_llm.completion.side_effect = Exception('Test error')
|
||||
mock_llm.completion.conversation_ide_effect = Exception('Test error')
|
||||
|
||||
# Create test settings with LLM config
|
||||
settings = Settings(
|
||||
|
||||
@@ -99,7 +99,7 @@ async def test_cleanup_session_handles_exceptions(
|
||||
):
|
||||
"""Test that cleanup_session handles exceptions during cleanup gracefully."""
|
||||
loop = asyncio.get_running_loop()
|
||||
mock_controller.close.side_effect = Exception('Test cleanup error')
|
||||
mock_controller.close.conversation_ide_effect = Exception('Test cleanup error')
|
||||
with patch('openhands.cli.main.logger.error') as mock_log_error:
|
||||
await cli.cleanup_session(loop, mock_agent, mock_runtime, mock_controller)
|
||||
|
||||
@@ -455,7 +455,7 @@ async def test_main_with_task(
|
||||
mock_read_task.return_value = task_str
|
||||
|
||||
# Mock run_session to return True and then False (one new session requested)
|
||||
mock_run_session.side_effect = [True, False]
|
||||
mock_run_session.conversation_ide_effect = [True, False]
|
||||
|
||||
# Run the function
|
||||
await cli.main(loop)
|
||||
@@ -623,7 +623,7 @@ async def test_run_session_with_name_attempts_state_restore(
|
||||
|
||||
mock_runtime = AsyncMock()
|
||||
mock_runtime.event_stream = MagicMock() # This is the EventStream instance
|
||||
mock_runtime.event_stream.sid = expected_sid
|
||||
mock_runtime.event_stream.conversation_id = expected_sid
|
||||
mock_runtime.event_stream.file_store = (
|
||||
MagicMock()
|
||||
) # Mock the file_store attribute on the EventStream
|
||||
@@ -660,7 +660,7 @@ async def test_run_session_with_name_attempts_state_restore(
|
||||
mock_generate_sid.assert_called_once_with(mock_config, test_session_name)
|
||||
|
||||
# State.restore_from_session is called from within core.setup.create_controller,
|
||||
# which receives the runtime object (and thus its event_stream with sid and file_store).
|
||||
# which receives the runtime object (and thus its event_stream with conversation_id and file_store).
|
||||
mock_restore_from_session.assert_called_once_with(
|
||||
expected_sid, mock_runtime.event_stream.file_store
|
||||
)
|
||||
|
||||
@@ -26,7 +26,7 @@ class TestHandleCommands:
|
||||
def mock_dependencies(self):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
conversation_id = 'test-session-id'
|
||||
config = MagicMock(spec=OpenHandsConfig)
|
||||
current_dir = '/test/dir'
|
||||
settings_store = MagicMock(spec=FileSettingsStore)
|
||||
@@ -34,7 +34,7 @@ class TestHandleCommands:
|
||||
return {
|
||||
'event_stream': event_stream,
|
||||
'usage_metrics': usage_metrics,
|
||||
'sid': sid,
|
||||
'conversation_id': conversation_id,
|
||||
'config': config,
|
||||
'current_dir': current_dir,
|
||||
'settings_store': settings_store,
|
||||
@@ -52,7 +52,7 @@ class TestHandleCommands:
|
||||
mock_handle_exit.assert_called_once_with(
|
||||
mock_dependencies['event_stream'],
|
||||
mock_dependencies['usage_metrics'],
|
||||
mock_dependencies['sid'],
|
||||
mock_dependencies['conversation_id'],
|
||||
)
|
||||
assert close_repl is True
|
||||
assert reload_microagents is False
|
||||
@@ -100,7 +100,7 @@ class TestHandleCommands:
|
||||
)
|
||||
|
||||
mock_handle_status.assert_called_once_with(
|
||||
mock_dependencies['usage_metrics'], mock_dependencies['sid']
|
||||
mock_dependencies['usage_metrics'], mock_dependencies['conversation_id']
|
||||
)
|
||||
assert close_repl is False
|
||||
assert reload_microagents is False
|
||||
@@ -118,7 +118,7 @@ class TestHandleCommands:
|
||||
mock_handle_new.assert_called_once_with(
|
||||
mock_dependencies['event_stream'],
|
||||
mock_dependencies['usage_metrics'],
|
||||
mock_dependencies['sid'],
|
||||
mock_dependencies['conversation_id'],
|
||||
)
|
||||
assert close_repl is True
|
||||
assert reload_microagents is False
|
||||
@@ -168,13 +168,13 @@ class TestHandleExitCommand:
|
||||
def test_exit_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
conversation_id = 'test-session-id'
|
||||
|
||||
# Mock user confirming exit
|
||||
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
|
||||
|
||||
# Call the function under test
|
||||
result = handle_exit_command(event_stream, usage_metrics, sid)
|
||||
result = handle_exit_command(event_stream, usage_metrics, conversation_id)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
@@ -185,7 +185,7 @@ class TestHandleExitCommand:
|
||||
assert args[0].agent_state == AgentState.STOPPED
|
||||
assert args[1] == EventSource.ENVIRONMENT
|
||||
|
||||
mock_display_shutdown.assert_called_once_with(usage_metrics, sid)
|
||||
mock_display_shutdown.assert_called_once_with(usage_metrics, conversation_id)
|
||||
assert result is True
|
||||
|
||||
@patch('openhands.cli.commands.cli_confirm')
|
||||
@@ -193,13 +193,13 @@ class TestHandleExitCommand:
|
||||
def test_exit_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
conversation_id = 'test-session-id'
|
||||
|
||||
# Mock user rejecting exit
|
||||
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
|
||||
|
||||
# Call the function under test
|
||||
result = handle_exit_command(event_stream, usage_metrics, sid)
|
||||
result = handle_exit_command(event_stream, usage_metrics, conversation_id)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
@@ -219,11 +219,11 @@ class TestHandleStatusCommand:
|
||||
@patch('openhands.cli.commands.display_status')
|
||||
def test_status_command(self, mock_display_status):
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
conversation_id = 'test-session-id'
|
||||
|
||||
handle_status_command(usage_metrics, sid)
|
||||
handle_status_command(usage_metrics, conversation_id)
|
||||
|
||||
mock_display_status.assert_called_once_with(usage_metrics, sid)
|
||||
mock_display_status.assert_called_once_with(usage_metrics, conversation_id)
|
||||
|
||||
|
||||
class TestHandleNewCommand:
|
||||
@@ -232,13 +232,15 @@ class TestHandleNewCommand:
|
||||
def test_new_with_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
conversation_id = 'test-session-id'
|
||||
|
||||
# Mock user confirming new session
|
||||
mock_cli_confirm.return_value = 0 # First option, which is "Yes, proceed"
|
||||
|
||||
# Call the function under test
|
||||
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
|
||||
close_repl, new_session = handle_new_command(
|
||||
event_stream, usage_metrics, conversation_id
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
@@ -249,7 +251,7 @@ class TestHandleNewCommand:
|
||||
assert args[0].agent_state == AgentState.STOPPED
|
||||
assert args[1] == EventSource.ENVIRONMENT
|
||||
|
||||
mock_display_shutdown.assert_called_once_with(usage_metrics, sid)
|
||||
mock_display_shutdown.assert_called_once_with(usage_metrics, conversation_id)
|
||||
assert close_repl is True
|
||||
assert new_session is True
|
||||
|
||||
@@ -258,13 +260,15 @@ class TestHandleNewCommand:
|
||||
def test_new_without_confirmation(self, mock_display_shutdown, mock_cli_confirm):
|
||||
event_stream = MagicMock(spec=EventStream)
|
||||
usage_metrics = MagicMock(spec=UsageMetrics)
|
||||
sid = 'test-session-id'
|
||||
conversation_id = 'test-session-id'
|
||||
|
||||
# Mock user rejecting new session
|
||||
mock_cli_confirm.return_value = 1 # Second option, which is "No, dismiss"
|
||||
|
||||
# Call the function under test
|
||||
close_repl, new_session = handle_new_command(event_stream, usage_metrics, sid)
|
||||
close_repl, new_session = handle_new_command(
|
||||
event_stream, usage_metrics, conversation_id
|
||||
)
|
||||
|
||||
# Verify correct behavior
|
||||
mock_cli_confirm.assert_called_once()
|
||||
|
||||
@@ -44,7 +44,7 @@ class TestProcessAgentPause:
|
||||
keys_ready_func = callback
|
||||
return mock_attach
|
||||
|
||||
mock_input.attach.side_effect = fake_attach
|
||||
mock_input.attach.conversation_ide_effect = fake_attach
|
||||
|
||||
# Create a task to run process_agent_pause
|
||||
task = asyncio.create_task(process_agent_pause(done, event_stream=MagicMock()))
|
||||
@@ -244,7 +244,7 @@ class TestCliCommandsPauseResume:
|
||||
message = '/resume'
|
||||
event_stream = MagicMock()
|
||||
usage_metrics = MagicMock()
|
||||
sid = 'test-session-id'
|
||||
conversation_id = 'test-session-id'
|
||||
config = MagicMock()
|
||||
current_dir = '/test/dir'
|
||||
settings_store = MagicMock()
|
||||
@@ -257,7 +257,7 @@ class TestCliCommandsPauseResume:
|
||||
message,
|
||||
event_stream,
|
||||
usage_metrics,
|
||||
sid,
|
||||
conversation_id,
|
||||
config,
|
||||
current_dir,
|
||||
settings_store,
|
||||
|
||||
@@ -366,7 +366,7 @@ class TestModifyLLMSettingsAdvanced:
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Mock user confirmations
|
||||
mock_confirm.side_effect = [
|
||||
mock_confirm.conversation_ide_effect = [
|
||||
0, # Enable confirmation mode
|
||||
0, # Enable memory condensation
|
||||
0, # Save settings
|
||||
@@ -498,7 +498,7 @@ class TestModifyLLMSettingsAdvanced:
|
||||
mock_session.return_value = session_instance
|
||||
|
||||
# Mock user confirmations
|
||||
mock_confirm.side_effect = [
|
||||
mock_confirm.conversation_ide_effect = [
|
||||
0, # Enable confirmation mode
|
||||
0, # Enable memory condensation
|
||||
1, # Reject saving settings
|
||||
|
||||
@@ -351,7 +351,7 @@ class TestReadConfirmationInput:
|
||||
self, mock_create_session
|
||||
):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.side_effect = KeyboardInterrupt
|
||||
mock_session.prompt_async.conversation_ide_effect = KeyboardInterrupt
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
@@ -361,7 +361,7 @@ class TestReadConfirmationInput:
|
||||
@patch('openhands.cli.tui.create_prompt_session')
|
||||
async def test_read_confirmation_input_eof_error(self, mock_create_session):
|
||||
mock_session = AsyncMock()
|
||||
mock_session.prompt_async.side_effect = EOFError
|
||||
mock_session.prompt_async.conversation_ide_effect = EOFError
|
||||
mock_create_session.return_value = mock_session
|
||||
|
||||
result = await read_confirmation_input()
|
||||
|
||||
@@ -63,7 +63,7 @@ def conversation_memory(agent_config):
|
||||
return ''
|
||||
return '\n'.join(agent.content for agent in triggered_agents)
|
||||
|
||||
prompt_manager.build_microagent_info.side_effect = build_microagent_info
|
||||
prompt_manager.build_microagent_info.conversation_ide_effect = build_microagent_info
|
||||
return ConversationMemory(agent_config, prompt_manager)
|
||||
|
||||
|
||||
|
||||
@@ -45,14 +45,18 @@ 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 = DockerRuntime(
|
||||
config, event_stream, conversation_id='test-conversation_id'
|
||||
)
|
||||
runtime.container = mock_docker_client.containers.get.return_value
|
||||
|
||||
# Act
|
||||
runtime.close()
|
||||
|
||||
# Assert
|
||||
mock_stop_containers.assert_called_once_with('openhands-runtime-test-sid')
|
||||
mock_stop_containers.assert_called_once_with(
|
||||
'openhands-runtime-test-conversation_id'
|
||||
)
|
||||
|
||||
|
||||
@patch('openhands.runtime.impl.docker.docker_runtime.stop_all_containers')
|
||||
@@ -61,7 +65,9 @@ def test_container_not_stopped_when_keep_runtime_alive_true(
|
||||
):
|
||||
# Arrange
|
||||
config.sandbox.keep_runtime_alive = True
|
||||
runtime = DockerRuntime(config, event_stream, sid='test-sid')
|
||||
runtime = DockerRuntime(
|
||||
config, event_stream, conversation_id='test-conversation_id'
|
||||
)
|
||||
runtime.container = mock_docker_client.containers.get.return_value
|
||||
|
||||
# Act
|
||||
|
||||
@@ -24,7 +24,9 @@ async def test_load_nonexistent_data(file_settings_store):
|
||||
'openhands.storage.data_models.settings.load_openhands_config',
|
||||
MagicMock(return_value=OpenHandsConfig()),
|
||||
):
|
||||
file_settings_store.file_store.read.side_effect = FileNotFoundError()
|
||||
file_settings_store.file_store.read.conversation_ide_effect = (
|
||||
FileNotFoundError()
|
||||
)
|
||||
assert await file_settings_store.load() is None
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ class TestGitHooks:
|
||||
return ErrorObservation(content='File not found')
|
||||
return ErrorObservation(content='Unexpected path')
|
||||
|
||||
mock_runtime.read.side_effect = mock_read
|
||||
mock_runtime.read.conversation_ide_effect = mock_read
|
||||
|
||||
mock_runtime.run_action.return_value = CmdOutputObservation(
|
||||
content='', exit_code=0, command='test command'
|
||||
@@ -69,7 +69,7 @@ class TestGitHooks:
|
||||
|
||||
def test_maybe_setup_git_hooks_no_script(self, mock_runtime):
|
||||
# Test when pre-commit script doesn't exist
|
||||
mock_runtime.read.side_effect = lambda action: ErrorObservation(
|
||||
mock_runtime.read.conversation_ide_effect = lambda action: ErrorObservation(
|
||||
content='File not found'
|
||||
)
|
||||
|
||||
@@ -98,7 +98,7 @@ class TestGitHooks:
|
||||
)
|
||||
return CmdOutputObservation(content='', exit_code=0, command=action.command)
|
||||
|
||||
mock_runtime.run_action.side_effect = mock_run_action
|
||||
mock_runtime.run_action.conversation_ide_effect = mock_run_action
|
||||
|
||||
Runtime.maybe_setup_git_hooks(mock_runtime)
|
||||
|
||||
@@ -129,7 +129,7 @@ class TestGitHooks:
|
||||
)
|
||||
return ErrorObservation(content='Unexpected path')
|
||||
|
||||
mock_runtime.read.side_effect = mock_read
|
||||
mock_runtime.read.conversation_ide_effect = mock_read
|
||||
|
||||
Runtime.maybe_setup_git_hooks(mock_runtime)
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ async def test_github_service_fetch_data():
|
||||
|
||||
# Test error handling with 401 status code
|
||||
mock_response.status_code = 401
|
||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||
mock_response.raise_for_status.conversation_ide_effect = httpx.HTTPStatusError(
|
||||
message='401 Unauthorized', request=Mock(), response=mock_response
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user