diff --git a/reme/steps/evolve/_auto_resource.py b/reme/steps/evolve/_auto_resource.py index 9c90168c..83623cba 100644 --- a/reme/steps/evolve/_auto_resource.py +++ b/reme/steps/evolve/_auto_resource.py @@ -140,6 +140,14 @@ class BaseAutoResourceStep(BaseStep): return before_bytes is not None return after_path != before_path or before_bytes != after_bytes + async def _refresh_day_index(self, day: str) -> dict: + """Refresh and return the derived daily index for a resource-note change.""" + daily_dir = self.config_value("daily_dir") + self.logger.info(f"[{self.name}] refresh index start date={day} daily_dir={daily_dir}") + index_payload = await refresh_day_index(self.file_store, day, daily_dir) + self.logger.info(f"[{self.name}] refresh index done date={day}") + return index_payload + def _find_resource_note(self, notes: list[dict], file_path: str, fallback_path: str) -> dict | None: source = self._source_resource_link(file_path) for note in notes: @@ -259,9 +267,7 @@ class BaseAutoResourceStep(BaseStep): await self.file_store.delete([note_rel]) self.logger.info(f"[{self.name}] catalog delete done note={note_rel}") - self.logger.info(f"[{self.name}] refresh index start date={date_str} daily_dir={daily_dir}") - index_payload = await refresh_day_index(self.file_store, date_str, daily_dir) - self.logger.info(f"[{self.name}] refresh index done date={date_str}") + index_payload = await self._refresh_day_index(date_str) self.context.response.success = True self.context.response.answer = f"Deleted resource note: {note_rel}" diff --git a/reme/steps/evolve/auto_image_resource.py b/reme/steps/evolve/auto_image_resource.py index 50a5e7ab..65935d06 100644 --- a/reme/steps/evolve/auto_image_resource.py +++ b/reme/steps/evolve/auto_image_resource.py @@ -11,7 +11,7 @@ from agentscope.message import Base64Source, DataBlock, TextBlock, UserMsg from agentscope.model import ChatModelBase from pydantic import BaseModel, Field -from ..file_io import is_image_file, refresh_day_index +from ..file_io import is_image_file from ..file_io._path import IMAGE_MIME_BY_EXT, IMAGE_SUFFIXES from ._auto_resource import _SOURCE_RESOURCE_KEY, _sanitize_note_name, BaseAutoResourceStep from ...components import R @@ -477,7 +477,7 @@ class AutoImageResourceStep(BaseAutoResourceStep): modified = self._note_modified(before_note_path, before_note_bytes, note_path) self.context.response.metadata.update({"path": note_path, "created": note_created, "modified": modified}) - index_payload = await refresh_day_index(self.file_store, date_str, daily_dir) + index_payload = await self._refresh_day_index(date_str) self.context.response.success = True self.context.response.answer = f"Captioned image resource {file_path} -> {note_path}" diff --git a/reme/steps/evolve/auto_resource.py b/reme/steps/evolve/auto_resource.py index 1ce6aec1..6db314b6 100644 --- a/reme/steps/evolve/auto_resource.py +++ b/reme/steps/evolve/auto_resource.py @@ -4,6 +4,7 @@ import copy import inspect from ...components import R +from ...enumeration import ComponentEnum from ..base_step import BaseStep from ._auto_resource import BaseAutoResourceStep, _results_answer @@ -35,7 +36,23 @@ class AutoResourceStep(BaseStep): """Resolve configured processors and allocate their per-invocation batches.""" raw_specs = self.dispatch_step_specs if not raw_specs: - raise RuntimeError("AutoResourceStep requires resource processors in dispatch_steps") + registry = self.app_context.registry if self.app_context is not None else R + raw_specs = [ + backend + for backend, step_cls in registry.get_all(ComponentEnum.STEP).items() + if isinstance(step_cls, type) + and issubclass(step_cls, BaseAutoResourceStep) + and step_cls.resource_fallback + ] + if len(raw_specs) != 1: + candidates = ", ".join(sorted(raw_specs)) or "none" + raise RuntimeError( + "AutoResourceStep without dispatch_steps requires exactly one registered " + f"fallback resource processor; found: {candidates}", + ) + self.logger.warning( + f"[{self.name}] dispatch_steps omitted; using registered fallback processor={raw_specs[0]}", + ) routes: list[_ProcessorRoute] = [] fallback_indexes: list[int] = [] diff --git a/reme/steps/evolve/auto_text_resource.py b/reme/steps/evolve/auto_text_resource.py index c89349e7..1dcee228 100644 --- a/reme/steps/evolve/auto_text_resource.py +++ b/reme/steps/evolve/auto_text_resource.py @@ -5,7 +5,6 @@ import uuid import aiofiles from ...components import R -from ..file_io import refresh_day_index from ._evolve import agent_reply_result_text from ._auto_resource import BaseAutoResourceStep @@ -176,9 +175,7 @@ class AutoTextResourceStep(BaseAutoResourceStep): return modified = self._note_modified(before_note_path, before_note_bytes, note_path) - self.logger.info(f"[{self.name}] refresh index start date={date_str} daily_dir={daily_dir}") - index_payload = await refresh_day_index(self.file_store, date_str, daily_dir) - self.logger.info(f"[{self.name}] refresh index done date={date_str}") + index_payload = await self._refresh_day_index(date_str) self.context.response.success = True self.context.response.answer = agent_reply_result_text(result) diff --git a/tests/unit/test_auto_image_steps.py b/tests/unit/test_auto_image_steps.py index 2b2ca590..011b59a9 100644 --- a/tests/unit/test_auto_image_steps.py +++ b/tests/unit/test_auto_image_steps.py @@ -28,7 +28,9 @@ from PIL import Image from reme.components import R from reme.components.agent_wrapper import BaseAgentWrapper +from reme.components.component_registry import ComponentRegistry from reme.components.file_store import LocalFileStore +from reme.components.job import BaseJob from reme.components.runtime_context import RuntimeContext from reme.enumeration import ComponentEnum from reme.steps.evolve._auto_resource import BaseAutoResourceStep @@ -725,6 +727,85 @@ def test_auto_resource_router_requires_the_fallback_processor_to_be_last(): step._processor_routes() +def test_auto_resource_router_discovers_unique_fallback_for_legacy_config(): + """An old Step spec without dispatch_steps keeps its text-resource behavior.""" + app_ctx = _make_app_context(Path.cwd()) + app_ctx.registry = R.copy() + step = AutoResourceStep(app_context=app_ctx) + + routes = step._processor_routes() + + assert [(spec, step_cls) for spec, step_cls, _ in routes] == [ + ({"backend": "auto_text_resource_step"}, AutoTextResourceStep), + ] + + +def test_auto_resource_legacy_job_config_dispatches_text_resource(): + """A real BaseJob accepts the pre-router auto_resource Step configuration.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmpdir, temp_chdir(tmpdir): + cwd = Path.cwd() + app_ctx = _make_app_context(cwd) + app_ctx.registry = R.copy() + fs = LocalFileStore(name="test_store", embedding_store="") + await fs.start() + _install_file_jobs(app_ctx, fs) + wrapper = _FakeAgentWrapper() + job = BaseJob( + name="legacy_auto_resource", + app_context=app_ctx, + steps=[ + { + "backend": "auto_resource_step", + "file_store": fs, + "agent_wrapper": wrapper, + }, + ], + ) + await job.start() + try: + source = cwd / "resource" / "2026-01-01" / "legacy.txt" + source.parent.mkdir(parents=True, exist_ok=True) + source.write_text("legacy text resource", encoding="utf-8") + + response = await job(changes=[{"change": "added", "path": str(source)}]) + + assert response.success is True + assert response.metadata["processed"] == 1 + assert response.metadata["results"][0]["success"] is True + assert "legacy text resource" in wrapper.inputs + finally: + await job.close() + await fs.close() + + asyncio.run(run()) + + +def test_auto_resource_router_rejects_ambiguous_registered_fallbacks(): + """Legacy discovery fails closed when plugins register multiple fallbacks.""" + app_ctx = _make_app_context(Path.cwd()) + app_ctx.registry = R.copy() + app_ctx.registry.add("second_text_fallback", AutoTextResourceStep, owner=__name__) + step = AutoResourceStep(app_context=app_ctx) + + with pytest.raises(RuntimeError, match="exactly one registered fallback resource processor"): + step._processor_routes() + + explicit = AutoResourceStep(app_context=app_ctx, dispatch_steps=["auto_text_resource_step"]) + assert len(explicit._processor_routes()) == 1 + + +def test_auto_resource_router_rejects_missing_registered_fallback(): + """Legacy discovery fails clearly when no fallback processor is installed.""" + app_ctx = _make_app_context(Path.cwd()) + app_ctx.registry = ComponentRegistry() + step = AutoResourceStep(app_context=app_ctx) + + with pytest.raises(RuntimeError, match=r"fallback resource processor; found: none"): + step._processor_routes() + + def test_resource_processors_have_canonical_registrations_and_isolated_prompts(): """Each modality owns one backend and loads only its module-local prompts.""" assert R.get(ComponentEnum.STEP, "auto_resource_step") is AutoResourceStep @@ -875,7 +956,7 @@ def test_auto_image_reports_modified_when_index_refresh_fails_after_write(): async def fail_refresh(*_args, **_kwargs): raise RuntimeError("index refresh failed") - with patch("reme.steps.evolve.auto_image_resource.refresh_day_index", new=fail_refresh): + with patch("reme.steps.evolve._auto_resource.refresh_day_index", new=fail_refresh): resp = await _run_step(step, [{"change": "added", "path": str(source)}]) result = resp.metadata["results"][0]