mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
7 Commits
0.41.1
...
feature/ag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6903223ef7 | ||
|
|
22420ce6a7 | ||
|
|
ef360c1f67 | ||
|
|
b202407a3d | ||
|
|
52cb8341fb | ||
|
|
d37dfc49c0 | ||
|
|
01b4729095 |
249
docs/design/agent-mode-toggle.md
Normal file
249
docs/design/agent-mode-toggle.md
Normal 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.
|
||||
55
docs/user/agent-mode-toggle.md
Normal file
55
docs/user/agent-mode-toggle.md
Normal 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
|
||||
@@ -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 },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
47
frontend/src/hooks/use-agent-mode-handler.ts
Normal file
47
frontend/src/hooks/use-agent-mode-handler.ts
Normal 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]);
|
||||
}
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "シークレット値は必須です",
|
||||
|
||||
24
frontend/src/services/agent-mode-service.ts
Normal file
24
frontend/src/services/agent-mode-service.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
162
tests/unit/test_agent_mode_toggle.py
Normal file
162
tests/unit/test_agent_mode_toggle.py
Normal 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
|
||||
Reference in New Issue
Block a user