Skip to content

Commit 1a171f7

Browse files
feat: add tool error resilience — auto-retry with prerequisite resolution for ui_open_panel/ui_click undefined failures
1 parent 6d77959 commit 1a171f7

1 file changed

Lines changed: 299 additions & 0 deletions

File tree

src/agent/tool-resilience.js

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
/**
2+
* OpenTrade — Tool Error Resilience
3+
*
4+
* Handles the "ui_open_panel: undefined" and "ui_click: undefined" failures
5+
* by implementing retry logic, fallback strategies, and graceful degradation.
6+
*
7+
* TradingView UI tools depend on DOM state — panels must be visible,
8+
* buttons must exist. This module wraps tool calls with awareness of
9+
* these dependencies.
10+
*/
11+
12+
// ─── Configuration ────────────────────────────────────────────────────────────
13+
14+
const RETRY_CONFIG = {
15+
MAX_RETRIES: 2,
16+
RETRY_DELAY_MS: 1_000,
17+
UI_SETTLE_DELAY_MS: 500,
18+
};
19+
20+
// ─── Tool Dependency Map ──────────────────────────────────────────────────────
21+
22+
/**
23+
* Tools that depend on specific UI state being active.
24+
* When these fail, we know what prerequisite to try first.
25+
*/
26+
const TOOL_DEPENDENCIES = {
27+
// Strategy Tester requires the panel to be open
28+
data_get_strategy_results: {
29+
requires: 'strategy-tester-panel',
30+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'strategy-tester' } },
31+
fallback: { tool: 'ui_click', args: { text: 'Strategy Tester' } },
32+
},
33+
data_get_trades: {
34+
requires: 'strategy-tester-panel',
35+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'strategy-tester' } },
36+
fallback: { tool: 'ui_click', args: { text: 'Strategy Tester' } },
37+
},
38+
data_get_equity: {
39+
requires: 'strategy-tester-panel',
40+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'strategy-tester' } },
41+
},
42+
43+
// Pine Editor tools require the editor panel
44+
pine_set_source: {
45+
requires: 'pine-editor-panel',
46+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'pine-editor' } },
47+
},
48+
pine_smart_compile: {
49+
requires: 'pine-editor-panel',
50+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'pine-editor' } },
51+
},
52+
pine_compile: {
53+
requires: 'pine-editor-panel',
54+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'pine-editor' } },
55+
},
56+
pine_get_errors: {
57+
requires: 'pine-editor-panel',
58+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'pine-editor' } },
59+
},
60+
pine_get_source: {
61+
requires: 'pine-editor-panel',
62+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'pine-editor' } },
63+
},
64+
pine_save: {
65+
requires: 'pine-editor-panel',
66+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'pine-editor' } },
67+
},
68+
pine_new: {
69+
requires: 'pine-editor-panel',
70+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'pine-editor' } },
71+
},
72+
73+
// DOM/depth requires the panel
74+
depth_get: {
75+
requires: 'dom-panel',
76+
prerequisite: { tool: 'ui_open_panel', args: { panel: 'trading' } },
77+
},
78+
};
79+
80+
// ─── Error Classification ─────────────────────────────────────────────────────
81+
82+
/**
83+
* Classify a tool error to determine the right recovery strategy.
84+
*/
85+
function classifyError(toolName, error) {
86+
const msg = typeof error === 'string' ? error : error?.message || String(error);
87+
const lower = msg.toLowerCase();
88+
89+
// UI element not found / not visible
90+
if (
91+
lower.includes('undefined') ||
92+
lower.includes('not found') ||
93+
lower.includes('could not find') ||
94+
lower.includes('not visible') ||
95+
lower.includes('no element') ||
96+
lower.includes('could not open')
97+
) {
98+
return {
99+
type: 'UI_NOT_READY',
100+
retryable: true,
101+
needsPrerequisite: true,
102+
message: `${toolName} failed because required UI element isn't visible`,
103+
};
104+
}
105+
106+
// Connection issues
107+
if (
108+
lower.includes('cdp') ||
109+
lower.includes('connection') ||
110+
lower.includes('disconnected') ||
111+
lower.includes('timeout') ||
112+
lower.includes('econnrefused')
113+
) {
114+
return {
115+
type: 'CONNECTION_ERROR',
116+
retryable: true,
117+
needsPrerequisite: false,
118+
message: `${toolName} failed due to connection issue — TradingView may need restart`,
119+
};
120+
}
121+
122+
// Pine Script specific errors (not retryable — need code fix)
123+
if (
124+
lower.includes('compilation') ||
125+
lower.includes('syntax error') ||
126+
lower.includes('undeclared')
127+
) {
128+
return {
129+
type: 'PINE_ERROR',
130+
retryable: false,
131+
needsPrerequisite: false,
132+
message: `${toolName} failed with Pine Script error — needs code fix`,
133+
};
134+
}
135+
136+
// Protected/encrypted indicator
137+
if (lower.includes('protected') || lower.includes('encrypted')) {
138+
return {
139+
type: 'PROTECTED_INDICATOR',
140+
retryable: false,
141+
needsPrerequisite: false,
142+
message: `${toolName} cannot read protected indicator — use data_get_study_values instead`,
143+
};
144+
}
145+
146+
// Unknown error
147+
return {
148+
type: 'UNKNOWN',
149+
retryable: false,
150+
needsPrerequisite: false,
151+
message: `${toolName} failed: ${msg}`,
152+
};
153+
}
154+
155+
// ─── Resilient Tool Caller ────────────────────────────────────────────────────
156+
157+
/**
158+
* Wrap a callTool function with retry logic and dependency resolution.
159+
*
160+
* @param {Function} callTool - The original MCP callTool function
161+
* @returns {Function} Wrapped callTool with resilience
162+
*/
163+
function createResilientCaller(callTool) {
164+
// Track which prerequisites we've already tried this session
165+
const prerequisiteAttempts = new Set();
166+
167+
return async function resilientCallTool(toolName, input) {
168+
let lastError;
169+
170+
for (let attempt = 0; attempt <= RETRY_CONFIG.MAX_RETRIES; attempt++) {
171+
try {
172+
const result = await callTool(toolName, input);
173+
174+
// Check for "success: false" in the result
175+
if (result && typeof result === 'object' && result.success === false) {
176+
const errorMsg = result.error || result.message || 'Tool returned success: false';
177+
const classification = classifyError(toolName, errorMsg);
178+
179+
if (classification.retryable && attempt < RETRY_CONFIG.MAX_RETRIES) {
180+
// Try running prerequisite if available
181+
if (classification.needsPrerequisite) {
182+
await tryPrerequisite(toolName, callTool, prerequisiteAttempts);
183+
}
184+
await delay(RETRY_CONFIG.RETRY_DELAY_MS);
185+
continue;
186+
}
187+
188+
// Return the error result — let the agent handle it
189+
return result;
190+
}
191+
192+
// Check for undefined/null result (the exact bug in the screenshot)
193+
if (result === undefined || result === null) {
194+
const classification = classifyError(toolName, 'returned undefined');
195+
196+
if (classification.retryable && attempt < RETRY_CONFIG.MAX_RETRIES) {
197+
if (classification.needsPrerequisite) {
198+
await tryPrerequisite(toolName, callTool, prerequisiteAttempts);
199+
}
200+
await delay(RETRY_CONFIG.RETRY_DELAY_MS);
201+
continue;
202+
}
203+
204+
return {
205+
success: false,
206+
error: `${toolName} returned undefined — UI element may not be available. Try opening the required panel first.`,
207+
suggestion: getSuggestion(toolName),
208+
};
209+
}
210+
211+
return result;
212+
} catch (err) {
213+
lastError = err;
214+
const classification = classifyError(toolName, err);
215+
216+
if (classification.retryable && attempt < RETRY_CONFIG.MAX_RETRIES) {
217+
console.error(
218+
`[tool-resilience] ${toolName} attempt ${attempt + 1} failed (${classification.type}), retrying...`
219+
);
220+
221+
if (classification.needsPrerequisite) {
222+
await tryPrerequisite(toolName, callTool, prerequisiteAttempts);
223+
}
224+
await delay(RETRY_CONFIG.RETRY_DELAY_MS);
225+
continue;
226+
}
227+
228+
throw err;
229+
}
230+
}
231+
232+
// All retries exhausted
233+
throw lastError || new Error(`${toolName} failed after ${RETRY_CONFIG.MAX_RETRIES + 1} attempts`);
234+
};
235+
}
236+
237+
/**
238+
* Try to satisfy a tool's prerequisite (e.g., open a panel before reading from it).
239+
*/
240+
async function tryPrerequisite(toolName, callTool, attempts) {
241+
const dep = TOOL_DEPENDENCIES[toolName];
242+
if (!dep || attempts.has(dep.requires)) return;
243+
244+
attempts.add(dep.requires);
245+
console.error(`[tool-resilience] Running prerequisite for ${toolName}: ${dep.prerequisite.tool}`);
246+
247+
try {
248+
await callTool(dep.prerequisite.tool, dep.prerequisite.args);
249+
await delay(RETRY_CONFIG.UI_SETTLE_DELAY_MS);
250+
} catch (prereqErr) {
251+
console.error(`[tool-resilience] Prerequisite ${dep.prerequisite.tool} also failed: ${prereqErr.message}`);
252+
253+
// Try fallback if available
254+
if (dep.fallback) {
255+
try {
256+
console.error(`[tool-resilience] Trying fallback: ${dep.fallback.tool}`);
257+
await callTool(dep.fallback.tool, dep.fallback.args);
258+
await delay(RETRY_CONFIG.UI_SETTLE_DELAY_MS);
259+
} catch {
260+
console.error(`[tool-resilience] Fallback also failed`);
261+
}
262+
}
263+
}
264+
}
265+
266+
/**
267+
* Get a human-readable suggestion when a tool fails.
268+
*/
269+
function getSuggestion(toolName) {
270+
const dep = TOOL_DEPENDENCIES[toolName];
271+
if (dep) {
272+
return `Try running ${dep.prerequisite.tool}(${JSON.stringify(dep.prerequisite.args)}) first to open the required panel.`;
273+
}
274+
275+
const suggestions = {
276+
ui_open_panel: 'Make sure TradingView is open in Chrome and the Pine Editor tab is visible.',
277+
ui_click: 'The button or element may not be visible. Try navigating to the correct view first.',
278+
data_get_indicator: 'This indicator may be protected/encrypted. Use data_get_study_values instead.',
279+
};
280+
281+
return suggestions[toolName] || 'Check that TradingView is running and connected.';
282+
}
283+
284+
/**
285+
* Simple delay helper.
286+
*/
287+
function delay(ms) {
288+
return new Promise((resolve) => setTimeout(resolve, ms));
289+
}
290+
291+
// ─── Exports ──────────────────────────────────────────────────────────────────
292+
293+
export {
294+
RETRY_CONFIG,
295+
TOOL_DEPENDENCIES,
296+
classifyError,
297+
createResilientCaller,
298+
getSuggestion,
299+
};

0 commit comments

Comments
 (0)