fast-agent-mcp 0.2.36__py3-none-any.whl → 0.2.38__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 fast-agent-mcp might be problematic. Click here for more details.
- {fast_agent_mcp-0.2.36.dist-info → fast_agent_mcp-0.2.38.dist-info}/METADATA +10 -7
- {fast_agent_mcp-0.2.36.dist-info → fast_agent_mcp-0.2.38.dist-info}/RECORD +45 -47
- {fast_agent_mcp-0.2.36.dist-info → fast_agent_mcp-0.2.38.dist-info}/licenses/LICENSE +1 -1
- mcp_agent/cli/commands/quickstart.py +60 -5
- mcp_agent/config.py +10 -0
- mcp_agent/context.py +1 -4
- mcp_agent/core/agent_types.py +7 -6
- mcp_agent/core/direct_decorators.py +14 -0
- mcp_agent/core/direct_factory.py +1 -0
- mcp_agent/core/fastagent.py +23 -2
- mcp_agent/human_input/elicitation_form.py +723 -0
- mcp_agent/human_input/elicitation_forms.py +59 -0
- mcp_agent/human_input/elicitation_handler.py +88 -0
- mcp_agent/human_input/elicitation_state.py +34 -0
- mcp_agent/llm/providers/augmented_llm_google_native.py +4 -2
- mcp_agent/llm/providers/augmented_llm_openai.py +1 -1
- mcp_agent/mcp/elicitation_factory.py +84 -0
- mcp_agent/mcp/elicitation_handlers.py +155 -0
- mcp_agent/mcp/helpers/content_helpers.py +27 -0
- mcp_agent/mcp/helpers/server_config_helpers.py +10 -8
- mcp_agent/mcp/mcp_agent_client_session.py +44 -1
- mcp_agent/mcp/mcp_aggregator.py +56 -11
- mcp_agent/mcp/mcp_connection_manager.py +30 -18
- mcp_agent/mcp_server/agent_server.py +2 -0
- mcp_agent/mcp_server_registry.py +16 -8
- mcp_agent/resources/examples/data-analysis/analysis.py +1 -2
- mcp_agent/resources/examples/mcp/elicitations/elicitation_account_server.py +88 -0
- mcp_agent/resources/examples/mcp/elicitations/elicitation_forms_server.py +232 -0
- mcp_agent/resources/examples/mcp/elicitations/elicitation_game_server.py +164 -0
- mcp_agent/resources/examples/mcp/elicitations/fastagent.config.yaml +35 -0
- mcp_agent/resources/examples/mcp/elicitations/fastagent.secrets.yaml.example +17 -0
- mcp_agent/resources/examples/mcp/elicitations/forms_demo.py +111 -0
- mcp_agent/resources/examples/mcp/elicitations/game_character.py +65 -0
- mcp_agent/resources/examples/mcp/elicitations/game_character_handler.py +256 -0
- mcp_agent/resources/examples/{prompting/agent.py → mcp/elicitations/tool_call.py} +4 -5
- mcp_agent/resources/examples/mcp/state-transfer/agent_two.py +1 -1
- mcp_agent/resources/examples/mcp/state-transfer/fastagent.config.yaml +1 -1
- mcp_agent/resources/examples/mcp/state-transfer/fastagent.secrets.yaml.example +1 -0
- mcp_agent/resources/examples/workflows/evaluator.py +1 -1
- mcp_agent/resources/examples/workflows/graded_report.md +89 -0
- mcp_agent/resources/examples/workflows/orchestrator.py +7 -9
- mcp_agent/resources/examples/workflows/parallel.py +0 -2
- mcp_agent/resources/examples/workflows/short_story.md +13 -0
- mcp_agent/resources/examples/in_dev/agent_build.py +0 -84
- mcp_agent/resources/examples/in_dev/css-LICENSE.txt +0 -21
- mcp_agent/resources/examples/in_dev/slides.py +0 -110
- mcp_agent/resources/examples/internal/agent.py +0 -20
- mcp_agent/resources/examples/internal/fastagent.config.yaml +0 -66
- mcp_agent/resources/examples/internal/history_transfer.py +0 -35
- mcp_agent/resources/examples/internal/job.py +0 -84
- mcp_agent/resources/examples/internal/prompt_category.py +0 -21
- mcp_agent/resources/examples/internal/prompt_sizing.py +0 -51
- mcp_agent/resources/examples/internal/simple.txt +0 -2
- mcp_agent/resources/examples/internal/sizer.py +0 -20
- mcp_agent/resources/examples/internal/social.py +0 -67
- mcp_agent/resources/examples/prompting/__init__.py +0 -3
- mcp_agent/resources/examples/prompting/delimited_prompt.txt +0 -14
- mcp_agent/resources/examples/prompting/fastagent.config.yaml +0 -43
- mcp_agent/resources/examples/prompting/image_server.py +0 -52
- mcp_agent/resources/examples/prompting/prompt1.txt +0 -6
- mcp_agent/resources/examples/prompting/work_with_image.py +0 -19
- {fast_agent_mcp-0.2.36.dist-info → fast_agent_mcp-0.2.38.dist-info}/WHEEL +0 -0
- {fast_agent_mcp-0.2.36.dist-info → fast_agent_mcp-0.2.38.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,723 @@
|
|
|
1
|
+
"""Simplified, robust elicitation form dialog."""
|
|
2
|
+
|
|
3
|
+
from datetime import date, datetime
|
|
4
|
+
from typing import Any, Dict, Optional
|
|
5
|
+
|
|
6
|
+
from mcp.types import ElicitRequestedSchema
|
|
7
|
+
from prompt_toolkit import Application
|
|
8
|
+
from prompt_toolkit.buffer import Buffer
|
|
9
|
+
from prompt_toolkit.filters import Condition
|
|
10
|
+
from prompt_toolkit.formatted_text import FormattedText
|
|
11
|
+
from prompt_toolkit.key_binding import KeyBindings
|
|
12
|
+
from prompt_toolkit.key_binding.bindings.focus import focus_next, focus_previous
|
|
13
|
+
from prompt_toolkit.layout import HSplit, Layout, ScrollablePane, VSplit, Window
|
|
14
|
+
from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
|
|
15
|
+
from prompt_toolkit.validation import ValidationError, Validator
|
|
16
|
+
from prompt_toolkit.widgets import (
|
|
17
|
+
Button,
|
|
18
|
+
Checkbox,
|
|
19
|
+
Dialog,
|
|
20
|
+
Frame,
|
|
21
|
+
Label,
|
|
22
|
+
RadioList,
|
|
23
|
+
)
|
|
24
|
+
from pydantic import AnyUrl, EmailStr
|
|
25
|
+
from pydantic import ValidationError as PydanticValidationError
|
|
26
|
+
|
|
27
|
+
from mcp_agent.human_input.elicitation_forms import ELICITATION_STYLE
|
|
28
|
+
from mcp_agent.human_input.elicitation_state import elicitation_state
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SimpleNumberValidator(Validator):
|
|
32
|
+
"""Simple number validator with real-time feedback."""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self, field_type: str, minimum: Optional[float] = None, maximum: Optional[float] = None
|
|
36
|
+
):
|
|
37
|
+
self.field_type = field_type
|
|
38
|
+
self.minimum = minimum
|
|
39
|
+
self.maximum = maximum
|
|
40
|
+
|
|
41
|
+
def validate(self, document):
|
|
42
|
+
text = document.text.strip()
|
|
43
|
+
if not text:
|
|
44
|
+
return # Empty is OK for optional fields
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
if self.field_type == "integer":
|
|
48
|
+
value = int(text)
|
|
49
|
+
else:
|
|
50
|
+
value = float(text)
|
|
51
|
+
|
|
52
|
+
if self.minimum is not None and value < self.minimum:
|
|
53
|
+
raise ValidationError(
|
|
54
|
+
message=f"Must be ≥ {self.minimum}", cursor_position=len(text)
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if self.maximum is not None and value > self.maximum:
|
|
58
|
+
raise ValidationError(
|
|
59
|
+
message=f"Must be ≤ {self.maximum}", cursor_position=len(text)
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
except ValueError:
|
|
63
|
+
raise ValidationError(message=f"Invalid {self.field_type}", cursor_position=len(text))
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SimpleStringValidator(Validator):
|
|
67
|
+
"""Simple string validator with real-time feedback."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, min_length: Optional[int] = None, max_length: Optional[int] = None):
|
|
70
|
+
self.min_length = min_length
|
|
71
|
+
self.max_length = max_length
|
|
72
|
+
|
|
73
|
+
def validate(self, document):
|
|
74
|
+
text = document.text
|
|
75
|
+
if not text:
|
|
76
|
+
return # Empty is OK for optional fields
|
|
77
|
+
|
|
78
|
+
if self.min_length is not None and len(text) < self.min_length:
|
|
79
|
+
raise ValidationError(
|
|
80
|
+
message=f"Need {self.min_length - len(text)} more chars", cursor_position=len(text)
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
if self.max_length is not None and len(text) > self.max_length:
|
|
84
|
+
raise ValidationError(
|
|
85
|
+
message=f"Too long by {len(text) - self.max_length} chars",
|
|
86
|
+
cursor_position=self.max_length,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class FormatValidator(Validator):
|
|
91
|
+
"""Format-specific validator using Pydantic validators."""
|
|
92
|
+
|
|
93
|
+
def __init__(self, format_type: str):
|
|
94
|
+
self.format_type = format_type
|
|
95
|
+
|
|
96
|
+
def validate(self, document):
|
|
97
|
+
text = document.text.strip()
|
|
98
|
+
if not text:
|
|
99
|
+
return # Empty is OK for optional fields
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
if self.format_type == "email":
|
|
103
|
+
# Use Pydantic model validation for email
|
|
104
|
+
from pydantic import BaseModel
|
|
105
|
+
|
|
106
|
+
class EmailModel(BaseModel):
|
|
107
|
+
email: EmailStr
|
|
108
|
+
|
|
109
|
+
EmailModel(email=text)
|
|
110
|
+
elif self.format_type == "uri":
|
|
111
|
+
# Use Pydantic model validation for URI
|
|
112
|
+
from pydantic import BaseModel
|
|
113
|
+
|
|
114
|
+
class UriModel(BaseModel):
|
|
115
|
+
uri: AnyUrl
|
|
116
|
+
|
|
117
|
+
UriModel(uri=text)
|
|
118
|
+
elif self.format_type == "date":
|
|
119
|
+
# Validate ISO date format (YYYY-MM-DD)
|
|
120
|
+
date.fromisoformat(text)
|
|
121
|
+
elif self.format_type == "date-time":
|
|
122
|
+
# Validate ISO datetime format
|
|
123
|
+
datetime.fromisoformat(text.replace("Z", "+00:00"))
|
|
124
|
+
except (PydanticValidationError, ValueError):
|
|
125
|
+
# Extract readable error message
|
|
126
|
+
if self.format_type == "email":
|
|
127
|
+
message = "Invalid email format"
|
|
128
|
+
elif self.format_type == "uri":
|
|
129
|
+
message = "Invalid URI format"
|
|
130
|
+
elif self.format_type == "date":
|
|
131
|
+
message = "Invalid date (use YYYY-MM-DD)"
|
|
132
|
+
elif self.format_type == "date-time":
|
|
133
|
+
message = "Invalid datetime (use ISO 8601)"
|
|
134
|
+
else:
|
|
135
|
+
message = f"Invalid {self.format_type} format"
|
|
136
|
+
|
|
137
|
+
raise ValidationError(message=message, cursor_position=len(text))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ElicitationForm:
|
|
141
|
+
"""Simplified elicitation form with all fields visible."""
|
|
142
|
+
|
|
143
|
+
def __init__(
|
|
144
|
+
self, schema: ElicitRequestedSchema, message: str, agent_name: str, server_name: str
|
|
145
|
+
):
|
|
146
|
+
self.schema = schema
|
|
147
|
+
self.message = message
|
|
148
|
+
self.agent_name = agent_name
|
|
149
|
+
self.server_name = server_name
|
|
150
|
+
|
|
151
|
+
# Parse schema
|
|
152
|
+
self.properties = schema.get("properties", {})
|
|
153
|
+
self.required_fields = schema.get("required", [])
|
|
154
|
+
|
|
155
|
+
# Field storage
|
|
156
|
+
self.field_widgets = {}
|
|
157
|
+
self.multiline_fields = set() # Track which fields are multiline
|
|
158
|
+
|
|
159
|
+
# Result
|
|
160
|
+
self.result = None
|
|
161
|
+
self.action = "cancel"
|
|
162
|
+
|
|
163
|
+
# Build form
|
|
164
|
+
self._build_form()
|
|
165
|
+
|
|
166
|
+
def _build_form(self):
|
|
167
|
+
"""Build the form layout."""
|
|
168
|
+
|
|
169
|
+
# Fast-agent provided data (Agent and MCP Server) - aligned labels
|
|
170
|
+
fastagent_info = FormattedText(
|
|
171
|
+
[
|
|
172
|
+
("class:label", "Agent: "),
|
|
173
|
+
("class:agent-name", self.agent_name),
|
|
174
|
+
("class:label", "\nMCP Server: "),
|
|
175
|
+
("class:server-name", self.server_name),
|
|
176
|
+
]
|
|
177
|
+
)
|
|
178
|
+
fastagent_header = Window(
|
|
179
|
+
FormattedTextControl(fastagent_info),
|
|
180
|
+
height=2, # Just agent and server lines
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# MCP Server provided message
|
|
184
|
+
mcp_message = FormattedText([("class:message", self.message)])
|
|
185
|
+
mcp_header = Window(
|
|
186
|
+
FormattedTextControl(mcp_message),
|
|
187
|
+
height=len(self.message.split("\n")),
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# Create form fields - removed useless horizontal divider
|
|
191
|
+
form_fields = [
|
|
192
|
+
fastagent_header, # Fast-agent info
|
|
193
|
+
Window(height=1), # Spacing
|
|
194
|
+
mcp_header, # MCP server message
|
|
195
|
+
Window(height=1), # Spacing
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
for field_name, field_def in self.properties.items():
|
|
199
|
+
field_widget = self._create_field(field_name, field_def)
|
|
200
|
+
if field_widget:
|
|
201
|
+
form_fields.append(field_widget)
|
|
202
|
+
form_fields.append(Window(height=1)) # Spacing
|
|
203
|
+
|
|
204
|
+
# Status line for error display (disabled ValidationToolbar to avoid confusion)
|
|
205
|
+
self.status_control = FormattedTextControl(text="")
|
|
206
|
+
self.status_line = Window(
|
|
207
|
+
self.status_control, height=1
|
|
208
|
+
) # Store reference for later clearing
|
|
209
|
+
|
|
210
|
+
# Buttons - ensure they accept focus
|
|
211
|
+
submit_btn = Button("Accept", handler=self._accept)
|
|
212
|
+
cancel_btn = Button("Cancel", handler=self._cancel)
|
|
213
|
+
decline_btn = Button("Decline", handler=self._decline)
|
|
214
|
+
cancel_all_btn = Button("Cancel All", handler=self._cancel_all)
|
|
215
|
+
|
|
216
|
+
# Store button references for focus debugging
|
|
217
|
+
self.buttons = [submit_btn, decline_btn, cancel_btn, cancel_all_btn]
|
|
218
|
+
|
|
219
|
+
buttons = VSplit(
|
|
220
|
+
[
|
|
221
|
+
submit_btn,
|
|
222
|
+
Window(width=2),
|
|
223
|
+
decline_btn,
|
|
224
|
+
Window(width=2),
|
|
225
|
+
cancel_btn,
|
|
226
|
+
Window(width=2),
|
|
227
|
+
cancel_all_btn,
|
|
228
|
+
]
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Main layout
|
|
232
|
+
form_fields.extend([self.status_line, buttons])
|
|
233
|
+
content = HSplit(form_fields)
|
|
234
|
+
|
|
235
|
+
# Add padding around content using HSplit and VSplit with empty windows
|
|
236
|
+
padded_content = HSplit(
|
|
237
|
+
[
|
|
238
|
+
Window(height=1), # Top padding
|
|
239
|
+
VSplit(
|
|
240
|
+
[
|
|
241
|
+
Window(width=2), # Left padding
|
|
242
|
+
content,
|
|
243
|
+
Window(width=2), # Right padding
|
|
244
|
+
]
|
|
245
|
+
),
|
|
246
|
+
Window(height=1), # Bottom padding
|
|
247
|
+
]
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
# Wrap content in ScrollablePane to handle oversized forms
|
|
251
|
+
scrollable_content = ScrollablePane(
|
|
252
|
+
content=padded_content,
|
|
253
|
+
show_scrollbar=False, # Only show when content exceeds available space
|
|
254
|
+
display_arrows=False, # Only show when content exceeds available space
|
|
255
|
+
keep_cursor_visible=True,
|
|
256
|
+
keep_focused_window_visible=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Dialog - formatted title with better styling and text
|
|
260
|
+
dialog = Dialog(
|
|
261
|
+
title=FormattedText([("class:title", "Elicitation Request")]),
|
|
262
|
+
body=scrollable_content,
|
|
263
|
+
with_background=True, # Re-enable background for proper layout
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
# Key bindings
|
|
267
|
+
kb = KeyBindings()
|
|
268
|
+
|
|
269
|
+
@kb.add("tab")
|
|
270
|
+
def focus_next_with_refresh(event):
|
|
271
|
+
focus_next(event)
|
|
272
|
+
|
|
273
|
+
@kb.add("s-tab")
|
|
274
|
+
def focus_previous_with_refresh(event):
|
|
275
|
+
focus_previous(event)
|
|
276
|
+
|
|
277
|
+
# Arrow key navigation - let radio lists handle up/down first
|
|
278
|
+
@kb.add("down")
|
|
279
|
+
def focus_next_arrow(event):
|
|
280
|
+
focus_next(event)
|
|
281
|
+
|
|
282
|
+
@kb.add("up")
|
|
283
|
+
def focus_previous_arrow(event):
|
|
284
|
+
focus_previous(event)
|
|
285
|
+
|
|
286
|
+
@kb.add("right", eager=True)
|
|
287
|
+
def focus_next_right(event):
|
|
288
|
+
focus_next(event)
|
|
289
|
+
|
|
290
|
+
@kb.add("left", eager=True)
|
|
291
|
+
def focus_previous_left(event):
|
|
292
|
+
focus_previous(event)
|
|
293
|
+
|
|
294
|
+
# Create filter for non-multiline fields
|
|
295
|
+
not_in_multiline = Condition(lambda: not self._is_in_multiline_field())
|
|
296
|
+
|
|
297
|
+
@kb.add("c-m", filter=not_in_multiline) # Enter to submit only when not in multiline
|
|
298
|
+
def submit(event):
|
|
299
|
+
self._accept()
|
|
300
|
+
|
|
301
|
+
@kb.add("c-j") # Ctrl+J as alternative submit for multiline fields
|
|
302
|
+
def submit_alt(event):
|
|
303
|
+
self._accept()
|
|
304
|
+
|
|
305
|
+
@kb.add("escape")
|
|
306
|
+
def cancel(event):
|
|
307
|
+
self._cancel()
|
|
308
|
+
|
|
309
|
+
# Create a root layout with the dialog and bottom toolbar
|
|
310
|
+
def get_toolbar():
|
|
311
|
+
# When clearing, return empty to hide the toolbar completely
|
|
312
|
+
if hasattr(self, "_toolbar_hidden") and self._toolbar_hidden:
|
|
313
|
+
return FormattedText([])
|
|
314
|
+
|
|
315
|
+
return FormattedText(
|
|
316
|
+
[
|
|
317
|
+
(
|
|
318
|
+
"class:bottom-toolbar.text",
|
|
319
|
+
" <TAB> or ↑↓→← to navigate. <ENTER> submit (<Ctrl+J> in multiline). <ESC> to cancel. ",
|
|
320
|
+
),
|
|
321
|
+
(
|
|
322
|
+
"class:bottom-toolbar.text",
|
|
323
|
+
"<Cancel All> Auto-Cancel further elicitations from this Server.",
|
|
324
|
+
),
|
|
325
|
+
]
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
# Store toolbar function reference for later control
|
|
329
|
+
self._get_toolbar = get_toolbar
|
|
330
|
+
self._dialog = dialog
|
|
331
|
+
|
|
332
|
+
# Create toolbar window that we can reference later
|
|
333
|
+
self._toolbar_window = Window(
|
|
334
|
+
FormattedTextControl(get_toolbar), height=1, style="class:bottom-toolbar"
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
# Add toolbar to the layout
|
|
338
|
+
root_layout = HSplit(
|
|
339
|
+
[
|
|
340
|
+
dialog, # The main dialog
|
|
341
|
+
self._toolbar_window,
|
|
342
|
+
]
|
|
343
|
+
)
|
|
344
|
+
self._root_layout = root_layout
|
|
345
|
+
|
|
346
|
+
# Application with toolbar and validation - ensure our styles override defaults
|
|
347
|
+
self.app = Application(
|
|
348
|
+
layout=Layout(root_layout),
|
|
349
|
+
key_bindings=kb,
|
|
350
|
+
full_screen=False, # Back to windowed mode for better integration
|
|
351
|
+
mouse_support=False,
|
|
352
|
+
style=ELICITATION_STYLE,
|
|
353
|
+
include_default_pygments_style=False, # Use only our custom style
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Set initial focus to first form field
|
|
357
|
+
def set_initial_focus():
|
|
358
|
+
try:
|
|
359
|
+
# Find first form field to focus on
|
|
360
|
+
first_field = None
|
|
361
|
+
for field_name in self.properties.keys():
|
|
362
|
+
widget = self.field_widgets.get(field_name)
|
|
363
|
+
if widget:
|
|
364
|
+
first_field = widget
|
|
365
|
+
break
|
|
366
|
+
|
|
367
|
+
if first_field:
|
|
368
|
+
self.app.layout.focus(first_field)
|
|
369
|
+
else:
|
|
370
|
+
# Fallback to first button if no fields
|
|
371
|
+
self.app.layout.focus(submit_btn)
|
|
372
|
+
except Exception:
|
|
373
|
+
pass # If focus fails, continue without it
|
|
374
|
+
|
|
375
|
+
# Schedule focus setting for after layout is ready
|
|
376
|
+
self.app.invalidate() # Ensure layout is built
|
|
377
|
+
set_initial_focus()
|
|
378
|
+
|
|
379
|
+
def _extract_string_constraints(self, field_def: Dict[str, Any]) -> Dict[str, Any]:
|
|
380
|
+
"""Extract string constraints from field definition, handling anyOf schemas."""
|
|
381
|
+
constraints = {}
|
|
382
|
+
|
|
383
|
+
# Check direct constraints
|
|
384
|
+
if field_def.get("minLength") is not None:
|
|
385
|
+
constraints["minLength"] = field_def["minLength"]
|
|
386
|
+
if field_def.get("maxLength") is not None:
|
|
387
|
+
constraints["maxLength"] = field_def["maxLength"]
|
|
388
|
+
|
|
389
|
+
# Check anyOf constraints (for Optional fields)
|
|
390
|
+
if "anyOf" in field_def:
|
|
391
|
+
for variant in field_def["anyOf"]:
|
|
392
|
+
if variant.get("type") == "string":
|
|
393
|
+
if variant.get("minLength") is not None:
|
|
394
|
+
constraints["minLength"] = variant["minLength"]
|
|
395
|
+
if variant.get("maxLength") is not None:
|
|
396
|
+
constraints["maxLength"] = variant["maxLength"]
|
|
397
|
+
break
|
|
398
|
+
|
|
399
|
+
return constraints
|
|
400
|
+
|
|
401
|
+
def _create_field(self, field_name: str, field_def: Dict[str, Any]):
|
|
402
|
+
"""Create a field widget."""
|
|
403
|
+
|
|
404
|
+
field_type = field_def.get("type", "string")
|
|
405
|
+
title = field_def.get("title", field_name)
|
|
406
|
+
description = field_def.get("description", "")
|
|
407
|
+
is_required = field_name in self.required_fields
|
|
408
|
+
|
|
409
|
+
# Build label with validation hints
|
|
410
|
+
label_text = title
|
|
411
|
+
if is_required:
|
|
412
|
+
label_text += " *"
|
|
413
|
+
if description:
|
|
414
|
+
label_text += f" - {description}"
|
|
415
|
+
|
|
416
|
+
# Add validation hints (simple ones stay on same line)
|
|
417
|
+
hints = []
|
|
418
|
+
format_hint = None
|
|
419
|
+
|
|
420
|
+
if field_type == "string":
|
|
421
|
+
constraints = self._extract_string_constraints(field_def)
|
|
422
|
+
if constraints.get("minLength"):
|
|
423
|
+
hints.append(f"min {constraints['minLength']} chars")
|
|
424
|
+
if constraints.get("maxLength"):
|
|
425
|
+
hints.append(f"max {constraints['maxLength']} chars")
|
|
426
|
+
|
|
427
|
+
# Handle format hints separately (these go on next line)
|
|
428
|
+
format_type = field_def.get("format")
|
|
429
|
+
if format_type:
|
|
430
|
+
format_info = {
|
|
431
|
+
"email": ("Email", "user@example.com"),
|
|
432
|
+
"uri": ("URI", "https://example.com"),
|
|
433
|
+
"date": ("Date", "YYYY-MM-DD"),
|
|
434
|
+
"date-time": ("Date Time", "YYYY-MM-DD HH:MM:SS"),
|
|
435
|
+
}
|
|
436
|
+
if format_type in format_info:
|
|
437
|
+
friendly_name, example = format_info[format_type]
|
|
438
|
+
format_hint = f"{friendly_name}: {example}"
|
|
439
|
+
else:
|
|
440
|
+
format_hint = format_type
|
|
441
|
+
|
|
442
|
+
elif field_type in ["number", "integer"]:
|
|
443
|
+
if field_def.get("minimum") is not None:
|
|
444
|
+
hints.append(f"min {field_def['minimum']}")
|
|
445
|
+
if field_def.get("maximum") is not None:
|
|
446
|
+
hints.append(f"max {field_def['maximum']}")
|
|
447
|
+
elif field_type == "string" and "enum" in field_def:
|
|
448
|
+
enum_names = field_def.get("enumNames", field_def["enum"])
|
|
449
|
+
hints.append(f"choose from: {', '.join(enum_names)}")
|
|
450
|
+
|
|
451
|
+
# Add simple hints to main label line
|
|
452
|
+
if hints:
|
|
453
|
+
label_text += f" ({', '.join(hints)})"
|
|
454
|
+
|
|
455
|
+
# Create multiline label if we have format hints
|
|
456
|
+
if format_hint:
|
|
457
|
+
label_lines = [label_text, f" → {format_hint}"]
|
|
458
|
+
label = Label(text="\n".join(label_lines))
|
|
459
|
+
else:
|
|
460
|
+
label = Label(text=label_text)
|
|
461
|
+
|
|
462
|
+
# Create input widget based on type
|
|
463
|
+
if field_type == "boolean":
|
|
464
|
+
default = field_def.get("default", False)
|
|
465
|
+
checkbox = Checkbox(text="Yes")
|
|
466
|
+
checkbox.checked = default
|
|
467
|
+
self.field_widgets[field_name] = checkbox
|
|
468
|
+
|
|
469
|
+
return HSplit([label, Frame(checkbox)])
|
|
470
|
+
|
|
471
|
+
elif field_type == "string" and "enum" in field_def:
|
|
472
|
+
enum_values = field_def["enum"]
|
|
473
|
+
enum_names = field_def.get("enumNames", enum_values)
|
|
474
|
+
values = [(val, name) for val, name in zip(enum_values, enum_names)]
|
|
475
|
+
|
|
476
|
+
radio_list = RadioList(values=values)
|
|
477
|
+
self.field_widgets[field_name] = radio_list
|
|
478
|
+
|
|
479
|
+
return HSplit([label, Frame(radio_list, height=min(len(values) + 2, 6))])
|
|
480
|
+
|
|
481
|
+
else:
|
|
482
|
+
# Text/number input
|
|
483
|
+
validator = None
|
|
484
|
+
|
|
485
|
+
if field_type in ["number", "integer"]:
|
|
486
|
+
validator = SimpleNumberValidator(
|
|
487
|
+
field_type=field_type,
|
|
488
|
+
minimum=field_def.get("minimum"),
|
|
489
|
+
maximum=field_def.get("maximum"),
|
|
490
|
+
)
|
|
491
|
+
elif field_type == "string":
|
|
492
|
+
constraints = self._extract_string_constraints(field_def)
|
|
493
|
+
format_type = field_def.get("format")
|
|
494
|
+
|
|
495
|
+
if format_type in ["email", "uri", "date", "date-time"]:
|
|
496
|
+
# Use format validator for specific formats
|
|
497
|
+
validator = FormatValidator(format_type)
|
|
498
|
+
else:
|
|
499
|
+
# Use string length validator for regular strings
|
|
500
|
+
validator = SimpleStringValidator(
|
|
501
|
+
min_length=constraints.get("minLength"),
|
|
502
|
+
max_length=constraints.get("maxLength"),
|
|
503
|
+
)
|
|
504
|
+
else:
|
|
505
|
+
constraints = {}
|
|
506
|
+
|
|
507
|
+
# Determine if field should be multiline based on max_length
|
|
508
|
+
if field_type == "string":
|
|
509
|
+
max_length = constraints.get("maxLength")
|
|
510
|
+
else:
|
|
511
|
+
max_length = None
|
|
512
|
+
if max_length and max_length > 100:
|
|
513
|
+
# Use multiline for longer fields
|
|
514
|
+
multiline = True
|
|
515
|
+
self.multiline_fields.add(field_name) # Track multiline fields
|
|
516
|
+
if max_length <= 300:
|
|
517
|
+
height = 3
|
|
518
|
+
else:
|
|
519
|
+
height = 5
|
|
520
|
+
else:
|
|
521
|
+
# Single line for shorter fields
|
|
522
|
+
multiline = False
|
|
523
|
+
height = 1
|
|
524
|
+
|
|
525
|
+
buffer = Buffer(
|
|
526
|
+
validator=validator,
|
|
527
|
+
multiline=multiline,
|
|
528
|
+
validate_while_typing=True, # Enable real-time validation
|
|
529
|
+
complete_while_typing=False, # Disable completion for cleaner experience
|
|
530
|
+
enable_history_search=False, # Disable history for cleaner experience
|
|
531
|
+
)
|
|
532
|
+
self.field_widgets[field_name] = buffer
|
|
533
|
+
|
|
534
|
+
# Create dynamic style function for focus highlighting and validation errors
|
|
535
|
+
def get_field_style():
|
|
536
|
+
"""Dynamic style that changes based on focus and validation state."""
|
|
537
|
+
from prompt_toolkit.application.current import get_app
|
|
538
|
+
|
|
539
|
+
# Check if buffer has validation errors
|
|
540
|
+
if buffer.validation_error:
|
|
541
|
+
return "class:input-field.error"
|
|
542
|
+
elif get_app().layout.has_focus(buffer):
|
|
543
|
+
return "class:input-field.focused"
|
|
544
|
+
else:
|
|
545
|
+
return "class:input-field"
|
|
546
|
+
|
|
547
|
+
text_input = Window(
|
|
548
|
+
BufferControl(buffer=buffer),
|
|
549
|
+
height=height,
|
|
550
|
+
style=get_field_style, # Use dynamic style function
|
|
551
|
+
wrap_lines=True if multiline else False, # Enable word wrap for multiline
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
return HSplit([label, Frame(text_input)])
|
|
555
|
+
|
|
556
|
+
def _is_in_multiline_field(self) -> bool:
|
|
557
|
+
"""Check if currently focused field is a multiline field."""
|
|
558
|
+
from prompt_toolkit.application.current import get_app
|
|
559
|
+
|
|
560
|
+
focused = get_app().layout.current_control
|
|
561
|
+
|
|
562
|
+
# Find which field this control belongs to
|
|
563
|
+
# Only Buffer widgets can be multiline, so only check those
|
|
564
|
+
for field_name, widget in self.field_widgets.items():
|
|
565
|
+
if (
|
|
566
|
+
isinstance(widget, Buffer)
|
|
567
|
+
and hasattr(focused, "buffer")
|
|
568
|
+
and widget == focused.buffer
|
|
569
|
+
):
|
|
570
|
+
return field_name in self.multiline_fields
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
def _validate_form(self) -> tuple[bool, Optional[str]]:
|
|
574
|
+
"""Validate the entire form."""
|
|
575
|
+
|
|
576
|
+
# First, check all fields for validation errors from their validators
|
|
577
|
+
for field_name, field_def in self.properties.items():
|
|
578
|
+
widget = self.field_widgets.get(field_name)
|
|
579
|
+
if widget is None:
|
|
580
|
+
continue
|
|
581
|
+
|
|
582
|
+
# Check for validation errors from validators
|
|
583
|
+
if isinstance(widget, Buffer):
|
|
584
|
+
if widget.validation_error:
|
|
585
|
+
title = field_def.get("title", field_name)
|
|
586
|
+
return False, f"'{title}': {widget.validation_error.message}"
|
|
587
|
+
|
|
588
|
+
# Then check if required fields are empty
|
|
589
|
+
for field_name in self.required_fields:
|
|
590
|
+
widget = self.field_widgets.get(field_name)
|
|
591
|
+
if widget is None:
|
|
592
|
+
continue
|
|
593
|
+
|
|
594
|
+
# Check if required field has value
|
|
595
|
+
if isinstance(widget, Buffer):
|
|
596
|
+
if not widget.text.strip():
|
|
597
|
+
title = self.properties[field_name].get("title", field_name)
|
|
598
|
+
return False, f"'{title}' is required"
|
|
599
|
+
elif isinstance(widget, RadioList):
|
|
600
|
+
if widget.current_value is None:
|
|
601
|
+
title = self.properties[field_name].get("title", field_name)
|
|
602
|
+
return False, f"'{title}' is required"
|
|
603
|
+
|
|
604
|
+
return True, None
|
|
605
|
+
|
|
606
|
+
def _get_form_data(self) -> Dict[str, Any]:
|
|
607
|
+
"""Extract data from form fields."""
|
|
608
|
+
data = {}
|
|
609
|
+
|
|
610
|
+
for field_name, field_def in self.properties.items():
|
|
611
|
+
widget = self.field_widgets.get(field_name)
|
|
612
|
+
if widget is None:
|
|
613
|
+
continue
|
|
614
|
+
|
|
615
|
+
field_type = field_def.get("type", "string")
|
|
616
|
+
|
|
617
|
+
if isinstance(widget, Buffer):
|
|
618
|
+
value = widget.text.strip()
|
|
619
|
+
if value:
|
|
620
|
+
if field_type == "integer":
|
|
621
|
+
try:
|
|
622
|
+
data[field_name] = int(value)
|
|
623
|
+
except ValueError:
|
|
624
|
+
# This should not happen due to validation, but be safe
|
|
625
|
+
raise ValueError(f"Invalid integer value for {field_name}: {value}")
|
|
626
|
+
elif field_type == "number":
|
|
627
|
+
try:
|
|
628
|
+
data[field_name] = float(value)
|
|
629
|
+
except ValueError:
|
|
630
|
+
# This should not happen due to validation, but be safe
|
|
631
|
+
raise ValueError(f"Invalid number value for {field_name}: {value}")
|
|
632
|
+
else:
|
|
633
|
+
data[field_name] = value
|
|
634
|
+
elif field_name not in self.required_fields:
|
|
635
|
+
data[field_name] = None
|
|
636
|
+
|
|
637
|
+
elif isinstance(widget, Checkbox):
|
|
638
|
+
data[field_name] = widget.checked
|
|
639
|
+
|
|
640
|
+
elif isinstance(widget, RadioList):
|
|
641
|
+
if widget.current_value is not None:
|
|
642
|
+
data[field_name] = widget.current_value
|
|
643
|
+
|
|
644
|
+
return data
|
|
645
|
+
|
|
646
|
+
def _accept(self):
|
|
647
|
+
"""Handle form submission."""
|
|
648
|
+
# Validate
|
|
649
|
+
is_valid, error_msg = self._validate_form()
|
|
650
|
+
if not is_valid:
|
|
651
|
+
# Use styled error message
|
|
652
|
+
self.status_control.text = FormattedText(
|
|
653
|
+
[("class:validation-error", f"Error: {error_msg}")]
|
|
654
|
+
)
|
|
655
|
+
return
|
|
656
|
+
|
|
657
|
+
# Get data
|
|
658
|
+
try:
|
|
659
|
+
self.result = self._get_form_data()
|
|
660
|
+
self.action = "accept"
|
|
661
|
+
self._clear_status_bar()
|
|
662
|
+
self.app.exit()
|
|
663
|
+
except Exception as e:
|
|
664
|
+
# Use styled error message
|
|
665
|
+
self.status_control.text = FormattedText(
|
|
666
|
+
[("class:validation-error", f"Error: {str(e)}")]
|
|
667
|
+
)
|
|
668
|
+
|
|
669
|
+
def _cancel(self):
|
|
670
|
+
"""Handle cancel."""
|
|
671
|
+
self.action = "cancel"
|
|
672
|
+
self._clear_status_bar()
|
|
673
|
+
self.app.exit()
|
|
674
|
+
|
|
675
|
+
def _decline(self):
|
|
676
|
+
"""Handle decline."""
|
|
677
|
+
self.action = "decline"
|
|
678
|
+
self._clear_status_bar()
|
|
679
|
+
self.app.exit()
|
|
680
|
+
|
|
681
|
+
def _cancel_all(self):
|
|
682
|
+
"""Handle cancel all - cancels and disables future elicitations."""
|
|
683
|
+
elicitation_state.disable_server(self.server_name)
|
|
684
|
+
self.action = "disable"
|
|
685
|
+
self._clear_status_bar()
|
|
686
|
+
self.app.exit()
|
|
687
|
+
|
|
688
|
+
def _clear_status_bar(self):
|
|
689
|
+
"""Hide the status bar by removing it from the layout."""
|
|
690
|
+
# Create completely clean layout - just empty space with application background
|
|
691
|
+
from prompt_toolkit.layout import HSplit, Window
|
|
692
|
+
from prompt_toolkit.layout.controls import FormattedTextControl
|
|
693
|
+
|
|
694
|
+
# Create a simple empty window with application background
|
|
695
|
+
empty_window = Window(
|
|
696
|
+
FormattedTextControl(FormattedText([("class:application", "")])), height=1
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
# Replace entire layout with just the empty window
|
|
700
|
+
new_layout = HSplit([empty_window])
|
|
701
|
+
|
|
702
|
+
# Update the app's layout
|
|
703
|
+
if hasattr(self, "app") and self.app:
|
|
704
|
+
self.app.layout.container = new_layout
|
|
705
|
+
self.app.invalidate()
|
|
706
|
+
|
|
707
|
+
async def run_async(self) -> tuple[str, Optional[Dict[str, Any]]]:
|
|
708
|
+
"""Run the form and return result."""
|
|
709
|
+
try:
|
|
710
|
+
await self.app.run_async()
|
|
711
|
+
except Exception as e:
|
|
712
|
+
print(f"Form error: {e}")
|
|
713
|
+
self.action = "cancel"
|
|
714
|
+
self._clear_status_bar()
|
|
715
|
+
return self.action, self.result
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
async def show_simple_elicitation_form(
|
|
719
|
+
schema: ElicitRequestedSchema, message: str, agent_name: str, server_name: str
|
|
720
|
+
) -> tuple[str, Optional[Dict[str, Any]]]:
|
|
721
|
+
"""Show the simplified elicitation form."""
|
|
722
|
+
form = ElicitationForm(schema, message, agent_name, server_name)
|
|
723
|
+
return await form.run_async()
|