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,15 @@
1
+ """LangChain/LangGraph integration package for the Sondera SDK."""
2
+
3
+ from .analyze import analyze_langchain_tools, create_agent_from_langchain_tools
4
+ from .exceptions import GuardrailViolationError
5
+ from .graph import SonderaGraph
6
+ from .middleware import SonderaHarnessMiddleware, Strategy
7
+
8
+ __all__ = [
9
+ "GuardrailViolationError",
10
+ "SonderaHarnessMiddleware",
11
+ "SonderaGraph",
12
+ "Strategy",
13
+ "analyze_langchain_tools",
14
+ "create_agent_from_langchain_tools",
15
+ ]
@@ -0,0 +1,543 @@
1
+ """LangGraph agent analysis and automatic Agent message generation."""
2
+
3
+ import contextlib
4
+ import inspect
5
+ import json
6
+ import logging
7
+ from collections.abc import Callable
8
+ from typing import Any, get_type_hints
9
+
10
+ from langchain_core.tools import BaseTool
11
+
12
+ from sondera.types import Agent, Parameter, SourceCode, Tool
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def _python_type_to_json_schema_type(python_type: str) -> str:
18
+ """Convert Python type name to JSON Schema type."""
19
+ type_mapping = {
20
+ "str": "string",
21
+ "int": "integer",
22
+ "float": "number",
23
+ "bool": "boolean",
24
+ "list": "array",
25
+ "dict": "object",
26
+ "None": "null",
27
+ "NoneType": "null",
28
+ }
29
+ return type_mapping.get(python_type, "string")
30
+
31
+
32
+ def _extract_json_schema_from_pydantic(schema_class: Any) -> str | None:
33
+ """Extract JSON schema from a Pydantic model class.
34
+
35
+ Works with both Pydantic v1 and v2.
36
+ """
37
+ if schema_class is None:
38
+ return None
39
+
40
+ try:
41
+ # Try Pydantic v2 style first
42
+ if hasattr(schema_class, "model_json_schema"):
43
+ return json.dumps(schema_class.model_json_schema())
44
+ # Fallback to Pydantic v1 style
45
+ elif hasattr(schema_class, "schema"):
46
+ return json.dumps(schema_class.schema())
47
+ except Exception as e:
48
+ logger.debug(f"Could not extract JSON schema from Pydantic model: {e}")
49
+
50
+ return None
51
+
52
+
53
+ def _extract_tool_json_schemas(tool: Any) -> tuple[str | None, str | None]:
54
+ """Extract parameters and response JSON schemas from a LangChain tool.
55
+
56
+ Args:
57
+ tool: A LangChain tool (BaseTool instance or decorated function)
58
+
59
+ Returns:
60
+ Tuple of (parameters_json_schema, response_json_schema)
61
+ """
62
+ parameters_json_schema = None
63
+ response_json_schema = None
64
+
65
+ try:
66
+ # For BaseTool instances, extract from args_schema
67
+ if hasattr(tool, "args_schema") and tool.args_schema is not None:
68
+ parameters_json_schema = _extract_json_schema_from_pydantic(
69
+ tool.args_schema
70
+ )
71
+
72
+ # Try to get the tool's input schema directly (LangChain provides this)
73
+ if parameters_json_schema is None and hasattr(tool, "get_input_schema"):
74
+ try:
75
+ input_schema = tool.get_input_schema()
76
+ if input_schema is not None:
77
+ parameters_json_schema = _extract_json_schema_from_pydantic(
78
+ input_schema
79
+ )
80
+ except Exception:
81
+ pass
82
+
83
+ # For decorated functions, try to build schema from function signature
84
+ func = None
85
+ if inspect.isfunction(tool):
86
+ func = tool
87
+ elif hasattr(tool, "func") and inspect.isfunction(tool.func):
88
+ func = tool.func
89
+
90
+ if func is not None and parameters_json_schema is None:
91
+ parameters_json_schema = _build_json_schema_from_function(func)
92
+
93
+ # Extract response schema from return type
94
+ if func is not None:
95
+ response_json_schema = _build_response_schema_from_function(func)
96
+
97
+ except Exception as e:
98
+ logger.debug(f"Could not extract JSON schemas from tool: {e}")
99
+
100
+ return parameters_json_schema, response_json_schema
101
+
102
+
103
+ def _build_json_schema_from_function(func: Callable) -> str | None:
104
+ """Build a JSON schema from function signature and type hints."""
105
+ try:
106
+ sig = inspect.signature(func)
107
+ type_hints = {}
108
+ with contextlib.suppress(Exception):
109
+ type_hints = get_type_hints(func)
110
+
111
+ properties = {}
112
+ required = []
113
+
114
+ for param_name, param in sig.parameters.items():
115
+ # Skip special parameters
116
+ if param_name in ["self", "cls", "callbacks", "run_manager"]:
117
+ continue
118
+
119
+ # Determine the type
120
+ param_type = "string" # default
121
+ if param.annotation != inspect.Parameter.empty:
122
+ if isinstance(param.annotation, type):
123
+ param_type = _python_type_to_json_schema_type(
124
+ param.annotation.__name__
125
+ )
126
+ else:
127
+ type_str = str(param.annotation)
128
+ # Handle common typing module types
129
+ if "str" in type_str.lower():
130
+ param_type = "string"
131
+ elif "int" in type_str.lower():
132
+ param_type = "integer"
133
+ elif "float" in type_str.lower():
134
+ param_type = "number"
135
+ elif "bool" in type_str.lower():
136
+ param_type = "boolean"
137
+ elif "list" in type_str.lower():
138
+ param_type = "array"
139
+ elif "dict" in type_str.lower():
140
+ param_type = "object"
141
+ elif param_name in type_hints:
142
+ hint = type_hints[param_name]
143
+ if isinstance(hint, type):
144
+ param_type = _python_type_to_json_schema_type(hint.__name__)
145
+
146
+ # Extract description from docstring
147
+ description = f"Parameter {param_name}"
148
+ if func.__doc__:
149
+ lines = func.__doc__.split("\n")
150
+ for line in lines:
151
+ if param_name in line and ":" in line:
152
+ parts = line.split(":")
153
+ if len(parts) > 1:
154
+ description = parts[1].strip()
155
+ break
156
+
157
+ properties[param_name] = {"type": param_type, "description": description}
158
+
159
+ # Check if parameter is required (no default value)
160
+ if param.default == inspect.Parameter.empty:
161
+ required.append(param_name)
162
+
163
+ if not properties:
164
+ return None
165
+
166
+ schema = {"type": "object", "properties": properties}
167
+ if required:
168
+ schema["required"] = required
169
+
170
+ return json.dumps(schema)
171
+
172
+ except Exception as e:
173
+ logger.debug(f"Could not build JSON schema from function: {e}")
174
+ return None
175
+
176
+
177
+ def _build_response_schema_from_function(func: Callable) -> str | None:
178
+ """Build a response JSON schema from function return type."""
179
+ try:
180
+ sig = inspect.signature(func)
181
+ return_type = None
182
+
183
+ if sig.return_annotation != inspect.Signature.empty:
184
+ if isinstance(sig.return_annotation, type):
185
+ return_type = sig.return_annotation.__name__
186
+ else:
187
+ return_type = str(sig.return_annotation)
188
+
189
+ if return_type is None:
190
+ try:
191
+ type_hints = get_type_hints(func)
192
+ if "return" in type_hints:
193
+ hint = type_hints["return"]
194
+ return_type = hint.__name__ if isinstance(hint, type) else str(hint)
195
+ except Exception:
196
+ pass
197
+
198
+ if return_type and return_type not in ["Any", "None", "NoneType"]:
199
+ json_type = _python_type_to_json_schema_type(return_type)
200
+ return json.dumps(
201
+ {
202
+ "type": json_type,
203
+ "description": f"Return value of type {return_type}",
204
+ }
205
+ )
206
+
207
+ except Exception as e:
208
+ logger.debug(f"Could not build response schema from function: {e}")
209
+
210
+ return None
211
+
212
+
213
+ def _get_function_source(func: Callable) -> tuple[str, str]:
214
+ """Extract source code and language from a function."""
215
+ try:
216
+ source = inspect.getsource(func)
217
+ return "python", source
218
+ except (OSError, TypeError):
219
+ # Source not available (e.g., built-in function)
220
+ return "python", f"# Source code not available for {func.__name__}"
221
+
222
+
223
+ def _analyze_function_parameters(func: Callable) -> list[Parameter]:
224
+ """Analyze function parameters and return Sondera format Parameters."""
225
+ parameters = []
226
+ sig = inspect.signature(func)
227
+
228
+ # Try to get type hints for better type information
229
+ try:
230
+ type_hints = get_type_hints(func)
231
+ except Exception:
232
+ type_hints = {}
233
+
234
+ for param_name, param in sig.parameters.items():
235
+ # Skip special parameters that LangChain injects
236
+ if param_name in ["self", "cls", "callbacks", "run_manager"]:
237
+ continue
238
+
239
+ # Get parameter type
240
+ param_type = "Any"
241
+ if param.annotation != inspect.Parameter.empty:
242
+ if isinstance(param.annotation, type):
243
+ param_type = param.annotation.__name__
244
+ else:
245
+ param_type = str(param.annotation)
246
+ elif param_name in type_hints:
247
+ hint = type_hints[param_name]
248
+ param_type = hint.__name__ if isinstance(hint, type) else str(hint)
249
+
250
+ # Extract parameter description from docstring if available
251
+ description = f"Parameter {param_name}"
252
+ if func.__doc__:
253
+ # Simple extraction - could be enhanced with proper docstring parsing
254
+ lines = func.__doc__.split("\n")
255
+ for line in lines:
256
+ if param_name in line and ":" in line:
257
+ # Try to extract description after parameter name
258
+ parts = line.split(":")
259
+ if len(parts) > 1:
260
+ description = parts[1].strip()
261
+ break
262
+
263
+ parameters.append(
264
+ Parameter(name=param_name, description=description, type=param_type)
265
+ )
266
+
267
+ return parameters
268
+
269
+
270
+ def _get_function_return_type(func: Callable) -> str:
271
+ """Extract the return type from a function."""
272
+ sig = inspect.signature(func)
273
+ if sig.return_annotation != inspect.Signature.empty:
274
+ if isinstance(sig.return_annotation, type):
275
+ return sig.return_annotation.__name__
276
+ else:
277
+ return str(sig.return_annotation)
278
+
279
+ # Try type hints as fallback
280
+ try:
281
+ type_hints = get_type_hints(func)
282
+ if "return" in type_hints:
283
+ hint = type_hints["return"]
284
+ if isinstance(hint, type):
285
+ return hint.__name__
286
+ else:
287
+ return str(hint)
288
+ except Exception:
289
+ pass
290
+
291
+ return "Any"
292
+
293
+
294
+ def _analyze_langchain_tool(tool: Any) -> Tool:
295
+ """Analyze a LangChain tool and convert it to Sondera Tool format."""
296
+ # Extract JSON schemas for the tool (works for all tool types)
297
+ parameters_json_schema, response_json_schema = _extract_tool_json_schemas(tool)
298
+
299
+ if inspect.isfunction(tool):
300
+ # It's a raw function decorated with @tool
301
+ func = tool
302
+ tool_name = func.__name__
303
+ tool_description = func.__doc__ or f"Function {tool_name}"
304
+
305
+ # Analyze function signature for parameters
306
+ parameters = _analyze_function_parameters(func)
307
+
308
+ # Get return type
309
+ response_type = _get_function_return_type(func)
310
+
311
+ # Extract source code
312
+ language, source_code = _get_function_source(func)
313
+
314
+ return Tool(
315
+ name=tool_name,
316
+ description=tool_description.strip(),
317
+ parameters=parameters,
318
+ parameters_json_schema=parameters_json_schema,
319
+ response=response_type,
320
+ response_json_schema=response_json_schema,
321
+ source=SourceCode(language=language, code=source_code),
322
+ )
323
+
324
+ elif isinstance(tool, BaseTool) or hasattr(tool, "func"):
325
+ # It's a BaseTool instance (including StructuredTool from @tool decorator)
326
+ tool_name = tool.name
327
+ tool_description = tool.description or f"Tool {tool_name}"
328
+
329
+ # If it has a func attribute (from @tool decorator), analyze the underlying function
330
+ # Note: StructuredTool has func attr, but BaseTool doesn't - use getattr for type safety
331
+ if (func := getattr(tool, "func", None)) and inspect.isfunction(func):
332
+ parameters = _analyze_function_parameters(func)
333
+ response_type = _get_function_return_type(func)
334
+ language, source_code = _get_function_source(func)
335
+ else:
336
+ # For other BaseTool instances, try to extract parameters from the schema
337
+ parameters = []
338
+ if hasattr(tool, "args_schema") and tool.args_schema:
339
+ schema = tool.args_schema
340
+ # Pydantic v1 style - has __fields__ dict with ModelField objects
341
+ if v1_fields := getattr(schema, "__fields__", None):
342
+ for field_name, field_info in v1_fields.items():
343
+ param_type = "Any"
344
+ # Pydantic v1 ModelField uses type_ attribute
345
+ if field_type := getattr(field_info, "type_", None):
346
+ if isinstance(field_type, type):
347
+ param_type = field_type.__name__
348
+ else:
349
+ param_type = str(field_type)
350
+
351
+ description = getattr(
352
+ field_info, "description", f"Parameter {field_name}"
353
+ )
354
+ if description is None:
355
+ description = f"Parameter {field_name}"
356
+ parameters.append(
357
+ Parameter(
358
+ name=field_name,
359
+ description=description,
360
+ type=param_type,
361
+ )
362
+ )
363
+ # Pydantic v2 style - has model_fields dict with FieldInfo objects
364
+ elif v2_fields := getattr(schema, "model_fields", None):
365
+ for field_name, field_info in v2_fields.items():
366
+ param_type = "Any"
367
+ if annotation := getattr(field_info, "annotation", None):
368
+ if isinstance(annotation, type):
369
+ param_type = annotation.__name__
370
+ else:
371
+ param_type = str(annotation)
372
+
373
+ description = getattr(
374
+ field_info, "description", f"Parameter {field_name}"
375
+ )
376
+ if description is None:
377
+ description = f"Parameter {field_name}"
378
+ parameters.append(
379
+ Parameter(
380
+ name=field_name,
381
+ description=description,
382
+ type=param_type,
383
+ )
384
+ )
385
+
386
+ response_type = "Any"
387
+
388
+ # Try to get source code from various methods
389
+ language = "python"
390
+ source_code = f"# BaseTool instance: {tool_name}"
391
+ for method_name in ["_run", "_arun", "run", "__call__"]:
392
+ if hasattr(tool, method_name):
393
+ try:
394
+ method = getattr(tool, method_name)
395
+ source_code = inspect.getsource(method)
396
+ break
397
+ except Exception:
398
+ pass
399
+
400
+ return Tool(
401
+ name=tool_name,
402
+ description=tool_description,
403
+ parameters=parameters,
404
+ parameters_json_schema=parameters_json_schema,
405
+ response=response_type,
406
+ response_json_schema=response_json_schema,
407
+ source=SourceCode(language=language, code=source_code),
408
+ )
409
+
410
+ else:
411
+ # Unknown tool type, do our best
412
+ tool_name = getattr(tool, "name", tool.__class__.__name__)
413
+ tool_description = getattr(tool, "description", f"Tool {tool_name}")
414
+
415
+ return Tool(
416
+ name=tool_name,
417
+ description=tool_description,
418
+ parameters=[],
419
+ parameters_json_schema=parameters_json_schema,
420
+ response="Any",
421
+ response_json_schema=response_json_schema,
422
+ source=SourceCode(
423
+ language="python", code=f"# Unknown tool type: {type(tool)}"
424
+ ),
425
+ )
426
+
427
+
428
+ def analyze_langchain_tools(
429
+ tools: list[Any],
430
+ agent_id: str,
431
+ agent_name: str | None = None,
432
+ agent_description: str | None = None,
433
+ agent_instruction: str | None = None,
434
+ provider_id: str = "langchain",
435
+ ) -> Agent:
436
+ """Analyze LangChain tools and generate a Sondera Agent object.
437
+
438
+ Args:
439
+ tools: List of LangChain tools (functions decorated with @tool or BaseTool instances)
440
+ agent_id: Unique identifier for the agent
441
+ agent_name: Name of the agent (defaults to agent_id)
442
+ agent_description: Description of the agent
443
+ agent_instruction: Instruction or goal of the agent
444
+ provider_id: Provider identifier (defaults to "langchain")
445
+
446
+ Returns:
447
+ Agent object with automatically analyzed tools
448
+ """
449
+ agent_name = agent_name or agent_id
450
+ agent_description = agent_description or f"Agent {agent_name}"
451
+ agent_instruction = agent_instruction or "Execute tasks using available tools"
452
+
453
+ sondera_tools = []
454
+ for tool in tools:
455
+ try:
456
+ sondera_tool = _analyze_langchain_tool(tool)
457
+ sondera_tools.append(sondera_tool)
458
+ except Exception as e:
459
+ # Log the error but continue with other tools
460
+ import logging
461
+
462
+ logging.warning(f"Failed to analyze tool {tool}: {e}")
463
+ # Create a minimal tool entry
464
+ tool_name = getattr(tool, "name", str(tool))
465
+ sondera_tools.append(
466
+ Tool(
467
+ name=tool_name,
468
+ description=f"Tool {tool_name} (analysis failed)",
469
+ parameters=[],
470
+ response="Any",
471
+ source=SourceCode(
472
+ language="python", code=f"# Analysis failed for {tool_name}"
473
+ ),
474
+ )
475
+ )
476
+
477
+ return Agent(
478
+ id=agent_id,
479
+ provider_id=provider_id,
480
+ name=agent_name,
481
+ description=agent_description,
482
+ instruction=agent_instruction,
483
+ tools=sondera_tools,
484
+ )
485
+
486
+
487
+ def create_agent_from_langchain_tools(
488
+ tools: list[Any],
489
+ agent_id: str,
490
+ agent_name: str | None = None,
491
+ agent_description: str | None = None,
492
+ agent_instruction: str | None = None,
493
+ provider_id: str = "langchain",
494
+ system_prompt_func: Callable[[], str] | None = None,
495
+ ) -> Agent:
496
+ """Convenience function to create a Sondera Agent from LangChain tools.
497
+
498
+ This function automatically analyzes LangChain tools and creates a Sondera Agent.
499
+ It can also extract system instructions from a provided system prompt function.
500
+
501
+ Args:
502
+ tools: List of LangChain tools (functions decorated with @tool or BaseTool instances)
503
+ agent_id: Unique identifier for the agent
504
+ agent_name: Human-readable name for the agent
505
+ agent_description: Description of what the agent does
506
+ agent_instruction: Instructions for the agent behavior (optional if system_prompt_func provided)
507
+ provider_id: Provider identifier (default: "langchain")
508
+ system_prompt_func: Optional function that returns system prompt/instructions
509
+
510
+ Returns:
511
+ Agent: Configured Sondera Agent with automatically analyzed tools
512
+ """
513
+
514
+ # Extract system instruction from system_prompt_func if provided and agent_instruction is None
515
+ final_instruction = agent_instruction
516
+ if final_instruction is None and system_prompt_func is not None:
517
+ try:
518
+ system_prompt = system_prompt_func()
519
+ if isinstance(system_prompt, str) and system_prompt.strip():
520
+ final_instruction = system_prompt.strip()
521
+ logger.info(
522
+ f"Extracted system instruction from system_prompt_func: {len(final_instruction)} characters"
523
+ )
524
+ except Exception as e:
525
+ logger.warning(
526
+ f"Failed to extract system instruction from system_prompt_func: {e}"
527
+ )
528
+
529
+ # Fallback to a default instruction if none provided
530
+ if final_instruction is None:
531
+ final_instruction = (
532
+ "Use the available tools to assist users effectively and safely."
533
+ )
534
+ logger.info("Using default agent instruction")
535
+
536
+ return analyze_langchain_tools(
537
+ tools=tools,
538
+ agent_id=agent_id,
539
+ agent_name=agent_name,
540
+ agent_description=agent_description,
541
+ agent_instruction=final_instruction,
542
+ provider_id=provider_id,
543
+ )
@@ -0,0 +1,19 @@
1
+ """LangChain agent integration exceptions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from sondera.types import Stage
8
+
9
+
10
+ @dataclass(slots=True)
11
+ class GuardrailViolationError(RuntimeError):
12
+ """Raised when guardrail enforcement blocks agent execution."""
13
+
14
+ stage: Stage
15
+ node: str
16
+ reason: str
17
+
18
+ def __str__(self) -> str: # pragma: no cover - string formatting
19
+ return f"Guardrail violation at {self.node} during {self.stage.value}: {self.reason}"