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