openhands 1.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- openhands-1.3.0.dist-info/METADATA +56 -0
- openhands-1.3.0.dist-info/RECORD +43 -0
- openhands-1.3.0.dist-info/WHEEL +4 -0
- openhands-1.3.0.dist-info/entry_points.txt +3 -0
- openhands-1.3.0.dist-info/licenses/LICENSE +21 -0
- openhands_cli/__init__.py +9 -0
- openhands_cli/acp_impl/README.md +68 -0
- openhands_cli/acp_impl/__init__.py +1 -0
- openhands_cli/acp_impl/agent.py +483 -0
- openhands_cli/acp_impl/event.py +512 -0
- openhands_cli/acp_impl/main.py +21 -0
- openhands_cli/acp_impl/test_utils.py +174 -0
- openhands_cli/acp_impl/utils/__init__.py +14 -0
- openhands_cli/acp_impl/utils/convert.py +103 -0
- openhands_cli/acp_impl/utils/mcp.py +66 -0
- openhands_cli/acp_impl/utils/resources.py +189 -0
- openhands_cli/agent_chat.py +236 -0
- openhands_cli/argparsers/main_parser.py +78 -0
- openhands_cli/argparsers/serve_parser.py +31 -0
- openhands_cli/gui_launcher.py +224 -0
- openhands_cli/listeners/__init__.py +4 -0
- openhands_cli/listeners/pause_listener.py +83 -0
- openhands_cli/locations.py +14 -0
- openhands_cli/pt_style.py +33 -0
- openhands_cli/runner.py +190 -0
- openhands_cli/setup.py +136 -0
- openhands_cli/simple_main.py +71 -0
- openhands_cli/tui/__init__.py +6 -0
- openhands_cli/tui/settings/mcp_screen.py +225 -0
- openhands_cli/tui/settings/settings_screen.py +226 -0
- openhands_cli/tui/settings/store.py +132 -0
- openhands_cli/tui/status.py +110 -0
- openhands_cli/tui/tui.py +120 -0
- openhands_cli/tui/utils.py +14 -0
- openhands_cli/tui/visualizer.py +22 -0
- openhands_cli/user_actions/__init__.py +18 -0
- openhands_cli/user_actions/agent_action.py +82 -0
- openhands_cli/user_actions/exit_session.py +18 -0
- openhands_cli/user_actions/settings_action.py +176 -0
- openhands_cli/user_actions/types.py +17 -0
- openhands_cli/user_actions/utils.py +199 -0
- openhands_cli/utils.py +122 -0
- openhands_cli/version_check.py +83 -0
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Argument parser for serve subcommand."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def add_serve_parser(subparsers: argparse._SubParsersAction) -> argparse.ArgumentParser:
|
|
7
|
+
"""Add serve subcommand parser.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
subparsers: The subparsers object to add the serve parser to
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
The serve argument parser
|
|
14
|
+
"""
|
|
15
|
+
serve_parser = subparsers.add_parser(
|
|
16
|
+
"serve", help="Launch the OpenHands GUI server using Docker (web interface)"
|
|
17
|
+
)
|
|
18
|
+
serve_parser.add_argument(
|
|
19
|
+
"--mount-cwd",
|
|
20
|
+
help="Mount the current working directory into the GUI server container",
|
|
21
|
+
action="store_true",
|
|
22
|
+
default=False,
|
|
23
|
+
)
|
|
24
|
+
serve_parser.add_argument(
|
|
25
|
+
"--gpu",
|
|
26
|
+
help="Enable GPU support by mounting all GPUs into the Docker "
|
|
27
|
+
"container via nvidia-docker",
|
|
28
|
+
action="store_true",
|
|
29
|
+
default=False,
|
|
30
|
+
)
|
|
31
|
+
return serve_parser
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""GUI launcher for OpenHands CLI."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from prompt_toolkit import print_formatted_text
|
|
10
|
+
from prompt_toolkit.formatted_text import HTML
|
|
11
|
+
|
|
12
|
+
from openhands_cli.locations import PERSISTENCE_DIR
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _format_docker_command_for_logging(cmd: list[str]) -> str:
|
|
16
|
+
"""Format a Docker command for logging with grey color.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
cmd (list[str]): The Docker command as a list of strings
|
|
20
|
+
|
|
21
|
+
Returns:
|
|
22
|
+
str: The formatted command string in grey HTML color
|
|
23
|
+
"""
|
|
24
|
+
cmd_str = " ".join(cmd)
|
|
25
|
+
return f"<grey>Running Docker command: {cmd_str}</grey>"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def check_docker_requirements() -> bool:
|
|
29
|
+
"""Check if Docker is installed and running.
|
|
30
|
+
|
|
31
|
+
Returns:
|
|
32
|
+
bool: True if Docker is available and running, False otherwise.
|
|
33
|
+
"""
|
|
34
|
+
# Check if Docker is installed
|
|
35
|
+
if not shutil.which("docker"):
|
|
36
|
+
print_formatted_text(
|
|
37
|
+
HTML("<ansired>❌ Docker is not installed or not in PATH.</ansired>")
|
|
38
|
+
)
|
|
39
|
+
print_formatted_text(
|
|
40
|
+
HTML(
|
|
41
|
+
"<grey>Please install Docker first: https://docs.docker.com/get-docker/</grey>"
|
|
42
|
+
)
|
|
43
|
+
)
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
# Check if Docker daemon is running
|
|
47
|
+
try:
|
|
48
|
+
result = subprocess.run(
|
|
49
|
+
["docker", "info"], capture_output=True, text=True, timeout=10
|
|
50
|
+
)
|
|
51
|
+
if result.returncode != 0:
|
|
52
|
+
print_formatted_text(
|
|
53
|
+
HTML("<ansired>❌ Docker daemon is not running.</ansired>")
|
|
54
|
+
)
|
|
55
|
+
print_formatted_text(
|
|
56
|
+
HTML("<grey>Please start Docker and try again.</grey>")
|
|
57
|
+
)
|
|
58
|
+
return False
|
|
59
|
+
except (subprocess.TimeoutExpired, subprocess.SubprocessError) as e:
|
|
60
|
+
print_formatted_text(
|
|
61
|
+
HTML("<ansired>❌ Failed to check Docker status.</ansired>")
|
|
62
|
+
)
|
|
63
|
+
print_formatted_text(HTML(f"<grey>Error: {e}</grey>"))
|
|
64
|
+
return False
|
|
65
|
+
|
|
66
|
+
return True
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ensure_config_dir_exists() -> Path:
|
|
70
|
+
"""Ensure the OpenHands configuration directory exists and return its path."""
|
|
71
|
+
path = Path(PERSISTENCE_DIR)
|
|
72
|
+
path.mkdir(exist_ok=True, parents=True)
|
|
73
|
+
return path
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_openhands_version() -> str:
|
|
77
|
+
"""Get the OpenHands version for Docker images.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
str: The version string to use for Docker images
|
|
81
|
+
"""
|
|
82
|
+
# For now, use 'latest' as the default version
|
|
83
|
+
# In the future, this could be read from a version file or environment variable
|
|
84
|
+
return os.environ.get("OPENHANDS_VERSION", "latest")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def launch_gui_server(mount_cwd: bool = False, gpu: bool = False) -> None:
|
|
88
|
+
"""Launch the OpenHands GUI server using Docker.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
mount_cwd: If True, mount the current working directory into the container.
|
|
92
|
+
gpu: If True, enable GPU support by mounting all GPUs into the
|
|
93
|
+
container via nvidia-docker.
|
|
94
|
+
"""
|
|
95
|
+
print_formatted_text(
|
|
96
|
+
HTML("<ansiblue>🚀 Launching OpenHands GUI server...</ansiblue>")
|
|
97
|
+
)
|
|
98
|
+
print_formatted_text("")
|
|
99
|
+
|
|
100
|
+
# Check Docker requirements
|
|
101
|
+
if not check_docker_requirements():
|
|
102
|
+
sys.exit(1)
|
|
103
|
+
|
|
104
|
+
# Ensure config directory exists
|
|
105
|
+
config_dir = ensure_config_dir_exists()
|
|
106
|
+
|
|
107
|
+
# Get the current version for the Docker image
|
|
108
|
+
version = get_openhands_version()
|
|
109
|
+
runtime_image = f"docker.openhands.dev/openhands/runtime:{version}-nikolaik"
|
|
110
|
+
app_image = f"docker.openhands.dev/openhands/openhands:{version}"
|
|
111
|
+
|
|
112
|
+
print_formatted_text(HTML("<grey>Pulling required Docker images...</grey>"))
|
|
113
|
+
|
|
114
|
+
# Pull the runtime image first
|
|
115
|
+
pull_cmd = ["docker", "pull", runtime_image]
|
|
116
|
+
print_formatted_text(HTML(_format_docker_command_for_logging(pull_cmd)))
|
|
117
|
+
try:
|
|
118
|
+
subprocess.run(pull_cmd, check=True)
|
|
119
|
+
except subprocess.CalledProcessError:
|
|
120
|
+
print_formatted_text(
|
|
121
|
+
HTML("<ansired>❌ Failed to pull runtime image.</ansired>")
|
|
122
|
+
)
|
|
123
|
+
sys.exit(1)
|
|
124
|
+
|
|
125
|
+
print_formatted_text("")
|
|
126
|
+
print_formatted_text(
|
|
127
|
+
HTML("<ansigreen>✅ Starting OpenHands GUI server...</ansigreen>")
|
|
128
|
+
)
|
|
129
|
+
print_formatted_text(
|
|
130
|
+
HTML("<grey>The server will be available at: http://localhost:3000</grey>")
|
|
131
|
+
)
|
|
132
|
+
print_formatted_text(HTML("<grey>Press Ctrl+C to stop the server.</grey>"))
|
|
133
|
+
print_formatted_text("")
|
|
134
|
+
|
|
135
|
+
# Build the Docker command
|
|
136
|
+
docker_cmd = [
|
|
137
|
+
"docker",
|
|
138
|
+
"run",
|
|
139
|
+
"-it",
|
|
140
|
+
"--rm",
|
|
141
|
+
"--pull=always",
|
|
142
|
+
"-e",
|
|
143
|
+
f"SANDBOX_RUNTIME_CONTAINER_IMAGE={runtime_image}",
|
|
144
|
+
"-e",
|
|
145
|
+
"LOG_ALL_EVENTS=true",
|
|
146
|
+
"-v",
|
|
147
|
+
"/var/run/docker.sock:/var/run/docker.sock",
|
|
148
|
+
"-v",
|
|
149
|
+
f"{config_dir}:/.openhands",
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
# Add GPU support if requested
|
|
153
|
+
if gpu:
|
|
154
|
+
print_formatted_text(
|
|
155
|
+
HTML("<ansigreen>🖥️ Enabling GPU support via nvidia-docker...</ansigreen>")
|
|
156
|
+
)
|
|
157
|
+
# Add the --gpus all flag to enable all GPUs
|
|
158
|
+
docker_cmd.insert(2, "--gpus")
|
|
159
|
+
docker_cmd.insert(3, "all")
|
|
160
|
+
# Add environment variable to pass GPU support to sandbox containers
|
|
161
|
+
docker_cmd.extend(
|
|
162
|
+
[
|
|
163
|
+
"-e",
|
|
164
|
+
"SANDBOX_ENABLE_GPU=true",
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Add current working directory mount if requested
|
|
169
|
+
if mount_cwd:
|
|
170
|
+
cwd = Path.cwd()
|
|
171
|
+
# Following the documentation at https://docs.all-hands.dev/usage/runtimes/docker#connecting-to-your-filesystem
|
|
172
|
+
docker_cmd.extend(
|
|
173
|
+
[
|
|
174
|
+
"-e",
|
|
175
|
+
f"SANDBOX_VOLUMES={cwd}:/workspace:rw",
|
|
176
|
+
]
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Set user ID for Unix-like systems only
|
|
180
|
+
if os.name != "nt": # Not Windows
|
|
181
|
+
try:
|
|
182
|
+
user_id = subprocess.check_output(["id", "-u"], text=True).strip()
|
|
183
|
+
docker_cmd.extend(["-e", f"SANDBOX_USER_ID={user_id}"])
|
|
184
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
185
|
+
# If 'id' command fails or doesn't exist, skip setting user ID
|
|
186
|
+
pass
|
|
187
|
+
# Print the folder that will be mounted to inform the user
|
|
188
|
+
print_formatted_text(
|
|
189
|
+
HTML(
|
|
190
|
+
f"<ansigreen>📂 Mounting current directory:</ansigreen> "
|
|
191
|
+
f"<ansiyellow>{cwd}</ansiyellow> <ansigreen>to</ansigreen> "
|
|
192
|
+
f"<ansiyellow>/workspace</ansiyellow>"
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
docker_cmd.extend(
|
|
197
|
+
[
|
|
198
|
+
"-p",
|
|
199
|
+
"3000:3000",
|
|
200
|
+
"--add-host",
|
|
201
|
+
"host.docker.internal:host-gateway",
|
|
202
|
+
"--name",
|
|
203
|
+
"openhands-app",
|
|
204
|
+
app_image,
|
|
205
|
+
]
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
# Log and run the Docker command
|
|
210
|
+
print_formatted_text(HTML(_format_docker_command_for_logging(docker_cmd)))
|
|
211
|
+
subprocess.run(docker_cmd, check=True)
|
|
212
|
+
except subprocess.CalledProcessError as e:
|
|
213
|
+
print_formatted_text("")
|
|
214
|
+
print_formatted_text(
|
|
215
|
+
HTML("<ansired>❌ Failed to start OpenHands GUI server.</ansired>")
|
|
216
|
+
)
|
|
217
|
+
print_formatted_text(HTML(f"<grey>Error: {e}</grey>"))
|
|
218
|
+
sys.exit(1)
|
|
219
|
+
except KeyboardInterrupt:
|
|
220
|
+
print_formatted_text("")
|
|
221
|
+
print_formatted_text(
|
|
222
|
+
HTML("<ansigreen>✓ OpenHands GUI server stopped successfully.</ansigreen>")
|
|
223
|
+
)
|
|
224
|
+
sys.exit(0)
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import threading
|
|
2
|
+
from collections.abc import Callable, Iterator
|
|
3
|
+
from contextlib import contextmanager
|
|
4
|
+
|
|
5
|
+
from prompt_toolkit import HTML, print_formatted_text
|
|
6
|
+
from prompt_toolkit.input import Input, create_input
|
|
7
|
+
from prompt_toolkit.keys import Keys
|
|
8
|
+
|
|
9
|
+
from openhands.sdk import BaseConversation
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class PauseListener(threading.Thread):
|
|
13
|
+
"""Background key listener that triggers pause on Ctrl-P.
|
|
14
|
+
|
|
15
|
+
Starts and stops around agent run() loops to avoid interfering with user prompts.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
on_pause: Callable,
|
|
21
|
+
input_source: Input | None = None, # used to pipe inputs for unit tests
|
|
22
|
+
):
|
|
23
|
+
super().__init__(daemon=True)
|
|
24
|
+
self.on_pause = on_pause
|
|
25
|
+
self._stop_event = threading.Event()
|
|
26
|
+
self._pause_event = threading.Event()
|
|
27
|
+
self._input = input_source or create_input()
|
|
28
|
+
|
|
29
|
+
def _detect_pause_key_presses(self) -> bool:
|
|
30
|
+
pause_detected = False
|
|
31
|
+
|
|
32
|
+
for key_press in self._input.read_keys():
|
|
33
|
+
pause_detected = pause_detected or key_press.key == Keys.ControlP
|
|
34
|
+
pause_detected = pause_detected or key_press.key == Keys.ControlC
|
|
35
|
+
pause_detected = pause_detected or key_press.key == Keys.ControlD
|
|
36
|
+
|
|
37
|
+
return pause_detected
|
|
38
|
+
|
|
39
|
+
def _execute_pause(self) -> None:
|
|
40
|
+
self._pause_event.set() # Mark pause event occurred
|
|
41
|
+
print_formatted_text(HTML(""))
|
|
42
|
+
print_formatted_text(
|
|
43
|
+
HTML("<gold>Pausing agent once step is completed...</gold>")
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
self.on_pause()
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
|
|
50
|
+
def run(self) -> None:
|
|
51
|
+
try:
|
|
52
|
+
with self._input.raw_mode():
|
|
53
|
+
# User hasn't paused and pause listener hasn't been shut down
|
|
54
|
+
while not (self.is_paused() or self.is_stopped()):
|
|
55
|
+
if self._detect_pause_key_presses():
|
|
56
|
+
self._execute_pause()
|
|
57
|
+
finally:
|
|
58
|
+
try:
|
|
59
|
+
self._input.close()
|
|
60
|
+
except Exception:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
def stop(self) -> None:
|
|
64
|
+
self._stop_event.set()
|
|
65
|
+
|
|
66
|
+
def is_stopped(self) -> bool:
|
|
67
|
+
return self._stop_event.is_set()
|
|
68
|
+
|
|
69
|
+
def is_paused(self) -> bool:
|
|
70
|
+
return self._pause_event.is_set()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@contextmanager
|
|
74
|
+
def pause_listener(
|
|
75
|
+
conversation: BaseConversation, input_source: Input | None = None
|
|
76
|
+
) -> Iterator[PauseListener]:
|
|
77
|
+
"""Ensure PauseListener always starts/stops cleanly."""
|
|
78
|
+
listener = PauseListener(on_pause=conversation.pause, input_source=input_source)
|
|
79
|
+
listener.start()
|
|
80
|
+
try:
|
|
81
|
+
yield listener
|
|
82
|
+
finally:
|
|
83
|
+
listener.stop()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
# Configuration directory for storing agent settings and CLI configuration
|
|
5
|
+
PERSISTENCE_DIR = os.path.expanduser("~/.openhands")
|
|
6
|
+
CONVERSATIONS_DIR = os.path.join(PERSISTENCE_DIR, "conversations")
|
|
7
|
+
|
|
8
|
+
# Working directory for agent operations (current directory where CLI is run)
|
|
9
|
+
WORK_DIR = os.getcwd()
|
|
10
|
+
|
|
11
|
+
AGENT_SETTINGS_PATH = "agent_settings.json"
|
|
12
|
+
|
|
13
|
+
# MCP configuration file (relative to PERSISTENCE_DIR)
|
|
14
|
+
MCP_CONFIG_FILE = "mcp.json"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from prompt_toolkit.styles import Style, merge_styles
|
|
2
|
+
from prompt_toolkit.styles.base import BaseStyle
|
|
3
|
+
from prompt_toolkit.styles.defaults import default_ui_style
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Centralized helper for CLI styles so we can safely merge our custom colors
|
|
7
|
+
# with prompt_toolkit's default UI style. This preserves completion menu and
|
|
8
|
+
# fuzzy-match visibility across different terminal themes (e.g., Ubuntu).
|
|
9
|
+
|
|
10
|
+
COLOR_GOLD = "#FFD700"
|
|
11
|
+
COLOR_GREY = "#808080"
|
|
12
|
+
COLOR_AGENT_BLUE = "#4682B4" # Steel blue - readable on light/dark backgrounds
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_cli_style() -> BaseStyle:
|
|
16
|
+
base = default_ui_style()
|
|
17
|
+
custom = Style.from_dict(
|
|
18
|
+
{
|
|
19
|
+
"gold": COLOR_GOLD,
|
|
20
|
+
"grey": COLOR_GREY,
|
|
21
|
+
"prompt": f"{COLOR_GOLD} bold",
|
|
22
|
+
# Ensure good contrast for fuzzy matches on the selected completion row
|
|
23
|
+
# across terminals/themes (e.g., Ubuntu GNOME, Alacritty, Kitty).
|
|
24
|
+
# See https://github.com/OpenHands/OpenHands/issues/10330
|
|
25
|
+
"completion-menu.completion.current fuzzymatch.outside": (
|
|
26
|
+
"fg:#ffffff bg:#888888"
|
|
27
|
+
),
|
|
28
|
+
"selected": COLOR_GOLD,
|
|
29
|
+
"risk-high": "#FF0000 bold", # Red bold for HIGH risk
|
|
30
|
+
"placeholder": "#888888 italic",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
return merge_styles([base, custom])
|
openhands_cli/runner.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
from prompt_toolkit import HTML, print_formatted_text
|
|
2
|
+
|
|
3
|
+
from openhands.sdk import BaseConversation, Message
|
|
4
|
+
from openhands.sdk.conversation.state import (
|
|
5
|
+
ConversationExecutionStatus,
|
|
6
|
+
ConversationState,
|
|
7
|
+
)
|
|
8
|
+
from openhands.sdk.security.confirmation_policy import (
|
|
9
|
+
AlwaysConfirm,
|
|
10
|
+
ConfirmationPolicyBase,
|
|
11
|
+
ConfirmRisky,
|
|
12
|
+
NeverConfirm,
|
|
13
|
+
)
|
|
14
|
+
from openhands_cli.listeners.pause_listener import PauseListener, pause_listener
|
|
15
|
+
from openhands_cli.setup import setup_conversation
|
|
16
|
+
from openhands_cli.user_actions import ask_user_confirmation
|
|
17
|
+
from openhands_cli.user_actions.types import UserConfirmation
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class ConversationRunner:
|
|
21
|
+
"""Handles the conversation state machine logic cleanly."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, conversation: BaseConversation):
|
|
24
|
+
self.conversation = conversation
|
|
25
|
+
|
|
26
|
+
@property
|
|
27
|
+
def is_confirmation_mode_active(self):
|
|
28
|
+
return self.conversation.is_confirmation_mode_active
|
|
29
|
+
|
|
30
|
+
def toggle_confirmation_mode(self):
|
|
31
|
+
new_confirmation_mode_state = not self.is_confirmation_mode_active
|
|
32
|
+
|
|
33
|
+
self.conversation = setup_conversation(
|
|
34
|
+
self.conversation.id, include_security_analyzer=new_confirmation_mode_state
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if new_confirmation_mode_state:
|
|
38
|
+
# Enable confirmation mode: set AlwaysConfirm policy
|
|
39
|
+
self.set_confirmation_policy(AlwaysConfirm())
|
|
40
|
+
else:
|
|
41
|
+
# Disable confirmation mode: set NeverConfirm policy and remove
|
|
42
|
+
# security analyzer
|
|
43
|
+
self.set_confirmation_policy(NeverConfirm())
|
|
44
|
+
|
|
45
|
+
def set_confirmation_policy(
|
|
46
|
+
self, confirmation_policy: ConfirmationPolicyBase
|
|
47
|
+
) -> None:
|
|
48
|
+
self.conversation.set_confirmation_policy(confirmation_policy)
|
|
49
|
+
|
|
50
|
+
def _start_listener(self) -> None:
|
|
51
|
+
self.listener = PauseListener(on_pause=self.conversation.pause)
|
|
52
|
+
self.listener.start()
|
|
53
|
+
|
|
54
|
+
def _print_run_status(self) -> None:
|
|
55
|
+
print_formatted_text("")
|
|
56
|
+
if (
|
|
57
|
+
self.conversation.state.execution_status
|
|
58
|
+
== ConversationExecutionStatus.PAUSED
|
|
59
|
+
):
|
|
60
|
+
print_formatted_text(
|
|
61
|
+
HTML(
|
|
62
|
+
"<yellow>Resuming paused conversation...</yellow>"
|
|
63
|
+
"<grey> (Press Ctrl-P to pause)</grey>"
|
|
64
|
+
)
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
else:
|
|
68
|
+
print_formatted_text(
|
|
69
|
+
HTML(
|
|
70
|
+
"<yellow>Agent running...</yellow>"
|
|
71
|
+
"<grey> (Press Ctrl-P to pause)</grey>"
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
print_formatted_text("")
|
|
75
|
+
|
|
76
|
+
def process_message(self, message: Message | None) -> None:
|
|
77
|
+
"""Process a user message through the conversation.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
message: The user message to process
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
self._print_run_status()
|
|
84
|
+
|
|
85
|
+
# Send message to conversation
|
|
86
|
+
if message:
|
|
87
|
+
self.conversation.send_message(message)
|
|
88
|
+
|
|
89
|
+
if self.is_confirmation_mode_active:
|
|
90
|
+
self._run_with_confirmation()
|
|
91
|
+
else:
|
|
92
|
+
self._run_without_confirmation()
|
|
93
|
+
|
|
94
|
+
def _run_without_confirmation(self) -> None:
|
|
95
|
+
with pause_listener(self.conversation):
|
|
96
|
+
self.conversation.run()
|
|
97
|
+
|
|
98
|
+
def _run_with_confirmation(self) -> None:
|
|
99
|
+
# If agent was paused, resume with confirmation request
|
|
100
|
+
if (
|
|
101
|
+
self.conversation.state.execution_status
|
|
102
|
+
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
|
|
103
|
+
):
|
|
104
|
+
user_confirmation = self._handle_confirmation_request()
|
|
105
|
+
if user_confirmation == UserConfirmation.DEFER:
|
|
106
|
+
return
|
|
107
|
+
|
|
108
|
+
while True:
|
|
109
|
+
with pause_listener(self.conversation) as listener:
|
|
110
|
+
self.conversation.run()
|
|
111
|
+
|
|
112
|
+
if listener.is_paused():
|
|
113
|
+
break
|
|
114
|
+
|
|
115
|
+
# In confirmation mode, agent either finishes or waits for user confirmation
|
|
116
|
+
if (
|
|
117
|
+
self.conversation.state.execution_status
|
|
118
|
+
== ConversationExecutionStatus.FINISHED
|
|
119
|
+
):
|
|
120
|
+
break
|
|
121
|
+
|
|
122
|
+
elif (
|
|
123
|
+
self.conversation.state.execution_status
|
|
124
|
+
== ConversationExecutionStatus.WAITING_FOR_CONFIRMATION
|
|
125
|
+
):
|
|
126
|
+
user_confirmation = self._handle_confirmation_request()
|
|
127
|
+
if user_confirmation == UserConfirmation.DEFER:
|
|
128
|
+
return
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
raise Exception("Infinite loop")
|
|
132
|
+
|
|
133
|
+
def _handle_confirmation_request(self) -> UserConfirmation:
|
|
134
|
+
"""Handle confirmation request from user.
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
UserConfirmation indicating the user's choice
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
pending_actions = ConversationState.get_unmatched_actions(
|
|
141
|
+
self.conversation.state.events
|
|
142
|
+
)
|
|
143
|
+
if not pending_actions:
|
|
144
|
+
return UserConfirmation.ACCEPT
|
|
145
|
+
|
|
146
|
+
result = ask_user_confirmation(
|
|
147
|
+
pending_actions,
|
|
148
|
+
isinstance(self.conversation.state.confirmation_policy, ConfirmRisky),
|
|
149
|
+
)
|
|
150
|
+
decision = result.decision
|
|
151
|
+
policy_change = result.policy_change
|
|
152
|
+
|
|
153
|
+
if decision == UserConfirmation.REJECT:
|
|
154
|
+
self.conversation.reject_pending_actions(
|
|
155
|
+
result.reason or "User rejected the actions"
|
|
156
|
+
)
|
|
157
|
+
return decision
|
|
158
|
+
|
|
159
|
+
if decision == UserConfirmation.DEFER:
|
|
160
|
+
self.conversation.pause()
|
|
161
|
+
return decision
|
|
162
|
+
|
|
163
|
+
if isinstance(policy_change, NeverConfirm):
|
|
164
|
+
print_formatted_text(
|
|
165
|
+
HTML(
|
|
166
|
+
"<yellow>Confirmation mode disabled. Agent will proceed "
|
|
167
|
+
"without asking.</yellow>"
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Remove security analyzer when policy is never confirm
|
|
172
|
+
self.toggle_confirmation_mode()
|
|
173
|
+
return decision
|
|
174
|
+
|
|
175
|
+
if isinstance(policy_change, ConfirmRisky):
|
|
176
|
+
print_formatted_text(
|
|
177
|
+
HTML(
|
|
178
|
+
"<yellow>Security-based confirmation enabled. "
|
|
179
|
+
"LOW/MEDIUM risk actions will auto-confirm, HIGH risk actions "
|
|
180
|
+
"will ask for confirmation.</yellow>"
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
# Keep security analyzer, change existing policy
|
|
185
|
+
self.set_confirmation_policy(policy_change)
|
|
186
|
+
return decision
|
|
187
|
+
|
|
188
|
+
# Accept action without changing existing policies
|
|
189
|
+
assert decision == UserConfirmation.ACCEPT
|
|
190
|
+
return decision
|