@poolzin/pool-bot 2026.2.11 → 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.
Files changed (70) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/agents/auth-profiles/usage.js +22 -0
  3. package/dist/agents/auth-profiles.js +1 -1
  4. package/dist/agents/glob-pattern.js +42 -0
  5. package/dist/agents/memory-search.js +33 -0
  6. package/dist/agents/model-fallback.js +59 -8
  7. package/dist/agents/pi-tools.before-tool-call.js +145 -4
  8. package/dist/agents/pi-tools.js +27 -9
  9. package/dist/agents/pi-tools.policy.js +85 -92
  10. package/dist/agents/pi-tools.schema.js +54 -27
  11. package/dist/agents/sandbox/validate-sandbox-security.js +157 -0
  12. package/dist/agents/sandbox-tool-policy.js +26 -0
  13. package/dist/agents/sanitize-for-prompt.js +18 -0
  14. package/dist/agents/session-write-lock.js +203 -39
  15. package/dist/agents/system-prompt.js +52 -10
  16. package/dist/agents/tool-loop-detection.js +466 -0
  17. package/dist/agents/tool-policy.js +6 -0
  18. package/dist/auto-reply/reply/post-compaction-audit.js +96 -0
  19. package/dist/auto-reply/reply/post-compaction-context.js +98 -0
  20. package/dist/build-info.json +3 -3
  21. package/dist/config/zod-schema.agent-defaults.js +14 -0
  22. package/dist/config/zod-schema.agent-runtime.js +14 -0
  23. package/dist/infra/path-safety.js +16 -0
  24. package/dist/logging/diagnostic-session-state.js +73 -0
  25. package/dist/logging/diagnostic.js +22 -0
  26. package/dist/memory/embeddings.js +36 -9
  27. package/dist/memory/hybrid.js +24 -5
  28. package/dist/memory/manager.js +76 -28
  29. package/dist/memory/mmr.js +164 -0
  30. package/dist/memory/query-expansion.js +331 -0
  31. package/dist/memory/temporal-decay.js +119 -0
  32. package/dist/process/kill-tree.js +98 -0
  33. package/dist/shared/pid-alive.js +12 -0
  34. package/dist/shared/process-scoped-map.js +10 -0
  35. package/extensions/bluebubbles/package.json +1 -1
  36. package/extensions/copilot-proxy/package.json +1 -1
  37. package/extensions/diagnostics-otel/package.json +1 -1
  38. package/extensions/discord/package.json +1 -1
  39. package/extensions/google-antigravity-auth/package.json +1 -1
  40. package/extensions/google-gemini-cli-auth/package.json +1 -1
  41. package/extensions/googlechat/package.json +1 -1
  42. package/extensions/imessage/package.json +1 -1
  43. package/extensions/line/package.json +1 -1
  44. package/extensions/llm-task/package.json +1 -1
  45. package/extensions/lobster/package.json +1 -1
  46. package/extensions/matrix/CHANGELOG.md +5 -0
  47. package/extensions/matrix/package.json +1 -1
  48. package/extensions/mattermost/package.json +1 -1
  49. package/extensions/memory-core/package.json +1 -1
  50. package/extensions/memory-lancedb/package.json +1 -1
  51. package/extensions/msteams/CHANGELOG.md +5 -0
  52. package/extensions/msteams/package.json +1 -1
  53. package/extensions/nextcloud-talk/package.json +1 -1
  54. package/extensions/nostr/CHANGELOG.md +5 -0
  55. package/extensions/nostr/package.json +1 -1
  56. package/extensions/open-prose/package.json +1 -1
  57. package/extensions/signal/package.json +1 -1
  58. package/extensions/slack/package.json +1 -1
  59. package/extensions/telegram/package.json +1 -1
  60. package/extensions/tlon/package.json +1 -1
  61. package/extensions/twitch/CHANGELOG.md +5 -0
  62. package/extensions/twitch/package.json +1 -1
  63. package/extensions/voice-call/CHANGELOG.md +5 -0
  64. package/extensions/voice-call/package.json +1 -1
  65. package/extensions/whatsapp/package.json +1 -1
  66. package/extensions/zalo/CHANGELOG.md +5 -0
  67. package/extensions/zalo/package.json +1 -1
  68. package/extensions/zalouser/CHANGELOG.md +5 -0
  69. package/extensions/zalouser/package.json +1 -1
  70. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,3 +1,20 @@
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
+
1
18
  ## v2026.2.11 (2026-02-16)
2
19
 
3
20
  ### 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";
@@ -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; skip without attempting
161
- attempts.push({
162
- provider: candidate.provider,
163
- model: candidate.model,
164
- error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
165
- reason: "rate_limit",
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
- continue;
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
- return {
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
- return await execute(toolCallId, outcome.params, signal, onUpdate);
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
  };
@@ -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 mergeAlsoAllow = (policy, alsoAllow) => {
89
- if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0)
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
- const normalized = subagentFiltered.map(normalizeToolParameters);
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))