erdo 0.1.31__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 (48) hide show
  1. erdo/__init__.py +35 -0
  2. erdo/_generated/__init__.py +18 -0
  3. erdo/_generated/actions/__init__.py +34 -0
  4. erdo/_generated/actions/analysis.py +179 -0
  5. erdo/_generated/actions/bot.py +186 -0
  6. erdo/_generated/actions/codeexec.py +199 -0
  7. erdo/_generated/actions/llm.py +148 -0
  8. erdo/_generated/actions/memory.py +463 -0
  9. erdo/_generated/actions/pdfextractor.py +97 -0
  10. erdo/_generated/actions/resource_definitions.py +296 -0
  11. erdo/_generated/actions/sqlexec.py +90 -0
  12. erdo/_generated/actions/utils.py +475 -0
  13. erdo/_generated/actions/webparser.py +119 -0
  14. erdo/_generated/actions/websearch.py +85 -0
  15. erdo/_generated/condition/__init__.py +556 -0
  16. erdo/_generated/internal.py +51 -0
  17. erdo/_generated/internal_actions.py +91 -0
  18. erdo/_generated/parameters.py +17 -0
  19. erdo/_generated/secrets.py +17 -0
  20. erdo/_generated/template_functions.py +55 -0
  21. erdo/_generated/types.py +3907 -0
  22. erdo/actions/__init__.py +40 -0
  23. erdo/bot_permissions.py +266 -0
  24. erdo/cli_entry.py +73 -0
  25. erdo/conditions/__init__.py +11 -0
  26. erdo/config/__init__.py +5 -0
  27. erdo/config/config.py +140 -0
  28. erdo/formatting.py +279 -0
  29. erdo/install_cli.py +140 -0
  30. erdo/integrations.py +131 -0
  31. erdo/invoke/__init__.py +11 -0
  32. erdo/invoke/client.py +234 -0
  33. erdo/invoke/invoke.py +555 -0
  34. erdo/state.py +376 -0
  35. erdo/sync/__init__.py +17 -0
  36. erdo/sync/client.py +95 -0
  37. erdo/sync/extractor.py +492 -0
  38. erdo/sync/sync.py +327 -0
  39. erdo/template.py +136 -0
  40. erdo/test/__init__.py +41 -0
  41. erdo/test/evaluate.py +272 -0
  42. erdo/test/runner.py +263 -0
  43. erdo/types.py +1431 -0
  44. erdo-0.1.31.dist-info/METADATA +471 -0
  45. erdo-0.1.31.dist-info/RECORD +48 -0
  46. erdo-0.1.31.dist-info/WHEEL +4 -0
  47. erdo-0.1.31.dist-info/entry_points.txt +2 -0
  48. erdo-0.1.31.dist-info/licenses/LICENSE +22 -0
erdo/types.py ADDED
@@ -0,0 +1,1431 @@
1
+ """
2
+ Erdo Agent SDK - Core Types
3
+
4
+ This file contains the main SDK classes for building AI agents.
5
+ Auto-generated types are imported from the generated module.
6
+ """
7
+
8
+ import os
9
+ from enum import Enum
10
+ from pathlib import Path
11
+ from typing import Any, Callable, Dict, List, Optional, Protocol, Tuple, Union, cast
12
+
13
+ from pydantic import BaseModel, Field, field_serializer, model_serializer
14
+
15
+ from ._generated.types import (
16
+ BotResource,
17
+ Dataset,
18
+ DatasetType,
19
+ ExecutionModeType,
20
+ HandlerType,
21
+ OutputContentType,
22
+ OutputVisibility,
23
+ ParameterDefinition,
24
+ ParameterHydrationBehaviour,
25
+ Tool,
26
+ )
27
+ from .bot_permissions import (
28
+ BotPermissions,
29
+ check_bot_access,
30
+ get_bot_permissions,
31
+ set_bot_org_permission,
32
+ set_bot_public,
33
+ set_bot_user_permission,
34
+ )
35
+ from .template import TemplateString
36
+
37
+ # Type aliases for complex types that are json.RawMessage in Go
38
+ ExecutionCondition = Dict[str, Any] # Complex execution condition configuration
39
+
40
+
41
+ class MessageRole(str, Enum):
42
+ """Role for step output in message history."""
43
+
44
+ USER = "user"
45
+ ASSISTANT = "assistant"
46
+
47
+
48
+ # Protocols for better type safety
49
+ class ActionProtocol(Protocol):
50
+ """Protocol for action objects that can be converted to parameters."""
51
+
52
+ name: str
53
+
54
+ def model_dump(self) -> Dict[str, Any]: ...
55
+
56
+
57
+ class StepLike(Protocol):
58
+ """Protocol for step-like objects."""
59
+
60
+ key: Optional[str]
61
+ id: Optional[str]
62
+
63
+
64
+ class ConditionProtocol(Protocol):
65
+ """Protocol for condition objects."""
66
+
67
+ def to_dict(self) -> Dict[str, Any]: ...
68
+
69
+
70
+ class ExecutionModeProtocol(Protocol):
71
+ """Protocol for execution mode objects."""
72
+
73
+ def to_dict(self) -> Dict[str, Any]: ...
74
+
75
+
76
+ class PythonFile(BaseModel):
77
+ """Reference to a Python file for code execution.
78
+
79
+ Can be used in code_files parameter to reference local Python files.
80
+ When used, the file content will be automatically loaded during export.
81
+ """
82
+
83
+ filename: str = Field(
84
+ ..., description="Path to the file (e.g., 'analyze_file_files/analyze.py')"
85
+ )
86
+
87
+ def to_dict(self) -> Dict[str, Any]:
88
+ """Convert to dict format for serialization."""
89
+ return {
90
+ "filename": self.filename,
91
+ "_type": "PythonFile", # Marker to identify this as a file reference
92
+ }
93
+
94
+ @model_serializer
95
+ def _serialize_model(self) -> Dict[str, Any]:
96
+ """Pydantic v2 serializer - always use our custom format."""
97
+ return self.to_dict()
98
+
99
+ def resolve_content(self, base_path: Optional[str] = None) -> Dict[str, str]:
100
+ """Resolve the file content for inclusion in code_files.
101
+
102
+ Args:
103
+ base_path: Base directory path to resolve relative paths
104
+
105
+ Returns:
106
+ Dict with filename and content
107
+ """
108
+ if base_path is None:
109
+ base_path = os.getcwd()
110
+
111
+ file_path = Path(base_path) / self.filename
112
+
113
+ try:
114
+ with open(file_path, "r", encoding="utf-8") as f:
115
+ content = f.read()
116
+ # Return just the base filename, not the full path
117
+ base_filename = Path(self.filename).name
118
+ return {"filename": base_filename, "content": content}
119
+ except FileNotFoundError:
120
+ raise FileNotFoundError(f"Python file not found: {file_path}")
121
+ except Exception as e:
122
+ raise RuntimeError(f"Error reading Python file {file_path}: {e}")
123
+
124
+
125
+ def _extract_step_config_from_action(action: Any) -> Dict[str, Any]:
126
+ """Extract step configuration from an action object.
127
+
128
+ Actions can include a step_metadata parameter that configures the Step properties.
129
+ This function extracts those properties and returns them as a dict for Step(**config).
130
+
131
+ Example:
132
+ utils.echo(
133
+ data={"result": "value"},
134
+ step_metadata=StepMetadata(
135
+ key="my_step",
136
+ output_behavior={"result": OutputBehaviorType.MERGE}
137
+ )
138
+ )
139
+
140
+ The step_metadata fields are extracted and used to configure the Step:
141
+ - key: Step identifier
142
+ - output_behavior: How output fields are merged into state
143
+ - execution_mode: Parallel/sequential/background/iterate
144
+ - depends_on: Step dependencies
145
+ - output_channels: Where output is sent
146
+ - visibility settings: User and bot visibility
147
+ - messages: Running/finished messages
148
+ - content types: Output/history/UI content types
149
+
150
+ Args:
151
+ action: Action object (from utils.echo, llm.message, etc.)
152
+
153
+ Returns:
154
+ Dict with 'action' and any extracted step_metadata fields
155
+ """
156
+ step_config: Dict[str, Any] = {"action": action}
157
+
158
+ # Extract step_metadata if present on the action object
159
+ if hasattr(action, "step_metadata") and action.step_metadata is not None:
160
+ metadata = action.step_metadata
161
+
162
+ # Extract each metadata field if it's set (not None)
163
+ if hasattr(metadata, "key") and metadata.key:
164
+ step_config["key"] = metadata.key
165
+ if hasattr(metadata, "depends_on") and metadata.depends_on:
166
+ step_config["depends_on"] = metadata.depends_on
167
+ if hasattr(metadata, "execution_mode") and metadata.execution_mode:
168
+ step_config["execution_mode"] = metadata.execution_mode
169
+ if hasattr(metadata, "output_behavior") and metadata.output_behavior:
170
+ step_config["output_behavior"] = metadata.output_behavior
171
+ if hasattr(metadata, "output_channels") and metadata.output_channels:
172
+ step_config["output_channels"] = metadata.output_channels
173
+ if hasattr(metadata, "output_content_type") and metadata.output_content_type:
174
+ step_config["output_content_type"] = metadata.output_content_type
175
+ if hasattr(metadata, "history_content_type") and metadata.history_content_type:
176
+ step_config["history_content_type"] = metadata.history_content_type
177
+ if hasattr(metadata, "history_role") and metadata.history_role:
178
+ step_config["history_role"] = metadata.history_role
179
+ if hasattr(metadata, "ui_content_type") and metadata.ui_content_type:
180
+ step_config["ui_content_type"] = metadata.ui_content_type
181
+ if (
182
+ hasattr(metadata, "user_output_visibility")
183
+ and metadata.user_output_visibility
184
+ ):
185
+ step_config["user_output_visibility"] = metadata.user_output_visibility
186
+ if (
187
+ hasattr(metadata, "bot_output_visibility")
188
+ and metadata.bot_output_visibility
189
+ ):
190
+ step_config["bot_output_visibility"] = metadata.bot_output_visibility
191
+ if hasattr(metadata, "running_status") and metadata.running_status:
192
+ step_config["running_status"] = metadata.running_status
193
+ if hasattr(metadata, "finished_status") and metadata.finished_status:
194
+ step_config["finished_status"] = metadata.finished_status
195
+ if (
196
+ hasattr(metadata, "parameter_hydration_behaviour")
197
+ and metadata.parameter_hydration_behaviour
198
+ ):
199
+ step_config["parameter_hydration_behaviour"] = (
200
+ metadata.parameter_hydration_behaviour
201
+ )
202
+
203
+ return step_config
204
+
205
+
206
+ class StepMetadata(BaseModel):
207
+ """Metadata for workflow steps, containing configuration and execution parameters.
208
+
209
+ StepMetadata is used to configure step properties when creating steps via actions.
210
+ It's particularly useful in result handlers where you need to configure step behavior:
211
+
212
+ Example:
213
+ step.on(IsSuccess(),
214
+ utils.parse_json(
215
+ json_data="{{output}}",
216
+ step_metadata=StepMetadata(
217
+ key="parse_result",
218
+ output_behavior={"data": OutputBehaviorType.MERGE}
219
+ )
220
+ )
221
+ )
222
+
223
+ Fields:
224
+ key: Step identifier (used for referencing in templates and dependencies)
225
+ output_behavior: Controls how output fields are merged into state
226
+ - STEP_ONLY: Output only available via steps.step_key.field
227
+ - MERGE: Output fields merged into root state
228
+ - OVERWRITE: Output replaces entire state
229
+ execution_mode: How the step executes (sequential/parallel/background/iterate)
230
+ depends_on: Other steps this step depends on
231
+ output_channels: Where step output is sent (e.g., ["user", "bot"])
232
+ visibility: Control who sees the output (user/bot)
233
+ messages: Custom running/finished messages
234
+ content_types: Specify output/history/UI content types
235
+ """
236
+
237
+ model_config = {"arbitrary_types_allowed": True}
238
+
239
+ key: Optional[str] = None
240
+ depends_on: Union[List[Union[Any, str]], None] = (
241
+ None # Can be Step objects or strings
242
+ )
243
+ execution_mode: Optional[Union[Any, Dict[str, Any]]] = (
244
+ None # Can be ExecutionMode object or dict
245
+ )
246
+ output_behavior: Optional[Dict[str, Any]] = None
247
+ output_channels: List[str] = Field(default_factory=list)
248
+ output_content_type: OutputContentType = OutputContentType.TEXT
249
+ history_content_type: Optional[str] = None
250
+ history_role: Optional[MessageRole] = None
251
+ ui_content_type: Optional[str] = None
252
+ user_output_visibility: OutputVisibility = OutputVisibility.VISIBLE
253
+ bot_output_visibility: OutputVisibility = OutputVisibility.HIDDEN
254
+ running_status: Optional[Union[str, TemplateString]] = None
255
+ finished_status: Optional[Union[str, TemplateString]] = None
256
+ parameter_hydration_behaviour: Optional[ParameterHydrationBehaviour] = None
257
+
258
+ @field_serializer(
259
+ "running_status", "finished_status", mode="wrap", when_used="always"
260
+ )
261
+ def serialize_message_fields(
262
+ self, value: Optional[Union[str, TemplateString]], _info
263
+ ) -> Optional[str]:
264
+ """Convert TemplateString to str during serialization."""
265
+ if isinstance(value, TemplateString):
266
+ return str(value)
267
+ return value
268
+
269
+
270
+ class Step(StepMetadata):
271
+ """A single step in an agent workflow."""
272
+
273
+ agent: Optional[Any] = None # Reference to the agent this step belongs to
274
+ result_handler: Optional[Any] = (
275
+ None # Reference to the result handler this step belongs to
276
+ )
277
+ action: Union[Any, Dict[str, Any]] # Function call like codeexec.execute(...)
278
+ parameters: Dict[str, Any] = Field(default_factory=dict)
279
+ result_handlers: List["ResultHandler"] = Field(default_factory=list)
280
+
281
+ def __init__(self, action: Any = None, step: Optional["Step"] = None, **data: Any):
282
+ # Validate that only one of action or step is provided
283
+ if action is not None and step is not None:
284
+ raise ValueError("Cannot specify both 'action' and 'step' parameters")
285
+
286
+ if action is None and step is None:
287
+ raise ValueError("Must specify either 'action' or 'step' parameter")
288
+
289
+ if step is not None:
290
+ # Copy all fields from the provided step
291
+ # Type check is redundant due to type annotation, but kept for runtime safety
292
+ if not hasattr(step, "action"):
293
+ raise ValueError("'step' parameter must be a Step object")
294
+
295
+ # Copy fields directly to preserve object types (especially action)
296
+ # Don't use model_dump() as it serializes objects to dicts
297
+ data.update(
298
+ {
299
+ "action": step.action,
300
+ "key": step.key,
301
+ "parameters": step.parameters,
302
+ "depends_on": step.depends_on,
303
+ "execution_mode": step.execution_mode,
304
+ "output_behavior": step.output_behavior,
305
+ "result_handlers": step.result_handlers,
306
+ "output_channels": step.output_channels,
307
+ "output_content_type": step.output_content_type,
308
+ "history_content_type": step.history_content_type,
309
+ "history_role": step.history_role,
310
+ "ui_content_type": step.ui_content_type,
311
+ "user_output_visibility": step.user_output_visibility,
312
+ "bot_output_visibility": step.bot_output_visibility,
313
+ "running_status": step.running_status,
314
+ "finished_status": step.finished_status,
315
+ "parameter_hydration_behaviour": step.parameter_hydration_behaviour,
316
+ }
317
+ )
318
+ else:
319
+ # Standard action-based step
320
+ data["action"] = action
321
+
322
+ # Handle step_metadata - can be provided as kwarg or on action object
323
+ step_metadata = None
324
+ if "step_metadata" in data and data["step_metadata"] is not None:
325
+ step_metadata = data.pop("step_metadata")
326
+ elif (
327
+ hasattr(action, "step_metadata")
328
+ and getattr(action, "step_metadata", None) is not None
329
+ ):
330
+ step_metadata = getattr(action, "step_metadata")
331
+
332
+ if step_metadata is not None:
333
+ # Extract all fields from StepMetadata
334
+ if hasattr(step_metadata, "model_dump"):
335
+ metadata_dict = step_metadata.model_dump(exclude_none=True)
336
+ # Apply metadata fields, but don't overwrite explicitly provided fields
337
+ for key, value in metadata_dict.items():
338
+ if key not in data:
339
+ data[key] = value
340
+ elif isinstance(step_metadata, dict):
341
+ # If it's already a dict, apply it
342
+ for key, value in step_metadata.items():
343
+ if key not in data and value is not None:
344
+ data[key] = value
345
+
346
+ super().__init__(**data)
347
+ # For enhanced syntax support - use private field not tracked by pydantic
348
+ self._decorator_handlers: List[Tuple[Any, Callable[..., Any]]] = []
349
+
350
+ # Validate depends_on to ensure no empty strings
351
+ if self.depends_on is not None:
352
+ for dep in self.depends_on:
353
+ if isinstance(dep, str) and dep == "":
354
+ raise ValueError(
355
+ f"Step '{self.key or '(unnamed)'}' has an empty string in depends_on. "
356
+ "Dependencies must be non-empty step keys or Step objects. "
357
+ "Remove the empty string or set depends_on=[] for no dependencies."
358
+ )
359
+
360
+ # Automatically register with agent if provided and this is not a nested step
361
+ if self.agent is not None and self.result_handler is None:
362
+ self.agent.add_step(self)
363
+
364
+ def extract_action_parameters(self) -> Dict[str, Any]:
365
+ """Extract parameters from the action object safely."""
366
+ if not self.action:
367
+ return {}
368
+
369
+ # Handle nested Step objects (for result handlers)
370
+ if isinstance(self.action, Step):
371
+ return self.action.extract_action_parameters()
372
+
373
+ # If action is a Pydantic model, get its dict representation
374
+ if hasattr(self.action, "model_dump") and callable(
375
+ getattr(self.action, "model_dump")
376
+ ):
377
+ try:
378
+ action_obj = cast(ActionProtocol, self.action)
379
+ params = action_obj.model_dump()
380
+ # Remove the redundant 'name' field since it's already known from action type
381
+ params.pop("name", None)
382
+ # Remove step_metadata - it's for Step config, not action parameters
383
+ params.pop("step_metadata", None)
384
+ # Map Python SDK field names to backend field names
385
+ if "json_data" in params:
386
+ params["json"] = params.pop("json_data")
387
+ # Remove None values for optional parameters (matches action function behavior)
388
+ params = {k: v for k, v in params.items() if v is not None}
389
+ # Sort parameters for deterministic output
390
+ return dict(sorted(params.items()))
391
+ except Exception:
392
+ return {}
393
+ elif hasattr(self.action, "dict") and callable(getattr(self.action, "dict")):
394
+ try:
395
+ # Legacy pydantic v1 support
396
+ action_dict_method = getattr(self.action, "dict")
397
+ params = action_dict_method()
398
+ # Remove the redundant 'name' field since it's already known from action type
399
+ params.pop("name", None)
400
+ # Remove step_metadata - it's for Step config, not action parameters
401
+ params.pop("step_metadata", None)
402
+ # Map Python SDK field names to backend field names
403
+ if "json_data" in params:
404
+ params["json"] = params.pop("json_data")
405
+ # Remove None values for optional parameters (matches action function behavior)
406
+ params = {k: v for k, v in params.items() if v is not None}
407
+ # Sort parameters for deterministic output
408
+ return dict(sorted(params.items()))
409
+ except Exception:
410
+ return {}
411
+ elif isinstance(self.action, dict):
412
+ # Map Python SDK field names to backend field names
413
+ dict_params: Dict[str, Any] = dict(self.action)
414
+ # Remove the redundant 'name' field since it's already known from action type
415
+ dict_params.pop("name", None)
416
+ if "json_data" in dict_params:
417
+ dict_params["json"] = dict_params.pop("json_data")
418
+ # Sort parameters for deterministic output
419
+ return dict(sorted(dict_params.items()))
420
+ else:
421
+ return {}
422
+
423
+ def get_action_type(self) -> str:
424
+ """Get the action type string safely."""
425
+ if not self.action:
426
+ raise ValueError("Action is not set")
427
+
428
+ # Handle nested Step objects (for result handlers)
429
+ if isinstance(self.action, Step):
430
+ return self.action.get_action_type()
431
+
432
+ # Action objects should have a name attribute with the action type
433
+ if hasattr(self.action, "name"):
434
+ name_attr = getattr(self.action, "name")
435
+ if isinstance(name_attr, str):
436
+ return name_attr
437
+ elif hasattr(name_attr, "__str__"):
438
+ return str(name_attr)
439
+
440
+ # Fallback for unexpected cases
441
+ return str(type(self.action).__name__).lower()
442
+
443
+ def get_depends_on_keys(self) -> Optional[List[str]]:
444
+ """Get dependency keys safely without circular references,
445
+ preserving None vs [] distinction."""
446
+ # CRITICAL: Preserve None vs [] distinction for 100% roundtrip parity
447
+ if self.depends_on is None:
448
+ return None # Explicitly return None for null dependencies
449
+
450
+ if not self.depends_on: # Empty list case
451
+ return []
452
+
453
+ # Depends_on is guaranteed to be a non-empty list at this point
454
+ depends_list = self.depends_on
455
+
456
+ result: List[str] = []
457
+ for dep in depends_list:
458
+ if isinstance(dep, str):
459
+ # Already a string identifier
460
+ result.append(dep)
461
+ elif hasattr(dep, "key") and getattr(dep, "key", None):
462
+ # Prefer the step key if available
463
+ key_val = getattr(dep, "key")
464
+ result.append(str(key_val) if key_val is not None else "")
465
+ elif hasattr(dep, "id") and getattr(dep, "id", None):
466
+ # Fall back to step ID if no key
467
+ id_val = getattr(dep, "id")
468
+ result.append(str(id_val) if id_val is not None else "")
469
+ else:
470
+ # Fail if we can't get a valid identifier
471
+ raise ValueError(f"Step dependency has no key or id: {dep}")
472
+ return result
473
+
474
+ @property
475
+ def output(self) -> "StepOutput":
476
+ """Get typed output reference for this step."""
477
+ return StepOutput(step_key=self.key or f"step_{id(self)}")
478
+
479
+ def when(
480
+ self, condition: Any
481
+ ) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
482
+ """Decorator for adding result handlers with conditions.
483
+
484
+ Usage:
485
+ @step.when(IsSuccess() & GreaterThan("confidence", 0.8))
486
+ def handle_high_confidence(result):
487
+ return store_analysis(result)
488
+ """
489
+
490
+ def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
491
+ # Store the handler function and condition for later processing
492
+ self._decorator_handlers.append((condition, func))
493
+ return func
494
+
495
+ return decorator
496
+
497
+ def on(
498
+ self,
499
+ condition: Any,
500
+ *actions: Any,
501
+ handler_type: HandlerType = HandlerType.INTERMEDIATE,
502
+ ) -> "Step":
503
+ """Add a result handler with condition and action(s).
504
+
505
+ Usage:
506
+ # Single action
507
+ step.on(IsSuccess(), utils.store_analysis(data=state.analyze_step.output))
508
+
509
+ # Multiple actions (variadic arguments)
510
+ step.on(And(IsError(), LessThan(number='r"{{coalesce "code_retry_loops?" 0}}"', value="2")),
511
+ send_status(status="retrying", message="Code execution failed, attempting to fix..."),
512
+ utils.echo(data={"code_retry_loops": 'r"{{incrementCounter "code_retry_loops"}}'}),
513
+ raise_action(status="go to step", message="code", parameters={...})
514
+ )
515
+ """
516
+ if not actions:
517
+ raise ValueError("Must specify at least one action")
518
+
519
+ # Create steps for each action, handling Step objects correctly
520
+ step_actions: List[Step] = []
521
+ for action in actions:
522
+ if isinstance(action, Step):
523
+ # If it's already a Step, use step= parameter
524
+ step_actions.append(Step(step=action))
525
+ else:
526
+ # Extract step_metadata from action if present and merge into Step
527
+ # This allows result handler steps to be configured via step_metadata parameter:
528
+ # step.on(condition,
529
+ # utils.echo(data=..., step_metadata=StepMetadata(
530
+ # key="my_step",
531
+ # output_behavior={"field": OutputBehaviorType.MERGE}
532
+ # ))
533
+ # )
534
+ step_config = _extract_step_config_from_action(action)
535
+ step_actions.append(Step(**step_config))
536
+
537
+ # Create a result handler and add it to this step
538
+ handler = ResultHandler(
539
+ type=handler_type,
540
+ if_conditions=condition,
541
+ steps=step_actions,
542
+ )
543
+ self.result_handlers.append(handler)
544
+ return self
545
+
546
+ def to_dict(self) -> Dict[str, Any]:
547
+ """Convert to dict format expected by backend."""
548
+ # Process decorator handlers before serialization
549
+ self._process_decorator_handlers()
550
+
551
+ # Use model_dump but exclude problematic circular reference fields
552
+ result = self.model_dump(
553
+ exclude={
554
+ "agent",
555
+ "result_handler",
556
+ "_decorator_handlers",
557
+ "action",
558
+ "depends_on",
559
+ },
560
+ )
561
+
562
+ # Filter out empty/None optional fields to match export behavior
563
+ if result.get("ui_content_type") in [None, ""]:
564
+ result.pop("ui_content_type", None)
565
+ if result.get("history_content_type") in [None, ""]:
566
+ result.pop("history_content_type", None)
567
+ if result.get("history_role") in [None, ""]:
568
+ result.pop("history_role", None)
569
+ if result.get("parameter_hydration_behaviour") is None:
570
+ result.pop("parameter_hydration_behaviour", None)
571
+
572
+ # Add action information without circular references
573
+ result["action_type"] = self.get_action_type()
574
+ result["parameters"] = self.extract_action_parameters()
575
+
576
+ # Add depends_on only if there are actual dependencies
577
+ if self.depends_on:
578
+ # Convert Step objects to their keys
579
+ deps = []
580
+ for dep in self.depends_on:
581
+ if isinstance(dep, Step):
582
+ # If it's a Step object, use its key
583
+ if dep.key:
584
+ deps.append(dep.key)
585
+ elif isinstance(dep, str):
586
+ # If it's already a string (step key), use it directly
587
+ deps.append(dep)
588
+ result["depends_on"] = deps
589
+
590
+ # Always include result_handlers, even if they were excluded by exclude_unset
591
+ # This ensures that result handlers added via .on() method are included
592
+ result["result_handlers"] = []
593
+ if self.result_handlers:
594
+ converted_handlers: List[Any] = []
595
+ for handler in self.result_handlers:
596
+ if hasattr(handler, "to_dict"):
597
+ converted_handlers.append(handler.to_dict())
598
+ elif isinstance(handler, dict):
599
+ # Already a dict, convert conditions if needed
600
+ handler_dict = cast(Dict[str, Any], handler)
601
+ if handler_dict.get("if_conditions") and hasattr(
602
+ handler_dict["if_conditions"], "to_dict"
603
+ ):
604
+ handler_dict["if_conditions"] = handler_dict[
605
+ "if_conditions"
606
+ ].to_dict()
607
+ converted_handlers.append(handler_dict)
608
+ else:
609
+ converted_handlers.append(handler)
610
+ result["result_handlers"] = converted_handlers
611
+
612
+ # Convert ExecutionMode objects to dictionaries
613
+ if self.execution_mode is not None:
614
+ if hasattr(self.execution_mode, "to_dict") and callable(
615
+ getattr(self.execution_mode, "to_dict")
616
+ ):
617
+ exec_mode_obj = cast(ExecutionModeProtocol, self.execution_mode)
618
+ result["execution_mode"] = exec_mode_obj.to_dict()
619
+ elif isinstance(self.execution_mode, dict):
620
+ result["execution_mode"] = self.execution_mode
621
+ else:
622
+ # Handle string mode types or other formats
623
+ result["execution_mode"] = self.execution_mode
624
+
625
+ # Recursively convert any other condition objects to dictionaries
626
+ def convert_conditions(obj: Any) -> Any:
627
+ """Recursively convert condition objects to dictionaries."""
628
+
629
+ if obj is None or isinstance(obj, (str, int, float, bool)):
630
+ return obj
631
+ elif isinstance(obj, Enum):
632
+ # Convert enum values to their string values for JSON serialization
633
+ return obj.value
634
+ elif hasattr(obj, "to_dict"):
635
+ return obj.to_dict()
636
+ elif isinstance(obj, list):
637
+ return [convert_conditions(item) for item in obj]
638
+ elif isinstance(obj, dict):
639
+ # Handle enum keys in dictionaries
640
+ converted_dict: Dict[str, Any] = {}
641
+ obj_dict = cast(Dict[Any, Any], obj)
642
+ for key, value in obj_dict.items():
643
+ # Convert enum keys to strings
644
+ str_key: str
645
+ if isinstance(key, Enum):
646
+ str_key = str(key.value)
647
+ else:
648
+ str_key = str(key)
649
+ converted_dict[str_key] = convert_conditions(value)
650
+ return converted_dict
651
+ else:
652
+ return obj
653
+
654
+ # Apply condition conversion to all fields
655
+ for key, value in result.items():
656
+ result[key] = convert_conditions(value)
657
+
658
+ # Rename output_behavior to output_behaviour for Go backend compatibility
659
+ # Python SDK uses American spelling (output_behavior) but Go backend expects
660
+ # British spelling (output_behaviour). This ensures correct serialization.
661
+ if "output_behavior" in result:
662
+ result["output_behaviour"] = result.pop("output_behavior")
663
+
664
+ # Recursively sort all dictionaries for deterministic serialization
665
+ def sort_dict_recursively(obj: Any) -> Any:
666
+ """Recursively sort all dictionaries to ensure deterministic JSON output."""
667
+ if isinstance(obj, dict):
668
+ # Convert keys to strings for sorting to handle mixed types (enums + strings)
669
+ def sort_key(item: Tuple[Any, Any]) -> str:
670
+ k, v = item
671
+ if hasattr(k, "value"): # Enum
672
+ return str(k.value)
673
+ return str(k)
674
+
675
+ obj_dict = cast(Dict[Any, Any], obj)
676
+ return dict(
677
+ sorted(
678
+ ((k, sort_dict_recursively(v)) for k, v in obj_dict.items()),
679
+ key=sort_key,
680
+ )
681
+ )
682
+ elif isinstance(obj, list):
683
+ return [sort_dict_recursively(item) for item in obj]
684
+ else:
685
+ return obj
686
+
687
+ result = sort_dict_recursively(result)
688
+ return cast(Dict[str, Any], result)
689
+
690
+ def _process_decorator_handlers(self) -> None:
691
+ """Process decorator handlers and convert them to ResultHandler objects."""
692
+ if not hasattr(self, "_decorator_handlers") or not self._decorator_handlers:
693
+ return
694
+
695
+ # Get current result_handlers list or create new one
696
+ current_handlers = list(self.result_handlers) if self.result_handlers else []
697
+
698
+ for condition, _ in self._decorator_handlers:
699
+ # Create a simple handler that calls the function
700
+ # For now, we'll create a basic handler structure
701
+ # In a full implementation, you'd want to analyze the function and create
702
+ # appropriate steps
703
+ handler = ResultHandler(
704
+ type=HandlerType.FINAL,
705
+ if_conditions=condition,
706
+ output_content_type=OutputContentType.TEXT,
707
+ steps=[], # Would need to convert function to steps
708
+ )
709
+ current_handlers.append(handler)
710
+
711
+ # Update the result_handlers field
712
+ self.result_handlers = current_handlers
713
+
714
+ # Clear processed handlers
715
+ self._decorator_handlers.clear()
716
+
717
+
718
+ class StepOutput(BaseModel):
719
+ """Represents the output of a step for type-safe access."""
720
+
721
+ step_key: str
722
+
723
+ def __init__(self, **data: Any):
724
+ super().__init__(**data)
725
+ # Use object attribute instead of Pydantic field
726
+ object.__setattr__(self, "_expected_fields", {})
727
+
728
+ def __getitem__(self, key: str) -> str:
729
+ """Allow bracket notation for accessing output fields."""
730
+ # Track that this field is being accessed for validation
731
+ if hasattr(self, "_expected_fields"):
732
+ expected_fields = getattr(self, "_expected_fields", {})
733
+ expected_fields[key] = (
734
+ None # Will be populated with actual type during execution
735
+ )
736
+ return f"{{{{{self.step_key}.{key}}}}}"
737
+
738
+ def __getattr__(self, name: str) -> str:
739
+ """Allow dot notation for accessing output fields."""
740
+ # Avoid infinite recursion for Pydantic internal attributes
741
+ if name.startswith("_") or name in {"step_key", "model_fields", "model_config"}:
742
+ raise AttributeError(
743
+ f"'{type(self).__name__}' object has no attribute '{name}'"
744
+ )
745
+
746
+ # Track that this field is being accessed for validation
747
+ if hasattr(self, "_expected_fields"):
748
+ expected_fields = getattr(self, "_expected_fields", {})
749
+ expected_fields[name] = (
750
+ None # Will be populated with actual type during execution
751
+ )
752
+ return f"{{{{{self.step_key}.{name}}}}}"
753
+
754
+ def get_expected_fields(self) -> Dict[str, Any]:
755
+ """Get the fields that have been accessed on this step output."""
756
+ return getattr(self, "_expected_fields", {})
757
+
758
+ def validate_field_access(self, available_fields: Dict[str, Any]) -> List[str]:
759
+ """Validate that all accessed fields are available in the step result."""
760
+ errors = []
761
+ expected = self.get_expected_fields()
762
+
763
+ for field_name in expected.keys():
764
+ if field_name not in available_fields:
765
+ errors.append(
766
+ f"Step '{self.step_key}' output field '{field_name}' is not available"
767
+ )
768
+
769
+ return errors
770
+
771
+
772
+ class ResultHandler(BaseModel):
773
+ """Result handler definition that matches the Go backend structure."""
774
+
775
+ type: HandlerType = HandlerType.FINAL
776
+ if_conditions: Optional[Any] = None # Can be condition objects or None
777
+ output_content_type: OutputContentType = OutputContentType.TEXT
778
+ history_content_type: Optional[str] = None
779
+ ui_content_type: Optional[str] = None
780
+ steps: List[Step] = Field(default_factory=list)
781
+
782
+ def to_dict(self) -> Dict[str, Any]:
783
+ """Convert to dict format expected by backend."""
784
+ # Convert Step objects in steps list to dictionaries BEFORE model_dump()
785
+ # because model_dump() will convert Step objects to dicts but won't call to_dict()
786
+ converted_steps = []
787
+ if self.steps:
788
+ for step in self.steps:
789
+ if hasattr(step, "to_dict"):
790
+ step_dict = step.to_dict()
791
+ # DO NOT auto-generate keys for result handler steps
792
+ # Only include keys if explicitly set to preserve roundtrip
793
+ # parity
794
+
795
+ # Ensure bot_output_visibility is preserved (defaults to hidden for
796
+ # result handler steps)
797
+ if "bot_output_visibility" not in step_dict:
798
+ step_dict["bot_output_visibility"] = "hidden"
799
+
800
+ converted_steps.append(step_dict)
801
+ else:
802
+ # Convert step to dict if it doesn't have to_dict method
803
+ if isinstance(step, dict):
804
+ converted_steps.append(step)
805
+ else:
806
+ # Fallback: convert to dict using model_dump if available
807
+ if hasattr(step, "model_dump"):
808
+ converted_steps.append(step.model_dump())
809
+ else:
810
+ converted_steps.append({})
811
+
812
+ # Use model_dump but exclude steps, then add our properly converted steps
813
+ result = self.model_dump(exclude={"steps"})
814
+ result["steps"] = converted_steps
815
+
816
+ # Filter out empty/None optional fields to match export behavior
817
+ if result.get("ui_content_type") in [None, ""]:
818
+ result.pop("ui_content_type", None)
819
+ if result.get("history_content_type") in [None, ""]:
820
+ result.pop("history_content_type", None)
821
+ if result.get("parameter_hydration_behaviour") is None:
822
+ result.pop("parameter_hydration_behaviour", None)
823
+
824
+ # Convert condition objects to dictionaries if needed
825
+ if self.if_conditions is not None and hasattr(self.if_conditions, "to_dict"):
826
+ result["if_conditions"] = self.if_conditions.to_dict()
827
+
828
+ # Convert enum values to their string values (both keys and values)
829
+ converted_result = {}
830
+ for key, value in result.items():
831
+ # Convert enum keys to strings
832
+ if isinstance(key, Enum):
833
+ key = key.value
834
+ # Convert enum values to strings
835
+ if isinstance(value, Enum):
836
+ value = value.value
837
+ converted_result[key] = value
838
+ result = converted_result
839
+
840
+ return result
841
+
842
+
843
+ class SecretsDict(Dict[str, Any]):
844
+ """A dictionary wrapper that provides .get() method for secrets access."""
845
+
846
+ def __init__(self, secrets_data: Optional[Dict[str, Any]] = None):
847
+ super().__init__(secrets_data or {})
848
+
849
+ def get(self, key: str, default: Any = None) -> Any:
850
+ """Get decrypted secrets for a specific resource/service key."""
851
+ return super().get(key, default)
852
+
853
+
854
+ class ParametersDict(Dict[str, Any]):
855
+ """A dictionary wrapper that provides .get() method for parameters access."""
856
+
857
+ def __init__(self, parameters_data: Optional[Dict[str, Any]] = None):
858
+ super().__init__(parameters_data or {})
859
+
860
+ def get(self, key: str, default: Any = None) -> Any:
861
+ """Get a step parameter with optional default."""
862
+ return super().get(key, default)
863
+
864
+
865
+ class StepContext(BaseModel):
866
+ """Context available to step functions with type-safe access
867
+
868
+ Provides access to:
869
+ - User query and parameters
870
+ - Previous step results (state)
871
+ - Available resources (datasets, APIs)
872
+ - Encrypted secrets
873
+ - System information
874
+ """
875
+
876
+ # Core user input
877
+ query: Optional[str] = None
878
+
879
+ # Previous step results and state
880
+ state: Dict[str, Any] = Field(default_factory=dict)
881
+ steps: Dict[str, Any] = Field(default_factory=dict) # Alias for state.steps
882
+
883
+ # Resources and data access
884
+ resources: List[Dict[str, Any]] = Field(default_factory=list)
885
+ resource_definitions: Optional[Dict[str, Any]] = None
886
+
887
+ # Security and credentials - raw data
888
+ secrets_data: Dict[str, Any] = Field(default_factory=dict)
889
+
890
+ # Step-specific parameters - raw data
891
+ parameters_data: Dict[str, Any] = Field(default_factory=dict)
892
+
893
+ # System context
894
+ system: Dict[str, Any] = Field(default_factory=dict)
895
+
896
+ # Raw context for advanced users
897
+ raw: Dict[str, Any] = Field(default_factory=dict)
898
+
899
+ def __init__(self, **data: Any):
900
+ # Extract secrets and parameters from input data
901
+ secrets_data = data.pop("secrets", {})
902
+ parameters_data = data.pop("parameters", {})
903
+
904
+ # Set the internal data fields
905
+ data["secrets_data"] = secrets_data
906
+ data["parameters_data"] = parameters_data
907
+
908
+ super().__init__(**data)
909
+
910
+ # Create wrapper objects for secrets and parameters
911
+ self._secrets_wrapper = SecretsDict(self.secrets_data)
912
+ self._parameters_wrapper = ParametersDict(self.parameters_data)
913
+
914
+ @property
915
+ def secrets(self) -> SecretsDict:
916
+ """Get secrets wrapper that supports .get() method."""
917
+ return self._secrets_wrapper
918
+
919
+ @property
920
+ def parameters(self) -> ParametersDict:
921
+ """Get parameters wrapper that supports .get() method."""
922
+ return self._parameters_wrapper
923
+
924
+ def __getitem__(self, key: str) -> Any:
925
+ """Allow bracket notation access to state"""
926
+ try:
927
+ state_value = super().__getattribute__("state")
928
+ return state_value.get(key) if isinstance(state_value, dict) else None
929
+ except AttributeError:
930
+ return None
931
+
932
+ def __getattr__(self, name: str) -> Any:
933
+ """Allow dot notation access to state"""
934
+ # Avoid infinite recursion for Pydantic internal attributes
935
+ if name.startswith("_") or name in {
936
+ "state",
937
+ "steps",
938
+ "resources",
939
+ "secrets",
940
+ "parameters",
941
+ "system",
942
+ "raw",
943
+ "query",
944
+ "resource_definitions",
945
+ }:
946
+ raise AttributeError(
947
+ f"'{self.__class__.__name__}' object has no attribute '{name}'"
948
+ )
949
+
950
+ try:
951
+ state_value = super().__getattribute__("state")
952
+ if isinstance(state_value, dict) and name in state_value:
953
+ return state_value[name]
954
+ except AttributeError:
955
+ pass
956
+ raise AttributeError(
957
+ f"'{self.__class__.__name__}' object has no attribute '{name}'"
958
+ )
959
+
960
+ def get_resource(self, key: str) -> Optional[Dict[str, Any]]:
961
+ """Get a specific resource by key"""
962
+ for resource in self.resources:
963
+ if resource.get("key") == key:
964
+ return resource
965
+ return None
966
+
967
+ def get_secret(self, key: str) -> Optional[Any]:
968
+ """Get decrypted secrets for a specific resource/service"""
969
+ return self.secrets.get(key)
970
+
971
+ def get_parameter(self, key: str, default: Any = None) -> Any:
972
+ """Get a step parameter with optional default"""
973
+ return self.parameters.get(key, default)
974
+
975
+
976
+ class StepResult(BaseModel):
977
+ """Result from executing a workflow step."""
978
+
979
+ success: bool
980
+ data: Any = None
981
+ error: Optional[str] = None
982
+ step_name: str
983
+
984
+
985
+ class Agent(BaseModel):
986
+ """Main agent class for defining AI workflows."""
987
+
988
+ name: str
989
+ key: Optional[str] = None
990
+ description: Optional[str] = None
991
+ persona: Optional[str] = None
992
+ visibility: str = "public"
993
+ running_status: Optional[Union[str, TemplateString]] = None
994
+ finished_status: Optional[Union[str, TemplateString]] = None
995
+ running_status_context: Optional[Union[str, TemplateString]] = None
996
+ finished_status_context: Optional[Union[str, TemplateString]] = None
997
+ running_status_prompt: Optional[Union[str, TemplateString]] = None
998
+ finished_status_prompt: Optional[Union[str, TemplateString]] = None
999
+ version: str = "1.0"
1000
+ timeout: Optional[int] = None
1001
+ retry_attempts: int = 0
1002
+ tags: List[str] = Field(default_factory=list)
1003
+ steps: List[Step] = Field(default_factory=list)
1004
+ parameter_definitions: List["ParameterDefinition"] = Field(default_factory=list)
1005
+
1006
+ @field_serializer(
1007
+ "running_status",
1008
+ "finished_status",
1009
+ "running_status_context",
1010
+ "finished_status_context",
1011
+ "running_status_prompt",
1012
+ "finished_status_prompt",
1013
+ mode="wrap",
1014
+ when_used="always",
1015
+ )
1016
+ def serialize_message_fields(
1017
+ self, value: Optional[Union[str, TemplateString]], _info
1018
+ ) -> Optional[str]:
1019
+ """Convert TemplateString to str during serialization."""
1020
+ if isinstance(value, TemplateString):
1021
+ return str(value)
1022
+ return value
1023
+
1024
+ def step(
1025
+ self,
1026
+ action: Any,
1027
+ key: Optional[str] = None,
1028
+ depends_on: Optional[Union[Step, List[Step], str, List[str]]] = None,
1029
+ **kwargs: Any,
1030
+ ) -> Step:
1031
+ """Create a step with cleaner syntax and better defaults.
1032
+
1033
+ Args:
1034
+ action: The action to execute (e.g., codeexec.execute(...))
1035
+ key: Optional step key, auto-generated if not provided
1036
+ depends_on: Step(s) or key(s) this step depends on
1037
+ **kwargs: Additional step configuration
1038
+
1039
+ Returns:
1040
+ Step: The created step with sensible defaults
1041
+ """
1042
+ # Extract step_metadata from the action if present
1043
+ extracted_config = _extract_step_config_from_action(action)
1044
+
1045
+ # Auto-generate key if not provided (either via param or step_metadata)
1046
+ if key is None and "key" not in extracted_config:
1047
+ key = f"step_{len(self.steps) + 1}"
1048
+
1049
+ # Set sensible defaults
1050
+ step_config = {
1051
+ "agent": self,
1052
+ "key": key,
1053
+ "action": action,
1054
+ "depends_on": (
1055
+ [depends_on]
1056
+ if depends_on and not isinstance(depends_on, list)
1057
+ else depends_on
1058
+ ),
1059
+ "user_output_visibility": OutputVisibility.VISIBLE,
1060
+ "bot_output_visibility": OutputVisibility.HIDDEN,
1061
+ "output_content_type": OutputContentType.TEXT,
1062
+ }
1063
+
1064
+ # First merge extracted_config (from step_metadata), then kwargs
1065
+ # This allows kwargs to override step_metadata if needed
1066
+ step_config.update(extracted_config)
1067
+ step_config.update(kwargs)
1068
+
1069
+ step = Step(**step_config)
1070
+ return step
1071
+
1072
+ def exec(
1073
+ self,
1074
+ step_metadata: Optional[StepMetadata] = None,
1075
+ **codeexec_params: Any,
1076
+ ) -> Callable[..., Step]:
1077
+ """Create a codeexec step using decorator syntax.
1078
+
1079
+ This method is designed to be used as a decorator for functions that implement
1080
+ code execution logic. It creates a codeexec.execute action with the provided
1081
+ parameters and step metadata.
1082
+
1083
+ Args:
1084
+ step_metadata: Step configuration (key, depends_on, etc.)
1085
+ **codeexec_params: Parameters for codeexec.execute (entrypoint, parameters,
1086
+ resources, etc.)
1087
+
1088
+ Returns:
1089
+ Callable: Decorator function that creates and returns a Step
1090
+ """
1091
+
1092
+ def decorator(func: Callable[..., Any]) -> Step:
1093
+ # Import here to avoid circular imports
1094
+ from .actions import codeexec
1095
+
1096
+ # Create the codeexec.execute action with the provided parameters
1097
+ action = codeexec.execute(**codeexec_params)
1098
+
1099
+ # Extract step metadata or use defaults
1100
+ if step_metadata:
1101
+ step_config = {
1102
+ "agent": self,
1103
+ "action": action,
1104
+ "key": step_metadata.key or func.__name__,
1105
+ "depends_on": step_metadata.depends_on,
1106
+ "execution_mode": step_metadata.execution_mode,
1107
+ "output_behavior": step_metadata.output_behavior,
1108
+ "output_channels": step_metadata.output_channels,
1109
+ "output_content_type": step_metadata.output_content_type,
1110
+ "history_content_type": step_metadata.history_content_type,
1111
+ "history_role": step_metadata.history_role,
1112
+ "ui_content_type": step_metadata.ui_content_type,
1113
+ "user_output_visibility": step_metadata.user_output_visibility,
1114
+ "bot_output_visibility": step_metadata.bot_output_visibility,
1115
+ "running_status": step_metadata.running_status,
1116
+ "finished_status": step_metadata.finished_status,
1117
+ "parameter_hydration_behaviour": step_metadata.parameter_hydration_behaviour,
1118
+ }
1119
+ else:
1120
+ step_config = {
1121
+ "agent": self,
1122
+ "action": action,
1123
+ "key": func.__name__,
1124
+ "user_output_visibility": OutputVisibility.VISIBLE,
1125
+ "bot_output_visibility": OutputVisibility.HIDDEN,
1126
+ "output_content_type": OutputContentType.TEXT,
1127
+ }
1128
+
1129
+ # Remove None values and extract action separately
1130
+ filtered_config = {
1131
+ k: v for k, v in step_config.items() if v is not None and k != "action"
1132
+ }
1133
+
1134
+ # Create step with the codeexec action, not the function
1135
+ step = Step(action=action, step=None, **filtered_config)
1136
+
1137
+ # Store the function name on the step for runtime extraction
1138
+ if hasattr(step, "__dict__"):
1139
+ step.__dict__["__name__"] = func.__name__
1140
+
1141
+ return step
1142
+
1143
+ return decorator
1144
+
1145
+ def add_step(self, step: Step) -> None:
1146
+ """Add a step to this agent if it's not already present."""
1147
+ # Only add if the step is not already in the list
1148
+ if step in self.steps:
1149
+ return
1150
+
1151
+ # Create a new list with the existing steps plus the new one
1152
+ current_steps = list(self.steps) if self.steps else []
1153
+ current_steps.append(step)
1154
+ self.steps = current_steps
1155
+
1156
+ def get_step(self, key: str) -> Optional[Step]:
1157
+ """Get a step by its key."""
1158
+ for step in self.steps:
1159
+ if step.key == key:
1160
+ return step
1161
+ return None
1162
+
1163
+ def to_json(self) -> str:
1164
+ """Export to JSON format expected by Go backend."""
1165
+ export_data = {
1166
+ "bot": {
1167
+ "Name": self.name,
1168
+ "Key": self.key,
1169
+ "Description": self.description or "",
1170
+ "Visibility": self.visibility,
1171
+ "Persona": self.persona, # Export as string or null, not object
1172
+ "RunningStatus": self.running_status,
1173
+ "FinishedStatus": self.finished_status,
1174
+ "RunningStatusContext": self.running_status_context,
1175
+ "FinishedStatusContext": self.finished_status_context,
1176
+ "RunningStatusPrompt": self.running_status_prompt,
1177
+ "FinishedStatusPrompt": self.finished_status_prompt,
1178
+ "Source": "python",
1179
+ },
1180
+ "parameter_definitions": self.parameter_definitions,
1181
+ "steps": [step.to_dict() for step in self.steps],
1182
+ }
1183
+
1184
+ import json
1185
+
1186
+ return json.dumps(export_data, indent=2)
1187
+
1188
+
1189
+ class ExecutionMode(BaseModel):
1190
+ """Execution mode matching Go backend structure."""
1191
+
1192
+ mode: ExecutionModeType = ExecutionModeType.ALL
1193
+ data: Optional[Any] = None
1194
+ if_condition: Optional[Any] = None
1195
+
1196
+ def to_dict(self) -> Dict[str, Any]:
1197
+ """Convert to dict format expected by backend."""
1198
+ result: Dict[str, Any] = {"mode": self.mode}
1199
+ if self.data is not None:
1200
+ result["data"] = self.data
1201
+ # Always include if_condition to maintain parity with backend structure
1202
+ result["if_condition"] = self.if_condition
1203
+ return result
1204
+
1205
+
1206
+ class Prompt(BaseModel):
1207
+ """A prompt that can be loaded from a .prompt file or defined inline.
1208
+
1209
+ This class provides a clean way to manage prompts in agent code,
1210
+ supporting both inline strings and external .prompt files.
1211
+ """
1212
+
1213
+ content: str = Field(..., description="The prompt content")
1214
+ filename: Optional[str] = Field(
1215
+ None, description="Source filename if loaded from file"
1216
+ )
1217
+
1218
+ def __init__(
1219
+ self, content: Optional[str] = None, filename: Optional[str] = None, **data: Any
1220
+ ):
1221
+ """Initialize a Prompt.
1222
+
1223
+ Args:
1224
+ content: Direct prompt content
1225
+ filename: Path to .prompt file to load
1226
+ **data: Additional model data
1227
+ """
1228
+ if content is not None and filename is not None:
1229
+ raise ValueError("Cannot specify both 'content' and 'filename' parameters")
1230
+
1231
+ if content is None and filename is None:
1232
+ raise ValueError("Must specify either 'content' or 'filename' parameter")
1233
+
1234
+ if filename is not None:
1235
+ # Load content from file
1236
+ content = self._load_from_file(filename)
1237
+ data.update({"content": content, "filename": filename})
1238
+ else:
1239
+ data.update({"content": content})
1240
+
1241
+ super().__init__(**data)
1242
+
1243
+ @classmethod
1244
+ def from_file(cls, filename: str, base_path: Optional[str] = None) -> "Prompt":
1245
+ """Load a prompt from a .prompt file.
1246
+
1247
+ Args:
1248
+ filename: Path to the .prompt file
1249
+ base_path: Base directory to resolve relative paths (defaults to cwd)
1250
+
1251
+ Returns:
1252
+ Prompt: Loaded prompt instance
1253
+ """
1254
+ if base_path is None:
1255
+ base_path = os.getcwd()
1256
+
1257
+ file_path = Path(base_path) / filename
1258
+
1259
+ try:
1260
+ with open(file_path, "r", encoding="utf-8") as f:
1261
+ content = f.read().strip()
1262
+ return cls(content=content)
1263
+ except FileNotFoundError:
1264
+ raise FileNotFoundError(f"Prompt file not found: {file_path}")
1265
+ except Exception as e:
1266
+ raise RuntimeError(f"Error reading prompt file {file_path}: {e}")
1267
+
1268
+ def _load_from_file(self, filename: str) -> str:
1269
+ """Load content from a .prompt file."""
1270
+ return self.from_file(filename).content
1271
+
1272
+ def __str__(self) -> str:
1273
+ """Return the prompt content when used as a string."""
1274
+ return self.content
1275
+
1276
+ def __call__(self) -> str:
1277
+ """Allow the prompt to be called like a function, returning content."""
1278
+ return self.content
1279
+
1280
+ def __repr__(self) -> str:
1281
+ """Return a helpful representation."""
1282
+ if self.filename:
1283
+ return f"Prompt(filename='{self.filename}')"
1284
+ else:
1285
+ preview = (
1286
+ self.content[:50] + "..." if len(self.content) > 50 else self.content
1287
+ )
1288
+ return f"Prompt(content='{preview}')"
1289
+
1290
+ @classmethod
1291
+ def load_from_directory(
1292
+ cls, directory: str, base_path: Optional[str] = None
1293
+ ) -> Dict[str, "Prompt"]:
1294
+ """Load all .prompt files from a directory as Prompt objects.
1295
+
1296
+ Args:
1297
+ directory: Directory containing .prompt files
1298
+ base_path: Base directory to resolve relative paths (defaults to smart detection)
1299
+
1300
+ Returns:
1301
+ Dict[str, Prompt]: Mapping of filename (without extension) to Prompt objects
1302
+ """
1303
+ if base_path is None:
1304
+ # Use stack inspection to determine the calling file's directory
1305
+ # This allows agents to load prompts from their own directory
1306
+ # even when executed from a different working directory
1307
+ import inspect
1308
+
1309
+ frame = inspect.currentframe()
1310
+ try:
1311
+ # Get the caller's frame (skip current frame)
1312
+ if frame and frame.f_back:
1313
+ caller_frame = frame.f_back
1314
+ caller_file = caller_frame.f_code.co_filename
1315
+ caller_dir = Path(caller_file).parent
1316
+ # Check if the caller is in an agent directory structure
1317
+ # (i.e., the calling file is agent.py in a directory under erdo-agents)
1318
+ if caller_file.endswith("agent.py") and "erdo-agents" in str(
1319
+ caller_dir
1320
+ ):
1321
+ # The caller is an agent.py file, use its directory
1322
+ base_path = str(caller_dir)
1323
+ else:
1324
+ # Fall back to current working directory
1325
+ base_path = os.getcwd()
1326
+ else:
1327
+ # Fall back to current working directory
1328
+ base_path = os.getcwd()
1329
+ finally:
1330
+ del frame # Avoid reference cycles
1331
+
1332
+ dir_path = Path(base_path) / directory
1333
+
1334
+ if not dir_path.exists() or not dir_path.is_dir():
1335
+ # Provide a more helpful error message
1336
+ cwd = os.getcwd()
1337
+ caller_path = None
1338
+ import inspect
1339
+
1340
+ frame = inspect.currentframe()
1341
+ try:
1342
+ if frame and frame.f_back:
1343
+ caller_file = frame.f_back.f_code.co_filename
1344
+ caller_path = str(Path(caller_file).parent)
1345
+ finally:
1346
+ del frame
1347
+
1348
+ error_msg = f"Prompts directory not found: {dir_path}"
1349
+ if caller_path and caller_path != str(Path(base_path)):
1350
+ error_msg += f"\nCalling file: {caller_path}"
1351
+ error_msg += f"\nCurrent working directory: {cwd}"
1352
+ error_msg += f"\nBase path used: {base_path}"
1353
+ raise FileNotFoundError(error_msg)
1354
+
1355
+ prompts = {}
1356
+ for prompt_file in dir_path.glob("*.prompt"):
1357
+ prompt_name = prompt_file.stem # filename without extension
1358
+ relative_path = str(prompt_file.relative_to(base_path))
1359
+ prompts[prompt_name] = cls.from_file(relative_path, base_path)
1360
+
1361
+ return prompts
1362
+
1363
+
1364
+ class ConditionDefinition(BaseModel):
1365
+ """Condition definition for test expectations."""
1366
+
1367
+ type: str # "TextContains", "IsSuccess", etc.
1368
+ path: str # JSONPath or template path to value
1369
+ parameters: Dict[str, Any] = Field(default_factory=dict)
1370
+
1371
+ def to_dict(self) -> Dict[str, Any]:
1372
+ """Convert to dict format expected by backend."""
1373
+ return {"type": self.type, "path": self.path, "parameters": self.parameters}
1374
+
1375
+
1376
+ class APIConditionDefinition(BaseModel):
1377
+ """API condition definition that matches backend expectations."""
1378
+
1379
+ type: str # "TextContains", "IsSuccess", etc.
1380
+ conditions: List[Any] = Field(default_factory=list) # For composite conditions
1381
+ leaf: Dict[str, Any] = Field(default_factory=dict) # Parameters for leaf conditions
1382
+
1383
+ @field_serializer("leaf")
1384
+ def serialize_leaf(self, leaf_data: Dict[str, Any]) -> str:
1385
+ """Serialize leaf field as JSON string to match Go backend json.RawMessage expectation."""
1386
+ import json
1387
+
1388
+ if leaf_data:
1389
+ # Convert TemplateString objects to strings
1390
+ converted_leaf = {}
1391
+ for key, value in leaf_data.items():
1392
+ if hasattr(value, "template"): # This is a TemplateString
1393
+ converted_leaf[key] = value.template
1394
+ else:
1395
+ converted_leaf[key] = value
1396
+ return json.dumps(converted_leaf)
1397
+ else:
1398
+ # Even if leaf is empty, ensure it's a JSON string
1399
+ return "{}"
1400
+
1401
+
1402
+ # Re-export commonly used types for convenience
1403
+ __all__ = [
1404
+ "Agent",
1405
+ "Step",
1406
+ "StepMetadata",
1407
+ "StepContext",
1408
+ "StepResult",
1409
+ "StepOutput",
1410
+ "ResultHandler",
1411
+ "ExecutionMode",
1412
+ "MessageRole",
1413
+ "Tool",
1414
+ "PythonFile",
1415
+ "Prompt",
1416
+ "TemplateString",
1417
+ # Complex types
1418
+ "ExecutionCondition",
1419
+ "ConditionDefinition",
1420
+ # Resource types
1421
+ "BotResource",
1422
+ "Dataset",
1423
+ "DatasetType",
1424
+ # Bot permissions
1425
+ "BotPermissions",
1426
+ "set_bot_public",
1427
+ "set_bot_user_permission",
1428
+ "set_bot_org_permission",
1429
+ "get_bot_permissions",
1430
+ "check_bot_access",
1431
+ ]