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