Skip to content

Commit 89d9bda

Browse files
Raman369AIxuanyang15
authored andcommitted
fix: terminate infinite retry loop in RunSkillScriptTool on SCRIPT_NOT_FOUND
Merge #5683 Fixes #5684 Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 933947720
1 parent 3963a45 commit 89d9bda

2 files changed

Lines changed: 134 additions & 0 deletions

File tree

src/google/adk/tools/skill_toolset.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@ def _build_skill_system_instruction(prefix: str | None = None) -> str:
9393
"needed.\n"
9494
f"5. If `{p}load_skill_resource` returns any error, do not retry any "
9595
"path. Report the error to the user and stop.\n"
96+
f"6. If `{p}run_skill_script` returns an error (for example "
97+
f"`SCRIPT_NOT_FOUND`), do not retry the same script or guess a "
98+
"different script path. Report the error to the user and stop.\n"
9699
)
97100

98101

@@ -894,6 +897,25 @@ async def run_async(
894897
script = skill.resources.get_script(file_path)
895898

896899
if script is None:
900+
# Invocation-scoped failure counter. Counts SCRIPT_NOT_FOUND across ALL
901+
# paths so the guard fires even when the LLM hallucinates a different
902+
# script path on each retry. The `temp:` prefix prevents persistence to
903+
# durable session storage; invocation_id isolates in-memory backends.
904+
counter_key = (
905+
f"temp:_adk_skill_script_not_found_count_{tool_context.invocation_id}"
906+
)
907+
fail_count = int(tool_context.state.get(counter_key) or 0) + 1
908+
tool_context.state[counter_key] = fail_count
909+
if fail_count > 1:
910+
return {
911+
"error": (
912+
f"Script '{file_path}' not found in skill '{skill_name}'."
913+
f" This is script lookup failure #{fail_count} this"
914+
" invocation. Do not retry any script path — report the"
915+
" error to the user and stop."
916+
),
917+
"error_code": "SCRIPT_NOT_FOUND_FATAL",
918+
}
897919
return {
898920
"error": f"Script '{file_path}' not found in skill '{skill_name}'.",
899921
"error_code": "SCRIPT_NOT_FOUND",

tests/unittests/tools/test_skill_toolset.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,118 @@ async def test_execute_script_script_not_found(mock_skill1):
721721
tool_context=ctx,
722722
)
723723
assert result["error_code"] == "SCRIPT_NOT_FOUND"
724+
assert result["error"] == (
725+
"Script 'nonexistent.py' not found in skill 'skill1'."
726+
)
727+
728+
729+
@pytest.mark.asyncio
730+
async def test_execute_script_repeated_failure_escalates_to_fatal(mock_skill1):
731+
"""Any second SCRIPT_NOT_FOUND within an invocation returns SCRIPT_NOT_FOUND_FATAL."""
732+
executor = _make_mock_executor()
733+
toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
734+
tool = skill_toolset.RunSkillScriptTool(toolset)
735+
ctx = _make_tool_context_with_agent()
736+
737+
args = {"skill_name": "skill1", "file_path": "scripts/nonexistent.py"}
738+
739+
result1 = await tool.run_async(args=args, tool_context=ctx)
740+
assert result1["error_code"] == "SCRIPT_NOT_FOUND"
741+
742+
result2 = await tool.run_async(args=args, tool_context=ctx)
743+
assert result2["error_code"] == "SCRIPT_NOT_FOUND_FATAL"
744+
assert "Do not retry" in result2["error"]
745+
assert "stop" in result2["error"].lower()
746+
assert "failure #2" in result2["error"]
747+
748+
749+
@pytest.mark.asyncio
750+
async def test_execute_script_different_path_also_escalates_to_fatal(
751+
mock_skill1,
752+
):
753+
"""A different missing script on the second call still escalates to SCRIPT_NOT_FOUND_FATAL.
754+
755+
The counter is path-agnostic: any second not-found within the same invocation
756+
is fatal, even when the LLM hallucinates a different script path on each
757+
retry.
758+
"""
759+
executor = _make_mock_executor()
760+
toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
761+
tool = skill_toolset.RunSkillScriptTool(toolset)
762+
ctx = _make_tool_context_with_agent()
763+
764+
result1 = await tool.run_async(
765+
args={"skill_name": "skill1", "file_path": "scripts/missing_a.py"},
766+
tool_context=ctx,
767+
)
768+
assert result1["error_code"] == "SCRIPT_NOT_FOUND"
769+
770+
result2 = await tool.run_async(
771+
args={"skill_name": "skill1", "file_path": "scripts/missing_b.py"},
772+
tool_context=ctx,
773+
)
774+
assert result2["error_code"] == "SCRIPT_NOT_FOUND_FATAL"
775+
assert "Do not retry" in result2["error"]
776+
777+
778+
@pytest.mark.asyncio
779+
async def test_execute_script_failures_isolated_per_invocation(mock_skill1):
780+
"""Failure counter does not leak across invocations.
781+
782+
A SCRIPT_NOT_FOUND in invocation A must not increment invocation B's
783+
counter; invocation B's first missing-script call must still return the
784+
soft error, even when both invocations share the same session state dict.
785+
"""
786+
executor = _make_mock_executor()
787+
toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
788+
tool = skill_toolset.RunSkillScriptTool(toolset)
789+
790+
shared_state = {}
791+
ctx_a = _make_tool_context_with_agent(invocation_id="inv_a")
792+
ctx_a.state = shared_state
793+
ctx_b = _make_tool_context_with_agent(invocation_id="inv_b")
794+
ctx_b.state = shared_state
795+
796+
# invocation A: one failure — counter for inv_a reaches 1 (soft).
797+
result_a = await tool.run_async(
798+
args={"skill_name": "skill1", "file_path": "scripts/typo.py"},
799+
tool_context=ctx_a,
800+
)
801+
assert result_a["error_code"] == "SCRIPT_NOT_FOUND"
802+
803+
# invocation B, first attempt (same path) — counter for inv_b = 1 (soft).
804+
result_b1 = await tool.run_async(
805+
args={"skill_name": "skill1", "file_path": "scripts/typo.py"},
806+
tool_context=ctx_b,
807+
)
808+
assert result_b1["error_code"] == "SCRIPT_NOT_FOUND"
809+
810+
# invocation B, second attempt (different path) — counter for inv_b = 2 (fatal).
811+
result_b2 = await tool.run_async(
812+
args={"skill_name": "skill1", "file_path": "scripts/other.py"},
813+
tool_context=ctx_b,
814+
)
815+
assert result_b2["error_code"] == "SCRIPT_NOT_FOUND_FATAL"
816+
817+
818+
@pytest.mark.asyncio
819+
async def test_execute_script_counter_uses_temp_prefix(mock_skill1):
820+
"""Failure-counter key uses the `temp:` prefix so it is not persisted."""
821+
executor = _make_mock_executor()
822+
toolset = skill_toolset.SkillToolset([mock_skill1], code_executor=executor)
823+
tool = skill_toolset.RunSkillScriptTool(toolset)
824+
ctx = _make_tool_context_with_agent()
825+
826+
await tool.run_async(
827+
args={"skill_name": "skill1", "file_path": "scripts/missing.py"},
828+
tool_context=ctx,
829+
)
830+
831+
# The counter key must start with `temp:` so it is trimmed from the event
832+
# delta and never reaches durable storage.
833+
guard_keys = [k for k in ctx.state if "skill_script_not_found_count" in k]
834+
assert guard_keys, "Failure counter did not write a tracking key."
835+
assert all(k.startswith("temp:") for k in guard_keys)
724836

725837

726838
@pytest.mark.asyncio

0 commit comments

Comments
 (0)