glaip-sdk 0.7.9__py3-none-any.whl → 0.7.11__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 +61 -10
- glaip_sdk/branding.py +113 -2
- glaip_sdk/cli/slash/remote_runs_controller.py +2 -0
- glaip_sdk/cli/slash/session.py +331 -30
- glaip_sdk/cli/slash/tui/accounts.tcss +72 -0
- glaip_sdk/cli/slash/tui/accounts_app.py +827 -101
- glaip_sdk/cli/slash/tui/clipboard.py +56 -8
- glaip_sdk/cli/slash/tui/context.py +5 -2
- glaip_sdk/cli/slash/tui/layouts/__init__.py +14 -0
- glaip_sdk/cli/slash/tui/layouts/harlequin.py +160 -0
- glaip_sdk/cli/slash/tui/remote_runs_app.py +119 -12
- glaip_sdk/cli/slash/tui/terminal.py +8 -3
- glaip_sdk/cli/slash/tui/toast.py +270 -19
- glaip_sdk/client/run_rendering.py +76 -29
- glaip_sdk/guardrails/__init__.py +80 -0
- glaip_sdk/guardrails/serializer.py +89 -0
- glaip_sdk/payload_schemas/agent.py +1 -0
- glaip_sdk/payload_schemas/guardrails.py +34 -0
- glaip_sdk/runner/langgraph.py +1 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/METADATA +3 -1
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/RECORD +24 -19
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/WHEEL +0 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/entry_points.txt +0 -0
- {glaip_sdk-0.7.9.dist-info → glaip_sdk-0.7.11.dist-info}/top_level.txt +0 -0
glaip_sdk/cli/slash/tui/toast.py
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
"""Toast
|
|
2
|
-
|
|
3
|
-
Authors:
|
|
4
|
-
Raymond Christopher (raymond.christopher@gdplabs.id)
|
|
5
|
-
"""
|
|
1
|
+
"""Toast widgets and state management for the TUI."""
|
|
6
2
|
|
|
7
3
|
from __future__ import annotations
|
|
8
4
|
|
|
9
5
|
import asyncio
|
|
6
|
+
from collections.abc import Callable
|
|
10
7
|
from dataclasses import dataclass
|
|
11
8
|
from enum import Enum
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widgets import Static
|
|
12
14
|
|
|
13
15
|
|
|
14
16
|
class ToastVariant(str, Enum):
|
|
15
|
-
"""Toast message variant."""
|
|
17
|
+
"""Toast message variant for styling and behavior."""
|
|
16
18
|
|
|
17
19
|
INFO = "info"
|
|
18
20
|
SUCCESS = "success"
|
|
@@ -30,7 +32,7 @@ DEFAULT_TOAST_DURATIONS_SECONDS: dict[ToastVariant, float] = {
|
|
|
30
32
|
|
|
31
33
|
@dataclass(frozen=True, slots=True)
|
|
32
34
|
class ToastState:
|
|
33
|
-
"""Immutable toast
|
|
35
|
+
"""Immutable toast notification state."""
|
|
34
36
|
|
|
35
37
|
message: str
|
|
36
38
|
variant: ToastVariant
|
|
@@ -38,16 +40,25 @@ class ToastState:
|
|
|
38
40
|
|
|
39
41
|
|
|
40
42
|
class ToastBus:
|
|
41
|
-
"""
|
|
43
|
+
"""Toast state manager with auto-dismiss functionality."""
|
|
42
44
|
|
|
43
|
-
|
|
44
|
-
"""
|
|
45
|
+
class Changed(Message):
|
|
46
|
+
"""Message sent when toast state changes."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, state: ToastState | None) -> None:
|
|
49
|
+
"""Initialize the changed message with new toast state."""
|
|
50
|
+
super().__init__()
|
|
51
|
+
self.state = state
|
|
52
|
+
|
|
53
|
+
def __init__(self, on_change: Callable[[ToastBus.Changed], None] | None = None) -> None:
|
|
54
|
+
"""Initialize the toast bus with optional change callback."""
|
|
45
55
|
self._state: ToastState | None = None
|
|
46
56
|
self._dismiss_task: asyncio.Task[None] | None = None
|
|
57
|
+
self._on_change = on_change
|
|
47
58
|
|
|
48
59
|
@property
|
|
49
60
|
def state(self) -> ToastState | None:
|
|
50
|
-
"""Return the current toast state."""
|
|
61
|
+
"""Return the current toast state, or None if no toast is shown."""
|
|
51
62
|
return self._state
|
|
52
63
|
|
|
53
64
|
def show(
|
|
@@ -57,7 +68,14 @@ class ToastBus:
|
|
|
57
68
|
*,
|
|
58
69
|
duration_seconds: float | None = None,
|
|
59
70
|
) -> None:
|
|
60
|
-
"""
|
|
71
|
+
"""Show a toast notification with the given message and variant.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
message: The message to display in the toast.
|
|
75
|
+
variant: The visual variant of the toast (INFO, SUCCESS, WARNING, ERROR).
|
|
76
|
+
duration_seconds: Optional custom duration in seconds. If None, uses default
|
|
77
|
+
duration for the variant (2s for SUCCESS, 3s for INFO/WARNING, 5s for ERROR).
|
|
78
|
+
"""
|
|
61
79
|
resolved_variant = self._coerce_variant(variant)
|
|
62
80
|
resolved_duration = (
|
|
63
81
|
DEFAULT_TOAST_DURATIONS_SECONDS[resolved_variant] if duration_seconds is None else float(duration_seconds)
|
|
@@ -80,23 +98,26 @@ class ToastBus:
|
|
|
80
98
|
) from None
|
|
81
99
|
|
|
82
100
|
self._dismiss_task = loop.create_task(self._auto_dismiss(resolved_duration))
|
|
101
|
+
self._notify_changed()
|
|
83
102
|
|
|
84
103
|
def clear(self) -> None:
|
|
85
|
-
"""Clear the current toast."""
|
|
104
|
+
"""Clear the current toast notification immediately."""
|
|
86
105
|
self._cancel_dismiss_task()
|
|
87
106
|
self._state = None
|
|
107
|
+
self._notify_changed()
|
|
88
108
|
|
|
89
109
|
def copy_success(self, label: str | None = None) -> None:
|
|
90
|
-
"""Show
|
|
110
|
+
"""Show a success toast for clipboard copy operations.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
label: Optional label for what was copied (e.g., "Run ID", "JSON").
|
|
114
|
+
"""
|
|
91
115
|
message = "Copied to clipboard" if not label else f"Copied {label} to clipboard"
|
|
92
116
|
self.show(message=message, variant=ToastVariant.SUCCESS)
|
|
93
117
|
|
|
94
118
|
def copy_failed(self) -> None:
|
|
95
|
-
"""Show clipboard
|
|
96
|
-
self.show(
|
|
97
|
-
message="Clipboard unavailable. Text printed below",
|
|
98
|
-
variant=ToastVariant.WARNING,
|
|
99
|
-
)
|
|
119
|
+
"""Show a warning toast when clipboard copy fails."""
|
|
120
|
+
self.show(message="Clipboard unavailable. Text printed below.", variant=ToastVariant.WARNING)
|
|
100
121
|
|
|
101
122
|
def _coerce_variant(self, variant: ToastVariant | str) -> ToastVariant:
|
|
102
123
|
if isinstance(variant, ToastVariant):
|
|
@@ -121,3 +142,233 @@ class ToastBus:
|
|
|
121
142
|
|
|
122
143
|
self._state = None
|
|
123
144
|
self._dismiss_task = None
|
|
145
|
+
self._notify_changed()
|
|
146
|
+
|
|
147
|
+
def _notify_changed(self) -> None:
|
|
148
|
+
if self._on_change:
|
|
149
|
+
self._on_change(ToastBus.Changed(self._state))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class ToastHandlerMixin:
|
|
153
|
+
"""Mixin providing common toast handling functionality.
|
|
154
|
+
|
|
155
|
+
Classes that inherit from this mixin can handle ToastBus.Changed messages
|
|
156
|
+
by automatically updating all Toast widgets in the component tree.
|
|
157
|
+
"""
|
|
158
|
+
|
|
159
|
+
def on_toast_bus_changed(self, message: ToastBus.Changed) -> None:
|
|
160
|
+
"""Refresh the toast widget when the toast bus updates.
|
|
161
|
+
|
|
162
|
+
Args:
|
|
163
|
+
message: The toast bus changed message containing the new state.
|
|
164
|
+
"""
|
|
165
|
+
try:
|
|
166
|
+
for toast in self.query(Toast):
|
|
167
|
+
toast.update_state(message.state)
|
|
168
|
+
except Exception:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ClipboardToastMixin:
|
|
173
|
+
"""Mixin providing clipboard and toast orchestration functionality.
|
|
174
|
+
|
|
175
|
+
Classes that inherit from this mixin get shared clipboard adapter selection,
|
|
176
|
+
OSC52 writer setup, toast bus lookup, and copy-success/failure orchestration.
|
|
177
|
+
This consolidates duplicate clipboard/toast logic across TUI apps.
|
|
178
|
+
|
|
179
|
+
Expected attributes:
|
|
180
|
+
_ctx: TUIContext | None - Shared TUI context (optional)
|
|
181
|
+
_clipboard: ClipboardAdapter | None - Cached clipboard adapter (optional)
|
|
182
|
+
_local_toasts: ToastBus | None - Local toast bus instance (optional)
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def _clipboard_adapter(self) -> Any: # ClipboardAdapter
|
|
186
|
+
"""Get or create a clipboard adapter instance.
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
ClipboardAdapter instance, preferring context's adapter if available.
|
|
190
|
+
"""
|
|
191
|
+
# Import here to avoid circular dependency
|
|
192
|
+
from glaip_sdk.cli.slash.tui.clipboard import ClipboardAdapter # noqa: PLC0415
|
|
193
|
+
|
|
194
|
+
ctx = getattr(self, "_ctx", None)
|
|
195
|
+
clipboard = getattr(self, "_clipboard", None)
|
|
196
|
+
|
|
197
|
+
if ctx is not None and ctx.clipboard is not None:
|
|
198
|
+
return cast(ClipboardAdapter, ctx.clipboard)
|
|
199
|
+
if clipboard is not None:
|
|
200
|
+
return clipboard
|
|
201
|
+
|
|
202
|
+
adapter = ClipboardAdapter(terminal=ctx.terminal if ctx else None)
|
|
203
|
+
if ctx is not None:
|
|
204
|
+
ctx.clipboard = adapter
|
|
205
|
+
else:
|
|
206
|
+
self._clipboard = adapter
|
|
207
|
+
return adapter
|
|
208
|
+
|
|
209
|
+
def _osc52_writer(self) -> Callable[[str], Any] | None:
|
|
210
|
+
"""Get an OSC52 writer function if console output is available.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
Writer function that writes OSC52 sequences to console output, or None.
|
|
214
|
+
"""
|
|
215
|
+
try:
|
|
216
|
+
# Try self.app.console first (for Screen subclasses)
|
|
217
|
+
if hasattr(self, "app") and hasattr(self.app, "console"):
|
|
218
|
+
console = self.app.console
|
|
219
|
+
# Fall back to self.console (for App subclasses)
|
|
220
|
+
else:
|
|
221
|
+
console = getattr(self, "console", None)
|
|
222
|
+
except Exception:
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
if console is None:
|
|
226
|
+
return None
|
|
227
|
+
|
|
228
|
+
output = getattr(console, "file", None)
|
|
229
|
+
if output is None:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
def _write(sequence: str, _output: Any = output) -> None:
|
|
233
|
+
_output.write(sequence)
|
|
234
|
+
_output.flush()
|
|
235
|
+
|
|
236
|
+
return _write
|
|
237
|
+
|
|
238
|
+
def _toast_bus(self) -> ToastBus | None:
|
|
239
|
+
"""Get the toast bus instance.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
ToastBus instance, preferring context's bus if available, or None.
|
|
243
|
+
"""
|
|
244
|
+
local_toasts = getattr(self, "_local_toasts", None)
|
|
245
|
+
ctx = getattr(self, "_ctx", None)
|
|
246
|
+
|
|
247
|
+
if local_toasts is not None:
|
|
248
|
+
return local_toasts
|
|
249
|
+
if ctx is not None and ctx.toasts is not None:
|
|
250
|
+
return ctx.toasts
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
def _copy_to_clipboard(self, text: str, *, label: str | None = None) -> None:
|
|
254
|
+
"""Copy text to clipboard and show toast notification.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
text: The text to copy to clipboard.
|
|
258
|
+
label: Optional label for what was copied (e.g., "Run ID", "JSON").
|
|
259
|
+
"""
|
|
260
|
+
adapter = self._clipboard_adapter()
|
|
261
|
+
writer = self._osc52_writer()
|
|
262
|
+
if writer:
|
|
263
|
+
result = adapter.copy(text, writer=writer)
|
|
264
|
+
else:
|
|
265
|
+
result = adapter.copy(text)
|
|
266
|
+
|
|
267
|
+
toasts = self._toast_bus()
|
|
268
|
+
if result.success:
|
|
269
|
+
if toasts:
|
|
270
|
+
toasts.copy_success(label)
|
|
271
|
+
else:
|
|
272
|
+
# Fallback to status announcement if toast bus unavailable
|
|
273
|
+
if hasattr(self, "_announce_status"):
|
|
274
|
+
if label:
|
|
275
|
+
self._announce_status(f"Copied {label} to clipboard.")
|
|
276
|
+
else:
|
|
277
|
+
self._announce_status("Copied to clipboard.")
|
|
278
|
+
return
|
|
279
|
+
|
|
280
|
+
# Copy failed
|
|
281
|
+
if toasts:
|
|
282
|
+
toasts.copy_failed()
|
|
283
|
+
else:
|
|
284
|
+
# Fallback to status announcement if toast bus unavailable
|
|
285
|
+
if hasattr(self, "_announce_status"):
|
|
286
|
+
self._announce_status("Clipboard unavailable. Text printed below.")
|
|
287
|
+
|
|
288
|
+
# Append fallback text output
|
|
289
|
+
if hasattr(self, "_append_copy_fallback"):
|
|
290
|
+
self._append_copy_fallback(text)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
class Toast(Static):
|
|
294
|
+
"""A Textual widget that displays toast notifications at the top-right of the screen.
|
|
295
|
+
|
|
296
|
+
The Toast widget is updated via `update_state()` calls from message handlers
|
|
297
|
+
(e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to ToastBus
|
|
298
|
+
state changes; the app must call `update_state()` when toast state changes.
|
|
299
|
+
"""
|
|
300
|
+
|
|
301
|
+
DEFAULT_CSS = """
|
|
302
|
+
#toast-container {
|
|
303
|
+
width: 100%;
|
|
304
|
+
height: auto;
|
|
305
|
+
dock: top;
|
|
306
|
+
align: right top;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
Toast {
|
|
310
|
+
width: auto;
|
|
311
|
+
min-width: 20;
|
|
312
|
+
max-width: 40;
|
|
313
|
+
height: auto;
|
|
314
|
+
padding: 0 1;
|
|
315
|
+
margin: 1 2;
|
|
316
|
+
background: $surface;
|
|
317
|
+
color: $text;
|
|
318
|
+
border: solid $primary;
|
|
319
|
+
display: none;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
Toast.visible {
|
|
323
|
+
display: block;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
Toast.info {
|
|
327
|
+
border: solid $accent;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
Toast.success {
|
|
331
|
+
border: solid $success;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
Toast.warning {
|
|
335
|
+
border: solid $warning;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
Toast.error {
|
|
339
|
+
border: solid $error;
|
|
340
|
+
}
|
|
341
|
+
"""
|
|
342
|
+
|
|
343
|
+
def __init__(self) -> None:
|
|
344
|
+
"""Initialize the Toast widget.
|
|
345
|
+
|
|
346
|
+
The widget is updated via `update_state()` calls from message handlers
|
|
347
|
+
(e.g., `on_toast_bus_changed`). The widget does not auto-subscribe to
|
|
348
|
+
a ToastBus; the app must call `update_state()` when toast state changes.
|
|
349
|
+
"""
|
|
350
|
+
super().__init__("")
|
|
351
|
+
|
|
352
|
+
def update_state(self, state: ToastState | None) -> None:
|
|
353
|
+
"""Update the toast display based on the provided state.
|
|
354
|
+
|
|
355
|
+
Args:
|
|
356
|
+
state: The toast state to display, or None to hide the toast.
|
|
357
|
+
"""
|
|
358
|
+
if not state:
|
|
359
|
+
self.remove_class("visible")
|
|
360
|
+
return
|
|
361
|
+
|
|
362
|
+
icon = "ℹ️"
|
|
363
|
+
if state.variant == ToastVariant.SUCCESS:
|
|
364
|
+
icon = "✅"
|
|
365
|
+
elif state.variant == ToastVariant.WARNING:
|
|
366
|
+
icon = "⚠️"
|
|
367
|
+
elif state.variant == ToastVariant.ERROR:
|
|
368
|
+
icon = "❌"
|
|
369
|
+
|
|
370
|
+
self.update(Text.assemble((f"{icon} ", "bold"), state.message))
|
|
371
|
+
|
|
372
|
+
self.remove_class("info", "success", "warning", "error")
|
|
373
|
+
self.add_class(state.variant.value)
|
|
374
|
+
self.add_class("visible")
|
|
@@ -172,6 +172,63 @@ class AgentRunRenderingManager:
|
|
|
172
172
|
finished_monotonic = monotonic()
|
|
173
173
|
return final_text, stats_usage, started_monotonic, finished_monotonic
|
|
174
174
|
|
|
175
|
+
async def _consume_event_stream(
|
|
176
|
+
self,
|
|
177
|
+
event_stream: AsyncIterable[dict[str, Any]],
|
|
178
|
+
renderer: RichStreamRenderer,
|
|
179
|
+
final_text: str,
|
|
180
|
+
stats_usage: dict[str, Any],
|
|
181
|
+
meta: dict[str, Any],
|
|
182
|
+
skip_final_render: bool,
|
|
183
|
+
last_rendered_content: str | None,
|
|
184
|
+
controller: Any | None,
|
|
185
|
+
) -> tuple[str, dict[str, Any], float | None]:
|
|
186
|
+
"""Consume event stream and update state.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
event_stream: Async iterable yielding SSE-like event dicts.
|
|
190
|
+
renderer: Renderer to use for displaying events.
|
|
191
|
+
final_text: Current accumulated final text.
|
|
192
|
+
stats_usage: Usage statistics dictionary.
|
|
193
|
+
meta: Metadata dictionary.
|
|
194
|
+
skip_final_render: If True, skip rendering final_response events.
|
|
195
|
+
last_rendered_content: Last rendered content to avoid duplicates.
|
|
196
|
+
controller: Controller instance.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple of (final_text, stats_usage, started_monotonic).
|
|
200
|
+
"""
|
|
201
|
+
started_monotonic: float | None = None
|
|
202
|
+
|
|
203
|
+
async for event in event_stream:
|
|
204
|
+
if started_monotonic is None:
|
|
205
|
+
started_monotonic = monotonic()
|
|
206
|
+
|
|
207
|
+
parsed_event = self._parse_event(event)
|
|
208
|
+
if parsed_event is None:
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
final_text, stats_usage = self._handle_parsed_event(
|
|
212
|
+
parsed_event,
|
|
213
|
+
renderer,
|
|
214
|
+
final_text,
|
|
215
|
+
stats_usage,
|
|
216
|
+
meta,
|
|
217
|
+
skip_final_render=skip_final_render,
|
|
218
|
+
last_rendered_content=last_rendered_content,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
content_str = self._extract_content_string(parsed_event)
|
|
222
|
+
if content_str:
|
|
223
|
+
last_rendered_content = content_str
|
|
224
|
+
|
|
225
|
+
if controller and getattr(controller, "enabled", False):
|
|
226
|
+
controller.poll(renderer)
|
|
227
|
+
if parsed_event and self._is_final_event(parsed_event):
|
|
228
|
+
break
|
|
229
|
+
|
|
230
|
+
return final_text, stats_usage, started_monotonic
|
|
231
|
+
|
|
175
232
|
async def async_process_stream_events(
|
|
176
233
|
self,
|
|
177
234
|
event_stream: AsyncIterable[dict[str, Any]],
|
|
@@ -207,35 +264,25 @@ class AgentRunRenderingManager:
|
|
|
207
264
|
controller.on_stream_start(renderer)
|
|
208
265
|
|
|
209
266
|
try:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
# Track last rendered content to avoid duplicates
|
|
231
|
-
content_str = self._extract_content_string(parsed_event)
|
|
232
|
-
if content_str:
|
|
233
|
-
last_rendered_content = content_str
|
|
234
|
-
|
|
235
|
-
if controller and getattr(controller, "enabled", False):
|
|
236
|
-
controller.poll(renderer)
|
|
237
|
-
if parsed_event and self._is_final_event(parsed_event):
|
|
238
|
-
break
|
|
267
|
+
final_text, stats_usage, started_monotonic = await self._consume_event_stream(
|
|
268
|
+
event_stream,
|
|
269
|
+
renderer,
|
|
270
|
+
final_text,
|
|
271
|
+
stats_usage,
|
|
272
|
+
meta,
|
|
273
|
+
skip_final_render,
|
|
274
|
+
last_rendered_content,
|
|
275
|
+
controller,
|
|
276
|
+
)
|
|
277
|
+
except Exception as e:
|
|
278
|
+
err_msg = str(e)
|
|
279
|
+
reason = getattr(getattr(e, "result", None), "reason", None)
|
|
280
|
+
if reason:
|
|
281
|
+
final_text = f"⚠️ Guardrail violation: {reason}"
|
|
282
|
+
elif "⚠️ Guardrail violation" in err_msg or "Content blocked by guardrails" in err_msg:
|
|
283
|
+
final_text = err_msg
|
|
284
|
+
else:
|
|
285
|
+
raise e
|
|
239
286
|
finally:
|
|
240
287
|
if controller and getattr(controller, "enabled", False):
|
|
241
288
|
controller.on_stream_complete()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Guardrails package for content filtering and safety checks.
|
|
2
|
+
|
|
3
|
+
This package provides modular guardrail engines and managers for filtering
|
|
4
|
+
harmful content in AI agent interactions. All components support lazy loading
|
|
5
|
+
from aip-agents to maintain Principle VII compliance.
|
|
6
|
+
|
|
7
|
+
Authors:
|
|
8
|
+
Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from aip_agents.guardrails.engines.nemo import NemoGuardrailEngine
|
|
16
|
+
from aip_agents.guardrails.engines.phrase_matcher import PhraseMatcherEngine
|
|
17
|
+
from aip_agents.guardrails.manager import GuardrailManager
|
|
18
|
+
from aip_agents.guardrails.schemas import GuardrailMode
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ImportableName(StrEnum):
|
|
22
|
+
"""Names of the importable attributes."""
|
|
23
|
+
|
|
24
|
+
GUARDRAIL_MANAGER = "GuardrailManager"
|
|
25
|
+
PHRASE_MATCHER_ENGINE = "PhraseMatcherEngine"
|
|
26
|
+
NEMO_GUARDRAIL_ENGINE = "NemoGuardrailEngine"
|
|
27
|
+
GUARDRAIL_MODE = "GuardrailMode"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# Lazy loading support - components are only imported when actually used
|
|
31
|
+
_LAZY_IMPORTS = {}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def __getattr__(name: str) -> Any:
|
|
35
|
+
"""Lazy import to avoid eager loading of optional aip-agents dependency.
|
|
36
|
+
|
|
37
|
+
This function is called by Python when an attribute is not found in the module.
|
|
38
|
+
It performs the import from aip_agents.guardrails at runtime.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
name: The name of the attribute to get.
|
|
42
|
+
|
|
43
|
+
Returns:
|
|
44
|
+
The attribute value from aip_agents.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
AttributeError: If the attribute doesn't exist.
|
|
48
|
+
ImportError: If aip-agents is not installed but a component is accessed.
|
|
49
|
+
"""
|
|
50
|
+
if name in _LAZY_IMPORTS:
|
|
51
|
+
return _LAZY_IMPORTS[name]
|
|
52
|
+
|
|
53
|
+
if name == ImportableName.GUARDRAIL_MANAGER:
|
|
54
|
+
from aip_agents.guardrails.manager import GuardrailManager # noqa: PLC0415
|
|
55
|
+
|
|
56
|
+
_LAZY_IMPORTS[name] = GuardrailManager
|
|
57
|
+
return GuardrailManager
|
|
58
|
+
|
|
59
|
+
if name == ImportableName.PHRASE_MATCHER_ENGINE:
|
|
60
|
+
from aip_agents.guardrails.engines.phrase_matcher import ( # noqa: PLC0415
|
|
61
|
+
PhraseMatcherEngine,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
_LAZY_IMPORTS[name] = PhraseMatcherEngine
|
|
65
|
+
return PhraseMatcherEngine
|
|
66
|
+
|
|
67
|
+
if name == ImportableName.NEMO_GUARDRAIL_ENGINE:
|
|
68
|
+
from aip_agents.guardrails.engines.nemo import NemoGuardrailEngine # noqa: PLC0415
|
|
69
|
+
|
|
70
|
+
_LAZY_IMPORTS[name] = NemoGuardrailEngine
|
|
71
|
+
return NemoGuardrailEngine
|
|
72
|
+
|
|
73
|
+
if name == ImportableName.GUARDRAIL_MODE:
|
|
74
|
+
from aip_agents.guardrails.schemas import GuardrailMode # noqa: PLC0415
|
|
75
|
+
|
|
76
|
+
_LAZY_IMPORTS[name] = GuardrailMode
|
|
77
|
+
return GuardrailMode
|
|
78
|
+
|
|
79
|
+
msg = f"module {__name__!r} has no attribute {name!r}"
|
|
80
|
+
raise AttributeError(msg)
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Guardrail serialization logic.
|
|
2
|
+
|
|
3
|
+
This module provides functionality to serialize GuardrailManager and its engines
|
|
4
|
+
into the JSON format expected by the GL AIP backend. This keeps the serialization
|
|
5
|
+
logic within the SDK rather than polluting the core aip-agents logic.
|
|
6
|
+
|
|
7
|
+
Authors:
|
|
8
|
+
Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from glaip_sdk.guardrails import (
|
|
17
|
+
GuardrailManager,
|
|
18
|
+
NemoGuardrailEngine,
|
|
19
|
+
PhraseMatcherEngine,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _serialize_phrase_matcher(engine: PhraseMatcherEngine) -> dict[str, Any]:
|
|
24
|
+
"""Serialize a PhraseMatcherEngine configuration."""
|
|
25
|
+
config: dict[str, Any] = {}
|
|
26
|
+
|
|
27
|
+
# Extract config from BaseGuardrailEngineConfig
|
|
28
|
+
if hasattr(engine, "config") and engine.config:
|
|
29
|
+
config.update(engine.config.model_dump())
|
|
30
|
+
|
|
31
|
+
# Extract specific fields
|
|
32
|
+
if hasattr(engine, "banned_phrases"):
|
|
33
|
+
config["banned_phrases"] = engine.banned_phrases
|
|
34
|
+
|
|
35
|
+
return config
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _serialize_nemo(engine: NemoGuardrailEngine) -> dict[str, Any]:
|
|
39
|
+
"""Serialize a NemoGuardrailEngine configuration."""
|
|
40
|
+
config: dict[str, Any] = {}
|
|
41
|
+
|
|
42
|
+
# Extract config from BaseGuardrailEngineConfig
|
|
43
|
+
if hasattr(engine, "config") and engine.config:
|
|
44
|
+
config.update(engine.config.model_dump())
|
|
45
|
+
|
|
46
|
+
# Extract specific fields
|
|
47
|
+
nemo_fields = [
|
|
48
|
+
"topic_safety_mode",
|
|
49
|
+
"allowed_topics",
|
|
50
|
+
"denied_topics",
|
|
51
|
+
"include_core_restrictions",
|
|
52
|
+
"core_restriction_categories",
|
|
53
|
+
"config_dict",
|
|
54
|
+
"denial_phrases",
|
|
55
|
+
]
|
|
56
|
+
for field in nemo_fields:
|
|
57
|
+
if hasattr(engine, field):
|
|
58
|
+
val = getattr(engine, field)
|
|
59
|
+
if val is not None:
|
|
60
|
+
config[field] = val
|
|
61
|
+
|
|
62
|
+
return config
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def serialize_guardrail_manager(manager: GuardrailManager) -> dict[str, Any]:
|
|
66
|
+
"""Serialize a GuardrailManager into the backend JSON format.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
manager: The GuardrailManager instance to serialize.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
A dictionary matching the agent_config.guardrails schema.
|
|
73
|
+
"""
|
|
74
|
+
from glaip_sdk.guardrails import NemoGuardrailEngine, PhraseMatcherEngine # noqa: PLC0415
|
|
75
|
+
|
|
76
|
+
engines_config = []
|
|
77
|
+
|
|
78
|
+
if hasattr(manager, "engines"):
|
|
79
|
+
for engine in manager.engines:
|
|
80
|
+
if isinstance(engine, PhraseMatcherEngine):
|
|
81
|
+
engines_config.append({"type": "phrase_matcher", "config": _serialize_phrase_matcher(engine)})
|
|
82
|
+
elif isinstance(engine, NemoGuardrailEngine):
|
|
83
|
+
engines_config.append({"type": "nemo", "config": _serialize_nemo(engine)})
|
|
84
|
+
else:
|
|
85
|
+
# Fallback for unknown engines
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
enabled = getattr(manager, "enabled", True)
|
|
89
|
+
return {"enabled": enabled, "engines": engines_config}
|
|
@@ -57,6 +57,7 @@ AGENT_FIELD_RULES: Mapping[str, FieldRule] = {
|
|
|
57
57
|
"timeout": FieldRule(cli_managed_create=True, cli_managed_update=True),
|
|
58
58
|
# Fields requiring sanitisation before sending to the API
|
|
59
59
|
"agent_config": FieldRule(requires_sanitization=True),
|
|
60
|
+
"guardrail": FieldRule(requires_sanitization=True),
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Guardrail payload schemas for API communication.
|
|
2
|
+
|
|
3
|
+
Authors:
|
|
4
|
+
Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, Field
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class GuardrailEnginePayload(BaseModel):
|
|
14
|
+
"""Payload schema for a single guardrail engine configuration.
|
|
15
|
+
|
|
16
|
+
This model defines the structure for individual safety engines (e.g., phrase_matcher, nemo)
|
|
17
|
+
when communicating with the GL AIP backend.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
type: str = Field(..., description="The type of guardrail engine (e.g., 'phrase_matcher', 'nemo')")
|
|
21
|
+
config: Mapping[str, Any] = Field(..., description="Engine-specific configuration parameters")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class GuardrailPayload(BaseModel):
|
|
25
|
+
"""Payload schema for global guardrail settings.
|
|
26
|
+
|
|
27
|
+
This model acts as the container for all guardrail configurations within the agent_config.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
enabled: bool = Field(default=True, description="Global toggle to enable or disable all guardrails")
|
|
31
|
+
engines: Sequence[GuardrailEnginePayload] = Field(
|
|
32
|
+
default_factory=list,
|
|
33
|
+
description="List of configured guardrail engines",
|
|
34
|
+
)
|
glaip_sdk/runner/langgraph.py
CHANGED
|
@@ -387,6 +387,7 @@ class LangGraphRunner(BaseRunner):
|
|
|
387
387
|
agents=sub_agent_instances if sub_agent_instances else None,
|
|
388
388
|
tool_configs=tool_configs if tool_configs else None,
|
|
389
389
|
tool_output_manager=tool_output_manager,
|
|
390
|
+
guardrail=agent.guardrail,
|
|
390
391
|
**agent_config_params,
|
|
391
392
|
**agent_config_kwargs,
|
|
392
393
|
)
|