@navels/neal 0.2.0 → 0.3.1

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/README.md +206 -209
  2. package/SECURITY.md +16 -19
  3. package/dist/neal/adjudicator/{blocked-adjudicator.js → consultant.js} +21 -21
  4. package/dist/neal/adjudicator/execute.js +33 -33
  5. package/dist/neal/adjudicator/final-completion.js +2 -31
  6. package/dist/neal/adjudicator/planning.js +230 -38
  7. package/dist/neal/agents/prompts.js +6 -17
  8. package/dist/neal/agents/rounds.js +43 -73
  9. package/dist/neal/agents/schemas.js +47 -13
  10. package/dist/neal/blocked-guidance.js +16 -9
  11. package/dist/neal/cli.js +2 -2
  12. package/dist/neal/commands/check.js +2 -2
  13. package/dist/neal/commands/compat.js +100 -36
  14. package/dist/neal/commands/interactive-activity.js +2 -2
  15. package/dist/neal/commands/recovery-guidance.js +20 -8
  16. package/dist/neal/commands/runtime.js +4 -2
  17. package/dist/neal/config.js +16 -7
  18. package/dist/neal/context/inline-review-context.js +13 -28
  19. package/dist/neal/context/reviewer-context.js +19 -14
  20. package/dist/neal/eval/reviewer-eval-manifest.js +86 -0
  21. package/dist/neal/eval/reviewer-eval.js +155 -0
  22. package/dist/neal/interactive-controls.js +5 -0
  23. package/dist/neal/orchestrator/completion.js +49 -2
  24. package/dist/neal/orchestrator/failures.js +7 -0
  25. package/dist/neal/orchestrator/notifications.js +9 -9
  26. package/dist/neal/orchestrator/phases/planning.js +117 -80
  27. package/dist/neal/orchestrator/phases/recovery.js +151 -115
  28. package/dist/neal/orchestrator/phases/review.js +28 -1
  29. package/dist/neal/orchestrator/transitions.js +7 -1
  30. package/dist/neal/orchestrator.js +3 -0
  31. package/dist/neal/plan-queue.js +29 -1
  32. package/dist/neal/prompts/execute.js +7 -19
  33. package/dist/neal/prompts/planning.js +64 -55
  34. package/dist/neal/prompts/review-doctrine.js +13 -50
  35. package/dist/neal/prompts/specialized.js +7 -17
  36. package/dist/neal/prompts/specs.js +180 -32
  37. package/dist/neal/providers/agent-settings-isolation.js +35 -0
  38. package/dist/neal/providers/anthropic-claude.js +18 -1
  39. package/dist/neal/providers/detection.js +3 -7
  40. package/dist/neal/providers/git-config-isolation.js +83 -0
  41. package/dist/neal/providers/openai-codex.js +25 -3
  42. package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
  43. package/dist/neal/providers/openai-compatible.js +1078 -430
  44. package/dist/neal/providers/openrouter-routing.js +68 -0
  45. package/dist/neal/providers/pricing.js +1 -1
  46. package/dist/neal/providers/rate-card.js +6 -32
  47. package/dist/neal/providers/registry.js +6 -17
  48. package/dist/neal/providers/types.js +12 -0
  49. package/dist/neal/resume-decision.js +7 -6
  50. package/dist/neal/resume-planner.js +9 -0
  51. package/dist/neal/retrospective.js +3 -3
  52. package/dist/neal/review-debt.js +30 -0
  53. package/dist/neal/review-findings/provider.js +17 -29
  54. package/dist/neal/review.js +17 -1
  55. package/dist/neal/state-invariants.js +11 -3
  56. package/dist/neal/state-views.js +27 -5
  57. package/dist/neal/state.js +59 -15
  58. package/dist/neal/status.js +28 -1
  59. package/dist/neal/support.js +3 -3
  60. package/docs/README.md +22 -18
  61. package/docs/{ADJUDICATOR_INVENTORY.md → adjudicator-inventory.md} +58 -57
  62. package/docs/architecture.md +79 -83
  63. package/docs/automation.md +11 -11
  64. package/docs/compat.md +91 -61
  65. package/docs/compatible-models.md +145 -121
  66. package/docs/demo.md +15 -5
  67. package/docs/maintenance.md +65 -34
  68. package/docs/plan-format.md +61 -33
  69. package/docs/prompt-evals.md +96 -0
  70. package/docs/{PROMPT_SPECS.md → prompt-specs.md} +52 -48
  71. package/docs/providers.md +279 -349
  72. package/docs/release.md +63 -81
  73. package/docs/review-convergence.md +246 -0
  74. package/docs/state-machine.md +61 -59
  75. package/docs/storage.md +40 -31
  76. package/docs/troubleshooting.md +32 -29
  77. package/examples/compat/add-edit-verify/PLAN.md +2 -0
  78. package/examples/compat/add-edit-verify/broken.diff +2 -2
  79. package/examples/compat/add-edit-verify/good.diff +2 -2
  80. package/examples/compat/add-edit-verify/src/add.js +1 -1
  81. package/examples/compat/manifest.json +25 -29
  82. package/examples/compat/sum-grep-edit/PLAN.md +33 -0
  83. package/examples/compat/sum-grep-edit/broken.diff +12 -0
  84. package/examples/compat/sum-grep-edit/good.diff +12 -0
  85. package/examples/compat/sum-grep-edit/package.json +5 -0
  86. package/examples/compat/sum-grep-edit/src/numbers.js +9 -0
  87. package/examples/compat/sum-grep-edit/test/numbers.test.js +15 -0
  88. package/examples/issue-triage-js/README.md +0 -15
  89. package/examples/reviewer-eval/clean-doc/base/src/clamp.ts +3 -0
  90. package/examples/reviewer-eval/clean-doc/change.diff +9 -0
  91. package/examples/reviewer-eval/clean-extract/base/src/greet.ts +3 -0
  92. package/examples/reviewer-eval/clean-extract/change.diff +13 -0
  93. package/examples/reviewer-eval/dropped-error/base/src/load.ts +4 -0
  94. package/examples/reviewer-eval/dropped-error/change.diff +15 -0
  95. package/examples/reviewer-eval/manifest.json +45 -0
  96. package/examples/reviewer-eval/off-by-one/base/src/paginate.ts +5 -0
  97. package/examples/reviewer-eval/off-by-one/change.diff +11 -0
  98. package/{config.yml → neal.yml} +1 -1
  99. package/package.json +15 -14
  100. package/dist/neal/providers/generic-agentic.js +0 -1256
  101. package/docs/comparison.md +0 -105
  102. package/docs/issue-pipeline.md +0 -124
  103. package/examples/compat/is-even-add-test/PLAN.md +0 -30
  104. package/examples/compat/is-even-add-test/broken.diff +0 -11
  105. package/examples/compat/is-even-add-test/good.diff +0 -11
  106. package/examples/compat/is-even-add-test/package.json +0 -5
  107. package/examples/compat/is-even-add-test/src/is-even.js +0 -3
  108. package/examples/compat/is-even-add-test/test/is-even.test.js +0 -9
  109. package/examples/compat/reverse-grep-edit/PLAN.md +0 -32
  110. package/examples/compat/reverse-grep-edit/broken.diff +0 -12
  111. package/examples/compat/reverse-grep-edit/good.diff +0 -12
  112. package/examples/compat/reverse-grep-edit/package.json +0 -5
  113. package/examples/compat/reverse-grep-edit/src/strings.js +0 -10
  114. package/examples/compat/reverse-grep-edit/test/strings.test.js +0 -15
@@ -1,43 +1,74 @@
1
1
  /**
2
- * OpenAI-compatible structured-advisor provider on the AI SDK chat path
3
- * (`@ai-sdk/openai-compatible`), replacing the bespoke `openai` npm client
4
- * (Phase 2 adapter consolidation). The external contract is unchanged: same
5
- * provider id, same config block, same capabilities (chat-only, no tools,
6
- * no-read reviewer with inline context), same `NealProviderError`
7
- * kinds/retryability, same provider events. The one sanctioned observable
8
- * change is the `usage` payload shape, which is now the AI SDK's normalized
9
- * shape (`inputTokens`/`outputTokens`/`totalTokens` plus details and a `raw`
10
- * passthrough) instead of the raw response-body object.
2
+ * openai-compatible provider: neal's generic OpenAI-compatible provider,
3
+ * implementing the CoderAdapter contract on the Vercel AI SDK so any
4
+ * OpenAI-compatible endpoint with a strong tool-calling model can serve the
5
+ * planner, coder, and reviewer roles through ordinary config. Key mechanics:
11
6
  *
12
- * Gateway behaviors learned from live failures are preserved through a
13
- * neal-owned `fetch` interceptor that inspects HTTP-200 JSON bodies before
14
- * the SDK parses them (structural markers only never text matching on
15
- * content channels):
16
- *
17
- * - upstream errors embedded as a top-level `error` object inside an HTTP
18
- * 200 body (OpenRouter wraps upstream 429s this way) are mapped with the
19
- * same status table as real HTTP errors so rate limits stay retryable;
20
- * - responses with no assistant text in `message.content`,
21
- * `message.reasoning`, or `message.reasoning_content` are a retryable
22
- * `api_error` flowing into the bounded api-retry loop.
23
- *
24
- * Reasoning-model fallback (final text in `message.reasoning` /
25
- * `message.reasoning_content` with empty `content`) is handled natively by
26
- * the AI SDK, which surfaces both fields as `reasoningText`; the turn code
27
- * prefers `result.text` and falls back to `result.reasoningText`.
7
+ * - one `generateText` call per turn (`stopWhen: stepCountIs(1)`,
8
+ * `maxRetries: 0`) with a neal-owned outer loop over an explicit message
9
+ * history; the AI SDK is never used as a multi-step black box;
10
+ * - per-turn bounded transient retry (`args.apiRetryLimit ?? 0`) with the
11
+ * standard `api_retry` `tool_progress` event shape and the bespoke
12
+ * adapter's backoff schedule (500 ms base, doubling, 5 s cap);
13
+ * - abort/inactivity wiring: every primary turn receives
14
+ * `AbortSignal.any([args.signal, AbortSignal.timeout(inactivityTimeoutMs)])`
15
+ * (the caller signal is omitted when absent). A caller-signal abort is a
16
+ * non-retryable `timeout`; an inactivity expiry is a retryable `timeout`;
17
+ * the two are disambiguated by checking `args.signal?.aborted` after the
18
+ * abort (`AbortSignal.timeout` aborts with a `TimeoutError` DOMException);
19
+ * - structured output (coder and structured-advisor alike) through the AI
20
+ * SDK's native structured-output channel: the tool loop runs to its normal
21
+ * zero-tool-call completion turn, then exactly one dedicated finalization
22
+ * turn carries `output: Output.object(...)` (see
23
+ * `runStructuredFinalizationTurn`, shared by both paths); there are no
24
+ * fence instructions and no repair turns on this provider;
25
+ * - events-only synthetic session handles: `supportsSessionResume: false`,
26
+ * so `args.onSessionStarted` is never invoked, returned session handles
27
+ * are always null, and a non-null `args.resumeHandle` is corrupted state
28
+ * (`session_unavailable`);
29
+ * - a structured-advisor adapter running the same outer loop over the
30
+ * read-only toolset (`read_file`, `list_dir`, `grep`, `git_diff`) with its own smaller
31
+ * step cap (`OPENAI_COMPATIBLE_ADVISOR_MAX_STEPS`), the same settings
32
+ * resolution, retry semantics, and cancellation contract, so the coder
33
+ * role passes the final-completion structured-advisor gate and a generic
34
+ * reviewer inspects the repository directly with read tools instead of
35
+ * relying on Neal-inlined context.
28
36
  */
29
37
  import { randomBytes } from 'node:crypto';
30
38
  import { createOpenAICompatible } from '@ai-sdk/openai-compatible';
31
- import { generateText } from 'ai';
32
- import { runStructuredJsonProtocol } from '../agents/structured-json.js';
39
+ import { generateText, jsonSchema, NoObjectGeneratedError, Output, stepCountIs, } from 'ai';
33
40
  import { getOpenAICompatibleSettings } from '../config.js';
41
+ import { withOpenRouterRouting } from './openrouter-routing.js';
34
42
  import { resolveRateCost } from './pricing.js';
35
- import { NealProviderError } from './types.js';
43
+ import { createCoderToolset, createPlanAuthorToolset, createReadOnlyToolset, } from './openai-compatible-tools.js';
44
+ import { isContentSafetyRefusalMessage, NealProviderError } from './types.js';
36
45
  const OPENAI_COMPATIBLE_PROVIDER_ID = 'openai-compatible';
46
+ /**
47
+ * Step cap for the coder loop: the maximum number of successful model turns
48
+ * per prompt before the adapter fails the attempt with `provider_failed`.
49
+ * This is a constant, not a config knob, by design. It originally shipped at
50
+ * 24, and live runs on `examples/issue-triage-js` hit that cap repeatedly on
51
+ * ordinary scopes (one tool call per turn means read/inspect/edit/test cycles
52
+ * consume turns quickly), so it was raised to 48. Raise it again only on live
53
+ * evidence that the cap binds on real projects, not speculatively.
54
+ */
55
+ export const OPENAI_COMPATIBLE_MAX_STEPS = 48;
56
+ /**
57
+ * Step cap for the structured-advisor read-only tool loop: the maximum number
58
+ * of successful model turns per round before the adapter fails the round with
59
+ * a non-retryable `provider_failed`. A constant, not a config knob, by design
60
+ * (mirroring `OPENAI_COMPATIBLE_MAX_STEPS`). It ships at half the coder cap
61
+ * because reviews are bounded inspections, not implementations: the advisor
62
+ * only reads, lists, and greps before judging, while the coder's
63
+ * read/inspect/edit/test cycles consume turns far faster. Provider telemetry
64
+ * records tool turns per review round; raise this cap only on live evidence
65
+ * that reviews hit it, the same way the coder cap moved 24 -> 48.
66
+ */
67
+ export const OPENAI_COMPATIBLE_ADVISOR_MAX_STEPS = 24;
37
68
  const API_RETRY_BASE_DELAY_MS = 500;
38
69
  const API_RETRY_MAX_DELAY_MS = 5_000;
39
- // Network-level error codes treated as transient (mirrors the
40
- // generic-agentic classification; statusCode 408/429/5xx, explicit
70
+ // Network-level error codes treated as transient, mirroring the spike's
71
+ // isTransientError classification (statusCode 408/429/5xx, explicit
41
72
  // isRetryable, or one of these codes on the error or its cause).
42
73
  const NETWORK_ERROR_CODES = new Set([
43
74
  'ECONNRESET',
@@ -55,27 +86,78 @@ function defaultSleep(ms) {
55
86
  });
56
87
  }
57
88
  function createDefaultOpenAICompatibleModel(args) {
58
- // maxRetries: 0 on each generateText call keeps Neal's own apiRetryLimit
59
- // retry loop the only retry layer for this provider.
89
+ // Exactly the spike's model construction: an OpenAI-compatible chat model.
90
+ // maxRetries: 0 on each generateText call keeps neal's own apiRetryLimit
91
+ // loop the only retry layer for this provider.
92
+ //
93
+ // supportsStructuredOutputs: true tells the SDK to send the structured
94
+ // finalization turn's request with `response_format.type: 'json_schema'`
95
+ // carrying neal's schema (the `Output.object`/`jsonSchema` constraint in
96
+ // runAgentModelTurn). Without it the SDK silently drops the schema,
97
+ // downgrades to loose `json_object`, and emits the request-build warning
98
+ // "JSON response format schema is only supported with structuredOutputs" —
99
+ // a silent schema-drop that makes neal ask for enforced JSON but receive
100
+ // unenforced JSON. With the flag set, a gateway that cannot honor the
101
+ // schema fails attributably instead.
60
102
  return createOpenAICompatible({
61
103
  name: OPENAI_COMPATIBLE_PROVIDER_ID,
62
104
  baseURL: args.baseUrl,
63
105
  apiKey: args.apiKey,
64
106
  headers: args.headers,
65
- fetch: args.fetch,
107
+ supportsStructuredOutputs: true,
108
+ // On OpenRouter, constrain routing to backends that support the parameters
109
+ // neal sends — above all the coder's `response_format: json_schema`. Without
110
+ // it OpenRouter can route the same slug to a backend that can't do
111
+ // structured output, which fails the coder attributably (structured_output)
112
+ // even though the slug "works" on other backends. No-op off OpenRouter.
113
+ fetch: withOpenRouterRouting(args.baseUrl, globalThis.fetch),
66
114
  }).chatModel(args.model);
67
115
  }
68
116
  function createOpenAICompatibleProviderError(args) {
69
117
  return new NealProviderError({
70
118
  message: args.message,
71
119
  provider: OPENAI_COMPATIBLE_PROVIDER_ID,
72
- role: 'structured-advisor',
120
+ role: args.role,
73
121
  sessionHandle: args.sessionHandle,
74
122
  kind: args.kind,
75
123
  retryable: args.retryable,
76
124
  cause: args.cause,
77
125
  });
78
126
  }
127
+ function buildSyntheticSessionHandle() {
128
+ // Synthetic events-only handle. This provider has no session resume;
129
+ // adapter methods always return sessionHandle: null and never invoke
130
+ // onSessionStarted, so no resumable handle is ever persisted for it.
131
+ return `${OPENAI_COMPATIBLE_PROVIDER_ID}:${new Date().toISOString()}:${randomBytes(4).toString('hex')}`;
132
+ }
133
+ function withEventsOnlySessionHandle(error) {
134
+ // The synthetic session handle is events-only telemetry and must never ride
135
+ // on thrown errors: the orchestrator persists `error.sessionHandle` into
136
+ // `state.coderSessionHandle` on coder-phase and final-completion failures
137
+ // (src/neal/orchestrator/phases/coder.ts, src/neal/orchestrator/completion.ts),
138
+ // and any persisted handle makes `assertAgentConfigSupportsResume` demand
139
+ // session_resume support — which this provider declares false — rejecting
140
+ // `neal resume` for the whole run.
141
+ if (error.sessionHandle === null) {
142
+ return error;
143
+ }
144
+ const stripped = new NealProviderError({
145
+ message: error.message,
146
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
147
+ role: error.role,
148
+ sessionHandle: null,
149
+ kind: error.kind,
150
+ retryable: error.retryable,
151
+ cause: error.cause,
152
+ });
153
+ if (error.stack !== undefined) {
154
+ stripped.stack = error.stack;
155
+ }
156
+ return stripped;
157
+ }
158
+ function getApiRetryDelayMs(retryCount) {
159
+ return Math.min(API_RETRY_BASE_DELAY_MS * 2 ** (retryCount - 1), API_RETRY_MAX_DELAY_MS);
160
+ }
79
161
  function readStatusCode(error) {
80
162
  const status = error.statusCode;
81
163
  return typeof status === 'number' && Number.isFinite(status) ? status : null;
@@ -87,16 +169,14 @@ function isAbortLikeError(error) {
87
169
  const name = error.name;
88
170
  return name === 'TimeoutError' || name === 'AbortError';
89
171
  }
90
- // Transient classification for errors WITHOUT an HTTP status: an explicit
91
- // SDK isRetryable marker or a known network error code (on the error or its
92
- // cause). Status-bearing errors must never reach this check — Neal's own
93
- // status table is the contract for those, and the AI SDK's APICallError
94
- // default marks statuses Neal treats as non-retryable (for example 409) as
95
- // retryable.
96
- function hasTransientStatuslessShape(error) {
172
+ function hasTransientShape(error) {
97
173
  if (error.isRetryable === true) {
98
174
  return true;
99
175
  }
176
+ const status = readStatusCode(error);
177
+ if (status !== null && (status === 408 || status === 429 || status >= 500)) {
178
+ return true;
179
+ }
100
180
  const cause = error.cause;
101
181
  const codes = [
102
182
  error.code,
@@ -110,32 +190,87 @@ function describeError(error) {
110
190
  }
111
191
  if (typeof error === 'object' && error !== null) {
112
192
  const candidate = error;
113
- return typeof candidate.message === 'string' ? candidate.message : String(error);
193
+ const message = typeof candidate.message === 'string' ? candidate.message : String(error);
194
+ return typeof candidate.statusCode === 'number'
195
+ ? `HTTP ${candidate.statusCode}: ${message}`
196
+ : message;
114
197
  }
115
198
  return String(error);
116
199
  }
200
+ const MAX_ERROR_DIAGNOSTIC_TEXT_LENGTH = 2_000;
201
+ const MAX_ERROR_CAUSE_DEPTH = 3;
202
+ function truncateDiagnosticText(text) {
203
+ if (text.length <= MAX_ERROR_DIAGNOSTIC_TEXT_LENGTH) {
204
+ return text;
205
+ }
206
+ return `${text.slice(0, MAX_ERROR_DIAGNOSTIC_TEXT_LENGTH)}...[truncated ${text.length - MAX_ERROR_DIAGNOSTIC_TEXT_LENGTH} chars]`;
207
+ }
208
+ function readErrorProperty(error, key) {
209
+ if (typeof error !== 'object' || error === null) {
210
+ return undefined;
211
+ }
212
+ return error[key];
213
+ }
214
+ function buildErrorDiagnostic(error, depth = 0) {
215
+ const diagnostic = {
216
+ message: describeError(error),
217
+ };
218
+ if (error instanceof Error && error.name) {
219
+ diagnostic.name = error.name;
220
+ }
221
+ else {
222
+ const name = readErrorProperty(error, 'name');
223
+ if (typeof name === 'string' && name.trim() !== '') {
224
+ diagnostic.name = name;
225
+ }
226
+ }
227
+ const statusCode = readErrorProperty(error, 'statusCode');
228
+ if (typeof statusCode === 'number') {
229
+ diagnostic.statusCode = statusCode;
230
+ }
231
+ const url = readErrorProperty(error, 'url');
232
+ if (typeof url === 'string' && url.trim() !== '') {
233
+ diagnostic.url = url;
234
+ }
235
+ const responseBody = readErrorProperty(error, 'responseBody');
236
+ if (typeof responseBody === 'string' && responseBody.trim() !== '') {
237
+ diagnostic.responseBody = truncateDiagnosticText(responseBody);
238
+ diagnostic.responseBodyTruncated = responseBody.length > MAX_ERROR_DIAGNOSTIC_TEXT_LENGTH;
239
+ }
240
+ // Rejected structured-output text: NoObjectGeneratedError carries the
241
+ // assistant text it could not parse as `text`, and the adapter constructs
242
+ // validator-failure causes with the generated JSON as `text`, so failed
243
+ // structured runs stay classifiable ("emitted XML garbage" vs "wrote a real
244
+ // patch and fumbled the payload") from the provider_error event alone.
245
+ const text = readErrorProperty(error, 'text');
246
+ if (typeof text === 'string' && text.trim() !== '') {
247
+ diagnostic.text = truncateDiagnosticText(text);
248
+ diagnostic.textTruncated = text.length > MAX_ERROR_DIAGNOSTIC_TEXT_LENGTH;
249
+ }
250
+ const cause = readErrorProperty(error, 'cause');
251
+ if (cause !== undefined && depth < MAX_ERROR_CAUSE_DEPTH) {
252
+ diagnostic.cause = buildErrorDiagnostic(cause, depth + 1);
253
+ }
254
+ return diagnostic;
255
+ }
256
+ function providerErrorData(error) {
257
+ return {
258
+ retryable: error.retryable,
259
+ diagnostic: buildErrorDiagnostic(error.cause ?? error),
260
+ };
261
+ }
117
262
  /**
118
- * Normalizes any thrown value into a NealProviderError with the bespoke
119
- * adapter's classification table, expressed over AI SDK error shapes
120
- * (structural markers only: `statusCode`, `isRetryable`, network `code`s,
121
- * abort names) plus the abort-source disambiguation contract:
263
+ * Normalizes any thrown value into a NealProviderError with the same
264
+ * classification table as the bespoke openai-compatible adapter plus the
265
+ * abort-source disambiguation contract:
122
266
  *
123
267
  * - caller signal aborted -> `timeout`, retryable: false (the caller owns
124
268
  * the cancellation; never retried in-round);
125
269
  * - abort without a caller abort -> inactivity expiry -> `timeout`,
126
270
  * retryable: true;
127
- * - HTTP 401 -> `permission_denied`, retryable (OpenAI intermittently
128
- * returns 401 for keys that are valid moments before and after; a
129
- * persistent 401 exhausts the bounded retry budget and still surfaces
130
- * as `permission_denied`);
131
- * - HTTP 403 -> `permission_denied`, non-retryable;
132
- * - HTTP 408/429/5xx -> `api_error`, retryable;
133
- * - any other HTTP status -> `provider_failed`, non-retryable (Neal's
134
- * status table is authoritative for status-bearing errors; the SDK's
135
- * `isRetryable` flag is never consulted for them because its default
136
- * marks 409 retryable, which this contract does not);
137
- * - no HTTP status with explicit isRetryable or a network error code ->
138
- * `api_error`, retryable;
271
+ * - HTTP 401/403 -> `permission_denied`, non-retryable;
272
+ * - HTTP 408/429/5xx, explicit isRetryable, network codes -> `api_error`,
273
+ * retryable;
139
274
  * - everything else -> `provider_failed`, non-retryable.
140
275
  */
141
276
  function normalizeOpenAICompatibleError(error, ctx) {
@@ -144,17 +279,35 @@ function normalizeOpenAICompatibleError(error, ctx) {
144
279
  }
145
280
  if (ctx.callerSignal?.aborted) {
146
281
  return createOpenAICompatibleProviderError({
147
- message: `OpenAI-compatible ${ctx.label} request was aborted before completion.`,
282
+ message: `openai-compatible ${ctx.label} turn was aborted by the caller before completion.`,
283
+ role: ctx.role,
148
284
  sessionHandle: ctx.sessionHandle,
149
285
  kind: 'timeout',
150
286
  retryable: false,
151
287
  cause: error,
152
288
  });
153
289
  }
290
+ // Content-safety refusal on the genuine-transport path. describeError reads
291
+ // the provider-authored transport message (the AI SDK's APICallError message
292
+ // is `data.error.message`, with any statusCode prefixed). Classified before
293
+ // the status table so a refusal phrased as a 401/403 lands as a terminal
294
+ // content_refused rather than permission_denied. Error channel only, never
295
+ // the assistant review content.
296
+ if (isContentSafetyRefusalMessage(describeError(error))) {
297
+ return createOpenAICompatibleProviderError({
298
+ message: `openai-compatible ${ctx.label} request was refused on content-safety grounds: ${describeError(error)}`,
299
+ role: ctx.role,
300
+ sessionHandle: ctx.sessionHandle,
301
+ kind: 'content_refused',
302
+ retryable: false,
303
+ cause: error,
304
+ });
305
+ }
154
306
  if (typeof error === 'object' && error !== null) {
155
307
  if (isAbortLikeError(error)) {
156
308
  return createOpenAICompatibleProviderError({
157
- message: `OpenAI-compatible ${ctx.label} request exceeded the inactivity timeout and was aborted.`,
309
+ message: `openai-compatible ${ctx.label} turn exceeded the inactivity timeout and was aborted.`,
310
+ role: ctx.role,
158
311
  sessionHandle: ctx.sessionHandle,
159
312
  kind: 'timeout',
160
313
  retryable: true,
@@ -162,41 +315,20 @@ function normalizeOpenAICompatibleError(error, ctx) {
162
315
  });
163
316
  }
164
317
  const status = readStatusCode(error);
165
- if (status !== null) {
166
- // Status-bearing errors follow Neal's explicit table exclusively;
167
- // the SDK's isRetryable flag is intentionally not consulted here.
168
- if (status === 401 || status === 403) {
169
- return createOpenAICompatibleProviderError({
170
- message: `OpenAI-compatible ${ctx.label} request was rejected with HTTP ${status}: ${describeError(error)}`,
171
- sessionHandle: ctx.sessionHandle,
172
- kind: 'permission_denied',
173
- // 401 gets the bounded api-retry budget: OpenAI intermittently
174
- // rejects otherwise-valid keys with 401, and a single blip must
175
- // not terminate an unattended run. 403 stays terminal.
176
- retryable: status === 401,
177
- cause: error,
178
- });
179
- }
180
- if (status === 408 || status === 429 || status >= 500) {
181
- return createOpenAICompatibleProviderError({
182
- message: `OpenAI-compatible ${ctx.label} request failed with HTTP ${status}: ${describeError(error)}`,
183
- sessionHandle: ctx.sessionHandle,
184
- kind: 'api_error',
185
- retryable: true,
186
- cause: error,
187
- });
188
- }
318
+ if (status === 401 || status === 403) {
189
319
  return createOpenAICompatibleProviderError({
190
- message: `OpenAI-compatible ${ctx.label} request failed with HTTP ${status}: ${describeError(error)}`,
320
+ message: `openai-compatible ${ctx.label} request was rejected with HTTP ${status}: ${describeError(error)}`,
321
+ role: ctx.role,
191
322
  sessionHandle: ctx.sessionHandle,
192
- kind: 'provider_failed',
323
+ kind: 'permission_denied',
193
324
  retryable: false,
194
325
  cause: error,
195
326
  });
196
327
  }
197
- if (hasTransientStatuslessShape(error)) {
328
+ if (hasTransientShape(error)) {
198
329
  return createOpenAICompatibleProviderError({
199
- message: `OpenAI-compatible ${ctx.label} request failed with a network error: ${describeError(error)}`,
330
+ message: `openai-compatible ${ctx.label} request failed transiently: ${describeError(error)}`,
331
+ role: ctx.role,
200
332
  sessionHandle: ctx.sessionHandle,
201
333
  kind: 'api_error',
202
334
  retryable: true,
@@ -205,181 +337,44 @@ function normalizeOpenAICompatibleError(error, ctx) {
205
337
  }
206
338
  }
207
339
  return createOpenAICompatibleProviderError({
208
- message: `OpenAI-compatible ${ctx.label} request failed: ${describeError(error)}`,
340
+ message: `openai-compatible ${ctx.label} request failed: ${describeError(error)}`,
341
+ role: ctx.role,
209
342
  sessionHandle: ctx.sessionHandle,
210
343
  kind: 'provider_failed',
211
344
  retryable: false,
212
345
  cause: error,
213
346
  });
214
347
  }
215
- const EMBEDDED_ERROR_RAW_DETAIL_MAX_CHARS = 400;
216
- function extractEmbeddedErrorStatus(code) {
217
- if (typeof code === 'number' && Number.isFinite(code)) {
218
- return code;
219
- }
220
- if (typeof code === 'string' && /^\d+$/.test(code)) {
221
- return Number(code);
222
- }
223
- return null;
224
- }
225
- // Some OpenAI-compatible gateways (for example OpenRouter) report upstream
226
- // provider failures as an `error` object inside an HTTP 200 body instead of a
227
- // non-200 status. Map those embedded errors with the same status rules as real
228
- // HTTP errors so upstream rate limits and outages stay retryable.
229
- function createEmbeddedResponseError(args) {
230
- const errorObject = typeof args.embeddedError === 'object' && args.embeddedError !== null
231
- ? args.embeddedError
232
- : null;
233
- const status = extractEmbeddedErrorStatus(errorObject?.code);
234
- const messageText = errorObject && typeof errorObject.message === 'string' && errorObject.message
235
- ? errorObject.message
236
- : JSON.stringify(args.embeddedError);
237
- const metadata = errorObject?.metadata;
238
- const rawDetail = metadata && typeof metadata === 'object' && typeof metadata.raw === 'string'
239
- ? ` (${String(metadata.raw).slice(0, EMBEDDED_ERROR_RAW_DETAIL_MAX_CHARS)})`
240
- : '';
241
- const message = `OpenAI-compatible ${args.label} response body reported ` +
242
- `${status === null ? 'an error' : `error code ${status}`}: ${messageText}${rawDetail}`;
243
- if (status === 401 || status === 403) {
244
- return createOpenAICompatibleProviderError({
245
- message,
246
- sessionHandle: args.sessionHandle,
247
- kind: 'permission_denied',
248
- retryable: status === 401,
249
- });
348
+ function createTurnAbortController(callerSignal, inactivityTimeoutMs) {
349
+ const controller = new AbortController();
350
+ const abortFromCaller = () => {
351
+ controller.abort(callerSignal?.reason);
352
+ };
353
+ if (callerSignal?.aborted) {
354
+ abortFromCaller();
250
355
  }
251
- if (status === 408 || status === 429 || (status !== null && status >= 500)) {
252
- return createOpenAICompatibleProviderError({
253
- message,
254
- sessionHandle: args.sessionHandle,
255
- kind: 'api_error',
256
- retryable: true,
257
- });
356
+ else {
357
+ callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
258
358
  }
259
- return createOpenAICompatibleProviderError({
260
- message,
261
- sessionHandle: args.sessionHandle,
262
- kind: 'provider_failed',
263
- retryable: false,
264
- });
265
- }
266
- // Reasoning models served through OpenAI-compatible gateways can return
267
- // `message.content` as null or empty while the final text lands in
268
- // `message.reasoning` (OpenRouter) or `message.reasoning_content` (DeepSeek
269
- //-style APIs), for example when the response budget is consumed by reasoning
270
- // tokens. The AI SDK surfaces both fields as reasoning text, so a body with
271
- // text in any of these fields is usable; a body with text in none of them is
272
- // a transient upstream failure (see createInterceptingFetch).
273
- function extractAssistantText(message) {
274
- if (typeof message?.content === 'string' && message.content !== '') {
275
- return message.content;
276
- }
277
- if (typeof message?.reasoning === 'string' && message.reasoning !== '') {
278
- return message.reasoning;
279
- }
280
- if (typeof message?.reasoning_content === 'string' && message.reasoning_content !== '') {
281
- return message.reasoning_content;
282
- }
283
- return null;
284
- }
285
- function describeMissingAssistantText(response) {
286
- const choice = response.choices?.[0];
287
- if (!choice) {
288
- return 'choices[0] was missing';
289
- }
290
- const message = choice.message;
291
- const finishReason = typeof choice.finish_reason === 'string' ? choice.finish_reason : 'absent';
292
- if (!message) {
293
- return `choices[0].message was missing; finish_reason=${finishReason}`;
294
- }
295
- const describeField = (value) => value === undefined ? 'absent' : value === null ? 'null' : value === '' ? 'empty' : 'present';
296
- return (`content=${describeField(message.content)}, reasoning=${describeField(message.reasoning)}, ` +
297
- `reasoning_content=${describeField(message.reasoning_content)}; finish_reason=${finishReason}`);
298
- }
299
- /**
300
- * Neal-owned fetch interceptor wired into the AI SDK provider. It inspects
301
- * successful (2xx) JSON response bodies before the SDK parses them and
302
- * throws the mapped NealProviderError for two gateway behaviors the SDK
303
- * does not classify:
304
- *
305
- * - a top-level `error` object inside an HTTP 200 body (structural marker;
306
- * mapped by createEmbeddedResponseError's status table);
307
- * - no assistant text in `message.content`, `message.reasoning`, or
308
- * `message.reasoning_content` (retryable `api_error`, flowing into the
309
- * bounded api-retry loop).
310
- *
311
- * Both checks gate on structural body shape — presence of an `error`
312
- * object, absence of text fields — never on matching text that can contain
313
- * document content (the channel rule). Non-2xx responses pass through
314
- * untouched so the SDK's error handler attaches `statusCode` for
315
- * normalizeOpenAICompatibleError. Thrown NealProviderErrors propagate
316
- * unchanged through the SDK (it rethrows non-network fetch errors as-is and
317
- * performs no internal retries with maxRetries: 0).
318
- */
319
- function createInterceptingFetch(ctx) {
320
- return async (input, init) => {
321
- const response = await fetch(input, init);
322
- if (!response.ok) {
323
- return response;
324
- }
325
- const contentType = response.headers.get('content-type') ?? '';
326
- if (!contentType.toLowerCase().includes('json')) {
327
- return response;
328
- }
329
- let body;
330
- try {
331
- body = await response.clone().json();
332
- }
333
- catch {
334
- // Let the SDK's own response handling report unparseable bodies.
335
- return response;
336
- }
337
- if (typeof body !== 'object' || body === null) {
338
- return response;
339
- }
340
- const completion = body;
341
- if (completion.error !== undefined && completion.error !== null) {
342
- throw createEmbeddedResponseError({
343
- embeddedError: completion.error,
344
- label: ctx.label,
345
- sessionHandle: ctx.sessionHandle,
346
- });
347
- }
348
- if (extractAssistantText(completion.choices?.[0]?.message) === null) {
349
- // A response with no assistant text in any known field is treated as
350
- // a transient upstream failure so it flows into the bounded API retry
351
- // loop instead of permanently failing the round.
352
- throw createOpenAICompatibleProviderError({
353
- message: `OpenAI-compatible ${ctx.label} response did not include assistant text ` +
354
- `(${describeMissingAssistantText(completion)}).`,
355
- sessionHandle: ctx.sessionHandle,
356
- kind: 'api_error',
357
- retryable: true,
358
- });
359
- }
360
- return response;
359
+ // Keep this as an ordinary ref'ed timer. In Node 22, AbortSignal.timeout()
360
+ // can let a mocked or idle SDK call be cancelled by node:test before the
361
+ // timeout fires because the process has no remaining ref'ed handles.
362
+ const timeout = setTimeout(() => {
363
+ controller.abort(new DOMException('The operation timed out.', 'TimeoutError'));
364
+ }, inactivityTimeoutMs);
365
+ return {
366
+ signal: controller.signal,
367
+ dispose: () => {
368
+ clearTimeout(timeout);
369
+ callerSignal?.removeEventListener('abort', abortFromCaller);
370
+ },
361
371
  };
362
372
  }
363
- function buildSyntheticSessionHandle() {
364
- // Synthetic per-round handle for events only. This provider has no session
365
- // resume; runStructuredRound always returns sessionHandle: null so no
366
- // resumable reviewer handle is ever persisted for it.
367
- return `${OPENAI_COMPATIBLE_PROVIDER_ID}:${new Date().toISOString()}:${randomBytes(4).toString('hex')}`;
368
- }
369
- function getApiRetryDelayMs(retryCount) {
370
- return Math.min(API_RETRY_BASE_DELAY_MS * 2 ** (retryCount - 1), API_RETRY_MAX_DELAY_MS);
371
- }
372
- function composeAbortSignal(callerSignal, inactivityTimeoutMs) {
373
- // Non-streaming chat turn, so the inactivity timeout acts as a
374
- // whole-request timeout here. That is acceptable for advisor rounds, which
375
- // are single prompt-in/text-out calls.
376
- const timeoutSignal = AbortSignal.timeout(inactivityTimeoutMs);
377
- return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
378
- }
379
373
  async function emitProviderEvent(events, event) {
380
374
  await events?.(event);
381
375
  }
382
- function resolveRoundSettings(args) {
376
+ function resolveOpenAICompatibleSettings(args) {
377
+ const { context } = args;
383
378
  let settings;
384
379
  try {
385
380
  settings = args.resolveSettings(args.cwd);
@@ -387,7 +382,8 @@ function resolveRoundSettings(args) {
387
382
  catch (error) {
388
383
  const message = error instanceof Error ? error.message : String(error);
389
384
  throw createOpenAICompatibleProviderError({
390
- message: `OpenAI-compatible ${args.label} settings could not be resolved: ${message}`,
385
+ message: `openai-compatible ${context.noun} settings could not be resolved: ${message}`,
386
+ role: context.role,
391
387
  kind: 'provider_failed',
392
388
  retryable: false,
393
389
  cause: error,
@@ -395,8 +391,9 @@ function resolveRoundSettings(args) {
395
391
  }
396
392
  if (!settings.baseUrl) {
397
393
  throw createOpenAICompatibleProviderError({
398
- message: `OpenAI-compatible ${args.label} round has no base URL. ` +
399
- 'Set providers.openai_compatible.base_url in config.yml or the OPENAI_COMPATIBLE_BASE_URL environment variable.',
394
+ message: `openai-compatible ${context.subject} has no base URL. ` +
395
+ 'Set providers.openai_compatible.base_url in neal.yml, ~/.neal/config.yml, or the OPENAI_COMPATIBLE_BASE_URL environment variable.',
396
+ role: context.role,
400
397
  kind: 'provider_failed',
401
398
  retryable: false,
402
399
  });
@@ -404,16 +401,18 @@ function resolveRoundSettings(args) {
404
401
  const model = args.roundModel ?? args.roleModel ?? settings.defaultModel;
405
402
  if (!model) {
406
403
  throw createOpenAICompatibleProviderError({
407
- message: `OpenAI-compatible ${args.label} round has no model. ` +
408
- 'Set a role model override (for example agent.reviewer.model), providers.openai_compatible.default_model in config.yml, or the OPENAI_COMPATIBLE_MODEL environment variable.',
404
+ message: `openai-compatible ${context.subject} has no model. ` +
405
+ `Set a role model override (for example ${context.modelOverrideExample}), providers.openai_compatible.default_model in neal.yml or ~/.neal/config.yml, or the OPENAI_COMPATIBLE_MODEL environment variable.`,
406
+ role: context.role,
409
407
  kind: 'provider_failed',
410
408
  retryable: false,
411
409
  });
412
410
  }
413
411
  if (!settings.apiKey) {
414
412
  throw createOpenAICompatibleProviderError({
415
- message: `OpenAI-compatible ${args.label} round has no API key. ` +
413
+ message: `openai-compatible ${context.subject} has no API key. ` +
416
414
  `Set the ${settings.apiKeyEnv} environment variable.`,
415
+ role: context.role,
417
416
  kind: 'permission_denied',
418
417
  retryable: false,
419
418
  });
@@ -426,9 +425,717 @@ function resolveRoundSettings(args) {
426
425
  pricing: settings.pricing,
427
426
  };
428
427
  }
428
+ const CODER_STEP_CAP = {
429
+ limit: OPENAI_COMPATIBLE_MAX_STEPS,
430
+ constantName: 'OPENAI_COMPATIBLE_MAX_STEPS',
431
+ loopDescription: 'coder loop',
432
+ };
433
+ function advisorStepCap(label) {
434
+ return {
435
+ limit: OPENAI_COMPATIBLE_ADVISOR_MAX_STEPS,
436
+ constantName: 'OPENAI_COMPATIBLE_ADVISOR_MAX_STEPS',
437
+ loopDescription: `${label} advisor loop`,
438
+ };
439
+ }
440
+ /**
441
+ * Maps toolset events onto the provider runtime event grammar (adding the
442
+ * base fields for the owning role) and maintains the cumulative per-tool
443
+ * telemetry maps. The toolset hook is synchronous, so async sink results are
444
+ * fire-and-forget; sink failures must never turn a tool result into a thrown
445
+ * error.
446
+ */
447
+ function forwardAgentToolEvent(event, ctx) {
448
+ const base = {
449
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
450
+ role: ctx.role,
451
+ ...(ctx.label !== undefined ? { label: ctx.label } : {}),
452
+ sessionHandle: ctx.sessionHandle,
453
+ };
454
+ const forward = (runtimeEvent) => {
455
+ if (!ctx.events) {
456
+ return;
457
+ }
458
+ try {
459
+ void Promise.resolve(ctx.events(runtimeEvent)).catch(() => { });
460
+ }
461
+ catch {
462
+ // Telemetry must never break tool execution.
463
+ }
464
+ };
465
+ switch (event.type) {
466
+ case 'tool_started':
467
+ ctx.state.toolCalls[event.toolName] = (ctx.state.toolCalls[event.toolName] ?? 0) + 1;
468
+ forward({ type: 'tool_started', ...base, toolName: event.toolName });
469
+ return;
470
+ case 'tool_progress':
471
+ if (event.isError) {
472
+ ctx.state.toolErrors[event.toolName] = (ctx.state.toolErrors[event.toolName] ?? 0) + 1;
473
+ }
474
+ forward({
475
+ type: 'tool_progress',
476
+ ...base,
477
+ toolName: event.toolName,
478
+ message: event.message,
479
+ isError: event.isError,
480
+ });
481
+ return;
482
+ case 'command_completed':
483
+ forward({
484
+ type: 'command_completed',
485
+ ...base,
486
+ command: event.command,
487
+ exitCode: event.exitCode,
488
+ output: event.output,
489
+ cwd: event.cwd,
490
+ });
491
+ return;
492
+ case 'file_changed':
493
+ forward({ type: 'file_changed', ...base, files: event.files });
494
+ return;
495
+ }
496
+ }
497
+ /**
498
+ * The outer tool loop: pushes the prompt onto the shared history and runs
499
+ * model turns (each with its own bounded transient retry) until a turn
500
+ * makes zero tool calls — its assistant text is the loop's completion text
501
+ * — or the step cap is exhausted (`provider_failed`).
502
+ *
503
+ * Discovery (recorded, ai@6.0.202): a tool call whose input fails the tool's
504
+ * inputSchema does NOT throw out of `generateText`. The SDK's parseToolCall
505
+ * catches `InvalidToolInputError`, marks the call `invalid: true,
506
+ * dynamic: true` (without executing the tool), and the generate loop feeds an
507
+ * `error-text` tool-result ("Invalid input for tool <name>: ...") back to the
508
+ * model. The invalid call still appears in `result.toolCalls`, so this loop
509
+ * counts it as a tool-calling turn and continues — the model sees the error
510
+ * and self-corrects or runs into the step cap. That native feedback behavior
511
+ * is the whole strict-input contract; no coercion or repair hook wraps it.
512
+ */
513
+ async function runAgentToolLoop(ctx, prompt) {
514
+ ctx.state.messages.push({ role: 'user', content: prompt });
515
+ while (true) {
516
+ if (ctx.state.steps >= ctx.stepCap.limit) {
517
+ throw createOpenAICompatibleProviderError({
518
+ message: `openai-compatible ${ctx.stepCap.loopDescription} reached the ${ctx.stepCap.constantName} cap of ` +
519
+ `${ctx.stepCap.limit} model turns without a completion turn (a turn with zero tool calls).`,
520
+ role: ctx.role,
521
+ sessionHandle: ctx.sessionHandle,
522
+ kind: 'provider_failed',
523
+ retryable: false,
524
+ });
525
+ }
526
+ const turn = await runAgentModelTurn(ctx, { useTools: true });
527
+ ctx.state.messages.push(...turn.responseMessages);
528
+ if (turn.toolCallCount > 0) {
529
+ // Completion is structural only: a model that narrates completion
530
+ // while still calling tools keeps looping until it makes a turn with
531
+ // zero tool calls or exhausts the step cap (channel rule — no
532
+ // substring classification on content channels).
533
+ continue;
534
+ }
535
+ return turn.assistantText;
536
+ }
537
+ }
538
+ /**
539
+ * True when any object node in the schema declares a property that is not in its
540
+ * own `required` tuple — a genuinely-optional property. The plan reviewer's
541
+ * `findingClass` and the consultant's `targetCanonicalIds` are such
542
+ * properties, kept optional so the tolerant validator can default an omitted
543
+ * value. `@ai-sdk/openai-compatible` sends `strict: true` by default, and an
544
+ * OpenAI-strict json_schema request requires every property to be required and
545
+ * rejects an omitted optional one before the validator can normalize it. The
546
+ * finalization turn therefore disables strict mode (below) for these schemas.
547
+ */
548
+ function schemaHasOptionalProperties(schema) {
549
+ if (Array.isArray(schema)) {
550
+ return schema.some((entry) => schemaHasOptionalProperties(entry));
551
+ }
552
+ if (!schema || typeof schema !== 'object') {
553
+ return false;
554
+ }
555
+ const record = schema;
556
+ const properties = record.properties;
557
+ if (properties && typeof properties === 'object' && !Array.isArray(properties)) {
558
+ const required = Array.isArray(record.required) ? record.required : [];
559
+ if (Object.keys(properties).some((name) => !required.includes(name))) {
560
+ return true;
561
+ }
562
+ }
563
+ return Object.values(record).some((value) => schemaHasOptionalProperties(value));
564
+ }
565
+ /**
566
+ * One model turn: emits `turn_started`, makes a single `generateText` call
567
+ * (`stopWhen: stepCountIs(1)`, `maxRetries: 0`, abort/inactivity signal),
568
+ * applies the missing-content rule, emits `assistant_text` (when text is
569
+ * non-empty) then `usage_reported` and `turn_completed` with the
570
+ * cumulative per-tool telemetry providerData. Transient failures retry up
571
+ * to `apiRetryLimit` times with backoff and the standard `api_retry`
572
+ * event; a successful call consumes one step.
573
+ */
574
+ async function runAgentModelTurn(ctx, turnOptions) {
575
+ const base = {
576
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
577
+ role: ctx.role,
578
+ ...(ctx.label !== undefined ? { label: ctx.label } : {}),
579
+ sessionHandle: ctx.sessionHandle,
580
+ };
581
+ // The finalization schema is submitted as an SDK-native json_schema constraint.
582
+ // `@ai-sdk/openai-compatible` defaults strict: true, which requires every
583
+ // property to be in `required` and rejects an omitted optional property before
584
+ // the validator runs. For schemas that legitimately carry an optional property
585
+ // (the plan reviewer's findingClass, the consultant's
586
+ // targetCanonicalIds), disable strict mode on the request so the omitted
587
+ // property survives to the tolerant validator, which then applies its canonical
588
+ // default. The schema still rides as a json_schema constraint and the validator
589
+ // stays the real contract; all-required schemas keep strict enforcement.
590
+ const relaxStrictJsonSchema = turnOptions.structuredOutput !== undefined && schemaHasOptionalProperties(turnOptions.structuredOutput.schema);
591
+ let apiRetryCount = 0;
592
+ while (true) {
593
+ try {
594
+ // turn_started before each model call so startup silence is
595
+ // observable by the liveness watchdog.
596
+ await emitProviderEvent(ctx.events, { type: 'turn_started', ...base });
597
+ const turnAbort = createTurnAbortController(ctx.signal, ctx.inactivityTimeoutMs);
598
+ const result = await generateText({
599
+ model: ctx.state.model,
600
+ messages: ctx.state.messages,
601
+ ...(turnOptions.useTools ? { tools: ctx.state.tools } : {}),
602
+ ...(turnOptions.structuredOutput
603
+ ? { output: Output.object({ schema: jsonSchema(turnOptions.structuredOutput.schema) }) }
604
+ : {}),
605
+ ...(relaxStrictJsonSchema
606
+ ? { providerOptions: { openaiCompatible: { strictJsonSchema: false } } }
607
+ : {}),
608
+ maxRetries: 0,
609
+ stopWhen: stepCountIs(1),
610
+ abortSignal: turnAbort.signal,
611
+ }).finally(() => {
612
+ turnAbort.dispose();
613
+ });
614
+ // Reasoning-model fallback: when content text is empty the final
615
+ // text can land in reasoningText (mirrors extractAssistantText in
616
+ // the bespoke adapter).
617
+ const assistantText = result.text.trim().length > 0 ? result.text : (result.reasoningText ?? '');
618
+ if (result.toolCalls.length === 0 && assistantText.trim().length === 0) {
619
+ // Missing-content rule: covers embedded HTTP-200 gateway errors
620
+ // and reasoning-only responses on the AI SDK path — transient, so
621
+ // it flows into the bounded retry loop.
622
+ throw createOpenAICompatibleProviderError({
623
+ message: `openai-compatible ${ctx.label ?? ctx.role} turn produced neither tool calls nor ` +
624
+ 'non-empty assistant text (after the reasoning-text fallback).',
625
+ role: ctx.role,
626
+ sessionHandle: ctx.sessionHandle,
627
+ kind: 'api_error',
628
+ retryable: true,
629
+ });
630
+ }
631
+ // Discovery (recorded, ai@6.0.202): the SDK awaits structured-output
632
+ // parsing inside `generateText` itself, so a non-JSON or
633
+ // schema-mismatched final text throws `NoObjectGeneratedError`
634
+ // (carrying the rejected assistant text as `.text`) from the call
635
+ // above — classified in the catch below. The `result.output` getter
636
+ // throws the *distinct* `NoOutputGeneratedError` only when no output
637
+ // was resolved because the turn's finish reason was not 'stop' (for
638
+ // example a length-truncated response); that case is mapped here.
639
+ let structuredOutputValue;
640
+ if (turnOptions.structuredOutput) {
641
+ const { schemaLabel } = turnOptions.structuredOutput;
642
+ try {
643
+ structuredOutputValue = result.output;
644
+ }
645
+ catch (outputError) {
646
+ throw createOpenAICompatibleProviderError({
647
+ message: `openai-compatible ${ctx.label ?? ctx.role} finalization turn completed without a ` +
648
+ `"${schemaLabel}" structured output (the response did not finish cleanly).`,
649
+ role: ctx.role,
650
+ sessionHandle: ctx.sessionHandle,
651
+ kind: 'structured_output_missing',
652
+ retryable: false,
653
+ // Carry the turn's assistant text as `text` so the rejected
654
+ // excerpt rides into provider_error providerData.diagnostic.
655
+ cause: {
656
+ message: describeError(outputError),
657
+ text: assistantText,
658
+ cause: outputError,
659
+ },
660
+ });
661
+ }
662
+ }
663
+ ctx.state.steps += 1;
664
+ if (assistantText.trim().length > 0) {
665
+ await emitProviderEvent(ctx.events, {
666
+ type: 'assistant_text',
667
+ ...base,
668
+ text: assistantText,
669
+ });
670
+ }
671
+ // Cumulative per-tool telemetry so run records are self-sufficient for
672
+ // per-tool error attribution. Advisor rounds add the cumulative step
673
+ // count (see includeStepsTelemetry).
674
+ const providerData = {
675
+ toolCalls: { ...ctx.state.toolCalls },
676
+ toolErrors: { ...ctx.state.toolErrors },
677
+ ...(ctx.includeStepsTelemetry ? { steps: ctx.state.steps } : {}),
678
+ };
679
+ // Rate-computed cost: config pricing (when present) wins; otherwise a
680
+ // card-listed model is priced by its resolved slug with no configuration.
681
+ // When neither yields pricing the cost fields are omitted so the
682
+ // tokens-only event shape is preserved (resolveRateCost returns null,
683
+ // matching the prior `computeRateCostUsd`-returns-0-for-empty behavior).
684
+ const cost = resolveRateCost({
685
+ usage: result.usage,
686
+ model: ctx.state.modelSlug,
687
+ configPricing: ctx.state.pricing,
688
+ }) ?? {};
689
+ await emitProviderEvent(ctx.events, {
690
+ type: 'usage_reported',
691
+ ...base,
692
+ usage: result.usage,
693
+ ...cost,
694
+ providerData,
695
+ });
696
+ await emitProviderEvent(ctx.events, {
697
+ type: 'turn_completed',
698
+ ...base,
699
+ usage: result.usage,
700
+ ...cost,
701
+ providerData,
702
+ });
703
+ return {
704
+ assistantText,
705
+ toolCallCount: result.toolCalls.length,
706
+ responseMessages: result.response.messages,
707
+ ...(turnOptions.structuredOutput ? { structuredOutput: structuredOutputValue } : {}),
708
+ };
709
+ }
710
+ catch (error) {
711
+ // Structured-output failure semantics (no repair): NoObjectGeneratedError
712
+ // is classified before generic normalization. An empty rejected text is
713
+ // the missing-content rule (a transport fault — embedded HTTP-200
714
+ // gateway errors, reasoning-only responses — so it stays retryable);
715
+ // any other rejected text is the model failing the structured-output
716
+ // contract: non-retryable `structured_output_missing` with the SDK
717
+ // error (and its `.text` excerpt) as the cause.
718
+ let classified = error;
719
+ // Content-safety refusal takes precedence over the structured-finalization
720
+ // classification below: a refusal returned as an HTTP-400 finalization
721
+ // rejection must land as a terminal content_refused, never
722
+ // structured_output_invalid. Matched on the error channel (describeError
723
+ // reads error.message) only, never the assistant content channel, so a
724
+ // benign schema rejection (no refusal substrings) keeps its
725
+ // structured_output_invalid mapping.
726
+ if (isContentSafetyRefusalMessage(describeError(error))) {
727
+ classified = createOpenAICompatibleProviderError({
728
+ message: `openai-compatible ${ctx.label ?? ctx.role} request was refused on content-safety grounds: ${describeError(error)}`,
729
+ role: ctx.role,
730
+ sessionHandle: ctx.sessionHandle,
731
+ kind: 'content_refused',
732
+ retryable: false,
733
+ cause: error,
734
+ });
735
+ }
736
+ else if (turnOptions.structuredOutput && NoObjectGeneratedError.isInstance(error)) {
737
+ const { schemaLabel } = turnOptions.structuredOutput;
738
+ classified =
739
+ (error.text ?? '').trim() === ''
740
+ ? createOpenAICompatibleProviderError({
741
+ message: `openai-compatible ${ctx.label ?? ctx.role} finalization turn produced neither tool calls nor ` +
742
+ 'non-empty assistant text (after the reasoning-text fallback).',
743
+ role: ctx.role,
744
+ sessionHandle: ctx.sessionHandle,
745
+ kind: 'api_error',
746
+ retryable: true,
747
+ cause: error,
748
+ })
749
+ : createOpenAICompatibleProviderError({
750
+ message: `openai-compatible ${ctx.label ?? ctx.role} finalization turn did not produce a parsable ` +
751
+ `"${schemaLabel}" JSON payload: ${describeError(error)}`,
752
+ role: ctx.role,
753
+ sessionHandle: ctx.sessionHandle,
754
+ kind: 'structured_output_missing',
755
+ retryable: false,
756
+ cause: error,
757
+ });
758
+ }
759
+ else if (turnOptions.structuredOutput &&
760
+ typeof error === 'object' &&
761
+ error !== null &&
762
+ readStatusCode(error) === 400) {
763
+ // Request-time schema rejection: now that the SDK sends
764
+ // `response_format.type: 'json_schema'`, a gateway/model that cannot
765
+ // honor schema-enforced JSON rejects the request with HTTP 400. That
766
+ // is the model failing the structured-output contract, not a generic
767
+ // transport fault, so it is an attributable, non-retryable
768
+ // structured_output_invalid (the same compat `structured_output`
769
+ // mode as the NoObjectGeneratedError/validator paths) rather than the
770
+ // `provider_failed` bucket normalizeOpenAICompatibleError would assign.
771
+ // The narrowing to a structured-output turn keeps a 400 on a normal
772
+ // tool turn on its existing `provider_failed` mapping. The raw SDK
773
+ // error rides as the cause so its message/responseBody reach the
774
+ // provider_error diagnostic, exactly like the NoObjectGeneratedError
775
+ // branch above.
776
+ const { schemaLabel } = turnOptions.structuredOutput;
777
+ classified = createOpenAICompatibleProviderError({
778
+ message: `openai-compatible ${ctx.label ?? ctx.role} finalization turn was rejected with HTTP 400 ` +
779
+ `while requesting schema-enforced "${schemaLabel}" structured output ` +
780
+ `(the model or gateway does not honor json_schema response_format): ${describeError(error)}`,
781
+ role: ctx.role,
782
+ sessionHandle: ctx.sessionHandle,
783
+ kind: 'structured_output_invalid',
784
+ retryable: false,
785
+ cause: error,
786
+ });
787
+ }
788
+ const providerError = normalizeOpenAICompatibleError(classified, {
789
+ role: ctx.role,
790
+ label: ctx.label ?? ctx.role,
791
+ sessionHandle: ctx.sessionHandle,
792
+ callerSignal: ctx.signal,
793
+ });
794
+ // Caller cancellation never burns retry budget: a caller-signal
795
+ // abort is non-retryable and the aborted check is defensive depth.
796
+ if (providerError.retryable && apiRetryCount < ctx.apiRetryLimit && !ctx.signal?.aborted) {
797
+ apiRetryCount += 1;
798
+ await emitProviderEvent(ctx.events, {
799
+ type: 'tool_progress',
800
+ ...base,
801
+ toolName: 'api_retry',
802
+ message: `transient API failure; retrying (${apiRetryCount}/${ctx.apiRetryLimit})`,
803
+ isError: true,
804
+ providerData: {
805
+ retryCount: apiRetryCount,
806
+ retryLimit: ctx.apiRetryLimit,
807
+ message: providerError.message,
808
+ },
809
+ });
810
+ await ctx.sleep(getApiRetryDelayMs(apiRetryCount));
811
+ continue;
812
+ }
813
+ throw providerError;
814
+ }
815
+ }
816
+ }
817
+ /**
818
+ * The dedicated SDK-native structured-output finalization turn, shared by the
819
+ * coder and structured-advisor paths: appends one user message requesting the
820
+ * final control payload, runs exactly one no-tools `runAgentModelTurn` with
821
+ * `output: Output.object(...)`, validates the SDK-parsed object with the
822
+ * protocol spec's validator (the single source of truth — the SDK schema is
823
+ * transport-level enforcement of the same JSON schema object, not a parallel
824
+ * contract), and emits `structured_output_received` on success.
825
+ *
826
+ * Why a dedicated turn instead of constraining the tool loop itself:
827
+ * - it preserves per-turn liveness granularity (each turn keeps its own
828
+ * `turn_started`/inactivity-abort window);
829
+ * - it avoids applying `response_format` to tool-calling turns, a known
830
+ * cross-provider compatibility gap on OpenAI-compatible gateways;
831
+ * - it sidesteps the SDK's documented +1-step interaction between
832
+ * structured-output generation and `stopWhen`.
833
+ *
834
+ * The turn goes through the existing `runAgentModelTurn` machinery, so the
835
+ * inactivity abort, caller-signal composition, the missing-content rule, and
836
+ * the bounded `api_retry` transient loop are the same code path; transport
837
+ * retries are not model repair. The turn increments the `steps` telemetry
838
+ * counter like any successful turn but is never subject to the step cap
839
+ * (the cap binds only inside `runAgentToolLoop`). There are no repair turns,
840
+ * no fence extraction, and no raw-JSON tolerance: a model that cannot answer
841
+ * this turn with the payload fails non-retryably.
842
+ */
843
+ async function runStructuredFinalizationTurn(args) {
844
+ const { ctx, protocol, schema } = args;
845
+ // The schema (and example payload, when the spec provides one) rides in the
846
+ // message because some OpenAI-compatible endpoints silently ignore
847
+ // `response_format` (a known gateway gap); without it those models would
848
+ // have no shape information at all. This is prompt content, not a parsing
849
+ // protocol — nothing reads the text back.
850
+ const promptLines = [
851
+ `Return the final ${protocol.schemaLabel} control payload now as a single JSON object. Do not call tools.`,
852
+ '',
853
+ 'The payload must satisfy this JSON schema:',
854
+ JSON.stringify(schema, null, 2),
855
+ ];
856
+ if (protocol.examplePayload !== undefined) {
857
+ promptLines.push('', 'Example payload:', JSON.stringify(protocol.examplePayload, null, 2));
858
+ }
859
+ ctx.state.messages.push({ role: 'user', content: promptLines.join('\n') });
860
+ const turn = await runAgentModelTurn(ctx, {
861
+ useTools: false,
862
+ structuredOutput: { schema, schemaLabel: protocol.schemaLabel },
863
+ });
864
+ ctx.state.messages.push(...turn.responseMessages);
865
+ let structured;
866
+ try {
867
+ structured = protocol.validator(turn.structuredOutput);
868
+ }
869
+ catch (validationError) {
870
+ throw createOpenAICompatibleProviderError({
871
+ message: `openai-compatible ${ctx.label ?? ctx.role} finalization payload failed "${protocol.schemaLabel}" ` +
872
+ `validation: ${describeError(validationError)}`,
873
+ role: ctx.role,
874
+ sessionHandle: ctx.sessionHandle,
875
+ kind: 'structured_output_invalid',
876
+ retryable: false,
877
+ // The generated JSON rides as `text` on the cause so the excerpt
878
+ // reaches provider_error providerData.diagnostic (same capture as
879
+ // NoObjectGeneratedError.text).
880
+ cause: {
881
+ message: describeError(validationError),
882
+ text: JSON.stringify(turn.structuredOutput, null, 2) ?? String(turn.structuredOutput),
883
+ cause: validationError,
884
+ },
885
+ });
886
+ }
887
+ await emitProviderEvent(ctx.events, {
888
+ type: 'structured_output_received',
889
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
890
+ role: ctx.role,
891
+ ...(ctx.label !== undefined ? { label: ctx.label } : {}),
892
+ sessionHandle: ctx.sessionHandle,
893
+ providerData: { schemaLabel: protocol.schemaLabel },
894
+ });
895
+ return structured;
896
+ }
897
+ /**
898
+ * Appends the protocol spec's `responseShapeHint` (decision-content guidance,
899
+ * not transport instructions) to the base prompt. The base prompt is sent
900
+ * fence-free: no neal-json instructions and no schema dump — the schema is
901
+ * delivered by the finalization turn.
902
+ */
903
+ function appendResponseShapeHint(prompt, protocol) {
904
+ const hint = protocol.responseShapeHint?.trim();
905
+ return hint ? `${prompt}\n\nResponse shape guidance:\n${hint}` : prompt;
906
+ }
907
+ class OpenAICompatibleCoderAdapter {
908
+ options;
909
+ constructor(options) {
910
+ this.options = options;
911
+ }
912
+ async runPrompt(args) {
913
+ const sessionHandle = buildSyntheticSessionHandle();
914
+ try {
915
+ this.assertNoResumeHandle(args.resumeHandle, sessionHandle);
916
+ const state = this.prepareLoopState({
917
+ cwd: args.cwd,
918
+ sessionHandle,
919
+ events: args.events,
920
+ toolPolicy: args.toolPolicy,
921
+ });
922
+ await this.emitSessionStarted({ sessionHandle, events: args.events });
923
+ // CoderRunPromptArgs carries no apiRetryLimit: runPrompt performs no
924
+ // in-round transient retries; the liveness supervisor and orchestrator
925
+ // retries own recovery there.
926
+ const finalResponse = await runAgentToolLoop({
927
+ role: 'coder',
928
+ state,
929
+ sessionHandle,
930
+ inactivityTimeoutMs: args.inactivityTimeoutMs,
931
+ apiRetryLimit: 0,
932
+ stepCap: CODER_STEP_CAP,
933
+ includeStepsTelemetry: false,
934
+ sleep: this.options.sleep ?? defaultSleep,
935
+ signal: args.signal,
936
+ events: args.events,
937
+ }, args.prompt);
938
+ // Never persist a resumable handle for this provider, and never invoke
939
+ // args.onSessionStarted: the orchestrator persists that callback's
940
+ // handle and would then demand session_resume support.
941
+ return { sessionHandle: null, finalResponse };
942
+ }
943
+ catch (error) {
944
+ throw await this.surfaceError(error, {
945
+ sessionHandle,
946
+ events: args.events,
947
+ callerSignal: args.signal,
948
+ });
949
+ }
950
+ }
951
+ async runStructuredPrompt(args) {
952
+ const sessionHandle = buildSyntheticSessionHandle();
953
+ try {
954
+ this.assertNoResumeHandle(args.resumeHandle, sessionHandle);
955
+ const protocol = args.structuredJsonProtocol;
956
+ if (!protocol || protocol.protocol !== 'neal-json-block-v1') {
957
+ // The spec is still required: it carries the schema, validator, and
958
+ // labels that drive the SDK-native structured-output finalization
959
+ // turn (the fence protocol itself is never rendered here).
960
+ throw createOpenAICompatibleProviderError({
961
+ message: `openai-compatible ${args.label} prompts require the neal-json-block-v1 structured JSON ` +
962
+ 'protocol spec; its schema, validator, and labels drive the SDK-native structured-output ' +
963
+ 'finalization turn.',
964
+ role: 'coder',
965
+ sessionHandle,
966
+ kind: 'provider_failed',
967
+ retryable: false,
968
+ });
969
+ }
970
+ if (typeof protocol.schema === 'string') {
971
+ // Configuration error: the SDK's jsonSchema(...) needs the object
972
+ // form. No runtime caller passes the string arm of the spec's
973
+ // schema union today.
974
+ throw createOpenAICompatibleProviderError({
975
+ message: `openai-compatible ${args.label} prompts require an object-form JSON schema for ` +
976
+ `"${protocol.schemaLabel}"; a string schema cannot drive the SDK-native structured-output ` +
977
+ 'finalization turn.',
978
+ role: 'coder',
979
+ sessionHandle,
980
+ kind: 'provider_failed',
981
+ retryable: false,
982
+ });
983
+ }
984
+ const state = this.prepareLoopState({
985
+ cwd: args.cwd,
986
+ sessionHandle,
987
+ label: args.label,
988
+ events: args.events,
989
+ toolPolicy: args.toolPolicy,
990
+ });
991
+ await this.emitSessionStarted({ sessionHandle, label: args.label, events: args.events });
992
+ const turnContext = {
993
+ role: 'coder',
994
+ state,
995
+ sessionHandle,
996
+ label: args.label,
997
+ inactivityTimeoutMs: args.inactivityTimeoutMs,
998
+ apiRetryLimit: args.apiRetryLimit ?? 0,
999
+ stepCap: CODER_STEP_CAP,
1000
+ includeStepsTelemetry: false,
1001
+ sleep: this.options.sleep ?? defaultSleep,
1002
+ signal: args.signal,
1003
+ events: args.events,
1004
+ };
1005
+ // The tool loop runs to its normal zero-tool-call completion turn on a
1006
+ // fence-free prompt; its assistant text stays in the shared history.
1007
+ // Then exactly one finalization turn produces the structured payload.
1008
+ await runAgentToolLoop(turnContext, appendResponseShapeHint(args.prompt, protocol));
1009
+ const structured = await runStructuredFinalizationTurn({
1010
+ ctx: turnContext,
1011
+ protocol,
1012
+ schema: protocol.schema,
1013
+ });
1014
+ // Never persist a resumable handle; args.onSessionStarted is never
1015
+ // invoked for this provider (see runPrompt).
1016
+ return { sessionHandle: null, structured };
1017
+ }
1018
+ catch (error) {
1019
+ throw await this.surfaceError(error, {
1020
+ sessionHandle,
1021
+ label: args.label,
1022
+ events: args.events,
1023
+ callerSignal: args.signal,
1024
+ });
1025
+ }
1026
+ }
1027
+ assertNoResumeHandle(resumeHandle, sessionHandle) {
1028
+ if (resumeHandle === undefined || resumeHandle === null) {
1029
+ return;
1030
+ }
1031
+ // Defensive: this provider never persists a session handle, so a
1032
+ // non-null resume handle can only come from corrupted run state.
1033
+ throw createOpenAICompatibleProviderError({
1034
+ message: 'openai-compatible does not support session resume and never persists session handles, ' +
1035
+ `but a resume handle was provided (${JSON.stringify(resumeHandle)}); this indicates corrupted run state.`,
1036
+ role: 'coder',
1037
+ sessionHandle,
1038
+ kind: 'session_unavailable',
1039
+ retryable: false,
1040
+ });
1041
+ }
1042
+ prepareLoopState(args) {
1043
+ const resolveSettings = this.options.resolveSettings ?? getOpenAICompatibleSettings;
1044
+ const settings = resolveOpenAICompatibleSettings({
1045
+ cwd: args.cwd,
1046
+ roundModel: null,
1047
+ roleModel: this.options.model ?? null,
1048
+ context: {
1049
+ role: 'coder',
1050
+ noun: 'coder',
1051
+ subject: 'coder run',
1052
+ modelOverrideExample: 'agent.coder.model',
1053
+ },
1054
+ resolveSettings,
1055
+ });
1056
+ const createModel = this.options.createModel ?? createDefaultOpenAICompatibleModel;
1057
+ const model = createModel({
1058
+ baseUrl: settings.baseUrl,
1059
+ apiKey: settings.apiKey,
1060
+ headers: settings.headers,
1061
+ model: settings.model,
1062
+ });
1063
+ const state = {
1064
+ model,
1065
+ modelSlug: settings.model,
1066
+ // Assigned immediately below; the toolset's event hook needs the state
1067
+ // object to update the cumulative per-tool telemetry maps.
1068
+ tools: undefined,
1069
+ messages: [],
1070
+ toolCalls: {},
1071
+ toolErrors: {},
1072
+ steps: 0,
1073
+ pricing: settings.pricing,
1074
+ };
1075
+ const createToolset = args.toolPolicy?.allowRun === false ? createPlanAuthorToolset : createCoderToolset;
1076
+ state.tools = createToolset(args.cwd, {
1077
+ allowedWritePaths: args.toolPolicy?.allowedWritePaths,
1078
+ emitToolEvent: (event) => forwardAgentToolEvent(event, {
1079
+ role: 'coder',
1080
+ state,
1081
+ sessionHandle: args.sessionHandle,
1082
+ label: args.label,
1083
+ events: args.events,
1084
+ }),
1085
+ });
1086
+ return state;
1087
+ }
1088
+ async emitSessionStarted(args) {
1089
+ await emitProviderEvent(args.events, {
1090
+ type: 'session_started',
1091
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
1092
+ role: 'coder',
1093
+ ...(args.label !== undefined ? { label: args.label } : {}),
1094
+ sessionHandle: args.sessionHandle,
1095
+ });
1096
+ }
1097
+ async surfaceError(error, ctx) {
1098
+ const providerError = normalizeOpenAICompatibleError(error, {
1099
+ role: 'coder',
1100
+ label: ctx.label ?? 'coder',
1101
+ sessionHandle: ctx.sessionHandle,
1102
+ callerSignal: ctx.callerSignal,
1103
+ });
1104
+ await emitProviderEvent(ctx.events, {
1105
+ type: 'provider_error',
1106
+ provider: OPENAI_COMPATIBLE_PROVIDER_ID,
1107
+ role: 'coder',
1108
+ ...(ctx.label !== undefined ? { label: ctx.label } : {}),
1109
+ sessionHandle: providerError.sessionHandle ?? ctx.sessionHandle,
1110
+ message: providerError.message,
1111
+ errorKind: providerError.kind,
1112
+ providerData: providerErrorData(providerError),
1113
+ });
1114
+ // Thrown errors must not carry the events-only synthetic handle (see
1115
+ // withEventsOnlySessionHandle): the orchestrator persists it from the
1116
+ // error and resume would then demand session_resume support.
1117
+ return withEventsOnlySessionHandle(providerError);
1118
+ }
1119
+ }
1120
+ /**
1121
+ * Structured-advisor adapter with a bounded read-only tool loop: rounds run
1122
+ * the shared outer loop over `createReadOnlyToolset` (`read_file`,
1123
+ * `list_dir`, `grep`, `git_diff`; never write or shell) until a zero-tool-call turn,
1124
+ * then exactly one SDK-native structured-output finalization turn produces
1125
+ * the round payload (`runStructuredFinalizationTurn`, shared with the coder
1126
+ * structured path). Settings resolution, transient retry,
1127
+ * abort/inactivity composition, and the missing-content rule are the shared
1128
+ * per-turn machinery. This is the adapter
1129
+ * `runCoderFinalCompletionSummaryRound` uses when the coder is
1130
+ * `openai-compatible`, and it is what lets a generic reviewer inspect the
1131
+ * repository directly instead of relying on Neal-inlined context. Advisor
1132
+ * rounds are stateless: no advisor handle is ever persisted, so
1133
+ * `args.resumeHandle` is silently ignored (unlike the coder path's
1134
+ * `session_unavailable` guard).
1135
+ */
429
1136
  class OpenAICompatibleStructuredAdvisorAdapter {
430
1137
  options;
431
- constructor(options = {}) {
1138
+ constructor(options) {
432
1139
  this.options = options;
433
1140
  }
434
1141
  async runStructuredRound(args) {
@@ -436,20 +1143,47 @@ class OpenAICompatibleStructuredAdvisorAdapter {
436
1143
  try {
437
1144
  const protocol = args.structuredJsonProtocol;
438
1145
  if (!protocol || protocol.protocol !== 'neal-json-block-v1') {
1146
+ // The spec is still required: it carries the schema, validator, and
1147
+ // labels that drive the SDK-native structured-output finalization
1148
+ // turn (the fence protocol itself is never rendered here).
1149
+ throw createOpenAICompatibleProviderError({
1150
+ message: `openai-compatible ${args.label} rounds require the neal-json-block-v1 structured JSON ` +
1151
+ 'protocol spec; its schema, validator, and labels drive the SDK-native structured-output ' +
1152
+ 'finalization turn.',
1153
+ role: 'structured-advisor',
1154
+ sessionHandle,
1155
+ kind: 'provider_failed',
1156
+ retryable: false,
1157
+ });
1158
+ }
1159
+ if (typeof protocol.schema === 'string') {
1160
+ // Configuration error: the SDK's jsonSchema(...) needs the object
1161
+ // form. No runtime caller passes the string arm of the spec's
1162
+ // schema union today.
439
1163
  throw createOpenAICompatibleProviderError({
440
- message: `OpenAI-compatible ${args.label} rounds require the neal-json-block-v1 structured JSON protocol; ` +
441
- 'this provider does not support provider-native structured output schemas.',
1164
+ message: `openai-compatible ${args.label} rounds require an object-form JSON schema for ` +
1165
+ `"${protocol.schemaLabel}"; a string schema cannot drive the SDK-native structured-output ` +
1166
+ 'finalization turn.',
1167
+ role: 'structured-advisor',
442
1168
  sessionHandle,
443
1169
  kind: 'provider_failed',
444
1170
  retryable: false,
445
1171
  });
446
1172
  }
447
1173
  const resolveSettings = this.options.resolveSettings ?? getOpenAICompatibleSettings;
448
- const settings = resolveRoundSettings({
1174
+ const settings = resolveOpenAICompatibleSettings({
449
1175
  cwd: args.cwd,
450
- roleModel: this.options.model ?? null,
1176
+ // Round-level args.model wins over the adapter/role model and
1177
+ // default_model (matching resolveRoundSettings in the bespoke
1178
+ // adapter).
451
1179
  roundModel: args.model ?? null,
452
- label: args.label,
1180
+ roleModel: this.options.model ?? null,
1181
+ context: {
1182
+ role: 'structured-advisor',
1183
+ noun: args.label,
1184
+ subject: `${args.label} round`,
1185
+ modelOverrideExample: 'agent.reviewer.model',
1186
+ },
453
1187
  resolveSettings,
454
1188
  });
455
1189
  const createModel = this.options.createModel ?? createDefaultOpenAICompatibleModel;
@@ -458,8 +1192,47 @@ class OpenAICompatibleStructuredAdvisorAdapter {
458
1192
  apiKey: settings.apiKey,
459
1193
  headers: settings.headers,
460
1194
  model: settings.model,
461
- fetch: createInterceptingFetch({ label: args.label, sessionHandle }),
462
1195
  });
1196
+ const state = {
1197
+ model,
1198
+ modelSlug: settings.model,
1199
+ // Assigned immediately below; the toolset's event hook needs the
1200
+ // state object to update the cumulative per-tool telemetry maps.
1201
+ tools: undefined,
1202
+ messages: [],
1203
+ toolCalls: {},
1204
+ toolErrors: {},
1205
+ steps: 0,
1206
+ pricing: settings.pricing,
1207
+ };
1208
+ // Read-only inspection toolset only: advisor rounds must never gain
1209
+ // write or shell access. Tool events forward with the advisor role and
1210
+ // the round label.
1211
+ state.tools = createReadOnlyToolset(args.cwd, {
1212
+ emitToolEvent: (event) => forwardAgentToolEvent(event, {
1213
+ role: 'structured-advisor',
1214
+ state,
1215
+ sessionHandle,
1216
+ label: args.label,
1217
+ events: args.events,
1218
+ }),
1219
+ });
1220
+ const turnContext = {
1221
+ role: 'structured-advisor',
1222
+ state,
1223
+ sessionHandle,
1224
+ label: args.label,
1225
+ inactivityTimeoutMs: args.inactivityTimeoutMs,
1226
+ apiRetryLimit: args.apiRetryLimit,
1227
+ stepCap: advisorStepCap(args.label),
1228
+ includeStepsTelemetry: true,
1229
+ sleep: this.options.sleep ?? defaultSleep,
1230
+ // Caller cancellation is wired into every turn — the read-only tool
1231
+ // loop and the finalization turn alike (matching the coder
1232
+ // structured path).
1233
+ signal: args.signal,
1234
+ events: args.events,
1235
+ };
463
1236
  await emitProviderEvent(args.events, {
464
1237
  type: 'session_started',
465
1238
  provider: OPENAI_COMPATIBLE_PROVIDER_ID,
@@ -467,55 +1240,24 @@ class OpenAICompatibleStructuredAdvisorAdapter {
467
1240
  label: args.label,
468
1241
  sessionHandle,
469
1242
  });
470
- const result = await runStructuredJsonProtocol({
471
- provider: OPENAI_COMPATIBLE_PROVIDER_ID,
472
- role: 'structured-advisor',
473
- label: args.label,
1243
+ // The read-only tool loop runs to its normal zero-tool-call completion
1244
+ // turn on a fence-free prompt; its assistant text stays in the shared
1245
+ // history. Then exactly one finalization turn produces the structured
1246
+ // payload (it increments the `steps` telemetry like any successful
1247
+ // turn but is never subject to the advisor step cap, which binds only
1248
+ // inside the tool loop).
1249
+ await runAgentToolLoop(turnContext, appendResponseShapeHint(args.prompt, protocol));
1250
+ const structured = await runStructuredFinalizationTurn({
1251
+ ctx: turnContext,
474
1252
  protocol,
475
- prompt: args.prompt,
476
- events: args.events,
477
- initialSessionHandle: sessionHandle,
478
- runInitial: async (prompt) => ({
479
- assistantText: await this.runChatTurnWithRetry({
480
- model,
481
- modelSlug: settings.model,
482
- prompt,
483
- roundArgs: args,
484
- sessionHandle,
485
- pricing: settings.pricing,
486
- // Only the primary turn is wired to external cancellation; repair
487
- // turns are short prompt-only turns (matching other adapters).
488
- signal: args.signal,
489
- }),
490
- sessionHandle,
491
- }),
492
- runRepair: async (prompt) => ({
493
- assistantText: await this.runChatTurnWithRetry({
494
- model,
495
- modelSlug: settings.model,
496
- prompt,
497
- roundArgs: args,
498
- sessionHandle,
499
- pricing: settings.pricing,
500
- }),
501
- sessionHandle,
502
- }),
503
- createProviderError: (errorArgs) => createOpenAICompatibleProviderError({
504
- message: errorArgs.message,
505
- sessionHandle: errorArgs.sessionHandle,
506
- kind: errorArgs.kind,
507
- retryable: false,
508
- cause: errorArgs.cause,
509
- }),
1253
+ schema: protocol.schema,
510
1254
  });
511
- return {
512
- // Never persist a resumable handle for this provider.
513
- sessionHandle: null,
514
- structured: result.structured,
515
- };
1255
+ // Never persist a resumable handle for this provider.
1256
+ return { sessionHandle: null, structured };
516
1257
  }
517
1258
  catch (error) {
518
1259
  const providerError = normalizeOpenAICompatibleError(error, {
1260
+ role: 'structured-advisor',
519
1261
  label: args.label,
520
1262
  sessionHandle,
521
1263
  callerSignal: args.signal,
@@ -528,155 +1270,61 @@ class OpenAICompatibleStructuredAdvisorAdapter {
528
1270
  sessionHandle: providerError.sessionHandle ?? sessionHandle,
529
1271
  message: providerError.message,
530
1272
  errorKind: providerError.kind,
1273
+ providerData: providerErrorData(providerError),
531
1274
  });
532
- throw providerError;
533
- }
534
- }
535
- /**
536
- * One chat turn with the bounded transient-retry loop: emits
537
- * `turn_started`, makes a single tool-less `generateText` call
538
- * (`maxRetries: 0`, abort/inactivity signal; no sampling parameters —
539
- * reasoning-family models such as OpenAI's o-series and gpt-5.x reject any
540
- * non-default `temperature` with HTTP 400, and the structured-output
541
- * protocol, not sampling, is what disciplines the response), applies
542
- * the reasoning-text fallback, then emits `assistant_text`,
543
- * `turn_completed`, and `usage_reported` (the latter only when the
544
- * response body actually carried usage, observed structurally via the AI
545
- * SDK's `usage.raw` passthrough — preserving the bespoke adapter's
546
- * usage-is-opportunistic event behavior). Transient failures retry up to
547
- * `args.apiRetryLimit` times with the standard backoff and `api_retry`
548
- * event shape; a caller abort is non-retryable and never burns retry
549
- * budget.
550
- */
551
- async runChatTurnWithRetry(callArgs) {
552
- const { roundArgs } = callArgs;
553
- const sleep = this.options.sleep ?? defaultSleep;
554
- const apiRetryLimit = roundArgs.apiRetryLimit;
555
- const base = {
556
- provider: OPENAI_COMPATIBLE_PROVIDER_ID,
557
- role: 'structured-advisor',
558
- label: roundArgs.label,
559
- sessionHandle: callArgs.sessionHandle,
560
- };
561
- let apiRetryCount = 0;
562
- while (true) {
563
- try {
564
- await emitProviderEvent(roundArgs.events, { type: 'turn_started', ...base });
565
- const result = await generateText({
566
- model: callArgs.model,
567
- prompt: callArgs.prompt,
568
- maxRetries: 0,
569
- abortSignal: composeAbortSignal(callArgs.signal, roundArgs.inactivityTimeoutMs),
570
- });
571
- // Reasoning-model fallback: the AI SDK surfaces `message.reasoning`
572
- // and `message.reasoning_content` as reasoningText. The intercepting
573
- // fetch already rejected bodies with no assistant text in any field,
574
- // so this guard is defense in depth for non-body-shaped gaps.
575
- const assistantText = result.text.trim().length > 0 ? result.text : (result.reasoningText ?? '');
576
- if (assistantText.trim().length === 0) {
577
- throw createOpenAICompatibleProviderError({
578
- message: `OpenAI-compatible ${roundArgs.label} response did not include assistant text ` +
579
- '(after the reasoning-text fallback).',
580
- sessionHandle: callArgs.sessionHandle,
581
- kind: 'api_error',
582
- retryable: true,
583
- });
584
- }
585
- await emitProviderEvent(roundArgs.events, {
586
- type: 'assistant_text',
587
- ...base,
588
- text: assistantText,
589
- });
590
- // usage.raw is the AI SDK's passthrough of the response body's usage
591
- // object; it is undefined when the body carried no usage (structural
592
- // marker, matching the bespoke adapter's "only report usage when the
593
- // gateway provided it" behavior).
594
- const hasUsage = result.usage.raw !== undefined;
595
- // Rate-computed cost only when the round has usage. Config pricing (when
596
- // present) wins; otherwise a card-listed model is priced by its resolved
597
- // slug with no configuration. When neither yields pricing the cost fields
598
- // are omitted entirely so the tokens-only event shape stays byte-stable.
599
- const cost = hasUsage
600
- ? (resolveRateCost({
601
- usage: result.usage,
602
- model: callArgs.modelSlug,
603
- configPricing: callArgs.pricing,
604
- }) ?? {})
605
- : {};
606
- await emitProviderEvent(roundArgs.events, {
607
- type: 'turn_completed',
608
- ...base,
609
- ...(hasUsage ? { usage: result.usage } : {}),
610
- ...cost,
611
- });
612
- if (hasUsage) {
613
- await emitProviderEvent(roundArgs.events, {
614
- type: 'usage_reported',
615
- ...base,
616
- usage: result.usage,
617
- ...cost,
618
- });
619
- }
620
- return assistantText;
621
- }
622
- catch (error) {
623
- const providerError = normalizeOpenAICompatibleError(error, {
624
- label: roundArgs.label,
625
- sessionHandle: callArgs.sessionHandle,
626
- callerSignal: callArgs.signal,
627
- });
628
- // Caller cancellation never burns retry budget: a caller-signal
629
- // abort is non-retryable and the aborted check is defensive depth.
630
- if (providerError.retryable && apiRetryCount < apiRetryLimit && !callArgs.signal?.aborted) {
631
- apiRetryCount += 1;
632
- await emitProviderEvent(roundArgs.events, {
633
- type: 'tool_progress',
634
- ...base,
635
- toolName: 'api_retry',
636
- message: `transient API failure; retrying (${apiRetryCount}/${apiRetryLimit})`,
637
- isError: true,
638
- providerData: {
639
- retryCount: apiRetryCount,
640
- retryLimit: apiRetryLimit,
641
- message: providerError.message,
642
- },
643
- });
644
- await sleep(getApiRetryDelayMs(apiRetryCount));
645
- continue;
646
- }
647
- throw providerError;
648
- }
1275
+ // Strip the events-only handle before throwing: the final-completion
1276
+ // summary path persists `error.sessionHandle` into
1277
+ // `state.coderSessionHandle` (src/neal/orchestrator/completion.ts), and
1278
+ // a persisted handle rejects `neal resume` for this provider.
1279
+ throw withEventsOnlySessionHandle(providerError);
649
1280
  }
650
1281
  }
651
1282
  }
1283
+ export function createOpenAICompatibleCoderAdapter(options = {}) {
1284
+ return new OpenAICompatibleCoderAdapter(options);
1285
+ }
652
1286
  export function createOpenAICompatibleStructuredAdvisorAdapter(options = {}) {
653
1287
  return new OpenAICompatibleStructuredAdvisorAdapter(options);
654
1288
  }
655
1289
  export const openAICompatibleProviderDefinition = {
656
1290
  id: OPENAI_COMPATIBLE_PROVIDER_ID,
657
- displayName: 'OpenAI-Compatible',
1291
+ displayName: 'OpenAI-compatible',
658
1292
  capabilities: {
659
1293
  coder: {
660
- supported: false,
661
- toolAccess: { read: false, write: false, shell: false },
1294
+ supported: true,
1295
+ toolAccess: { read: true, write: true, shell: true },
662
1296
  supportsSessionResume: false,
663
1297
  supportsModelOverride: true,
664
- supportsStructuredOutput: false,
665
- usageReporting: 'none',
1298
+ supportsStructuredOutput: true,
1299
+ usageReporting: 'opportunistic',
666
1300
  },
1301
+ // Required so the coder role passes the final-completion
1302
+ // structured-advisor gate (assertAgentConfigSupportsWriterRun asserts
1303
+ // the coder provider's structured-advisor path for
1304
+ // runCoderFinalCompletionSummaryRound). Advisor rounds run the bounded
1305
+ // read-only tool loop (read_file, list_dir, grep), so read is true:
1306
+ // reviewers inspect the repository directly (the 'read-only' review
1307
+ // doctrine mode) instead of receiving Neal-inlined context. Write and
1308
+ // shell stay false — advisor rounds never mutate or execute.
667
1309
  'structured-advisor': {
668
1310
  supported: true,
669
- toolAccess: { read: false, write: false, shell: false },
1311
+ toolAccess: { read: true, write: false, shell: false },
1312
+ // The read-only advisor tool loop exposes a real commit-range diff tool
1313
+ // (git_diff in createReadOnlyToolset), so the read-only review doctrine
1314
+ // instructs that tool directly instead of having Neal inline the diff.
1315
+ providesRangeDiffTool: true,
670
1316
  supportsSessionResume: false,
671
1317
  supportsModelOverride: true,
672
1318
  supportsStructuredOutput: true,
673
1319
  usageReporting: 'opportunistic',
674
1320
  },
675
1321
  },
1322
+ createCoderAdapter: createOpenAICompatibleCoderAdapter,
676
1323
  createStructuredAdvisorAdapter: createOpenAICompatibleStructuredAdvisorAdapter,
677
1324
  };
678
1325
  export const openAICompatibleProviderTestHooks = {
679
1326
  createDefaultOpenAICompatibleModel,
680
1327
  normalizeOpenAICompatibleError,
1328
+ createCoderAdapterWithInjection: (injection, options) => new OpenAICompatibleCoderAdapter({ ...options, ...injection }),
681
1329
  createStructuredAdvisorAdapterWithInjection: (injection, options) => new OpenAICompatibleStructuredAdvisorAdapter({ ...options, ...injection }),
682
1330
  };