Compare commits

...

7 Commits

13 changed files with 771 additions and 26 deletions

View File

@@ -0,0 +1,249 @@
# Agent Mode Toggle Design Document
## Overview
This document outlines the design for implementing a toggle switch between "Read-only mode" and "Execute mode" in the OpenHands application. This feature will allow users to switch between a restricted ReadOnlyAgent that can only explore and analyze code, and the fully capable CodeActAgent that can modify code and execute commands.
## Motivation
Users often want to explore a codebase and discuss implementation details with the agent before making any changes. The ability to switch between read-only and execute modes provides several benefits:
1. **Safety**: Users can ensure no changes are made during the exploration phase
2. **Clarity**: Clear indication of the agent's current capabilities
3. **Control**: Users decide when to transition from planning to execution
4. **Workflow**: Supports a natural workflow of exploration → planning → implementation
## Architecture
The implementation will leverage the existing agent delegation mechanism in OpenHands. When a user toggles the switch:
1. In **Execute Mode** (default): The application uses the standard CodeActAgent
2. In **Read-only Mode**: The application delegates to a ReadOnlyAgent
### Key Components
#### Frontend
1. **Toggle Switch Component**:
- UI element that shows the current mode and allows switching
- Sends appropriate actions to the event stream when toggled
2. **Agent State Tracking**:
- Redux state to track current agent type and delegation status
- Event listeners to update state based on event stream
3. **Visual Indicators**:
- Mode indicator showing current agent mode
- Visual styling differences between modes
#### Backend
1. **Agent Delegation**:
- Uses existing delegation mechanism to switch to ReadOnlyAgent
- User-initiated FinishAction to end delegation and return to CodeActAgent
2. **Event Stream Integration**:
- AgentDelegateAction to start read-only mode
- AgentFinishAction to end read-only mode
- System messages to indicate mode changes
## Implementation Details
### Frontend Implementation
#### Redux State Extension
```typescript
interface AgentState {
curAgentState: AgentState;
currentAgentType: string; // Track the agent type
isDelegated: boolean; // Track if we're in a delegation
// other existing fields...
}
const initialState: AgentState = {
curAgentState: AgentState.IDLE,
currentAgentType: "CodeActAgent", // Default agent type
isDelegated: false,
// other initial values...
};
```
#### Action Generators
```typescript
export const generateDelegateToReadOnlyAction = () => ({
action: ActionType.DELEGATE,
args: {
agent: "ReadOnlyAgent",
inputs: {
task: "Continue the conversation in READ-ONLY MODE. You can explore and analyze code but cannot make changes."
},
thought: "Switching to read-only mode at user's request"
}
});
export const generateFinishDelegationAction = () => ({
action: ActionType.FINISH,
args: {
message: "Switching back to EXECUTE MODE. You now have full capabilities to modify code and execute commands.",
task_completed: "true",
outputs: {
mode_switch: true
}
}
});
```
#### Toggle Switch Component
```tsx
function AgentModeToggle() {
const { t } = useTranslation();
const dispatch = useDispatch();
const { send } = useWsClient();
// Get agent type from Redux
const { currentAgentType, isDelegated } = useSelector((state: RootState) => state.agent);
// Compute if we're in read-only mode
const isReadOnly = currentAgentType === "ReadOnlyAgent";
const handleToggle = () => {
if (isReadOnly) {
// Currently in read-only mode, switch back to execute mode
send(generateFinishDelegationAction());
} else {
// Currently in execute mode, switch to read-only mode
send(generateDelegateToReadOnlyAction());
}
};
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
{isReadOnly ? "Read-Only Mode" : "Execute Mode"}
</span>
<Switch
checked={isReadOnly}
onChange={handleToggle}
className={`${isReadOnly ? 'bg-amber-600' : 'bg-blue-600'} relative inline-flex h-6 w-11 items-center rounded-full`}
>
<span className="sr-only">Toggle agent mode</span>
<span
className={`${isReadOnly ? 'translate-x-6' : 'translate-x-1'} inline-block h-4 w-4 transform rounded-full bg-white transition`}
/>
</Switch>
</div>
);
}
```
#### Event Listener for State Updates
```typescript
function handleEvent(event) {
// Handle agent delegation events
if (event.action === ActionType.DELEGATE) {
// A delegation is starting
dispatch(setDelegationState(true));
dispatch(setAgentType(event.args.agent));
}
// Handle agent delegate observation (delegation ended)
else if (event.observation === "delegate") {
// Delegation has ended, returning to parent agent
dispatch(setDelegationState(false));
dispatch(setAgentType("CodeActAgent")); // Reset to default agent
}
// Handle other events...
}
```
### Backend Considerations
The backend implementation will leverage the existing agent delegation mechanism:
1. When the user toggles to read-only mode:
- An AgentDelegateAction is sent to the event stream
- The AgentController creates a ReadOnlyAgent delegate
- All subsequent events are handled by the delegate
2. When the user toggles back to execute mode:
- An AgentFinishAction is sent to the event stream
- The delegate agent finishes its task
- The parent AgentController resumes normal operation
No backend code changes are required as we're using the existing delegation mechanism.
## User Experience
1. **Initial State**: The application starts in Execute Mode with CodeActAgent
2. **Mode Switching**:
- User clicks the toggle switch to enter Read-only Mode
- System message indicates the mode change
- Agent capabilities are restricted to read-only tools
- UI shows visual indicators of the current mode
- User clicks the toggle switch again to return to Execute Mode
- System message indicates the return to full capabilities
3. **Visual Indicators**:
- Toggle switch position (left/right)
- Color coding (amber for read-only, blue for execute)
- Mode label text
- System messages in the conversation
## Future Enhancements
1. **Persistent Mode Preference**: Remember the user's preferred starting mode
2. **Context Preservation**: Improve context retention when switching modes
3. **Custom Tool Sets**: Allow users to customize which tools are available in each mode
4. **Mode-specific Prompts**: Optimize agent prompts for each mode
## Implementation Plan
1. **Frontend Implementation**:
- Add Redux state for agent type tracking ✅
- Create toggle switch component ✅
- Implement event listeners for state updates ✅
- Add visual indicators for current mode ✅
- Add notifications for mode changes ✅
2. **Testing**:
- Test mode switching with various conversation states
- Verify proper tool restrictions in read-only mode
- Test persistence across page refreshes
3. **Documentation**:
- Update user documentation to explain the mode toggle feature
- Add developer documentation for the implementation details ✅
## Implementation Status
The agent mode toggle feature has been implemented with the following components:
1. **Redux State**:
- Added `currentAgentType` and `isDelegated` properties to the agent slice
- Default agent type is set to "CodeActAgent"
2. **Agent Mode Service**:
- Created `agent-mode-service.ts` with action generators for delegation
- Implemented `generateDelegateToReadOnlyAction()` and `generateFinishDelegationAction()`
3. **UI Components**:
- Created `AgentModeToggle` component with toggle switch UI
- Integrated toggle into the agent control bar
- Updated agent status bar to display current mode
- Added color coding (amber for read-only, blue for execute)
4. **Event Handling**:
- Updated `use-handle-ws-events.ts` to process agent delegation events
- Added state updates when delegation starts/ends
- Added notifications to inform users of mode changes
5. **Internationalization**:
- Added translations for all UI elements
- Supported multiple languages through i18n
The implementation is complete and ready for testing. The feature allows users to seamlessly switch between read-only and execute modes during a conversation, with clear visual indicators and notifications of the current mode.

View File

@@ -0,0 +1,55 @@
# Agent Mode Toggle
The Agent Mode Toggle feature allows you to switch between two different agent modes:
1. **Execute Mode** (default): Full capabilities with the CodeActAgent, which can modify code and execute commands
2. **Read-only Mode**: Restricted capabilities with the ReadOnlyAgent, which can only explore and analyze code
## Why Use Different Modes?
- **Safety**: Ensure no changes are made during the exploration phase
- **Clarity**: Clear indication of the agent's current capabilities
- **Control**: Decide when to transition from planning to execution
- **Workflow**: Support a natural workflow of exploration → planning → implementation
## How to Use
1. **Toggle Switch**: Click the toggle switch in the agent control bar to switch between modes
- Blue toggle: Execute Mode (default)
- Amber toggle: Read-only Mode
2. **Mode Indicators**:
- The current mode is displayed in the agent status bar
- System messages indicate when the mode changes
## Available Tools in Each Mode
### Execute Mode (CodeActAgent)
All tools are available, including:
- File editing (`str_replace_editor`)
- Command execution (`execute_bash`)
- Python code execution (`execute_ipython_cell`)
- Web browsing (`browser`, `web_read`)
- Thinking and finishing (`think`, `finish`)
### Read-only Mode (ReadOnlyAgent)
Only non-destructive tools are available:
- File viewing (`view`)
- File searching (`grep`, `glob`)
- Web reading (`web_read`)
- Thinking and finishing (`think`, `finish`)
## Best Practices
1. **Start in Read-only Mode** for new codebases to safely explore without making changes
2. **Switch to Execute Mode** when you're ready to implement changes
3. **Return to Read-only Mode** when you want to explore different parts of the codebase
## Technical Details
The agent mode toggle uses OpenHands' agent delegation mechanism:
- When toggling to Read-only Mode, the system delegates to a ReadOnlyAgent
- When toggling back to Execute Mode, the delegation ends and returns to the CodeActAgent
- Context is preserved between mode switches

View File

@@ -59,7 +59,11 @@ describe("useTerminal", () => {
it("should render", () => {
renderWithProviders(<TestTerminalComponent commands={[]} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
agent: {
curAgentState: AgentState.RUNNING,
currentAgentType: "CodeActAgent",
isDelegated: false
},
cmd: { commands: [] },
},
});
@@ -73,7 +77,11 @@ describe("useTerminal", () => {
renderWithProviders(<TestTerminalComponent commands={commands} />, {
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
agent: {
curAgentState: AgentState.RUNNING,
currentAgentType: "CodeActAgent",
isDelegated: false
},
cmd: { commands },
},
});
@@ -100,7 +108,11 @@ describe("useTerminal", () => {
/>,
{
preloadedState: {
agent: { curAgentState: AgentState.RUNNING },
agent: {
curAgentState: AgentState.RUNNING,
currentAgentType: "CodeActAgent",
isDelegated: false
},
cmd: { commands },
},
},

View File

@@ -9,6 +9,7 @@ import { AgentState } from "#/types/agent-state";
import { useWsClient } from "#/context/ws-client-provider";
import { IGNORE_TASK_STATE_MAP } from "#/ignore-task-state-map.constant";
import { ActionButton } from "#/components/shared/buttons/action-button";
import { AgentModeToggle } from "./agent-mode-toggle";
export function AgentControlBar() {
const { t } = useTranslation();
@@ -23,25 +24,29 @@ export function AgentControlBar() {
return (
<div className="flex justify-between items-center gap-20">
<ActionButton
isDisabled={
curAgentState !== AgentState.RUNNING &&
curAgentState !== AgentState.PAUSED
}
content={
curAgentState === AgentState.PAUSED
? t(I18nKey.AGENT$RESUME_TASK)
: t(I18nKey.AGENT$PAUSE_TASK)
}
action={
curAgentState === AgentState.PAUSED
? AgentState.RUNNING
: AgentState.PAUSED
}
handleAction={handleAction}
>
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
</ActionButton>
<div className="flex items-center gap-4">
<ActionButton
isDisabled={
curAgentState !== AgentState.RUNNING &&
curAgentState !== AgentState.PAUSED
}
content={
curAgentState === AgentState.PAUSED
? t(I18nKey.AGENT$RESUME_TASK)
: t(I18nKey.AGENT$PAUSE_TASK)
}
action={
curAgentState === AgentState.PAUSED
? AgentState.RUNNING
: AgentState.PAUSED
}
handleAction={handleAction}
>
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
</ActionButton>
<AgentModeToggle />
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
import { useSelector } from "react-redux";
import { useTranslation } from "react-i18next";
import { Switch } from "@heroui/react";
import { useWsClient } from "#/context/ws-client-provider";
import { RootState } from "#/store";
import { cn } from "#/utils/utils";
import {
generateDelegateToReadOnlyAction,
generateFinishDelegationAction,
} from "#/services/agent-mode-service";
import { AgentState } from "#/types/agent-state";
import { I18nKey } from "#/i18n/declaration";
export function AgentModeToggle() {
const { t } = useTranslation();
const { send } = useWsClient();
// Get agent type and state from Redux
const { currentAgentType, curAgentState } = useSelector(
(state: RootState) => state.agent,
);
// Compute if we're in read-only mode
const isReadOnly = currentAgentType === "ReadOnlyAgent";
// Check if toggle is disabled (should be disabled during certain agent states)
const isDisabled = [
AgentState.LOADING,
AgentState.INIT,
AgentState.ERROR,
AgentState.RATE_LIMITED,
].includes(curAgentState);
const handleToggle = () => {
if (isReadOnly) {
// Currently in read-only mode, switch back to execute mode
send(generateFinishDelegationAction());
} else {
// Currently in execute mode, switch to read-only mode
send(generateDelegateToReadOnlyAction());
}
};
return (
<div className="flex items-center gap-2">
<Switch
isDisabled={isDisabled}
name="agent-mode"
isSelected={isReadOnly}
onValueChange={handleToggle}
classNames={{
thumb: cn("bg-white w-3 h-3"),
wrapper: cn(
"border border-[#D4D4D4] bg-white px-[6px] w-12 h-6",
"group-data-[selected=true]:border-transparent",
isReadOnly
? "group-data-[selected=true]:bg-amber-600"
: "group-data-[selected=true]:bg-blue-600",
),
label: "text-[#A3A3A3] text-xs",
}}
>
<span className="sr-only">{t(I18nKey.AGENT$MODE_TOGGLE_LABEL)}</span>
<span className="text-sm font-medium ml-2">
{isReadOnly
? t(I18nKey.AGENT$MODE_READ_ONLY)
: t(I18nKey.AGENT$MODE_EXECUTE)}
</span>
</Switch>
</div>
);
}

View File

@@ -24,7 +24,9 @@ const notificationStates = [
export function AgentStatusBar() {
const { t, i18n } = useTranslation();
const { curAgentState } = useSelector((state: RootState) => state.agent);
const { curAgentState, currentAgentType } = useSelector(
(state: RootState) => state.agent,
);
const { curStatusMessage } = useSelector((state: RootState) => state.status);
const { status } = useWsClient();
const { notify } = useNotification();
@@ -99,6 +101,10 @@ export function AgentStatusBar() {
}
}, [curAgentState, status, notify, t]);
// Determine agent mode badge color
const agentModeBadgeColor =
currentAgentType === "ReadOnlyAgent" ? "bg-amber-600" : "bg-blue-600";
return (
<div className="flex flex-col items-center">
<div className="flex items-center bg-base-secondary px-2 py-1 text-gray-400 rounded-[100px] text-sm gap-[6px]">
@@ -106,6 +112,15 @@ export function AgentStatusBar() {
className={`w-2 h-2 rounded-full animate-pulse ${indicatorColor}`}
/>
<span className="text-sm text-stone-400">{t(statusMessage)}</span>
{/* Agent Mode Badge */}
<div
className={`ml-2 px-2 py-0.5 rounded-full text-xs text-white ${agentModeBadgeColor}`}
>
{currentAgentType === "ReadOnlyAgent"
? t(I18nKey.AGENT$MODE_READ_ONLY)
: t(I18nKey.AGENT$MODE_EXECUTE)}
</div>
</div>
</div>
);

View File

@@ -0,0 +1,47 @@
import { useEffect } from "react";
import { useDispatch } from "react-redux";
import { setAgentType, setDelegationState } from "#/state/agent-slice";
import ActionType from "#/types/action-type";
/**
* Hook to handle agent mode changes based on WebSocket events
*/
export function useAgentModeHandler(events: Record<string, unknown>[]) {
const dispatch = useDispatch();
useEffect(() => {
// Process only the latest event
if (events.length === 0) return;
const latestEvent = events[events.length - 1];
// Handle agent delegation events
if (
"action" in latestEvent &&
latestEvent.action === ActionType.DELEGATE &&
"args" in latestEvent &&
typeof latestEvent.args === "object" &&
latestEvent.args !== null &&
"agent" in latestEvent.args
) {
// A delegation is starting
dispatch(setDelegationState(true));
dispatch(setAgentType(latestEvent.args.agent as string));
}
// Handle agent delegate observation (delegation ended)
else if (
"observation" in latestEvent &&
latestEvent.observation === "delegate" &&
"data" in latestEvent &&
typeof latestEvent.data === "object" &&
latestEvent.data !== null &&
"status" in latestEvent.data &&
latestEvent.data.status === "finished"
) {
// Delegation has ended, returning to parent agent
dispatch(setDelegationState(false));
dispatch(setAgentType("CodeActAgent")); // Reset to default agent
}
}, [events, dispatch]);
}

View File

@@ -1,11 +1,19 @@
import React from "react";
import { useDispatch } from "react-redux";
import { useTranslation } from "react-i18next";
import { useWsClient } from "#/context/ws-client-provider";
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
import { addErrorMessage } from "#/state/chat-slice";
import { AgentState } from "#/types/agent-state";
import { ErrorObservation } from "#/types/core/observations";
import { displayErrorToast } from "#/utils/custom-toast-handlers";
import { useEndSession } from "./use-end-session";
import {
displayErrorToast,
displaySuccessToast,
} from "#/utils/custom-toast-handlers";
import { setAgentType, setDelegationState } from "#/state/agent-slice";
import ActionType from "#/types/action-type";
import { I18nKey } from "#/i18n/declaration";
interface ServerError {
error: boolean | string;
@@ -21,6 +29,7 @@ const isErrorObservation = (data: object): data is ErrorObservation =>
export const useHandleWSEvents = () => {
const { events, send } = useWsClient();
const dispatch = useDispatch();
const { t } = useTranslation();
React.useEffect(() => {
if (!events.length) {
@@ -58,5 +67,43 @@ export const useHandleWSEvents = () => {
}),
);
}
}, [events.length]);
// Handle agent mode changes
// Handle agent delegation events
if (
"action" in event &&
event.action === ActionType.DELEGATE &&
"args" in event &&
typeof event.args === "object" &&
event.args !== null &&
"agent" in event.args
) {
// A delegation is starting
const agentType = event.args.agent as string;
dispatch(setDelegationState(true));
dispatch(setAgentType(agentType));
// Show notification
if (agentType === "ReadOnlyAgent") {
displaySuccessToast(t(I18nKey.AGENT$MODE_READ_ONLY));
}
}
// Handle agent delegate observation (delegation ended)
else if (
"observation" in event &&
event.observation === "delegate" &&
"data" in event &&
typeof event.data === "object" &&
event.data !== null &&
"status" in event.data &&
event.data.status === "finished"
) {
// Delegation has ended, returning to parent agent
dispatch(setDelegationState(false));
dispatch(setAgentType("CodeActAgent")); // Reset to default agent
// Show notification
displaySuccessToast(t(I18nKey.AGENT$MODE_EXECUTE));
}
}, [events.length, dispatch, send, t]);
};

View File

@@ -1,5 +1,8 @@
// this file generate by script, don't modify it manually!!!
export enum I18nKey {
AGENT$MODE_READ_ONLY = "AGENT$MODE_READ_ONLY",
AGENT$MODE_EXECUTE = "AGENT$MODE_EXECUTE",
AGENT$MODE_TOGGLE_LABEL = "AGENT$MODE_TOGGLE_LABEL",
SECRETS$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
SECRETS$EDIT_SECRET = "SECRETS$EDIT_SECRET",

View File

@@ -1,4 +1,49 @@
{
"AGENT$MODE_READ_ONLY": {
"en": "Read-Only Mode",
"ja": "読み取り専用モード",
"zh-CN": "只读模式",
"zh-TW": "唯讀模式",
"ko-KR": "읽기 전용 모드",
"no": "Skrivebeskyttet modus",
"it": "Modalità di sola lettura",
"pt": "Modo somente leitura",
"es": "Modo de solo lectura",
"ar": "وضع القراءة فقط",
"fr": "Mode lecture seule",
"tr": "Salt okunur mod",
"de": "Nur-Lese-Modus"
},
"AGENT$MODE_EXECUTE": {
"en": "Execute Mode",
"ja": "実行モード",
"zh-CN": "执行模式",
"zh-TW": "執行模式",
"ko-KR": "실행 모드",
"no": "Utførelsesmodus",
"it": "Modalità di esecuzione",
"pt": "Modo de execução",
"es": "Modo de ejecución",
"ar": "وضع التنفيذ",
"fr": "Mode d'exécution",
"tr": "Yürütme modu",
"de": "Ausführungsmodus"
},
"AGENT$MODE_TOGGLE_LABEL": {
"en": "Toggle agent mode",
"ja": "エージェントモードを切り替える",
"zh-CN": "切换代理模式",
"zh-TW": "切換代理模式",
"ko-KR": "에이전트 모드 전환",
"no": "Bytt agentmodus",
"it": "Cambia modalità agente",
"pt": "Alternar modo do agente",
"es": "Cambiar modo del agente",
"ar": "تبديل وضع الوكيل",
"fr": "Basculer le mode de l'agent",
"tr": "Ajan modunu değiştir",
"de": "Agentenmodus umschalten"
},
"SECRETS$SECRET_VALUE_REQUIRED": {
"en": "Secret value is required",
"ja": "シークレット値は必須です",

View File

@@ -0,0 +1,24 @@
import ActionType from "#/types/action-type";
export const generateDelegateToReadOnlyAction = () => ({
action: ActionType.DELEGATE,
args: {
agent: "ReadOnlyAgent",
inputs: {
task: "Continue the conversation in READ-ONLY MODE. You can explore and analyze code but cannot make changes.",
},
thought: "Switching to read-only mode at user's request",
},
});
export const generateFinishDelegationAction = () => ({
action: ActionType.FINISH,
args: {
message:
"Switching back to EXECUTE MODE. You now have full capabilities to modify code and execute commands.",
task_completed: "true",
outputs: {
mode_switch: true,
},
},
});

View File

@@ -5,14 +5,23 @@ export const agentSlice = createSlice({
name: "agent",
initialState: {
curAgentState: AgentState.LOADING,
currentAgentType: "CodeActAgent", // Default agent type
isDelegated: false, // Track if we're in a delegation
},
reducers: {
setCurrentAgentState: (state, action) => {
state.curAgentState = action.payload;
},
setAgentType: (state, action) => {
state.currentAgentType = action.payload;
},
setDelegationState: (state, action) => {
state.isDelegated = action.payload;
},
},
});
export const { setCurrentAgentState } = agentSlice.actions;
export const { setCurrentAgentState, setAgentType, setDelegationState } =
agentSlice.actions;
export default agentSlice.reducer;

View File

@@ -0,0 +1,162 @@
import asyncio
from unittest.mock import MagicMock, Mock
from uuid import uuid4
import pytest
from openhands.agenthub.codeact_agent.codeact_agent import CodeActAgent
from openhands.agenthub.readonly_agent.readonly_agent import ReadOnlyAgent
from openhands.controller.agent import Agent
from openhands.controller.agent_controller import AgentController
from openhands.controller.state.state import State
from openhands.core.config import AgentConfig, LLMConfig
from openhands.core.schema import AgentState
from openhands.events import EventSource, EventStream
from openhands.events.action import (
AgentDelegateAction,
AgentFinishAction,
MessageAction,
)
from openhands.events.observation import AgentDelegateObservation
from openhands.llm.llm import LLM
from openhands.llm.metrics import Metrics
from openhands.storage.memory import InMemoryFileStore
@pytest.fixture
def mock_event_stream():
"""Creates an event stream in memory."""
sid = f'test-{uuid4()}'
file_store = InMemoryFileStore({})
return EventStream(sid=sid, file_store=file_store)
@pytest.fixture
def mock_codeact_agent():
"""Creates a mock CodeActAgent for testing."""
agent = MagicMock(spec=CodeActAgent)
agent.name = 'CodeActAgent'
agent.llm = MagicMock(spec=LLM)
agent.llm.metrics = Metrics()
agent.llm.config = LLMConfig()
agent.config = AgentConfig()
# Add a proper system message mock
from openhands.events.action.message import SystemMessageAction
system_message = SystemMessageAction(content='Test system message for CodeActAgent')
system_message._source = EventSource.AGENT
system_message._id = -1 # Set invalid ID to avoid the ID check
agent.get_system_message.return_value = system_message
return agent
@pytest.fixture
def mock_readonly_agent():
"""Creates a mock ReadOnlyAgent for testing."""
agent = MagicMock(spec=ReadOnlyAgent)
agent.name = 'ReadOnlyAgent'
agent.llm = MagicMock(spec=LLM)
agent.llm.metrics = Metrics()
agent.llm.config = LLMConfig()
agent.config = AgentConfig()
# Add a proper system message mock
from openhands.events.action.message import SystemMessageAction
system_message = SystemMessageAction(content='Test system message for ReadOnlyAgent')
system_message._source = EventSource.AGENT
system_message._id = -1 # Set invalid ID to avoid the ID check
agent.get_system_message.return_value = system_message
return agent
@pytest.mark.asyncio
async def test_agent_mode_toggle(mock_codeact_agent, mock_readonly_agent, mock_event_stream):
"""
Test that the agent mode toggle works correctly:
1. Start with CodeActAgent
2. Toggle to ReadOnlyAgent
3. Toggle back to CodeActAgent
"""
# Mock the agent class resolution so that AgentController can instantiate mock_readonly_agent
original_get_cls = Agent.get_cls
def mock_get_cls(agent_name):
if agent_name == 'ReadOnlyAgent':
return lambda llm, config: mock_readonly_agent
return original_get_cls(agent_name)
Agent.get_cls = Mock(side_effect=mock_get_cls)
# Create parent controller with CodeActAgent
parent_state = State(max_iterations=10)
parent_controller = AgentController(
agent=mock_codeact_agent,
event_stream=mock_event_stream,
max_iterations=10,
sid='parent',
confirmation_mode=False,
headless_mode=True,
initial_state=parent_state,
)
# Verify we're starting with CodeActAgent
assert parent_controller.agent.name == 'CodeActAgent'
assert parent_controller.delegate is None
# Create a delegate action to switch to ReadOnlyAgent
delegate_action = AgentDelegateAction(
agent='ReadOnlyAgent',
inputs={
'task': 'Continue the conversation in READ-ONLY MODE. You can explore and analyze code but cannot make changes.'
},
thought='Switching to read-only mode at user\'s request'
)
# Simulate the delegate action
await parent_controller._on_event(delegate_action)
# Give time for the async step() to execute
await asyncio.sleep(0.5)
# Verify that we've delegated to ReadOnlyAgent
assert parent_controller.delegate is not None
assert parent_controller.delegate.agent.name == 'ReadOnlyAgent'
# Simulate a user message to the ReadOnlyAgent
message_action = MessageAction(content='Show me the files in this directory')
message_action._source = EventSource.USER
await parent_controller.delegate._on_event(message_action)
# Give time for the async step() to execute
await asyncio.sleep(0.5)
# Now simulate switching back to CodeActAgent with a finish action
finish_action = AgentFinishAction(
final_thought='Switching back to EXECUTE MODE. You now have full capabilities to modify code and execute commands.',
task_completed=True,
outputs={'mode_switch': True}
)
# Send the finish action to the delegate
await parent_controller.delegate._on_event(finish_action)
# Give time for the async step() to execute
await asyncio.sleep(0.5)
# Verify that we're back to the parent CodeActAgent
assert parent_controller.delegate is None
assert parent_controller.agent.name == 'CodeActAgent'
# Verify that a delegate observation was added to the event stream
events = list(mock_event_stream.get_events())
assert any(isinstance(event, AgentDelegateObservation) for event in events)
# Cleanup
await parent_controller.close()
# Restore the original get_cls method
Agent.get_cls = original_get_cls