digitalkin 0.3.1.dev2__py3-none-any.whl → 0.3.2a3__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 (87) hide show
  1. base_server/server_async_insecure.py +6 -5
  2. base_server/server_async_secure.py +6 -5
  3. base_server/server_sync_insecure.py +5 -4
  4. base_server/server_sync_secure.py +5 -4
  5. digitalkin/__version__.py +1 -1
  6. digitalkin/core/job_manager/base_job_manager.py +1 -1
  7. digitalkin/core/job_manager/single_job_manager.py +78 -36
  8. digitalkin/core/job_manager/taskiq_broker.py +7 -6
  9. digitalkin/core/job_manager/taskiq_job_manager.py +9 -5
  10. digitalkin/core/task_manager/base_task_manager.py +3 -1
  11. digitalkin/core/task_manager/surrealdb_repository.py +29 -7
  12. digitalkin/core/task_manager/task_executor.py +46 -12
  13. digitalkin/core/task_manager/task_session.py +132 -102
  14. digitalkin/grpc_servers/module_server.py +95 -171
  15. digitalkin/grpc_servers/module_servicer.py +121 -19
  16. digitalkin/grpc_servers/utils/grpc_client_wrapper.py +36 -10
  17. digitalkin/grpc_servers/utils/utility_schema_extender.py +106 -0
  18. digitalkin/models/__init__.py +1 -1
  19. digitalkin/models/core/job_manager_models.py +0 -8
  20. digitalkin/models/core/task_monitor.py +23 -1
  21. digitalkin/models/grpc_servers/models.py +95 -8
  22. digitalkin/models/module/__init__.py +26 -13
  23. digitalkin/models/module/base_types.py +61 -0
  24. digitalkin/models/module/module_context.py +279 -13
  25. digitalkin/models/module/module_types.py +28 -392
  26. digitalkin/models/module/setup_types.py +547 -0
  27. digitalkin/models/module/tool_cache.py +230 -0
  28. digitalkin/models/module/tool_reference.py +160 -0
  29. digitalkin/models/module/utility.py +167 -0
  30. digitalkin/models/services/cost.py +22 -1
  31. digitalkin/models/services/registry.py +77 -0
  32. digitalkin/modules/__init__.py +5 -1
  33. digitalkin/modules/_base_module.py +188 -63
  34. digitalkin/modules/archetype_module.py +6 -1
  35. digitalkin/modules/tool_module.py +6 -1
  36. digitalkin/modules/triggers/__init__.py +8 -0
  37. digitalkin/modules/triggers/healthcheck_ping_trigger.py +45 -0
  38. digitalkin/modules/triggers/healthcheck_services_trigger.py +63 -0
  39. digitalkin/modules/triggers/healthcheck_status_trigger.py +52 -0
  40. digitalkin/services/__init__.py +4 -0
  41. digitalkin/services/communication/__init__.py +7 -0
  42. digitalkin/services/communication/communication_strategy.py +87 -0
  43. digitalkin/services/communication/default_communication.py +104 -0
  44. digitalkin/services/communication/grpc_communication.py +264 -0
  45. digitalkin/services/cost/cost_strategy.py +36 -14
  46. digitalkin/services/cost/default_cost.py +61 -1
  47. digitalkin/services/cost/grpc_cost.py +98 -2
  48. digitalkin/services/filesystem/grpc_filesystem.py +9 -2
  49. digitalkin/services/registry/__init__.py +22 -1
  50. digitalkin/services/registry/default_registry.py +156 -4
  51. digitalkin/services/registry/exceptions.py +47 -0
  52. digitalkin/services/registry/grpc_registry.py +382 -0
  53. digitalkin/services/registry/registry_models.py +15 -0
  54. digitalkin/services/registry/registry_strategy.py +106 -4
  55. digitalkin/services/services_config.py +25 -3
  56. digitalkin/services/services_models.py +5 -1
  57. digitalkin/services/setup/default_setup.py +1 -1
  58. digitalkin/services/setup/grpc_setup.py +1 -1
  59. digitalkin/services/storage/grpc_storage.py +1 -1
  60. digitalkin/services/user_profile/__init__.py +11 -0
  61. digitalkin/services/user_profile/grpc_user_profile.py +2 -2
  62. digitalkin/services/user_profile/user_profile_strategy.py +0 -15
  63. digitalkin/utils/__init__.py +15 -3
  64. digitalkin/utils/conditional_schema.py +260 -0
  65. digitalkin/utils/dynamic_schema.py +4 -0
  66. digitalkin/utils/schema_splitter.py +290 -0
  67. {digitalkin-0.3.1.dev2.dist-info → digitalkin-0.3.2a3.dist-info}/METADATA +12 -12
  68. digitalkin-0.3.2a3.dist-info/RECORD +144 -0
  69. {digitalkin-0.3.1.dev2.dist-info → digitalkin-0.3.2a3.dist-info}/WHEEL +1 -1
  70. {digitalkin-0.3.1.dev2.dist-info → digitalkin-0.3.2a3.dist-info}/top_level.txt +1 -0
  71. modules/archetype_with_tools_module.py +232 -0
  72. modules/cpu_intensive_module.py +1 -1
  73. modules/dynamic_setup_module.py +5 -29
  74. modules/minimal_llm_module.py +1 -1
  75. modules/text_transform_module.py +1 -1
  76. monitoring/digitalkin_observability/__init__.py +46 -0
  77. monitoring/digitalkin_observability/http_server.py +150 -0
  78. monitoring/digitalkin_observability/interceptors.py +176 -0
  79. monitoring/digitalkin_observability/metrics.py +201 -0
  80. monitoring/digitalkin_observability/prometheus.py +137 -0
  81. monitoring/tests/test_metrics.py +172 -0
  82. services/filesystem_module.py +7 -5
  83. services/storage_module.py +4 -2
  84. digitalkin/grpc_servers/registry_server.py +0 -65
  85. digitalkin/grpc_servers/registry_servicer.py +0 -456
  86. digitalkin-0.3.1.dev2.dist-info/RECORD +0 -119
  87. {digitalkin-0.3.1.dev2.dist-info → digitalkin-0.3.2a3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,87 @@
1
+ """Abstract base class for communication strategies."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from collections.abc import AsyncGenerator, Awaitable, Callable
5
+
6
+ from digitalkin.services.base_strategy import BaseStrategy
7
+
8
+
9
+ class CommunicationStrategy(BaseStrategy, ABC):
10
+ """Abstract base class for module-to-module communication.
11
+
12
+ This service enables:
13
+ - Archetype → Tool communication
14
+ - Archetype → Archetype communication
15
+ - Tool → Tool communication
16
+ - Any module → Any module communication
17
+
18
+ The service wraps the Module Service protocol from agentic-mesh-protocol.
19
+ """
20
+
21
+ @abstractmethod
22
+ async def cleanup(self) -> None:
23
+ """Clean up communication resources.
24
+
25
+ This method should release any held resources such as
26
+ gRPC channels, connection pools, etc.
27
+ """
28
+ raise NotImplementedError
29
+
30
+ @abstractmethod
31
+ async def get_module_schemas(
32
+ self,
33
+ module_address: str,
34
+ module_port: int,
35
+ *,
36
+ llm_format: bool = False,
37
+ ) -> dict[str, dict]:
38
+ """Get module schemas (input/output/setup/secret/cost).
39
+
40
+ Args:
41
+ module_address: Target module address
42
+ module_port: Target module port
43
+ llm_format: Return LLM-friendly format (simplified schema).
44
+ Note: cost always returns actual data regardless of this flag.
45
+
46
+ Returns:
47
+ Dictionary containing schemas:
48
+ {
49
+ "input": {...},
50
+ "output": {...},
51
+ "setup": {...},
52
+ "secret": {...},
53
+ "cost": {...}
54
+ }
55
+ """
56
+ raise NotImplementedError
57
+
58
+ @abstractmethod
59
+ async def call_module(
60
+ self,
61
+ module_address: str,
62
+ module_port: int,
63
+ input_data: dict,
64
+ setup_id: str,
65
+ mission_id: str,
66
+ callback: Callable[[dict], Awaitable[None]] | None = None,
67
+ ) -> AsyncGenerator[dict, None]:
68
+ """Call a module and stream responses.
69
+
70
+ Uses Module Service StartModule RPC to execute the module.
71
+ Streams responses as they are generated by the module.
72
+
73
+ Args:
74
+ module_address: Target module address
75
+ module_port: Target module port
76
+ input_data: Input data as dictionary
77
+ setup_id: Setup configuration ID
78
+ mission_id: Mission context ID
79
+ callback: Optional callback for each response
80
+
81
+ Yields:
82
+ Streaming responses from module as dictionaries
83
+ """
84
+ # Make this an actual async generator to satisfy type checkers
85
+ if False: # pragma: no cover
86
+ yield {}
87
+ raise NotImplementedError
@@ -0,0 +1,104 @@
1
+ """Default communication implementation (local, for testing)."""
2
+
3
+ from collections.abc import AsyncGenerator, Awaitable, Callable
4
+
5
+ from digitalkin.logger import logger
6
+ from digitalkin.services.communication.communication_strategy import CommunicationStrategy
7
+
8
+
9
+ class DefaultCommunication(CommunicationStrategy):
10
+ """Default communication strategy (local implementation).
11
+
12
+ This implementation is primarily for testing and development.
13
+ For production, use GrpcCommunication to connect to remote modules.
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ mission_id: str,
19
+ setup_id: str,
20
+ setup_version_id: str,
21
+ ) -> None:
22
+ """Initialize the default communication service.
23
+
24
+ Args:
25
+ mission_id: Mission identifier
26
+ setup_id: Setup identifier
27
+ setup_version_id: Setup version identifier
28
+ """
29
+ super().__init__(mission_id, setup_id, setup_version_id)
30
+ logger.debug("Initialized DefaultCommunication (local)")
31
+
32
+ async def get_module_schemas( # noqa: PLR6301
33
+ self,
34
+ module_address: str,
35
+ module_port: int,
36
+ *,
37
+ llm_format: bool = False,
38
+ ) -> dict[str, dict]:
39
+ """Get module schemas (local implementation returns empty schemas).
40
+
41
+ Args:
42
+ module_address: Target module address
43
+ module_port: Target module port
44
+ llm_format: Return LLM-friendly format
45
+
46
+ Returns:
47
+ Empty schemas dictionary
48
+ """
49
+ logger.debug(
50
+ "DefaultCommunication.get_module_schemas called (returns empty)",
51
+ extra={
52
+ "module_address": module_address,
53
+ "module_port": module_port,
54
+ "llm_format": llm_format,
55
+ },
56
+ )
57
+ return {
58
+ "input": {},
59
+ "output": {},
60
+ "setup": {},
61
+ "secret": {},
62
+ }
63
+
64
+ async def call_module( # noqa: PLR6301
65
+ self,
66
+ module_address: str,
67
+ module_port: int,
68
+ input_data: dict, # noqa: ARG002
69
+ setup_id: str,
70
+ mission_id: str,
71
+ callback: Callable[[dict], Awaitable[None]] | None = None,
72
+ ) -> AsyncGenerator[dict, None]:
73
+ """Call module (local implementation yields empty response).
74
+
75
+ Args:
76
+ module_address: Target module address
77
+ module_port: Target module port
78
+ input_data: Input data
79
+ setup_id: Setup ID
80
+ mission_id: Mission ID
81
+ callback: Optional callback
82
+
83
+ Yields:
84
+ Empty response dictionary
85
+ """
86
+ logger.debug(
87
+ "DefaultCommunication.call_module called (returns empty)",
88
+ extra={
89
+ "module_address": module_address,
90
+ "module_port": module_port,
91
+ "setup_id": setup_id,
92
+ "mission_id": mission_id,
93
+ },
94
+ )
95
+
96
+ # Yield empty response
97
+ response = {"status": "error", "message": "Local communication not implemented"}
98
+ if callback:
99
+ await callback(response)
100
+ yield response
101
+ return # Explicit return for async generator
102
+
103
+ async def cleanup(self) -> None:
104
+ """No-op for local communication."""
@@ -0,0 +1,264 @@
1
+ """gRPC client implementation for Communication service."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncGenerator, Awaitable, Callable
5
+
6
+ import grpc
7
+ from agentic_mesh_protocol.module.v1 import (
8
+ information_pb2,
9
+ lifecycle_pb2,
10
+ module_service_pb2_grpc,
11
+ )
12
+ from google.protobuf import json_format, struct_pb2
13
+
14
+ from digitalkin.grpc_servers.utils.grpc_client_wrapper import GrpcClientWrapper
15
+ from digitalkin.logger import logger
16
+ from digitalkin.models.grpc_servers.models import ClientConfig
17
+ from digitalkin.services.base_strategy import BaseStrategy
18
+ from digitalkin.services.communication.communication_strategy import CommunicationStrategy
19
+
20
+
21
+ class GrpcCommunication(CommunicationStrategy, GrpcClientWrapper):
22
+ """gRPC client for module-to-module communication.
23
+
24
+ This class provides methods to communicate with remote modules
25
+ using the Module Service gRPC protocol.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ mission_id: str,
31
+ setup_id: str,
32
+ setup_version_id: str,
33
+ client_config: ClientConfig,
34
+ ) -> None:
35
+ """Initialize the gRPC communication client.
36
+
37
+ Args:
38
+ mission_id: Mission identifier
39
+ setup_id: Setup identifier
40
+ setup_version_id: Setup version identifier
41
+ client_config: Client configuration for gRPC connection
42
+ """
43
+ BaseStrategy.__init__(self, mission_id, setup_id, setup_version_id)
44
+ self.client_config = client_config
45
+ self._channel_pool: dict[tuple[str, int], grpc.Channel] = {}
46
+
47
+ logger.debug(
48
+ "Initialized GrpcCommunication",
49
+ extra={"security": client_config.security},
50
+ )
51
+
52
+ def _get_or_create_channel(self, module_address: str, module_port: int) -> grpc.Channel:
53
+ """Get existing channel or create new one for the target module.
54
+
55
+ Args:
56
+ module_address: Module host address
57
+ module_port: Module port
58
+
59
+ Returns:
60
+ gRPC channel for the target module
61
+ """
62
+ key = (module_address, module_port)
63
+ if key not in self._channel_pool:
64
+ logger.debug(
65
+ "Creating new channel",
66
+ extra={"address": module_address, "port": module_port},
67
+ )
68
+ config = ClientConfig(
69
+ host=module_address,
70
+ port=module_port,
71
+ mode=self.client_config.mode,
72
+ security=self.client_config.security,
73
+ credentials=self.client_config.credentials,
74
+ channel_options=self.client_config.channel_options,
75
+ )
76
+ self._channel_pool[key] = self._init_channel(config)
77
+ return self._channel_pool[key]
78
+
79
+ def close_all_channels(self) -> None:
80
+ """Close all pooled gRPC channels."""
81
+ for channel in self._channel_pool.values():
82
+ channel.close()
83
+ self._channel_pool.clear()
84
+
85
+ async def cleanup(self) -> None:
86
+ """Clean up all gRPC channels."""
87
+ self.close_all_channels()
88
+
89
+ def _create_stub(self, module_address: str, module_port: int) -> module_service_pb2_grpc.ModuleServiceStub:
90
+ """Create a new stub for the target module.
91
+
92
+ Args:
93
+ module_address: Module host address
94
+ module_port: Module port
95
+
96
+ Returns:
97
+ ModuleServiceStub for the target module
98
+ """
99
+ channel = self._get_or_create_channel(module_address, module_port)
100
+ return module_service_pb2_grpc.ModuleServiceStub(channel)
101
+
102
+ async def get_module_schemas(
103
+ self,
104
+ module_address: str,
105
+ module_port: int,
106
+ *,
107
+ llm_format: bool = False,
108
+ ) -> dict[str, dict]:
109
+ """Get module schemas via gRPC.
110
+
111
+ Args:
112
+ module_address: Target module address
113
+ module_port: Target module port
114
+ llm_format: Return LLM-friendly format
115
+
116
+ Returns:
117
+ Dictionary containing schemas: input, output, setup, secret, cost
118
+ """
119
+ stub = self._create_stub(module_address, module_port)
120
+
121
+ # Create requests
122
+ # Note: cost always uses llm_format=False to get actual config data (rates, units)
123
+ # No LLM are allowed to set costs
124
+ input_request = information_pb2.GetModuleInputRequest(llm_format=llm_format)
125
+ output_request = information_pb2.GetModuleOutputRequest(llm_format=llm_format)
126
+ setup_request = information_pb2.GetModuleSetupRequest(llm_format=llm_format)
127
+ secret_request = information_pb2.GetModuleSecretRequest(llm_format=llm_format)
128
+ cost_request = information_pb2.GetModuleCostRequest(llm_format=False)
129
+
130
+ # Get all schemas in parallel
131
+ try:
132
+ input_response, output_response, setup_response, secret_response, cost_response = await asyncio.gather(
133
+ asyncio.to_thread(stub.GetModuleInput, input_request),
134
+ asyncio.to_thread(stub.GetModuleOutput, output_request),
135
+ asyncio.to_thread(stub.GetModuleSetup, setup_request),
136
+ asyncio.to_thread(stub.GetModuleSecret, secret_request),
137
+ asyncio.to_thread(stub.GetModuleCost, cost_request),
138
+ )
139
+
140
+ logger.debug(
141
+ "Retrieved module schemas",
142
+ extra={
143
+ "module_address": module_address,
144
+ "module_port": module_port,
145
+ "llm_format": llm_format,
146
+ },
147
+ )
148
+
149
+ return {
150
+ "input": json_format.MessageToDict(input_response.input_schema),
151
+ "output": json_format.MessageToDict(output_response.output_schema),
152
+ "setup": json_format.MessageToDict(setup_response.setup_schema),
153
+ "secret": json_format.MessageToDict(secret_response.secret_schema),
154
+ "cost": json_format.MessageToDict(cost_response.cost_schema),
155
+ }
156
+ except Exception:
157
+ logger.exception(
158
+ "Failed to get module schemas",
159
+ extra={
160
+ "module_address": module_address,
161
+ "module_port": module_port,
162
+ },
163
+ )
164
+ raise
165
+
166
+ async def call_module(
167
+ self,
168
+ module_address: str,
169
+ module_port: int,
170
+ input_data: dict,
171
+ setup_id: str,
172
+ mission_id: str,
173
+ callback: Callable[[dict], Awaitable[None]] | None = None,
174
+ ) -> AsyncGenerator[dict, None]:
175
+ """Call a module and stream responses via gRPC.
176
+
177
+ Args:
178
+ module_address: Target module address
179
+ module_port: Target module port
180
+ input_data: Input data as dictionary
181
+ setup_id: Setup configuration ID
182
+ mission_id: Mission context ID
183
+ callback: Optional callback for each response
184
+
185
+ Yields:
186
+ Streaming responses from module as dictionaries
187
+ """
188
+ stub = self._create_stub(module_address, module_port)
189
+
190
+ # Convert input data to protobuf Struct
191
+ input_struct = struct_pb2.Struct()
192
+ input_struct.update(input_data)
193
+
194
+ # Create request
195
+ request = lifecycle_pb2.StartModuleRequest(
196
+ input=input_struct,
197
+ setup_id=setup_id,
198
+ mission_id=mission_id,
199
+ )
200
+
201
+ logger.debug(
202
+ "Calling module",
203
+ extra={
204
+ "module_address": module_address,
205
+ "module_port": module_port,
206
+ "setup_id": setup_id,
207
+ "mission_id": mission_id,
208
+ },
209
+ )
210
+
211
+ try:
212
+ # Call StartModule with streaming response
213
+ response_stream = stub.StartModule(request)
214
+
215
+ # Stream responses
216
+ for response in response_stream:
217
+ # Convert protobuf Struct to dict
218
+ output_dict = json_format.MessageToDict(response.output)
219
+
220
+ # Check for end_of_stream signal
221
+ if output_dict.get("root", {}).get("protocol") == "end_of_stream":
222
+ logger.debug(
223
+ "End of stream received",
224
+ extra={
225
+ "module_address": module_address,
226
+ "module_port": module_port,
227
+ },
228
+ )
229
+ break
230
+
231
+ # Add job_id and success flag
232
+ response_dict = {
233
+ "success": response.success,
234
+ "job_id": response.job_id,
235
+ "output": output_dict,
236
+ }
237
+
238
+ logger.debug(
239
+ "Received module response",
240
+ extra={
241
+ "module_address": module_address,
242
+ "module_port": module_port,
243
+ "success": response.success,
244
+ "job_id": response.job_id,
245
+ },
246
+ )
247
+
248
+ # Call callback if provided
249
+ if callback:
250
+ await callback(response_dict)
251
+
252
+ yield response_dict
253
+
254
+ except Exception:
255
+ logger.exception(
256
+ "Failed to call module",
257
+ extra={
258
+ "module_address": module_address,
259
+ "module_port": module_port,
260
+ "setup_id": setup_id,
261
+ "mission_id": mission_id,
262
+ },
263
+ )
264
+ raise
@@ -6,6 +6,7 @@ from typing import Literal
6
6
 
7
7
  from pydantic import BaseModel
8
8
 
9
+ from digitalkin.models.services.cost import AmountLimit, QuantityLimit
9
10
  from digitalkin.services.base_strategy import BaseStrategy
10
11
 
11
12
 
@@ -57,23 +58,25 @@ class CostServiceError(Exception):
57
58
  class CostStrategy(BaseStrategy, ABC):
58
59
  """Abstract base class for cost strategies."""
59
60
 
60
- def __init__(
61
- self,
62
- mission_id: str,
63
- setup_id: str,
64
- setup_version_id: str,
65
- config: dict[str, CostConfig],
66
- ) -> None:
67
- """Initialize the strategy.
61
+ @abstractmethod
62
+ def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None:
63
+ """Set cost limits for this session.
64
+
65
+ Args:
66
+ limits: List of CostLimit objects to enforce.
67
+ """
68
+
69
+ @abstractmethod
70
+ def check_limit(self, cost_config_name: str, quantity: float) -> bool:
71
+ """Check if adding this cost would exceed any limits.
68
72
 
69
73
  Args:
70
- mission_id: The ID of the mission this strategy is associated with
71
- setup_id: The ID of the setup
72
- setup_version_id: The ID of the setup version this strategy is associated with
73
- config: Configuration dictionary for the strategy
74
+ cost_config_name: Name of the cost config.
75
+ quantity: Quantity to add.
76
+
77
+ Returns:
78
+ True if within limits, False if would exceed.
74
79
  """
75
- super().__init__(mission_id, setup_id, setup_version_id)
76
- self.config = config
77
80
 
78
81
  @abstractmethod
79
82
  def add(
@@ -98,3 +101,22 @@ class CostStrategy(BaseStrategy, ABC):
98
101
  cost_types: list[Literal["TOKEN_INPUT", "TOKEN_OUTPUT", "API_CALL", "STORAGE", "TIME", "OTHER"]] | None = None,
99
102
  ) -> list[CostData]:
100
103
  """Get filtered costs."""
104
+
105
+ @abstractmethod
106
+ def get_cost_config(self) -> list[CostConfig]:
107
+ """Get cost configuration for the current setup version.
108
+
109
+ Returns:
110
+ List of CostConfig objects from the database.
111
+ """
112
+
113
+ @abstractmethod
114
+ def set_cost_config(self, configs: list[CostConfig]) -> bool:
115
+ """Store cost configuration for the current setup version.
116
+
117
+ Args:
118
+ configs: List of CostConfig objects to store.
119
+
120
+ Returns:
121
+ True if successfully stored.
122
+ """
@@ -3,6 +3,7 @@
3
3
  from typing import Literal
4
4
 
5
5
  from digitalkin.logger import logger
6
+ from digitalkin.models.services.cost import AmountLimit, QuantityLimit
6
7
  from digitalkin.services.cost.cost_strategy import (
7
8
  CostConfig,
8
9
  CostData,
@@ -24,8 +25,46 @@ class DefaultCost(CostStrategy):
24
25
  setup_version_id: The ID of the setup version this strategy is associated with
25
26
  config: The configuration dictionary for the cost
26
27
  """
27
- super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id, config=config)
28
+ super().__init__(mission_id=mission_id, setup_id=setup_id, setup_version_id=setup_version_id)
29
+ self.config = config
28
30
  self.db: dict[str, list[CostData]] = {}
31
+ self._limits: dict[str, QuantityLimit | AmountLimit] = {}
32
+ self._accumulated: dict[str, float] = {}
33
+
34
+ def set_limits(self, limits: list[QuantityLimit | AmountLimit]) -> None:
35
+ """Set cost limits for this session.
36
+
37
+ Args:
38
+ limits: List of CostLimit objects to enforce.
39
+ """
40
+ self._limits = {limit.name: limit for limit in limits}
41
+ self._accumulated = {}
42
+
43
+ def check_limit(self, cost_config_name: str, quantity: float) -> bool:
44
+ """Check if adding this cost would exceed any limits.
45
+
46
+ Args:
47
+ cost_config_name: Name of the cost config.
48
+ quantity: Quantity to add.
49
+
50
+ Returns:
51
+ True if within limits, False if would exceed.
52
+ """
53
+ limit = self._limits.get(cost_config_name)
54
+ if limit is None:
55
+ return True
56
+
57
+ cost_config = self.config.get(cost_config_name)
58
+ if cost_config is None:
59
+ return True
60
+
61
+ if limit.limit_type == "quantity":
62
+ current = self._accumulated.get(f"{cost_config_name}_quantity", 0)
63
+ return current + quantity <= limit.max_value
64
+
65
+ current = self._accumulated.get(f"{cost_config_name}_amount", 0)
66
+ projected = cost_config.rate * quantity
67
+ return current + projected <= limit.max_value
29
68
 
30
69
  def add(
31
70
  self,
@@ -112,3 +151,24 @@ class DefaultCost(CostStrategy):
112
151
  for cost in self.db[self.mission_id]
113
152
  if (names and cost.name in names) or (cost_types and cost.cost_type in cost_types)
114
153
  ]
154
+
155
+ def get_cost_config(self) -> list[CostConfig]:
156
+ """Get cost configuration from in-memory config.
157
+
158
+ Returns:
159
+ List of CostConfig objects from the config dictionary.
160
+ """
161
+ return list(self.config.values())
162
+
163
+ def set_cost_config(self, configs: list[CostConfig]) -> bool:
164
+ """Store cost configuration in memory.
165
+
166
+ Args:
167
+ configs: List of CostConfig objects to store.
168
+
169
+ Returns:
170
+ True if successfully stored.
171
+ """
172
+ self.config = {config.cost_name: config for config in configs}
173
+ logger.debug("Cost configs stored in memory: %s", self.config)
174
+ return True