Skip to content

agent

zocrates.agents.online_search.agent

Subagent factories for Online Literature Discovery and Synthesis.

ONLINE_SEARCH_AGENT_INSTRUCTION module-attribute

ONLINE_SEARCH_AGENT_INSTRUCTION = '# Role and Objectives\n\nYou are the Online Search Subagent for the Zocrates literature discovery pipeline (a plugin of the Zotero application).\n\nYour objectives are:\n- Formulate targeted academic search queries to discover relevant, novel external literature, preprints, and papers via OpenAlex.\n- Inspect discovered candidate titles, publication years, author lists, abstracts, citation counts, topics, keywords, and open-access status.\n- Identify and curate novel, high-impact candidate papers that directly address the user\'s research inquiry.\n- Conclude retrieval cleanly once high-quality candidate papers are identified, or proactively request an extension if deep exploration across adjacent fields is needed.\n\n# Available Tools\n\n1. `search_openalex(query, year_from=None, year_to=None, is_oa_only=False)`:\n   - Queries OpenAlex across 250M+ scholarly works and arXiv preprints.\n   - You can pass:\n       - Free-text conceptual keywords (e.g., "speculative decoding draft model verification small language models").\n       - Specific paper titles or methodology terms.\n       - Direct identifiers: DOIs (e.g., "10.48550/arXiv.2401.12345") or arXiv IDs (e.g., "2401.12345").\n   - Filter parameters:\n       - `year_from` / `year_to`: restrict to specific publication year ranges (e.g., recent works: `year_from=2023`).\n       - `is_oa_only`: restrict strictly to verified open-access works with accessible full-text PDFs.\n   - Automatic Deduplication: Papers already present in the user\'s local Zotero library are automatically filtered out. Every candidate returned is novel.\n\n## Termination Tools\n\n1. `conclude_online_search(recommendations, summary=None)`:\n   - MANDATORY TERMINATION TOOL: Concludes the online search phase.\n   - Pass a list of structured candidate recommendations:\n     `recommendations=[{"candidate_id": "https://openalex.org/W...", "rationale": "1-2 sentences explaining why this paper is recommended for import and answers the inquiry"}]`.\n   - Each recommendation pairs the candidate ID (as shown in search results, e.g. \'https://openalex.org/W...\' or short \'W...\') with a clear, compelling rationale. Each rationale MUST be strictly 1–2 sentences MAX (under 35 words). State only the core takeaway/mechanism answering the inquiry. Never write long summaries or paragraphs.\n   - Provide a concise summary explaining why these papers were chosen to answer the user\'s research goal.\n   - Calling this tool concludes your turn. Do NOT write conversational responses, summaries, or final syntheses in your text output.\n\n2. `request_search_extension(rationale)`:\n   - Proactively request permission from the user to extend the search query budget.\n   - Use this tool when you realize that crucial adjacent subfields, novel preprints, or distinct technical approaches require more queries to explore thoroughly.\n   - Calling this tool registers your request and concludes your turn so the user can review and approve the extension.\n\n# Search Strategy Guidelines\n\n- **Formulating High-Recall Queries**:\n  - Avoid conversational phrases (e.g., do NOT search "can you find me papers about..."). Use concise, keyword-dense academic search queries (e.g., "kv cache compression streaming llm attention").\n  - Test distinct search angles across calls: if an initial query returns broad or tangential papers, refine with more specific terminology, author names, or key techniques.\n  - If a query returns zero results, broaden your terminology or drop restrictive year/OA constraints.\n\n- **Candidate Evaluation**:\n  - Review candidate titles, abstracts, citation counts, and primary topics to evaluate relevance and methodological rigor.\n  - Prioritize papers that introduce novel mechanisms, establish key benchmarks, or provide authoritative surveys directly answering the user\'s goal.\n\n- **Bounded Budget**:\n  - You operate under a finite budget of retrieval calls.\n  - Track your remaining calls from tool notices. Call `conclude_online_search` as soon as you have identified at least a few strong candidates, or request an extension (by calling `request_search_extension`) if warranted.\n'

ONLINE_SYNTHESIZER_AGENT_INSTRUCTION module-attribute

ONLINE_SYNTHESIZER_AGENT_INSTRUCTION = '# Role and Objective\n\nYou are the Online Synthesizer Agent for Zocrates (a plugin of the Zotero application).\nYour role is to produce a comprehensive, rigorous, and factually grounded response to the user\'s research inquiry based strictly on the discovered candidate papers provided in your prompt.\n\n# Context\n\nDiscovered candidate papers are provided under `# Discovered Candidate Papers` as an ordered list of `Candidate [1]`, `Candidate [2]`, etc., with titles, authors, publication years, venues, citation counts, and abstracts.\n\n# Synthesis & Grounding Guidelines\n\n- **Direct Inquiry Overview & Synthesis**:\n   - Directly answer the user\'s research question by summarizing the state of the literature, key methodologies, algorithmic trade-offs, and empirical findings revealed by the discovered papers.\n   - Structure your response with clean Markdown headings, comparative analysis, and bullet points where precision is needed.\n   - In headings, use natural academic titles (e.g., "## KV Cache Compression Architectures"), NEVER candidate markers (e.g., \'Candidate [1]\', \'Paper 1\'), candidate IDs, or raw URLs.\n- **Strict Factual Grounding & Epistemic Honesty**:\n   - Ground all statements strictly in the provided candidate metadata and abstracts. Never invent or hallucinate papers, authors, methodologies, benchmarks, or empirical findings.\n   - If no relevant candidate papers were found, or if certain facets remain unaddressed in the available abstracts, clearly and directly state what is missing. Never extrapolate beyond what the abstracts support.\n- **Scientific Attribution & Citation Tokens**:\n   - Produce a concise yet rigorous academic synthesis citing discovered papers using standard citation markers like `[1]`, `[2]`, or `[1, 2]` corresponding to the numbered candidate papers in the provided list.\n   - Attribute findings naturally in the prose for academic readability by mentioning author names and publication years alongside the citation token (e.g., "Vaswani et al. (2017) demonstrated that multi-head attention... [1]").\n   - Do not fabricate markdown hyperlink URLs, include internal candidate IDs, or invent citations for unsupported claims.\n   - CRITICAL REQUIREMENT: Do NOT include a "References" or "Cited Sources" section at the end of your response. Interactive candidate paper cards with complete links and import controls will be displayed to the user automatically below your answer.\n\n## Visual Content and Math Expression Guidelines\n\n- **Create Visual Diagrams & Workflows (Mermaid)**: when explaining research workflows, comparative architectures, conceptual taxonomies, or when the user explicitly requests a diagram or flowchart, you may generate structured diagrams using fenced ```mermaid code blocks. Ensure standard, valid Mermaid syntax.\n- **Math expression formatting (LaTeX)**: for LaTeX expressions, use the $...$ delimiters for inline expressions and $$..$$ for display blocks.\n'

logger module-attribute

logger = logging.getLogger(__name__)
conclude_online_search(recommendations, summary=None, tool_context=None)

Conclude the online literature retrieval phase with selected candidate papers.

Call this tool when novel, high-impact candidate papers addressing the user's research inquiry have been identified.

Parameters:

Name Type Description Default
recommendations list[CandidateRecommendation]

Structured list of recommendations pairing candidate_id and rationale.

required
summary str | None

Brief summary of the literature discovered and why these papers were selected.

None
tool_context ToolContext | None

ADK ToolContext.

None

Returns:

Type Description
str

Confirmation message concluding the retrieval phase.

Source code in zocrates/agents/online_search/tools.py
def conclude_online_search(
    recommendations: list[CandidateRecommendation],
    summary: str | None = None,
    tool_context: ToolContext | None = None,
) -> str:
    """Conclude the online literature retrieval phase with selected candidate papers.

    Call this tool when novel, high-impact candidate papers addressing the user's
    research inquiry have been identified.

    Args:
        recommendations: Structured list of recommendations pairing candidate_id and rationale.
        summary: Brief summary of the literature discovered and why these papers were selected.
        tool_context: ADK ToolContext.

    Returns:
        Confirmation message concluding the retrieval phase.
    """
    selected_candidates: list[CandidatePaper] = []
    if tool_context is not None and tool_context.state is not None:
        state = tool_context.state
        pool = (
            state.get(TEMP_ONLINE_CANDIDATES)
            if state.get(TEMP_ONLINE_CANDIDATES) is not None
            else state.get("temp_online_candidates")
        ) or {}
        if not isinstance(pool, dict):
            pool = {}

        seen_ids: set[str] = set()
        for rec in recommendations or []:
            if isinstance(rec, dict):
                cid_raw = rec.get("candidate_id")
                rat = str(rec.get("rationale", "") or "").strip()
            elif hasattr(rec, "candidate_id"):
                cid_raw = getattr(rec, "candidate_id", None)
                rat = str(getattr(rec, "rationale", "") or "").strip()
            else:
                continue

            if cid_raw is None or cid_raw == "":
                continue

            cand_obj = _resolve_candidate_from_pool(cid_raw, pool)
            if cand_obj is not None:
                if cand_obj.candidate_id in seen_ids:
                    continue
                seen_ids.add(cand_obj.candidate_id)
                cand = (
                    CandidatePaper.from_dict(cand_obj.to_dict())
                    if hasattr(cand_obj, "to_dict")
                    else CandidatePaper.from_dict(cand_obj)
                )
                if rat:
                    cand.tldr = rat
                selected_candidates.append(cand)
            else:
                logger.warning("Candidate ID %r not found in online candidate pool.", cid_raw)

        state["selected_candidates"] = selected_candidates
        state[CANDIDATE_PAPERS] = [c.to_dict() for c in selected_candidates]

        report = {
            "summary": summary or f"Selected {len(selected_candidates)} candidate papers.",
            "gaps": [],
            "notes": summary or "",
            "source": "conclude_online_search",
        }
        state[TEMP_SEARCH_REPORT] = report
        state["temp_online_search_report"] = report

        if tool_context.actions:
            tool_context.actions.skip_summarization = True

    count = (
        len(selected_candidates)
        if (tool_context and tool_context.state is not None)
        else len(recommendations or [])
    )
    return (
        f"Online literature search concluded with {count} candidate paper(s) selected. "
        "The Literature Synthesizer will now prepare the recommendations."
    )

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),
        ),
    )

request_search_extension

request_search_extension(rationale, covered_summary=None, missing_aspects=None, tool_context=None)

Request permission from the user to extend the online literature retrieval budget.

Call this tool when discovering adjacent subfields or preprints that warrant deeper literature exploration beyond the initial tool budget.

Parameters:

Name Type Description Default
rationale str

Clear explanation of why additional search queries or deeper prospecting are needed.

required
covered_summary str | None

Optional summary of candidates found so far.

None
missing_aspects list[str] | None

Optional list of missing topics or subfields to explore.

None
tool_context ToolContext | None

ADK ToolContext.

None

Returns:

Type Description
str

Confirmation message confirming extension request.

Source code in zocrates/agents/online_search/tools.py
def request_search_extension(
    rationale: str,
    covered_summary: str | None = None,
    missing_aspects: list[str] | None = None,
    tool_context: ToolContext | None = None,
) -> str:
    """Request permission from the user to extend the online literature retrieval budget.

    Call this tool when discovering adjacent subfields or preprints that warrant deeper
    literature exploration beyond the initial tool budget.

    Args:
        rationale: Clear explanation of why additional search queries or deeper prospecting are needed.
        covered_summary: Optional summary of candidates found so far.
        missing_aspects: Optional list of missing topics or subfields to explore.
        tool_context: ADK ToolContext.

    Returns:
        Confirmation message confirming extension request.
    """
    if tool_context is not None and tool_context.state is not None:
        state = tool_context.state
        state[TEMP_EXTENSION_REQUESTED] = True
        state["temp_extension_requested"] = True
        state["extension_rationale"] = rationale

        ext_num = int(
            state.get(TEMP_EXTENSION_COUNT)
            if state.get(TEMP_EXTENSION_COUNT) is not None
            else state.get("temp_extension_count", 0)
        ) + 1
        state[TEMP_EXTENSION_REQUEST_PASS] = ext_num

        report = {
            "summary": covered_summary or "Preliminary literature discovery pass",
            "gaps": missing_aspects or [rationale],
            "notes": rationale,
            "source": "subagent_request",
        }
        state[TEMP_SEARCH_REPORT] = report
        state["temp_online_search_report"] = report

        if tool_context.actions:
            tool_context.actions.skip_summarization = True

    return "Online search extension requested. Concluding retrieval turn for user confirmation."

search_openalex async

search_openalex(query, year_from=None, year_to=None, is_oa_only=False, tool_context=None)

Search OpenAlex for external scholarly papers and preprints.

Automatically excludes papers already in your local Zotero library and retrieves essential metadata, abstracts, and open-access PDF links.

Parameters:

Name Type Description Default
query str

Academic search query, keywords, paper title, or exact DOI/arXiv identifier.

required
year_from int | None

Optional starting publication year (e.g. 2023).

None
year_to int | None

Optional ending publication year (e.g. 2025).

None
is_oa_only bool

When True, restricts results strictly to verified open-access works.

False
tool_context ToolContext | None

ADK ToolContext.

None

Returns:

Type Description
str

Structured summary of discovered novel candidate papers.

Source code in zocrates/agents/online_search/tools.py
async def search_openalex(
    query: str,
    year_from: int | None = None,
    year_to: int | None = None,
    is_oa_only: bool = False,
    tool_context: ToolContext | None = None,
) -> str:
    """Search OpenAlex for external scholarly papers and preprints.

    Automatically excludes papers already in your local Zotero library and retrieves
    essential metadata, abstracts, and open-access PDF links.

    Args:
        query: Academic search query, keywords, paper title, or exact DOI/arXiv identifier.
        year_from: Optional starting publication year (e.g. 2023).
        year_to: Optional ending publication year (e.g. 2025).
        is_oa_only: When True, restricts results strictly to verified open-access works.
        tool_context: ADK ToolContext.

    Returns:
        Structured summary of discovered novel candidate papers.
    """
    allowed, err_msg, notice = _check_and_increment_online_budget(tool_context)
    if not allowed:
        return err_msg or "Search budget exhausted."

    engine: DiscoveryEngine | None = None
    if tool_context is not None and tool_context.state:
        raw_engine = tool_context.state.get("discovery_engine")
        if hasattr(raw_engine, "search") and callable(getattr(raw_engine, "search")):
            engine = raw_engine
        elif hasattr(tool_context.state, "pop"):
            tool_context.state.pop("discovery_engine", None)
        elif raw_engine is not None:
            tool_context.state["discovery_engine"] = None
    if engine is None:
        engine = get_default_discovery_engine()

    try:
        results: list[CandidatePaper] = await engine.search(
            query=query,
            limit=10,
            year_from=year_from,
            year_to=year_to,
            is_oa_only=is_oa_only,
        )
    except Exception as e:
        logger.warning("search_openalex failed for query %r: %s", query, e)
        return f"Error executing search for query '{query}': {e}"

    # Update candidate pool in state
    if tool_context is not None and tool_context.state is not None:
        state = tool_context.state
        pool = (
            state.get(TEMP_ONLINE_CANDIDATES)
            if state.get(TEMP_ONLINE_CANDIDATES) is not None
            else state.get("temp_online_candidates")
        )
        if not isinstance(pool, dict):
            pool = {}
        for c in results:
            pool[c.candidate_id] = c.to_dict() if hasattr(c, "to_dict") else c
        state[TEMP_ONLINE_CANDIDATES] = pool
        state["temp_online_candidates"] = pool

    if not results:
        msg = (
            f"No novel candidate papers found matching query: '{query}'. "
            "Try refining, broadening, or using alternative technical terminology."
        )
        if notice:
            msg += f"\n\n{notice}"
        return msg

    lines = [f"Found {len(results)} novel candidate paper(s) matching query '{query}':\n"]
    for i, c in enumerate(results, 1):
        authors_str = ", ".join(c.authors[:3]) + (" et al." if len(c.authors) > 3 else "")
        oa_str = "Yes" if c.is_oa else "No"
        venue_str = f" | Venue: {c.venue}" if c.venue else ""
        lines.append(
            f"{i}. [Candidate ID: {c.candidate_id}] {c.title} ({c.year or 'n.d.'})\n"
            f"   Authors: {authors_str or 'Unknown'}{venue_str} | Citations: {c.citation_count} | OA: {oa_str}\n"
            f"   Topic: {c.primary_topic or 'N/A'}"
        )
        abstract_txt = c.abstract or c.tldr
        if abstract_txt:
            snippet = abstract_txt[:300].strip() + ("..." if len(abstract_txt) > 300 else "")
            lines.append(f"   Abstract: {snippet}")
        elif c.keywords:
            lines.append(f"   Keywords: {', '.join(c.keywords[:5])}")
        lines.append("")

    if notice:
        lines.append(notice)

    return "\n".join(lines).strip()