openhands 0.0.0__py3-none-any.whl → 1.0.1__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.

Potentially problematic release.


This version of openhands might be problematic. Click here for more details.

Files changed (124) hide show
  1. openhands-1.0.1.dist-info/METADATA +52 -0
  2. openhands-1.0.1.dist-info/RECORD +31 -0
  3. {openhands-0.0.0.dist-info → openhands-1.0.1.dist-info}/WHEEL +1 -2
  4. openhands-1.0.1.dist-info/entry_points.txt +2 -0
  5. openhands_cli/__init__.py +8 -0
  6. openhands_cli/agent_chat.py +186 -0
  7. openhands_cli/argparsers/main_parser.py +56 -0
  8. openhands_cli/argparsers/serve_parser.py +31 -0
  9. openhands_cli/gui_launcher.py +220 -0
  10. openhands_cli/listeners/__init__.py +4 -0
  11. openhands_cli/listeners/loading_listener.py +63 -0
  12. openhands_cli/listeners/pause_listener.py +83 -0
  13. openhands_cli/llm_utils.py +57 -0
  14. openhands_cli/locations.py +13 -0
  15. openhands_cli/pt_style.py +30 -0
  16. openhands_cli/runner.py +178 -0
  17. openhands_cli/setup.py +116 -0
  18. openhands_cli/simple_main.py +59 -0
  19. openhands_cli/tui/__init__.py +5 -0
  20. openhands_cli/tui/settings/mcp_screen.py +217 -0
  21. openhands_cli/tui/settings/settings_screen.py +202 -0
  22. openhands_cli/tui/settings/store.py +93 -0
  23. openhands_cli/tui/status.py +109 -0
  24. openhands_cli/tui/tui.py +100 -0
  25. openhands_cli/tui/utils.py +14 -0
  26. openhands_cli/user_actions/__init__.py +17 -0
  27. openhands_cli/user_actions/agent_action.py +95 -0
  28. openhands_cli/user_actions/exit_session.py +18 -0
  29. openhands_cli/user_actions/settings_action.py +171 -0
  30. openhands_cli/user_actions/types.py +18 -0
  31. openhands_cli/user_actions/utils.py +199 -0
  32. openhands/__init__.py +0 -1
  33. openhands/sdk/__init__.py +0 -45
  34. openhands/sdk/agent/__init__.py +0 -8
  35. openhands/sdk/agent/agent/__init__.py +0 -6
  36. openhands/sdk/agent/agent/agent.py +0 -349
  37. openhands/sdk/agent/base.py +0 -103
  38. openhands/sdk/context/__init__.py +0 -28
  39. openhands/sdk/context/agent_context.py +0 -153
  40. openhands/sdk/context/condenser/__init__.py +0 -5
  41. openhands/sdk/context/condenser/condenser.py +0 -73
  42. openhands/sdk/context/condenser/no_op_condenser.py +0 -13
  43. openhands/sdk/context/manager.py +0 -5
  44. openhands/sdk/context/microagents/__init__.py +0 -26
  45. openhands/sdk/context/microagents/exceptions.py +0 -11
  46. openhands/sdk/context/microagents/microagent.py +0 -345
  47. openhands/sdk/context/microagents/types.py +0 -70
  48. openhands/sdk/context/utils/__init__.py +0 -8
  49. openhands/sdk/context/utils/prompt.py +0 -52
  50. openhands/sdk/context/view.py +0 -116
  51. openhands/sdk/conversation/__init__.py +0 -12
  52. openhands/sdk/conversation/conversation.py +0 -207
  53. openhands/sdk/conversation/state.py +0 -50
  54. openhands/sdk/conversation/types.py +0 -6
  55. openhands/sdk/conversation/visualizer.py +0 -300
  56. openhands/sdk/event/__init__.py +0 -27
  57. openhands/sdk/event/base.py +0 -148
  58. openhands/sdk/event/condenser.py +0 -49
  59. openhands/sdk/event/llm_convertible.py +0 -265
  60. openhands/sdk/event/types.py +0 -5
  61. openhands/sdk/event/user_action.py +0 -12
  62. openhands/sdk/event/utils.py +0 -30
  63. openhands/sdk/llm/__init__.py +0 -19
  64. openhands/sdk/llm/exceptions.py +0 -108
  65. openhands/sdk/llm/llm.py +0 -867
  66. openhands/sdk/llm/llm_registry.py +0 -116
  67. openhands/sdk/llm/message.py +0 -216
  68. openhands/sdk/llm/metadata.py +0 -34
  69. openhands/sdk/llm/utils/fn_call_converter.py +0 -1049
  70. openhands/sdk/llm/utils/metrics.py +0 -311
  71. openhands/sdk/llm/utils/model_features.py +0 -153
  72. openhands/sdk/llm/utils/retry_mixin.py +0 -122
  73. openhands/sdk/llm/utils/telemetry.py +0 -252
  74. openhands/sdk/logger.py +0 -167
  75. openhands/sdk/mcp/__init__.py +0 -20
  76. openhands/sdk/mcp/client.py +0 -113
  77. openhands/sdk/mcp/definition.py +0 -69
  78. openhands/sdk/mcp/tool.py +0 -104
  79. openhands/sdk/mcp/utils.py +0 -59
  80. openhands/sdk/tests/llm/test_llm.py +0 -447
  81. openhands/sdk/tests/llm/test_llm_fncall_converter.py +0 -691
  82. openhands/sdk/tests/llm/test_model_features.py +0 -221
  83. openhands/sdk/tool/__init__.py +0 -30
  84. openhands/sdk/tool/builtins/__init__.py +0 -34
  85. openhands/sdk/tool/builtins/finish.py +0 -57
  86. openhands/sdk/tool/builtins/think.py +0 -60
  87. openhands/sdk/tool/schema.py +0 -236
  88. openhands/sdk/tool/security_prompt.py +0 -5
  89. openhands/sdk/tool/tool.py +0 -142
  90. openhands/sdk/utils/__init__.py +0 -14
  91. openhands/sdk/utils/discriminated_union.py +0 -210
  92. openhands/sdk/utils/json.py +0 -48
  93. openhands/sdk/utils/truncate.py +0 -44
  94. openhands/tools/__init__.py +0 -44
  95. openhands/tools/execute_bash/__init__.py +0 -30
  96. openhands/tools/execute_bash/constants.py +0 -31
  97. openhands/tools/execute_bash/definition.py +0 -166
  98. openhands/tools/execute_bash/impl.py +0 -38
  99. openhands/tools/execute_bash/metadata.py +0 -101
  100. openhands/tools/execute_bash/terminal/__init__.py +0 -22
  101. openhands/tools/execute_bash/terminal/factory.py +0 -113
  102. openhands/tools/execute_bash/terminal/interface.py +0 -189
  103. openhands/tools/execute_bash/terminal/subprocess_terminal.py +0 -412
  104. openhands/tools/execute_bash/terminal/terminal_session.py +0 -492
  105. openhands/tools/execute_bash/terminal/tmux_terminal.py +0 -160
  106. openhands/tools/execute_bash/utils/command.py +0 -150
  107. openhands/tools/str_replace_editor/__init__.py +0 -17
  108. openhands/tools/str_replace_editor/definition.py +0 -158
  109. openhands/tools/str_replace_editor/editor.py +0 -683
  110. openhands/tools/str_replace_editor/exceptions.py +0 -41
  111. openhands/tools/str_replace_editor/impl.py +0 -66
  112. openhands/tools/str_replace_editor/utils/__init__.py +0 -0
  113. openhands/tools/str_replace_editor/utils/config.py +0 -2
  114. openhands/tools/str_replace_editor/utils/constants.py +0 -9
  115. openhands/tools/str_replace_editor/utils/encoding.py +0 -135
  116. openhands/tools/str_replace_editor/utils/file_cache.py +0 -154
  117. openhands/tools/str_replace_editor/utils/history.py +0 -122
  118. openhands/tools/str_replace_editor/utils/shell.py +0 -72
  119. openhands/tools/task_tracker/__init__.py +0 -16
  120. openhands/tools/task_tracker/definition.py +0 -336
  121. openhands/tools/utils/__init__.py +0 -1
  122. openhands-0.0.0.dist-info/METADATA +0 -3
  123. openhands-0.0.0.dist-info/RECORD +0 -94
  124. openhands-0.0.0.dist-info/top_level.txt +0 -1
@@ -0,0 +1,217 @@
1
+ import json
2
+ from pathlib import Path
3
+ from typing import Any
4
+
5
+ from fastmcp.mcp_config import MCPConfig
6
+ from openhands_cli.locations import MCP_CONFIG_FILE, PERSISTENCE_DIR
7
+ from prompt_toolkit import HTML, print_formatted_text
8
+
9
+ from openhands.sdk import Agent
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 about it."""
33
+ config_path = Path(PERSISTENCE_DIR) / MCP_CONFIG_FILE
34
+
35
+ if not config_path.exists():
36
+ return {
37
+ 'exists': False,
38
+ 'valid': False,
39
+ 'servers': {},
40
+ 'message': f'MCP configuration file not found at ~/.openhands/{MCP_CONFIG_FILE}',
41
+ }
42
+
43
+ try:
44
+ mcp_config = MCPConfig.from_file(config_path)
45
+ servers = mcp_config.to_dict().get('mcpServers', {})
46
+ return {
47
+ 'exists': True,
48
+ 'valid': True,
49
+ 'servers': servers,
50
+ 'message': f'Valid MCP configuration found with {len(servers)} server(s)',
51
+ }
52
+ except Exception as e:
53
+ return {
54
+ 'exists': True,
55
+ 'valid': False,
56
+ 'servers': {},
57
+ 'message': f'Invalid MCP configuration file: {str(e)}',
58
+ }
59
+
60
+ # ---------- TUI helpers ----------
61
+
62
+ def _get_mcp_server_diff(
63
+ self,
64
+ current: dict[str, Any],
65
+ incoming: dict[str, Any],
66
+ ) -> None:
67
+ """
68
+ Display a diff-style view:
69
+
70
+ - Always show the MCP servers the agent is *currently* configured with
71
+ - If there are incoming servers (from ~/.openhands/mcp.json),
72
+ clearly show which ones are NEW (not in current) and which ones are CHANGED
73
+ (same name but different config). Unchanged servers are not repeated.
74
+ """
75
+
76
+ print_formatted_text(HTML('<white>Current Agent MCP Servers:</white>'))
77
+ if current:
78
+ for name, cfg in current.items():
79
+ self._render_server_summary(name, cfg, indent=2)
80
+ else:
81
+ print_formatted_text(
82
+ HTML(' <yellow>None configured on the current agent.</yellow>')
83
+ )
84
+ print_formatted_text('')
85
+
86
+ # If no incoming, we're done
87
+ if not incoming:
88
+ print_formatted_text(
89
+ HTML('<grey>No incoming servers detected for next restart.</grey>')
90
+ )
91
+ print_formatted_text('')
92
+ return
93
+
94
+ # Compare names and configs
95
+ current_names = set(current.keys())
96
+ incoming_names = set(incoming.keys())
97
+ new_servers = sorted(incoming_names - current_names)
98
+
99
+ overriden_servers = []
100
+ for name in sorted(incoming_names & current_names):
101
+ if not self._check_server_specs_are_equal(current[name], incoming[name]):
102
+ overriden_servers.append(name)
103
+
104
+ # Display incoming section header
105
+ print_formatted_text(
106
+ HTML(
107
+ '<white>Incoming Servers on Restart (from ~/.openhands/mcp.json):</white>'
108
+ )
109
+ )
110
+
111
+ if not new_servers and not overriden_servers:
112
+ print_formatted_text(
113
+ HTML(
114
+ ' <grey>All configured servers match the current agent configuration.</grey>'
115
+ )
116
+ )
117
+ print_formatted_text('')
118
+ return
119
+
120
+ if new_servers:
121
+ print_formatted_text(HTML(' <green>New servers (will be added):</green>'))
122
+ for name in new_servers:
123
+ self._render_server_summary(name, incoming[name], indent=4)
124
+
125
+ if overriden_servers:
126
+ print_formatted_text(
127
+ HTML(' <yellow>Updated servers (configuration will change):</yellow>')
128
+ )
129
+ for name in overriden_servers:
130
+ print_formatted_text(HTML(f' <white>• {name}</white>'))
131
+ print_formatted_text(HTML(' <grey>Current:</grey>'))
132
+ self._render_server_summary(None, current[name], indent=8)
133
+ print_formatted_text(HTML(' <grey>Incoming:</grey>'))
134
+ self._render_server_summary(None, incoming[name], indent=8)
135
+
136
+ print_formatted_text('')
137
+
138
+ def _render_server_summary(
139
+ self, server_name: str | None, server_spec: dict[str, Any], indent: int = 2
140
+ ) -> None:
141
+ pad = ' ' * indent
142
+
143
+ if server_name:
144
+ print_formatted_text(HTML(f'{pad}<white>• {server_name}</white>'))
145
+
146
+ if isinstance(server_spec, dict):
147
+ if 'command' in server_spec:
148
+ cmd = server_spec.get('command', '')
149
+ args = server_spec.get('args', [])
150
+ args_str = ' '.join(args) if args else ''
151
+ print_formatted_text(HTML(f'{pad} <grey>Type: Command-based</grey>'))
152
+ if cmd or args_str:
153
+ print_formatted_text(
154
+ HTML(f'{pad} <grey>Command: {cmd} {args_str}</grey>')
155
+ )
156
+ elif 'url' in server_spec:
157
+ url = server_spec.get('url', '')
158
+ auth = server_spec.get('auth', 'none')
159
+ print_formatted_text(HTML(f'{pad} <grey>Type: URL-based</grey>'))
160
+ if url:
161
+ print_formatted_text(HTML(f'{pad} <grey>URL: {url}</grey>'))
162
+ print_formatted_text(HTML(f'{pad} <grey>Auth: {auth}</grey>'))
163
+
164
+ def _display_information_header(self) -> None:
165
+ print_formatted_text(
166
+ HTML('<gold>MCP (Model Context Protocol) Configuration</gold>')
167
+ )
168
+ print_formatted_text('')
169
+ print_formatted_text(HTML('<white>To get started:</white>'))
170
+ print_formatted_text(
171
+ HTML(
172
+ ' 1. Create the configuration file: <cyan>~/.openhands/mcp.json</cyan>'
173
+ )
174
+ )
175
+ print_formatted_text(
176
+ HTML(
177
+ ' 2. Add your MCP server configurations '
178
+ '<cyan>https://gofastmcp.com/clients/client#configuration-format</cyan>'
179
+ )
180
+ )
181
+ print_formatted_text(
182
+ HTML(' 3. Restart your OpenHands session to load the new configuration')
183
+ )
184
+ print_formatted_text('')
185
+
186
+ # ---------- status + display entrypoint ----------
187
+
188
+ def display_mcp_info(self, existing_agent: Agent) -> None:
189
+ """Display comprehensive MCP configuration information."""
190
+
191
+ self._display_information_header()
192
+
193
+ # Always determine current & incoming first
194
+ status = self._check_mcp_config_status()
195
+ incoming_servers = status.get('servers', {}) if status.get('valid') else {}
196
+ current_servers = existing_agent.mcp_config.get('mcpServers', {})
197
+
198
+ # Show file status
199
+ if not status['exists']:
200
+ print_formatted_text(
201
+ HTML('<yellow>Status: Configuration file not found</yellow>')
202
+ )
203
+
204
+ elif not status['valid']:
205
+ print_formatted_text(HTML(f'<red>Status: {status["message"]}</red>'))
206
+ print_formatted_text('')
207
+ print_formatted_text(
208
+ HTML('<white>Please check your configuration file format.</white>')
209
+ )
210
+ else:
211
+ print_formatted_text(HTML(f'<green>Status: {status["message"]}</green>'))
212
+
213
+ print_formatted_text('')
214
+
215
+ # Always show the agent's current servers
216
+ # Then show incoming (deduped and changes highlighted)
217
+ self._get_mcp_server_diff(current_servers, incoming_servers)
@@ -0,0 +1,202 @@
1
+ import os
2
+
3
+ from openhands.sdk import LLM, BaseConversation, LocalFileStore
4
+ from openhands.sdk.security.confirmation_policy import NeverConfirm
5
+ from openhands.tools.preset.default import get_default_agent
6
+ from prompt_toolkit import HTML, print_formatted_text
7
+ from prompt_toolkit.shortcuts import print_container
8
+ from prompt_toolkit.widgets import Frame, TextArea
9
+
10
+ from openhands_cli.llm_utils import get_llm_metadata
11
+ from openhands_cli.locations import AGENT_SETTINGS_PATH, PERSISTENCE_DIR
12
+ from openhands_cli.pt_style import COLOR_GREY
13
+ from openhands_cli.tui.settings.store import AgentStore
14
+ from openhands_cli.tui.utils import StepCounter
15
+ from openhands_cli.user_actions.settings_action import (
16
+ SettingsType,
17
+ choose_llm_model,
18
+ choose_llm_provider,
19
+ choose_memory_condensation,
20
+ prompt_api_key,
21
+ prompt_base_url,
22
+ prompt_custom_model,
23
+ save_settings_confirmation,
24
+ settings_type_confirmation,
25
+ )
26
+
27
+
28
+ class SettingsScreen:
29
+ def __init__(self, conversation: BaseConversation | None = None):
30
+ self.file_store = LocalFileStore(PERSISTENCE_DIR)
31
+ self.agent_store = AgentStore()
32
+ self.conversation = conversation
33
+
34
+ def display_settings(self) -> None:
35
+ agent_spec = self.agent_store.load()
36
+ if not agent_spec:
37
+ return
38
+ assert self.conversation is not None, (
39
+ 'Conversation must be set to display settings.'
40
+ )
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
+ ' Confirmation Mode',
69
+ 'Enabled'
70
+ if self.conversation.is_confirmation_mode_active
71
+ else 'Disabled',
72
+ ),
73
+ (
74
+ ' Memory Condensation',
75
+ 'Enabled' if agent_spec.condenser else 'Disabled',
76
+ ),
77
+ (
78
+ ' Configuration File',
79
+ os.path.join(PERSISTENCE_DIR, AGENT_SETTINGS_PATH),
80
+ ),
81
+ ]
82
+ )
83
+
84
+ # Calculate max widths for alignment
85
+ # Ensure values are strings for len() calculation
86
+ str_labels_and_values = [
87
+ (label, str(value)) for label, value in labels_and_values
88
+ ]
89
+ max_label_width = (
90
+ max(len(label) for label, _ in str_labels_and_values)
91
+ if str_labels_and_values
92
+ else 0
93
+ )
94
+
95
+ # Construct the summary text with aligned columns
96
+ settings_lines = [
97
+ f'{label + ":":<{max_label_width + 1}} {value:<}' # Changed value alignment to left (<)
98
+ for label, value in str_labels_and_values
99
+ ]
100
+ settings_text = '\n'.join(settings_lines)
101
+
102
+ container = Frame(
103
+ TextArea(
104
+ text=settings_text,
105
+ read_only=True,
106
+ style=COLOR_GREY,
107
+ wrap_lines=True,
108
+ ),
109
+ title='Settings',
110
+ style=f'fg:{COLOR_GREY}',
111
+ )
112
+
113
+ print_container(container)
114
+
115
+ self.configure_settings()
116
+
117
+ def configure_settings(self, first_time=False):
118
+ try:
119
+ settings_type = settings_type_confirmation(first_time=first_time)
120
+ except KeyboardInterrupt:
121
+ return
122
+
123
+ if settings_type == SettingsType.BASIC:
124
+ self.handle_basic_settings()
125
+ elif settings_type == SettingsType.ADVANCED:
126
+ self.handle_advanced_settings()
127
+
128
+ def handle_basic_settings(self):
129
+ step_counter = StepCounter(3)
130
+ try:
131
+ provider = choose_llm_provider(step_counter, escapable=True)
132
+ llm_model = choose_llm_model(step_counter, provider, escapable=True)
133
+ api_key = prompt_api_key(
134
+ step_counter,
135
+ provider,
136
+ self.conversation.state.agent.llm.api_key
137
+ if self.conversation
138
+ else None,
139
+ escapable=True,
140
+ )
141
+ save_settings_confirmation()
142
+ except KeyboardInterrupt:
143
+ print_formatted_text(HTML('\n<red>Cancelled settings change.</red>'))
144
+ return
145
+
146
+ # Store the collected settings for persistence
147
+ self._save_llm_settings(f'{provider}/{llm_model}', api_key)
148
+
149
+ def handle_advanced_settings(self, escapable=True):
150
+ """Handle advanced settings configuration with clean step-by-step flow."""
151
+ step_counter = StepCounter(4)
152
+ try:
153
+ custom_model = prompt_custom_model(step_counter)
154
+ base_url = prompt_base_url(step_counter)
155
+ api_key = prompt_api_key(
156
+ step_counter,
157
+ custom_model.split('/')[0] if len(custom_model.split('/')) > 1 else '',
158
+ self.conversation.agent.llm.api_key if self.conversation else None,
159
+ escapable=escapable,
160
+ )
161
+ memory_condensation = choose_memory_condensation(step_counter)
162
+
163
+ # Confirm save
164
+ save_settings_confirmation()
165
+ except KeyboardInterrupt:
166
+ print_formatted_text(HTML('\n<red>Cancelled settings change.</red>'))
167
+ return
168
+
169
+ # Store the collected settings for persistence
170
+ self._save_advanced_settings(
171
+ custom_model, base_url, api_key, memory_condensation
172
+ )
173
+
174
+ def _save_llm_settings(self, model, api_key, base_url: str | None = None) -> None:
175
+ llm = LLM(
176
+ model=model,
177
+ api_key=api_key,
178
+ base_url=base_url,
179
+ service_id='agent',
180
+ metadata=get_llm_metadata(model_name=model, llm_type='agent'),
181
+ )
182
+
183
+ agent = self.agent_store.load()
184
+ if not agent:
185
+ agent = get_default_agent(llm=llm, cli_mode=True)
186
+
187
+ agent = agent.model_copy(update={'llm': llm})
188
+ self.agent_store.save(agent)
189
+
190
+ def _save_advanced_settings(
191
+ self, custom_model: str, base_url: str, api_key: str, memory_condensation: bool
192
+ ):
193
+ self._save_llm_settings(custom_model, api_key, base_url=base_url)
194
+
195
+ agent_spec = self.agent_store.load()
196
+ if not agent_spec:
197
+ return
198
+
199
+ if not memory_condensation:
200
+ agent_spec.model_copy(update={'condenser': None})
201
+
202
+ self.agent_store.save(agent_spec)
@@ -0,0 +1,93 @@
1
+ # openhands_cli/settings/store.py
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from fastmcp.mcp_config import MCPConfig
8
+ from openhands_cli.llm_utils import get_llm_metadata
9
+ from openhands_cli.locations import (
10
+ AGENT_SETTINGS_PATH,
11
+ MCP_CONFIG_FILE,
12
+ PERSISTENCE_DIR,
13
+ WORK_DIR,
14
+ )
15
+ from prompt_toolkit import HTML, print_formatted_text
16
+
17
+ from openhands.sdk import Agent, AgentContext, LocalFileStore
18
+ from openhands.sdk.context.condenser import LLMSummarizingCondenser
19
+ from openhands.tools.preset.default import get_default_tools
20
+
21
+
22
+ class AgentStore:
23
+ """Single source of truth for persisting/retrieving AgentSpec."""
24
+
25
+ def __init__(self) -> None:
26
+ self.file_store = LocalFileStore(root=PERSISTENCE_DIR)
27
+
28
+ def load_mcp_configuration(self) -> dict[str, Any]:
29
+ try:
30
+ mcp_config_path = Path(self.file_store.root) / MCP_CONFIG_FILE
31
+ mcp_config = MCPConfig.from_file(mcp_config_path)
32
+ return mcp_config.to_dict()['mcpServers']
33
+ except Exception:
34
+ return {}
35
+
36
+ def load(self, session_id: str | None = None) -> Agent | None:
37
+ try:
38
+ str_spec = self.file_store.read(AGENT_SETTINGS_PATH)
39
+ agent = Agent.model_validate_json(str_spec)
40
+
41
+ # Update tools with most recent working directory
42
+ updated_tools = get_default_tools(enable_browser=False)
43
+
44
+ agent_context = AgentContext(
45
+ system_message_suffix=f'You current working directory is: {WORK_DIR}',
46
+ )
47
+
48
+ additional_mcp_config = self.load_mcp_configuration()
49
+ mcp_config: dict = agent.mcp_config.copy().get('mcpServers', {})
50
+ mcp_config.update(additional_mcp_config)
51
+
52
+ # Update LLM metadata with current information
53
+ agent_llm_metadata = get_llm_metadata(
54
+ model_name=agent.llm.model, llm_type='agent', session_id=session_id
55
+ )
56
+ updated_llm = agent.llm.model_copy(update={'metadata': agent_llm_metadata})
57
+
58
+ condenser_updates = {}
59
+ if agent.condenser and isinstance(agent.condenser, LLMSummarizingCondenser):
60
+ condenser_updates['llm'] = agent.condenser.llm.model_copy(
61
+ update={
62
+ 'metadata': get_llm_metadata(
63
+ model_name=agent.condenser.llm.model,
64
+ llm_type='condenser',
65
+ session_id=session_id,
66
+ )
67
+ }
68
+ )
69
+
70
+ agent = agent.model_copy(
71
+ update={
72
+ 'llm': updated_llm,
73
+ 'tools': updated_tools,
74
+ 'mcp_config': {'mcpServers': mcp_config} if mcp_config else {},
75
+ 'agent_context': agent_context,
76
+ 'condenser': agent.condenser.model_copy(update=condenser_updates)
77
+ if agent.condenser
78
+ else None,
79
+ }
80
+ )
81
+
82
+ return agent
83
+ except FileNotFoundError:
84
+ return None
85
+ except Exception:
86
+ print_formatted_text(
87
+ HTML('\n<red>Agent configuration file is corrupted!</red>')
88
+ )
89
+ return None
90
+
91
+ def save(self, agent: Agent) -> None:
92
+ serialized_spec = agent.model_dump_json(context={'expose_secrets': True})
93
+ self.file_store.write(AGENT_SETTINGS_PATH, serialized_spec)
@@ -0,0 +1,109 @@
1
+ """Status display components for OpenHands CLI TUI."""
2
+
3
+ from datetime import datetime
4
+
5
+ from openhands.sdk import BaseConversation
6
+ from prompt_toolkit import print_formatted_text
7
+ from prompt_toolkit.formatted_text import HTML
8
+ from prompt_toolkit.shortcuts import print_container
9
+ from prompt_toolkit.widgets import Frame, TextArea
10
+
11
+
12
+ def display_status(
13
+ conversation: BaseConversation,
14
+ session_start_time: datetime,
15
+ ) -> None:
16
+ """Display detailed conversation status including metrics and uptime.
17
+
18
+ Args:
19
+ conversation: The conversation to display status for
20
+ session_start_time: The session start time for uptime calculation
21
+ """
22
+ # Get conversation stats
23
+ stats = conversation.conversation_stats.get_combined_metrics()
24
+
25
+ # Calculate uptime from session start time
26
+ now = datetime.now()
27
+ diff = now - session_start_time
28
+
29
+ # Format as hours, minutes, seconds
30
+ total_seconds = int(diff.total_seconds())
31
+ hours = total_seconds // 3600
32
+ minutes = (total_seconds % 3600) // 60
33
+ seconds = total_seconds % 60
34
+ uptime_str = f"{hours}h {minutes}m {seconds}s"
35
+
36
+ # Display conversation ID and uptime
37
+ print_formatted_text(HTML(f'<grey>Conversation ID: {conversation.id}</grey>'))
38
+ print_formatted_text(HTML(f'<grey>Uptime: {uptime_str}</grey>'))
39
+ print_formatted_text('')
40
+
41
+ # Calculate token metrics
42
+ token_usage = stats.accumulated_token_usage
43
+ total_input_tokens = token_usage.prompt_tokens if token_usage else 0
44
+ total_output_tokens = token_usage.completion_tokens if token_usage else 0
45
+ cache_hits = token_usage.cache_read_tokens if token_usage else 0
46
+ cache_writes = token_usage.cache_write_tokens if token_usage else 0
47
+ total_tokens = total_input_tokens + total_output_tokens
48
+ total_cost = stats.accumulated_cost
49
+
50
+ # Use prompt_toolkit containers for formatted display
51
+ _display_usage_metrics_container(
52
+ total_cost,
53
+ total_input_tokens,
54
+ total_output_tokens,
55
+ cache_hits,
56
+ cache_writes,
57
+ total_tokens
58
+ )
59
+
60
+
61
+ def _display_usage_metrics_container(
62
+ total_cost: float,
63
+ total_input_tokens: int,
64
+ total_output_tokens: int,
65
+ cache_hits: int,
66
+ cache_writes: int,
67
+ total_tokens: int
68
+ ) -> None:
69
+ """Display usage metrics using prompt_toolkit containers."""
70
+ # Format values with proper formatting
71
+ cost_str = f'${total_cost:.6f}'
72
+ input_tokens_str = f'{total_input_tokens:,}'
73
+ cache_read_str = f'{cache_hits:,}'
74
+ cache_write_str = f'{cache_writes:,}'
75
+ output_tokens_str = f'{total_output_tokens:,}'
76
+ total_tokens_str = f'{total_tokens:,}'
77
+
78
+ labels_and_values = [
79
+ (' Total Cost (USD):', cost_str),
80
+ ('', ''),
81
+ (' Total Input Tokens:', input_tokens_str),
82
+ (' Cache Hits:', cache_read_str),
83
+ (' Cache Writes:', cache_write_str),
84
+ (' Total Output Tokens:', output_tokens_str),
85
+ ('', ''),
86
+ (' Total Tokens:', total_tokens_str),
87
+ ]
88
+
89
+ # Calculate max widths for alignment
90
+ max_label_width = max(len(label) for label, _ in labels_and_values)
91
+ max_value_width = max(len(value) for _, value in labels_and_values)
92
+
93
+ # Construct the summary text with aligned columns
94
+ summary_lines = [
95
+ f'{label:<{max_label_width}} {value:<{max_value_width}}'
96
+ for label, value in labels_and_values
97
+ ]
98
+ summary_text = '\n'.join(summary_lines)
99
+
100
+ container = Frame(
101
+ TextArea(
102
+ text=summary_text,
103
+ read_only=True,
104
+ wrap_lines=True,
105
+ ),
106
+ title='Usage Metrics',
107
+ )
108
+
109
+ print_container(container)