@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.
@@ -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 lastImplementationScope = args.packet.lastNonEmptyImplementationScope
32
- ? JSON.stringify(args.packet.lastNonEmptyImplementationScope, null, 2)
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
- verificationCommandResults: args.packet.verificationCommandResults,
62
- verificationSummary: args.packet.verificationSummary,
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 lastImplementationScope = args.packet.lastNonEmptyImplementationScope
89
- ? JSON.stringify(args.packet.lastNonEmptyImplementationScope, null, 2)
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
- verificationCommandResults: args.packet.verificationCommandResults,
185
- verificationSummary: args.packet.verificationSummary,
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: 4,
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: 1,
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: 3,
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,
@@ -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'}`,
@@ -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.';