Skip to content

agent

zocrates.agents.coordinator.agent

Coordinator agent factory and model configuration helpers.

COORDINATOR_INSTRUCTION module-attribute

COORDINATOR_INSTRUCTION = '# Role\n\nYou are the Coordinator Agent for the **Zocrates** research assistant (a plugin of the Zotero application).\nYour role is to understand the user\'s research questions and either converse directly or delegate literature research/discovery to the appropriate tools.\n\n# Available Tools\n\n- `search_library`: for searching and synthesizing papers, notes, and evidence in the user\'s local Zotero library.\n- `search_online_literature`: for exploring external scholarly literature (OpenAlex, preprints, arXiv) to discover new candidate papers to import into the user\'s Zotero library.\n\nBoth tools execute autonomous, multi-agent long-running workflows with direct synthesized streaming output. \n\n# Operational & Tool Dispatch Guidelines\n\n- You are a conversational agent with zero direct database or external index access. \n- You cannot peek into the library or pre-judge whether relevant papers exist without invoking the appropriate tool.\n- You cannot directly import papers into the library.\n\n## The Coordinator as the Information Bridge\n\n- Each tool (`search_library` and `search_online_literature`) dispatches to autonomous subagents that execute within isolated retrieval workflows. These subagents do not share each other\'s state or possess the full conversational history that is in your context. They rely entirely on the `request` argument you formulate as the search goal.\n- You are the critical information bridge across research turns. You are responsible for providing context so each delegated tool invocation has the necessary information to succeed.\n- When delegating any inquiry—especially follow-ups, comparative queries, or transitions (e.g., searching online for topics discussed in your local library, or searching your library for concepts discovered online):\n  - **Disambiguate conversational references**: Resolve all pronouns and anaphora (e.g., replace "this method", "their second paper", or "the authors\' approach" with the actual author names, publication years, paper titles, model architectures, or mechanisms from earlier turns).\n  - **Distill prior findings into the request**: Explicitly state key concepts, benchmark names, or techniques already established in the conversation, and specify what new or deeper aspects to investigate so subagents build upon prior turns rather than repeating initial findings.\n  - **Dense yet Concise**: Formulate self-contained, keyword-rich search requests that are concise and focused (typically up to 5 sentences). Your goal is to maximize the technical or bibliographic value of the request. \n    - Never write multi-paragraph requests, verbose preambles, or conversational filler, and never paste full excerpts.\n    - Never include numeric citation brackets (e.g., [1], [2]) in delegated requests.\n\n## Strict Dispatch Rules\n\n- Targeted Library Inquiries (`search_library`):\n   - For factual, technical, methodological, comparative, or empirical questions targeting literature, notes, figures, tables, or topics in the user\'s Zotero library (including active selections in the UI), call `search_library`.\n   - **Figure and image requests**: Requests such as "Show me Figure 3" or "Display the architecture diagram in this paper" MUST be delegated to `search_library`. The search pipeline has the tools to retrieve and display embedded figures. You cannot fetch images yourself, unless the figure or area is already provided inline as an attached image snippet in this conversation.\n   - Formulate the delegated `request` adhering strictly to the **Information Bridge** principles above (disambiguate references, distill prior findings, focus follow-up scope, and maintain brevity without citation brackets).\n\n- Explicit Online Requests (`search_online_literature`):\n   - You MUST ONLY invoke `search_online_literature` when the user explicitly asks to explore external literature.\n   - Explicit trigger phrases include: "search online", "find papers to import", "look up preprints on arXiv", "discover external papers on...", "find new literature outside my library", or pasting a raw external DOI / arXiv link.\n   - Formulate the delegated `request` adhering strictly to the **Information Bridge** principles above (disambiguate references, distill prior findings or library topics into external search keywords, and be concise).\n\n- Strict Mutual Exclusivity of Tool Calling: you MUST NEVER call both `search_library` and `search_online_literature` in parallel.\n\n- Active Context Preservation: when active papers are selected (see \'Context: Active Items Selected in Zotero\' section), prioritize referencing or searching them in your formulated request for `search_library`, unless the user explicitly requests a general search or online search.\n\n- Direct Conversational Clarification for Ambiguous Queries (No Tool Calls):\n   - If a query is broad or ambiguous regarding whether the user wants to analyze their existing local library or discover new external papers (e.g., "What are the latest techniques in speculative decoding?"), you MUST NOT call a tool (`search_library` or `search_online_literature`).\n   - If you are unsure of the user\'s intent (search the local library or external paper sources), do not call a tool.\n   - Instead, reply directly asking for conversational clarification.\n     For example, "Would you like me to search your local library for this topic, or search online literature and preprints to discover new papers to import?"\n\n- Purely Conversational Turns & Direct Reply (No Tool Calls): \n   - Reply directly without invoking any tool if the user is offering a greeting, a brief courtesy ("Thanks!"), asking a meta-question about Zocrates capabilities, or asking a conversational follow-up whose answer is ALREADY fully, explicitly, and unambiguously stated in the recent messages of this conversation.\n   - Visual Diagrams & Workflows (Mermaid): When explaining workflows, architectures, pipelines, conceptual taxonomies, state machines, or when the user asks for a diagram or flowchart, you may generate standard diagrams using fenced ```mermaid code blocks. Ensure standard, valid Mermaid syntax.\n   - Math LaTeX expressions: if you respond to the user directly and must mention math expressions, use the $...$ delimiters for inline expressions and $$..$$ for display blocks.\n\n- Focal Snippets from Attachments (Adaptive Soft Dispatch):\n   - When the user provides focal snippets (under \'# Context: User-Selected Focal Snippets from Attachment\' or as an attached image):\n     - **Direct Answering**: If the user\'s query can be fully, accurately, and authoritatively resolved using the provided snippet alone (e.g., explaining mathematical notation in an equation, summarizing a highlighted passage, describing visual trends in an attached chart, or parsing syntax), answer directly with zero tool calls.\n     - **No Fabricated Numeric Citations**: When answering directly from focal snippets without calling `search_library`, do NOT emit bracketed numeric citations (e.g., [1], [2]). Refer directly to the source paper and author by name.\n     - **Image Snippets & Vision Inspection**: \n        - If you have vision capabilities, inspect the attached visual image directly. \n        - If an attached image snippet is untranscribed because the model is text-only, inform the user and consult surrounding document text or section by calling the `search_library` tool.\n     - **Document Context & Library Exploration**: If answering the query requires broader surrounding context from the document (e.g., identifying experimental parameters mentioned elsewhere in the paper, finding proofs or lemmas referenced by a theorem, understanding baseline definitions) or comparing findings against other papers in the library, you ARE fully empowered to call `search_library` (or `search_online_literature` if external discovery is requested).\n     - **Formulating Delegated Searches with Snippet Context**: Search subagents already receive the focal snippet context directly.\n        - For text snippets: simply specify the research goal and questions to investigate without repeating keys or copying the full excerpt (if delegating to `search_online_literature`, incorporate the relevant conceptual topic or excerpt findings into your `request` parameter).\n        - For visual image snippets: describe what the image depicts in the \'request\' parameter (e.g. diagrams, components, equations, charts) so the search subagent knows what concepts to search for.\n\n'

logger module-attribute

logger = logging.getLogger(__name__)

_apply_agent_model_or_config

_apply_agent_model_or_config(agent_obj, target)

Apply a model instance/string or a role config dict to an Agent.

Source code in zocrates/agents/coordinator/agent.py
def _apply_agent_model_or_config(agent_obj: Agent, target: Any) -> None:
    """Apply a model instance/string or a role config dict to an Agent."""
    if target is None:
        return

    if isinstance(target, dict) and any(
        k in target for k in ("model", "generate_content_config", "thinking_level")
    ):
        if "model" in target and target["model"] is not None:
            agent_obj.model = target["model"]
        if "generate_content_config" in target:
            agent_obj.generate_content_config = target["generate_content_config"]
        elif "thinking_level" in target:
            agent_obj.generate_content_config = get_agent_generate_content_config(
                target["thinking_level"]
            )
    else:
        agent_obj.model = target

create_coordinator_agent

create_coordinator_agent(model=None, thinking_level='medium', generate_content_config=None, search_library_tool=None, search_online_tool=None)

Create the root Coordinator agent.

The coordinator answers conversational turns directly and delegates literature retrieval queries to the autonomous search pipeline(s). model can be a single model or a dict specifying per-agent models.

Source code in zocrates/agents/coordinator/agent.py
def create_coordinator_agent(
    model: Any = None,
    thinking_level: str | None = "medium",
    generate_content_config: types.GenerateContentConfig | None = None,
    search_library_tool: Any = None,
    search_online_tool: Any = None,
) -> Agent:
    """Create the root Coordinator agent.

    The coordinator answers conversational turns directly and delegates
    literature retrieval queries to the autonomous search pipeline(s).
    `model` can be a single model or a dict specifying per-agent models.
    """
    if model is None:
        base_model = get_llm_model()
    elif isinstance(model, dict):
        base_model = (
            model.get("default")
            or model.get("coordinator")
            or get_llm_model()
        )
    else:
        base_model = model

    if not isinstance(base_model, (str, BaseLlm)) and hasattr(base_model, "_mock_name"):
        base_model = "test-model"

    library_search_subagent = create_library_search_agent(base_model, thinking_level="low")
    library_synth_subagent = create_library_synthesizer_agent(base_model, thinking_level="high")
    online_search_subagent = create_online_search_agent(base_model, thinking_level="low")
    online_synth_subagent = create_online_synthesizer_agent(base_model, thinking_level="high")

    if search_library_tool is None:
        search_library_tool = FunctionTool(func=search_library)
    if search_online_tool is None:
        search_online_tool = FunctionTool(func=search_online_literature)

    tools = [search_library_tool, search_online_tool]

    if generate_content_config is None and thinking_level is not None:
        generate_content_config = get_agent_generate_content_config(thinking_level)

    coordinator = Agent(
        name="coordinator_agent",
        model=base_model,
        static_instruction=COORDINATOR_INSTRUCTION,
        instruction=get_coordinator_instruction,
        tools=tools,
        generate_content_config=generate_content_config,
        mode="chat",
    )
    coordinator._search_tool = search_library_tool
    coordinator._online_search_tool = search_online_tool
    coordinator._subagents = {
        "library_search": library_search_subagent,
        "library_synthesizer": library_synth_subagent,
        "online_search": online_search_subagent,
        "online_synthesizer": online_synth_subagent,
    }

    if isinstance(model, dict):
        set_agent_models(coordinator, model)

    return coordinator

create_library_search_agent

create_library_search_agent(model=None, thinking_level='low', generate_content_config=None)

Create the stateless retrieval subagent equipped with the consolidated retrieval tool suite.

Source code in zocrates/agents/library_search/search_pipeline.py
def create_library_search_agent(
    model: Any = None,
    thinking_level: str | None = "low",
    generate_content_config: types.GenerateContentConfig | None = None,
) -> Agent:
    """Create the stateless retrieval subagent equipped with the consolidated retrieval tool suite."""
    if model is None:
        from zocrates.llm import get_llm_model

        model = get_llm_model()

    if generate_content_config is None and thinking_level is not None:
        generate_content_config = get_agent_generate_content_config(thinking_level)

    return Agent(
        name="library_search_agent",
        model=model,
        instruction=LIBRARY_SEARCH_AGENT_INSTRUCTION,
        tools=[
            FunctionTool(search_corpus),
            FunctionTool(get_document_toc),
            FunctionTool(read_document),
            FunctionTool(get_related_items),
            FunctionTool(conclude_search),
            FunctionTool(request_search_extension),
        ],
        generate_content_config=generate_content_config,
        mode="single_turn",
        include_contents="default",
    )

create_library_synthesizer_agent

create_library_synthesizer_agent(model=None, thinking_level='high', generate_content_config=None, citations_enabled=None)

Create the grounded literature synthesizer agent.

Source code in zocrates/agents/library_search/search_pipeline.py
def create_library_synthesizer_agent(
    model: Any = None,
    thinking_level: str | None = "high",
    generate_content_config: types.GenerateContentConfig | None = None,
    citations_enabled: bool | None = None,
) -> Agent:
    """Create the grounded literature synthesizer agent."""
    if model is None:
        from zocrates.llm import get_llm_model

        model = get_llm_model()

    if generate_content_config is None and thinking_level is not None:
        generate_content_config = get_agent_generate_content_config(thinking_level)

    if citations_enabled is None:
        try:
            citations_enabled = get_citations_enabled()
        except Exception:
            citations_enabled = True

    return Agent(
        name="library_synthesizer_agent",
        model=model,
        instruction=get_synthesizer_instruction(citations_enabled),
        generate_content_config=generate_content_config,
        mode="single_turn",
        include_contents="none",
    )

create_online_search_agent

create_online_search_agent(model=None, thinking_level='low', generate_content_config=None)

Create the stateless online literature search subagent.

Equipped with OpenAlex retrieval, candidate conclusion, and extension request tools.

Source code in zocrates/agents/online_search/agent.py
def create_online_search_agent(
    model: Any = None,
    thinking_level: str | None = "low",
    generate_content_config: types.GenerateContentConfig | None = None,
) -> Agent:
    """Create the stateless online literature search subagent.

    Equipped with OpenAlex retrieval, candidate conclusion, and extension request tools.
    """
    if model is None:
        from zocrates.llm import get_llm_model

        model = get_llm_model()

    if generate_content_config is None and thinking_level is not None:
        generate_content_config = get_agent_generate_content_config(thinking_level)

    return Agent(
        name="online_search_agent",
        model=model,
        instruction=ONLINE_SEARCH_AGENT_INSTRUCTION,
        tools=[
            FunctionTool(search_openalex),
            FunctionTool(conclude_online_search),
            FunctionTool(request_search_extension),
        ],
        generate_content_config=generate_content_config,
        mode="single_turn",
        include_contents="default",
    )

create_online_synthesizer_agent

create_online_synthesizer_agent(model=None, thinking_level='high', generate_content_config=None)

Create the online synthesizer subagent.

Produces an inquiry overview and structured candidate recommendations with numbered references.

Source code in zocrates/agents/online_search/agent.py
def create_online_synthesizer_agent(
    model: Any = None,
    thinking_level: str | None = "high",
    generate_content_config: types.GenerateContentConfig | None = None,
) -> Agent:
    """Create the online synthesizer subagent.

    Produces an inquiry overview and structured candidate recommendations with numbered references.
    """
    if model is None:
        from zocrates.llm import get_llm_model

        model = get_llm_model()

    if generate_content_config is None and thinking_level is not None:
        generate_content_config = get_agent_generate_content_config(thinking_level)

    return Agent(
        name="online_synthesizer_agent",
        model=model,
        instruction=ONLINE_SYNTHESIZER_AGENT_INSTRUCTION,
        tools=[],
        generate_content_config=generate_content_config,
        mode="single_turn",
        include_contents="none",
    )

get_agent_generate_content_config

get_agent_generate_content_config(thinking_level=DEFAULT_THINKING_LEVEL, include_thoughts=True, thinking_budget=None, num_retries=None, zotero_data_dir=None, model=None)

Construct an ADK GenerateContentConfig with thinking/reasoning parameters.

Sets ADK's native thinking_config (for Google Gemini models) as well as http_options.extra_body (for LiteLLM / OpenRouter / OpenAI reasoning models) and http_options.retry_options for baked-in network retries.

Parameters:

Name Type Description Default
thinking_level str | ThinkingLevel | None

Thinking level ('minimal', 'low', 'medium', 'high', or None/disabled).

DEFAULT_THINKING_LEVEL
include_thoughts bool

Whether thoughts are returned in the response stream.

True
thinking_budget int | None

Optional token budget for reasoning (int or None).

None
num_retries int | None

Optional maximum retry attempts for HTTP requests (defaults to llm_num_retries in config or DEFAULT_LLM_NUM_RETRIES = 4).

None
zotero_data_dir Path | str | None

Optional root Zotero data directory for loading config.

None
model str | Any | None

Optional model identifier or LLM instance to tailor reasoning schema and Ollama thinking gating.

None

Returns:

Type Description
GenerateContentConfig | None

Configured GenerateContentConfig, or None if thinking is disabled.

Source code in zocrates/agents/common/config.py
def get_agent_generate_content_config(
    thinking_level: str | types.ThinkingLevel | None = DEFAULT_THINKING_LEVEL,
    include_thoughts: bool = True,
    thinking_budget: int | None = None,
    num_retries: int | None = None,
    zotero_data_dir: Path | str | None = None,
    model: str | Any | None = None,
) -> types.GenerateContentConfig | None:
    """Construct an ADK GenerateContentConfig with thinking/reasoning parameters.

    Sets ADK's native thinking_config (for Google Gemini models) as well as
    http_options.extra_body (for LiteLLM / OpenRouter / OpenAI reasoning models)
    and http_options.retry_options for baked-in network retries.

    Args:
        thinking_level: Thinking level ('minimal', 'low', 'medium', 'high', or None/disabled).
        include_thoughts: Whether thoughts are returned in the response stream.
        thinking_budget: Optional token budget for reasoning (int or None).
        num_retries: Optional maximum retry attempts for HTTP requests (defaults to llm_num_retries in config or DEFAULT_LLM_NUM_RETRIES = 4).
        zotero_data_dir: Optional root Zotero data directory for loading config.
        model: Optional model identifier or LLM instance to tailor reasoning schema and Ollama thinking gating.

    Returns:
        Configured GenerateContentConfig, or None if thinking is disabled.
    """
    if thinking_level is None:
        return None

    level_str = str(thinking_level).lower().replace("thinkinglevel.", "").strip()
    if level_str in ("none", "off", "false", "disabled", ""):
        return None

    thinking_config = types.ThinkingConfig(
        thinking_level=level_str,
        include_thoughts=include_thoughts,
        thinking_budget=thinking_budget,
    )

    # Map "minimal" to "low" for OpenAI / vLLM reasoning_effort schemas (avoiding HTTP 422)
    openai_reasoning_effort = "low" if level_str == "minimal" else level_str

    extra_body: dict[str, Any] = {
        "reasoning": {"effort": level_str},
        "reasoning_effort": openai_reasoning_effort,
    }
    if thinking_budget is not None:
        extra_body["reasoning"]["max_tokens"] = thinking_budget

    # Inspect config and model to gate think: true on Ollama models
    try:
        cfg = load_zocrates_config(zotero_data_dir)
    except Exception:
        cfg = {}

    model_name_str = None
    if model is not None:
        model_name_str = getattr(model, "model", None) or str(model)
    elif cfg:
        model_name_str = cfg.get("llm_model")

    provider = str(cfg.get("llm_provider") or "").lower()
    is_ollama = (
        (model_name_str and (
            model_name_str.startswith("ollama/")
            or model_name_str.startswith("ollama_chat/")
        ))
        or provider == "ollama"
    )

    if is_ollama:
        base_url = cfg.get("llm_base_url")
        if is_ollama_thinking_supported(model_name_str, base_url=base_url, zotero_data_dir=zotero_data_dir):
            extra_body["think"] = True
        else:
            # Model does not support thinking; do not send think: true (avoiding Ollama HTTP 400)
            extra_body.pop("think", None)

    if num_retries is None:
        try:
            val = cfg.get("llm_num_retries", DEFAULT_LLM_NUM_RETRIES)
            effective_retries = int(val) if val is not None else DEFAULT_LLM_NUM_RETRIES
        except Exception:
            effective_retries = DEFAULT_LLM_NUM_RETRIES
    else:
        effective_retries = int(num_retries)

    return types.GenerateContentConfig(
        thinking_config=thinking_config,
        http_options=types.HttpOptions(
            extra_body=extra_body,
            retry_options=types.HttpRetryOptions(attempts=effective_retries),
        ),
    )

get_coordinator_instruction

get_coordinator_instruction(context)

Dynamic instruction provider for the Coordinator agent.

Ensures the coordinator receives focal snippet context with internal Zotero item keys stripped via strip_snippet_keys, while retaining all other attributes and text context under '# Context: User-Selected Focal Snippets from Attachment'.

Source code in zocrates/agents/coordinator/prompts.py
def get_coordinator_instruction(context: Any) -> str:
    """Dynamic instruction provider for the Coordinator agent.

    Ensures the coordinator receives focal snippet context with internal
    Zotero item keys stripped via strip_snippet_keys, while retaining all
    other attributes and text context under '# Context: User-Selected Focal Snippets from Attachment'.
    """
    if hasattr(context, "state"):
        state = context.state
    elif isinstance(context, dict):
        state = context
    else:
        state = {}

    parts: list[str] = []

    profile_str = str(state.get("user_research_profile_str") or "").strip()
    if profile_str:
        parts.append(profile_str)

    active_items_str = str(state.get("active_context_items_str") or "").strip()
    if active_items_str:
        if "# Context: Active Items Selected in Zotero" not in active_items_str:
            parts.append(f"# Context: Active Items Selected in Zotero\n\n{active_items_str}")
        else:
            parts.append(active_items_str)

    focal_snippets_str = str(state.get("focal_snippets_str") or "").strip()
    if focal_snippets_str:
        clean_snippets = strip_snippet_keys(focal_snippets_str).strip()
        if clean_snippets:
            if "# Context: User-Selected Focal Snippets from Attachment" not in clean_snippets:
                parts.append(
                    f"# Context: User-Selected Focal Snippets from Attachment\n\n{clean_snippets}"
                )
            else:
                parts.append(clean_snippets)

    return "\n\n".join(parts)

get_llm_model

get_llm_model(zotero_data_dir=None)

Instantiate the active ADK BaseLlm instance based on Zocrates configuration.

Parameters:

Name Type Description Default
zotero_data_dir Path | str | None

Optional path to Zotero data directory.

None

Returns:

Type Description
BaseLlm

Configured ADK BaseLlm (LiteLlm) instance.

Source code in zocrates/llm.py
def get_llm_model(zotero_data_dir: Path | str | None = None) -> BaseLlm:
    """Instantiate the active ADK BaseLlm instance based on Zocrates configuration.

    Args:
        zotero_data_dir: Optional path to Zotero data directory.

    Returns:
        Configured ADK BaseLlm (LiteLlm) instance.
    """
    return build_agent_llm({}, zotero_data_dir=zotero_data_dir)

search_library async

search_library(request, tool_context)

Autonomous search tool executing the local library search and synthesis pipeline.

Use this tool when the user asks questions about papers, notes, or topics contained in their existing Zotero library.

Source code in zocrates/agents/library_search/search_pipeline.py
async def search_library(request: str, tool_context: ToolContext) -> str:
    """Autonomous search tool executing the local library search and synthesis pipeline.

    Use this tool when the user asks questions about papers, notes, or topics
    contained in their existing Zotero library.
    """
    tool_context.actions.skip_summarization = True
    fc_id = (
        getattr(tool_context, "function_call_id", None)
        or getattr(tool_context, "invocation_id", None)
        or getattr(getattr(tool_context, "_invocation_context", None), "invocation_id", None)
        or "default"
    )
    result = await tool_context.run_node(
        search_library_pipeline_node,
        node_input=request,
        override_isolation_scope=f"search_library_pipeline:{fc_id}",
    )
    return coerce_to_text(result)

search_online_literature async

search_online_literature(request, tool_context)

Autonomous discovery tool executing the online scholarly literature search pipeline.

Use this tool when the user wants to discover new papers, preprints, external literature, or resolve external DOIs/arXiv links not in their library.

Source code in zocrates/agents/online_search/search_pipeline.py
async def search_online_literature(request: str, tool_context: ToolContext) -> str:
    """Autonomous discovery tool executing the online scholarly literature search pipeline.

    Use this tool when the user wants to discover new papers, preprints, external literature,
    or resolve external DOIs/arXiv links not in their library.
    """
    tool_context.actions.skip_summarization = True
    fc_id = (
        getattr(tool_context, "function_call_id", None)
        or getattr(tool_context, "invocation_id", None)
        or getattr(getattr(tool_context, "_invocation_context", None), "invocation_id", None)
        or "default"
    )
    result = await tool_context.run_node(
        search_online_pipeline_node,
        node_input=request,
        override_isolation_scope=f"search_online_pipeline:{fc_id}",
    )
    return coerce_to_text(result)

set_agent_models

set_agent_models(coordinator, new_model)

Propagate updated LLM model(s) and content configs across coordinator and subagents.

Resolves 1:1 canonical role keys (coordinator, library_search, library_synthesizer, online_search, online_synthesizer).

Source code in zocrates/agents/coordinator/agent.py
def set_agent_models(coordinator: Agent, new_model: Any) -> None:
    """Propagate updated LLM model(s) and content configs across coordinator and subagents.

    Resolves 1:1 canonical role keys (coordinator, library_search, library_synthesizer,
    online_search, online_synthesizer).
    """
    if not isinstance(new_model, dict):
        coordinator.model = new_model
        if hasattr(coordinator, "_subagents") and isinstance(
            coordinator._subagents, dict
        ):
            for subagent in coordinator._subagents.values():
                if hasattr(subagent, "model"):
                    subagent.model = new_model
        logger.debug(
            "Propagated uniform model update across coordinator agent tree: %s",
            new_model,
        )
        return

    default_entry = new_model.get("default")

    # 1. Resolve coordinator model/config
    coord_entry = new_model.get("coordinator")
    if coord_entry is None and default_entry is not None:
        coord_entry = default_entry
    if coord_entry is not None:
        _apply_agent_model_or_config(coordinator, coord_entry)

    # 2. Resolve subagents models/configs
    if hasattr(coordinator, "_subagents") and isinstance(
        coordinator._subagents, dict
    ):
        subagents = coordinator._subagents
        roles = (
            "library_search",
            "library_synthesizer",
            "online_search",
            "online_synthesizer",
        )

        for role_name in roles:
            agent_obj = subagents.get(role_name)
            if agent_obj is None:
                continue
            target_entry = new_model.get(role_name)
            if target_entry is None and default_entry is not None:
                target_entry = default_entry

            if target_entry is not None:
                _apply_agent_model_or_config(agent_obj, target_entry)

    logger.debug(
        "Propagated per-agent model updates across coordinator agent tree: %s",
        new_model,
    )