band-aider-pub/band_aider/loop/proto_loop.py
2026-07-28 16:40:20 -07:00

107 lines
4.1 KiB
Python

# SPDX-FileCopyrightText: 2026 Art Sea LLC
#
# SPDX-License-Identifier: AGPL-3.0-only
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
import logging
from typing import Generic, TypeVar
from band_aider.ai_client import llm_logging
from band_aider.context.contex_bag import ContextBag
from band_aider.context.global_context import GlobalContext
from band_aider.loop.loop_gate import LoopGate
from band_aider.ui.status import publish_loop_status
T = TypeVar("T", bound=LoopGate)
U = TypeVar("U", bound=ContextBag)
@dataclass(init=False)
class ProtoLoop(Generic[T, U], ABC):
"""ProtoLoop runs one gated workflow phase and records child-loop summaries as evidence."""
gate: T
context: U
_logger_stack: list[logging.Logger] = field(default_factory=list, init=False, repr=False)
def __init__(self, gate: T, context: U, logger: logging.Logger | None = None) -> None:
self.gate = gate
self.context = context
self._logger_stack = [logger] if logger is not None else []
@abstractmethod
def _prepare_work(self) -> "ProtoLoop | None":
"""Return the next child loop to run, or None when this phase has no child work."""
...
@abstractmethod
def _post_run(self) -> str:
"""Return this loop's summary after all gate-controlled work has finished."""
...
def _run(self, inner: "ProtoLoop") -> None:
"""Run one child loop and append its summary to this context's marginal results."""
publish_loop_status(
previous_loop=type(self).__name__,
current_loop=type(inner).__name__,
current_item=getattr(getattr(inner, "context", None), "goal", None),
)
result = inner.run()
if isinstance(self.context, GlobalContext):
self.context.story_cursor.marginal_result.append(result)
else:
self.context.marginal_result.append(result)
@property
def logger(self) -> logging.Logger:
return self._logger_stack[-1] if self._logger_stack else llm_logging.logger_for_loop(self)
def run(self, logger_name: str | None = None) -> str:
"""Run child work while the gate allows entry, retry, or continuation, then return a summary."""
if not self._logger_stack: self._logger_stack.append(llm_logging.logger_for_loop(self))
if logger_name: self._logger_stack.append(self.logger.getChild(llm_logging.safe_log_segment(logger_name)))
llm_logging.set_current_logger(self.logger)
count = 0
try:
publish_loop_status(
current_loop=type(self).__name__,
current_item=getattr(self.context, "goal", None),
)
while ((self.gate.may_enter() and count == 0) or self.gate.should_retry() or self.gate.should_continue()):
count += 1;
inner = self._prepare_work()
if inner is None: break
self._run(inner)
return self._post_run()
finally:
if logger_name: self._logger_stack.pop()
llm_logging.set_current_logger(self.logger if self._logger_stack else None)
"""
FAQ
Why return str instead of bool?
Because loop output is evidence, not control flow. A summary can describe success, partial progress, blocked state, requested files, or frontier expansion.
Why not return (bool, str)?
Because that compresses ambiguous workflow states too early. “Needs more files” is not success or failure; it is continuation evidence.
Who controls whether the loop continues?
The gate. may_enter(), shall_retry(), and should_continue() own control flow.
Where do child results go?
Into context.marginal_result as plain string summaries.
Where should success/failure be judged?
In loop-specific gates or judgement checks that inspect context, marginal summaries, FileManager CDC, and current task state.
Why remove next and parent?
Because _prepare_work() directly returns the child loop. The loop tree is produced by execution, not stored as mutable pointers.
"""