idun-agent-engine 0.4.0__py3-none-any.whl → 0.4.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.
@@ -13,9 +13,11 @@ from idun_platform_cli.tui.css.create_agent import CREATE_AGENT_CSS
13
13
  from idun_platform_cli.tui.schemas.create_agent import TUIAgentConfig
14
14
  from idun_platform_cli.tui.utils.config import ConfigManager
15
15
  from idun_platform_cli.tui.widgets import (
16
+ ChatWidget,
16
17
  GuardrailsWidget,
17
18
  IdentityWidget,
18
19
  MCPsWidget,
20
+ MemoryWidget,
19
21
  ObservabilityWidget,
20
22
  ServeWidget,
21
23
  )
@@ -32,10 +34,12 @@ class CreateAgentScreen(Screen):
32
34
  active_section = reactive("identity")
33
35
  nav_panes = [
34
36
  "nav-identity",
37
+ "nav-memory",
35
38
  "nav-observability",
36
39
  "nav-guardrails",
37
40
  "nav-mcps",
38
41
  "nav-serve",
42
+ "nav-chat",
39
43
  ]
40
44
  current_nav_index = 0
41
45
  focus_on_nav = True # Track if focus is on nav or content
@@ -48,6 +52,35 @@ class CreateAgentScreen(Screen):
48
52
  self.server_process = None
49
53
  self.server_running = False
50
54
 
55
+ def on_unmount(self) -> None:
56
+ if self.server_process:
57
+ import os
58
+ import signal
59
+ import subprocess
60
+
61
+ try:
62
+ pgid = os.getpgid(self.server_process.pid)
63
+ os.killpg(pgid, signal.SIGKILL)
64
+ except (ProcessLookupError, OSError, AttributeError, PermissionError):
65
+ try:
66
+ self.server_process.kill()
67
+ except:
68
+ pass
69
+
70
+ try:
71
+ config = self.config_manager.load_config()
72
+ if config:
73
+ port = config.get("server", {}).get("api", {}).get("port", 8008)
74
+ pids = subprocess.check_output(["lsof", "-ti", f":{port}"], text=True).strip().split("\n")
75
+ for pid in pids:
76
+ if pid:
77
+ try:
78
+ os.kill(int(pid), signal.SIGKILL)
79
+ except:
80
+ pass
81
+ except:
82
+ pass
83
+
51
84
  def watch_active_section(self, new_section: str) -> None:
52
85
  for section_id, widget in self.widgets_map.items():
53
86
  if section_id == new_section:
@@ -67,12 +100,20 @@ class CreateAgentScreen(Screen):
67
100
  severity="warning",
68
101
  )
69
102
 
103
+ if new_section == "chat":
104
+ config = self.config_manager.load_config()
105
+ chat_widget = self.widgets_map.get("chat")
106
+ if chat_widget and config:
107
+ chat_widget.load_config(config)
108
+
70
109
  for pane_id in [
71
110
  "nav-identity",
111
+ "nav-memory",
72
112
  "nav-observability",
73
113
  "nav-guardrails",
74
114
  "nav-mcps",
75
115
  "nav-serve",
116
+ "nav-chat",
76
117
  ]:
77
118
  pane = self.query_one(f"#{pane_id}")
78
119
  if pane_id == f"nav-{new_section}":
@@ -101,6 +142,18 @@ class CreateAgentScreen(Screen):
101
142
  nav_identity.can_focus = True
102
143
  yield nav_identity
103
144
 
145
+ nav_memory = Vertical(
146
+ Label(
147
+ "Configure agent\ncheckpointing",
148
+ id="nav-memory-label",
149
+ ),
150
+ classes="nav-pane",
151
+ id="nav-memory",
152
+ )
153
+ nav_memory.border_title = "Memory"
154
+ nav_memory.can_focus = True
155
+ yield nav_memory
156
+
104
157
  nav_observability = Vertical(
105
158
  Label(
106
159
  "Setup monitoring\nand tracing",
@@ -140,6 +193,15 @@ class CreateAgentScreen(Screen):
140
193
  nav_serve.can_focus = True
141
194
  yield nav_serve
142
195
 
196
+ nav_chat = Vertical(
197
+ Label("Chat with your\nrunning agent"),
198
+ classes="nav-pane",
199
+ id="nav-chat",
200
+ )
201
+ nav_chat.border_title = "Chat"
202
+ nav_chat.can_focus = True
203
+ yield nav_chat
204
+
143
205
  with Horizontal(classes="action-buttons"):
144
206
  yield Button("Back", id="back_button", classes="action-btn")
145
207
  yield Button("Next", id="next_button", classes="action-btn")
@@ -147,6 +209,9 @@ class CreateAgentScreen(Screen):
147
209
  with Vertical(classes="content-area"):
148
210
  identity = IdentityWidget(id="widget-identity", classes="section")
149
211
 
212
+ memory = MemoryWidget(id="widget-memory", classes="section")
213
+ memory.border_title = "Memory & Checkpointing"
214
+
150
215
  observability = ObservabilityWidget(
151
216
  id="widget-observability", classes="section"
152
217
  )
@@ -161,26 +226,35 @@ class CreateAgentScreen(Screen):
161
226
  serve = ServeWidget(id="widget-serve", classes="section")
162
227
  serve.border_title = "Validate & Run"
163
228
 
229
+ chat = ChatWidget(id="widget-chat", classes="section")
230
+ chat.border_title = "Chat"
231
+
164
232
  self.widgets_map = {
165
233
  "identity": identity,
234
+ "memory": memory,
166
235
  "observability": observability,
167
236
  "guardrails": guardrails,
168
237
  "mcps": mcps,
169
238
  "serve": serve,
239
+ "chat": chat,
170
240
  }
171
241
 
242
+ memory.display = False
172
243
  observability.display = False
173
244
  guardrails.display = False
174
245
  mcps.display = False
175
246
  serve.display = False
247
+ chat.display = False
176
248
 
177
249
  yield identity
250
+ yield memory
178
251
  yield observability
179
252
  yield guardrails
180
253
  yield mcps
181
254
  yield serve
255
+ yield chat
182
256
 
183
- footer = Static("💡 Press Next to save section", classes="custom-footer")
257
+ footer = Static("💡 Press Next to save section | Press Ctrl+Q to exit", classes="custom-footer")
184
258
  yield footer
185
259
 
186
260
  def on_mount(self) -> None:
@@ -201,7 +275,7 @@ class CreateAgentScreen(Screen):
201
275
  if active_widget:
202
276
  try:
203
277
  focusable = active_widget.query(
204
- "Input, OptionList, DirectoryTree, Button"
278
+ "Input, OptionList, DirectoryTree, Button, RadioSet"
205
279
  ).first()
206
280
  if focusable:
207
281
  focusable.focus()
@@ -288,6 +362,8 @@ class CreateAgentScreen(Screen):
288
362
  return
289
363
 
290
364
  if section == "identity":
365
+ if not widget.validate():
366
+ return
291
367
  data = widget.get_data()
292
368
  if data is None:
293
369
  self.notify("Please complete all required fields", severity="error")
@@ -307,13 +383,28 @@ class CreateAgentScreen(Screen):
307
383
  return
308
384
  self.validated_sections.add("identity")
309
385
  self._update_nav_checkmark("identity")
310
- except ValidationError as e:
386
+ except ValidationError:
311
387
  self.notify(
312
- f"Validation error: {str(e)}",
388
+ "Error validating Identity: make sure all fields are correct.",
313
389
  severity="error",
314
390
  )
315
391
  return
316
392
 
393
+ elif section == "memory":
394
+ data = widget.get_data()
395
+ if data is not None:
396
+ success, msg = self.config_manager.save_partial("memory", data)
397
+ if not success:
398
+ self.notify(
399
+ "Memory configuration is invalid", severity="error"
400
+ )
401
+ return
402
+ self.validated_sections.add("memory")
403
+ self._update_nav_checkmark("memory")
404
+ else:
405
+ self.notify("Please configure checkpoint settings", severity="error")
406
+ return
407
+
317
408
  elif section == "observability":
318
409
  data = widget.get_data()
319
410
  if data is None:
@@ -356,15 +447,14 @@ class CreateAgentScreen(Screen):
356
447
 
357
448
  validated_servers, msg = validate_mcp_servers(data)
358
449
  if validated_servers is None:
359
- error_msg = str(msg).replace("[", "").replace("]", "")
360
- self.notify(f"MCP validation failed: {error_msg[:200]}", severity="error")
450
+ self.notify("Error validating MCPs: make sure all fields are correct.", severity="error")
361
451
  return
362
452
 
363
453
  success, save_msg = self.config_manager.save_partial(
364
454
  "mcp_servers", validated_servers
365
455
  )
366
456
  if not success:
367
- self.notify(f"Failed to save: {save_msg}", severity="error")
457
+ self.notify("Error saving MCPs: make sure all fields are correct.", severity="error")
368
458
  return
369
459
 
370
460
  self.validated_sections.add("mcps")
@@ -378,19 +468,68 @@ class CreateAgentScreen(Screen):
378
468
  section = self.nav_panes[self.current_nav_index].replace("nav-", "")
379
469
  self.active_section = section
380
470
 
381
- elif event.button.id == "validate_run_button":
471
+ elif event.button.id == "save_exit_button":
472
+ if self.server_running and self.server_process:
473
+ self.notify("Kill Server before exiting", severity="warning")
474
+ return
475
+ self.notify("Configuration saved. Exiting...", severity="information")
476
+ self.app.exit()
477
+
478
+ elif event.button.id == "save_run_button":
382
479
  if self.server_running and self.server_process:
383
- self.server_process.terminate()
480
+ import os
481
+ import signal
482
+ import subprocess
483
+
484
+ rich_log = self.query_one("#server_logs", RichLog)
485
+ rich_log.write("\n[yellow]Stopping server...[/yellow]")
486
+
487
+ try:
488
+ pgid = os.getpgid(self.server_process.pid)
489
+ os.killpg(pgid, signal.SIGTERM)
490
+ try:
491
+ self.server_process.wait(timeout=3)
492
+ except subprocess.TimeoutExpired:
493
+ os.killpg(pgid, signal.SIGKILL)
494
+ self.server_process.wait(timeout=1)
495
+ except (ProcessLookupError, OSError, PermissionError):
496
+ try:
497
+ self.server_process.terminate()
498
+ self.server_process.wait(timeout=2)
499
+ except subprocess.TimeoutExpired:
500
+ self.server_process.kill()
501
+ self.server_process.wait(timeout=1)
502
+ except:
503
+ pass
504
+
505
+ config = self.config_manager.load_config()
506
+ if config:
507
+ port = config.get("server", {}).get("api", {}).get("port", 8008)
508
+ try:
509
+ subprocess.run(
510
+ ["lsof", "-ti", f":{port}"],
511
+ capture_output=True,
512
+ text=True,
513
+ check=True
514
+ )
515
+ pids = subprocess.check_output(["lsof", "-ti", f":{port}"], text=True).strip().split("\n")
516
+ for pid in pids:
517
+ if pid:
518
+ try:
519
+ os.kill(int(pid), signal.SIGKILL)
520
+ except:
521
+ pass
522
+ except:
523
+ pass
524
+
384
525
  self.server_process = None
385
526
  self.server_running = False
386
527
 
387
- button = self.query_one("#validate_run_button", Button)
388
- button.label = "Validate & Run"
528
+ button = self.query_one("#save_run_button", Button)
529
+ button.label = "Save and Run"
389
530
  button.remove_class("kill-mode")
390
531
 
391
- rich_log = self.query_one("#server_logs", RichLog)
392
532
  rich_log.write("\n[red]Server stopped[/red]")
393
-
394
533
  self.notify("Server stopped", severity="information")
395
534
  return
396
535
 
@@ -429,6 +568,7 @@ class CreateAgentScreen(Screen):
429
568
  stderr=subprocess.STDOUT,
430
569
  text=True,
431
570
  bufsize=1,
571
+ start_new_session=True,
432
572
  )
433
573
 
434
574
  fd = process.stdout.fileno()
@@ -438,14 +578,14 @@ class CreateAgentScreen(Screen):
438
578
  self.server_process = process
439
579
  self.server_running = True
440
580
 
441
- button = self.query_one("#validate_run_button", Button)
581
+ button = self.query_one("#save_run_button", Button)
442
582
  button.label = "Kill Server"
443
583
  button.add_class("kill-mode")
444
584
 
445
585
  self.run_worker(self._stream_logs(process), exclusive=True)
446
586
 
447
- except Exception as e:
448
- self.notify(f"Failed to start server: {e}", severity="error")
587
+ except Exception:
588
+ self.notify("Failed to start server. Check your configuration.", severity="error")
449
589
 
450
590
  async def _stream_logs(self, process) -> None:
451
591
  import asyncio
@@ -469,8 +609,8 @@ class CreateAgentScreen(Screen):
469
609
 
470
610
  self.server_running = False
471
611
  self.server_process = None
472
- button = self.query_one("#validate_run_button", Button)
473
- button.label = "Validate & Run"
612
+ button = self.query_one("#save_run_button", Button)
613
+ button.label = "Save and Run"
474
614
  button.remove_class("kill-mode")
475
615
  rich_log.write("\n[yellow]Server exited[/yellow]")
476
616
  break
@@ -7,7 +7,6 @@ from idun_agent_schema.engine.observability_v2 import ObservabilityConfig
7
7
  from pydantic import ValidationError
8
8
 
9
9
  from idun_platform_cli.tui.schemas.create_agent import (
10
- AGENT_SOURCE_KEY_MAPPING,
11
10
  TUIAgentConfig,
12
11
  )
13
12
 
@@ -128,6 +127,28 @@ class ConfigManager:
128
127
  existing_config["mcp_servers"] = mcp_servers_list
129
128
  else:
130
129
  existing_config["mcp_servers"] = data
130
+ elif section == "memory":
131
+ from idun_agent_schema.engine.langgraph import CheckpointConfig
132
+
133
+ if "agent" not in existing_config:
134
+ return False, "Agent configuration not found. Save identity first."
135
+
136
+ agent_type = existing_config.get("agent", {}).get("type")
137
+ if agent_type != "LANGGRAPH":
138
+ return (
139
+ True,
140
+ "Checkpoint configuration skipped for non-LANGGRAPH agents",
141
+ )
142
+
143
+ if isinstance(data, CheckpointConfig):
144
+ checkpoint_dict = data.model_dump(by_alias=False, mode="json")
145
+
146
+ if "config" not in existing_config["agent"]:
147
+ existing_config["agent"]["config"] = {}
148
+
149
+ existing_config["agent"]["config"]["checkpointer"] = checkpoint_dict
150
+ else:
151
+ return False, "Invalid checkpoint configuration type"
131
152
  else:
132
153
  existing_config[section] = data
133
154
 
@@ -16,14 +16,20 @@ def validate_guardrail(guardrail_id: str, config: dict) -> tuple[any, str]:
16
16
  case "bias_check":
17
17
  threshold = float(config.get("threshold", 0.5))
18
18
  validated = BiasCheckConfig(
19
- config_id=GuardrailConfigId.BIAS_CHECK, threshold=threshold
19
+ config_id=GuardrailConfigId.BIAS_CHECK,
20
+ api_key=config.get("api_key", ""),
21
+ reject_message=config.get("reject_message", "Bias detected"),
22
+ threshold=threshold
20
23
  )
21
24
  return validated, "ok"
22
25
 
23
26
  case "toxic_language":
24
27
  threshold = float(config.get("threshold", 0.5))
25
28
  validated = ToxicLanguageConfig(
26
- config_id=GuardrailConfigId.TOXIC_LANGUAGE, threshold=threshold
29
+ config_id=GuardrailConfigId.TOXIC_LANGUAGE,
30
+ api_key=config.get("api_key", ""),
31
+ reject_message=config.get("reject_message", "Toxic language detected"),
32
+ threshold=threshold
27
33
  )
28
34
  return validated, "ok"
29
35
 
@@ -35,6 +41,8 @@ def validate_guardrail(guardrail_id: str, config: dict) -> tuple[any, str]:
35
41
  ]
36
42
  validated = CompetitionCheckConfig(
37
43
  config_id=GuardrailConfigId.COMPETITION_CHECK,
44
+ api_key=config.get("api_key", ""),
45
+ reject_message=config.get("reject_message", "Competitor mentioned"),
38
46
  competitors=competitors,
39
47
  )
40
48
  return validated, "ok"
@@ -73,4 +81,8 @@ def validate_guardrail(guardrail_id: str, config: dict) -> tuple[any, str]:
73
81
  return None, f"Unknown guardrail type: {guardrail_id}"
74
82
 
75
83
  except Exception as e:
76
- return None, f"Validation error for {guardrail_id}: {str(e)}"
84
+ error_msg = str(e)
85
+ if len(error_msg) > 100:
86
+ error_msg = error_msg[:100] + "..."
87
+ error_msg = error_msg.replace("<", "").replace(">", "")
88
+ return None, f"Validation error for {guardrail_id}: {error_msg}"
@@ -1,15 +1,19 @@
1
1
  """Widget components for the agent configuration screens."""
2
2
 
3
- from .identity_widget import IdentityWidget
4
- from .observability_widget import ObservabilityWidget
3
+ from .chat_widget import ChatWidget
5
4
  from .guardrails_widget import GuardrailsWidget
5
+ from .identity_widget import IdentityWidget
6
6
  from .mcps_widget import MCPsWidget
7
+ from .memory_widget import MemoryWidget
8
+ from .observability_widget import ObservabilityWidget
7
9
  from .serve_widget import ServeWidget
8
10
 
9
11
  __all__ = [
10
- "IdentityWidget",
11
- "ObservabilityWidget",
12
+ "ChatWidget",
12
13
  "GuardrailsWidget",
14
+ "IdentityWidget",
13
15
  "MCPsWidget",
16
+ "MemoryWidget",
17
+ "ObservabilityWidget",
14
18
  "ServeWidget",
15
19
  ]
@@ -0,0 +1,153 @@
1
+ """Chat widget for interacting with running agent."""
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Horizontal, Vertical
5
+ from textual.reactive import reactive
6
+ from textual.widget import Widget
7
+ from textual.widgets import Button, Input, Label, LoadingIndicator, RichLog
8
+
9
+
10
+ class ChatWidget(Widget):
11
+ server_running = reactive(False)
12
+
13
+ def __init__(self, *args, **kwargs):
14
+ super().__init__(*args, **kwargs)
15
+ self.config_data = {}
16
+ self.server_port = None
17
+ self.agent_name = ""
18
+
19
+ def compose(self) -> ComposeResult:
20
+ chat_container = Vertical(classes="chat-history-container")
21
+ chat_container.border_title = "Conversation"
22
+ with chat_container:
23
+ yield RichLog(id="chat_history", highlight=True, markup=True, wrap=True)
24
+
25
+ thinking_container = Horizontal(classes="chat-thinking-container", id="chat_thinking")
26
+ thinking_container.display = False
27
+ with thinking_container:
28
+ yield LoadingIndicator(id="chat_spinner")
29
+ yield Label("Thinking...", id="thinking_label")
30
+
31
+ input_container = Horizontal(classes="chat-input-container")
32
+ with input_container:
33
+ yield Input(
34
+ placeholder="Type your message...",
35
+ id="chat_input",
36
+ classes="chat-input",
37
+ )
38
+ yield Button("Send", id="send_button", classes="send-btn")
39
+
40
+ def load_config(self, config: dict) -> None:
41
+ self.config_data = config
42
+ server_config = config.get("server", {})
43
+ api_config = server_config.get("api", {})
44
+ self.server_port = api_config.get("port", 8008)
45
+
46
+ agent_config = config.get("agent", {}).get("config", {})
47
+ self.agent_name = agent_config.get("name", "Agent")
48
+
49
+ self.run_worker(self._check_server_status())
50
+
51
+ def on_mount(self) -> None:
52
+ chat_log = self.query_one("#chat_history", RichLog)
53
+ chat_log.write("[dim]Start chatting with your agent...[/dim]")
54
+ chat_log.write(
55
+ "[dim]Make sure the agent server is running from the Serve page.[/dim]"
56
+ )
57
+
58
+ def on_button_pressed(self, event: Button.Pressed) -> None:
59
+ if event.button.id == "send_button":
60
+ self._handle_send()
61
+
62
+ def on_input_submitted(self, event: Input.Submitted) -> None:
63
+ if event.input.id == "chat_input":
64
+ self._handle_send()
65
+
66
+ def _handle_send(self) -> None:
67
+ input_widget = self.query_one("#chat_input", Input)
68
+ message = input_widget.value.strip()
69
+
70
+ if not message:
71
+ return
72
+
73
+ if not self.server_port:
74
+ self.app.notify("Server not configured", severity="error")
75
+ return
76
+
77
+ input_widget.value = ""
78
+
79
+ chat_log = self.query_one("#chat_history", RichLog)
80
+ chat_log.write(f"[cyan]You:[/cyan] {message}")
81
+
82
+ thinking_container = self.query_one("#chat_thinking")
83
+ thinking_container.display = True
84
+
85
+ self.run_worker(self._send_message(message))
86
+
87
+ async def _send_message(self, message: str) -> None:
88
+ import httpx
89
+
90
+ chat_log = self.query_one("#chat_history", RichLog)
91
+ thinking_container = self.query_one("#chat_thinking")
92
+
93
+ try:
94
+ url = f"http://localhost:{self.server_port}/agent/invoke"
95
+ async with httpx.AsyncClient(timeout=60.0) as client:
96
+ response = await client.post(
97
+ url, json={"session_id": "123", "query": message}
98
+ )
99
+ result = response.json()
100
+
101
+ agent_response = result.get(
102
+ "output", result.get("response", "No response")
103
+ )
104
+ thinking_container.display = False
105
+ chat_log.write(f"[green]{self.agent_name}:[/green] {agent_response}")
106
+
107
+ except httpx.ConnectError:
108
+ thinking_container.display = False
109
+ chat_log.write("[red]Error:[/red] Cannot connect to server. Is it running?")
110
+ self.app.notify(
111
+ "Server not reachable. Start it from the Serve page.", severity="error"
112
+ )
113
+ except httpx.TimeoutException:
114
+ thinking_container.display = False
115
+ chat_log.write("[red]Error:[/red] Request timed out")
116
+ self.app.notify("Request timed out", severity="error")
117
+ except Exception as e:
118
+ thinking_container.display = False
119
+ chat_log.write(f"[red]Error:[/red] Failed to send message: {e}")
120
+ self.app.notify(
121
+ "Failed to send message. Check server connection.", severity="error"
122
+ )
123
+
124
+ async def _check_server_status(self) -> None:
125
+ import httpx
126
+
127
+ if not self.server_port:
128
+ return
129
+
130
+ try:
131
+ url = f"http://localhost:{self.server_port}/health"
132
+ async with httpx.AsyncClient(timeout=2.0) as client:
133
+ response = await client.get(url)
134
+ self.server_running = response.status_code == 200
135
+
136
+ if self.server_running:
137
+ chat_log = self.query_one("#chat_history", RichLog)
138
+ chat_log.write(
139
+ f"[green]✓ Connected to server on port {self.server_port}[/green]"
140
+ )
141
+ except Exception:
142
+ self.server_running = False
143
+
144
+ def watch_server_running(self, is_running: bool) -> None:
145
+ input_widget = self.query_one("#chat_input", Input)
146
+ send_button = self.query_one("#send_button", Button)
147
+
148
+ if is_running:
149
+ input_widget.disabled = False
150
+ send_button.disabled = False
151
+ else:
152
+ input_widget.disabled = True
153
+ send_button.disabled = True
@@ -304,7 +304,15 @@ class GuardrailsWidget(Widget):
304
304
  if applies_to in ["output", "both"]:
305
305
  output_guardrails.append(validated_config)
306
306
 
307
- return GuardrailsV2(input=input_guardrails, output=output_guardrails)
307
+ try:
308
+ return GuardrailsV2(input=input_guardrails, output=output_guardrails)
309
+ except Exception:
310
+ self.app.notify(
311
+ "Error validating Guardrails: make sure all fields are correct.",
312
+ severity="error",
313
+ timeout=10
314
+ )
315
+ return None
308
316
 
309
317
  def _extract_config(self, guardrail_id: str) -> dict:
310
318
  config = {}