@poolzin/pool-bot 2026.2.10 → 2026.2.17
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/CHANGELOG.md +24 -0
- package/dist/agents/auth-profiles/usage.js +22 -0
- package/dist/agents/auth-profiles.js +1 -1
- package/dist/agents/bash-tools.exec.js +4 -6
- package/dist/agents/glob-pattern.js +42 -0
- package/dist/agents/memory-search.js +33 -0
- package/dist/agents/model-fallback.js +59 -8
- package/dist/agents/pi-tools.before-tool-call.js +145 -4
- package/dist/agents/pi-tools.js +27 -9
- package/dist/agents/pi-tools.policy.js +85 -92
- package/dist/agents/pi-tools.schema.js +54 -27
- package/dist/agents/sandbox/validate-sandbox-security.js +157 -0
- package/dist/agents/sandbox-tool-policy.js +26 -0
- package/dist/agents/sanitize-for-prompt.js +18 -0
- package/dist/agents/session-write-lock.js +203 -39
- package/dist/agents/system-prompt.js +52 -10
- package/dist/agents/tool-loop-detection.js +466 -0
- package/dist/agents/tool-policy.js +6 -0
- package/dist/auto-reply/reply/post-compaction-audit.js +96 -0
- package/dist/auto-reply/reply/post-compaction-context.js +98 -0
- package/dist/build-info.json +3 -3
- package/dist/config/zod-schema.agent-defaults.js +14 -0
- package/dist/config/zod-schema.agent-runtime.js +14 -0
- package/dist/infra/path-safety.js +16 -0
- package/dist/logging/diagnostic-session-state.js +73 -0
- package/dist/logging/diagnostic.js +22 -0
- package/dist/memory/embeddings.js +36 -9
- package/dist/memory/hybrid.js +24 -5
- package/dist/memory/manager.js +76 -28
- package/dist/memory/mmr.js +164 -0
- package/dist/memory/query-expansion.js +331 -0
- package/dist/memory/temporal-decay.js +119 -0
- package/dist/process/kill-tree.js +98 -0
- package/dist/shared/pid-alive.js +12 -0
- package/dist/shared/process-scoped-map.js +10 -0
- package/extensions/bluebubbles/package.json +1 -1
- package/extensions/copilot-proxy/package.json +1 -1
- package/extensions/diagnostics-otel/package.json +1 -1
- package/extensions/discord/package.json +1 -1
- package/extensions/google-antigravity-auth/package.json +1 -1
- package/extensions/google-gemini-cli-auth/package.json +1 -1
- package/extensions/googlechat/package.json +1 -1
- package/extensions/imessage/package.json +1 -1
- package/extensions/line/package.json +1 -1
- package/extensions/llm-task/package.json +1 -1
- package/extensions/lobster/package.json +1 -1
- package/extensions/matrix/CHANGELOG.md +5 -0
- package/extensions/matrix/package.json +1 -1
- package/extensions/mattermost/package.json +1 -1
- package/extensions/memory-core/package.json +1 -1
- package/extensions/memory-lancedb/package.json +1 -1
- package/extensions/msteams/CHANGELOG.md +5 -0
- package/extensions/msteams/package.json +1 -1
- package/extensions/nextcloud-talk/package.json +1 -1
- package/extensions/nostr/CHANGELOG.md +5 -0
- package/extensions/nostr/package.json +1 -1
- package/extensions/open-prose/package.json +1 -1
- package/extensions/signal/package.json +1 -1
- package/extensions/slack/package.json +1 -1
- package/extensions/telegram/package.json +1 -1
- package/extensions/tlon/package.json +1 -1
- package/extensions/twitch/CHANGELOG.md +5 -0
- package/extensions/twitch/package.json +1 -1
- package/extensions/voice-call/CHANGELOG.md +5 -0
- package/extensions/voice-call/package.json +1 -1
- package/extensions/whatsapp/package.json +1 -1
- package/extensions/zalo/CHANGELOG.md +5 -0
- package/extensions/zalo/package.json +1 -1
- package/extensions/zalouser/CHANGELOG.md +5 -0
- package/extensions/zalouser/package.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { createSubsystemLogger } from "../logging/subsystem.js";
|
|
3
|
+
import { isPlainObject } from "../utils.js";
|
|
4
|
+
const log = createSubsystemLogger("agents/loop-detection");
|
|
5
|
+
export const TOOL_CALL_HISTORY_SIZE = 30;
|
|
6
|
+
export const WARNING_THRESHOLD = 10;
|
|
7
|
+
export const CRITICAL_THRESHOLD = 20;
|
|
8
|
+
export const GLOBAL_CIRCUIT_BREAKER_THRESHOLD = 30;
|
|
9
|
+
const DEFAULT_LOOP_DETECTION_CONFIG = {
|
|
10
|
+
enabled: false,
|
|
11
|
+
historySize: TOOL_CALL_HISTORY_SIZE,
|
|
12
|
+
warningThreshold: WARNING_THRESHOLD,
|
|
13
|
+
criticalThreshold: CRITICAL_THRESHOLD,
|
|
14
|
+
globalCircuitBreakerThreshold: GLOBAL_CIRCUIT_BREAKER_THRESHOLD,
|
|
15
|
+
detectors: {
|
|
16
|
+
genericRepeat: true,
|
|
17
|
+
knownPollNoProgress: true,
|
|
18
|
+
pingPong: true,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
function asPositiveInt(value, fallback) {
|
|
22
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
|
|
23
|
+
return fallback;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
function resolveLoopDetectionConfig(config) {
|
|
28
|
+
let warningThreshold = asPositiveInt(config?.warningThreshold, DEFAULT_LOOP_DETECTION_CONFIG.warningThreshold);
|
|
29
|
+
let criticalThreshold = asPositiveInt(config?.criticalThreshold, DEFAULT_LOOP_DETECTION_CONFIG.criticalThreshold);
|
|
30
|
+
let globalCircuitBreakerThreshold = asPositiveInt(config?.globalCircuitBreakerThreshold, DEFAULT_LOOP_DETECTION_CONFIG.globalCircuitBreakerThreshold);
|
|
31
|
+
if (criticalThreshold <= warningThreshold) {
|
|
32
|
+
criticalThreshold = warningThreshold + 1;
|
|
33
|
+
}
|
|
34
|
+
if (globalCircuitBreakerThreshold <= criticalThreshold) {
|
|
35
|
+
globalCircuitBreakerThreshold = criticalThreshold + 1;
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
enabled: config?.enabled ?? DEFAULT_LOOP_DETECTION_CONFIG.enabled,
|
|
39
|
+
historySize: asPositiveInt(config?.historySize, DEFAULT_LOOP_DETECTION_CONFIG.historySize),
|
|
40
|
+
warningThreshold,
|
|
41
|
+
criticalThreshold,
|
|
42
|
+
globalCircuitBreakerThreshold,
|
|
43
|
+
detectors: {
|
|
44
|
+
genericRepeat: config?.detectors?.genericRepeat ?? DEFAULT_LOOP_DETECTION_CONFIG.detectors.genericRepeat,
|
|
45
|
+
knownPollNoProgress: config?.detectors?.knownPollNoProgress ??
|
|
46
|
+
DEFAULT_LOOP_DETECTION_CONFIG.detectors.knownPollNoProgress,
|
|
47
|
+
pingPong: config?.detectors?.pingPong ?? DEFAULT_LOOP_DETECTION_CONFIG.detectors.pingPong,
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Hash a tool call for pattern matching.
|
|
53
|
+
* Uses tool name + deterministic JSON serialization digest of params.
|
|
54
|
+
*/
|
|
55
|
+
export function hashToolCall(toolName, params) {
|
|
56
|
+
return `${toolName}:${digestStable(params)}`;
|
|
57
|
+
}
|
|
58
|
+
function stableStringify(value) {
|
|
59
|
+
if (value === null || typeof value !== "object") {
|
|
60
|
+
return JSON.stringify(value);
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(value)) {
|
|
63
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
64
|
+
}
|
|
65
|
+
const obj = value;
|
|
66
|
+
const keys = Object.keys(obj).toSorted();
|
|
67
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(obj[k])}`).join(",")}}`;
|
|
68
|
+
}
|
|
69
|
+
function digestStable(value) {
|
|
70
|
+
const serialized = stableStringifyFallback(value);
|
|
71
|
+
return createHash("sha256").update(serialized).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
function stableStringifyFallback(value) {
|
|
74
|
+
try {
|
|
75
|
+
return stableStringify(value);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
if (value === null || value === undefined) {
|
|
79
|
+
return `${value}`;
|
|
80
|
+
}
|
|
81
|
+
if (typeof value === "string") {
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
|
|
85
|
+
return `${value}`;
|
|
86
|
+
}
|
|
87
|
+
if (value instanceof Error) {
|
|
88
|
+
return `${value.name}:${value.message}`;
|
|
89
|
+
}
|
|
90
|
+
return Object.prototype.toString.call(value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
function isKnownPollToolCall(toolName, params) {
|
|
94
|
+
if (toolName === "command_status") {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (toolName !== "process" || !isPlainObject(params)) {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const action = params.action;
|
|
101
|
+
return action === "poll" || action === "log";
|
|
102
|
+
}
|
|
103
|
+
function extractTextContent(result) {
|
|
104
|
+
if (!isPlainObject(result) || !Array.isArray(result.content)) {
|
|
105
|
+
return "";
|
|
106
|
+
}
|
|
107
|
+
return result.content
|
|
108
|
+
.filter((entry) => isPlainObject(entry) && typeof entry.type === "string" && typeof entry.text === "string")
|
|
109
|
+
.map((entry) => entry.text)
|
|
110
|
+
.join("\n")
|
|
111
|
+
.trim();
|
|
112
|
+
}
|
|
113
|
+
function formatErrorForHash(error) {
|
|
114
|
+
if (error instanceof Error) {
|
|
115
|
+
return error.message || error.name;
|
|
116
|
+
}
|
|
117
|
+
if (typeof error === "string") {
|
|
118
|
+
return error;
|
|
119
|
+
}
|
|
120
|
+
if (typeof error === "number" || typeof error === "boolean" || typeof error === "bigint") {
|
|
121
|
+
return `${error}`;
|
|
122
|
+
}
|
|
123
|
+
return stableStringify(error);
|
|
124
|
+
}
|
|
125
|
+
function hashToolOutcome(toolName, params, result, error) {
|
|
126
|
+
if (error !== undefined) {
|
|
127
|
+
return `error:${digestStable(formatErrorForHash(error))}`;
|
|
128
|
+
}
|
|
129
|
+
if (!isPlainObject(result)) {
|
|
130
|
+
return result === undefined ? undefined : digestStable(result);
|
|
131
|
+
}
|
|
132
|
+
const details = isPlainObject(result.details) ? result.details : {};
|
|
133
|
+
const text = extractTextContent(result);
|
|
134
|
+
if (isKnownPollToolCall(toolName, params) && toolName === "process" && isPlainObject(params)) {
|
|
135
|
+
const action = params.action;
|
|
136
|
+
if (action === "poll") {
|
|
137
|
+
return digestStable({
|
|
138
|
+
action,
|
|
139
|
+
status: details.status,
|
|
140
|
+
exitCode: details.exitCode ?? null,
|
|
141
|
+
exitSignal: details.exitSignal ?? null,
|
|
142
|
+
aggregated: details.aggregated ?? null,
|
|
143
|
+
text,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (action === "log") {
|
|
147
|
+
return digestStable({
|
|
148
|
+
action,
|
|
149
|
+
status: details.status,
|
|
150
|
+
totalLines: details.totalLines ?? null,
|
|
151
|
+
totalChars: details.totalChars ?? null,
|
|
152
|
+
truncated: details.truncated ?? null,
|
|
153
|
+
exitCode: details.exitCode ?? null,
|
|
154
|
+
exitSignal: details.exitSignal ?? null,
|
|
155
|
+
text,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return digestStable({
|
|
160
|
+
details,
|
|
161
|
+
text,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function getNoProgressStreak(history, toolName, argsHash) {
|
|
165
|
+
let streak = 0;
|
|
166
|
+
let latestResultHash;
|
|
167
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
168
|
+
const record = history[i];
|
|
169
|
+
if (!record || record.toolName !== toolName || record.argsHash !== argsHash) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (typeof record.resultHash !== "string" || !record.resultHash) {
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (!latestResultHash) {
|
|
176
|
+
latestResultHash = record.resultHash;
|
|
177
|
+
streak = 1;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (record.resultHash !== latestResultHash) {
|
|
181
|
+
break;
|
|
182
|
+
}
|
|
183
|
+
streak += 1;
|
|
184
|
+
}
|
|
185
|
+
return { count: streak, latestResultHash };
|
|
186
|
+
}
|
|
187
|
+
function getPingPongStreak(history, currentSignature) {
|
|
188
|
+
const last = history.at(-1);
|
|
189
|
+
if (!last) {
|
|
190
|
+
return { count: 0, noProgressEvidence: false };
|
|
191
|
+
}
|
|
192
|
+
let otherSignature;
|
|
193
|
+
let otherToolName;
|
|
194
|
+
for (let i = history.length - 2; i >= 0; i -= 1) {
|
|
195
|
+
const call = history[i];
|
|
196
|
+
if (!call) {
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
if (call.argsHash !== last.argsHash) {
|
|
200
|
+
otherSignature = call.argsHash;
|
|
201
|
+
otherToolName = call.toolName;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (!otherSignature || !otherToolName) {
|
|
206
|
+
return { count: 0, noProgressEvidence: false };
|
|
207
|
+
}
|
|
208
|
+
let alternatingTailCount = 0;
|
|
209
|
+
for (let i = history.length - 1; i >= 0; i -= 1) {
|
|
210
|
+
const call = history[i];
|
|
211
|
+
if (!call) {
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const expected = alternatingTailCount % 2 === 0 ? last.argsHash : otherSignature;
|
|
215
|
+
if (call.argsHash !== expected) {
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
alternatingTailCount += 1;
|
|
219
|
+
}
|
|
220
|
+
if (alternatingTailCount < 2) {
|
|
221
|
+
return { count: 0, noProgressEvidence: false };
|
|
222
|
+
}
|
|
223
|
+
const expectedCurrentSignature = otherSignature;
|
|
224
|
+
if (currentSignature !== expectedCurrentSignature) {
|
|
225
|
+
return { count: 0, noProgressEvidence: false };
|
|
226
|
+
}
|
|
227
|
+
const tailStart = Math.max(0, history.length - alternatingTailCount);
|
|
228
|
+
let firstHashA;
|
|
229
|
+
let firstHashB;
|
|
230
|
+
let noProgressEvidence = true;
|
|
231
|
+
for (let i = tailStart; i < history.length; i += 1) {
|
|
232
|
+
const call = history[i];
|
|
233
|
+
if (!call) {
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (!call.resultHash) {
|
|
237
|
+
noProgressEvidence = false;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
if (call.argsHash === last.argsHash) {
|
|
241
|
+
if (!firstHashA) {
|
|
242
|
+
firstHashA = call.resultHash;
|
|
243
|
+
}
|
|
244
|
+
else if (firstHashA !== call.resultHash) {
|
|
245
|
+
noProgressEvidence = false;
|
|
246
|
+
break;
|
|
247
|
+
}
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
if (call.argsHash === otherSignature) {
|
|
251
|
+
if (!firstHashB) {
|
|
252
|
+
firstHashB = call.resultHash;
|
|
253
|
+
}
|
|
254
|
+
else if (firstHashB !== call.resultHash) {
|
|
255
|
+
noProgressEvidence = false;
|
|
256
|
+
break;
|
|
257
|
+
}
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
noProgressEvidence = false;
|
|
261
|
+
break;
|
|
262
|
+
}
|
|
263
|
+
// Need repeated stable outcomes on both sides before treating ping-pong as no-progress.
|
|
264
|
+
if (!firstHashA || !firstHashB) {
|
|
265
|
+
noProgressEvidence = false;
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
count: alternatingTailCount + 1,
|
|
269
|
+
pairedToolName: last.toolName,
|
|
270
|
+
pairedSignature: last.argsHash,
|
|
271
|
+
noProgressEvidence,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
function canonicalPairKey(signatureA, signatureB) {
|
|
275
|
+
return [signatureA, signatureB].toSorted().join("|");
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Detect if an agent is stuck in a repetitive tool call loop.
|
|
279
|
+
* Checks if the same tool+params combination has been called excessively.
|
|
280
|
+
*/
|
|
281
|
+
export function detectToolCallLoop(state, toolName, params, config) {
|
|
282
|
+
const resolvedConfig = resolveLoopDetectionConfig(config);
|
|
283
|
+
if (!resolvedConfig.enabled) {
|
|
284
|
+
return { stuck: false };
|
|
285
|
+
}
|
|
286
|
+
const history = state.toolCallHistory ?? [];
|
|
287
|
+
const currentHash = hashToolCall(toolName, params);
|
|
288
|
+
const noProgress = getNoProgressStreak(history, toolName, currentHash);
|
|
289
|
+
const noProgressStreak = noProgress.count;
|
|
290
|
+
const knownPollTool = isKnownPollToolCall(toolName, params);
|
|
291
|
+
const pingPong = getPingPongStreak(history, currentHash);
|
|
292
|
+
if (noProgressStreak >= resolvedConfig.globalCircuitBreakerThreshold) {
|
|
293
|
+
log.error(`Global circuit breaker triggered: ${toolName} repeated ${noProgressStreak} times with no progress`);
|
|
294
|
+
return {
|
|
295
|
+
stuck: true,
|
|
296
|
+
level: "critical",
|
|
297
|
+
detector: "global_circuit_breaker",
|
|
298
|
+
count: noProgressStreak,
|
|
299
|
+
message: `CRITICAL: ${toolName} has repeated identical no-progress outcomes ${noProgressStreak} times. Session execution blocked by global circuit breaker to prevent runaway loops.`,
|
|
300
|
+
warningKey: `global:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (knownPollTool &&
|
|
304
|
+
resolvedConfig.detectors.knownPollNoProgress &&
|
|
305
|
+
noProgressStreak >= resolvedConfig.criticalThreshold) {
|
|
306
|
+
log.error(`Critical polling loop detected: ${toolName} repeated ${noProgressStreak} times`);
|
|
307
|
+
return {
|
|
308
|
+
stuck: true,
|
|
309
|
+
level: "critical",
|
|
310
|
+
detector: "known_poll_no_progress",
|
|
311
|
+
count: noProgressStreak,
|
|
312
|
+
message: `CRITICAL: Called ${toolName} with identical arguments and no progress ${noProgressStreak} times. This appears to be a stuck polling loop. Session execution blocked to prevent resource waste.`,
|
|
313
|
+
warningKey: `poll:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
if (knownPollTool &&
|
|
317
|
+
resolvedConfig.detectors.knownPollNoProgress &&
|
|
318
|
+
noProgressStreak >= resolvedConfig.warningThreshold) {
|
|
319
|
+
log.warn(`Polling loop warning: ${toolName} repeated ${noProgressStreak} times`);
|
|
320
|
+
return {
|
|
321
|
+
stuck: true,
|
|
322
|
+
level: "warning",
|
|
323
|
+
detector: "known_poll_no_progress",
|
|
324
|
+
count: noProgressStreak,
|
|
325
|
+
message: `WARNING: You have called ${toolName} ${noProgressStreak} times with identical arguments and no progress. Stop polling and either (1) increase wait time between checks, or (2) report the task as failed if the process is stuck.`,
|
|
326
|
+
warningKey: `poll:${toolName}:${currentHash}:${noProgress.latestResultHash ?? "none"}`,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const pingPongWarningKey = pingPong.pairedSignature
|
|
330
|
+
? `pingpong:${canonicalPairKey(currentHash, pingPong.pairedSignature)}`
|
|
331
|
+
: `pingpong:${toolName}:${currentHash}`;
|
|
332
|
+
if (resolvedConfig.detectors.pingPong &&
|
|
333
|
+
pingPong.count >= resolvedConfig.criticalThreshold &&
|
|
334
|
+
pingPong.noProgressEvidence) {
|
|
335
|
+
log.error(`Critical ping-pong loop detected: alternating calls count=${pingPong.count} currentTool=${toolName}`);
|
|
336
|
+
return {
|
|
337
|
+
stuck: true,
|
|
338
|
+
level: "critical",
|
|
339
|
+
detector: "ping_pong",
|
|
340
|
+
count: pingPong.count,
|
|
341
|
+
message: `CRITICAL: You are alternating between repeated tool-call patterns (${pingPong.count} consecutive calls) with no progress. This appears to be a stuck ping-pong loop. Session execution blocked to prevent resource waste.`,
|
|
342
|
+
pairedToolName: pingPong.pairedToolName,
|
|
343
|
+
warningKey: pingPongWarningKey,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
if (resolvedConfig.detectors.pingPong && pingPong.count >= resolvedConfig.warningThreshold) {
|
|
347
|
+
log.warn(`Ping-pong loop warning: alternating calls count=${pingPong.count} currentTool=${toolName}`);
|
|
348
|
+
return {
|
|
349
|
+
stuck: true,
|
|
350
|
+
level: "warning",
|
|
351
|
+
detector: "ping_pong",
|
|
352
|
+
count: pingPong.count,
|
|
353
|
+
message: `WARNING: You are alternating between repeated tool-call patterns (${pingPong.count} consecutive calls). This looks like a ping-pong loop; stop retrying and report the task as failed.`,
|
|
354
|
+
pairedToolName: pingPong.pairedToolName,
|
|
355
|
+
warningKey: pingPongWarningKey,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
// Generic detector: warn-only for repeated identical calls.
|
|
359
|
+
const recentCount = history.filter((h) => h.toolName === toolName && h.argsHash === currentHash).length;
|
|
360
|
+
if (!knownPollTool &&
|
|
361
|
+
resolvedConfig.detectors.genericRepeat &&
|
|
362
|
+
recentCount >= resolvedConfig.warningThreshold) {
|
|
363
|
+
log.warn(`Loop warning: ${toolName} called ${recentCount} times with identical arguments`);
|
|
364
|
+
return {
|
|
365
|
+
stuck: true,
|
|
366
|
+
level: "warning",
|
|
367
|
+
detector: "generic_repeat",
|
|
368
|
+
count: recentCount,
|
|
369
|
+
message: `WARNING: You have called ${toolName} ${recentCount} times with identical arguments. If this is not making progress, stop retrying and report the task as failed.`,
|
|
370
|
+
warningKey: `generic:${toolName}:${currentHash}`,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return { stuck: false };
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Record a tool call in the session's history for loop detection.
|
|
377
|
+
* Maintains sliding window of last N calls.
|
|
378
|
+
*/
|
|
379
|
+
export function recordToolCall(state, toolName, params, toolCallId, config) {
|
|
380
|
+
const resolvedConfig = resolveLoopDetectionConfig(config);
|
|
381
|
+
if (!state.toolCallHistory) {
|
|
382
|
+
state.toolCallHistory = [];
|
|
383
|
+
}
|
|
384
|
+
state.toolCallHistory.push({
|
|
385
|
+
toolName,
|
|
386
|
+
argsHash: hashToolCall(toolName, params),
|
|
387
|
+
toolCallId,
|
|
388
|
+
timestamp: Date.now(),
|
|
389
|
+
});
|
|
390
|
+
if (state.toolCallHistory.length > resolvedConfig.historySize) {
|
|
391
|
+
state.toolCallHistory.shift();
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Record a completed tool call outcome so loop detection can identify no-progress repeats.
|
|
396
|
+
*/
|
|
397
|
+
export function recordToolCallOutcome(state, params) {
|
|
398
|
+
const resolvedConfig = resolveLoopDetectionConfig(params.config);
|
|
399
|
+
const resultHash = hashToolOutcome(params.toolName, params.toolParams, params.result, params.error);
|
|
400
|
+
if (!resultHash) {
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (!state.toolCallHistory) {
|
|
404
|
+
state.toolCallHistory = [];
|
|
405
|
+
}
|
|
406
|
+
const argsHash = hashToolCall(params.toolName, params.toolParams);
|
|
407
|
+
let matched = false;
|
|
408
|
+
for (let i = state.toolCallHistory.length - 1; i >= 0; i -= 1) {
|
|
409
|
+
const call = state.toolCallHistory[i];
|
|
410
|
+
if (!call) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
if (params.toolCallId && call.toolCallId !== params.toolCallId) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (call.toolName !== params.toolName || call.argsHash !== argsHash) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (call.resultHash !== undefined) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
call.resultHash = resultHash;
|
|
423
|
+
matched = true;
|
|
424
|
+
break;
|
|
425
|
+
}
|
|
426
|
+
if (!matched) {
|
|
427
|
+
state.toolCallHistory.push({
|
|
428
|
+
toolName: params.toolName,
|
|
429
|
+
argsHash,
|
|
430
|
+
toolCallId: params.toolCallId,
|
|
431
|
+
resultHash,
|
|
432
|
+
timestamp: Date.now(),
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
if (state.toolCallHistory.length > resolvedConfig.historySize) {
|
|
436
|
+
state.toolCallHistory.splice(0, state.toolCallHistory.length - resolvedConfig.historySize);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Get current tool call statistics for a session (for debugging/monitoring).
|
|
441
|
+
*/
|
|
442
|
+
export function getToolCallStats(state) {
|
|
443
|
+
const history = state.toolCallHistory ?? [];
|
|
444
|
+
const patterns = new Map();
|
|
445
|
+
for (const call of history) {
|
|
446
|
+
const key = call.argsHash;
|
|
447
|
+
const existing = patterns.get(key);
|
|
448
|
+
if (existing) {
|
|
449
|
+
existing.count += 1;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
patterns.set(key, { toolName: call.toolName, count: 1 });
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
let mostFrequent = null;
|
|
456
|
+
for (const pattern of patterns.values()) {
|
|
457
|
+
if (!mostFrequent || pattern.count > mostFrequent.count) {
|
|
458
|
+
mostFrequent = pattern;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
totalCalls: history.length,
|
|
463
|
+
uniquePatterns: patterns.size,
|
|
464
|
+
mostFrequent,
|
|
465
|
+
};
|
|
466
|
+
}
|
|
@@ -221,3 +221,9 @@ export function resolveToolProfilePolicy(profile) {
|
|
|
221
221
|
deny: resolved.deny ? [...resolved.deny] : undefined,
|
|
222
222
|
};
|
|
223
223
|
}
|
|
224
|
+
export function mergeAlsoAllowPolicy(policy, alsoAllow) {
|
|
225
|
+
if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) {
|
|
226
|
+
return policy;
|
|
227
|
+
}
|
|
228
|
+
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
|
|
229
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
// Default required files — constants, extensible to config later
|
|
4
|
+
const DEFAULT_REQUIRED_READS = [
|
|
5
|
+
"WORKFLOW_AUTO.md",
|
|
6
|
+
/memory\/\d{4}-\d{2}-\d{2}\.md/, // daily memory files
|
|
7
|
+
];
|
|
8
|
+
/**
|
|
9
|
+
* Audit whether agent read required startup files after compaction.
|
|
10
|
+
* Returns list of missing file patterns.
|
|
11
|
+
*/
|
|
12
|
+
export function auditPostCompactionReads(readFilePaths, workspaceDir, requiredReads = DEFAULT_REQUIRED_READS) {
|
|
13
|
+
const normalizedReads = readFilePaths.map((p) => path.resolve(workspaceDir, p));
|
|
14
|
+
const missingPatterns = [];
|
|
15
|
+
for (const required of requiredReads) {
|
|
16
|
+
if (typeof required === "string") {
|
|
17
|
+
const requiredResolved = path.resolve(workspaceDir, required);
|
|
18
|
+
const found = normalizedReads.some((r) => r === requiredResolved);
|
|
19
|
+
if (!found) {
|
|
20
|
+
missingPatterns.push(required);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
// RegExp — match against relative paths from workspace
|
|
25
|
+
const found = readFilePaths.some((p) => {
|
|
26
|
+
const rel = path.relative(workspaceDir, path.resolve(workspaceDir, p));
|
|
27
|
+
// Normalize to forward slashes for cross-platform RegExp matching
|
|
28
|
+
const normalizedRel = rel.split(path.sep).join("/");
|
|
29
|
+
return required.test(normalizedRel);
|
|
30
|
+
});
|
|
31
|
+
if (!found) {
|
|
32
|
+
missingPatterns.push(required.source);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return { passed: missingPatterns.length === 0, missingPatterns };
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Read messages from a session JSONL file.
|
|
40
|
+
* Returns messages from the last N lines (default 100).
|
|
41
|
+
*/
|
|
42
|
+
export function readSessionMessages(sessionFile, maxLines = 100) {
|
|
43
|
+
if (!fs.existsSync(sessionFile)) {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const content = fs.readFileSync(sessionFile, "utf-8");
|
|
48
|
+
const lines = content.trim().split("\n");
|
|
49
|
+
const recentLines = lines.slice(-maxLines);
|
|
50
|
+
const messages = [];
|
|
51
|
+
for (const line of recentLines) {
|
|
52
|
+
try {
|
|
53
|
+
const entry = JSON.parse(line);
|
|
54
|
+
if (entry.type === "message" && entry.message) {
|
|
55
|
+
messages.push(entry.message);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// Skip malformed lines
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return messages;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Extract file paths from Read tool calls in agent messages.
|
|
70
|
+
* Looks for tool_use blocks with name="read" and extracts path/file_path args.
|
|
71
|
+
*/
|
|
72
|
+
export function extractReadPaths(messages) {
|
|
73
|
+
const paths = [];
|
|
74
|
+
for (const msg of messages) {
|
|
75
|
+
if (msg.role !== "assistant" || !Array.isArray(msg.content)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const block of msg.content) {
|
|
79
|
+
if (block.type === "tool_use" && block.name === "read") {
|
|
80
|
+
const filePath = block.input?.file_path ?? block.input?.path;
|
|
81
|
+
if (typeof filePath === "string") {
|
|
82
|
+
paths.push(filePath);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return paths;
|
|
88
|
+
}
|
|
89
|
+
/** Format the audit warning message */
|
|
90
|
+
export function formatAuditWarning(missingPatterns) {
|
|
91
|
+
const fileList = missingPatterns.map((p) => ` - ${p}`).join("\n");
|
|
92
|
+
return ("⚠️ Post-Compaction Audit: The following required startup files were not read after context reset:\n" +
|
|
93
|
+
fileList +
|
|
94
|
+
"\n\nPlease read them now using the Read tool before continuing. " +
|
|
95
|
+
"This ensures your operating protocols are restored after memory compaction.");
|
|
96
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const MAX_CONTEXT_CHARS = 3000;
|
|
4
|
+
/**
|
|
5
|
+
* Read critical sections from workspace AGENTS.md for post-compaction injection.
|
|
6
|
+
* Returns formatted system event text, or null if no AGENTS.md or no relevant sections.
|
|
7
|
+
*/
|
|
8
|
+
export async function readPostCompactionContext(workspaceDir) {
|
|
9
|
+
const agentsPath = path.join(workspaceDir, "AGENTS.md");
|
|
10
|
+
try {
|
|
11
|
+
if (!fs.existsSync(agentsPath)) {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
const content = await fs.promises.readFile(agentsPath, "utf-8");
|
|
15
|
+
// Extract "## Session Startup" and "## Red Lines" sections
|
|
16
|
+
// Each section ends at the next "## " heading or end of file
|
|
17
|
+
const sections = extractSections(content, ["Session Startup", "Red Lines"]);
|
|
18
|
+
if (sections.length === 0) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
const combined = sections.join("\n\n");
|
|
22
|
+
const safeContent = combined.length > MAX_CONTEXT_CHARS
|
|
23
|
+
? combined.slice(0, MAX_CONTEXT_CHARS) + "\n...[truncated]..."
|
|
24
|
+
: combined;
|
|
25
|
+
return ("[Post-compaction context refresh]\n\n" +
|
|
26
|
+
"Session was just compacted. The conversation summary above is a hint, NOT a substitute for your startup sequence. " +
|
|
27
|
+
"Execute your Session Startup sequence now — read the required files before responding to the user.\n\n" +
|
|
28
|
+
"Critical rules from AGENTS.md:\n\n" +
|
|
29
|
+
safeContent);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Extract named sections from markdown content.
|
|
37
|
+
* Matches H2 (##) or H3 (###) headings case-insensitively.
|
|
38
|
+
* Skips content inside fenced code blocks.
|
|
39
|
+
* Captures until the next heading of same or higher level, or end of string.
|
|
40
|
+
*/
|
|
41
|
+
export function extractSections(content, sectionNames) {
|
|
42
|
+
const results = [];
|
|
43
|
+
const lines = content.split("\n");
|
|
44
|
+
for (const name of sectionNames) {
|
|
45
|
+
let sectionLines = [];
|
|
46
|
+
let inSection = false;
|
|
47
|
+
let sectionLevel = 0;
|
|
48
|
+
let inCodeBlock = false;
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
// Track fenced code blocks
|
|
51
|
+
if (line.trimStart().startsWith("```")) {
|
|
52
|
+
inCodeBlock = !inCodeBlock;
|
|
53
|
+
if (inSection) {
|
|
54
|
+
sectionLines.push(line);
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
// Skip heading detection inside code blocks
|
|
59
|
+
if (inCodeBlock) {
|
|
60
|
+
if (inSection) {
|
|
61
|
+
sectionLines.push(line);
|
|
62
|
+
}
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
// Check if this line is a heading
|
|
66
|
+
const headingMatch = line.match(/^(#{2,3})\s+(.+?)\s*$/);
|
|
67
|
+
if (headingMatch) {
|
|
68
|
+
const level = headingMatch[1].length; // 2 or 3
|
|
69
|
+
const headingText = headingMatch[2];
|
|
70
|
+
if (!inSection) {
|
|
71
|
+
// Check if this is our target section (case-insensitive)
|
|
72
|
+
if (headingText.toLowerCase() === name.toLowerCase()) {
|
|
73
|
+
inSection = true;
|
|
74
|
+
sectionLevel = level;
|
|
75
|
+
sectionLines = [line];
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// We're in section — stop if we hit a heading of same or higher level
|
|
81
|
+
if (level <= sectionLevel) {
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
// Lower-level heading (e.g., ### inside ##) — include it
|
|
85
|
+
sectionLines.push(line);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (inSection) {
|
|
90
|
+
sectionLines.push(line);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (sectionLines.length > 0) {
|
|
94
|
+
results.push(sectionLines.join("\n").trim());
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return results;
|
|
98
|
+
}
|
package/dist/build-info.json
CHANGED