@caupulican/pi-agent-core 0.93.7 → 0.93.9

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.
@@ -1,11 +1,8 @@
1
1
  import { getToolExecutionUnchangedRetryLimit } from "@caupulican/pi-ai/tool-repair-registry";
2
- import { forEachPairedToolResult, getToolExecutionKey, getToolExecutionKeyHashParts, getToolFailureRecordExecutionKey, isClosedOperationFailureCode, isPromptScopedFailureCode, readVisibleToolFailureCode, restoreToolFailureRecord, sanitizeToolFailureEvidence, } from "./tool-failure-memory.js";
2
+ import { getToolExecutionKey, getToolExecutionKeyHashParts, getToolFailureRecordExecutionKey, isPromptScopedFailureCode, readVisibleToolFailureCode, restoreToolFailureRecord, sanitizeToolFailureEvidence, } from "./tool-failure-memory.js";
3
+ import { TOOL_FAILURE_READMISSION_RULE } from "./tool-failure-recovery-protocol.js";
3
4
  import { isAgentToolFailureRecoveryAuthority } from "./types.js";
4
- const MAX_BLOCKED_REPLAYS_PER_FAILURE = 2;
5
- const BASE_FAILURE_EXECUTIONS_PER_OPERATION = 1;
6
- const MAX_RECOVERY_PROBES_PER_OPERATION = 1;
7
- const MAX_REJECTIONS_PER_OPERATION = 4;
8
- const MAX_HOT_RECOVERY_STATES = 64;
5
+ const MAX_TRACKED_OPERATIONS = 64;
9
6
  const SEEN_EXECUTION_FILTER_BYTES = 64 * 1024;
10
7
  const MAX_RECOVERY_TARGETS = 8;
11
8
  const MAX_RECOVERY_ACTIONS = 8;
@@ -17,10 +14,10 @@ const TARGET_KIND_PATTERN = /^[a-z0-9][a-z0-9._:-]*$/;
17
14
  /**
18
15
  * Bounded negative lookup for exact execution identities.
19
16
  *
20
- * A miss proves the operation has not failed while this gate has been alive. A hit only means
21
- * "possibly seen" and must be verified against the transcript, so collisions can cost a scan but
22
- * can never deny an execution. Keeping this separate from the hot state cache lets old exact
23
- * circuits survive eviction without retaining one live object graph per historical operation.
17
+ * A miss proves the operation has not been unproductive while this gate has been alive. A hit only
18
+ * means "possibly seen" and must be verified against the transcript, so collisions can cost a scan
19
+ * but can never deny an execution. Keeping this separate from the hot state cache lets old
20
+ * operations survive eviction without retaining one live object graph per historical operation.
24
21
  */
25
22
  class SeenExecutionFilter {
26
23
  constructor() {
@@ -47,209 +44,151 @@ class SeenExecutionFilter {
47
44
  }
48
45
  }
49
46
  /**
50
- * Owns execution admission and bounded unresolved-failure budgets.
47
+ * Admission governor for exact tool operations.
51
48
  *
52
- * Recovery authority is exact and tool-owned. A failed tool declares opaque backend-specific targets;
53
- * a loaded recovery tool may teach actions only for the same authority and target kind. Only raw
54
- * successful repair evidence with byte-exact scope can reopen one probe. Argument text and hooks have
55
- * no recovery authority.
49
+ * It answers exactly one question: can repeating this identical operation, right now, tell the agent
50
+ * anything it does not already know? An operation whose last execution was unproductive is admitted
51
+ * again once the world has moved that is, once any tool has succeeded, or the user has spoken,
52
+ * since that operation last ran. Until then the replay is refused, because its result is already in
53
+ * the transcript.
56
54
  *
57
- * Run-level halt stays on the current run, but admission is never denied because unrelated operations
58
- * happened to fail. A bounded hot cache carries active per-operation state. A fixed-size negative
59
- * lookup sends an evicted exact replay back to the transcript for authoritative reconstruction, so
60
- * cache pressure cannot either stop new work or reopen an already-exhausted operation.
55
+ * The world cursor is the whole budget. There are no per-operation attempt counts, no probe quotas,
56
+ * and no circuits that stay open for the rest of the session: correct repair work always re-admits
57
+ * the operation it repaired, however many times the agent needs it.
58
+ *
59
+ * Refusal is always local to one operation. This gate cannot deny an unrelated tool, cannot
60
+ * terminate a tool batch, and cannot end a run — a stuck agent is the runaway-loop backstop's
61
+ * problem (`maxStallTurns`), and reporting a dead end is the model's own job.
61
62
  */
62
63
  export class ToolFailureRecoveryGate {
63
64
  constructor() {
64
65
  this.statesByExecutionKey = new Map();
65
- this.seenFailedExecutions = new SeenExecutionFilter();
66
+ this.seenUnproductiveExecutions = new SeenExecutionFilter();
66
67
  /** Exact successes not yet present in the transcript snapshot consulted by admission. */
67
68
  this.resolvedBeforeTranscriptCommit = new Set();
68
69
  this.transcriptMessages = [];
69
70
  this.transcriptLength = 0;
70
71
  this.restoredFromTranscript = false;
72
+ this.worldCursor = 0;
71
73
  }
72
74
  isEmpty() {
73
- return this.statesByExecutionKey.size === 0 && this.halted === undefined;
75
+ return this.statesByExecutionKey.size === 0;
74
76
  }
75
77
  restoreFromMessages(messages) {
76
78
  this.trackTranscript(messages);
77
79
  if (this.restoredFromTranscript || !this.isEmpty())
78
80
  return;
79
81
  this.restoredFromTranscript = true;
80
- forEachPairedToolResult(messages, ({ tool, args, executionKey, result }) => {
81
- const reduction = this.reduceTranscriptResult(this.statesByExecutionKey.get(executionKey), tool, args, result);
82
- if (reduction.kind === "resolved") {
83
- this.clearResolvedState(executionKey);
82
+ this.worldCursor = walkTranscript(messages, (event) => {
83
+ if (event.kind === "resolved") {
84
+ this.statesByExecutionKey.delete(event.executionKey);
84
85
  return;
85
86
  }
86
- if (reduction.kind === "ignored")
87
- return;
88
- this.seenFailedExecutions.add(executionKey);
89
- this.retainHotState(executionKey, reduction.state);
87
+ this.seenUnproductiveExecutions.add(event.executionKey);
88
+ // A restored state starts with no transient-retry allowance: the transcript already shows the
89
+ // attempts that were made, and the new user turn that triggers a restore has itself moved the
90
+ // world, which is the broader permission anyway.
91
+ this.retainState(event.executionKey, {
92
+ record: event.record,
93
+ worldCursorAtLastExecution: event.worldCursor,
94
+ unchangedRetriesRemaining: 0,
95
+ });
90
96
  });
91
97
  }
92
- planFailure(failedTool, args, failure, availableTools, reservation) {
98
+ /**
99
+ * Record that the world moved for a reason other than a tool result — a new user turn. Authority,
100
+ * intent, and files can all change across one, so every operation becomes worth attempting again.
101
+ */
102
+ noteWorldAdvance() {
103
+ this.worldCursor++;
104
+ }
105
+ planFailure(failedTool, args, failure, availableTools) {
93
106
  const targets = readFailureTargets(failedTool, args, failure.failureCode);
94
107
  const actions = readAvailableRecoveryActions(availableTools, targets);
95
- const unchangedRetryRemaining = this.hasUnchangedRetryRemaining(failedTool, args, failure.failureCode, reservation);
96
108
  const evidence = readFailureEvidence(failedTool, args, failure);
97
109
  return {
98
110
  targets,
99
- guidance: formatRecoveryGuidance(failure.failureCode, actions, unchangedRetryRemaining),
111
+ guidance: formatRecoveryGuidance(actions, this.transientRetryStanding(getToolExecutionKey(failedTool.name, args), failure.failureCode)),
100
112
  ...(evidence ? { evidence } : {}),
101
113
  };
102
114
  }
115
+ /**
116
+ * Whether this failure class allows an immediate identical retry, and whether one survives the
117
+ * failure about to be recorded. Read before that failure is observed, so it mirrors exactly what
118
+ * `observeUnproductive` is about to leave behind.
119
+ */
120
+ transientRetryStanding(executionKey, failureCode) {
121
+ const retryLimit = getToolExecutionUnchangedRetryLimit(failureCode);
122
+ if (retryLimit === 0)
123
+ return "none";
124
+ const state = this.statesByExecutionKey.get(executionKey);
125
+ const remaining = !state || state.worldCursorAtLastExecution !== this.worldCursor ? retryLimit : state.unchangedRetriesRemaining;
126
+ return remaining > 0 ? "available" : "spent";
127
+ }
103
128
  admit(tool, args, record, messages = this.transcriptMessages) {
104
129
  this.trackTranscript(messages);
105
- const runHalt = this.halted;
106
- if (runHalt) {
107
- return {
108
- kind: "blocked",
109
- record: runHalt.record,
110
- exhausted: true,
111
- scope: "run",
112
- diagnostic: runHalt.diagnostic,
113
- };
114
- }
115
130
  const executionKey = getToolExecutionKey(tool.name, args);
116
131
  if (this.resolvedBeforeTranscriptCommit.has(executionKey))
117
132
  return { kind: "allowed" };
118
133
  let state = this.getHotState(executionKey);
119
- if (!state && this.seenFailedExecutions.mightContain(executionKey)) {
134
+ if (!state && this.seenUnproductiveExecutions.mightContain(executionKey)) {
120
135
  state = this.restoreOperationFromTranscript(executionKey);
121
136
  }
122
- if (!state && record && getToolFailureRecordExecutionKey(record) === executionKey) {
123
- state = this.getOrCreateState(executionKey, record, readFailureTargets(tool, args, record.failureCode));
137
+ if (!state &&
138
+ record &&
139
+ getToolFailureRecordExecutionKey(record) === executionKey &&
140
+ // A prompt-scoped block is cleared by a new owner prompt, never by the agent. It is not a
141
+ // repetition state, so it must not become one through the caller's failure memory either.
142
+ !isPromptScopedFailureCode(record.failureCode)) {
143
+ state = { record, worldCursorAtLastExecution: this.worldCursor, unchangedRetriesRemaining: 0 };
144
+ this.retainState(executionKey, state);
124
145
  }
125
146
  if (!state)
126
147
  return { kind: "allowed" };
127
148
  if (record && getToolFailureRecordExecutionKey(record) === executionKey)
128
149
  state.record = record;
129
- if (state.operationCircuitOpen) {
130
- if (state.recoveryAvailable && state.recoveryProbes < MAX_RECOVERY_PROBES_PER_OPERATION) {
131
- state.operationCircuitOpen = false;
132
- state.recoveryAvailable = false;
133
- state.recoveryProbes++;
134
- state.reservedExecutions++;
135
- state.blockedReplays = 0;
136
- return { kind: "allowed", reservation: { executionKey } };
137
- }
138
- if (usesOperationLocalExhaustion(tool)) {
139
- const diagnostic = `Operation recovery circuit remains closed after replay of ${state.record.failureCode}.`;
140
- return { kind: "blocked", record: state.record, exhausted: true, scope: "operation", diagnostic };
141
- }
142
- const diagnostic = `Run recovery circuit opened after replay of an operation whose local circuit was already open for ${state.record.failureCode}.`;
143
- this.halted = { record: state.record, diagnostic };
144
- return { kind: "blocked", record: state.record, exhausted: true, scope: "run", diagnostic };
145
- }
146
- const automaticExecutionLimit = BASE_FAILURE_EXECUTIONS_PER_OPERATION + getToolExecutionUnchangedRetryLimit(state.record.failureCode);
147
- if (state.reservedExecutions < automaticExecutionLimit) {
148
- state.reservedExecutions++;
149
- state.blockedReplays = 0;
150
- return { kind: "allowed", reservation: { executionKey } };
151
- }
152
- if (state.recoveryAvailable && state.recoveryProbes < MAX_RECOVERY_PROBES_PER_OPERATION) {
153
- state.recoveryAvailable = false;
154
- state.recoveryProbes++;
155
- state.reservedExecutions++;
156
- state.blockedReplays = 0;
157
- return { kind: "allowed", reservation: { executionKey } };
158
- }
159
- state.blockedReplays++;
160
- if (state.blockedReplays >= MAX_BLOCKED_REPLAYS_PER_FAILURE) {
161
- state.operationCircuitOpen = true;
162
- const diagnostic = `Operation recovery circuit opened after ${state.blockedReplays} blocked replays of ${state.record.failureCode}.`;
163
- return { kind: "blocked", record: state.record, exhausted: true, scope: "operation", diagnostic };
150
+ if (this.worldCursor > state.worldCursorAtLastExecution)
151
+ return { kind: "allowed" };
152
+ if (state.unchangedRetriesRemaining > 0) {
153
+ state.unchangedRetriesRemaining--;
154
+ return { kind: "allowed" };
164
155
  }
165
- return { kind: "blocked", record: state.record, exhausted: false };
156
+ return { kind: "blocked", record: state.record };
166
157
  }
167
158
  apply(effect) {
168
- if (!effect || this.halted)
169
- return undefined;
159
+ if (!effect)
160
+ return;
170
161
  if (effect.kind === "success") {
171
- this.observeSuccess(effect.tool, effect.args, effect.evidenceResult);
172
- return undefined;
162
+ this.observeSuccess(effect.tool, effect.args);
163
+ return;
173
164
  }
174
- this.observeFailure(effect.tool, effect.record, effect.args, effect.targets ?? [], effect.reservation);
175
- return this.halted;
176
- }
177
- isHalted() {
178
- return this.halted !== undefined;
179
- }
180
- getHalt() {
181
- return this.halted;
182
- }
183
- hasUnchangedRetryRemaining(tool, args, failureCode, reservation) {
184
- const retryLimit = getToolExecutionUnchangedRetryLimit(failureCode);
185
- if (retryLimit === 0)
186
- return false;
187
- const executionKey = getToolExecutionKey(tool.name, args);
188
- const state = this.statesByExecutionKey.get(executionKey);
189
- const executionsIncludingCurrent = state
190
- ? state.reservedExecutions + (reservation?.executionKey === executionKey ? 0 : 1)
191
- : 1;
192
- return executionsIncludingCurrent < BASE_FAILURE_EXECUTIONS_PER_OPERATION + retryLimit;
193
- }
194
- getOrCreateState(executionKey, record, targets) {
195
- const existing = this.getHotState(executionKey);
196
- if (existing)
197
- return existing;
198
- const state = createFailureRecoveryState(record, targets);
199
- this.retainHotState(executionKey, state);
200
- return state;
165
+ this.observeUnproductive(effect.record, effect.args);
201
166
  }
202
- observeFailure(tool, record, args, targets, reservation) {
167
+ observeUnproductive(record, args) {
203
168
  const executionKey = getToolExecutionKey(record.tool, args);
204
169
  this.resolvedBeforeTranscriptCommit.delete(executionKey);
205
- this.seenFailedExecutions.add(executionKey);
206
- const state = this.getOrCreateState(executionKey, record, targets);
207
- if (this.halted)
208
- return;
209
- state.record = record;
210
- state.recoveryTargets = targets;
211
- state.recoveryAvailable = false;
212
- state.blockedReplays = 0;
213
- if (reservation?.executionKey !== executionKey)
214
- state.reservedExecutions++;
215
- state.failures++;
216
- const operationFailureLimit = record.state === "failed"
217
- ? BASE_FAILURE_EXECUTIONS_PER_OPERATION +
218
- getToolExecutionUnchangedRetryLimit(record.failureCode) +
219
- MAX_RECOVERY_PROBES_PER_OPERATION
220
- : MAX_REJECTIONS_PER_OPERATION;
221
- if (state.failures >= operationFailureLimit) {
222
- if (usesOperationLocalExhaustion(tool)) {
223
- state.operationCircuitOpen = true;
224
- return;
225
- }
226
- this.halted = {
227
- record,
228
- diagnostic: `Recovery circuit opened after ${state.failures} failed outcomes for one operation.`,
229
- };
230
- }
170
+ this.seenUnproductiveExecutions.add(executionKey);
171
+ const previous = this.statesByExecutionKey.get(executionKey);
172
+ // The transient-retry allowance belongs to one episode: it refills when the world has moved
173
+ // since this operation last ran, and is otherwise spent down so a transient class cannot
174
+ // bankroll an unbounded run of identical calls.
175
+ const startsFreshEpisode = !previous || previous.worldCursorAtLastExecution !== this.worldCursor;
176
+ this.retainState(executionKey, {
177
+ record,
178
+ worldCursorAtLastExecution: this.worldCursor,
179
+ unchangedRetriesRemaining: startsFreshEpisode
180
+ ? getToolExecutionUnchangedRetryLimit(record.failureCode)
181
+ : previous.unchangedRetriesRemaining,
182
+ });
231
183
  }
232
- observeSuccess(tool, args, result) {
233
- const successfulExecutionKey = getToolExecutionKey(tool.name, args);
184
+ observeSuccess(tool, args) {
185
+ const executionKey = getToolExecutionKey(tool.name, args);
234
186
  // Tool results are appended to the transcript after the current execution batch completes.
235
187
  // Until then the last persisted failure is stale authority: remember the exact success so a
236
188
  // later sequential call in this same batch cannot resurrect that failure from the transcript.
237
- this.resolvedBeforeTranscriptCommit.add(successfulExecutionKey);
238
- const evidenceTargets = readRecoveryEvidenceTargets(tool, args, result);
239
- for (const [executionKey, state] of this.statesByExecutionKey) {
240
- if (executionKey === successfulExecutionKey) {
241
- this.clearResolvedState(executionKey);
242
- continue;
243
- }
244
- if (state.recoveryProbes < MAX_RECOVERY_PROBES_PER_OPERATION &&
245
- hasSharedRecoveryTarget(state.recoveryTargets, evidenceTargets)) {
246
- state.recoveryAvailable = true;
247
- state.blockedReplays = 0;
248
- }
249
- }
250
- }
251
- clearResolvedState(executionKey) {
189
+ this.resolvedBeforeTranscriptCommit.add(executionKey);
252
190
  this.statesByExecutionKey.delete(executionKey);
191
+ this.worldCursor++;
253
192
  }
254
193
  trackTranscript(messages) {
255
194
  const tail = messages[messages.length - 1];
@@ -272,10 +211,10 @@ export class ToolFailureRecoveryGate {
272
211
  this.statesByExecutionKey.set(executionKey, state);
273
212
  return state;
274
213
  }
275
- retainHotState(executionKey, state) {
214
+ retainState(executionKey, state) {
276
215
  this.statesByExecutionKey.delete(executionKey);
277
216
  this.statesByExecutionKey.set(executionKey, state);
278
- while (this.statesByExecutionKey.size > MAX_HOT_RECOVERY_STATES) {
217
+ while (this.statesByExecutionKey.size > MAX_TRACKED_OPERATIONS) {
279
218
  const oldest = this.statesByExecutionKey.keys().next().value;
280
219
  if (oldest === undefined)
281
220
  break;
@@ -283,49 +222,63 @@ export class ToolFailureRecoveryGate {
283
222
  }
284
223
  }
285
224
  restoreOperationFromTranscript(executionKey) {
286
- let restoredState;
287
- forEachPairedToolResult(this.transcriptMessages, ({ tool, args, executionKey: candidateKey, result }) => {
288
- if (candidateKey !== executionKey)
225
+ let restored;
226
+ walkTranscript(this.transcriptMessages, (event) => {
227
+ if (event.executionKey !== executionKey)
289
228
  return;
290
- const reduction = this.reduceTranscriptResult(restoredState, tool, args, result);
291
- if (reduction.kind === "resolved") {
292
- restoredState = undefined;
293
- return;
294
- }
295
- if (reduction.kind === "failed")
296
- restoredState = reduction.state;
229
+ restored =
230
+ event.kind === "resolved"
231
+ ? undefined
232
+ : { record: event.record, worldCursorAtLastExecution: event.worldCursor, unchangedRetriesRemaining: 0 };
297
233
  });
298
- if (restoredState)
299
- this.retainHotState(executionKey, restoredState);
300
- return restoredState;
234
+ if (restored)
235
+ this.retainState(executionKey, restored);
236
+ return restored;
301
237
  }
302
- reduceTranscriptResult(state, tool, args, result) {
303
- if (!result.isError)
304
- return { kind: "resolved" };
305
- const record = restoreToolFailureRecord(result, tool, args);
306
- const visibleCode = readVisibleToolFailureCode(result);
307
- if (isPromptScopedFailureCode(visibleCode) || isPromptScopedFailureCode(record.failureCode)) {
308
- return { kind: "ignored", state };
238
+ }
239
+ /**
240
+ * Replay a transcript's world advances in order, reporting each completed operation with the cursor
241
+ * value that was current when it ran. Advances are counted exactly as the live gate counts them —
242
+ * every successful tool result, plus every user turn — so a resumed session admits precisely what an
243
+ * uninterrupted one would. Returns the final cursor.
244
+ */
245
+ function walkTranscript(messages, visit) {
246
+ const callsById = new Map();
247
+ let worldCursor = 0;
248
+ for (const message of messages) {
249
+ if (message.role === "user") {
250
+ worldCursor++;
251
+ continue;
309
252
  }
310
- const next = state ?? createFailureRecoveryState(record, []);
311
- next.record = record;
312
- if (isClosedOperationFailureCode(visibleCode)) {
313
- next.operationCircuitOpen = true;
314
- next.blockedReplays = MAX_BLOCKED_REPLAYS_PER_FAILURE;
315
- next.recoveryAvailable = false;
316
- return { kind: "failed", state: next };
253
+ if (message.role === "assistant") {
254
+ for (const block of message.content) {
255
+ if (block.type === "toolCall")
256
+ callsById.set(block.id, { name: block.name, args: block.arguments });
257
+ }
258
+ continue;
317
259
  }
318
- if (visibleCode === "repeated_failed_operation") {
319
- next.blockedReplays++;
320
- return { kind: "failed", state: next };
260
+ if (message.role !== "toolResult")
261
+ continue;
262
+ const call = callsById.get(message.toolCallId);
263
+ if (!call)
264
+ continue;
265
+ callsById.delete(message.toolCallId);
266
+ const executionKey = getToolExecutionKey(call.name, call.args);
267
+ if (!message.isError) {
268
+ worldCursor++;
269
+ visit({ kind: "resolved", executionKey, worldCursor });
270
+ continue;
321
271
  }
322
- next.reservedExecutions++;
323
- next.failures++;
324
- return { kind: "failed", state: next };
272
+ const record = restoreToolFailureRecord(message, call.name, call.args);
273
+ // A prompt-scoped block is cleared by a new owner prompt, not by anything the agent can do, so
274
+ // it never becomes a repetition state.
275
+ if (isPromptScopedFailureCode(readVisibleToolFailureCode(message)) ||
276
+ isPromptScopedFailureCode(record.failureCode)) {
277
+ continue;
278
+ }
279
+ visit({ kind: "unproductive", executionKey, worldCursor, record });
325
280
  }
326
- }
327
- function usesOperationLocalExhaustion(tool) {
328
- return tool?.failureRecovery?.exhaustionScope === "operation";
281
+ return worldCursor;
329
282
  }
330
283
  function readFailureEvidence(tool, args, failure) {
331
284
  try {
@@ -340,18 +293,6 @@ function readFailureEvidence(tool, args, failure) {
340
293
  return undefined;
341
294
  }
342
295
  }
343
- function createFailureRecoveryState(record, recoveryTargets) {
344
- return {
345
- record,
346
- recoveryTargets,
347
- reservedExecutions: 0,
348
- failures: 0,
349
- recoveryProbes: 0,
350
- blockedReplays: 0,
351
- recoveryAvailable: false,
352
- operationCircuitOpen: false,
353
- };
354
- }
355
296
  function readFailureTargets(tool, args, failureCode) {
356
297
  try {
357
298
  const contract = tool.failureRecovery;
@@ -439,80 +380,38 @@ function parseRecoveryAction(value) {
439
380
  if (!value || typeof value !== "object" || Array.isArray(value))
440
381
  return undefined;
441
382
  const candidate = value;
442
- if (!isAgentToolFailureRecoveryAuthority(candidate.authority) ||
383
+ if ((candidate.kind !== "correct" && candidate.kind !== "repair") ||
384
+ !isAgentToolFailureRecoveryAuthority(candidate.authority) ||
443
385
  !validTargetKind(candidate.targetKind) ||
444
386
  typeof candidate.instruction !== "string") {
445
387
  return undefined;
446
388
  }
447
- if (candidate.kind === "correct") {
448
- return {
449
- kind: candidate.kind,
450
- authority: candidate.authority,
451
- targetKind: candidate.targetKind,
452
- instruction: candidate.instruction,
453
- };
454
- }
455
- if (candidate.kind !== "repair" || typeof candidate.getEvidence !== "function")
456
- return undefined;
457
- const getEvidence = candidate.getEvidence;
458
389
  return {
459
390
  kind: candidate.kind,
460
391
  authority: candidate.authority,
461
392
  targetKind: candidate.targetKind,
462
393
  instruction: candidate.instruction,
463
- getEvidence: (params, result) => Reflect.apply(getEvidence, value, [params, result]),
464
394
  };
465
395
  }
466
396
  catch {
467
397
  return undefined;
468
398
  }
469
399
  }
470
- function readRecoveryEvidenceTargets(tool, args, result) {
471
- const targets = [];
472
- for (const candidate of readDeclaredRecoveryActions(tool)) {
473
- const action = parseRecoveryAction(candidate);
474
- if (targets.length >= MAX_RECOVERY_TARGETS || !action || action.kind !== "repair" || !action.getEvidence) {
475
- continue;
476
- }
477
- let scopes;
478
- try {
479
- scopes = action.getEvidence(args, result);
480
- }
481
- catch {
482
- continue;
483
- }
484
- if (!Array.isArray(scopes))
485
- continue;
486
- for (const scope of scopes) {
487
- if (targets.length >= MAX_RECOVERY_TARGETS)
488
- break;
489
- if (!validTargetScope(scope))
490
- continue;
491
- const target = { authority: action.authority, kind: action.targetKind, scope };
492
- if (!targets.some((candidate) => sameRecoveryTarget(candidate, target)))
493
- targets.push(target);
494
- }
495
- }
496
- return targets;
497
- }
498
- function formatRecoveryGuidance(failureCode, actions, unchangedRetryRemaining) {
499
- const hasTimeoutRetryPolicy = getToolExecutionUnchangedRetryLimit(failureCode) > 0;
500
- const timeoutPolicy = hasTimeoutRetryPolicy
501
- ? unchangedRetryRemaining
502
- ? "Timeout policy allows 1 unchanged retry; if it fails, never retry unchanged."
503
- : "Timeout unchanged retry exhausted; never retry unchanged."
504
- : undefined;
400
+ /**
401
+ * The admission rule comes first and is never the part that truncates: a model that reads only the
402
+ * opening clause still learns exactly what makes this operation runnable again.
403
+ */
404
+ function formatRecoveryGuidance(actions, transientRetry) {
405
+ const rule = transientRetry === "available"
406
+ ? `This failure class allows 1 immediate unchanged retry. ${TOOL_FAILURE_READMISSION_RULE}`
407
+ : transientRetry === "spent"
408
+ ? `Unchanged retry spent. ${TOOL_FAILURE_READMISSION_RULE}`
409
+ : TOOL_FAILURE_READMISSION_RULE;
505
410
  if (actions.length === 0) {
506
- if (timeoutPolicy)
507
- return `${timeoutPolicy} Change/narrow operation, or report blocker.`;
508
- return "No loaded tool declares recovery. Never retry unchanged. Use materially different operation justified by diagnostic/schema, or report blocker.";
411
+ return `${rule} Do the corrective work first, or use a materially different operation justified by the diagnostic.`;
509
412
  }
510
413
  const available = actions.map((action) => `${action.toolName} ${action.kind}: ${action.instruction}`).join(" ");
511
- const hasRepair = actions.some((action) => action.kind === "repair");
512
- const authority = hasRepair
513
- ? "Only exact matching repair evidence grants 1 probe; else change operation."
514
- : "Actions require changed operation; unchanged remains blocked.";
515
- return truncate(`${timeoutPolicy ? `${timeoutPolicy} ` : ""}Loaded actions: ${available} ${authority}`, MAX_RECOVERY_GUIDANCE_CHARS);
414
+ return truncate(`${rule} Loaded actions: ${available}`, MAX_RECOVERY_GUIDANCE_CHARS);
516
415
  }
517
416
  function truncate(value, maxChars) {
518
417
  if (value.length <= maxChars)
@@ -522,9 +421,4 @@ function truncate(value, maxChars) {
522
421
  function sameRecoveryTarget(left, right) {
523
422
  return left.authority === right.authority && left.kind === right.kind && left.scope === right.scope;
524
423
  }
525
- function hasSharedRecoveryTarget(left, right) {
526
- if (left.length > right.length)
527
- return hasSharedRecoveryTarget(right, left);
528
- return left.some((target) => right.some((candidate) => sameRecoveryTarget(target, candidate)));
529
- }
530
424
  //# sourceMappingURL=tool-failure-recovery-gate.js.map