Skip to content

[Bug] LoadSkillResourceTool retries RESOURCE_NOT_FOUND indefinitely; default max_llm_calls=500 is the only backstop #5652

Closed

Description

@kronos3610

🔴 Required Information

Describe the Bug:

LoadSkillResourceTool.run_async returns RESOURCE_NOT_FOUND as a structured soft-error string when a path passed by the LLM does not exist inside the skill's bundled resources. Because the response is a normal tool result (not an exception or terminal signal), the LLM treats it as a transient/recoverable failure and retries — but critically, it hallucinates a different plausible path on every retry, not the same path. Nothing in SkillToolset tracks total failures across paths, so the loop continues until RunConfig.max_llm_calls is exhausted.

max_llm_calls defaults to 500 (src/google/adk/agents/run_config.py:314). This means a single invocation can silently consume the entire per-invocation call budget on repeated failing tool calls before the framework intervenes — and max_llm_calls is a global cap on legitimate reasoning, not a defense against a repeated-failure loop on one specific tool.

Steps to Reproduce:

  1. Install google-adk (any version that ships SkillToolset — verified on 1.32.0).
  2. Create an agent with a SkillToolset containing a skill whose SKILL.md references files by natural-language names (e.g. "Document 1", "the reference guide") without exact filenames.
  3. Issue a query that prompts the model to read one of those resources.
  4. Observe in the trace that the model calls load_skill_resource with a hallucinated path, receives RESOURCE_NOT_FOUND, then calls it again with a different hallucinated path, receives RESOURCE_NOT_FOUND again, and loops.

Expected Behavior:

After the first RESOURCE_NOT_FOUND within an invocation, any subsequent load_skill_resource failure should return a terminal error code that unambiguously instructs the LLM to stop retrying and report the error. The agent's overall reasoning budget (max_llm_calls) should not be the only thing standing between an imperfect prompt and a runaway invocation.

Observed Behavior:

The same RESOURCE_NOT_FOUND soft error is returned on every attempt regardless of path or how many times it has already failed. The loop terminates only when max_llm_calls is exceeded.

Live trace evidence (captured via GET /debug/trace/session/{session_id} against adk web):

SPAN: execute_tool load_skill_resource
  args:       {'file_path': 'references/reference_doc.md', 'skill_name': 'document-classifier'}
  error_code: RESOURCE_NOT_FOUND
  error:      Resource 'references/reference_doc.md' not found in skill 'document-classifier'.

SPAN: execute_tool load_skill_resource
  args:       {'skill_name': 'document-classifier', 'file_path': 'references/Document1.md'}
  error_code: RESOURCE_NOT_FOUND
  error:      Resource 'references/Document1.md' not found in skill 'document-classifier'.

The model tried references/reference_doc.md first, then hallucinated a completely different path (references/Document1.md) on the retry. Both returned the same soft error — the LLM had no signal to stop. This pattern continues indefinitely.

Environment Details:

  • ADK Library Version: 1.32.0 (issue exists on main as of commit 2d61cb69)
  • Desktop OS: Linux (defect is in framework logic, not OS-specific)
  • Python Version: 3.12.3

Model Information:

  • Are you using LiteLLM: N/A (defect is provider-agnostic)
  • Which model: gemini-3-flash-preview (observed; reproducible across any function-calling model — the retry behavior is a consequence of the soft error signal, not model-specific)

🟡 Optional Information

Regression:

Not a regression. The defect has existed since SkillToolset was introduced — LoadSkillResourceTool.run_async has never had any retry-guard logic.

Additional Context:

Four factors combine to make this loop reachable through ordinary use:

  1. No resource manifest at L2 — the load_skill response intentionally omits available file paths (progressive-disclosure spec). The LLM must infer paths from prose, and inferred paths are routinely wrong.
  2. Soft error string — RESOURCE_NOT_FOUND looks transient and recoverable to the model; retry is its default response.
  3. No terminal signal — nothing escalates after the first miss.
  4. No scope boundary in default prompt — the system instruction doesn't distinguish skill-bundled files from runtime user inputs (e.g. a PDF the user is processing), so the model sometimes routes runtime documents through load_skill_resource and loops on them.

Considered and rejected alternatives:

Alternative Why not
Per-path retry guard LLM hallucinates a different path on each retry — confirmed in live trace; a per-path list never triggers
Tighten or default-lower max_llm_calls Caps overall reasoning budget; punishes legitimate long-running agents
User-side after_tool_callback workaround Symptomatic; pushes the fix onto every SkillToolset user
Add available_resources manifest to L2 load_skill Defeats the lazy-loading / token-saving design
New list_skill_resources tool Violates the L1→L2→L3 progressive disclosure contract

Minimal Reproduction Code:

import asyncio
from unittest import mock
from google.adk.skills import models
from google.adk.tools import skill_toolset, tool_context

skill = mock.create_autospec(models.Skill, instance=True)
skill.name = "demo"
skill.resources = mock.MagicMock()
skill.resources.get_reference.return_value = None  # every path "missing"

ctx = mock.MagicMock(spec=tool_context.ToolContext)
ctx.state = {}
ctx.invocation_id = "inv1"
ctx._invocation_context = mock.MagicMock()
ctx.agent_name = "agent"

toolset_obj = skill_toolset.SkillToolset([skill])
tool = skill_toolset.LoadSkillResourceTool(toolset_obj)

async def main():
    paths = [
        "references/missing.md",
        "references/other_guess.md",   # different path — LLM hallucination pattern
        "references/yet_another.md",
    ]
    for i, path in enumerate(paths):
        r = await tool.run_async(
            args={"skill_name": "demo", "file_path": path},
            tool_context=ctx,
        )
        print(i, r["error_code"])
    # On main (unpatched): all 3 print RESOURCE_NOT_FOUND — LLM has no reason to stop
    # With fix applied:    call 0 → RESOURCE_NOT_FOUND, calls 1-2 → RESOURCE_NOT_FOUND_FATAL

asyncio.run(main())

How often has this issue occurred?: Always (100%) — deterministic given any skill whose SKILL.md lets the model infer plausible-looking paths that don't literally exist.


Proposed Fix

A two-layer fix is in linked PR #5651:

Code: an invocation-scoped total failure counter inside LoadSkillResourceTool.run_async. The counter tracks the number of RESOURCE_NOT_FOUND responses across all paths within an invocation (not per-path — live testing confirmed the LLM uses a different path on each retry). State key:

temp:_adk_skill_resource_not_found_count_<invocation_id>
  • First failure → RESOURCE_NOT_FOUND (unchanged behavior).
  • Any subsequent failure → RESOURCE_NOT_FOUND_FATAL with an explicit stop instruction and failure count.

The temp: prefix uses ADK's existing convention to prevent persistence to durable storage. The <invocation_id> suffix isolates in-memory backends where temp: keys are not auto-cleared between invocations.

Prompt: a no-retry rule and a scope boundary added to _DEFAULT_SKILL_SYSTEM_INSTRUCTION.

Live trace with fix applied (same session, patched build):

SPAN: execute_tool load_skill_resource
  args:       {'file_path': 'references/reference_doc.md', 'skill_name': 'document-classifier'}
  error_code: RESOURCE_NOT_FOUND
  error:      Resource 'references/reference_doc.md' not found in skill 'document-classifier'.

SPAN: execute_tool load_skill_resource
  args:       {'skill_name': 'document-classifier', 'file_path': 'references/Document1.md'}
  error_code: RESOURCE_NOT_FOUND_FATAL
  error:      Resource 'references/Document1.md' not found in skill 'document-classifier'.
              This is resource lookup failure #2 this invocation. Do not retry any path
              — report the error to the user and stop.

Loop terminated on the second call. The model attempted a different path (Document1.md vs reference_doc.md) — exactly the hallucination pattern that a per-path guard would have missed.

Linked PR: #5651

Activity

  1. kronos3610 commented on May 12, 2026

    @kronos3610
    ContributorAuthor

    E2E Reproduction Evidence — Runner

    This comment adds live end-to-end evidence as required by CONTRIBUTING.md § Manual End-to-End Tests (Runner).


    Minimal Reproduction Agent

    # test_agent/agent.py
    from google.adk.agents import Agent
    from google.adk.skills import models
    from google.adk.tools.skill_toolset import SkillToolset
    
    greeting_skill = models.Skill(
        frontmatter=models.Frontmatter(
            name="document-classifier",
            description=(
                "Classifies the document supplied by the user based on the reference document"
            ),
        ),
        instructions=(
            "Use Document 1 as reference document and Document 2 as input. Classify each document as"
            " python-code, non-python-code, or mixed. Return a short comparison."
        ),
    )
    root_agent = Agent(
        model="gemini-3-flash-preview",
        name="root_agent",
        description="A repro agent for SkillToolset inline document handling.",
        instruction=("Always use the document as the reference and summarize it."),
        tools=[SkillToolset(skills=[greeting_skill])],
    )

    Run command:

    adk web

    Trigger prompt (any vague document reference is sufficient):

    summarize my document
    

    Uploaded one document and what happened is as follows:

    What happens (unpatched)

    The skill instructions reference "Document 1" and "Document 2" as if they are bundled skill resources, but no files are attached to the skill (references/, assets/, or scripts/ directories are absent). The agent's top-level instruction reinforces this by saying "Always use the document as the reference", which nudges the LLM further toward attempting resource loads.

    From the LLM's perspective, RESOURCE_NOT_FOUND is structurally indistinguishable from a transient failure (network hiccup, lazy-load not yet available). With no termination guard and no manifest of valid paths, the model hallucinates plausible path variants and retries indefinitely:

    ADK Web trace — load_skill_resource call sequence (calls #6–#18 visible, loop was still running at kill time):

    ⚡ #6 load_skill_resource
    ✓ #7 load_skill_resource
    ⚡ #8 load_skill_resource
    ✓ #9 load_skill_resource
    ⚡ #10 load_skill_resource
    ✓ #11 load_skill_resource
    ⚡ #12 load_skill_resource
    ✓ #13 load_skill_resource
    ⚡ #14 load_skill_resource
    ✓ #15 load_skill_resource
    ⚡ #16 load_skill_resource
    ✓ #17 load_skill_resource
    ⚡ #18 load_skill_resource
    (continues…)

    (⚡ = RESOURCE_NOT_FOUND error returned; ✓ = tool call dispatched successfully but returned the error string — the alternating pattern reflects the model immediately re-invoking after each soft failure.)

    Server log — loop persisting through and beyond CTRL+C:

    2026-05-11 21:20:00,837 - INFO - google_llm.py:208 - Sending out request, model: gemini-3-flash-preview
    2026-05-11 21:20:00,861 - INFO - google_llm.py:276 - Response received from the model.
    2026-05-11 21:20:02,762 - INFO - google_llm.py:276 - Response received from the model.
    2026-05-11 21:20:02,785 - INFO - google_llm.py:208 - Sending out request, model: gemini-3-flash-preview
    ...
    ^CINFO:     Shutting down
    INFO:     Waiting for connections to close. (CTRL+C to force quit)
    ^C2026-05-11 21:20:16,718 - INFO - google_llm.py:276 - Response received from the model.
    2026-05-11 21:20:16,741 - INFO - google_llm.py:208 - Sending out request, model: gemini-3-flash-preview
    ...
    ^C^C^C^C^C^C^C2026-05-11 21:20:31,023 - INFO - google_llm.py:276 - Response received from the model.
    2026-05-11 21:20:31,072 - INFO - google_llm.py:208 - Sending out request, model: gemini-3-flash-preview
    

    The loop continued firing new LLM requests for ~20 seconds after the first CTRL+C, requiring repeated interrupt signals to force-quit. In-flight API calls are not cancellable — every iteration that completes before the process dies is billed.


    Why ambiguous prompts make this a framework issue, not a user error

    This reproduction agent is not contrived — skill instructions that reference documents by natural-language names ("Document 1", "reference document") are a normal and expected authoring pattern. The ambiguity is unavoidable: skill authors do not know at write-time whether end-users will supply input inline or whether the model will infer a resource load. The framework must be defensive here because:

    1. RESOURCE_NOT_FOUND is a soft error by design — it communicates "file absent" but looks transient to the LLM.
    2. No path manifest is returned (intentional, per the progressive-disclosure spec) — the LLM has no way to self-correct.
    3. max_llm_calls does not help — it caps the agent's total budget, not this specific failure mode, so a short cap breaks legitimate long-running agents.
    4. A prompt-only fix is insufficient — imperfect upstream system prompts (like this repro) will always exist in the wild. A $105 bill from a prior production incident confirms real-world exposure.

    The defense-in-depth approach in PR #5651 — RESOURCE_NOT_FOUND_FATAL on the second same-path failure within an invocation, plus an explicit no-retry instruction in the default system prompt - is the correct framework-level fix.

  2. surajksharma07 commented on May 12, 2026

    @surajksharma07
    Collaborator

    @Raman369AI The core problem is that RESOURCE_NOT_FOUND is structurally indistinguishable from a transient failure, so one approach is to track failed (skill, path) pairs per-invocation in tool_context.state under a temp:-prefixed key and return a terminal RESOURCE_NOT_FOUND_FATAL on the second hit to force a hard stop.

    Could you verify that the approach in #5651 actually stops the loop in your environment and test it across a couple of different skill configurations before we move forward with the review?
    Want to make sure the fix holds up end-to-end before we finalize things on our side!

  3. kronos3610 commented on May 13, 2026

    @kronos3610
    ContributorAuthor

    @surajksharma07 @xuanyang15 — verified end-to-end. Sharing the live trace evidence and a note on why the per-path guard was upgraded to an invocation-wide counter.


    Why the per-path guard was insufficient

    The per-path approach tracks failed `(skill, path)` pairs and escalates on a second hit of the same path. In live testing the LLM never repeated the same path — it hallucinated a different plausible variant on every retry. So the per-path list was always empty at each attempt and `RESOURCE_NOT_FOUND_FATAL` was never returned.

    Unpatched trace — two different paths, both soft errors, loop continues:

    ```
    SPAN: execute_tool load_skill_resource
    args: {'file_path': 'references/reference_doc.md', 'skill_name': 'document-classifier'}
    error_code: RESOURCE_NOT_FOUND
    error: Resource 'references/reference_doc.md' not found in skill 'document-classifier'.

    SPAN: execute_tool load_skill_resource
    args: {'skill_name': 'document-classifier', 'file_path': 'references/Document1.md'}
    error_code: RESOURCE_NOT_FOUND
    error: Resource 'references/Document1.md' not found in skill 'document-classifier'.
    ```

    The model tried `references/reference_doc.md` first, then hallucinated `references/Document1.md` — a completely different path. A per-path guard would never fire here.


    What was changed in #5651

    The per-path list (`temp:adk_skill_resource_failed_paths<invocation_id>`) was replaced with a total failure counter (`temp:adk_skill_resource_not_found_count<invocation_id>`) that counts `RESOURCE_NOT_FOUND` responses across all paths within an invocation. Any second failure — regardless of which path was tried — returns `RESOURCE_NOT_FOUND_FATAL`.

    The issue body and PR description have been updated to reflect this.


    Patched trace — loop terminates on call #2 despite different path

    ```
    SPAN: execute_tool load_skill_resource
    args: {'file_path': 'references/reference_doc.md', 'skill_name': 'document-classifier'}
    error_code: RESOURCE_NOT_FOUND
    error: Resource 'references/reference_doc.md' not found in skill 'document-classifier'.

    SPAN: execute_tool load_skill_resource
    args: {'skill_name': 'document-classifier', 'file_path': 'references/Document1.md'}
    error_code: RESOURCE_NOT_FOUND_FATAL
    error: Resource 'references/Document1.md' not found in skill 'document-classifier'.
    This is resource lookup failure #2 this invocation. Do not retry any path
    — report the error to the user and stop.
    ```

    Counter fired on the second call even though the path was different. The loop stopped. Verified against session `40115502-78db-4590-ab94-db7732694b4a` via `GET /debug/trace/session/{session_id}` on `adk web`.


    Happy to provide any additional verification or context.

  4. surajksharma07 commented on May 18, 2026

    @surajksharma07
    Collaborator

    The switch to an invocation-wide counter is the right call, @Raman369AI — the live trace (two different paths: reference_doc.md → Document1.md) and test_load_resource_different_path_also_escalates_to_fatal together make it airtight that a per-path guard would never have fired here.

    One minor inconsistency worth tidying before the PR lands: rule 5 in _DEFAULT_SKILL_SYSTEM_INSTRUCTION says "do not retry the same path" while the code and the RESOURCE_NOT_FOUND_FATAL message both block any path on the second failure.
    Aligning the instruction text to "any path" closes that semantic gap cleanly.

    And on the companion issue #5684 (RunSkillScriptTool same defect) — #5683 looks like the right parallel fix to track once this lands!

  5. kronos3610 commented on May 19, 2026

    @kronos3610
    ContributorAuthor

    @surajksharma07 - addressed the rule 5 inconsistency you flagged. Pushed 330c41e4 on PR #5651 updating "do not retry the same path" - "do not retry any path" so the system instruction now aligns with the invocation-wide counter and the RESOURCE_NOT_FOUND_FATAL message. All 95 test_skill_toolset.py tests pass.

  6. surajksharma07 commented on May 25, 2026

    @surajksharma07
    Collaborator

    That closes the last gap cleanly, @Raman369AI — the system instruction, the escalation logic in run_async, and the RESOURCE_NOT_FOUND_FATAL error message now all consistently say "any path", so there's no ambiguity left for the LLM to interpret as a per-path limit it can sidestep by hallucinating a fresh variant.

    95 tests passing is solid, and test_load_resource_different_path_also_escalates_to_fatal specifically is the proof that matters most here — it's the exact scenario (different path on each retry) that a per-path guard would have silently missed in production.

    On the companion issue #5684 (RunSkillScriptTool same defect) and PR #5683 — good to see those are already tracked.
    They should follow naturally once #5651 lands and establishes the pattern.

    Team is looking more into it.

  7. added
    needs review[Status] The PR/issue is awaiting review from the maintainer
    and removed
    request clarification[Status] The maintainer need clarification or more information from the author
    on May 25, 2026
  8. added a commit that references this issue on May 28, 2026
    bc45ee6
  9. added a commit that references this issue on Aug 11, 2026
    e0c8609
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

needs review[Status] The PR/issue is awaiting review from the maintainertools[Component] This issue is related to tools

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions