mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6903223ef7 | |||
| 22420ce6a7 | |||
| ef360c1f67 | |||
| b202407a3d | |||
| 52cb8341fb | |||
| d37dfc49c0 | |||
| 01b4729095 |
@@ -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.
|
||||||
@@ -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", () => {
|
it("should render", () => {
|
||||||
renderWithProviders(<TestTerminalComponent commands={[]} />, {
|
renderWithProviders(<TestTerminalComponent commands={[]} />, {
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
agent: { curAgentState: AgentState.RUNNING },
|
agent: {
|
||||||
|
curAgentState: AgentState.RUNNING,
|
||||||
|
currentAgentType: "CodeActAgent",
|
||||||
|
isDelegated: false
|
||||||
|
},
|
||||||
cmd: { commands: [] },
|
cmd: { commands: [] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -73,7 +77,11 @@ describe("useTerminal", () => {
|
|||||||
|
|
||||||
renderWithProviders(<TestTerminalComponent commands={commands} />, {
|
renderWithProviders(<TestTerminalComponent commands={commands} />, {
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
agent: { curAgentState: AgentState.RUNNING },
|
agent: {
|
||||||
|
curAgentState: AgentState.RUNNING,
|
||||||
|
currentAgentType: "CodeActAgent",
|
||||||
|
isDelegated: false
|
||||||
|
},
|
||||||
cmd: { commands },
|
cmd: { commands },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -100,7 +108,11 @@ describe("useTerminal", () => {
|
|||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
preloadedState: {
|
preloadedState: {
|
||||||
agent: { curAgentState: AgentState.RUNNING },
|
agent: {
|
||||||
|
curAgentState: AgentState.RUNNING,
|
||||||
|
currentAgentType: "CodeActAgent",
|
||||||
|
isDelegated: false
|
||||||
|
},
|
||||||
cmd: { commands },
|
cmd: { commands },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AgentState } from "#/types/agent-state";
|
|||||||
import { useWsClient } from "#/context/ws-client-provider";
|
import { useWsClient } from "#/context/ws-client-provider";
|
||||||
import { IGNORE_TASK_STATE_MAP } from "#/ignore-task-state-map.constant";
|
import { IGNORE_TASK_STATE_MAP } from "#/ignore-task-state-map.constant";
|
||||||
import { ActionButton } from "#/components/shared/buttons/action-button";
|
import { ActionButton } from "#/components/shared/buttons/action-button";
|
||||||
|
import { AgentModeToggle } from "./agent-mode-toggle";
|
||||||
|
|
||||||
export function AgentControlBar() {
|
export function AgentControlBar() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -23,25 +24,29 @@ export function AgentControlBar() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-between items-center gap-20">
|
<div className="flex justify-between items-center gap-20">
|
||||||
<ActionButton
|
<div className="flex items-center gap-4">
|
||||||
isDisabled={
|
<ActionButton
|
||||||
curAgentState !== AgentState.RUNNING &&
|
isDisabled={
|
||||||
curAgentState !== AgentState.PAUSED
|
curAgentState !== AgentState.RUNNING &&
|
||||||
}
|
curAgentState !== AgentState.PAUSED
|
||||||
content={
|
}
|
||||||
curAgentState === AgentState.PAUSED
|
content={
|
||||||
? t(I18nKey.AGENT$RESUME_TASK)
|
curAgentState === AgentState.PAUSED
|
||||||
: t(I18nKey.AGENT$PAUSE_TASK)
|
? t(I18nKey.AGENT$RESUME_TASK)
|
||||||
}
|
: t(I18nKey.AGENT$PAUSE_TASK)
|
||||||
action={
|
}
|
||||||
curAgentState === AgentState.PAUSED
|
action={
|
||||||
? AgentState.RUNNING
|
curAgentState === AgentState.PAUSED
|
||||||
: AgentState.PAUSED
|
? AgentState.RUNNING
|
||||||
}
|
: AgentState.PAUSED
|
||||||
handleAction={handleAction}
|
}
|
||||||
>
|
handleAction={handleAction}
|
||||||
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
|
>
|
||||||
</ActionButton>
|
{curAgentState === AgentState.PAUSED ? <PlayIcon /> : <PauseIcon />}
|
||||||
|
</ActionButton>
|
||||||
|
|
||||||
|
<AgentModeToggle />
|
||||||
|
</div>
|
||||||
</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() {
|
export function AgentStatusBar() {
|
||||||
const { t, i18n } = useTranslation();
|
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 { curStatusMessage } = useSelector((state: RootState) => state.status);
|
||||||
const { status } = useWsClient();
|
const { status } = useWsClient();
|
||||||
const { notify } = useNotification();
|
const { notify } = useNotification();
|
||||||
@@ -99,6 +101,10 @@ export function AgentStatusBar() {
|
|||||||
}
|
}
|
||||||
}, [curAgentState, status, notify, t]);
|
}, [curAgentState, status, notify, t]);
|
||||||
|
|
||||||
|
// Determine agent mode badge color
|
||||||
|
const agentModeBadgeColor =
|
||||||
|
currentAgentType === "ReadOnlyAgent" ? "bg-amber-600" : "bg-blue-600";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center">
|
<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]">
|
<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}`}
|
className={`w-2 h-2 rounded-full animate-pulse ${indicatorColor}`}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-stone-400">{t(statusMessage)}</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 React from "react";
|
||||||
import { useDispatch } from "react-redux";
|
import { useDispatch } from "react-redux";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { useWsClient } from "#/context/ws-client-provider";
|
import { useWsClient } from "#/context/ws-client-provider";
|
||||||
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
import { generateAgentStateChangeEvent } from "#/services/agent-state-service";
|
||||||
import { addErrorMessage } from "#/state/chat-slice";
|
import { addErrorMessage } from "#/state/chat-slice";
|
||||||
import { AgentState } from "#/types/agent-state";
|
import { AgentState } from "#/types/agent-state";
|
||||||
import { ErrorObservation } from "#/types/core/observations";
|
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 {
|
interface ServerError {
|
||||||
error: boolean | string;
|
error: boolean | string;
|
||||||
@@ -21,6 +29,7 @@ const isErrorObservation = (data: object): data is ErrorObservation =>
|
|||||||
export const useHandleWSEvents = () => {
|
export const useHandleWSEvents = () => {
|
||||||
const { events, send } = useWsClient();
|
const { events, send } = useWsClient();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!events.length) {
|
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!!!
|
// this file generate by script, don't modify it manually!!!
|
||||||
export enum I18nKey {
|
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$SECRET_VALUE_REQUIRED = "SECRETS$SECRET_VALUE_REQUIRED",
|
||||||
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
|
SECRETS$ADD_SECRET = "SECRETS$ADD_SECRET",
|
||||||
SECRETS$EDIT_SECRET = "SECRETS$EDIT_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": {
|
"SECRETS$SECRET_VALUE_REQUIRED": {
|
||||||
"en": "Secret value is required",
|
"en": "Secret value is required",
|
||||||
"ja": "シークレット値は必須です",
|
"ja": "シークレット値は必須です",
|
||||||
|
|||||||
@@ -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",
|
name: "agent",
|
||||||
initialState: {
|
initialState: {
|
||||||
curAgentState: AgentState.LOADING,
|
curAgentState: AgentState.LOADING,
|
||||||
|
currentAgentType: "CodeActAgent", // Default agent type
|
||||||
|
isDelegated: false, // Track if we're in a delegation
|
||||||
},
|
},
|
||||||
reducers: {
|
reducers: {
|
||||||
setCurrentAgentState: (state, action) => {
|
setCurrentAgentState: (state, action) => {
|
||||||
state.curAgentState = action.payload;
|
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;
|
export default agentSlice.reducer;
|
||||||
|
|||||||
@@ -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