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
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.binding import Binding
|
|
3
|
+
from textual.containers import Container
|
|
4
|
+
from textual.screen import Screen
|
|
5
|
+
from textual.widget import Widget
|
|
6
|
+
from textual.widgets import Footer, Header, ListItem, ListView, Markdown, Pretty, Tree
|
|
7
|
+
|
|
8
|
+
from sondera.types import AdjudicatedTrajectory
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TrajectoryScreen(Screen):
|
|
12
|
+
"""A screen for displaying a trajectory."""
|
|
13
|
+
|
|
14
|
+
BINDINGS = [
|
|
15
|
+
Binding("escape", "app.pop_screen", "Back to Dashboard"),
|
|
16
|
+
Binding("tab", "focus_next", "Next Widget", show=False),
|
|
17
|
+
Binding("shift+tab", "focus_previous", "Previous Widget", show=False),
|
|
18
|
+
Binding("j", "cursor_down", "Move Down", show=False),
|
|
19
|
+
Binding("k", "cursor_up", "Move Up", show=False),
|
|
20
|
+
Binding("h", "cursor_left", "Move Left", show=False),
|
|
21
|
+
Binding("l", "cursor_right", "Move Right", show=False),
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
def __init__(self, trajectory: AdjudicatedTrajectory):
|
|
25
|
+
super().__init__()
|
|
26
|
+
self.trajectory = trajectory
|
|
27
|
+
|
|
28
|
+
def compose(self) -> ComposeResult:
|
|
29
|
+
yield Header()
|
|
30
|
+
with Container(id="sidebar"):
|
|
31
|
+
tree = Tree(f"Trajectory {self.trajectory.id}", id="trajectory-tree")
|
|
32
|
+
tree.root.expand()
|
|
33
|
+
for i, step in enumerate(self.trajectory.steps):
|
|
34
|
+
if step.step.role.value == "model":
|
|
35
|
+
name = "🤖"
|
|
36
|
+
elif step.step.role.value == "tool":
|
|
37
|
+
tool_id = step.step.content.tool_id
|
|
38
|
+
name = f"🛠 [bold]({tool_id})[/bold]"
|
|
39
|
+
elif step.step.role.value == "user":
|
|
40
|
+
name = "👤"
|
|
41
|
+
elif step.step.role.value == "system":
|
|
42
|
+
name = "💻"
|
|
43
|
+
else:
|
|
44
|
+
name = "UNKNOWN"
|
|
45
|
+
tree.root.add(f"[{i}] {name}")
|
|
46
|
+
yield tree
|
|
47
|
+
with ListView(id="trajectory-list"):
|
|
48
|
+
for i, step in enumerate(self.trajectory.steps):
|
|
49
|
+
with ListItem():
|
|
50
|
+
if step.step.content.content_type == "prompt":
|
|
51
|
+
row = Markdown(step.step.content.text, classes="step")
|
|
52
|
+
else:
|
|
53
|
+
row = Pretty(step.step.content, classes="step")
|
|
54
|
+
title = f"Stage:{step.step.stage.value} | Decision:{step.adjudication.decision.value}"
|
|
55
|
+
if step.step.role.value == "model":
|
|
56
|
+
title = "🤖 [bold]MODEL[/bold] | " + title
|
|
57
|
+
elif step.step.role.value == "tool":
|
|
58
|
+
title = "🛠 [bold]TOOL[/bold] | " + title
|
|
59
|
+
elif step.step.role.value == "user":
|
|
60
|
+
title = "👤 [bold]USER[/bold] | " + title
|
|
61
|
+
elif step.step.role.value == "system":
|
|
62
|
+
title = "💻 [bold]SYSTEM[/bold] | " + title
|
|
63
|
+
row.border_title = f"[{i}] {title}"
|
|
64
|
+
row.border_subtitle = step.step.created_at.strftime(
|
|
65
|
+
"%Y-%m-%d %H:%M:%S"
|
|
66
|
+
)
|
|
67
|
+
yield row
|
|
68
|
+
yield Footer()
|
|
69
|
+
|
|
70
|
+
def on_mount(self) -> None:
|
|
71
|
+
"""Ensure widgets can receive focus."""
|
|
72
|
+
tree = self.query_one("#trajectory-tree", Tree)
|
|
73
|
+
list_view = self.query_one("#trajectory-list", ListView)
|
|
74
|
+
tree.can_focus = True
|
|
75
|
+
list_view.can_focus = True
|
|
76
|
+
# Focus the list view by default
|
|
77
|
+
list_view.focus()
|
|
78
|
+
|
|
79
|
+
def _get_focusable_widgets(self) -> list[Tree | ListView]:
|
|
80
|
+
"""Get list of focusable widgets in order."""
|
|
81
|
+
return [
|
|
82
|
+
self.query_one("#trajectory-tree", Tree),
|
|
83
|
+
self.query_one("#trajectory-list", ListView),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def _find_current_index(
|
|
87
|
+
self, widgets: list[Tree | ListView], focused: Widget | None
|
|
88
|
+
) -> int | None:
|
|
89
|
+
"""Find index of currently focused widget."""
|
|
90
|
+
if not focused:
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
for i, widget in enumerate(widgets):
|
|
94
|
+
if widget == focused:
|
|
95
|
+
return i
|
|
96
|
+
# Check parent chain
|
|
97
|
+
parent = focused.parent
|
|
98
|
+
while parent:
|
|
99
|
+
if parent == widget:
|
|
100
|
+
return i
|
|
101
|
+
parent = parent.parent
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def action_focus_next(self) -> None:
|
|
105
|
+
"""Move focus to next focusable widget."""
|
|
106
|
+
widgets = self._get_focusable_widgets()
|
|
107
|
+
if not widgets:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
current_index = self._find_current_index(widgets, self.focused)
|
|
111
|
+
next_index = (
|
|
112
|
+
(current_index + 1) % len(widgets) if current_index is not None else 0
|
|
113
|
+
)
|
|
114
|
+
widgets[next_index].focus()
|
|
115
|
+
|
|
116
|
+
def action_focus_previous(self) -> None:
|
|
117
|
+
"""Move focus to previous focusable widget."""
|
|
118
|
+
widgets = self._get_focusable_widgets()
|
|
119
|
+
if not widgets:
|
|
120
|
+
return
|
|
121
|
+
|
|
122
|
+
current_index = self._find_current_index(widgets, self.focused)
|
|
123
|
+
prev_index = (
|
|
124
|
+
(current_index - 1) % len(widgets)
|
|
125
|
+
if current_index is not None
|
|
126
|
+
else len(widgets) - 1
|
|
127
|
+
)
|
|
128
|
+
widgets[prev_index].focus()
|
|
129
|
+
|
|
130
|
+
def action_cursor_left(self) -> None:
|
|
131
|
+
"""Move cursor left or go to parent node."""
|
|
132
|
+
focused = self.focused
|
|
133
|
+
if isinstance(focused, Tree):
|
|
134
|
+
focused.action_cursor_parent()
|
|
135
|
+
elif isinstance(focused, ListView):
|
|
136
|
+
# ListView doesn't have left/right, so do nothing
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def action_cursor_right(self) -> None:
|
|
140
|
+
"""Move cursor right or go to next sibling."""
|
|
141
|
+
focused = self.focused
|
|
142
|
+
if isinstance(focused, Tree):
|
|
143
|
+
focused.action_cursor_next_sibling()
|
|
144
|
+
elif isinstance(focused, ListView):
|
|
145
|
+
# ListView doesn't have left/right, so do nothing
|
|
146
|
+
pass
|
|
147
|
+
|
|
148
|
+
def action_cursor_up(self) -> None:
|
|
149
|
+
"""Move cursor up."""
|
|
150
|
+
focused = self.focused
|
|
151
|
+
if isinstance(focused, (Tree, ListView)):
|
|
152
|
+
focused.action_cursor_up()
|
|
153
|
+
|
|
154
|
+
def action_cursor_down(self) -> None:
|
|
155
|
+
"""Move cursor down."""
|
|
156
|
+
focused = self.focused
|
|
157
|
+
if isinstance(focused, (Tree, ListView)):
|
|
158
|
+
focused.action_cursor_down()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
from .agent_card import AgentCard
|
|
2
|
+
from .agent_list import AgentItem, AgentList, AgentRecord
|
|
3
|
+
from .recent_adjudications import RecentAdjudications
|
|
4
|
+
from .recent_trajectories import RecentTrajectories
|
|
5
|
+
from .summary import Summary
|
|
6
|
+
from .tool_card import ToolCard
|
|
7
|
+
from .violation_panel import ViolationPanel
|
|
8
|
+
from .violations_list import ViolationsList
|
|
9
|
+
from .violations_summary import ViolationsSummary
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"AgentCard",
|
|
13
|
+
"AgentItem",
|
|
14
|
+
"AgentList",
|
|
15
|
+
"AgentRecord",
|
|
16
|
+
"RecentAdjudications",
|
|
17
|
+
"RecentTrajectories",
|
|
18
|
+
"Summary",
|
|
19
|
+
"ToolCard",
|
|
20
|
+
"ViolationPanel",
|
|
21
|
+
"ViolationsList",
|
|
22
|
+
"ViolationsSummary",
|
|
23
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.containers import Grid, Vertical
|
|
3
|
+
from textual.reactive import reactive
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import ListView, Static
|
|
6
|
+
|
|
7
|
+
from sondera.types import Agent
|
|
8
|
+
|
|
9
|
+
from .tool_card import ToolCard
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AgentCard(Widget):
|
|
13
|
+
"""A reusable widget for displaying agent details in a grid layout."""
|
|
14
|
+
|
|
15
|
+
BORDER_TITLE = "Agent Details"
|
|
16
|
+
|
|
17
|
+
agent: reactive[Agent | None] = reactive(None)
|
|
18
|
+
|
|
19
|
+
def __init__(self, agent: Agent | None = None, **kwargs):
|
|
20
|
+
super().__init__(**kwargs)
|
|
21
|
+
self.agent = agent
|
|
22
|
+
|
|
23
|
+
def compose(self) -> ComposeResult:
|
|
24
|
+
with Vertical(id="agent-card-container"):
|
|
25
|
+
with Grid(id="agent-details-grid", classes="agent-details-grid"):
|
|
26
|
+
yield Static("[bold]ID:[/bold]", classes="label")
|
|
27
|
+
yield Static(
|
|
28
|
+
self.agent.id if self.agent else "N/A",
|
|
29
|
+
id="agent-id",
|
|
30
|
+
classes="value",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
yield Static("[bold]Name:[/bold]", classes="label")
|
|
34
|
+
yield Static(
|
|
35
|
+
self.agent.name if self.agent else "N/A",
|
|
36
|
+
id="agent-name",
|
|
37
|
+
classes="value",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
yield Static("[bold]Provider:[/bold]", classes="label")
|
|
41
|
+
yield Static(
|
|
42
|
+
self.agent.provider_id if self.agent else "N/A",
|
|
43
|
+
id="agent-provider",
|
|
44
|
+
classes="value",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
yield Static("[bold]Description:[/bold]", classes="label")
|
|
48
|
+
yield Static(
|
|
49
|
+
self.agent.description if self.agent else "N/A",
|
|
50
|
+
id="agent-description",
|
|
51
|
+
classes="value description",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
yield Static("[bold]Instruction:[/bold]", classes="label")
|
|
55
|
+
yield Static(
|
|
56
|
+
self.agent.instruction if self.agent else "N/A",
|
|
57
|
+
id="agent-instruction",
|
|
58
|
+
classes="value instruction",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
yield Static("[bold]Tools[/bold]", classes="section-header")
|
|
62
|
+
yield ListView(id="tools-list")
|
|
63
|
+
|
|
64
|
+
def on_mount(self) -> None:
|
|
65
|
+
"""Populate tools list on mount."""
|
|
66
|
+
self._update_tools_list()
|
|
67
|
+
|
|
68
|
+
def watch_agent(self, _old_agent: Agent | None, new_agent: Agent | None) -> None:
|
|
69
|
+
"""Update display when agent changes."""
|
|
70
|
+
if new_agent:
|
|
71
|
+
self._update_agent_details(new_agent)
|
|
72
|
+
self._update_tools_list()
|
|
73
|
+
|
|
74
|
+
def _update_agent_details(self, agent: Agent) -> None:
|
|
75
|
+
"""Update the agent detail fields."""
|
|
76
|
+
try:
|
|
77
|
+
self.query_one("#agent-id", Static).update(agent.id)
|
|
78
|
+
self.query_one("#agent-name", Static).update(agent.name)
|
|
79
|
+
self.query_one("#agent-provider", Static).update(agent.provider_id)
|
|
80
|
+
self.query_one("#agent-description", Static).update(agent.description)
|
|
81
|
+
self.query_one("#agent-instruction", Static).update(agent.instruction)
|
|
82
|
+
except Exception:
|
|
83
|
+
pass # Widgets may not be mounted yet during reactive updates
|
|
84
|
+
|
|
85
|
+
def _update_tools_list(self) -> None:
|
|
86
|
+
"""Update the tools ListView."""
|
|
87
|
+
try:
|
|
88
|
+
tools_list = self.query_one("#tools-list", ListView)
|
|
89
|
+
tools_list.clear()
|
|
90
|
+
if self.agent and self.agent.tools:
|
|
91
|
+
for tool in self.agent.tools:
|
|
92
|
+
tools_list.append(ToolCard(tool))
|
|
93
|
+
except Exception:
|
|
94
|
+
pass # Widget may not be mounted yet during reactive updates
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
from textual.app import ComposeResult
|
|
3
|
+
from textual.containers import Horizontal, HorizontalScroll
|
|
4
|
+
from textual.reactive import reactive
|
|
5
|
+
from textual.widget import Widget
|
|
6
|
+
from textual.widgets import Digits, ListItem, ListView, Markdown, Sparkline, Static
|
|
7
|
+
|
|
8
|
+
from sondera.types import Agent
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AgentRecord(BaseModel):
|
|
12
|
+
agent: Agent
|
|
13
|
+
total_trajectories: int
|
|
14
|
+
recent_trajectories: list[int] = [1, 2, 2, 1, 1, 4, 3, 1, 1, 8, 8, 2]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AgentItem(ListItem):
|
|
18
|
+
def __init__(self, element: AgentRecord):
|
|
19
|
+
super().__init__()
|
|
20
|
+
self.element = element
|
|
21
|
+
self.border_title = self.element.agent.name
|
|
22
|
+
|
|
23
|
+
def compose(self) -> ComposeResult:
|
|
24
|
+
with HorizontalScroll():
|
|
25
|
+
yield Static(self.element.agent.id, classes="column")
|
|
26
|
+
yield Static(self.element.agent.provider_id or "", classes="column")
|
|
27
|
+
yield Markdown(self.element.agent.description, classes="column")
|
|
28
|
+
tools = ", ".join([tool.name for tool in self.element.agent.tools])
|
|
29
|
+
yield Static(tools, classes="column")
|
|
30
|
+
yield Digits(str(self.element.total_trajectories))
|
|
31
|
+
yield Sparkline(
|
|
32
|
+
self.element.recent_trajectories, summary_function=max, classes="column"
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class AgentList(Widget):
|
|
37
|
+
BORDER_TITLE = "Agents"
|
|
38
|
+
BORDER_SUBTITLE = "List of agents"
|
|
39
|
+
|
|
40
|
+
records: reactive[list[AgentRecord]] = reactive([])
|
|
41
|
+
|
|
42
|
+
def compose(self) -> ComposeResult:
|
|
43
|
+
with Horizontal(classes="header"):
|
|
44
|
+
yield Static("Id", classes="header-column")
|
|
45
|
+
yield Static("Provider", classes="header-column")
|
|
46
|
+
yield Static("Description", classes="header-column")
|
|
47
|
+
yield Static("Tools", classes="header-column")
|
|
48
|
+
yield Static("Total Trajectories", classes="header-column")
|
|
49
|
+
yield Static("Recent Trajectories", classes="header-column")
|
|
50
|
+
yield ListView()
|
|
51
|
+
|
|
52
|
+
def on_mount(self) -> None:
|
|
53
|
+
"""Ensure ListView can receive focus."""
|
|
54
|
+
list_view = self.query_one(ListView)
|
|
55
|
+
list_view.can_focus = True
|
|
56
|
+
|
|
57
|
+
def watch_records(
|
|
58
|
+
self, _old_agents: list[AgentRecord], new_agents: list[AgentRecord]
|
|
59
|
+
) -> None:
|
|
60
|
+
"""Update the list view when agents change."""
|
|
61
|
+
list_view = self.query_one(ListView)
|
|
62
|
+
list_view.clear()
|
|
63
|
+
for agent in new_agents:
|
|
64
|
+
list_view.append(AgentItem(agent))
|
|
65
|
+
|
|
66
|
+
def get_selected_agent(self) -> Agent | None:
|
|
67
|
+
"""Get the currently selected agent from the list."""
|
|
68
|
+
list_view = self.query_one(ListView)
|
|
69
|
+
if list_view.highlighted_child is not None and isinstance(
|
|
70
|
+
list_view.highlighted_child, AgentItem
|
|
71
|
+
):
|
|
72
|
+
return list_view.highlighted_child.element.agent
|
|
73
|
+
return None
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.reactive import reactive
|
|
3
|
+
from textual.widget import Widget
|
|
4
|
+
from textual.widgets import DataTable
|
|
5
|
+
|
|
6
|
+
from sondera.types import AdjudicationRecord
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RecentAdjudications(Widget):
|
|
10
|
+
BORDER_TITLE = "Recent Adjudications"
|
|
11
|
+
|
|
12
|
+
adjudications: reactive[list[AdjudicationRecord]] = reactive([])
|
|
13
|
+
agents_map: reactive[dict[str, str]] = reactive({})
|
|
14
|
+
|
|
15
|
+
HEADERS = ["Id", "Agent", "Provider", "Decision", "Reason"]
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
yield DataTable()
|
|
19
|
+
|
|
20
|
+
def on_mount(self) -> None:
|
|
21
|
+
table = self.query_one(DataTable)
|
|
22
|
+
table.add_columns(*self.HEADERS)
|
|
23
|
+
table.cursor_type = "row"
|
|
24
|
+
table.zebra_stripes = True
|
|
25
|
+
table.can_focus = True
|
|
26
|
+
|
|
27
|
+
def watch_adjudications(
|
|
28
|
+
self,
|
|
29
|
+
_old_adjudications: list[AdjudicationRecord],
|
|
30
|
+
new_adjudications: list[AdjudicationRecord],
|
|
31
|
+
) -> None:
|
|
32
|
+
table = self.query_one(DataTable)
|
|
33
|
+
table.clear()
|
|
34
|
+
for record in new_adjudications:
|
|
35
|
+
provider = self.agents_map.get(record.agent_id, "N/A")
|
|
36
|
+
table.add_row(
|
|
37
|
+
record.trajectory_id[:6] + "...",
|
|
38
|
+
record.agent_id,
|
|
39
|
+
provider,
|
|
40
|
+
record.adjudication.decision.value.upper(),
|
|
41
|
+
record.adjudication.reason[:30] + "..."
|
|
42
|
+
if len(record.adjudication.reason) > 30
|
|
43
|
+
else record.adjudication.reason,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def get_selected_adjudication(self) -> AdjudicationRecord | None:
|
|
47
|
+
"""Get the currently selected adjudication from the table."""
|
|
48
|
+
table = self.query_one(DataTable)
|
|
49
|
+
cursor_row = table.cursor_row
|
|
50
|
+
if cursor_row is not None and 0 <= cursor_row < len(self.adjudications):
|
|
51
|
+
return self.adjudications[cursor_row]
|
|
52
|
+
return None
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.reactive import reactive
|
|
3
|
+
from textual.widget import Widget
|
|
4
|
+
from textual.widgets import DataTable
|
|
5
|
+
|
|
6
|
+
from sondera.types import AdjudicatedTrajectory
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RecentTrajectories(Widget):
|
|
10
|
+
BORDER_TITLE = "Recent Trajectories"
|
|
11
|
+
|
|
12
|
+
trajectories: reactive[list[AdjudicatedTrajectory]] = reactive([])
|
|
13
|
+
agents_map: reactive[dict[str, str]] = reactive({})
|
|
14
|
+
|
|
15
|
+
HEADERS = ["Id", "Agent", "Provider", "Status", "Turns", "Last Active"]
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
yield DataTable()
|
|
19
|
+
|
|
20
|
+
def on_mount(self) -> None:
|
|
21
|
+
table = self.query_one(DataTable)
|
|
22
|
+
table.add_columns(*self.HEADERS)
|
|
23
|
+
table.cursor_type = "row"
|
|
24
|
+
table.zebra_stripes = True
|
|
25
|
+
# Ensure table can receive focus
|
|
26
|
+
table.can_focus = True
|
|
27
|
+
|
|
28
|
+
def watch_trajectories(
|
|
29
|
+
self,
|
|
30
|
+
_old_trajectories: list[AdjudicatedTrajectory],
|
|
31
|
+
new_trajectories: list[AdjudicatedTrajectory],
|
|
32
|
+
) -> None:
|
|
33
|
+
table = self.query_one(DataTable)
|
|
34
|
+
table.clear()
|
|
35
|
+
for trajectory in new_trajectories:
|
|
36
|
+
provider = self.agents_map.get(trajectory.agent_id, "N/A")
|
|
37
|
+
table.add_row(
|
|
38
|
+
trajectory.id[:6] + "...",
|
|
39
|
+
trajectory.agent_id,
|
|
40
|
+
provider,
|
|
41
|
+
trajectory.status.value,
|
|
42
|
+
len(trajectory.steps),
|
|
43
|
+
trajectory.updated_at.strftime("%Y-%m-%d %H:%M:%S")
|
|
44
|
+
if trajectory.ended_at
|
|
45
|
+
else "N/A",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def get_selected_trajectory(self) -> AdjudicatedTrajectory | None:
|
|
49
|
+
"""Get the currently selected trajectory from the table."""
|
|
50
|
+
table = self.query_one(DataTable)
|
|
51
|
+
cursor_row = table.cursor_row
|
|
52
|
+
if cursor_row is not None and 0 <= cursor_row < len(self.trajectories):
|
|
53
|
+
return self.trajectories[cursor_row]
|
|
54
|
+
return None
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.containers import Container, Horizontal
|
|
3
|
+
from textual.reactive import reactive
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Digits, Sparkline, Static
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Summary(Widget):
|
|
9
|
+
BORDER_TITLE = "Summary"
|
|
10
|
+
|
|
11
|
+
running = reactive(0, recompose=True)
|
|
12
|
+
suspended = reactive(0, recompose=True)
|
|
13
|
+
completed = reactive(0, recompose=True)
|
|
14
|
+
failed = reactive(0, recompose=True)
|
|
15
|
+
pending = reactive(0, recompose=True)
|
|
16
|
+
|
|
17
|
+
violations = reactive(0, recompose=True)
|
|
18
|
+
approved = reactive(0, recompose=True)
|
|
19
|
+
|
|
20
|
+
def compose(self) -> ComposeResult:
|
|
21
|
+
with Horizontal(classes="summary-section"):
|
|
22
|
+
yield Static("[bold]Trajectories[/bold]", classes="section-label")
|
|
23
|
+
with Container(classes="stat"):
|
|
24
|
+
yield Static("Running", classes="stat-label")
|
|
25
|
+
yield Digits(str(self.running), classes="stat-running")
|
|
26
|
+
with Container(classes="stat"):
|
|
27
|
+
yield Static("Suspended", classes="stat-label")
|
|
28
|
+
yield Digits(str(self.suspended), classes="stat-suspended")
|
|
29
|
+
with Container(classes="stat"):
|
|
30
|
+
yield Static("Completed", classes="stat-label")
|
|
31
|
+
yield Digits(str(self.completed), classes="stat-completed")
|
|
32
|
+
with Container(classes="stat"):
|
|
33
|
+
yield Static("Pending", classes="stat-label")
|
|
34
|
+
yield Digits(str(self.pending), classes="stat-pending")
|
|
35
|
+
with Container(classes="stat"):
|
|
36
|
+
yield Static("Failed", classes="stat-label")
|
|
37
|
+
yield Digits(str(self.failed), classes="stat-failed")
|
|
38
|
+
with Horizontal(classes="summary-section"):
|
|
39
|
+
yield Static("[bold]Policies[/bold]", classes="section-label")
|
|
40
|
+
with Container(classes="stat"):
|
|
41
|
+
yield Static("Violations", classes="stat-label")
|
|
42
|
+
yield Digits(str(self.violations), classes="stat-violations")
|
|
43
|
+
with Container(classes="stat"):
|
|
44
|
+
yield Static("Approved", classes="stat-label")
|
|
45
|
+
yield Digits(str(self.approved), classes="stat-approved")
|
|
46
|
+
with Horizontal(classes="summary-section"):
|
|
47
|
+
yield Static("[bold]Utility[/bold]", classes="section-label")
|
|
48
|
+
with Container(classes="stat"):
|
|
49
|
+
yield Static("Tokens: 0 tokens", classes="stat-label")
|
|
50
|
+
yield Sparkline(
|
|
51
|
+
[0, 0, 100, 200, 300, 400, 500, 600, 700, 800], summary_function=max
|
|
52
|
+
)
|
|
53
|
+
with Container(classes="stat"):
|
|
54
|
+
yield Static("Cost: $0", classes="stat-label")
|
|
55
|
+
yield Sparkline(
|
|
56
|
+
[0, 0, 100, 200, 300, 400, 500, 600, 700, 800], summary_function=max
|
|
57
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.containers import Vertical
|
|
3
|
+
from textual.widgets import ListItem, Static
|
|
4
|
+
|
|
5
|
+
from sondera.types import Tool
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ToolCard(ListItem):
|
|
9
|
+
"""A reusable widget for displaying tool details as a ListItem."""
|
|
10
|
+
|
|
11
|
+
def __init__(self, tool: Tool):
|
|
12
|
+
super().__init__()
|
|
13
|
+
self.tool = tool
|
|
14
|
+
self.border_title = f"🛠 {tool.name}"
|
|
15
|
+
|
|
16
|
+
def compose(self) -> ComposeResult:
|
|
17
|
+
with Vertical(classes="tool-card-content"):
|
|
18
|
+
yield Static(
|
|
19
|
+
f"[bold]Description:[/bold] {self.tool.description}",
|
|
20
|
+
classes="tool-description",
|
|
21
|
+
)
|
|
22
|
+
if self.tool.parameters:
|
|
23
|
+
params_text = ", ".join(
|
|
24
|
+
f"[cyan]{p.name}[/cyan]: {p.type}" for p in self.tool.parameters
|
|
25
|
+
)
|
|
26
|
+
yield Static(
|
|
27
|
+
f"[bold]Parameters:[/bold] {params_text}", classes="tool-parameters"
|
|
28
|
+
)
|
|
29
|
+
if self.tool.response:
|
|
30
|
+
yield Static(
|
|
31
|
+
f"[bold]Returns:[/bold] {self.tool.response}",
|
|
32
|
+
classes="tool-response",
|
|
33
|
+
)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from textual.app import ComposeResult
|
|
2
|
+
from textual.containers import Container, Grid, VerticalScroll
|
|
3
|
+
from textual.reactive import reactive
|
|
4
|
+
from textual.widget import Widget
|
|
5
|
+
from textual.widgets import Markdown, Static
|
|
6
|
+
|
|
7
|
+
from sondera.types import AdjudicationRecord, Decision
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ViolationPanel(Widget):
|
|
11
|
+
"""Widget displaying detailed information about a selected violation/adjudication."""
|
|
12
|
+
|
|
13
|
+
BORDER_TITLE = "Violation Details"
|
|
14
|
+
|
|
15
|
+
adjudication: reactive[AdjudicationRecord | None] = reactive(None, recompose=True)
|
|
16
|
+
|
|
17
|
+
def compose(self) -> ComposeResult:
|
|
18
|
+
if self.adjudication is None:
|
|
19
|
+
yield Static(
|
|
20
|
+
"Select an adjudication to view details", classes="empty-message"
|
|
21
|
+
)
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
record = self.adjudication
|
|
25
|
+
decision = record.adjudication.decision
|
|
26
|
+
|
|
27
|
+
# Decision badge
|
|
28
|
+
if decision == Decision.DENY:
|
|
29
|
+
decision_class = "decision-deny"
|
|
30
|
+
decision_text = "❌ DENIED"
|
|
31
|
+
elif decision == Decision.ESCALATE:
|
|
32
|
+
decision_class = "decision-escalate"
|
|
33
|
+
decision_text = "⚠️ ESCALATED"
|
|
34
|
+
else:
|
|
35
|
+
decision_class = "decision-allow"
|
|
36
|
+
decision_text = "✅ ALLOWED"
|
|
37
|
+
|
|
38
|
+
yield Static(decision_text, classes=f"decision-badge {decision_class}")
|
|
39
|
+
|
|
40
|
+
# Details grid
|
|
41
|
+
with Grid(id="violation-details-grid"):
|
|
42
|
+
yield Static("Agent ID:", classes="label")
|
|
43
|
+
yield Static(record.agent_id, classes="value")
|
|
44
|
+
|
|
45
|
+
yield Static("Trajectory ID:", classes="label")
|
|
46
|
+
yield Static(record.trajectory_id, classes="value")
|
|
47
|
+
|
|
48
|
+
yield Static("Step ID:", classes="label")
|
|
49
|
+
yield Static(record.step_id, classes="value")
|
|
50
|
+
|
|
51
|
+
# Reason section
|
|
52
|
+
yield Static("[bold]Reason[/bold]", classes="section-header")
|
|
53
|
+
with Container(classes="reason-container"):
|
|
54
|
+
yield Markdown(record.adjudication.reason, classes="reason-text")
|
|
55
|
+
|
|
56
|
+
# Annotations section
|
|
57
|
+
annotations = record.adjudication.annotations
|
|
58
|
+
if annotations:
|
|
59
|
+
yield Static("[bold]Policy Annotations[/bold]", classes="section-header")
|
|
60
|
+
with VerticalScroll(classes="annotations-container"):
|
|
61
|
+
for ann in annotations:
|
|
62
|
+
with Container(classes="annotation-card"):
|
|
63
|
+
yield Static(f"[bold]{ann.id}[/bold]", classes="annotation-id")
|
|
64
|
+
if ann.description:
|
|
65
|
+
yield Static(
|
|
66
|
+
ann.description, classes="annotation-description"
|
|
67
|
+
)
|
|
68
|
+
if ann.custom:
|
|
69
|
+
with Grid(classes="annotation-custom-grid"):
|
|
70
|
+
for key, value in ann.custom.items():
|
|
71
|
+
yield Static(f"{key}:", classes="label")
|
|
72
|
+
yield Static(value, classes="value")
|