Skip to content

Commit 9ef58de

Browse files
ozandclaude
andauthored
fix(context): degrade the prompt visibly instead of failing the cycle (#1438) (#1440)
* wip(context): uniform degradation ladder for prompt cap overflow (#1438) Work-in-progress snapshot committed by the operator after the agent's session was terminated by a gateway 504. Not reviewed, not test-run. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(context): make the ladder's degradation visible, fair, and correctly named Four defects in the WIP snapshot: - Both degradation warnings used %-style placeholders while the project logs through loguru, so the alert emitted its own format string: "starved=%s shortfall=%d cap=%d". An alert that carries no data reads the same as one that never fired. - The rung recorded on the non-strict path was "uniform_trim", but that branch line-trims and then drops whole sections in iteration order. It is now "line_trim": prompt_fit_rung must not count a positional mechanism and the ladder as one thing. - The budget was a flat available//n, so a 50-char section reserved the same allowance as a 40,000-char one and the slack was unusable. Budgets are now water-filled: an entry shorter than its share keeps everything and the remainder is redistributed. The allocation is keyed on length, not order — permuting the entries permutes the budgets identically. - A trimmed entry was cut with no marker, so the loss was visible only in our log and not in the artifact the model reads. Each trimmed entry now carries "[trimmed N chars]" inside its own budget. Tests: _fair_budgets is pinned against permutation (the property a tail-slice implementation fails while still "fitting"), two over-budget entries of very different length are asserted to receive the same allowance, and the trim note is asserted to name the loss. Corrects one assertion rather than the code: the subagent test required the whole returned prompt under the cap, but #1379 appends the operator charter after the fit, deliberately outside it. The test now bounds the capped portion and pins the charter tail explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent e5276f8 commit 9ef58de

5 files changed

Lines changed: 220 additions & 12 deletions

File tree

nanobot/agent/context.py

Lines changed: 153 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,16 +78,17 @@ def build_system_prompt(
7878
excluded_skill_names: list[str] | None = None,
7979
loop_profile: bool = False,
8080
strict: bool | None = None,
81+
degrade_on_overflow: bool = False,
8182
) -> str:
8283
"""Build the system prompt from identity, bootstrap files, skills, and memory.
8384
8485
Prompt ordering is identity, bootstrap, active skills, skills
8586
catalogue, then memory. Under the cap (#1300):
8687
87-
* ``strict`` (default for the loop profile): only bootstrap ``## ``
88+
* ``strict`` (default for the loop profile): only bootstrap ``## ```
8889
sections carrying :data:`DROPPABLE_MARKER` may be dropped, whole,
89-
largest first. If the rest still does not fit, raise
90-
:class:`SystemPromptOverflowError` — never choose survivors by position.
90+
largest first. If the rest still does not fit, the prompt degrades
91+
uniformly and visibly — never choose survivors by position.
9192
* non-strict (interactive sessions): the pre-#1300 behaviour, bootstrap
9293
trimmed first at complete-line boundaries, loss logged.
9394
@@ -128,7 +129,9 @@ def build_system_prompt(
128129

129130
memory = self.memory.get_memory_context(loop=loop_profile)
130131
sections.append(("memory", f"# Memory\n\n{memory}" if memory else ""))
131-
return self._fit_system_prompt(sections, strict=strict)
132+
return self._fit_system_prompt(
133+
sections, strict=strict, degrade_on_overflow=degrade_on_overflow,
134+
)
132135

133136
SECTION_SEPARATOR = "\n\n---\n\n"
134137

@@ -275,15 +278,96 @@ def _droppable_reserve_chars(self, sections: list[tuple[str, str]]) -> int:
275278
units = self._split_bootstrap_sections(sections[index][1])
276279
return sum(len(text) for _, text in units if self.DROPPABLE_MARKER in text)
277280

278-
def _fit_system_prompt(self, sections: list[tuple[str, str]], strict: bool = False) -> str:
281+
@staticmethod
282+
def _entry_names_only(sections: list[tuple[str, str]]) -> list[tuple[str, str]]:
283+
"""Keep each assembled entry's identifier while dropping its body."""
284+
return [(name, f"# {name}") for name, content in sections if content]
285+
286+
TRIM_NOTE = "\n\n[trimmed {n} chars]"
287+
288+
@staticmethod
289+
def _fair_budgets(lengths: list[int], available: int) -> list[int]:
290+
"""Split ``available`` across entries by equal share, water-filling.
291+
292+
An entry shorter than its share keeps all of its content, and what it
293+
does not use is redistributed to the entries still over budget. A flat
294+
``available // n`` would gut a long entry to hand unusable slack to a
295+
short one.
296+
297+
The allocation is keyed on LENGTH, never on order: permuting the input
298+
permutes the output identically. That is what "no survivor chosen by
299+
position" means here — a shorter entry is never cut so a longer one can
300+
survive whole, and where the input arrives in the list decides nothing.
301+
"""
302+
budgets = [0] * len(lengths)
303+
pending = list(range(len(lengths)))
304+
remaining = max(0, available)
305+
while pending:
306+
share = remaining // len(pending)
307+
if share <= 0:
308+
break
309+
settled = [i for i in pending if lengths[i] <= share]
310+
if not settled:
311+
# Everyone left is over the share: they all take it, equally.
312+
for i in pending:
313+
budgets[i] = share
314+
break
315+
for i in settled:
316+
budgets[i] = lengths[i]
317+
remaining -= lengths[i]
318+
pending.remove(i)
319+
return budgets
320+
321+
def _uniform_trim(
322+
self,
323+
sections: list[tuple[str, str]],
324+
cap: int,
325+
) -> tuple[list[tuple[str, str]], int]:
326+
"""Trim each non-empty entry to its share of one shared budget.
327+
328+
No survivor is selected by position and the assembled prompt is never
329+
sliced as one string. A section is an atomic entry: its contents may
330+
be shortened, but it cannot be split into separately-selected pieces.
331+
332+
A trimmed entry carries :data:`TRIM_NOTE` inside its own budget, so the
333+
loss is visible in the artifact the model reads and not only in the log
334+
line we emit. Degradation nobody can see reads exactly like degradation
335+
that never happened.
336+
"""
337+
entries = [(name, content) for name, content in sections if content]
338+
if not entries:
339+
return [], 0
340+
separator_chars = len(self.SECTION_SEPARATOR) * max(0, len(entries) - 1)
341+
budgets = self._fair_budgets(
342+
[len(content) for _, content in entries], max(0, cap - separator_chars)
343+
)
344+
trimmed: list[tuple[str, str]] = []
345+
for (name, content), budget in zip(entries, budgets):
346+
if len(content) <= budget:
347+
trimmed.append((name, content))
348+
continue
349+
note = self.TRIM_NOTE.format(n=len(content) - budget)
350+
kept = max(0, budget - len(note))
351+
# If the note itself does not fit the budget, the body is already
352+
# down to noise: keep the truncated body rather than only a note.
353+
trimmed.append((name, content[:kept] + note if kept else content[:budget]))
354+
shortfall = max(0, len(self._join_sections(sections)) - cap)
355+
return trimmed, shortfall
356+
357+
def _fit_system_prompt(
358+
self,
359+
sections: list[tuple[str, str]],
360+
strict: bool = False,
361+
degrade_on_overflow: bool = False,
362+
) -> str:
279363
"""Fit sections under the cap and record the outcome in :attr:`last_fit`.
280364
281365
Strict (#1300): the only content the cap may remove is a bootstrap
282366
section the operator marked :data:`DROPPABLE_MARKER`, removed whole,
283367
largest first. Position never decides. If critical content still does
284-
not fit, :class:`SystemPromptOverflowError` is raised — an under-specified
285-
prompt is a failed build, not a quieter one. The decision recorded
286-
here: the cap never drops a critical section.
368+
not fit, the prompt uses the uniform degradation ladder: trim every
369+
entry to the same budget, then fall back to names only. The decision
370+
recorded here: the cap never drops a critical section by position.
287371
288372
Non-strict (interactive sessions): the pre-#1300 behaviour — bootstrap
289373
is trimmed first at complete-line boundaries (#1191), then the later
@@ -300,6 +384,18 @@ def _fit_system_prompt(self, sections: list[tuple[str, str]], strict: bool = Fal
300384
fit: dict[str, Any] = {"cap": cap, "strict": strict, "dropped": []}
301385
joined = self._record_fit(fit, section_names, sections)
302386
if len(joined) <= cap:
387+
occupancy = len(joined) / cap if cap else 1.0
388+
fit.update(
389+
rung="full",
390+
shortfall=0,
391+
starved=[],
392+
occupancy_alert=occupancy >= 0.92,
393+
)
394+
if occupancy >= 0.92:
395+
logger.warning(
396+
"System prompt occupancy alert: rung=full occupancy={:.1%} cap={} chars={}",
397+
occupancy, cap, len(joined),
398+
)
303399
fit["droppable_reserve_chars"] = self._droppable_reserve_chars(sections)
304400
self.last_fit = fit
305401
return joined
@@ -310,6 +406,47 @@ def _fit_system_prompt(self, sections: list[tuple[str, str]], strict: bool = Fal
310406
droppable_reserve_chars = self._droppable_reserve_chars(sections)
311407
fit.update(dropped=dropped, droppable_reserve_chars=droppable_reserve_chars)
312408
self.last_fit = fit
409+
if len(prompt) > cap and degrade_on_overflow:
410+
shortfall = len(prompt) - cap
411+
degraded, _ = self._uniform_trim(sections, cap)
412+
uniform_prompt = self._record_fit(fit, section_names, degraded)
413+
if len(uniform_prompt) <= cap and all(content for _, content in degraded):
414+
starved = [name for name, content in sections if len(content) > len(dict(degraded).get(name, ""))]
415+
fit.update(
416+
rung="uniform_trim",
417+
shortfall=shortfall,
418+
starved=starved,
419+
occupancy_alert=False,
420+
dropped=dropped,
421+
droppable_reserve_chars=0,
422+
)
423+
self.last_fit = fit
424+
logger.warning(
425+
"System prompt cap degradation: rung=uniform_trim "
426+
"starved={} shortfall={} cap={}",
427+
",".join(starved) or "none", shortfall, cap,
428+
)
429+
return uniform_prompt
430+
431+
names_only = self._entry_names_only(sections)
432+
names_prompt = self._record_fit(fit, section_names, names_only)
433+
if len(names_prompt) <= cap:
434+
starved = [name for name, content in sections if content]
435+
fit.update(
436+
rung="names_only",
437+
shortfall=shortfall,
438+
starved=starved,
439+
occupancy_alert=False,
440+
dropped=dropped,
441+
droppable_reserve_chars=0,
442+
)
443+
self.last_fit = fit
444+
logger.warning(
445+
"System prompt cap degradation: rung=names_only "
446+
"starved={} shortfall={} cap={}",
447+
",".join(starved) or "none", shortfall, cap,
448+
)
449+
return names_prompt
313450
if len(prompt) > cap:
314451
raise SystemPromptOverflowError(
315452
over_by=len(prompt) - cap, cap=cap,
@@ -348,6 +485,14 @@ def _fit_system_prompt(self, sections: list[tuple[str, str]], strict: bool = Fal
348485
logger.warning("System prompt cap dropped content: {}", details)
349486
prompt = self._record_fit(fit, section_names, sections)
350487
fit.update(
488+
# NOT `uniform_trim`: this branch line-trims and then drops whole
489+
# sections in iteration order. Labelling a positional mechanism
490+
# with the ladder's name would make `prompt_fit_rung` count two
491+
# different behaviours as one.
492+
rung="line_trim" if dropped_chars else "full",
493+
shortfall=0,
494+
starved=list(dropped_chars),
495+
occupancy_alert=(len(prompt) / cap >= 0.92 if cap else True),
351496
dropped=[{"section": n, "chars": c, "how": "line-trim"} for n, c in dropped_chars.items()],
352497
droppable_reserve_chars=self._droppable_reserve_chars(sections),
353498
)

nanobot/agent/subagent.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -704,6 +704,7 @@ def _build_subagent_prompt(self) -> str:
704704
prompt = builder.build_system_prompt(
705705
excluded_skill_names=self._excluded_skill_names or None,
706706
loop_profile=True,
707+
degrade_on_overflow=True,
707708
)
708709
finally:
709710
self.last_prompt_fit = builder.last_fit

nanobot/runtime/bridge.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2959,9 +2959,13 @@ async def _evaluate_candidate(cand_cycle_id: str, do_integration: bool, meas_met
29592959
# here; a critical one that does not fit raises above instead.
29602960
_prompt_fit = getattr(mgr, 'last_prompt_fit', None)
29612961
if isinstance(_prompt_fit, dict):
2962+
_prompt_fit_rung = _prompt_fit.get('rung')
29622963
append_event(STATE_DIR, {
29632964
'phase': 'system_prompt', 'cycle_id': _cycle_id,
29642965
'chars': _prompt_fit.get('chars'), 'cap': _prompt_fit.get('cap'),
2966+
'rung': _prompt_fit.get('rung'),
2967+
'shortfall': _prompt_fit.get('shortfall', 0),
2968+
'occupancy_alert': bool(_prompt_fit.get('occupancy_alert', False)),
29652969
# #1379: the per-section breakdown on EVERY row, not only
29662970
# at overflow — a legitimately empty section is 0, not
29672971
# absent. ``None`` only if the builder recorded nothing
@@ -3735,6 +3739,7 @@ async def _evaluate_candidate(cand_cycle_id: str, do_integration: bool, meas_met
37353739
'subagent_task_id': locals().get('_subagent_task_id', None),
37363740
'executor_llm_error': locals().get('_executor_llm_error_text', ''),
37373741
'system_prompt_overflow': locals().get('_system_prompt_overflow_text', ''),
3742+
'prompt_fit_rung': locals().get('_prompt_fit_rung'),
37383743
'origin_main_observed': locals().get('_origin_main_observed', locals().get('main_sha_before', ''))
37393744
}
37403745

@@ -3836,6 +3841,7 @@ async def _evaluate_candidate(cand_cycle_id: str, do_integration: bool, meas_met
38363841
_subagent_task_id = _res.get('subagent_task_id')
38373842
_executor_llm_error_text = str(_res.get('executor_llm_error') or '')
38383843
_system_prompt_overflow_text = str(_res.get('system_prompt_overflow') or '')
3844+
_prompt_fit_rung = None
38393845
commits_pushed = cycle_commit_count if _integrated else 0
38403846
import subprocess as _sp
38413847

@@ -3959,6 +3965,7 @@ async def _evaluate_candidate(cand_cycle_id: str, do_integration: bool, meas_met
39593965
# dead executor is countable from this row alone.
39603966
executor_llm_error=bool(_executor_llm_error_text),
39613967
lane=req.get('lane') or None,
3968+
prompt_fit_rung=_res.get('prompt_fit_rung'),
39623969
)
39633970
# #721: post-cycle tag at the terminal HEAD, same outcome value as the
39643971
# ledger row above. Integrated -> main_sha_after (shared checkout stayed on

nanobot/runtime/cycle_ledger.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,7 @@ def record_cycle_outcome(
239239
verdict_reason: str | None = None,
240240
executor_llm_error: bool = False,
241241
lane: str | None = None,
242+
prompt_fit_rung: str | None = None,
242243
) -> None:
243244
"""Write the terminal, exactly-once-per-cycle row with an enum ``outcome``.
244245
@@ -294,6 +295,8 @@ def record_cycle_outcome(
294295
# written without ``lane`` (every pre-#1411 call site) is byte-
295296
# identical to before.
296297
row["lane"] = str(lane)
298+
if prompt_fit_rung:
299+
row["prompt_fit_rung"] = str(prompt_fit_rung)[:40]
297300
if files_changed is not None:
298301
try:
299302
from nanobot.runtime.demand import classify_change_tier

tests/test_context_prompt_fit.py

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def test_strict_drops_only_declared_sections_largest_first_and_records_them(tmp_
7171

7272

7373
def test_strict_refuses_when_critical_sections_do_not_fit(tmp_path, monkeypatch):
74-
"""The decision recorded for #1300: the cap never drops a critical section."""
74+
"""Direct strict callers retain the old refusal contract."""
7575
monkeypatch.setattr(ContextBuilder, "MAX_SYSTEM_PROMPT_CHARS", 4_000)
7676
bootstrap = _section("Working knowledge", 40) + _section("Optional", 4, droppable=True) + _section("Standard test runner", 40)
7777
builder = _builder(tmp_path, bootstrap)
@@ -87,7 +87,7 @@ def test_strict_refuses_when_critical_sections_do_not_fit(tmp_path, monkeypatch)
8787

8888

8989
def test_strict_never_trims_lines_inside_a_section(tmp_path, monkeypatch):
90-
"""Position-based line trimming is the defect; strict mode must not fall back to it."""
90+
"""Position-based line trimming is the defect; direct strict mode must not fall back to it."""
9191
monkeypatch.setattr(ContextBuilder, "MAX_SYSTEM_PROMPT_CHARS", 3_000)
9292
builder = _builder(tmp_path, _section("Working knowledge", 80))
9393
with pytest.raises(SystemPromptOverflowError):
@@ -176,11 +176,63 @@ def test_subagent_prompt_is_strict_and_exposes_the_fit(tmp_path, monkeypatch):
176176
mgr.workspace = tmp_path
177177
mgr._excluded_skill_names = []
178178
mgr.system_context = "# Immutable operator charter\n\ncharter"
179-
with pytest.raises(SystemPromptOverflowError):
180-
mgr._build_subagent_prompt()
179+
prompt = mgr._build_subagent_prompt()
181180
assert isinstance(mgr.last_prompt_fit, dict) and mgr.last_prompt_fit["strict"] is True
181+
assert mgr.last_prompt_fit["rung"] == "uniform_trim"
182+
# #1379: the operator charter is appended AFTER the fit and is deliberately
183+
# outside the cap, so the capped portion is what the ladder bounds — not
184+
# the returned string. Asserting the whole string would be asserting the
185+
# charter away.
186+
charter_tail = ContextBuilder.SECTION_SEPARATOR + mgr.system_context
187+
assert prompt.endswith(charter_tail)
188+
assert len(prompt) - len(charter_tail) <= 3_000
189+
assert mgr.last_prompt_fit["chars"] <= 3_000
182190

183191
monkeypatch.setenv(ContextBuilder.SYSTEM_PROMPT_CAP_ENV, "60000")
184192
prompt = mgr._build_subagent_prompt()
185193
assert prompt.endswith("# Immutable operator charter\n\ncharter") and "## Working knowledge" in prompt
186194
assert mgr.last_prompt_fit["dropped"] == []
195+
196+
197+
def test_fair_budgets_are_keyed_on_length_not_position():
198+
"""Permuting the entries must permute the budgets identically.
199+
200+
This is the property that separates the ladder from positional
201+
truncation: where an entry sits in the list decides nothing. A tail-slice
202+
implementation would pass a "does it fit" assertion but fail this one.
203+
"""
204+
lengths = [10, 4_000, 40, 4_000]
205+
budgets = ContextBuilder._fair_budgets(lengths, 2_000)
206+
reversed_budgets = ContextBuilder._fair_budgets(lengths[::-1], 2_000)
207+
assert budgets == reversed_budgets[::-1]
208+
# The two short entries fit outright; the two long ones share what is left
209+
# and receive the SAME budget as each other, not a prefix-first split.
210+
assert budgets[0] == 10 and budgets[2] == 40
211+
assert budgets[1] == budgets[3]
212+
assert sum(budgets) <= 2_000
213+
214+
215+
def test_uniform_trim_gives_over_budget_entries_the_same_allowance(tmp_path):
216+
"""Two entries of very different length get the same budget when both overflow."""
217+
builder = _builder(tmp_path, "")
218+
sections = [("short", "s" * 5_000), ("long", "l" * 50_000)]
219+
trimmed, shortfall = builder._uniform_trim(sections, 6_000)
220+
kept = dict(trimmed)
221+
assert len(kept["short"]) == len(kept["long"]), (
222+
"a 10x length difference must not buy a larger allowance"
223+
)
224+
assert shortfall > 0
225+
assert builder.TRIM_NOTE.split("{")[0].strip() in kept["long"], (
226+
"the loss must be visible in the artifact the model reads"
227+
)
228+
229+
230+
def test_trimmed_entry_reports_the_characters_it_lost(tmp_path):
231+
builder = _builder(tmp_path, "")
232+
sections = [("only", "x" * 10_000)]
233+
trimmed, _ = builder._uniform_trim(sections, 1_000)
234+
body = dict(trimmed)["only"]
235+
assert len(body) <= 1_000
236+
lost = 10_000 - (1_000 - len(builder.TRIM_NOTE.format(n=0)))
237+
assert "[trimmed" in body and "chars]" in body
238+
assert str(lost)[:2] in body, "the note names how much went, not just that something did"

0 commit comments

Comments
 (0)