band-aider-pub/band_aider/judgement/stabilization_decision.py
2026-10-17 11:57:37 -07:00

419 lines
17 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
"""Shared base for request-scoped stabilization judgements."""
from __future__ import annotations
from band_aider.judgement.work_definition_constants import ResponseValidation
from abc import abstractmethod
from dataclasses import replace
import json
import logging
from typing import Callable, cast
from band_aider.ai_client.completion_client import CompletionClient
from band_aider.ai_client.stabilization_escalation import StabilizationEscalation
from band_aider.ai_client import llm_logging
from band_aider.context.provenance import FutureGuidance
from band_aider.context.validation_utils import (
SemanticValidationError,
ValidationError,
ValidationLocation,
ValidationResult,
validate_shape,
)
from band_aider.judgement.edit_request import AiderEditRequest
from band_aider.judgement.contextual_cot_renderer import ContextualCotRenderer
from band_aider.judgement.edit_request import AiderEditRequestMask, AiderFailureMode
from band_aider.judgement.file_manager import FileManager
from band_aider.judgement.vibe_check import VibeCodeCheck
class StabilizationDecision(VibeCodeCheck):
"""Carry the exact request whose local stabilization evidence is being judged.
``_client`` is the normal runtime client for rendering and helper construction.
Judgment calls use ``escalation`` and its dumb clients capped at TokenJacker.
"""
def __init__(
self,
*,
client: CompletionClient,
file_manager: FileManager,
request: AiderEditRequest,
current_cot: str | None = None,
logger: logging.Logger | None = None,
prompt: str = "",
cot_projection: Callable[[str], str] | None = None,
current_cot_in_prompt: bool = False,
disambiguation: str | None = None,
) -> None:
"""Initialize one decision without creating or transforming workflow evidence."""
self.escalation = StabilizationEscalation.for_stabilization(
client, check_name=type(self).__name__,
prompt_formatter_builder=file_manager.prompt_formatter_builder,
)
super().__init__(
client=client,
file_manager=file_manager,
prompt=prompt,
)
self.request = request
self.escalation.files = tuple(map(file_manager.repo_location, request.editable_files))
self.disambiguation = disambiguation
self.logger = logger or llm_logging.current_logger() or logging.getLogger(__name__)
self._future_guidance_composed = False
self._rendered_prompt: tuple[str, str] | None = None
# Keep the caller's current authority when prior records need contextual selection.
resolved_current_cot = (
current_cot
if current_cot is not None
else (
file_manager.prompt
if request.previous_cot_records
else ""
)
)
self._contextual_cot_renderer = ContextualCotRenderer(
client=client,
current_cot=cot_projection(resolved_current_cot) if cot_projection else resolved_current_cot,
task_projection=cot_projection,
current_cot_in_prompt=current_cot_in_prompt,
prompt_formatter_builder=file_manager.prompt_formatter_builder,
logger=self.logger.getChild(
"contextual-cot-renderer"
),
)
def render_prompt_for_turn(self, prompt: str) -> str:
"""Render prior COT authority for one exact decision attempt."""
base_prompt = self.prompt
work_logs = self.request.work_logs
# Constructors that already render backlog guidance must not duplicate retained history.
if work_logs and work_logs.strip() not in base_prompt:
base_prompt += "\n\n" + self._prompt_formatter_builder.generate("BACKLOG GUIDANCE", escape=True)(work_logs)
# Reuse authority while the input is stationary; refreshed source must remain visible.
if self._rendered_prompt is None or self._rendered_prompt[0] != base_prompt:
rendered = self._contextual_cot_renderer.render(
AiderEditRequestMask(self.request, AiderFailureMode.SEMANTIC), base_prompt,
)
self._rendered_prompt = (base_prompt, rendered)
self.escalation.ask = rendered
original = self._rendered_prompt[1] + prompt.removeprefix(self.prompt)
# Localize the completed invocation, including this attempt's immediate correction.
# Import here because localization reuses this parent's validation loop without rendering recursively.
from band_aider.judgement.prompt_localization import PromptLocalization
return PromptLocalization(client=self._client, file_manager=self._file_manager,
request=self.request, owner=type(self).__name__, logger=self.logger,
current_cot=self._contextual_cot_renderer.current_cot or self._file_manager.prompt,
).localize(original)
@property
@abstractmethod
def valid_response_references(self) -> list[object]:
"""Return every JSON reference that this decision accepts as structurally valid."""
@abstractmethod
def _decide(
self,
response: object | None = None,
validation: ValidationResult | None = None,
) -> tuple[object | None, ValidationResult]:
"""Run one response attempt from the preceding response and validation state."""
def _format_prompt_w_feedback(
self,
validation: ValidationResult,
*,
base_prompt: str | None = None,
) -> str:
"""Render one base prompt with only the latest response correction."""
section = self._prompt_formatter_builder.generate
retry_guidance = section(ResponseValidation.HEADER)(lambda: (
ResponseValidation.RETRY
+ section("VALIDATION ERRORS", escape=True)(validation.stringify())
+ "\n\n"
+ section("REJECTED PREVIOUS RESPONSE")(self.escalation.last_response)
+ "\n\n"
+ ResponseValidation.RETURN_MOVE
))
return "\n\n".join((base_prompt if base_prompt is not None else self.prompt, retry_guidance))
def __call__(self, *args, **kwargs) -> bool:
"""Iterate child-owned response attempts until validation is clean."""
# Localize each revived observation against this concrete decision before it decides.
if not self._future_guidance_composed:
localize_future_guidance(self)
self._future_guidance_composed = True
response: object | None = None
validation: ValidationResult | None = None
# Continue the reduction until one child attempt records an admitted decision.
while validation is None or not validation.valid:
self.escalation.validation_errors = validation.stringify() if validation else ""
try:
response, validation = self._decide(response, validation)
except json.JSONDecodeError as error:
response = error.doc
validation = ValidationResult(errors=(ValidationError(ValidationLocation([]), str(error)),))
return self.changes[-1][1]
def last_result(self) -> dict[str, object]:
"""Return the admitted local result for an explicit boundary decision."""
return {
"decision": type(self).__name__,
"outcome": self.outcome,
"reason": self.reason,
"additional": self.additional,
}
class FutureGuidanceApplicability(StabilizationDecision):
"""Decide whether one future observation belongs to one decision input.
Example:
applies = FutureGuidanceApplicability(... )()
"""
_VALID_RESPONSE_REFERENCES: list[object] = [{
"applicable": True,
"reason": "why this future observation changes this decision",
}]
@property
def valid_response_references(self) -> list[object]:
"""Return the exact response shape for one applicability judgement."""
return self._VALID_RESPONSE_REFERENCES
def __init__(
self,
*,
client: CompletionClient,
file_manager: FileManager,
request: AiderEditRequest,
future_guidance: FutureGuidance,
current_prompt: str,
logger: logging.Logger,
disambiguation: str | None = None,
) -> None:
"""Construct one future-guidance applicability decision."""
super().__init__(
client=client,
file_manager=file_manager,
request=request,
logger=logger,
disambiguation=disambiguation,
)
section = self._prompt_formatter_builder.generate
self.prompt = section("FUTURE GUIDANCE APPLICABILITY")(lambda: "\n\n".join((
"Decide whether the later observation applies to this exact current decision point. "
"It applies only when changing this decision within its existing authority can avoid "
"the described dead end. Do not use it to select a different phase, file, or owner.",
section(
"CURRENT DECISION PROMPT",
escape=True,
neutralize_json=True,
)(current_prompt),
section("FUTURE GUIDANCE")(lambda: "\n\n".join((
section("MESSAGE", escape=True)(future_guidance.message),
section("JUSTIFICATION", escape=True)(future_guidance.justification),
))),
"Return strict JSON only. applicable must be true or false and reason must be a "
"nonempty string:\n"
+ json.dumps(self.valid_response_references[0], separators=(",", ":")),
)))
def _decide(
self,
response: object | None = None,
validation: ValidationResult | None = None,
) -> tuple[object | None, ValidationResult]:
"""Request and admit one applicability response."""
prompt = self.prompt if validation is None else self._format_prompt_w_feedback(validation)
root = ValidationLocation([])
try:
data = json.loads(self.escalation.complete(
self.render_prompt_for_turn(prompt),
disambiguation=self.disambiguation,
).strip())
except json.JSONDecodeError as error:
return error.doc, ValidationResult(errors=(ValidationError(
location=root,
error=str(error),
),))
validation = validate_shape(data, self.valid_response_references, location=root)
# Require a reason for both admission outcomes so the phase log remains diagnostic.
if not validation.valid:
return data, validation
result = cast("dict[str, object]", data)
reason = cast(str, result["reason"])
# Reject a blank explanation because applicability must be auditable in the phase log.
if not reason.strip():
return data, ValidationResult(errors=(SemanticValidationError(
location=root.child("reason"),
error="must be a nonempty string.",
),))
applicable = cast(bool, result["applicable"])
self.outcome = "applicable" if applicable else "not_applicable"
self.reason = reason
self.additional = {"applicable": applicable}
self.changes.append((self, applicable))
return data, ValidationResult()
class FutureGuidanceRelocalization(StabilizationDecision):
"""Derive one current-decision correction from an applicable future observation.
Example:
FutureGuidanceRelocalization(... )()
"""
_VALID_RESPONSE_REFERENCES: list[object] = [{
"localized_prompt": "compact instruction for the current decision",
}]
@property
def valid_response_references(self) -> list[object]:
"""Return the exact response shape for one localized instruction."""
return self._VALID_RESPONSE_REFERENCES
def __init__(
self,
*,
client: CompletionClient,
file_manager: FileManager,
request: AiderEditRequest,
future_guidance: FutureGuidance,
current_prompt: str,
logger: logging.Logger,
disambiguation: str | None = None,
) -> None:
"""Construct one input-local future-guidance correction."""
super().__init__(
client=client,
file_manager=file_manager,
request=request,
logger=logger,
disambiguation=disambiguation,
)
section = self._prompt_formatter_builder.generate
self.prompt = section("FUTURE GUIDANCE RELOCALIZATION")(lambda: "\n\n".join((
"Rewrite one compact backlog instruction for this exact decision so it can avoid "
"the described dead end while preserving the decision's current authority. "
"Do not describe work for another phase, file, or owner.",
section(
"CURRENT DECISION PROMPT",
escape=True,
neutralize_json=True,
)(current_prompt),
section("APPLICABLE FUTURE GUIDANCE")(lambda: "\n\n".join((
section("MESSAGE", escape=True)(future_guidance.message),
section("JUSTIFICATION", escape=True)(future_guidance.justification),
))),
"Return strict JSON only with this shape:\n"
+ json.dumps(self.valid_response_references[0], separators=(",", ":")),
)))
def _decide(
self,
response: object | None = None,
validation: ValidationResult | None = None,
) -> tuple[object | None, ValidationResult]:
"""Request and admit one prompt-local correction."""
prompt = self.prompt if validation is None else self._format_prompt_w_feedback(validation)
root = ValidationLocation([])
try:
data = json.loads(self.escalation.complete(
self.render_prompt_for_turn(prompt),
disambiguation=self.disambiguation,
).strip())
except json.JSONDecodeError as error:
return error.doc, ValidationResult(errors=(ValidationError(
location=root,
error=str(error),
),))
validation = validate_shape(data, self.valid_response_references, location=root)
# Refuse an empty correction because it cannot alter the current backlog guidance.
if not validation.valid:
return data, validation
result = cast("dict[str, object]", data)
localized_prompt = cast(str, result["localized_prompt"])
# Reject a blank instruction because localization must change the current decision request.
if not localized_prompt.strip():
return data, ValidationResult(errors=(SemanticValidationError(
location=root.child("localized_prompt"),
error="must be a nonempty string.",
),))
self.outcome = "localized"
self.reason = "Future guidance was localized to the current decision prompt."
self.additional = {"localized_prompt": localized_prompt}
self.changes.append((self, True))
return data, ValidationResult()
def localize_future_guidance(decision: StabilizationDecision) -> None:
"""Apply only applicable future observations to one in-memory decision request."""
logger = decision.logger or logging.getLogger(__name__)
# Evaluate each later observation in provenance order against the prompt it can refine.
for future_guidance in decision.request.future_guidance:
helper_request = replace(
decision.request,
backlog_guidance=list(decision.request.backlog_guidance),
future_guidance=(),
)
applicability = FutureGuidanceApplicability(
client=decision._client,
file_manager=decision._file_manager,
request=helper_request,
future_guidance=future_guidance,
current_prompt=decision.prompt,
logger=logger,
disambiguation=decision.disambiguation,
)
applicable = applicability()
logger.info(
"Future guidance applicability decision=%s origin=%s addressed=%s applicable=%s reason=%s",
type(decision).__name__,
future_guidance.origin_message_id,
future_guidance.addressed_message_id,
applicable,
applicability.reason,
)
# Change current guidance only after the model admits that this decision owns it.
if applicable:
relocalization = FutureGuidanceRelocalization(
client=decision._client,
file_manager=decision._file_manager,
request=helper_request,
future_guidance=future_guidance,
current_prompt=decision.prompt,
logger=logger,
disambiguation=decision.disambiguation,
)
relocalization()
localized_prompt = cast(str, relocalization.additional["localized_prompt"])
decision.request.backlog_guidance.append(localized_prompt)
decision.prompt = "\n\n".join((
decision.prompt,
decision._prompt_formatter_builder.generate(
"LOCALIZED BACKLOG GUIDANCE", escape=True, neutralize_json=True,
)(localized_prompt),
))