tactus 0.36.0__py3-none-any.whl → 0.38.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 (65) hide show
  1. tactus/__init__.py +1 -1
  2. tactus/adapters/channels/base.py +22 -2
  3. tactus/adapters/channels/broker.py +1 -0
  4. tactus/adapters/channels/host.py +3 -1
  5. tactus/adapters/channels/ipc.py +18 -3
  6. tactus/adapters/channels/sse.py +2 -0
  7. tactus/adapters/mcp_manager.py +24 -7
  8. tactus/backends/http_backend.py +2 -2
  9. tactus/backends/pytorch_backend.py +2 -2
  10. tactus/broker/client.py +3 -3
  11. tactus/broker/server.py +17 -5
  12. tactus/cli/app.py +212 -57
  13. tactus/core/compaction.py +17 -0
  14. tactus/core/context_assembler.py +73 -0
  15. tactus/core/context_models.py +41 -0
  16. tactus/core/dsl_stubs.py +560 -20
  17. tactus/core/exceptions.py +8 -0
  18. tactus/core/execution_context.py +24 -24
  19. tactus/core/message_history_manager.py +2 -2
  20. tactus/core/mocking.py +12 -0
  21. tactus/core/output_validator.py +6 -6
  22. tactus/core/registry.py +171 -29
  23. tactus/core/retrieval.py +317 -0
  24. tactus/core/retriever_tasks.py +30 -0
  25. tactus/core/runtime.py +431 -117
  26. tactus/dspy/agent.py +143 -82
  27. tactus/dspy/broker_lm.py +13 -7
  28. tactus/dspy/config.py +23 -4
  29. tactus/dspy/module.py +12 -1
  30. tactus/ide/coding_assistant.py +2 -2
  31. tactus/primitives/handles.py +79 -7
  32. tactus/primitives/model.py +1 -1
  33. tactus/primitives/procedure.py +1 -1
  34. tactus/primitives/state.py +2 -2
  35. tactus/sandbox/config.py +1 -1
  36. tactus/sandbox/container_runner.py +13 -6
  37. tactus/sandbox/entrypoint.py +51 -8
  38. tactus/sandbox/protocol.py +5 -0
  39. tactus/stdlib/README.md +10 -1
  40. tactus/stdlib/biblicus/__init__.py +3 -0
  41. tactus/stdlib/biblicus/text.py +189 -0
  42. tactus/stdlib/tac/biblicus/text.tac +32 -0
  43. tactus/stdlib/tac/tactus/biblicus.spec.tac +179 -0
  44. tactus/stdlib/tac/tactus/corpora/base.tac +42 -0
  45. tactus/stdlib/tac/tactus/corpora/filesystem.tac +5 -0
  46. tactus/stdlib/tac/tactus/retrievers/base.tac +37 -0
  47. tactus/stdlib/tac/tactus/retrievers/embedding_index_file.tac +6 -0
  48. tactus/stdlib/tac/tactus/retrievers/embedding_index_inmemory.tac +6 -0
  49. tactus/stdlib/tac/tactus/retrievers/index.md +137 -0
  50. tactus/stdlib/tac/tactus/retrievers/init.tac +11 -0
  51. tactus/stdlib/tac/tactus/retrievers/sqlite_full_text_search.tac +6 -0
  52. tactus/stdlib/tac/tactus/retrievers/tf_vector.tac +6 -0
  53. tactus/testing/behave_integration.py +2 -0
  54. tactus/testing/context.py +10 -6
  55. tactus/testing/evaluation_runner.py +5 -5
  56. tactus/testing/steps/builtin.py +2 -2
  57. tactus/testing/test_runner.py +6 -4
  58. tactus/utils/asyncio_helpers.py +2 -1
  59. tactus/validation/semantic_visitor.py +357 -6
  60. tactus/validation/validator.py +142 -2
  61. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/METADATA +9 -6
  62. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/RECORD +65 -47
  63. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/WHEEL +0 -0
  64. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/entry_points.txt +0 -0
  65. {tactus-0.36.0.dist-info → tactus-0.38.0.dist-info}/licenses/LICENSE +0 -0
tactus/core/exceptions.py CHANGED
@@ -11,6 +11,14 @@ class TactusRuntimeError(Exception):
11
11
  pass
12
12
 
13
13
 
14
+ class TaskSelectionRequired(TactusRuntimeError):
15
+ """Raised when multiple tasks are available and no default can be chosen."""
16
+
17
+ def __init__(self, tasks: list[str]):
18
+ self.tasks = tasks
19
+ super().__init__("Multiple tasks available; select one explicitly.")
20
+
21
+
14
22
  class ProcedureWaitingForHuman(Exception):
15
23
  """
16
24
  Raised to exit workflow when waiting for human response.
@@ -6,7 +6,7 @@ Uses pluggable storage and HITL handlers via protocols.
6
6
  """
7
7
 
8
8
  from abc import ABC, abstractmethod
9
- from typing import Any, Callable
9
+ from typing import Any, Callable, Dict, List, Optional
10
10
  from datetime import datetime, timezone
11
11
  import logging
12
12
  import time
@@ -39,7 +39,7 @@ class ExecutionContext(ABC):
39
39
  self,
40
40
  fn: Callable[[], Any],
41
41
  checkpoint_type: str,
42
- source_info: dict[str, Any] | None = None,
42
+ source_info: Optional[Dict[str, Any]] = None,
43
43
  ) -> Any:
44
44
  """
45
45
  Execute fn with position-based checkpointing. On replay, return stored result.
@@ -59,9 +59,9 @@ class ExecutionContext(ABC):
59
59
  self,
60
60
  request_type: str,
61
61
  message: str,
62
- timeout_seconds: int | None,
62
+ timeout_seconds: Optional[int],
63
63
  default_value: Any,
64
- options: list[dict] | None,
64
+ options: Optional[List[dict]],
65
65
  metadata: dict,
66
66
  ) -> HITLResponse:
67
67
  """
@@ -121,7 +121,7 @@ class BaseExecutionContext(ExecutionContext):
121
121
  self,
122
122
  procedure_id: str,
123
123
  storage_backend: StorageBackend,
124
- hitl_handler: HITLHandler | None = None,
124
+ hitl_handler: Optional[HITLHandler] = None,
125
125
  strict_determinism: bool = False,
126
126
  log_handler=None,
127
127
  ):
@@ -145,14 +145,14 @@ class BaseExecutionContext(ExecutionContext):
145
145
  self._inside_checkpoint = False
146
146
 
147
147
  # Run ID tracking for distinguishing between different executions
148
- self.current_run_id: str | None = None
148
+ self.current_run_id: Optional[str] = None
149
149
 
150
150
  # .tac file tracking for accurate source locations
151
- self.current_tac_file: str | None = None
152
- self.current_tac_content: str | None = None
151
+ self.current_tac_file: Optional[str] = None
152
+ self.current_tac_content: Optional[str] = None
153
153
 
154
154
  # Lua sandbox reference for debug.getinfo access
155
- self.lua_sandbox: Any | None = None
155
+ self.lua_sandbox: Optional[Any] = None
156
156
 
157
157
  # Rich metadata for HITL notifications
158
158
  self._initialize_run_metadata(procedure_id)
@@ -177,7 +177,7 @@ class BaseExecutionContext(ExecutionContext):
177
177
  """Set the run_id for subsequent checkpoints in this execution."""
178
178
  self.current_run_id = run_id
179
179
 
180
- def set_tac_file(self, file_path: str, content: str | None = None) -> None:
180
+ def set_tac_file(self, file_path: str, content: Optional[str] = None) -> None:
181
181
  """
182
182
  Store the currently executing .tac file for accurate source location capture.
183
183
 
@@ -193,7 +193,7 @@ class BaseExecutionContext(ExecutionContext):
193
193
  self.lua_sandbox = lua_sandbox
194
194
 
195
195
  def set_procedure_metadata(
196
- self, procedure_name: str | None = None, input_data: Any = None
196
+ self, procedure_name: Optional[str] = None, input_data: Any = None
197
197
  ) -> None:
198
198
  """
199
199
  Set rich metadata for HITL notifications.
@@ -211,7 +211,7 @@ class BaseExecutionContext(ExecutionContext):
211
211
  self,
212
212
  fn: Callable[[], Any],
213
213
  checkpoint_type: str,
214
- source_info: dict[str, Any] | None = None,
214
+ source_info: Optional[Dict[str, Any]] = None,
215
215
  ) -> Any:
216
216
  """
217
217
  Execute fn with position-based checkpointing and source tracking.
@@ -397,7 +397,7 @@ class BaseExecutionContext(ExecutionContext):
397
397
  except Exception as exception:
398
398
  logger.warning("Failed to emit checkpoint event: %s", exception)
399
399
  else:
400
- logger.warning("[CHECKPOINT] No log_handler available to emit checkpoint event")
400
+ logger.debug("[CHECKPOINT] No log_handler available to emit checkpoint event")
401
401
 
402
402
  # Persist metadata
403
403
  self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
@@ -406,7 +406,7 @@ class BaseExecutionContext(ExecutionContext):
406
406
 
407
407
  def _get_code_context(
408
408
  self, file_path: str, line_number: int, context_lines: int = 3
409
- ) -> str | None:
409
+ ) -> Optional[str]:
410
410
  """Read source file and extract surrounding lines for debugging."""
411
411
  try:
412
412
  with open(file_path, "r") as source_file:
@@ -421,9 +421,9 @@ class BaseExecutionContext(ExecutionContext):
421
421
  self,
422
422
  request_type: str,
423
423
  message: str,
424
- timeout_seconds: int | None,
424
+ timeout_seconds: Optional[int],
425
425
  default_value: Any,
426
- options: list[dict] | None,
426
+ options: Optional[List[dict]],
427
427
  metadata: dict,
428
428
  ) -> HITLResponse:
429
429
  """
@@ -505,7 +505,7 @@ class BaseExecutionContext(ExecutionContext):
505
505
  async_procedure_handles[handle.procedure_id] = handle.to_dict()
506
506
  self.storage.save_procedure_metadata(self.procedure_id, self.metadata)
507
507
 
508
- def get_procedure_handle(self, procedure_id: str) -> dict[str, Any] | None:
508
+ def get_procedure_handle(self, procedure_id: str) -> Optional[Dict[str, Any]]:
509
509
  """
510
510
  Retrieve procedure handle.
511
511
 
@@ -609,7 +609,7 @@ class BaseExecutionContext(ExecutionContext):
609
609
 
610
610
  return run_id
611
611
 
612
- def get_subject(self) -> str | None:
612
+ def get_subject(self) -> Optional[str]:
613
613
  """
614
614
  Return a human-readable subject line for this execution.
615
615
 
@@ -621,7 +621,7 @@ class BaseExecutionContext(ExecutionContext):
621
621
  return f"{self.procedure_name} (checkpoint {checkpoint_position})"
622
622
  return f"Procedure {self.procedure_id} (checkpoint {checkpoint_position})"
623
623
 
624
- def get_started_at(self) -> datetime | None:
624
+ def get_started_at(self) -> Optional[datetime]:
625
625
  """
626
626
  Return when this execution started.
627
627
 
@@ -630,7 +630,7 @@ class BaseExecutionContext(ExecutionContext):
630
630
  """
631
631
  return self._started_at
632
632
 
633
- def get_input_summary(self) -> dict[str, Any] | None:
633
+ def get_input_summary(self) -> Optional[Dict[str, Any]]:
634
634
  """
635
635
  Return a summary of the initial input to this procedure.
636
636
 
@@ -647,7 +647,7 @@ class BaseExecutionContext(ExecutionContext):
647
647
  # Otherwise wrap it in a dict
648
648
  return {"value": self._input_data}
649
649
 
650
- def get_conversation_history(self) -> list[dict] | None:
650
+ def get_conversation_history(self) -> Optional[List[dict]]:
651
651
  """
652
652
  Return conversation history if available.
653
653
 
@@ -658,7 +658,7 @@ class BaseExecutionContext(ExecutionContext):
658
658
  # in future implementations
659
659
  return None
660
660
 
661
- def get_prior_control_interactions(self) -> list[dict] | None:
661
+ def get_prior_control_interactions(self) -> Optional[List[dict]]:
662
662
  """
663
663
  Return list of prior HITL interactions in this execution.
664
664
 
@@ -682,7 +682,7 @@ class BaseExecutionContext(ExecutionContext):
682
682
 
683
683
  return hitl_checkpoints if hitl_checkpoints else None
684
684
 
685
- def get_lua_source_line(self) -> int | None:
685
+ def get_lua_source_line(self) -> Optional[int]:
686
686
  """
687
687
  Get the current source line from Lua debug.getinfo.
688
688
 
@@ -768,7 +768,7 @@ class InMemoryExecutionContext(BaseExecutionContext):
768
768
  and simple CLI workflows that don't need to survive restarts.
769
769
  """
770
770
 
771
- def __init__(self, procedure_id: str, hitl_handler: HITLHandler | None = None):
771
+ def __init__(self, procedure_id: str, hitl_handler: Optional[HITLHandler] = None):
772
772
  """
773
773
  Initialize with in-memory storage.
774
774
 
@@ -8,7 +8,7 @@ Aligned with pydantic-ai's message_history concept.
8
8
  """
9
9
 
10
10
  from datetime import datetime, timezone
11
- from typing import Any, Optional
11
+ from typing import Any, Optional, Tuple
12
12
 
13
13
  try:
14
14
  from pydantic_ai.messages import ModelMessage
@@ -146,7 +146,7 @@ class MessageHistoryManager:
146
146
  return self._apply_named_filter(messages, filter_name, filter_value)
147
147
 
148
148
  @staticmethod
149
- def _parse_filter_spec(filter_specification: Any) -> tuple[str | None, Any]:
149
+ def _parse_filter_spec(filter_specification: Any) -> Tuple[Optional[str], Any]:
150
150
  if not isinstance(filter_specification, tuple) or len(filter_specification) < 2:
151
151
  return None, None
152
152
 
tactus/core/mocking.py CHANGED
@@ -13,6 +13,18 @@ import logging
13
13
  from typing import Any, Optional, Union
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
+ _CURRENT_MOCK_MANAGER: Optional["MockManager"] = None
17
+
18
+
19
+ def set_current_mock_manager(manager: Optional["MockManager"]) -> None:
20
+ """Set the globally accessible mock manager for stdlib helpers."""
21
+ global _CURRENT_MOCK_MANAGER
22
+ _CURRENT_MOCK_MANAGER = manager
23
+
24
+
25
+ def get_current_mock_manager() -> Optional["MockManager"]:
26
+ """Get the globally accessible mock manager for stdlib helpers."""
27
+ return _CURRENT_MOCK_MANAGER
16
28
 
17
29
 
18
30
  @dataclass
@@ -6,7 +6,7 @@ Enables type safety and composability for sub-agent workflows.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Any, Optional
9
+ from typing import Any, Optional, Tuple
10
10
 
11
11
  logger = logging.getLogger(__name__)
12
12
 
@@ -78,7 +78,7 @@ class OutputValidator:
78
78
  logger.debug("OutputValidator initialized with %s output fields", field_count)
79
79
 
80
80
  @staticmethod
81
- def _unwrap_result(output: Any) -> tuple[Any, Any | None]:
81
+ def _unwrap_result(output: Any) -> Tuple[Any, Optional[Any]]:
82
82
  from tactus.protocols.result import TactusResult
83
83
 
84
84
  wrapped_result = output if isinstance(output, TactusResult) else None
@@ -94,7 +94,7 @@ class OutputValidator:
94
94
 
95
95
  @staticmethod
96
96
  def _wrap_validated_output(
97
- wrapped_result: Any | None,
97
+ wrapped_result: Optional[Any],
98
98
  validated_payload: Any,
99
99
  ) -> Any:
100
100
  if wrapped_result is not None:
@@ -129,7 +129,7 @@ class OutputValidator:
129
129
  def _validate_without_schema(
130
130
  self,
131
131
  output: Any,
132
- wrapped_result: Any | None,
132
+ wrapped_result: Optional[Any],
133
133
  ) -> Any:
134
134
  """Accept any output when no schema is defined."""
135
135
  logger.debug("No output schema defined, skipping validation")
@@ -139,7 +139,7 @@ class OutputValidator:
139
139
  def _validate_scalar_schema(
140
140
  self,
141
141
  output: Any,
142
- wrapped_result: Any | None,
142
+ wrapped_result: Optional[Any],
143
143
  ) -> Any:
144
144
  """Validate scalar outputs (`field.string{}` etc.)."""
145
145
  # Lua tables are not valid scalar outputs.
@@ -168,7 +168,7 @@ class OutputValidator:
168
168
  def _validate_structured_schema(
169
169
  self,
170
170
  output: Any,
171
- wrapped_result: Any | None,
171
+ wrapped_result: Optional[Any],
172
172
  ) -> Any:
173
173
  """Validate dict/table outputs against a schema."""
174
174
  if hasattr(output, "items") or isinstance(output, dict):
tactus/core/registry.py CHANGED
@@ -6,10 +6,17 @@ procedure declarations from .tac files.
6
6
  """
7
7
 
8
8
  import logging
9
- from typing import Any
9
+ from typing import Any, Dict, Optional, Union
10
10
 
11
11
  from pydantic import BaseModel, Field, ValidationError, ConfigDict
12
12
 
13
+ from tactus.core.context_models import (
14
+ CompactorDeclaration,
15
+ ContextDeclaration,
16
+ CorpusDeclaration,
17
+ RetrieverDeclaration,
18
+ )
19
+
13
20
  logger = logging.getLogger(__name__)
14
21
 
15
22
 
@@ -19,7 +26,7 @@ class OutputFieldDeclaration(BaseModel):
19
26
  name: str
20
27
  field_type: str = Field(alias="type") # string, number, boolean, array, object
21
28
  required: bool = False
22
- description: str | None = None
29
+ description: Optional[str] = None
23
30
 
24
31
  model_config = ConfigDict(populate_by_name=True)
25
32
 
@@ -31,7 +38,7 @@ class MessageHistoryConfiguration(BaseModel):
31
38
  """
32
39
 
33
40
  source: str = "own" # "own", "shared", or another agent's name
34
- filter: Any | None = None # Lua function reference or filter name
41
+ filter: Optional[Any] = None # Lua function reference or filter name
35
42
 
36
43
 
37
44
  class AgentOutputSchema(BaseModel):
@@ -44,21 +51,21 @@ class AgentDeclaration(BaseModel):
44
51
  """Agent declaration from DSL."""
45
52
 
46
53
  name: str
47
- provider: str | None = None
48
- model: str | dict[str, Any] = "gpt-4o"
49
- system_prompt: str | Any # String with {markers} or Lua function
50
- initial_message: str | None = None
54
+ provider: Optional[str] = None
55
+ model: Union[str, Dict[str, Any]] = "gpt-4o"
56
+ system_prompt: Union[str, Any] # String with {markers} or Lua function
57
+ initial_message: Optional[str] = None
51
58
  tools: list[Any] = Field(default_factory=list) # Tool/toolset references and expressions
52
59
  inline_tools: list[dict[str, Any]] = Field(default_factory=list) # Inline tool definitions
53
- output: AgentOutputSchema | None = None # Aligned with pydantic-ai
54
- message_history: MessageHistoryConfiguration | None = None
60
+ output: Optional[AgentOutputSchema] = None # Aligned with pydantic-ai
61
+ message_history: Optional[MessageHistoryConfiguration] = None
55
62
  max_turns: int = 50
56
63
  disable_streaming: bool = (
57
64
  False # Disable streaming for models that don't support tools in streaming mode
58
65
  )
59
- temperature: float | None = None
60
- max_tokens: int | None = None
61
- model_type: str | None = None # e.g., "chat", "responses" for reasoning models
66
+ temperature: Optional[float] = None
67
+ max_tokens: Optional[int] = None
68
+ model_type: Optional[str] = None # e.g., "chat", "responses" for reasoning models
62
69
 
63
70
  model_config = ConfigDict(extra="allow")
64
71
 
@@ -69,9 +76,9 @@ class HITLDeclaration(BaseModel):
69
76
  name: str
70
77
  hitl_type: str = Field(alias="type") # approval, input, review
71
78
  message: str
72
- timeout: int | None = None
79
+ timeout: Optional[int] = None
73
80
  default: Any = None
74
- options: list[dict[str, Any]] | None = None
81
+ options: Optional[list[dict[str, Any]]] = None
75
82
 
76
83
  model_config = ConfigDict(populate_by_name=True)
77
84
 
@@ -81,9 +88,9 @@ class ScenarioDeclaration(BaseModel):
81
88
 
82
89
  name: str
83
90
  given: dict[str, Any] = Field(default_factory=dict)
84
- when: str | None = None # defaults to "procedure_completes"
85
- then_output: dict[str, Any] | None = None
86
- then_state: dict[str, Any] | None = None
91
+ when: Optional[str] = None # defaults to "procedure_completes"
92
+ then_output: Optional[Dict[str, Any]] = None
93
+ then_state: Optional[Dict[str, Any]] = None
87
94
  mocks: dict[str, Any] = Field(default_factory=dict) # tool_name -> response
88
95
 
89
96
 
@@ -128,13 +135,25 @@ class AgentMockConfig(BaseModel):
128
135
  )
129
136
 
130
137
 
138
+ class TaskDeclaration(BaseModel):
139
+ """Task declaration from DSL."""
140
+
141
+ name: str
142
+ children: dict[str, "TaskDeclaration"] = Field(default_factory=dict)
143
+
144
+ model_config = ConfigDict(extra="allow")
145
+
146
+
147
+ TaskDeclaration.model_rebuild()
148
+
149
+
131
150
  class ProcedureRegistry(BaseModel):
132
151
  """Collects all declarations from a .tac file."""
133
152
 
134
153
  model_config = {"arbitrary_types_allowed": True}
135
154
 
136
155
  # Metadata
137
- description: str | None = None
156
+ description: Optional[str] = None
138
157
 
139
158
  # Declarations
140
159
  input_schema: dict[str, Any] = Field(default_factory=dict)
@@ -149,31 +168,37 @@ class ProcedureRegistry(BaseModel):
149
168
  dependencies: dict[str, DependencyDeclaration] = Field(default_factory=dict)
150
169
  mocks: dict[str, dict[str, Any]] = Field(default_factory=dict) # Mock configurations
151
170
  agent_mocks: dict[str, AgentMockConfig] = Field(default_factory=dict) # Agent mock configs
171
+ contexts: dict[str, ContextDeclaration] = Field(default_factory=dict)
172
+ corpora: dict[str, CorpusDeclaration] = Field(default_factory=dict)
173
+ retrievers: dict[str, RetrieverDeclaration] = Field(default_factory=dict)
174
+ compactors: dict[str, CompactorDeclaration] = Field(default_factory=dict)
175
+ tasks: dict[str, TaskDeclaration] = Field(default_factory=dict)
176
+ include_tasks: list[dict[str, Any]] = Field(default_factory=list)
152
177
 
153
178
  # Message history configuration (aligned with pydantic-ai)
154
179
  message_history_config: dict[str, Any] = Field(default_factory=dict)
155
180
 
156
181
  # Gherkin BDD Testing
157
- gherkin_specifications: str | None = None # Raw Gherkin text
182
+ gherkin_specifications: Optional[str] = None # Raw Gherkin text
158
183
  specs_from_references: list[str] = Field(default_factory=list) # External spec file paths
159
184
  custom_steps: dict[str, Any] = Field(default_factory=dict) # step_text -> lua_function
160
185
  evaluation_config: dict[str, Any] = Field(default_factory=dict) # runs, parallel, etc.
161
186
 
162
187
  # Pydantic Evals Integration
163
- pydantic_evaluations: dict[str, Any] | None = None # Pydantic Evals configuration
188
+ pydantic_evaluations: Optional[Dict[str, Any]] = None # Pydantic Evals configuration
164
189
 
165
190
  # Prompts
166
191
  prompts: dict[str, str] = Field(default_factory=dict)
167
- return_prompt: str | None = None
168
- error_prompt: str | None = None
169
- status_prompt: str | None = None
192
+ return_prompt: Optional[str] = None
193
+ error_prompt: Optional[str] = None
194
+ status_prompt: Optional[str] = None
170
195
 
171
196
  # Execution settings
172
197
  async_enabled: bool = False
173
198
  max_depth: int = 5
174
199
  max_turns: int = 50
175
- default_provider: str | None = None
176
- default_model: str | None = None
200
+ default_provider: Optional[str] = None
201
+ default_model: Optional[str] = None
177
202
 
178
203
  # Named procedures (for in-file sub-procedures)
179
204
  named_procedures: dict[str, dict[str, Any]] = Field(default_factory=dict)
@@ -193,8 +218,8 @@ class ValidationMessage(BaseModel):
193
218
 
194
219
  level: str # "error" or "warning"
195
220
  message: str
196
- location: tuple[int, int] | None = None
197
- declaration: str | None = None
221
+ location: Optional[tuple[int, int]] = None
222
+ declaration: Optional[str] = None
198
223
 
199
224
 
200
225
  class ValidationResult(BaseModel):
@@ -203,7 +228,7 @@ class ValidationResult(BaseModel):
203
228
  valid: bool
204
229
  errors: list[ValidationMessage] = Field(default_factory=list)
205
230
  warnings: list[ValidationMessage] = Field(default_factory=list)
206
- registry: ProcedureRegistry | None = None
231
+ registry: Optional["ProcedureRegistry"] = None
207
232
 
208
233
 
209
234
  class RegistryBuilder:
@@ -229,7 +254,7 @@ class RegistryBuilder:
229
254
  self,
230
255
  name: str,
231
256
  config: dict,
232
- output_schema: dict | None = None,
257
+ output_schema: Optional[dict] = None,
233
258
  ) -> None:
234
259
  """Register an agent declaration."""
235
260
  agent_config = dict(config)
@@ -334,6 +359,123 @@ class RegistryBuilder:
334
359
  except Exception as exception:
335
360
  self._add_error(f"Invalid agent mock config for '{agent_name}': {exception}")
336
361
 
362
+ def register_context(self, name: str, config: dict) -> None:
363
+ """Register a context declaration."""
364
+ context_config = dict(config)
365
+ context_config["name"] = name
366
+ try:
367
+ self.registry.contexts[name] = ContextDeclaration(**context_config)
368
+ except ValidationError as exception:
369
+ self._add_error(f"Invalid context '{name}': {exception}")
370
+
371
+ def register_corpus(self, name: str, config: dict) -> None:
372
+ """Register a corpus declaration."""
373
+ corpus_config = dict(config)
374
+ if "root" in corpus_config and "corpus_root" not in corpus_config:
375
+ corpus_config["corpus_root"] = corpus_config.pop("root")
376
+ try:
377
+ self.registry.corpora[name] = CorpusDeclaration(name=name, config=corpus_config)
378
+ except ValidationError as exception:
379
+ self._add_error(f"Invalid corpus '{name}': {exception}")
380
+
381
+ def register_retriever(self, name: str, config: dict) -> None:
382
+ """Register a retriever declaration."""
383
+ retriever_config = dict(config)
384
+ if "retriever_id" not in retriever_config:
385
+ candidate = retriever_config.get("retriever_type")
386
+ if candidate is not None:
387
+ retriever_config["retriever_id"] = candidate
388
+ if isinstance(retriever_config.get("configuration"), dict):
389
+ pipeline = retriever_config["configuration"].get("pipeline", {}) or {}
390
+ if isinstance(pipeline, dict) and isinstance(pipeline.get("query"), dict):
391
+ query_config = pipeline.get("query") or {}
392
+ for key in (
393
+ "limit",
394
+ "offset",
395
+ "maximum_total_characters",
396
+ "maximum_items_per_source",
397
+ "max_items_per_source",
398
+ "include_metadata",
399
+ "metadata_fields",
400
+ "join_with",
401
+ ):
402
+ if key in query_config and key not in retriever_config:
403
+ retriever_config[key] = query_config.get(key)
404
+ corpus_name = retriever_config.pop("corpus", None)
405
+ try:
406
+ self.registry.retrievers[name] = RetrieverDeclaration(
407
+ name=name,
408
+ corpus=corpus_name,
409
+ config=retriever_config,
410
+ )
411
+ except ValidationError as exception:
412
+ self._add_error(f"Invalid retriever '{name}': {exception}")
413
+
414
+ def register_task(
415
+ self,
416
+ name: str,
417
+ task_config: Optional[dict] = None,
418
+ parent: Optional[str] = None,
419
+ ) -> None:
420
+ """Register a task declaration (optionally nested under a parent task)."""
421
+ if not name:
422
+ self._add_error("Task name is required.")
423
+ return
424
+
425
+ if ":" in name:
426
+ self._add_error(f"Task name '{name}' may not contain ':'")
427
+ return
428
+
429
+ task_payload = dict(task_config or {})
430
+ task_payload["name"] = name
431
+
432
+ try:
433
+ task = TaskDeclaration(**task_payload)
434
+ except ValidationError as exception:
435
+ self._add_error(f"Invalid task '{name}': {exception}")
436
+ return
437
+
438
+ if parent is None:
439
+ if name in self.registry.tasks:
440
+ self._add_error(f"Duplicate task '{name}'")
441
+ return
442
+ self.registry.tasks[name] = task
443
+ return
444
+
445
+ parent_task = self._find_task(parent)
446
+ if parent_task is None:
447
+ self._add_error(f"Parent task '{parent}' not found for '{name}'")
448
+ return
449
+
450
+ if name in parent_task.children:
451
+ self._add_error(f"Duplicate task '{parent}:{name}'")
452
+ return
453
+
454
+ parent_task.children[name] = task
455
+
456
+ def register_include_tasks(self, path: str, namespace: Optional[str] = None) -> None:
457
+ """Register an IncludeTasks directive for static task discovery."""
458
+ payload = {"path": path}
459
+ if namespace:
460
+ payload["namespace"] = namespace
461
+ self.registry.include_tasks.append(payload)
462
+
463
+ def _find_task(self, name: str) -> Optional[TaskDeclaration]:
464
+ if name in self.registry.tasks:
465
+ return self.registry.tasks[name]
466
+ return None
467
+
468
+ def register_compactor(self, name: str, config: dict) -> None:
469
+ """Register a compactor declaration."""
470
+ compactor_config = dict(config)
471
+ try:
472
+ self.registry.compactors[name] = CompactorDeclaration(
473
+ name=name,
474
+ config=compactor_config,
475
+ )
476
+ except ValidationError as exception:
477
+ self._add_error(f"Invalid compactor '{name}': {exception}")
478
+
337
479
  def register_specification(self, name: str, scenarios: list) -> None:
338
480
  """Register a BDD specification."""
339
481
  try: