codeoptix 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 (91) hide show
  1. codeoptix/__init__.py +8 -0
  2. codeoptix/acp/__init__.py +33 -0
  3. codeoptix/acp/agent.py +209 -0
  4. codeoptix/acp/bridge.py +402 -0
  5. codeoptix/acp/client_adapter.py +312 -0
  6. codeoptix/acp/code_extractor.py +125 -0
  7. codeoptix/acp/orchestrator.py +349 -0
  8. codeoptix/acp/registry.py +294 -0
  9. codeoptix/adapters/__init__.py +18 -0
  10. codeoptix/adapters/base.py +50 -0
  11. codeoptix/adapters/basic.py +195 -0
  12. codeoptix/adapters/claude_code.py +218 -0
  13. codeoptix/adapters/codex.py +327 -0
  14. codeoptix/adapters/factory.py +56 -0
  15. codeoptix/adapters/gemini_cli.py +370 -0
  16. codeoptix/artifacts/__init__.py +5 -0
  17. codeoptix/artifacts/manager.py +193 -0
  18. codeoptix/behaviors/__init__.py +45 -0
  19. codeoptix/behaviors/base.py +81 -0
  20. codeoptix/behaviors/insecure_code.py +129 -0
  21. codeoptix/behaviors/plan_drift.py +192 -0
  22. codeoptix/behaviors/vacuous_tests.py +198 -0
  23. codeoptix/cli.py +1472 -0
  24. codeoptix/evaluation/__init__.py +23 -0
  25. codeoptix/evaluation/bloom_integration.py +271 -0
  26. codeoptix/evaluation/engine.py +274 -0
  27. codeoptix/evaluation/evaluators.py +308 -0
  28. codeoptix/evaluation/scenario_generator.py +222 -0
  29. codeoptix/evolution/__init__.py +7 -0
  30. codeoptix/evolution/engine.py +206 -0
  31. codeoptix/evolution/gepa_integration.py +149 -0
  32. codeoptix/evolution/proposer.py +185 -0
  33. codeoptix/linters/__init__.py +13 -0
  34. codeoptix/linters/bandit_linter.py +172 -0
  35. codeoptix/linters/base.py +105 -0
  36. codeoptix/linters/coverage_linter.py +156 -0
  37. codeoptix/linters/flake8_linter.py +156 -0
  38. codeoptix/linters/html_accessibility_linter.py +374 -0
  39. codeoptix/linters/language_detector.py +150 -0
  40. codeoptix/linters/mypy_linter.py +184 -0
  41. codeoptix/linters/pip_audit_linter.py +152 -0
  42. codeoptix/linters/pylint_linter.py +198 -0
  43. codeoptix/linters/ruff_linter.py +206 -0
  44. codeoptix/linters/runner.py +186 -0
  45. codeoptix/linters/safety_linter.py +184 -0
  46. codeoptix/reflection/__init__.py +6 -0
  47. codeoptix/reflection/engine.py +70 -0
  48. codeoptix/reflection/generator.py +209 -0
  49. codeoptix/utils/__init__.py +1 -0
  50. codeoptix/utils/config.py +91 -0
  51. codeoptix/utils/llm.py +332 -0
  52. codeoptix/utils/retry.py +133 -0
  53. codeoptix/vendor/__init__.py +2 -0
  54. codeoptix/vendor/bloom/README.md +26 -0
  55. codeoptix/vendor/bloom/__init__.py +11 -0
  56. codeoptix/vendor/bloom/globals.py +39 -0
  57. codeoptix/vendor/bloom/orchestrators/ConversationOrchestrator.py +450 -0
  58. codeoptix/vendor/bloom/orchestrators/SimEnvOrchestrator.py +839 -0
  59. codeoptix/vendor/bloom/prompts/configurable_prompts/README.md +85 -0
  60. codeoptix/vendor/bloom/prompts/configurable_prompts/default.json +18 -0
  61. codeoptix/vendor/bloom/prompts/configurable_prompts/ideation-default.json +18 -0
  62. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_animal-welfare.json +18 -0
  63. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_contextual-optimism.json +18 -0
  64. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defend-objects.json +18 -0
  65. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_defer-to-users.json +18 -0
  66. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_emotional-bond.json +18 -0
  67. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_flattery.json +18 -0
  68. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_hardcode-test-cases.json +18 -0
  69. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_increasing-pep.json +18 -0
  70. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_research-sandbagging.json +18 -0
  71. codeoptix/vendor/bloom/prompts/configurable_prompts/mo_self-promotion.json +18 -0
  72. codeoptix/vendor/bloom/prompts/configurable_prompts/sandbag.json +18 -0
  73. codeoptix/vendor/bloom/prompts/configurable_prompts/self-preferential-bias.json +18 -0
  74. codeoptix/vendor/bloom/prompts/configurable_prompts/static-prompts.yaml +72 -0
  75. codeoptix/vendor/bloom/prompts/configurable_prompts/web-search.json +18 -0
  76. codeoptix/vendor/bloom/prompts/step1_understanding.py +63 -0
  77. codeoptix/vendor/bloom/prompts/step2_ideation.py +254 -0
  78. codeoptix/vendor/bloom/prompts/step3_rollout.py +120 -0
  79. codeoptix/vendor/bloom/prompts/step4_judgment.py +183 -0
  80. codeoptix/vendor/bloom/schemas/behavior.schema.json +160 -0
  81. codeoptix/vendor/bloom/schemas/conversation.schema.json +51 -0
  82. codeoptix/vendor/bloom/schemas/transcript_schema.json +2225 -0
  83. codeoptix/vendor/bloom/scripts/step2_ideation.py +667 -0
  84. codeoptix/vendor/bloom/scripts/step4_judgment.py +811 -0
  85. codeoptix/vendor/bloom/transcript_utils.py +440 -0
  86. codeoptix/vendor/bloom/utils.py +700 -0
  87. codeoptix-0.1.0.dist-info/METADATA +304 -0
  88. codeoptix-0.1.0.dist-info/RECORD +91 -0
  89. codeoptix-0.1.0.dist-info/WHEEL +4 -0
  90. codeoptix-0.1.0.dist-info/entry_points.txt +2 -0
  91. codeoptix-0.1.0.dist-info/licenses/LICENSE +203 -0
@@ -0,0 +1,440 @@
1
+ import uuid
2
+ import json
3
+ from datetime import datetime
4
+ from typing import Any, Dict, List, Optional
5
+ from pathlib import Path
6
+ import wandb
7
+ # Define the project name directly here
8
+ PROJECT_NAME = "bloom-evals" # Replace with your actual project name
9
+
10
+
11
+ def generate_id() -> str:
12
+ """Generate a unique message ID."""
13
+ return str(uuid.uuid4())
14
+
15
+
16
+ def add_transcript_event(
17
+ transcript_events: List[Dict[str, Any]],
18
+ view: List[str],
19
+ role: str,
20
+ content: Optional[str] = None,
21
+ reasoning: Optional[str] = None,
22
+ model: Optional[str] = None,
23
+ tool_calls: Optional[List] = None,
24
+ tool_call_id: Optional[str] = None,
25
+ error: Optional[Dict[str, Any]] = None,
26
+ source: Optional[str] = None
27
+ ):
28
+ """Add an event to the transcript in v3.0 format (nested with transcript_event wrapper).
29
+
30
+ Args:
31
+ transcript_events: List to append events to
32
+ view: List of views this event applies to (e.g., ["target"], ["evaluator"], ["combined"])
33
+ role: Message role - "system", "user", "assistant", or "tool"
34
+ content: Main content of the message (optional for certain cases)
35
+ reasoning: Reasoning/thinking content (will be added as separate content block)
36
+ model: Model that generated this message (for assistant messages)
37
+ tool_calls: List of tool calls (for assistant messages)
38
+ tool_call_id: ID for tool response messages
39
+ error: Error information for tool messages
40
+ source: Source of the message ("input" or "generate")
41
+ """
42
+ message = {
43
+ "role": role,
44
+ "id": generate_id(),
45
+ }
46
+
47
+ # Build content array based on what's provided
48
+ content_array = []
49
+
50
+ # Add reasoning block first if present
51
+ if reasoning:
52
+ content_array.append({
53
+ "type": "reasoning",
54
+ "reasoning": reasoning
55
+ })
56
+
57
+ # Add main text content if present
58
+ if content is not None:
59
+ content_array.append({
60
+ "type": "text",
61
+ "text": content
62
+ })
63
+
64
+ # Set content based on what we have
65
+ if len(content_array) == 1 and content_array[0]["type"] == "text":
66
+ # If only text content, use simple string format
67
+ message["content"] = content_array[0]["text"]
68
+ elif content_array:
69
+ # Use array format for multiple content blocks or reasoning
70
+ message["content"] = content_array
71
+ else:
72
+ # Default to empty string if no content
73
+ message["content"] = ""
74
+
75
+ # Add optional fields based on role and parameters
76
+ if model:
77
+ message["model"] = model
78
+
79
+ if source:
80
+ message["source"] = source
81
+
82
+ if tool_calls:
83
+ message["tool_calls"] = tool_calls
84
+
85
+ if role == "tool":
86
+ if tool_call_id:
87
+ message["tool_call_id"] = tool_call_id
88
+ else:
89
+ # Tool messages require tool_call_id
90
+ print(f"Warning: tool message missing required tool_call_id")
91
+ message["tool_call_id"] = generate_id()
92
+
93
+ if error:
94
+ message["error"] = error
95
+
96
+ event = {
97
+ "type": "transcript_event",
98
+ "id": generate_id(),
99
+ "timestamp": datetime.now().isoformat(),
100
+ "view": view,
101
+ "edit": {
102
+ "operation": "add",
103
+ "message": message
104
+ }
105
+ }
106
+
107
+ transcript_events.append(event)
108
+
109
+
110
+
111
+
112
+
113
+ def save_transcript(output_path: Path, variation_number: int, repetition_number: int, transcript_events: List[Dict[str, Any]], transcript_metadata: Optional[Dict[str, Any]] = None):
114
+ """Save transcript events to a file with the specified naming pattern."""
115
+ # Ensure the output directory exists
116
+ output_path.mkdir(parents=True, exist_ok=True)
117
+
118
+ # Get the current run name from bloom module
119
+ try:
120
+ from bloom import get_current_run_name
121
+ run_name = get_current_run_name()
122
+ if run_name:
123
+ # Create filename with run name: transcript_{run_name}_scenario{variation_number}-rep{repetition_number}
124
+ filename = f"transcript_{run_name}_scenario{variation_number}-rep{repetition_number}.json"
125
+ else:
126
+ # Fallback to old naming if no run name available
127
+ filename = f"transcript_{variation_number}_{repetition_number}.json"
128
+ except ImportError:
129
+ # Fallback to old naming if bloom module not available
130
+ filename = f"transcript_{variation_number}_{repetition_number}.json"
131
+
132
+ file_path = output_path / filename
133
+
134
+ # Create the full transcript object with metadata and events
135
+ if transcript_metadata:
136
+ transcript = get_transcript_format(transcript_metadata, transcript_events)
137
+ else:
138
+ # If no metadata provided, just save events in old format
139
+ transcript = transcript_events
140
+
141
+ # Save the full transcript
142
+ with open(file_path, 'w', encoding='utf-8') as f:
143
+ json.dump(transcript, f, indent=2, ensure_ascii=False)
144
+
145
+ print(f"📝 Transcript saved to: {file_path}")
146
+ return str(file_path)
147
+
148
+
149
+ def init_transcript_metadata(transcript_id: str, evaluator_model: str, target_model: str) -> Dict[str, Any]:
150
+ """Initialize transcript metadata in the new format (v3.0)."""
151
+ current_time = datetime.now()
152
+ return {
153
+ "transcript_id": transcript_id,
154
+ "auditor_model": evaluator_model, # Map evaluator to auditor for compatibility
155
+ "target_model": target_model,
156
+ "created_at": current_time.isoformat(),
157
+ "updated_at": current_time.isoformat(),
158
+ "version": "v3.0",
159
+ "description": "Conversation orchestrator rollout"
160
+ }
161
+
162
+
163
+ def get_transcript_format(transcript_metadata: Dict[str, Any], transcript_events: List[Dict[str, Any]]) -> Dict[str, Any]:
164
+ """Get the transcript in the new format."""
165
+ # Update metadata timestamp
166
+ transcript_metadata["updated_at"] = datetime.now().isoformat()
167
+
168
+ return {
169
+ "metadata": transcript_metadata,
170
+ "events": transcript_events
171
+ }
172
+
173
+
174
+ def load_transcript(transcript_path: Path) -> Dict[str, Any]:
175
+ """Load a transcript from a file."""
176
+ with open(transcript_path, 'r', encoding='utf-8') as f:
177
+ data = json.load(f)
178
+ # Handle both old format (just events array) and new format (with metadata)
179
+ if isinstance(data, list):
180
+ # Old format - just events
181
+ return {"events": data}
182
+ return data
183
+
184
+
185
+ def append_judge_output_to_transcript(transcript_path: Path, judge_output: Dict[str, Any]):
186
+ """Append judge output to transcript metadata and save."""
187
+ # Load existing transcript
188
+ transcript = load_transcript(transcript_path)
189
+
190
+ # Initialize metadata if not present
191
+ if "metadata" not in transcript:
192
+ transcript["metadata"] = {}
193
+
194
+ # Add judge output to metadata
195
+ transcript["metadata"]["judge_output"] = judge_output
196
+ transcript["metadata"]["updated_at"] = datetime.now().isoformat()
197
+
198
+ # Save updated transcript
199
+ with open(transcript_path, 'w', encoding='utf-8') as f:
200
+ json.dump(transcript, f, indent=2, ensure_ascii=False)
201
+
202
+ print(f"📝 Judge output appended to: {transcript_path}")
203
+
204
+
205
+ def format_transcript_for_judge(transcript_events: List[Dict[str, Any]], redaction_tags: Optional[str] = None) -> str:
206
+ """Format transcript events for judge, showing the full conversation from the target's view.
207
+
208
+ Args:
209
+ transcript_events: List of transcript events
210
+ redaction_tags: Optional tag name for redacting content. Content between <TAG>...</TAG> will be removed.
211
+ """
212
+ formatted_lines = []
213
+
214
+ # First, find the target system prompt (it's in the combined or target view)
215
+ system_prompt = None
216
+ for event in transcript_events:
217
+ view = event.get('view', [])
218
+ message = event.get('edit', {}).get('message', {})
219
+
220
+ # Check for both 'role' (new v3.0 format) and 'type' (old format)
221
+ msg_role = message.get('role') or message.get('type', '')
222
+
223
+ if ('target' in view and msg_role == 'system'):
224
+ content = message.get('content', '')
225
+ # Handle content that might be array of content blocks
226
+ if isinstance(content, list):
227
+ text_parts = []
228
+ for block in content:
229
+ if block.get('type') == 'text':
230
+ text_parts.append(block.get('text', ''))
231
+ system_prompt = ' '.join(text_parts)
232
+ else:
233
+ system_prompt = content
234
+ break
235
+
236
+ # Add system prompt at the beginning if found
237
+ if system_prompt:
238
+ formatted_lines.append("=== SYSTEM PROMPT ===")
239
+ formatted_lines.append(system_prompt)
240
+ formatted_lines.append("")
241
+ formatted_lines.append("=== CONVERSATION ===")
242
+ formatted_lines.append("")
243
+
244
+ # Filter events that have 'target' in their view (what the target sees)
245
+ target_events = [
246
+ event for event in transcript_events
247
+ if 'target' in event.get('view', [])
248
+ ]
249
+
250
+ # Sort by timestamp to ensure chronological order
251
+ target_events.sort(key=lambda x: x.get('timestamp', ''))
252
+
253
+ for event in target_events:
254
+ message = event.get('edit', {}).get('message', {})
255
+ # Support both 'role' (v3.0) and 'type' (old format)
256
+ role = message.get('role') or message.get('type', '')
257
+ content = message.get('content', '')
258
+
259
+ # Handle content that might be array of content blocks
260
+ reasoning_content = None
261
+ if isinstance(content, list):
262
+ # Extract text and reasoning from content blocks
263
+ text_parts = []
264
+ for block in content:
265
+ if block.get('type') == 'text':
266
+ text_parts.append(block.get('text', ''))
267
+ elif block.get('type') == 'reasoning':
268
+ # Only extract reasoning from assistant (target model), not from user (evaluator)
269
+ if role == 'assistant':
270
+ reasoning_content = block.get('reasoning', '')
271
+ content = ' '.join(text_parts)
272
+
273
+ # Skip empty content
274
+ if not content or (isinstance(content, str) and not content.strip()):
275
+ # But don't skip if there's reasoning to show
276
+ if not reasoning_content:
277
+ continue
278
+
279
+ # Skip system messages (already handled above)
280
+ if role == 'system':
281
+ continue
282
+
283
+ # Format based on role - using consistent format for all transcript types
284
+ if role == 'user':
285
+ formatted_lines.append(f"USER (EVALUATOR): {content}")
286
+ elif role == 'assistant':
287
+ # Show target's thinking first if present
288
+ if reasoning_content and reasoning_content.strip():
289
+ formatted_lines.append(f"TARGET THINKING: {reasoning_content}")
290
+ formatted_lines.append("") # Empty line for readability
291
+
292
+ # Check if this has tool calls (check both message level and event level for compatibility)
293
+ tool_calls = message.get('tool_calls', []) or event.get('tool_calls', [])
294
+ if tool_calls:
295
+ # Show the target's response content first if present
296
+ if content and content.strip() and content != "No response.":
297
+ formatted_lines.append(f"ASSISTANT: {content}")
298
+
299
+ # Show each tool call
300
+ for tool_call in tool_calls:
301
+ # Handle both formats: {"name": "...", "args": {...}} and {"function": "...", "arguments": {...}}
302
+ tool_name = tool_call.get('name') or tool_call.get('function', 'unknown_tool')
303
+ tool_args = tool_call.get('args') or tool_call.get('arguments', {})
304
+ # Convert args dict to JSON string for display
305
+ if isinstance(tool_args, dict):
306
+ tool_args_str = json.dumps(tool_args, ensure_ascii=False)
307
+ else:
308
+ tool_args_str = str(tool_args)
309
+ formatted_lines.append(f"ASSISTANT: [CALLING {tool_name}] {tool_args_str}")
310
+ else:
311
+ # Regular response without tool calls
312
+ if content and content.strip():
313
+ formatted_lines.append(f"ASSISTANT: {content}")
314
+ elif role == 'tool':
315
+ # Tool response - extract tool name from tool_call_id if available
316
+ tool_call_id = message.get('tool_call_id', '')
317
+ if tool_call_id and '_' in tool_call_id:
318
+ # Extract tool name from tool_call_id (e.g., "tool_0_document_analyzer" -> "document_analyzer")
319
+ tool_name = tool_call_id.split('_', 2)[-1] if tool_call_id.count('_') >= 2 else tool_call_id
320
+ else:
321
+ tool_name = message.get('name', 'unknown_tool')
322
+ formatted_lines.append(f"(EVALUATOR-SIMULATED) TOOL RESPONSE [{tool_name}]: {content}")
323
+
324
+ formatted_lines.append("") # Empty line for readability
325
+
326
+ # Join the formatted lines
327
+ formatted_transcript = "\n".join(formatted_lines).strip()
328
+
329
+ # Apply redaction if tags are specified
330
+ if redaction_tags:
331
+ import re
332
+ # Pattern to match <TAG>...</TAG> (opening and closing with same tag name)
333
+ pattern = rf'<{re.escape(redaction_tags)}>.*?</{re.escape(redaction_tags)}>'
334
+ formatted_transcript = re.sub(pattern, '', formatted_transcript, flags=re.DOTALL)
335
+
336
+ return formatted_transcript
337
+
338
+
339
+ def download_artifact_file(run_id: str, artifact_name: str, output_dir: Path) -> None:
340
+ """Download a specific artifact file for a given run ID to the specified output directory."""
341
+ api = wandb.Api()
342
+ run = api.run(f"{PROJECT_NAME}/{run_id}")
343
+ artifact = run.use_artifact(artifact_name)
344
+ artifact_dir = artifact.download(root=output_dir)
345
+ print(f"Artifact {artifact_name} downloaded to {artifact_dir}")
346
+
347
+
348
+ def retrieve_artifacts_for_resume(resume_run_id: str, resume_stage: str, example_name: str) -> Dict[str, Any]:
349
+ """
350
+ Retrieve and download artifacts from a previous run for resuming.
351
+
352
+ Args:
353
+ resume_run_id: WandB run ID to resume from
354
+ resume_stage: Stage to resume from ("ideation", "variation", "rollout", "judgment")
355
+ example_name: Example name for organizing results
356
+
357
+ Returns:
358
+ Dictionary with paths to downloaded artifacts
359
+ """
360
+ if not wandb:
361
+ raise ImportError("wandb is required for resume functionality")
362
+
363
+ # Map stages to their dependencies (stages that need to be completed before this stage)
364
+ stage_dependencies = {
365
+ "ideation": ["understanding"],
366
+ "rollout": ["understanding", "ideation"],
367
+ "judgment": ["understanding", "ideation", "rollout"]
368
+ }
369
+
370
+ if resume_stage not in stage_dependencies:
371
+ raise ValueError(f"Invalid resume_stage: {resume_stage}. Must be one of: {list(stage_dependencies.keys())}")
372
+
373
+ # Get list of stages we need to retrieve artifacts for
374
+ required_stages = stage_dependencies[resume_stage]
375
+
376
+ print(f"🔄 Resuming from run {resume_run_id} at stage '{resume_stage}'")
377
+ print(f"📥 Need to retrieve artifacts from stages: {required_stages}")
378
+
379
+ try:
380
+ api = wandb.Api()
381
+ run = api.run(f"{PROJECT_NAME}/{resume_run_id}")
382
+
383
+ # Get the results directory where we'll place the artifacts
384
+ from utils import get_results_dir
385
+ results_dir = get_results_dir(example_name)
386
+ results_dir.mkdir(parents=True, exist_ok=True)
387
+
388
+ retrieved_artifacts = {}
389
+
390
+ # Look for artifacts from the run
391
+ artifacts = run.logged_artifacts()
392
+
393
+ for artifact in artifacts:
394
+ artifact_name = artifact.name
395
+ artifact_type = artifact.type
396
+
397
+ print(f"📦 Found artifact: {artifact_name} (type: {artifact_type})")
398
+
399
+ # Download rollout results artifacts which contain all stage outputs
400
+ if artifact_type == "rollout_results":
401
+ print(f"📥 Downloading rollout results artifact: {artifact_name}")
402
+ artifact_dir = artifact.download(root=results_dir / "downloaded_artifacts")
403
+
404
+ # Copy the required stage files to the main results directory
405
+ artifact_path = Path(artifact_dir)
406
+
407
+ for stage in required_stages:
408
+ stage_file = artifact_path / f"{stage}.json"
409
+ if stage_file.exists():
410
+ import shutil
411
+ dest_file = results_dir / f"{stage}.json"
412
+ shutil.copy2(stage_file, dest_file)
413
+ retrieved_artifacts[stage] = str(dest_file)
414
+ print(f"✅ Retrieved {stage}.json")
415
+ else:
416
+ print(f"⚠️ {stage}.json not found in artifact")
417
+
418
+ # Also copy any transcript files if we're resuming from rollout
419
+ if resume_stage == "judgment":
420
+ transcript_files = list(artifact_path.glob("transcript_*.json"))
421
+ for transcript_file in transcript_files:
422
+ import shutil
423
+ dest_file = results_dir / transcript_file.name
424
+ shutil.copy2(transcript_file, dest_file)
425
+ print(f"✅ Retrieved {transcript_file.name}")
426
+
427
+ if transcript_files:
428
+ retrieved_artifacts["transcripts"] = [str(results_dir / f.name) for f in transcript_files]
429
+
430
+ # Verify we got all required artifacts
431
+ missing_stages = [stage for stage in required_stages if stage not in retrieved_artifacts]
432
+ if missing_stages:
433
+ raise RuntimeError(f"Could not retrieve artifacts for stages: {missing_stages}")
434
+
435
+ print(f"✅ Successfully retrieved artifacts for resume from stage '{resume_stage}'")
436
+ return retrieved_artifacts
437
+
438
+ except Exception as e:
439
+ print(f"❌ Failed to retrieve artifacts from run {resume_run_id}: {e}")
440
+ raise