Skip to content

Commit e7f13d5

Browse files
Harden action-cache replay when cached XPath is missing
Co-authored-by: Shri Sukhani <shrisukhani@users.noreply.github.com>
1 parent 029b668 commit e7f13d5

2 files changed

Lines changed: 154 additions & 1 deletion

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { HyperAgent } from "@/agent";
2+
import { TaskStatus, type ActionCacheOutput } from "@/types/agent/types";
3+
import type { HyperAgentLLM } from "@/llm/types";
4+
5+
function createMockLLM(): HyperAgentLLM {
6+
return {
7+
invoke: async () => ({ role: "assistant", content: "ok" }),
8+
invokeStructured: async () => ({ rawText: "{}", parsed: null }),
9+
getProviderId: () => "mock",
10+
getModelId: () => "mock-model",
11+
getCapabilities: () => ({
12+
multimodal: false,
13+
toolCalling: true,
14+
jsonMode: true,
15+
}),
16+
};
17+
}
18+
19+
describe("runFromActionCache hardening", () => {
20+
it("falls back to instruction perform when helper method cache lacks xpath", async () => {
21+
const agent = new HyperAgent({
22+
llm: createMockLLM(),
23+
cdpActions: false,
24+
});
25+
const perform = jest.fn().mockResolvedValue({
26+
taskId: "perform-task",
27+
status: TaskStatus.COMPLETED,
28+
steps: [],
29+
output: "performed via instruction",
30+
replayStepMeta: {
31+
usedCachedAction: false,
32+
fallbackUsed: true,
33+
retries: 1,
34+
cachedXPath: null,
35+
fallbackXPath: "/html/body/button[1]",
36+
fallbackElementId: "0-1",
37+
},
38+
});
39+
const performClick = jest.fn();
40+
41+
const page = {
42+
perform,
43+
performClick,
44+
} as unknown as import("@/types/agent/types").HyperPage;
45+
46+
const cache: ActionCacheOutput = {
47+
taskId: "cache-task",
48+
createdAt: new Date().toISOString(),
49+
status: TaskStatus.COMPLETED,
50+
steps: [
51+
{
52+
stepIndex: 0,
53+
instruction: "click login",
54+
elementId: "0-1",
55+
method: "click",
56+
arguments: [],
57+
frameIndex: 0,
58+
xpath: null,
59+
actionType: "actElement",
60+
success: true,
61+
message: "cached",
62+
},
63+
],
64+
};
65+
66+
const replay = await agent.runFromActionCache(cache, page);
67+
68+
expect(perform).toHaveBeenCalledWith("click login");
69+
expect(performClick).not.toHaveBeenCalled();
70+
expect(replay.status).toBe(TaskStatus.COMPLETED);
71+
expect(replay.steps[0]?.usedXPath).toBe(false);
72+
});
73+
74+
it("fails fast when method cache lacks both xpath and instruction", async () => {
75+
const agent = new HyperAgent({
76+
llm: createMockLLM(),
77+
cdpActions: false,
78+
});
79+
const page = {} as import("@/types/agent/types").HyperPage;
80+
const cache: ActionCacheOutput = {
81+
taskId: "cache-task",
82+
createdAt: new Date().toISOString(),
83+
status: TaskStatus.COMPLETED,
84+
steps: [
85+
{
86+
stepIndex: 0,
87+
instruction: undefined,
88+
elementId: "0-1",
89+
method: "click",
90+
arguments: [],
91+
frameIndex: 0,
92+
xpath: null,
93+
actionType: "actElement",
94+
success: true,
95+
message: "cached",
96+
},
97+
],
98+
};
99+
100+
const replay = await agent.runFromActionCache(cache, page);
101+
102+
expect(replay.status).toBe(TaskStatus.FAILED);
103+
expect(replay.steps[0]?.message).toContain("without XPath or instruction");
104+
});
105+
});

‎src/agent/index.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -777,6 +777,54 @@ export class HyperAgent<T extends BrowserProviders = "Local"> {
777777
} else {
778778
const method = step.method;
779779
if (method && validHelperMethods.has(method)) {
780+
const xpath = step.xpath;
781+
const hasXPath =
782+
typeof xpath === "string" && xpath.trim().length > 0;
783+
if (!hasXPath) {
784+
if (step.instruction) {
785+
result = await hyperPage.perform(step.instruction);
786+
} else {
787+
result = {
788+
taskId: cache.taskId,
789+
status: TaskStatus.FAILED,
790+
steps: [],
791+
output: `Cannot replay action type "${step.actionType}" with method "${method}" without XPath or instruction`,
792+
replayStepMeta: {
793+
usedCachedAction: false,
794+
fallbackUsed: false,
795+
retries: 0,
796+
cachedXPath: null,
797+
fallbackXPath: null,
798+
fallbackElementId: null,
799+
},
800+
};
801+
}
802+
const finalMeta = result.replayStepMeta;
803+
const finalSuccess = result.status === TaskStatus.COMPLETED;
804+
805+
stepsResult.push({
806+
stepIndex: step.stepIndex,
807+
actionType: step.actionType,
808+
usedXPath: finalMeta?.usedCachedAction ?? false,
809+
fallbackUsed: finalMeta?.fallbackUsed ?? false,
810+
cachedXPath: finalMeta?.cachedXPath ?? null,
811+
fallbackXPath: finalMeta?.fallbackXPath ?? null,
812+
fallbackElementId: finalMeta?.fallbackElementId ?? null,
813+
retries: finalMeta?.retries ?? 0,
814+
success: finalSuccess,
815+
message:
816+
result.output ||
817+
(finalSuccess
818+
? "Completed"
819+
: "Failed to execute cached action"),
820+
});
821+
822+
if (!finalSuccess) {
823+
replayStatus = TaskStatus.FAILED;
824+
break;
825+
}
826+
continue;
827+
}
780828
const options: PerformOptions = {
781829
performInstruction: step.instruction ?? null,
782830
maxSteps: maxXPathRetries,
@@ -788,7 +836,7 @@ export class HyperAgent<T extends BrowserProviders = "Local"> {
788836
result = await dispatchPerformHelper(
789837
hyperPage,
790838
method,
791-
step.xpath ?? "",
839+
xpath,
792840
valueArg,
793841
options
794842
);

0 commit comments

Comments
 (0)