diff --git a/src/agentarts/toolkit/operations/runtime/deploy.py b/src/agentarts/toolkit/operations/runtime/deploy.py index 8c3e9182..80cddb64 100644 --- a/src/agentarts/toolkit/operations/runtime/deploy.py +++ b/src/agentarts/toolkit/operations/runtime/deploy.py @@ -114,11 +114,7 @@ def create_agentarts_runtime( if runtime_cfg.storage_config: sc = runtime_cfg.storage_config.to_dict() - # to_dict() returns {"sfs_turbo": [{...}]} (list) or {} (unconfigured). - # Only enforce required fields when the user explicitly opted - # into SFS Turbo by providing sfs_turbo_id. An unset/all-null - # storage_config (e.g. from `agentarts init` / `agentarts config`) - # is treated as "not configured" so deploy is never blocked. + # Validate sfs_turbo: mount_path is required when sfs_turbo_id is set. st_list = sc.get("sfs_turbo") or [] if st_list: st = st_list[0] @@ -126,7 +122,8 @@ def create_agentarts_runtime( if not st.get("mount_path"): msg = "storage_config.sfs_turbo.mount_path is required when sfs_turbo_id is set" raise ValueError(msg) - storage_config = sc + if sc: + storage_config = sc if runtime_cfg.environment_variables: env_vars = [{"key": kv.key, "value": kv.value} for kv in runtime_cfg.environment_variables if kv.value] diff --git a/src/agentarts/toolkit/operations/runtime/init.py b/src/agentarts/toolkit/operations/runtime/init.py index 2630cb72..8f6e7472 100644 --- a/src/agentarts/toolkit/operations/runtime/init.py +++ b/src/agentarts/toolkit/operations/runtime/init.py @@ -239,6 +239,8 @@ def create_config_file( sfs_path: null mount_path: null # required when using SFS Turbo read_only: false + session_storage: + mount_path: null # Session storage mount path in the container environment_variables:{env_vars_yaml} diff --git a/src/agentarts/toolkit/utils/runtime/config.py b/src/agentarts/toolkit/utils/runtime/config.py index 59a27158..93dc4b48 100644 --- a/src/agentarts/toolkit/utils/runtime/config.py +++ b/src/agentarts/toolkit/utils/runtime/config.py @@ -225,6 +225,24 @@ def to_dict(self) -> dict[str, Any]: return {k: v for k, v in data.items() if v not in ([], {})} +class SessionStorageConfig(BaseModel): + """Session storage configuration.""" + + mount_path: str | None = Field( + default=None, + description="Session storage mount path in the container", + ) + + model_config = { + "extra": "allow", + } + + def to_dict(self) -> dict[str, Any]: + """Convert configuration to dictionary.""" + data = self.model_dump(mode="json", exclude_none=True) + return {k: v for k, v in data.items() if v not in ([], {})} + + class StorageConfig(BaseModel): """Storage configuration for the runtime.""" @@ -232,6 +250,10 @@ class StorageConfig(BaseModel): default_factory=SfsTurboConfig, description="SFS Turbo storage configuration", ) + session_storage: SessionStorageConfig | None = Field( + default_factory=SessionStorageConfig, + description="Session storage configuration", + ) model_config = { "extra": "allow", @@ -242,15 +264,19 @@ def to_dict(self) -> dict[str, Any]: The API expects ``sfs_turbo`` as an **array** of config objects (not a single object). This method wraps the single config object - in a list. Returns ``{}`` when SFS is not configured (no sfs_turbo_id). + in a list. Returns ``{}`` when nothing is configured. """ + result: dict[str, Any] = {} st = self.sfs_turbo - if st is None or not st.sfs_turbo_id: - return {} - item = st.to_dict() - if not item: - return {} - return {"sfs_turbo": [item]} + if st is not None and st.sfs_turbo_id: + item = st.to_dict() + if item: + result["sfs_turbo"] = [item] + if self.session_storage is not None: + ss = self.session_storage.to_dict() + if ss: + result["session_storage"] = ss + return result class NetworkConfig(BaseModel): diff --git a/tests/unit/toolkit/operations/runtime/test_config.py b/tests/unit/toolkit/operations/runtime/test_config.py index 4d2ed72b..fbe77da0 100644 --- a/tests/unit/toolkit/operations/runtime/test_config.py +++ b/tests/unit/toolkit/operations/runtime/test_config.py @@ -28,6 +28,7 @@ CustomJWTAuthConfig, InboundIdentityConfig, SfsTurboConfig, + SessionStorageConfig, StorageConfig, ) @@ -588,6 +589,32 @@ def test_storage_config_default_to_dict_is_empty(self): """A default StorageConfig (no sfs_turbo_id) serializes to {}.""" assert StorageConfig().to_dict() == {} + def test_storage_config_to_dict_session_storage_only(self): + """StorageConfig with only session_storage serializes correctly.""" + cfg = StorageConfig( + session_storage=SessionStorageConfig(mount_path="/home/user/sessions"), + ) + result = cfg.to_dict() + assert result == {"session_storage": {"mount_path": "/home/user/sessions"}} + + def test_storage_config_to_dict_both_fields(self): + """StorageConfig with both sfs_turbo and session_storage.""" + cfg = StorageConfig( + sfs_turbo=SfsTurboConfig( + sfs_turbo_id="12345678-1234-1234-1234-123456789012", + mount_path="/data", + ), + session_storage=SessionStorageConfig(mount_path="/home/user/sessions"), + ) + result = cfg.to_dict() + assert result == { + "sfs_turbo": [{ + "sfs_turbo_id": "12345678-1234-1234-1234-123456789012", + "mount_path": "/data", + }], + "session_storage": {"mount_path": "/home/user/sessions"}, + } + def test_invalid_sfs_turbo_id_rejected(self): """A non-UUID sfs_turbo_id is rejected by validation.""" import pytest diff --git a/tests/unit/toolkit/operations/runtime/test_deploy.py b/tests/unit/toolkit/operations/runtime/test_deploy.py index 6ea8ac87..3c7af6d6 100644 --- a/tests/unit/toolkit/operations/runtime/test_deploy.py +++ b/tests/unit/toolkit/operations/runtime/test_deploy.py @@ -10,6 +10,7 @@ from agentarts.toolkit.utils.runtime.config import ( AgentArtsConfig, AgentArtsRuntimeConfig, + SessionStorageConfig, SfsTurboConfig, StorageConfig, ) @@ -221,6 +222,78 @@ def test_storage_config_forwarded(self, mock_client, tmp_path, monkeypatch): }] } + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") + def test_storage_config_session_storage_only_forwarded(self, mock_client, tmp_path, monkeypatch): + """session_storage alone is forwarded without sfs_turbo.""" + monkeypatch.chdir(tmp_path) + + mock_client_instance = MagicMock() + mock_client.return_value = mock_client_instance + mock_client_instance.create_or_update_agent.return_value = { + "id": "agent-123", + "latest_version": "v1", + } + + agent_config = AgentArtsConfig( + runtime=AgentArtsRuntimeConfig( + storage_config=StorageConfig( + session_storage=SessionStorageConfig(mount_path="/home/user/sessions"), + ), + ), + ) + + create_agentarts_runtime( + agent_name="test-agent", + swr_image="swr.cn-north-4.myhuaweicloud.com/org/repo:latest", + region="cn-north-4", + agent_config=agent_config, + ) + + call_args = mock_client_instance.create_or_update_agent.call_args + assert call_args.kwargs["storage_config"] == { + "session_storage": {"mount_path": "/home/user/sessions"}, + } + + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") + def test_storage_config_both_fields_forwarded(self, mock_client, tmp_path, monkeypatch): + """Both sfs_turbo and session_storage are forwarded together.""" + monkeypatch.chdir(tmp_path) + + mock_client_instance = MagicMock() + mock_client.return_value = mock_client_instance + mock_client_instance.create_or_update_agent.return_value = { + "id": "agent-123", + "latest_version": "v1", + } + + agent_config = AgentArtsConfig( + runtime=AgentArtsRuntimeConfig( + storage_config=StorageConfig( + sfs_turbo=SfsTurboConfig( + sfs_turbo_id="12345678-1234-1234-1234-123456789012", + mount_path="/data", + ), + session_storage=SessionStorageConfig(mount_path="/home/user/sessions"), + ), + ), + ) + + create_agentarts_runtime( + agent_name="test-agent", + swr_image="swr.cn-north-4.myhuaweicloud.com/org/repo:latest", + region="cn-north-4", + agent_config=agent_config, + ) + + call_args = mock_client_instance.create_or_update_agent.call_args + assert call_args.kwargs["storage_config"] == { + "sfs_turbo": [{ + "sfs_turbo_id": "12345678-1234-1234-1234-123456789012", + "mount_path": "/data", + }], + "session_storage": {"mount_path": "/home/user/sessions"}, + } + @patch("agentarts.toolkit.operations.runtime.deploy.RuntimeClient") def test_storage_config_requires_mount_path_when_id_set(self, mock_client, tmp_path, monkeypatch): """When sfs_turbo_id is set, mount_path is required (returns None)."""