handhold-sdk 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +427 -0
- package/dist/d44f1fe8001e32f8e74d.svg +3056 -0
- package/dist/fonts/Phosphor.ttf +0 -0
- package/dist/fonts/Phosphor.woff +0 -0
- package/dist/fonts/Phosphor.woff2 +0 -0
- package/dist/handhold.min.js +1 -0
- package/package.json +114 -0
- package/src/HandHold.js +1599 -0
- package/src/agent/VENDOR.md +203 -0
- package/src/agent/agent/AgentLoop.js +366 -0
- package/src/agent/agent/prompts.js +163 -0
- package/src/agent/agent/tools.js +148 -0
- package/src/agent/controller.js +235 -0
- package/src/agent/dom/actions.js +456 -0
- package/src/agent/dom/domTree.js +1761 -0
- package/src/agent/dom/redact.js +98 -0
- package/src/agent/dom/serializer.js +332 -0
- package/src/agent/dom/utils.js +99 -0
- package/src/agent/index.js +169 -0
- package/src/agent/llm/connector.js +143 -0
- package/src/agent/package.json +3 -0
- package/src/agent/policy/PolicyGuard.js +172 -0
- package/src/agent/site/manifest.js +145 -0
- package/src/agent/ui/ChatWidget.js +219 -0
- package/src/agent-mode/AgentMode.js +429 -0
- package/src/api/APIClient.js +258 -0
- package/src/core/Config.js +207 -0
- package/src/core/UiState.js +83 -0
- package/src/core/defaults.js +20 -0
- package/src/events.js +59 -0
- package/src/index.js +77 -0
- package/src/intent/ActionVerbMap.js +324 -0
- package/src/intent/IntentExtractor.js +317 -0
- package/src/observers/ElementScanner.js +284 -0
- package/src/observers/NavigationObserver.js +182 -0
- package/src/observers/PageObserver.js +293 -0
- package/src/session/SessionManager.js +402 -0
- package/src/session/SessionStorage.js +148 -0
- package/src/ui/HelpWidget.js +1189 -0
- package/src/ui/UICoach.js +1725 -0
- package/src/ui/components/Button.css +137 -0
- package/src/ui/components/Button.js +102 -0
- package/src/ui/widget.css +599 -0
- package/src/utils/Logger.js +132 -0
- package/src/utils/MarkdownRenderer.js +39 -0
- package/src/utils/domUtils.js +350 -0
- package/src/utils/eventBus.js +102 -0
- package/src/utils/index.js +8 -0
- package/src/utils/stringUtils.js +202 -0
- package/types/index.d.ts +538 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - AgentMode (Task H5)
|
|
3
|
+
*
|
|
4
|
+
* Thin bridge between Handhold (Config/EventBus/HelpWidget/UICoach) and the
|
|
5
|
+
* vendored trusted-page-agent engine. AgentMode owns the engine lifecycle for
|
|
6
|
+
* "Do it for me" runs; the widget renders what the engine reports and answers
|
|
7
|
+
* its confirm prompts. Feature-flagged: HandHold only constructs this when
|
|
8
|
+
* `config.agentMode` is true (SDK default false) — "Show me"/"Explain" paths
|
|
9
|
+
* are untouched.
|
|
10
|
+
*
|
|
11
|
+
* Security posture (inherits the engine's guarantees — never weaken here):
|
|
12
|
+
* - Model calls ride the Handhold backend proxy (`/api/v1/agent/llm`) using
|
|
13
|
+
* the tenant apiKey the SDK already has. No LLM provider key in the browser.
|
|
14
|
+
* - `allowedOrigins` is pinned to the current origin.
|
|
15
|
+
* - onConfirm stays fail-closed: the returned promise only resolves when the
|
|
16
|
+
* user answers a confirm card; anything except an explicit `true` denies.
|
|
17
|
+
* - The widget's own DOM (#handhold-container and every descendant) is
|
|
18
|
+
* blacklisted from engine snapshots, so the agent can never "see" or click
|
|
19
|
+
* Handhold's own UI (no self-driving loops, no Allow-button self-clicks).
|
|
20
|
+
*
|
|
21
|
+
* Events (per plan H5):
|
|
22
|
+
* in: 'agent:start' (agent_plan from the widget button), 'agent:stop'
|
|
23
|
+
* out: 'agent:activity' (engine activity, executing events enriched with a
|
|
24
|
+
* best-effort targetSelector for UICoach), 'agent:confirm'
|
|
25
|
+
* ({pending, respond}), 'agent:ask' ({question, respond}),
|
|
26
|
+
* 'agent:done' ({success, text, steps})
|
|
27
|
+
*/
|
|
28
|
+
import { generateSelector } from '../utils/domUtils.js';
|
|
29
|
+
|
|
30
|
+
const WIDGET_CONTAINER_ID = 'handhold-container';
|
|
31
|
+
const LLM_PROXY_PATH = '/api/v1/agent/llm';
|
|
32
|
+
|
|
33
|
+
class AgentMode {
|
|
34
|
+
/**
|
|
35
|
+
* @param {Object} deps
|
|
36
|
+
* @param {Object} deps.config Config instance (or stub with get())
|
|
37
|
+
* @param {Object} deps.eventBus Handhold event bus
|
|
38
|
+
* @param {Object} [deps.sessionManager] SessionManager — carries the agent
|
|
39
|
+
* checkpoint across full page loads (H7). Optional: without it runs
|
|
40
|
+
* work but do not survive reloads.
|
|
41
|
+
* @param {(url:string)=>void} [deps.navigateImpl] performs the actual
|
|
42
|
+
* navigation after checkpointing (default location.assign; test seam)
|
|
43
|
+
* @param {(cfg:Object)=>Object} [deps.engineFactory]
|
|
44
|
+
* Test seam: receives the exact TrustedPageAgent config and returns an
|
|
45
|
+
* engine ({run, stop, getAuditLog}). Default lazily imports the
|
|
46
|
+
* vendored engine — lazy so environments that never start a run
|
|
47
|
+
* (and jest, which fakes the engine) never load it.
|
|
48
|
+
*/
|
|
49
|
+
constructor({ config, eventBus, sessionManager, navigateImpl, engineFactory } = {}) {
|
|
50
|
+
if (!config) throw new Error('[HandHold AgentMode] config is required');
|
|
51
|
+
this.config = config;
|
|
52
|
+
this.eventBus = eventBus || null;
|
|
53
|
+
this.sessionManager = sessionManager || null;
|
|
54
|
+
this._navigate = navigateImpl || ((url) => globalThis.window?.location?.assign(url));
|
|
55
|
+
this._customFactory = typeof engineFactory === 'function';
|
|
56
|
+
this.engineFactory = this._customFactory
|
|
57
|
+
? engineFactory
|
|
58
|
+
: async (cfg) => {
|
|
59
|
+
const { default: TrustedPageAgent } = await import(
|
|
60
|
+
/* webpackMode: "eager" */ '../agent/index.js'
|
|
61
|
+
);
|
|
62
|
+
return new TrustedPageAgent(cfg);
|
|
63
|
+
};
|
|
64
|
+
this.engine = null;
|
|
65
|
+
this.running = false;
|
|
66
|
+
this._currentTask = null;
|
|
67
|
+
this._abort = null; // per-run AbortController (H9: responsive Stop)
|
|
68
|
+
this._engineMod = null; // vendored module, preloaded for spotlight resolution
|
|
69
|
+
this._manifestPromise = null; // one fetch per AgentMode instance (H6)
|
|
70
|
+
|
|
71
|
+
if (this.eventBus) {
|
|
72
|
+
this.eventBus.on('agent:start', (plan) => {
|
|
73
|
+
this.start(plan);
|
|
74
|
+
});
|
|
75
|
+
this.eventBus.on('agent:stop', () => this.stop());
|
|
76
|
+
// SPA settle only (H7): in-app route changes keep this JS context alive,
|
|
77
|
+
// so a running engine simply continues — resume is BOOT-ONLY (a full
|
|
78
|
+
// reload destroyed the context). Never resume from here.
|
|
79
|
+
this.eventBus.on('navigation:changed', () => {});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_get(key) {
|
|
84
|
+
return typeof this.config.get === 'function' ? this.config.get(key) : this.config[key];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
_emit(name, payload) {
|
|
88
|
+
if (this.eventBus) this.eventBus.emit(name, payload);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The Handhold-UI blacklist: a function re-evaluated on every engine snapshot,
|
|
93
|
+
* returning every Handhold widget element + ALL descendants so the agent can
|
|
94
|
+
* never see or click its own UI (the engine matches exact elements — see
|
|
95
|
+
* resolveElementList in the vendored serializer).
|
|
96
|
+
*
|
|
97
|
+
* Covers BOTH the SDK's own container (`#handhold-container`) AND any host-
|
|
98
|
+
* rendered Handhold UI marked `data-handhold-ui`. The demo replaces the
|
|
99
|
+
* legacy widget with its own React panels; without the marker those panels
|
|
100
|
+
* were visible to the agent, which then mis-clicked its own controls.
|
|
101
|
+
*/
|
|
102
|
+
_widgetBlacklist() {
|
|
103
|
+
return [
|
|
104
|
+
() => {
|
|
105
|
+
const roots = [
|
|
106
|
+
document.getElementById(WIDGET_CONTAINER_ID),
|
|
107
|
+
...document.querySelectorAll('[data-handhold-ui]'),
|
|
108
|
+
].filter(Boolean);
|
|
109
|
+
const out = [];
|
|
110
|
+
for (const root of roots) {
|
|
111
|
+
out.push(root, ...root.querySelectorAll('*'));
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
},
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Fetch the tenant's site manifest once and cache it (H6). The server only
|
|
120
|
+
* serves reviewed manifests (404 otherwise); reviewed===true is re-checked
|
|
121
|
+
* here anyway, and the engine's loadSiteManifest enforces it a third time at
|
|
122
|
+
* construction — an unreviewed map must never steer the agent. Any failure
|
|
123
|
+
* (404, network, bad JSON) degrades to null: single-page mode, run proceeds.
|
|
124
|
+
*/
|
|
125
|
+
_getSiteManifest() {
|
|
126
|
+
if (!this._manifestPromise) {
|
|
127
|
+
this._manifestPromise = (async () => {
|
|
128
|
+
try {
|
|
129
|
+
const res = await globalThis.fetch(`${this._get('baseUrl')}/api/v1/manifest`, {
|
|
130
|
+
headers: { Authorization: `Bearer ${this._get('apiKey')}` },
|
|
131
|
+
});
|
|
132
|
+
if (!res || !res.ok) return null;
|
|
133
|
+
const json = await res.json();
|
|
134
|
+
return json && json.reviewed === true ? json : null;
|
|
135
|
+
} catch (e) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
})();
|
|
139
|
+
}
|
|
140
|
+
return this._manifestPromise;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The exact TrustedPageAgent config (also what tests assert against). */
|
|
144
|
+
_engineConfig() {
|
|
145
|
+
return {
|
|
146
|
+
llm: {
|
|
147
|
+
provider: 'openai-compatible',
|
|
148
|
+
baseURL: `${this._get('baseUrl')}${LLM_PROXY_PATH}`,
|
|
149
|
+
apiKey: this._get('apiKey'),
|
|
150
|
+
model: 'tenant-default', // hint only — the backend proxy picks the real model
|
|
151
|
+
},
|
|
152
|
+
allowedOrigins: [window.location.origin],
|
|
153
|
+
onConfirm: (pending) =>
|
|
154
|
+
new Promise((resolve) => {
|
|
155
|
+
let settled = false;
|
|
156
|
+
const respond = (allowed) => {
|
|
157
|
+
if (settled) return;
|
|
158
|
+
settled = true;
|
|
159
|
+
resolve(allowed === true); // fail-closed: only explicit true allows
|
|
160
|
+
};
|
|
161
|
+
this._emit('agent:confirm', { pending, respond });
|
|
162
|
+
}),
|
|
163
|
+
onAskUser: (question) =>
|
|
164
|
+
new Promise((resolve, reject) => {
|
|
165
|
+
let settled = false;
|
|
166
|
+
this._emit('agent:ask', {
|
|
167
|
+
question,
|
|
168
|
+
respond: (answer) => {
|
|
169
|
+
if (settled) return;
|
|
170
|
+
settled = true;
|
|
171
|
+
resolve(String(answer ?? ''));
|
|
172
|
+
},
|
|
173
|
+
cancel: () => {
|
|
174
|
+
if (settled) return;
|
|
175
|
+
settled = true;
|
|
176
|
+
reject(new Error('dismissed'));
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
}),
|
|
180
|
+
onActivity: (ev) => this._onEngineActivity(ev),
|
|
181
|
+
domConfig: { interactiveBlacklist: this._widgetBlacklist() },
|
|
182
|
+
// H7: every engine navigation checkpoints first, then really navigates
|
|
183
|
+
navigateImpl: (url) => this._checkpointAndNavigate(url),
|
|
184
|
+
maxSteps: 25,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Write the run checkpoint, then navigate (H7). Called by the engine's
|
|
190
|
+
* controller AFTER the guard allowed the navigation. The checkpoint is
|
|
191
|
+
* best-effort — a failure to save must never block the navigation itself.
|
|
192
|
+
* The in-flight navigate step is not in engine history yet (the loop pushes
|
|
193
|
+
* after the action), so a synthetic marker entry records where we headed.
|
|
194
|
+
*/
|
|
195
|
+
async _checkpointAndNavigate(url) {
|
|
196
|
+
try {
|
|
197
|
+
if (this.sessionManager && this.engine && typeof this.engine.getRunState === 'function') {
|
|
198
|
+
const st = this.engine.getRunState() || {};
|
|
199
|
+
const history = (st.history ?? []).slice(-50).map(String);
|
|
200
|
+
history.push(`<step_${st.stepsUsed ?? '?'}> navigate → heading to ${url} (checkpoint saved)`);
|
|
201
|
+
const checkpoint = {
|
|
202
|
+
v: 1,
|
|
203
|
+
task: this._currentTask ?? '',
|
|
204
|
+
stepsUsed: Number.isInteger(st.stepsUsed) ? st.stepsUsed : 0,
|
|
205
|
+
history: history.slice(-50),
|
|
206
|
+
audit: (st.audit ?? []).slice(-50),
|
|
207
|
+
savedAt: Date.now(),
|
|
208
|
+
};
|
|
209
|
+
// size caps write-side too: drop audit first, then trim history harder
|
|
210
|
+
if (JSON.stringify(checkpoint).length > 40000) checkpoint.audit = [];
|
|
211
|
+
if (JSON.stringify(checkpoint).length > 40000) checkpoint.history = checkpoint.history.slice(-20);
|
|
212
|
+
this.sessionManager.saveAgentCheckpoint(checkpoint);
|
|
213
|
+
}
|
|
214
|
+
} catch (e) {
|
|
215
|
+
/* checkpoint is best-effort; navigation must proceed */
|
|
216
|
+
}
|
|
217
|
+
return this._navigate(url);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Restore-side checkpoint validation (spec §7). Returns a human-readable
|
|
222
|
+
* problem string, or null when the checkpoint is safe to resume.
|
|
223
|
+
*/
|
|
224
|
+
_checkpointProblem(cp) {
|
|
225
|
+
if (!cp || typeof cp !== 'object') return 'malformed (not an object)';
|
|
226
|
+
if (cp.v !== 1) return `malformed (unsupported checkpoint version ${cp.v})`;
|
|
227
|
+
if (typeof cp.task !== 'string' || !cp.task.trim()) return 'malformed (missing task)';
|
|
228
|
+
if (!Array.isArray(cp.history) || cp.history.some((h) => typeof h !== 'string')) {
|
|
229
|
+
return 'malformed (history must be an array of strings)';
|
|
230
|
+
}
|
|
231
|
+
if (!Number.isInteger(cp.stepsUsed) || cp.stepsUsed < 0) return 'malformed (bad stepsUsed)';
|
|
232
|
+
if (typeof cp.savedAt !== 'number') return 'malformed (bad savedAt)';
|
|
233
|
+
if (Date.now() - cp.savedAt > 10 * 60 * 1000) return 'stale (older than 10 minutes)';
|
|
234
|
+
if (cp.history.length > 50) return 'oversized (history exceeds 50 entries)';
|
|
235
|
+
try {
|
|
236
|
+
if (JSON.stringify(cp).length > 40000) return 'oversized (exceeds 40 kB)';
|
|
237
|
+
} catch {
|
|
238
|
+
return 'malformed (not serializable)';
|
|
239
|
+
}
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Boot-time resume (H7): if the active session carries a checkpoint, either
|
|
245
|
+
* resume it (restored history + remaining step budget) or discard it with an
|
|
246
|
+
* audited reason. SPA route changes never reach this — resume is boot-only.
|
|
247
|
+
* @returns {Promise<{resumed:boolean, reason?:string, result?:Object}>}
|
|
248
|
+
*/
|
|
249
|
+
async resumeIfCheckpointed() {
|
|
250
|
+
const sm = this.sessionManager;
|
|
251
|
+
if (!sm || typeof sm.getAgentCheckpoint !== 'function') return { resumed: false };
|
|
252
|
+
const cp = sm.getAgentCheckpoint();
|
|
253
|
+
if (!cp) return { resumed: false };
|
|
254
|
+
|
|
255
|
+
const reason = this._checkpointProblem(cp);
|
|
256
|
+
if (reason) {
|
|
257
|
+
try {
|
|
258
|
+
sm.clearAgentCheckpoint();
|
|
259
|
+
} catch (e) {
|
|
260
|
+
/* discard is best-effort */
|
|
261
|
+
}
|
|
262
|
+
this._emit('agent:activity', { type: 'checkpoint_discarded', reason });
|
|
263
|
+
return { resumed: false, reason };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Start the resumed run in the BACKGROUND so boot completes promptly (the
|
|
267
|
+
// run may be long and may navigate again). The run clears the checkpoint
|
|
268
|
+
// when it finishes. runPromise is exposed for callers/tests that want to
|
|
269
|
+
// await completion.
|
|
270
|
+
const runPromise = this._run(
|
|
271
|
+
cp.task,
|
|
272
|
+
{ restoredHistory: cp.history, stepsUsed: cp.stepsUsed },
|
|
273
|
+
{ type: 'resuming', task: cp.task }
|
|
274
|
+
);
|
|
275
|
+
return { resumed: true, runPromise };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
_onEngineActivity(ev) {
|
|
279
|
+
const enriched = { ...ev };
|
|
280
|
+
if (ev && ev.type === 'executing' && ev.input && typeof ev.input.index === 'number') {
|
|
281
|
+
enriched.targetSelector = this._resolveTargetSelector(ev.input.index);
|
|
282
|
+
}
|
|
283
|
+
this._emit('agent:activity', enriched);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Best-effort: map the engine-reported element index back to a selector so
|
|
288
|
+
* UICoach can spotlight what is about to be acted on. A fresh snapshot with
|
|
289
|
+
* the same domConfig yields the same indices in practice; any failure (e.g.
|
|
290
|
+
* no layout engine) degrades to null — never blocks the run.
|
|
291
|
+
*/
|
|
292
|
+
_resolveTargetSelector(index) {
|
|
293
|
+
try {
|
|
294
|
+
if (!this._engineMod) return null;
|
|
295
|
+
const snapshot = this._engineMod.domSnapshotProvider({
|
|
296
|
+
interactiveBlacklist: this._widgetBlacklist(),
|
|
297
|
+
})();
|
|
298
|
+
const el = snapshot.selectorMap.get(index)?.ref;
|
|
299
|
+
return el ? generateSelector(el) : null;
|
|
300
|
+
} catch {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Frame the run task from the guidance agent_plan. The plan is trusted
|
|
307
|
+
* backend output (a restatement of the reviewed KB), unlike page content.
|
|
308
|
+
*/
|
|
309
|
+
buildTask(plan = {}) {
|
|
310
|
+
const goal = String(plan.goal || '').trim() || 'Complete the task the user requested.';
|
|
311
|
+
const lines = [goal];
|
|
312
|
+
if (Array.isArray(plan.hints) && plan.hints.length) {
|
|
313
|
+
lines.push('', 'Guidance from the help article (trusted):');
|
|
314
|
+
for (const hint of plan.hints.slice(0, 7)) lines.push(`- ${hint}`);
|
|
315
|
+
}
|
|
316
|
+
if (plan.target_page_path) {
|
|
317
|
+
lines.push(
|
|
318
|
+
'',
|
|
319
|
+
`This task is normally performed on the page at ${plan.target_page_path}. ` +
|
|
320
|
+
'If the current page is not that page, work with what is visible or ' +
|
|
321
|
+
'report what is missing — never guess URLs.'
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
// Behavioral rules surfaced by live testing (see feedback memory):
|
|
325
|
+
// (1) stop when done — the model was completing tasks but not calling done,
|
|
326
|
+
// looping to the step cap; (2) ask, never invent, missing input values.
|
|
327
|
+
lines.push(
|
|
328
|
+
'',
|
|
329
|
+
'Rules for this task:',
|
|
330
|
+
'- The moment the objective above is achieved, call done(success=true) with a ' +
|
|
331
|
+
'one-line summary. Do NOT keep acting once the goal is met, and do not ' +
|
|
332
|
+
're-verify by repeating actions.',
|
|
333
|
+
'- If a field needs a value you were not given (a title, name, amount, date, ' +
|
|
334
|
+
'email, or similar), call ask_user FIRST and WAIT for the answer, THEN type ' +
|
|
335
|
+
'EXACTLY what the person said. Do NOT type a value before asking, and never ' +
|
|
336
|
+
'invent, guess, use a placeholder, or copy an unrelated value from the page ' +
|
|
337
|
+
'(e.g. an existing row) into an input field.',
|
|
338
|
+
'- Ask for a given value only ONCE. After the person answers, immediately use ' +
|
|
339
|
+
'their exact answer in your next action — never ask the same thing again.',
|
|
340
|
+
'- If a form needs SEVERAL values you do not have (e.g. first name, last name, ' +
|
|
341
|
+
'email), ask for them ALL in ONE ask_user question (list them) rather than ' +
|
|
342
|
+
'one at a time, then fill each field with the matching answer.',
|
|
343
|
+
'- After each action, check the Current page and the visible elements to ' +
|
|
344
|
+
'confirm it had the effect you intended BEFORE moving on. If the page did ' +
|
|
345
|
+
'not change when you expected navigation, the element was wrong — pick a ' +
|
|
346
|
+
'different element or use the navigate tool; do not assume it worked.',
|
|
347
|
+
'- If you are blocked or an action is denied, call done(success=false) and ' +
|
|
348
|
+
'explain — do not retry a blocked action.'
|
|
349
|
+
);
|
|
350
|
+
return lines.join('\n');
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Start a run from an agent_plan. One run at a time.
|
|
355
|
+
* @returns {Promise<{success:boolean, text:string, steps:number}|null>}
|
|
356
|
+
*/
|
|
357
|
+
async start(plan) {
|
|
358
|
+
const task = this.buildTask(plan);
|
|
359
|
+
return this._run(task, {}, { type: 'run_start', goal: plan?.goal ?? task });
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Shared run path for fresh starts and checkpoint resumes.
|
|
364
|
+
* @param {string} task
|
|
365
|
+
* @param {Object} engineOpts {restoredHistory?, stepsUsed?} on resume
|
|
366
|
+
* @param {Object} startActivity emitted once the engine is ready
|
|
367
|
+
*/
|
|
368
|
+
async _run(task, engineOpts, startActivity) {
|
|
369
|
+
if (this.running) {
|
|
370
|
+
this._emit('agent:activity', { type: 'info', message: 'Agent is already running.' });
|
|
371
|
+
return null;
|
|
372
|
+
}
|
|
373
|
+
this.running = true;
|
|
374
|
+
this._currentTask = task;
|
|
375
|
+
let result;
|
|
376
|
+
try {
|
|
377
|
+
if (!this._customFactory && !this._engineMod) {
|
|
378
|
+
// preload once for spotlight index->selector resolution
|
|
379
|
+
this._engineMod = await import(/* webpackMode: "eager" */ '../agent/index.js');
|
|
380
|
+
}
|
|
381
|
+
// Whole-site (H6): reviewed manifest -> engine siteManifest (null = single-page)
|
|
382
|
+
const siteManifest = await this._getSiteManifest();
|
|
383
|
+
this.engine = await this.engineFactory({ ...this._engineConfig(), siteManifest });
|
|
384
|
+
this._emit('agent:activity', startActivity);
|
|
385
|
+
// H9: pass an abort signal so Stop cancels the in-flight LLM call
|
|
386
|
+
// immediately instead of waiting for the next step boundary.
|
|
387
|
+
this._abort = new AbortController();
|
|
388
|
+
result = await this.engine.run(task, { ...engineOpts, signal: this._abort.signal });
|
|
389
|
+
} catch (e) {
|
|
390
|
+
result = { success: false, text: `Agent error: ${e.message}`, steps: 0 };
|
|
391
|
+
} finally {
|
|
392
|
+
this.running = false;
|
|
393
|
+
}
|
|
394
|
+
// The run finished IN THIS CONTEXT (done / error / SPA-continued): any
|
|
395
|
+
// checkpoint is now obsolete. A real full-page unload never reaches this
|
|
396
|
+
// line — the checkpoint survives for the boot-time resume (H7).
|
|
397
|
+
try {
|
|
398
|
+
if (this.sessionManager) this.sessionManager.clearAgentCheckpoint();
|
|
399
|
+
} catch (e) {
|
|
400
|
+
/* best-effort */
|
|
401
|
+
}
|
|
402
|
+
this._emit('agent:done', result);
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Stop the current run. Aborts the in-flight LLM call (so it halts within
|
|
408
|
+
* the current step, not after it) AND sets the engine's cooperative stop
|
|
409
|
+
* flag. Emits immediate feedback so the UI acknowledges the click.
|
|
410
|
+
*/
|
|
411
|
+
stop() {
|
|
412
|
+
if (!this.running) return;
|
|
413
|
+
this._emit('agent:activity', { type: 'info', message: 'Stopping…' });
|
|
414
|
+
try {
|
|
415
|
+
if (this._abort) this._abort.abort();
|
|
416
|
+
} catch (e) {
|
|
417
|
+
/* noop */
|
|
418
|
+
}
|
|
419
|
+
if (this.engine && typeof this.engine.stop === 'function') this.engine.stop();
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
getAuditLog() {
|
|
423
|
+
return this.engine && typeof this.engine.getAuditLog === 'function'
|
|
424
|
+
? this.engine.getAuditLog()
|
|
425
|
+
: [];
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export default AgentMode;
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hand Hold SDK - API Communication Layer
|
|
3
|
+
* Handles all HTTP communication with the Hand Hold backend
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
class APIClient {
|
|
7
|
+
constructor(config) {
|
|
8
|
+
this.config = config;
|
|
9
|
+
this.baseUrl = config.baseUrl;
|
|
10
|
+
this.apiKey = config.apiKey;
|
|
11
|
+
this.timeout = config.apiTimeout || 30000;
|
|
12
|
+
this.retryAttempts = config.retryAttempts || 3;
|
|
13
|
+
this.retryDelay = config.retryDelay || 1000;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Make HTTP request with retry logic
|
|
18
|
+
*/
|
|
19
|
+
async _request(endpoint, options = {}) {
|
|
20
|
+
const url = `${this.baseUrl}${endpoint}`;
|
|
21
|
+
|
|
22
|
+
const defaultHeaders = {
|
|
23
|
+
'Content-Type': 'application/json',
|
|
24
|
+
'Authorization': `Bearer ${this.apiKey}`,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const config = {
|
|
28
|
+
...options,
|
|
29
|
+
headers: {
|
|
30
|
+
...defaultHeaders,
|
|
31
|
+
...options.headers,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
let lastError;
|
|
36
|
+
|
|
37
|
+
for (let attempt = 1; attempt <= this.retryAttempts; attempt++) {
|
|
38
|
+
try {
|
|
39
|
+
const controller = new AbortController();
|
|
40
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
41
|
+
|
|
42
|
+
const response = await fetch(url, {
|
|
43
|
+
...config,
|
|
44
|
+
signal: controller.signal,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
clearTimeout(timeoutId);
|
|
48
|
+
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
const errorData = await response.json().catch(() => ({}));
|
|
51
|
+
const error = new Error(errorData.detail || `HTTP ${response.status}`);
|
|
52
|
+
error.status = response.status;
|
|
53
|
+
error.data = errorData;
|
|
54
|
+
|
|
55
|
+
// Don't retry on client errors (4xx) except 429
|
|
56
|
+
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Handle rate limiting
|
|
61
|
+
if (response.status === 429) {
|
|
62
|
+
const retryAfter = response.headers.get('Retry-After') || 5;
|
|
63
|
+
await this._delay(parseInt(retryAfter, 10) * 1000);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return await response.json();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
lastError = error;
|
|
73
|
+
|
|
74
|
+
if (error.name === 'AbortError') {
|
|
75
|
+
lastError = new Error('Request timeout');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Don't retry on client errors
|
|
79
|
+
if (error.status >= 400 && error.status < 500) {
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (attempt < this.retryAttempts) {
|
|
84
|
+
await this._delay(this.retryDelay * attempt);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
throw lastError;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Delay utility
|
|
94
|
+
*/
|
|
95
|
+
_delay(ms) {
|
|
96
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* POST request helper
|
|
101
|
+
*/
|
|
102
|
+
async _post(endpoint, data) {
|
|
103
|
+
return this._request(endpoint, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
body: JSON.stringify(data),
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* GET request helper
|
|
111
|
+
*/
|
|
112
|
+
async _get(endpoint, params = {}) {
|
|
113
|
+
const queryString = new URLSearchParams(params).toString();
|
|
114
|
+
const url = queryString ? `${endpoint}?${queryString}` : endpoint;
|
|
115
|
+
return this._request(url, { method: 'GET' });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ============================================
|
|
119
|
+
// Intent APIs
|
|
120
|
+
// ============================================
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Infer intents from UI elements (server validation/caching)
|
|
124
|
+
* @param {Object} data - { route, page_title, ui_elements }
|
|
125
|
+
* @returns {Promise<Object>} - { intents, cached, cache_key }
|
|
126
|
+
*/
|
|
127
|
+
async inferIntents(data) {
|
|
128
|
+
return this._post('/api/v1/intents/infer', data);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Extract intent from free-text query (LLM-based)
|
|
133
|
+
* @param {Object} data - { user_query, route, available_intents }
|
|
134
|
+
* @returns {Promise<Object>} - { intent, confidence, clarifying_question }
|
|
135
|
+
*/
|
|
136
|
+
async extractIntent(data) {
|
|
137
|
+
return this._post('/api/v1/intents/extract', data);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ============================================
|
|
141
|
+
// Guidance APIs
|
|
142
|
+
// ============================================
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Get step-by-step guidance for an intent
|
|
146
|
+
* @param {Object} data - { intent, route, page_context, ui_elements, session_id }
|
|
147
|
+
* @returns {Promise<Object>} - Full guidance response with steps
|
|
148
|
+
*/
|
|
149
|
+
async getGuidance(data) {
|
|
150
|
+
return this._post('/api/v1/guidance', data);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Re-match element on a new page (multi-page flow tracking)
|
|
155
|
+
* @param {Object} data - { step_description, original_selector, route, ui_elements, session_id }
|
|
156
|
+
* @returns {Promise<Object>} - { selector, confidence, match_type, found, fallback_message }
|
|
157
|
+
*/
|
|
158
|
+
async matchElement(data) {
|
|
159
|
+
return this._post('/api/v1/guidance/match-element', data);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Check page status for active session
|
|
164
|
+
* @param {Object} data - { session_id, route, ui_elements }
|
|
165
|
+
* @returns {Promise<Object>} - { status, action, step_info }
|
|
166
|
+
*/
|
|
167
|
+
async checkPageStatus(data) {
|
|
168
|
+
return this._post('/api/v1/guidance/check-page', data);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Update guidance session status
|
|
173
|
+
* @param {Object} data - { session_id, status, current_step, total_steps }
|
|
174
|
+
* @returns {Promise<Object>} - { session_id, status, updated_at }
|
|
175
|
+
*/
|
|
176
|
+
async updateSession(data) {
|
|
177
|
+
try {
|
|
178
|
+
return await this._post('/api/v1/guidance/sessions/update', data);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (error.status === 404) {
|
|
181
|
+
// Session already gone on backend, treat as success/cleanup
|
|
182
|
+
return { status: 'session_not_found', session_id: data.session_id };
|
|
183
|
+
}
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Get session details
|
|
190
|
+
* @param {string} sessionId - Session ID
|
|
191
|
+
* @returns {Promise<Object>} - Session details
|
|
192
|
+
*/
|
|
193
|
+
async getSession(sessionId) {
|
|
194
|
+
return this._get(`/api/v1/guidance/sessions/${sessionId}`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Explain a selected term
|
|
199
|
+
* @param {Object} data - { text, route, page_title, context }
|
|
200
|
+
* @returns {Promise<Object>} - { term, explanation, related_articles, confidence }
|
|
201
|
+
*/
|
|
202
|
+
async explainTerm(data) {
|
|
203
|
+
return this._post('/api/v1/explain/term', data);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ============================================
|
|
207
|
+
// Knowledge Base APIs
|
|
208
|
+
// ============================================
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Search knowledge base
|
|
212
|
+
* @param {string} query - Search query
|
|
213
|
+
* @param {Object} options - { limit, intent, route }
|
|
214
|
+
* @returns {Promise<Object>} - { query, results, total }
|
|
215
|
+
*/
|
|
216
|
+
async searchKB(query, options = {}) {
|
|
217
|
+
return this._get('/api/v1/kb/search', {
|
|
218
|
+
q: query,
|
|
219
|
+
...options,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Get KB article by ID
|
|
225
|
+
* @param {string} articleId - Article ID
|
|
226
|
+
* @returns {Promise<Object>} - Article details
|
|
227
|
+
*/
|
|
228
|
+
async getArticle(articleId) {
|
|
229
|
+
return this._get(`/api/v1/kb/${articleId}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ============================================
|
|
233
|
+
// Health Check
|
|
234
|
+
// ============================================
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Check backend health
|
|
238
|
+
* @returns {Promise<Object>} - Health status
|
|
239
|
+
*/
|
|
240
|
+
async healthCheck() {
|
|
241
|
+
return this._get('/health');
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Check if backend is available
|
|
246
|
+
* @returns {Promise<boolean>}
|
|
247
|
+
*/
|
|
248
|
+
async isAvailable() {
|
|
249
|
+
try {
|
|
250
|
+
await this.healthCheck();
|
|
251
|
+
return true;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export default APIClient;
|