@navels/neal 0.1.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +264 -24
- package/dist/neal/agents/prompts.js +7 -15
- package/dist/neal/agents/rounds.js +51 -73
- package/dist/neal/agents/schemas.js +615 -502
- package/dist/neal/agents/structured-json.js +36 -0
- 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 +40 -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/git.js +9 -3
- package/dist/neal/interactive-controls.js +5 -0
- package/dist/neal/orchestrator/completion.js +215 -113
- package/dist/neal/orchestrator/failures.js +7 -0
- package/dist/neal/orchestrator/notifications.js +9 -9
- package/dist/neal/orchestrator/phases/planning.js +124 -112
- package/dist/neal/orchestrator/phases/recovery.js +151 -115
- package/dist/neal/orchestrator/phases/review.js +28 -1
- package/dist/neal/orchestrator/split-plan.js +12 -11
- package/dist/neal/orchestrator/transitions.js +36 -71
- package/dist/neal/orchestrator.js +3 -0
- package/dist/neal/plan-doc.js +24 -1
- package/dist/neal/plan-queue.js +29 -1
- package/dist/neal/prompts/assert-builder.js +8 -1
- package/dist/neal/prompts/execute.js +11 -19
- package/dist/neal/prompts/planning.js +64 -55
- package/dist/neal/prompts/review-doctrine.js +13 -50
- package/dist/neal/prompts/specialized.js +28 -22
- package/dist/neal/prompts/specs.js +223 -17
- package/dist/neal/providers/agent-settings-isolation.js +35 -0
- package/dist/neal/providers/anthropic-claude.js +309 -248
- 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 +102 -204
- package/dist/neal/providers/{generic-agentic-tools.js → openai-compatible-tools.js} +11 -5
- package/dist/neal/providers/openai-compatible.js +1079 -403
- package/dist/neal/providers/openrouter-routing.js +68 -0
- package/dist/neal/providers/pricing.js +124 -0
- package/dist/neal/providers/rate-card.js +2275 -0
- package/dist/neal/providers/registry.js +6 -17
- package/dist/neal/providers/telemetry.js +4 -0
- 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 +36 -7
- 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/run-metrics.js +74 -9
- 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 -110
- package/docs/demo.md +15 -5
- package/docs/maintenance.md +74 -32
- 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 +330 -283
- package/docs/release.md +43 -40
- 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 +20 -15
- package/dist/neal/providers/generic-agentic.js +0 -1238
- package/docs/comparison.md +0 -105
- 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,42 +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';
|
|
34
|
-
import {
|
|
41
|
+
import { withOpenRouterRouting } from './openrouter-routing.js';
|
|
42
|
+
import { resolveRateCost } from './pricing.js';
|
|
43
|
+
import { createCoderToolset, createPlanAuthorToolset, createReadOnlyToolset, } from './openai-compatible-tools.js';
|
|
44
|
+
import { isContentSafetyRefusalMessage, NealProviderError } from './types.js';
|
|
35
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;
|
|
36
68
|
const API_RETRY_BASE_DELAY_MS = 500;
|
|
37
69
|
const API_RETRY_MAX_DELAY_MS = 5_000;
|
|
38
|
-
// Network-level error codes treated as transient
|
|
39
|
-
//
|
|
70
|
+
// Network-level error codes treated as transient, mirroring the spike's
|
|
71
|
+
// isTransientError classification (statusCode 408/429/5xx, explicit
|
|
40
72
|
// isRetryable, or one of these codes on the error or its cause).
|
|
41
73
|
const NETWORK_ERROR_CODES = new Set([
|
|
42
74
|
'ECONNRESET',
|
|
@@ -54,27 +86,78 @@ function defaultSleep(ms) {
|
|
|
54
86
|
});
|
|
55
87
|
}
|
|
56
88
|
function createDefaultOpenAICompatibleModel(args) {
|
|
57
|
-
//
|
|
58
|
-
//
|
|
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.
|
|
59
102
|
return createOpenAICompatible({
|
|
60
103
|
name: OPENAI_COMPATIBLE_PROVIDER_ID,
|
|
61
104
|
baseURL: args.baseUrl,
|
|
62
105
|
apiKey: args.apiKey,
|
|
63
106
|
headers: args.headers,
|
|
64
|
-
|
|
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),
|
|
65
114
|
}).chatModel(args.model);
|
|
66
115
|
}
|
|
67
116
|
function createOpenAICompatibleProviderError(args) {
|
|
68
117
|
return new NealProviderError({
|
|
69
118
|
message: args.message,
|
|
70
119
|
provider: OPENAI_COMPATIBLE_PROVIDER_ID,
|
|
71
|
-
role:
|
|
120
|
+
role: args.role,
|
|
72
121
|
sessionHandle: args.sessionHandle,
|
|
73
122
|
kind: args.kind,
|
|
74
123
|
retryable: args.retryable,
|
|
75
124
|
cause: args.cause,
|
|
76
125
|
});
|
|
77
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
|
+
}
|
|
78
161
|
function readStatusCode(error) {
|
|
79
162
|
const status = error.statusCode;
|
|
80
163
|
return typeof status === 'number' && Number.isFinite(status) ? status : null;
|
|
@@ -86,16 +169,14 @@ function isAbortLikeError(error) {
|
|
|
86
169
|
const name = error.name;
|
|
87
170
|
return name === 'TimeoutError' || name === 'AbortError';
|
|
88
171
|
}
|
|
89
|
-
|
|
90
|
-
// SDK isRetryable marker or a known network error code (on the error or its
|
|
91
|
-
// cause). Status-bearing errors must never reach this check — Neal's own
|
|
92
|
-
// status table is the contract for those, and the AI SDK's APICallError
|
|
93
|
-
// default marks statuses Neal treats as non-retryable (for example 409) as
|
|
94
|
-
// retryable.
|
|
95
|
-
function hasTransientStatuslessShape(error) {
|
|
172
|
+
function hasTransientShape(error) {
|
|
96
173
|
if (error.isRetryable === true) {
|
|
97
174
|
return true;
|
|
98
175
|
}
|
|
176
|
+
const status = readStatusCode(error);
|
|
177
|
+
if (status !== null && (status === 408 || status === 429 || status >= 500)) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
99
180
|
const cause = error.cause;
|
|
100
181
|
const codes = [
|
|
101
182
|
error.code,
|
|
@@ -109,28 +190,87 @@ function describeError(error) {
|
|
|
109
190
|
}
|
|
110
191
|
if (typeof error === 'object' && error !== null) {
|
|
111
192
|
const candidate = error;
|
|
112
|
-
|
|
193
|
+
const message = typeof candidate.message === 'string' ? candidate.message : String(error);
|
|
194
|
+
return typeof candidate.statusCode === 'number'
|
|
195
|
+
? `HTTP ${candidate.statusCode}: ${message}`
|
|
196
|
+
: message;
|
|
113
197
|
}
|
|
114
198
|
return String(error);
|
|
115
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
|
+
}
|
|
116
262
|
/**
|
|
117
|
-
* Normalizes any thrown value into a NealProviderError with the
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* 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:
|
|
121
266
|
*
|
|
122
267
|
* - caller signal aborted -> `timeout`, retryable: false (the caller owns
|
|
123
268
|
* the cancellation; never retried in-round);
|
|
124
269
|
* - abort without a caller abort -> inactivity expiry -> `timeout`,
|
|
125
270
|
* retryable: true;
|
|
126
271
|
* - HTTP 401/403 -> `permission_denied`, non-retryable;
|
|
127
|
-
* - HTTP 408/429/5xx -> `api_error`,
|
|
128
|
-
*
|
|
129
|
-
* status table is authoritative for status-bearing errors; the SDK's
|
|
130
|
-
* `isRetryable` flag is never consulted for them because its default
|
|
131
|
-
* marks 409 retryable, which this contract does not);
|
|
132
|
-
* - no HTTP status with explicit isRetryable or a network error code ->
|
|
133
|
-
* `api_error`, retryable;
|
|
272
|
+
* - HTTP 408/429/5xx, explicit isRetryable, network codes -> `api_error`,
|
|
273
|
+
* retryable;
|
|
134
274
|
* - everything else -> `provider_failed`, non-retryable.
|
|
135
275
|
*/
|
|
136
276
|
function normalizeOpenAICompatibleError(error, ctx) {
|
|
@@ -139,17 +279,35 @@ function normalizeOpenAICompatibleError(error, ctx) {
|
|
|
139
279
|
}
|
|
140
280
|
if (ctx.callerSignal?.aborted) {
|
|
141
281
|
return createOpenAICompatibleProviderError({
|
|
142
|
-
message: `
|
|
282
|
+
message: `openai-compatible ${ctx.label} turn was aborted by the caller before completion.`,
|
|
283
|
+
role: ctx.role,
|
|
143
284
|
sessionHandle: ctx.sessionHandle,
|
|
144
285
|
kind: 'timeout',
|
|
145
286
|
retryable: false,
|
|
146
287
|
cause: error,
|
|
147
288
|
});
|
|
148
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
|
+
}
|
|
149
306
|
if (typeof error === 'object' && error !== null) {
|
|
150
307
|
if (isAbortLikeError(error)) {
|
|
151
308
|
return createOpenAICompatibleProviderError({
|
|
152
|
-
message: `
|
|
309
|
+
message: `openai-compatible ${ctx.label} turn exceeded the inactivity timeout and was aborted.`,
|
|
310
|
+
role: ctx.role,
|
|
153
311
|
sessionHandle: ctx.sessionHandle,
|
|
154
312
|
kind: 'timeout',
|
|
155
313
|
retryable: true,
|
|
@@ -157,38 +315,20 @@ function normalizeOpenAICompatibleError(error, ctx) {
|
|
|
157
315
|
});
|
|
158
316
|
}
|
|
159
317
|
const status = readStatusCode(error);
|
|
160
|
-
if (status
|
|
161
|
-
// Status-bearing errors follow Neal's explicit table exclusively;
|
|
162
|
-
// the SDK's isRetryable flag is intentionally not consulted here.
|
|
163
|
-
if (status === 401 || status === 403) {
|
|
164
|
-
return createOpenAICompatibleProviderError({
|
|
165
|
-
message: `OpenAI-compatible ${ctx.label} request was rejected with HTTP ${status}: ${describeError(error)}`,
|
|
166
|
-
sessionHandle: ctx.sessionHandle,
|
|
167
|
-
kind: 'permission_denied',
|
|
168
|
-
retryable: false,
|
|
169
|
-
cause: error,
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
if (status === 408 || status === 429 || status >= 500) {
|
|
173
|
-
return createOpenAICompatibleProviderError({
|
|
174
|
-
message: `OpenAI-compatible ${ctx.label} request failed with HTTP ${status}: ${describeError(error)}`,
|
|
175
|
-
sessionHandle: ctx.sessionHandle,
|
|
176
|
-
kind: 'api_error',
|
|
177
|
-
retryable: true,
|
|
178
|
-
cause: error,
|
|
179
|
-
});
|
|
180
|
-
}
|
|
318
|
+
if (status === 401 || status === 403) {
|
|
181
319
|
return createOpenAICompatibleProviderError({
|
|
182
|
-
message: `
|
|
320
|
+
message: `openai-compatible ${ctx.label} request was rejected with HTTP ${status}: ${describeError(error)}`,
|
|
321
|
+
role: ctx.role,
|
|
183
322
|
sessionHandle: ctx.sessionHandle,
|
|
184
|
-
kind: '
|
|
323
|
+
kind: 'permission_denied',
|
|
185
324
|
retryable: false,
|
|
186
325
|
cause: error,
|
|
187
326
|
});
|
|
188
327
|
}
|
|
189
|
-
if (
|
|
328
|
+
if (hasTransientShape(error)) {
|
|
190
329
|
return createOpenAICompatibleProviderError({
|
|
191
|
-
message: `
|
|
330
|
+
message: `openai-compatible ${ctx.label} request failed transiently: ${describeError(error)}`,
|
|
331
|
+
role: ctx.role,
|
|
192
332
|
sessionHandle: ctx.sessionHandle,
|
|
193
333
|
kind: 'api_error',
|
|
194
334
|
retryable: true,
|
|
@@ -197,181 +337,44 @@ function normalizeOpenAICompatibleError(error, ctx) {
|
|
|
197
337
|
}
|
|
198
338
|
}
|
|
199
339
|
return createOpenAICompatibleProviderError({
|
|
200
|
-
message: `
|
|
340
|
+
message: `openai-compatible ${ctx.label} request failed: ${describeError(error)}`,
|
|
341
|
+
role: ctx.role,
|
|
201
342
|
sessionHandle: ctx.sessionHandle,
|
|
202
343
|
kind: 'provider_failed',
|
|
203
344
|
retryable: false,
|
|
204
345
|
cause: error,
|
|
205
346
|
});
|
|
206
347
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
}
|
|
212
|
-
if (
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
return null;
|
|
216
|
-
}
|
|
217
|
-
// Some OpenAI-compatible gateways (for example OpenRouter) report upstream
|
|
218
|
-
// provider failures as an `error` object inside an HTTP 200 body instead of a
|
|
219
|
-
// non-200 status. Map those embedded errors with the same status rules as real
|
|
220
|
-
// HTTP errors so upstream rate limits and outages stay retryable.
|
|
221
|
-
function createEmbeddedResponseError(args) {
|
|
222
|
-
const errorObject = typeof args.embeddedError === 'object' && args.embeddedError !== null
|
|
223
|
-
? args.embeddedError
|
|
224
|
-
: null;
|
|
225
|
-
const status = extractEmbeddedErrorStatus(errorObject?.code);
|
|
226
|
-
const messageText = errorObject && typeof errorObject.message === 'string' && errorObject.message
|
|
227
|
-
? errorObject.message
|
|
228
|
-
: JSON.stringify(args.embeddedError);
|
|
229
|
-
const metadata = errorObject?.metadata;
|
|
230
|
-
const rawDetail = metadata && typeof metadata === 'object' && typeof metadata.raw === 'string'
|
|
231
|
-
? ` (${String(metadata.raw).slice(0, EMBEDDED_ERROR_RAW_DETAIL_MAX_CHARS)})`
|
|
232
|
-
: '';
|
|
233
|
-
const message = `OpenAI-compatible ${args.label} response body reported ` +
|
|
234
|
-
`${status === null ? 'an error' : `error code ${status}`}: ${messageText}${rawDetail}`;
|
|
235
|
-
if (status === 401 || status === 403) {
|
|
236
|
-
return createOpenAICompatibleProviderError({
|
|
237
|
-
message,
|
|
238
|
-
sessionHandle: args.sessionHandle,
|
|
239
|
-
kind: 'permission_denied',
|
|
240
|
-
retryable: false,
|
|
241
|
-
});
|
|
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();
|
|
242
355
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
message,
|
|
246
|
-
sessionHandle: args.sessionHandle,
|
|
247
|
-
kind: 'api_error',
|
|
248
|
-
retryable: true,
|
|
249
|
-
});
|
|
356
|
+
else {
|
|
357
|
+
callerSignal?.addEventListener('abort', abortFromCaller, { once: true });
|
|
250
358
|
}
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
// text in any of these fields is usable; a body with text in none of them is
|
|
264
|
-
// a transient upstream failure (see createInterceptingFetch).
|
|
265
|
-
function extractAssistantText(message) {
|
|
266
|
-
if (typeof message?.content === 'string' && message.content !== '') {
|
|
267
|
-
return message.content;
|
|
268
|
-
}
|
|
269
|
-
if (typeof message?.reasoning === 'string' && message.reasoning !== '') {
|
|
270
|
-
return message.reasoning;
|
|
271
|
-
}
|
|
272
|
-
if (typeof message?.reasoning_content === 'string' && message.reasoning_content !== '') {
|
|
273
|
-
return message.reasoning_content;
|
|
274
|
-
}
|
|
275
|
-
return null;
|
|
276
|
-
}
|
|
277
|
-
function describeMissingAssistantText(response) {
|
|
278
|
-
const choice = response.choices?.[0];
|
|
279
|
-
if (!choice) {
|
|
280
|
-
return 'choices[0] was missing';
|
|
281
|
-
}
|
|
282
|
-
const message = choice.message;
|
|
283
|
-
const finishReason = typeof choice.finish_reason === 'string' ? choice.finish_reason : 'absent';
|
|
284
|
-
if (!message) {
|
|
285
|
-
return `choices[0].message was missing; finish_reason=${finishReason}`;
|
|
286
|
-
}
|
|
287
|
-
const describeField = (value) => value === undefined ? 'absent' : value === null ? 'null' : value === '' ? 'empty' : 'present';
|
|
288
|
-
return (`content=${describeField(message.content)}, reasoning=${describeField(message.reasoning)}, ` +
|
|
289
|
-
`reasoning_content=${describeField(message.reasoning_content)}; finish_reason=${finishReason}`);
|
|
290
|
-
}
|
|
291
|
-
/**
|
|
292
|
-
* Neal-owned fetch interceptor wired into the AI SDK provider. It inspects
|
|
293
|
-
* successful (2xx) JSON response bodies before the SDK parses them and
|
|
294
|
-
* throws the mapped NealProviderError for two gateway behaviors the SDK
|
|
295
|
-
* does not classify:
|
|
296
|
-
*
|
|
297
|
-
* - a top-level `error` object inside an HTTP 200 body (structural marker;
|
|
298
|
-
* mapped by createEmbeddedResponseError's status table);
|
|
299
|
-
* - no assistant text in `message.content`, `message.reasoning`, or
|
|
300
|
-
* `message.reasoning_content` (retryable `api_error`, flowing into the
|
|
301
|
-
* bounded api-retry loop).
|
|
302
|
-
*
|
|
303
|
-
* Both checks gate on structural body shape — presence of an `error`
|
|
304
|
-
* object, absence of text fields — never on matching text that can contain
|
|
305
|
-
* document content (the channel rule). Non-2xx responses pass through
|
|
306
|
-
* untouched so the SDK's error handler attaches `statusCode` for
|
|
307
|
-
* normalizeOpenAICompatibleError. Thrown NealProviderErrors propagate
|
|
308
|
-
* unchanged through the SDK (it rethrows non-network fetch errors as-is and
|
|
309
|
-
* performs no internal retries with maxRetries: 0).
|
|
310
|
-
*/
|
|
311
|
-
function createInterceptingFetch(ctx) {
|
|
312
|
-
return async (input, init) => {
|
|
313
|
-
const response = await fetch(input, init);
|
|
314
|
-
if (!response.ok) {
|
|
315
|
-
return response;
|
|
316
|
-
}
|
|
317
|
-
const contentType = response.headers.get('content-type') ?? '';
|
|
318
|
-
if (!contentType.toLowerCase().includes('json')) {
|
|
319
|
-
return response;
|
|
320
|
-
}
|
|
321
|
-
let body;
|
|
322
|
-
try {
|
|
323
|
-
body = await response.clone().json();
|
|
324
|
-
}
|
|
325
|
-
catch {
|
|
326
|
-
// Let the SDK's own response handling report unparseable bodies.
|
|
327
|
-
return response;
|
|
328
|
-
}
|
|
329
|
-
if (typeof body !== 'object' || body === null) {
|
|
330
|
-
return response;
|
|
331
|
-
}
|
|
332
|
-
const completion = body;
|
|
333
|
-
if (completion.error !== undefined && completion.error !== null) {
|
|
334
|
-
throw createEmbeddedResponseError({
|
|
335
|
-
embeddedError: completion.error,
|
|
336
|
-
label: ctx.label,
|
|
337
|
-
sessionHandle: ctx.sessionHandle,
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
if (extractAssistantText(completion.choices?.[0]?.message) === null) {
|
|
341
|
-
// A response with no assistant text in any known field is treated as
|
|
342
|
-
// a transient upstream failure so it flows into the bounded API retry
|
|
343
|
-
// loop instead of permanently failing the round.
|
|
344
|
-
throw createOpenAICompatibleProviderError({
|
|
345
|
-
message: `OpenAI-compatible ${ctx.label} response did not include assistant text ` +
|
|
346
|
-
`(${describeMissingAssistantText(completion)}).`,
|
|
347
|
-
sessionHandle: ctx.sessionHandle,
|
|
348
|
-
kind: 'api_error',
|
|
349
|
-
retryable: true,
|
|
350
|
-
});
|
|
351
|
-
}
|
|
352
|
-
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
|
+
},
|
|
353
371
|
};
|
|
354
372
|
}
|
|
355
|
-
function buildSyntheticSessionHandle() {
|
|
356
|
-
// Synthetic per-round handle for events only. This provider has no session
|
|
357
|
-
// resume; runStructuredRound always returns sessionHandle: null so no
|
|
358
|
-
// resumable reviewer handle is ever persisted for it.
|
|
359
|
-
return `${OPENAI_COMPATIBLE_PROVIDER_ID}:${new Date().toISOString()}:${randomBytes(4).toString('hex')}`;
|
|
360
|
-
}
|
|
361
|
-
function getApiRetryDelayMs(retryCount) {
|
|
362
|
-
return Math.min(API_RETRY_BASE_DELAY_MS * 2 ** (retryCount - 1), API_RETRY_MAX_DELAY_MS);
|
|
363
|
-
}
|
|
364
|
-
function composeAbortSignal(callerSignal, inactivityTimeoutMs) {
|
|
365
|
-
// Non-streaming chat turn, so the inactivity timeout acts as a
|
|
366
|
-
// whole-request timeout here. That is acceptable for advisor rounds, which
|
|
367
|
-
// are single prompt-in/text-out calls.
|
|
368
|
-
const timeoutSignal = AbortSignal.timeout(inactivityTimeoutMs);
|
|
369
|
-
return callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal;
|
|
370
|
-
}
|
|
371
373
|
async function emitProviderEvent(events, event) {
|
|
372
374
|
await events?.(event);
|
|
373
375
|
}
|
|
374
|
-
function
|
|
376
|
+
function resolveOpenAICompatibleSettings(args) {
|
|
377
|
+
const { context } = args;
|
|
375
378
|
let settings;
|
|
376
379
|
try {
|
|
377
380
|
settings = args.resolveSettings(args.cwd);
|
|
@@ -379,7 +382,8 @@ function resolveRoundSettings(args) {
|
|
|
379
382
|
catch (error) {
|
|
380
383
|
const message = error instanceof Error ? error.message : String(error);
|
|
381
384
|
throw createOpenAICompatibleProviderError({
|
|
382
|
-
message: `
|
|
385
|
+
message: `openai-compatible ${context.noun} settings could not be resolved: ${message}`,
|
|
386
|
+
role: context.role,
|
|
383
387
|
kind: 'provider_failed',
|
|
384
388
|
retryable: false,
|
|
385
389
|
cause: error,
|
|
@@ -387,8 +391,9 @@ function resolveRoundSettings(args) {
|
|
|
387
391
|
}
|
|
388
392
|
if (!settings.baseUrl) {
|
|
389
393
|
throw createOpenAICompatibleProviderError({
|
|
390
|
-
message: `
|
|
391
|
-
'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,
|
|
392
397
|
kind: 'provider_failed',
|
|
393
398
|
retryable: false,
|
|
394
399
|
});
|
|
@@ -396,16 +401,18 @@ function resolveRoundSettings(args) {
|
|
|
396
401
|
const model = args.roundModel ?? args.roleModel ?? settings.defaultModel;
|
|
397
402
|
if (!model) {
|
|
398
403
|
throw createOpenAICompatibleProviderError({
|
|
399
|
-
message: `
|
|
400
|
-
|
|
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,
|
|
401
407
|
kind: 'provider_failed',
|
|
402
408
|
retryable: false,
|
|
403
409
|
});
|
|
404
410
|
}
|
|
405
411
|
if (!settings.apiKey) {
|
|
406
412
|
throw createOpenAICompatibleProviderError({
|
|
407
|
-
message: `
|
|
413
|
+
message: `openai-compatible ${context.subject} has no API key. ` +
|
|
408
414
|
`Set the ${settings.apiKeyEnv} environment variable.`,
|
|
415
|
+
role: context.role,
|
|
409
416
|
kind: 'permission_denied',
|
|
410
417
|
retryable: false,
|
|
411
418
|
});
|
|
@@ -415,11 +422,720 @@ function resolveRoundSettings(args) {
|
|
|
415
422
|
apiKey: settings.apiKey,
|
|
416
423
|
model,
|
|
417
424
|
headers: settings.headers,
|
|
425
|
+
pricing: settings.pricing,
|
|
426
|
+
};
|
|
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,
|
|
418
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
|
+
}
|
|
419
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
|
+
*/
|
|
420
1136
|
class OpenAICompatibleStructuredAdvisorAdapter {
|
|
421
1137
|
options;
|
|
422
|
-
constructor(options
|
|
1138
|
+
constructor(options) {
|
|
423
1139
|
this.options = options;
|
|
424
1140
|
}
|
|
425
1141
|
async runStructuredRound(args) {
|
|
@@ -427,20 +1143,47 @@ class OpenAICompatibleStructuredAdvisorAdapter {
|
|
|
427
1143
|
try {
|
|
428
1144
|
const protocol = args.structuredJsonProtocol;
|
|
429
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.
|
|
430
1163
|
throw createOpenAICompatibleProviderError({
|
|
431
|
-
message: `
|
|
432
|
-
|
|
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',
|
|
433
1168
|
sessionHandle,
|
|
434
1169
|
kind: 'provider_failed',
|
|
435
1170
|
retryable: false,
|
|
436
1171
|
});
|
|
437
1172
|
}
|
|
438
1173
|
const resolveSettings = this.options.resolveSettings ?? getOpenAICompatibleSettings;
|
|
439
|
-
const settings =
|
|
1174
|
+
const settings = resolveOpenAICompatibleSettings({
|
|
440
1175
|
cwd: args.cwd,
|
|
441
|
-
|
|
1176
|
+
// Round-level args.model wins over the adapter/role model and
|
|
1177
|
+
// default_model (matching resolveRoundSettings in the bespoke
|
|
1178
|
+
// adapter).
|
|
442
1179
|
roundModel: args.model ?? null,
|
|
443
|
-
|
|
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
|
+
},
|
|
444
1187
|
resolveSettings,
|
|
445
1188
|
});
|
|
446
1189
|
const createModel = this.options.createModel ?? createDefaultOpenAICompatibleModel;
|
|
@@ -449,8 +1192,47 @@ class OpenAICompatibleStructuredAdvisorAdapter {
|
|
|
449
1192
|
apiKey: settings.apiKey,
|
|
450
1193
|
headers: settings.headers,
|
|
451
1194
|
model: settings.model,
|
|
452
|
-
fetch: createInterceptingFetch({ label: args.label, sessionHandle }),
|
|
453
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
|
+
};
|
|
454
1236
|
await emitProviderEvent(args.events, {
|
|
455
1237
|
type: 'session_started',
|
|
456
1238
|
provider: OPENAI_COMPATIBLE_PROVIDER_ID,
|
|
@@ -458,51 +1240,24 @@ class OpenAICompatibleStructuredAdvisorAdapter {
|
|
|
458
1240
|
label: args.label,
|
|
459
1241
|
sessionHandle,
|
|
460
1242
|
});
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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,
|
|
465
1252
|
protocol,
|
|
466
|
-
|
|
467
|
-
events: args.events,
|
|
468
|
-
initialSessionHandle: sessionHandle,
|
|
469
|
-
runInitial: async (prompt) => ({
|
|
470
|
-
assistantText: await this.runChatTurnWithRetry({
|
|
471
|
-
model,
|
|
472
|
-
prompt,
|
|
473
|
-
roundArgs: args,
|
|
474
|
-
sessionHandle,
|
|
475
|
-
// Only the primary turn is wired to external cancellation; repair
|
|
476
|
-
// turns are short prompt-only turns (matching other adapters).
|
|
477
|
-
signal: args.signal,
|
|
478
|
-
}),
|
|
479
|
-
sessionHandle,
|
|
480
|
-
}),
|
|
481
|
-
runRepair: async (prompt) => ({
|
|
482
|
-
assistantText: await this.runChatTurnWithRetry({
|
|
483
|
-
model,
|
|
484
|
-
prompt,
|
|
485
|
-
roundArgs: args,
|
|
486
|
-
sessionHandle,
|
|
487
|
-
}),
|
|
488
|
-
sessionHandle,
|
|
489
|
-
}),
|
|
490
|
-
createProviderError: (errorArgs) => createOpenAICompatibleProviderError({
|
|
491
|
-
message: errorArgs.message,
|
|
492
|
-
sessionHandle: errorArgs.sessionHandle,
|
|
493
|
-
kind: errorArgs.kind,
|
|
494
|
-
retryable: false,
|
|
495
|
-
cause: errorArgs.cause,
|
|
496
|
-
}),
|
|
1253
|
+
schema: protocol.schema,
|
|
497
1254
|
});
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
sessionHandle: null,
|
|
501
|
-
structured: result.structured,
|
|
502
|
-
};
|
|
1255
|
+
// Never persist a resumable handle for this provider.
|
|
1256
|
+
return { sessionHandle: null, structured };
|
|
503
1257
|
}
|
|
504
1258
|
catch (error) {
|
|
505
1259
|
const providerError = normalizeOpenAICompatibleError(error, {
|
|
1260
|
+
role: 'structured-advisor',
|
|
506
1261
|
label: args.label,
|
|
507
1262
|
sessionHandle,
|
|
508
1263
|
callerSignal: args.signal,
|
|
@@ -515,140 +1270,61 @@ class OpenAICompatibleStructuredAdvisorAdapter {
|
|
|
515
1270
|
sessionHandle: providerError.sessionHandle ?? sessionHandle,
|
|
516
1271
|
message: providerError.message,
|
|
517
1272
|
errorKind: providerError.kind,
|
|
1273
|
+
providerData: providerErrorData(providerError),
|
|
518
1274
|
});
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
* `turn_started`, makes a single tool-less `generateText` call
|
|
525
|
-
* (`temperature: 0`, `maxRetries: 0`, abort/inactivity signal), applies
|
|
526
|
-
* the reasoning-text fallback, then emits `assistant_text`,
|
|
527
|
-
* `turn_completed`, and `usage_reported` (the latter only when the
|
|
528
|
-
* response body actually carried usage, observed structurally via the AI
|
|
529
|
-
* SDK's `usage.raw` passthrough — preserving the bespoke adapter's
|
|
530
|
-
* usage-is-opportunistic event behavior). Transient failures retry up to
|
|
531
|
-
* `args.apiRetryLimit` times with the standard backoff and `api_retry`
|
|
532
|
-
* event shape; a caller abort is non-retryable and never burns retry
|
|
533
|
-
* budget.
|
|
534
|
-
*/
|
|
535
|
-
async runChatTurnWithRetry(callArgs) {
|
|
536
|
-
const { roundArgs } = callArgs;
|
|
537
|
-
const sleep = this.options.sleep ?? defaultSleep;
|
|
538
|
-
const apiRetryLimit = roundArgs.apiRetryLimit;
|
|
539
|
-
const base = {
|
|
540
|
-
provider: OPENAI_COMPATIBLE_PROVIDER_ID,
|
|
541
|
-
role: 'structured-advisor',
|
|
542
|
-
label: roundArgs.label,
|
|
543
|
-
sessionHandle: callArgs.sessionHandle,
|
|
544
|
-
};
|
|
545
|
-
let apiRetryCount = 0;
|
|
546
|
-
while (true) {
|
|
547
|
-
try {
|
|
548
|
-
await emitProviderEvent(roundArgs.events, { type: 'turn_started', ...base });
|
|
549
|
-
const result = await generateText({
|
|
550
|
-
model: callArgs.model,
|
|
551
|
-
prompt: callArgs.prompt,
|
|
552
|
-
temperature: 0,
|
|
553
|
-
maxRetries: 0,
|
|
554
|
-
abortSignal: composeAbortSignal(callArgs.signal, roundArgs.inactivityTimeoutMs),
|
|
555
|
-
});
|
|
556
|
-
// Reasoning-model fallback: the AI SDK surfaces `message.reasoning`
|
|
557
|
-
// and `message.reasoning_content` as reasoningText. The intercepting
|
|
558
|
-
// fetch already rejected bodies with no assistant text in any field,
|
|
559
|
-
// so this guard is defense in depth for non-body-shaped gaps.
|
|
560
|
-
const assistantText = result.text.trim().length > 0 ? result.text : (result.reasoningText ?? '');
|
|
561
|
-
if (assistantText.trim().length === 0) {
|
|
562
|
-
throw createOpenAICompatibleProviderError({
|
|
563
|
-
message: `OpenAI-compatible ${roundArgs.label} response did not include assistant text ` +
|
|
564
|
-
'(after the reasoning-text fallback).',
|
|
565
|
-
sessionHandle: callArgs.sessionHandle,
|
|
566
|
-
kind: 'api_error',
|
|
567
|
-
retryable: true,
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
await emitProviderEvent(roundArgs.events, {
|
|
571
|
-
type: 'assistant_text',
|
|
572
|
-
...base,
|
|
573
|
-
text: assistantText,
|
|
574
|
-
});
|
|
575
|
-
// usage.raw is the AI SDK's passthrough of the response body's usage
|
|
576
|
-
// object; it is undefined when the body carried no usage (structural
|
|
577
|
-
// marker, matching the bespoke adapter's "only report usage when the
|
|
578
|
-
// gateway provided it" behavior).
|
|
579
|
-
const hasUsage = result.usage.raw !== undefined;
|
|
580
|
-
await emitProviderEvent(roundArgs.events, {
|
|
581
|
-
type: 'turn_completed',
|
|
582
|
-
...base,
|
|
583
|
-
...(hasUsage ? { usage: result.usage } : {}),
|
|
584
|
-
});
|
|
585
|
-
if (hasUsage) {
|
|
586
|
-
await emitProviderEvent(roundArgs.events, {
|
|
587
|
-
type: 'usage_reported',
|
|
588
|
-
...base,
|
|
589
|
-
usage: result.usage,
|
|
590
|
-
});
|
|
591
|
-
}
|
|
592
|
-
return assistantText;
|
|
593
|
-
}
|
|
594
|
-
catch (error) {
|
|
595
|
-
const providerError = normalizeOpenAICompatibleError(error, {
|
|
596
|
-
label: roundArgs.label,
|
|
597
|
-
sessionHandle: callArgs.sessionHandle,
|
|
598
|
-
callerSignal: callArgs.signal,
|
|
599
|
-
});
|
|
600
|
-
// Caller cancellation never burns retry budget: a caller-signal
|
|
601
|
-
// abort is non-retryable and the aborted check is defensive depth.
|
|
602
|
-
if (providerError.retryable && apiRetryCount < apiRetryLimit && !callArgs.signal?.aborted) {
|
|
603
|
-
apiRetryCount += 1;
|
|
604
|
-
await emitProviderEvent(roundArgs.events, {
|
|
605
|
-
type: 'tool_progress',
|
|
606
|
-
...base,
|
|
607
|
-
toolName: 'api_retry',
|
|
608
|
-
message: `transient API failure; retrying (${apiRetryCount}/${apiRetryLimit})`,
|
|
609
|
-
isError: true,
|
|
610
|
-
providerData: {
|
|
611
|
-
retryCount: apiRetryCount,
|
|
612
|
-
retryLimit: apiRetryLimit,
|
|
613
|
-
message: providerError.message,
|
|
614
|
-
},
|
|
615
|
-
});
|
|
616
|
-
await sleep(getApiRetryDelayMs(apiRetryCount));
|
|
617
|
-
continue;
|
|
618
|
-
}
|
|
619
|
-
throw providerError;
|
|
620
|
-
}
|
|
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);
|
|
621
1280
|
}
|
|
622
1281
|
}
|
|
623
1282
|
}
|
|
1283
|
+
export function createOpenAICompatibleCoderAdapter(options = {}) {
|
|
1284
|
+
return new OpenAICompatibleCoderAdapter(options);
|
|
1285
|
+
}
|
|
624
1286
|
export function createOpenAICompatibleStructuredAdvisorAdapter(options = {}) {
|
|
625
1287
|
return new OpenAICompatibleStructuredAdvisorAdapter(options);
|
|
626
1288
|
}
|
|
627
1289
|
export const openAICompatibleProviderDefinition = {
|
|
628
1290
|
id: OPENAI_COMPATIBLE_PROVIDER_ID,
|
|
629
|
-
displayName: 'OpenAI-
|
|
1291
|
+
displayName: 'OpenAI-compatible',
|
|
630
1292
|
capabilities: {
|
|
631
1293
|
coder: {
|
|
632
|
-
supported:
|
|
633
|
-
toolAccess: { read:
|
|
1294
|
+
supported: true,
|
|
1295
|
+
toolAccess: { read: true, write: true, shell: true },
|
|
634
1296
|
supportsSessionResume: false,
|
|
635
1297
|
supportsModelOverride: true,
|
|
636
|
-
supportsStructuredOutput:
|
|
637
|
-
usageReporting: '
|
|
1298
|
+
supportsStructuredOutput: true,
|
|
1299
|
+
usageReporting: 'opportunistic',
|
|
638
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.
|
|
639
1309
|
'structured-advisor': {
|
|
640
1310
|
supported: true,
|
|
641
|
-
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,
|
|
642
1316
|
supportsSessionResume: false,
|
|
643
1317
|
supportsModelOverride: true,
|
|
644
1318
|
supportsStructuredOutput: true,
|
|
645
1319
|
usageReporting: 'opportunistic',
|
|
646
1320
|
},
|
|
647
1321
|
},
|
|
1322
|
+
createCoderAdapter: createOpenAICompatibleCoderAdapter,
|
|
648
1323
|
createStructuredAdvisorAdapter: createOpenAICompatibleStructuredAdvisorAdapter,
|
|
649
1324
|
};
|
|
650
1325
|
export const openAICompatibleProviderTestHooks = {
|
|
651
1326
|
createDefaultOpenAICompatibleModel,
|
|
652
1327
|
normalizeOpenAICompatibleError,
|
|
1328
|
+
createCoderAdapterWithInjection: (injection, options) => new OpenAICompatibleCoderAdapter({ ...options, ...injection }),
|
|
653
1329
|
createStructuredAdvisorAdapterWithInjection: (injection, options) => new OpenAICompatibleStructuredAdvisorAdapter({ ...options, ...injection }),
|
|
654
1330
|
};
|