@caupulican/pi-agent-core 0.93.8 → 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 +160 -154
- 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 +8 -4
- package/dist/tool-failure-memory.d.ts.map +1 -1
- package/dist/tool-failure-memory.js +87 -79
- 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 -287
- 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 +41 -20
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +5 -2
- package/dist/types.js.map +1 -1
- package/package.json +2 -2
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tool-failure-recovery-gate.d.ts","sourceRoot":"","sources":["../src/tool-failure-recovery-gate.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"tool-failure-recovery-gate.d.ts","sourceRoot":"","sources":["../src/tool-failure-recovery-gate.ts"],"names":[],"mappings":"AACA,OAAO,EAQN,KAAK,uBAAuB,EAC5B,MAAM,0BAA0B,CAAC;AAElC,OAAO,KAAK,EACX,YAAY,EACZ,SAAS,EACT,+BAA+B,EAE/B,8BAA8B,EAC9B,MAAM,YAAY,CAAC;AAapB,MAAM,WAAW,uBAAuB;IACvC,OAAO,EAAE,SAAS,8BAA8B,EAAE,CAAC;IACnD,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,MAAM,6BAA6B,GACtC;IACA,IAAI,EAAE,cAAc,CAAC;IACrB,IAAI,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC;IACtB,MAAM,EAAE,uBAAuB,CAAC;IAChC,IAAI,EAAE,OAAO,CAAC;CACb,GACD;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,IAAI,EAAE,SAAS,CAAC,GAAG,CAAC,CAAC;IAAC,IAAI,EAAE,OAAO,CAAA;CAAE,CAAC;AAE5D,MAAM,MAAM,4BAA4B,GAAG;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,MAAM,EAAE,uBAAuB,CAAA;CAAE,CAAC;AA2DtH;;;;;;;;;;;;;;;;GAgBG;AACH,qBAAa,uBAAuB;IACnC,OAAO,CAAC,QAAQ,CAAC,oBAAoB,CAAqC;IAC1E,OAAO,CAAC,QAAQ,CAAC,0BAA0B,CAA6B;IACxE,yFAAyF;IACzF,OAAO,CAAC,QAAQ,CAAC,8BAA8B,CAAqB;IACpE,OAAO,CAAC,kBAAkB,CAA+B;IACzD,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,cAAc,CAA2B;IACjD,OAAO,CAAC,sBAAsB,CAAS;IACvC,OAAO,CAAC,WAAW,CAAK;IAExB,OAAO,IAAI,OAAO,CAEjB;IAED,mBAAmB,CAAC,QAAQ,EAAE,SAAS,YAAY,EAAE,GAAG,IAAI,CAmB3D;IAED;;;OAGG;IACH,gBAAgB,IAAI,IAAI,CAEvB;IAED,WAAW,CACV,UAAU,EAAE,SAAS,CAAC,GAAG,CAAC,EAC1B,IAAI,EAAE,OAAO,EACb,OAAO,EAAE,+BAA+B,EACxC,cAAc,EAAE,SAAS,SAAS,CAAC,GAAG,CAAC,EAAE,GACvC,uBAAuB,CAYzB;IAED;;;;OAIG;IACH,OAAO,CAAC,sBAAsB;IAS9B,KAAK,CACJ,IAAI,EAAE,SAAS,CAAC,GAAG,CAAC,EACpB,IAAI,EAAE,OAAO,EACb,MAAM,EAAE,uBAAuB,GAAG,SAAS,EAC3C,QAAQ,GAAE,SAAS,YAAY,EAA4B,GACzD,4BAA4B,CA2B9B;IAED,KAAK,CAAC,MAAM,EAAE,6BAA6B,GAAG,SAAS,GAAG,IAAI,CAO7D;IAED,OAAO,CAAC,mBAAmB;IAkB3B,OAAO,CAAC,cAAc;IAUtB,OAAO,CAAC,eAAe;IAgBvB,OAAO,CAAC,WAAW;IAQnB,OAAO,CAAC,WAAW;IAUnB,OAAO,CAAC,8BAA8B;CAYtC"}
|
|
@@ -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,212 +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;
|
|
165
|
+
this.observeUnproductive(effect.record, effect.args);
|
|
176
166
|
}
|
|
177
|
-
|
|
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;
|
|
201
|
-
}
|
|
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.
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
state.failures++;
|
|
219
|
-
const operationFailureLimit = record.state === "failed"
|
|
220
|
-
? BASE_FAILURE_EXECUTIONS_PER_OPERATION +
|
|
221
|
-
getToolExecutionUnchangedRetryLimit(record.failureCode) +
|
|
222
|
-
MAX_RECOVERY_PROBES_PER_OPERATION
|
|
223
|
-
: MAX_REJECTIONS_PER_OPERATION;
|
|
224
|
-
if (state.failures >= operationFailureLimit) {
|
|
225
|
-
if (usesOperationLocalExhaustion(tool)) {
|
|
226
|
-
state.operationCircuitOpen = true;
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
229
|
-
this.halted = {
|
|
230
|
-
record,
|
|
231
|
-
diagnostic: `Recovery circuit opened after ${state.failures} failed outcomes for one operation.`,
|
|
232
|
-
};
|
|
233
|
-
}
|
|
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
|
+
});
|
|
234
183
|
}
|
|
235
|
-
observeSuccess(tool, args
|
|
236
|
-
const
|
|
184
|
+
observeSuccess(tool, args) {
|
|
185
|
+
const executionKey = getToolExecutionKey(tool.name, args);
|
|
237
186
|
// Tool results are appended to the transcript after the current execution batch completes.
|
|
238
187
|
// Until then the last persisted failure is stale authority: remember the exact success so a
|
|
239
188
|
// later sequential call in this same batch cannot resurrect that failure from the transcript.
|
|
240
|
-
this.resolvedBeforeTranscriptCommit.add(
|
|
241
|
-
const evidenceTargets = readRecoveryEvidenceTargets(tool, args, result);
|
|
242
|
-
for (const [executionKey, state] of this.statesByExecutionKey) {
|
|
243
|
-
if (executionKey === successfulExecutionKey) {
|
|
244
|
-
this.clearResolvedState(executionKey);
|
|
245
|
-
continue;
|
|
246
|
-
}
|
|
247
|
-
if (state.recoveryProbes < MAX_RECOVERY_PROBES_PER_OPERATION &&
|
|
248
|
-
hasSharedRecoveryTarget(state.recoveryTargets, evidenceTargets)) {
|
|
249
|
-
state.recoveryAvailable = true;
|
|
250
|
-
state.blockedReplays = 0;
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
clearResolvedState(executionKey) {
|
|
189
|
+
this.resolvedBeforeTranscriptCommit.add(executionKey);
|
|
255
190
|
this.statesByExecutionKey.delete(executionKey);
|
|
191
|
+
this.worldCursor++;
|
|
256
192
|
}
|
|
257
193
|
trackTranscript(messages) {
|
|
258
194
|
const tail = messages[messages.length - 1];
|
|
@@ -275,10 +211,10 @@ export class ToolFailureRecoveryGate {
|
|
|
275
211
|
this.statesByExecutionKey.set(executionKey, state);
|
|
276
212
|
return state;
|
|
277
213
|
}
|
|
278
|
-
|
|
214
|
+
retainState(executionKey, state) {
|
|
279
215
|
this.statesByExecutionKey.delete(executionKey);
|
|
280
216
|
this.statesByExecutionKey.set(executionKey, state);
|
|
281
|
-
while (this.statesByExecutionKey.size >
|
|
217
|
+
while (this.statesByExecutionKey.size > MAX_TRACKED_OPERATIONS) {
|
|
282
218
|
const oldest = this.statesByExecutionKey.keys().next().value;
|
|
283
219
|
if (oldest === undefined)
|
|
284
220
|
break;
|
|
@@ -286,66 +222,63 @@ export class ToolFailureRecoveryGate {
|
|
|
286
222
|
}
|
|
287
223
|
}
|
|
288
224
|
restoreOperationFromTranscript(executionKey) {
|
|
289
|
-
let
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
return;
|
|
293
|
-
const reduction = this.reduceTranscriptResult(restoredState, tool, args, result);
|
|
294
|
-
if (reduction.kind === "resolved") {
|
|
295
|
-
restoredState = undefined;
|
|
225
|
+
let restored;
|
|
226
|
+
walkTranscript(this.transcriptMessages, (event) => {
|
|
227
|
+
if (event.executionKey !== executionKey)
|
|
296
228
|
return;
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
229
|
+
restored =
|
|
230
|
+
event.kind === "resolved"
|
|
231
|
+
? undefined
|
|
232
|
+
: { record: event.record, worldCursorAtLastExecution: event.worldCursor, unchangedRetriesRemaining: 0 };
|
|
300
233
|
});
|
|
301
|
-
if (
|
|
302
|
-
this.
|
|
303
|
-
return
|
|
234
|
+
if (restored)
|
|
235
|
+
this.retainState(executionKey, restored);
|
|
236
|
+
return restored;
|
|
304
237
|
}
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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;
|
|
312
252
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
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;
|
|
320
259
|
}
|
|
321
|
-
if (
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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;
|
|
271
|
+
}
|
|
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;
|
|
325
278
|
}
|
|
326
|
-
|
|
327
|
-
resetFailureEpisode(next);
|
|
328
|
-
next.record = record;
|
|
329
|
-
next.reservedExecutions++;
|
|
330
|
-
next.failures++;
|
|
331
|
-
return { kind: "failed", state: next };
|
|
279
|
+
visit({ kind: "unproductive", executionKey, worldCursor, record });
|
|
332
280
|
}
|
|
333
|
-
|
|
334
|
-
function usesOperationLocalExhaustion(tool) {
|
|
335
|
-
return tool?.failureRecovery?.exhaustionScope === "operation";
|
|
336
|
-
}
|
|
337
|
-
function sameFailureOutcome(left, right) {
|
|
338
|
-
return (left.failureCode === right.failureCode &&
|
|
339
|
-
left.outputSignature !== undefined &&
|
|
340
|
-
left.outputSignature === right.outputSignature);
|
|
341
|
-
}
|
|
342
|
-
function resetFailureEpisode(state) {
|
|
343
|
-
state.reservedExecutions = 0;
|
|
344
|
-
state.failures = 0;
|
|
345
|
-
state.recoveryProbes = 0;
|
|
346
|
-
state.blockedReplays = 0;
|
|
347
|
-
state.recoveryAvailable = false;
|
|
348
|
-
state.operationCircuitOpen = false;
|
|
281
|
+
return worldCursor;
|
|
349
282
|
}
|
|
350
283
|
function readFailureEvidence(tool, args, failure) {
|
|
351
284
|
try {
|
|
@@ -360,18 +293,6 @@ function readFailureEvidence(tool, args, failure) {
|
|
|
360
293
|
return undefined;
|
|
361
294
|
}
|
|
362
295
|
}
|
|
363
|
-
function createFailureRecoveryState(record, recoveryTargets) {
|
|
364
|
-
return {
|
|
365
|
-
record,
|
|
366
|
-
recoveryTargets,
|
|
367
|
-
reservedExecutions: 0,
|
|
368
|
-
failures: 0,
|
|
369
|
-
recoveryProbes: 0,
|
|
370
|
-
blockedReplays: 0,
|
|
371
|
-
recoveryAvailable: false,
|
|
372
|
-
operationCircuitOpen: false,
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
296
|
function readFailureTargets(tool, args, failureCode) {
|
|
376
297
|
try {
|
|
377
298
|
const contract = tool.failureRecovery;
|
|
@@ -459,80 +380,38 @@ function parseRecoveryAction(value) {
|
|
|
459
380
|
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
460
381
|
return undefined;
|
|
461
382
|
const candidate = value;
|
|
462
|
-
if (
|
|
383
|
+
if ((candidate.kind !== "correct" && candidate.kind !== "repair") ||
|
|
384
|
+
!isAgentToolFailureRecoveryAuthority(candidate.authority) ||
|
|
463
385
|
!validTargetKind(candidate.targetKind) ||
|
|
464
386
|
typeof candidate.instruction !== "string") {
|
|
465
387
|
return undefined;
|
|
466
388
|
}
|
|
467
|
-
if (candidate.kind === "correct") {
|
|
468
|
-
return {
|
|
469
|
-
kind: candidate.kind,
|
|
470
|
-
authority: candidate.authority,
|
|
471
|
-
targetKind: candidate.targetKind,
|
|
472
|
-
instruction: candidate.instruction,
|
|
473
|
-
};
|
|
474
|
-
}
|
|
475
|
-
if (candidate.kind !== "repair" || typeof candidate.getEvidence !== "function")
|
|
476
|
-
return undefined;
|
|
477
|
-
const getEvidence = candidate.getEvidence;
|
|
478
389
|
return {
|
|
479
390
|
kind: candidate.kind,
|
|
480
391
|
authority: candidate.authority,
|
|
481
392
|
targetKind: candidate.targetKind,
|
|
482
393
|
instruction: candidate.instruction,
|
|
483
|
-
getEvidence: (params, result) => Reflect.apply(getEvidence, value, [params, result]),
|
|
484
394
|
};
|
|
485
395
|
}
|
|
486
396
|
catch {
|
|
487
397
|
return undefined;
|
|
488
398
|
}
|
|
489
399
|
}
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
}
|
|
501
|
-
catch {
|
|
502
|
-
continue;
|
|
503
|
-
}
|
|
504
|
-
if (!Array.isArray(scopes))
|
|
505
|
-
continue;
|
|
506
|
-
for (const scope of scopes) {
|
|
507
|
-
if (targets.length >= MAX_RECOVERY_TARGETS)
|
|
508
|
-
break;
|
|
509
|
-
if (!validTargetScope(scope))
|
|
510
|
-
continue;
|
|
511
|
-
const target = { authority: action.authority, kind: action.targetKind, scope };
|
|
512
|
-
if (!targets.some((candidate) => sameRecoveryTarget(candidate, target)))
|
|
513
|
-
targets.push(target);
|
|
514
|
-
}
|
|
515
|
-
}
|
|
516
|
-
return targets;
|
|
517
|
-
}
|
|
518
|
-
function formatRecoveryGuidance(failureCode, actions, unchangedRetryRemaining) {
|
|
519
|
-
const hasTimeoutRetryPolicy = getToolExecutionUnchangedRetryLimit(failureCode) > 0;
|
|
520
|
-
const timeoutPolicy = hasTimeoutRetryPolicy
|
|
521
|
-
? unchangedRetryRemaining
|
|
522
|
-
? "Timeout policy allows 1 unchanged retry; if it fails, never retry unchanged."
|
|
523
|
-
: "Timeout unchanged retry exhausted; never retry unchanged."
|
|
524
|
-
: 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;
|
|
525
410
|
if (actions.length === 0) {
|
|
526
|
-
|
|
527
|
-
return `${timeoutPolicy} Change/narrow operation, or report blocker.`;
|
|
528
|
-
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.`;
|
|
529
412
|
}
|
|
530
413
|
const available = actions.map((action) => `${action.toolName} ${action.kind}: ${action.instruction}`).join(" ");
|
|
531
|
-
|
|
532
|
-
const authority = hasRepair
|
|
533
|
-
? "Only exact matching repair evidence grants 1 probe; else change operation."
|
|
534
|
-
: "Actions require changed operation; unchanged remains blocked.";
|
|
535
|
-
return truncate(`${timeoutPolicy ? `${timeoutPolicy} ` : ""}Loaded actions: ${available} ${authority}`, MAX_RECOVERY_GUIDANCE_CHARS);
|
|
414
|
+
return truncate(`${rule} Loaded actions: ${available}`, MAX_RECOVERY_GUIDANCE_CHARS);
|
|
536
415
|
}
|
|
537
416
|
function truncate(value, maxChars) {
|
|
538
417
|
if (value.length <= maxChars)
|
|
@@ -542,9 +421,4 @@ function truncate(value, maxChars) {
|
|
|
542
421
|
function sameRecoveryTarget(left, right) {
|
|
543
422
|
return left.authority === right.authority && left.kind === right.kind && left.scope === right.scope;
|
|
544
423
|
}
|
|
545
|
-
function hasSharedRecoveryTarget(left, right) {
|
|
546
|
-
if (left.length > right.length)
|
|
547
|
-
return hasSharedRecoveryTarget(right, left);
|
|
548
|
-
return left.some((target) => right.some((candidate) => sameRecoveryTarget(target, candidate)));
|
|
549
|
-
}
|
|
550
424
|
//# sourceMappingURL=tool-failure-recovery-gate.js.map
|