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.
- sondera/__init__.py +111 -0
- sondera/__main__.py +4 -0
- sondera/adk/__init__.py +3 -0
- sondera/adk/analyze.py +222 -0
- sondera/adk/plugin.py +387 -0
- sondera/cli.py +22 -0
- sondera/exceptions.py +167 -0
- sondera/harness/__init__.py +6 -0
- sondera/harness/abc.py +102 -0
- sondera/harness/cedar/__init__.py +0 -0
- sondera/harness/cedar/harness.py +363 -0
- sondera/harness/cedar/schema.py +225 -0
- sondera/harness/sondera/__init__.py +0 -0
- sondera/harness/sondera/_grpc.py +354 -0
- sondera/harness/sondera/harness.py +890 -0
- sondera/langgraph/__init__.py +15 -0
- sondera/langgraph/analyze.py +543 -0
- sondera/langgraph/exceptions.py +19 -0
- sondera/langgraph/graph.py +210 -0
- sondera/langgraph/middleware.py +454 -0
- sondera/proto/google/protobuf/any_pb2.py +37 -0
- sondera/proto/google/protobuf/any_pb2.pyi +14 -0
- sondera/proto/google/protobuf/any_pb2_grpc.py +24 -0
- sondera/proto/google/protobuf/duration_pb2.py +37 -0
- sondera/proto/google/protobuf/duration_pb2.pyi +14 -0
- sondera/proto/google/protobuf/duration_pb2_grpc.py +24 -0
- sondera/proto/google/protobuf/empty_pb2.py +37 -0
- sondera/proto/google/protobuf/empty_pb2.pyi +9 -0
- sondera/proto/google/protobuf/empty_pb2_grpc.py +24 -0
- sondera/proto/google/protobuf/struct_pb2.py +47 -0
- sondera/proto/google/protobuf/struct_pb2.pyi +49 -0
- sondera/proto/google/protobuf/struct_pb2_grpc.py +24 -0
- sondera/proto/google/protobuf/timestamp_pb2.py +37 -0
- sondera/proto/google/protobuf/timestamp_pb2.pyi +14 -0
- sondera/proto/google/protobuf/timestamp_pb2_grpc.py +24 -0
- sondera/proto/google/protobuf/wrappers_pb2.py +53 -0
- sondera/proto/google/protobuf/wrappers_pb2.pyi +59 -0
- sondera/proto/google/protobuf/wrappers_pb2_grpc.py +24 -0
- sondera/proto/sondera/__init__.py +0 -0
- sondera/proto/sondera/core/__init__.py +0 -0
- sondera/proto/sondera/core/v1/__init__.py +0 -0
- sondera/proto/sondera/core/v1/primitives_pb2.py +88 -0
- sondera/proto/sondera/core/v1/primitives_pb2.pyi +259 -0
- sondera/proto/sondera/core/v1/primitives_pb2_grpc.py +24 -0
- sondera/proto/sondera/harness/__init__.py +0 -0
- sondera/proto/sondera/harness/v1/__init__.py +0 -0
- sondera/proto/sondera/harness/v1/harness_pb2.py +81 -0
- sondera/proto/sondera/harness/v1/harness_pb2.pyi +192 -0
- sondera/proto/sondera/harness/v1/harness_pb2_grpc.py +498 -0
- sondera/py.typed +0 -0
- sondera/settings.py +20 -0
- sondera/strands/__init__.py +5 -0
- sondera/strands/analyze.py +244 -0
- sondera/strands/harness.py +333 -0
- sondera/tui/__init__.py +0 -0
- sondera/tui/app.py +309 -0
- sondera/tui/screens/__init__.py +5 -0
- sondera/tui/screens/adjudication.py +184 -0
- sondera/tui/screens/agent.py +158 -0
- sondera/tui/screens/trajectory.py +158 -0
- sondera/tui/widgets/__init__.py +23 -0
- sondera/tui/widgets/agent_card.py +94 -0
- sondera/tui/widgets/agent_list.py +73 -0
- sondera/tui/widgets/recent_adjudications.py +52 -0
- sondera/tui/widgets/recent_trajectories.py +54 -0
- sondera/tui/widgets/summary.py +57 -0
- sondera/tui/widgets/tool_card.py +33 -0
- sondera/tui/widgets/violation_panel.py +72 -0
- sondera/tui/widgets/violations_list.py +78 -0
- sondera/tui/widgets/violations_summary.py +104 -0
- sondera/types.py +346 -0
- sondera_harness-0.6.0.dist-info/METADATA +323 -0
- sondera_harness-0.6.0.dist-info/RECORD +77 -0
- sondera_harness-0.6.0.dist-info/WHEEL +5 -0
- sondera_harness-0.6.0.dist-info/entry_points.txt +2 -0
- sondera_harness-0.6.0.dist-info/licenses/LICENSE +21 -0
- 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,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()
|