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
@@ -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")