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.
Files changed (43) hide show
  1. openhands-1.3.0.dist-info/METADATA +56 -0
  2. openhands-1.3.0.dist-info/RECORD +43 -0
  3. openhands-1.3.0.dist-info/WHEEL +4 -0
  4. openhands-1.3.0.dist-info/entry_points.txt +3 -0
  5. openhands-1.3.0.dist-info/licenses/LICENSE +21 -0
  6. openhands_cli/__init__.py +9 -0
  7. openhands_cli/acp_impl/README.md +68 -0
  8. openhands_cli/acp_impl/__init__.py +1 -0
  9. openhands_cli/acp_impl/agent.py +483 -0
  10. openhands_cli/acp_impl/event.py +512 -0
  11. openhands_cli/acp_impl/main.py +21 -0
  12. openhands_cli/acp_impl/test_utils.py +174 -0
  13. openhands_cli/acp_impl/utils/__init__.py +14 -0
  14. openhands_cli/acp_impl/utils/convert.py +103 -0
  15. openhands_cli/acp_impl/utils/mcp.py +66 -0
  16. openhands_cli/acp_impl/utils/resources.py +189 -0
  17. openhands_cli/agent_chat.py +236 -0
  18. openhands_cli/argparsers/main_parser.py +78 -0
  19. openhands_cli/argparsers/serve_parser.py +31 -0
  20. openhands_cli/gui_launcher.py +224 -0
  21. openhands_cli/listeners/__init__.py +4 -0
  22. openhands_cli/listeners/pause_listener.py +83 -0
  23. openhands_cli/locations.py +14 -0
  24. openhands_cli/pt_style.py +33 -0
  25. openhands_cli/runner.py +190 -0
  26. openhands_cli/setup.py +136 -0
  27. openhands_cli/simple_main.py +71 -0
  28. openhands_cli/tui/__init__.py +6 -0
  29. openhands_cli/tui/settings/mcp_screen.py +225 -0
  30. openhands_cli/tui/settings/settings_screen.py +226 -0
  31. openhands_cli/tui/settings/store.py +132 -0
  32. openhands_cli/tui/status.py +110 -0
  33. openhands_cli/tui/tui.py +120 -0
  34. openhands_cli/tui/utils.py +14 -0
  35. openhands_cli/tui/visualizer.py +22 -0
  36. openhands_cli/user_actions/__init__.py +18 -0
  37. openhands_cli/user_actions/agent_action.py +82 -0
  38. openhands_cli/user_actions/exit_session.py +18 -0
  39. openhands_cli/user_actions/settings_action.py +176 -0
  40. openhands_cli/user_actions/types.py +17 -0
  41. openhands_cli/user_actions/utils.py +199 -0
  42. openhands_cli/utils.py +122 -0
  43. 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,6 @@
1
+ from openhands_cli.tui.tui import DEFAULT_STYLE
2
+
3
+
4
+ __all__ = [
5
+ "DEFAULT_STYLE",
6
+ ]
@@ -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)