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,244 @@
1
+ """Strands Agent analysis utilities."""
2
+
3
+ import inspect
4
+ import json
5
+ import logging
6
+ from collections.abc import Callable
7
+ from typing import Any, get_type_hints
8
+
9
+ from sondera.types import Agent, Parameter, SourceCode, Tool
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _get_function_source(func: Callable) -> tuple[str, str]:
15
+ """Extract source code and language from a function."""
16
+ try:
17
+ source = inspect.getsource(func)
18
+ return "python", source
19
+ except (OSError, TypeError):
20
+ return "python", f"# Source code not available for {func.__name__}"
21
+
22
+
23
+ def _analyze_function_parameters(func: Callable) -> list[Parameter]:
24
+ """Analyze function parameters and return Sondera format Parameters."""
25
+ parameters = []
26
+ sig = inspect.signature(func)
27
+
28
+ try:
29
+ type_hints = get_type_hints(func)
30
+ except Exception:
31
+ type_hints = {}
32
+
33
+ for param_name, param in sig.parameters.items():
34
+ if param_name in ["tool_context", "self", "cls"]:
35
+ continue
36
+
37
+ param_type = "Any"
38
+ if param.annotation != inspect.Parameter.empty:
39
+ if isinstance(param.annotation, type):
40
+ param_type = param.annotation.__name__
41
+ else:
42
+ param_type = str(param.annotation)
43
+ elif param_name in type_hints:
44
+ hint = type_hints[param_name]
45
+ param_type = hint.__name__ if isinstance(hint, type) else str(hint)
46
+
47
+ description = f"Parameter {param_name}"
48
+ if func.__doc__:
49
+ lines = func.__doc__.split("\n")
50
+ for line in lines:
51
+ if param_name in line:
52
+ description = line.strip()
53
+ break
54
+
55
+ parameters.append(
56
+ Parameter(name=param_name, description=description, type=param_type)
57
+ )
58
+
59
+ return parameters
60
+
61
+
62
+ def _get_function_return_type(func: Callable) -> str:
63
+ """Extract the return type from a function."""
64
+ sig = inspect.signature(func)
65
+ if sig.return_annotation != inspect.Signature.empty:
66
+ if isinstance(sig.return_annotation, type):
67
+ return sig.return_annotation.__name__
68
+ else:
69
+ return str(sig.return_annotation)
70
+
71
+ try:
72
+ type_hints = get_type_hints(func)
73
+ if "return" in type_hints:
74
+ hint = type_hints["return"]
75
+ if isinstance(hint, type):
76
+ return hint.__name__
77
+ else:
78
+ return str(hint)
79
+ except Exception:
80
+ pass
81
+
82
+ return "Any"
83
+
84
+
85
+ def _extract_strands_tool_schema(tool: Any) -> tuple[str | None, str | None]:
86
+ """Extract JSON schemas from a Strands tool.
87
+
88
+ Strands tools decorated with @tool have a tool_spec attribute containing:
89
+ - name: tool name
90
+ - description: tool description
91
+ - inputSchema: dict with 'json' key containing the JSON schema
92
+
93
+ Args:
94
+ tool: A Strands tool (decorated function or tool object)
95
+
96
+ Returns:
97
+ Tuple of (parameters_json_schema, response_json_schema)
98
+ """
99
+ parameters_json_schema = None
100
+ response_json_schema = None
101
+
102
+ try:
103
+ # Check for tool_spec attribute (Strands @tool decorated functions)
104
+ tool_spec = getattr(tool, "tool_spec", None)
105
+ if tool_spec and isinstance(tool_spec, dict):
106
+ # Extract inputSchema from tool_spec
107
+ input_schema = tool_spec.get("inputSchema", {})
108
+ if input_schema:
109
+ # Strands stores the JSON schema under the 'json' key
110
+ json_schema = input_schema.get("json", input_schema)
111
+ if json_schema:
112
+ parameters_json_schema = json.dumps(json_schema)
113
+
114
+ # Try to generate response schema from return type annotation
115
+ if callable(tool):
116
+ return_type = _get_function_return_type(tool)
117
+ if return_type and return_type != "Any":
118
+ response_json_schema = json.dumps(
119
+ {
120
+ "type": _python_type_to_json_schema_type(return_type),
121
+ "description": f"Return value of type {return_type}",
122
+ }
123
+ )
124
+ except Exception as e:
125
+ logger.debug(f"Could not extract JSON schema from tool: {e}")
126
+
127
+ return parameters_json_schema, response_json_schema
128
+
129
+
130
+ def _python_type_to_json_schema_type(python_type: str) -> str:
131
+ """Convert Python type name to JSON Schema type."""
132
+ type_mapping = {
133
+ "str": "string",
134
+ "int": "integer",
135
+ "float": "number",
136
+ "bool": "boolean",
137
+ "list": "array",
138
+ "dict": "object",
139
+ "None": "null",
140
+ "NoneType": "null",
141
+ }
142
+ return type_mapping.get(python_type, "string")
143
+
144
+
145
+ def _extract_tool_info(tool: Any) -> dict[str, Any]:
146
+ """Extract all tool information from a Strands tool.
147
+
148
+ Args:
149
+ tool: A Strands tool
150
+
151
+ Returns:
152
+ Dictionary with tool name, description, and schemas
153
+ """
154
+ # Get tool_spec if available (Strands @tool decorated functions)
155
+ tool_spec = getattr(tool, "tool_spec", None)
156
+
157
+ if tool_spec and isinstance(tool_spec, dict):
158
+ tool_name = tool_spec.get(
159
+ "name", getattr(tool, "tool_name", getattr(tool, "__name__", "unknown"))
160
+ )
161
+ tool_description = tool_spec.get("description", "")
162
+ else:
163
+ tool_name = getattr(
164
+ tool,
165
+ "tool_name",
166
+ getattr(tool, "name", getattr(tool, "__name__", "unknown")),
167
+ )
168
+ tool_description = getattr(
169
+ tool, "description", tool.__doc__ or f"Tool {tool_name}"
170
+ )
171
+
172
+ return {
173
+ "name": tool_name,
174
+ "description": tool_description,
175
+ }
176
+
177
+
178
+ def format_strands_agent(agent: Any) -> Agent:
179
+ """Transform a Strands agent into Sondera Agent format.
180
+
181
+ Extracts tool metadata including JSON schemas for parameters and responses.
182
+
183
+ Args:
184
+ agent: The Strands agent instance
185
+
186
+ Returns:
187
+ Sondera Agent representation with full tool schemas
188
+ """
189
+ # Extract agent metadata
190
+ agent_name = getattr(agent, "name", "strands-agent")
191
+ agent_id = agent_name
192
+ system_prompt = getattr(agent, "system_prompt", "")
193
+ description = getattr(agent, "description", f"Strands agent: {agent_name}")
194
+
195
+ # Extract tools
196
+ tools = []
197
+ agent_tools = getattr(agent, "tools", []) or []
198
+
199
+ for tool in agent_tools:
200
+ try:
201
+ # Extract tool info from tool_spec or attributes
202
+ tool_info = _extract_tool_info(tool)
203
+ tool_name = tool_info["name"]
204
+ tool_description = tool_info["description"]
205
+
206
+ # Extract JSON schemas
207
+ parameters_json_schema, response_json_schema = _extract_strands_tool_schema(
208
+ tool
209
+ )
210
+
211
+ # Analyze function for additional info
212
+ if callable(tool):
213
+ parameters = _analyze_function_parameters(tool)
214
+ response_type = _get_function_return_type(tool)
215
+ language, source_code = _get_function_source(tool)
216
+ else:
217
+ parameters = []
218
+ response_type = "Any"
219
+ language, source_code = "python", f"# Tool object: {tool_name}"
220
+
221
+ tools.append(
222
+ Tool(
223
+ name=tool_name,
224
+ description=tool_description.strip()
225
+ if isinstance(tool_description, str)
226
+ else str(tool_description),
227
+ parameters=parameters,
228
+ parameters_json_schema=parameters_json_schema,
229
+ response=response_type,
230
+ response_json_schema=response_json_schema,
231
+ source=SourceCode(language=language, code=source_code),
232
+ )
233
+ )
234
+ except Exception as e:
235
+ logger.warning(f"Could not analyze tool {tool}: {e}")
236
+
237
+ return Agent(
238
+ id=agent_id,
239
+ provider_id="strands",
240
+ name=agent_name,
241
+ instruction=system_prompt,
242
+ description=description,
243
+ tools=tools,
244
+ )
@@ -0,0 +1,333 @@
1
+ """Sondera Harness Hook for Strands Agent SDK integration."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ from strands.hooks import HookProvider, HookRegistry
7
+ from strands.hooks.events import (
8
+ AfterInvocationEvent,
9
+ AfterModelCallEvent,
10
+ AfterToolCallEvent,
11
+ BeforeInvocationEvent,
12
+ BeforeModelCallEvent,
13
+ BeforeToolCallEvent,
14
+ )
15
+
16
+ from sondera.harness import Harness
17
+ from sondera.strands.analyze import format_strands_agent
18
+ from sondera.types import (
19
+ PromptContent,
20
+ Role,
21
+ Stage,
22
+ ToolRequestContent,
23
+ ToolResponseContent,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ class SonderaHarnessHook(HookProvider):
30
+ """Sondera Harness Hook for Strands integration.
31
+
32
+ This hook implements the HookProvider protocol to integrate with
33
+ Strands' hook system. It uses dependency injection for the Harness
34
+ instance, allowing flexibility in choosing RemoteHarness or LocalHarness.
35
+
36
+ The hook intercepts agent execution at key lifecycle points:
37
+ - Before/after invocation: Initialize and finalize trajectory
38
+ - Before/after model call: Evaluate model requests and responses
39
+ - Before/after tool call: Evaluate tool calls and results
40
+
41
+ Example:
42
+ ```python
43
+ from strands import Agent
44
+ from sondera.strands import SonderaHarnessHook
45
+ from sondera.harness import RemoteHarness
46
+
47
+ # Create harness instance
48
+ harness = RemoteHarness(
49
+ sondera_harness_endpoint="localhost:50051",
50
+ sondera_api_key="<YOUR_SONDERA_API_KEY>",
51
+ )
52
+
53
+ # Create hook with harness using dependency injection
54
+ hook = SonderaHarnessHook(harness=harness)
55
+
56
+ # Create Strands agent with hooks
57
+ agent = Agent(
58
+ system_prompt="You are a helpful assistant",
59
+ model="anthropic.claude-3-5-sonnet-20241022-v2:0",
60
+ hooks=[hook],
61
+ )
62
+
63
+ # Run agent (hooks will fire automatically)
64
+ response = agent("What is 5 + 3?")
65
+ ```
66
+
67
+ Hook Lifecycle:
68
+ 1. BeforeInvocationEvent - Initialize trajectory
69
+ 2. BeforeModelCallEvent - Pre-model guardrails
70
+ 3. AfterModelCallEvent - Post-model guardrails
71
+ 4. BeforeToolCallEvent - Pre-tool guardrails (can cancel with event.cancel_tool)
72
+ 5. AfterToolCallEvent - Post-tool guardrails (can modify event.result)
73
+ 6. AfterInvocationEvent - Finalize trajectory
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ harness: Harness,
79
+ *,
80
+ logger_instance: logging.Logger | None = None,
81
+ ):
82
+ """Initialize the Strands Harness Hook.
83
+
84
+ Args:
85
+ harness: The Sondera Harness instance to use for policy enforcement.
86
+ Can be RemoteHarness for production or LocalHarness for testing.
87
+ logger_instance: Optional custom logger instance.
88
+ """
89
+ self._harness = harness
90
+ self._log = logger_instance or logger
91
+ self._strands_agent: Any | None = None
92
+
93
+ # -------------------------------------------------------------------------
94
+ # HookProvider interface - required by Strands
95
+ # -------------------------------------------------------------------------
96
+
97
+ def register_hooks(self, registry: HookRegistry, **kwargs: Any) -> None:
98
+ """Register all Strands lifecycle hooks.
99
+
100
+ This method is called by Strands when the agent is constructed with
101
+ hooks=[SonderaHarnessHook(harness=...)].
102
+
103
+ Args:
104
+ registry: The Strands hook registry
105
+ **kwargs: Additional keyword arguments (unused)
106
+ """
107
+ registry.add_callback(BeforeInvocationEvent, self._on_before_invocation)
108
+ registry.add_callback(AfterInvocationEvent, self._on_after_invocation)
109
+ registry.add_callback(BeforeModelCallEvent, self._on_before_model_call)
110
+ registry.add_callback(AfterModelCallEvent, self._on_after_model_call)
111
+ registry.add_callback(BeforeToolCallEvent, self._on_before_tool_call)
112
+ registry.add_callback(AfterToolCallEvent, self._on_after_tool_call)
113
+
114
+ self._log.info("[SonderaHarness] Registered Strands hooks")
115
+
116
+ # -------------------------------------------------------------------------
117
+ # Invocation Callbacks
118
+ # -------------------------------------------------------------------------
119
+
120
+ async def _on_before_invocation(self, event: BeforeInvocationEvent) -> None:
121
+ """Callback for BeforeInvocationEvent - Initialize trajectory."""
122
+ try:
123
+ self._strands_agent = event.agent
124
+ agent = format_strands_agent(event.agent)
125
+ await self._harness.initialize(agent=agent)
126
+ self._log.debug(
127
+ f"[SonderaHarness] Initialized trajectory {self._harness.trajectory_id}"
128
+ )
129
+ except Exception as e:
130
+ self._log.error(
131
+ f"[SonderaHarness] Error in before_invocation: {e}", exc_info=True
132
+ )
133
+
134
+ async def _on_after_invocation(self, event: AfterInvocationEvent) -> None:
135
+ """Callback for AfterInvocationEvent - Finalize trajectory."""
136
+ try:
137
+ trajectory_id = self._harness.trajectory_id
138
+ await self._harness.finalize()
139
+ self._log.info(f"[SonderaHarness] Finalized trajectory {trajectory_id}")
140
+ except Exception as e:
141
+ self._log.error(
142
+ f"[SonderaHarness] Error in after_invocation: {e}", exc_info=True
143
+ )
144
+
145
+ # -------------------------------------------------------------------------
146
+ # Model Callbacks
147
+ # -------------------------------------------------------------------------
148
+
149
+ async def _on_before_model_call(self, event: BeforeModelCallEvent) -> None:
150
+ """Callback for BeforeModelCallEvent - Pre-model guardrails."""
151
+ try:
152
+ if not self._harness.trajectory_id:
153
+ self._log.warning(
154
+ "[SonderaHarness] No active trajectory for before_model_call"
155
+ )
156
+ return
157
+
158
+ content = self._extract_text_from_event(event)
159
+ adjudication = await self._harness.adjudicate(
160
+ Stage.PRE_MODEL, Role.MODEL, PromptContent(text=content)
161
+ )
162
+ self._log.info(
163
+ f"[SonderaHarness] Before model adjudication for trajectory {self._harness.trajectory_id}"
164
+ )
165
+
166
+ if adjudication.is_denied:
167
+ self._log.warning(
168
+ f"[SonderaHarness] Model call blocked: {adjudication.reason}"
169
+ )
170
+ except Exception as e:
171
+ self._log.error(
172
+ f"[SonderaHarness] Error in before_model_call: {e}", exc_info=True
173
+ )
174
+
175
+ async def _on_after_model_call(self, event: AfterModelCallEvent) -> None:
176
+ """Callback for AfterModelCallEvent - Post-model guardrails."""
177
+ try:
178
+ if not self._harness.trajectory_id:
179
+ self._log.warning(
180
+ "[SonderaHarness] No active trajectory for after_model_call"
181
+ )
182
+ return
183
+
184
+ content = self._extract_text_from_event(event)
185
+ if not content:
186
+ return
187
+
188
+ adjudication = await self._harness.adjudicate(
189
+ Stage.POST_MODEL, Role.MODEL, PromptContent(text=content)
190
+ )
191
+ self._log.info(
192
+ f"[SonderaHarness] After model adjudication for trajectory {self._harness.trajectory_id}"
193
+ )
194
+
195
+ if adjudication.is_denied:
196
+ self._log.warning(
197
+ f"[SonderaHarness] Model response blocked: {adjudication.reason}"
198
+ )
199
+ except Exception as e:
200
+ self._log.error(
201
+ f"[SonderaHarness] Error in after_model_call: {e}", exc_info=True
202
+ )
203
+
204
+ # -------------------------------------------------------------------------
205
+ # Tool Callbacks
206
+ # -------------------------------------------------------------------------
207
+
208
+ async def _on_before_tool_call(self, event: BeforeToolCallEvent) -> None:
209
+ """Callback for BeforeToolCallEvent - Pre-tool guardrails."""
210
+ try:
211
+ if not self._harness.trajectory_id:
212
+ self._log.warning(
213
+ "[SonderaHarness] No active trajectory for before_tool_call"
214
+ )
215
+ return
216
+
217
+ tool_name = event.tool_use.get("name", "unknown")
218
+ tool_input = event.tool_use.get("input", {})
219
+
220
+ adjudication = await self._harness.adjudicate(
221
+ Stage.PRE_TOOL,
222
+ Role.TOOL,
223
+ ToolRequestContent(
224
+ tool_id=tool_name,
225
+ args=tool_input
226
+ if isinstance(tool_input, dict)
227
+ else {"input": tool_input},
228
+ ),
229
+ )
230
+ self._log.info(
231
+ f"[SonderaHarness] Before tool adjudication for trajectory {self._harness.trajectory_id}"
232
+ )
233
+
234
+ if adjudication.is_denied:
235
+ # Cancel the tool call using Strands' cancel_tool mechanism
236
+ event.cancel_tool = f"Tool blocked by policy: {adjudication.reason}"
237
+ self._log.warning(
238
+ f"[SonderaHarness] Blocked tool '{tool_name}': {adjudication.reason}"
239
+ )
240
+ except Exception as e:
241
+ self._log.error(
242
+ f"[SonderaHarness] Error in before_tool_call: {e}", exc_info=True
243
+ )
244
+
245
+ async def _on_after_tool_call(self, event: AfterToolCallEvent) -> None:
246
+ """Callback for AfterToolCallEvent - Post-tool guardrails."""
247
+ try:
248
+ if not self._harness.trajectory_id:
249
+ self._log.warning(
250
+ "[SonderaHarness] No active trajectory for after_tool_call"
251
+ )
252
+ return
253
+
254
+ tool_name = event.tool_use.get("name", "unknown")
255
+
256
+ adjudication = await self._harness.adjudicate(
257
+ Stage.POST_TOOL,
258
+ Role.TOOL,
259
+ ToolResponseContent(tool_id=tool_name, response=event.result),
260
+ )
261
+ self._log.info(
262
+ f"[SonderaHarness] After tool adjudication for trajectory {self._harness.trajectory_id}"
263
+ )
264
+
265
+ if adjudication.is_denied:
266
+ # Modify the result to indicate policy violation
267
+ event.result = {
268
+ "content": [
269
+ {"text": f"Tool result blocked: {adjudication.reason}"}
270
+ ],
271
+ "status": "error",
272
+ "toolUseId": event.tool_use.get("toolUseId", ""),
273
+ }
274
+ self._log.warning(
275
+ f"[SonderaHarness] Tool result blocked: {adjudication.reason}"
276
+ )
277
+ except Exception as e:
278
+ self._log.error(
279
+ f"[SonderaHarness] Error in after_tool_call: {e}", exc_info=True
280
+ )
281
+
282
+ # -------------------------------------------------------------------------
283
+ # Helper methods
284
+ # -------------------------------------------------------------------------
285
+
286
+ def _extract_text_from_event(self, event: Any) -> str:
287
+ """Extract text content from Strands events for adjudication.
288
+
289
+ Args:
290
+ event: The Strands hook event
291
+
292
+ Returns:
293
+ Extracted text content for adjudication
294
+ """
295
+ if isinstance(event, BeforeModelCallEvent):
296
+ try:
297
+ if (
298
+ hasattr(event.agent, "conversation_manager")
299
+ and event.agent.conversation_manager
300
+ ):
301
+ messages = []
302
+ conv_mgr = event.agent.conversation_manager
303
+ messages_attr = getattr(conv_mgr, "messages", None)
304
+ if messages_attr:
305
+ for msg in messages_attr:
306
+ role = (
307
+ msg.get("role", "unknown")
308
+ if hasattr(msg, "get")
309
+ else getattr(msg, "role", "unknown")
310
+ )
311
+ content = (
312
+ msg.get("content", "")
313
+ if hasattr(msg, "get")
314
+ else getattr(msg, "content", "")
315
+ )
316
+ messages.append(f"{role}: {content}")
317
+ if messages:
318
+ return "\n".join(messages)
319
+ except (AttributeError, TypeError) as e:
320
+ self._log.debug(f"Could not extract conversation: {e}")
321
+ return ""
322
+
323
+ if isinstance(event, AfterModelCallEvent):
324
+ if event.stop_response and event.stop_response.message:
325
+ content = (
326
+ event.stop_response.message.get("content", "")
327
+ if hasattr(event.stop_response.message, "get")
328
+ else getattr(event.stop_response.message, "content", "")
329
+ )
330
+ return str(content)
331
+ return ""
332
+
333
+ return ""
File without changes