Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion core/src/tools/skill/run_skill_script_tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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<number>(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,
};
}
Expand Down
119 changes: 118 additions & 1 deletion core/test/tools/skills/run_skill_script_tool_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ describe('RunSkillScriptTool', () => {
function createMockContext(
agentName = 'test-agent',
agentExecutor?: BaseCodeExecutor,
invocationId = 'inv-1',
sessionState: Record<string, unknown> = {},
): Context {
const agentObj: Record<string | symbol, unknown> = {name: agentName};
if (agentExecutor) {
Expand All @@ -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,
});
Expand Down Expand Up @@ -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<string, unknown> = {};
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);
Expand Down Expand Up @@ -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',
);
});
});
});
Loading