sondera-harness 0.6.0__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.
Files changed (77) hide show
  1. sondera/__init__.py +111 -0
  2. sondera/__main__.py +4 -0
  3. sondera/adk/__init__.py +3 -0
  4. sondera/adk/analyze.py +222 -0
  5. sondera/adk/plugin.py +387 -0
  6. sondera/cli.py +22 -0
  7. sondera/exceptions.py +167 -0
  8. sondera/harness/__init__.py +6 -0
  9. sondera/harness/abc.py +102 -0
  10. sondera/harness/cedar/__init__.py +0 -0
  11. sondera/harness/cedar/harness.py +363 -0
  12. sondera/harness/cedar/schema.py +225 -0
  13. sondera/harness/sondera/__init__.py +0 -0
  14. sondera/harness/sondera/_grpc.py +354 -0
  15. sondera/harness/sondera/harness.py +890 -0
  16. sondera/langgraph/__init__.py +15 -0
  17. sondera/langgraph/analyze.py +543 -0
  18. sondera/langgraph/exceptions.py +19 -0
  19. sondera/langgraph/graph.py +210 -0
  20. sondera/langgraph/middleware.py +454 -0
  21. sondera/proto/google/protobuf/any_pb2.py +37 -0
  22. sondera/proto/google/protobuf/any_pb2.pyi +14 -0
  23. sondera/proto/google/protobuf/any_pb2_grpc.py +24 -0
  24. sondera/proto/google/protobuf/duration_pb2.py +37 -0
  25. sondera/proto/google/protobuf/duration_pb2.pyi +14 -0
  26. sondera/proto/google/protobuf/duration_pb2_grpc.py +24 -0
  27. sondera/proto/google/protobuf/empty_pb2.py +37 -0
  28. sondera/proto/google/protobuf/empty_pb2.pyi +9 -0
  29. sondera/proto/google/protobuf/empty_pb2_grpc.py +24 -0
  30. sondera/proto/google/protobuf/struct_pb2.py +47 -0
  31. sondera/proto/google/protobuf/struct_pb2.pyi +49 -0
  32. sondera/proto/google/protobuf/struct_pb2_grpc.py +24 -0
  33. sondera/proto/google/protobuf/timestamp_pb2.py +37 -0
  34. sondera/proto/google/protobuf/timestamp_pb2.pyi +14 -0
  35. sondera/proto/google/protobuf/timestamp_pb2_grpc.py +24 -0
  36. sondera/proto/google/protobuf/wrappers_pb2.py +53 -0
  37. sondera/proto/google/protobuf/wrappers_pb2.pyi +59 -0
  38. sondera/proto/google/protobuf/wrappers_pb2_grpc.py +24 -0
  39. sondera/proto/sondera/__init__.py +0 -0
  40. sondera/proto/sondera/core/__init__.py +0 -0
  41. sondera/proto/sondera/core/v1/__init__.py +0 -0
  42. sondera/proto/sondera/core/v1/primitives_pb2.py +88 -0
  43. sondera/proto/sondera/core/v1/primitives_pb2.pyi +259 -0
  44. sondera/proto/sondera/core/v1/primitives_pb2_grpc.py +24 -0
  45. sondera/proto/sondera/harness/__init__.py +0 -0
  46. sondera/proto/sondera/harness/v1/__init__.py +0 -0
  47. sondera/proto/sondera/harness/v1/harness_pb2.py +81 -0
  48. sondera/proto/sondera/harness/v1/harness_pb2.pyi +192 -0
  49. sondera/proto/sondera/harness/v1/harness_pb2_grpc.py +498 -0
  50. sondera/py.typed +0 -0
  51. sondera/settings.py +20 -0
  52. sondera/strands/__init__.py +5 -0
  53. sondera/strands/analyze.py +244 -0
  54. sondera/strands/harness.py +333 -0
  55. sondera/tui/__init__.py +0 -0
  56. sondera/tui/app.py +309 -0
  57. sondera/tui/screens/__init__.py +5 -0
  58. sondera/tui/screens/adjudication.py +184 -0
  59. sondera/tui/screens/agent.py +158 -0
  60. sondera/tui/screens/trajectory.py +158 -0
  61. sondera/tui/widgets/__init__.py +23 -0
  62. sondera/tui/widgets/agent_card.py +94 -0
  63. sondera/tui/widgets/agent_list.py +73 -0
  64. sondera/tui/widgets/recent_adjudications.py +52 -0
  65. sondera/tui/widgets/recent_trajectories.py +54 -0
  66. sondera/tui/widgets/summary.py +57 -0
  67. sondera/tui/widgets/tool_card.py +33 -0
  68. sondera/tui/widgets/violation_panel.py +72 -0
  69. sondera/tui/widgets/violations_list.py +78 -0
  70. sondera/tui/widgets/violations_summary.py +104 -0
  71. sondera/types.py +346 -0
  72. sondera_harness-0.6.0.dist-info/METADATA +323 -0
  73. sondera_harness-0.6.0.dist-info/RECORD +77 -0
  74. sondera_harness-0.6.0.dist-info/WHEEL +5 -0
  75. sondera_harness-0.6.0.dist-info/entry_points.txt +2 -0
  76. sondera_harness-0.6.0.dist-info/licenses/LICENSE +21 -0
  77. sondera_harness-0.6.0.dist-info/top_level.txt +1 -0
sondera/tui/app.py ADDED
@@ -0,0 +1,309 @@
1
+ from textual import work
2
+ from textual.app import App, ComposeResult
3
+ from textual.binding import Binding
4
+ from textual.color import Color
5
+ from textual.theme import Theme
6
+ from textual.widget import Widget
7
+ from textual.widgets import (
8
+ Button,
9
+ DataTable,
10
+ Footer,
11
+ Header,
12
+ ListView,
13
+ TabbedContent,
14
+ TabPane,
15
+ )
16
+
17
+ from sondera.harness.sondera.harness import SonderaRemoteHarness
18
+ from sondera.settings import SETTINGS
19
+ from sondera.tui.screens import AdjudicationScreen, AgentScreen, TrajectoryScreen
20
+ from sondera.tui.widgets import (
21
+ AgentList,
22
+ AgentRecord,
23
+ RecentAdjudications,
24
+ RecentTrajectories,
25
+ Summary,
26
+ )
27
+ from sondera.types import Decision, TrajectoryStatus
28
+
29
+ sondera_theme = Theme(
30
+ name="sondera",
31
+ primary="#81DDB4",
32
+ secondary="#81DDB4",
33
+ accent="#81DDB4",
34
+ foreground="#EAEAEA",
35
+ background="#06110B",
36
+ success="#A3BE8C",
37
+ warning="#EBCB8B",
38
+ error="#BF616A",
39
+ surface="#06110B",
40
+ panel="#054C53",
41
+ dark=True,
42
+ variables={"scrollbar": "#054C53", "scrollbar-background": "#06110B"},
43
+ )
44
+
45
+
46
+ class SonderaApp(App):
47
+ """A Textual app for exploring Sondera agents and harness operations."""
48
+
49
+ TITLE = "Sondera Harness"
50
+ SUB_TITLE = "Trustworthy Agent Governance Console"
51
+ CSS_PATH = "app.tcss"
52
+
53
+ BINDINGS = [
54
+ Binding("q", "quit", "Quit"),
55
+ Binding("r", "refresh", "Refresh"),
56
+ Binding("e", "select_row", "Select"),
57
+ Binding("1", "show_tab('trajectories-tab')", "Trajectories"),
58
+ Binding("2", "show_tab('adjudications-tab')", "Adjudications"),
59
+ Binding("3", "show_tab('agents-tab')", "Agents"),
60
+ Binding("j", "cursor_down", "Move Down", show=False),
61
+ Binding("k", "cursor_up", "Move Up", show=False),
62
+ Binding("h", "cursor_left", "Move Left", show=False),
63
+ Binding("l", "cursor_right", "Move Right", show=False),
64
+ Binding("tab", "focus_next", "Next Widget", show=False),
65
+ Binding("shift+tab", "focus_previous", "Previous Widget", show=False),
66
+ ]
67
+
68
+ SCREENS = {}
69
+
70
+ def __init__(self, *args, **kwargs):
71
+ """Initialize the app with shared resources."""
72
+ super().__init__(*args, **kwargs)
73
+ # Initialize the shared harness connection
74
+ # This stays alive for the entire app lifetime
75
+ self.harness = SonderaRemoteHarness(
76
+ sondera_harness_endpoint=SETTINGS.sondera_harness_endpoint,
77
+ sondera_api_key=SETTINGS.sondera_api_token,
78
+ )
79
+
80
+ def on_mount(self) -> None:
81
+ """Mount the app and initialize shared resources."""
82
+ self.register_theme(sondera_theme)
83
+ self.theme = "sondera"
84
+ self.screen.styles.background = Color(r=5, g=76, b=83, a=0.7)
85
+ self.update_dataset()
86
+
87
+ @work(exclusive=True)
88
+ async def update_dataset(self) -> None:
89
+ self.notify("Fetching agents, trajectories, and adjudications...")
90
+ agents = await self.harness.list_agents()
91
+ all_trajectories = []
92
+ agent_records = []
93
+ running = 0
94
+ suspended = 0
95
+ completed = 0
96
+ failed = 0
97
+ pending = 0
98
+ for agent in agents:
99
+ trajectories = await self.harness.list_trajectories(agent_id=agent.id)
100
+ all_trajectories.extend(trajectories)
101
+ agent_records.append(
102
+ AgentRecord(
103
+ agent=agent,
104
+ total_trajectories=len(trajectories),
105
+ )
106
+ )
107
+ for trajectory in trajectories:
108
+ if trajectory.status == TrajectoryStatus.RUNNING:
109
+ running += 1
110
+ elif trajectory.status == TrajectoryStatus.PENDING:
111
+ pending += 1
112
+ elif trajectory.status == TrajectoryStatus.SUSPENDED:
113
+ suspended += 1
114
+ elif trajectory.status == TrajectoryStatus.COMPLETED:
115
+ completed += 1
116
+ elif trajectory.status == TrajectoryStatus.FAILED:
117
+ failed += 1
118
+ agent_list = self.query_one(AgentList)
119
+ agent_list.records = agent_records
120
+
121
+ # Fetch adjudications
122
+ adjudications, _ = await self.harness.list_adjudications(page_size=100)
123
+ violations_count = sum(
124
+ 1 for adj in adjudications if adj.adjudication.decision == Decision.DENY
125
+ )
126
+ approved_count = sum(
127
+ 1 for adj in adjudications if adj.adjudication.decision == Decision.ALLOW
128
+ )
129
+
130
+ summary = self.query_one(Summary)
131
+ summary.running = running
132
+ summary.pending = pending
133
+ summary.suspended = suspended
134
+ summary.completed = completed
135
+ summary.failed = failed
136
+ summary.violations = violations_count
137
+ summary.approved = approved_count
138
+
139
+ all_trajectories.sort(key=lambda t: t.created_at, reverse=True)
140
+ recent_trajectories = all_trajectories[:20]
141
+ for trajectory in recent_trajectories:
142
+ traj = await self.harness.get_trajectory(trajectory.id)
143
+ if traj:
144
+ trajectory.steps = traj.steps
145
+
146
+ # Build agents map for provider lookup
147
+ agents_map = {agent.id: agent.provider_id for agent in agents}
148
+
149
+ recent_trajectories_widget = self.query_one(RecentTrajectories)
150
+ recent_trajectories_widget.agents_map = agents_map
151
+ recent_trajectories_widget.trajectories = recent_trajectories
152
+
153
+ # Update recent adjudications
154
+ adjudications_widget = self.query_one(RecentAdjudications)
155
+ adjudications_widget.agents_map = agents_map
156
+ adjudications_widget.adjudications = adjudications
157
+
158
+ def compose(self) -> ComposeResult:
159
+ yield Header()
160
+ yield Summary(classes="card")
161
+ with TabbedContent(id="main-tabs", classes="card"):
162
+ with TabPane("Trajectories", id="trajectories-tab"):
163
+ yield RecentTrajectories()
164
+ with TabPane("Adjudications", id="adjudications-tab"):
165
+ yield RecentAdjudications(id="recent-adjudications")
166
+ with TabPane("Agents", id="agents-tab"):
167
+ yield AgentList(id="agent-list")
168
+ yield Footer()
169
+
170
+ def on_button_pressed(self, event: Button.Pressed) -> None:
171
+ """Handle button presses."""
172
+ self.notify(f"Button pressed: {event.button.id}")
173
+
174
+ def action_refresh(self) -> None:
175
+ """Refresh the dataset."""
176
+ self.update_dataset()
177
+
178
+ def action_show_tab(self, tab: str) -> None:
179
+ """Switch to a tab."""
180
+ self.query_one("#main-tabs", TabbedContent).active = tab
181
+
182
+ def _get_focused_table_or_list(self) -> DataTable | ListView | None:
183
+ """Get the focused DataTable or ListView."""
184
+ focused = self.focused
185
+ if not focused:
186
+ return None
187
+
188
+ if isinstance(focused, (DataTable, ListView)):
189
+ return focused
190
+
191
+ # Check parent chain for containers
192
+ parent = focused.parent
193
+ while parent:
194
+ if isinstance(parent, RecentTrajectories):
195
+ return parent.query_one(DataTable)
196
+ if isinstance(parent, RecentAdjudications):
197
+ return parent.query_one(DataTable)
198
+ if isinstance(parent, AgentList):
199
+ return parent.query_one(ListView)
200
+ parent = parent.parent
201
+
202
+ return None
203
+
204
+ def action_cursor_down(self) -> None:
205
+ """Move cursor down in focused table or list."""
206
+ if widget := self._get_focused_table_or_list():
207
+ widget.action_cursor_down()
208
+
209
+ def action_cursor_up(self) -> None:
210
+ """Move cursor up in focused table or list."""
211
+ if widget := self._get_focused_table_or_list():
212
+ widget.action_cursor_up()
213
+
214
+ def action_cursor_left(self) -> None:
215
+ """Move cursor left in focused table."""
216
+ if isinstance(widget := self._get_focused_table_or_list(), DataTable):
217
+ widget.action_cursor_left()
218
+
219
+ def action_cursor_right(self) -> None:
220
+ """Move cursor right in focused table."""
221
+ if isinstance(widget := self._get_focused_table_or_list(), DataTable):
222
+ widget.action_cursor_right()
223
+
224
+ def _get_focusable_widgets(self) -> list[DataTable | ListView]:
225
+ """Get list of focusable widgets in order."""
226
+ return [
227
+ self.query_one(RecentTrajectories).query_one(DataTable),
228
+ self.query_one("#recent-adjudications", RecentAdjudications).query_one(
229
+ DataTable
230
+ ),
231
+ self.query_one("#agent-list", AgentList).query_one(ListView),
232
+ ]
233
+
234
+ def _find_current_index(
235
+ self, widgets: list[DataTable | ListView], focused: Widget | None
236
+ ) -> int | None:
237
+ """Find index of currently focused widget."""
238
+ if not focused:
239
+ return None
240
+
241
+ for i, widget in enumerate(widgets):
242
+ if widget == focused:
243
+ return i
244
+ # Check parent chain
245
+ parent = focused.parent
246
+ while parent:
247
+ if parent == widget:
248
+ return i
249
+ parent = parent.parent
250
+ return None
251
+
252
+ def action_focus_next(self) -> None:
253
+ """Move focus to next focusable widget."""
254
+ widgets = self._get_focusable_widgets()
255
+ if not widgets:
256
+ return
257
+
258
+ current_index = self._find_current_index(widgets, self.focused)
259
+ next_index = (
260
+ (current_index + 1) % len(widgets) if current_index is not None else 0
261
+ )
262
+ widgets[next_index].focus()
263
+
264
+ def action_focus_previous(self) -> None:
265
+ """Move focus to previous focusable widget."""
266
+ widgets = self._get_focusable_widgets()
267
+ if not widgets:
268
+ return
269
+
270
+ current_index = self._find_current_index(widgets, self.focused)
271
+ prev_index = (
272
+ (current_index - 1) % len(widgets)
273
+ if current_index is not None
274
+ else len(widgets) - 1
275
+ )
276
+ widgets[prev_index].focus()
277
+
278
+ def action_select_row(self) -> None:
279
+ """Select the current row in the focused table or list."""
280
+ focused_widget = self._get_focused_table_or_list()
281
+ if isinstance(focused_widget, DataTable):
282
+ # Check if focused on RecentTrajectories
283
+ trajectories_widget = self.query_one(RecentTrajectories)
284
+ if focused_widget == trajectories_widget.query_one(DataTable):
285
+ if trajectory := trajectories_widget.get_selected_trajectory():
286
+ self.push_screen(TrajectoryScreen(trajectory))
287
+ else:
288
+ self.notify("No trajectory selected")
289
+ return
290
+
291
+ # Check if focused on RecentAdjudications
292
+ adjudications_widget = self.query_one(
293
+ "#recent-adjudications", RecentAdjudications
294
+ )
295
+ if focused_widget == adjudications_widget.query_one(DataTable):
296
+ if adjudication := adjudications_widget.get_selected_adjudication():
297
+ self.push_screen(AdjudicationScreen(agent_id=adjudication.agent_id))
298
+ else:
299
+ self.notify("No adjudication selected")
300
+ return
301
+
302
+ self.notify("No item selected")
303
+ elif isinstance(focused_widget, ListView):
304
+ # Handle agent selection from AgentList
305
+ agent_list = self.query_one(AgentList)
306
+ if agent := agent_list.get_selected_agent():
307
+ self.push_screen(AgentScreen(agent))
308
+ else:
309
+ self.notify("No agent selected")
@@ -0,0 +1,5 @@
1
+ from .adjudication import AdjudicationScreen
2
+ from .agent import AgentScreen
3
+ from .trajectory import TrajectoryScreen
4
+
5
+ __all__ = ["AdjudicationScreen", "AgentScreen", "TrajectoryScreen"]
@@ -0,0 +1,184 @@
1
+ from __future__ import annotations
2
+
3
+ from textual import work
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, Vertical
7
+ from textual.screen import Screen
8
+ from textual.widget import Widget
9
+ from textual.widgets import DataTable, Footer, Header
10
+
11
+ from sondera.types import AdjudicationRecord
12
+
13
+ from ..widgets.violation_panel import ViolationPanel
14
+ from ..widgets.violations_list import ViolationsList
15
+ from ..widgets.violations_summary import ViolationsSummary
16
+ from .trajectory import TrajectoryScreen
17
+
18
+
19
+ class AdjudicationScreen(Screen):
20
+ app: "sondera.tui.app.SonderaApp" # type: ignore[name-defined] # noqa: UP037, F821
21
+ """Screen for displaying adjudication/violation information.
22
+
23
+ Shows:
24
+ - Violations Summary: Count, by agent, by policy
25
+ - Violations List: All adjudication records
26
+ - Violation Panel: Detailed view of selected violation
27
+ """
28
+
29
+ BINDINGS = [
30
+ Binding("escape", "app.pop_screen", "Back to Dashboard"),
31
+ Binding("tab", "focus_next", "Next Widget", show=False),
32
+ Binding("shift+tab", "focus_previous", "Previous Widget", show=False),
33
+ Binding("j", "cursor_down", "Move Down", show=False),
34
+ Binding("k", "cursor_up", "Move Up", show=False),
35
+ Binding("r", "refresh", "Refresh"),
36
+ Binding("e", "select_row", "Select"),
37
+ ]
38
+
39
+ def __init__(self, agent_id: str | None = None):
40
+ super().__init__()
41
+ self.agent_id = agent_id
42
+ self.adjudications: list[AdjudicationRecord] = []
43
+
44
+ def compose(self) -> ComposeResult:
45
+ yield Header()
46
+ with Horizontal(classes="adjudication-content"):
47
+ with Vertical(id="adjudication-left-panel"):
48
+ yield ViolationsSummary(id="violations-summary", classes="card")
49
+ yield ViolationsList(id="violations-list", classes="card")
50
+ yield ViolationPanel(id="violation-panel", classes="card")
51
+ yield Footer()
52
+
53
+ def on_mount(self) -> None:
54
+ """Initialize the screen."""
55
+ if self.agent_id:
56
+ self.sub_title = f"Adjudications: {self.agent_id}"
57
+ else:
58
+ self.sub_title = "All Adjudications"
59
+ self.load_adjudications()
60
+
61
+ @work(exclusive=True)
62
+ async def load_adjudications(self) -> None:
63
+ """Load adjudications from the harness service."""
64
+ self.notify("Loading adjudications...")
65
+ try:
66
+ adjudications, _ = await self.app.harness.list_adjudications(
67
+ agent_id=self.agent_id, page_size=100
68
+ )
69
+ self.adjudications = adjudications
70
+
71
+ # Update widgets
72
+ summary = self.query_one("#violations-summary", ViolationsSummary)
73
+ summary.adjudications = adjudications
74
+
75
+ violations_list = self.query_one("#violations-list", ViolationsList)
76
+ violations_list.adjudications = adjudications
77
+
78
+ self.notify(f"Loaded {len(adjudications)} adjudications")
79
+ except Exception as e:
80
+ self.notify(f"Failed to load adjudications: {e}", severity="error")
81
+
82
+ def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
83
+ """Handle row selection in the violations list."""
84
+ violations_list = self.query_one("#violations-list", ViolationsList)
85
+ if adjudication := violations_list.get_selected_adjudication():
86
+ self.open_trajectory(adjudication.trajectory_id)
87
+
88
+ @work(exclusive=True)
89
+ async def open_trajectory(self, trajectory_id: str) -> None:
90
+ """Fetch trajectory and push to TrajectoryScreen."""
91
+ self.notify(f"Loading trajectory {trajectory_id[:8]}...")
92
+ try:
93
+ trajectory = await self.app.harness.get_trajectory(trajectory_id)
94
+ if trajectory:
95
+ self.app.push_screen(TrajectoryScreen(trajectory))
96
+ else:
97
+ self.notify("Trajectory not found", severity="error")
98
+ except Exception as e:
99
+ self.notify(f"Failed to load trajectory: {e}", severity="error")
100
+
101
+ def on_data_table_row_highlighted(self, event: DataTable.RowHighlighted) -> None:
102
+ """Handle row highlight (cursor movement) in the violations list."""
103
+ violations_list = self.query_one("#violations-list", ViolationsList)
104
+ if adjudication := violations_list.get_selected_adjudication():
105
+ panel = self.query_one("#violation-panel", ViolationPanel)
106
+ panel.adjudication = adjudication
107
+
108
+ def action_refresh(self) -> None:
109
+ """Refresh adjudications."""
110
+ self.load_adjudications()
111
+
112
+ def _get_focusable_widgets(self) -> list[DataTable]:
113
+ """Get list of focusable widgets in order."""
114
+ widgets: list[DataTable] = []
115
+ try:
116
+ violations_list = self.query_one("#violations-list", ViolationsList)
117
+ table = violations_list.query_one(DataTable)
118
+ widgets.append(table)
119
+ except Exception:
120
+ pass
121
+ return widgets
122
+
123
+ def _find_current_index(
124
+ self, widgets: list[DataTable], focused: Widget | None
125
+ ) -> int | None:
126
+ """Find index of currently focused widget."""
127
+ if not focused:
128
+ return None
129
+
130
+ for i, widget in enumerate(widgets):
131
+ if widget == focused:
132
+ return i
133
+ parent = focused.parent
134
+ while parent:
135
+ if parent == widget:
136
+ return i
137
+ parent = parent.parent
138
+ return None
139
+
140
+ def action_focus_next(self) -> None:
141
+ """Move focus to next focusable widget."""
142
+ widgets = self._get_focusable_widgets()
143
+ if not widgets:
144
+ return
145
+
146
+ current_index = self._find_current_index(widgets, self.focused)
147
+ next_index = (
148
+ (current_index + 1) % len(widgets) if current_index is not None else 0
149
+ )
150
+ widgets[next_index].focus()
151
+
152
+ def action_focus_previous(self) -> None:
153
+ """Move focus to previous focusable widget."""
154
+ widgets = self._get_focusable_widgets()
155
+ if not widgets:
156
+ return
157
+
158
+ current_index = self._find_current_index(widgets, self.focused)
159
+ prev_index = (
160
+ (current_index - 1) % len(widgets)
161
+ if current_index is not None
162
+ else len(widgets) - 1
163
+ )
164
+ widgets[prev_index].focus()
165
+
166
+ def action_cursor_down(self) -> None:
167
+ """Move cursor down in focused widget."""
168
+ focused = self.focused
169
+ if isinstance(focused, DataTable):
170
+ focused.action_cursor_down()
171
+
172
+ def action_cursor_up(self) -> None:
173
+ """Move cursor up in focused widget."""
174
+ focused = self.focused
175
+ if isinstance(focused, DataTable):
176
+ focused.action_cursor_up()
177
+
178
+ def action_select_row(self) -> None:
179
+ """Select the current row and open the trajectory."""
180
+ violations_list = self.query_one("#violations-list", ViolationsList)
181
+ if adjudication := violations_list.get_selected_adjudication():
182
+ self.open_trajectory(adjudication.trajectory_id)
183
+ else:
184
+ self.notify("No adjudication selected")
@@ -0,0 +1,158 @@
1
+ from __future__ import annotations
2
+
3
+ from textual import work
4
+ from textual.app import ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal
7
+ from textual.screen import Screen
8
+ from textual.widget import Widget
9
+ from textual.widgets import DataTable, Footer, Header, ListView, Tree
10
+
11
+ from sondera.types import AdjudicatedTrajectory, Agent
12
+
13
+ from ..widgets.agent_card import AgentCard
14
+ from ..widgets.recent_trajectories import RecentTrajectories
15
+ from .trajectory import TrajectoryScreen
16
+
17
+
18
+ class AgentScreen(Screen):
19
+ app: "sondera.tui.app.SonderaApp" # type: ignore[name-defined] # noqa: UP037, F821
20
+ """A screen for displaying agent details and trajectories."""
21
+
22
+ BINDINGS = [
23
+ Binding("escape", "app.pop_screen", "Back to Dashboard"),
24
+ Binding("tab", "focus_next", "Next Widget", show=False),
25
+ Binding("shift+tab", "focus_previous", "Previous Widget", show=False),
26
+ Binding("j", "cursor_down", "Move Down", show=False),
27
+ Binding("k", "cursor_up", "Move Up", show=False),
28
+ Binding("e", "select_trajectory", "Select Trajectory"),
29
+ Binding("r", "refresh", "Refresh"),
30
+ ]
31
+
32
+ def __init__(self, agent: Agent):
33
+ super().__init__()
34
+ self.agent = agent
35
+ self.trajectories: list[AdjudicatedTrajectory] = []
36
+
37
+ def compose(self) -> ComposeResult:
38
+ yield Header()
39
+ with Horizontal(classes="panel"):
40
+ yield AgentCard(agent=self.agent, id="agent-card", classes="card")
41
+ yield RecentTrajectories(id="recent-trajectories", classes="card")
42
+ yield Footer()
43
+
44
+ def on_mount(self) -> None:
45
+ """Initialize the screen."""
46
+ self.sub_title = f"Agent: {self.agent.name}"
47
+ self.load_trajectories()
48
+
49
+ @work(exclusive=True)
50
+ async def load_trajectories(self) -> None:
51
+ """Load trajectories for this agent."""
52
+ self.notify(f"Loading trajectories for {self.agent.name}...")
53
+ trajectories = await self.app.harness.list_trajectories(agent_id=self.agent.id)
54
+ trajectories.sort(key=lambda t: t.created_at, reverse=True)
55
+ recent_trajectories = trajectories[:50] # Limit to 50 most recent
56
+
57
+ # Fetch full adjudicated trajectory details for each
58
+ adjudicated_trajectories: list[AdjudicatedTrajectory] = []
59
+ for trajectory in recent_trajectories:
60
+ traj = await self.app.harness.get_trajectory(trajectory.id)
61
+ if traj:
62
+ adjudicated_trajectories.append(traj)
63
+
64
+ self.trajectories = adjudicated_trajectories
65
+
66
+ # Update the RecentTrajectories widget
67
+ recent_trajectories = self.query_one("#recent-trajectories", RecentTrajectories)
68
+ recent_trajectories.trajectories = self.trajectories
69
+
70
+ def get_selected_trajectory(self) -> AdjudicatedTrajectory | None:
71
+ """Get the currently selected trajectory from the RecentTrajectories widget."""
72
+ recent_trajectories = self.query_one("#recent-trajectories", RecentTrajectories)
73
+ return recent_trajectories.get_selected_trajectory()
74
+
75
+ def action_select_trajectory(self) -> None:
76
+ """Select the current trajectory and push TrajectoryScreen."""
77
+ if trajectory := self.get_selected_trajectory():
78
+ self.app.push_screen(TrajectoryScreen(trajectory))
79
+ else:
80
+ self.notify("No trajectory selected")
81
+
82
+ def action_refresh(self) -> None:
83
+ """Refresh trajectories."""
84
+ self.load_trajectories()
85
+
86
+ def _get_focusable_widgets(self) -> list[ListView | DataTable | Tree]:
87
+ """Get list of focusable widgets in order."""
88
+ widgets: list[ListView | DataTable | Tree] = []
89
+ try:
90
+ tools_list = self.query_one("#tools-list", ListView)
91
+ widgets.append(tools_list)
92
+ except Exception:
93
+ pass # Widget is optional; if not present, just skip it
94
+ try:
95
+ recent_trajectories = self.query_one(
96
+ "#recent-trajectories", RecentTrajectories
97
+ )
98
+ # Get the DataTable from within the RecentTrajectories widget
99
+ table = recent_trajectories.query_one(DataTable)
100
+ widgets.append(table)
101
+ except Exception:
102
+ pass # Widget is optional; if not present, just skip it
103
+ return widgets
104
+
105
+ def _find_current_index(
106
+ self, widgets: list[ListView | DataTable | Tree], focused: Widget | None
107
+ ) -> int | None:
108
+ """Find index of currently focused widget."""
109
+ if not focused:
110
+ return None
111
+
112
+ for i, widget in enumerate(widgets):
113
+ if widget == focused:
114
+ return i
115
+ parent = focused.parent
116
+ while parent:
117
+ if parent == widget:
118
+ return i
119
+ parent = parent.parent
120
+ return None
121
+
122
+ def action_focus_next(self) -> None:
123
+ """Move focus to next focusable widget."""
124
+ widgets = self._get_focusable_widgets()
125
+ if not widgets:
126
+ return
127
+
128
+ current_index = self._find_current_index(widgets, self.focused)
129
+ next_index = (
130
+ (current_index + 1) % len(widgets) if current_index is not None else 0
131
+ )
132
+ widgets[next_index].focus()
133
+
134
+ def action_focus_previous(self) -> None:
135
+ """Move focus to previous focusable widget."""
136
+ widgets = self._get_focusable_widgets()
137
+ if not widgets:
138
+ return
139
+
140
+ current_index = self._find_current_index(widgets, self.focused)
141
+ prev_index = (
142
+ (current_index - 1) % len(widgets)
143
+ if current_index is not None
144
+ else len(widgets) - 1
145
+ )
146
+ widgets[prev_index].focus()
147
+
148
+ def action_cursor_down(self) -> None:
149
+ """Move cursor down in focused widget."""
150
+ focused = self.focused
151
+ if isinstance(focused, (DataTable, ListView)):
152
+ focused.action_cursor_down()
153
+
154
+ def action_cursor_up(self) -> None:
155
+ """Move cursor up in focused widget."""
156
+ focused = self.focused
157
+ if isinstance(focused, (DataTable, ListView)):
158
+ focused.action_cursor_up()