Skip to content

Commit

Permalink
Merge branch 'main' into add-port-mappings
Browse files Browse the repository at this point in the history
  • Loading branch information
rbren authored Jan 1, 2025
2 parents 7aa527d + 3d4d66a commit 020df3c
Show file tree
Hide file tree
Showing 24 changed files with 470 additions and 218 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,10 @@ describe("BaseModal", () => {
expect(screen.getByText("Save")).toBeInTheDocument();
expect(screen.getByText("Cancel")).toBeInTheDocument();

await act(async () => {
await userEvent.click(screen.getByText("Save"));
});
await userEvent.click(screen.getByText("Save"));
expect(onPrimaryClickMock).toHaveBeenCalledTimes(1);

await act(async () => {
await userEvent.click(screen.getByText("Cancel"));
});
await userEvent.click(screen.getByText("Cancel"));
expect(onSecondaryClickMock).toHaveBeenCalledTimes(1);
});

Expand All @@ -80,9 +76,7 @@ describe("BaseModal", () => {
/>,
);

await act(async () => {
await userEvent.click(screen.getByText("Save"));
});
await userEvent.click(screen.getByText("Save"));
expect(onOpenChangeMock).toHaveBeenCalledTimes(1);
});

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/components/shared/buttons/icon-button.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Button } from "@nextui-org/react";
import React, { MouseEventHandler, ReactElement } from "react";
import React, { ReactElement } from "react";

export interface IconButtonProps {
icon: ReactElement;
onClick: MouseEventHandler<HTMLButtonElement>;
onClick: () => void;
ariaLabel: string;
testId?: string;
}
Expand All @@ -18,7 +18,7 @@ export function IconButton({
<Button
type="button"
variant="flat"
onClick={onClick}
onPress={onClick}
className="cursor-pointer text-[12px] bg-transparent aspect-square px-0 min-w-[20px] h-[20px]"
aria-label={ariaLabel}
data-testid={testId}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function FooterContent({ actions, closeModal }: FooterContentProps) {
key={label}
type="button"
isDisabled={isDisabled}
onClick={() => {
onPress={() => {
action();
if (closeAfterAction) closeModal();
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function SecurityInvariant() {
<>
<div className="flex justify-between items-center border-b border-neutral-600 mb-4 p-4">
<h2 className="text-2xl">{t(I18nKey.INVARIANT$LOG_LABEL)}</h2>
<Button onClick={() => exportTraces()} className="bg-neutral-700">
<Button onPress={() => exportTraces()} className="bg-neutral-700">
{t(I18nKey.INVARIANT$EXPORT_TRACE_LABEL)}
</Button>
</div>
Expand Down Expand Up @@ -162,7 +162,7 @@ function SecurityInvariant() {
<h2 className="text-2xl">{t(I18nKey.INVARIANT$POLICY_LABEL)}</h2>
<Button
className="bg-neutral-700"
onClick={() => updatePolicy({ policy })}
onPress={() => updatePolicy({ policy })}
>
{t(I18nKey.INVARIANT$UPDATE_POLICY_LABEL)}
</Button>
Expand All @@ -184,7 +184,7 @@ function SecurityInvariant() {
<h2 className="text-2xl">{t(I18nKey.INVARIANT$SETTINGS_LABEL)}</h2>
<Button
className="bg-neutral-700"
onClick={() => updateRiskSeverity({ riskSeverity: selectedRisk })}
onPress={() => updateRiskSeverity({ riskSeverity: selectedRisk })}
>
{t(I18nKey.INVARIANT$UPDATE_SETTINGS_LABEL)}
</Button>
Expand Down
4 changes: 1 addition & 3 deletions frontend/src/hooks/use-maybe-migrate-settings.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Sometimes we ship major changes, like a new default agent.

import React from "react";
import { useAuth } from "#/context/auth-context";
import { useSettingsUpToDate } from "#/context/settings-up-to-date-context";
import {
DEFAULT_SETTINGS,
Expand All @@ -12,7 +11,6 @@ import { useSaveSettings } from "./mutation/use-save-settings";

// In this case, we may want to override a previous choice made by the user.
export const useMaybeMigrateSettings = () => {
const { logout } = useAuth();
const { mutateAsync: saveSettings } = useSaveSettings();
const { isUpToDate } = useSettingsUpToDate();

Expand All @@ -35,7 +33,7 @@ export const useMaybeMigrateSettings = () => {
}

if (currentVersion < 4) {
logout();
// We used to log out here, but it's breaking things
}

// Only save settings if user already previously saved settings
Expand Down
13 changes: 1 addition & 12 deletions openhands/agenthub/codeact_agent/codeact_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -482,18 +482,7 @@ def _get_messages(self, state: State) -> list[Message]:
if message:
if message.role == 'user':
self.prompt_manager.enhance_message(message)
# handle error if the message is the SAME role as the previous message
# litellm.exceptions.BadRequestError: litellm.BadRequestError: OpenAIException - Error code: 400 - {'detail': 'Only supports u/a/u/a/u...'}
# there shouldn't be two consecutive messages from the same role
# NOTE: we shouldn't combine tool messages because each of them has a different tool_call_id
if (
messages
and messages[-1].role == message.role
and message.role != 'tool'
):
messages[-1].content.extend(message.content)
else:
messages.append(message)
messages.append(message)

if self.llm.is_caching_prompt_active():
# NOTE: this is only needed for anthropic
Expand Down
102 changes: 71 additions & 31 deletions openhands/controller/agent_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,6 @@
)
from openhands.events.serialization.event import truncate_content
from openhands.llm.llm import LLM
from openhands.utils.shutdown_listener import should_continue

# note: RESUME is only available on web GUI
TRAFFIC_CONTROL_REMINDER = (
Expand All @@ -64,7 +63,6 @@ class AgentController:
confirmation_mode: bool
agent_to_llm_config: dict[str, LLMConfig]
agent_configs: dict[str, AgentConfig]
agent_task: asyncio.Future | None = None
parent: 'AgentController | None' = None
delegate: 'AgentController | None' = None
_pending_action: Action | None = None
Expand Down Expand Up @@ -109,7 +107,6 @@ def __init__(
headless_mode: Whether the agent is run in headless mode.
status_callback: Optional callback function to handle status updates.
"""
self._step_lock = asyncio.Lock()
self.id = sid
self.agent = agent
self.headless_mode = headless_mode
Expand Down Expand Up @@ -199,32 +196,44 @@ async def _react_to_exception(
err_id = 'STATUS$ERROR_LLM_AUTHENTICATION'
self.status_callback('error', err_id, type(e).__name__ + ': ' + str(e))

async def start_step_loop(self):
"""The main loop for the agent's step-by-step execution."""
self.log('info', 'Starting step loop...')
while True:
if not self._is_awaiting_observation() and not should_continue():
break
if self._closed:
break
try:
await self._step()
except asyncio.CancelledError:
self.log('debug', 'AgentController task was cancelled')
break
except Exception as e:
traceback.print_exc()
self.log('error', f'Error while running the agent: {e}')
await self._react_to_exception(e)
def step(self):
asyncio.create_task(self._step_with_exception_handling())

await asyncio.sleep(0.1)
async def _step_with_exception_handling(self):
try:
await self._step()
except Exception as e:
traceback.print_exc()
self.log('error', f'Error while running the agent: {e}')
reported = RuntimeError(
'There was an unexpected error while running the agent.'
)
if isinstance(e, litellm.LLMError):
reported = e
await self._react_to_exception(reported)

async def on_event(self, event: Event) -> None:
def should_step(self, event: Event) -> bool:
if isinstance(event, Action):
if isinstance(event, MessageAction) and event.source == EventSource.USER:
return True
return False
if isinstance(event, Observation):
if isinstance(event, NullObservation) or isinstance(
event, AgentStateChangedObservation
):
return False
return True
return False

def on_event(self, event: Event) -> None:
"""Callback from the event stream. Notifies the controller of incoming events.
Args:
event (Event): The incoming event to process.
"""
asyncio.get_event_loop().run_until_complete(self._on_event(event))

async def _on_event(self, event: Event) -> None:
if hasattr(event, 'hidden') and event.hidden:
return

Expand All @@ -237,6 +246,9 @@ async def on_event(self, event: Event) -> None:
elif isinstance(event, Observation):
await self._handle_observation(event)

if self.should_step(event):
self.step()

async def _handle_action(self, action: Action) -> None:
"""Handles actions from the event stream.
Expand Down Expand Up @@ -335,6 +347,28 @@ async def _handle_message_action(self, action: MessageAction) -> None:
def _reset(self) -> None:
"""Resets the agent controller"""

# make sure there is an Observation with the tool call metadata to be recognized by the agent
# otherwise the pending action is found in history, but it's incomplete without an obs with tool result
if self._pending_action and hasattr(self._pending_action, 'tool_call_metadata'):
# find out if there already is an observation with the same tool call metadata
found_observation = False
for event in self.state.history:
if (
isinstance(event, Observation)
and event.tool_call_metadata
== self._pending_action.tool_call_metadata
):
found_observation = True
break

# make a new ErrorObservation with the tool call metadata
if not found_observation:
obs = ErrorObservation(content='The action has not been executed.')
obs.tool_call_metadata = self._pending_action.tool_call_metadata
obs._cause = self._pending_action.id # type: ignore[attr-defined]
self.event_stream.add_event(obs, EventSource.AGENT)

# reset the pending action, this will be called when the agent is STOPPED or ERROR
self._pending_action = None
self.agent.reset()

Expand Down Expand Up @@ -465,19 +499,16 @@ async def start_delegate(self, action: AgentDelegateAction) -> None:
async def _step(self) -> None:
"""Executes a single step of the parent or delegate agent. Detects stuck agents and limits on the number of iterations and the task budget."""
if self.get_agent_state() != AgentState.RUNNING:
await asyncio.sleep(1)
return

if self._pending_action:
await asyncio.sleep(1)
return

if self.delegate is not None:
assert self.delegate != self
if self.delegate.get_agent_state() == AgentState.PAUSED:
# no need to check too often
await asyncio.sleep(1)
else:
# TODO this conditional will always be false, because the parent controllers are unsubscribed
# remove if it's still useless when delegation is reworked
if self.delegate.get_agent_state() != AgentState.PAUSED:
await self._delegate_step()
return

Expand All @@ -487,7 +518,6 @@ async def _step(self) -> None:
extra={'msg_type': 'STEP'},
)

# check if agent hit the resources limit
stop_step = False
if self.state.iteration >= self.state.max_iterations:
stop_step = await self._handle_traffic_control(
Expand All @@ -500,6 +530,7 @@ async def _step(self) -> None:
'budget', current_cost, self.max_budget_per_task
)
if stop_step:
logger.warning('Stopping agent due to traffic control')
return

if self._is_stuck():
Expand Down Expand Up @@ -699,12 +730,20 @@ def set_initial_state(
# - the previous session, in which case it has history
# - from a parent agent, in which case it has no history
# - None / a new state

# If state is None, we create a brand new state and still load the event stream so we can restore the history
if state is None:
self.state = State(
inputs={},
max_iterations=max_iterations,
confirmation_mode=confirmation_mode,
)
self.state.start_id = 0

self.log(
'debug',
f'AgentController {self.id} - created new state. start_id: {self.state.start_id}',
)
else:
self.state = state

Expand All @@ -716,7 +755,8 @@ def set_initial_state(
f'AgentController {self.id} initializing history from event {self.state.start_id}',
)

self._init_history()
# Always load from the event stream to avoid losing history
self._init_history()

def _init_history(self) -> None:
"""Initializes the agent's history from the event stream.
Expand Down Expand Up @@ -945,7 +985,7 @@ def __repr__(self):
return (
f'AgentController(id={self.id}, agent={self.agent!r}, '
f'event_stream={self.event_stream!r}, '
f'state={self.state!r}, agent_task={self.agent_task!r}, '
f'state={self.state!r}, '
f'delegate={self.delegate!r}, _pending_action={self._pending_action!r})'
)

Expand Down
13 changes: 7 additions & 6 deletions openhands/core/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ def display_event(event: Event, config: AppConfig):
display_confirmation(event.confirmation_state)


async def main():
async def main(loop):
"""Runs the agent in CLI mode"""

parser = get_parser()
Expand All @@ -112,7 +112,7 @@ async def main():

logger.setLevel(logging.WARNING)
config = load_app_config(config_file=args.config_file)
sid = 'cli'
sid = str(uuid4())

agent_cls: Type[Agent] = Agent.get_cls(config.default_agent)
agent_config = config.get_agent_config(config.default_agent)
Expand Down Expand Up @@ -150,7 +150,6 @@ async def main():

async def prompt_for_next_task():
# Run input() in a thread pool to avoid blocking the event loop
loop = asyncio.get_event_loop()
next_message = await loop.run_in_executor(
None, lambda: input('How can I help? >> ')
)
Expand All @@ -165,13 +164,12 @@ async def prompt_for_next_task():
event_stream.add_event(action, EventSource.USER)

async def prompt_for_user_confirmation():
loop = asyncio.get_event_loop()
user_confirmation = await loop.run_in_executor(
None, lambda: input('Confirm action (possible security risk)? (y/n) >> ')
)
return user_confirmation.lower() == 'y'

async def on_event(event: Event):
async def on_event_async(event: Event):
display_event(event, config)
if isinstance(event, AgentStateChangedObservation):
if event.agent_state in [
Expand All @@ -193,6 +191,9 @@ async def on_event(event: Event):
ChangeAgentStateAction(AgentState.USER_REJECTED), EventSource.USER
)

def on_event(event: Event) -> None:
loop.create_task(on_event_async(event))

event_stream.subscribe(EventStreamSubscriber.MAIN, on_event, str(uuid4()))

await runtime.connect()
Expand All @@ -208,7 +209,7 @@ async def on_event(event: Event):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(main())
loop.run_until_complete(main(loop))
except KeyboardInterrupt:
print('Received keyboard interrupt, shutting down...')
except ConnectionRefusedError as e:
Expand Down
Loading

0 comments on commit 020df3c

Please sign in to comment.