@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.
- package/dist/agent-loop.d.ts.map +1 -1
- package/dist/agent-loop.js +179 -155
- package/dist/agent-loop.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/tool-failure-memory.d.ts +17 -5
- package/dist/tool-failure-memory.d.ts.map +1 -1
- package/dist/tool-failure-memory.js +128 -82
- package/dist/tool-failure-memory.js.map +1 -1
- package/dist/tool-failure-recovery-gate.d.ts +32 -42
- package/dist/tool-failure-recovery-gate.d.ts.map +1 -1
- package/dist/tool-failure-recovery-gate.js +161 -267
- package/dist/tool-failure-recovery-gate.js.map +1 -1
- package/dist/tool-failure-recovery-protocol.d.ts +2 -0
- package/dist/tool-failure-recovery-protocol.d.ts.map +1 -1
- package/dist/tool-failure-recovery-protocol.js +7 -6
- package/dist/tool-failure-recovery-protocol.js.map +1 -1
- package/dist/tool-protocol-residue.d.ts +9 -0
- package/dist/tool-protocol-residue.d.ts.map +1 -1
- package/dist/tool-protocol-residue.js +28 -1
- package/dist/tool-protocol-residue.js.map +1 -1
- package/dist/types.d.ts +50 -18
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
import { getToolExecutionUnchangedRetryLimit } from "@caupulican/pi-ai/tool-repair-registry";
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
21
|
-
* "possibly seen" and must be verified against the transcript, so collisions can cost a scan
|
|
22
|
-
* can never deny an execution. Keeping this separate from the hot state cache lets old
|
|
23
|
-
*
|
|
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
|
-
*
|
|
47
|
+
* Admission governor for exact tool operations.
|
|
51
48
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
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
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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(
|
|
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.
|
|
134
|
+
if (!state && this.seenUnproductiveExecutions.mightContain(executionKey)) {
|
|
120
135
|
state = this.restoreOperationFromTranscript(executionKey);
|
|
121
136
|
}
|
|
122
|
-
if (!state &&
|
|
123
|
-
|
|
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.
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
156
|
+
return { kind: "blocked", record: state.record };
|
|
166
157
|
}
|
|
167
158
|
apply(effect) {
|
|
168
|
-
if (!effect
|
|
169
|
-
return
|
|
159
|
+
if (!effect)
|
|
160
|
+
return;
|
|
170
161
|
if (effect.kind === "success") {
|
|
171
|
-
this.observeSuccess(effect.tool, effect.args
|
|
172
|
-
return
|
|
162
|
+
this.observeSuccess(effect.tool, effect.args);
|
|
163
|
+
return;
|
|
173
164
|
}
|
|
174
|
-
this.
|
|
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
|
-
|
|
167
|
+
observeUnproductive(record, args) {
|
|
203
168
|
const executionKey = getToolExecutionKey(record.tool, args);
|
|
204
169
|
this.resolvedBeforeTranscriptCommit.delete(executionKey);
|
|
205
|
-
this.
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
233
|
-
const
|
|
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(
|
|
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
|
-
|
|
214
|
+
retainState(executionKey, state) {
|
|
276
215
|
this.statesByExecutionKey.delete(executionKey);
|
|
277
216
|
this.statesByExecutionKey.set(executionKey, state);
|
|
278
|
-
while (this.statesByExecutionKey.size >
|
|
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
|
|
287
|
-
|
|
288
|
-
if (
|
|
225
|
+
let restored;
|
|
226
|
+
walkTranscript(this.transcriptMessages, (event) => {
|
|
227
|
+
if (event.executionKey !== executionKey)
|
|
289
228
|
return;
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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 (
|
|
299
|
-
this.
|
|
300
|
-
return
|
|
234
|
+
if (restored)
|
|
235
|
+
this.retainState(executionKey, restored);
|
|
236
|
+
return restored;
|
|
301
237
|
}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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 (
|
|
319
|
-
|
|
320
|
-
|
|
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
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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 (
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|