diff --git a/core/src/tools/skill/run_skill_script_tool.ts b/core/src/tools/skill/run_skill_script_tool.ts index 2f7845bbb..61573c653 100644 --- a/core/src/tools/skill/run_skill_script_tool.ts +++ b/core/src/tools/skill/run_skill_script_tool.ts @@ -32,10 +32,19 @@ export enum RunSkillScriptErrorCode { REGISTRY_ERROR = 'REGISTRY_ERROR', SKILL_NOT_FOUND = 'SKILL_NOT_FOUND', SCRIPT_NOT_FOUND = 'SCRIPT_NOT_FOUND', + SCRIPT_NOT_FOUND_FATAL = 'SCRIPT_NOT_FOUND_FATAL', NO_CODE_EXECUTOR = 'NO_CODE_EXECUTOR', EXECUTION_ERROR = 'EXECUTION_ERROR', } +/** + * Prefix of the invocation-scoped script-lookup failure counter. `temp:` keeps + * the counter out of durable session storage; the invocation id suffix stops + * in-memory session backends from carrying a count into the next invocation. + */ +const SCRIPT_NOT_FOUND_COUNT_KEY_PREFIX = + 'temp:_adk_skill_script_not_found_count_'; + @experimental export class RunSkillScriptTool extends BaseTool { constructor(private toolset: SkillToolset) { @@ -123,8 +132,24 @@ export class RunSkillScriptTool extends BaseTool { } if (!script) { + // Counted across all paths and skills so the guard still fires when the + // model hallucinates a different script path on each retry. + const counterKey = `${SCRIPT_NOT_FOUND_COUNT_KEY_PREFIX}${toolContext.invocationId}`; + const failCount = (toolContext.state.get(counterKey) || 0) + 1; + toolContext.state.set(counterKey, failCount); + + const notFoundMessage = `Script '${scriptPath}' not found in skill '${skillName}'.`; + if (failCount > 1) { + return { + error: + `${notFoundMessage} This is script lookup failure #${failCount}` + + ' this invocation. Do not retry any script path — report the' + + ' error to the user and stop.', + errorCode: RunSkillScriptErrorCode.SCRIPT_NOT_FOUND_FATAL, + }; + } return { - error: `Script '${scriptPath}' not found in skill '${skillName}'.`, + error: notFoundMessage, errorCode: RunSkillScriptErrorCode.SCRIPT_NOT_FOUND, }; } diff --git a/core/test/tools/skills/run_skill_script_tool_test.ts b/core/test/tools/skills/run_skill_script_tool_test.ts index 27d1fd3fe..a5120fb40 100644 --- a/core/test/tools/skills/run_skill_script_tool_test.ts +++ b/core/test/tools/skills/run_skill_script_tool_test.ts @@ -54,6 +54,8 @@ describe('RunSkillScriptTool', () => { function createMockContext( agentName = 'test-agent', agentExecutor?: BaseCodeExecutor, + invocationId = 'inv-1', + sessionState: Record = {}, ): Context { const agentObj: Record = {name: agentName}; if (agentExecutor) { @@ -63,7 +65,8 @@ describe('RunSkillScriptTool', () => { return new Context({ invocationContext: { - session: {state: {}}, + invocationId, + session: {state: sessionState}, agent: agentObj as unknown as LlmAgent, } as unknown as InvocationContext, }); @@ -145,6 +148,114 @@ describe('RunSkillScriptTool', () => { }); }); + it('returns SCRIPT_NOT_FOUND on the first script lookup miss', async () => { + const toolset = new SkillToolset([mockSkill]); + const tool = new RunSkillScriptTool(toolset); + const result = (await tool.runAsync({ + args: {skill_name: 'test-skill', script_path: 'scripts/invalid.js'}, + toolContext: createMockContext(), + })) as ToolErrorResponse; + + expect(result.errorCode).toBe(RunSkillScriptErrorCode.SCRIPT_NOT_FOUND); + expect(result.error).not.toContain('#'); + }); + + it('escalates to SCRIPT_NOT_FOUND_FATAL on the second miss in the same invocation', async () => { + const toolset = new SkillToolset([mockSkill]); + const tool = new RunSkillScriptTool(toolset); + const toolContext = createMockContext(); + const args = {skill_name: 'test-skill', script_path: 'scripts/invalid.js'}; + + const first = (await tool.runAsync({ + args, + toolContext, + })) as ToolErrorResponse; + const second = (await tool.runAsync({ + args, + toolContext, + })) as ToolErrorResponse; + + expect(first).toEqual({ + error: "Script 'scripts/invalid.js' not found in skill 'test-skill'.", + errorCode: RunSkillScriptErrorCode.SCRIPT_NOT_FOUND, + }); + expect(second).toEqual({ + error: + "Script 'scripts/invalid.js' not found in skill 'test-skill'. This is" + + ' script lookup failure #2 this invocation. Do not retry any script' + + ' path — report the error to the user and stop.', + errorCode: RunSkillScriptErrorCode.SCRIPT_NOT_FOUND_FATAL, + }); + }); + + it('escalates even when the second miss uses a different script path', async () => { + const toolset = new SkillToolset([mockSkill]); + const tool = new RunSkillScriptTool(toolset); + const toolContext = createMockContext(); + + const first = (await tool.runAsync({ + args: {skill_name: 'test-skill', script_path: 'scripts/invalid.js'}, + toolContext, + })) as ToolErrorResponse; + const second = (await tool.runAsync({ + args: { + skill_name: 'test-skill', + script_path: 'scripts/some-other-guess.ts', + }, + toolContext, + })) as ToolErrorResponse; + + expect(first.errorCode).toBe(RunSkillScriptErrorCode.SCRIPT_NOT_FOUND); + expect(second).toEqual({ + error: + "Script 'scripts/some-other-guess.ts' not found in skill 'test-skill'." + + ' This is script lookup failure #2 this invocation. Do not retry any' + + ' script path — report the error to the user and stop.', + errorCode: RunSkillScriptErrorCode.SCRIPT_NOT_FOUND_FATAL, + }); + }); + + it('resets the counter for a new invocation id', async () => { + const toolset = new SkillToolset([mockSkill]); + const tool = new RunSkillScriptTool(toolset); + const sharedState: Record = {}; + const args = {skill_name: 'test-skill', script_path: 'scripts/invalid.js'}; + const firstInvocation = createMockContext( + 'test-agent', + undefined, + 'inv-1', + sharedState, + ); + const secondInvocation = createMockContext( + 'test-agent', + undefined, + 'inv-2', + sharedState, + ); + + await tool.runAsync({args, toolContext: firstInvocation}); + const secondMiss = (await tool.runAsync({ + args, + toolContext: firstInvocation, + })) as ToolErrorResponse; + const newInvocationMiss = (await tool.runAsync({ + args, + toolContext: secondInvocation, + })) as ToolErrorResponse; + + expect(secondMiss.errorCode).toBe( + RunSkillScriptErrorCode.SCRIPT_NOT_FOUND_FATAL, + ); + expect(newInvocationMiss).toEqual({ + error: "Script 'scripts/invalid.js' not found in skill 'test-skill'.", + errorCode: RunSkillScriptErrorCode.SCRIPT_NOT_FOUND, + }); + expect(sharedState).toEqual({ + 'temp:_adk_skill_script_not_found_count_inv-1': 2, + 'temp:_adk_skill_script_not_found_count_inv-2': 1, + }); + }); + it('returns error if no code executor configured', async () => { const toolset = new SkillToolset([mockSkill]); // no executor const tool = new RunSkillScriptTool(toolset); @@ -263,5 +374,11 @@ describe('RunSkillScriptTool', () => { expect(RunSkillScriptErrorCode.NO_CODE_EXECUTOR).toBe('NO_CODE_EXECUTOR'); expect(RunSkillScriptErrorCode.EXECUTION_ERROR).toBe('EXECUTION_ERROR'); }); + + it('exposes the fatal script-lookup code shared with the Python SDK', () => { + expect(RunSkillScriptErrorCode.SCRIPT_NOT_FOUND_FATAL).toBe( + 'SCRIPT_NOT_FOUND_FATAL', + ); + }); }); });