@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
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,27 @@
|
|
|
1
|
+
## v2026.2.17 (2026-02-17)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
- Upstream gap port — comprehensive security, memory, and agent tooling update:
|
|
5
|
+
- **Security:** path traversal guards, sandbox security validation, prompt literal sanitization, PID-based process liveness checks
|
|
6
|
+
- **Tool loop detection:** configurable detector with cooldown, warning injection, and diagnostic logging to prevent runaway tool-call loops
|
|
7
|
+
- **Memory search:** MMR (maximal marginal relevance) re-ranking, temporal decay scoring, query expansion — all configurable via `mmr` and `temporalDecay` search options
|
|
8
|
+
- **Hybrid search:** async merge with MMR diversity + temporal recency scoring
|
|
9
|
+
- **Embeddings:** graceful FTS-only fallback when all embedding providers are unavailable
|
|
10
|
+
- **Model fallback:** probe-during-cooldown logic — automatically tests primary model recovery without waiting for full cooldown expiry
|
|
11
|
+
- **Session write lock:** rewritten with PID liveness checks, watchdog timer, and stale lock cleanup
|
|
12
|
+
- **System prompt:** `llms.txt` context section, sanitized workspace variables, valid context file filtering
|
|
13
|
+
- **Tool policy:** extracted glob patterns to shared module, sandbox tool policy helpers, configurable subagent spawn depth/children limits
|
|
14
|
+
- **Post-compaction:** audit and context enrichment for compacted conversation summaries
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## v2026.2.11 (2026-02-16)
|
|
19
|
+
|
|
20
|
+
### Fixes
|
|
21
|
+
- Restore default exec timeout to 1800s (30 min) — the 120s default from v2026.2.10 could kill long-running installs/builds; env var override `POOLBOT_EXEC_TIMEOUT_SEC` and config `tools.exec.timeoutSec` still available for per-deployment tuning
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
1
25
|
## v2026.2.10 (2026-02-16)
|
|
2
26
|
|
|
3
27
|
### Fixes
|
|
@@ -8,6 +8,28 @@ function resolveProfileUnusableUntil(stats) {
|
|
|
8
8
|
return null;
|
|
9
9
|
return Math.max(...values);
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* Returns the soonest `cooldownUntil` / `disabledUntil` timestamp across the given
|
|
13
|
+
* profiles, or `null` when no profile has a recorded cooldown. Note: the
|
|
14
|
+
* returned timestamp may be in the past if the cooldown has already expired.
|
|
15
|
+
*/
|
|
16
|
+
export function getSoonestCooldownExpiry(store, profileIds) {
|
|
17
|
+
let soonest = null;
|
|
18
|
+
for (const id of profileIds) {
|
|
19
|
+
const stats = store.usageStats?.[id];
|
|
20
|
+
if (!stats) {
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
const until = resolveProfileUnusableUntil(stats);
|
|
24
|
+
if (typeof until !== "number" || !Number.isFinite(until) || until <= 0) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
if (soonest === null || until < soonest) {
|
|
28
|
+
soonest = until;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return soonest;
|
|
32
|
+
}
|
|
11
33
|
/**
|
|
12
34
|
* Check if a profile is currently in cooldown (due to rate limiting or errors).
|
|
13
35
|
*/
|
|
@@ -7,4 +7,4 @@ export { resolveAuthStorePathForDisplay } from "./auth-profiles/paths.js";
|
|
|
7
7
|
export { listProfilesForProvider, markAuthProfileGood, setAuthProfileOrder, upsertAuthProfile, upsertAuthProfileWithLock, } from "./auth-profiles/profiles.js";
|
|
8
8
|
export { repairOAuthProfileIdMismatch, suggestOAuthProfileIdForLegacyDefault, } from "./auth-profiles/repair.js";
|
|
9
9
|
export { ensureAuthProfileStore, loadAuthProfileStore, saveAuthProfileStore, } from "./auth-profiles/store.js";
|
|
10
|
-
export { calculateAuthProfileCooldownMs, clearAuthProfileCooldown, isProfileInCooldown, markAuthProfileCooldown, markAuthProfileFailure, markAuthProfileUsed, resolveProfileUnusableUntilForDisplay, } from "./auth-profiles/usage.js";
|
|
10
|
+
export { calculateAuthProfileCooldownMs, clearAuthProfileCooldown, getSoonestCooldownExpiry, isProfileInCooldown, markAuthProfileCooldown, markAuthProfileFailure, markAuthProfileUsed, resolveProfileUnusableUntilForDisplay, } from "./auth-profiles/usage.js";
|
|
@@ -58,11 +58,9 @@ function validateHostEnv(env) {
|
|
|
58
58
|
const DEFAULT_MAX_OUTPUT = clampNumber(readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"), 200_000, 1_000, 200_000);
|
|
59
59
|
const DEFAULT_PENDING_MAX_OUTPUT = clampNumber(readEnvInt("POOLBOT_BASH_PENDING_MAX_OUTPUT_CHARS") ??
|
|
60
60
|
readEnvInt("CLAWDBOT_BASH_PENDING_MAX_OUTPUT_CHARS"), 200_000, 1_000, 200_000);
|
|
61
|
-
// Default exec timeout:
|
|
62
|
-
//
|
|
63
|
-
|
|
64
|
-
// Previous default (1800s / 30 min) caused the bot to hang silently on stuck commands.
|
|
65
|
-
const DEFAULT_EXEC_TIMEOUT_SEC = clampNumber(readEnvInt("POOLBOT_EXEC_TIMEOUT_SEC") ?? readEnvInt("CLAWDBOT_EXEC_TIMEOUT_SEC"), 120, 1, 86_400);
|
|
61
|
+
// Default exec timeout: 1800s (30 min) to accommodate long installs/builds.
|
|
62
|
+
// Users can override via config (`tools.exec.timeoutSec`) or env var.
|
|
63
|
+
const DEFAULT_EXEC_TIMEOUT_SEC = clampNumber(readEnvInt("POOLBOT_EXEC_TIMEOUT_SEC") ?? readEnvInt("CLAWDBOT_EXEC_TIMEOUT_SEC"), 1800, 1, 86_400);
|
|
66
64
|
const DEFAULT_PATH = process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
|
67
65
|
const DEFAULT_NOTIFY_TAIL_CHARS = 400;
|
|
68
66
|
const DEFAULT_APPROVAL_TIMEOUT_MS = 120_000;
|
|
@@ -78,7 +76,7 @@ const execSchema = Type.Object({
|
|
|
78
76
|
})),
|
|
79
77
|
background: Type.Optional(Type.Boolean({ description: "Run in background immediately" })),
|
|
80
78
|
timeout: Type.Optional(Type.Number({
|
|
81
|
-
description: "Timeout in seconds (default
|
|
79
|
+
description: "Timeout in seconds (default 1800, kills process on expiry).",
|
|
82
80
|
})),
|
|
83
81
|
pty: Type.Optional(Type.Boolean({
|
|
84
82
|
description: "Run in a pseudo-terminal (PTY) when available (TTY-required CLIs, coding agents)",
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
function escapeRegex(value) {
|
|
2
|
+
// Standard "escape string for regex literal" pattern.
|
|
3
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
4
|
+
}
|
|
5
|
+
export function compileGlobPattern(params) {
|
|
6
|
+
const normalized = params.normalize(params.raw);
|
|
7
|
+
if (!normalized) {
|
|
8
|
+
return { kind: "exact", value: "" };
|
|
9
|
+
}
|
|
10
|
+
if (normalized === "*") {
|
|
11
|
+
return { kind: "all" };
|
|
12
|
+
}
|
|
13
|
+
if (!normalized.includes("*")) {
|
|
14
|
+
return { kind: "exact", value: normalized };
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
kind: "regex",
|
|
18
|
+
value: new RegExp(`^${escapeRegex(normalized).replaceAll("\\*", ".*")}$`),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export function compileGlobPatterns(params) {
|
|
22
|
+
if (!Array.isArray(params.raw)) {
|
|
23
|
+
return [];
|
|
24
|
+
}
|
|
25
|
+
return params.raw
|
|
26
|
+
.map((raw) => compileGlobPattern({ raw, normalize: params.normalize }))
|
|
27
|
+
.filter((pattern) => pattern.kind !== "exact" || pattern.value);
|
|
28
|
+
}
|
|
29
|
+
export function matchesAnyGlobPattern(value, patterns) {
|
|
30
|
+
for (const pattern of patterns) {
|
|
31
|
+
if (pattern.kind === "all") {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (pattern.kind === "exact" && value === pattern.value) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
if (pattern.kind === "regex" && pattern.value.test(value)) {
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
@@ -17,6 +17,10 @@ const DEFAULT_HYBRID_ENABLED = true;
|
|
|
17
17
|
const DEFAULT_HYBRID_VECTOR_WEIGHT = 0.7;
|
|
18
18
|
const DEFAULT_HYBRID_TEXT_WEIGHT = 0.3;
|
|
19
19
|
const DEFAULT_HYBRID_CANDIDATE_MULTIPLIER = 4;
|
|
20
|
+
const DEFAULT_MMR_ENABLED = false;
|
|
21
|
+
const DEFAULT_MMR_LAMBDA = 0.7;
|
|
22
|
+
const DEFAULT_TEMPORAL_DECAY_ENABLED = false;
|
|
23
|
+
const DEFAULT_TEMPORAL_DECAY_HALF_LIFE_DAYS = 30;
|
|
20
24
|
const DEFAULT_CACHE_ENABLED = true;
|
|
21
25
|
const DEFAULT_SOURCES = ["memory"];
|
|
22
26
|
function normalizeSources(sources, sessionMemoryEnabled) {
|
|
@@ -141,6 +145,22 @@ function mergeConfig(defaults, overrides, agentId) {
|
|
|
141
145
|
candidateMultiplier: overrides?.query?.hybrid?.candidateMultiplier ??
|
|
142
146
|
defaults?.query?.hybrid?.candidateMultiplier ??
|
|
143
147
|
DEFAULT_HYBRID_CANDIDATE_MULTIPLIER,
|
|
148
|
+
mmr: {
|
|
149
|
+
enabled: overrides?.query?.hybrid?.mmr?.enabled ??
|
|
150
|
+
defaults?.query?.hybrid?.mmr?.enabled ??
|
|
151
|
+
DEFAULT_MMR_ENABLED,
|
|
152
|
+
lambda: overrides?.query?.hybrid?.mmr?.lambda ??
|
|
153
|
+
defaults?.query?.hybrid?.mmr?.lambda ??
|
|
154
|
+
DEFAULT_MMR_LAMBDA,
|
|
155
|
+
},
|
|
156
|
+
temporalDecay: {
|
|
157
|
+
enabled: overrides?.query?.hybrid?.temporalDecay?.enabled ??
|
|
158
|
+
defaults?.query?.hybrid?.temporalDecay?.enabled ??
|
|
159
|
+
DEFAULT_TEMPORAL_DECAY_ENABLED,
|
|
160
|
+
halfLifeDays: overrides?.query?.hybrid?.temporalDecay?.halfLifeDays ??
|
|
161
|
+
defaults?.query?.hybrid?.temporalDecay?.halfLifeDays ??
|
|
162
|
+
DEFAULT_TEMPORAL_DECAY_HALF_LIFE_DAYS,
|
|
163
|
+
},
|
|
144
164
|
};
|
|
145
165
|
const cache = {
|
|
146
166
|
enabled: overrides?.cache?.enabled ?? defaults?.cache?.enabled ?? DEFAULT_CACHE_ENABLED,
|
|
@@ -154,6 +174,9 @@ function mergeConfig(defaults, overrides, agentId) {
|
|
|
154
174
|
const normalizedVectorWeight = sum > 0 ? vectorWeight / sum : DEFAULT_HYBRID_VECTOR_WEIGHT;
|
|
155
175
|
const normalizedTextWeight = sum > 0 ? textWeight / sum : DEFAULT_HYBRID_TEXT_WEIGHT;
|
|
156
176
|
const candidateMultiplier = clampInt(hybrid.candidateMultiplier, 1, 20);
|
|
177
|
+
const temporalDecayHalfLifeDays = Math.max(1, Math.floor(Number.isFinite(hybrid.temporalDecay.halfLifeDays)
|
|
178
|
+
? hybrid.temporalDecay.halfLifeDays
|
|
179
|
+
: DEFAULT_TEMPORAL_DECAY_HALF_LIFE_DAYS));
|
|
157
180
|
const deltaBytes = clampInt(sync.sessions.deltaBytes, 0, Number.MAX_SAFE_INTEGER);
|
|
158
181
|
const deltaMessages = clampInt(sync.sessions.deltaMessages, 0, Number.MAX_SAFE_INTEGER);
|
|
159
182
|
return {
|
|
@@ -185,6 +208,16 @@ function mergeConfig(defaults, overrides, agentId) {
|
|
|
185
208
|
vectorWeight: normalizedVectorWeight,
|
|
186
209
|
textWeight: normalizedTextWeight,
|
|
187
210
|
candidateMultiplier,
|
|
211
|
+
mmr: {
|
|
212
|
+
enabled: Boolean(hybrid.mmr.enabled),
|
|
213
|
+
lambda: Number.isFinite(hybrid.mmr.lambda)
|
|
214
|
+
? Math.max(0, Math.min(1, hybrid.mmr.lambda))
|
|
215
|
+
: DEFAULT_MMR_LAMBDA,
|
|
216
|
+
},
|
|
217
|
+
temporalDecay: {
|
|
218
|
+
enabled: Boolean(hybrid.temporalDecay.enabled),
|
|
219
|
+
halfLifeDays: temporalDecayHalfLifeDays,
|
|
220
|
+
},
|
|
188
221
|
},
|
|
189
222
|
},
|
|
190
223
|
cache: {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
|
|
2
2
|
import { coerceToFailoverError, describeFailoverError, isFailoverError, isTimeoutError, } from "./failover-error.js";
|
|
3
3
|
import { buildModelAliasIndex, modelKey, parseModelRef, resolveConfiguredModelRef, resolveModelRefFromString, } from "./model-selection.js";
|
|
4
|
-
import { ensureAuthProfileStore, isProfileInCooldown, resolveAuthProfileOrder, } from "./auth-profiles.js";
|
|
4
|
+
import { ensureAuthProfileStore, getSoonestCooldownExpiry, isProfileInCooldown, resolveAuthProfileOrder, } from "./auth-profiles.js";
|
|
5
5
|
function isAbortError(err) {
|
|
6
6
|
if (!err || typeof err !== "object")
|
|
7
7
|
return false;
|
|
@@ -135,6 +135,36 @@ function resolveFallbackCandidates(params) {
|
|
|
135
135
|
}
|
|
136
136
|
return candidates;
|
|
137
137
|
}
|
|
138
|
+
const lastProbeAttempt = new Map();
|
|
139
|
+
const MIN_PROBE_INTERVAL_MS = 30_000; // 30 seconds between probes per key
|
|
140
|
+
const PROBE_MARGIN_MS = 2 * 60 * 1000;
|
|
141
|
+
const PROBE_SCOPE_DELIMITER = "::";
|
|
142
|
+
function resolveProbeThrottleKey(provider, agentDir) {
|
|
143
|
+
const scope = String(agentDir ?? "").trim();
|
|
144
|
+
return scope ? `${scope}${PROBE_SCOPE_DELIMITER}${provider}` : provider;
|
|
145
|
+
}
|
|
146
|
+
function shouldProbePrimaryDuringCooldown(params) {
|
|
147
|
+
if (!params.isPrimary || !params.hasFallbackCandidates) {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const lastProbe = lastProbeAttempt.get(params.throttleKey) ?? 0;
|
|
151
|
+
if (params.now - lastProbe < MIN_PROBE_INTERVAL_MS) {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
const soonest = getSoonestCooldownExpiry(params.authStore, params.profileIds);
|
|
155
|
+
if (soonest === null || !Number.isFinite(soonest)) {
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
// Probe when cooldown already expired or within the configured margin.
|
|
159
|
+
return params.now >= soonest - PROBE_MARGIN_MS;
|
|
160
|
+
}
|
|
161
|
+
/** @internal – exposed for unit tests only */
|
|
162
|
+
export const _probeThrottleInternals = {
|
|
163
|
+
lastProbeAttempt,
|
|
164
|
+
MIN_PROBE_INTERVAL_MS,
|
|
165
|
+
PROBE_MARGIN_MS,
|
|
166
|
+
resolveProbeThrottleKey,
|
|
167
|
+
};
|
|
138
168
|
export async function runWithModelFallback(params) {
|
|
139
169
|
const candidates = resolveFallbackCandidates({
|
|
140
170
|
cfg: params.cfg,
|
|
@@ -146,6 +176,7 @@ export async function runWithModelFallback(params) {
|
|
|
146
176
|
? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false })
|
|
147
177
|
: null;
|
|
148
178
|
const attempts = [];
|
|
179
|
+
const hasFallbackCandidates = candidates.length > 1;
|
|
149
180
|
let lastError;
|
|
150
181
|
for (let i = 0; i < candidates.length; i += 1) {
|
|
151
182
|
const candidate = candidates[i];
|
|
@@ -157,14 +188,34 @@ export async function runWithModelFallback(params) {
|
|
|
157
188
|
});
|
|
158
189
|
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
|
|
159
190
|
if (profileIds.length > 0 && !isAnyProfileAvailable) {
|
|
160
|
-
// All profiles for this provider are in cooldown
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
191
|
+
// All profiles for this provider are in cooldown.
|
|
192
|
+
// For the primary model (i === 0), probe it if the soonest cooldown
|
|
193
|
+
// expiry is close or already past. This avoids staying on a fallback
|
|
194
|
+
// model long after the real rate-limit window clears.
|
|
195
|
+
const now = Date.now();
|
|
196
|
+
const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir);
|
|
197
|
+
const shouldProbe = shouldProbePrimaryDuringCooldown({
|
|
198
|
+
isPrimary: i === 0,
|
|
199
|
+
hasFallbackCandidates,
|
|
200
|
+
now,
|
|
201
|
+
throttleKey: probeThrottleKey,
|
|
202
|
+
authStore,
|
|
203
|
+
profileIds,
|
|
166
204
|
});
|
|
167
|
-
|
|
205
|
+
if (!shouldProbe) {
|
|
206
|
+
// Skip without attempting
|
|
207
|
+
attempts.push({
|
|
208
|
+
provider: candidate.provider,
|
|
209
|
+
model: candidate.model,
|
|
210
|
+
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
|
|
211
|
+
reason: "rate_limit",
|
|
212
|
+
});
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
// Primary model probe: attempt it despite cooldown to detect recovery.
|
|
216
|
+
// If it fails, the error is caught below and we fall through to the
|
|
217
|
+
// next candidate as usual.
|
|
218
|
+
lastProbeAttempt.set(probeThrottleKey, now);
|
|
168
219
|
}
|
|
169
220
|
}
|
|
170
221
|
try {
|
|
@@ -3,13 +3,108 @@ import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
|
|
|
3
3
|
import { isPlainObject } from "../utils.js";
|
|
4
4
|
import { normalizeToolName } from "./tool-policy.js";
|
|
5
5
|
const log = createSubsystemLogger("agents/tools");
|
|
6
|
+
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
|
|
7
|
+
const adjustedParamsByToolCallId = new Map();
|
|
8
|
+
const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
|
|
9
|
+
const LOOP_WARNING_BUCKET_SIZE = 10;
|
|
10
|
+
const MAX_LOOP_WARNING_KEYS = 256;
|
|
11
|
+
function shouldEmitLoopWarning(state, warningKey, count) {
|
|
12
|
+
if (!state.toolLoopWarningBuckets) {
|
|
13
|
+
state.toolLoopWarningBuckets = new Map();
|
|
14
|
+
}
|
|
15
|
+
const bucket = Math.floor(count / LOOP_WARNING_BUCKET_SIZE);
|
|
16
|
+
const lastBucket = state.toolLoopWarningBuckets.get(warningKey) ?? 0;
|
|
17
|
+
if (bucket <= lastBucket) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
state.toolLoopWarningBuckets.set(warningKey, bucket);
|
|
21
|
+
if (state.toolLoopWarningBuckets.size > MAX_LOOP_WARNING_KEYS) {
|
|
22
|
+
const oldest = state.toolLoopWarningBuckets.keys().next().value;
|
|
23
|
+
if (oldest) {
|
|
24
|
+
state.toolLoopWarningBuckets.delete(oldest);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
async function recordLoopOutcome(args) {
|
|
30
|
+
if (!args.ctx?.sessionKey) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const { getDiagnosticSessionState } = await import("../logging/diagnostic-session-state.js");
|
|
35
|
+
const { recordToolCallOutcome } = await import("./tool-loop-detection.js");
|
|
36
|
+
const sessionState = getDiagnosticSessionState({
|
|
37
|
+
sessionKey: args.ctx.sessionKey,
|
|
38
|
+
sessionId: args.ctx?.agentId,
|
|
39
|
+
});
|
|
40
|
+
recordToolCallOutcome(sessionState, {
|
|
41
|
+
toolName: args.toolName,
|
|
42
|
+
toolParams: args.toolParams,
|
|
43
|
+
toolCallId: args.toolCallId,
|
|
44
|
+
result: args.result,
|
|
45
|
+
error: args.error,
|
|
46
|
+
config: args.ctx.loopDetection,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (err) {
|
|
50
|
+
log.warn(`tool loop outcome tracking failed: tool=${args.toolName} error=${String(err)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
6
53
|
export async function runBeforeToolCallHook(args) {
|
|
54
|
+
const toolName = normalizeToolName(args.toolName || "tool");
|
|
55
|
+
const params = args.params;
|
|
56
|
+
if (args.ctx?.sessionKey) {
|
|
57
|
+
const { getDiagnosticSessionState } = await import("../logging/diagnostic-session-state.js");
|
|
58
|
+
const { logToolLoopAction } = await import("../logging/diagnostic.js");
|
|
59
|
+
const { detectToolCallLoop, recordToolCall } = await import("./tool-loop-detection.js");
|
|
60
|
+
const sessionState = getDiagnosticSessionState({
|
|
61
|
+
sessionKey: args.ctx.sessionKey,
|
|
62
|
+
sessionId: args.ctx?.agentId,
|
|
63
|
+
});
|
|
64
|
+
const loopResult = detectToolCallLoop(sessionState, toolName, params, args.ctx.loopDetection);
|
|
65
|
+
if (loopResult.stuck) {
|
|
66
|
+
if (loopResult.level === "critical") {
|
|
67
|
+
log.error(`Blocking ${toolName} due to critical loop: ${loopResult.message}`);
|
|
68
|
+
logToolLoopAction({
|
|
69
|
+
sessionKey: args.ctx.sessionKey,
|
|
70
|
+
sessionId: args.ctx?.agentId,
|
|
71
|
+
toolName,
|
|
72
|
+
level: "critical",
|
|
73
|
+
action: "block",
|
|
74
|
+
detector: loopResult.detector,
|
|
75
|
+
count: loopResult.count,
|
|
76
|
+
message: loopResult.message,
|
|
77
|
+
pairedToolName: loopResult.pairedToolName,
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
blocked: true,
|
|
81
|
+
reason: loopResult.message,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
else {
|
|
85
|
+
const warningKey = loopResult.warningKey ?? `${loopResult.detector}:${toolName}`;
|
|
86
|
+
if (shouldEmitLoopWarning(sessionState, warningKey, loopResult.count)) {
|
|
87
|
+
log.warn(`Loop warning for ${toolName}: ${loopResult.message}`);
|
|
88
|
+
logToolLoopAction({
|
|
89
|
+
sessionKey: args.ctx.sessionKey,
|
|
90
|
+
sessionId: args.ctx?.agentId,
|
|
91
|
+
toolName,
|
|
92
|
+
level: "warning",
|
|
93
|
+
action: "warn",
|
|
94
|
+
detector: loopResult.detector,
|
|
95
|
+
count: loopResult.count,
|
|
96
|
+
message: loopResult.message,
|
|
97
|
+
pairedToolName: loopResult.pairedToolName,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
recordToolCall(sessionState, toolName, params, args.toolCallId, args.ctx.loopDetection);
|
|
103
|
+
}
|
|
7
104
|
const hookRunner = getGlobalHookRunner();
|
|
8
105
|
if (!hookRunner?.hasHooks("before_tool_call")) {
|
|
9
106
|
return { blocked: false, params: args.params };
|
|
10
107
|
}
|
|
11
|
-
const toolName = normalizeToolName(args.toolName || "tool");
|
|
12
|
-
const params = args.params;
|
|
13
108
|
try {
|
|
14
109
|
const normalizedParams = isPlainObject(params) ? params : {};
|
|
15
110
|
const hookResult = await hookRunner.runBeforeToolCall({
|
|
@@ -45,7 +140,7 @@ export function wrapToolWithBeforeToolCallHook(tool, ctx) {
|
|
|
45
140
|
return tool;
|
|
46
141
|
}
|
|
47
142
|
const toolName = tool.name || "tool";
|
|
48
|
-
|
|
143
|
+
const wrappedTool = {
|
|
49
144
|
...tool,
|
|
50
145
|
execute: async (toolCallId, params, signal, onUpdate) => {
|
|
51
146
|
const outcome = await runBeforeToolCallHook({
|
|
@@ -57,11 +152,57 @@ export function wrapToolWithBeforeToolCallHook(tool, ctx) {
|
|
|
57
152
|
if (outcome.blocked) {
|
|
58
153
|
throw new Error(outcome.reason);
|
|
59
154
|
}
|
|
60
|
-
|
|
155
|
+
if (toolCallId) {
|
|
156
|
+
adjustedParamsByToolCallId.set(toolCallId, outcome.params);
|
|
157
|
+
if (adjustedParamsByToolCallId.size > MAX_TRACKED_ADJUSTED_PARAMS) {
|
|
158
|
+
const oldest = adjustedParamsByToolCallId.keys().next().value;
|
|
159
|
+
if (oldest) {
|
|
160
|
+
adjustedParamsByToolCallId.delete(oldest);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const normalizedToolName = normalizeToolName(toolName || "tool");
|
|
165
|
+
try {
|
|
166
|
+
const result = await execute(toolCallId, outcome.params, signal, onUpdate);
|
|
167
|
+
await recordLoopOutcome({
|
|
168
|
+
ctx,
|
|
169
|
+
toolName: normalizedToolName,
|
|
170
|
+
toolParams: outcome.params,
|
|
171
|
+
toolCallId,
|
|
172
|
+
result,
|
|
173
|
+
});
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
catch (err) {
|
|
177
|
+
await recordLoopOutcome({
|
|
178
|
+
ctx,
|
|
179
|
+
toolName: normalizedToolName,
|
|
180
|
+
toolParams: outcome.params,
|
|
181
|
+
toolCallId,
|
|
182
|
+
error: err,
|
|
183
|
+
});
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
61
186
|
},
|
|
62
187
|
};
|
|
188
|
+
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, {
|
|
189
|
+
value: true,
|
|
190
|
+
enumerable: false,
|
|
191
|
+
});
|
|
192
|
+
return wrappedTool;
|
|
193
|
+
}
|
|
194
|
+
export function isToolWrappedWithBeforeToolCallHook(tool) {
|
|
195
|
+
const taggedTool = tool;
|
|
196
|
+
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
|
|
197
|
+
}
|
|
198
|
+
export function consumeAdjustedParamsForToolCall(toolCallId) {
|
|
199
|
+
const params = adjustedParamsByToolCallId.get(toolCallId);
|
|
200
|
+
adjustedParamsByToolCallId.delete(toolCallId);
|
|
201
|
+
return params;
|
|
63
202
|
}
|
|
64
203
|
export const __testing = {
|
|
204
|
+
BEFORE_TOOL_CALL_WRAPPED,
|
|
205
|
+
adjustedParamsByToolCallId,
|
|
65
206
|
runBeforeToolCallHook,
|
|
66
207
|
isPlainObject,
|
|
67
208
|
};
|
package/dist/agents/pi-tools.js
CHANGED
|
@@ -5,12 +5,13 @@ import { createApplyPatchTool } from "./apply-patch.js";
|
|
|
5
5
|
import { createExecTool, createProcessTool, } from "./bash-tools.js";
|
|
6
6
|
import { listChannelAgentTools } from "./channel-tools.js";
|
|
7
7
|
import { createPoolBotTools } from "./poolbot-tools.js";
|
|
8
|
+
import { resolveAgentConfig } from "./agent-scope.js";
|
|
8
9
|
import { wrapToolWithAbortSignal } from "./pi-tools.abort.js";
|
|
9
10
|
import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
|
|
10
11
|
import { filterToolsByPolicy, isToolAllowedByPolicies, resolveEffectiveToolPolicy, resolveGroupToolPolicy, resolveSubagentToolPolicy, } from "./pi-tools.policy.js";
|
|
11
12
|
import { assertRequiredParams, CLAUDE_PARAM_GROUPS, createPoolbotReadTool, createSandboxedEditTool, createSandboxedReadTool, createSandboxedWriteTool, normalizeToolParams, patchToolSchemaForClaudeCompatibility, wrapToolParamNormalization, } from "./pi-tools.read.js";
|
|
12
13
|
import { cleanToolSchemaForGemini, normalizeToolParameters } from "./pi-tools.schema.js";
|
|
13
|
-
import { buildPluginToolGroups, collectExplicitAllowlist, expandPolicyWithPluginGroups, normalizeToolName, resolveToolProfilePolicy, stripPluginOnlyAllowlist, applyOwnerOnlyToolPolicy, } from "./tool-policy.js";
|
|
14
|
+
import { buildPluginToolGroups, collectExplicitAllowlist, expandPolicyWithPluginGroups, mergeAlsoAllowPolicy, normalizeToolName, resolveToolProfilePolicy, stripPluginOnlyAllowlist, applyOwnerOnlyToolPolicy, } from "./tool-policy.js";
|
|
14
15
|
import { getPluginToolMeta } from "../plugins/tools.js";
|
|
15
16
|
import { logWarn } from "../logger.js";
|
|
16
17
|
function isOpenAIProvider(provider) {
|
|
@@ -53,6 +54,26 @@ function resolveExecConfig(cfg) {
|
|
|
53
54
|
applyPatch: globalExec?.applyPatch,
|
|
54
55
|
};
|
|
55
56
|
}
|
|
57
|
+
export function resolveToolLoopDetectionConfig(params) {
|
|
58
|
+
const global = params.cfg?.tools?.loopDetection;
|
|
59
|
+
const agent = params.agentId && params.cfg
|
|
60
|
+
? resolveAgentConfig(params.cfg, params.agentId)?.tools?.loopDetection
|
|
61
|
+
: undefined;
|
|
62
|
+
if (!agent) {
|
|
63
|
+
return global;
|
|
64
|
+
}
|
|
65
|
+
if (!global) {
|
|
66
|
+
return agent;
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
...global,
|
|
70
|
+
...agent,
|
|
71
|
+
detectors: {
|
|
72
|
+
...global.detectors,
|
|
73
|
+
...agent.detectors,
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
56
77
|
export const __testing = {
|
|
57
78
|
cleanToolSchemaForGemini,
|
|
58
79
|
normalizeToolParams,
|
|
@@ -85,13 +106,8 @@ export function createPoolbotCodingTools(options) {
|
|
|
85
106
|
});
|
|
86
107
|
const profilePolicy = resolveToolProfilePolicy(profile);
|
|
87
108
|
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
return policy;
|
|
91
|
-
return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
|
|
92
|
-
};
|
|
93
|
-
const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
|
|
94
|
-
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(providerProfilePolicy, providerProfileAlsoAllow);
|
|
109
|
+
const profilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(profilePolicy, profileAlsoAllow);
|
|
110
|
+
const providerProfilePolicyWithAlsoAllow = mergeAlsoAllowPolicy(providerProfilePolicy, providerProfileAlsoAllow);
|
|
95
111
|
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
|
|
96
112
|
const subagentPolicy = isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
|
|
97
113
|
? resolveSubagentToolPolicy(options.config)
|
|
@@ -292,10 +308,12 @@ export function createPoolbotCodingTools(options) {
|
|
|
292
308
|
: sandboxed;
|
|
293
309
|
// Always normalize tool JSON Schemas before handing them to pi-agent/pi-ai.
|
|
294
310
|
// Without this, some providers (notably OpenAI) will reject root-level union schemas.
|
|
295
|
-
|
|
311
|
+
// Provider-specific cleaning: Gemini needs constraint keywords stripped, but Anthropic expects them.
|
|
312
|
+
const normalized = subagentFiltered.map((tool) => normalizeToolParameters(tool, { modelProvider: options?.modelProvider }));
|
|
296
313
|
const withHooks = normalized.map((tool) => wrapToolWithBeforeToolCallHook(tool, {
|
|
297
314
|
agentId,
|
|
298
315
|
sessionKey: options?.sessionKey,
|
|
316
|
+
loopDetection: resolveToolLoopDetectionConfig({ cfg: options?.config, agentId }),
|
|
299
317
|
}));
|
|
300
318
|
const withAbort = options?.abortSignal
|
|
301
319
|
? withHooks.map((tool) => wrapToolWithAbortSignal(tool, options.abortSignal))
|