@navels/neal 0.5.0 → 0.6.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/dist/neal/adjudicator/execute.js +32 -6
- package/dist/neal/agents/prompts.js +10 -5
- package/dist/neal/commands/check.js +6 -0
- package/dist/neal/config.js +1 -1
- package/dist/neal/context/inline-review-context.js +116 -0
- package/dist/neal/final-completion.js +4 -7
- package/dist/neal/orchestrator/phases/planning.js +53 -8
- package/dist/neal/prompts/execute.js +24 -7
- package/dist/neal/prompts/guidance.js +8 -1
- package/dist/neal/prompts/planning.js +9 -3
- package/dist/neal/prompts/specialized.js +83 -20
- package/dist/neal/prompts/specs.js +15 -3
- package/dist/neal/providers/input-budget.js +80 -0
- package/dist/neal/providers/openai-codex.js +69 -1
- package/dist/neal/scopes.js +4 -4
- package/dist/neal/status.js +52 -1
- package/dist/neal/verification-events.js +49 -0
- package/docs/prompt-specs.md +78 -0
- package/docs/providers.md +19 -0
- package/neal.yml +1 -1
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { guardStructuredJsonOutputFormatLines } from '../agents/structured-json.js';
|
|
2
|
-
import { renderInlinedRangeDiffSection } from '../context/inline-review-context.js';
|
|
2
|
+
import { AGENT_FREE_TEXT_SECTION_MAX_CHARS, boundChangedFileList, boundCommitSubjectList, boundFreeTextValues, GIT_SUMMARY_SECTION_MAX_CHARS, renderInlinedRangeDiffSection, truncateInlineSectionBody, } from '../context/inline-review-context.js';
|
|
3
3
|
import { assertPromptBuilder } from './assert-builder.js';
|
|
4
4
|
import { getUserGuidanceLines } from './guidance.js';
|
|
5
5
|
import { getAdversarialReviewDoctrineLines, getCodeReviewFalsificationLines, getFindingQualityLines, getPreexistingFailureContractLines, getRegressionPreservationLines, getVerificationSkepticismLines, } from './review-doctrine.js';
|
|
@@ -28,9 +28,8 @@ export function buildFinalCompletionSummaryPrompt(args) {
|
|
|
28
28
|
if (!finalCompletionVariant) {
|
|
29
29
|
throw new Error('Prompt spec completion_coder is missing a final_completion variant');
|
|
30
30
|
}
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
: 'null';
|
|
31
|
+
const boundedLastScope = boundLastImplementationScope(args.packet.lastNonEmptyImplementationScope);
|
|
32
|
+
const lastImplementationScope = boundedLastScope ? JSON.stringify(boundedLastScope, null, 2) : 'null';
|
|
34
33
|
return [
|
|
35
34
|
`Summarize whether the execute-mode plan at ${args.planDoc} is complete as a whole.`,
|
|
36
35
|
'',
|
|
@@ -52,19 +51,19 @@ export function buildFinalCompletionSummaryPrompt(args) {
|
|
|
52
51
|
currentScopeLabel: args.packet.currentScopeLabel,
|
|
53
52
|
acceptedScopeRecordCount: args.packet.acceptedScopeCount,
|
|
54
53
|
blockedScopeCount: args.packet.blockedScopeCount,
|
|
55
|
-
scopeAccountingSummary: args.packet.scopeAccountingSummary,
|
|
54
|
+
scopeAccountingSummary: boundScopeAccountingSummary(args.packet.scopeAccountingSummary),
|
|
56
55
|
verificationOnlyCompletion: args.packet.verificationOnlyCompletion,
|
|
57
|
-
aggregateReviewContext: args.packet.aggregateReviewContext,
|
|
58
|
-
completedScopeSummary: args.packet.completedScopeSummary,
|
|
56
|
+
aggregateReviewContext: boundAggregateReviewContext(args.packet.aggregateReviewContext),
|
|
57
|
+
completedScopeSummary: boundCompletedScopeSummary(args.packet.completedScopeSummary),
|
|
59
58
|
terminalChangedFilesSummary: args.packet.terminalChangedFilesSummary,
|
|
60
59
|
planChangedFilesSummary: args.packet.planChangedFilesSummary,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
lastNonEmptyImplementationScope: args.packet.lastNonEmptyImplementationScope,
|
|
60
|
+
verificationTally: args.packet.verificationTally,
|
|
61
|
+
lastNonEmptyImplementationScope: boundedLastScope,
|
|
64
62
|
continueExecutionCount: args.packet.continueExecutionCount,
|
|
65
63
|
continueExecutionMax: args.packet.continueExecutionMax,
|
|
66
64
|
}, null, 2),
|
|
67
65
|
'',
|
|
66
|
+
'`verificationTally` is a bounded summary of the run\'s recorded verification commands; the complete per-command record is in the run directory\'s events.ndjson.',
|
|
68
67
|
'If the completion is verification-only, say so directly in `whatChangedOverall` or `remainingKnownGaps` instead of pretending there was a terminal implementation diff.',
|
|
69
68
|
...getUserGuidanceLines('coder'),
|
|
70
69
|
'',
|
|
@@ -72,6 +71,71 @@ export function buildFinalCompletionSummaryPrompt(args) {
|
|
|
72
71
|
lastImplementationScope,
|
|
73
72
|
].join('\n');
|
|
74
73
|
}
|
|
74
|
+
// Maximum remaining-known-gap entries rendered per completion prompt. Gaps
|
|
75
|
+
// beyond the limit collapse to one explicit overflow entry so the rendered
|
|
76
|
+
// list stays bounded as the gap count grows. Also keeps the summary's
|
|
77
|
+
// free-text value count far under boundFreeTextValues' cardinality bound.
|
|
78
|
+
const REMAINING_GAPS_PROMPT_ITEM_LIMIT = 100;
|
|
79
|
+
// Render-only view of the coder's completion summary: planGoalSatisfied is
|
|
80
|
+
// copied exactly, the free-text fields and the first
|
|
81
|
+
// REMAINING_GAPS_PROMPT_ITEM_LIMIT gap entries share the fixed aggregate
|
|
82
|
+
// free-text budget, and any further gaps collapse to one explicit overflow
|
|
83
|
+
// entry. The stored summary keeps its full text.
|
|
84
|
+
function boundCompletionSummaryForPrompt(summary) {
|
|
85
|
+
const gaps = summary.remainingKnownGaps.slice(0, REMAINING_GAPS_PROMPT_ITEM_LIMIT);
|
|
86
|
+
const bounded = boundFreeTextValues([summary.whatChangedOverall, summary.verificationSummary, ...gaps]);
|
|
87
|
+
const remainingKnownGaps = bounded.slice(2);
|
|
88
|
+
const omittedGaps = summary.remainingKnownGaps.length - gaps.length;
|
|
89
|
+
if (omittedGaps > 0) {
|
|
90
|
+
remainingKnownGaps.push(`(+${omittedGaps} more remaining known gaps omitted from this prompt)`);
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
planGoalSatisfied: summary.planGoalSatisfied,
|
|
94
|
+
whatChangedOverall: bounded[0],
|
|
95
|
+
verificationSummary: bounded[1],
|
|
96
|
+
remainingKnownGaps,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
// Prompt-render view of the completion packet's aggregate review context with
|
|
100
|
+
// its run-scaling fields bounded: the commit-subject and changed-file lists
|
|
101
|
+
// collapse past their limits and the diff stat truncates with a marker. The
|
|
102
|
+
// packet keeps the full values for non-prompt consumers.
|
|
103
|
+
function boundAggregateReviewContext(context) {
|
|
104
|
+
return {
|
|
105
|
+
...context,
|
|
106
|
+
commitSubjects: boundCommitSubjectList(context.commitSubjects),
|
|
107
|
+
diffStat: truncateInlineSectionBody(context.diffStat, GIT_SUMMARY_SECTION_MAX_CHARS),
|
|
108
|
+
changedFiles: boundChangedFileList(context.changedFiles),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Prompt-render view of the packet's completed-scope summary. The summary
|
|
112
|
+
// carries agent-authored blocker and residual-debt text for every completed
|
|
113
|
+
// scope, so it shares the fixed agent free-text cap; the packet keeps the full
|
|
114
|
+
// string for non-prompt consumers.
|
|
115
|
+
function boundCompletedScopeSummary(summary) {
|
|
116
|
+
return truncateInlineSectionBody(summary, AGENT_FREE_TEXT_SECTION_MAX_CHARS);
|
|
117
|
+
}
|
|
118
|
+
// Prompt-render view of the packet's last non-empty implementation scope: the
|
|
119
|
+
// changed-file list is bounded, and the agent-authored commit subject gets the
|
|
120
|
+
// fixed free-text cap while a null subject stays null. The packet keeps the
|
|
121
|
+
// full values for non-prompt consumers.
|
|
122
|
+
function boundLastImplementationScope(scope) {
|
|
123
|
+
if (!scope) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
...scope,
|
|
128
|
+
commitSubject: scope.commitSubject === null ? null : boundFreeTextValues([scope.commitSubject])[0],
|
|
129
|
+
changedFiles: boundChangedFileList(scope.changedFiles),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
// Prompt-render view of the packet's scope-accounting summary. The summary
|
|
133
|
+
// grows with derived-plan replacements (one path per replaced parent scope),
|
|
134
|
+
// so it shares the fixed agent free-text cap; the packet keeps the full string
|
|
135
|
+
// for non-prompt consumers.
|
|
136
|
+
function boundScopeAccountingSummary(summary) {
|
|
137
|
+
return truncateInlineSectionBody(summary, AGENT_FREE_TEXT_SECTION_MAX_CHARS);
|
|
138
|
+
}
|
|
75
139
|
export function buildFinalCompletionReviewerPrompt(args) {
|
|
76
140
|
const spec = assertPromptBuilder('completion_reviewer', 'buildFinalCompletionReviewerPrompt', PROMPT_MODULE_PATH);
|
|
77
141
|
const finalCompletionVariant = spec.variants.find((variant) => variant.kind === 'final_completion');
|
|
@@ -85,9 +149,8 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
85
149
|
// instead of falling back to git_diff-tool phrasing the reviewer cannot use.
|
|
86
150
|
const rangeDiffInlined = accessMode === 'read-only' && args.inlinedRangeDiff !== null && args.inlinedRangeDiff !== undefined;
|
|
87
151
|
const completionSummary = args.summary;
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
: 'null';
|
|
152
|
+
const boundedLastScope = boundLastImplementationScope(args.packet.lastNonEmptyImplementationScope);
|
|
153
|
+
const lastImplementationScope = boundedLastScope ? JSON.stringify(boundedLastScope, null, 2) : 'null';
|
|
91
154
|
const aggregateRange = args.packet.aggregateReviewContext.range;
|
|
92
155
|
const falsificationLines = getCodeReviewFalsificationLines({
|
|
93
156
|
rangeLabel: aggregateRange ? `aggregate range ${aggregateRange}` : null,
|
|
@@ -166,7 +229,7 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
166
229
|
'- Invalid example: subject "Finish scope 4 cleanup"; bullets ["Summarize per-scope plan work", "Describe reviewer process"].',
|
|
167
230
|
'',
|
|
168
231
|
'Coder whole-plan completion summary:',
|
|
169
|
-
JSON.stringify(completionSummary, null, 2),
|
|
232
|
+
JSON.stringify(boundCompletionSummaryForPrompt(completionSummary), null, 2),
|
|
170
233
|
'',
|
|
171
234
|
'Whole-plan completion packet:',
|
|
172
235
|
JSON.stringify({
|
|
@@ -174,20 +237,20 @@ export function buildFinalCompletionReviewerPrompt(args) {
|
|
|
174
237
|
currentScopeLabel: args.packet.currentScopeLabel,
|
|
175
238
|
acceptedScopeRecordCount: args.packet.acceptedScopeCount,
|
|
176
239
|
blockedScopeCount: args.packet.blockedScopeCount,
|
|
177
|
-
scopeAccountingSummary: args.packet.scopeAccountingSummary,
|
|
240
|
+
scopeAccountingSummary: boundScopeAccountingSummary(args.packet.scopeAccountingSummary),
|
|
178
241
|
verificationOnlyCompletion: args.packet.verificationOnlyCompletion,
|
|
179
|
-
aggregateReviewContext: args.packet.aggregateReviewContext,
|
|
242
|
+
aggregateReviewContext: boundAggregateReviewContext(args.packet.aggregateReviewContext),
|
|
180
243
|
finalCommit: args.packet.finalCommit,
|
|
181
|
-
completedScopeSummary: args.packet.completedScopeSummary,
|
|
244
|
+
completedScopeSummary: boundCompletedScopeSummary(args.packet.completedScopeSummary),
|
|
182
245
|
terminalChangedFilesSummary: args.packet.terminalChangedFilesSummary,
|
|
183
246
|
planChangedFilesSummary: args.packet.planChangedFilesSummary,
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
lastNonEmptyImplementationScope: args.packet.lastNonEmptyImplementationScope,
|
|
247
|
+
verificationTally: args.packet.verificationTally,
|
|
248
|
+
lastNonEmptyImplementationScope: boundedLastScope,
|
|
187
249
|
continueExecutionCount: args.packet.continueExecutionCount,
|
|
188
250
|
continueExecutionMax: args.packet.continueExecutionMax,
|
|
189
251
|
}, null, 2),
|
|
190
252
|
'',
|
|
253
|
+
'`verificationTally` is a bounded summary of the run\'s recorded verification commands; the complete per-command record is in the run directory\'s events.ndjson.',
|
|
191
254
|
'If this was a verification-only terminal scope, judge the whole-plan result directly instead of pretending there was a final implementation diff.',
|
|
192
255
|
...getUserGuidanceLines('reviewer'),
|
|
193
256
|
'',
|
|
@@ -88,7 +88,7 @@ const CONSULTANT_CONTEXT = context('ConsultantPromptContext', [
|
|
|
88
88
|
export const PROMPT_SPECS = [
|
|
89
89
|
{
|
|
90
90
|
id: 'plan_author',
|
|
91
|
-
version:
|
|
91
|
+
version: 5,
|
|
92
92
|
changelog: [
|
|
93
93
|
{
|
|
94
94
|
version: 1,
|
|
@@ -106,6 +106,10 @@ export const PROMPT_SPECS = [
|
|
|
106
106
|
version: 4,
|
|
107
107
|
renderSha: 'e9f5b2dd5f66df87986b2e2f884c560d02a235db053b544221dcb43512e057a4',
|
|
108
108
|
},
|
|
109
|
+
{
|
|
110
|
+
version: 5,
|
|
111
|
+
renderSha: '148ed793be3f39c4a5505289ab3045ff124d8c44a53bf5fa9ccc8c3c5f20d408',
|
|
112
|
+
},
|
|
109
113
|
],
|
|
110
114
|
role: 'coder',
|
|
111
115
|
purpose: 'Author or revise concise, human-reviewable Neal-executable plans at moderate-to-high-level implementation detail.',
|
|
@@ -505,12 +509,16 @@ export const PROMPT_SPECS = [
|
|
|
505
509
|
},
|
|
506
510
|
{
|
|
507
511
|
id: 'completion_coder',
|
|
508
|
-
version:
|
|
512
|
+
version: 2,
|
|
509
513
|
changelog: [
|
|
510
514
|
{
|
|
511
515
|
version: 1,
|
|
512
516
|
renderSha: 'f88b43d206de28212ad7f6f3ae84576718934b0a5e36953e35afee89c4d7ce5e',
|
|
513
517
|
},
|
|
518
|
+
{
|
|
519
|
+
version: 2,
|
|
520
|
+
renderSha: '85f36db1245090baa3c8f74e5fe2d3de774664dcb6a3b1b84dcc49425cc3c646',
|
|
521
|
+
},
|
|
514
522
|
],
|
|
515
523
|
role: 'coder',
|
|
516
524
|
purpose: 'Summarize whole-plan completion state in compact structured JSON.',
|
|
@@ -565,7 +573,7 @@ export const PROMPT_SPECS = [
|
|
|
565
573
|
},
|
|
566
574
|
{
|
|
567
575
|
id: 'completion_reviewer',
|
|
568
|
-
version:
|
|
576
|
+
version: 4,
|
|
569
577
|
changelog: [
|
|
570
578
|
{
|
|
571
579
|
version: 1,
|
|
@@ -579,6 +587,10 @@ export const PROMPT_SPECS = [
|
|
|
579
587
|
version: 3,
|
|
580
588
|
renderSha: '127097db4b0d06cba8943681d12d93fbb07e577e078c99c1d81036399bedb66a',
|
|
581
589
|
},
|
|
590
|
+
{
|
|
591
|
+
version: 4,
|
|
592
|
+
renderSha: 'c47009016178fc29c34440e06636ba7b21c6beb59202dd3fe63e365cb32a75cf',
|
|
593
|
+
},
|
|
582
594
|
],
|
|
583
595
|
role: 'reviewer',
|
|
584
596
|
purpose: 'Judge whole-plan completion and decide whether Neal should accept completion, continue execution, or block for operator input.',
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { NealProviderError } from './types.js';
|
|
2
|
+
const REPORTED_SECTION_COUNT = 3;
|
|
3
|
+
const SECTION_NAME_MAX_CHARS = 60;
|
|
4
|
+
// Measures the prompt as contiguous sections split on `## ` headings, the
|
|
5
|
+
// heading level Neal's prompt builders use for top-level sections. Text before
|
|
6
|
+
// the first heading is labeled "instructions". Section sizes include the
|
|
7
|
+
// heading line and sum exactly to the prompt length.
|
|
8
|
+
export function measurePromptSections(prompt) {
|
|
9
|
+
const boundaries = [];
|
|
10
|
+
for (const match of prompt.matchAll(/^## (.*)$/gm)) {
|
|
11
|
+
boundaries.push({
|
|
12
|
+
index: match.index,
|
|
13
|
+
name: match[1].trim() === '' ? 'untitled section' : match[1].trim(),
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const sections = [];
|
|
17
|
+
const leadingChars = boundaries.length === 0 ? prompt.length : boundaries[0].index;
|
|
18
|
+
if (leadingChars > 0) {
|
|
19
|
+
sections.push({ name: 'instructions', chars: leadingChars });
|
|
20
|
+
}
|
|
21
|
+
for (const [position, boundary] of boundaries.entries()) {
|
|
22
|
+
const end = position + 1 < boundaries.length ? boundaries[position + 1].index : prompt.length;
|
|
23
|
+
sections.push({ name: boundary.name, chars: end - boundary.index });
|
|
24
|
+
}
|
|
25
|
+
if (sections.length === 0) {
|
|
26
|
+
sections.push({ name: 'instructions', chars: prompt.length });
|
|
27
|
+
}
|
|
28
|
+
return sections;
|
|
29
|
+
}
|
|
30
|
+
function formatChars(value) {
|
|
31
|
+
return value.toLocaleString('en-US');
|
|
32
|
+
}
|
|
33
|
+
function formatSectionName(name) {
|
|
34
|
+
return name.length > SECTION_NAME_MAX_CHARS
|
|
35
|
+
? `${name.slice(0, SECTION_NAME_MAX_CHARS - 3)}...`
|
|
36
|
+
: name;
|
|
37
|
+
}
|
|
38
|
+
export function buildInputTooLargeMessage(args) {
|
|
39
|
+
const largest = [...args.sections]
|
|
40
|
+
.sort((a, b) => b.chars - a.chars)
|
|
41
|
+
.slice(0, REPORTED_SECTION_COUNT);
|
|
42
|
+
const sectionReport = largest
|
|
43
|
+
.map((section) => `"${formatSectionName(section.name)}" ${formatChars(section.chars)} chars`)
|
|
44
|
+
.join('; ');
|
|
45
|
+
return [
|
|
46
|
+
`Prompt is ${formatChars(args.promptChars)} chars; ${args.provider} accepts at most ${formatChars(args.maxInputChars)} input chars per turn.`,
|
|
47
|
+
`Largest sections: ${sectionReport}.`,
|
|
48
|
+
].join('\n');
|
|
49
|
+
}
|
|
50
|
+
// Reads the largest section name back out of a message produced by
|
|
51
|
+
// buildInputTooLargeMessage, so `neal status` can name the input to shrink in
|
|
52
|
+
// its Next Action. Provider-authored input_too_large rejections carry no
|
|
53
|
+
// section report, so this returns null for them and the caller falls back to
|
|
54
|
+
// generic wording. Kept next to the builder so the format and its one parser
|
|
55
|
+
// stay in lockstep.
|
|
56
|
+
export function largestSectionNameFromInputTooLargeMessage(message) {
|
|
57
|
+
const match = message.match(/Largest sections: "([^"]+)"/);
|
|
58
|
+
return match ? match[1] : null;
|
|
59
|
+
}
|
|
60
|
+
// No-op when the role declares no budget or the prompt fits. Throws before any
|
|
61
|
+
// SDK work otherwise; the error is non-retryable by construction, so it never
|
|
62
|
+
// consumes API-retry budget.
|
|
63
|
+
export function assertPromptWithinInputBudget(args) {
|
|
64
|
+
if (args.maxInputChars === undefined || args.prompt.length <= args.maxInputChars) {
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
throw new NealProviderError({
|
|
68
|
+
message: buildInputTooLargeMessage({
|
|
69
|
+
provider: args.provider,
|
|
70
|
+
promptChars: args.prompt.length,
|
|
71
|
+
maxInputChars: args.maxInputChars,
|
|
72
|
+
sections: measurePromptSections(args.prompt),
|
|
73
|
+
}),
|
|
74
|
+
provider: args.provider,
|
|
75
|
+
role: args.role,
|
|
76
|
+
sessionHandle: args.sessionHandle ?? null,
|
|
77
|
+
kind: 'input_too_large',
|
|
78
|
+
retryable: false,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
import { Codex } from '@openai/codex-sdk';
|
|
2
|
-
import { runStructuredJsonProtocol } from '../agents/structured-json.js';
|
|
2
|
+
import { buildStructuredJsonPrompt, runStructuredJsonProtocol } from '../agents/structured-json.js';
|
|
3
3
|
import { agentSettingsIsolated } from './agent-settings-isolation.js';
|
|
4
4
|
import { agentSubprocessEnv } from './git-config-isolation.js';
|
|
5
|
+
import { assertPromptWithinInputBudget } from './input-budget.js';
|
|
5
6
|
import { resolveRateCost } from './pricing.js';
|
|
6
7
|
import { isContentSafetyRefusalMessage, NealProviderError } from './types.js';
|
|
7
8
|
const OPENAI_CODEX_PROVIDER_ID = 'openai-codex';
|
|
9
|
+
// Codex's app-server rejects any single turn whose input exceeds this size
|
|
10
|
+
// (JSON-RPC code -32602, input_error_code `input_too_large`). Declared as
|
|
11
|
+
// `maxInputChars` on both role capabilities and enforced by the preflight at
|
|
12
|
+
// every turn/round entry point below.
|
|
13
|
+
const OPENAI_CODEX_MAX_INPUT_CHARS = 1_048_576;
|
|
14
|
+
function assertCodexInputBudget(args) {
|
|
15
|
+
assertPromptWithinInputBudget({
|
|
16
|
+
prompt: args.prompt,
|
|
17
|
+
maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
|
|
18
|
+
provider: OPENAI_CODEX_PROVIDER_ID,
|
|
19
|
+
role: args.role,
|
|
20
|
+
sessionHandle: args.sessionHandle,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
8
23
|
class CodexInactivityTimeoutError extends Error {
|
|
9
24
|
constructor(timeoutMs) {
|
|
10
25
|
super(`Codex timed out after ${Math.round(timeoutMs / 1000)}s without progress`);
|
|
@@ -76,6 +91,12 @@ function inferCodexErrorKind(error, message, fallback) {
|
|
|
76
91
|
if (isContentSafetyRefusalMessage(message)) {
|
|
77
92
|
return 'content_refused';
|
|
78
93
|
}
|
|
94
|
+
// Codex's app-server rejects an over-limit turn with JSON-RPC code -32602
|
|
95
|
+
// and input_error_code `input_too_large`; matching the stable error code
|
|
96
|
+
// means a limit the preflight did not predict still classifies correctly.
|
|
97
|
+
if (text.includes('input_too_large')) {
|
|
98
|
+
return 'input_too_large';
|
|
99
|
+
}
|
|
79
100
|
if (text.includes('permission') || text.includes('denied') || text.includes('forbidden') || text.includes('not authorized')) {
|
|
80
101
|
return 'permission_denied';
|
|
81
102
|
}
|
|
@@ -568,6 +589,11 @@ class OpenAICodexCoderAdapter {
|
|
|
568
589
|
async runPrompt(args) {
|
|
569
590
|
let thread = null;
|
|
570
591
|
try {
|
|
592
|
+
assertCodexInputBudget({
|
|
593
|
+
prompt: args.prompt,
|
|
594
|
+
role: 'coder',
|
|
595
|
+
sessionHandle: args.resumeHandle ?? null,
|
|
596
|
+
});
|
|
571
597
|
const createThread = this.options.createThread ?? createCodexThread;
|
|
572
598
|
thread = createThread({
|
|
573
599
|
cwd: args.cwd,
|
|
@@ -620,6 +646,16 @@ class OpenAICodexCoderAdapter {
|
|
|
620
646
|
while (true) {
|
|
621
647
|
let thread = null;
|
|
622
648
|
try {
|
|
649
|
+
// Preflight the exact text the initial SDK turn will send: the
|
|
650
|
+
// protocol-wrapped prompt. buildStructuredJsonPrompt is the same pure
|
|
651
|
+
// builder runStructuredJsonProtocol applies to the same inputs below,
|
|
652
|
+
// so the checked text is byte-identical to the sent text. Repair
|
|
653
|
+
// prompts are checked in runRepair before their thread is created.
|
|
654
|
+
assertCodexInputBudget({
|
|
655
|
+
prompt: buildStructuredJsonPrompt(args.prompt, args.structuredJsonProtocol),
|
|
656
|
+
role: 'coder',
|
|
657
|
+
sessionHandle: args.resumeHandle ?? null,
|
|
658
|
+
});
|
|
623
659
|
const createThread = this.options.createThread ?? createCodexThread;
|
|
624
660
|
thread = createThread({
|
|
625
661
|
cwd: args.cwd,
|
|
@@ -650,6 +686,14 @@ class OpenAICodexCoderAdapter {
|
|
|
650
686
|
};
|
|
651
687
|
},
|
|
652
688
|
runRepair: async (prompt) => {
|
|
689
|
+
// A generated repair prompt embeds the invalid payload and the
|
|
690
|
+
// original response, so it can exceed the budget even when the
|
|
691
|
+
// initial prompt fit; preflight it before creating its thread.
|
|
692
|
+
assertCodexInputBudget({
|
|
693
|
+
prompt,
|
|
694
|
+
role: 'coder',
|
|
695
|
+
sessionHandle: thread.id ?? args.resumeHandle ?? null,
|
|
696
|
+
});
|
|
653
697
|
// Repair runs on a fresh prompt-only thread whose prompt forbids
|
|
654
698
|
// tool use entirely, so nothing in the repair flow needs write
|
|
655
699
|
// access; the read-only sandbox enforces that mechanically
|
|
@@ -740,6 +784,20 @@ class OpenAICodexStructuredAdvisorAdapter {
|
|
|
740
784
|
while (true) {
|
|
741
785
|
let thread = null;
|
|
742
786
|
try {
|
|
787
|
+
// Preflight the exact text the initial SDK turn will send: the
|
|
788
|
+
// protocol-wrapped prompt on the local-JSON path
|
|
789
|
+
// (buildStructuredJsonPrompt is the same pure builder
|
|
790
|
+
// runStructuredJsonProtocol applies to the same inputs below, so the
|
|
791
|
+
// checked text is byte-identical to the sent text), the bare prompt on
|
|
792
|
+
// the provider-native path. Repair prompts are checked in runRepair
|
|
793
|
+
// before their thread is created.
|
|
794
|
+
assertCodexInputBudget({
|
|
795
|
+
prompt: args.structuredJsonProtocol?.protocol === 'neal-json-block-v1'
|
|
796
|
+
? buildStructuredJsonPrompt(args.prompt, args.structuredJsonProtocol)
|
|
797
|
+
: args.prompt,
|
|
798
|
+
role: 'structured-advisor',
|
|
799
|
+
sessionHandle: args.resumeHandle ?? null,
|
|
800
|
+
});
|
|
743
801
|
thread = createThread({
|
|
744
802
|
cwd: args.cwd,
|
|
745
803
|
sessionHandle: args.resumeHandle,
|
|
@@ -770,6 +828,14 @@ class OpenAICodexStructuredAdvisorAdapter {
|
|
|
770
828
|
};
|
|
771
829
|
},
|
|
772
830
|
runRepair: async (prompt) => {
|
|
831
|
+
// A generated repair prompt embeds the invalid payload and the
|
|
832
|
+
// original response, so it can exceed the budget even when the
|
|
833
|
+
// initial prompt fit; preflight it before creating its thread.
|
|
834
|
+
assertCodexInputBudget({
|
|
835
|
+
prompt,
|
|
836
|
+
role: 'structured-advisor',
|
|
837
|
+
sessionHandle: thread.id ?? args.resumeHandle ?? null,
|
|
838
|
+
});
|
|
773
839
|
const repairThread = createThread({
|
|
774
840
|
cwd: args.cwd,
|
|
775
841
|
model: this.options.model ?? undefined,
|
|
@@ -875,6 +941,7 @@ export const openAICodexProviderDefinition = {
|
|
|
875
941
|
write: true,
|
|
876
942
|
shell: true,
|
|
877
943
|
},
|
|
944
|
+
maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
|
|
878
945
|
supportsSessionResume: true,
|
|
879
946
|
supportsModelOverride: true,
|
|
880
947
|
supportsStructuredOutput: true,
|
|
@@ -891,6 +958,7 @@ export const openAICodexProviderDefinition = {
|
|
|
891
958
|
write: false,
|
|
892
959
|
shell: false,
|
|
893
960
|
},
|
|
961
|
+
maxInputChars: OPENAI_CODEX_MAX_INPUT_CHARS,
|
|
894
962
|
supportsSessionResume: true,
|
|
895
963
|
supportsModelOverride: true,
|
|
896
964
|
supportsStructuredOutput: true,
|
package/dist/neal/scopes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { boundChangedFileList } from './context/inline-review-context.js';
|
|
2
3
|
import { validatePlanDocument } from './plan-validation.js';
|
|
3
4
|
import { getDerivedPlanIdentityView } from './state-views.js';
|
|
4
5
|
export const DEFAULT_PARENT_OBJECTIVE_HISTORY_WINDOW = 5;
|
|
@@ -390,14 +391,13 @@ export function renderRecentAcceptedScopesSummary(state, parentScopeLabel, windo
|
|
|
390
391
|
}
|
|
391
392
|
const concentrationSummary = touchedFileCounts.size === 0
|
|
392
393
|
? '(no changed files recorded)'
|
|
393
|
-
: [...touchedFileCounts.entries()]
|
|
394
|
+
: boundChangedFileList([...touchedFileCounts.entries()]
|
|
394
395
|
.sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]))
|
|
395
|
-
.map(([file, touches]) => `${file} (${touches}/${recentScopes.length} scopes)`)
|
|
396
|
-
.join(', ');
|
|
396
|
+
.map(([file, touches]) => `${file} (${touches}/${recentScopes.length} scopes)`)).join(', ');
|
|
397
397
|
return [
|
|
398
398
|
`Accepted scope history for parent objective ${parentScopeLabel} (oldest to newest, last ${window} max):`,
|
|
399
399
|
...recentScopes.map((scope) => {
|
|
400
|
-
const changedFiles = scope.changedFiles.length > 0 ? scope.changedFiles.join(', ') : '(no changed files)';
|
|
400
|
+
const changedFiles = scope.changedFiles.length > 0 ? boundChangedFileList(scope.changedFiles).join(', ') : '(no changed files)';
|
|
401
401
|
return [
|
|
402
402
|
`- Scope ${scope.number}`,
|
|
403
403
|
` commit: ${scope.finalCommit ?? 'pending'}`,
|
package/dist/neal/status.js
CHANGED
|
@@ -10,6 +10,7 @@ import { listRuns } from './run-registry.js';
|
|
|
10
10
|
import { formatPublicRunStatus, getRunDisplayStatus } from './run-status.js';
|
|
11
11
|
import { getRunStatePath, loadState } from './state.js';
|
|
12
12
|
import { getDerivedPlanView } from './state-views.js';
|
|
13
|
+
import { largestSectionNameFromInputTooLargeMessage } from './providers/input-budget.js';
|
|
13
14
|
import { getNealBuildMetadata } from './version.js';
|
|
14
15
|
export { formatPublicPhase } from './phase-display.js';
|
|
15
16
|
const EVENT_TAIL_BYTES = 256 * 1024;
|
|
@@ -25,6 +26,7 @@ const PROVIDER_ERROR_KINDS = new Set([
|
|
|
25
26
|
'permission_denied',
|
|
26
27
|
'session_unavailable',
|
|
27
28
|
'content_refused',
|
|
29
|
+
'input_too_large',
|
|
28
30
|
'provider_failed',
|
|
29
31
|
'unknown',
|
|
30
32
|
]);
|
|
@@ -104,16 +106,21 @@ export async function buildStatusSnapshot(args) {
|
|
|
104
106
|
const health = classifyHealth(state, eventSummary, nowMs, finalCompletionStaleness);
|
|
105
107
|
const publicStatus = formatPublicStatusForDisplayStatus(displayStatus, health);
|
|
106
108
|
const publicPhase = formatPublicPhase(state.phase);
|
|
109
|
+
const providerError = summarizeProviderError(tail.events);
|
|
107
110
|
const nextAction = formatNextAction({
|
|
108
111
|
manualGate,
|
|
109
112
|
resumeDecision,
|
|
110
113
|
finalCompletionStaleness,
|
|
111
114
|
runId,
|
|
112
115
|
blockedGuidance,
|
|
116
|
+
// The Next Action must reflect the run's current failure, not history: the
|
|
117
|
+
// provider-error summary stays on the snapshot as historical information,
|
|
118
|
+
// but it only drives the Next Action while no later provider turn or phase
|
|
119
|
+
// has completed successfully after it.
|
|
120
|
+
providerError: providerError && isProviderErrorActive(tail.events) ? providerError : null,
|
|
113
121
|
});
|
|
114
122
|
const commits = summarizeCommits(state);
|
|
115
123
|
const squash = await summarizeSquashArtifact(state.runDir);
|
|
116
|
-
const providerError = summarizeProviderError(tail.events);
|
|
117
124
|
const build = await summarizeBuild(state);
|
|
118
125
|
const patch = await summarizePatch(state, displayStatus, squash);
|
|
119
126
|
return {
|
|
@@ -372,6 +379,24 @@ function formatNextAction(snapshot) {
|
|
|
372
379
|
if (snapshot.finalCompletionStaleness.stale && snapshot.resumeDecision.kind === 'continue') {
|
|
373
380
|
return `Final completion appears stale after reviewer output. Inspect artifacts and recover explicitly after confirming the branch state: neal status --run ${snapshot.runId}`;
|
|
374
381
|
}
|
|
382
|
+
// An input_too_large failure keeps the resume decision at `continue` on
|
|
383
|
+
// purpose: the adapter-boundary preflight re-measures the actual rebuilt
|
|
384
|
+
// prompt against the provider budget on every attempt, so resume is always
|
|
385
|
+
// executable and an unchanged oversized prompt fails fast before any
|
|
386
|
+
// provider call. This branch only redirects the operator to shrink the named
|
|
387
|
+
// input before that resume. Every lever it names works on an existing run:
|
|
388
|
+
// operator guidance files are re-read at every prompt build, and upgrading
|
|
389
|
+
// neal applies the current prompt bounds on resume. Per-run provider
|
|
390
|
+
// rebinding does not exist, so a prompt that cannot fit needs a new run on a
|
|
391
|
+
// provider with a larger or no declared limit.
|
|
392
|
+
if (snapshot.providerError?.kind === 'input_too_large' && snapshot.resumeDecision.kind === 'continue') {
|
|
393
|
+
const largestSection = largestSectionNameFromInputTooLargeMessage(snapshot.providerError.message);
|
|
394
|
+
const namedInput = largestSection ? `the "${largestSection}" prompt section` : 'the oversized prompt input';
|
|
395
|
+
return (`The last attempt failed because the prompt exceeded the provider's input limit. ` +
|
|
396
|
+
`Shrink ${namedInput} first (trim operator guidance files, or upgrade neal so the current prompt bounds apply), ` +
|
|
397
|
+
`then resume this run: ${snapshot.resumeDecision.resumeCommand}. ` +
|
|
398
|
+
`If the prompt cannot fit under the limit, start a new run with a provider that has a larger or no input limit.`);
|
|
399
|
+
}
|
|
375
400
|
if (snapshot.resumeDecision.kind === 'needs_message' && snapshot.blockedGuidance) {
|
|
376
401
|
const firstOption = snapshot.blockedGuidance.options[0]?.command;
|
|
377
402
|
if (firstOption) {
|
|
@@ -401,6 +426,9 @@ export function formatStatusNextActionForState(state) {
|
|
|
401
426
|
},
|
|
402
427
|
runId,
|
|
403
428
|
blockedGuidance: buildBlockedGuidance({ state, runId }),
|
|
429
|
+
// This state-only path has no events access, so it cannot see provider
|
|
430
|
+
// errors and renders the plain decision-based action.
|
|
431
|
+
providerError: null,
|
|
404
432
|
});
|
|
405
433
|
}
|
|
406
434
|
function formatNextActionForDecision(decision) {
|
|
@@ -1026,6 +1054,29 @@ function parseSquashArtifact(value) {
|
|
|
1026
1054
|
originalFinalCommit,
|
|
1027
1055
|
};
|
|
1028
1056
|
}
|
|
1057
|
+
// Events that prove the run progressed past a provider failure: a provider
|
|
1058
|
+
// turn or structured round finished, or a whole phase completed. A provider
|
|
1059
|
+
// error followed by any of these is resolved history, not the active failure.
|
|
1060
|
+
const PROVIDER_ERROR_RESOLUTION_EVENT_TYPES = new Set([
|
|
1061
|
+
'provider.turn_completed',
|
|
1062
|
+
'provider.structured_output_received',
|
|
1063
|
+
'phase.complete',
|
|
1064
|
+
]);
|
|
1065
|
+
// True while the latest provider_error event has no later resolution event
|
|
1066
|
+
// after it in the tail, so conditional guidance keyed off the error (the
|
|
1067
|
+
// input_too_large Next Action) stops as soon as a retry or resume succeeds.
|
|
1068
|
+
function isProviderErrorActive(events) {
|
|
1069
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
1070
|
+
const type = events[index].type;
|
|
1071
|
+
if (type === 'provider.provider_error') {
|
|
1072
|
+
return true;
|
|
1073
|
+
}
|
|
1074
|
+
if (PROVIDER_ERROR_RESOLUTION_EVENT_TYPES.has(type)) {
|
|
1075
|
+
return false;
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return false;
|
|
1079
|
+
}
|
|
1029
1080
|
function summarizeProviderError(events) {
|
|
1030
1081
|
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
1031
1082
|
const event = events[index];
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { truncateInlineSectionBody } from './context/inline-review-context.js';
|
|
1
2
|
function stringValue(value) {
|
|
2
3
|
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
3
4
|
}
|
|
@@ -56,6 +57,54 @@ function renderCommandStatus(result) {
|
|
|
56
57
|
}
|
|
57
58
|
return 'unknown';
|
|
58
59
|
}
|
|
60
|
+
// Bounds for the final-completion verification tally: at most this many recent
|
|
61
|
+
// failing commands, each command string capped so one pathological command
|
|
62
|
+
// cannot inflate the tally.
|
|
63
|
+
export const VERIFICATION_TALLY_RECENT_FAILURE_LIMIT = 10;
|
|
64
|
+
export const VERIFICATION_TALLY_COMMAND_MAX_CHARS = 300;
|
|
65
|
+
// Builds the bounded verification tally the final-completion prompts embed:
|
|
66
|
+
// pass/fail/unknown counts over the latest result per distinct command, plus
|
|
67
|
+
// the last few failing commands with their exit codes. Distinct commands are
|
|
68
|
+
// ordered by their latest event position (not first appearance), so a repeated
|
|
69
|
+
// command that failed at the end of the run counts as recent. The complete
|
|
70
|
+
// per-command record stays in events.ndjson.
|
|
71
|
+
export function buildVerificationTally(results) {
|
|
72
|
+
const latestByCommand = new Map();
|
|
73
|
+
results.forEach((result, index) => {
|
|
74
|
+
latestByCommand.set(result.command, { result, lastIndex: index });
|
|
75
|
+
});
|
|
76
|
+
const latestResults = [...latestByCommand.values()]
|
|
77
|
+
.sort((a, b) => a.lastIndex - b.lastIndex)
|
|
78
|
+
.map((entry) => entry.result);
|
|
79
|
+
let passed = 0;
|
|
80
|
+
let failed = 0;
|
|
81
|
+
let unknown = 0;
|
|
82
|
+
const failures = [];
|
|
83
|
+
for (const result of latestResults) {
|
|
84
|
+
const status = renderCommandStatus(result);
|
|
85
|
+
if (status === 'passed') {
|
|
86
|
+
passed += 1;
|
|
87
|
+
}
|
|
88
|
+
else if (status === 'failed') {
|
|
89
|
+
failed += 1;
|
|
90
|
+
failures.push({
|
|
91
|
+
command: truncateInlineSectionBody(result.command, VERIFICATION_TALLY_COMMAND_MAX_CHARS),
|
|
92
|
+
exitCode: result.exitCode,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
unknown += 1;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
totalRuns: results.length,
|
|
101
|
+
distinctCommands: latestResults.length,
|
|
102
|
+
passed,
|
|
103
|
+
failed,
|
|
104
|
+
unknown,
|
|
105
|
+
recentFailures: failures.slice(-VERIFICATION_TALLY_RECENT_FAILURE_LIMIT),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
59
108
|
export function summarizeVerificationCommandResults(results) {
|
|
60
109
|
if (results.length === 0) {
|
|
61
110
|
return 'No verification commands were recorded in events.ndjson.';
|