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,890 @@
1
+ """Async GRPC client for the Sondera Harness Service."""
2
+
3
+ import logging
4
+ from datetime import UTC, datetime
5
+ from typing import Any
6
+
7
+ import grpc
8
+ from google.protobuf.json_format import MessageToDict
9
+ from google.protobuf.timestamp_pb2 import Timestamp
10
+
11
+ from sondera.exceptions import (
12
+ AuthenticationError,
13
+ ConfigurationError,
14
+ TrajectoryError,
15
+ TrajectoryNotInitializedError,
16
+ )
17
+ from sondera.harness.abc import Harness as AbstractHarness
18
+ from sondera.harness.sondera._grpc import (
19
+ _convert_pb_adjudicated_trajectory_to_sdk,
20
+ _convert_pb_adjudication_record_to_sdk,
21
+ _convert_pb_adjudication_to_sdk,
22
+ _convert_pb_agent_to_sdk,
23
+ _convert_pb_trajectory_to_sdk,
24
+ _convert_sdk_content_to_pb,
25
+ _convert_sdk_role_to_pb,
26
+ _convert_sdk_stage_to_pb,
27
+ _convert_sdk_trajectory_status_to_pb,
28
+ )
29
+ from sondera.proto.sondera.core.v1 import primitives_pb2
30
+ from sondera.proto.sondera.harness.v1 import harness_pb2, harness_pb2_grpc
31
+ from sondera.settings import SETTINGS
32
+ from sondera.types import (
33
+ AdjudicatedTrajectory,
34
+ Adjudication,
35
+ AdjudicationRecord,
36
+ Agent,
37
+ Content,
38
+ Role,
39
+ Stage,
40
+ Trajectory,
41
+ TrajectoryStatus,
42
+ )
43
+
44
+
45
+ class SonderaRemoteHarness(AbstractHarness):
46
+ """gRPC-based Harness implementation for the Sondera Platform.
47
+
48
+ Example:
49
+ ```python
50
+ from sondera.harness import Harness
51
+ from sondera.types import Agent, Stage, Role, PromptContent
52
+
53
+ harness = Harness(
54
+ sondera_harness_endpoint="localhost:50051",
55
+ sondera_api_key="<YOUR_SONDERA_API_KEY>",
56
+ agent=Agent(
57
+ id="my-agent",
58
+ provider_id="my-provider",
59
+ name="My Agent",
60
+ description="An agent with Sondera governance",
61
+ instruction="Be helpful",
62
+ tools=[],
63
+ ),
64
+ )
65
+
66
+ await harness.initialize()
67
+ adjudication = await harness.adjudicate(
68
+ Stage.PRE_MODEL,
69
+ Role.USER,
70
+ PromptContent(text="Hello, world!"),
71
+ )
72
+ await harness.finalize()
73
+ ```
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ *,
79
+ agent: Agent | None = None,
80
+ sondera_harness_endpoint: str = SETTINGS.sondera_harness_endpoint,
81
+ sondera_api_key: str | None = SETTINGS.sondera_api_token,
82
+ sondera_harness_client_secure: bool = SETTINGS.sondera_harness_client_secure,
83
+ sondera_harness_client_options: list[tuple] | None = None,
84
+ ):
85
+ """Initialize the harness.
86
+
87
+ Args:
88
+ agent: The agent to be governed
89
+ sondera_harness_endpoint: The endpoint of the Sondera Harness service
90
+ sondera_api_key: JWT token for authentication (required, must include organization_id claim)
91
+ sondera_harness_client_secure: Whether to use a secure (TLS) connection
92
+ sondera_harness_client_options: Optional gRPC channel options
93
+
94
+ Raises:
95
+ ConfigurationError: If sondera_api_key is None or empty
96
+
97
+ Note:
98
+ The organization_id for multi-tenancy is now derived from the JWT token's organization_id claim.
99
+ Ensure your Clerk JWT template includes: {{org.public_metadata.organization_id}}
100
+ """
101
+ # Validate sondera_api_key
102
+ if not sondera_api_key:
103
+ raise ConfigurationError(
104
+ "sondera_api_key is required and cannot be None or empty"
105
+ )
106
+
107
+ self._sondera_api_key = sondera_api_key
108
+ self._agent: Agent | None = agent
109
+ self._sondera_harness_endpoint = sondera_harness_endpoint
110
+ self._secure = sondera_harness_client_secure
111
+ self._options = sondera_harness_client_options or []
112
+ # Client connection state
113
+ self._channel: grpc.aio.Channel | None = None
114
+ self._stub: harness_pb2_grpc.HarnessServiceStub | None = None
115
+
116
+ # Current trajectory state
117
+ self._trajectory_id: str | None = None
118
+
119
+ def _get_metadata(self) -> list[tuple[str, str]]:
120
+ """Build gRPC metadata with JWT auth token.
121
+
122
+ The JWT token includes the organization_id claim, which is extracted by the
123
+ Harness service for tenant isolation.
124
+
125
+ Returns:
126
+ List of metadata tuples to pass to gRPC calls
127
+ """
128
+ metadata: list[tuple[str, str]] = [
129
+ ("authorization", f"Bearer {self._sondera_api_key}")
130
+ ]
131
+ return metadata
132
+
133
+ async def initialize(
134
+ self,
135
+ *,
136
+ agent: Agent | None = None,
137
+ ) -> None:
138
+ """Initialize a new trajectory for the current execution."""
139
+ await self._ensure_connected()
140
+ assert self._stub is not None, "Client not connected"
141
+ if agent:
142
+ self._agent = agent
143
+ assert self._agent is not None, (
144
+ "Agent not provided on initialization or in constructor."
145
+ )
146
+ # Get or register an agent
147
+ try:
148
+ await self._get_agent(self._agent.id)
149
+ logging.debug(f"Agent {self._agent.id} already exists")
150
+ except grpc.aio.AioRpcError as e:
151
+ if e.code() == grpc.StatusCode.NOT_FOUND:
152
+ logging.debug(f"Agent {self._agent.id} not found, registering...")
153
+ registered_agent = await self._register_agent(self._agent)
154
+ # Update agent ID to match what the server assigned
155
+ self._agent.id = registered_agent.id
156
+ else:
157
+ raise
158
+ # Create trajectory
159
+ logging.debug(f"Creating trajectory for agent {self._agent.id}...")
160
+ response = await self._create_trajectory(self._agent.id)
161
+ logging.debug(f"Trajectory created for agent {self._agent.id}: {response.id}")
162
+ self._trajectory_id = response.id
163
+
164
+ async def resume(self, trajectory_id: str, *, agent: Agent | None = None) -> None:
165
+ """Resume an existing trajectory for continued execution.
166
+
167
+ Args:
168
+ trajectory_id: The ID of the trajectory to resume
169
+ agent: Optional agent to use for this trajectory. If provided, overrides
170
+ any agent set during construction.
171
+
172
+ Raises:
173
+ ValueError: If the trajectory doesn't exist or belongs to a different agent
174
+ RuntimeError: If there's already an active trajectory
175
+ """
176
+ if self._trajectory_id:
177
+ raise RuntimeError(
178
+ f"Already have active trajectory {self._trajectory_id}. Call finalize first."
179
+ )
180
+
181
+ if agent:
182
+ self._agent = agent
183
+ assert self._agent is not None, (
184
+ "Agent not provided on initialization or in constructor."
185
+ )
186
+
187
+ await self._ensure_connected()
188
+
189
+ # Verify the trajectory exists and get its details
190
+ trajectory = await self._get_trajectory(trajectory_id)
191
+ if trajectory is None:
192
+ raise TrajectoryError(f"Trajectory {trajectory_id} not found")
193
+
194
+ self._trajectory_id = trajectory.id
195
+
196
+ # Verify the trajectory belongs to our agent (if we have one)
197
+ if self._agent and trajectory.agent_id != self._agent.id:
198
+ raise TrajectoryError(
199
+ f"Trajectory {trajectory_id} belongs to agent {trajectory.agent_id}, not {self._agent.id}"
200
+ )
201
+
202
+ # Set the trajectory as active
203
+ await self._update_trajectory_status(
204
+ self._trajectory_id, primitives_pb2.TRAJECTORY_STATUS_RUNNING
205
+ )
206
+ self._trajectory_id = trajectory_id
207
+ logging.debug(
208
+ f"Resumed trajectory {trajectory_id} for agent {trajectory.agent_id}"
209
+ )
210
+
211
+ async def finalize(self) -> None:
212
+ """Finalize the current trajectory and save artifacts."""
213
+ if not self._trajectory_id:
214
+ raise TrajectoryNotInitializedError()
215
+ assert self._stub is not None, "Client not connected"
216
+ # Update trajectory status to completed
217
+ await self._update_trajectory_status(
218
+ self._trajectory_id, primitives_pb2.TRAJECTORY_STATUS_COMPLETED
219
+ )
220
+ # Clear trajectory ID to indicate no active trajectory
221
+ self._trajectory_id = None
222
+
223
+ async def adjudicate(
224
+ self,
225
+ stage: Stage,
226
+ role: Role,
227
+ content: Content,
228
+ ) -> Adjudication:
229
+ """Adjudicate a trajectory step using the policy engine.
230
+
231
+ Args:
232
+ stage: The stage of the step
233
+ role: The role of the step
234
+ content: The content of the step
235
+
236
+ Returns:
237
+ The adjudication result
238
+ """
239
+ if not self._trajectory_id:
240
+ raise RuntimeError(
241
+ "No active trajectory. Call initialize_trajectory first."
242
+ )
243
+
244
+ await self._ensure_connected()
245
+
246
+ # Convert SDK types to protobuf
247
+ pb_stage = _convert_sdk_stage_to_pb(stage)
248
+ pb_role = _convert_sdk_role_to_pb(role)
249
+ pb_content = _convert_sdk_content_to_pb(content)
250
+
251
+ # Add step and get adjudication
252
+ logging.debug(
253
+ f"Adjudicating (trajectory_id: {self._trajectory_id}): {stage} {role} {content}"
254
+ )
255
+ adjudicated_step = await self._add_trajectory_step(
256
+ self._trajectory_id, pb_stage, pb_role, pb_content
257
+ )
258
+ # Convert protobuf adjudication to SDK type
259
+ adjudication = _convert_pb_adjudication_to_sdk(adjudicated_step.adjudication)
260
+ logging.debug(
261
+ f"Adjudication (trajectory_id:{self._trajectory_id}): {adjudication}"
262
+ )
263
+ return adjudication
264
+
265
+ async def _ensure_connected(self):
266
+ """Ensure the client is connected."""
267
+ if not self._is_connected():
268
+ await self._connect()
269
+ assert self._stub is not None, "Client not connected"
270
+
271
+ def _is_connected(self) -> bool:
272
+ """Check if the client is connected."""
273
+ return self._channel is not None and self._stub is not None
274
+
275
+ async def _connect(self):
276
+ """Establish connection to the gRPC server."""
277
+ if self._secure:
278
+ self._channel = grpc.aio.secure_channel(
279
+ self._sondera_harness_endpoint,
280
+ credentials=grpc.ssl_channel_credentials(),
281
+ options=self._options,
282
+ )
283
+ else:
284
+ self._channel = grpc.aio.insecure_channel(
285
+ self._sondera_harness_endpoint,
286
+ options=self._options,
287
+ )
288
+ self._stub = harness_pb2_grpc.HarnessServiceStub(self._channel)
289
+
290
+ async def _close(self):
291
+ """Close the gRPC channel."""
292
+ if self._channel:
293
+ await self._channel.close()
294
+ self._channel = None
295
+ self._stub = None
296
+
297
+ async def _get_agent(self, agent_id: str) -> primitives_pb2.Agent:
298
+ """Get an agent internally.
299
+
300
+ Args:
301
+ agent_id: The agent ID
302
+
303
+ Returns:
304
+ The agent
305
+
306
+ Raises:
307
+ AuthenticationError: If authentication fails
308
+ grpc.RpcError: If other gRPC error occurs
309
+ """
310
+ request = harness_pb2.GetAgentRequest(agent_id=agent_id)
311
+ await self._ensure_connected()
312
+ assert self._stub is not None, "Client not connected"
313
+
314
+ # Inject organization_id and auth metadata
315
+ metadata = self._get_metadata()
316
+
317
+ try:
318
+ return await self._stub.GetAgent(request, metadata=metadata)
319
+ except grpc.aio.AioRpcError as e:
320
+ # Handle authentication errors
321
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
322
+ raise AuthenticationError(
323
+ f"Authentication failed: {e.details()}"
324
+ ) from e
325
+ raise
326
+
327
+ async def _register_agent(self, agent: Agent) -> primitives_pb2.Agent:
328
+ """Register an agent internally.
329
+
330
+ Args:
331
+ agent: The agent to register
332
+
333
+ Returns:
334
+ The registered agent
335
+
336
+ Raises:
337
+ AuthenticationError: If authentication fails
338
+ grpc.RpcError: If other gRPC error occurs
339
+ """
340
+ # Convert SDK agent to protobuf tools
341
+ pb_tools = []
342
+ for tool in agent.tools:
343
+ pb_params = []
344
+ for param in tool.parameters:
345
+ pb_params.append(
346
+ primitives_pb2.Parameter(
347
+ name=param.name, description=param.description, type=param.type
348
+ )
349
+ )
350
+
351
+ pb_tool = primitives_pb2.Tool(
352
+ name=tool.name,
353
+ description=tool.description,
354
+ parameters=pb_params,
355
+ response=tool.response,
356
+ )
357
+ pb_tools.append(pb_tool)
358
+
359
+ request = harness_pb2.RegisterAgentRequest(
360
+ provider_id=agent.provider_id,
361
+ name=agent.name,
362
+ description=agent.description,
363
+ instruction=agent.instruction,
364
+ tools=pb_tools,
365
+ )
366
+ await self._ensure_connected()
367
+ assert self._stub is not None, "Client not connected"
368
+
369
+ # Inject organization_id and auth metadata
370
+ metadata = self._get_metadata()
371
+
372
+ try:
373
+ response = await self._stub.RegisterAgent(request, metadata=metadata)
374
+ return response.agent
375
+ except grpc.aio.AioRpcError as e:
376
+ # Handle authentication errors
377
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
378
+ raise AuthenticationError(
379
+ f"Authentication failed: {e.details()}"
380
+ ) from e
381
+ logging.error(f"Failed to register agent: {e.code()} - {e.details()}")
382
+ raise
383
+
384
+ async def _create_trajectory(self, agent_id: str) -> primitives_pb2.Trajectory:
385
+ """Create a trajectory internally.
386
+
387
+ Args:
388
+ agent_id: The agent ID
389
+
390
+ Returns:
391
+ The created trajectory
392
+
393
+ Raises:
394
+ AuthenticationError: If authentication fails
395
+ grpc.RpcError: If other gRPC error occurs
396
+ """
397
+ request = harness_pb2.CreateTrajectoryRequest(agent_id=agent_id)
398
+ await self._ensure_connected()
399
+ assert self._stub is not None, "Client not connected"
400
+
401
+ # Inject organization_id and auth metadata
402
+ metadata = self._get_metadata()
403
+
404
+ try:
405
+ response = await self._stub.CreateTrajectory(request, metadata=metadata)
406
+ return response.trajectory
407
+ except grpc.aio.AioRpcError as e:
408
+ # Handle authentication errors
409
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
410
+ raise AuthenticationError(
411
+ f"Authentication failed: {e.details()}"
412
+ ) from e
413
+ logging.error(
414
+ f"Failed to create trajectory for agent {agent_id}: {e.code()} - {e.details()}"
415
+ )
416
+ raise
417
+
418
+ async def _add_trajectory_step(
419
+ self,
420
+ trajectory_id: str,
421
+ stage: primitives_pb2.Stage,
422
+ role: primitives_pb2.Role,
423
+ content: primitives_pb2.Content,
424
+ ) -> primitives_pb2.AdjudicatedStep:
425
+ """Add a trajectory step internally.
426
+
427
+ Args:
428
+ trajectory_id: The trajectory ID
429
+ stage: The step stage
430
+ role: The step role
431
+ content: The step content
432
+
433
+ Returns:
434
+ The adjudicated step
435
+
436
+ Raises:
437
+ AuthenticationError: If authentication fails
438
+ grpc.RpcError: If other gRPC error occurs
439
+ """
440
+ request = harness_pb2.AddTrajectoryStepRequest(
441
+ trajectory_id=trajectory_id, stage=stage, role=role, content=content
442
+ )
443
+ await self._ensure_connected()
444
+ assert self._stub is not None, "Client not connected"
445
+
446
+ # Inject organization_id and auth metadata
447
+ metadata = self._get_metadata()
448
+
449
+ try:
450
+ response = await self._stub.AddTrajectoryStep(request, metadata=metadata)
451
+ return response.adjudicated_step
452
+ except grpc.aio.AioRpcError as e:
453
+ # Handle authentication errors
454
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
455
+ raise AuthenticationError(
456
+ f"Authentication failed: {e.details()}"
457
+ ) from e
458
+ logging.error(
459
+ f"Failed to add step to trajectory {trajectory_id}: {e.code()} - {e.details()}"
460
+ )
461
+ raise
462
+
463
+ async def _update_trajectory_status(
464
+ self, trajectory_id: str, status: primitives_pb2.TrajectoryStatus
465
+ ) -> primitives_pb2.Trajectory:
466
+ """Update trajectory status internally.
467
+
468
+ Args:
469
+ trajectory_id: The trajectory ID
470
+ status: The new status
471
+
472
+ Returns:
473
+ The updated trajectory
474
+
475
+ Raises:
476
+ AuthenticationError: If authentication fails
477
+ grpc.RpcError: If other gRPC error occurs
478
+ """
479
+ request = harness_pb2.UpdateTrajectoryStatusRequest(
480
+ trajectory_id=trajectory_id, status=status
481
+ )
482
+ await self._ensure_connected()
483
+ assert self._stub is not None, "Client not connected"
484
+
485
+ # Inject organization_id and auth metadata
486
+ metadata = self._get_metadata()
487
+
488
+ try:
489
+ response = await self._stub.UpdateTrajectoryStatus(
490
+ request, metadata=metadata
491
+ )
492
+ return response.trajectory
493
+ except grpc.aio.AioRpcError as e:
494
+ # Handle authentication errors
495
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
496
+ raise AuthenticationError(
497
+ f"Authentication failed: {e.details()}"
498
+ ) from e
499
+ logging.error(
500
+ f"Failed to update trajectory {trajectory_id} status: {e.code()} - {e.details()}"
501
+ )
502
+ raise
503
+
504
+ async def _list_trajectories(
505
+ self,
506
+ agent_id: str | None = None,
507
+ status: primitives_pb2.TrajectoryStatus | None = None,
508
+ page_size: int = 100,
509
+ page_token: str = "",
510
+ ) -> list[primitives_pb2.Trajectory]:
511
+ """List trajectories internally."""
512
+ request = harness_pb2.ListTrajectoriesRequest(
513
+ agent_id=agent_id or "",
514
+ page_size=page_size,
515
+ page_token=page_token,
516
+ )
517
+ if status is not None:
518
+ request.status = status
519
+ await self._ensure_connected()
520
+ assert self._stub is not None, "Client not connected"
521
+
522
+ # Inject organization_id and auth metadata
523
+ metadata = self._get_metadata()
524
+
525
+ try:
526
+ response = await self._stub.ListTrajectories(request, metadata=metadata)
527
+ return list(response.trajectories)
528
+ except grpc.aio.AioRpcError as e:
529
+ logging.error(f"Failed to list trajectories: {e.code()} - {e.details()}")
530
+ raise
531
+
532
+ async def _get_adjudicated_trajectory(
533
+ self, trajectory_id: str
534
+ ) -> harness_pb2.GetTrajectoryResponse | None:
535
+ """Get a trajectory internally. Returns None if not found (fail-closed)."""
536
+ request = harness_pb2.GetTrajectoryRequest(
537
+ trajectory_id=trajectory_id,
538
+ )
539
+ await self._ensure_connected()
540
+ assert self._stub is not None, "Client not connected"
541
+
542
+ # Inject organization_id and auth metadata
543
+ metadata = self._get_metadata()
544
+
545
+ try:
546
+ response = await self._stub.GetTrajectory(request, metadata=metadata)
547
+ return response
548
+ except grpc.aio.AioRpcError as e:
549
+ if e.code() == grpc.StatusCode.NOT_FOUND:
550
+ return None
551
+ logging.error(
552
+ f"Failed to get adjudicated trajectory {trajectory_id}: {e.code()} - {e.details()}"
553
+ )
554
+ raise
555
+
556
+ async def _get_trajectory(
557
+ self, trajectory_id: str
558
+ ) -> primitives_pb2.Trajectory | None:
559
+ """Get a trajectory internally. Returns None if not found (fail-closed)."""
560
+ request = harness_pb2.GetTrajectoryRequest(
561
+ trajectory_id=trajectory_id,
562
+ )
563
+ await self._ensure_connected()
564
+ assert self._stub is not None, "Client not connected"
565
+
566
+ # Inject organization_id and auth metadata
567
+ metadata = self._get_metadata()
568
+
569
+ try:
570
+ response = await self._stub.GetTrajectory(request, metadata=metadata)
571
+ return response.trajectory
572
+ except grpc.aio.AioRpcError as e:
573
+ if e.code() == grpc.StatusCode.NOT_FOUND:
574
+ return None
575
+ logging.error(
576
+ f"Failed to get trajectory {trajectory_id}: {e.code()} - {e.details()}"
577
+ )
578
+ raise
579
+
580
+ async def _list_trajectory_steps(
581
+ self, trajectory_id: str
582
+ ) -> list[primitives_pb2.AdjudicatedStep]:
583
+ """List trajectory steps internally."""
584
+ request = harness_pb2.GetTrajectoryRequest(
585
+ trajectory_id=trajectory_id,
586
+ )
587
+ await self._ensure_connected()
588
+ assert self._stub is not None, "Client not connected"
589
+
590
+ # Inject organization_id and auth metadata
591
+ metadata = self._get_metadata()
592
+
593
+ try:
594
+ response = await self._stub.GetTrajectory(request, metadata=metadata)
595
+ return list(response.steps)
596
+ except grpc.aio.AioRpcError as e:
597
+ if e.code() == grpc.StatusCode.NOT_FOUND:
598
+ return []
599
+ logging.error(
600
+ f"Failed to list trajectory steps for {trajectory_id}: {e.code()} - {e.details()}"
601
+ )
602
+ raise
603
+
604
+ async def _list_agents(
605
+ self,
606
+ provider_id: str | None = None,
607
+ page_size: int = 50,
608
+ page_token: str = "",
609
+ ) -> tuple[list[primitives_pb2.Agent], str]:
610
+ """List agents internally.
611
+
612
+ Args:
613
+ provider_id: Optional provider ID to filter agents
614
+ page_size: Maximum number of agents to return
615
+ page_token: Token for pagination
616
+
617
+ Returns:
618
+ Tuple of (list of protobuf agents, next page token)
619
+
620
+ Raises:
621
+ AuthenticationError: If authentication fails
622
+ grpc.RpcError: If other gRPC error occurs
623
+ """
624
+ request = harness_pb2.ListAgentsRequest(
625
+ page_size=page_size,
626
+ page_token=page_token,
627
+ )
628
+ if provider_id is not None:
629
+ request.provider_id = provider_id
630
+
631
+ await self._ensure_connected()
632
+ assert self._stub is not None, "Client not connected"
633
+
634
+ # Inject organization_id and auth metadata
635
+ metadata = self._get_metadata()
636
+
637
+ try:
638
+ response = await self._stub.ListAgents(request, metadata=metadata)
639
+ return list(response.agents), response.next_page_token
640
+ except grpc.aio.AioRpcError as e:
641
+ # Handle authentication errors
642
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
643
+ raise AuthenticationError(
644
+ f"Authentication failed: {e.details()}"
645
+ ) from e
646
+ logging.error(f"Failed to list agents: {e.code()} - {e.details()}")
647
+ raise
648
+
649
+ async def list_agents(
650
+ self,
651
+ provider_id: str | None = None,
652
+ page_size: int = 50,
653
+ page_token: str = "",
654
+ ) -> list[Agent]:
655
+ """List registered agents.
656
+
657
+ Args:
658
+ provider_id: Optional provider ID to filter agents
659
+ page_size: Maximum number of agents to return per page
660
+ page_token: Token for pagination (empty string for first page)
661
+
662
+ Returns:
663
+ List of Agent objects
664
+
665
+ Raises:
666
+ AuthenticationError: If authentication fails
667
+ grpc.RpcError: If other gRPC error occurs
668
+ """
669
+ pb_agents, _ = await self._list_agents(
670
+ provider_id=provider_id,
671
+ page_size=page_size,
672
+ page_token=page_token,
673
+ )
674
+ return [_convert_pb_agent_to_sdk(pb_agent) for pb_agent in pb_agents]
675
+
676
+ async def get_trajectory(self, trajectory_id: str) -> AdjudicatedTrajectory | None:
677
+ """Get a trajectory by ID.
678
+
679
+ Args:
680
+ trajectory_id: The unique identifier of the trajectory
681
+
682
+ Returns:
683
+ Trajectory object if found, None otherwise
684
+
685
+ Raises:
686
+ grpc.RpcError: If gRPC error occurs (other than NOT_FOUND)
687
+ """
688
+ pb_adjudicated_trajectory = await self._get_adjudicated_trajectory(
689
+ trajectory_id
690
+ )
691
+ if pb_adjudicated_trajectory is None:
692
+ return None
693
+ return _convert_pb_adjudicated_trajectory_to_sdk(pb_adjudicated_trajectory)
694
+
695
+ async def list_trajectories(
696
+ self,
697
+ agent_id: str,
698
+ status: TrajectoryStatus | None = None,
699
+ page_size: int = 50,
700
+ page_token: str = "",
701
+ ) -> list[Trajectory]:
702
+ """List trajectories for an agent.
703
+
704
+ Args:
705
+ agent_id: The agent ID to filter trajectories
706
+ status: Optional status to filter trajectories
707
+ page_size: Maximum number of trajectories to return per page
708
+ page_token: Token for pagination (empty string for first page)
709
+
710
+ Returns:
711
+ List of Trajectory objects
712
+
713
+ Raises:
714
+ grpc.RpcError: If gRPC error occurs
715
+ """
716
+ pb_status = _convert_sdk_trajectory_status_to_pb(status) if status else None
717
+ pb_trajectories = await self._list_trajectories(
718
+ agent_id=agent_id,
719
+ status=pb_status,
720
+ page_size=page_size,
721
+ page_token=page_token,
722
+ )
723
+ return [_convert_pb_trajectory_to_sdk(pb_traj) for pb_traj in pb_trajectories]
724
+
725
+ async def analyze_trajectories(
726
+ self,
727
+ agent_id: str,
728
+ start_time: "datetime | None" = None,
729
+ end_time: "datetime | None" = None,
730
+ analytics: list[str] | None = None,
731
+ ) -> dict[str, Any]:
732
+ """Analyze trajectories for an agent (AIP-136 custom method).
733
+
734
+ Args:
735
+ agent_id: The agent ID to analyze trajectories for
736
+ start_time: Optional start time filter (inclusive). Only count trajectories
737
+ created at or after this time.
738
+ end_time: Optional end time filter (inclusive). Only count trajectories
739
+ created at or before this time.
740
+ analytics: List of analytics to compute. Empty or None means all available analytics.
741
+ Available analytics:
742
+ - "trajectory_count": Total number of trajectories for the agent
743
+
744
+ Returns:
745
+ Dictionary containing:
746
+ - "analytics": Dict of computed analytics (keys are analytic names)
747
+ - "trajectory_count": Total number of trajectories analyzed
748
+ - "computed_at": Timestamp when analytics were computed (datetime)
749
+
750
+ Raises:
751
+ grpc.RpcError: If gRPC error occurs
752
+
753
+ Example:
754
+ ```python
755
+ from datetime import datetime, timedelta, timezone
756
+
757
+ # Get all trajectory analytics
758
+ result = await harness.analyze_trajectories(
759
+ agent_id="my-agent",
760
+ analytics=["trajectory_count"],
761
+ )
762
+ print(f"Total trajectories: {result['analytics']['trajectory_count']['total']}")
763
+
764
+ # Get trajectories from the last 24 hours
765
+ result = await harness.analyze_trajectories(
766
+ agent_id="my-agent",
767
+ start_time=datetime.now(timezone.utc) - timedelta(hours=24),
768
+ )
769
+ ```
770
+ """
771
+ request = harness_pb2.AnalyzeTrajectoriesRequest(
772
+ agent_id=agent_id,
773
+ analytics=analytics or [],
774
+ )
775
+
776
+ # Convert datetime to protobuf Timestamp if provided
777
+ if start_time is not None:
778
+ ts = Timestamp()
779
+ ts.FromDatetime(start_time)
780
+ request.start_time.CopyFrom(ts)
781
+
782
+ if end_time is not None:
783
+ ts = Timestamp()
784
+ ts.FromDatetime(end_time)
785
+ request.end_time.CopyFrom(ts)
786
+
787
+ await self._ensure_connected()
788
+ assert self._stub is not None, "Client not connected"
789
+
790
+ # Inject organization_id and auth metadata
791
+ metadata = self._get_metadata()
792
+
793
+ try:
794
+ response = await self._stub.AnalyzeTrajectories(request, metadata=metadata)
795
+
796
+ # Convert protobuf Struct to Python dict
797
+ analytics_dict = {}
798
+ if response.analytics:
799
+ analytics_dict = MessageToDict(
800
+ response.analytics, preserving_proto_field_name=True
801
+ )
802
+
803
+ # Convert computed_at timestamp
804
+ computed_at = (
805
+ datetime.fromtimestamp(response.computed_at.seconds, tz=UTC)
806
+ if response.computed_at
807
+ else datetime.now(tz=UTC)
808
+ )
809
+
810
+ return {
811
+ "analytics": analytics_dict,
812
+ "trajectory_count": response.trajectory_count,
813
+ "computed_at": computed_at,
814
+ }
815
+ except grpc.aio.AioRpcError as e:
816
+ logging.error(
817
+ f"Failed to analyze trajectories for agent {agent_id}: {e.code()} - {e.details()}"
818
+ )
819
+ raise
820
+
821
+ async def list_adjudications(
822
+ self,
823
+ agent_id: str | None = None,
824
+ page_size: int = 50,
825
+ page_token: str = "",
826
+ ) -> tuple[list[AdjudicationRecord], str]:
827
+ """List adjudication records with optional agent filtering.
828
+
829
+ This method retrieves adjudication records (policy decisions) that have
830
+ occurred during agent execution. Results can be filtered by agent and
831
+ are paginated.
832
+
833
+ Args:
834
+ agent_id: Optional agent ID to filter adjudications. If None, returns
835
+ adjudications for all agents.
836
+ page_size: Maximum number of records to return per page (default: 50)
837
+ page_token: Token for pagination (empty string for first page)
838
+
839
+ Returns:
840
+ Tuple of (list of AdjudicationRecord objects, next page token).
841
+ The next page token will be empty if there are no more results.
842
+
843
+ Raises:
844
+ AuthenticationError: If authentication fails
845
+ grpc.RpcError: If other gRPC error occurs
846
+
847
+ Example:
848
+ ```python
849
+ # List all adjudications
850
+ records, next_token = await harness.list_adjudications()
851
+
852
+ # List adjudications for a specific agent
853
+ records, next_token = await harness.list_adjudications(agent_id="my-agent")
854
+
855
+ # Paginate through results
856
+ all_records = []
857
+ token = ""
858
+ while True:
859
+ records, token = await harness.list_adjudications(page_token=token)
860
+ all_records.extend(records)
861
+ if not token:
862
+ break
863
+ ```
864
+ """
865
+ request = harness_pb2.ListAdjudicationsRequest(
866
+ page_size=page_size,
867
+ page_token=page_token,
868
+ )
869
+ if agent_id is not None:
870
+ request.agent_id = agent_id
871
+
872
+ await self._ensure_connected()
873
+ assert self._stub is not None, "Client not connected"
874
+
875
+ metadata = self._get_metadata()
876
+
877
+ try:
878
+ response = await self._stub.ListAdjudications(request, metadata=metadata)
879
+ records = [
880
+ _convert_pb_adjudication_record_to_sdk(pb_record)
881
+ for pb_record in response.adjudications
882
+ ]
883
+ return records, response.next_page_token
884
+ except grpc.aio.AioRpcError as e:
885
+ if e.code() == grpc.StatusCode.UNAUTHENTICATED:
886
+ raise AuthenticationError(
887
+ f"Authentication failed: {e.details()}"
888
+ ) from e
889
+ logging.error(f"Failed to list adjudications: {e.code()} - {e.details()}")
890
+ raise