mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-04-29 03:00:45 -04:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e5ea2ac478 |
@@ -71,14 +71,6 @@ jobs:
|
||||
|
||||
echo "✅ Build & test finished without ❌ markers"
|
||||
|
||||
- name: Verify binary files exist
|
||||
run: |
|
||||
if ! ls openhands-cli/dist/openhands* 1> /dev/null 2>&1; then
|
||||
echo "❌ No binaries found to upload!"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ Found binaries to upload."
|
||||
|
||||
- name: Upload binary artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
Generated
+1
-1
@@ -5765,7 +5765,7 @@ subdirectory = "openhands-agent-server"
|
||||
|
||||
[[package]]
|
||||
name = "openhands-ai"
|
||||
version = "0.0.0-post.5456+15c207c40"
|
||||
version = "0.59.0"
|
||||
description = "OpenHands: Code Less, Make More"
|
||||
optional = false
|
||||
python-versions = "^3.12,<3.14"
|
||||
|
||||
@@ -68,7 +68,6 @@ export function ChatInterface() {
|
||||
const conversationWebSocket = useConversationWebSocket();
|
||||
const { send } = useSendMessage();
|
||||
const storeEvents = useEventStore((state) => state.events);
|
||||
const uiEvents = useEventStore((state) => state.uiEvents);
|
||||
const { setOptimisticUserMessage, getOptimisticUserMessage } =
|
||||
useOptimisticUserMessageStore();
|
||||
const { t } = useTranslation();
|
||||
@@ -122,13 +121,11 @@ export function ChatInterface() {
|
||||
.filter(isActionOrObservation)
|
||||
.filter(shouldRenderEvent);
|
||||
|
||||
// Filter V1 events - use uiEvents for rendering (actions replaced by observations)
|
||||
const v1UiEvents = uiEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
// Keep full v1 events for lookups (includes both actions and observations)
|
||||
const v1FullEvents = storeEvents.filter(isV1Event);
|
||||
// Filter V1 events
|
||||
const v1Events = storeEvents.filter(isV1Event).filter(shouldRenderV1Event);
|
||||
|
||||
// Combined events count for tracking
|
||||
const totalEvents = v0Events.length || v1UiEvents.length;
|
||||
const totalEvents = v0Events.length || v1Events.length;
|
||||
|
||||
// Check if there are any substantive agent actions (not just system messages)
|
||||
const hasSubstantiveAgentActions = React.useMemo(
|
||||
@@ -226,7 +223,7 @@ export function ChatInterface() {
|
||||
};
|
||||
|
||||
const v0UserEventsExist = hasUserEvent(v0Events);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1FullEvents);
|
||||
const v1UserEventsExist = hasV1UserEvent(v1Events);
|
||||
const userEventsExist = v0UserEventsExist || v1UserEventsExist;
|
||||
|
||||
return (
|
||||
@@ -270,7 +267,7 @@ export function ChatInterface() {
|
||||
)}
|
||||
|
||||
{!conversationWebSocket?.isLoadingHistory && v1UserEventsExist && (
|
||||
<V1Messages messages={v1UiEvents} allEvents={v1FullEvents} />
|
||||
<V1Messages messages={v1Events} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
+1
-7
@@ -8,7 +8,6 @@ import { TabContentArea } from "./tab-content-area";
|
||||
import { ConversationTabTitle } from "../conversation-tab-title";
|
||||
import Terminal from "#/components/features/terminal/terminal";
|
||||
import { useConversationStore } from "#/state/conversation-store";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
// Lazy load all tab components
|
||||
const EditorTab = lazy(() => import("#/routes/changes-tab"));
|
||||
@@ -18,7 +17,6 @@ const VSCodeTab = lazy(() => import("#/routes/vscode-tab"));
|
||||
|
||||
export function ConversationTabContent() {
|
||||
const { selectedTab, shouldShownAgentLoading } = useConversationStore();
|
||||
const { conversationId } = useConversationId();
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -80,11 +78,7 @@ export function ConversationTabContent() {
|
||||
<ConversationTabTitle title={conversationTabTitle} />
|
||||
<TabContentArea>
|
||||
{tabs.map(({ key, component: Component, isActive }) => (
|
||||
<TabWrapper
|
||||
// Force Terminal tab remount to reset XTerm buffer/state when conversationId changes
|
||||
key={key === "terminal" ? `${key}-${conversationId}` : key}
|
||||
isActive={isActive}
|
||||
>
|
||||
<TabWrapper key={key} isActive={isActive}>
|
||||
<Component />
|
||||
</TabWrapper>
|
||||
))}
|
||||
|
||||
@@ -7,5 +7,5 @@ export function paragraph({
|
||||
}: React.ClassAttributes<HTMLParagraphElement> &
|
||||
React.HTMLAttributes<HTMLParagraphElement> &
|
||||
ExtraProps) {
|
||||
return <p className="py-2.5 first:pt-0 last:pb-0">{children}</p>;
|
||||
return <p className="pb-[10px] last:pb-0">{children}</p>;
|
||||
}
|
||||
|
||||
@@ -134,16 +134,9 @@ const getObservationEventTitle = (event: OpenHandsEvent): React.ReactNode => {
|
||||
case "BrowserObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$BROWSE";
|
||||
break;
|
||||
case "TaskTrackerObservation": {
|
||||
const { command } = event.observation;
|
||||
if (command === "plan") {
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_PLAN";
|
||||
} else {
|
||||
// command === "view"
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING_VIEW";
|
||||
}
|
||||
case "TaskTrackerObservation":
|
||||
observationKey = "OBSERVATION_MESSAGE$TASK_TRACKING";
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// For unknown observations, use the type name
|
||||
return observationType.replace("Observation", "").toUpperCase();
|
||||
|
||||
@@ -7,6 +7,17 @@ import {
|
||||
isConversationStateUpdateEvent,
|
||||
} from "#/types/v1/type-guards";
|
||||
|
||||
// V1 events that should not be rendered
|
||||
const NO_RENDER_ACTION_TYPES = [
|
||||
"ThinkAction",
|
||||
// Add more action types that should not be rendered
|
||||
];
|
||||
|
||||
const NO_RENDER_OBSERVATION_TYPES = [
|
||||
"ThinkObservation",
|
||||
// Add more observation types that should not be rendered
|
||||
];
|
||||
|
||||
export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
// Explicitly exclude system events that should not be rendered in chat
|
||||
if (isConversationStateUpdateEvent(event)) {
|
||||
@@ -23,12 +34,18 @@ export const shouldRenderEvent = (event: OpenHandsEvent) => {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return !NO_RENDER_ACTION_TYPES.includes(actionType);
|
||||
}
|
||||
|
||||
// Render observation events
|
||||
// Render observation events (with filtering)
|
||||
if (isObservationEvent(event)) {
|
||||
return true;
|
||||
// For V1, observation is an object with kind property
|
||||
const observationType = event.observation.kind;
|
||||
|
||||
// Note: ObservationEvent source is always "environment", not "user"
|
||||
// So no need to check for user source here
|
||||
|
||||
return !NO_RENDER_OBSERVATION_TYPES.includes(observationType);
|
||||
}
|
||||
|
||||
// Render message events (user and assistant messages)
|
||||
|
||||
@@ -3,4 +3,3 @@ export { ObservationPairEventMessage } from "./observation-pair-event-message";
|
||||
export { ErrorEventMessage } from "./error-event-message";
|
||||
export { FinishEventMessage } from "./finish-event-message";
|
||||
export { GenericEventMessageWrapper } from "./generic-event-message-wrapper";
|
||||
export { ThoughtEventMessage } from "./thought-event-message";
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import React from "react";
|
||||
import { ActionEvent } from "#/types/v1/core";
|
||||
import { ChatMessage } from "../../../features/chat/chat-message";
|
||||
|
||||
interface ThoughtEventMessageProps {
|
||||
event: ActionEvent;
|
||||
actions?: Array<{
|
||||
icon: React.ReactNode;
|
||||
onClick: () => void;
|
||||
tooltip?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function ThoughtEventMessage({
|
||||
event,
|
||||
actions,
|
||||
}: ThoughtEventMessageProps) {
|
||||
// Extract thought content from the action event
|
||||
const thoughtContent = event.thought
|
||||
.filter((t) => t.type === "text")
|
||||
.map((t) => t.text)
|
||||
.join("\n");
|
||||
|
||||
// If there's no thought content, don't render anything
|
||||
if (!thoughtContent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ChatMessage type="agent" message={thoughtContent} actions={actions} />
|
||||
);
|
||||
}
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
ErrorEventMessage,
|
||||
UserAssistantEventMessage,
|
||||
FinishEventMessage,
|
||||
ObservationPairEventMessage,
|
||||
GenericEventMessageWrapper,
|
||||
ThoughtEventMessage,
|
||||
} from "./event-message-components";
|
||||
|
||||
interface EventMessageProps {
|
||||
event: OpenHandsEvent;
|
||||
messages: OpenHandsEvent[];
|
||||
hasObservationPair: boolean;
|
||||
isLastMessage: boolean;
|
||||
microagentStatus?: MicroagentStatus | null;
|
||||
microagentConversationId?: string;
|
||||
@@ -36,7 +36,7 @@ interface EventMessageProps {
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
export function EventMessage({
|
||||
event,
|
||||
messages,
|
||||
hasObservationPair,
|
||||
isLastMessage,
|
||||
microagentStatus,
|
||||
microagentConversationId,
|
||||
@@ -69,6 +69,19 @@ export function EventMessage({
|
||||
return <ErrorEventMessage event={event} {...commonProps} />;
|
||||
}
|
||||
|
||||
// Observation pairs with actions
|
||||
if (hasObservationPair && isActionEvent(event)) {
|
||||
return (
|
||||
<ObservationPairEventMessage
|
||||
event={event}
|
||||
microagentStatus={microagentStatus}
|
||||
microagentConversationId={microagentConversationId}
|
||||
microagentPRUrl={microagentPRUrl}
|
||||
actions={actions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Finish actions
|
||||
if (isActionEvent(event) && event.action.kind === "FinishAction") {
|
||||
return (
|
||||
@@ -79,39 +92,6 @@ export function EventMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// Action events - render thought + action (will be replaced by thought + observation)
|
||||
if (isActionEvent(event)) {
|
||||
return (
|
||||
<>
|
||||
<ThoughtEventMessage event={event} actions={actions} />
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Observation events - find the corresponding action and render thought + observation
|
||||
if (isObservationEvent(event)) {
|
||||
// Find the action that this observation is responding to
|
||||
const correspondingAction = messages.find(
|
||||
(msg) => isActionEvent(msg) && msg.id === event.action_id,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{correspondingAction && isActionEvent(correspondingAction) && (
|
||||
<ThoughtEventMessage event={correspondingAction} actions={actions} />
|
||||
)}
|
||||
<GenericEventMessageWrapper
|
||||
event={event}
|
||||
isLastMessage={isLastMessage}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// Message events (user and assistant messages)
|
||||
if (!isActionEvent(event) && !isObservationEvent(event)) {
|
||||
// This is a MessageEvent
|
||||
@@ -124,7 +104,7 @@ export function EventMessage({
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback for all other events
|
||||
// Generic fallback for all other events (including observation events)
|
||||
return (
|
||||
<GenericEventMessageWrapper event={event} isLastMessage={isLastMessage} />
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from "react";
|
||||
import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isActionEvent, isObservationEvent } from "#/types/v1/type-guards";
|
||||
import { EventMessage } from "./event-message";
|
||||
import { ChatMessage } from "../../features/chat/chat-message";
|
||||
import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-store";
|
||||
@@ -8,16 +9,29 @@ import { useOptimisticUserMessageStore } from "#/stores/optimistic-user-message-
|
||||
// import MemoryIcon from "#/icons/memory_icon.svg?react";
|
||||
|
||||
interface MessagesProps {
|
||||
messages: OpenHandsEvent[]; // UI events (actions replaced by observations)
|
||||
allEvents: OpenHandsEvent[]; // Full event history (for action lookup)
|
||||
messages: OpenHandsEvent[];
|
||||
}
|
||||
|
||||
export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
({ messages, allEvents }) => {
|
||||
({ messages }) => {
|
||||
const { getOptimisticUserMessage } = useOptimisticUserMessageStore();
|
||||
|
||||
const optimisticUserMessage = getOptimisticUserMessage();
|
||||
|
||||
const actionHasObservationPair = React.useCallback(
|
||||
(event: OpenHandsEvent): boolean => {
|
||||
if (isActionEvent(event)) {
|
||||
// Check if there's a corresponding observation event
|
||||
return !!messages.some(
|
||||
(msg) => isObservationEvent(msg) && msg.action_id === event.id,
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[messages],
|
||||
);
|
||||
|
||||
// TODO: Implement microagent functionality for V1 if needed
|
||||
// For now, we'll skip microagent features
|
||||
|
||||
@@ -27,7 +41,7 @@ export const Messages: React.FC<MessagesProps> = React.memo(
|
||||
<EventMessage
|
||||
key={message.id}
|
||||
event={message}
|
||||
messages={allEvents}
|
||||
hasObservationPair={actionHasObservationPair(message)}
|
||||
isLastMessage={messages.length - 1 === index}
|
||||
isInLast10Actions={messages.length - 1 - index < 10}
|
||||
// Microagent props - not implemented yet for V1
|
||||
|
||||
@@ -91,7 +91,6 @@ export const useTerminal = () => {
|
||||
|
||||
return () => {
|
||||
terminal.current?.dispose();
|
||||
lastCommandIndex.current = 0;
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from "react";
|
||||
import { useWsClient, V0_WebSocketStatus } from "#/context/ws-client-provider";
|
||||
import { useActiveConversation } from "#/hooks/query/use-active-conversation";
|
||||
import { useConversationWebSocket } from "#/contexts/conversation-websocket-context";
|
||||
import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
|
||||
/**
|
||||
* Unified hook that returns the current WebSocket status
|
||||
@@ -10,15 +9,11 @@ import { useConversationId } from "#/hooks/use-conversation-id";
|
||||
* - For V1 conversations: Returns status from ConversationWebSocketProvider
|
||||
*/
|
||||
export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
|
||||
const { conversationId } = useConversationId();
|
||||
const { data: conversation } = useActiveConversation();
|
||||
const v0Status = useWsClient();
|
||||
const v1Context = useConversationWebSocket();
|
||||
|
||||
// Check if this is a V1 conversation:
|
||||
const isV1Conversation =
|
||||
conversationId.startsWith("task-") ||
|
||||
conversation?.conversation_version === "V1";
|
||||
const isV1Conversation = conversation?.conversation_version === "V1";
|
||||
|
||||
const webSocketStatus = useMemo(() => {
|
||||
if (isV1Conversation) {
|
||||
@@ -38,13 +33,7 @@ export function useUnifiedWebSocketStatus(): V0_WebSocketStatus {
|
||||
}
|
||||
}
|
||||
return v0Status.webSocketStatus;
|
||||
}, [
|
||||
isV1Conversation,
|
||||
v1Context,
|
||||
v0Status.webSocketStatus,
|
||||
conversationId,
|
||||
conversation,
|
||||
]);
|
||||
}, [isV1Conversation, v1Context, v0Status.webSocketStatus]);
|
||||
|
||||
return webSocketStatus;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { OpenHandsEvent } from "#/types/v1/core";
|
||||
import { isObservationEvent } from "#/types/v1/type-guards";
|
||||
|
||||
/**
|
||||
* Handles adding an event to the UI events array
|
||||
* Replaces actions with observations when they arrive (so UI shows observation instead of action)
|
||||
* Handles adding an event to the UI events array, with special logic for observation events
|
||||
*/
|
||||
export const handleEventForUI = (
|
||||
event: OpenHandsEvent,
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
# Ctrl+C Implementation for OpenHands CLI
|
||||
|
||||
## Overview
|
||||
|
||||
This implementation adds improved Ctrl+C handling to the OpenHands CLI where:
|
||||
1. **First Ctrl+C**: Attempts graceful pause of the agent
|
||||
2. **Second Ctrl+C** (within 3 seconds): Immediately kills the process
|
||||
|
||||
## Architecture
|
||||
|
||||
### Signal Handling (`signal_handler.py`)
|
||||
|
||||
**SignalHandler Class:**
|
||||
- Tracks Ctrl+C presses with a 3-second timeout
|
||||
- First press: calls graceful shutdown callback
|
||||
- Second press: forces immediate exit with `os._exit(1)`
|
||||
|
||||
**ProcessSignalHandler Class:**
|
||||
- Manages conversation runner processes
|
||||
- Implements graceful shutdown by terminating the process
|
||||
- Provides clean installation/uninstallation of signal handlers
|
||||
|
||||
### Process Management (`process_runner.py`)
|
||||
|
||||
**ProcessBasedConversationRunner Class:**
|
||||
- Runs conversation in a separate process using `multiprocessing`
|
||||
- Provides inter-process communication via queues
|
||||
- Supports commands: process_message, get_status, toggle_confirmation_mode, resume
|
||||
- Handles process lifecycle (start, stop, cleanup)
|
||||
|
||||
### Modified Components
|
||||
|
||||
**Pause Listener (`listeners/pause_listener.py`):**
|
||||
- Removed Ctrl+C and Ctrl+D handling (now handled by signal handler)
|
||||
- Only handles Ctrl+P for pause functionality
|
||||
|
||||
**Agent Chat (`agent_chat.py`):**
|
||||
- Integrated ProcessSignalHandler for Ctrl+C management
|
||||
- Updated to use ProcessBasedConversationRunner
|
||||
- All commands (/new, /status, /confirm, /resume) work with process-based approach
|
||||
- Proper cleanup in finally block
|
||||
|
||||
**Simple Main (`simple_main.py`):**
|
||||
- Added basic SignalHandler installation for graceful shutdown
|
||||
|
||||
## Key Features
|
||||
|
||||
### Graceful Shutdown
|
||||
- First Ctrl+C sends SIGTERM to conversation process
|
||||
- Gives 2 seconds for graceful shutdown
|
||||
- Shows appropriate user feedback
|
||||
|
||||
### Immediate Termination
|
||||
- Second Ctrl+C within 3 seconds forces immediate exit
|
||||
- Uses `os._exit(1)` to bypass Python cleanup
|
||||
- Ensures agent stops immediately
|
||||
|
||||
### Process Communication
|
||||
- Queue-based communication between main and conversation processes
|
||||
- Status queries work across process boundaries
|
||||
- Command handling preserved for all CLI features
|
||||
|
||||
### Error Handling
|
||||
- Proper exception handling in both processes
|
||||
- Cleanup of resources in finally blocks
|
||||
- Fallback KeyboardInterrupt handlers
|
||||
|
||||
## Usage
|
||||
|
||||
The implementation is transparent to users:
|
||||
- Press Ctrl+C once to pause the agent gracefully
|
||||
- Press Ctrl+C again within 3 seconds to force immediate termination
|
||||
- All existing CLI commands continue to work
|
||||
|
||||
## Testing
|
||||
|
||||
A test script `test_ctrl_c.py` is provided to verify the signal handling behavior:
|
||||
```bash
|
||||
uv run python test_ctrl_c.py
|
||||
```
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
**New Files:**
|
||||
- `openhands_cli/signal_handler.py` - Signal handling classes
|
||||
- `openhands_cli/process_runner.py` - Process-based conversation runner
|
||||
- `test_ctrl_c.py` - Test script for Ctrl+C behavior
|
||||
|
||||
**Modified Files:**
|
||||
- `openhands_cli/listeners/pause_listener.py` - Removed Ctrl+C handling
|
||||
- `openhands_cli/agent_chat.py` - Integrated new signal handling and process runner
|
||||
- `openhands_cli/simple_main.py` - Added basic signal handler
|
||||
|
||||
## Dependencies
|
||||
|
||||
Uses standard Python libraries:
|
||||
- `signal` - For signal handling
|
||||
- `multiprocessing` - For separate process execution
|
||||
- `queue` - For inter-process communication
|
||||
- `threading` - For thread-safe signal counting
|
||||
- `time` - For timeout management
|
||||
@@ -1,88 +0,0 @@
|
||||
# Ctrl+C Handling Improvements
|
||||
|
||||
## Summary
|
||||
|
||||
Simplified the overly complex Ctrl+C handling implementation in the OpenHands CLI to make it more reliable and easier to understand.
|
||||
|
||||
## Problems Addressed
|
||||
|
||||
1. **Second Ctrl+C not registering properly** - The original implementation had complex queue-based communication that could miss signals
|
||||
2. **Overly complex multiprocessing** - Many methods were unnecessarily wrapped in separate processes
|
||||
3. **No reset of Ctrl+C count** - The count wasn't reset when starting new message processing
|
||||
4. **Unnecessary queue communication** - Status and settings methods didn't need separate processes
|
||||
|
||||
## Solution
|
||||
|
||||
### 1. Simplified Signal Handler (`simple_signal_handler.py`)
|
||||
|
||||
- **Direct signal handling** in the main process instead of complex queue communication
|
||||
- **Simple Ctrl+C counting** with immediate force kill on second press within 3 seconds
|
||||
- **Clear process management** with direct process termination
|
||||
- **Reset functionality** to clear count when starting new operations
|
||||
|
||||
Key features:
|
||||
- First Ctrl+C: Graceful termination (SIGTERM)
|
||||
- Second Ctrl+C (within 3 seconds): Force kill (SIGKILL)
|
||||
- Automatic count reset after 3 seconds
|
||||
- Manual count reset via `reset_count()`
|
||||
|
||||
### 2. Simplified Process Runner (`simple_process_runner.py`)
|
||||
|
||||
- **Minimal multiprocessing** - Only the `process_message` method runs in a subprocess
|
||||
- **Direct method calls** for status, settings, and other operations
|
||||
- **Simple API** with clear process lifecycle management
|
||||
- **No queue communication** for methods that don't need it
|
||||
|
||||
Key features:
|
||||
- `process_message()`: Runs in subprocess for isolation
|
||||
- `get_status()`, `get_settings()`, etc.: Run directly in main process
|
||||
- `cleanup()`: Simple process termination
|
||||
- `current_process` property for signal handler integration
|
||||
|
||||
### 3. Updated Main CLI (`agent_chat.py`)
|
||||
|
||||
- **Simplified imports** using the new signal handler and process runner
|
||||
- **Reset Ctrl+C count** when starting new message processing
|
||||
- **Direct method calls** for commands that don't need process isolation
|
||||
- **Cleaner error handling** and resource cleanup
|
||||
|
||||
## Files Modified
|
||||
|
||||
### New Files
|
||||
- `openhands_cli/simple_signal_handler.py` - Simplified signal handling
|
||||
- `openhands_cli/simple_process_runner.py` - Minimal process wrapper
|
||||
|
||||
### Modified Files
|
||||
- `openhands_cli/agent_chat.py` - Updated to use simplified components
|
||||
- `openhands_cli/simple_main.py` - Updated imports
|
||||
|
||||
### Test Files
|
||||
- `test_basic_signal.py` - Basic signal handler test
|
||||
- `manual_test_ctrl_c.py` - Manual Ctrl+C testing
|
||||
|
||||
## Key Improvements
|
||||
|
||||
1. **Reliability**: Direct signal handling eliminates race conditions
|
||||
2. **Simplicity**: Removed complex queue-based communication
|
||||
3. **Performance**: Most operations run directly in main process
|
||||
4. **Maintainability**: Clear, simple code that's easy to understand
|
||||
5. **User Experience**: Consistent Ctrl+C behavior with immediate force kill option
|
||||
|
||||
## Testing
|
||||
|
||||
The implementation includes test scripts to verify:
|
||||
- Basic signal handler functionality
|
||||
- Ctrl+C counting and reset behavior
|
||||
- Process termination (graceful and force)
|
||||
- Integration with the CLI
|
||||
|
||||
## Usage
|
||||
|
||||
The simplified implementation maintains the same external API:
|
||||
- First Ctrl+C: Attempts graceful pause/termination
|
||||
- Second Ctrl+C (within 3 seconds): Force kills the process immediately
|
||||
- Count resets automatically or when starting new operations
|
||||
|
||||
## Migration
|
||||
|
||||
The changes are backward compatible with the existing CLI interface. The complex `ProcessSignalHandler` and `ProcessBasedConversationRunner` classes are replaced with simpler equivalents that provide the same functionality with better reliability.
|
||||
@@ -1,6 +1,8 @@
|
||||
# OpenHands V1 CLI
|
||||
|
||||
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [OpenHands software-agent-sdk](https://github.com/OpenHands/software-agent-sdk)).
|
||||
A **lightweight, modern CLI** to interact with the OpenHands agent (powered by [agent-sdk](https://github.com/OpenHands/agent-sdk)).
|
||||
|
||||
The [OpenHands V0 CLI (legacy)](https://github.com/OpenHands/OpenHands/tree/main/openhands/cli) is being deprecated.
|
||||
|
||||
---
|
||||
|
||||
@@ -31,4 +33,4 @@ uv run openhands
|
||||
# The binary will be in dist/
|
||||
./dist/openhands # macOS/Linux
|
||||
# dist/openhands.exe # Windows
|
||||
```
|
||||
```
|
||||
+12
-16
@@ -20,6 +20,15 @@ from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
|
||||
|
||||
from openhands.sdk import LLM
|
||||
|
||||
dummy_agent = get_default_cli_agent(
|
||||
llm=LLM(
|
||||
model='dummy-model',
|
||||
api_key='dummy-key',
|
||||
metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
|
||||
),
|
||||
cli_mode=True,
|
||||
)
|
||||
|
||||
# =================================================
|
||||
# SECTION: Build Binary
|
||||
# =================================================
|
||||
@@ -117,7 +126,7 @@ def _is_welcome(line: str) -> bool:
|
||||
return any(marker in s for marker in WELCOME_MARKERS)
|
||||
|
||||
|
||||
def test_executable(dummy_agent) -> bool:
|
||||
def test_executable() -> bool:
|
||||
"""Test the built executable, measuring boot time and total test time."""
|
||||
print('🧪 Testing the built executable...')
|
||||
|
||||
@@ -265,14 +274,7 @@ def main() -> int:
|
||||
|
||||
# Test the executable
|
||||
if not args.no_test:
|
||||
dummy_agent = get_default_cli_agent(
|
||||
llm=LLM(
|
||||
model='dummy-model',
|
||||
api_key='dummy-key',
|
||||
metadata=get_llm_metadata(model_name='dummy-model', llm_type='openhands'),
|
||||
)
|
||||
)
|
||||
if not test_executable(dummy_agent):
|
||||
if not test_executable():
|
||||
print('❌ Executable test failed, build process failed')
|
||||
return 1
|
||||
|
||||
@@ -283,10 +285,4 @@ def main() -> int:
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
sys.exit(main())
|
||||
except Exception as e:
|
||||
print(e)
|
||||
print('❌ Executable test failed')
|
||||
sys.exit(1)
|
||||
|
||||
sys.exit(main())
|
||||
|
||||
@@ -17,8 +17,6 @@ from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
from openhands_cli.simple_process_runner import SimpleProcessRunner
|
||||
from openhands_cli.simple_signal_handler import SimpleSignalHandler
|
||||
from openhands_cli.setup import (
|
||||
MissingAgentSpec,
|
||||
setup_conversation,
|
||||
@@ -97,144 +95,119 @@ def run_cli_entry(resume_conversation_id: str | None = None) -> None:
|
||||
# Track session start time for uptime calculation
|
||||
session_start_time = datetime.now()
|
||||
|
||||
# Create simple signal handler and session
|
||||
signal_handler = SimpleSignalHandler()
|
||||
signal_handler.install()
|
||||
# Create conversation runner to handle state machine logic
|
||||
runner = None
|
||||
session = get_session_prompter()
|
||||
|
||||
# Set up conversation
|
||||
conversation = setup_conversation(conversation_id)
|
||||
|
||||
# Create simple process runner
|
||||
process_runner = SimpleProcessRunner(conversation)
|
||||
|
||||
try:
|
||||
# Main chat loop
|
||||
while True:
|
||||
try:
|
||||
# Get user input
|
||||
user_input = session.prompt(
|
||||
HTML('<gold>> </gold>'),
|
||||
multiline=False,
|
||||
)
|
||||
# Main chat loop
|
||||
while True:
|
||||
try:
|
||||
# Get user input
|
||||
user_input = session.prompt(
|
||||
HTML('<gold>> </gold>'),
|
||||
multiline=False,
|
||||
)
|
||||
|
||||
if not user_input.strip():
|
||||
continue
|
||||
if not user_input.strip():
|
||||
continue
|
||||
|
||||
# Handle commands
|
||||
command = user_input.strip().lower()
|
||||
# Handle commands
|
||||
command = user_input.strip().lower()
|
||||
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[TextContent(text=user_input)],
|
||||
)
|
||||
message = Message(
|
||||
role='user',
|
||||
content=[TextContent(text=user_input)],
|
||||
)
|
||||
|
||||
if command == '/exit':
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
if command == '/exit':
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
|
||||
elif command == '/settings':
|
||||
# For process-based runner, we can't directly access the conversation
|
||||
# TODO: Implement settings access through process communication if needed
|
||||
settings_screen = SettingsScreen(None)
|
||||
settings_screen.display_settings()
|
||||
continue
|
||||
elif command == '/settings':
|
||||
settings_screen = SettingsScreen(conversation)
|
||||
settings_screen.display_settings()
|
||||
continue
|
||||
|
||||
elif command == '/mcp':
|
||||
mcp_screen = MCPScreen()
|
||||
mcp_screen.display_mcp_info(initialized_agent)
|
||||
continue
|
||||
elif command == '/mcp':
|
||||
mcp_screen = MCPScreen()
|
||||
mcp_screen.display_mcp_info(initialized_agent)
|
||||
continue
|
||||
|
||||
elif command == '/clear':
|
||||
display_welcome(conversation_id)
|
||||
continue
|
||||
elif command == '/clear':
|
||||
display_welcome(conversation_id)
|
||||
continue
|
||||
|
||||
elif command == '/new':
|
||||
try:
|
||||
# Clean up existing process runner
|
||||
if process_runner:
|
||||
process_runner.cleanup()
|
||||
|
||||
# Create fresh conversation with new process runner
|
||||
conversation_id = uuid.uuid4()
|
||||
conversation = setup_conversation(conversation_id)
|
||||
process_runner = SimpleProcessRunner(conversation)
|
||||
display_welcome(conversation_id, resume=False)
|
||||
print_formatted_text(
|
||||
HTML('<green>✓ Started fresh conversation</green>')
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
print_formatted_text(
|
||||
HTML(f'<red>Error starting fresh conversation: {e}</red>')
|
||||
)
|
||||
continue
|
||||
|
||||
elif command == '/help':
|
||||
display_help()
|
||||
continue
|
||||
|
||||
elif command == '/status':
|
||||
status = process_runner.get_status()
|
||||
print_formatted_text(HTML(f'<yellow>Conversation ID:</yellow> {status["conversation_id"]}'))
|
||||
print_formatted_text(HTML(f'<yellow>Agent State:</yellow> {status.get("agent_state", "Unknown")}'))
|
||||
print_formatted_text(HTML(f'<yellow>Process Running:</yellow> {status["is_running"]}'))
|
||||
continue
|
||||
|
||||
elif command == '/confirm':
|
||||
result = process_runner.toggle_confirmation_mode()
|
||||
mode_text = "Enabled" if result else "Disabled"
|
||||
print_formatted_text(HTML(f'<yellow>Confirmation mode: {mode_text}</yellow>'))
|
||||
continue
|
||||
|
||||
elif command == '/resume':
|
||||
try:
|
||||
process_runner.resume()
|
||||
print_formatted_text(HTML('<green>Agent resumed</green>'))
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Failed to resume: {e}</red>'))
|
||||
continue
|
||||
|
||||
# Reset Ctrl+C count when starting new message processing
|
||||
signal_handler.reset_count()
|
||||
|
||||
# Process the message
|
||||
elif command == '/new':
|
||||
try:
|
||||
# Set the current process for signal handling
|
||||
signal_handler.set_process(process_runner.current_process)
|
||||
|
||||
# Create message object
|
||||
message = Message(role='user', content=[TextContent(text=user_input)])
|
||||
result = process_runner.process_message(message)
|
||||
print() # Add spacing for successful processing
|
||||
|
||||
# Start a fresh conversation (no resume ID = new conversation)
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
display_welcome(conversation_id, resume=False)
|
||||
print_formatted_text(
|
||||
HTML('<green>✓ Started fresh conversation</green>')
|
||||
)
|
||||
continue
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Failed to process message: {e}</red>'))
|
||||
finally:
|
||||
# Clear the process reference
|
||||
signal_handler.set_process(None)
|
||||
print_formatted_text(
|
||||
HTML(f'<red>Error starting fresh conversation: {e}</red>')
|
||||
)
|
||||
continue
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# KeyboardInterrupt should be handled by the signal handler now
|
||||
# Just continue the loop - the signal handler manages the process
|
||||
continue
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error in chat loop: {e}</red>'))
|
||||
elif command == '/help':
|
||||
display_help()
|
||||
continue
|
||||
|
||||
except KeyboardInterrupt:
|
||||
# Final fallback for KeyboardInterrupt - only exit if we're not in the main loop
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
elif command == '/status':
|
||||
display_status(conversation, session_start_time=session_start_time)
|
||||
continue
|
||||
|
||||
finally:
|
||||
# Clean up resources
|
||||
if process_runner:
|
||||
process_runner.cleanup()
|
||||
signal_handler.uninstall()
|
||||
|
||||
# Clean up terminal state
|
||||
_restore_tty()
|
||||
elif command == '/confirm':
|
||||
runner.toggle_confirmation_mode()
|
||||
new_status = (
|
||||
'enabled' if runner.is_confirmation_mode_active else 'disabled'
|
||||
)
|
||||
print_formatted_text(
|
||||
HTML(f'<yellow>Confirmation mode {new_status}</yellow>')
|
||||
)
|
||||
continue
|
||||
|
||||
elif command == '/resume':
|
||||
if not runner:
|
||||
print_formatted_text(
|
||||
HTML('<yellow>No active conversation running...</yellow>')
|
||||
)
|
||||
continue
|
||||
|
||||
conversation = runner.conversation
|
||||
if not (
|
||||
conversation.state.agent_status == AgentExecutionStatus.PAUSED
|
||||
or conversation.state.agent_status
|
||||
== AgentExecutionStatus.WAITING_FOR_CONFIRMATION
|
||||
):
|
||||
print_formatted_text(
|
||||
HTML('<red>No paused conversation to resume...</red>')
|
||||
)
|
||||
continue
|
||||
|
||||
# Resume without new message
|
||||
message = None
|
||||
|
||||
if not runner:
|
||||
conversation = setup_conversation(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
runner.process_message(message)
|
||||
|
||||
print() # Add spacing
|
||||
|
||||
except KeyboardInterrupt:
|
||||
exit_confirmation = exit_session_confirmation()
|
||||
if exit_confirmation == UserConfirmation.ACCEPT:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
_print_exit_hint(conversation_id)
|
||||
break
|
||||
|
||||
# Clean up terminal state
|
||||
_restore_tty()
|
||||
|
||||
@@ -31,9 +31,8 @@ class PauseListener(threading.Thread):
|
||||
|
||||
for key_press in self._input.read_keys():
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlP
|
||||
# Note: Ctrl+C and Ctrl+D are now handled by the signal handler
|
||||
# pause_detected = pause_detected or key_press.key == Keys.ControlC
|
||||
# pause_detected = pause_detected or key_press.key == Keys.ControlD
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlC
|
||||
pause_detected = pause_detected or key_press.key == Keys.ControlD
|
||||
|
||||
return pause_detected
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
"""
|
||||
Process-based conversation runner for handling agent execution in a separate process.
|
||||
|
||||
This allows for immediate termination of the agent when needed while maintaining
|
||||
the ability to gracefully pause on the first Ctrl+C.
|
||||
"""
|
||||
|
||||
import multiprocessing
|
||||
import queue
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from openhands.sdk import BaseConversation, Message
|
||||
from openhands.sdk.conversation.state import AgentExecutionStatus
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
|
||||
|
||||
class ProcessCommand(Enum):
|
||||
"""Commands that can be sent to the conversation process."""
|
||||
PROCESS_MESSAGE = "process_message"
|
||||
PAUSE = "pause"
|
||||
RESUME = "resume"
|
||||
TOGGLE_CONFIRMATION = "toggle_confirmation"
|
||||
GET_STATUS = "get_status"
|
||||
SHUTDOWN = "shutdown"
|
||||
|
||||
|
||||
class ProcessResponse(Enum):
|
||||
"""Response types from the conversation process."""
|
||||
SUCCESS = "success"
|
||||
ERROR = "error"
|
||||
STATUS = "status"
|
||||
|
||||
|
||||
def conversation_worker(
|
||||
conversation_id: str,
|
||||
command_queue: multiprocessing.Queue,
|
||||
response_queue: multiprocessing.Queue,
|
||||
setup_conversation_func: Any, # Function to setup conversation
|
||||
) -> None:
|
||||
"""Worker function that runs in a separate process to handle conversation."""
|
||||
|
||||
# Set up signal handling in the worker process
|
||||
def signal_handler(signum, frame):
|
||||
print_formatted_text(HTML('<yellow>Conversation process received termination signal.</yellow>'))
|
||||
return
|
||||
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
signal.signal(signal.SIGINT, signal.SIG_IGN) # Ignore SIGINT in worker process
|
||||
|
||||
try:
|
||||
# Setup conversation in the worker process
|
||||
conversation = setup_conversation_func(conversation_id)
|
||||
runner = ConversationRunner(conversation)
|
||||
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Conversation process initialized"
|
||||
})
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check for commands with timeout
|
||||
try:
|
||||
command_data = command_queue.get(timeout=0.1)
|
||||
except queue.Empty:
|
||||
continue
|
||||
|
||||
command = command_data.get("command")
|
||||
args = command_data.get("args", {})
|
||||
|
||||
if command == ProcessCommand.SHUTDOWN:
|
||||
break
|
||||
|
||||
elif command == ProcessCommand.PROCESS_MESSAGE:
|
||||
message = args.get("message")
|
||||
try:
|
||||
runner.process_message(message)
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Message processed"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error processing message: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.PAUSE:
|
||||
try:
|
||||
runner.conversation.pause()
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Conversation paused"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error pausing conversation: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.RESUME:
|
||||
try:
|
||||
runner.process_message(None) # Resume without new message
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": "Conversation resumed"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error resuming conversation: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.TOGGLE_CONFIRMATION:
|
||||
try:
|
||||
runner.toggle_confirmation_mode()
|
||||
new_status = 'enabled' if runner.is_confirmation_mode_active else 'disabled'
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.SUCCESS,
|
||||
"message": f"Confirmation mode {new_status}"
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error toggling confirmation mode: {e}"
|
||||
})
|
||||
|
||||
elif command == ProcessCommand.GET_STATUS:
|
||||
try:
|
||||
status = {
|
||||
"agent_status": runner.conversation.state.agent_status,
|
||||
"confirmation_mode": runner.is_confirmation_mode_active
|
||||
}
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.STATUS,
|
||||
"data": status
|
||||
})
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Error getting status: {e}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Unexpected error in conversation worker: {e}"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
response_queue.put({
|
||||
"type": ProcessResponse.ERROR,
|
||||
"message": f"Failed to initialize conversation process: {e}"
|
||||
})
|
||||
|
||||
|
||||
class ProcessBasedConversationRunner:
|
||||
"""Manages a conversation runner in a separate process."""
|
||||
|
||||
def __init__(self, conversation_id: str, setup_conversation_func: Any):
|
||||
self.conversation_id = conversation_id
|
||||
self.setup_conversation_func = setup_conversation_func
|
||||
self.process: Optional[multiprocessing.Process] = None
|
||||
self.command_queue: Optional[multiprocessing.Queue] = None
|
||||
self.response_queue: Optional[multiprocessing.Queue] = None
|
||||
self.is_running = False
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Start the conversation process."""
|
||||
if self.is_running:
|
||||
return True
|
||||
|
||||
try:
|
||||
# Create queues for communication
|
||||
self.command_queue = multiprocessing.Queue()
|
||||
self.response_queue = multiprocessing.Queue()
|
||||
|
||||
# Start the worker process
|
||||
self.process = multiprocessing.Process(
|
||||
target=conversation_worker,
|
||||
args=(
|
||||
self.conversation_id,
|
||||
self.command_queue,
|
||||
self.response_queue,
|
||||
self.setup_conversation_func
|
||||
)
|
||||
)
|
||||
self.process.start()
|
||||
|
||||
# Wait for initialization confirmation
|
||||
try:
|
||||
response = self.response_queue.get(timeout=10.0)
|
||||
if response["type"] == ProcessResponse.SUCCESS:
|
||||
self.is_running = True
|
||||
return True
|
||||
else:
|
||||
print_formatted_text(HTML(f'<red>Failed to initialize conversation process: {response.get("message", "Unknown error")}</red>'))
|
||||
self.stop()
|
||||
return False
|
||||
except queue.Empty:
|
||||
print_formatted_text(HTML('<red>Timeout waiting for conversation process to initialize</red>'))
|
||||
self.stop()
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error starting conversation process: {e}</red>'))
|
||||
return False
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the conversation process."""
|
||||
if not self.is_running:
|
||||
return
|
||||
|
||||
try:
|
||||
if self.command_queue:
|
||||
self.command_queue.put({"command": ProcessCommand.SHUTDOWN})
|
||||
|
||||
if self.process:
|
||||
self.process.join(timeout=2.0)
|
||||
if self.process.is_alive():
|
||||
self.process.terminate()
|
||||
self.process.join(timeout=1.0)
|
||||
if self.process.is_alive():
|
||||
self.process.kill()
|
||||
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<yellow>Warning: Error stopping conversation process: {e}</yellow>'))
|
||||
|
||||
finally:
|
||||
self.is_running = False
|
||||
self.process = None
|
||||
self.command_queue = None
|
||||
self.response_queue = None
|
||||
|
||||
def send_command(self, command: ProcessCommand, args: Optional[Dict] = None, timeout: float = 5.0) -> Optional[Dict]:
|
||||
"""Send a command to the conversation process and wait for response."""
|
||||
if not self.is_running or not self.command_queue or not self.response_queue:
|
||||
return None
|
||||
|
||||
try:
|
||||
command_data = {"command": command, "args": args or {}}
|
||||
self.command_queue.put(command_data)
|
||||
|
||||
response = self.response_queue.get(timeout=timeout)
|
||||
return response
|
||||
|
||||
except queue.Empty:
|
||||
print_formatted_text(HTML(f'<yellow>Timeout waiting for response to {command.value}</yellow>'))
|
||||
return None
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error sending command {command.value}: {e}</red>'))
|
||||
return None
|
||||
|
||||
def process_message(self, message: Optional[Message]) -> bool:
|
||||
"""Process a message through the conversation."""
|
||||
response = self.send_command(ProcessCommand.PROCESS_MESSAGE, {"message": message})
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return True
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return False
|
||||
|
||||
def pause(self) -> bool:
|
||||
"""Pause the conversation."""
|
||||
response = self.send_command(ProcessCommand.PAUSE)
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return True
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return False
|
||||
|
||||
def resume(self) -> bool:
|
||||
"""Resume the conversation."""
|
||||
response = self.send_command(ProcessCommand.RESUME)
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return True
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return False
|
||||
|
||||
def toggle_confirmation_mode(self) -> Optional[str]:
|
||||
"""Toggle confirmation mode and return the new status."""
|
||||
response = self.send_command(ProcessCommand.TOGGLE_CONFIRMATION)
|
||||
if response and response["type"] == ProcessResponse.SUCCESS:
|
||||
return response.get("message")
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return None
|
||||
|
||||
def get_status(self) -> Optional[Dict]:
|
||||
"""Get the current status of the conversation."""
|
||||
response = self.send_command(ProcessCommand.GET_STATUS)
|
||||
if response and response["type"] == ProcessResponse.STATUS:
|
||||
return response.get("data")
|
||||
elif response:
|
||||
print_formatted_text(HTML(f'<red>{response.get("message", "Unknown error")}</red>'))
|
||||
return None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
"""Check if the conversation process is alive."""
|
||||
return self.is_running and self.process and self.process.is_alive()
|
||||
|
||||
def force_terminate(self) -> None:
|
||||
"""Force terminate the conversation process immediately."""
|
||||
if self.process and self.process.is_alive():
|
||||
self.process.kill()
|
||||
self.process.join(timeout=1.0)
|
||||
self.is_running = False
|
||||
@@ -1,113 +0,0 @@
|
||||
"""
|
||||
Signal handling for graceful shutdown and immediate termination.
|
||||
|
||||
This module provides a signal handler that tracks Ctrl+C presses:
|
||||
- First Ctrl+C: Attempt graceful pause of the agent
|
||||
- Second Ctrl+C: Immediately terminate the process
|
||||
"""
|
||||
|
||||
import signal
|
||||
import threading
|
||||
import time
|
||||
from typing import Callable, Optional
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
|
||||
class SignalHandler:
|
||||
"""Handles SIGINT (Ctrl+C) with graceful shutdown on first press and immediate termination on second."""
|
||||
|
||||
def __init__(self, graceful_shutdown_callback: Optional[Callable] = None):
|
||||
self.graceful_shutdown_callback = graceful_shutdown_callback
|
||||
self.sigint_count = 0
|
||||
self.last_sigint_time = 0.0
|
||||
self.sigint_timeout = 3.0 # Reset counter after 3 seconds
|
||||
self.lock = threading.Lock()
|
||||
self.original_handler = None
|
||||
|
||||
def install(self) -> None:
|
||||
"""Install the signal handler."""
|
||||
self.original_handler = signal.signal(signal.SIGINT, self._handle_sigint)
|
||||
|
||||
def uninstall(self) -> None:
|
||||
"""Restore the original signal handler."""
|
||||
if self.original_handler is not None:
|
||||
signal.signal(signal.SIGINT, self.original_handler)
|
||||
self.original_handler = None
|
||||
|
||||
def _handle_sigint(self, signum: int, frame) -> None:
|
||||
"""Handle SIGINT (Ctrl+C) signal."""
|
||||
current_time = time.time()
|
||||
|
||||
with self.lock:
|
||||
# Reset counter if too much time has passed since last Ctrl+C
|
||||
if current_time - self.last_sigint_time > self.sigint_timeout:
|
||||
self.sigint_count = 0
|
||||
|
||||
self.sigint_count += 1
|
||||
self.last_sigint_time = current_time
|
||||
|
||||
if self.sigint_count == 1:
|
||||
# First Ctrl+C: attempt graceful shutdown
|
||||
print_formatted_text(HTML('\n<yellow>Received Ctrl+C. Attempting to pause agent gracefully...</yellow>'))
|
||||
print_formatted_text(HTML('<grey>Press Ctrl+C again within 3 seconds to force immediate termination.</grey>'))
|
||||
|
||||
if self.graceful_shutdown_callback:
|
||||
try:
|
||||
self.graceful_shutdown_callback()
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error during graceful shutdown: {e}</red>'))
|
||||
|
||||
elif self.sigint_count >= 2:
|
||||
# Second Ctrl+C: immediate termination
|
||||
print_formatted_text(HTML('\n<red>Received second Ctrl+C. Terminating immediately...</red>'))
|
||||
self.uninstall()
|
||||
# Force immediate exit
|
||||
import os
|
||||
os._exit(1)
|
||||
|
||||
|
||||
class ProcessSignalHandler:
|
||||
"""Signal handler for managing conversation runner processes."""
|
||||
|
||||
def __init__(self):
|
||||
self.conversation_process = None
|
||||
self.signal_handler = None
|
||||
|
||||
def set_conversation_process(self, process) -> None:
|
||||
"""Set the conversation process to manage."""
|
||||
self.conversation_process = process
|
||||
|
||||
def graceful_shutdown(self) -> None:
|
||||
"""Attempt graceful shutdown of the conversation process."""
|
||||
if hasattr(self, 'conversation_process') and self.conversation_process and self.conversation_process.is_alive():
|
||||
print_formatted_text(HTML('<yellow>Pausing agent once current step is completed...</yellow>'))
|
||||
# Send SIGTERM to the process for graceful shutdown
|
||||
self.conversation_process.terminate()
|
||||
|
||||
# Give it a moment to shut down gracefully
|
||||
self.conversation_process.join(timeout=2.0)
|
||||
|
||||
if self.conversation_process.is_alive():
|
||||
print_formatted_text(HTML('<yellow>Agent is taking time to pause. Press Ctrl+C again to force termination.</yellow>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<green>Agent paused successfully.</green>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<yellow>No active conversation process to pause.</yellow>'))
|
||||
|
||||
def install_handler(self) -> None:
|
||||
"""Install the signal handler."""
|
||||
self.signal_handler = SignalHandler(graceful_shutdown_callback=self.graceful_shutdown)
|
||||
self.signal_handler.install()
|
||||
|
||||
def uninstall_handler(self) -> None:
|
||||
"""Uninstall the signal handler."""
|
||||
if self.signal_handler:
|
||||
self.signal_handler.uninstall()
|
||||
self.signal_handler = None
|
||||
|
||||
def force_terminate(self) -> None:
|
||||
"""Force terminate the conversation process."""
|
||||
if self.conversation_process and self.conversation_process.is_alive():
|
||||
self.conversation_process.kill()
|
||||
self.conversation_process.join(timeout=1.0)
|
||||
@@ -18,7 +18,6 @@ from prompt_toolkit import print_formatted_text
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
|
||||
from openhands_cli.argparsers.main_parser import create_main_parser
|
||||
from openhands_cli.simple_signal_handler import SimpleSignalHandler
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -31,15 +30,8 @@ def main() -> None:
|
||||
parser = create_main_parser()
|
||||
args = parser.parse_args()
|
||||
|
||||
# Install basic signal handler for the main process
|
||||
# The agent_chat module will install its own more sophisticated handler
|
||||
signal_handler = SimpleSignalHandler()
|
||||
|
||||
try:
|
||||
if args.command == 'serve':
|
||||
# For GUI mode, use basic signal handling
|
||||
signal_handler.install()
|
||||
|
||||
# Import gui_launcher only when needed
|
||||
from openhands_cli.gui_launcher import launch_gui_server
|
||||
|
||||
@@ -49,7 +41,7 @@ def main() -> None:
|
||||
# Import agent_chat only when needed
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
|
||||
# Start agent chat (it will install its own signal handler)
|
||||
# Start agent chat
|
||||
run_cli_entry(resume_conversation_id=args.resume)
|
||||
except KeyboardInterrupt:
|
||||
print_formatted_text(HTML('\n<yellow>Goodbye! 👋</yellow>'))
|
||||
@@ -61,8 +53,6 @@ def main() -> None:
|
||||
|
||||
traceback.print_exc()
|
||||
raise
|
||||
finally:
|
||||
signal_handler.uninstall()
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
"""
|
||||
Simple process-based conversation runner for OpenHands CLI.
|
||||
|
||||
Only the actual conversation running (process_message) is wrapped in a separate process.
|
||||
All other methods run in the main process.
|
||||
"""
|
||||
|
||||
import multiprocessing
|
||||
from typing import Any, Optional
|
||||
|
||||
from openhands.sdk import BaseConversation, Message
|
||||
from openhands_cli.runner import ConversationRunner
|
||||
|
||||
|
||||
def _run_conversation_in_process(conversation_id: str, message_data: Optional[dict], result_queue: multiprocessing.Queue):
|
||||
"""Run the conversation in a separate process."""
|
||||
try:
|
||||
from openhands_cli.setup import setup_conversation
|
||||
from openhands.sdk import Message, TextContent
|
||||
import uuid
|
||||
|
||||
# Recreate conversation in this process
|
||||
conv_id = uuid.UUID(conversation_id)
|
||||
conversation = setup_conversation(conv_id)
|
||||
|
||||
# Create conversation runner
|
||||
runner = ConversationRunner(conversation)
|
||||
|
||||
if message_data:
|
||||
# Recreate message from data
|
||||
message = Message(
|
||||
role=message_data['role'],
|
||||
content=[TextContent(text=message_data['content_text'])]
|
||||
)
|
||||
# Process the message
|
||||
runner.process_message(message)
|
||||
|
||||
# Put success result in the queue
|
||||
result_queue.put(('success', None))
|
||||
|
||||
except KeyboardInterrupt:
|
||||
result_queue.put(('interrupted', None))
|
||||
except Exception as e:
|
||||
result_queue.put(('error', str(e)))
|
||||
|
||||
|
||||
class SimpleProcessRunner:
|
||||
"""Simple conversation runner that only uses multiprocessing for the actual conversation."""
|
||||
|
||||
def __init__(self, conversation: BaseConversation):
|
||||
"""Initialize the process runner.
|
||||
|
||||
Args:
|
||||
conversation: The conversation instance
|
||||
"""
|
||||
self.conversation = conversation
|
||||
self.conversation_id = str(conversation.conversation_id)
|
||||
self.current_process: Optional[multiprocessing.Process] = None
|
||||
self.result_queue: Optional[multiprocessing.Queue] = None
|
||||
|
||||
# Create a runner for main process operations
|
||||
self.runner = ConversationRunner(conversation)
|
||||
|
||||
def process_message(self, message: Optional[Message]) -> bool:
|
||||
"""Process a message in a separate process.
|
||||
|
||||
Args:
|
||||
message: The user message to process
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise
|
||||
"""
|
||||
# Create queue for result
|
||||
self.result_queue = multiprocessing.Queue()
|
||||
|
||||
# Prepare message data for serialization
|
||||
message_data = None
|
||||
if message:
|
||||
# Extract text content from the message
|
||||
content_text = ""
|
||||
for content in message.content:
|
||||
if hasattr(content, 'text'):
|
||||
content_text += content.text
|
||||
|
||||
message_data = {
|
||||
'role': message.role,
|
||||
'content_text': content_text
|
||||
}
|
||||
|
||||
# Create and start process
|
||||
self.current_process = multiprocessing.Process(
|
||||
target=_run_conversation_in_process,
|
||||
args=(self.conversation_id, message_data, self.result_queue)
|
||||
)
|
||||
self.current_process.start()
|
||||
|
||||
# Wait for result
|
||||
try:
|
||||
result_type, result_data = self.result_queue.get()
|
||||
self.current_process.join()
|
||||
|
||||
if result_type == 'success':
|
||||
return True
|
||||
elif result_type == 'interrupted':
|
||||
print("Agent was interrupted by user")
|
||||
return False
|
||||
else:
|
||||
print(f"Process error: {result_data}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
# Check if process was killed by signal handler
|
||||
if self.current_process and not self.current_process.is_alive():
|
||||
# Process was killed, likely by Ctrl+C handler
|
||||
return False
|
||||
|
||||
# Clean up if process is still alive
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.terminate()
|
||||
self.current_process.join(timeout=2)
|
||||
if self.current_process.is_alive():
|
||||
self.current_process.kill()
|
||||
self.current_process.join()
|
||||
raise e
|
||||
finally:
|
||||
self.current_process = None
|
||||
self.result_queue = None
|
||||
|
||||
def get_status(self) -> dict:
|
||||
"""Get conversation status (runs in main process)."""
|
||||
return {
|
||||
'conversation_id': self.conversation.id,
|
||||
'agent_status': self.conversation.state.agent_status.value if self.conversation.state else 'unknown',
|
||||
'is_running': self.current_process is not None and self.current_process.is_alive()
|
||||
}
|
||||
|
||||
def toggle_confirmation_mode(self) -> bool:
|
||||
"""Toggle confirmation mode (runs in main process)."""
|
||||
self.runner.toggle_confirmation_mode()
|
||||
# Update our conversation reference
|
||||
self.conversation = self.runner.conversation
|
||||
return self.conversation.is_confirmation_mode_active
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume the agent (runs in main process)."""
|
||||
# This would be handled by the conversation state
|
||||
pass
|
||||
|
||||
def cleanup(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.terminate()
|
||||
self.current_process.join(timeout=2)
|
||||
if self.current_process.is_alive():
|
||||
self.current_process.kill()
|
||||
self.current_process.join()
|
||||
|
||||
# Clean up conversation resources if needed
|
||||
if hasattr(self.conversation, 'close'):
|
||||
self.conversation.close()
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
Simple signal handling for Ctrl+C behavior in OpenHands CLI.
|
||||
|
||||
- First Ctrl+C: Attempt graceful pause of the agent
|
||||
- Second Ctrl+C: Immediately kill the process
|
||||
"""
|
||||
|
||||
import signal
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
|
||||
|
||||
class SimpleSignalHandler:
|
||||
"""Simple signal handler that tracks Ctrl+C presses and manages a subprocess."""
|
||||
|
||||
def __init__(self):
|
||||
self.ctrl_c_count = 0
|
||||
self.last_ctrl_c_time = 0.0
|
||||
self.timeout = 3.0 # Reset counter after 3 seconds
|
||||
self.original_handler = None
|
||||
self.current_process: Optional[object] = None
|
||||
|
||||
def install(self) -> None:
|
||||
"""Install the signal handler."""
|
||||
self.original_handler = signal.signal(signal.SIGINT, self._handle_ctrl_c)
|
||||
|
||||
def uninstall(self) -> None:
|
||||
"""Restore the original signal handler."""
|
||||
if self.original_handler is not None:
|
||||
signal.signal(signal.SIGINT, self.original_handler)
|
||||
self.original_handler = None
|
||||
|
||||
def reset_count(self) -> None:
|
||||
"""Reset the Ctrl+C count (called when starting new message processing)."""
|
||||
self.ctrl_c_count = 0
|
||||
self.last_ctrl_c_time = 0.0
|
||||
|
||||
def set_process(self, process) -> None:
|
||||
"""Set the current process to manage."""
|
||||
self.current_process = process
|
||||
|
||||
def _handle_ctrl_c(self, signum: int, frame) -> None:
|
||||
"""Handle Ctrl+C signal."""
|
||||
current_time = time.time()
|
||||
|
||||
# Reset counter if too much time has passed
|
||||
if current_time - self.last_ctrl_c_time > self.timeout:
|
||||
self.ctrl_c_count = 0
|
||||
|
||||
self.ctrl_c_count += 1
|
||||
self.last_ctrl_c_time = current_time
|
||||
|
||||
if self.ctrl_c_count == 1:
|
||||
print_formatted_text(HTML('<yellow>Received Ctrl+C. Attempting to pause agent...</yellow>'))
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.terminate()
|
||||
print_formatted_text(HTML('<yellow>Press Ctrl+C again within 3 seconds to force kill.</yellow>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<yellow>No active process to pause.</yellow>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<red>Received second Ctrl+C. Force killing process...</red>'))
|
||||
if self.current_process and self.current_process.is_alive():
|
||||
self.current_process.kill()
|
||||
# Reset the counter so user can continue with new messages
|
||||
self.reset_count()
|
||||
print_formatted_text(HTML('<green>Process stopped. You can continue sending messages.</green>'))
|
||||
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
|
||||
from openhands.sdk import LLM, BaseConversation, LLMSummarizingCondenser, LocalFileStore
|
||||
from openhands.sdk import LLM, BaseConversation, LocalFileStore
|
||||
from prompt_toolkit import HTML, print_formatted_text
|
||||
from prompt_toolkit.shortcuts import print_container
|
||||
from prompt_toolkit.widgets import Frame, TextArea
|
||||
@@ -33,6 +33,9 @@ class SettingsScreen:
|
||||
agent_spec = self.agent_store.load()
|
||||
if not agent_spec:
|
||||
return
|
||||
assert self.conversation is not None, (
|
||||
'Conversation must be set to display settings.'
|
||||
)
|
||||
|
||||
llm = agent_spec.llm
|
||||
advanced_llm_settings = True if llm.base_url else False
|
||||
@@ -59,20 +62,12 @@ class SettingsScreen:
|
||||
labels_and_values.extend(
|
||||
[
|
||||
(' API Key', '********' if llm.api_key else 'Not Set'),
|
||||
]
|
||||
)
|
||||
|
||||
if self.conversation:
|
||||
labels_and_values.extend([
|
||||
(
|
||||
' Confirmation Mode',
|
||||
'Enabled'
|
||||
if self.conversation.is_confirmation_mode_active
|
||||
else 'Disabled',
|
||||
)
|
||||
])
|
||||
|
||||
labels_and_values.extend([
|
||||
),
|
||||
(
|
||||
' Memory Condensation',
|
||||
'Enabled' if agent_spec.condenser else 'Disabled',
|
||||
@@ -158,7 +153,7 @@ class SettingsScreen:
|
||||
api_key = prompt_api_key(
|
||||
step_counter,
|
||||
custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '',
|
||||
self.conversation.state.agent.llm.api_key if self.conversation else None,
|
||||
self.conversation.agent.llm.api_key if self.conversation else None,
|
||||
escapable=escapable,
|
||||
)
|
||||
memory_condensation = choose_memory_condensation(step_counter)
|
||||
@@ -187,14 +182,7 @@ class SettingsScreen:
|
||||
if not agent:
|
||||
agent = get_default_cli_agent(llm=llm)
|
||||
|
||||
# Must update all LLMs
|
||||
agent = agent.model_copy(update={'llm': llm})
|
||||
condenser = LLMSummarizingCondenser(
|
||||
llm=llm.model_copy(
|
||||
update={"usage_id": "condenser"}
|
||||
)
|
||||
)
|
||||
agent = agent.model_copy(update={'condenser': condenser})
|
||||
self.agent_store.save(agent)
|
||||
|
||||
def _save_advanced_settings(
|
||||
|
||||
@@ -4,7 +4,7 @@ requires = [ "hatchling>=1.25" ]
|
||||
|
||||
[project]
|
||||
name = "openhands"
|
||||
version = "1.0.5"
|
||||
version = "1.0.4"
|
||||
description = "OpenHands CLI - Terminal User Interface for OpenHands AI Agent"
|
||||
readme = "README.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to verify Ctrl+C behavior in the OpenHands CLI.
|
||||
|
||||
This script simulates the signal handling behavior to test:
|
||||
1. First Ctrl+C attempts graceful pause
|
||||
2. Second Ctrl+C (within 3 seconds) kills process immediately
|
||||
"""
|
||||
|
||||
import signal
|
||||
import time
|
||||
import multiprocessing
|
||||
from openhands_cli.signal_handler import ProcessSignalHandler
|
||||
|
||||
|
||||
def mock_conversation_process():
|
||||
"""Mock conversation process that runs indefinitely"""
|
||||
print("Mock conversation process started...")
|
||||
try:
|
||||
while True:
|
||||
print("Agent is working...")
|
||||
time.sleep(2)
|
||||
except KeyboardInterrupt:
|
||||
print("Mock conversation process received KeyboardInterrupt")
|
||||
except Exception as e:
|
||||
print(f"Mock conversation process error: {e}")
|
||||
finally:
|
||||
print("Mock conversation process ending")
|
||||
|
||||
|
||||
def test_signal_handling():
|
||||
"""Test the signal handling behavior"""
|
||||
print("Testing Ctrl+C signal handling...")
|
||||
print("Instructions:")
|
||||
print("1. Press Ctrl+C once - should attempt graceful pause")
|
||||
print("2. Press Ctrl+C again within 3 seconds - should kill immediately")
|
||||
print("3. Wait more than 3 seconds between presses to test timeout reset")
|
||||
print()
|
||||
|
||||
# Create and start mock process
|
||||
process = multiprocessing.Process(target=mock_conversation_process)
|
||||
process.start()
|
||||
|
||||
# Install signal handler
|
||||
signal_handler = ProcessSignalHandler()
|
||||
signal_handler.install_handler()
|
||||
signal_handler.set_conversation_process(process)
|
||||
|
||||
try:
|
||||
print("Process started. Press Ctrl+C to test signal handling...")
|
||||
print("Process PID:", process.pid)
|
||||
|
||||
# Wait for process to finish or be killed
|
||||
while process.is_alive():
|
||||
time.sleep(0.5)
|
||||
|
||||
print(f"Process finished with exit code: {process.exitcode}")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Main process received KeyboardInterrupt")
|
||||
finally:
|
||||
# Clean up
|
||||
signal_handler.uninstall_handler()
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=2)
|
||||
if process.is_alive():
|
||||
process.kill()
|
||||
process.join()
|
||||
print("Test completed")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_signal_handling()
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Test for the /settings command functionality."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
from prompt_toolkit.input.defaults import create_pipe_input
|
||||
from prompt_toolkit.output.defaults import DummyOutput
|
||||
|
||||
from openhands_cli.agent_chat import run_cli_entry
|
||||
from openhands_cli.user_actions import UserConfirmation
|
||||
|
||||
|
||||
@patch('openhands_cli.agent_chat.exit_session_confirmation')
|
||||
@patch('openhands_cli.agent_chat.get_session_prompter')
|
||||
@patch('openhands_cli.agent_chat.setup_conversation')
|
||||
@patch('openhands_cli.agent_chat.verify_agent_exists_or_setup_agent')
|
||||
@patch('openhands_cli.agent_chat.ConversationRunner')
|
||||
@patch('openhands_cli.agent_chat.SettingsScreen')
|
||||
def test_settings_command_works_without_conversation(
|
||||
mock_settings_screen_class,
|
||||
mock_runner_cls,
|
||||
mock_verify_agent,
|
||||
mock_setup_conversation,
|
||||
mock_get_session_prompter,
|
||||
mock_exit_confirm,
|
||||
):
|
||||
"""Test that /settings command works when no conversation is active (bug fix scenario)."""
|
||||
# Auto-accept the exit prompt to avoid interactive UI
|
||||
mock_exit_confirm.return_value = UserConfirmation.ACCEPT
|
||||
|
||||
# Mock agent verification to succeed
|
||||
mock_agent = MagicMock()
|
||||
mock_verify_agent.return_value = mock_agent
|
||||
|
||||
# Mock the SettingsScreen instance
|
||||
mock_settings_screen = MagicMock()
|
||||
mock_settings_screen_class.return_value = mock_settings_screen
|
||||
|
||||
# No runner initially (simulates starting CLI without a conversation)
|
||||
mock_runner_cls.return_value = None
|
||||
|
||||
# Real session fed by a pipe
|
||||
from openhands_cli.user_actions.utils import get_session_prompter as real_get_session_prompter
|
||||
with create_pipe_input() as pipe:
|
||||
output = DummyOutput()
|
||||
session = real_get_session_prompter(input=pipe, output=output)
|
||||
mock_get_session_prompter.return_value = session
|
||||
|
||||
# Trigger /settings, then /exit (exit will be auto-accepted)
|
||||
for ch in "/settings\r/exit\r":
|
||||
pipe.send_text(ch)
|
||||
|
||||
run_cli_entry(None)
|
||||
|
||||
# Assert SettingsScreen was created with None conversation (the bug fix)
|
||||
mock_settings_screen_class.assert_called_once_with(None)
|
||||
|
||||
# Assert display_settings was called (settings screen was shown)
|
||||
mock_settings_screen.display_settings.assert_called_once()
|
||||
@@ -121,38 +121,6 @@ def test_update_existing_settings_workflow(tmp_path: Path):
|
||||
assert True # If we get here, the workflow completed successfully
|
||||
|
||||
|
||||
def test_all_llms_in_agent_are_updated():
|
||||
"""Test that modifying LLM settings creates multiple LLMs with same API key but different usage_ids."""
|
||||
# Create a screen with existing agent settings
|
||||
screen = SettingsScreen(conversation=None)
|
||||
initial_llm = LLM(model='openai/gpt-3.5-turbo', api_key=SecretStr('sk-initial'), usage_id='test-service')
|
||||
initial_agent = get_default_cli_agent(llm=initial_llm)
|
||||
|
||||
# Mock the agent store to return the initial agent and capture the save call
|
||||
with (
|
||||
patch.object(screen.agent_store, 'load', return_value=initial_agent),
|
||||
patch.object(screen.agent_store, 'save') as mock_save
|
||||
):
|
||||
# Modify the LLM settings with new API key
|
||||
screen._save_llm_settings(model='openai/gpt-4o-mini', api_key='sk-updated-123')
|
||||
mock_save.assert_called_once()
|
||||
|
||||
# Get the saved agent from the mock
|
||||
saved_agent = mock_save.call_args[0][0]
|
||||
all_llms = list(saved_agent.get_all_llms())
|
||||
assert len(all_llms) >= 2, f"Expected at least 2 LLMs, got {len(all_llms)}"
|
||||
|
||||
# Verify all LLMs have the same API key
|
||||
api_keys = [llm.api_key.get_secret_value() for llm in all_llms]
|
||||
assert all(api_key == 'sk-updated-123' for api_key in api_keys), \
|
||||
f"Not all LLMs have the same API key: {api_keys}"
|
||||
|
||||
# Verify none of the usage_id attributes match
|
||||
usage_ids = [llm.usage_id for llm in all_llms]
|
||||
assert len(set(usage_ids)) == len(usage_ids), \
|
||||
f"Some usage_ids are duplicated: {usage_ids}"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'step_to_cancel',
|
||||
['type', 'provider', 'model', 'apikey', 'save'],
|
||||
|
||||
Generated
+1
-1
@@ -1828,7 +1828,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "openhands"
|
||||
version = "1.0.5"
|
||||
version = "1.0.3"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "openhands-sdk" },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
{
|
||||
"workbench.colorTheme": "Default Dark Modern",
|
||||
"workbench.startupEditor": "none",
|
||||
"chat.commandCenter.enabled": false
|
||||
"workbench.startupEditor": "none"
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ way to manage PowerShell processes compared to using temporary script files.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import RLock
|
||||
@@ -47,121 +45,39 @@ except Exception as coreclr_ex:
|
||||
logger.error(f'{error_msg} Details: {details}')
|
||||
raise DotNetMissingError(error_msg, details)
|
||||
|
||||
|
||||
def find_latest_pwsh_sdk_path(
|
||||
executable_name='pwsh.exe',
|
||||
dll_name='System.Management.Automation.dll',
|
||||
min_version=(7, 0, 0),
|
||||
env_var='PWSH_DIR',
|
||||
):
|
||||
"""
|
||||
Checks PWSH_DIR environment variable first to find pwsh and DLL.
|
||||
If not found or not suitable, scans all pwsh executables in PATH, runs --version to find latest >= min_version.
|
||||
Returns full DLL path if found, else None.
|
||||
"""
|
||||
|
||||
def parse_version(output):
|
||||
# Extract semantic version from pwsh --version output
|
||||
match = re.search(r'(\d+)\.(\d+)\.(\d+)', output)
|
||||
if match:
|
||||
return tuple(map(int, match.groups()))
|
||||
return None
|
||||
|
||||
# Try environment variable override first
|
||||
pwsh_dir = os.environ.get(env_var)
|
||||
if pwsh_dir:
|
||||
pwsh_path = Path(pwsh_dir) / executable_name
|
||||
dll_path = Path(pwsh_dir) / dll_name
|
||||
if pwsh_path.is_file() and dll_path.is_file():
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(pwsh_path), '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
ver = parse_version(completed.stdout)
|
||||
if ver and ver >= min_version:
|
||||
logger.info(f'Found pwsh from env variable "{env_var}"')
|
||||
return str(dll_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Adjust executable_name for Windows if needed
|
||||
if os.name == 'nt' and not executable_name.lower().endswith('.exe'):
|
||||
executable_name += '.exe'
|
||||
|
||||
# Search PATH for all pwsh executables
|
||||
paths = os.environ.get('PATH', '').split(os.pathsep)
|
||||
candidates = []
|
||||
for p in paths:
|
||||
exe_path = Path(p) / executable_name
|
||||
if exe_path.is_file() and os.access(str(exe_path), os.X_OK):
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[str(exe_path), '--version'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
)
|
||||
if completed.returncode == 0:
|
||||
ver = parse_version(completed.stdout)
|
||||
if ver:
|
||||
candidates.append((ver, exe_path.resolve()))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Sort candidates by version descending
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
for ver, exe_path in candidates:
|
||||
if ver >= min_version:
|
||||
dll_path = exe_path.parent / dll_name
|
||||
if dll_path.is_file():
|
||||
return str(dll_path)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# Attempt to load the PowerShell SDK assembly only if clr and System loaded
|
||||
ps_sdk_path = None
|
||||
try:
|
||||
# Attempt primary detection via helper function
|
||||
ps_sdk_path = find_latest_pwsh_sdk_path()
|
||||
if ps_sdk_path:
|
||||
# Prioritize PowerShell 7+ if available (adjust path if necessary)
|
||||
pwsh7_path = (
|
||||
Path(os.environ.get('ProgramFiles', 'C:\\Program Files'))
|
||||
/ 'PowerShell'
|
||||
/ '7'
|
||||
/ 'System.Management.Automation.dll'
|
||||
)
|
||||
if pwsh7_path.exists():
|
||||
ps_sdk_path = str(pwsh7_path)
|
||||
clr.AddReference(ps_sdk_path)
|
||||
logger.info(f'Loaded PowerShell SDK dynamically detected: {ps_sdk_path}')
|
||||
logger.info(f'Loaded PowerShell SDK (Core): {ps_sdk_path}')
|
||||
else:
|
||||
pwsh7_path = (
|
||||
Path(os.environ.get('ProgramFiles', 'C:\\Program Files'))
|
||||
/ 'PowerShell'
|
||||
/ '7'
|
||||
# Fallback to Windows PowerShell 5.1 bundled with Windows
|
||||
winps_path = (
|
||||
Path(os.environ.get('SystemRoot', 'C:\\Windows'))
|
||||
/ 'System32'
|
||||
/ 'WindowsPowerShell'
|
||||
/ 'v1.0'
|
||||
/ 'System.Management.Automation.dll'
|
||||
)
|
||||
if pwsh7_path.exists():
|
||||
ps_sdk_path = str(pwsh7_path)
|
||||
if winps_path.exists():
|
||||
ps_sdk_path = str(winps_path)
|
||||
clr.AddReference(ps_sdk_path)
|
||||
logger.info(f'Loaded PowerShell SDK (Core): {ps_sdk_path}')
|
||||
logger.debug(f'Loaded PowerShell SDK (Desktop): {ps_sdk_path}')
|
||||
else:
|
||||
# Fallback to Windows PowerShell 5.1 bundled with Windows
|
||||
winps_path = (
|
||||
Path(os.environ.get('SystemRoot', 'C:\\Windows'))
|
||||
/ 'System32'
|
||||
/ 'WindowsPowerShell'
|
||||
/ 'v1.0'
|
||||
/ 'System.Management.Automation.dll'
|
||||
# Last resort: try loading by assembly name (might work if in GAC or path)
|
||||
clr.AddReference('System.Management.Automation')
|
||||
logger.info(
|
||||
'Attempted to load PowerShell SDK by name (System.Management.Automation)'
|
||||
)
|
||||
if winps_path.exists():
|
||||
ps_sdk_path = str(winps_path)
|
||||
clr.AddReference(ps_sdk_path)
|
||||
logger.debug(f'Loaded PowerShell SDK (Desktop): {ps_sdk_path}')
|
||||
else:
|
||||
# Last resort: try loading by assembly name (might work if in GAC or path)
|
||||
clr.AddReference('System.Management.Automation')
|
||||
logger.info(
|
||||
'Attempted to load PowerShell SDK by name (System.Management.Automation)'
|
||||
)
|
||||
|
||||
from System.Management.Automation import JobState, PowerShell
|
||||
from System.Management.Automation.Language import Parser
|
||||
|
||||
@@ -51,7 +51,6 @@ def get_platform_command(linux_cmd, windows_cmd):
|
||||
return windows_cmd if is_windows() else linux_cmd
|
||||
|
||||
|
||||
@pytest.mark.skip(reason='This test is flaky')
|
||||
def test_bash_server(temp_dir, runtime_cls, run_as_openhands):
|
||||
runtime, config = _load_runtime(temp_dir, runtime_cls, run_as_openhands)
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user