Skip to content

Configuration Loading and Management API

This section documents the HoloDeck configuration system, including YAML loading, validation, environment variable substitution, schema validation, and configuration management.

Overview

The configuration system is organized across seven modules:

Module Purpose
loader YAML parsing, global/project config loading, merging, and MCP server helpers
env_loader Environment variable substitution (${VAR} pattern) and .env file loading
validator Pydantic error flattening for human-readable validation messages
defaults Built-in default constants and embedding dimension lookup
context Request-scoped ContextVar for agent base directory
manager Configuration file creation, path resolution, and YAML generation
schema JSON Schema validation for LLM response formats

ConfigLoader

The main entry point for loading HoloDeck agent configurations from YAML.

ConfigLoader()

Loads and validates agent configuration from YAML files.

This class handles: - Parsing YAML files into Python dictionaries - Loading global configuration from ~/.holodeck/config.yaml - Merging configurations with proper precedence - Resolving file references (instructions, tools) - Converting validation errors into human-readable messages - Environment variable substitution

Initialize the ConfigLoader with empty caches.

Source code in src/holodeck/config/loader.py
264
265
266
267
268
def __init__(self) -> None:
    """Initialize the ConfigLoader with empty caches."""
    self._user_config_loaded = False
    self._user_config: GlobalConfig | None = None
    self._project_configs: dict[str, GlobalConfig | None] = {}

parse_yaml(file_path)

Parse a YAML file and return its contents as a dictionary.

Parameters:

Name Type Description Default
file_path str

Path to the YAML file to parse

required

Returns:

Type Description
dict[str, Any] | None

Dictionary containing parsed YAML content, or None if file is empty

Raises:

Type Description
FileNotFoundError

If the file does not exist

ConfigError

If YAML parsing fails

Source code in src/holodeck/config/loader.py
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
def parse_yaml(self, file_path: str) -> dict[str, Any] | None:
    """Parse a YAML file and return its contents as a dictionary.

    Args:
        file_path: Path to the YAML file to parse

    Returns:
        Dictionary containing parsed YAML content, or None if file is empty

    Raises:
        FileNotFoundError: If the file does not exist
        ConfigError: If YAML parsing fails
    """
    path = Path(file_path)

    try:
        with open(path, encoding="utf-8") as f:
            content = yaml.safe_load(f)
            return content if content is not None else {}
    except OSError as e:
        raise FileNotFoundError(
            file_path,
            f"Configuration file not found at {file_path}. "
            f"Please ensure the file exists at this path.",
        ) from e
    except yaml.YAMLError as e:
        raise ConfigError(
            "yaml_parse",
            f"Failed to parse YAML file {file_path}: {str(e)}",
        ) from e

load_agent_yaml(file_path)

Load and validate an agent configuration from YAML.

This method: 1. Parses the YAML file with env var substitution (single pass) 2. Loads and merges user + project configs (project overrides user) 3. Merges global config into agent config 4. Validates against Agent schema 5. Returns an Agent instance

Configuration precedence (highest to lowest): 1. agent.yaml explicit settings 2. Environment variables 3. Project-level config.yaml/config.yml 4. Global ~/.holodeck/config.yaml/config.yml

Parameters:

Name Type Description Default
file_path str

Path to agent.yaml file

required

Returns:

Type Description
Agent

Validated Agent instance

Raises:

Type Description
FileNotFoundError

If file doesn't exist

ConfigError

If YAML parsing fails

ValidationError

If configuration is invalid

Source code in src/holodeck/config/loader.py
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def load_agent_yaml(self, file_path: str) -> Agent:
    """Load and validate an agent configuration from YAML.

    This method:
    1. Parses the YAML file with env var substitution (single pass)
    2. Loads and merges user + project configs (project overrides user)
    3. Merges global config into agent config
    4. Validates against Agent schema
    5. Returns an Agent instance

    Configuration precedence (highest to lowest):
    1. agent.yaml explicit settings
    2. Environment variables
    3. Project-level config.yaml/config.yml
    4. Global ~/.holodeck/config.yaml/config.yml

    Args:
        file_path: Path to agent.yaml file

    Returns:
        Validated Agent instance

    Raises:
        FileNotFoundError: If file doesn't exist
        ConfigError: If YAML parsing fails
        ValidationError: If configuration is invalid
    """
    path = Path(file_path)
    try:
        agent_config = _read_yaml_with_env_substitution(path)
    except OSError as e:
        raise FileNotFoundError(
            file_path,
            f"Configuration file not found at {file_path}. "
            f"Please ensure the file exists at this path.",
        ) from e
    except yaml.YAMLError as e:
        raise ConfigError(
            "yaml_parse",
            f"Failed to parse YAML file {file_path}: {str(e)}",
        ) from e

    if not agent_config:
        agent_config = {}

    # Load and deep-merge user + project configs (project overrides user)
    agent_dir = str(path.parent)
    user_config = self.load_global_config()
    project_config = self.load_project_config(agent_dir)
    config = self._merge_global_configs(user_config, project_config)

    # Merge configurations with proper precedence
    merged_config = self.merge_configs(agent_config, config)

    # Resolve external test_cases_file reference (if any) into inline
    # `test_cases` before schema validation. Agent model uses
    # `extra="forbid"`, so the key must be removed from the dict.
    _resolve_test_cases_file(merged_config, path.parent)

    # Expose the agent directory on ``sys.path`` so ``CodeMetric``'s
    # grader resolver (``importlib.import_module``) can locate user-land
    # grader packages (e.g. ``graders.my_benchmarks``) placed next to
    # ``agent.yaml``.
    agent_dir_abs = str(path.parent.resolve())
    if agent_dir_abs not in sys.path:
        sys.path.insert(0, agent_dir_abs)

    # Validate against Agent schema
    try:
        agent = Agent(**merged_config)
        return agent
    except PydanticValidationError as e:
        error_messages = flatten_pydantic_errors(e)
        error_text = "\n".join(error_messages)
        raise ConfigError(
            "agent_validation",
            f"Invalid agent configuration in {file_path}:\n{error_text}",
        ) from e

load_global_config()

Load global configuration from ~/.holodeck/config.yml|config.yaml.

Results are cached after first load.

Returns:

Type Description
GlobalConfig | None

GlobalConfig instance, or None if no config file exists

Raises:

Type Description
ConfigError

If YAML parsing fails or validation fails

Source code in src/holodeck/config/loader.py
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
def load_global_config(self) -> GlobalConfig | None:
    """Load global configuration from ~/.holodeck/config.yml|config.yaml.

    Results are cached after first load.

    Returns:
        GlobalConfig instance, or None if no config file exists

    Raises:
        ConfigError: If YAML parsing fails or validation fails
    """
    if self._user_config_loaded:
        return self._user_config

    home_dir = Path.home()
    holodeck_dir = home_dir / ".holodeck"
    result = self._load_config_file(
        holodeck_dir, "global_config", "global configuration"
    )
    self._user_config = result
    self._user_config_loaded = True
    return result

load_project_config(project_dir)

Load project-level configuration from config.yml|config.yaml.

Results are cached per project_dir after first load.

Parameters:

Name Type Description Default
project_dir str

Path to project root directory

required

Returns:

Type Description
GlobalConfig | None

GlobalConfig instance, or None if no config file exists

Raises:

Type Description
ConfigError

If YAML parsing fails or validation fails

Source code in src/holodeck/config/loader.py
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
def load_project_config(self, project_dir: str) -> GlobalConfig | None:
    """Load project-level configuration from config.yml|config.yaml.

    Results are cached per project_dir after first load.

    Args:
        project_dir: Path to project root directory

    Returns:
        GlobalConfig instance, or None if no config file exists

    Raises:
        ConfigError: If YAML parsing fails or validation fails
    """
    if project_dir in self._project_configs:
        return self._project_configs[project_dir]

    project_path = Path(project_dir)
    result = self._load_config_file(
        project_path, "project_config", "project configuration"
    )
    self._project_configs[project_dir] = result
    return result

merge_configs(agent_config, global_config)

Merge agent config with global config using proper precedence.

Precedence (highest to lowest): 1. agent.yaml explicit settings 2. Environment variables (already substituted) 3. Global settings (merged user + project)

Merges: - Global LLM provider configs into agent model and evaluation model - Global vectorstore configs into tool database fields (by name reference)

Keys don't get overwritten if they already exist in the agent config.

Parameters:

Name Type Description Default
agent_config dict[str, Any]

Configuration from agent.yaml

required
global_config GlobalConfig | None

GlobalConfig instance (merged user + project)

required

Returns:

Type Description
dict[str, Any]

Merged configuration dictionary

Source code in src/holodeck/config/loader.py
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
def merge_configs(
    self, agent_config: dict[str, Any], global_config: GlobalConfig | None
) -> dict[str, Any]:
    """Merge agent config with global config using proper precedence.

    Precedence (highest to lowest):
    1. agent.yaml explicit settings
    2. Environment variables (already substituted)
    3. Global settings (merged user + project)

    Merges:
    - Global LLM provider configs into agent model and evaluation model
    - Global vectorstore configs into tool database fields (by name reference)

    Keys don't get overwritten if they already exist in the agent config.

    Args:
        agent_config: Configuration from agent.yaml
        global_config: GlobalConfig instance (merged user + project)

    Returns:
        Merged configuration dictionary
    """
    if not agent_config:
        return {}

    if not global_config:
        return agent_config

    # Merge LLM provider configs (dict key lookup with .provider fallback)
    if "model" in agent_config and global_config.providers:
        agent_model_provider = agent_config["model"].get("provider")
        if agent_model_provider:
            _merge_provider_into_model(
                agent_config["model"],
                agent_model_provider,
                global_config.providers,
            )

        # Also merge global provider config to evaluation model
        if (
            "evaluations" in agent_config
            and isinstance(agent_config["evaluations"], dict)
            and "model" in agent_config["evaluations"]
            and isinstance(agent_config["evaluations"]["model"], dict)
        ):
            eval_model: dict[str, Any] = agent_config["evaluations"]["model"]
            eval_model_provider = eval_model.get("provider")
            if eval_model_provider:
                _merge_provider_into_model(
                    eval_model,
                    eval_model_provider,
                    global_config.providers,
                )

    # Resolve vectorstore references in tools
    if (
        global_config.vectorstores
        and "tools" in agent_config
        and isinstance(agent_config["tools"], list)
    ):
        self._resolve_vectorstore_references(
            agent_config["tools"], global_config.vectorstores
        )

    # Merge global MCP servers into agent tools
    if global_config.mcp_servers and len(global_config.mcp_servers) > 0:
        tools_missing = "tools" not in agent_config
        tools_invalid = not isinstance(agent_config.get("tools"), list)
        if tools_missing or tools_invalid:
            agent_config["tools"] = []

        self._merge_mcp_servers(agent_config["tools"], global_config.mcp_servers)

    # Merge global deployment config (agent.yaml takes precedence)
    if global_config.deployment:
        if "deployment" not in agent_config:
            agent_config["deployment"] = global_config.deployment.model_dump(
                exclude_unset=True
            )
        else:
            global_deploy = global_config.deployment.model_dump(exclude_unset=True)
            agent_deploy = agent_config["deployment"]
            if isinstance(agent_deploy, dict):
                _deep_merge(global_deploy, agent_deploy)
                agent_config["deployment"] = global_deploy

    return agent_config

resolve_file_path(file_path, base_dir)

Resolve a file path relative to base directory.

Parameters:

Name Type Description Default
file_path str

Path to resolve (absolute or relative)

required
base_dir str

Base directory for relative path resolution

required

Returns:

Type Description
str

Absolute path to the file

Raises:

Type Description
FileNotFoundError

If the resolved file doesn't exist

Source code in src/holodeck/config/loader.py
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
def resolve_file_path(self, file_path: str, base_dir: str) -> str:
    """Resolve a file path relative to base directory.

    Args:
        file_path: Path to resolve (absolute or relative)
        base_dir: Base directory for relative path resolution

    Returns:
        Absolute path to the file

    Raises:
        FileNotFoundError: If the resolved file doesn't exist
    """
    path = Path(file_path)

    if path.is_absolute():
        resolved = path
    else:
        resolved = (Path(base_dir) / file_path).resolve()

    if not resolved.exists():
        raise FileNotFoundError(
            str(resolved),
            f"Referenced file not found: {resolved}\n"
            f"Please ensure the file exists at this path.",
        )

    return str(resolved)

resolve_execution_config(cli_config, yaml_config, project_config, user_config, defaults)

Resolve execution configuration with priority hierarchy.

Configuration priority (highest to lowest): 1. CLI flags (cli_config) 2. agent.yaml execution section (yaml_config) 3. Project config execution section (project_config from ./config.yaml) 4. User config execution section (user_config from ~/.holodeck/config.yaml) 5. Environment variables (HOLODECK_* vars) 6. Built-in defaults

Parameters:

Name Type Description Default
cli_config ExecutionConfig | None

Execution config from CLI flags (optional)

required
yaml_config ExecutionConfig | None

Execution config from agent.yaml (optional)

required
project_config ExecutionConfig | None

Execution config from project config.yaml (optional)

required
user_config ExecutionConfig | None

Execution config from ~/.holodeck/config.yaml (optional)

required
defaults dict[str, Any]

Dictionary of default values

required

Returns:

Type Description
ExecutionConfig

Resolved ExecutionConfig with all fields populated

Source code in src/holodeck/config/loader.py
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
def resolve_execution_config(
    self,
    cli_config: ExecutionConfig | None,
    yaml_config: ExecutionConfig | None,
    project_config: ExecutionConfig | None,
    user_config: ExecutionConfig | None,
    defaults: dict[str, Any],
) -> ExecutionConfig:
    """Resolve execution configuration with priority hierarchy.

    Configuration priority (highest to lowest):
    1. CLI flags (cli_config)
    2. agent.yaml execution section (yaml_config)
    3. Project config execution section (project_config from ./config.yaml)
    4. User config execution section (user_config from ~/.holodeck/config.yaml)
    5. Environment variables (HOLODECK_* vars)
    6. Built-in defaults

    Args:
        cli_config: Execution config from CLI flags (optional)
        yaml_config: Execution config from agent.yaml (optional)
        project_config: Execution config from project config.yaml (optional)
        user_config: Execution config from ~/.holodeck/config.yaml (optional)
        defaults: Dictionary of default values

    Returns:
        Resolved ExecutionConfig with all fields populated
    """
    resolved: dict[str, Any] = {}

    fields = list(ExecutionConfig.model_fields.keys())

    for field in fields:
        # Priority 1: CLI flag
        if cli_config and getattr(cli_config, field, None) is not None:
            resolved[field] = getattr(cli_config, field)
        # Priority 2: agent.yaml execution section
        elif yaml_config and getattr(yaml_config, field, None) is not None:
            resolved[field] = getattr(yaml_config, field)
        # Priority 3: Project config execution section
        elif project_config and getattr(project_config, field, None) is not None:
            resolved[field] = getattr(project_config, field)
        # Priority 4: User config execution section (~/.holodeck/)
        elif user_config and getattr(user_config, field, None) is not None:
            resolved[field] = getattr(user_config, field)
        # Priority 5: Environment variable
        elif (env_value := _get_env_value(field, os.environ)) is not None:
            resolved[field] = env_value
        # Priority 6: Built-in default
        else:
            resolved[field] = defaults.get(field)

    return ExecutionConfig(**resolved)

Module-Level Functions (loader)

load_agent_with_config

Convenience function that creates a ConfigLoader, loads the agent, sets the agent_base_dir context variable, and resolves the execution config in one call.

load_agent_with_config(agent_config_path, cli_config=None)

Load agent and resolve execution config in one call.

Encapsulates the config-loading boilerplate shared across CLI commands: - Creates ConfigLoader, loads agent.yaml - Sets agent_base_dir context variable - Resolves execution config with full priority hierarchy - Returns (agent, resolved_config, loader)

Parameters:

Name Type Description Default
agent_config_path str

Path to agent.yaml file

required
cli_config ExecutionConfig | None

Optional execution config from CLI flags

None

Returns:

Type Description
tuple[Agent, ExecutionConfig, ConfigLoader]

Tuple of (Agent, resolved ExecutionConfig, ConfigLoader)

Source code in src/holodeck/config/loader.py
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
def load_agent_with_config(
    agent_config_path: str,
    cli_config: ExecutionConfig | None = None,
) -> tuple[Agent, ExecutionConfig, ConfigLoader]:
    """Load agent and resolve execution config in one call.

    Encapsulates the config-loading boilerplate shared across CLI commands:
    - Creates ConfigLoader, loads agent.yaml
    - Sets agent_base_dir context variable
    - Resolves execution config with full priority hierarchy
    - Returns (agent, resolved_config, loader)

    Args:
        agent_config_path: Path to agent.yaml file
        cli_config: Optional execution config from CLI flags

    Returns:
        Tuple of (Agent, resolved ExecutionConfig, ConfigLoader)
    """
    from holodeck.config.context import agent_base_dir
    from holodeck.config.defaults import DEFAULT_EXECUTION_CONFIG

    loader = ConfigLoader()
    agent = loader.load_agent_yaml(agent_config_path)

    # Set the base directory context for resolving relative paths in tools
    agent_dir = str(Path(agent_config_path).parent.resolve())
    agent_base_dir.set(agent_dir)

    # Resolve execution config (CLI > agent.yaml > project > user > env > defaults)
    # Configs are already cached from load_agent_yaml, no duplicate I/O
    project_config = loader.load_project_config(agent_dir)
    project_execution = project_config.execution if project_config else None
    user_config = loader.load_global_config()
    user_execution = user_config.execution if user_config else None

    resolved_config = loader.resolve_execution_config(
        cli_config=cli_config,
        yaml_config=agent.execution,
        project_config=project_execution,
        user_config=user_execution,
        defaults=DEFAULT_EXECUTION_CONFIG,
    )

    return agent, resolved_config, loader

save_global_config

save_global_config(config, path=None)

Save GlobalConfig to ~/.holodeck/config.yaml.

Creates the ~/.holodeck/ directory if it doesn't exist. Preserves existing fields when updating.

Parameters:

Name Type Description Default
config GlobalConfig

GlobalConfig instance to save

required
path Path | None

Optional custom path (defaults to ~/.holodeck/config.yaml)

None

Returns:

Type Description
Path

Path where the configuration was saved

Raises:

Type Description
ConfigError

If file write fails

Source code in src/holodeck/config/loader.py
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
def save_global_config(
    config: GlobalConfig,
    path: Path | None = None,
) -> Path:
    """Save GlobalConfig to ~/.holodeck/config.yaml.

    Creates the ~/.holodeck/ directory if it doesn't exist.
    Preserves existing fields when updating.

    Args:
        config: GlobalConfig instance to save
        path: Optional custom path (defaults to ~/.holodeck/config.yaml)

    Returns:
        Path where the configuration was saved

    Raises:
        ConfigError: If file write fails
    """
    if path is None:
        path = Path.home() / ".holodeck" / "config.yaml"

    try:
        path.parent.mkdir(parents=True, exist_ok=True)

        config_dict = config.model_dump(
            exclude_unset=True, exclude_none=True, mode="json"
        )

        yaml_content = yaml.dump(
            config_dict,
            default_flow_style=False,
            sort_keys=False,
            allow_unicode=True,
        )

        path.write_text(yaml_content, encoding="utf-8")
        logger.debug(f"Saved global configuration to {path}")
        return path

    except OSError as e:
        raise ConfigError(
            "global_config_write",
            f"Failed to write global configuration to {path}: {e}",
        ) from e

MCP Server Helpers

Functions for adding, removing, and listing MCP servers in agent and global configs.

add_mcp_server_to_agent(agent_path, mcp_tool)

Add an MCP server to agent.yaml tools list.

Parameters:

Name Type Description Default
agent_path Path

Path to agent.yaml file

required
mcp_tool MCPTool

MCPTool configuration to add

required

Raises:

Type Description
FileNotFoundError

If agent.yaml doesn't exist

DuplicateServerError

If server already configured

ConfigError

If YAML parsing or writing fails

Source code in src/holodeck/config/loader.py
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
def add_mcp_server_to_agent(
    agent_path: Path,
    mcp_tool: MCPTool,
) -> None:
    """Add an MCP server to agent.yaml tools list.

    Args:
        agent_path: Path to agent.yaml file
        mcp_tool: MCPTool configuration to add

    Raises:
        FileNotFoundError: If agent.yaml doesn't exist
        DuplicateServerError: If server already configured
        ConfigError: If YAML parsing or writing fails
    """
    loader = ConfigLoader()

    try:
        agent_config = loader.parse_yaml(str(agent_path))
    except FileNotFoundError as e:
        raise FileNotFoundError(
            str(agent_path),
            "No agent.yaml found. Use --agent to specify a file "
            "or -g for global install.",
        ) from e

    if agent_config is None:
        agent_config = {}

    if "tools" not in agent_config:
        agent_config["tools"] = []

    _check_mcp_duplicate(agent_config["tools"], mcp_tool)

    tool_dict = mcp_tool.model_dump(exclude_unset=True, exclude_none=True, mode="json")

    agent_config["tools"].append(tool_dict)

    try:
        yaml_content = yaml.dump(
            agent_config,
            default_flow_style=False,
            sort_keys=False,
            allow_unicode=True,
        )
        agent_path.write_text(yaml_content, encoding="utf-8")
        logger.debug(f"Added MCP server '{mcp_tool.name}' to {agent_path}")

    except OSError as e:
        raise ConfigError(
            "agent_config_write",
            f"Failed to write agent configuration to {agent_path}: {e}",
        ) from e

add_mcp_server_to_global(mcp_tool, global_path=None)

Add an MCP server to global config mcp_servers list.

Parameters:

Name Type Description Default
mcp_tool MCPTool

MCPTool configuration to add

required
global_path Path | None

Optional custom path (defaults to ~/.holodeck/config.yaml)

None

Returns:

Type Description
Path

Path where the configuration was saved

Raises:

Type Description
DuplicateServerError

If server already configured

ConfigError

If YAML parsing or writing fails

Source code in src/holodeck/config/loader.py
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
def add_mcp_server_to_global(
    mcp_tool: MCPTool,
    global_path: Path | None = None,
) -> Path:
    """Add an MCP server to global config mcp_servers list.

    Args:
        mcp_tool: MCPTool configuration to add
        global_path: Optional custom path (defaults to ~/.holodeck/config.yaml)

    Returns:
        Path where the configuration was saved

    Raises:
        DuplicateServerError: If server already configured
        ConfigError: If YAML parsing or writing fails
    """
    if global_path is None:
        global_path = Path.home() / ".holodeck" / "config.yaml"

    loader = ConfigLoader()
    global_config = loader.load_global_config()

    if global_config is None:
        global_config = GlobalConfig(
            providers=None,
            vectorstores=None,
            execution=None,
            deployment=None,
            mcp_servers=None,
        )

    if global_config.mcp_servers is None:
        global_config.mcp_servers = []

    existing_tools = [t.model_dump(mode="json") for t in global_config.mcp_servers]

    _check_mcp_duplicate(existing_tools, mcp_tool)

    global_config.mcp_servers.append(mcp_tool)

    return save_global_config(global_config, global_path)

remove_mcp_server_from_agent(agent_path, server_name)

Remove an MCP server from agent.yaml tools list.

Parameters:

Name Type Description Default
agent_path Path

Path to agent.yaml file

required
server_name str

Name of the MCP server to remove

required

Raises:

Type Description
FileNotFoundError

If agent.yaml doesn't exist

ServerNotFoundError

If server not found in configuration

ConfigError

If YAML parsing or writing fails

Source code in src/holodeck/config/loader.py
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
def remove_mcp_server_from_agent(
    agent_path: Path,
    server_name: str,
) -> None:
    """Remove an MCP server from agent.yaml tools list.

    Args:
        agent_path: Path to agent.yaml file
        server_name: Name of the MCP server to remove

    Raises:
        FileNotFoundError: If agent.yaml doesn't exist
        ServerNotFoundError: If server not found in configuration
        ConfigError: If YAML parsing or writing fails
    """
    loader = ConfigLoader()

    try:
        agent_config = loader.parse_yaml(str(agent_path))
    except FileNotFoundError as e:
        raise FileNotFoundError(
            str(agent_path),
            f"Agent file not found: {agent_path}",
        ) from e

    if agent_config is None:
        agent_config = {}

    tools = agent_config.get("tools", [])

    original_len = len(tools)
    tools = [
        tool
        for tool in tools
        if not (tool.get("type") == "mcp" and tool.get("name") == server_name)
    ]

    if len(tools) == original_len:
        raise ServerNotFoundError(server_name, str(agent_path))

    agent_config["tools"] = tools

    try:
        yaml_content = yaml.dump(
            agent_config,
            default_flow_style=False,
            sort_keys=False,
            allow_unicode=True,
        )
        agent_path.write_text(yaml_content, encoding="utf-8")
        logger.debug(f"Removed MCP server '{server_name}' from {agent_path}")

    except OSError as e:
        raise ConfigError(
            "agent_config_write",
            f"Failed to write agent configuration to {agent_path}: {e}",
        ) from e

remove_mcp_server_from_global(server_name, global_path=None)

Remove an MCP server from global config mcp_servers list.

Parameters:

Name Type Description Default
server_name str

Name of the MCP server to remove

required
global_path Path | None

Optional custom path (defaults to ~/.holodeck/config.yaml)

None

Returns:

Type Description
Path

Path where the configuration was saved

Raises:

Type Description
ServerNotFoundError

If server not found in configuration

ConfigError

If YAML parsing or writing fails

Source code in src/holodeck/config/loader.py
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
def remove_mcp_server_from_global(
    server_name: str,
    global_path: Path | None = None,
) -> Path:
    """Remove an MCP server from global config mcp_servers list.

    Args:
        server_name: Name of the MCP server to remove
        global_path: Optional custom path (defaults to ~/.holodeck/config.yaml)

    Returns:
        Path where the configuration was saved

    Raises:
        ServerNotFoundError: If server not found in configuration
        ConfigError: If YAML parsing or writing fails
    """
    if global_path is None:
        global_path = Path.home() / ".holodeck" / "config.yaml"

    loader = ConfigLoader()
    if global_path.exists():
        raw_config = loader.parse_yaml(str(global_path))
        if raw_config is None:
            raise ServerNotFoundError(server_name, "global configuration")

        mcp_servers_raw = [
            s for s in raw_config.get("mcp_servers", []) if s.get("type") == "mcp"
        ]

        original_len = len(mcp_servers_raw)
        mcp_servers_raw = [s for s in mcp_servers_raw if s.get("name") != server_name]

        if len(mcp_servers_raw) == original_len:
            raise ServerNotFoundError(server_name, "global configuration")

        mcp_servers = (
            [MCPTool(**s) for s in mcp_servers_raw] if mcp_servers_raw else None
        )

        global_config = GlobalConfig(
            providers=raw_config.get("providers"),
            vectorstores=raw_config.get("vectorstores"),
            execution=raw_config.get("execution"),
            deployment=raw_config.get("deployment"),
            mcp_servers=mcp_servers,
        )
    else:
        raise ServerNotFoundError(server_name, "global configuration")

    return save_global_config(global_config, global_path)

get_mcp_servers_from_agent(agent_path)

Get all MCP servers from agent.yaml tools list.

Parameters:

Name Type Description Default
agent_path Path

Path to agent.yaml file

required

Returns:

Type Description
list[MCPTool]

List of MCPTool objects from agent config (empty list if no MCP tools)

Raises:

Type Description
FileNotFoundError

If agent file doesn't exist

ConfigError

If agent config is invalid YAML

Source code in src/holodeck/config/loader.py
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
def get_mcp_servers_from_agent(agent_path: Path) -> list[MCPTool]:
    """Get all MCP servers from agent.yaml tools list.

    Args:
        agent_path: Path to agent.yaml file

    Returns:
        List of MCPTool objects from agent config (empty list if no MCP tools)

    Raises:
        FileNotFoundError: If agent file doesn't exist
        ConfigError: If agent config is invalid YAML
    """
    loader = ConfigLoader()

    try:
        agent_config = loader.parse_yaml(str(agent_path))
    except FileNotFoundError as e:
        raise FileNotFoundError(
            str(agent_path),
            f"Agent file not found: {agent_path}",
        ) from e

    if agent_config is None:
        return []

    tools = agent_config.get("tools", [])
    if not tools:
        return []

    mcp_servers: list[MCPTool] = []
    for tool in tools:
        if not isinstance(tool, dict):
            continue
        if tool.get("type") != "mcp":
            continue

        try:
            mcp_tool = MCPTool(**tool)
            mcp_servers.append(mcp_tool)
        except PydanticValidationError as e:
            logger.warning(
                f"Failed to parse MCP tool '{tool.get('name', 'unknown')}': {e}"
            )
            continue

    return mcp_servers

get_mcp_servers_from_global(global_path=None)

Get all MCP servers from global config.

Parameters:

Name Type Description Default
global_path Path | None

Optional path to global config (default: ~/.holodeck/config.yaml)

None

Returns:

Type Description
list[MCPTool]

List of MCPTool objects from global config (empty list if no config or servers)

Source code in src/holodeck/config/loader.py
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
def get_mcp_servers_from_global(global_path: Path | None = None) -> list[MCPTool]:
    """Get all MCP servers from global config.

    Args:
        global_path: Optional path to global config (default: ~/.holodeck/config.yaml)

    Returns:
        List of MCPTool objects from global config (empty list if no config or servers)
    """
    loader = ConfigLoader()

    global_config = loader.load_global_config()

    if global_config is None:
        return []

    if global_config.mcp_servers is None:
        return []

    return global_config.mcp_servers

Environment Variable Utilities

Support for dynamic configuration using environment variables with the ${VAR_NAME} pattern.

substitute_env_vars(text)

Substitute environment variables in text using ${VAR_NAME} pattern.

Replaces all occurrences of ${VAR_NAME} with the corresponding environment variable value. Raises ConfigError if a referenced variable does not exist.

Parameters:

Name Type Description Default
text str

Text potentially containing ${VAR_NAME} patterns

required

Returns:

Type Description
str

Text with all environment variables substituted

Raises:

Type Description
ConfigError

If a referenced environment variable does not exist

Example

import os os.environ["API_KEY"] = "secret123" substitute_env_vars("key: ${API_KEY}") 'key: secret123'

Source code in src/holodeck/config/env_loader.py
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
def substitute_env_vars(text: str) -> str:
    """Substitute environment variables in text using ${VAR_NAME} pattern.

    Replaces all occurrences of ${VAR_NAME} with the corresponding environment
    variable value. Raises ConfigError if a referenced variable does not exist.

    Args:
        text: Text potentially containing ${VAR_NAME} patterns

    Returns:
        Text with all environment variables substituted

    Raises:
        ConfigError: If a referenced environment variable does not exist

    Example:
        >>> import os
        >>> os.environ["API_KEY"] = "secret123"
        >>> substitute_env_vars("key: ${API_KEY}")
        'key: secret123'
    """
    # Short-circuit: skip regex if no env var markers present
    if "${" not in text:
        return text

    # Pattern to match ${VAR_NAME} - captures alphanumeric, underscore
    pattern = r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}"

    def replace_var(match: re.Match[str]) -> str:
        """Replace a single ${VAR_NAME} pattern with env value.

        Args:
            match: Regex match object for ${VAR_NAME}

        Returns:
            Environment variable value

        Raises:
            ConfigError: If variable does not exist
        """
        var_name = match.group(1)
        if var_name not in os.environ:
            raise ConfigError(
                var_name,
                f"Environment variable '{var_name}' not found. "
                f"Please set it and try again.",
            )
        return os.environ[var_name]

    return re.sub(pattern, replace_var, text)

get_env_var(key, default=None)

Get environment variable with optional default.

Parameters:

Name Type Description Default
key str

Environment variable name

required
default Any

Default value if variable not set

None

Returns:

Type Description
Any

Environment variable value or default

Source code in src/holodeck/config/env_loader.py
62
63
64
65
66
67
68
69
70
71
72
def get_env_var(key: str, default: Any = None) -> Any:
    """Get environment variable with optional default.

    Args:
        key: Environment variable name
        default: Default value if variable not set

    Returns:
        Environment variable value or default
    """
    return os.environ.get(key, default)

load_env_file(path)

Load environment variables from a .env file.

Parameters:

Name Type Description Default
path str

Path to .env file

required

Returns:

Type Description
dict[str, str]

Dictionary of loaded environment variables

Raises:

Type Description
ConfigError

If file cannot be read

Source code in src/holodeck/config/env_loader.py
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
def load_env_file(path: str) -> dict[str, str]:
    """Load environment variables from a .env file.

    Args:
        path: Path to .env file

    Returns:
        Dictionary of loaded environment variables

    Raises:
        ConfigError: If file cannot be read
    """
    try:
        env_vars = {}
        with open(path) as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if "=" in line:
                    key, value = line.split("=", 1)
                    env_vars[key.strip()] = value.strip()
        return env_vars
    except OSError as e:
        raise ConfigError("env_file", f"Cannot read environment file: {e}") from e

Configuration Validation

Utility for converting Pydantic validation errors into user-friendly messages.

flatten_pydantic_errors(exc)

Flatten Pydantic ValidationError into human-readable messages.

Converts Pydantic's nested error structure into a flat list of user-friendly error messages that include field names and descriptions.

Parameters:

Name Type Description Default
exc ValidationError

Pydantic ValidationError exception

required

Returns:

Type Description
list[str]

List of human-readable error messages, one per field error

Example

from pydantic import BaseModel, ValidationError class Model(BaseModel): ... name: str try: ... Model(name=123) ... except ValidationError as e: ... msgs = flatten_pydantic_errors(e) ... # msgs contains human-readable descriptions

Source code in src/holodeck/config/validator.py
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
def flatten_pydantic_errors(exc: PydanticValidationError) -> list[str]:
    """Flatten Pydantic ValidationError into human-readable messages.

    Converts Pydantic's nested error structure into a flat list of
    user-friendly error messages that include field names and descriptions.

    Args:
        exc: Pydantic ValidationError exception

    Returns:
        List of human-readable error messages, one per field error

    Example:
        >>> from pydantic import BaseModel, ValidationError
        >>> class Model(BaseModel):
        ...     name: str
        >>> try:
        ...     Model(name=123)
        ... except ValidationError as e:
        ...     msgs = flatten_pydantic_errors(e)
        ...     # msgs contains human-readable descriptions
    """
    errors: list[str] = []

    for error in exc.errors():
        loc = error.get("loc", ())
        field_path = ".".join(str(item) for item in loc) if loc else "unknown"

        msg = error.get("msg", "Unknown error")
        error_type = error.get("type", "")

        if error_type == "value_error":
            input_val = error.get("input")
            formatted = f"Field '{field_path}': {msg} (received: {input_val!r})"
        else:
            formatted = f"Field '{field_path}': {msg}"

        errors.append(formatted)

    return errors if errors else ["Validation failed with unknown error"]

Default Configuration

Built-in default constants and embedding dimension resolution.

Constants

Constant Type Description
OLLAMA_DEFAULTS dict Default Ollama provider settings (endpoint, temperature, max_tokens, top_p, api_key)
OLLAMA_EMBEDDING_DEFAULTS dict Default Ollama embedding model (nomic-embed-text:latest)
DEFAULT_EXECUTION_CONFIG dict Default execution settings (timeouts, cache, verbosity)
EMBEDDING_MODEL_DIMENSIONS dict Known embedding model dimension mappings (OpenAI and Ollama models)

get_embedding_dimensions

get_embedding_dimensions(model_name, provider='openai')

Get embedding dimensions for a model.

Resolution order: 1. Known model in EMBEDDING_MODEL_DIMENSIONS 2. Provider default (openai: 1536, ollama: 768) 3. Fallback to 1536 with warning

Parameters:

Name Type Description Default
model_name str | None

Embedding model name (e.g., "text-embedding-3-small")

required
provider str

LLM provider ("openai", "azure_openai", "ollama")

'openai'

Returns:

Type Description
int

Embedding dimensions for the model

Source code in src/holodeck/config/defaults.py
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
def get_embedding_dimensions(
    model_name: str | None,
    provider: str = "openai",
) -> int:
    """Get embedding dimensions for a model.

    Resolution order:
    1. Known model in EMBEDDING_MODEL_DIMENSIONS
    2. Provider default (openai: 1536, ollama: 768)
    3. Fallback to 1536 with warning

    Args:
        model_name: Embedding model name (e.g., "text-embedding-3-small")
        provider: LLM provider ("openai", "azure_openai", "ollama")

    Returns:
        Embedding dimensions for the model
    """
    if model_name and model_name in EMBEDDING_MODEL_DIMENSIONS:
        return EMBEDDING_MODEL_DIMENSIONS[model_name]

    if provider == "ollama":
        if model_name:
            logger.warning(
                f"Unknown Ollama model '{model_name}', assuming 768 dimensions. "
                "Set 'embedding_dimensions' explicitly if different."
            )
        return 768

    if model_name:
        logger.warning(
            f"Unknown embedding model '{model_name}', assuming 1536 dimensions. "
            f"Supported: {', '.join(EMBEDDING_MODEL_DIMENSIONS.keys())}. "
            "Set 'embedding_dimensions' explicitly if different."
        )
    return 1536

Configuration Context

Request-scoped context variable for passing the agent base directory through async call stacks without explicit parameter threading.

agent_base_dir

from contextvars import ContextVar

agent_base_dir: ContextVar[str | None] = ContextVar("agent_base_dir", default=None)

Set at CLI entry points (e.g., holodeck test, holodeck chat) to the parent directory of agent.yaml. Read downstream by tools and resolvers that need to resolve relative file paths.

Usage:

# At CLI entry point:
from holodeck.config.context import agent_base_dir
agent_base_dir.set(str(Path(agent_yaml_path).parent))

# Anywhere downstream:
base_dir = agent_base_dir.get()  # Returns str | None

ConfigManager

Manager class for configuration file operations: creating defaults, resolving paths, generating YAML, and writing config files.

ConfigManager

Manager for configuration operations to improve testability.

create_default_config() staticmethod

Create a default GlobalConfig with sample settings.

Source code in src/holodeck/config/manager.py
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@staticmethod
def create_default_config() -> GlobalConfig:
    """Create a default GlobalConfig with sample settings."""
    # Create a default LLM provider
    default_provider = LLMProvider(
        provider=ProviderEnum.OPENAI,
        name="gpt-4",
        temperature=0.3,
        max_tokens=1000,
        api_key=SecretStr("your-openai-api-key-here"),
        endpoint=None,
    )

    # Create a default vectorstore config
    default_vectorstore = VectorstoreConfig(
        provider="postgres",
        connection_string="postgresql://user:password@localhost:5432/vectorstore",
        options={"sslmode": "prefer"},
    )

    # Create a default execution config
    default_execution = ExecutionConfig(
        file_timeout=30,
        llm_timeout=30,
        download_timeout=30,
        cache_enabled=True,
        cache_dir=".cache",
        verbose=False,
        quiet=False,
    )

    # Create a default deployment config
    default_deployment = DeploymentConfig(
        registry=RegistryConfig(url="docker.io", repository="holodeck/agent"),
        target=CloudTargetConfig(
            provider=CloudProvider.AWS,
            aws=AWSAppRunnerConfig(region="us-east-1"),
        ),
    )

    # Create the global config
    return GlobalConfig(
        providers={"openai": default_provider},
        vectorstores={"postgres": default_vectorstore},
        execution=default_execution,
        deployment=default_deployment,
        mcp_servers=None,
    )

get_config_path(global_config, project_config) staticmethod

Determine the configuration file path and type.

Parameters:

Name Type Description Default
global_config bool

Whether to use global configuration.

required
project_config bool

Whether to use project configuration.

required

Returns:

Type Description
tuple[Path, str]

Tuple of (config_path, config_type_name)

Source code in src/holodeck/config/manager.py
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
@staticmethod
def get_config_path(global_config: bool, project_config: bool) -> tuple[Path, str]:
    """Determine the configuration file path and type.

    Args:
        global_config: Whether to use global configuration.
        project_config: Whether to use project configuration.

    Returns:
        Tuple of (config_path, config_type_name)
    """
    if global_config:
        return Path.home() / ".holodeck" / "config.yaml", "global"
    else:
        # Default to project config if neither or project specified
        return Path.cwd() / "config.yaml", "project"

generate_config_content(config) staticmethod

Generate YAML content for the configuration.

Source code in src/holodeck/config/manager.py
 96
 97
 98
 99
100
@staticmethod
def generate_config_content(config: GlobalConfig) -> str:
    """Generate YAML content for the configuration."""
    config_dict = config.model_dump(exclude_unset=True, mode="json")
    return yaml.dump(config_dict, default_flow_style=False, sort_keys=False)

write_config(path, content) staticmethod

Write configuration content to file.

Source code in src/holodeck/config/manager.py
102
103
104
105
106
107
@staticmethod
def write_config(path: Path, content: str) -> None:
    """Write configuration content to file."""
    path.parent.mkdir(parents=True, exist_ok=True)
    with open(path, "w", encoding="utf-8") as f:
        f.write(content)

Schema Validation

JSON Schema validation for LLM response formats, aligned with OpenAI structured output requirements. Only Basic JSON Schema keywords are supported.

ALLOWED_KEYWORDS

ALLOWED_KEYWORDS = {
    "type", "properties", "required", "additionalProperties",
    "items", "enum", "default", "description", "minimum", "maximum",
}

SchemaValidator

SchemaValidator

Validates JSON Schema definitions for response formats.

validate_schema(schema, schema_name='schema') staticmethod

Validate a JSON schema against Basic JSON Schema specification.

Parameters:

Name Type Description Default
schema dict[str, Any] | str

Schema as dict (inline) or JSON string

required
schema_name str

Name for error messages (e.g., "response_format")

'schema'

Returns:

Type Description
dict[str, Any]

Validated schema as dictionary

Raises:

Type Description
ValueError

If schema is invalid or uses unsupported keywords

Source code in src/holodeck/config/schema.py
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
@staticmethod
def validate_schema(
    schema: dict[str, Any] | str, schema_name: str = "schema"
) -> dict[str, Any]:
    """Validate a JSON schema against Basic JSON Schema specification.

    Args:
        schema: Schema as dict (inline) or JSON string
        schema_name: Name for error messages (e.g., "response_format")

    Returns:
        Validated schema as dictionary

    Raises:
        ValueError: If schema is invalid or uses unsupported keywords
    """
    # Convert string to dict if needed
    if isinstance(schema, str):
        try:
            parsed = json.loads(schema)
            if not isinstance(parsed, dict):
                raise ValueError(f"Invalid JSON in {schema_name}: must be object")
            schema_dict = parsed
        except json.JSONDecodeError as e:
            raise ValueError(f"Invalid JSON in {schema_name}: {str(e)}") from e
    else:
        schema_dict = schema

    # Validate schema structure using our custom validation
    # We don't use jsonschema's check_schema() because it validates against
    # the full JSON Schema metaschema, which is stricter than what OpenAI's
    # structured output requires (e.g., additionalProperties: false is valid
    # for OpenAI but fails Draft 4/7/2020-12 metaschema validation)
    try:
        SchemaValidator._check_allowed_keywords(schema_dict, schema_name)
        SchemaValidator._validate_schema_structure(schema_dict, schema_name)
    except ValueError:
        raise
    except Exception as e:
        raise ValueError(f"Invalid {schema_name} schema: {str(e)}") from e

    return schema_dict

load_schema_from_file(file_path, base_dir=None) staticmethod

Load and validate a JSON schema from file.

Parameters:

Name Type Description Default
file_path str

Path to schema file (relative to base_dir or absolute)

required
base_dir str | Path | None

Base directory for relative paths (defaults to cwd)

None

Returns:

Type Description
dict[str, Any]

Validated schema as dictionary

Raises:

Type Description
FileNotFoundError

If schema file doesn't exist

ValueError

If schema is invalid

Source code in src/holodeck/config/schema.py
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
@staticmethod
def load_schema_from_file(
    file_path: str, base_dir: str | Path | None = None
) -> dict[str, Any]:
    """Load and validate a JSON schema from file.

    Args:
        file_path: Path to schema file (relative to base_dir or absolute)
        base_dir: Base directory for relative paths (defaults to cwd)

    Returns:
        Validated schema as dictionary

    Raises:
        FileNotFoundError: If schema file doesn't exist
        ValueError: If schema is invalid
    """
    # Resolve file path
    if base_dir is None:
        # Try to get from context variable
        from holodeck.config.context import agent_base_dir

        base_dir = agent_base_dir.get()

    base_dir = Path.cwd() if base_dir is None else Path(base_dir)

    path = Path(file_path)
    if not path.is_absolute():
        path = base_dir / file_path

    # Check file exists
    if not path.exists():
        raise FileNotFoundError(
            f"Schema file not found: {path}\n" f"Expected file at: {path.resolve()}"
        )

    # Read and parse JSON
    try:
        with open(path, encoding="utf-8") as f:
            loaded = json.load(f)
            if not isinstance(loaded, dict):
                raise ValueError(f"Schema file {path} must be JSON object")
            schema_dict = loaded
    except json.JSONDecodeError as e:
        raise ValueError(f"Invalid JSON in schema file {path}: {str(e)}") from e
    except OSError as e:
        raise FileNotFoundError(
            f"Failed to read schema file {path}: {str(e)}"
        ) from e

    # Validate schema
    SchemaValidator.validate_schema(schema_dict, f"schema file {path}")

    return schema_dict