clementine-agent 1.1.23 → 1.1.24
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/dist/agent/assistant.js
CHANGED
|
@@ -22,6 +22,7 @@ import { AgentManager } from './agent-manager.js';
|
|
|
22
22
|
import { extractLinks } from './link-extractor.js';
|
|
23
23
|
import { StallGuard } from './stall-guard.js';
|
|
24
24
|
import { collectToolCalls, detectContradiction, buildCorrectionPrompt } from './contradiction-validator.js';
|
|
25
|
+
import { recordToolOutcome as recordMcpToolOutcome } from './mcp-circuit-breaker.js';
|
|
25
26
|
import { assembleContext } from '../memory/context-assembler.js';
|
|
26
27
|
import { PromptCache } from './prompt-cache.js';
|
|
27
28
|
import { searchSkills as searchSkillsSync } from './skill-extractor.js';
|
|
@@ -2946,6 +2947,15 @@ You have a cost budget per message — not a hard turn limit. Work until the tas
|
|
|
2946
2947
|
if (!contradictionRetried && attempt < PersonalAssistant.RATE_LIMIT_MAX_RETRIES && responseText.trim()) {
|
|
2947
2948
|
try {
|
|
2948
2949
|
const toolCallRecords = collectToolCalls(collectedSdkMessages);
|
|
2950
|
+
// Feed every tool outcome to the MCP circuit breaker so flaky
|
|
2951
|
+
// connectors get tripped + surfaced via the advisor-events
|
|
2952
|
+
// path that insight-engine already monitors.
|
|
2953
|
+
for (const r of toolCallRecords) {
|
|
2954
|
+
try {
|
|
2955
|
+
recordMcpToolOutcome(r.name, r.resultClass);
|
|
2956
|
+
}
|
|
2957
|
+
catch { /* non-fatal */ }
|
|
2958
|
+
}
|
|
2949
2959
|
// Diagnostic — emits once per turn so we can see what the
|
|
2950
2960
|
// validator is working with even when it doesn't fire. Without
|
|
2951
2961
|
// this we're blind to the "regex missed the phrasing" case.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-MCP-server circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* When an MCP server starts returning errors (auth failures, connector
|
|
5
|
+
* timeouts, "no such tool available") repeatedly, agents keep calling it
|
|
6
|
+
* — burning tool turns on something that isn't going to work. This module
|
|
7
|
+
* tracks per-server failure rates with a sliding window and surfaces a
|
|
8
|
+
* tripped state to the existing insight-engine via advisor-events.jsonl
|
|
9
|
+
* (same path the cron-side circuit breaker uses).
|
|
10
|
+
*
|
|
11
|
+
* Trip rule: K failures of class auth_error/other_error within WINDOW_MS
|
|
12
|
+
* trips the breaker for COOLDOWN_MS. Argument errors are agent-fault, not
|
|
13
|
+
* connector-fault, and don't count toward the trip threshold.
|
|
14
|
+
*
|
|
15
|
+
* Auto-reset: COOLDOWN_MS after the trip moment, the breaker clears and
|
|
16
|
+
* the failure window resets. The next failure starts the count fresh.
|
|
17
|
+
*/
|
|
18
|
+
export type ToolResultClass = 'success' | 'arg_error' | 'auth_error' | 'other_error';
|
|
19
|
+
/**
|
|
20
|
+
* Extract the MCP server name from a fully-qualified tool name. Handles
|
|
21
|
+
* server names that themselves contain underscores (e.g. `claude_ai_Gmail`)
|
|
22
|
+
* by treating only the FINAL `__` separator as the server/tool boundary.
|
|
23
|
+
*
|
|
24
|
+
* mcp__clementine-tools__memory_search → "clementine-tools"
|
|
25
|
+
* mcp__claude_ai_Gmail__authenticate → "claude_ai_Gmail"
|
|
26
|
+
* mcp__ElevenLabs__text_to_speech → "ElevenLabs"
|
|
27
|
+
* mcp__plugin_x_y__do_thing → "plugin_x_y"
|
|
28
|
+
*
|
|
29
|
+
* Returns null for non-MCP tools (Bash, Read, etc.).
|
|
30
|
+
*/
|
|
31
|
+
export declare function extractServerName(toolName: string): string | null;
|
|
32
|
+
/**
|
|
33
|
+
* Record the outcome of a single tool invocation. Only auth_error /
|
|
34
|
+
* other_error count toward the failure window — arg_error is the agent's
|
|
35
|
+
* fault (bad parameters), and success obviously doesn't count.
|
|
36
|
+
*/
|
|
37
|
+
export declare function recordToolOutcome(toolName: string, resultClass: ToolResultClass): void;
|
|
38
|
+
/** True when the named server is currently in the open (failing) state. */
|
|
39
|
+
export declare function isServerTripped(server: string): boolean;
|
|
40
|
+
/** Get all currently-tripped servers — useful for status display + system-prompt injection. */
|
|
41
|
+
export declare function getTrippedServers(): Array<{
|
|
42
|
+
server: string;
|
|
43
|
+
trippedAt: string;
|
|
44
|
+
reason: string;
|
|
45
|
+
cooldownRemainingMs: number;
|
|
46
|
+
}>;
|
|
47
|
+
/** Manual reset — used by an `mcp circuit reset` admin command (future). */
|
|
48
|
+
export declare function resetServer(server: string): boolean;
|
|
49
|
+
/** Reset every breaker — used by tests. */
|
|
50
|
+
export declare function _resetAll(): void;
|
|
51
|
+
//# sourceMappingURL=mcp-circuit-breaker.d.ts.map
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-MCP-server circuit breaker.
|
|
3
|
+
*
|
|
4
|
+
* When an MCP server starts returning errors (auth failures, connector
|
|
5
|
+
* timeouts, "no such tool available") repeatedly, agents keep calling it
|
|
6
|
+
* — burning tool turns on something that isn't going to work. This module
|
|
7
|
+
* tracks per-server failure rates with a sliding window and surfaces a
|
|
8
|
+
* tripped state to the existing insight-engine via advisor-events.jsonl
|
|
9
|
+
* (same path the cron-side circuit breaker uses).
|
|
10
|
+
*
|
|
11
|
+
* Trip rule: K failures of class auth_error/other_error within WINDOW_MS
|
|
12
|
+
* trips the breaker for COOLDOWN_MS. Argument errors are agent-fault, not
|
|
13
|
+
* connector-fault, and don't count toward the trip threshold.
|
|
14
|
+
*
|
|
15
|
+
* Auto-reset: COOLDOWN_MS after the trip moment, the breaker clears and
|
|
16
|
+
* the failure window resets. The next failure starts the count fresh.
|
|
17
|
+
*/
|
|
18
|
+
import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import pino from 'pino';
|
|
21
|
+
import { BASE_DIR } from '../config.js';
|
|
22
|
+
const logger = pino({ name: 'clementine.mcp-circuit-breaker' });
|
|
23
|
+
/** Threshold to trip the breaker. */
|
|
24
|
+
const MAX_CONNECTOR_FAILURES = 5;
|
|
25
|
+
/** Sliding window for counting failures. */
|
|
26
|
+
const WINDOW_MS = 5 * 60 * 1000; // 5 minutes
|
|
27
|
+
/** How long the breaker stays open before auto-resetting. */
|
|
28
|
+
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes
|
|
29
|
+
const ADVISOR_EVENTS_FILE = path.join(BASE_DIR, 'cron', 'advisor-events.jsonl');
|
|
30
|
+
const state = new Map();
|
|
31
|
+
/**
|
|
32
|
+
* Extract the MCP server name from a fully-qualified tool name. Handles
|
|
33
|
+
* server names that themselves contain underscores (e.g. `claude_ai_Gmail`)
|
|
34
|
+
* by treating only the FINAL `__` separator as the server/tool boundary.
|
|
35
|
+
*
|
|
36
|
+
* mcp__clementine-tools__memory_search → "clementine-tools"
|
|
37
|
+
* mcp__claude_ai_Gmail__authenticate → "claude_ai_Gmail"
|
|
38
|
+
* mcp__ElevenLabs__text_to_speech → "ElevenLabs"
|
|
39
|
+
* mcp__plugin_x_y__do_thing → "plugin_x_y"
|
|
40
|
+
*
|
|
41
|
+
* Returns null for non-MCP tools (Bash, Read, etc.).
|
|
42
|
+
*/
|
|
43
|
+
export function extractServerName(toolName) {
|
|
44
|
+
if (!toolName.startsWith('mcp__'))
|
|
45
|
+
return null;
|
|
46
|
+
const rest = toolName.slice('mcp__'.length);
|
|
47
|
+
const lastSep = rest.lastIndexOf('__');
|
|
48
|
+
if (lastSep <= 0)
|
|
49
|
+
return null;
|
|
50
|
+
return rest.slice(0, lastSep);
|
|
51
|
+
}
|
|
52
|
+
function getServerState(server) {
|
|
53
|
+
let s = state.get(server);
|
|
54
|
+
if (!s) {
|
|
55
|
+
s = { failureTimestamps: [] };
|
|
56
|
+
state.set(server, s);
|
|
57
|
+
}
|
|
58
|
+
return s;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Record the outcome of a single tool invocation. Only auth_error /
|
|
62
|
+
* other_error count toward the failure window — arg_error is the agent's
|
|
63
|
+
* fault (bad parameters), and success obviously doesn't count.
|
|
64
|
+
*/
|
|
65
|
+
export function recordToolOutcome(toolName, resultClass) {
|
|
66
|
+
const server = extractServerName(toolName);
|
|
67
|
+
if (!server)
|
|
68
|
+
return; // built-in tool like Bash/Read — not our concern
|
|
69
|
+
const s = getServerState(server);
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
// Auto-reset if the cooldown has expired since the last trip.
|
|
72
|
+
if (s.trippedAt !== undefined && now - s.trippedAt >= COOLDOWN_MS) {
|
|
73
|
+
s.trippedAt = undefined;
|
|
74
|
+
s.trippedReason = undefined;
|
|
75
|
+
s.failureTimestamps = [];
|
|
76
|
+
logger.info({ server }, 'MCP circuit breaker auto-reset after cooldown');
|
|
77
|
+
emitAdvisorEvent({
|
|
78
|
+
type: 'circuit-breaker',
|
|
79
|
+
jobName: `mcp:${server}`,
|
|
80
|
+
detail: 'Connector breaker reset — probing again on next call',
|
|
81
|
+
reset: true,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
if (resultClass === 'success') {
|
|
85
|
+
// Successful call inside the window — clear the failure list so a flap
|
|
86
|
+
// doesn't accumulate forever. Don't auto-reset a tripped breaker on
|
|
87
|
+
// success though; that needs to wait for the cooldown so we don't
|
|
88
|
+
// ping-pong on intermittent failures.
|
|
89
|
+
if (s.trippedAt === undefined) {
|
|
90
|
+
s.failureTimestamps = [];
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (resultClass === 'arg_error') {
|
|
95
|
+
// Agent passed bad args — connector itself is fine.
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// auth_error or other_error — count toward the failure window.
|
|
99
|
+
s.failureTimestamps.push(now);
|
|
100
|
+
// Drop old timestamps outside the window.
|
|
101
|
+
s.failureTimestamps = s.failureTimestamps.filter(t => now - t <= WINDOW_MS);
|
|
102
|
+
if (s.trippedAt === undefined && s.failureTimestamps.length >= MAX_CONNECTOR_FAILURES) {
|
|
103
|
+
s.trippedAt = now;
|
|
104
|
+
s.trippedReason = `${s.failureTimestamps.length} ${resultClass} failure(s) in the last ${Math.round(WINDOW_MS / 60_000)}m`;
|
|
105
|
+
logger.warn({ server, failures: s.failureTimestamps.length, resultClass }, 'MCP circuit breaker tripped');
|
|
106
|
+
emitAdvisorEvent({
|
|
107
|
+
type: 'circuit-breaker',
|
|
108
|
+
jobName: `mcp:${server}`,
|
|
109
|
+
detail: `MCP connector "${server}" tripped — ${s.trippedReason}. Prefer alternatives until cooldown expires (~${Math.round(COOLDOWN_MS / 60_000)}m).`,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
/** True when the named server is currently in the open (failing) state. */
|
|
114
|
+
export function isServerTripped(server) {
|
|
115
|
+
const s = state.get(server);
|
|
116
|
+
if (!s || s.trippedAt === undefined)
|
|
117
|
+
return false;
|
|
118
|
+
if (Date.now() - s.trippedAt >= COOLDOWN_MS)
|
|
119
|
+
return false;
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
/** Get all currently-tripped servers — useful for status display + system-prompt injection. */
|
|
123
|
+
export function getTrippedServers() {
|
|
124
|
+
const now = Date.now();
|
|
125
|
+
const out = [];
|
|
126
|
+
for (const [server, s] of state) {
|
|
127
|
+
if (s.trippedAt === undefined)
|
|
128
|
+
continue;
|
|
129
|
+
const remaining = COOLDOWN_MS - (now - s.trippedAt);
|
|
130
|
+
if (remaining <= 0)
|
|
131
|
+
continue;
|
|
132
|
+
out.push({
|
|
133
|
+
server,
|
|
134
|
+
trippedAt: new Date(s.trippedAt).toISOString(),
|
|
135
|
+
reason: s.trippedReason ?? 'unknown',
|
|
136
|
+
cooldownRemainingMs: remaining,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
return out;
|
|
140
|
+
}
|
|
141
|
+
/** Manual reset — used by an `mcp circuit reset` admin command (future). */
|
|
142
|
+
export function resetServer(server) {
|
|
143
|
+
const s = state.get(server);
|
|
144
|
+
if (!s || s.trippedAt === undefined)
|
|
145
|
+
return false;
|
|
146
|
+
s.trippedAt = undefined;
|
|
147
|
+
s.trippedReason = undefined;
|
|
148
|
+
s.failureTimestamps = [];
|
|
149
|
+
logger.info({ server }, 'MCP circuit breaker manually reset');
|
|
150
|
+
emitAdvisorEvent({
|
|
151
|
+
type: 'circuit-breaker',
|
|
152
|
+
jobName: `mcp:${server}`,
|
|
153
|
+
detail: 'Manually reset',
|
|
154
|
+
reset: true,
|
|
155
|
+
});
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
/** Reset every breaker — used by tests. */
|
|
159
|
+
export function _resetAll() {
|
|
160
|
+
state.clear();
|
|
161
|
+
}
|
|
162
|
+
function emitAdvisorEvent(evt) {
|
|
163
|
+
try {
|
|
164
|
+
mkdirSync(path.dirname(ADVISOR_EVENTS_FILE), { recursive: true });
|
|
165
|
+
if (!existsSync(path.dirname(ADVISOR_EVENTS_FILE)))
|
|
166
|
+
return;
|
|
167
|
+
const line = JSON.stringify({ timestamp: new Date().toISOString(), ...evt }) + '\n';
|
|
168
|
+
appendFileSync(ADVISOR_EVENTS_FILE, line);
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
// Non-fatal — observability event, not load-bearing for the breaker logic.
|
|
172
|
+
logger.debug({ err }, 'Failed to emit advisor event for MCP circuit breaker');
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
//# sourceMappingURL=mcp-circuit-breaker.js.map
|