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
openhands_cli/setup.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from uuid import UUID
|
|
3
|
+
|
|
4
|
+
from prompt_toolkit import HTML, print_formatted_text
|
|
5
|
+
|
|
6
|
+
from openhands.sdk import Agent, BaseConversation, Conversation, Workspace
|
|
7
|
+
from openhands.sdk.context import AgentContext, Skill
|
|
8
|
+
from openhands.sdk.security.confirmation_policy import (
|
|
9
|
+
AlwaysConfirm,
|
|
10
|
+
)
|
|
11
|
+
from openhands.sdk.security.llm_analyzer import LLMSecurityAnalyzer
|
|
12
|
+
|
|
13
|
+
# Register tools on import
|
|
14
|
+
from openhands.tools.file_editor import FileEditorTool # noqa: F401
|
|
15
|
+
from openhands.tools.task_tracker import TaskTrackerTool # noqa: F401
|
|
16
|
+
from openhands.tools.terminal import TerminalTool # noqa: F401
|
|
17
|
+
from openhands_cli.locations import CONVERSATIONS_DIR, WORK_DIR
|
|
18
|
+
from openhands_cli.tui.settings.settings_screen import SettingsScreen
|
|
19
|
+
from openhands_cli.tui.settings.store import AgentStore
|
|
20
|
+
from openhands_cli.tui.visualizer import CLIVisualizer
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class MissingAgentSpec(Exception):
|
|
24
|
+
"""Raised when agent specification is not found or invalid."""
|
|
25
|
+
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def load_agent_specs(
|
|
30
|
+
conversation_id: str | None = None,
|
|
31
|
+
mcp_servers: dict[str, dict[str, Any]] | None = None,
|
|
32
|
+
skills: list[Skill] | None = None,
|
|
33
|
+
) -> Agent:
|
|
34
|
+
"""Load agent specifications.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
conversation_id: Optional conversation ID for session tracking
|
|
38
|
+
mcp_servers: Optional dict of MCP servers to augment agent configuration
|
|
39
|
+
skills: Optional list of skills to include in the agent configuration
|
|
40
|
+
|
|
41
|
+
Returns:
|
|
42
|
+
Configured Agent instance
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
MissingAgentSpec: If agent specification is not found or invalid
|
|
46
|
+
"""
|
|
47
|
+
agent_store = AgentStore()
|
|
48
|
+
agent = agent_store.load(session_id=conversation_id)
|
|
49
|
+
if not agent:
|
|
50
|
+
raise MissingAgentSpec(
|
|
51
|
+
"Agent specification not found. Please configure your agent settings."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# If MCP servers are provided, augment the agent's MCP configuration
|
|
55
|
+
if mcp_servers:
|
|
56
|
+
# Merge with existing MCP configuration (provided servers take precedence)
|
|
57
|
+
mcp_config: dict[str, Any] = agent.mcp_config or {}
|
|
58
|
+
existing_servers: dict[str, dict[str, Any]] = mcp_config.get("mcpServers", {})
|
|
59
|
+
existing_servers.update(mcp_servers)
|
|
60
|
+
agent = agent.model_copy(
|
|
61
|
+
update={"mcp_config": {"mcpServers": existing_servers}}
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
if skills:
|
|
65
|
+
if agent.agent_context is not None:
|
|
66
|
+
existing_skills = agent.agent_context.skills
|
|
67
|
+
existing_skills.extend(skills)
|
|
68
|
+
agent = agent.model_copy(
|
|
69
|
+
update={
|
|
70
|
+
"agent_context": agent.agent_context.model_copy(
|
|
71
|
+
update={"skills": existing_skills}
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
)
|
|
75
|
+
else:
|
|
76
|
+
agent = agent.model_copy(
|
|
77
|
+
update={"agent_context": AgentContext(skills=skills)}
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
return agent
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def verify_agent_exists_or_setup_agent() -> Agent:
|
|
84
|
+
"""Verify agent specs exists by attempting to load it."""
|
|
85
|
+
settings_screen = SettingsScreen()
|
|
86
|
+
try:
|
|
87
|
+
agent = load_agent_specs()
|
|
88
|
+
return agent
|
|
89
|
+
except MissingAgentSpec:
|
|
90
|
+
# For first-time users, show the full settings flow with choice
|
|
91
|
+
# between basic/advanced
|
|
92
|
+
settings_screen.configure_settings(first_time=True)
|
|
93
|
+
|
|
94
|
+
# Try once again after settings setup attempt
|
|
95
|
+
return load_agent_specs()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def setup_conversation(
|
|
99
|
+
conversation_id: UUID, include_security_analyzer: bool = True
|
|
100
|
+
) -> BaseConversation:
|
|
101
|
+
"""
|
|
102
|
+
Setup the conversation with agent.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
conversation_id: conversation ID to use. If not provided, a random UUID
|
|
106
|
+
will be generated.
|
|
107
|
+
|
|
108
|
+
Raises:
|
|
109
|
+
MissingAgentSpec: If agent specification is not found or invalid.
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
print_formatted_text(HTML("<white>Initializing agent...</white>"))
|
|
113
|
+
|
|
114
|
+
agent = load_agent_specs(str(conversation_id))
|
|
115
|
+
|
|
116
|
+
# Create conversation - agent context is now set in AgentStore.load()
|
|
117
|
+
conversation: BaseConversation = Conversation(
|
|
118
|
+
agent=agent,
|
|
119
|
+
workspace=Workspace(working_dir=WORK_DIR),
|
|
120
|
+
# Conversation will add /<conversation_id> to this path
|
|
121
|
+
persistence_dir=CONVERSATIONS_DIR,
|
|
122
|
+
conversation_id=conversation_id,
|
|
123
|
+
visualizer=CLIVisualizer,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
# Security analyzer is set though conversation API now
|
|
127
|
+
if not include_security_analyzer:
|
|
128
|
+
conversation.set_security_analyzer(None)
|
|
129
|
+
else:
|
|
130
|
+
conversation.set_security_analyzer(LLMSecurityAnalyzer())
|
|
131
|
+
conversation.set_confirmation_policy(AlwaysConfirm())
|
|
132
|
+
|
|
133
|
+
print_formatted_text(
|
|
134
|
+
HTML(f"<green>✓ Agent initialized with model: {agent.llm.model}</green>")
|
|
135
|
+
)
|
|
136
|
+
return conversation
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Simple main entry point for OpenHands CLI.
|
|
4
|
+
This is a simplified version that demonstrates the TUI functionality.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
import warnings
|
|
10
|
+
|
|
11
|
+
from prompt_toolkit import print_formatted_text
|
|
12
|
+
from prompt_toolkit.formatted_text import HTML
|
|
13
|
+
|
|
14
|
+
from openhands_cli.argparsers.main_parser import create_main_parser
|
|
15
|
+
from openhands_cli.utils import create_seeded_instructions_from_args
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
debug_env = os.getenv("DEBUG", "false").lower()
|
|
19
|
+
if debug_env != "1" and debug_env != "true":
|
|
20
|
+
logging.disable(logging.WARNING)
|
|
21
|
+
warnings.filterwarnings("ignore")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def main() -> None:
|
|
25
|
+
"""Main entry point for the OpenHands CLI.
|
|
26
|
+
|
|
27
|
+
Raises:
|
|
28
|
+
ImportError: If agent chat dependencies are missing
|
|
29
|
+
Exception: On other error conditions
|
|
30
|
+
"""
|
|
31
|
+
parser = create_main_parser()
|
|
32
|
+
args = parser.parse_args()
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
if args.command == "serve":
|
|
36
|
+
# Import gui_launcher only when needed
|
|
37
|
+
from openhands_cli.gui_launcher import launch_gui_server
|
|
38
|
+
|
|
39
|
+
launch_gui_server(mount_cwd=args.mount_cwd, gpu=args.gpu)
|
|
40
|
+
elif args.command == "acp":
|
|
41
|
+
import asyncio
|
|
42
|
+
|
|
43
|
+
from openhands_cli.acp_impl.agent import run_acp_server
|
|
44
|
+
|
|
45
|
+
asyncio.run(run_acp_server())
|
|
46
|
+
else:
|
|
47
|
+
# Default CLI behavior - no subcommand needed
|
|
48
|
+
# Import agent_chat only when needed
|
|
49
|
+
from openhands_cli.agent_chat import run_cli_entry
|
|
50
|
+
|
|
51
|
+
queued_inputs = create_seeded_instructions_from_args(args)
|
|
52
|
+
|
|
53
|
+
# Start agent chat
|
|
54
|
+
run_cli_entry(
|
|
55
|
+
resume_conversation_id=args.resume,
|
|
56
|
+
queued_inputs=queued_inputs,
|
|
57
|
+
)
|
|
58
|
+
except KeyboardInterrupt:
|
|
59
|
+
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
|
60
|
+
except EOFError:
|
|
61
|
+
print_formatted_text(HTML("\n<yellow>Goodbye! 👋</yellow>"))
|
|
62
|
+
except Exception as e:
|
|
63
|
+
print_formatted_text(HTML(f"<red>Error: {e}</red>"))
|
|
64
|
+
import traceback
|
|
65
|
+
|
|
66
|
+
traceback.print_exc()
|
|
67
|
+
raise
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
main()
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmcp.mcp_config import MCPConfig
|
|
6
|
+
from prompt_toolkit import HTML, print_formatted_text
|
|
7
|
+
|
|
8
|
+
from openhands.sdk import Agent
|
|
9
|
+
from openhands_cli.locations import MCP_CONFIG_FILE, PERSISTENCE_DIR
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class MCPScreen:
|
|
13
|
+
"""
|
|
14
|
+
MCP Screen
|
|
15
|
+
|
|
16
|
+
1. Display information about setting up MCP
|
|
17
|
+
2. See existing servers that are setup
|
|
18
|
+
3. Debug additional servers passed via mcp.json
|
|
19
|
+
4. Identify servers waiting to sync on session restart
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
# ---------- server spec handlers ----------
|
|
23
|
+
|
|
24
|
+
def _check_server_specs_are_equal(
|
|
25
|
+
self, first_server_spec, second_server_spec
|
|
26
|
+
) -> bool:
|
|
27
|
+
first_stringified_server_spec = json.dumps(first_server_spec, sort_keys=True)
|
|
28
|
+
second_stringified_server_spec = json.dumps(second_server_spec, sort_keys=True)
|
|
29
|
+
return first_stringified_server_spec == second_stringified_server_spec
|
|
30
|
+
|
|
31
|
+
def _check_mcp_config_status(self) -> dict:
|
|
32
|
+
"""Check the status of the MCP configuration file and return information
|
|
33
|
+
about it."""
|
|
34
|
+
config_path = Path(PERSISTENCE_DIR) / MCP_CONFIG_FILE
|
|
35
|
+
|
|
36
|
+
if not config_path.exists():
|
|
37
|
+
return {
|
|
38
|
+
"exists": False,
|
|
39
|
+
"valid": False,
|
|
40
|
+
"servers": {},
|
|
41
|
+
"message": (
|
|
42
|
+
f"MCP configuration file not found at "
|
|
43
|
+
f"~/.openhands/{MCP_CONFIG_FILE}"
|
|
44
|
+
),
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
mcp_config = MCPConfig.from_file(config_path)
|
|
49
|
+
servers = mcp_config.to_dict().get("mcpServers", {})
|
|
50
|
+
return {
|
|
51
|
+
"exists": True,
|
|
52
|
+
"valid": True,
|
|
53
|
+
"servers": servers,
|
|
54
|
+
"message": (
|
|
55
|
+
f"Valid MCP configuration found with {len(servers)} server(s)"
|
|
56
|
+
),
|
|
57
|
+
}
|
|
58
|
+
except Exception as e:
|
|
59
|
+
return {
|
|
60
|
+
"exists": True,
|
|
61
|
+
"valid": False,
|
|
62
|
+
"servers": {},
|
|
63
|
+
"message": f"Invalid MCP configuration file: {str(e)}",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# ---------- TUI helpers ----------
|
|
67
|
+
|
|
68
|
+
def _get_mcp_server_diff(
|
|
69
|
+
self,
|
|
70
|
+
current: dict[str, Any],
|
|
71
|
+
incoming: dict[str, Any],
|
|
72
|
+
) -> None:
|
|
73
|
+
"""
|
|
74
|
+
Display a diff-style view:
|
|
75
|
+
|
|
76
|
+
- Always show the MCP servers the agent is *currently* configured with
|
|
77
|
+
- If there are incoming servers (from ~/.openhands/mcp.json),
|
|
78
|
+
clearly show which ones are NEW (not in current) and which ones are CHANGED
|
|
79
|
+
(same name but different config). Unchanged servers are not repeated.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
print_formatted_text(HTML("<white>Current Agent MCP Servers:</white>"))
|
|
83
|
+
if current:
|
|
84
|
+
for name, cfg in current.items():
|
|
85
|
+
self._render_server_summary(name, cfg, indent=2)
|
|
86
|
+
else:
|
|
87
|
+
print_formatted_text(
|
|
88
|
+
HTML(" <yellow>None configured on the current agent.</yellow>")
|
|
89
|
+
)
|
|
90
|
+
print_formatted_text("")
|
|
91
|
+
|
|
92
|
+
# If no incoming, we're done
|
|
93
|
+
if not incoming:
|
|
94
|
+
print_formatted_text(
|
|
95
|
+
HTML("<grey>No incoming servers detected for next restart.</grey>")
|
|
96
|
+
)
|
|
97
|
+
print_formatted_text("")
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
# Compare names and configs
|
|
101
|
+
current_names = set(current.keys())
|
|
102
|
+
incoming_names = set(incoming.keys())
|
|
103
|
+
new_servers = sorted(incoming_names - current_names)
|
|
104
|
+
|
|
105
|
+
overriden_servers = []
|
|
106
|
+
for name in sorted(incoming_names & current_names):
|
|
107
|
+
if not self._check_server_specs_are_equal(current[name], incoming[name]):
|
|
108
|
+
overriden_servers.append(name)
|
|
109
|
+
|
|
110
|
+
# Display incoming section header
|
|
111
|
+
print_formatted_text(
|
|
112
|
+
HTML(
|
|
113
|
+
"<white>Incoming Servers on Restart "
|
|
114
|
+
"(from ~/.openhands/mcp.json):</white>"
|
|
115
|
+
)
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if not new_servers and not overriden_servers:
|
|
119
|
+
print_formatted_text(
|
|
120
|
+
HTML(
|
|
121
|
+
" <grey>All configured servers match the current agent "
|
|
122
|
+
"configuration.</grey>"
|
|
123
|
+
)
|
|
124
|
+
)
|
|
125
|
+
print_formatted_text("")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
if new_servers:
|
|
129
|
+
print_formatted_text(HTML(" <green>New servers (will be added):</green>"))
|
|
130
|
+
for name in new_servers:
|
|
131
|
+
self._render_server_summary(name, incoming[name], indent=4)
|
|
132
|
+
|
|
133
|
+
if overriden_servers:
|
|
134
|
+
print_formatted_text(
|
|
135
|
+
HTML(" <yellow>Updated servers (configuration will change):</yellow>")
|
|
136
|
+
)
|
|
137
|
+
for name in overriden_servers:
|
|
138
|
+
print_formatted_text(HTML(f" <white>• {name}</white>"))
|
|
139
|
+
print_formatted_text(HTML(" <grey>Current:</grey>"))
|
|
140
|
+
self._render_server_summary(None, current[name], indent=8)
|
|
141
|
+
print_formatted_text(HTML(" <grey>Incoming:</grey>"))
|
|
142
|
+
self._render_server_summary(None, incoming[name], indent=8)
|
|
143
|
+
|
|
144
|
+
print_formatted_text("")
|
|
145
|
+
|
|
146
|
+
def _render_server_summary(
|
|
147
|
+
self, server_name: str | None, server_spec: dict[str, Any], indent: int = 2
|
|
148
|
+
) -> None:
|
|
149
|
+
pad = " " * indent
|
|
150
|
+
|
|
151
|
+
if server_name:
|
|
152
|
+
print_formatted_text(HTML(f"{pad}<white>• {server_name}</white>"))
|
|
153
|
+
|
|
154
|
+
if isinstance(server_spec, dict):
|
|
155
|
+
if "command" in server_spec:
|
|
156
|
+
cmd = server_spec.get("command", "")
|
|
157
|
+
args = server_spec.get("args", [])
|
|
158
|
+
args_str = " ".join(args) if args else ""
|
|
159
|
+
print_formatted_text(HTML(f"{pad} <grey>Type: Command-based</grey>"))
|
|
160
|
+
if cmd or args_str:
|
|
161
|
+
print_formatted_text(
|
|
162
|
+
HTML(f"{pad} <grey>Command: {cmd} {args_str}</grey>")
|
|
163
|
+
)
|
|
164
|
+
elif "url" in server_spec:
|
|
165
|
+
url = server_spec.get("url", "")
|
|
166
|
+
auth = server_spec.get("auth", "none")
|
|
167
|
+
print_formatted_text(HTML(f"{pad} <grey>Type: URL-based</grey>"))
|
|
168
|
+
if url:
|
|
169
|
+
print_formatted_text(HTML(f"{pad} <grey>URL: {url}</grey>"))
|
|
170
|
+
print_formatted_text(HTML(f"{pad} <grey>Auth: {auth}</grey>"))
|
|
171
|
+
|
|
172
|
+
def _display_information_header(self) -> None:
|
|
173
|
+
print_formatted_text(
|
|
174
|
+
HTML("<gold>MCP (Model Context Protocol) Configuration</gold>")
|
|
175
|
+
)
|
|
176
|
+
print_formatted_text("")
|
|
177
|
+
print_formatted_text(HTML("<white>To get started:</white>"))
|
|
178
|
+
print_formatted_text(
|
|
179
|
+
HTML(
|
|
180
|
+
" 1. Create the configuration file: <cyan>~/.openhands/mcp.json</cyan>"
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
print_formatted_text(
|
|
184
|
+
HTML(
|
|
185
|
+
" 2. Add your MCP server configurations "
|
|
186
|
+
"<cyan>https://gofastmcp.com/clients/client#configuration-format</cyan>"
|
|
187
|
+
)
|
|
188
|
+
)
|
|
189
|
+
print_formatted_text(
|
|
190
|
+
HTML(" 3. Restart your OpenHands session to load the new configuration")
|
|
191
|
+
)
|
|
192
|
+
print_formatted_text("")
|
|
193
|
+
|
|
194
|
+
# ---------- status + display entrypoint ----------
|
|
195
|
+
|
|
196
|
+
def display_mcp_info(self, existing_agent: Agent) -> None:
|
|
197
|
+
"""Display comprehensive MCP configuration information."""
|
|
198
|
+
|
|
199
|
+
self._display_information_header()
|
|
200
|
+
|
|
201
|
+
# Always determine current & incoming first
|
|
202
|
+
status = self._check_mcp_config_status()
|
|
203
|
+
incoming_servers = status.get("servers", {}) if status.get("valid") else {}
|
|
204
|
+
current_servers = existing_agent.mcp_config.get("mcpServers", {})
|
|
205
|
+
|
|
206
|
+
# Show file status
|
|
207
|
+
if not status["exists"]:
|
|
208
|
+
print_formatted_text(
|
|
209
|
+
HTML("<yellow>Status: Configuration file not found</yellow>")
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
elif not status["valid"]:
|
|
213
|
+
print_formatted_text(HTML(f"<red>Status: {status['message']}</red>"))
|
|
214
|
+
print_formatted_text("")
|
|
215
|
+
print_formatted_text(
|
|
216
|
+
HTML("<white>Please check your configuration file format.</white>")
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
print_formatted_text(HTML(f"<green>Status: {status['message']}</green>"))
|
|
220
|
+
|
|
221
|
+
print_formatted_text("")
|
|
222
|
+
|
|
223
|
+
# Always show the agent's current servers
|
|
224
|
+
# Then show incoming (deduped and changes highlighted)
|
|
225
|
+
self._get_mcp_server_diff(current_servers, incoming_servers)
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Any
|
|
3
|
+
|
|
4
|
+
from prompt_toolkit import HTML, print_formatted_text
|
|
5
|
+
from prompt_toolkit.shortcuts import print_container
|
|
6
|
+
from prompt_toolkit.widgets import Frame, TextArea
|
|
7
|
+
|
|
8
|
+
from openhands.sdk import LLM, BaseConversation, LLMSummarizingCondenser, LocalFileStore
|
|
9
|
+
from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
|
|
10
|
+
from openhands_cli.pt_style import COLOR_GREY
|
|
11
|
+
from openhands_cli.tui.settings.store import AgentStore
|
|
12
|
+
from openhands_cli.tui.utils import StepCounter
|
|
13
|
+
from openhands_cli.user_actions.settings_action import (
|
|
14
|
+
SettingsType,
|
|
15
|
+
choose_llm_model,
|
|
16
|
+
choose_llm_provider,
|
|
17
|
+
choose_memory_condensation,
|
|
18
|
+
prompt_api_key,
|
|
19
|
+
prompt_base_url,
|
|
20
|
+
prompt_custom_model,
|
|
21
|
+
save_settings_confirmation,
|
|
22
|
+
settings_type_confirmation,
|
|
23
|
+
)
|
|
24
|
+
from openhands_cli.utils import (
|
|
25
|
+
get_default_cli_agent,
|
|
26
|
+
get_llm_metadata,
|
|
27
|
+
should_set_litellm_extra_body,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SettingsScreen:
|
|
32
|
+
def __init__(self, conversation: BaseConversation | None = None):
|
|
33
|
+
self.file_store = LocalFileStore(PERSISTENCE_DIR)
|
|
34
|
+
self.agent_store = AgentStore()
|
|
35
|
+
self.conversation = conversation
|
|
36
|
+
|
|
37
|
+
def display_settings(self) -> None:
|
|
38
|
+
agent_spec = self.agent_store.load()
|
|
39
|
+
if not agent_spec:
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
llm = agent_spec.llm
|
|
43
|
+
advanced_llm_settings = True if llm.base_url else False
|
|
44
|
+
|
|
45
|
+
# Prepare labels and values based on settings
|
|
46
|
+
labels_and_values = []
|
|
47
|
+
if not advanced_llm_settings:
|
|
48
|
+
# Attempt to determine provider, fallback if not directly available
|
|
49
|
+
provider = llm.model.split("/")[0] if "/" in llm.model else "Unknown"
|
|
50
|
+
|
|
51
|
+
labels_and_values.extend(
|
|
52
|
+
[
|
|
53
|
+
(" LLM Provider", str(provider)),
|
|
54
|
+
(" LLM Model", str(llm.model)),
|
|
55
|
+
]
|
|
56
|
+
)
|
|
57
|
+
else:
|
|
58
|
+
labels_and_values.extend(
|
|
59
|
+
[
|
|
60
|
+
(" Custom Model", llm.model),
|
|
61
|
+
(" Base URL", llm.base_url),
|
|
62
|
+
]
|
|
63
|
+
)
|
|
64
|
+
labels_and_values.extend(
|
|
65
|
+
[
|
|
66
|
+
(" API Key", "********" if llm.api_key else "Not Set"),
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if self.conversation:
|
|
71
|
+
labels_and_values.extend(
|
|
72
|
+
[
|
|
73
|
+
(
|
|
74
|
+
" Confirmation Mode",
|
|
75
|
+
"Enabled"
|
|
76
|
+
if self.conversation.is_confirmation_mode_active
|
|
77
|
+
else "Disabled",
|
|
78
|
+
)
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
labels_and_values.extend(
|
|
83
|
+
[
|
|
84
|
+
(
|
|
85
|
+
" Memory Condensation",
|
|
86
|
+
"Enabled" if agent_spec.condenser else "Disabled",
|
|
87
|
+
),
|
|
88
|
+
(
|
|
89
|
+
" Configuration File",
|
|
90
|
+
os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH),
|
|
91
|
+
),
|
|
92
|
+
]
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Calculate max widths for alignment
|
|
96
|
+
# Ensure values are strings for len() calculation
|
|
97
|
+
str_labels_and_values = [
|
|
98
|
+
(label, str(value)) for label, value in labels_and_values
|
|
99
|
+
]
|
|
100
|
+
max_label_width = (
|
|
101
|
+
max(len(label) for label, _ in str_labels_and_values)
|
|
102
|
+
if str_labels_and_values
|
|
103
|
+
else 0
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
# Construct the summary text with aligned columns
|
|
107
|
+
settings_lines = [
|
|
108
|
+
f"{label + ':':<{max_label_width + 1}} {value:<}" # Changed value
|
|
109
|
+
# alignment to left (<)
|
|
110
|
+
for label, value in str_labels_and_values
|
|
111
|
+
]
|
|
112
|
+
settings_text = "\n".join(settings_lines)
|
|
113
|
+
|
|
114
|
+
container = Frame(
|
|
115
|
+
TextArea(
|
|
116
|
+
text=settings_text,
|
|
117
|
+
read_only=True,
|
|
118
|
+
style=COLOR_GREY,
|
|
119
|
+
wrap_lines=True,
|
|
120
|
+
),
|
|
121
|
+
title="Settings",
|
|
122
|
+
style=f"fg:{COLOR_GREY}",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
print_container(container)
|
|
126
|
+
|
|
127
|
+
self.configure_settings()
|
|
128
|
+
|
|
129
|
+
def configure_settings(self, first_time=False):
|
|
130
|
+
try:
|
|
131
|
+
settings_type = settings_type_confirmation(first_time=first_time)
|
|
132
|
+
except KeyboardInterrupt:
|
|
133
|
+
return
|
|
134
|
+
|
|
135
|
+
if settings_type == SettingsType.BASIC:
|
|
136
|
+
self.handle_basic_settings()
|
|
137
|
+
elif settings_type == SettingsType.ADVANCED:
|
|
138
|
+
self.handle_advanced_settings()
|
|
139
|
+
|
|
140
|
+
def handle_basic_settings(self):
|
|
141
|
+
step_counter = StepCounter(3)
|
|
142
|
+
try:
|
|
143
|
+
provider = choose_llm_provider(step_counter, escapable=True)
|
|
144
|
+
llm_model = choose_llm_model(step_counter, provider, escapable=True)
|
|
145
|
+
api_key = prompt_api_key(
|
|
146
|
+
step_counter,
|
|
147
|
+
provider,
|
|
148
|
+
self.conversation.state.agent.llm.api_key
|
|
149
|
+
if self.conversation
|
|
150
|
+
else None,
|
|
151
|
+
escapable=True,
|
|
152
|
+
)
|
|
153
|
+
save_settings_confirmation()
|
|
154
|
+
except KeyboardInterrupt:
|
|
155
|
+
print_formatted_text(HTML("\n<red>Cancelled settings change.</red>"))
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
# Store the collected settings for persistence
|
|
159
|
+
self._save_llm_settings(f"{provider}/{llm_model}", api_key)
|
|
160
|
+
|
|
161
|
+
def handle_advanced_settings(self, escapable=True):
|
|
162
|
+
"""Handle advanced settings configuration with clean step-by-step flow."""
|
|
163
|
+
step_counter = StepCounter(4)
|
|
164
|
+
try:
|
|
165
|
+
custom_model = prompt_custom_model(step_counter)
|
|
166
|
+
base_url = prompt_base_url(step_counter)
|
|
167
|
+
api_key = prompt_api_key(
|
|
168
|
+
step_counter,
|
|
169
|
+
custom_model.split("/")[0] if len(custom_model.split("/")) > 1 else "",
|
|
170
|
+
self.conversation.state.agent.llm.api_key
|
|
171
|
+
if self.conversation
|
|
172
|
+
else None,
|
|
173
|
+
escapable=escapable,
|
|
174
|
+
)
|
|
175
|
+
memory_condensation = choose_memory_condensation(step_counter)
|
|
176
|
+
|
|
177
|
+
# Confirm save
|
|
178
|
+
save_settings_confirmation()
|
|
179
|
+
except KeyboardInterrupt:
|
|
180
|
+
print_formatted_text(HTML("\n<red>Cancelled settings change.</red>"))
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
# Store the collected settings for persistence
|
|
184
|
+
self._save_advanced_settings(
|
|
185
|
+
custom_model, base_url, api_key, memory_condensation
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def _save_llm_settings(self, model, api_key, base_url: str | None = None) -> None:
|
|
189
|
+
extra_kwargs: dict[str, Any] = {}
|
|
190
|
+
if should_set_litellm_extra_body(model):
|
|
191
|
+
extra_kwargs["litellm_extra_body"] = {
|
|
192
|
+
"metadata": get_llm_metadata(model_name=model, llm_type="agent")
|
|
193
|
+
}
|
|
194
|
+
llm = LLM(
|
|
195
|
+
model=model,
|
|
196
|
+
api_key=api_key,
|
|
197
|
+
base_url=base_url,
|
|
198
|
+
usage_id="agent",
|
|
199
|
+
**extra_kwargs,
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
agent = self.agent_store.load()
|
|
203
|
+
if not agent:
|
|
204
|
+
agent = get_default_cli_agent(llm=llm)
|
|
205
|
+
|
|
206
|
+
# Must update all LLMs
|
|
207
|
+
agent = agent.model_copy(update={"llm": llm})
|
|
208
|
+
condenser = LLMSummarizingCondenser(
|
|
209
|
+
llm=llm.model_copy(update={"usage_id": "condenser"})
|
|
210
|
+
)
|
|
211
|
+
agent = agent.model_copy(update={"condenser": condenser})
|
|
212
|
+
self.agent_store.save(agent)
|
|
213
|
+
|
|
214
|
+
def _save_advanced_settings(
|
|
215
|
+
self, custom_model: str, base_url: str, api_key: str, memory_condensation: bool
|
|
216
|
+
):
|
|
217
|
+
self._save_llm_settings(custom_model, api_key, base_url=base_url)
|
|
218
|
+
|
|
219
|
+
agent_spec = self.agent_store.load()
|
|
220
|
+
if not agent_spec:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
if not memory_condensation:
|
|
224
|
+
agent_spec.model_copy(update={"condenser": None})
|
|
225
|
+
|
|
226
|
+
self.agent_store.save(agent_spec)
|