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