Repository navigation
[Bug] LoadSkillResourceTool retries RESOURCE_NOT_FOUND indefinitely; default max_llm_calls=500 is the only backstop #5652
Description
🔴 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:
- Install
google-adk(any version that shipsSkillToolset— verified on1.32.0). - Create an agent with a
SkillToolsetcontaining a skill whoseSKILL.mdreferences files by natural-language names (e.g. "Document 1", "the reference guide") without exact filenames. - Issue a query that prompts the model to read one of those resources.
- Observe in the trace that the model calls
load_skill_resourcewith a hallucinated path, receivesRESOURCE_NOT_FOUND, then calls it again with a different hallucinated path, receivesRESOURCE_NOT_FOUNDagain, 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 onmainas of commit2d61cb69) - 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:
- No resource manifest at L2 — the
load_skillresponse intentionally omits available file paths (progressive-disclosure spec). The LLM must infer paths from prose, and inferred paths are routinely wrong. - Soft error string —
RESOURCE_NOT_FOUNDlooks transient and recoverable to the model; retry is its default response. - No terminal signal — nothing escalates after the first miss.
- 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_resourceand 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_FATALwith 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
kronos3610 commentedon May 12, 2026
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
Run command:
Trigger prompt (any vague document reference is sufficient):
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/, orscripts/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_FOUNDis 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_resourcecall sequence (calls #6–#18 visible, loop was still running at kill time):(⚡ = 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:
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:
RESOURCE_NOT_FOUNDis a soft error by design — it communicates "file absent" but looks transient to the LLM.max_llm_callsdoes not help — it caps the agent's total budget, not this specific failure mode, so a short cap breaks legitimate long-running agents.The defense-in-depth approach in PR #5651 —
RESOURCE_NOT_FOUND_FATALon 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.surajksharma07 commentedon May 12, 2026
@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!
kronos3610 commentedon May 13, 2026
@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.
surajksharma07 commentedon May 18, 2026
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!
kronos3610 commentedon May 19, 2026
@surajksharma07 - addressed the rule 5 inconsistency you flagged. Pushed
330c41e4on 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 theRESOURCE_NOT_FOUND_FATALmessage. All 95test_skill_toolset.pytests pass.surajksharma07 commentedon May 25, 2026
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.
fix: terminate infinite retry loop in LoadSkillResourceTool on RESOUR…
fix: terminate infinite retry loop in LoadSkillResourceTool on RESOUR…