Compare commits

..

1 Commits

Author SHA1 Message Date
rohitvinodmalhotra@gmail.com e5ea2ac478 Update pyproject.toml 2025-10-31 09:49:27 -04:00
34 changed files with 219 additions and 1423 deletions
@@ -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:
+1 -1
View File
@@ -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>
@@ -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} />
);
+18 -4
View File
@@ -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
-1
View File
@@ -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;
}
+1 -2
View File
@@ -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,
-101
View File
@@ -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
-88
View File
@@ -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.
+4 -2
View File
@@ -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
View File
@@ -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())
+100 -127
View File
@@ -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)
+1 -11
View File
@@ -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(
+1 -1
View File
@@ -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" }
-74
View File
@@ -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'],
+1 -1
View File
@@ -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"
}
+23 -107
View File
@@ -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
-1
View File
@@ -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: