ostruct-cli 0.8.2__py3-none-any.whl → 0.8.3__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.
@@ -0,0 +1,29 @@
1
+ """Sentinel JSON extraction utility for two-pass Code Interpreter workaround."""
2
+
3
+ import json
4
+ import re
5
+ from typing import Any, Dict, Optional
6
+
7
+ _SENT_RE = re.compile(r"===BEGIN_JSON===\s*(\{.*?})\s*===END_JSON===", re.S)
8
+
9
+
10
+ def extract_json_block(text: str) -> Optional[Dict[str, Any]]:
11
+ """Extract JSON block from sentinel markers in text.
12
+
13
+ Args:
14
+ text: Response text that may contain sentinel-wrapped JSON
15
+
16
+ Returns:
17
+ Parsed JSON dict if found, None otherwise
18
+ """
19
+ m = _SENT_RE.search(text)
20
+ if not m:
21
+ return None
22
+ try:
23
+ result = json.loads(m.group(1))
24
+ # Ensure we return a dict, not other JSON types
25
+ if isinstance(result, dict):
26
+ return result
27
+ return None
28
+ except json.JSONDecodeError:
29
+ return None
@@ -251,19 +251,15 @@ class TemplateOptimizer:
251
251
  if match:
252
252
  return match.group(1)
253
253
 
254
- # Pattern 2: variable.content where variable is likely a file
254
+ # Pattern 2: variable.content - handle ALL single-file variables
255
255
  match = re.search(r"(\w+)\.content", content_expr)
256
256
  if match:
257
257
  var_name = match.group(1)
258
258
  # Skip if this is a loop variable
259
259
  if var_name in loop_variables:
260
260
  return None
261
- # Common file variable patterns
262
- if any(
263
- keyword in var_name.lower()
264
- for keyword in ["file", "config", "data", "script", "code"]
265
- ):
266
- return f"${var_name}" # Use variable name as placeholder
261
+ # Return placeholder for ANY .content access to prevent directory misclassification
262
+ return f"${var_name}"
267
263
 
268
264
  # Pattern 3: files['filename'].content
269
265
  match = re.search(
@@ -374,6 +370,14 @@ class TemplateOptimizer:
374
370
  if dir_var in loop_variables:
375
371
  return full_match
376
372
 
373
+ # Skip if this is a single-file variable accessed via .content
374
+ # These should be handled by the file pattern, not directory pattern
375
+ if content_expr.endswith(".content"):
376
+ # Check if this would be handled by _extract_file_path
377
+ file_path = self._extract_file_path(content_expr, loop_variables)
378
+ if file_path:
379
+ return full_match # Let file pattern handle it
380
+
377
381
  reference = f"the files and subdirectories in <dir:{dir_var}>"
378
382
  self.dir_references[dir_var] = reference
379
383
  self.optimization_log.append(
@@ -23,6 +23,7 @@ from .errors import (
23
23
  VariableNameError,
24
24
  )
25
25
  from .explicit_file_processor import ProcessingResult
26
+ from .file_info import FileRoutingIntent
26
27
  from .file_utils import FileInfoList, collect_files
27
28
  from .path_utils import validate_path_mapping
28
29
  from .security import SecurityManager
@@ -263,6 +264,31 @@ def process_system_prompt(
263
264
  )
264
265
  base_prompt += web_search_instructions
265
266
 
267
+ # Add Code Interpreter download instructions if CI is enabled and auto_download is true
268
+ code_interpreter_enabled = template_context.get(
269
+ "code_interpreter_enabled", False
270
+ )
271
+ auto_download_enabled = template_context.get("auto_download_enabled", True)
272
+
273
+ if code_interpreter_enabled and auto_download_enabled:
274
+ ci_download_instructions = (
275
+ "\n\nWhen using Code Interpreter to create files, always include markdown download links "
276
+ "in your response using this exact format: [Download filename.ext](sandbox:/mnt/data/filename.ext). "
277
+ "This enables automatic file download for the user. Include the download link immediately "
278
+ "after creating each file."
279
+ )
280
+
281
+ # Add sentinel instructions for two-pass mode
282
+ ci_config = template_context.get("code_interpreter_config", {})
283
+ if ci_config.get("download_strategy") == "two_pass_sentinel":
284
+ ci_download_instructions += (
285
+ "\n\nAfter saving your files and printing the download links, output "
286
+ "your JSON response between exactly these markers:\n"
287
+ "===BEGIN_JSON===\n{ ... }\n===END_JSON===\n"
288
+ )
289
+
290
+ base_prompt += ci_download_instructions
291
+
266
292
  return base_prompt
267
293
 
268
294
 
@@ -779,25 +805,44 @@ async def create_template_context_from_routing(
779
805
  "file-search", []
780
806
  )
781
807
 
782
- # Convert to the format expected by create_template_context
783
- # For legacy compatibility, we need (name, path) tuples
784
- files_tuples = []
785
- seen_files = set() # Track files to avoid duplicates
808
+ # Intent mapping is implemented directly in the file categorization logic below
809
+ # This ensures files from different CLI flags get the correct routing intent
810
+
811
+ # Process files by intent groups instead of lumping them all together
812
+ files_dict = {}
786
813
 
787
- # Add template files - now single-argument auto-naming only
814
+ # Group files by their intent
815
+ files_by_intent: Dict[FileRoutingIntent, List[Tuple[str, str]]] = {
816
+ FileRoutingIntent.TEMPLATE_ONLY: [],
817
+ FileRoutingIntent.CODE_INTERPRETER: [],
818
+ FileRoutingIntent.FILE_SEARCH: [],
819
+ }
820
+
821
+ dirs_by_intent: Dict[FileRoutingIntent, List[Tuple[str, str]]] = {
822
+ FileRoutingIntent.TEMPLATE_ONLY: [],
823
+ FileRoutingIntent.CODE_INTERPRETER: [],
824
+ FileRoutingIntent.FILE_SEARCH: [],
825
+ }
826
+
827
+ # Track processed files to avoid duplicates between CLI args and routing result
828
+ seen_files: Set[str] = set()
829
+
830
+ # Categorize files by their source and assign appropriate intent
831
+ # Template files from CLI args
788
832
  template_file_paths = args.get("template_files", [])
789
833
  for template_file_path in template_file_paths:
790
834
  if isinstance(template_file_path, (str, Path)):
791
- # Auto-generate name for single-arg form: -ft config.yaml
792
835
  file_name = _generate_template_variable_name(
793
836
  str(template_file_path)
794
837
  )
795
838
  file_path_str = str(template_file_path)
796
839
  if file_path_str not in seen_files:
797
- files_tuples.append((file_name, file_path_str))
840
+ files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
841
+ (file_name, file_path_str)
842
+ )
798
843
  seen_files.add(file_path_str)
799
844
 
800
- # Add template file aliases (from --fta) - two-argument explicit naming
845
+ # Template file aliases from CLI args
801
846
  template_file_aliases = args.get("template_file_aliases", [])
802
847
  for name_path_tuple in template_file_aliases:
803
848
  if (
@@ -805,51 +850,26 @@ async def create_template_context_from_routing(
805
850
  and len(name_path_tuple) == 2
806
851
  ):
807
852
  custom_name, file_path_raw = name_path_tuple
808
- file_path = str(file_path_raw)
809
- file_name = str(
810
- custom_name
811
- ) # Always use custom name for aliases
812
-
813
- if file_path not in seen_files:
814
- files_tuples.append((file_name, file_path))
815
- seen_files.add(file_path)
816
-
817
- # Also process template_files from routing result (for compatibility)
818
- for template_file_item in template_files:
819
- if isinstance(template_file_item, (str, Path)):
820
- file_name = _generate_template_variable_name(
821
- str(template_file_item)
822
- )
823
- file_path_str = str(template_file_item)
853
+ file_path_str = str(file_path_raw)
824
854
  if file_path_str not in seen_files:
825
- files_tuples.append((file_name, file_path_str))
855
+ files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
856
+ (str(custom_name), file_path_str)
857
+ )
826
858
  seen_files.add(file_path_str)
827
- elif (
828
- isinstance(template_file_item, tuple)
829
- and len(template_file_item) == 2
830
- ):
831
- # Handle tuple format (name, path)
832
- _, template_file_path = template_file_item
833
- template_file_path_str = str(template_file_path)
834
- file_name = _generate_template_variable_name(
835
- template_file_path_str
836
- )
837
- if template_file_path_str not in seen_files:
838
- files_tuples.append((file_name, template_file_path_str))
839
- seen_files.add(template_file_path_str)
840
859
 
841
- # Add code interpreter files - now single-argument auto-naming only
860
+ # Code interpreter files from CLI args
842
861
  code_interpreter_file_paths = args.get("code_interpreter_files", [])
843
862
  for ci_file_path in code_interpreter_file_paths:
844
863
  if isinstance(ci_file_path, (str, Path)):
845
- # Auto-generate name: -fc data.csv
846
864
  file_name = _generate_template_variable_name(str(ci_file_path))
847
865
  file_path_str = str(ci_file_path)
848
866
  if file_path_str not in seen_files:
849
- files_tuples.append((file_name, file_path_str))
867
+ files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
868
+ (file_name, file_path_str)
869
+ )
850
870
  seen_files.add(file_path_str)
851
871
 
852
- # Add code interpreter file aliases (from --fca) - two-argument explicit naming
872
+ # Code interpreter file aliases from CLI args
853
873
  code_interpreter_file_aliases = args.get(
854
874
  "code_interpreter_file_aliases", []
855
875
  )
@@ -859,44 +879,26 @@ async def create_template_context_from_routing(
859
879
  and len(name_path_tuple) == 2
860
880
  ):
861
881
  custom_name, file_path_raw = name_path_tuple
862
- file_path = str(file_path_raw)
863
- file_name = str(
864
- custom_name
865
- ) # Always use custom name for aliases
866
-
867
- if file_path not in seen_files:
868
- files_tuples.append((file_name, file_path))
869
- seen_files.add(file_path)
870
-
871
- # Also process code_interpreter_files from routing result (for compatibility)
872
- for ci_file_item in code_interpreter_files:
873
- if isinstance(ci_file_item, (str, Path)):
874
- file_name = _generate_template_variable_name(str(ci_file_item))
875
- file_path_str = str(ci_file_item)
882
+ file_path_str = str(file_path_raw)
876
883
  if file_path_str not in seen_files:
877
- files_tuples.append((file_name, file_path_str))
884
+ files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
885
+ (str(custom_name), file_path_str)
886
+ )
878
887
  seen_files.add(file_path_str)
879
- elif isinstance(ci_file_item, tuple) and len(ci_file_item) == 2:
880
- # Handle tuple format (name, path)
881
- _, ci_file_path = ci_file_item
882
- ci_file_path_str = str(ci_file_path)
883
- file_name = _generate_template_variable_name(ci_file_path_str)
884
- if ci_file_path_str not in seen_files:
885
- files_tuples.append((file_name, ci_file_path_str))
886
- seen_files.add(ci_file_path_str)
887
888
 
888
- # Add file search files - now single-argument auto-naming only
889
+ # File search files from CLI args
889
890
  file_search_file_paths = args.get("file_search_files", [])
890
891
  for fs_file_path in file_search_file_paths:
891
892
  if isinstance(fs_file_path, (str, Path)):
892
- # Auto-generate name: -fs docs.pdf
893
893
  file_name = _generate_template_variable_name(str(fs_file_path))
894
894
  file_path_str = str(fs_file_path)
895
895
  if file_path_str not in seen_files:
896
- files_tuples.append((file_name, file_path_str))
896
+ files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
897
+ (file_name, file_path_str)
898
+ )
897
899
  seen_files.add(file_path_str)
898
900
 
899
- # Add file search file aliases (from --fsa) - two-argument explicit naming
901
+ # File search file aliases from CLI args
900
902
  file_search_file_aliases = args.get("file_search_file_aliases", [])
901
903
  for name_path_tuple in file_search_file_aliases:
902
904
  if (
@@ -904,79 +906,131 @@ async def create_template_context_from_routing(
904
906
  and len(name_path_tuple) == 2
905
907
  ):
906
908
  custom_name, file_path_raw = name_path_tuple
907
- file_path = str(file_path_raw)
908
- file_name = str(
909
- custom_name
910
- ) # Always use custom name for aliases
909
+ file_path_str = str(file_path_raw)
910
+ if file_path_str not in seen_files:
911
+ files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
912
+ (str(custom_name), file_path_str)
913
+ )
914
+ seen_files.add(file_path_str)
915
+
916
+ # Process files from routing result and map to their proper intents
917
+ # Only add files that haven't been processed from CLI args
918
+ for template_file_item in template_files:
919
+ if isinstance(template_file_item, (str, Path)):
920
+ file_name = _generate_template_variable_name(
921
+ str(template_file_item)
922
+ )
923
+ file_path_str = str(template_file_item)
924
+ if file_path_str not in seen_files:
925
+ files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
926
+ (file_name, file_path_str)
927
+ )
928
+ seen_files.add(file_path_str)
929
+ elif (
930
+ isinstance(template_file_item, tuple)
931
+ and len(template_file_item) == 2
932
+ ):
933
+ _, template_file_path = template_file_item
934
+ file_path_str = str(template_file_path)
935
+ file_name = _generate_template_variable_name(file_path_str)
936
+ if file_path_str not in seen_files:
937
+ files_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
938
+ (file_name, file_path_str)
939
+ )
940
+ seen_files.add(file_path_str)
911
941
 
912
- if file_path not in seen_files:
913
- files_tuples.append((file_name, file_path))
914
- seen_files.add(file_path)
942
+ for ci_file_item in code_interpreter_files:
943
+ if isinstance(ci_file_item, (str, Path)):
944
+ file_name = _generate_template_variable_name(str(ci_file_item))
945
+ file_path_str = str(ci_file_item)
946
+ if file_path_str not in seen_files:
947
+ files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
948
+ (file_name, file_path_str)
949
+ )
950
+ seen_files.add(file_path_str)
951
+ elif isinstance(ci_file_item, tuple) and len(ci_file_item) == 2:
952
+ _, ci_file_path = ci_file_item
953
+ file_path_str = str(ci_file_path)
954
+ file_name = _generate_template_variable_name(file_path_str)
955
+ if file_path_str not in seen_files:
956
+ files_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
957
+ (file_name, file_path_str)
958
+ )
959
+ seen_files.add(file_path_str)
915
960
 
916
- # Also process file_search_files from routing result (for compatibility)
917
961
  for fs_file_item in file_search_files:
918
962
  if isinstance(fs_file_item, (str, Path)):
919
963
  file_name = _generate_template_variable_name(str(fs_file_item))
920
964
  file_path_str = str(fs_file_item)
921
965
  if file_path_str not in seen_files:
922
- files_tuples.append((file_name, file_path_str))
966
+ files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
967
+ (file_name, file_path_str)
968
+ )
923
969
  seen_files.add(file_path_str)
924
970
  elif isinstance(fs_file_item, tuple) and len(fs_file_item) == 2:
925
- # Handle tuple format (name, path)
926
971
  _, fs_file_path = fs_file_item
927
- fs_file_path_str = str(fs_file_path)
928
- file_name = _generate_template_variable_name(fs_file_path_str)
929
- if fs_file_path_str not in seen_files:
930
- files_tuples.append((file_name, fs_file_path_str))
931
- seen_files.add(fs_file_path_str)
932
-
933
- # Handle directory aliases - create stable template variables for directories
934
- dir_mappings = []
972
+ file_path_str = str(fs_file_path)
973
+ file_name = _generate_template_variable_name(file_path_str)
974
+ if file_path_str not in seen_files:
975
+ files_by_intent[FileRoutingIntent.FILE_SEARCH].append(
976
+ (file_name, file_path_str)
977
+ )
978
+ seen_files.add(file_path_str)
935
979
 
936
- # Get directory aliases from routing result (these are already processed from CLI args)
980
+ # Categorize directories by their intent
937
981
  routing = routing_result.routing
938
982
  for alias_name, dir_path in routing.template_dir_aliases:
939
- dir_mappings.append((alias_name, str(dir_path)))
983
+ dirs_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
984
+ (alias_name, str(dir_path))
985
+ )
940
986
  for alias_name, dir_path in routing.code_interpreter_dir_aliases:
941
- dir_mappings.append((alias_name, str(dir_path)))
987
+ dirs_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
988
+ (alias_name, str(dir_path))
989
+ )
942
990
  for alias_name, dir_path in routing.file_search_dir_aliases:
943
- dir_mappings.append((alias_name, str(dir_path)))
991
+ dirs_by_intent[FileRoutingIntent.FILE_SEARCH].append(
992
+ (alias_name, str(dir_path))
993
+ )
944
994
 
945
- # Auto-naming directories (from -dt, -dc, -ds)
995
+ # Auto-naming directories from CLI args
946
996
  template_dirs = args.get("template_dirs", [])
947
997
  for dir_path in template_dirs:
948
998
  dir_name = _generate_template_variable_name(str(dir_path))
949
- dir_mappings.append((dir_name, str(dir_path)))
999
+ dirs_by_intent[FileRoutingIntent.TEMPLATE_ONLY].append(
1000
+ (dir_name, str(dir_path))
1001
+ )
950
1002
 
951
1003
  code_interpreter_dirs = args.get("code_interpreter_dirs", [])
952
1004
  for dir_path in code_interpreter_dirs:
953
1005
  dir_name = _generate_template_variable_name(str(dir_path))
954
- dir_mappings.append((dir_name, str(dir_path)))
1006
+ dirs_by_intent[FileRoutingIntent.CODE_INTERPRETER].append(
1007
+ (dir_name, str(dir_path))
1008
+ )
955
1009
 
956
1010
  file_search_dirs = args.get("file_search_dirs", [])
957
1011
  for dir_path in file_search_dirs:
958
1012
  dir_name = _generate_template_variable_name(str(dir_path))
959
- dir_mappings.append((dir_name, str(dir_path)))
1013
+ dirs_by_intent[FileRoutingIntent.FILE_SEARCH].append(
1014
+ (dir_name, str(dir_path))
1015
+ )
960
1016
 
961
- # Process files from explicit routing
962
- files_dict = collect_files(
963
- file_mappings=cast(
964
- List[Tuple[str, Union[str, Path]]], files_tuples
965
- ),
966
- dir_mappings=cast(
967
- List[Tuple[str, Union[str, Path]]], dir_mappings
968
- ),
969
- dir_recursive=args.get("recursive", False),
970
- security_manager=security_manager,
971
- routing_type="template", # Explicitly set routing_type for files processed here
972
- # This needs careful thought as files_tuples can come from various sources
973
- # For now, we assume files directly added to files_tuples are 'template' routed
974
- # if not overridden by a more specific tool routing later.
975
- # This is a simplification. A more robust way would be to track routing type
976
- # for each path as it's parsed from CLI args.
977
- # For the large file warning, FileInfo will default routing_type to None
978
- # which FileInfo.content interprets as potentially template-routed.
979
- )
1017
+ # Process files by intent groups with appropriate routing_intent
1018
+ for intent, file_mappings in files_by_intent.items():
1019
+ if file_mappings or dirs_by_intent[intent]:
1020
+ intent_files_dict = collect_files(
1021
+ file_mappings=cast(
1022
+ List[Tuple[str, Union[str, Path]]], file_mappings
1023
+ ),
1024
+ dir_mappings=cast(
1025
+ List[Tuple[str, Union[str, Path]]],
1026
+ dirs_by_intent[intent],
1027
+ ),
1028
+ dir_recursive=args.get("recursive", False),
1029
+ security_manager=security_manager,
1030
+ routing_type="template", # Keep routing_type for template context accessibility
1031
+ routing_intent=intent, # Use intent for warning logic
1032
+ )
1033
+ files_dict.update(intent_files_dict)
980
1034
 
981
1035
  # Handle legacy files and directories separately to preserve variable names
982
1036
  legacy_files = args.get("files", [])
@@ -997,6 +1051,7 @@ async def create_template_context_from_routing(
997
1051
  dir_recursive=args.get("recursive", False),
998
1052
  security_manager=security_manager,
999
1053
  routing_type="template", # Legacy flags are considered template-only
1054
+ routing_intent=FileRoutingIntent.TEMPLATE_ONLY, # Legacy files use template-only intent
1000
1055
  )
1001
1056
  # Merge legacy results into the main template context
1002
1057
  files_dict.update(legacy_files_dict)
@@ -1042,8 +1097,18 @@ async def create_template_context_from_routing(
1042
1097
  config = OstructConfig.load(config_path)
1043
1098
  web_search_config = config.get_web_search_config()
1044
1099
 
1100
+ # Apply universal tool toggle overrides (Step 3: Config override hook)
1101
+ enabled_tools: set[str] = args.get("_enabled_tools", set()) # type: ignore[assignment]
1102
+ disabled_tools: set[str] = args.get("_disabled_tools", set()) # type: ignore[assignment]
1103
+
1045
1104
  # Determine if web search should be enabled
1046
- if web_search_from_cli:
1105
+ if "web-search" in enabled_tools:
1106
+ # Universal --enable-tool web-search takes highest precedence
1107
+ web_search_enabled = True
1108
+ elif "web-search" in disabled_tools:
1109
+ # Universal --disable-tool web-search takes highest precedence
1110
+ web_search_enabled = False
1111
+ elif web_search_from_cli:
1047
1112
  # Explicit --web-search flag takes precedence
1048
1113
  web_search_enabled = True
1049
1114
  elif no_web_search_from_cli:
@@ -1055,6 +1120,69 @@ async def create_template_context_from_routing(
1055
1120
 
1056
1121
  context["web_search_enabled"] = web_search_enabled
1057
1122
 
1123
+ # Add Code Interpreter context variables
1124
+ # Check if Code Interpreter is enabled by looking for CI files or tools
1125
+ ci_enabled_by_routing = bool(
1126
+ args.get("code_interpreter_files")
1127
+ or args.get("code_interpreter_file_aliases")
1128
+ or args.get("code_interpreter_dirs")
1129
+ or args.get("code_interpreter_dir_aliases")
1130
+ or args.get("code_interpreter", False)
1131
+ )
1132
+
1133
+ # Apply universal tool toggle overrides for code-interpreter
1134
+ if "code-interpreter" in enabled_tools:
1135
+ # Universal --enable-tool takes highest precedence
1136
+ code_interpreter_enabled = True
1137
+ elif "code-interpreter" in disabled_tools:
1138
+ # Universal --disable-tool takes highest precedence
1139
+ code_interpreter_enabled = False
1140
+ else:
1141
+ # Fall back to routing-based enablement
1142
+ code_interpreter_enabled = ci_enabled_by_routing
1143
+ context["code_interpreter_enabled"] = code_interpreter_enabled
1144
+
1145
+ # Add auto_download setting from configuration
1146
+ if code_interpreter_enabled:
1147
+ ci_config = config.get_code_interpreter_config()
1148
+ context["auto_download_enabled"] = ci_config.get(
1149
+ "auto_download", True
1150
+ )
1151
+
1152
+ # Determine effective download strategy (config + feature flags)
1153
+ effective_ci_config = dict(
1154
+ ci_config
1155
+ ) # Copy to avoid modifying original
1156
+ enabled_features = args.get("enabled_features", [])
1157
+ disabled_features = args.get("disabled_features", [])
1158
+
1159
+ if enabled_features or disabled_features:
1160
+ try:
1161
+ from .click_options import parse_feature_flags
1162
+
1163
+ parsed_flags = parse_feature_flags(
1164
+ tuple(enabled_features), tuple(disabled_features)
1165
+ )
1166
+ ci_hack_flag = parsed_flags.get("ci-download-hack")
1167
+ if ci_hack_flag == "on":
1168
+ effective_ci_config["download_strategy"] = (
1169
+ "two_pass_sentinel"
1170
+ )
1171
+ elif ci_hack_flag == "off":
1172
+ effective_ci_config["download_strategy"] = (
1173
+ "single_pass"
1174
+ )
1175
+ except Exception as e:
1176
+ logger.warning(
1177
+ f"Failed to parse feature flags in template processor: {e}"
1178
+ )
1179
+
1180
+ # Add the effective CI config for template processing
1181
+ context["code_interpreter_config"] = effective_ci_config
1182
+ else:
1183
+ context["auto_download_enabled"] = False
1184
+ context["code_interpreter_config"] = {}
1185
+
1058
1186
  return context
1059
1187
 
1060
1188
  except PathSecurityError:
@@ -56,6 +56,7 @@ Notes:
56
56
 
57
57
  import logging
58
58
  import os
59
+ import re
59
60
  from typing import Any, Dict, List, Optional, Union
60
61
 
61
62
  import jinja2
@@ -95,6 +96,45 @@ TemplateContextValue = Union[
95
96
  ]
96
97
 
97
98
 
99
+ def _extract_variable_name_from_jinja_error(error_message: str) -> str:
100
+ """Extract the actual variable name from a Jinja2 UndefinedError message.
101
+
102
+ Handles various Jinja2 error message formats:
103
+ - "'variable_name' is undefined"
104
+ - "'object_description' has no attribute 'property_name'"
105
+ - Other formats
106
+
107
+ Args:
108
+ error_message: The string representation of the Jinja2 UndefinedError
109
+
110
+ Returns:
111
+ The extracted variable name, or a sanitized version if parsing fails
112
+ """
113
+ # Pattern 1: Standard undefined variable: "'variable_name' is undefined"
114
+ match = re.match(r"'([^']+)' is undefined", error_message)
115
+ if match:
116
+ return match.group(1)
117
+
118
+ # Pattern 2: Attribute access on object: "'object' has no attribute 'property'"
119
+ # In this case, we want to extract just the property name, not the object description
120
+ match = re.match(r"'[^']+' has no attribute '([^']+)'", error_message)
121
+ if match:
122
+ property_name = match.group(1)
123
+ return property_name
124
+
125
+ # Pattern 3: Try to find any quoted identifier that looks like a variable name
126
+ # Look for quoted strings that contain only valid Python identifier characters
127
+ quoted_parts: List[str] = re.findall(r"'([^']+)'", error_message)
128
+ for part in quoted_parts:
129
+ # Check if this looks like a variable name (not a class name or description)
130
+ if re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", part) and "." not in part:
131
+ return part
132
+
133
+ # Fallback: If we can't parse it properly, return a generic message
134
+ # This avoids exposing internal class names to users
135
+ return "unknown_variable"
136
+
137
+
98
138
  def render_template(
99
139
  template_str: str,
100
140
  context: Dict[str, Any],
@@ -343,7 +383,7 @@ def render_template(
343
383
  return result
344
384
  except jinja2.UndefinedError as e:
345
385
  # Extract variable name from error message
346
- var_name = str(e).split("'")[1]
386
+ var_name = _extract_variable_name_from_jinja_error(str(e))
347
387
  error_msg = (
348
388
  f"Missing required template variable: {var_name}\n"
349
389
  f"Available variables: {', '.join(sorted(context.keys()))}\n"