Skip to content

Commit bc45ee6

Browse files
google-genai-botxuanyang15
authored andcommitted
fix: terminate infinite retry loop in LoadSkillResourceTool on RESOURCE_NOT_FOUND
Merge #5651 Fixes #5652 ORIGINAL_AUTHOR=Botta Venkata Leela Raman Raj <110351568+Raman369AI@users.noreply.github.com> GitOrigin-RevId: e25c08e Change-Id: Ie5c151b22674845d7e92afbc88422ad873d3cd55
1 parent be03166 commit bc45ee6

2 files changed

Lines changed: 151 additions & 14 deletions

File tree

src/google/adk/tools/skill_toolset.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,10 +83,14 @@ def _build_skill_system_instruction(prefix: str | None = None) -> str:
8383
"of them in order.\n"
8484
f"3. The `{p}load_skill_resource` tool is for viewing files within a "
8585
"skill's directory (e.g., `references/*`, `assets/*`, `scripts/*`). "
86-
"Do NOT use other tools to access these files.\n"
86+
"It is ONLY for skill-bundled files — do NOT use it to access "
87+
"documents or files provided by the user at runtime. Do NOT use "
88+
"other tools to access skill files.\n"
8789
f"4. Use `{p}run_skill_script` to run scripts from a skill's `scripts/` "
8890
f"directory. Use `{p}load_skill_resource` to view script content first if "
8991
"needed.\n"
92+
f"5. If `{p}load_skill_resource` returns any error, do not retry any "
93+
"path. Report the error to the user and stop.\n"
9094
)
9195

9296

@@ -348,6 +352,23 @@ async def run_async(
348352
}
349353

350354
if content is None:
355+
# Invocation-scoped failure counter. Counts RESOURCE_NOT_FOUND across ALL
356+
# paths so the guard fires even when the LLM hallucinates a different path
357+
# on each retry. The `temp:` prefix prevents persistence to durable
358+
# session storage; invocation_id isolates in-memory backends.
359+
counter_key = f"temp:_adk_skill_resource_not_found_count_{tool_context.invocation_id}"
360+
fail_count = int(tool_context.state.get(counter_key) or 0) + 1
361+
tool_context.state[counter_key] = fail_count
362+
if fail_count > 1:
363+
return {
364+
"error": (
365+
f"Resource '{file_path}' not found in skill '{skill_name}'."
366+
f" This is resource lookup failure #{fail_count} this"
367+
" invocation. Do not retry any path — report the error to"
368+
" the user and stop."
369+
),
370+
"error_code": "RESOURCE_NOT_FOUND_FATAL",
371+
}
351372
return {
352373
"error": f"Resource '{file_path}' not found in skill '{skill_name}'.",
353374
"error_code": "RESOURCE_NOT_FOUND",

tests/unittests/tools/test_skill_toolset.py

Lines changed: 129 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -332,16 +332,10 @@ async def test_load_skill_run_async_state_none(
332332
"error_code": "SKILL_NOT_FOUND",
333333
},
334334
),
335-
(
336-
{"skill_name": "skill1", "file_path": "references/other.md"},
337-
{
338-
"error": (
339-
"Resource 'references/other.md' not found in skill"
340-
" 'skill1'."
341-
),
342-
"error_code": "RESOURCE_NOT_FOUND",
343-
},
344-
),
335+
# RESOURCE_NOT_FOUND is tested separately in
336+
# test_load_resource_first_missing_returns_soft_error because the
337+
# counter guard requires a real state dict (mock state.get() returns a
338+
# truthy MagicMock that int() coerces to 1, skipping the soft path).
345339
(
346340
{"skill_name": "skill1", "file_path": "invalid/path.txt"},
347341
{
@@ -479,27 +473,149 @@ def test_duplicate_skill_name_raises(mock_skill1):
479473

480474

481475
@pytest.mark.asyncio
482-
async def test_scripts_resource_not_found(mock_skill1, tool_context_instance):
476+
async def test_scripts_resource_not_found(mock_skill1):
483477
toolset = skill_toolset.SkillToolset([mock_skill1])
484478
tool = skill_toolset.LoadSkillResourceTool(toolset)
479+
ctx = _make_tool_context_with_agent()
485480
result = await tool.run_async(
486481
args={"skill_name": "skill1", "file_path": "scripts/nonexistent.sh"},
487-
tool_context=tool_context_instance,
482+
tool_context=ctx,
488483
)
489484
assert result["error_code"] == "RESOURCE_NOT_FOUND"
490485

491486

487+
@pytest.mark.asyncio
488+
async def test_load_resource_first_missing_returns_soft_error(mock_skill1):
489+
"""First RESOURCE_NOT_FOUND in an invocation returns the soft error code."""
490+
toolset = skill_toolset.SkillToolset([mock_skill1])
491+
tool = skill_toolset.LoadSkillResourceTool(toolset)
492+
ctx = _make_tool_context_with_agent()
493+
result = await tool.run_async(
494+
args={"skill_name": "skill1", "file_path": "references/other.md"},
495+
tool_context=ctx,
496+
)
497+
assert result["error_code"] == "RESOURCE_NOT_FOUND"
498+
assert result["error"] == (
499+
"Resource 'references/other.md' not found in skill 'skill1'."
500+
)
501+
502+
503+
@pytest.mark.asyncio
504+
async def test_load_resource_repeated_failure_escalates_to_fatal(mock_skill1):
505+
"""Any second RESOURCE_NOT_FOUND within an invocation returns RESOURCE_NOT_FOUND_FATAL."""
506+
toolset = skill_toolset.SkillToolset([mock_skill1])
507+
tool = skill_toolset.LoadSkillResourceTool(toolset)
508+
ctx = _make_tool_context_with_agent()
509+
510+
args = {"skill_name": "skill1", "file_path": "references/nonexistent.md"}
511+
512+
result1 = await tool.run_async(args=args, tool_context=ctx)
513+
assert result1["error_code"] == "RESOURCE_NOT_FOUND"
514+
515+
result2 = await tool.run_async(args=args, tool_context=ctx)
516+
assert result2["error_code"] == "RESOURCE_NOT_FOUND_FATAL"
517+
assert "Do not retry" in result2["error"]
518+
assert "stop" in result2["error"].lower()
519+
assert "failure #2" in result2["error"]
520+
521+
522+
@pytest.mark.asyncio
523+
async def test_load_resource_different_path_also_escalates_to_fatal(
524+
mock_skill1,
525+
):
526+
"""A different missing path on the second call still escalates to RESOURCE_NOT_FOUND_FATAL.
527+
528+
The counter is path-agnostic: any second not-found within the same invocation
529+
is fatal, even when the LLM hallucinates a different path on each retry.
530+
"""
531+
toolset = skill_toolset.SkillToolset([mock_skill1])
532+
tool = skill_toolset.LoadSkillResourceTool(toolset)
533+
ctx = _make_tool_context_with_agent()
534+
535+
result1 = await tool.run_async(
536+
args={"skill_name": "skill1", "file_path": "references/missing_a.md"},
537+
tool_context=ctx,
538+
)
539+
assert result1["error_code"] == "RESOURCE_NOT_FOUND"
540+
541+
result2 = await tool.run_async(
542+
args={"skill_name": "skill1", "file_path": "references/missing_b.md"},
543+
tool_context=ctx,
544+
)
545+
assert result2["error_code"] == "RESOURCE_NOT_FOUND_FATAL"
546+
assert "Do not retry" in result2["error"]
547+
548+
549+
@pytest.mark.asyncio
550+
async def test_load_resource_failures_isolated_per_invocation(mock_skill1):
551+
"""Failure counter does not leak across invocations.
552+
553+
A RESOURCE_NOT_FOUND in invocation A must not increment invocation B's
554+
counter; invocation B's first missing-resource call must still return the
555+
soft error, even when both invocations share the same session state dict.
556+
"""
557+
toolset = skill_toolset.SkillToolset([mock_skill1])
558+
tool = skill_toolset.LoadSkillResourceTool(toolset)
559+
560+
shared_state = {}
561+
ctx_a = _make_tool_context_with_agent(invocation_id="inv_a")
562+
ctx_a.state = shared_state
563+
ctx_b = _make_tool_context_with_agent(invocation_id="inv_b")
564+
ctx_b.state = shared_state
565+
566+
# invocation A: one failure — counter for inv_a reaches 1 (soft).
567+
result_a = await tool.run_async(
568+
args={"skill_name": "skill1", "file_path": "references/typo.md"},
569+
tool_context=ctx_a,
570+
)
571+
assert result_a["error_code"] == "RESOURCE_NOT_FOUND"
572+
573+
# invocation B, first attempt (different path) — counter for inv_b = 1 (soft).
574+
result_b1 = await tool.run_async(
575+
args={"skill_name": "skill1", "file_path": "references/typo.md"},
576+
tool_context=ctx_b,
577+
)
578+
assert result_b1["error_code"] == "RESOURCE_NOT_FOUND"
579+
580+
# invocation B, second attempt (different path) — counter for inv_b = 2 (fatal).
581+
result_b2 = await tool.run_async(
582+
args={"skill_name": "skill1", "file_path": "references/other.md"},
583+
tool_context=ctx_b,
584+
)
585+
assert result_b2["error_code"] == "RESOURCE_NOT_FOUND_FATAL"
586+
587+
588+
@pytest.mark.asyncio
589+
async def test_load_resource_counter_uses_temp_prefix(mock_skill1):
590+
"""Failure-counter key uses the `temp:` prefix so it is not persisted."""
591+
toolset = skill_toolset.SkillToolset([mock_skill1])
592+
tool = skill_toolset.LoadSkillResourceTool(toolset)
593+
ctx = _make_tool_context_with_agent()
594+
595+
await tool.run_async(
596+
args={"skill_name": "skill1", "file_path": "references/missing.md"},
597+
tool_context=ctx,
598+
)
599+
600+
# The counter key must start with `temp:` so it is trimmed from the event
601+
# delta and never reaches durable storage.
602+
guard_keys = [k for k in ctx.state if "skill_resource_not_found_count" in k]
603+
assert guard_keys, "Failure counter did not write a tracking key."
604+
assert all(k.startswith("temp:") for k in guard_keys)
605+
606+
492607
# RunSkillScriptTool tests
493608

494609

495-
def _make_tool_context_with_agent(agent=None):
610+
def _make_tool_context_with_agent(agent=None, invocation_id="test_invocation"):
496611
"""Creates a mock ToolContext with _invocation_context.agent."""
497612
ctx = mock.MagicMock(spec=tool_context.ToolContext)
498613
ctx._invocation_context = mock.MagicMock()
499614
ctx._invocation_context.agent = agent or mock.MagicMock()
500615
ctx._invocation_context.agent.name = "test_agent"
501616
ctx._invocation_context.agent_states = {}
502617
ctx.agent_name = "test_agent"
618+
ctx.invocation_id = invocation_id
503619
ctx.state = {}
504620
return ctx
505621

0 commit comments

Comments
 (0)