diff --git a/copilotj/core/config.py b/copilotj/core/config.py index 422b52d9..c2aa4216 100644 --- a/copilotj/core/config.py +++ b/copilotj/core/config.py @@ -9,6 +9,7 @@ import os import shutil import uuid +from collections.abc import Callable from dataclasses import dataclass, replace from pathlib import Path @@ -18,6 +19,8 @@ __all__ = [ "Config", + "ConfigLike", + "ConfigProvider", "load_config", "SINGLE_CLIENT_ID", "get_home", @@ -27,6 +30,7 @@ "load_managed_config", "save_managed_config", "bootstrap_assets", + "resolve_config", "resolve_vision_config", ] @@ -92,6 +96,25 @@ def vision_available(self) -> bool: return self.llm_supports_vision or self.vlm_configured +# A live config source: a zero-arg callable returning the current ``Config``. +# ``LeaderDriven`` passes one of these to factory tools / ``PluginTools`` so they +# observe ``update_config()`` changes without being rebuilt. A static ``Config`` +# is always accepted too (``resolve_config`` returns it unchanged). +ConfigProvider = Callable[[], Config] +ConfigLike = Config | ConfigProvider + + +def resolve_config(cfg: ConfigLike) -> Config: + """Resolve a config that may be a static ``Config`` or a zero-arg callable returning one. + + Factory-built tools and ``PluginTools`` receive either a ``Config`` instance (snapshot) + or a ``ConfigProvider`` (live) and resolve it at execution time, so a tool observes + config changes performed via ``update_config`` without being rebuilt. A frozen + ``Config`` is not callable, so ``callable(cfg)`` cleanly distinguishes the two forms. + """ + return cfg() if callable(cfg) else cfg + + def load_config() -> Config: """Load .env files and return a Config object. diff --git a/copilotj/multiagent/agent_loader.py b/copilotj/multiagent/agent_loader.py index 67757b55..71c60be7 100644 --- a/copilotj/multiagent/agent_loader.py +++ b/copilotj/multiagent/agent_loader.py @@ -9,7 +9,7 @@ import tomllib from copilotj.core import FunctionTool, ModelClient, Tool -from copilotj.core.config import Config +from copilotj.core.config import ConfigLike from copilotj.multiagent.Executor import Executor __all__ = ["load_agent_configs"] @@ -19,11 +19,11 @@ GLOB_PATTERN = os.path.join(os.path.dirname(__file__), "agent_configs", "*_agent.toml") -def load_agent_configs(*, model_client: ModelClient, cfg: Config): +def load_agent_configs(*, model_client: ModelClient, cfg: ConfigLike): return _load_agent_configs(GLOB_PATTERN, model_client=model_client, cfg=cfg) -def _load_agent_configs(glob_pattern: str, *, model_client: ModelClient, cfg: Config): +def _load_agent_configs(glob_pattern: str, *, model_client: ModelClient, cfg: ConfigLike): agents = {} configs = glob.glob(glob_pattern) _log.info("Found %d configs in %s.", len(configs), glob_pattern) diff --git a/copilotj/multiagent/leader_multiagent.py b/copilotj/multiagent/leader_multiagent.py index 95d35f1d..58361b9f 100644 --- a/copilotj/multiagent/leader_multiagent.py +++ b/copilotj/multiagent/leader_multiagent.py @@ -30,7 +30,7 @@ Tool, new_model_client, ) -from copilotj.core.config import Config +from copilotj.core.config import Config, ConfigLike from copilotj.multiagent.agent_loader import load_agent_configs from copilotj.multiagent.Executor import Executor from copilotj.multiagent.kb_tools import ( @@ -106,7 +106,7 @@ def __init__( agents: dict[str, Executor] | None = None, model_client: ModelClient, apis: ClientPluginAPI, - cfg: Config, + cfg: ConfigLike, ): super().__init__(name, description, model_client=model_client) @@ -142,7 +142,7 @@ def __init__( FunctionTool(workflow_tools.export_workflow, PROMPT_TOOL_EXPORT_WORKFLOW), FunctionTool(self.execute_workflow, PROMPT_TOOL_EXECUTE_WORKFLOW), FunctionTool( - batch_qc.make_batch_precheck(lambda: self._cfg), + batch_qc.make_batch_precheck(self._cfg), PROMPT_TOOL_BATCH_PRECHECK, name="batch_precheck", display_name="Batch Pre-check QC", @@ -424,7 +424,9 @@ def __init__( # Load agents defined in configurations try: - self.specialized_agents = load_agent_configs(model_client=wrapped_model_client, cfg=cfg) + # Pass a live config provider (not the snapshot) so factory-built tools observe + # config changes from update_config() without being rebuilt. + self.specialized_agents = load_agent_configs(model_client=wrapped_model_client, cfg=self.get_config) for agent in self.specialized_agents.values(): self.register(agent) @@ -451,12 +453,16 @@ def __init__( model_client=wrapped_model_client, agents=self.specialized_agents, apis=apis, - cfg=cfg, + cfg=self.get_config, ) self.workflow_saver.chat_history = self.leader_agent.chat_history self.leader_agent.set_save_workflow_handler(self.workflow_saver.save) self.log_info("Leader Agent initialized.") + def get_config(self) -> Config: + """Return the current live config (reflects ``update_config()``).""" + return self._cfg + def update_config( self, *, diff --git a/copilotj/multiagent/research_tools.py b/copilotj/multiagent/research_tools.py index 9c6c6b16..a9436f50 100644 --- a/copilotj/multiagent/research_tools.py +++ b/copilotj/multiagent/research_tools.py @@ -21,7 +21,7 @@ from sklearn.metrics.pairwise import cosine_similarity from tavily import TavilyClient -from copilotj.core.config import Config +from copilotj.core.config import ConfigLike, resolve_config from copilotj.core.embedding import get_embeddings from copilotj.core.kb import ( DEFAULT_INDEX_DIR, @@ -254,8 +254,8 @@ def ddg_search( time.sleep(1 * attempt) -def make_tavily_search(cfg: Config): - """Factory: return a ``tavily_search`` callable bound to *cfg*.""" +def make_tavily_search(cfg: ConfigLike): + """Factory: return a ``tavily_search`` callable bound to *cfg* (static or live provider).""" def tavily_search( query: Annotated[str, "Search query string describing what information you need"], @@ -265,10 +265,11 @@ def tavily_search( include_raw_content: Annotated[bool, "Whether to include raw content from sources"] = False, ) -> str | list[dict[str, str]]: try: - if not cfg.tavily_api_key: + _cfg = resolve_config(cfg) + if not _cfg.tavily_api_key: return "Tavily API key not found. Please set COPILOTJ_TAVILY_API_KEY environment variable." - client = TavilyClient(api_key=cfg.tavily_api_key) + client = TavilyClient(api_key=_cfg.tavily_api_key) response = client.search( query=query, @@ -589,8 +590,8 @@ async def imagej_retriever(query: Annotated[str, "Search query string describing # deep research tool -def make_deep_research(cfg: Config): - """Factory: return a ``deep_research`` callable bound to *cfg*.""" +def make_deep_research(cfg: ConfigLike): + """Factory: return a ``deep_research`` callable bound to *cfg* (static or live provider).""" _tavily_search = make_tavily_search(cfg) async def deep_research(query: Annotated[str, "The research question to investigate thoroughly"]) -> str: @@ -690,8 +691,8 @@ async def download_resource(code: Annotated[str, "The code to download the resou # BioImage Model Zoo tools -def make_bioimage_search_models(cfg: Config): - """Factory: return a ``bioimage_search_models`` callable bound to *cfg*.""" +def make_bioimage_search_models(cfg: ConfigLike): + """Factory: return a ``bioimage_search_models`` callable bound to *cfg* (static or live provider).""" def bioimage_search_models( query: Annotated[str | None, "Free-text search query for model name, description, or keywords"] = None, @@ -705,9 +706,7 @@ def bioimage_search_models( Use this to find models for specific tasks like denoising, segmentation, detection, etc. """ try: - from copilotj.core.config import load_config - - _cfg = cfg or load_config() + _cfg = resolve_config(cfg) # Fetch collection models = _fetch_collection( base_url=_cfg.bioimage_model_zoo_url, @@ -817,8 +816,8 @@ def bioimage_get_model_info(model_id: Annotated[str, "Model ID or name to get de return f"Error getting model info: {str(e)}" -def make_bioimage_download_model(cfg: Config): - """Factory: return a ``bioimage_download_model`` callable bound to *cfg*.""" +def make_bioimage_download_model(cfg: ConfigLike): + """Factory: return a ``bioimage_download_model`` callable bound to *cfg* (static or live provider).""" def bioimage_download_model( model_id: Annotated[str, "Model ID or name to download"], @@ -831,6 +830,7 @@ def bioimage_download_model( Returns the local file path where the model was downloaded. """ try: + _cfg = resolve_config(cfg) if dest_dir: dest_path = Path(dest_dir) else: @@ -839,9 +839,9 @@ def bioimage_download_model( dest_path.mkdir(parents=True, exist_ok=True) models = _fetch_collection( - base_url=cfg.bioimage_model_zoo_url, - cache_dir=Path(cfg.bioimage_model_zoo_cache).resolve(), - ttl_seconds=cfg.bioimage_model_zoo_cache_ttl, + base_url=_cfg.bioimage_model_zoo_url, + cache_dir=Path(_cfg.bioimage_model_zoo_cache).resolve(), + ttl_seconds=_cfg.bioimage_model_zoo_cache_ttl, force_refresh=False, ) diff --git a/copilotj/multiagent/tools.py b/copilotj/multiagent/tools.py index 525a7926..47efbebb 100644 --- a/copilotj/multiagent/tools.py +++ b/copilotj/multiagent/tools.py @@ -14,7 +14,7 @@ from typing import Annotated from copilotj.core import ImageMessage, TextMessage, new_vlm_model_client -from copilotj.core.config import Config +from copilotj.core.config import Config, ConfigLike, resolve_config from copilotj.core.lifecycle import register_cleanup from copilotj.plugin.api import ClientPluginAPI from copilotj.util import JupyterNotebook @@ -29,11 +29,18 @@ class PluginTools: - def __init__(self, apis: ClientPluginAPI, *, cfg: Config): + def __init__(self, apis: ClientPluginAPI, *, cfg: ConfigLike): super().__init__() self.apis = apis + # ``cfg`` may be a static ``Config`` snapshot or a live ``ConfigProvider``. Resolve lazily via + # the ``cfg`` property so config changes from ``update_config`` are observed without rebuilding. self._cfg = cfg + @property + def cfg(self) -> Config: + """Resolve the current config (static instance or live provider).""" + return resolve_config(self._cfg) + def _detect_timeout_from_script(self, script: str) -> float: script_lower = script.lower() if any(keyword in script_lower for keyword in ["batch", "for(", "for (", "getfilelist", "while"]): @@ -51,6 +58,7 @@ async def run_macro( ) -> str: if timeout is None: timeout = self._detect_timeout_from_script(script) + _cfg = self.cfg try: script = script + "\n" + 'print("Macro executed.");' response = await self.apis.run_script("macro", script, timeout=timeout) @@ -70,7 +78,7 @@ async def run_macro( result = f"{script} has executed successfully.\n" # Perform result verification if requested - if verify_result and operation_intent and self._cfg.vision_enabled: + if verify_result and operation_intent and _cfg.vision_enabled: verification = await self.simple_result_verification(operation_intent) result += f"\nOperation verification: {verification}" @@ -88,11 +96,12 @@ async def label_image(self) -> str: async def capture_screen(self, query: str | None = None) -> str: """Capture ImageJ Screen.""" - if not self._cfg.vision_enabled: + _cfg = self.cfg + if not _cfg.vision_enabled: window_info = await self.imagej_windowInfo() return f"[Vision disabled] Basic ImageJ window info:\n{window_info}" - if not self._cfg.vision_available: + if not _cfg.vision_available: window_info = await self.imagej_windowInfo() return ( "[Vision not available] The configured model does not support image input. " @@ -101,7 +110,7 @@ async def capture_screen(self, query: str | None = None) -> str: f"Basic ImageJ window info:\n{window_info}" ) - vlm = new_vlm_model_client(self._cfg) + vlm = new_vlm_model_client(_cfg) imagejCapture_resp = await self.apis.capture_screen() vision_prompt = """ You are an ImageJ Vision Perception tool. @@ -177,11 +186,12 @@ async def capture_screen(self, query: str | None = None) -> str: async def imagej_perception(self, query: str | None = None) -> str: """ImageJ Perception Tool.""" perception = await self.imagej_windowInfo() + _cfg = self.cfg - if not self._cfg.vision_enabled: + if not _cfg.vision_enabled: return f"[Vision disabled] ImageJ Window Information:\n{perception}" - if not self._cfg.vision_available: + if not _cfg.vision_available: return ( "[Vision not available] The configured model does not support image input. " "Please configure a vision-capable model (COPILOTJ_VLM_MODEL) " @@ -221,17 +231,18 @@ async def imagej_windowInfo(self) -> str: async def simple_result_verification(self, operation_intent: str, expected_outcome: str = None) -> str: """Simple perception-based result verification.""" - if not self._cfg.vision_enabled: + _cfg = self.cfg + if not _cfg.vision_enabled: return "[Vision disabled] Visual verification skipped. Operation assumed successful." - if not self._cfg.vision_available: + if not _cfg.vision_available: return ( "[Vision not available] Visual verification skipped — " "the configured model does not support image input. " "Operation assumed successful." ) - vlm = new_vlm_model_client(self._cfg) + vlm = new_vlm_model_client(_cfg) imagejCapture_resp = await self.apis.capture_screen() verification_prompt = f""" diff --git a/copilotj/test/multiagent/test_agent_loader.py b/copilotj/test/multiagent/test_agent_loader.py index 9bba4d2e..4d29cc06 100644 --- a/copilotj/test/multiagent/test_agent_loader.py +++ b/copilotj/test/multiagent/test_agent_loader.py @@ -9,7 +9,7 @@ import pytest from copilotj.core import FunctionTool -from copilotj.core.config import Config +from copilotj.core.config import Config, ConfigLike, resolve_config from copilotj.multiagent import agent_loader as _loader_module from copilotj.multiagent.agent_loader import _load_agent_configs @@ -202,6 +202,64 @@ class = "copilotj.test.multiagent.test_agent_loader._FakeExecutor" asyncio.run(_run()) +def test_factory_tool_sees_live_config_update(): + """A factory-built tool reads config via the provider, so updating the provider's + config is visible without rebuilding the tool.""" + + async def _run(): + holder = {"cfg": Config(tavily_api_key="OLD")} + + def provider(): + return holder["cfg"] + + tool = FunctionTool(make_echo_api_key(provider), "echo") + args = tool.args_type()(query="x") + assert await tool.run(args) == "OLD" + # Mutate the config the provider returns — same tool, no rebuild. + holder["cfg"] = Config(tavily_api_key="NEW") + assert await tool.run(args) == "NEW" + + import asyncio + + asyncio.run(_run()) + + +def test_factory_tool_static_config_still_works(): + """Passing a static Config (no provider) still resolves correctly (backward compat).""" + + async def _run(): + tool = FunctionTool(make_echo_api_key(Config(tavily_api_key="STATIC")), "echo") + args = tool.args_type()(query="x") + assert await tool.run(args) == "STATIC" + + import asyncio + + asyncio.run(_run()) + + +def test_plugin_tools_cfg_resolves_live_provider(): + """PluginTools.cfg resolves a live provider and reflects config updates.""" + from copilotj.multiagent.tools import PluginTools + + holder = {"cfg": Config(vision_enabled=False)} + + def provider(): + return holder["cfg"] + + pt = PluginTools(MagicMock(), cfg=provider) + assert pt.cfg.vision_enabled is False + holder["cfg"] = Config(vision_enabled=True) + assert pt.cfg.vision_enabled is True + + +def test_plugin_tools_cfg_static_config_passthrough(): + """PluginTools.cfg returns a static Config unchanged (backward compat).""" + from copilotj.multiagent.tools import PluginTools + + pt = PluginTools(MagicMock(), cfg=Config(vision_enabled=True)) + assert pt.cfg.vision_enabled is True + + # --------------------------------------------------------------------------- # Tests — error handling & edge cases # --------------------------------------------------------------------------- @@ -311,19 +369,20 @@ async def _sample_tool(query: Annotated[str, "Search query"]) -> str: return f"result: {query}" -def make_sample_tool(cfg: Config): - """Factory that returns a closure with access to cfg.""" +def make_sample_tool(cfg: ConfigLike): + """Factory that returns a closure with access to cfg (static or live provider).""" async def _tool(query: Annotated[str, "Search query"]) -> str: - return f"result: {query}, key={cfg.tavily_api_key}" + _cfg = resolve_config(cfg) + return f"result: {query}, key={_cfg.tavily_api_key}" return _tool -def make_echo_api_key(cfg: Config): - """Factory that echoes the tavily_api_key — for verifying config binding.""" +def make_echo_api_key(cfg: ConfigLike): + """Factory that echoes the tavily_api_key — for verifying config binding (static or live provider).""" async def _echo(query: Annotated[str, "Ignored"]) -> str: - return cfg.tavily_api_key or "" + return resolve_config(cfg).tavily_api_key or "" return _echo diff --git a/copilotj/workflow/batch_qc.py b/copilotj/workflow/batch_qc.py index a2dc8835..81335d02 100644 --- a/copilotj/workflow/batch_qc.py +++ b/copilotj/workflow/batch_qc.py @@ -17,7 +17,7 @@ from skimage import io, transform from copilotj.core import ImageMessage, TextMessage, new_vlm_model_client -from copilotj.core.config import Config +from copilotj.core.config import Config, ConfigLike, resolve_config from copilotj.multiagent.py_tools import get_project_temp_dir logger = logging.getLogger(__name__) @@ -34,10 +34,7 @@ MAX_IMAGE_METADATA_LINES = 20 -def make_batch_precheck(cfg: Config | Callable[[], Config], on_result: Callable[[str], None] | None = None): - def current_cfg() -> Config: - return cfg() if callable(cfg) else cfg - +def make_batch_precheck(cfg: ConfigLike, on_result: Callable[[str], None] | None = None): async def batch_precheck( folder_path: Annotated[str, "Path to the image folder to check before batch processing"], *, @@ -54,7 +51,7 @@ async def batch_precheck( output_dir=output_dir, skip_analysis=skip_analysis, montage_count=montage_count, - cfg=current_cfg(), + cfg=resolve_config(cfg), ) if on_result is not None: on_result(result)