Skip to content
Merged
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,7 @@ AgenticLoop/
├── agent/ # Agent implementations
│ ├── base.py # BaseAgent abstract class
│ ├── context.py # Context injection
│ ├── react_agent.py # ReAct mode
│ ├── agent.py # ReAct agent + Ralph verification loop
│ ├── plan_execute_agent.py # Plan-and-Execute mode
│ ├── tool_executor.py # Tool execution engine
│ └── todo.py # Todo list management
Expand Down
6 changes: 4 additions & 2 deletions agent/react_agent.py → agent/agent.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""ReAct (Reasoning + Acting) agent implementation."""

from config import Config
from llm import LLMMessage
from utils import terminal_ui

Expand Down Expand Up @@ -146,13 +147,14 @@ async def run(self, task: str) -> str:

tools = self.tool_executor.get_tool_schemas()

# Use the generic ReAct loop implementation
result = await self._react_loop(
# Use ralph loop (outer verification wrapping the inner ReAct loop)
result = await self._ralph_loop(
messages=[], # Not used when use_memory=True
tools=tools,
use_memory=True,
save_to_memory=True,
task=task,
max_iterations=Config.RALPH_LOOP_MAX_ITERATIONS,
)

self._print_memory_stats()
Expand Down
97 changes: 97 additions & 0 deletions agent/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from .todo import TodoList
from .tool_executor import ToolExecutor
from .verification import LLMVerifier, VerificationResult, Verifier

if TYPE_CHECKING:
from llm import LiteLLMAdapter, ModelManager
Expand Down Expand Up @@ -300,3 +301,99 @@ def get_current_model_info(self) -> Optional[dict]:
"provider": profile.provider,
}
return None

async def _ralph_loop(
self,
messages: List[LLMMessage],
tools: List,
use_memory: bool = True,
save_to_memory: bool = True,
task: str = "",
max_iterations: int = 3,
verifier: Optional[Verifier] = None,
) -> str:
"""Outer verification loop that wraps _react_loop.

After _react_loop returns a final answer, a verifier judges whether the
original task is satisfied. If not, feedback is injected and the inner
loop re-enters.

Args:
messages: Initial message list (passed through to _react_loop).
tools: List of available tool schemas.
use_memory: If True, use self.memory for context.
save_to_memory: If True, save messages to self.memory.
task: The original task description.
max_iterations: Maximum number of outer verification iterations.
verifier: Optional custom Verifier instance. Defaults to LLMVerifier.

Returns:
Final answer as a string.
"""
if verifier is None:
verifier = LLMVerifier(self.llm, terminal_ui)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable terminal_ui is used without being imported or defined in this scope. It should likely be self._tui or imported from the utils module.

Copilot uses AI. Check for mistakes.

previous_results: List[VerificationResult] = []

for iteration in range(1, max_iterations + 1):
logger.debug(f"Ralph loop iteration {iteration}/{max_iterations}")

result = await self._react_loop(
messages=messages,
tools=tools,
use_memory=use_memory,
save_to_memory=save_to_memory,
task=task,
)

# Skip verification on last iteration — just return whatever we got
if iteration == max_iterations:
logger.debug("Ralph loop: max iterations reached, returning result")
terminal_ui.console.print(
f"\n[bold dark_orange]⚠ Verification skipped "
f"(max iterations {max_iterations} reached), returning last result[/bold dark_orange]"
)
Comment on lines +352 to +355
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable terminal_ui is used without being imported or defined. This should be replaced with a proper reference to the terminal UI instance, likely self._tui if available, or imported from utils.

Copilot uses AI. Check for mistakes.
return result

verification = await verifier.verify(
task=task,
result=result,
iteration=iteration,
previous_results=previous_results,
)
previous_results.append(verification)

if verification.complete:
logger.debug(f"Ralph loop: verified complete — {verification.reason}")
terminal_ui.console.print(
f"\n[bold green]✓ Verification passed "
f"(attempt {iteration}/{max_iterations}): {verification.reason}[/bold green]"
)
Comment on lines +368 to +371
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable terminal_ui is referenced without being defined in scope. Replace with appropriate terminal UI reference.

Copilot uses AI. Check for mistakes.
return result

# Print the incomplete result so the user can see what the agent produced
terminal_ui.print_unfinished_answer(result)

# Inject feedback as a user message so the next _react_loop iteration
# picks it up from memory.
feedback = (
f"Your previous answer was reviewed and found incomplete. "
f"Feedback: {verification.reason}\n\n"
f"Please address the feedback and provide a complete answer."
)
# Print the incomplete result so the user can see what the agent produced
terminal_ui.print_unfinished_answer(result)
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable terminal_ui is used without being defined in this method's scope. This needs to be properly imported or referenced.

Copilot uses AI. Check for mistakes.

logger.debug(f"Ralph loop: injecting feedback — {verification.reason}")
terminal_ui.console.print(
f"\n[bold yellow]⟳ Verification feedback (attempt {iteration}/{max_iterations}): "
f"{verification.reason}[/bold yellow]"
)
Comment on lines +388 to +391
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The variable terminal_ui is referenced but not defined in the method scope. Ensure proper import or instance reference.

Copilot uses AI. Check for mistakes.

if use_memory and save_to_memory:
await self.memory.add_message(LLMMessage(role="user", content=feedback))
else:
messages.append(LLMMessage(role="user", content=feedback))

# Should not reach here, but return last result as safety fallback
return result # type: ignore[possibly-undefined]
Copy link

Copilot AI Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback return statement uses result which may be undefined if max_iterations is 0 or negative. Consider adding validation for max_iterations > 0 at the start of the method, or initializing result to an empty string before the loop.

Copilot uses AI. Check for mistakes.
135 changes: 135 additions & 0 deletions agent/verification.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
"""Verification interface and default LLM verifier for the Ralph Loop.

The verifier judges whether the agent's final answer truly satisfies the
original task. If not, feedback is returned so the outer loop can re-enter
the inner ReAct loop with corrective guidance.
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Protocol, runtime_checkable

from llm import LLMMessage
from utils import get_logger
from utils.tui.progress import AsyncSpinner

if TYPE_CHECKING:
from llm import LiteLLMAdapter
from utils.tui.terminal_ui import TerminalUI

logger = get_logger(__name__)


@dataclass
class VerificationResult:
"""Result of a verification check."""

complete: bool
reason: str


@runtime_checkable
class Verifier(Protocol):
"""Protocol for task-completion verifiers."""

async def verify(
self,
task: str,
result: str,
iteration: int,
previous_results: list[VerificationResult],
) -> VerificationResult:
"""Judge whether *result* satisfies *task*.

Args:
task: The original user task description.
result: The agent's final answer from the inner loop.
iteration: Current outer-loop iteration (1-indexed).
previous_results: Verification results from earlier iterations.

Returns:
VerificationResult indicating completion status and reasoning.
"""
... # pragma: no cover


_VERIFICATION_PROMPT = """\
You are a strict verification assistant. Your job is to determine whether an \
AI agent's answer fully and correctly completes the user's original task.

<task>
{task}
</task>

<agent_answer>
{result}
</agent_answer>

{previous_context}

<judgment_rules>
1. If the task is a ONE-TIME request (e.g. "calculate 1+1", "summarize this file"), \
judge whether the answer is correct and complete.

2. If the task requires MULTIPLE steps and only some were done, respond INCOMPLETE \
with specific feedback on what remains.
</judgment_rules>

Respond with EXACTLY one of:
- COMPLETE: <brief reason why the task is satisfied>
- INCOMPLETE: <specific feedback on what is missing or wrong>

Do NOT restate the answer. Only judge it."""


class LLMVerifier:
"""Default verifier that uses a lightweight LLM call (no tools)."""

def __init__(self, llm: LiteLLMAdapter, terminal_ui: TerminalUI | None = None):
self.llm = llm
self._tui = terminal_ui

async def verify(
self,
task: str,
result: str,
iteration: int,
previous_results: list[VerificationResult],
) -> VerificationResult:
previous_context = ""
if previous_results:
lines = []
for i, pr in enumerate(previous_results, 1):
status = "complete" if pr.complete else "incomplete"
lines.append(f" Attempt {i}: {status} — {pr.reason}")
previous_context = "Previous verification attempts:\n" + "\n".join(lines)

prompt = _VERIFICATION_PROMPT.format(
task=task,
result=result[:4000], # Truncate to avoid excessive tokens
previous_context=previous_context,
)

messages = [
LLMMessage(role="system", content="You are a task-completion verifier."),
LLMMessage(role="user", content=prompt),
]

console = self._tui.console if self._tui else None
if console:
async with AsyncSpinner(console, "Verifying completion..."):
response = await self.llm.call_async(messages=messages, tools=None, max_tokens=512)
else:
response = await self.llm.call_async(messages=messages, tools=None, max_tokens=512)

text = (response.content or "").strip()
logger.debug(f"Verification response (iter {iteration}): {text}")

upper = text.upper()
if upper.startswith("COMPLETE"):
reason = text.split(":", 1)[1].strip() if ":" in text else text
return VerificationResult(complete=True, reason=reason)
else:
reason = text.split(":", 1)[1].strip() if ":" in text else text
return VerificationResult(complete=False, reason=reason)
6 changes: 6 additions & 0 deletions config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@

TOOL_TIMEOUT=600
MAX_ITERATIONS=1000

# Ralph Loop (outer verification loop — re-checks task completion)
# RALPH_LOOP_MAX_ITERATIONS=3
"""


Expand Down Expand Up @@ -75,6 +78,9 @@ class Config:
# Agent Configuration
MAX_ITERATIONS = int(_cfg.get("MAX_ITERATIONS", "1000"))

# Ralph Loop (outer verification loop)
RALPH_LOOP_MAX_ITERATIONS = int(_cfg.get("RALPH_LOOP_MAX_ITERATIONS", "3"))

# Retry Configuration
RETRY_MAX_ATTEMPTS = int(_cfg.get("RETRY_MAX_ATTEMPTS", "3"))
RETRY_INITIAL_DELAY = float(_cfg.get("RETRY_INITIAL_DELAY", "1.0"))
Expand Down
10 changes: 10 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,16 @@ Open `.aloop/models.yaml` in your editor, then it will auto-reload after you sav

Add/remove/default are done by editing `.aloop/models.yaml` directly.

## Ralph Loop (Outer Verification)

An outer loop verifies that the agent's final answer actually satisfies
the original task. If incomplete, feedback is injected and the inner
ReAct loop re-enters. Enabled by default.

```bash
RALPH_LOOP_MAX_ITERATIONS=3 # Max verification attempts before returning
```

## Email Notification Configuration (Resend)

Used by the `notify` tool to send emails via [Resend](https://resend.com):
Expand Down
2 changes: 1 addition & 1 deletion docs/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ See [Memory Management](memory-management.md) for more details.
```python
import asyncio

from agent.react_agent import ReActAgent
from agent.agent import ReActAgent
from llm import LiteLLMAdapter, ModelManager
from tools import CalculatorTool, FileReadTool
from config import Config
Expand Down
2 changes: 1 addition & 1 deletion docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ Here's an agent that breaks tasks into subtasks and delegates:
```python
# agent/collaborative_agent.py
from .base import BaseAgent
from .react_agent import ReActAgent
from .agent import ReActAgent

class CollaborativeAgent(BaseAgent):
"""Agent that breaks tasks into subtasks and delegates to specialists."""
Expand Down
2 changes: 1 addition & 1 deletion docs/memory-management.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ This provides exact token counts instead of estimates.

```python
from memory import MemoryManager, MemoryConfig
from agent.react_agent import ReActAgent
from agent.agent import ReActAgent

# Create memory config
config = MemoryConfig(
Expand Down
2 changes: 1 addition & 1 deletion examples/react_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
# Add parent directory to path to import modules
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from agent.react_agent import ReActAgent
from agent.agent import ReActAgent
from llm import LiteLLMAdapter, ModelManager
from tools.calculator import CalculatorTool
from tools.file_ops import FileReadTool, FileWriteTool
Expand Down
2 changes: 1 addition & 1 deletion examples/web_fetch_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))

from agent.react_agent import ReActAgent
from agent.agent import ReActAgent
from llm import LiteLLMAdapter, ModelManager
from tools.web_fetch import WebFetchTool

Expand Down
4 changes: 1 addition & 3 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import asyncio
import warnings

from agent.react_agent import ReActAgent
from agent.agent import ReActAgent
from config import Config
from interactive import run_interactive_mode, run_model_setup_mode
from llm import LiteLLMAdapter, ModelManager
Expand All @@ -18,7 +18,6 @@
from tools.shell import ShellTool
from tools.shell_background import BackgroundTaskManager, ShellTaskStatusTool
from tools.smart_edit import SmartEditTool
from tools.timer import TimerTool
from tools.web_fetch import WebFetchTool
from tools.web_search import WebSearchTool
from utils import get_log_file_path, setup_logger, terminal_ui
Expand Down Expand Up @@ -54,7 +53,6 @@ def create_agent(model_id: str | None = None):
CodeNavigatorTool(),
ShellTool(task_manager=task_manager),
ShellTaskStatusTool(task_manager=task_manager),
TimerTool(),
NotifyTool(),
]

Expand Down
2 changes: 1 addition & 1 deletion rfc/003-asyncio-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Current execution paths contain multiple blocking operations (HTTP, subprocess,
This RFC targets the runtime path that executes agent loops and tools:

- **Entrypoints**: `main.py`, `cli.py`, `interactive.py`
- **Agent runtime**: `agent/base.py`, `agent/react_agent.py`, `agent/plan_execute_agent.py`, `agent/tool_executor.py`
- **Agent runtime**: `agent/base.py`, `agent/agent.py`, `agent/plan_execute_agent.py`, `agent/tool_executor.py`
- **LLM layer**: `llm/litellm_adapter.py`, `llm/retry.py`
- **Memory/persistence**: `memory/manager.py`, `memory/store.py`
- **Tools**: `tools/*` (prioritized conversions, not all at once)
Expand Down
Loading