forgex-cli 0.1.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 (115) hide show
  1. forgex/__init__.py +3 -0
  2. forgex/__main__.py +6 -0
  3. forgex/agents/__init__.py +36 -0
  4. forgex/agents/architect.py +233 -0
  5. forgex/agents/base.py +385 -0
  6. forgex/agents/builder.py +269 -0
  7. forgex/agents/ensemble.py +334 -0
  8. forgex/agents/expert_coder.py +283 -0
  9. forgex/agents/supervisor.py +303 -0
  10. forgex/cli/__init__.py +1 -0
  11. forgex/cli/app.py +232 -0
  12. forgex/cli/build_views.py +94 -0
  13. forgex/cli/commands/__init__.py +1 -0
  14. forgex/cli/commands/build.py +63 -0
  15. forgex/cli/commands/checkpoint.py +41 -0
  16. forgex/cli/commands/config.py +225 -0
  17. forgex/cli/commands/context.py +94 -0
  18. forgex/cli/commands/diff.py +32 -0
  19. forgex/cli/commands/do_cmd.py +50 -0
  20. forgex/cli/commands/doctor.py +175 -0
  21. forgex/cli/commands/fix.py +46 -0
  22. forgex/cli/commands/init.py +41 -0
  23. forgex/cli/commands/logs.py +102 -0
  24. forgex/cli/commands/models.py +144 -0
  25. forgex/cli/commands/plan.py +103 -0
  26. forgex/cli/commands/review.py +76 -0
  27. forgex/cli/commands/rollback.py +65 -0
  28. forgex/cli/commands/run.py +75 -0
  29. forgex/cli/commands/status.py +141 -0
  30. forgex/cli/commands/supervise.py +61 -0
  31. forgex/cli/commands/task.py +136 -0
  32. forgex/cli/commands/tools.py +116 -0
  33. forgex/cli/commands/verify.py +65 -0
  34. forgex/cli/context_memory.py +62 -0
  35. forgex/cli/intent.py +209 -0
  36. forgex/cli/progress.py +101 -0
  37. forgex/cli/repl.py +377 -0
  38. forgex/cli/runtime.py +233 -0
  39. forgex/cli/session.py +95 -0
  40. forgex/cli/ui.py +113 -0
  41. forgex/config/__init__.py +53 -0
  42. forgex/config/loader.py +189 -0
  43. forgex/config/schema.py +145 -0
  44. forgex/context/__init__.py +6 -0
  45. forgex/context/chunker.py +102 -0
  46. forgex/context/dependency_graph.py +110 -0
  47. forgex/context/indexer.py +161 -0
  48. forgex/context/manager.py +174 -0
  49. forgex/context/retriever.py +99 -0
  50. forgex/models/__init__.py +25 -0
  51. forgex/models/base.py +112 -0
  52. forgex/models/capabilities.py +21 -0
  53. forgex/models/factory.py +66 -0
  54. forgex/models/groq.py +13 -0
  55. forgex/models/local.py +10 -0
  56. forgex/models/openai_compatible.py +233 -0
  57. forgex/models/registry.py +81 -0
  58. forgex/models/router.py +332 -0
  59. forgex/orchestration/__init__.py +36 -0
  60. forgex/orchestration/checkpoints.py +117 -0
  61. forgex/orchestration/engine.py +573 -0
  62. forgex/orchestration/events.py +101 -0
  63. forgex/orchestration/lockfile.py +82 -0
  64. forgex/orchestration/retry.py +65 -0
  65. forgex/orchestration/state.py +89 -0
  66. forgex/orchestration/stores.py +90 -0
  67. forgex/orchestration/workflow.py +103 -0
  68. forgex/project.py +209 -0
  69. forgex/prompts/__init__.py +27 -0
  70. forgex/prompts/architect.md +18 -0
  71. forgex/prompts/builder.md +20 -0
  72. forgex/prompts/expert_coder.md +25 -0
  73. forgex/prompts/reviewer.md +11 -0
  74. forgex/prompts/supervisor.md +26 -0
  75. forgex/research/__init__.py +15 -0
  76. forgex/research/citations.py +16 -0
  77. forgex/research/engine.py +83 -0
  78. forgex/research/sources.py +154 -0
  79. forgex/sandbox/__init__.py +37 -0
  80. forgex/sandbox/executor.py +418 -0
  81. forgex/sandbox/permissions.py +67 -0
  82. forgex/sandbox/policies.py +172 -0
  83. forgex/schemas/__init__.py +101 -0
  84. forgex/schemas/agent.py +109 -0
  85. forgex/schemas/checkpoint.py +19 -0
  86. forgex/schemas/error.py +125 -0
  87. forgex/schemas/event.py +76 -0
  88. forgex/schemas/plan.py +84 -0
  89. forgex/schemas/project.py +92 -0
  90. forgex/schemas/research.py +25 -0
  91. forgex/schemas/result.py +68 -0
  92. forgex/schemas/task.py +118 -0
  93. forgex/storage/__init__.py +25 -0
  94. forgex/storage/database.py +40 -0
  95. forgex/storage/migrations.py +130 -0
  96. forgex/storage/repositories.py +492 -0
  97. forgex/tools/__init__.py +73 -0
  98. forgex/tools/base.py +212 -0
  99. forgex/tools/dependencies.py +137 -0
  100. forgex/tools/filesystem.py +322 -0
  101. forgex/tools/git.py +239 -0
  102. forgex/tools/project.py +39 -0
  103. forgex/tools/search.py +205 -0
  104. forgex/tools/terminal.py +81 -0
  105. forgex/tools/testing.py +100 -0
  106. forgex/tools/verification.py +174 -0
  107. forgex/utils/__init__.py +15 -0
  108. forgex/utils/logging.py +28 -0
  109. forgex/utils/paths.py +48 -0
  110. forgex/utils/redact.py +44 -0
  111. forgex_cli-0.1.0.dist-info/METADATA +189 -0
  112. forgex_cli-0.1.0.dist-info/RECORD +115 -0
  113. forgex_cli-0.1.0.dist-info/WHEEL +4 -0
  114. forgex_cli-0.1.0.dist-info/entry_points.txt +2 -0
  115. forgex_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,332 @@
1
+ """Model router (spec sections 13, 14, 30, 31).
2
+
3
+ Agents ask the router — never a provider SDK. The router:
4
+ * resolves the provider+model binding for an agent role from config
5
+ * adapts requests to model capabilities (drops unsupported fields)
6
+ * applies provider fallback (spec 30) after retries are exhausted
7
+ * records usage for observability (spec sections 40, 41)
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import logging
14
+ import time
15
+ from collections.abc import AsyncIterator, Callable
16
+ from dataclasses import dataclass
17
+ from typing import Any
18
+
19
+ from forgex.config.schema import ForgexConfig, ProviderConfig
20
+ from forgex.models.base import (
21
+ Message,
22
+ ModelProvider,
23
+ ModelRequest,
24
+ ModelResponse,
25
+ ProviderHealth,
26
+ )
27
+ from forgex.models.capabilities import ModelCapabilities
28
+ from forgex.models.factory import build_provider
29
+ from forgex.models.registry import ModelRegistry
30
+ from forgex.schemas.error import ConfigError, ProviderError
31
+ from forgex.schemas.result import ModelUsageRecord
32
+ from forgex.storage.repositories import ModelUsageRepository
33
+
34
+ logger = logging.getLogger("forgex.models.router")
35
+
36
+
37
+ @dataclass
38
+ class ModelBinding:
39
+ agent: str
40
+ provider_name: str
41
+ model: str
42
+ provider: ModelProvider
43
+ capabilities: ModelCapabilities
44
+
45
+
46
+ class ModelRouter:
47
+ def __init__(
48
+ self,
49
+ config: ForgexConfig,
50
+ *,
51
+ usage: ModelUsageRepository | None = None,
52
+ provider_builder: Callable[[str, ProviderConfig], ModelProvider] = build_provider,
53
+ ) -> None:
54
+ self._config = config
55
+ self._usage = usage
56
+ self._builder = provider_builder
57
+ self._registry = ModelRegistry(config.models.capabilities)
58
+ self._providers: dict[str, ModelProvider] = {}
59
+
60
+ def _provider(self, name: str) -> ModelProvider:
61
+ if name not in self._providers:
62
+ cfg = self._config.providers.get(name)
63
+ if cfg is None:
64
+ raise ConfigError(f"Provider '{name}' not found in configuration")
65
+ self._providers[name] = self._builder(name, cfg)
66
+ return self._providers[name]
67
+
68
+ def _resolve_binding(self, agent: str) -> ModelBinding:
69
+ models_cfg = self._config.models
70
+ binding_cfg = getattr(models_cfg, agent, None)
71
+ if binding_cfg is None:
72
+ raise ConfigError(f"No model binding configured for agent role '{agent}'")
73
+
74
+ prov_name = binding_cfg.provider
75
+ prov_cfg = self._config.providers.get(prov_name)
76
+ if prov_cfg is None:
77
+ raise ConfigError(f"Provider '{prov_name}' specified for '{agent}' does not exist")
78
+
79
+ model_name = binding_cfg.model or prov_cfg.default_model
80
+ if not model_name:
81
+ raise ConfigError(f"No model specified for agent '{agent}' and provider '{prov_name}' has no default_model")
82
+
83
+ caps = self._registry.lookup(model_name)
84
+ return ModelBinding(
85
+ agent=agent,
86
+ provider_name=prov_name,
87
+ model=model_name,
88
+ provider=self._provider(prov_name),
89
+ capabilities=caps,
90
+ )
91
+
92
+ def binding_for(self, provider: str, model: str) -> ModelBinding:
93
+ cfg = self._config.providers.get(provider)
94
+ if cfg is None:
95
+ raise ConfigError(f"Unknown provider '{provider}' — check [providers.{provider}] in config")
96
+ resolved_model = model or cfg.default_model
97
+ if not resolved_model:
98
+ raise ConfigError(f"No model for explicit binding on provider '{provider}'")
99
+ return ModelBinding(
100
+ agent="explicit",
101
+ provider_name=provider,
102
+ model=resolved_model,
103
+ provider=self._provider(provider),
104
+ capabilities=self._registry.lookup(resolved_model),
105
+ )
106
+
107
+ def capabilities_for(self, agent: str) -> ModelCapabilities:
108
+ return self._resolve_binding(agent).capabilities
109
+
110
+ def capabilities_for_binding(self, provider: str, model: str) -> ModelCapabilities:
111
+ return self.binding_for(provider, model).capabilities
112
+
113
+ def _build_request(
114
+ self,
115
+ binding: ModelBinding,
116
+ messages: list[Message],
117
+ *,
118
+ system: str | None = None,
119
+ tools: list[dict[str, Any]] | None = None,
120
+ tool_choice: str | None = None,
121
+ response_format: dict[str, str] | None = None,
122
+ temperature: float | None = None,
123
+ max_tokens: int | None = None,
124
+ ) -> ModelRequest:
125
+ adapted_tools = tools if binding.capabilities.supports_tools else None
126
+ adapted_rf = response_format if binding.capabilities.supports_json else None
127
+
128
+ return ModelRequest(
129
+ model=binding.model,
130
+ messages=messages,
131
+ system=system,
132
+ tools=adapted_tools,
133
+ tool_choice=tool_choice if adapted_tools else None,
134
+ response_format=adapted_rf,
135
+ temperature=temperature,
136
+ max_tokens=max_tokens,
137
+ )
138
+
139
+ def _record(
140
+ self,
141
+ binding: ModelBinding,
142
+ *,
143
+ run_id: str | None = None,
144
+ latency_ms: float | None = None,
145
+ response: ModelResponse | None = None,
146
+ error: str | None = None,
147
+ attempts: int = 1,
148
+ ) -> None:
149
+ if self._usage is None:
150
+ return
151
+ rec = ModelUsageRecord(
152
+ run_id=run_id,
153
+ agent=binding.agent,
154
+ provider=binding.provider_name,
155
+ model=binding.model,
156
+ input_tokens=response.usage.input_tokens if response and response.usage else None,
157
+ output_tokens=response.usage.output_tokens if response and response.usage else None,
158
+ total_tokens=response.usage.total_tokens if response and response.usage else None,
159
+ latency_ms=latency_ms,
160
+ attempts=attempts,
161
+ success=error is None,
162
+ error=error,
163
+ )
164
+ try:
165
+ self._usage.insert(rec)
166
+ except Exception:
167
+ logger.warning("Failed to record model usage", exc_info=True)
168
+
169
+ async def generate(
170
+ self,
171
+ agent: str,
172
+ *,
173
+ messages: list[Message],
174
+ system: str | None = None,
175
+ tools: list[dict[str, Any]] | None = None,
176
+ tool_choice: str | None = None,
177
+ response_format: dict[str, str] | None = None,
178
+ temperature: float | None = None,
179
+ max_tokens: int | None = None,
180
+ run_id: str | None = None,
181
+ ) -> ModelResponse:
182
+ primary = self._resolve_binding(agent)
183
+ req = self._build_request(
184
+ primary,
185
+ messages,
186
+ system=system,
187
+ tools=tools,
188
+ tool_choice=tool_choice,
189
+ response_format=response_format,
190
+ temperature=temperature,
191
+ max_tokens=max_tokens,
192
+ )
193
+
194
+ start = time.perf_counter()
195
+ try:
196
+ resp = await primary.provider.generate(req)
197
+ self._record(primary, run_id=run_id, latency_ms=(time.perf_counter() - start) * 1000, response=resp, attempts=resp.attempts)
198
+ return resp
199
+ except Exception as primary_exc:
200
+ latency = (time.perf_counter() - start) * 1000
201
+ self._record(primary, run_id=run_id, latency_ms=latency, error=f"{type(primary_exc).__name__}: {primary_exc}", attempts=getattr(primary_exc, "attempts", 1))
202
+
203
+ # Fallback chain (§30)
204
+ if self._config.fallback.enabled and self._config.fallback.providers:
205
+ for fb_name in self._config.fallback.providers:
206
+ if fb_name == primary.provider_name:
207
+ continue
208
+ fb_cfg = self._config.providers.get(fb_name)
209
+ if not fb_cfg or not fb_cfg.default_model:
210
+ continue
211
+ fb_binding = ModelBinding(
212
+ agent=agent,
213
+ provider_name=fb_name,
214
+ model=fb_cfg.default_model,
215
+ provider=self._provider(fb_name),
216
+ capabilities=self._registry.lookup(fb_cfg.default_model),
217
+ )
218
+ fb_req = self._build_request(
219
+ fb_binding,
220
+ messages,
221
+ system=system,
222
+ tools=tools,
223
+ tool_choice=tool_choice,
224
+ response_format=response_format,
225
+ temperature=temperature,
226
+ max_tokens=max_tokens,
227
+ )
228
+ fb_start = time.perf_counter()
229
+ try:
230
+ logger.warning("Primary provider %s failed; falling back to %s", primary.provider_name, fb_name)
231
+ fb_resp = await fb_binding.provider.generate(fb_req)
232
+ self._record(fb_binding, run_id=run_id, latency_ms=(time.perf_counter() - fb_start) * 1000, response=fb_resp, attempts=fb_resp.attempts)
233
+ return fb_resp
234
+ except Exception as fb_exc:
235
+ self._record(fb_binding, run_id=run_id, latency_ms=(time.perf_counter() - fb_start) * 1000, error=f"{type(fb_exc).__name__}: {fb_exc}", attempts=getattr(fb_exc, "attempts", 1))
236
+ continue
237
+
238
+ raise primary_exc
239
+
240
+ async def generate_with(
241
+ self,
242
+ *,
243
+ provider: str,
244
+ model: str,
245
+ agent: str,
246
+ messages: list[Message],
247
+ system: str | None = None,
248
+ tools: list[dict[str, Any]] | None = None,
249
+ tool_choice: str | None = None,
250
+ response_format: dict[str, str] | None = None,
251
+ temperature: float | None = None,
252
+ max_tokens: int | None = None,
253
+ run_id: str | None = None,
254
+ ) -> ModelResponse:
255
+ """Generate against an EXPLICIT provider/model binding with NO fallback."""
256
+ binding = self.binding_for(provider, model)
257
+ req = self._build_request(
258
+ binding,
259
+ messages,
260
+ system=system,
261
+ tools=tools,
262
+ tool_choice=tool_choice,
263
+ response_format=response_format,
264
+ temperature=temperature,
265
+ max_tokens=max_tokens,
266
+ )
267
+ start = time.perf_counter()
268
+ try:
269
+ resp = await binding.provider.generate(req)
270
+ self._record(binding, run_id=run_id, latency_ms=(time.perf_counter() - start) * 1000, response=resp, attempts=resp.attempts)
271
+ return resp
272
+ except Exception as exc:
273
+ self._record(binding, run_id=run_id, latency_ms=(time.perf_counter() - start) * 1000, error=f"{type(exc).__name__}: {exc}", attempts=getattr(exc, "attempts", 1))
274
+ raise
275
+
276
+ async def stream(
277
+ self,
278
+ agent: str,
279
+ *,
280
+ messages: list[Message],
281
+ system: str | None = None,
282
+ temperature: float | None = None,
283
+ ) -> AsyncIterator[str]:
284
+ binding = self._resolve_binding(agent)
285
+ req = ModelRequest(
286
+ model=binding.model,
287
+ messages=messages,
288
+ system=system,
289
+ temperature=temperature,
290
+ )
291
+ async for chunk in binding.provider.stream(req):
292
+ yield chunk
293
+
294
+ def health_all(self) -> dict[str, ProviderHealth]:
295
+ results: dict[str, ProviderHealth] = {}
296
+ for name in self._config.providers:
297
+ try:
298
+ prov = self._provider(name)
299
+ # Run async health in an event loop or thread
300
+ try:
301
+ loop = asyncio.get_running_loop()
302
+ except RuntimeError:
303
+ loop = None
304
+ if loop and loop.is_running():
305
+ import concurrent.futures
306
+
307
+ def _run_health(p: ModelProvider = prov) -> ProviderHealth:
308
+ return asyncio.run(p.health())
309
+
310
+ with concurrent.futures.ThreadPoolExecutor() as pool:
311
+ health = pool.submit(_run_health).result()
312
+ else:
313
+ health = asyncio.run(prov.health())
314
+ results[name] = health
315
+ except (ConfigError, ProviderError) as exc:
316
+ results[name] = ProviderHealth(provider=name, ok=False, detail=str(exc))
317
+ except Exception as exc: # noqa: BLE001
318
+ results[name] = ProviderHealth(provider=name, ok=False, detail=str(exc))
319
+ return results
320
+
321
+ async def aclose(self) -> None:
322
+ for p in self._providers.values():
323
+ await p.aclose()
324
+ self._providers.clear()
325
+
326
+
327
+ def collect_health(config: ForgexConfig) -> dict[str, ProviderHealth]:
328
+ router = ModelRouter(config)
329
+ try:
330
+ return router.health_all()
331
+ finally:
332
+ asyncio.run(router.aclose())
@@ -0,0 +1,36 @@
1
+ """Orchestration: state persistence, events, workflow rules, and engine."""
2
+
3
+ from forgex.orchestration.checkpoints import (
4
+ create_checkpoint,
5
+ get_checkpoint,
6
+ latest_checkpoint,
7
+ list_checkpoints,
8
+ rollback_to,
9
+ )
10
+ from forgex.orchestration.events import EventRecorder, read_events
11
+ from forgex.orchestration.lockfile import RunLock
12
+ from forgex.orchestration.retry import RetryPolicy, is_retryable, run_with_retries
13
+ from forgex.orchestration.state import StateStore, TaskStore
14
+ from forgex.orchestration.stores import ProjectStores, open_stores
15
+ from forgex.orchestration.workflow import ALLOWED_TRANSITIONS, can_transition, transition
16
+
17
+ __all__ = [
18
+ "ALLOWED_TRANSITIONS",
19
+ "EventRecorder",
20
+ "ProjectStores",
21
+ "RetryPolicy",
22
+ "RunLock",
23
+ "StateStore",
24
+ "TaskStore",
25
+ "can_transition",
26
+ "create_checkpoint",
27
+ "get_checkpoint",
28
+ "is_retryable",
29
+ "latest_checkpoint",
30
+ "list_checkpoints",
31
+ "open_stores",
32
+ "read_events",
33
+ "rollback_to",
34
+ "run_with_retries",
35
+ "transition",
36
+ ]
@@ -0,0 +1,117 @@
1
+ """Git checkpoint system (spec section 21).
2
+
3
+ rollback_to: create a SAFETY checkpoint of the current state FIRST, then
4
+ git reset --hard to the target commit — so every rollback is itself
5
+ reversible.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import uuid
12
+
13
+ from forgex.orchestration.stores import ProjectStores
14
+ from forgex.schemas.checkpoint import CheckpointRecord
15
+ from forgex.schemas.error import GitError
16
+ from forgex.schemas.event import EventType
17
+ from forgex.tools.git import git_checkpoint, git_reset_hard, has_git_repo
18
+
19
+ logger = logging.getLogger("forgex.checkpoints")
20
+
21
+
22
+ def _project_id(stores: ProjectStores) -> str:
23
+ state = stores.state.load()
24
+ return state.project_id if state else "unknown"
25
+
26
+
27
+ def create_checkpoint(
28
+ label: str,
29
+ *,
30
+ stores: ProjectStores,
31
+ description: str | None = None,
32
+ run_id: str | None = None,
33
+ ) -> CheckpointRecord | None:
34
+ """Commit current state and record it. Returns None when git is unavailable."""
35
+ if not has_git_repo(stores.root):
36
+ return None
37
+ commit = git_checkpoint(stores.root, label)
38
+ if commit is None:
39
+ logger.warning("checkpoint %r: git commit failed", label)
40
+ return None
41
+ record = CheckpointRecord(
42
+ id=uuid.uuid4().hex[:12],
43
+ project_id=_project_id(stores),
44
+ label=label,
45
+ git_commit=commit,
46
+ description=description,
47
+ run_id=run_id,
48
+ )
49
+ if stores.checkpoints is not None:
50
+ stores.checkpoints.insert(record)
51
+ stores.recorder.record(
52
+ EventType.CHECKPOINT_CREATED,
53
+ {"id": record.id, "label": label, "commit": commit},
54
+ run_id=run_id,
55
+ )
56
+ return record
57
+
58
+
59
+ def get_checkpoint(checkpoint_id: str, *, stores: ProjectStores) -> CheckpointRecord | None:
60
+ if stores.checkpoints is None:
61
+ return None
62
+ return stores.checkpoints.get(checkpoint_id)
63
+
64
+
65
+ def latest_checkpoint(*, stores: ProjectStores) -> CheckpointRecord | None:
66
+ if stores.checkpoints is None:
67
+ return None
68
+ return stores.checkpoints.latest(_project_id(stores))
69
+
70
+
71
+ def list_checkpoints(*, stores: ProjectStores, limit: int = 20) -> list[CheckpointRecord]:
72
+ if stores.checkpoints is None:
73
+ return []
74
+ return stores.checkpoints.list(_project_id(stores), limit)
75
+
76
+
77
+ def find_pre_task_checkpoint(task_id: str, *, stores: ProjectStores) -> CheckpointRecord | None:
78
+ target_label = f"pre-{task_id}"
79
+ for cp in list_checkpoints(stores=stores, limit=100):
80
+ if cp.label == target_label:
81
+ return cp
82
+ return None
83
+
84
+
85
+ def rollback_to(
86
+ record: CheckpointRecord,
87
+ *,
88
+ stores: ProjectStores,
89
+ run_id: str | None = None,
90
+ ) -> CheckpointRecord | None:
91
+ if not record.git_commit:
92
+ raise GitError(f"Checkpoint {record.id} has no git commit hash")
93
+
94
+ # 1. Safety checkpoint of current state
95
+ safety = create_checkpoint(
96
+ f"pre-rollback-{record.id}",
97
+ stores=stores,
98
+ description=f"Safety checkpoint before rollback to {record.id}",
99
+ run_id=run_id,
100
+ )
101
+
102
+ # 2. Reset hard
103
+ ok = git_reset_hard(stores.root, record.git_commit)
104
+ if not ok:
105
+ raise GitError(f"Failed to reset workspace to commit {record.git_commit}")
106
+
107
+ # 3. Record event
108
+ stores.recorder.record(
109
+ EventType.CHECKPOINT_ROLLED_BACK,
110
+ {
111
+ "to": record.id,
112
+ "commit": record.git_commit,
113
+ "safety_checkpoint": safety.id if safety else None,
114
+ },
115
+ run_id=run_id,
116
+ )
117
+ return safety