@gaunt-sloth/core 2.0.0-beta.3 → 2.0.0-beta.4
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/core/GthAbstractAgent.d.ts +66 -0
- package/dist/core/GthAbstractAgent.js +185 -1
- package/dist/core/GthAbstractAgent.js.map +1 -1
- package/dist/core/GthAgentRunner.d.ts +82 -1
- package/dist/core/GthAgentRunner.js +265 -8
- package/dist/core/GthAgentRunner.js.map +1 -1
- package/dist/core/GthLangChainAgent.d.ts +3 -2
- package/dist/core/GthLangChainAgent.js +24 -4
- package/dist/core/GthLangChainAgent.js.map +1 -1
- package/dist/core/refusal.d.ts +54 -1
- package/dist/core/refusal.js +109 -1
- package/dist/core/refusal.js.map +1 -1
- package/dist/core/terminationNotice.d.ts +111 -0
- package/dist/core/terminationNotice.js +209 -0
- package/dist/core/terminationNotice.js.map +1 -0
- package/dist/core/terminationReason.d.ts +271 -0
- package/dist/core/terminationReason.js +405 -0
- package/dist/core/terminationReason.js.map +1 -0
- package/dist/core/types.d.ts +27 -0
- package/dist/core/types.js.map +1 -1
- package/dist/runtime/conversation.d.ts +11 -0
- package/dist/runtime/conversation.js +13 -1
- package/dist/runtime/conversation.js.map +1 -1
- package/dist/runtime/singleShot.d.ts +11 -0
- package/dist/runtime/singleShot.js +21 -1
- package/dist/runtime/singleShot.js.map +1 -1
- package/dist/utils/debugDump.d.ts +54 -0
- package/dist/utils/debugDump.js +32 -0
- package/dist/utils/debugDump.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* EXT-159 — the typed reason a run ended.
|
|
4
|
+
*
|
|
5
|
+
* A run can stop for a dozen unrelated causes — a rate limit, a provider-side fault, a full context
|
|
6
|
+
* window, a content-policy refusal, the approvals gate, a tool-error budget, the user pressing Esc
|
|
7
|
+
* — and every one of them used to reach the surface as one untyped sentence. This module is the
|
|
8
|
+
* single taxonomy those causes are classified into, so the fact "why did this end" is carried as a
|
|
9
|
+
* value rather than reconstructed from prose.
|
|
10
|
+
*
|
|
11
|
+
* **Two feeders converge here, and they are not interchangeable.**
|
|
12
|
+
*
|
|
13
|
+
* - A **metadata reader** (`detectStopMetadata` in `core/refusal.ts`, called from
|
|
14
|
+
* `GthAbstractAgent`) handles reasons that arrive *on a message* — a stop/finish reason in
|
|
15
|
+
* `response_metadata` or
|
|
16
|
+
* `additional_kwargs`. It sits at that layer because that is the only place the metadata is
|
|
17
|
+
* visible.
|
|
18
|
+
* - An **exception classifier** ({@link classifyThrownTermination}, called from the runner's
|
|
19
|
+
* catches) handles reasons that arrive as a *thrown error*. Those are not in `response_metadata`
|
|
20
|
+
* at all, so no metadata reader can ever see them.
|
|
21
|
+
*
|
|
22
|
+
* Built as one metadata reader the whole thrown-error half of the class falls outside it; built as
|
|
23
|
+
* two taxonomies every consumer grows its own. Hence: two feeders, one taxonomy.
|
|
24
|
+
*
|
|
25
|
+
* **Retryability is two facts, never a boolean.** `@langchain/core` exports a typed
|
|
26
|
+
* `ContextOverflowError` that stamps itself non-retryable in its own constructor. That is right for
|
|
27
|
+
* "send the same prompt again" and exactly backwards for the remedy this cause actually has, which
|
|
28
|
+
* is to send a *smaller* one. {@link GthTerminationReason} therefore carries
|
|
29
|
+
* {@link GthTerminationReason#retryableAsIs} and
|
|
30
|
+
* {@link GthTerminationReason#retryableAfterRemedy} separately, with the remedy named.
|
|
31
|
+
*
|
|
32
|
+
* Classification only. Nothing here surfaces anything, formats anything, or changes what a run
|
|
33
|
+
* does; the user-facing strings stay where they are and keep their own wording.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* What ended the run, as one closed vocabulary shared by every site and every consumer.
|
|
37
|
+
*
|
|
38
|
+
* Members are causes, not messages: two sites that stop for the same reason report the same
|
|
39
|
+
* category and are told apart by {@link GthTerminationSite}.
|
|
40
|
+
*/
|
|
41
|
+
export type GthTerminationCategory =
|
|
42
|
+
/** The model finished of its own accord — the ordinary end of a turn. */
|
|
43
|
+
'completed'
|
|
44
|
+
/** The turn produced no content at all (no refusal, no error, nothing). */
|
|
45
|
+
| 'empty_response'
|
|
46
|
+
/** The model or the provider's safety system declined to answer. */
|
|
47
|
+
| 'content_refusal'
|
|
48
|
+
/** The answer was cut off against the output cap rather than finished. */
|
|
49
|
+
| 'output_truncated'
|
|
50
|
+
/** Prompt plus history exceeded the model's input window. */
|
|
51
|
+
| 'context_overflow'
|
|
52
|
+
/** The provider refused for rate/quota reasons (HTTP 429). */
|
|
53
|
+
| 'rate_limited'
|
|
54
|
+
/** Credentials were missing, wrong or unauthorised (HTTP 401/403). */
|
|
55
|
+
| 'auth_failed'
|
|
56
|
+
/** The provider rejected the request itself (HTTP 400) for a reason retrying cannot change. */
|
|
57
|
+
| 'invalid_request'
|
|
58
|
+
/** A fault on the provider's side (HTTP 5xx, "internal error during token generation"). */
|
|
59
|
+
| 'provider_error'
|
|
60
|
+
/** The request never completed at the transport level. */
|
|
61
|
+
| 'network_error'
|
|
62
|
+
/** A deadline elapsed before the run finished. */
|
|
63
|
+
| 'timeout'
|
|
64
|
+
/** The user stopped it — Esc, a cancelled signal, a closed client. */
|
|
65
|
+
| 'cancelled'
|
|
66
|
+
/** The approvals gate deliberately ended the run. */
|
|
67
|
+
| 'approval_stop'
|
|
68
|
+
/** The tool-error budget ended the run rather than spend another model call. */
|
|
69
|
+
| 'tool_error_budget'
|
|
70
|
+
/** The tool-loop guard ended a no-progress identical-call loop. */
|
|
71
|
+
| 'tool_loop_guard'
|
|
72
|
+
/**
|
|
73
|
+
* The tool-approval interrupt drain gave up: the graph re-suspended on a gated tool call more
|
|
74
|
+
* times in one turn than the drain loop is willing to resume, so the RUNTIME ended the turn.
|
|
75
|
+
*
|
|
76
|
+
* Deliberately not `recursion_limit`, which states that the graph hit *its* recursion limit — a
|
|
77
|
+
* different bound, owned by LangGraph, with a different knob. Reporting one for the other would
|
|
78
|
+
* be the same false-category defect this taxonomy exists to remove, and `site` is what separates
|
|
79
|
+
* the two surfaces this bound has.
|
|
80
|
+
*/
|
|
81
|
+
| 'interrupt_drain_guard'
|
|
82
|
+
/** A tool threw, and the failure ended the turn. */
|
|
83
|
+
| 'tool_error'
|
|
84
|
+
/** The graph suspended on an `interrupt()` and is waiting to be resumed. */
|
|
85
|
+
| 'suspended'
|
|
86
|
+
/** The graph hit its recursion limit. */
|
|
87
|
+
| 'recursion_limit'
|
|
88
|
+
/** The consumer stopped consuming the turn before it ended. */
|
|
89
|
+
| 'abandoned'
|
|
90
|
+
/** Nothing in the taxonomy matched — recorded as such rather than guessed at. */
|
|
91
|
+
| 'unknown';
|
|
92
|
+
/**
|
|
93
|
+
* Where the classification was made, as a stable identifier per termination site.
|
|
94
|
+
*
|
|
95
|
+
* The site is a distinct fact from the category: several sites classify into the same category (the
|
|
96
|
+
* runner's two exception wrappers both report whatever the classifier says), and several categories
|
|
97
|
+
* can be reported from one site (an aborted stream and a suspended graph leave `streamWithEvents`
|
|
98
|
+
* at the same place). Diagnosis needs both.
|
|
99
|
+
*/
|
|
100
|
+
export type GthTerminationSite =
|
|
101
|
+
/** `GthAgentRunner.processMessages` returned an answer. */
|
|
102
|
+
'runner.completed'
|
|
103
|
+
/** The streamed turn was empty and the non-streaming fallback was empty too. */
|
|
104
|
+
| 'runner.empty-after-fallback'
|
|
105
|
+
/** The non-streaming turn produced no content. */
|
|
106
|
+
| 'runner.empty-invoke'
|
|
107
|
+
/** An approvals stop re-thrown out of the stream drain. */
|
|
108
|
+
| 'runner.stream-approval-stop'
|
|
109
|
+
/** An approvals stop re-thrown out of the turn. */
|
|
110
|
+
| 'runner.turn-approval-stop'
|
|
111
|
+
/** The stream drain threw. */
|
|
112
|
+
| 'runner.stream-error'
|
|
113
|
+
/** The turn threw. */
|
|
114
|
+
| 'runner.turn-error'
|
|
115
|
+
/** The string path's tool-approval drain ran out of resume rounds. */
|
|
116
|
+
| 'runner.interrupt-guard-exhausted'
|
|
117
|
+
/** `GthAgentRunner.processMessagesWithEvents` drained its stream to the end. */
|
|
118
|
+
| 'runner.events-completed'
|
|
119
|
+
/** The typed-event turn ended having yielded no answer text at all. */
|
|
120
|
+
| 'runner.events-empty'
|
|
121
|
+
/** The typed-event turn ended because its signal was aborted. */
|
|
122
|
+
| 'runner.events-cancelled'
|
|
123
|
+
/** The typed-event turn threw. */
|
|
124
|
+
| 'runner.events-error'
|
|
125
|
+
/** The consumer stopped consuming the typed-event turn before it ended. */
|
|
126
|
+
| 'runner.events-abandoned'
|
|
127
|
+
/** The typed-event path's tool-approval drain ran out of resume rounds. */
|
|
128
|
+
| 'runner.events-interrupt-guard-exhausted'
|
|
129
|
+
/** The metadata reader fired on the non-streaming `invoke` path. */
|
|
130
|
+
| 'agent.invoke-stop-metadata'
|
|
131
|
+
/** The metadata reader fired on the string-streaming path. */
|
|
132
|
+
| 'agent.stream-stop-metadata'
|
|
133
|
+
/** The metadata reader fired on the typed-event path. */
|
|
134
|
+
| 'agent.events-stop-metadata'
|
|
135
|
+
/** A `ToolException` was turned into the turn's answer on the `invoke` path. */
|
|
136
|
+
| 'agent.invoke-tool-exception'
|
|
137
|
+
/** The string-streaming path ended on Esc / an abort. */
|
|
138
|
+
| 'agent.stream-cancelled'
|
|
139
|
+
/** `streamWithEvents` ended on a suspend or an abort. */
|
|
140
|
+
| 'agent.events-ended'
|
|
141
|
+
/** `streamWithEventsResume` ended on a suspend or an abort. */
|
|
142
|
+
| 'agent.events-resume-ended'
|
|
143
|
+
/** The tool-error budget's `jumpTo: 'end'`. */
|
|
144
|
+
| 'middleware.tool-error-budget'
|
|
145
|
+
/** The tool-loop guard's `jumpTo: 'end'`. */
|
|
146
|
+
| 'middleware.tool-loop-guard';
|
|
147
|
+
/**
|
|
148
|
+
* Which feeder produced the classification.
|
|
149
|
+
*
|
|
150
|
+
* `metadata` — read off a message's stop/finish reason. `exception` — classified from a thrown
|
|
151
|
+
* error. `control` — the runtime itself decided to end the run (a gate, a middleware, a
|
|
152
|
+
* cancellation, an ordinary completion), so there was nothing to classify.
|
|
153
|
+
*/
|
|
154
|
+
export type GthTerminationSource = 'metadata' | 'exception' | 'control';
|
|
155
|
+
/**
|
|
156
|
+
* What would have to change before a retry is worth making.
|
|
157
|
+
*
|
|
158
|
+
* Named rather than implied, because {@link GthTerminationReason#retryableAfterRemedy} is only
|
|
159
|
+
* actionable if a consumer knows *which* remedy it is being told about.
|
|
160
|
+
*/
|
|
161
|
+
export type GthTerminationRemedy =
|
|
162
|
+
/** Send less: compact the history, drop context, summarise. */
|
|
163
|
+
'reduce-context'
|
|
164
|
+
/** Wait, then send the same thing again. */
|
|
165
|
+
| 'back-off'
|
|
166
|
+
/** Send something different — rephrase, narrow, change approach. */
|
|
167
|
+
| 'change-request'
|
|
168
|
+
/** Send it to a different model. */
|
|
169
|
+
| 'change-model'
|
|
170
|
+
/** Repair credentials or configuration first. */
|
|
171
|
+
| 'fix-credentials'
|
|
172
|
+
/** Nothing is wrong: the run is parked and can be continued where it stopped. */
|
|
173
|
+
| 'resume';
|
|
174
|
+
/**
|
|
175
|
+
* The retry posture of a category — the two facts, plus the remedy the second one refers to.
|
|
176
|
+
*
|
|
177
|
+
* **Posture is per-CATEGORY, so anything acting on a reason must read its `site` as well.** One
|
|
178
|
+
* table decides the posture precisely so no site can invent its own, and the price of that is that
|
|
179
|
+
* the table cannot know how far a given site already got. The standing example is
|
|
180
|
+
* `empty_response`, which is `retryableAsIs: true` because an empty turn usually is worth asking
|
|
181
|
+
* again — yet `runner.empty-after-fallback` is reached only *because* that as-is retry was already
|
|
182
|
+
* spent, while `runner.events-empty` is reached with it never spent at all. A consumer reading the
|
|
183
|
+
* posture flags alone would retry the first of those a second time.
|
|
184
|
+
*/
|
|
185
|
+
export interface GthTerminationPosture {
|
|
186
|
+
/** Is sending the identical request again a sane thing to do? */
|
|
187
|
+
retryableAsIs: boolean;
|
|
188
|
+
/** Is sending it again worthwhile once {@link remedy} has been applied? */
|
|
189
|
+
retryableAfterRemedy: boolean;
|
|
190
|
+
/** The change that makes {@link retryableAfterRemedy} true; absent when it is `false`. */
|
|
191
|
+
remedy?: GthTerminationRemedy;
|
|
192
|
+
}
|
|
193
|
+
/** The retry posture of a category. */
|
|
194
|
+
export declare function terminationPosture(category: GthTerminationCategory): GthTerminationPosture;
|
|
195
|
+
/** Why a run ended, as one value. */
|
|
196
|
+
export interface GthTerminationReason extends GthTerminationPosture {
|
|
197
|
+
/** The taxonomy member. */
|
|
198
|
+
category: GthTerminationCategory;
|
|
199
|
+
/** The site that classified it. */
|
|
200
|
+
site: GthTerminationSite;
|
|
201
|
+
/** Which feeder classified it. */
|
|
202
|
+
source: GthTerminationSource;
|
|
203
|
+
/** Provider family, where the classification knew one. */
|
|
204
|
+
provider?: string;
|
|
205
|
+
/**
|
|
206
|
+
* The raw token the classification was made from — a `finish_reason`, a `stop_reason`, an error
|
|
207
|
+
* name or status. Diagnostic detail, never the carrier of the classification itself.
|
|
208
|
+
*/
|
|
209
|
+
detail?: string;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* [[EXT-159]] — one observation of what the provider said about why a model message stopped.
|
|
213
|
+
*
|
|
214
|
+
* Recorded per finished model message, on every path, whether or not the provider said anything.
|
|
215
|
+
* No `finish_reason` was written to any log anywhere before this, so the artifact a maintainer
|
|
216
|
+
* reaches for first could not answer the question it exists to answer — and a turn where the
|
|
217
|
+
* provider stayed silent looked exactly like one that ended normally.
|
|
218
|
+
*
|
|
219
|
+
* **The absence is the observation.** `token: null` states that the message carried no stop or
|
|
220
|
+
* finish reason at all; it is never a stand-in for one, and no observation is invented for a
|
|
221
|
+
* message that was never seen.
|
|
222
|
+
*/
|
|
223
|
+
export interface GthFinishReasonObservation {
|
|
224
|
+
/** When the message was observed, as an ISO instant with a zone. */
|
|
225
|
+
at: string;
|
|
226
|
+
/** Which of the agent's three paths produced the message. */
|
|
227
|
+
path: 'invoke' | 'stream' | 'events';
|
|
228
|
+
/** The provider's raw token, lower-cased — or `null` when the message carried none. */
|
|
229
|
+
token: string | null;
|
|
230
|
+
}
|
|
231
|
+
/** The classification a feeder produces, before a site is attached to it. */
|
|
232
|
+
export interface GthTerminationClassification {
|
|
233
|
+
category: GthTerminationCategory;
|
|
234
|
+
provider?: string;
|
|
235
|
+
detail?: string;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Build a {@link GthTerminationReason}: attach a site and a feeder to a classification and fill in
|
|
239
|
+
* the posture from the one table. Every site builds through here, so no site can invent a posture.
|
|
240
|
+
*/
|
|
241
|
+
export declare function terminationReason(site: GthTerminationSite, source: GthTerminationSource, classification: GthTerminationCategory | GthTerminationClassification): GthTerminationReason;
|
|
242
|
+
/**
|
|
243
|
+
* Whether a thrown value is a context overflow.
|
|
244
|
+
*
|
|
245
|
+
* The predicate is `ContextOverflowError.isInstance`, never `lc_error_code`: the code is set
|
|
246
|
+
* asymmetrically across providers (Anthropic stamps both the class and the code, OpenAI only the
|
|
247
|
+
* class), so keying on it silently misses most of where the typed class actually works. The
|
|
248
|
+
* substring fallback then covers the providers LangChain does not type at all, and the case where
|
|
249
|
+
* a reworded provider message drops the class.
|
|
250
|
+
*/
|
|
251
|
+
export declare function isContextOverflow(error: unknown): boolean;
|
|
252
|
+
/**
|
|
253
|
+
* The exception feeder: classify a thrown value into the taxonomy.
|
|
254
|
+
*
|
|
255
|
+
* Order matters. The typed and named cases are decided first, because a context overflow is also an
|
|
256
|
+
* HTTP 400 and an abort is also a `DOMException`; only once those are excluded does the status code
|
|
257
|
+
* and then the prose get a say. Never throws: an unclassifiable value is `unknown`, which is a
|
|
258
|
+
* recorded fact rather than a guess.
|
|
259
|
+
*/
|
|
260
|
+
export declare function classifyThrownTermination(error: unknown): GthTerminationClassification;
|
|
261
|
+
/**
|
|
262
|
+
* Attach a reason to a thrown value and return it, so a `throw` site reads as one expression.
|
|
263
|
+
*
|
|
264
|
+
* Non-enumerable, so the reason never widens what an error serialises to (a logged or
|
|
265
|
+
* JSON-stringified error keeps exactly the shape it had), and first-write-wins so a re-throw
|
|
266
|
+
* through an outer wrapper cannot overwrite the inner, truer classification. Fail-soft: a frozen or
|
|
267
|
+
* primitive throw value is returned unchanged rather than turning a failure into a different one.
|
|
268
|
+
*/
|
|
269
|
+
export declare function attachTerminationReason<T>(error: T, reason: GthTerminationReason): T;
|
|
270
|
+
/** The reason attached to a thrown value, following one `cause` link. Undefined when none is. */
|
|
271
|
+
export declare function terminationReasonOf(error: unknown): GthTerminationReason | undefined;
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @packageDocumentation
|
|
3
|
+
* EXT-159 — the typed reason a run ended.
|
|
4
|
+
*
|
|
5
|
+
* A run can stop for a dozen unrelated causes — a rate limit, a provider-side fault, a full context
|
|
6
|
+
* window, a content-policy refusal, the approvals gate, a tool-error budget, the user pressing Esc
|
|
7
|
+
* — and every one of them used to reach the surface as one untyped sentence. This module is the
|
|
8
|
+
* single taxonomy those causes are classified into, so the fact "why did this end" is carried as a
|
|
9
|
+
* value rather than reconstructed from prose.
|
|
10
|
+
*
|
|
11
|
+
* **Two feeders converge here, and they are not interchangeable.**
|
|
12
|
+
*
|
|
13
|
+
* - A **metadata reader** (`detectStopMetadata` in `core/refusal.ts`, called from
|
|
14
|
+
* `GthAbstractAgent`) handles reasons that arrive *on a message* — a stop/finish reason in
|
|
15
|
+
* `response_metadata` or
|
|
16
|
+
* `additional_kwargs`. It sits at that layer because that is the only place the metadata is
|
|
17
|
+
* visible.
|
|
18
|
+
* - An **exception classifier** ({@link classifyThrownTermination}, called from the runner's
|
|
19
|
+
* catches) handles reasons that arrive as a *thrown error*. Those are not in `response_metadata`
|
|
20
|
+
* at all, so no metadata reader can ever see them.
|
|
21
|
+
*
|
|
22
|
+
* Built as one metadata reader the whole thrown-error half of the class falls outside it; built as
|
|
23
|
+
* two taxonomies every consumer grows its own. Hence: two feeders, one taxonomy.
|
|
24
|
+
*
|
|
25
|
+
* **Retryability is two facts, never a boolean.** `@langchain/core` exports a typed
|
|
26
|
+
* `ContextOverflowError` that stamps itself non-retryable in its own constructor. That is right for
|
|
27
|
+
* "send the same prompt again" and exactly backwards for the remedy this cause actually has, which
|
|
28
|
+
* is to send a *smaller* one. {@link GthTerminationReason} therefore carries
|
|
29
|
+
* {@link GthTerminationReason#retryableAsIs} and
|
|
30
|
+
* {@link GthTerminationReason#retryableAfterRemedy} separately, with the remedy named.
|
|
31
|
+
*
|
|
32
|
+
* Classification only. Nothing here surfaces anything, formats anything, or changes what a run
|
|
33
|
+
* does; the user-facing strings stay where they are and keep their own wording.
|
|
34
|
+
*/
|
|
35
|
+
import { ContextOverflowError } from '@langchain/core/errors';
|
|
36
|
+
/**
|
|
37
|
+
* The single posture table.
|
|
38
|
+
*
|
|
39
|
+
* One place decides what a category means for retrying, so the three consumers this taxonomy exists
|
|
40
|
+
* for — a retry posture, a "never retry a 400, a 429 is a different case" ruling, a nudge-or-back-off
|
|
41
|
+
* decision — read the same answer instead of each deriving its own.
|
|
42
|
+
*/
|
|
43
|
+
const POSTURE = {
|
|
44
|
+
// Nothing went wrong; there is nothing to retry.
|
|
45
|
+
completed: { retryableAsIs: false, retryableAfterRemedy: false },
|
|
46
|
+
// The one cause the runtime already retries as-is, and it is right to: an empty turn is usually
|
|
47
|
+
// transient. A model that keeps returning nothing needs a different model, not another attempt.
|
|
48
|
+
empty_response: { retryableAsIs: true, retryableAfterRemedy: true, remedy: 'change-model' },
|
|
49
|
+
// A refusal is deterministic for the same input, so the same prompt refuses again.
|
|
50
|
+
content_refusal: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'change-request' },
|
|
51
|
+
// The answer was cut off, not refused: asking for less, or for a continuation, gets the rest.
|
|
52
|
+
output_truncated: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'change-request' },
|
|
53
|
+
// THE case the two fields exist for. `ContextOverflowError.getRetryable()` is false, which is
|
|
54
|
+
// right for the same prompt and exactly wrong for the smaller one compaction exists to send.
|
|
55
|
+
context_overflow: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'reduce-context' },
|
|
56
|
+
// A 429 answered immediately is a 429 again; waiting is the whole remedy.
|
|
57
|
+
rate_limited: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'back-off' },
|
|
58
|
+
auth_failed: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'fix-credentials' },
|
|
59
|
+
// A rejected request is rejected identically every time, and a repaired request is a new request
|
|
60
|
+
// rather than a retry — so neither field is true and no remedy is named.
|
|
61
|
+
invalid_request: { retryableAsIs: false, retryableAfterRemedy: false },
|
|
62
|
+
// A provider-side fault is the transient case: the same request often succeeds on the next try.
|
|
63
|
+
provider_error: { retryableAsIs: true, retryableAfterRemedy: true, remedy: 'back-off' },
|
|
64
|
+
network_error: { retryableAsIs: true, retryableAfterRemedy: true, remedy: 'back-off' },
|
|
65
|
+
timeout: { retryableAsIs: true, retryableAfterRemedy: true, remedy: 'back-off' },
|
|
66
|
+
// The user chose to stop. Retrying without being asked overrides the one decision they made.
|
|
67
|
+
cancelled: { retryableAsIs: false, retryableAfterRemedy: false },
|
|
68
|
+
// The gate refused. Re-running the refused command automatically is the failure the gate exists
|
|
69
|
+
// to prevent, so neither field offers it.
|
|
70
|
+
approval_stop: { retryableAsIs: false, retryableAfterRemedy: false },
|
|
71
|
+
// Both guards end a loop that is going nowhere. Repeating it goes nowhere again; a changed
|
|
72
|
+
// approach is exactly what each guard's own notice asks the model for.
|
|
73
|
+
tool_error_budget: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'change-request' },
|
|
74
|
+
tool_loop_guard: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'change-request' },
|
|
75
|
+
// The same turn re-suspends the same way, so repeating it exhausts the same bound. Asking for
|
|
76
|
+
// less gated work in one turn is what gets under it.
|
|
77
|
+
interrupt_drain_guard: {
|
|
78
|
+
retryableAsIs: false,
|
|
79
|
+
retryableAfterRemedy: true,
|
|
80
|
+
remedy: 'change-request',
|
|
81
|
+
},
|
|
82
|
+
tool_error: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'change-request' },
|
|
83
|
+
// Not a failure at all: the run is parked mid-flight and continues where it stopped.
|
|
84
|
+
suspended: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'resume' },
|
|
85
|
+
recursion_limit: { retryableAsIs: false, retryableAfterRemedy: true, remedy: 'change-request' },
|
|
86
|
+
abandoned: { retryableAsIs: false, retryableAfterRemedy: false },
|
|
87
|
+
// Unclassified is not "probably fine": nothing is known, so nothing is offered.
|
|
88
|
+
unknown: { retryableAsIs: false, retryableAfterRemedy: false },
|
|
89
|
+
};
|
|
90
|
+
/** The retry posture of a category. */
|
|
91
|
+
export function terminationPosture(category) {
|
|
92
|
+
return POSTURE[category] ?? POSTURE.unknown;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Build a {@link GthTerminationReason}: attach a site and a feeder to a classification and fill in
|
|
96
|
+
* the posture from the one table. Every site builds through here, so no site can invent a posture.
|
|
97
|
+
*/
|
|
98
|
+
export function terminationReason(site, source, classification) {
|
|
99
|
+
const resolved = typeof classification === 'string' ? { category: classification } : classification;
|
|
100
|
+
return {
|
|
101
|
+
category: resolved.category,
|
|
102
|
+
site,
|
|
103
|
+
source,
|
|
104
|
+
...terminationPosture(resolved.category),
|
|
105
|
+
...(resolved.provider === undefined ? {} : { provider: resolved.provider }),
|
|
106
|
+
...(resolved.detail === undefined ? {} : { detail: resolved.detail }),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Substrings providers use when the input exceeds the model's window, matched case-insensitively.
|
|
111
|
+
*
|
|
112
|
+
* These sit **beside** `@langchain/core`'s own detection rather than behind it. LangChain types the
|
|
113
|
+
* error by substring-matching the provider's English prose in each provider package, so a provider
|
|
114
|
+
* rewording its 400 drops the typed class with nothing going red — and it covers only half our
|
|
115
|
+
* providers to begin with. A fallback that repeats the match here is what keeps the classification
|
|
116
|
+
* from quietly un-typing itself on a dependency bump.
|
|
117
|
+
*/
|
|
118
|
+
const CONTEXT_OVERFLOW_PATTERNS = [
|
|
119
|
+
'context_length_exceeded',
|
|
120
|
+
'context length exceeded',
|
|
121
|
+
'maximum context length',
|
|
122
|
+
'exceeds the context window',
|
|
123
|
+
'exceed the context window',
|
|
124
|
+
'input tokens exceed the configured limit',
|
|
125
|
+
'prompt is too long',
|
|
126
|
+
'too many tokens',
|
|
127
|
+
'reduce the length of the messages',
|
|
128
|
+
'request too large',
|
|
129
|
+
];
|
|
130
|
+
/** Substrings that mean the provider refused for rate or quota reasons. */
|
|
131
|
+
const RATE_LIMIT_PATTERNS = [
|
|
132
|
+
'rate limit',
|
|
133
|
+
'rate_limit',
|
|
134
|
+
'ratelimit',
|
|
135
|
+
'too many requests',
|
|
136
|
+
'quota exceeded',
|
|
137
|
+
'resource_exhausted',
|
|
138
|
+
'resource exhausted',
|
|
139
|
+
'overloaded_error',
|
|
140
|
+
];
|
|
141
|
+
/** Substrings that mean the caller was not authorised. */
|
|
142
|
+
const AUTH_PATTERNS = [
|
|
143
|
+
'unauthorized',
|
|
144
|
+
'unauthenticated',
|
|
145
|
+
'invalid api key',
|
|
146
|
+
'invalid_api_key',
|
|
147
|
+
'incorrect api key',
|
|
148
|
+
'api key not valid',
|
|
149
|
+
'permission denied',
|
|
150
|
+
'permission_denied',
|
|
151
|
+
'authentication_error',
|
|
152
|
+
'invalid_grant',
|
|
153
|
+
'forbidden',
|
|
154
|
+
];
|
|
155
|
+
/** Substrings that mean the fault was on the provider's side. */
|
|
156
|
+
const PROVIDER_ERROR_PATTERNS = [
|
|
157
|
+
'internal error',
|
|
158
|
+
'internal server error',
|
|
159
|
+
'internal_server_error',
|
|
160
|
+
'service unavailable',
|
|
161
|
+
'bad gateway',
|
|
162
|
+
'server_error',
|
|
163
|
+
'overloaded',
|
|
164
|
+
'model is overloaded',
|
|
165
|
+
'try again later',
|
|
166
|
+
];
|
|
167
|
+
/** Substrings that mean the request never completed at the transport level. */
|
|
168
|
+
const NETWORK_PATTERNS = [
|
|
169
|
+
'econnreset',
|
|
170
|
+
'econnrefused',
|
|
171
|
+
'enotfound',
|
|
172
|
+
'epipe',
|
|
173
|
+
'eai_again',
|
|
174
|
+
'socket hang up',
|
|
175
|
+
'fetch failed',
|
|
176
|
+
'network error',
|
|
177
|
+
'connection error',
|
|
178
|
+
'terminated',
|
|
179
|
+
];
|
|
180
|
+
/** Substrings that mean a deadline elapsed. */
|
|
181
|
+
const TIMEOUT_PATTERNS = [
|
|
182
|
+
'etimedout',
|
|
183
|
+
'timed out',
|
|
184
|
+
'timeout',
|
|
185
|
+
'deadline exceeded',
|
|
186
|
+
'deadline_exceeded',
|
|
187
|
+
];
|
|
188
|
+
/** Substrings that mean the provider rejected the request itself. */
|
|
189
|
+
const INVALID_REQUEST_PATTERNS = [
|
|
190
|
+
'invalid_request_error',
|
|
191
|
+
'invalid request',
|
|
192
|
+
'bad request',
|
|
193
|
+
'invalid argument',
|
|
194
|
+
'invalid_argument',
|
|
195
|
+
];
|
|
196
|
+
/** Read a property off an unknown value without asserting anything about its shape. */
|
|
197
|
+
function field(source, key) {
|
|
198
|
+
if (!source || (typeof source !== 'object' && typeof source !== 'function'))
|
|
199
|
+
return undefined;
|
|
200
|
+
return source[key];
|
|
201
|
+
}
|
|
202
|
+
/** Whether `haystack` contains any of `patterns` (both compared lower-cased). */
|
|
203
|
+
function containsAny(haystack, patterns) {
|
|
204
|
+
return patterns.some((pattern) => haystack.includes(pattern));
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Every text an error carries that a classification may read: its message, its name, and the
|
|
208
|
+
* nested provider payloads SDKs hang off `error`, `cause`, `body` and `response.data`. Bounded to
|
|
209
|
+
* one nesting level per branch so a self-referential payload cannot spin.
|
|
210
|
+
*/
|
|
211
|
+
function errorText(error) {
|
|
212
|
+
const parts = [];
|
|
213
|
+
const push = (value) => {
|
|
214
|
+
if (typeof value === 'string')
|
|
215
|
+
parts.push(value);
|
|
216
|
+
else if (typeof value === 'number')
|
|
217
|
+
parts.push(String(value));
|
|
218
|
+
};
|
|
219
|
+
push(field(error, 'message'));
|
|
220
|
+
push(field(error, 'name'));
|
|
221
|
+
push(field(error, 'code'));
|
|
222
|
+
push(field(error, 'type'));
|
|
223
|
+
if (typeof error === 'string')
|
|
224
|
+
parts.push(error);
|
|
225
|
+
for (const key of ['error', 'cause', 'body', 'data', 'response']) {
|
|
226
|
+
const nested = field(error, key);
|
|
227
|
+
if (typeof nested === 'string') {
|
|
228
|
+
parts.push(nested);
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
push(field(nested, 'message'));
|
|
232
|
+
push(field(nested, 'type'));
|
|
233
|
+
push(field(nested, 'code'));
|
|
234
|
+
const inner = field(nested, 'error');
|
|
235
|
+
push(field(inner, 'message'));
|
|
236
|
+
push(field(inner, 'type'));
|
|
237
|
+
push(field(inner, 'code'));
|
|
238
|
+
}
|
|
239
|
+
// The separator is deliberately not a plain space: the prose patterns below are multi-word
|
|
240
|
+
// English, and joining two adjacent fragments with a space lets a pattern match ACROSS them —
|
|
241
|
+
// a fragment ending in "rate" beside one starting with "limit" would read as a rate limit.
|
|
242
|
+
// A newline cannot occur mid-pattern, so it breaks that adjacency without hiding anything.
|
|
243
|
+
return parts.join(' \n ').toLowerCase();
|
|
244
|
+
}
|
|
245
|
+
/** The HTTP status an SDK error carries, wherever it hangs it. Undefined when there is none. */
|
|
246
|
+
function httpStatus(error) {
|
|
247
|
+
for (const holder of [error, field(error, 'response'), field(error, 'error')]) {
|
|
248
|
+
for (const key of ['status', 'statusCode', 'code']) {
|
|
249
|
+
const value = field(holder, key);
|
|
250
|
+
if (typeof value === 'number' && value >= 100 && value < 600)
|
|
251
|
+
return value;
|
|
252
|
+
if (typeof value === 'string' && /^[1-5]\d{2}$/.test(value))
|
|
253
|
+
return Number(value);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
/** The `name` of an error, or `undefined` for anything that carries none. */
|
|
259
|
+
function errorName(error) {
|
|
260
|
+
const name = field(error, 'name');
|
|
261
|
+
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Whether a thrown value is a context overflow.
|
|
265
|
+
*
|
|
266
|
+
* The predicate is `ContextOverflowError.isInstance`, never `lc_error_code`: the code is set
|
|
267
|
+
* asymmetrically across providers (Anthropic stamps both the class and the code, OpenAI only the
|
|
268
|
+
* class), so keying on it silently misses most of where the typed class actually works. The
|
|
269
|
+
* substring fallback then covers the providers LangChain does not type at all, and the case where
|
|
270
|
+
* a reworded provider message drops the class.
|
|
271
|
+
*/
|
|
272
|
+
export function isContextOverflow(error) {
|
|
273
|
+
try {
|
|
274
|
+
if (ContextOverflowError.isInstance(error))
|
|
275
|
+
return true;
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
/* fail-soft: a dependency that stops exporting the predicate must not break classification */
|
|
279
|
+
}
|
|
280
|
+
if (errorName(error) === 'ContextOverflowError')
|
|
281
|
+
return true;
|
|
282
|
+
return containsAny(errorText(error), CONTEXT_OVERFLOW_PATTERNS);
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* The exception feeder: classify a thrown value into the taxonomy.
|
|
286
|
+
*
|
|
287
|
+
* Order matters. The typed and named cases are decided first, because a context overflow is also an
|
|
288
|
+
* HTTP 400 and an abort is also a `DOMException`; only once those are excluded does the status code
|
|
289
|
+
* and then the prose get a say. Never throws: an unclassifiable value is `unknown`, which is a
|
|
290
|
+
* recorded fact rather than a guess.
|
|
291
|
+
*/
|
|
292
|
+
export function classifyThrownTermination(error) {
|
|
293
|
+
try {
|
|
294
|
+
const name = errorName(error);
|
|
295
|
+
const text = errorText(error);
|
|
296
|
+
const status = httpStatus(error);
|
|
297
|
+
// Typed / named first — these are unambiguous and several of them also carry a status that
|
|
298
|
+
// would classify them wrongly.
|
|
299
|
+
if (isContextOverflow(error)) {
|
|
300
|
+
return { category: 'context_overflow', detail: name ?? 'ContextOverflowError' };
|
|
301
|
+
}
|
|
302
|
+
if (name === 'AbortError' || name === 'ModelAbortError' || name === 'APIUserAbortError') {
|
|
303
|
+
return { category: 'cancelled', detail: name };
|
|
304
|
+
}
|
|
305
|
+
if (name === 'GraphInterrupt') {
|
|
306
|
+
return { category: 'suspended', detail: name };
|
|
307
|
+
}
|
|
308
|
+
if (name === 'ToolException') {
|
|
309
|
+
return { category: 'tool_error', detail: name };
|
|
310
|
+
}
|
|
311
|
+
if (name === 'GraphRecursionError' || text.includes('recursion limit')) {
|
|
312
|
+
return { category: 'recursion_limit', detail: name ?? 'recursion limit' };
|
|
313
|
+
}
|
|
314
|
+
if (name === 'TimeoutError' || name === 'APITimeoutError') {
|
|
315
|
+
return { category: 'timeout', detail: name };
|
|
316
|
+
}
|
|
317
|
+
if (name === 'APIConnectionError') {
|
|
318
|
+
return { category: 'network_error', detail: name };
|
|
319
|
+
}
|
|
320
|
+
// Status codes next: a number the provider set is stronger evidence than prose we matched.
|
|
321
|
+
if (status === 429)
|
|
322
|
+
return { category: 'rate_limited', detail: '429' };
|
|
323
|
+
if (status === 401 || status === 403)
|
|
324
|
+
return { category: 'auth_failed', detail: String(status) };
|
|
325
|
+
if (status === 408 || status === 504)
|
|
326
|
+
return { category: 'timeout', detail: String(status) };
|
|
327
|
+
if (status !== undefined && status >= 500) {
|
|
328
|
+
return { category: 'provider_error', detail: String(status) };
|
|
329
|
+
}
|
|
330
|
+
// Prose last, and in the order that keeps a specific signal from being eaten by a generic one:
|
|
331
|
+
// "quota exceeded" is a rate limit before it is an invalid request, and an auth failure often
|
|
332
|
+
// arrives as a 400 whose body says `invalid_grant`.
|
|
333
|
+
if (containsAny(text, RATE_LIMIT_PATTERNS))
|
|
334
|
+
return { category: 'rate_limited' };
|
|
335
|
+
if (containsAny(text, AUTH_PATTERNS))
|
|
336
|
+
return { category: 'auth_failed' };
|
|
337
|
+
if (containsAny(text, TIMEOUT_PATTERNS))
|
|
338
|
+
return { category: 'timeout' };
|
|
339
|
+
if (containsAny(text, NETWORK_PATTERNS))
|
|
340
|
+
return { category: 'network_error' };
|
|
341
|
+
if (containsAny(text, PROVIDER_ERROR_PATTERNS))
|
|
342
|
+
return { category: 'provider_error' };
|
|
343
|
+
if (status === 400 || containsAny(text, INVALID_REQUEST_PATTERNS)) {
|
|
344
|
+
// `detail` is the raw token the classification was made from, so it states the status the
|
|
345
|
+
// response ACTUALLY had. A 402 or 409 whose prose matches the patterns reaches this branch
|
|
346
|
+
// too (it is past the 429/401/403/408/5xx arms), and stamping a flat '400' on one would put
|
|
347
|
+
// a false statement in the field that exists to record what was seen.
|
|
348
|
+
return {
|
|
349
|
+
category: 'invalid_request',
|
|
350
|
+
detail: status === undefined ? undefined : String(status),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
return { category: 'unknown', ...(name === undefined ? {} : { detail: name }) };
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
// Classification must never be the thing that breaks a run that was already failing.
|
|
357
|
+
return { category: 'unknown' };
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* The property a reason is carried on when it rides a thrown error.
|
|
362
|
+
*
|
|
363
|
+
* A run that ends by throwing crosses layers the runner does not own, and the message is not the
|
|
364
|
+
* carrier — that is the whole defect this taxonomy exists to fix. Attaching the value to the error
|
|
365
|
+
* lets any catcher upstream read the classification without re-deriving it from prose.
|
|
366
|
+
*/
|
|
367
|
+
const TERMINATION_REASON_KEY = 'gthTerminationReason';
|
|
368
|
+
/**
|
|
369
|
+
* Attach a reason to a thrown value and return it, so a `throw` site reads as one expression.
|
|
370
|
+
*
|
|
371
|
+
* Non-enumerable, so the reason never widens what an error serialises to (a logged or
|
|
372
|
+
* JSON-stringified error keeps exactly the shape it had), and first-write-wins so a re-throw
|
|
373
|
+
* through an outer wrapper cannot overwrite the inner, truer classification. Fail-soft: a frozen or
|
|
374
|
+
* primitive throw value is returned unchanged rather than turning a failure into a different one.
|
|
375
|
+
*/
|
|
376
|
+
export function attachTerminationReason(error, reason) {
|
|
377
|
+
try {
|
|
378
|
+
if (!error || (typeof error !== 'object' && typeof error !== 'function'))
|
|
379
|
+
return error;
|
|
380
|
+
if (field(error, TERMINATION_REASON_KEY) !== undefined)
|
|
381
|
+
return error;
|
|
382
|
+
Object.defineProperty(error, TERMINATION_REASON_KEY, {
|
|
383
|
+
value: reason,
|
|
384
|
+
enumerable: false,
|
|
385
|
+
writable: true,
|
|
386
|
+
configurable: true,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
/* fail-soft */
|
|
391
|
+
}
|
|
392
|
+
return error;
|
|
393
|
+
}
|
|
394
|
+
/** The reason attached to a thrown value, following one `cause` link. Undefined when none is. */
|
|
395
|
+
export function terminationReasonOf(error) {
|
|
396
|
+
const own = field(error, TERMINATION_REASON_KEY);
|
|
397
|
+
if (own && typeof own === 'object')
|
|
398
|
+
return own;
|
|
399
|
+
const cause = field(error, 'cause');
|
|
400
|
+
const inherited = field(cause, TERMINATION_REASON_KEY);
|
|
401
|
+
if (inherited && typeof inherited === 'object')
|
|
402
|
+
return inherited;
|
|
403
|
+
return undefined;
|
|
404
|
+
}
|
|
405
|
+
//# sourceMappingURL=terminationReason.js.map
|