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,78 @@
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, Decision
7
+
8
+
9
+ class ViolationsList(Widget):
10
+ """Widget displaying a list of violations/adjudications in a table format."""
11
+
12
+ BORDER_TITLE = "Adjudications"
13
+
14
+ adjudications: reactive[list[AdjudicationRecord]] = reactive([])
15
+
16
+ HEADERS = ["Decision", "Agent", "Trajectory", "Step", "Reason", "Annotations"]
17
+
18
+ def compose(self) -> ComposeResult:
19
+ yield DataTable()
20
+
21
+ def on_mount(self) -> None:
22
+ table = self.query_one(DataTable)
23
+ table.add_columns(*self.HEADERS)
24
+ table.cursor_type = "row"
25
+ table.zebra_stripes = True
26
+ table.can_focus = True
27
+
28
+ def watch_adjudications(
29
+ self,
30
+ _old_adjudications: list[AdjudicationRecord],
31
+ new_adjudications: list[AdjudicationRecord],
32
+ ) -> None:
33
+ table = self.query_one(DataTable)
34
+ table.clear()
35
+ for record in new_adjudications:
36
+ decision = record.adjudication.decision
37
+ if decision == Decision.DENY:
38
+ decision_display = "❌ DENY"
39
+ elif decision == Decision.ESCALATE:
40
+ decision_display = "⚠️ ESCALATE"
41
+ else:
42
+ decision_display = "✅ ALLOW"
43
+
44
+ reason = record.adjudication.reason
45
+ reason_display = reason[:40] + "..." if len(reason) > 43 else reason
46
+
47
+ # Format annotations as comma-separated policy IDs
48
+ annotations = record.adjudication.annotations
49
+ if annotations:
50
+ ann_ids = [ann.id for ann in annotations]
51
+ annotations_display = ", ".join(ann_ids)
52
+ if len(annotations_display) > 30:
53
+ annotations_display = annotations_display[:27] + "..."
54
+ else:
55
+ annotations_display = "-"
56
+
57
+ table.add_row(
58
+ decision_display,
59
+ record.agent_id[:12] + "..."
60
+ if len(record.agent_id) > 15
61
+ else record.agent_id,
62
+ record.trajectory_id[:8] + "..."
63
+ if len(record.trajectory_id) > 11
64
+ else record.trajectory_id,
65
+ record.step_id[:8] + "..."
66
+ if len(record.step_id) > 11
67
+ else record.step_id,
68
+ reason_display,
69
+ annotations_display,
70
+ )
71
+
72
+ def get_selected_adjudication(self) -> AdjudicationRecord | None:
73
+ """Get the currently selected adjudication record from the table."""
74
+ table = self.query_one(DataTable)
75
+ cursor_row = table.cursor_row
76
+ if cursor_row is not None and 0 <= cursor_row < len(self.adjudications):
77
+ return self.adjudications[cursor_row]
78
+ return None
@@ -0,0 +1,104 @@
1
+ from collections import Counter
2
+
3
+ from textual.app import ComposeResult
4
+ from textual.containers import Container, Horizontal
5
+ from textual.reactive import reactive
6
+ from textual.widget import Widget
7
+ from textual.widgets import Digits, Static
8
+
9
+ from sondera.types import AdjudicationRecord, Decision
10
+
11
+
12
+ class ViolationsSummary(Widget):
13
+ """Widget displaying violations statistics.
14
+
15
+ Shows:
16
+ - Total violations count
17
+ - Violations grouped by agent
18
+ - Violations grouped by policy
19
+ """
20
+
21
+ BORDER_TITLE = "Violations Summary"
22
+
23
+ adjudications: reactive[list[AdjudicationRecord]] = reactive([], recompose=True)
24
+
25
+ def _get_violations(self) -> list[AdjudicationRecord]:
26
+ """Filter adjudications to only include violations (DENY decisions)."""
27
+ return [
28
+ adj
29
+ for adj in self.adjudications
30
+ if adj.adjudication.decision == Decision.DENY
31
+ ]
32
+
33
+ def _get_escalations(self) -> list[AdjudicationRecord]:
34
+ """Filter adjudications to include escalations."""
35
+ return [
36
+ adj
37
+ for adj in self.adjudications
38
+ if adj.adjudication.decision == Decision.ESCALATE
39
+ ]
40
+
41
+ def _count_by_agent(self, violations: list[AdjudicationRecord]) -> Counter:
42
+ """Count violations by agent ID."""
43
+ return Counter(v.agent_id for v in violations)
44
+
45
+ def _count_by_policy(self, violations: list[AdjudicationRecord]) -> Counter:
46
+ """Count violations by policy (from reason field)."""
47
+ # Extract policy identifier from reason - typically first word or phrase
48
+ return Counter(v.adjudication.reason.split(":")[0].strip() for v in violations)
49
+
50
+ def compose(self) -> ComposeResult:
51
+ violations = self._get_violations()
52
+ escalations = self._get_escalations()
53
+ total_adjudications = len(self.adjudications)
54
+ allowed = total_adjudications - len(violations) - len(escalations)
55
+
56
+ # Overall counts
57
+ yield Static("[bold]Overview[/bold]", classes="section-header")
58
+ with Horizontal(classes="summary-row"):
59
+ with Container(classes="summary-item"):
60
+ yield Static("Total Adjudications")
61
+ yield Digits(str(total_adjudications), classes="summary-digit")
62
+ with Container(classes="summary-item"):
63
+ yield Static("Allowed", classes="stat-allowed")
64
+ yield Digits(str(allowed), classes="summary-digit digit-allowed")
65
+ with Container(classes="summary-item"):
66
+ yield Static("Violations", classes="stat-violations")
67
+ yield Digits(
68
+ str(len(violations)), classes="summary-digit digit-violations"
69
+ )
70
+ with Container(classes="summary-item"):
71
+ yield Static("Escalated", classes="stat-escalated")
72
+ yield Digits(
73
+ str(len(escalations)), classes="summary-digit digit-escalated"
74
+ )
75
+
76
+ # Violations by Agent
77
+ yield Static("[bold]Violations by Agent[/bold]", classes="section-header")
78
+ by_agent = self._count_by_agent(violations)
79
+ if by_agent:
80
+ with Horizontal(classes="summary-row"):
81
+ for agent_id, count in by_agent.most_common(5):
82
+ with Container(classes="summary-item"):
83
+ display_id = (
84
+ agent_id[:12] + "..." if len(agent_id) > 15 else agent_id
85
+ )
86
+ yield Static(display_id, classes="agent-label")
87
+ yield Digits(str(count), classes="summary-digit")
88
+ else:
89
+ yield Static("No violations by agent", classes="empty-message")
90
+
91
+ # Violations by Policy/Reason
92
+ yield Static("[bold]Violations by Policy[/bold]", classes="section-header")
93
+ by_policy = self._count_by_policy(violations)
94
+ if by_policy:
95
+ with Horizontal(classes="summary-row"):
96
+ for policy, count in by_policy.most_common(5):
97
+ with Container(classes="summary-item"):
98
+ display_policy = (
99
+ policy[:15] + "..." if len(policy) > 18 else policy
100
+ )
101
+ yield Static(display_policy, classes="policy-label")
102
+ yield Digits(str(count), classes="summary-digit")
103
+ else:
104
+ yield Static("No violations by policy", classes="empty-message")
sondera/types.py ADDED
@@ -0,0 +1,346 @@
1
+ """Sondera SDK type definitions for agent interoperability and policy evaluation."""
2
+
3
+ import uuid
4
+ from datetime import datetime
5
+ from enum import Enum
6
+ from typing import Any, Literal
7
+
8
+ from pydantic import BaseModel, ConfigDict, Field
9
+ from pydantic.alias_generators import to_camel
10
+
11
+
12
+ class Model(BaseModel):
13
+ """Base model for all Sondera SDK types."""
14
+
15
+ model_config = ConfigDict(
16
+ alias_generator=to_camel,
17
+ populate_by_name=True,
18
+ from_attributes=True,
19
+ )
20
+
21
+
22
+ class Parameter(Model):
23
+ """
24
+ Parameter object allows the definition of input and output data types.
25
+
26
+ Simple parameter type theory for now.
27
+ """
28
+
29
+ name: str
30
+ """ Name of the parameter. For human-readable display or metadata.
31
+ """
32
+ description: str
33
+ """ Description of the parameter. For human-readable display or metadata.
34
+ """
35
+ type: str
36
+ """ Type of the parameter.
37
+ """
38
+
39
+
40
+ class SourceCode(Model):
41
+ language: str
42
+ code: str
43
+
44
+
45
+ class Tool(Model):
46
+ id: str | None = None
47
+ """ Optional unique identifier for the tool. Auto-generated if not provided.
48
+ """
49
+ name: str
50
+ """ Name of the tool. For human-readable display or metadata.
51
+ """
52
+ description: str
53
+ """ Description of the tool. For human-readable display or metadata.
54
+ """
55
+ parameters: list[Parameter]
56
+ """ The parameters that are used by the tool.
57
+ """
58
+ parameters_json_schema: str | None = None
59
+ """ JSON schema for the tool parameters.
60
+ """
61
+ response: str | None = None
62
+ """ The type that is returned by the tool.
63
+ """
64
+ response_json_schema: str | None = None
65
+ """ JSON schema for the tool response.
66
+ """
67
+ source: SourceCode | None = None
68
+ """ The source of the tool if available.
69
+ """
70
+
71
+
72
+ class Agent(Model):
73
+ id: str
74
+ """ Unique identifier for the agent.
75
+ """
76
+ provider_id: str
77
+ """ Identifier for the provider of the agent.
78
+ """
79
+ name: str
80
+ """ Name of the agent. For human-readable display or metadata.
81
+ """
82
+ description: str
83
+ """ Description of the agent. For human-readable display or metadata.
84
+ """
85
+ instruction: str
86
+ """ Instruction or goal of the agent.
87
+ """
88
+ tools: list[Tool]
89
+
90
+
91
+ class TrajectoryStatus(Enum):
92
+ """Status of the trajectory."""
93
+
94
+ UNKNOWN = "unknown"
95
+ PENDING = "pending"
96
+ RUNNING = "running"
97
+ COMPLETED = "completed"
98
+ SUSPENDED = "suspended"
99
+ FAILED = "failed"
100
+
101
+
102
+ class Stage(Enum):
103
+ """Lifecycle stage of the step."""
104
+
105
+ PRE_RUN = "pre_run"
106
+ PRE_MODEL = "pre_model"
107
+ POST_MODEL = "post_model"
108
+ PRE_TOOL = "pre_tool"
109
+ POST_TOOL = "post_tool"
110
+ POST_RUN = "post_run"
111
+
112
+
113
+ class Role(Enum):
114
+ """Role of the step."""
115
+
116
+ USER = "user"
117
+ MODEL = "model"
118
+ TOOL = "tool"
119
+ SYSTEM = "system"
120
+
121
+
122
+ class TrajectoryStep(Model):
123
+ role: Role
124
+ """ Role of the step.
125
+ """
126
+ state: dict[str, Any] = Field(default_factory=dict)
127
+ """ State of the step.
128
+ """
129
+ stage: Stage
130
+ """ Stage of the step.
131
+ """
132
+ created_at: datetime = Field(default_factory=datetime.now)
133
+ """ Created at timestamp.
134
+ """
135
+ context: Any | None = None
136
+ """ Context of the step.
137
+ """
138
+ content: Any
139
+ """ Content of the step.
140
+ """
141
+
142
+
143
+ class Trajectory(Model):
144
+ id: str = Field(default_factory=lambda: f"traj-{uuid.uuid4()}")
145
+ """ Unique identifier for the trajectory.
146
+ """
147
+ agent_id: str = Field(default_factory=lambda: "agent-1")
148
+ """ Identifier for the agent that created the trajectory.
149
+ """
150
+ status: TrajectoryStatus = Field(default=TrajectoryStatus.PENDING)
151
+ metadata: dict[str, Any] = Field(default_factory=dict)
152
+ """ Metadata of the trajectory.
153
+ """
154
+ created_at: datetime = Field(default_factory=datetime.now)
155
+ """ Created at timestamp.
156
+ """
157
+ updated_at: datetime = Field(default_factory=datetime.now)
158
+ """ Updated at timestamp.
159
+ """
160
+ started_at: datetime | None = Field(default=None)
161
+ """ Started at timestamp.
162
+ """
163
+ ended_at: datetime | None = Field(default=None)
164
+ """ Ended at timestamp.
165
+ """
166
+ steps: list[TrajectoryStep] = Field(default_factory=list)
167
+
168
+ @property
169
+ def duration(self) -> float | None:
170
+ """Calculate trajectory duration in seconds."""
171
+ if self.started_at and self.ended_at:
172
+ return (self.ended_at - self.started_at).total_seconds()
173
+ return None
174
+
175
+ @property
176
+ def step_count(self) -> int:
177
+ """Get the total number of steps."""
178
+ return len(self.steps)
179
+
180
+ @property
181
+ def is_completed(self) -> bool:
182
+ """Check if trajectory is in a terminal state."""
183
+ return self.status in [TrajectoryStatus.COMPLETED, TrajectoryStatus.FAILED]
184
+
185
+ @property
186
+ def is_active(self) -> bool:
187
+ """Check if trajectory is currently running."""
188
+ return self.status == TrajectoryStatus.RUNNING
189
+
190
+ def get_steps_by_role(self, role: Role) -> list[TrajectoryStep]:
191
+ """Get all steps with a specific role."""
192
+ return [step for step in self.steps if step.role == role]
193
+
194
+ def get_steps_by_stage(self, stage: Stage) -> list[TrajectoryStep]:
195
+ """Get all steps at a specific stage."""
196
+ return [step for step in self.steps if step.stage == stage]
197
+
198
+
199
+ class PolicyEngineMode(Enum):
200
+ """Policy engine mode."""
201
+
202
+ MONITOR = "monitor"
203
+ """Monitor policy. Run policies but allow all actions."""
204
+ GOVERN = "govern"
205
+ """Govern policy on all actions."""
206
+
207
+
208
+ class Decision(Enum):
209
+ """Decision of the adjudication."""
210
+
211
+ ALLOW = "allow"
212
+ DENY = "deny"
213
+ ESCALATE = "escalate"
214
+
215
+
216
+ class PolicyAnnotation(Model):
217
+ """Annotation from a policy evaluation."""
218
+
219
+ id: str
220
+ """Unique identifier of the policy that produced this annotation."""
221
+ description: str
222
+ """Human-readable description of why this annotation was added."""
223
+ custom: dict[str, str] = Field(default_factory=dict)
224
+ """Custom key-value metadata from the policy."""
225
+
226
+
227
+ class Adjudication(Model):
228
+ """Result of the adjudication."""
229
+
230
+ decision: Decision
231
+ """Whether the input is allowed."""
232
+ reason: str
233
+ """Reason for the adjudication decision."""
234
+ annotations: list[PolicyAnnotation] = Field(default_factory=list)
235
+ """Annotations from policy evaluations."""
236
+
237
+ @property
238
+ def is_denied(self) -> bool:
239
+ """Check if is denied."""
240
+ return self.decision == Decision.DENY
241
+
242
+ @property
243
+ def is_allowed(self) -> bool:
244
+ """Check if allowed."""
245
+ return self.decision == Decision.ALLOW
246
+
247
+ @property
248
+ def is_escalated(self) -> bool:
249
+ """Check if result requires escalation."""
250
+ return self.decision == Decision.ESCALATE
251
+
252
+
253
+ class AdjudicatedStep(Model):
254
+ """Result of the adjudicated input."""
255
+
256
+ mode: PolicyEngineMode
257
+ """Mode of the adjudication."""
258
+ adjudication: Adjudication
259
+ """Adjudication of the input."""
260
+ step: TrajectoryStep
261
+ """Step of the adjudication."""
262
+
263
+ @property
264
+ def is_denied(self) -> bool:
265
+ """Check if result is denied."""
266
+ return (
267
+ self.adjudication.decision == Decision.DENY
268
+ and self.mode == PolicyEngineMode.GOVERN
269
+ )
270
+
271
+ @property
272
+ def is_allowed(self) -> bool:
273
+ """Check if result is allowed."""
274
+ return self.adjudication.decision == Decision.ALLOW
275
+
276
+ @property
277
+ def is_escalated(self) -> bool:
278
+ """Check if result requires escalation."""
279
+ return (
280
+ self.adjudication.decision == Decision.ESCALATE
281
+ and self.mode == PolicyEngineMode.GOVERN
282
+ )
283
+
284
+ @property
285
+ def message(self) -> str:
286
+ """Get the adjudication reason in a friendly format."""
287
+ decision = self.adjudication.decision.value.capitalize()
288
+ return f"{decision}: {self.adjudication.reason}"
289
+
290
+
291
+ class AdjudicatedTrajectory(Trajectory):
292
+ """Adjudicated trajectory with annotated steps."""
293
+
294
+ steps: list[AdjudicatedStep] = Field(default_factory=list) # type: ignore[assignment]
295
+ """Steps of the adjudicated trajectory."""
296
+
297
+
298
+ class PromptContent(Model):
299
+ """Prompt content type for trajectory steps."""
300
+
301
+ content_type: Literal["prompt"] = "prompt"
302
+ text: str
303
+
304
+
305
+ class ToolRequestContent(Model):
306
+ """Tool request content type for trajectory steps."""
307
+
308
+ content_type: Literal["tool_request"] = "tool_request"
309
+ tool_id: str
310
+ args: dict[str, Any]
311
+
312
+
313
+ class ToolResponseContent(Model):
314
+ """Tool response content type for trajectory steps."""
315
+
316
+ content_type: Literal["tool_response"] = "tool_response"
317
+ tool_id: str
318
+ response: Any
319
+
320
+
321
+ Content = PromptContent | ToolRequestContent | ToolResponseContent
322
+ """Union type representing the content of a trajectory step.
323
+
324
+ Corresponds to the Content protobuf message which uses a oneof field
325
+ to represent different types of step content:
326
+ - PromptContent: Text prompts or messages
327
+ - ToolRequestContent: Requests to execute tools
328
+ - ToolResponseContent: Results from tool execution
329
+ """
330
+
331
+
332
+ class AdjudicationRecord(Model):
333
+ """Record of an adjudication event from the harness service.
334
+
335
+ Represents a single adjudication (policy decision) that occurred during
336
+ agent execution, linking the decision to its agent, trajectory, and step.
337
+ """
338
+
339
+ agent_id: str = Field(description="ID of the agent that triggered the adjudication")
340
+ trajectory_id: str = Field(
341
+ description="ID of the trajectory containing the adjudicated step"
342
+ )
343
+ step_id: str = Field(description="ID of the step that was adjudicated")
344
+ adjudication: Adjudication = Field(
345
+ description="The adjudication decision and reason"
346
+ )