@@ -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