Skip to content

Commit 48e0024

Browse files
committed
Stop large repositories from aborting the pipeline on node 2
Three fixes on the path a large target takes: list_files bounds its listing on all four return paths; requests over the context window are refused before dispatch and not retried; worktree-capacity refusals are distinguishable from "not a git repository" (cap 500k -> 1M). The context check deliberately fails open -- it is a reliability control, not a security boundary. Rationale is inline at each site. Change-Id: Id58b85fc73a049eb4b5e794d42b2099e25429afa
1 parent dd5d793 commit 48e0024

4 files changed

Lines changed: 840 additions & 29 deletions

File tree

‎reference/core/config.py‎

Lines changed: 183 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,179 @@ class MantisStreamingTruncationError(RuntimeError):
338338
pass
339339

340340

341+
class ContextBudgetExceededError(RuntimeError):
342+
"""Raised before dispatch when a request cannot possibly fit the model's context window.
343+
344+
Sending it anyway costs a full upload of the payload, a provider-side rejection, and --
345+
because the node retry layer cannot tell a deterministic overflow from a transient fault
346+
-- two more identical uploads of the same doomed request. The failure is deterministic in
347+
the request itself, so the only useful thing to do with it is refuse early and say which
348+
message was too big.
349+
"""
350+
351+
def __init__(self, message: str, estimated_tokens: int = 0, limit: int = 0):
352+
super().__init__(message)
353+
self.estimated_tokens = estimated_tokens
354+
self.limit = limit
355+
356+
357+
# Exceptions whose outcome is fully determined by the request, so re-running the node with
358+
# the identical request can only reproduce them. ADK's node retry cannot distinguish these
359+
# from transient faults and will burn the full attempt budget on them.
360+
#
361+
# Matched by class name because several of these types live in third-party packages that may
362+
# not be importable in every deployment, and a missing import must not silently disable the
363+
# rule. Note this is intentionally NOT the same list as the log-suppression filter below:
364+
# retrying an error and reporting it are separate decisions, and a context overflow must stay
365+
# loudly visible precisely because the operator is the one who has to act on it.
366+
_NON_RETRYABLE_EXC_NAMES = (
367+
"BudgetExceededError",
368+
"LlmCallsLimitExceededError",
369+
"MantisAuthError",
370+
"ContextBudgetExceededError",
371+
"ContextWindowExceededError",
372+
)
373+
374+
375+
# Characters per token. Deliberately a constant rather than a real tokenizer: tokenizing a
376+
# multi-megabyte payload to discover that it is multi-megabyte is self-defeating, and the
377+
# guard only needs to separate "plausibly fits" from "an order of magnitude over". Real code
378+
# runs denser than 4 chars/token, so this UNDER-estimates and the guard errs toward letting
379+
# a borderline request through to the provider -- the fail-open direction, which matters
380+
# because this is a reliability control, not a security boundary.
381+
_CHARS_PER_TOKEN = 4
382+
383+
# Fraction of the window a request may occupy before it is refused. Below 1.0 because the
384+
# estimate is approximate and the response needs room too; a request at 95% of the window
385+
# has no space left to answer in.
386+
_CONTEXT_BUDGET_RATIO = 0.9
387+
388+
389+
def _message_char_len(message: Any) -> int:
390+
"""Total character length of one chat message, including structured content parts."""
391+
if isinstance(message, str):
392+
return len(message)
393+
if not isinstance(message, Mapping):
394+
return len(str(message))
395+
396+
total = 0
397+
for key in ("role", "name", "tool_call_id"):
398+
value = message.get(key)
399+
if isinstance(value, str):
400+
total += len(value)
401+
402+
content = message.get("content")
403+
if isinstance(content, str):
404+
total += len(content)
405+
elif isinstance(content, (list, tuple)):
406+
for part in content:
407+
if isinstance(part, Mapping):
408+
text = part.get("text")
409+
total += len(text) if isinstance(text, str) else len(str(part))
410+
else:
411+
total += len(str(part))
412+
elif content is not None:
413+
total += len(str(content))
414+
415+
tool_calls = message.get("tool_calls")
416+
if isinstance(tool_calls, (list, tuple)):
417+
for call in tool_calls:
418+
total += len(str(call))
419+
420+
return total
421+
422+
423+
def estimate_prompt_tokens(messages: Any, tools: Any = None) -> int:
424+
"""Cheap upper-bound-ish token estimate for a request, without tokenizing it."""
425+
total_chars = 0
426+
if isinstance(messages, (list, tuple)):
427+
for message in messages:
428+
total_chars += _message_char_len(message)
429+
elif messages is not None:
430+
total_chars += _message_char_len(messages)
431+
432+
if tools:
433+
try:
434+
total_chars += len(json.dumps(tools, default=str))
435+
except (TypeError, ValueError):
436+
total_chars += len(str(tools))
437+
438+
return total_chars // _CHARS_PER_TOKEN
439+
440+
441+
def resolve_context_limit(model: Any) -> Optional[int]:
442+
"""Returns the model's max input tokens, or None when it cannot be determined.
443+
444+
Fails open on purpose. An unknown window means we cannot prove the request is doomed,
445+
and refusing a request that would have succeeded is a worse failure than the one this
446+
guard exists to prevent.
447+
"""
448+
try:
449+
import litellm
450+
except Exception:
451+
return None
452+
453+
model_name = str(model)
454+
# Only ever input limits. litellm's get_max_tokens()/"max_tokens" report the max OUTPUT
455+
# tokens -- often a couple of orders of magnitude smaller than the context window -- so
456+
# using either as an input budget would refuse ordinary requests.
457+
fn = getattr(litellm, "get_max_input_tokens", None)
458+
if fn is not None:
459+
try:
460+
value = fn(model_name)
461+
except Exception:
462+
value = None
463+
if isinstance(value, int) and value > 0:
464+
return value
465+
466+
try:
467+
info = litellm.get_model_info(model_name)
468+
except Exception:
469+
return None
470+
if isinstance(info, Mapping):
471+
value = info.get("max_input_tokens")
472+
if isinstance(value, int) and value > 0:
473+
return value
474+
return None
475+
476+
477+
def _largest_message_summary(messages: Any) -> str:
478+
"""Names the biggest contributor to an oversized request so the failure is actionable."""
479+
if not isinstance(messages, (list, tuple)) or not messages:
480+
return ""
481+
sizes = [(_message_char_len(m), i, m) for i, m in enumerate(messages)]
482+
chars, index, message = max(sizes, key=lambda item: item[0])
483+
role = message.get("role", "?") if isinstance(message, Mapping) else "?"
484+
return (
485+
f" Largest contributor: message {index} (role={role}) at {chars:,} characters "
486+
f"(~{chars // _CHARS_PER_TOKEN:,} tokens)."
487+
)
488+
489+
490+
def enforce_context_budget(model: Any, messages: Any, tools: Any = None) -> None:
491+
"""Refuses a request that cannot fit the model's context window, before dispatching it."""
492+
limit = resolve_context_limit(model)
493+
if not limit:
494+
return
495+
496+
estimated = estimate_prompt_tokens(messages, tools)
497+
budget = int(limit * _CONTEXT_BUDGET_RATIO)
498+
if estimated <= budget:
499+
return
500+
501+
raise ContextBudgetExceededError(
502+
f"Request to '{model}' is too large for its context window: estimated "
503+
f"~{estimated:,} input tokens against a {limit:,}-token window "
504+
f"({estimated / limit:.1f}x the window, budget {budget:,})."
505+
f"{_largest_message_summary(messages)}"
506+
" Refused before dispatch; retrying the identical request cannot succeed. "
507+
"Narrow the tool output feeding this node (for example, pass a 'directory' to "
508+
"list_files or a smaller range to read_file).",
509+
estimated_tokens=estimated,
510+
limit=limit,
511+
)
512+
513+
341514
def is_auth_error(e: Optional[Exception]) -> bool:
342515
"""Detects whether an exception represents an authentication or token refresh failure."""
343516
if e is None:
@@ -677,21 +850,13 @@ def _non_retryable_should_retry_node(
677850
if is_auth_error(exception) or isinstance(exception, MantisAuthError):
678851
return False
679852
exc_cls_name = getattr(getattr(exception, "__class__", None), "__name__", "")
680-
if exc_cls_name in (
681-
"BudgetExceededError",
682-
"LlmCallsLimitExceededError",
683-
"MantisAuthError",
684-
):
853+
if exc_cls_name in _NON_RETRYABLE_EXC_NAMES:
685854
return False
686855
cause = getattr(exception, "__cause__", None) or getattr(exception, "__context__", None)
687856
if cause is not None and (
688857
is_auth_error(cause)
689858
or isinstance(cause, MantisAuthError)
690-
or getattr(getattr(cause, "__class__", None), "__name__", "") in (
691-
"BudgetExceededError",
692-
"LlmCallsLimitExceededError",
693-
"MantisAuthError",
694-
)
859+
or getattr(getattr(cause, "__class__", None), "__name__", "") in _NON_RETRYABLE_EXC_NAMES
695860
):
696861
return False
697862
return _orig_adk_should_retry_node(exception, retry_config, node_state)
@@ -713,6 +878,10 @@ async def acompletion(
713878
) -> Any:
714879
import litellm
715880

881+
# Before the retry loop, not inside it: an oversized request is deterministic, so
882+
# every pass through the loop would upload the same doomed payload again.
883+
enforce_context_budget(model, messages, tools)
884+
716885
max_patience = float(os.environ.get("MANTIS_LLM_MAX_PATIENCE_SECONDS", "3600.0"))
717886
initial_delay = float(os.environ.get("MANTIS_LLM_RETRY_INITIAL_DELAY", "5.0"))
718887
max_delay = float(os.environ.get("MANTIS_LLM_RETRY_MAX_DELAY", "60.0"))
@@ -783,6 +952,10 @@ def completion(
783952
) -> Any:
784953
import litellm
785954

955+
# Both dispatch paths are guarded: a control that only covers the async path is a
956+
# control that a single synchronous caller silently disables.
957+
enforce_context_budget(model, messages, tools)
958+
786959
max_patience = float(os.environ.get("MANTIS_LLM_MAX_PATIENCE_SECONDS", "3600.0"))
787960
initial_delay = float(os.environ.get("MANTIS_LLM_RETRY_INITIAL_DELAY", "5.0"))
788961
max_delay = float(os.environ.get("MANTIS_LLM_RETRY_MAX_DELAY", "60.0"))

‎reference/scripts/neuter_matrix.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,46 @@
202202
r'for line in f_out.split("\0"):',
203203
'for line in f_out.splitlines():'),
204204
],
205+
206+
# ---- Large-repository scaling controls ----
207+
# Each reverts the exact line the corresponding pin exercises. Reverting a helper the
208+
# pin does not reach would produce an inert scenario that reds nothing.
209+
"scale_revert_bounded_listing": [
210+
("tools/research_tools.py",
211+
" return _bounded_listing(sorted(files), directory)\n except Exception as e:\n return f\"Error listing files: {e}\"",
212+
" return json.dumps(sorted(files), indent=2) # NEUTERED: unbounded listing\n except Exception as e:\n return f\"Error listing files: {e}\""),
213+
],
214+
"scale_revert_presend_context_guard": [
215+
("core/config.py",
216+
" # Before the retry loop, not inside it: an oversized request is deterministic, so\n # every pass through the loop would upload the same doomed payload again.\n enforce_context_budget(model, messages, tools)",
217+
" pass # NEUTERED: no pre-dispatch context check"),
218+
("core/config.py",
219+
" # Both dispatch paths are guarded: a control that only covers the async path is a\n # control that a single synchronous caller silently disables.\n enforce_context_budget(model, messages, tools)",
220+
" pass # NEUTERED: no pre-dispatch context check"),
221+
],
222+
"scale_revert_overflow_nonretryable": [
223+
("core/config.py",
224+
' "ContextBudgetExceededError",\n "ContextWindowExceededError",\n)',
225+
") # NEUTERED: overflow is retried three times identically"),
226+
],
227+
"scale_revert_capacity_sentinel": [
228+
("tools/research_tools.py",
229+
' f"{REPO_TOO_LARGE_PREFIX}the worktree holds more than {worktree_cap} entries, "\n f"so the submodule-escape inspection cannot complete. Set "\n f"{_MAX_WORKTREE_ENTRIES_ENV} to a higher entry count to raise this limit."',
230+
' f"Repository worktree exceeds entry limit ({worktree_cap}); refusing to validate." # NEUTERED'),
231+
],
232+
"scale_revert_vcs_capacity_branch": [
233+
("tools/research_tools.py",
234+
' if err.startswith(REPO_TOO_LARGE_PREFIX):\n detail = err.removeprefix(REPO_TOO_LARGE_PREFIX).rstrip()',
235+
' if False: # NEUTERED: capacity collapses back into absence\n detail = err.removeprefix(REPO_TOO_LARGE_PREFIX).rstrip()'),
236+
("tools/research_tools.py",
237+
' kind = "unknown" if err.startswith(REPO_TOO_LARGE_PREFIX) else "none"',
238+
' kind = "none" # NEUTERED'),
239+
],
240+
"scale_revert_worktree_cap_raise": [
241+
("tools/research_tools.py",
242+
"_MAX_WORKTREE_DIR_ENTRIES = 1000000",
243+
"_MAX_WORKTREE_DIR_ENTRIES = 500000 # NEUTERED: below real-world repository sizes"),
244+
],
205245
}
206246

207247

0 commit comments

Comments
 (0)