Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/loader/agent/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class ReasoningConfig:
@dataclass
class AgentConfig:
"""Configuration for the agent."""
max_iterations: int = 15 # Reduced from 20
max_iterations: int = 200 # High cap; text loop detector is the real termination mechanism
temperature: float = 0.3 # Low for better instruction following
max_tokens: int = 2048 # Reduced from 4096, most responses are shorter
force_react: bool = False # Force ReAct even if model supports native tools
Expand Down
3 changes: 2 additions & 1 deletion src/loader/runtime/prompting.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@
## Execute Mode
- Use tools directly to perform the task
- Read relevant files before editing them
- Keep `TodoWrite` current for multi-step work when progress tracking matters
- For tasks with 3+ steps, call `TodoWrite` first to outline the steps, then
update it as each step completes. This tracks progress visibly for the user
- Concise reporting is fine, and numbered lists are allowed when they
communicate plan or evidence clearly
""",
Expand Down
17 changes: 17 additions & 0 deletions src/loader/runtime/turn_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,23 @@ async def run_loop(
rlog.loop_exit(state.iterations, exit.reason_code, exit.reason_summary)
return exit
break
else:
# Loop exhausted max_iterations without breaking — notify user
await emit(
AgentEvent(
type="error",
content=(
f"Reached iteration limit ({self.context.config.max_iterations}). "
"Stopping — the work above may be incomplete."
),
)
)
exit = TurnLoopExit(
reason_code="max_iterations_reached",
reason_summary=f"Stopped after {state.iterations} iterations (limit reached)",
)
rlog.loop_exit(state.iterations, exit.reason_code, exit.reason_summary)
return exit

exit = TurnLoopExit(
reason_code="turn_complete",
Expand Down
36 changes: 36 additions & 0 deletions src/loader/ui/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,13 @@ class StepStarted(Message):
step_info: str


@dataclass
class TodoListUpdated(Message):
"""Agent updated its todo list."""

todos: list


@dataclass
class RecoveryAttempted(Message):
"""Error recovery was attempted."""
Expand Down Expand Up @@ -242,6 +249,29 @@ def _debug_log(self, message: str) -> None:
except Exception:
pass

@staticmethod
def _extract_todos(content: str, tool_args: dict) -> list[dict]:
"""Extract todo items from TodoWrite result or args."""
import json

# Try parsing the content as JSON (may be wrapped in Observation prefix)
for candidate in [content, content.split("Result: ", 1)[-1] if "Result:" in content else ""]:
candidate = candidate.strip()
if not candidate:
continue
try:
data = json.loads(candidate)
if isinstance(data, dict):
todos = data.get("new_todos", [])
if isinstance(todos, list):
return todos
except (json.JSONDecodeError, TypeError):
continue

# Fall back to the original tool args
todos = tool_args.get("todos", [])
return todos if isinstance(todos, list) else []

def handle_event(self, event: AgentEvent) -> None:
"""Convert AgentEvent to appropriate Textual message and post it."""
self._debug_log(f"handle_event: type={event.type}")
Expand Down Expand Up @@ -367,6 +397,12 @@ def handle_event(self, event: AgentEvent) -> None:
)
)

# Update the todo list widget when TodoWrite succeeds
if tool_name == "TodoWrite" and not event.is_error:
new_todos = self._extract_todos(event.content, tool_args)
if new_todos:
self.app.post_message(TodoListUpdated(todos=new_todos))

case "recovery":
self.app.post_message(
RecoveryAttempted(
Expand Down
21 changes: 20 additions & 1 deletion src/loader/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
StreamChunk,
SubtaskStarted,
ThinkingStarted,
TodoListUpdated,
ToolCallCompleted,
ToolCallStarted,
TurnPhaseChanged,
Expand All @@ -47,6 +48,7 @@
QuestionModal,
StatusLine,
StreamingText,
TodoListWidget,
ToolCallWidget,
)

Expand Down Expand Up @@ -109,6 +111,7 @@ def compose(self) -> ComposeResult:
yield Container(
ScrollableContainer(id="message-area"),
ApprovalBar(id="approval-bar"),
TodoListWidget(id="todo-list"),
InputArea(id="input-area"),
StatusLine(id="status-line"),
id="main-container",
Expand Down Expand Up @@ -654,7 +657,19 @@ def on_tool_call_completed(self, message: ToolCallCompleted) -> None:
pass

# Get the corresponding tool widget from queue (FIFO)
tool_widget = self._tool_widget_queue.pop(0) if self._tool_widget_queue else None
# Match widget by tool name instead of blind FIFO to prevent
# result/widget mismatches when events arrive out of order
tool_widget = None
if self._tool_widget_queue:
for i, w in enumerate(self._tool_widget_queue):
# Match on tool name (strip "verify " prefix for verification phase)
widget_name = w.tool_name.removeprefix("verify ")
if widget_name == message.tool_name:
tool_widget = self._tool_widget_queue.pop(i)
break
else:
# No name match — fall back to FIFO
tool_widget = self._tool_widget_queue.pop(0)

# Check if this is an edit tool with diff info
# Note: old_string can be empty string (inserting), so check `is not None`
Expand Down Expand Up @@ -695,6 +710,10 @@ def on_tool_call_completed(self, message: ToolCallCompleted) -> None:

msg_area.scroll_end(animate=False)

def on_todo_list_updated(self, message: TodoListUpdated) -> None:
"""Update the persistent todo widget when TodoWrite fires."""
self.query_one("#todo-list", TodoListWidget).update_todos(message.todos)

def on_plan_created(self, message: PlanCreated) -> None:
"""Handle plan creation."""
msg_area = self.query_one("#message-area", ScrollableContainer)
Expand Down
4 changes: 2 additions & 2 deletions src/loader/ui/styles/theme.tcss
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ Screen {
background: $surface;
}

/* Main layout — 4 rows: message area, approval bar (auto-hidden), input, status */
/* Main layout — 5 rows: messages, approval bar, todo list, input, status */
#main-container {
layout: grid;
grid-size: 1;
grid-rows: 1fr auto 3 1;
grid-rows: 1fr auto auto 3 1;
}

#message-area {
Expand Down
2 changes: 2 additions & 0 deletions src/loader/ui/widgets/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@
from .question import QuestionModal
from .status_line import StatusLine
from .streaming import StreamingText
from .todo_list import TodoListWidget
from .tool_widget import ToolCallWidget

__all__ = [
"ApprovalBar",
"InputArea",
"StatusLine",
"TodoListWidget",
"ToolCallWidget",
"DiffWidget",
"StreamingText",
Expand Down
72 changes: 72 additions & 0 deletions src/loader/ui/widgets/todo_list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Persistent todo list widget rendered above the input area."""

from __future__ import annotations

from rich.text import Text
from textual.app import ComposeResult
from textual.widget import Widget
from textual.widgets import Static

_STATUS_ICONS = {
"pending": (" [ ] ", "dim"),
"in_progress": (" [~] ", "bold yellow"),
"completed": (" [x] ", "green"),
}


class TodoListWidget(Widget):
"""Renders the agent's current todo list with checkboxes and strikethrough."""

DEFAULT_CSS = """
TodoListWidget {
height: auto;
max-height: 10;
display: none;
padding: 0 1;
border-top: solid $primary-darken-2;
}

TodoListWidget.has-items {
display: block;
}

TodoListWidget #todo-content {
width: 100%;
}
"""

def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self._items: list[dict[str, str]] = []

def compose(self) -> ComposeResult:
yield Static("", id="todo-content")

def update_todos(self, todos: list[dict[str, str]]) -> None:
"""Replace the displayed todo list."""
self._items = list(todos)
if not self._items:
self.remove_class("has-items")
return
self.add_class("has-items")
self._render()

def _render(self) -> None:
content = Text()
content.append(" Tasks\n", style="bold")
for item in self._items:
status = item.get("status", "pending")
icon, icon_style = _STATUS_ICONS.get(status, _STATUS_ICONS["pending"])
label = item.get("content", "")

content.append(icon, style=icon_style)
if status == "completed":
content.append(label, style="strike dim")
elif status == "in_progress":
active = item.get("active_form", label)
content.append(active, style="bold")
else:
content.append(label)
content.append("\n")

self.query_one("#todo-content", Static).update(content)
Loading