@namzu/sdk 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts +12 -0
  3. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts.map +1 -0
  4. package/dist/compaction/__tests__/verifier-empty-reply.test.js +73 -0
  5. package/dist/compaction/__tests__/verifier-empty-reply.test.js.map +1 -0
  6. package/dist/compaction/verifier.d.ts.map +1 -1
  7. package/dist/compaction/verifier.js +10 -1
  8. package/dist/compaction/verifier.js.map +1 -1
  9. package/dist/config/__tests__/compaction-budget-schema.test.d.ts +18 -0
  10. package/dist/config/__tests__/compaction-budget-schema.test.d.ts.map +1 -0
  11. package/dist/config/__tests__/compaction-budget-schema.test.js +96 -0
  12. package/dist/config/__tests__/compaction-budget-schema.test.js.map +1 -0
  13. package/dist/config/runtime.d.ts.map +1 -1
  14. package/dist/config/runtime.js +20 -11
  15. package/dist/config/runtime.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +2 -1
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +3 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/errors.test.d.ts +2 -0
  21. package/dist/provider/__tests__/errors.test.d.ts.map +1 -0
  22. package/dist/provider/__tests__/errors.test.js +61 -0
  23. package/dist/provider/__tests__/errors.test.js.map +1 -0
  24. package/dist/provider/errors.d.ts +108 -0
  25. package/dist/provider/errors.d.ts.map +1 -0
  26. package/dist/provider/errors.js +311 -0
  27. package/dist/provider/errors.js.map +1 -0
  28. package/dist/provider/index.d.ts +2 -0
  29. package/dist/provider/index.d.ts.map +1 -1
  30. package/dist/provider/index.js +1 -0
  31. package/dist/provider/index.js.map +1 -1
  32. package/dist/public-runtime.d.ts +11 -11
  33. package/dist/public-runtime.d.ts.map +1 -1
  34. package/dist/public-runtime.js +11 -11
  35. package/dist/public-runtime.js.map +1 -1
  36. package/dist/registry/tool/execute.d.ts.map +1 -1
  37. package/dist/registry/tool/execute.js +5 -2
  38. package/dist/registry/tool/execute.js.map +1 -1
  39. package/dist/registry/tool/execute.test.js +11 -0
  40. package/dist/registry/tool/execute.test.js.map +1 -1
  41. package/dist/runtime/query/__tests__/stream-recovery.test.js +54 -1
  42. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  43. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts +32 -0
  44. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts.map +1 -0
  45. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js +152 -0
  46. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js.map +1 -0
  47. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  48. package/dist/runtime/query/iteration/phases/compaction.js +52 -12
  49. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  50. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  51. package/dist/runtime/query/iteration/stream-turn.js +11 -5
  52. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  53. package/dist/runtime/query/result.d.ts.map +1 -1
  54. package/dist/runtime/query/result.js +11 -1
  55. package/dist/runtime/query/result.js.map +1 -1
  56. package/dist/tools/builtins/__tests__/edit.test.js +54 -0
  57. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  58. package/dist/tools/builtins/edit.d.ts +1 -1
  59. package/dist/tools/builtins/edit.d.ts.map +1 -1
  60. package/dist/tools/builtins/edit.js +11 -13
  61. package/dist/tools/builtins/edit.js.map +1 -1
  62. package/dist/tools/coordinator/__tests__/task-list.test.js +14 -0
  63. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  64. package/dist/tools/coordinator/agent.d.ts +6 -8
  65. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  66. package/dist/tools/coordinator/agent.js.map +1 -1
  67. package/dist/tools/coordinator/index.d.ts.map +1 -1
  68. package/dist/tools/coordinator/index.js +11 -32
  69. package/dist/tools/coordinator/index.js.map +1 -1
  70. package/dist/tools/defineTool.d.ts +1 -0
  71. package/dist/tools/defineTool.d.ts.map +1 -1
  72. package/dist/tools/defineTool.js +1 -0
  73. package/dist/tools/defineTool.js.map +1 -1
  74. package/dist/types/provider/error.d.ts +22 -0
  75. package/dist/types/provider/error.d.ts.map +1 -0
  76. package/dist/types/provider/error.js +2 -0
  77. package/dist/types/provider/error.js.map +1 -0
  78. package/dist/types/provider/index.d.ts +1 -0
  79. package/dist/types/provider/index.d.ts.map +1 -1
  80. package/dist/types/run/entity.d.ts +2 -0
  81. package/dist/types/run/entity.d.ts.map +1 -1
  82. package/dist/types/run/events.d.ts +2 -0
  83. package/dist/types/run/events.d.ts.map +1 -1
  84. package/dist/types/run/events.js.map +1 -1
  85. package/dist/types/tool/index.d.ts +6 -0
  86. package/dist/types/tool/index.d.ts.map +1 -1
  87. package/package.json +1 -1
  88. package/src/compaction/__tests__/verifier-empty-reply.test.ts +96 -0
  89. package/src/compaction/verifier.ts +10 -1
  90. package/src/config/__tests__/compaction-budget-schema.test.ts +114 -0
  91. package/src/config/runtime.ts +20 -11
  92. package/src/manager/run/persistence.ts +3 -1
  93. package/src/provider/__tests__/errors.test.ts +85 -0
  94. package/src/provider/errors.ts +344 -0
  95. package/src/provider/index.ts +15 -0
  96. package/src/public-runtime.ts +53 -10
  97. package/src/registry/tool/execute.test.ts +14 -0
  98. package/src/registry/tool/execute.ts +5 -2
  99. package/src/runtime/query/__tests__/stream-recovery.test.ts +64 -1
  100. package/src/runtime/query/iteration/phases/compaction-safe-cut.test.ts +186 -0
  101. package/src/runtime/query/iteration/phases/compaction.ts +54 -12
  102. package/src/runtime/query/iteration/stream-turn.ts +11 -6
  103. package/src/runtime/query/result.ts +11 -1
  104. package/src/tools/builtins/__tests__/edit.test.ts +76 -0
  105. package/src/tools/builtins/edit.ts +12 -12
  106. package/src/tools/coordinator/__tests__/task-list.test.ts +16 -0
  107. package/src/tools/coordinator/agent.ts +6 -8
  108. package/src/tools/coordinator/index.ts +11 -34
  109. package/src/tools/defineTool.ts +2 -0
  110. package/src/types/provider/error.ts +29 -0
  111. package/src/types/provider/index.ts +5 -0
  112. package/src/types/run/entity.ts +2 -0
  113. package/src/types/run/events.ts +7 -1
  114. package/src/types/tool/index.ts +6 -0
@@ -5,6 +5,7 @@ import { RunDiskStore } from '../../store/run/disk.js'
5
5
  import { type CostInfo, type TokenUsage, accumulateTokenUsage } from '../../types/common/index.js'
6
6
  import type { RunId, SessionId, TenantId } from '../../types/ids/index.js'
7
7
  import type { Message } from '../../types/message/index.js'
8
+ import type { ProviderErrorInfo } from '../../types/provider/index.js'
8
9
  import type { CheckpointRunScope, CheckpointStore } from '../../types/run/checkpoint-store.js'
9
10
  import type { EmergencySaveData } from '../../types/run/emergency.js'
10
11
  import type { Run, RunPersistenceConfig, StopReason } from '../../types/run/index.js'
@@ -164,10 +165,11 @@ export class RunPersistence {
164
165
  this.resolveResult()
165
166
  }
166
167
 
167
- markFailed(error: string): void {
168
+ markFailed(error: string, providerError?: ProviderErrorInfo): void {
168
169
  this.run.status = 'failed'
169
170
  this.run.stopReason = 'error'
170
171
  this.run.lastError = error
172
+ if (providerError) this.run.lastProviderError = providerError
171
173
  this.run.endedAt = Date.now()
172
174
  }
173
175
 
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import {
4
+ ProviderRequestError,
5
+ bodySaysContextOverflow,
6
+ classifyProviderHttpStatus,
7
+ isCallerAbortError,
8
+ isProviderRequestError,
9
+ parseRetryAfterMs,
10
+ providerVendorError,
11
+ } from '../errors.js'
12
+
13
+ describe('provider error taxonomy', () => {
14
+ it('classifies only context-specific limit messages as context overflow', () => {
15
+ expect(bodySaysContextOverflow('input token count exceeds the maximum for this model')).toBe(
16
+ true,
17
+ )
18
+ expect(bodySaysContextOverflow('tool count exceeds the maximum allowed')).toBe(false)
19
+ expect(bodySaysContextOverflow('reduce the tool list length')).toBe(false)
20
+ })
21
+
22
+ it('does not treat a generic HTTP 413 as model context overflow', () => {
23
+ expect(classifyProviderHttpStatus(413, 'request body too large at reverse proxy')).toBe(
24
+ 'bad_request',
25
+ )
26
+ expect(classifyProviderHttpStatus(413, 'prompt is too long for requested model')).toBe(
27
+ 'context_overflow',
28
+ )
29
+ })
30
+
31
+ it('parses both Retry-After forms and rejects stale or invalid values', () => {
32
+ expect(parseRetryAfterMs('2', 0)).toBe(2000)
33
+ expect(parseRetryAfterMs('Thu, 01 Jan 1970 00:00:05 GMT', 1000)).toBe(4000)
34
+ expect(parseRetryAfterMs('Thu, 01 Jan 1970 00:00:01 GMT', 1000)).toBeUndefined()
35
+ expect(parseRetryAfterMs('later', 0)).toBeUndefined()
36
+ })
37
+
38
+ it('drops the vendor object and its non-enumerable cause entirely', () => {
39
+ const secret = 'sk-FAKE-DO-NOT-LOG'
40
+ const vendor = new Error(`invalid api key ${secret}`, {
41
+ cause: new Error(`response body ${secret}`),
42
+ })
43
+ Object.assign(vendor, { status: 401 })
44
+
45
+ const classified = providerVendorError({
46
+ providerId: 'test-provider',
47
+ error: vendor,
48
+ })
49
+
50
+ expect(classified).toMatchObject({
51
+ name: 'ProviderRequestError',
52
+ kind: 'auth',
53
+ status: 401,
54
+ providerId: 'test-provider',
55
+ })
56
+ expect(classified.message).not.toContain(secret)
57
+ expect('cause' in classified).toBe(false)
58
+ })
59
+
60
+ it('recognizes a caller abort only when that signal actually aborted', () => {
61
+ const controller = new AbortController()
62
+ const sdkAbort = Object.assign(new Error('request aborted'), {
63
+ name: 'APIUserAbortError',
64
+ })
65
+
66
+ expect(isCallerAbortError(sdkAbort, controller.signal)).toBe(false)
67
+ controller.abort(new Error('user stopped'))
68
+ expect(isCallerAbortError(sdkAbort, controller.signal)).toBe(true)
69
+ expect(isCallerAbortError(controller.signal.reason, controller.signal)).toBe(true)
70
+ })
71
+
72
+ it('requires the complete structural contract when SDK copies differ', () => {
73
+ const classified = new ProviderRequestError({
74
+ kind: 'server',
75
+ providerId: 'test-provider',
76
+ })
77
+ expect(isProviderRequestError(classified)).toBe(true)
78
+
79
+ const impostor = Object.assign(new Error('not classified'), {
80
+ name: 'ProviderRequestError',
81
+ kind: 'anything',
82
+ })
83
+ expect(isProviderRequestError(impostor)).toBe(false)
84
+ })
85
+ })
@@ -0,0 +1,344 @@
1
+ /**
2
+ * One classified error for every provider failure.
3
+ *
4
+ * Before this, a failure from any of the seven drivers reached the caller as an
5
+ * opaque string, so nothing downstream could decide whether it was worth
6
+ * retrying, whether it was the caller's fault, or whether the context window had
7
+ * simply run out. Two drivers built that string by interpolating the response
8
+ * body; three more inherited a vendor SDK error whose message IS the response
9
+ * body. Either way, a credential the upstream echoed back landed in a message
10
+ * that gets logged.
11
+ *
12
+ * So the contract here is deliberately narrow:
13
+ *
14
+ * - the message is built from the STATUS LINE and the classified `kind`. The
15
+ * response body is read to classify and then dropped. It is never
16
+ * interpolated, never re-thrown, and never attached as `cause` — a `cause`
17
+ * survives every logger that serializes an error chain, which defeats the
18
+ * point.
19
+ * - `retryAfterMs` is DATA. Nothing in this module sleeps, backs off or
20
+ * retries. A retry loop inside a driver burns the run's wall clock and hides
21
+ * the failure from the layer that should decide.
22
+ */
23
+
24
+ import type { ProviderErrorKind, ProviderRequestErrorInit } from '../types/provider/error.js'
25
+ export type {
26
+ ProviderErrorInfo,
27
+ ProviderErrorKind,
28
+ ProviderRequestErrorInit,
29
+ } from '../types/provider/error.js'
30
+
31
+ const PROVIDER_ERROR_KINDS: readonly ProviderErrorKind[] = [
32
+ 'throttle',
33
+ 'network',
34
+ 'auth',
35
+ 'context_overflow',
36
+ 'bad_request',
37
+ 'server',
38
+ ]
39
+
40
+ /**
41
+ * A provider request that failed, classified.
42
+ *
43
+ * `name` is set explicitly rather than inherited, because the classifier is
44
+ * matched structurally across a package boundary (a driver in one package throws
45
+ * it; the runtime in another reads it) and `instanceof` is unreliable when two
46
+ * copies of the SDK end up in one process.
47
+ */
48
+ export class ProviderRequestError extends Error {
49
+ public readonly kind: ProviderErrorKind
50
+ public readonly providerId: string
51
+ public readonly status?: number
52
+ public readonly retryAfterMs?: number
53
+
54
+ constructor(init: ProviderRequestErrorInit) {
55
+ super(buildProviderErrorMessage(init))
56
+ this.name = 'ProviderRequestError'
57
+ this.kind = init.kind
58
+ this.providerId = init.providerId
59
+ if (init.status !== undefined) this.status = init.status
60
+ if (init.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs
61
+ }
62
+ }
63
+
64
+ /** Is this a classified provider failure, whichever SDK copy threw it? */
65
+ export function isProviderRequestError(err: unknown): err is ProviderRequestError {
66
+ return (
67
+ err instanceof Error &&
68
+ err.name === 'ProviderRequestError' &&
69
+ typeof (err as { providerId?: unknown }).providerId === 'string' &&
70
+ PROVIDER_ERROR_KINDS.includes((err as { kind?: ProviderErrorKind }).kind as ProviderErrorKind)
71
+ )
72
+ }
73
+
74
+ /**
75
+ * Did the caller's own AbortSignal terminate this request?
76
+ *
77
+ * Provider SDKs do not agree on the object they reject with: some preserve
78
+ * `signal.reason`, while others replace it with `AbortError` or
79
+ * `APIUserAbortError`. Reclassifying any of those as a network failure breaks
80
+ * the runtime's Stop/cancel path, which depends on the abort escaping the
81
+ * provider boundary.
82
+ *
83
+ * The signal must itself be aborted. That condition distinguishes a caller Stop
84
+ * from an SDK timeout which may also use an `AbortError`-shaped rejection.
85
+ */
86
+ export function isCallerAbortError(error: unknown, signal?: AbortSignal): boolean {
87
+ if (!signal?.aborted) return false
88
+ if (error === signal.reason) return true
89
+ if (!(error instanceof Error)) return false
90
+ return error.name === 'AbortError' || error.name === 'APIUserAbortError'
91
+ }
92
+
93
+ const KIND_SENTENCE: Record<ProviderErrorKind, string> = {
94
+ throttle: 'rate limited by the provider',
95
+ network: 'could not reach the provider',
96
+ auth: 'the provider rejected the request credentials',
97
+ context_overflow: 'the request exceeded the model context window',
98
+ bad_request: 'the provider rejected the request as invalid',
99
+ server: 'the provider failed to complete the request',
100
+ }
101
+
102
+ function buildProviderErrorMessage(init: ProviderRequestErrorInit): string {
103
+ const status = init.status !== undefined ? ` (HTTP ${init.status})` : ''
104
+ const detail = init.detail ? `: ${init.detail}` : ''
105
+ return `${init.providerId}${status} — ${KIND_SENTENCE[init.kind]}${detail}`
106
+ }
107
+
108
+ /**
109
+ * Body fragments that mean "the request did not fit", across the vendors we
110
+ * drive. Matched case-insensitively against the raw body, which is then
111
+ * discarded.
112
+ *
113
+ * A 400 is otherwise `bad_request`: an overflow is the one 400 a caller can act
114
+ * on automatically (compact and retry), and mistaking a genuine schema error for
115
+ * an overflow would send the run into a pointless compaction loop.
116
+ */
117
+ const OVERFLOW_BODY_PATTERNS: readonly RegExp[] = [
118
+ /prompt is too long/i,
119
+ /context[_ ]length[_ ]exceeded/i,
120
+ /maximum context length/i,
121
+ /too long for requested model/i,
122
+ /input is too long/i,
123
+ /(?:input|prompt|context).{0,80}exceeds the maximum/i,
124
+ /reduce the (?:input|prompt|context) length/i,
125
+ // Ollama phrases it the other way round — "input length exceeds context
126
+ // length" — so a pattern anchored on "exceeded" misses it entirely.
127
+ /exceeds?\s+(?:the\s+)?(?:model'?s?\s+)?context\s+(?:length|window|size)/i,
128
+ /context\s+(?:length|window|size)\s+exceed/i,
129
+ ]
130
+
131
+ /** Does this response body say the request did not fit the window? */
132
+ export function bodySaysContextOverflow(body: string | undefined | null): boolean {
133
+ if (!body) return false
134
+ return OVERFLOW_BODY_PATTERNS.some((re) => re.test(body))
135
+ }
136
+
137
+ /**
138
+ * `Retry-After` in milliseconds. The header is either delta-seconds or an
139
+ * HTTP-date; both are specified, and vendors use both.
140
+ *
141
+ * Returns undefined for anything unparseable or for a date already in the past —
142
+ * a negative delay is worse than none, because a caller would treat it as
143
+ * "retry immediately" against a provider that just asked it to wait.
144
+ */
145
+ export function parseRetryAfterMs(
146
+ headerValue: string | null | undefined,
147
+ now: number = Date.now(),
148
+ ): number | undefined {
149
+ if (!headerValue) return undefined
150
+ const trimmed = headerValue.trim()
151
+ if (trimmed === '') return undefined
152
+
153
+ if (/^\d+(\.\d+)?$/.test(trimmed)) {
154
+ const seconds = Number(trimmed)
155
+ if (!Number.isFinite(seconds) || seconds < 0) return undefined
156
+ return Math.round(seconds * 1000)
157
+ }
158
+
159
+ const at = Date.parse(trimmed)
160
+ if (Number.isNaN(at)) return undefined
161
+ const delta = at - now
162
+ return delta > 0 ? delta : undefined
163
+ }
164
+
165
+ /**
166
+ * Classify an HTTP failure. `body` is used ONLY to separate a context overflow
167
+ * from an ordinary bad request, and is not retained.
168
+ */
169
+ export function classifyProviderHttpStatus(
170
+ status: number,
171
+ body?: string | null,
172
+ ): ProviderErrorKind {
173
+ if (status === 401 || status === 403) return 'auth'
174
+ if (status === 429) return 'throttle'
175
+ if (status === 408 || status === 425) return 'network'
176
+ if (status >= 500) return 'server'
177
+ if (status === 413) {
178
+ return bodySaysContextOverflow(body) ? 'context_overflow' : 'bad_request'
179
+ }
180
+ if (status >= 400) {
181
+ return bodySaysContextOverflow(body) ? 'context_overflow' : 'bad_request'
182
+ }
183
+ // A non-failure status reaching here is a caller bug, not a provider one.
184
+ return 'server'
185
+ }
186
+
187
+ /**
188
+ * Vendor error TYPES, as the vendors name them in their own payloads. This is a
189
+ * defined vocabulary, not prose, which is what makes it safe to classify on:
190
+ * Anthropic sends `{"error":{"type":"overloaded_error"}}` and OpenAI sends
191
+ * `{"error":{"code":"context_length_exceeded"}}`.
192
+ *
193
+ * It matters for MID-STREAM failures above all. Those arrive after a 200, so
194
+ * there is no status to classify from, and without this an upstream overload
195
+ * would be filed as `network` — "we could not reach the provider" — when the
196
+ * provider answered and then gave up. The distinction is the whole point of the
197
+ * taxonomy: one is worth retrying elsewhere, the other is worth retrying here.
198
+ */
199
+ const VENDOR_TYPE_KINDS: ReadonlyArray<readonly [RegExp, ProviderErrorKind]> = [
200
+ [/overloaded_error|api_error|service_unavailable/i, 'server'],
201
+ [/rate_limit_error|rate_limit_exceeded/i, 'throttle'],
202
+ [/authentication_error|permission_error|invalid_api_key|unauthorized/i, 'auth'],
203
+ [/timeout_error|connection_error/i, 'network'],
204
+ ]
205
+
206
+ /** The kind a vendor's own error-type vocabulary implies, if it says one. */
207
+ function vendorTypeKind(message: string): ProviderErrorKind | undefined {
208
+ for (const [re, kind] of VENDOR_TYPE_KINDS) {
209
+ if (re.test(message)) return kind
210
+ }
211
+ return undefined
212
+ }
213
+
214
+ /**
215
+ * Status code off a vendor SDK's own error object, whatever it calls the field.
216
+ * Anthropic and OpenAI use `status`; the ollama client uses `status_code`; AWS
217
+ * puts it under `$metadata.httpStatusCode`.
218
+ */
219
+ function vendorErrorStatus(err: unknown): number | undefined {
220
+ if (typeof err !== 'object' || err === null) return undefined
221
+ const e = err as {
222
+ status?: unknown
223
+ statusCode?: unknown
224
+ status_code?: unknown
225
+ $metadata?: { httpStatusCode?: unknown }
226
+ }
227
+ for (const candidate of [e.status, e.statusCode, e.status_code, e.$metadata?.httpStatusCode]) {
228
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate
229
+ }
230
+ return undefined
231
+ }
232
+
233
+ /**
234
+ * `retry-after` off a vendor SDK error's captured response headers. The
235
+ * Anthropic and OpenAI clients both attach them (as a `Headers` instance or a
236
+ * plain object depending on version); the ollama client throws them away, which
237
+ * is why `retryAfterMs` is genuinely unavailable on that driver.
238
+ */
239
+ function vendorRetryAfter(err: unknown): string | undefined {
240
+ if (typeof err !== 'object' || err === null) return undefined
241
+ // AWS keeps the response one level down, on `$response`; the Anthropic and
242
+ // OpenAI clients attach `headers` directly.
243
+ const headers =
244
+ (err as { headers?: unknown }).headers ??
245
+ (err as { $response?: { headers?: unknown } }).$response?.headers
246
+ if (!headers) return undefined
247
+ if (typeof (headers as Headers).get === 'function') {
248
+ return (headers as Headers).get('retry-after') ?? undefined
249
+ }
250
+ const record = headers as Record<string, unknown>
251
+ for (const key of ['retry-after', 'Retry-After', 'retryAfter']) {
252
+ const value = record[key]
253
+ if (typeof value === 'string') return value
254
+ }
255
+ return undefined
256
+ }
257
+
258
+ /**
259
+ * Classify an error thrown by a vendor SDK and replace it.
260
+ *
261
+ * This exists because wrapping our OWN `!response.ok` throws is not enough. The
262
+ * Anthropic, OpenAI and ollama clients each build their error message FROM the
263
+ * response body, so a credential the upstream echoed back is already inside
264
+ * `err.message` before our code sees it. Proven with a planted fake token on all
265
+ * three.
266
+ *
267
+ * So the vendor error is read for its status and scanned for an overflow
268
+ * signature, and then **dropped entirely** — not re-thrown, not wrapped, and not
269
+ * attached as `cause`. A `cause` is exactly what a structured logger walks, so
270
+ * keeping one for debuggability would reintroduce the leak it is meant to close.
271
+ *
272
+ * `name` is checked too, because AWS models its failures as distinct classes
273
+ * (`ThrottlingException`, `ValidationException`, `AccessDeniedException`) rather
274
+ * than as status codes.
275
+ */
276
+ export function providerVendorError(input: {
277
+ readonly providerId: string
278
+ readonly error: unknown
279
+ readonly retryAfter?: string | null
280
+ readonly now?: number
281
+ }): ProviderRequestError {
282
+ const { error } = input
283
+ const status = vendorErrorStatus(error)
284
+ const name = error instanceof Error ? error.name : ''
285
+ const message = error instanceof Error ? error.message : ''
286
+
287
+ let kind: ProviderErrorKind
288
+ if (/ThrottlingException|TooManyRequests|ServiceQuotaExceeded/i.test(name)) {
289
+ kind = 'throttle'
290
+ } else if (/AccessDenied|Unauthorized|Forbidden|Authentication/i.test(name)) {
291
+ kind = 'auth'
292
+ } else if (/ValidationException/i.test(name)) {
293
+ kind = bodySaysContextOverflow(message) ? 'context_overflow' : 'bad_request'
294
+ } else if (
295
+ /ServiceUnavailable|InternalServer|ModelTimeout|ModelStreamError|ModelError|ModelNotReady/i.test(
296
+ name,
297
+ )
298
+ ) {
299
+ kind = 'server'
300
+ } else if (status !== undefined) {
301
+ kind = classifyProviderHttpStatus(status, message)
302
+ } else if (bodySaysContextOverflow(message)) {
303
+ kind = 'context_overflow'
304
+ } else {
305
+ // No status: a mid-stream failure, or a transport error. The vendor's own
306
+ // error-type vocabulary is the only signal left; absent that, the request
307
+ // genuinely did not get an answer.
308
+ kind = vendorTypeKind(message) ?? 'network'
309
+ }
310
+
311
+ const retryAfterMs = parseRetryAfterMs(
312
+ input.retryAfter ?? vendorRetryAfter(error),
313
+ input.now ?? Date.now(),
314
+ )
315
+ return new ProviderRequestError({
316
+ kind,
317
+ providerId: input.providerId,
318
+ ...(status !== undefined ? { status } : {}),
319
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
320
+ })
321
+ }
322
+
323
+ /**
324
+ * Build a classified error from a failed HTTP response.
325
+ *
326
+ * Callers pass the body they already read for classification; this function
327
+ * does not return it, store it, or put it in the message.
328
+ */
329
+ export function providerHttpError(input: {
330
+ readonly providerId: string
331
+ readonly status: number
332
+ readonly body?: string | null
333
+ readonly retryAfter?: string | null
334
+ readonly now?: number
335
+ }): ProviderRequestError {
336
+ const kind = classifyProviderHttpStatus(input.status, input.body)
337
+ const retryAfterMs = parseRetryAfterMs(input.retryAfter, input.now ?? Date.now())
338
+ return new ProviderRequestError({
339
+ kind,
340
+ providerId: input.providerId,
341
+ status: input.status,
342
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
343
+ })
344
+ }
@@ -1,3 +1,18 @@
1
+ export {
2
+ ProviderRequestError,
3
+ isCallerAbortError,
4
+ isProviderRequestError,
5
+ classifyProviderHttpStatus,
6
+ bodySaysContextOverflow,
7
+ parseRetryAfterMs,
8
+ providerHttpError,
9
+ providerVendorError,
10
+ } from './errors.js'
11
+ export type {
12
+ ProviderErrorInfo,
13
+ ProviderErrorKind,
14
+ ProviderRequestErrorInit,
15
+ } from './errors.js'
1
16
  export {
2
17
  ProviderRegistry,
3
18
  UnknownProviderError,
@@ -47,11 +47,19 @@ export * from './utils/id.js'
47
47
 
48
48
  // ─── utility helpers ─────────────────────────────────────────────────────
49
49
 
50
- export { accumulateCost, calculateCost, formatCost, ZERO_COST } from './utils/cost.js'
50
+ export {
51
+ accumulateCost,
52
+ calculateCost,
53
+ formatCost,
54
+ ZERO_COST,
55
+ } from './utils/cost.js'
51
56
  export { toErrorMessage } from './utils/error.js'
52
57
  export { configureLogger, getRootLogger, Logger } from './utils/logger.js'
53
58
  export { buildToolResultHashes, hashToolResult } from './utils/hash.js'
54
- export { compressShellOutput, compressShellOutputFull } from './utils/shell-compress.js'
59
+ export {
60
+ compressShellOutput,
61
+ compressShellOutputFull,
62
+ } from './utils/shell-compress.js'
55
63
  export { createChildAbortController } from './utils/abort.js'
56
64
  export { memoizeAsync } from './utils/memoize.js'
57
65
  export { extractFinalResponse } from './utils/conversation.js'
@@ -61,7 +69,10 @@ export { extractFinalResponse } from './utils/conversation.js'
61
69
  export { resolveTaskModel } from './router/task-router.js'
62
70
  export { drainQuery, query } from './runtime/query/index.js'
63
71
  export { ContextCache } from './runtime/query/context-cache.js'
64
- export { CheckpointManager, projectEmergencyToCheckpoint } from './runtime/query/checkpoint.js'
72
+ export {
73
+ CheckpointManager,
74
+ projectEmergencyToCheckpoint,
75
+ } from './runtime/query/checkpoint.js'
65
76
  export { prepareReplayState } from './runtime/query/replay/prepare.js'
66
77
  export { listCheckpoints } from './runtime/query/replay/list.js'
67
78
  export { DecisionParser, FallbackResolver } from './runtime/decision/index.js'
@@ -73,8 +84,17 @@ export {
73
84
 
74
85
  // ─── personas, skills, advisory ──────────────────────────────────────────
75
86
 
76
- export { assembleSystemPrompt, mergePersonas, withSessionContext } from './persona/index.js'
77
- export { discoverSkills, loadSkill, resolveSkillChain, SkillRegistry } from './skills/index.js'
87
+ export {
88
+ assembleSystemPrompt,
89
+ mergePersonas,
90
+ withSessionContext,
91
+ } from './persona/index.js'
92
+ export {
93
+ discoverSkills,
94
+ loadSkill,
95
+ resolveSkillChain,
96
+ SkillRegistry,
97
+ } from './skills/index.js'
78
98
  export {
79
99
  AdvisorRegistry,
80
100
  AdvisoryContext,
@@ -144,19 +164,30 @@ export { LocalTaskGateway } from './gateway/local.js'
144
164
  // ─── providers, sandbox, vault ───────────────────────────────────────────
145
165
 
146
166
  export {
167
+ bodySaysContextOverflow,
168
+ classifyProviderHttpStatus,
147
169
  DuplicateProviderError,
170
+ isCallerAbortError,
171
+ isProviderRequestError,
148
172
  LazyProviderLoadError,
149
173
  LazyProviderSyncCreateError,
150
174
  MOCK_CAPABILITIES,
151
175
  MockLLMProvider,
176
+ parseRetryAfterMs,
152
177
  PERMISSIVE_PROVIDER_CAPABILITIES,
178
+ providerHttpError,
179
+ providerVendorError,
153
180
  ProviderRegistry,
181
+ ProviderRequestError,
154
182
  registerMock,
155
183
  resolveProviderCapabilities,
156
184
  UnknownProviderError,
157
185
  } from './provider/index.js'
158
186
 
159
- export { LocalSandboxProvider, SandboxProviderFactory } from './sandbox/index.js'
187
+ export {
188
+ LocalSandboxProvider,
189
+ SandboxProviderFactory,
190
+ } from './sandbox/index.js'
160
191
 
161
192
  export { InMemoryCredentialVault } from './vault/index.js'
162
193
 
@@ -222,7 +253,10 @@ export {
222
253
  runToA2ATask,
223
254
  } from './bridge/a2a/index.js'
224
255
 
225
- export { mapRunToStreamEvent, mapSessionToStreamEvent } from './bridge/sse/index.js'
256
+ export {
257
+ mapRunToStreamEvent,
258
+ mapSessionToStreamEvent,
259
+ } from './bridge/sse/index.js'
226
260
 
227
261
  // ─── bus, verification ───────────────────────────────────────────────────
228
262
 
@@ -355,10 +389,19 @@ export {
355
389
  // ─── runtime helpers colocated with shapes under `types/` (§1.5) ─────────
356
390
 
357
391
  export { A2AProtocolError } from './types/a2a/index.js'
358
- export { isTerminalActivityStatus, resolveActivityTracking } from './types/activity/index.js'
392
+ export {
393
+ isTerminalActivityStatus,
394
+ resolveActivityTracking,
395
+ } from './types/activity/index.js'
359
396
  export { isTerminalAgentTaskState } from './types/agent/task.js'
360
- export { accumulateTokenUsage, isTerminalStatus } from './types/common/index.js'
361
- export { assertComputerUseActionType, assertDisplayServer } from './types/computer-use/index.js'
397
+ export {
398
+ accumulateTokenUsage,
399
+ isTerminalStatus,
400
+ } from './types/common/index.js'
401
+ export {
402
+ assertComputerUseActionType,
403
+ assertDisplayServer,
404
+ } from './types/computer-use/index.js'
362
405
  export { isConnectorActive } from './types/connector/core.js'
363
406
  export { CONNECTOR_SCOPE_ORDER } from './types/connector/scope.js'
364
407
  export { RoutingResponseSchema } from './types/decision/index.js'
@@ -433,6 +433,20 @@ describe('ToolRegistry — execute', () => {
433
433
  expect(result.error).toContain('Expected string, received number')
434
434
  })
435
435
 
436
+ it('appends a tool-specific recovery hint to validation failures', async () => {
437
+ const r = new ToolRegistry()
438
+ r.register(
439
+ makeTool('strict', {
440
+ inputSchema: z.object({ required: z.string() }),
441
+ validationErrorHint: 'Retry with {"required":"value"}.',
442
+ }),
443
+ )
444
+ const result = await r.execute('strict', { required: 123 }, makeContext())
445
+ expect(result.success).toBe(false)
446
+ expect(result.error).toContain('Required: required: string.')
447
+ expect(result.error).toContain('Retry with {"required":"value"}.')
448
+ })
449
+
436
450
  it('empty-args validation lists required params with descriptions', async () => {
437
451
  const r = new ToolRegistry()
438
452
  r.register(
@@ -370,10 +370,13 @@ Executable tool names, descriptions, and JSON input schemas are attached through
370
370
  Object.keys(rawInput as Record<string, unknown>).length === 0)
371
371
 
372
372
  const requiredHint = describeRequiredInput(tool.inputSchema)
373
+ const recoveryHint = tool.validationErrorHint?.trim()
374
+ ? ` ${tool.validationErrorHint.trim()}`
375
+ : ''
373
376
 
374
377
  const enrichedMessage = isEmptyInput
375
- ? `Tool "${toolName}" was called with no arguments. ${requiredHint} Retry the call with the required parameters populated.`
376
- : `Validation failed for "${toolName}": ${errorMessage}. ${requiredHint}`
378
+ ? `Tool "${toolName}" was called with no arguments. ${requiredHint}${recoveryHint} Retry the call with the required parameters populated.`
379
+ : `Validation failed for "${toolName}": ${errorMessage}. ${requiredHint}${recoveryHint}`
377
380
 
378
381
  this.log.error(`Tool input validation failed: ${toolName}`, {
379
382
  errors: errorMessage,