glaip-sdk 0.7.9__py3-none-any.whl → 0.7.10__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.
glaip_sdk/agents/base.py CHANGED
@@ -54,6 +54,7 @@ from glaip_sdk.utils.resource_refs import is_uuid
54
54
 
55
55
  if TYPE_CHECKING:
56
56
  from glaip_sdk.client.schedules import AgentScheduleManager
57
+ from glaip_sdk.guardrails import GuardrailManager
57
58
  from glaip_sdk.models import AgentResponse
58
59
  from glaip_sdk.registry import AgentRegistry, MCPRegistry, ToolRegistry
59
60
 
@@ -130,6 +131,7 @@ class Agent:
130
131
  agents: list | None = None,
131
132
  mcps: list | None = None,
132
133
  model: str | None = _UNSET, # type: ignore[assignment]
134
+ guardrail: GuardrailManager | None = None,
133
135
  _client: Any = None,
134
136
  **kwargs: Any,
135
137
  ) -> None:
@@ -147,7 +149,9 @@ class Agent:
147
149
  agents: List of sub-agents (Agent classes, instances, or strings).
148
150
  mcps: List of MCPs.
149
151
  model: Model identifier.
152
+ guardrail: The guardrail manager for content safety.
150
153
  _client: Internal client reference (set automatically).
154
+
151
155
  **kwargs: Additional configuration parameters:
152
156
  - timeout: Execution timeout in seconds.
153
157
  - metadata: Optional metadata dictionary.
@@ -173,6 +177,7 @@ class Agent:
173
177
  self._agents = agents
174
178
  self._mcps = mcps
175
179
  self._model = model
180
+ self._guardrail = guardrail
176
181
  self._language_model_id: str | None = None
177
182
  # Extract parameters from kwargs with _UNSET defaults
178
183
  self._timeout = kwargs.pop("timeout", Agent._UNSET) # type: ignore[assignment]
@@ -452,6 +457,11 @@ class Agent:
452
457
  return self._mcp_configs
453
458
  return None
454
459
 
460
+ @property
461
+ def guardrail(self) -> GuardrailManager | None:
462
+ """The guardrail manager for content safety."""
463
+ return self._guardrail
464
+
455
465
  @property
456
466
  def a2a_profile(self) -> dict[str, Any] | None:
457
467
  """A2A (Agent-to-Agent) profile configuration.
@@ -547,11 +557,20 @@ class Agent:
547
557
 
548
558
  # Handle agent_config with timeout
549
559
  # The timeout property is a convenience that maps to agent_config.execution_timeout
550
- agent_config = dict(self.agent_config) if self.agent_config else {}
560
+ raw_config = self.agent_config if self.agent_config is not self._UNSET else {}
561
+ agent_config = dict(raw_config) if raw_config else {}
562
+
551
563
  if self.timeout and "execution_timeout" not in agent_config:
552
564
  agent_config["execution_timeout"] = self.timeout
553
- if agent_config:
554
- config["agent_config"] = agent_config
565
+
566
+ if self.guardrail:
567
+ from glaip_sdk.guardrails.serializer import ( # noqa: PLC0415
568
+ serialize_guardrail_manager,
569
+ )
570
+
571
+ agent_config["guardrails"] = serialize_guardrail_manager(self.guardrail)
572
+
573
+ config["agent_config"] = agent_config
555
574
 
556
575
  # Handle tool_configs - resolve tool names/classes to IDs
557
576
  if self.tool_configs:
@@ -583,11 +602,20 @@ class Agent:
583
602
 
584
603
  Returns:
585
604
  List of resolved MCP IDs for the API payload.
605
+
606
+ Raises:
607
+ ValueError: If an MCP fails to resolve to a valid ID.
586
608
  """
587
609
  if not self.mcps:
588
610
  return []
589
611
 
590
- return [registry.resolve(mcp_ref).id for mcp_ref in self.mcps]
612
+ resolved_ids: list[str] = []
613
+ for mcp_ref in self.mcps:
614
+ mcp = registry.resolve(mcp_ref)
615
+ if not mcp.id:
616
+ raise ValueError(f"Failed to resolve ID for MCP: {mcp_ref}")
617
+ resolved_ids.append(mcp.id)
618
+ return resolved_ids
591
619
 
592
620
  def _resolve_tools(self, registry: ToolRegistry) -> list[str]:
593
621
  """Resolve tool references to IDs using ToolRegistry.
@@ -601,12 +629,20 @@ class Agent:
601
629
 
602
630
  Returns:
603
631
  List of resolved tool IDs for the API payload.
632
+
633
+ Raises:
634
+ ValueError: If a tool fails to resolve to a valid ID.
604
635
  """
605
636
  if not self.tools:
606
637
  return []
607
638
 
608
- # Resolve each tool reference to a Tool object, extract ID
609
- return [registry.resolve(tool_ref).id for tool_ref in self.tools]
639
+ resolved_ids: list[str] = []
640
+ for tool_ref in self.tools:
641
+ tool = registry.resolve(tool_ref)
642
+ if not tool.id:
643
+ raise ValueError(f"Failed to resolve ID for tool: {tool_ref}")
644
+ resolved_ids.append(tool.id)
645
+ return resolved_ids
610
646
 
611
647
  def _resolve_tool_configs(self, registry: ToolRegistry) -> dict[str, Any]:
612
648
  """Resolve tool_configs keys from tool names/classes to tool IDs.
@@ -649,6 +685,8 @@ class Agent:
649
685
  try:
650
686
  # Resolve key (tool name/class) to Tool object, get ID
651
687
  tool = registry.resolve(key)
688
+ if not tool.id:
689
+ raise ValueError(f"Resolved tool has no ID: {key}")
652
690
  resolved[tool.id] = config
653
691
  except (ValueError, KeyError) as e:
654
692
  raise ValueError(f"Failed to resolve tool config key: {key}") from e
@@ -684,6 +722,8 @@ class Agent:
684
722
  resolved_id = key
685
723
  else:
686
724
  mcp = registry.resolve(key)
725
+ if not mcp.id:
726
+ raise ValueError(f"Resolved MCP has no ID: {key}")
687
727
  resolved_id = mcp.id
688
728
 
689
729
  if resolved_id in resolved:
@@ -699,7 +739,7 @@ class Agent:
699
739
 
700
740
  return resolved
701
741
 
702
- def _resolve_agents(self, registry: AgentRegistry) -> list:
742
+ def _resolve_agents(self, registry: AgentRegistry) -> list[str]:
703
743
  """Resolve sub-agent references using AgentRegistry.
704
744
 
705
745
  Uses the global AgentRegistry to cache Agent objects across deployments.
@@ -711,12 +751,20 @@ class Agent:
711
751
 
712
752
  Returns:
713
753
  List of resolved agent IDs for the API payload.
754
+
755
+ Raises:
756
+ ValueError: If an agent fails to resolve to a valid ID.
714
757
  """
715
758
  if not self.agents:
716
759
  return []
717
760
 
718
- # Resolve each agent reference to a deployed Agent, extract ID
719
- return [registry.resolve(agent_ref).id for agent_ref in self.agents]
761
+ resolved_ids: list[str] = []
762
+ for agent_ref in self.agents:
763
+ agent = registry.resolve(agent_ref)
764
+ if not agent.id:
765
+ raise ValueError(f"Failed to resolve ID for agent: {agent_ref}")
766
+ resolved_ids.append(agent.id)
767
+ return resolved_ids
720
768
 
721
769
  def _create_or_update_agent(
722
770
  self,
@@ -807,6 +855,8 @@ class Agent:
807
855
  Returns:
808
856
  Dictionary containing Agent attributes.
809
857
  """
858
+ config = self._build_config(get_tool_registry(), get_mcp_registry())
859
+
810
860
  data = {
811
861
  "id": self._id,
812
862
  "name": self.name,
@@ -820,10 +870,11 @@ class Agent:
820
870
  "mcps": self.mcps,
821
871
  "timeout": self.timeout,
822
872
  "metadata": self.metadata,
823
- "agent_config": self.agent_config,
873
+ "agent_config": config.get("agent_config"),
824
874
  "tool_configs": self.tool_configs,
825
875
  "mcp_configs": self.mcp_configs,
826
876
  "a2a_profile": self.a2a_profile,
877
+ "guardrail": self.guardrail,
827
878
  "created_at": self._created_at,
828
879
  "updated_at": self._updated_at,
829
880
  }
@@ -294,12 +294,14 @@ class RemoteRunsController:
294
294
  fetch_detail=fetch_detail,
295
295
  export_run=export_run,
296
296
  )
297
+ tui_ctx = getattr(self.session, "tui_ctx", None)
297
298
  page, limit, cursor = run_remote_runs_textual(
298
299
  runs_page,
299
300
  state.get("cursor", 0),
300
301
  callbacks,
301
302
  agent_name=agent_name,
302
303
  agent_id=agent_id,
304
+ ctx=tui_ctx,
303
305
  )
304
306
  state["page"] = page
305
307
  state["limit"] = limit
@@ -3,6 +3,18 @@
3
3
  * Keep layout compact: filter sits tight above the table; header shows active account.
4
4
  */
5
5
 
6
+ Screen {
7
+ layers: base toasts;
8
+ }
9
+
10
+ #toast-container {
11
+ width: 100%;
12
+ height: auto;
13
+ dock: top;
14
+ align: right top;
15
+ layer: toasts;
16
+ }
17
+
6
18
  #header-info {
7
19
  padding: 0 1 0 1;
8
20
  margin: 0;
@@ -18,7 +18,7 @@ import asyncio
18
18
  import logging
19
19
  from collections.abc import Callable
20
20
  from dataclasses import dataclass
21
- from typing import Any
21
+ from typing import Any, cast
22
22
 
23
23
  from glaip_sdk.cli.account_store import AccountStore, AccountStoreError, get_account_store
24
24
  from glaip_sdk.cli.commands.common_config import check_connection_with_reason
@@ -34,7 +34,15 @@ from glaip_sdk.cli.slash.tui.keybind_registry import KeybindRegistry
34
34
  from glaip_sdk.cli.slash.tui.loading import hide_loading_indicator, show_loading_indicator
35
35
  from glaip_sdk.cli.slash.tui.terminal import TerminalCapabilities
36
36
  from glaip_sdk.cli.slash.tui.theme.catalog import _BUILTIN_THEMES
37
- from glaip_sdk.cli.slash.tui.toast import ToastBus
37
+
38
+ try: # pragma: no cover - optional dependency
39
+ from glaip_sdk.cli.slash.tui.toast import ClipboardToastMixin, Toast, ToastBus, ToastHandlerMixin, ToastVariant
40
+ except Exception: # pragma: no cover - optional dependency
41
+ ClipboardToastMixin = object # type: ignore[assignment, misc]
42
+ Toast = None # type: ignore[assignment]
43
+ ToastBus = None # type: ignore[assignment]
44
+ ToastHandlerMixin = object # type: ignore[assignment, misc]
45
+ ToastVariant = None # type: ignore[assignment]
38
46
  from glaip_sdk.cli.validators import validate_api_key
39
47
  from glaip_sdk.utils.validation import validate_url
40
48
 
@@ -450,7 +458,9 @@ class ConfirmDeleteModal(_ConfirmDeleteBase): # pragma: no cover - interactive
450
458
  self.dismiss(self._name)
451
459
 
452
460
 
453
- class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - interactive
461
+ class AccountsTextualApp( # pragma: no cover - interactive
462
+ ToastHandlerMixin, ClipboardToastMixin, BackgroundTaskMixin, _AppBase
463
+ ):
454
464
  """Textual application for browsing accounts."""
455
465
 
456
466
  CSS_PATH = CSS_FILE_NAME
@@ -494,7 +504,6 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
494
504
  self._ctx = ctx
495
505
  self._keybinds: KeybindRegistry | None = None
496
506
  self._toast_bus: ToastBus | None = None
497
- self._toast_ready = False
498
507
  self._clipboard: ClipboardAdapter | None = None
499
508
  self._filter_text: str = ""
500
509
  self._is_switching = False
@@ -526,6 +535,8 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
526
535
  main.styles.height = "1fr"
527
536
  main.styles.padding = (0, 0)
528
537
  yield main
538
+ if Toast is not None:
539
+ yield Container(Toast(), id="toast-container")
529
540
  yield Horizontal(
530
541
  LoadingIndicator(id=ACCOUNTS_LOADING_ID.lstrip("#")),
531
542
  Static("", id=STATUS_ID.lstrip("#")),
@@ -555,11 +566,14 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
555
566
  self._register_keybinds()
556
567
 
557
568
  def _initialize_context_services(self) -> None:
569
+ def _notify(message: ToastBus.Changed) -> None:
570
+ self.post_message(message)
571
+
558
572
  if self._ctx:
559
573
  if self._ctx.keybinds is None:
560
574
  self._ctx.keybinds = KeybindRegistry()
561
- if self._ctx.toasts is None:
562
- self._ctx.toasts = ToastBus()
575
+ if self._ctx.toasts is None and ToastBus is not None:
576
+ self._ctx.toasts = ToastBus(on_change=_notify)
563
577
  if self._ctx.clipboard is None:
564
578
  self._ctx.clipboard = ClipboardAdapter(terminal=self._ctx.terminal)
565
579
  self._keybinds = self._ctx.keybinds
@@ -571,10 +585,11 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
571
585
  tty=True, ansi=True, osc52=False, osc11_bg=None, mouse=False, truecolor=False
572
586
  )
573
587
  self._clipboard = ClipboardAdapter(terminal=terminal)
588
+ if ToastBus is not None:
589
+ self._toast_bus = ToastBus(on_change=_notify)
574
590
 
575
591
  def _prepare_toasts(self) -> None:
576
- """Prepare toast system by marking ready and clearing any existing toasts."""
577
- self._toast_ready = True
592
+ """Prepare toast system by clearing any existing toasts."""
578
593
  if self._toast_bus:
579
594
  self._toast_bus.clear()
580
595
 
@@ -768,7 +783,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
768
783
  self._hide_loading()
769
784
  self._is_switching = False
770
785
  error_msg = f"Switch failed to start: {exc}"
771
- if self._toast_ready and self._toast_bus:
786
+ if self._toast_bus:
772
787
  self._toast_bus.show(message=error_msg, variant="error")
773
788
  try:
774
789
  self._set_status(error_msg, "red")
@@ -800,7 +815,7 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
800
815
  if switched:
801
816
  self._active_account = name
802
817
  status_msg = message or f"Switched to '{name}'."
803
- if self._toast_ready and self._toast_bus:
818
+ if self._toast_bus:
804
819
  self._toast_bus.show(message=status_msg, variant="success")
805
820
  self._update_header()
806
821
  self._reload_rows()
@@ -913,34 +928,54 @@ class AccountsTextualApp(BackgroundTaskMixin, _AppBase): # pragma: no cover - i
913
928
  return
914
929
 
915
930
  text = f"Account: {name}\nURL: {account.get('api_url', '')}"
916
- adapter = self._clipboard or ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
917
- # OSC 52 works by writing to stdout, no custom writer needed
918
- try:
919
- asyncio.get_running_loop()
920
- except RuntimeError:
931
+ adapter = self._clipboard_adapter()
932
+ writer = self._osc52_writer()
933
+ if writer:
934
+ result = adapter.copy(text, writer=writer)
935
+ else:
921
936
  result = adapter.copy(text)
922
- self._handle_copy_result(name, result)
923
- return
924
-
925
- async def perform() -> None:
926
- result = await asyncio.to_thread(adapter.copy, text)
927
- self._handle_copy_result(name, result)
928
-
929
- self.track_task(perform(), logger=logging.getLogger(__name__))
937
+ self._handle_copy_result(name, result)
930
938
 
931
939
  def _handle_copy_result(self, name: str, result: ClipboardResult) -> None:
932
940
  """Update UI state after a copy attempt."""
933
941
  if result.success:
934
- if self._toast_ready and self._toast_bus:
935
- self._toast_bus.copy_success(label=name)
936
- # Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
942
+ if self._toast_bus:
943
+ self._toast_bus.copy_success(f"Account '{name}'")
937
944
  self._set_status(f"Copied '{name}' to clipboard.", "green")
938
945
  else:
939
- if self._toast_ready and self._toast_bus:
940
- self._toast_bus.show(message=f"Copy failed: {result.message}", variant="warning")
941
- # Status fallback until toast widget is implemented (see specs/workflow/tui-toast-system/spec.md Phase 2)
946
+ if self._toast_bus and ToastVariant is not None:
947
+ self._toast_bus.show(message=f"Copy failed: {result.message}", variant=ToastVariant.WARNING)
942
948
  self._set_status(f"Copy failed: {result.message}", "red")
943
949
 
950
+ def _clipboard_adapter(self) -> ClipboardAdapter:
951
+ if self._ctx is not None and self._ctx.clipboard is not None:
952
+ return cast(ClipboardAdapter, self._ctx.clipboard)
953
+ if self._clipboard is not None:
954
+ return self._clipboard
955
+ adapter = ClipboardAdapter(terminal=self._ctx.terminal if self._ctx else None)
956
+ if self._ctx is not None:
957
+ self._ctx.clipboard = adapter
958
+ else:
959
+ self._clipboard = adapter
960
+ return adapter
961
+
962
+ def _osc52_writer(self) -> Callable[[str], Any] | None:
963
+ try:
964
+ console = getattr(self, "console", None)
965
+ except Exception:
966
+ return None
967
+ if console is None:
968
+ return None
969
+ output = getattr(console, "file", None)
970
+ if output is None:
971
+ return None
972
+
973
+ def _write(sequence: str, _output=output) -> None:
974
+ _output.write(sequence)
975
+ _output.flush()
976
+
977
+ return _write
978
+
944
979
  def _check_env_lock_hotkey(self) -> bool:
945
980
  """Prevent mutations when env credentials are present."""
946
981
  if not self._is_env_locked():
@@ -45,6 +45,36 @@ _SUBPROCESS_COMMANDS: dict[ClipboardMethod, list[str]] = {
45
45
  ClipboardMethod.CLIP: ["clip"],
46
46
  }
47
47
 
48
+ _ENV_CLIPBOARD_METHOD = "AIP_TUI_CLIPBOARD_METHOD"
49
+ _ENV_CLIPBOARD_FORCE = "AIP_TUI_CLIPBOARD_FORCE"
50
+ _ENV_METHOD_MAP = {
51
+ "osc52": ClipboardMethod.OSC52,
52
+ "pbcopy": ClipboardMethod.PBCOPY,
53
+ "xclip": ClipboardMethod.XCLIP,
54
+ "xsel": ClipboardMethod.XSEL,
55
+ "wl-copy": ClipboardMethod.WL_COPY,
56
+ "wl_copy": ClipboardMethod.WL_COPY,
57
+ "clip": ClipboardMethod.CLIP,
58
+ "none": ClipboardMethod.NONE,
59
+ }
60
+
61
+
62
+ def _resolve_env_method() -> ClipboardMethod | None:
63
+ raw = os.getenv(_ENV_CLIPBOARD_METHOD)
64
+ if not raw:
65
+ return None
66
+ value = raw.strip().lower()
67
+ if value in ("auto", "default"):
68
+ return None
69
+ return _ENV_METHOD_MAP.get(value)
70
+
71
+
72
+ def _is_env_force_enabled() -> bool:
73
+ raw = os.getenv(_ENV_CLIPBOARD_FORCE)
74
+ if not raw:
75
+ return False
76
+ return raw.strip().lower() in {"1", "true", "yes", "on"}
77
+
48
78
 
49
79
  class ClipboardAdapter:
50
80
  """Cross-platform clipboard access with OSC 52 fallback."""
@@ -57,7 +87,16 @@ class ClipboardAdapter:
57
87
  ) -> None:
58
88
  """Initialize the adapter."""
59
89
  self._terminal = terminal
60
- self._method = method or self._detect_method()
90
+ self._force_method = False
91
+ if method is not None:
92
+ self._method = method
93
+ else:
94
+ env_method = _resolve_env_method()
95
+ if env_method is not None:
96
+ self._method = env_method
97
+ self._force_method = _is_env_force_enabled()
98
+ else:
99
+ self._method = self._detect_method()
61
100
 
62
101
  @property
63
102
  def method(self) -> ClipboardMethod:
@@ -77,25 +116,34 @@ class ClipboardAdapter:
77
116
 
78
117
  command = _SUBPROCESS_COMMANDS.get(self._method)
79
118
  if command is None:
119
+ if self._force_method:
120
+ return ClipboardResult(False, self._method, "Forced clipboard method unavailable.")
80
121
  return self._copy_osc52(text, writer=writer)
81
122
 
82
123
  result = self._copy_subprocess(command, text)
83
124
  if not result.success:
125
+ if self._force_method:
126
+ return result
84
127
  return self._copy_osc52(text, writer=writer)
85
128
 
86
129
  return result
87
130
 
88
131
  def _detect_method(self) -> ClipboardMethod:
132
+ system = platform.system()
133
+ method = ClipboardMethod.NONE
134
+ if system == "Darwin":
135
+ method = self._detect_darwin_method()
136
+ elif system == "Linux":
137
+ method = self._detect_linux_method()
138
+ elif system == "Windows":
139
+ method = self._detect_windows_method()
140
+
141
+ if method is not ClipboardMethod.NONE:
142
+ return method
143
+
89
144
  if self._terminal.osc52 if self._terminal else detect_osc52_support():
90
145
  return ClipboardMethod.OSC52
91
146
 
92
- system = platform.system()
93
- if system == "Darwin":
94
- return self._detect_darwin_method()
95
- if system == "Linux":
96
- return self._detect_linux_method()
97
- if system == "Windows":
98
- return self._detect_windows_method()
99
147
  return ClipboardMethod.NONE
100
148
 
101
149
  def _detect_darwin_method(self) -> ClipboardMethod: