browser-debug-mcp-bridge 1.11.0 → 1.12.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.
Files changed (26) hide show
  1. package/README.md +3 -1
  2. package/apps/mcp-server/dist/db/automation-repository.js +9 -4
  3. package/apps/mcp-server/dist/db/automation-repository.js.map +1 -1
  4. package/apps/mcp-server/dist/db/migrations.js +79 -0
  5. package/apps/mcp-server/dist/db/migrations.js.map +1 -1
  6. package/apps/mcp-server/dist/db/schema.js +60 -1
  7. package/apps/mcp-server/dist/db/schema.js.map +1 -1
  8. package/apps/mcp-server/dist/mcp/server.js +3455 -357
  9. package/apps/mcp-server/dist/mcp/server.js.map +1 -1
  10. package/apps/mcp-server/dist/mcp/target-resolution.js +390 -0
  11. package/apps/mcp-server/dist/mcp/target-resolution.js.map +1 -0
  12. package/apps/mcp-server/dist/mcp/tool-loop-guard.js +655 -0
  13. package/apps/mcp-server/dist/mcp/tool-loop-guard.js.map +1 -0
  14. package/apps/mcp-server/dist/override-audit.js +3 -3
  15. package/apps/mcp-server/dist/override-audit.js.map +1 -1
  16. package/apps/mcp-server/dist/override-capabilities.js +22 -1
  17. package/apps/mcp-server/dist/override-capabilities.js.map +1 -1
  18. package/apps/mcp-server/dist/override-poc.js +4 -4
  19. package/apps/mcp-server/dist/override-poc.js.map +1 -1
  20. package/apps/mcp-server/dist/override-profile-generator.js +3 -9
  21. package/apps/mcp-server/dist/override-profile-generator.js.map +1 -1
  22. package/apps/mcp-server/dist/override-response-planner.js +6 -4
  23. package/apps/mcp-server/dist/override-response-planner.js.map +1 -1
  24. package/apps/mcp-server/dist/websocket/messages.js +5 -0
  25. package/apps/mcp-server/dist/websocket/messages.js.map +1 -1
  26. package/package.json +8 -3
@@ -0,0 +1,655 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ const RECENT_WINDOW_MS = 5 * 60_000;
3
+ const TOOL_BLOCK_MS = 2 * 60_000;
4
+ const FAMILY_BLOCK_MS = 5 * 60_000;
5
+ const DEFAULT_WARN_THRESHOLD = 2;
6
+ const DEFAULT_BLOCK_THRESHOLD = 4;
7
+ const HIGH_RISK_WARN_THRESHOLD = 2;
8
+ const HIGH_RISK_BLOCK_THRESHOLD = 3;
9
+ const FAMILY_BLOCK_THRESHOLD = 4;
10
+ const HIGH_RISK_TOOLS = new Set([
11
+ 'enable_overrides',
12
+ 'disable_overrides',
13
+ 'observe_override_assets',
14
+ 'capture_override_response_body',
15
+ 'plan_override_response_patch',
16
+ 'plan_next_source_override',
17
+ 'map_next_override_assets',
18
+ 'execute_ui_action',
19
+ 'run_ui_steps',
20
+ ]);
21
+ const FAMILY_BLOCKED_TOOLS = new Set([
22
+ 'enable_overrides',
23
+ 'observe_override_assets',
24
+ 'capture_override_response_body',
25
+ 'plan_override_response_patch',
26
+ 'plan_next_source_override',
27
+ 'map_next_override_assets',
28
+ ]);
29
+ const LOOP_PRONE_NEXT_ACTIONS = new Set([
30
+ 'DIAGNOSE_OVERRIDES',
31
+ 'ENABLE_OVERRIDES',
32
+ 'GET_OVERRIDE_STATUS',
33
+ 'LOAD_ROUTE',
34
+ 'OBSERVE_ASSETS',
35
+ 'OBSERVE_OVERRIDE_ASSETS',
36
+ 'OBSERVE_TARGET_ROUTE',
37
+ 'PLAN_OVERRIDE',
38
+ 'PLAN_RESPONSE_PATCH',
39
+ 'RECONNECT_OR_RETRY_DISABLE',
40
+ 'RECONNECT_OR_RETRY_OVERRIDE_STATUS',
41
+ 'RECONNECT_SESSION',
42
+ 'RELOAD_OR_INTERACT',
43
+ 'RELOAD_TAB',
44
+ 'VERIFY_DISABLED',
45
+ 'WRITE_CONFIG',
46
+ 'WRITE_OVERRIDE_CONFIG',
47
+ 'WRITE_RESPONSE_BODY',
48
+ ]);
49
+ function hashText(value) {
50
+ return createHash('sha256').update(value).digest('hex');
51
+ }
52
+ function safeStringify(value) {
53
+ return JSON.stringify(value, (_key, nestedValue) => {
54
+ if (typeof nestedValue === 'bigint') {
55
+ return String(nestedValue);
56
+ }
57
+ return nestedValue;
58
+ });
59
+ }
60
+ function normalizeForHash(value) {
61
+ if (Array.isArray(value)) {
62
+ return value.map(normalizeForHash);
63
+ }
64
+ if (!value || typeof value !== 'object') {
65
+ return value;
66
+ }
67
+ const record = value;
68
+ return Object.fromEntries(Object.keys(record)
69
+ .sort()
70
+ .map((key) => [key, normalizeForHash(record[key])]));
71
+ }
72
+ function isSensitiveKey(key) {
73
+ const normalized = key.toLowerCase();
74
+ return normalized.includes('authorization')
75
+ || normalized.includes('cookie')
76
+ || normalized.includes('password')
77
+ || normalized.includes('secret')
78
+ || normalized.includes('token');
79
+ }
80
+ function summarizeInput(value, depth = 0) {
81
+ if (depth > 4) {
82
+ return '[depth-limit]';
83
+ }
84
+ if (typeof value === 'string') {
85
+ return value.length > 240 ? `${value.slice(0, 240)}...[truncated ${value.length}]` : value;
86
+ }
87
+ if (typeof value === 'number' || typeof value === 'boolean' || value === null || value === undefined) {
88
+ return value;
89
+ }
90
+ if (Array.isArray(value)) {
91
+ return value.slice(0, 20).map((entry) => summarizeInput(entry, depth + 1));
92
+ }
93
+ if (typeof value === 'object') {
94
+ const result = {};
95
+ for (const [key, nestedValue] of Object.entries(value).slice(0, 40)) {
96
+ result[key] = isSensitiveKey(key) ? '[redacted]' : summarizeInput(nestedValue, depth + 1);
97
+ }
98
+ return result;
99
+ }
100
+ return String(value);
101
+ }
102
+ function getSessionId(input) {
103
+ return typeof input.sessionId === 'string' ? input.sessionId : undefined;
104
+ }
105
+ function getToolFamily(toolName) {
106
+ if (toolName.includes('override')) {
107
+ return 'override';
108
+ }
109
+ if (toolName.includes('automation') || toolName.includes('ui_') || toolName.includes('page_state')) {
110
+ return 'automation';
111
+ }
112
+ return 'general';
113
+ }
114
+ function getRecentThresholds(toolName) {
115
+ if (HIGH_RISK_TOOLS.has(toolName)) {
116
+ return { warn: HIGH_RISK_WARN_THRESHOLD, block: HIGH_RISK_BLOCK_THRESHOLD };
117
+ }
118
+ return { warn: DEFAULT_WARN_THRESHOLD, block: DEFAULT_BLOCK_THRESHOLD };
119
+ }
120
+ function isRecord(value) {
121
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
122
+ }
123
+ function collectIssueCodes(value, codes = []) {
124
+ if (Array.isArray(value)) {
125
+ for (const entry of value) {
126
+ collectIssueCodes(entry, codes);
127
+ }
128
+ return codes;
129
+ }
130
+ if (!isRecord(value)) {
131
+ return codes;
132
+ }
133
+ if (typeof value.code === 'string' && value.code.trim().length > 0) {
134
+ codes.push(value.code.trim());
135
+ }
136
+ for (const nestedValue of Object.values(value)) {
137
+ collectIssueCodes(nestedValue, codes);
138
+ }
139
+ return codes;
140
+ }
141
+ function collectNextActionCodes(response) {
142
+ const actions = Array.isArray(response.nextActions) ? response.nextActions : [];
143
+ return actions
144
+ .map((action) => isRecord(action) && typeof action.code === 'string' ? action.code : undefined)
145
+ .filter((code) => typeof code === 'string' && code.length > 0);
146
+ }
147
+ function extractRootCauseFromError(error) {
148
+ const message = error instanceof Error ? error.message : String(error);
149
+ const explicit = message.match(/\b([A-Z][A-Z0-9_]{2,})\b/u)?.[1];
150
+ if (explicit) {
151
+ return explicit;
152
+ }
153
+ if (/timed out|timeout/i.test(message)) {
154
+ return 'TOOL_TIMEOUT';
155
+ }
156
+ return 'TOOL_ERROR';
157
+ }
158
+ function extractRootCauseFromResponse(response) {
159
+ const directCandidates = [
160
+ response.code,
161
+ response.failureCode,
162
+ response.lastErrorCode,
163
+ isRecord(response.write) ? response.write.failureCode : undefined,
164
+ isRecord(response.liveStatus) ? response.liveStatus.code : undefined,
165
+ isRecord(response.disableAttempt) ? response.disableAttempt.code : undefined,
166
+ isRecord(response.latestRun) ? response.latestRun.lastErrorCode : undefined,
167
+ ];
168
+ for (const candidate of directCandidates) {
169
+ if (typeof candidate === 'string' && candidate.length > 0) {
170
+ return candidate;
171
+ }
172
+ }
173
+ const codes = collectIssueCodes(response);
174
+ return codes[0];
175
+ }
176
+ function isBadResponse(response) {
177
+ if (response.blocked === true) {
178
+ return true;
179
+ }
180
+ if (response.valid === false || response.ready === false || response.ok === false) {
181
+ return true;
182
+ }
183
+ if (Array.isArray(response.issues) && response.issues.length > 0) {
184
+ return true;
185
+ }
186
+ if (Array.isArray(response.errors) && response.errors.length > 0) {
187
+ return true;
188
+ }
189
+ if (Array.isArray(response.blockers) && response.blockers.length > 0) {
190
+ return true;
191
+ }
192
+ if (isRecord(response.write) && typeof response.write.failureCode === 'string') {
193
+ return true;
194
+ }
195
+ if (isRecord(response.liveStatus) && (response.liveStatus.ok === false || response.liveStatus.available === false)) {
196
+ return true;
197
+ }
198
+ if (isRecord(response.disableAttempt) && response.disableAttempt.ok === false) {
199
+ return true;
200
+ }
201
+ if (isRecord(response.latestRun) && typeof response.latestRun.lastErrorCode === 'string') {
202
+ return true;
203
+ }
204
+ if (isRecord(response.preflight) && response.preflight.ready === false) {
205
+ return true;
206
+ }
207
+ if (isRecord(response.diagnosis) && Array.isArray(response.diagnosis.issues) && response.diagnosis.issues.length > 0) {
208
+ return true;
209
+ }
210
+ if (isRecord(response.diagnosis) && Array.isArray(response.diagnosis.blockers) && response.diagnosis.blockers.length > 0) {
211
+ return true;
212
+ }
213
+ if (response.bodyCaptured === false) {
214
+ return true;
215
+ }
216
+ return false;
217
+ }
218
+ function classifyResponse(response) {
219
+ const rootCauseCode = extractRootCauseFromResponse(response);
220
+ const nextActionCodes = collectNextActionCodes(response);
221
+ const issueCodes = collectIssueCodes(response);
222
+ const badResponse = isBadResponse(response);
223
+ const loopProneNoProgress = !badResponse
224
+ && nextActionCodes.length > 0
225
+ && nextActionCodes.every((code) => LOOP_PRONE_NEXT_ACTIONS.has(code));
226
+ const stateSummary = {
227
+ valid: response.valid,
228
+ ready: response.ready,
229
+ preflightReady: isRecord(response.preflight) ? response.preflight.ready : undefined,
230
+ ok: response.ok,
231
+ active: response.active,
232
+ statusSource: response.statusSource,
233
+ rootCauseCode,
234
+ issueCodes: issueCodes.slice(0, 8),
235
+ nextActionCodes: nextActionCodes.slice(0, 8),
236
+ latestRunError: isRecord(response.latestRun) ? response.latestRun.lastErrorCode : undefined,
237
+ writeFailure: isRecord(response.write) ? response.write.failureCode : undefined,
238
+ bodyCaptured: response.bodyCaptured,
239
+ };
240
+ const outcomeType = badResponse
241
+ ? 'failed'
242
+ : loopProneNoProgress
243
+ ? 'no_progress'
244
+ : 'success';
245
+ return {
246
+ outcomeType,
247
+ rootCauseCode: rootCauseCode ?? (loopProneNoProgress ? nextActionCodes[0] : undefined),
248
+ stateHash: hashText(safeStringify(normalizeForHash(stateSummary))),
249
+ stateSummary,
250
+ };
251
+ }
252
+ function classifyError(error) {
253
+ const rootCauseCode = extractRootCauseFromError(error);
254
+ const stateSummary = {
255
+ rootCauseCode,
256
+ message: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500),
257
+ };
258
+ return {
259
+ outcomeType: 'failed',
260
+ rootCauseCode,
261
+ stateHash: hashText(safeStringify(normalizeForHash(stateSummary))),
262
+ stateSummary,
263
+ };
264
+ }
265
+ function requiredStateChangeFor(rootCauseCode) {
266
+ switch (rootCauseCode) {
267
+ case 'CONFIG_DISABLED':
268
+ return ['override config/profile enabled state changes', 'override profile is regenerated or selected'];
269
+ case 'PROFILE_DISABLED':
270
+ case 'NO_ENABLED_RULES':
271
+ return ['override profile enabled rules change'];
272
+ case 'LIVE_SESSION_DISCONNECTED':
273
+ return ['live extension connection becomes connected', 'sessionId changes'];
274
+ case 'NO_OBSERVED_ASSETS':
275
+ case 'TARGET_ASSET_NOT_OBSERVED':
276
+ case 'TARGET_ASSET_NOT_OBSERVED_FOR_RULE':
277
+ return ['target route is loaded or interacted with', 'observed asset inventory changes'];
278
+ case 'SESSION_SCOPE_DRIFT':
279
+ return ['bound tab selection changes', 'observed asset tab scope changes'];
280
+ case 'LOCAL_FILE_MISSING':
281
+ return ['local override file exists', 'override profile localFilePath changes'];
282
+ case 'OVERRIDE_LIVE_COMMAND_TIMEOUT':
283
+ case 'TOOL_TIMEOUT':
284
+ return ['live extension returns a successful command result', 'session/tab state changes'];
285
+ default:
286
+ return ['tool input changes', 'session/page/override state changes'];
287
+ }
288
+ }
289
+ function buildDecision(options) {
290
+ const rootCause = options.rootCauseCode ?? 'NO_PROGRESS';
291
+ const message = options.severity === 'warning'
292
+ ? `Repeated ${options.toolName} attempts are returning the same ${rootCause} result. Change state before continuing.`
293
+ : `Blocked repeated ${options.toolName} attempts with unchanged ${rootCause} result before spending another tool call.`;
294
+ return {
295
+ status: options.severity,
296
+ reason: options.reason,
297
+ scope: options.scope,
298
+ attemptCount: options.attemptCount,
299
+ recentWindowMs: RECENT_WINDOW_MS,
300
+ rootCauseCode: options.rootCauseCode,
301
+ blockUntil: options.blockUntil,
302
+ message,
303
+ requiredStateChange: requiredStateChangeFor(options.rootCauseCode),
304
+ };
305
+ }
306
+ function buildBlockedResponse(call, decision) {
307
+ return {
308
+ sessionId: call.sessionId,
309
+ limitsApplied: {
310
+ maxResults: 0,
311
+ truncated: false,
312
+ },
313
+ redactionSummary: {
314
+ totalFields: 0,
315
+ redactedFields: 0,
316
+ rulesApplied: [],
317
+ },
318
+ blocked: true,
319
+ tool: call.toolName,
320
+ loopGuard: decision,
321
+ nextActions: [{
322
+ code: 'CHANGE_STATE_BEFORE_RETRY',
323
+ message: decision.message,
324
+ requiredStateChange: decision.requiredStateChange,
325
+ retryAfterMs: decision.blockUntil ? Math.max(0, decision.blockUntil - Date.now()) : undefined,
326
+ }],
327
+ };
328
+ }
329
+ function getOpenIncident(db, call, now) {
330
+ const sessionKey = call.sessionId ?? '';
331
+ const rows = db.prepare(`
332
+ SELECT fingerprint, scope, attempt_count, root_cause_code, blocked_until
333
+ FROM mcp_loop_incidents
334
+ WHERE status = 'open'
335
+ AND blocked_until IS NOT NULL
336
+ AND blocked_until > ?
337
+ AND (
338
+ (scope = 'tool-input' AND tool_name = ? AND input_hash = ? AND COALESCE(session_id, '') = ?)
339
+ OR
340
+ (scope = 'family-root-cause' AND family = ? AND COALESCE(session_id, '') = ?)
341
+ )
342
+ ORDER BY blocked_until DESC
343
+ LIMIT 5
344
+ `).all(now, call.toolName, call.inputHash, sessionKey, call.family, sessionKey);
345
+ const toolInputIncident = rows.find((row) => row.scope === 'tool-input');
346
+ if (toolInputIncident) {
347
+ return toolInputIncident;
348
+ }
349
+ return FAMILY_BLOCKED_TOOLS.has(call.toolName)
350
+ ? rows.find((row) => row.scope === 'family-root-cause')
351
+ : undefined;
352
+ }
353
+ function countRecentExactFailures(db, call, outcome, now) {
354
+ const row = db.prepare(`
355
+ SELECT COUNT(*) AS count
356
+ FROM mcp_tool_invocations
357
+ WHERE tool_name = ?
358
+ AND input_hash = ?
359
+ AND COALESCE(session_id, '') = ?
360
+ AND outcome_type IN ('failed', 'no_progress')
361
+ AND COALESCE(root_cause_code, '') = ?
362
+ AND COALESCE(state_hash, '') = ?
363
+ AND created_at >= ?
364
+ `).get(call.toolName, call.inputHash, call.sessionId ?? '', outcome.rootCauseCode ?? '', outcome.stateHash ?? '', now - RECENT_WINDOW_MS);
365
+ return row.count + 1;
366
+ }
367
+ function countRecentFamilyFailures(db, call, outcome, now) {
368
+ const row = db.prepare(`
369
+ SELECT COUNT(*) AS count
370
+ FROM mcp_tool_invocations
371
+ WHERE family = ?
372
+ AND COALESCE(session_id, '') = ?
373
+ AND outcome_type IN ('failed', 'no_progress')
374
+ AND COALESCE(root_cause_code, '') = ?
375
+ AND COALESCE(state_hash, '') = ?
376
+ AND created_at >= ?
377
+ `).get(call.family, call.sessionId ?? '', outcome.rootCauseCode ?? '', outcome.stateHash ?? '', now - RECENT_WINDOW_MS);
378
+ return row.count + 1;
379
+ }
380
+ function insertInvocation(db, options) {
381
+ db.prepare(`
382
+ INSERT INTO mcp_tool_invocations (
383
+ invocation_id, tool_name, session_id, family, input_hash, input_summary_json,
384
+ outcome_type, root_cause_code, state_hash, state_summary_json, response_bytes,
385
+ duration_ms, blocked, warning, message, created_at
386
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
387
+ `).run(randomUUID(), options.call.toolName, options.call.sessionId ?? null, options.call.family, options.call.inputHash, safeStringify(options.call.inputSummary), options.outcome.outcomeType, options.outcome.rootCauseCode ?? null, options.outcome.stateHash ?? null, safeStringify(options.outcome.stateSummary), options.responseBytes ?? null, options.durationMs, options.blocked ? 1 : 0, options.warning ? 1 : 0, options.message ?? null, options.now);
388
+ }
389
+ function upsertIncident(db, options) {
390
+ const fingerprint = hashText(safeStringify([
391
+ options.scope,
392
+ options.scope === 'tool-input' ? options.call.toolName : options.call.family,
393
+ options.call.sessionId ?? '',
394
+ options.scope === 'tool-input' ? options.call.inputHash : '',
395
+ options.outcome.rootCauseCode ?? '',
396
+ options.outcome.stateHash ?? '',
397
+ ]));
398
+ const existing = db.prepare(`
399
+ SELECT incident_id
400
+ FROM mcp_loop_incidents
401
+ WHERE fingerprint = ? AND status = 'open'
402
+ LIMIT 1
403
+ `).get(fingerprint);
404
+ if (existing) {
405
+ db.prepare(`
406
+ UPDATE mcp_loop_incidents
407
+ SET last_seen_at = ?,
408
+ attempt_count = ?,
409
+ severity = ?,
410
+ blocked_until = COALESCE(?, blocked_until),
411
+ message = ?,
412
+ updated_at = ?
413
+ WHERE incident_id = ?
414
+ `).run(options.now, options.attemptCount, options.severity, options.blockUntil ?? null, options.message, options.now, existing.incident_id);
415
+ return;
416
+ }
417
+ db.prepare(`
418
+ INSERT INTO mcp_loop_incidents (
419
+ incident_id, fingerprint, scope, status, tool_name, session_id, family, input_hash,
420
+ root_cause_code, state_hash, first_seen_at, last_seen_at, attempt_count,
421
+ blocked_until, severity, message, created_at, updated_at
422
+ ) VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
423
+ `).run(randomUUID(), fingerprint, options.scope, options.scope === 'tool-input' ? options.call.toolName : null, options.call.sessionId ?? null, options.call.family, options.scope === 'tool-input' ? options.call.inputHash : null, options.outcome.rootCauseCode ?? null, options.outcome.stateHash ?? null, options.now, options.now, options.attemptCount, options.blockUntil ?? null, options.severity, options.message, options.now, options.now);
424
+ }
425
+ function resolveOpenIncidentsForSuccess(db, call, now) {
426
+ db.prepare(`
427
+ UPDATE mcp_loop_incidents
428
+ SET status = 'resolved', updated_at = ?
429
+ WHERE status = 'open'
430
+ AND (
431
+ (scope = 'tool-input' AND tool_name = ? AND input_hash = ? AND COALESCE(session_id, '') = ?)
432
+ OR
433
+ (scope = 'family-root-cause' AND family = ? AND COALESCE(session_id, '') = ?)
434
+ )
435
+ `).run(now, call.toolName, call.inputHash, call.sessionId ?? '', call.family, call.sessionId ?? '');
436
+ }
437
+ function responseBytes(response) {
438
+ return typeof response.responseBytes === 'number' && Number.isFinite(response.responseBytes)
439
+ ? Math.floor(response.responseBytes)
440
+ : undefined;
441
+ }
442
+ function emit(options, event) {
443
+ try {
444
+ options.onEvent?.(event);
445
+ }
446
+ catch {
447
+ // Loop-guard notifications must never break tool execution.
448
+ }
449
+ }
450
+ function withGuardWarning(response, decision) {
451
+ return {
452
+ ...response,
453
+ loopGuard: decision,
454
+ };
455
+ }
456
+ export function createToolLoopGuard(options) {
457
+ const enabled = options.enabled !== false && process.env.MCP_LOOP_GUARD !== '0';
458
+ const safeDb = () => {
459
+ if (!enabled) {
460
+ return undefined;
461
+ }
462
+ try {
463
+ return options.getDb();
464
+ }
465
+ catch (error) {
466
+ emit(options, {
467
+ event: 'agent_loop_guard_error',
468
+ toolName: 'unknown',
469
+ message: error instanceof Error ? error.message : String(error),
470
+ });
471
+ return undefined;
472
+ }
473
+ };
474
+ return {
475
+ prepareCall: (toolName, input) => {
476
+ const normalizedInput = normalizeForHash(input);
477
+ return {
478
+ toolName,
479
+ input,
480
+ sessionId: getSessionId(input),
481
+ family: getToolFamily(toolName),
482
+ inputHash: hashText(safeStringify(normalizedInput)),
483
+ inputSummary: summarizeInput(input),
484
+ startedAt: Date.now(),
485
+ };
486
+ },
487
+ beforeCall: async (call) => {
488
+ const db = safeDb();
489
+ if (!db) {
490
+ return { blocked: false };
491
+ }
492
+ try {
493
+ const now = Date.now();
494
+ const incident = getOpenIncident(db, call, now);
495
+ if (!incident) {
496
+ return { blocked: false };
497
+ }
498
+ const decision = buildDecision({
499
+ severity: 'blocked',
500
+ reason: incident.scope === 'family-root-cause' ? 'repeated_family_failure' : 'repeated_same_failure',
501
+ scope: incident.scope,
502
+ attemptCount: incident.attempt_count,
503
+ rootCauseCode: incident.root_cause_code ?? undefined,
504
+ blockUntil: incident.blocked_until ?? undefined,
505
+ toolName: call.toolName,
506
+ });
507
+ insertInvocation(db, {
508
+ call,
509
+ outcome: {
510
+ outcomeType: 'blocked',
511
+ rootCauseCode: incident.root_cause_code ?? undefined,
512
+ stateHash: undefined,
513
+ stateSummary: { blockedByIncident: incident.fingerprint },
514
+ },
515
+ durationMs: 0,
516
+ blocked: true,
517
+ warning: false,
518
+ message: decision.message,
519
+ now,
520
+ });
521
+ emit(options, {
522
+ event: 'agent_loop_guard_blocked',
523
+ toolName: call.toolName,
524
+ sessionId: call.sessionId,
525
+ reason: decision.reason,
526
+ rootCauseCode: decision.rootCauseCode,
527
+ attemptCount: decision.attemptCount,
528
+ message: decision.message,
529
+ });
530
+ return {
531
+ blocked: true,
532
+ response: buildBlockedResponse(call, decision),
533
+ decision,
534
+ };
535
+ }
536
+ catch (error) {
537
+ emit(options, {
538
+ event: 'agent_loop_guard_error',
539
+ toolName: call.toolName,
540
+ sessionId: call.sessionId,
541
+ message: error instanceof Error ? error.message : String(error),
542
+ });
543
+ return { blocked: false };
544
+ }
545
+ },
546
+ afterCall: async (call, result) => {
547
+ const db = safeDb();
548
+ if (!db) {
549
+ return { response: result.response };
550
+ }
551
+ try {
552
+ const now = Date.now();
553
+ const outcome = result.error ? classifyError(result.error) : classifyResponse(result.response ?? {});
554
+ if (outcome.outcomeType === 'success') {
555
+ resolveOpenIncidentsForSuccess(db, call, now);
556
+ insertInvocation(db, {
557
+ call,
558
+ outcome,
559
+ durationMs: result.durationMs,
560
+ responseBytes: result.response ? responseBytes(result.response) : undefined,
561
+ blocked: false,
562
+ warning: false,
563
+ now,
564
+ });
565
+ return { response: result.response };
566
+ }
567
+ const exactCount = countRecentExactFailures(db, call, outcome, now);
568
+ const familyCount = countRecentFamilyFailures(db, call, outcome, now);
569
+ const thresholds = getRecentThresholds(call.toolName);
570
+ const shouldBlockExact = exactCount >= thresholds.block;
571
+ const shouldWarnExact = exactCount >= thresholds.warn;
572
+ const shouldBlockFamily = call.family !== 'general' && familyCount >= FAMILY_BLOCK_THRESHOLD;
573
+ const blockUntil = now + (shouldBlockFamily ? FAMILY_BLOCK_MS : TOOL_BLOCK_MS);
574
+ const decision = shouldBlockExact || shouldBlockFamily
575
+ ? buildDecision({
576
+ severity: 'blocked_next_attempt',
577
+ reason: shouldBlockFamily ? 'repeated_family_failure' : 'repeated_same_failure',
578
+ scope: shouldBlockFamily ? 'family-root-cause' : 'tool-input',
579
+ attemptCount: shouldBlockFamily ? familyCount : exactCount,
580
+ rootCauseCode: outcome.rootCauseCode,
581
+ blockUntil,
582
+ toolName: call.toolName,
583
+ })
584
+ : shouldWarnExact
585
+ ? buildDecision({
586
+ severity: 'warning',
587
+ reason: 'repeated_same_failure',
588
+ scope: 'tool-input',
589
+ attemptCount: exactCount,
590
+ rootCauseCode: outcome.rootCauseCode,
591
+ toolName: call.toolName,
592
+ })
593
+ : undefined;
594
+ insertInvocation(db, {
595
+ call,
596
+ outcome,
597
+ durationMs: result.durationMs,
598
+ responseBytes: result.response ? responseBytes(result.response) : undefined,
599
+ blocked: false,
600
+ warning: decision !== undefined,
601
+ message: decision?.message,
602
+ now,
603
+ });
604
+ if (decision?.status === 'blocked_next_attempt') {
605
+ upsertIncident(db, {
606
+ call,
607
+ outcome,
608
+ scope: decision.scope,
609
+ attemptCount: decision.attemptCount,
610
+ severity: 'blocked',
611
+ message: decision.message,
612
+ blockUntil,
613
+ now,
614
+ });
615
+ }
616
+ else if (decision?.status === 'warning') {
617
+ upsertIncident(db, {
618
+ call,
619
+ outcome,
620
+ scope: 'tool-input',
621
+ attemptCount: exactCount,
622
+ severity: 'warning',
623
+ message: decision.message,
624
+ now,
625
+ });
626
+ }
627
+ if (decision) {
628
+ emit(options, {
629
+ event: decision.status === 'warning' ? 'agent_loop_guard_warning' : 'agent_loop_guard_blocked',
630
+ toolName: call.toolName,
631
+ sessionId: call.sessionId,
632
+ reason: decision.reason,
633
+ rootCauseCode: decision.rootCauseCode,
634
+ attemptCount: decision.attemptCount,
635
+ message: decision.message,
636
+ });
637
+ }
638
+ return {
639
+ response: result.response && decision ? withGuardWarning(result.response, decision) : result.response,
640
+ decision,
641
+ };
642
+ }
643
+ catch (error) {
644
+ emit(options, {
645
+ event: 'agent_loop_guard_error',
646
+ toolName: call.toolName,
647
+ sessionId: call.sessionId,
648
+ message: error instanceof Error ? error.message : String(error),
649
+ });
650
+ return { response: result.response };
651
+ }
652
+ },
653
+ };
654
+ }
655
+ //# sourceMappingURL=tool-loop-guard.js.map