Compare commits

...

6 Commits

Author SHA1 Message Date
openhands
da828b8723 Fix merge conflicts with main branch 2025-05-28 20:22:04 +00:00
Robert Brennan
d3d8010d23 Merge branch 'main' into replace-sid-with-conversation-id 2025-05-28 10:29:23 -04:00
openhands
d298d99f78 Fix typo in test_exception_during_execution: conversation_ide_effect -> side_effect 2025-05-28 14:21:48 +00:00
openhands
550d003566 Fix test failures by correcting mock side_effect attribute 2025-05-28 13:39:07 +00:00
Robert Brennan
533ac9e988 Merge branch 'main' into replace-sid-with-conversation-id 2025-05-28 09:32:42 -04:00
openhands
30f0fff9a2 Replace all occurrences of sid with conversation_id 2025-05-28 13:04:41 +00:00
112 changed files with 858 additions and 682 deletions

2
.github/CODEOWNERS vendored
View File

@@ -5,7 +5,7 @@
/frontend/ @rbren @amanape
# Evaluation code owners
/evaluation/ @xingyaoww @neubig
/evaluation/ @xingyaoww @neubig
# Documentation code owners
/docs/ @mamoodi

View File

@@ -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

View File

@@ -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

View File

@@ -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/&lt;model-name&gt;).

View File

@@ -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/&lt;model-name&gt; 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).

View File

@@ -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.

View File

@@ -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

View File

@@ -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:
![image](./screenshots/1_select_power_user.png)
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`)

View File

@@ -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

View File

@@ -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/&lt;model-name&gt; like `openrouter/anthropic/claude-3.5-sonnet`).
* `API Key` to your OpenRouter API key.

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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'
)
)

View File

@@ -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,

View File

@@ -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");
});
});
});

View File

@@ -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!');
}
}

View File

@@ -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("");
});
});
});

View File

@@ -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()),
)

View File

@@ -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]

View File

@@ -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()),
)

View File

@@ -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

View File

@@ -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.

View File

@@ -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,

View File

@@ -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}')

View File

@@ -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,

View File

@@ -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

View File

@@ -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."""

View File

@@ -13,7 +13,7 @@ class EventStoreABC:
A stored list of events backing a conversation
"""
sid: str
conversation_id: str
user_id: str | None
@abstractmethod

View File

@@ -17,7 +17,7 @@ class NestedEventStore(EventStoreABC):
"""
base_url: str
sid: str
conversation_id: str
user_id: str | None
def search_events(

View File

@@ -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:

View File

@@ -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 = {}

View File

@@ -1 +1 @@
{{ issue_comment }}
{{ issue_comment }}

View File

@@ -1 +1 @@
Please fix issue number #{{ issue_number }} in your repository.
Please fix issue number #{{ issue_number }} in your repository.

View File

@@ -1 +1 @@
{{ pr_comment }}
{{ pr_comment }}

View File

@@ -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

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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:

View File

@@ -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(',')

View File

@@ -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()

View File

@@ -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

View File

@@ -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}')

View File

@@ -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,

View File

@@ -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',

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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()

View File

@@ -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':

View File

@@ -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:

View File

@@ -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)

View File

@@ -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:

View File

@@ -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'

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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})

View File

@@ -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}')

View File

@@ -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)

View File

@@ -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(

View File

@@ -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)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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}')

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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 ![First Image](https://sampleimage.com/image1.png)\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 ![image](https://sampleimage.com/sample.png)"""
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'},
],

View File

@@ -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)

View File

@@ -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

View File

@@ -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'
)

View File

@@ -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'},
],

View File

@@ -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)

View File

@@ -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()

View File

@@ -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():

View File

@@ -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,

View File

@@ -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,
)

View File

@@ -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')

View File

@@ -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,
)

View File

@@ -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(

View File

@@ -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
)

View File

@@ -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()

View File

@@ -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,

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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