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