Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions copilotj/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import os
import shutil
import uuid
from collections.abc import Callable
from dataclasses import dataclass, replace
from pathlib import Path

Expand All @@ -18,6 +19,8 @@

__all__ = [
"Config",
"ConfigLike",
"ConfigProvider",
"load_config",
"SINGLE_CLIENT_ID",
"get_home",
Expand All @@ -27,6 +30,7 @@
"load_managed_config",
"save_managed_config",
"bootstrap_assets",
"resolve_config",
"resolve_vision_config",
]

Expand Down Expand Up @@ -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.

Expand Down
6 changes: 3 additions & 3 deletions copilotj/multiagent/agent_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand All @@ -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)
Expand Down
16 changes: 11 additions & 5 deletions copilotj/multiagent/leader_multiagent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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)

Expand All @@ -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,
*,
Expand Down
34 changes: 17 additions & 17 deletions copilotj/multiagent/research_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"],
Expand All @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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"],
Expand All @@ -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:
Expand All @@ -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,
)

Expand Down
33 changes: 22 additions & 11 deletions copilotj/multiagent/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"]):
Expand All @@ -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)
Expand All @@ -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}"

Expand All @@ -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. "
Expand All @@ -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.
Expand Down Expand Up @@ -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) "
Expand Down Expand Up @@ -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"""
Expand Down
Loading