# `--repl` mode

Adds an interactive read-eval-print loop so the user can have a multi-turn conversation with the agent instead of exiting after a single question.

## Motivation

Currently the agent takes a single positional `question`, runs its tool-calling loop until a final answer, prints it, and exits. This is fine for one-shot tasks but awkward for exploratory work where you want to ask follow-up questions, refine instructions, or build on the agent's prior context. A REPL lets the user keep the session alive: the message history accumulates across questions, so the agent retains full context of earlier work.

## UX overview

```
$ uv run agent --repl --base-dir ./project --allowed-commands "ls,git"
swival> What files are in src/?
... (agent runs tool loop, prints answer) ...
swival> Now rename utils.py to helpers.py
... (agent runs tool loop, prints answer) ...
swival> /exit
$
```

- The prompt is `swival> ` (hardcoded, printed to stderr so stdout stays clean for piping).
- Empty lines are ignored.
- `/exit` or EOF (Ctrl-D) exits cleanly.
- The positional `question` argument becomes optional when `--repl` is set.
- Each user input starts a fresh agent loop (up to `--max-turns` per question), but the message history carries over between questions. This means the agent remembers prior tool calls and answers.

## Changes by file

### agent.py

#### `build_parser()`

- Add `--repl` as a `store_true` flag:
  ```python
  parser.add_argument(
      "--repl",
      action="store_true",
      help="Start an interactive session instead of answering a single question.",
  )
  ```
- Make `question` optional: change from a required positional to `nargs="?"` with `default=None`.
- Add validation in `main()`: if `--repl` is not set and `question` is `None`, call `parser.error("question is required (or use --repl)")`.

#### `main()` — extract the agent loop

The current `main()` is a 250-line monolith. Refactor by extracting the inner agent loop (lines 627–727) into a standalone function:

```python
def run_agent_loop(
    messages: list,
    tools: list,
    *,
    api_base: str,
    model_id: str,
    max_turns: int,
    max_output_tokens: int,
    temperature: float,
    top_p: float,
    context_length: int | None,
    base_dir: str,
    thinking_state: ThinkingState,
    resolved_commands: dict,
    skills_catalog: dict,
    skill_read_roots: list,
    yolo: bool,
    verbose: bool,
    llm_kwargs: dict,
) -> tuple[str | None, bool]:
    """Run the tool-calling loop until a final answer or max turns.

    Mutates `messages` in place (appends assistant/tool messages,
    in-place compaction on overflow).
    Returns (final_answer, exhausted). final_answer is the last
    assistant text (may be None). exhausted is True if max_turns hit.
    """
```

This function contains the existing `while turns < args.max_turns` loop, context overflow handling, and the `for tool_call in msg.tool_calls` dispatch. It returns `(answer, exhausted)` instead of calling `print()` / `sys.exit()` directly.

**Compaction and list identity**: The current loop rebinds `messages` on context overflow (`messages = compact_messages(messages)` / `messages = drop_middle_turns(messages)`). Inside `run_agent_loop` this would create a local rebinding, leaving the caller's list stale and breaking REPL history persistence. Fix this by using in-place replacement everywhere:

```python
messages[:] = compact_messages(messages)
# ...
messages[:] = drop_middle_turns(messages)
```

This keeps the caller's list reference valid. The single-shot path doesn't care either way, so the change is safe for both modes.

**Max-turns return value**: When max turns are exhausted, `run_agent_loop` must still return the last assistant text (matching current single-shot behavior where it prints the last assistant content before exiting). Return a `(answer: str | None, exhausted: bool)` tuple:

```python
def run_agent_loop(...) -> tuple[str | None, bool]:
    """...
    Returns (final_answer, exhausted). exhausted is True if max_turns hit.
    final_answer contains the last assistant text in either case (may be None
    if the model never produced text).
    """
```

The last-assistant-text extraction (currently lines 716–723 in `main()`) moves into the end of `run_agent_loop`.

`main()` then becomes:

```python
# -- single-shot path --
if not args.repl:
    messages.append({"role": "user", "content": args.question})
    answer, exhausted = run_agent_loop(messages, tools, ...)
    if answer is not None:
        print(answer)
    if exhausted:
        fmt.warning("max turns reached, agent stopped.")
        sys.exit(2)
    return

# -- REPL path --
if args.question:
    messages.append({"role": "user", "content": args.question})
    answer, exhausted = run_agent_loop(messages, tools, ...)
    if answer is not None:
        print(answer)
    if exhausted:
        fmt.warning("max turns reached for initial question.")

repl_loop(messages, tools, ...)
```

#### `repl_loop()`

New function that drives the interactive session:

```python
def repl_loop(
    messages: list,
    tools: list,
    *,
    # same kwargs as run_agent_loop
    ...
) -> None:
    """Interactive read-eval-print loop."""
    if verbose:
        fmt.repl_banner()

    while True:
        try:
            sys.stderr.write("swival> ")
            sys.stderr.flush()
            line = input()
        except (EOFError, KeyboardInterrupt):
            print(file=sys.stderr)  # newline after ^D / ^C
            break

        line = line.strip()
        if not line:
            continue
        if line in ("/exit", "/quit"):
            break

        messages.append({"role": "user", "content": line})
        try:
            answer, exhausted = run_agent_loop(messages, tools, ...)
        except KeyboardInterrupt:
            fmt.warning("interrupted, question aborted.")
            continue

        if answer is not None:
            print(answer)
        if exhausted:
            fmt.warning("max turns reached for this question.")
            # Don't exit — let the user ask another question.
```

Key behaviors:
- **Message history persists** across questions. The user's second question sees all prior assistant/tool messages, so the agent has full context.
- **ThinkingState persists** across questions (same instance). Notes from earlier questions remain accessible.
- **Max turns resets** per question (each `run_agent_loop` call starts its own counter). This prevents a long conversation from being cut short because earlier questions ate the turn budget.
- **Max-turns exhaustion is non-fatal** in REPL mode — print a warning and the last assistant text, don't `sys.exit(2)`.
- **Context overflow** is handled inside `run_agent_loop` as before (compact, then drop middle turns). In REPL mode this is especially useful since the history grows across questions.
- **Ctrl-C during input** exits the REPL cleanly (caught by `KeyboardInterrupt` in the `input()` call).
- **Ctrl-C during agent loop** — caught around `run_agent_loop()` so a stuck LLM call can be interrupted without killing the process. Prints a warning and continues the REPL.

#### Prompt on stderr

The `swival> ` prompt is written to stderr manually, then `input()` is called with no argument (so it doesn't write a prompt to stdout). This keeps stdout clean for piping while still getting `readline` line-editing and history support. `input()` (unlike `sys.stdin.readline()`) hooks into the `readline` module when it's imported, giving arrow-key navigation, Ctrl-R search, and per-session history for free. Add `import readline` at the top of `repl_loop` (guarded with a try/except for platforms without it).

### fmt.py

Add one function:

```python
def repl_banner() -> None:
    _console.print(Text("Interactive mode. Type /exit or Ctrl-D to quit.", style="dim"))
```

### tests/test_repl.py

New test file:

- **`test_question_optional_with_repl`** — Parsing `["--repl"]` succeeds and `args.question` is `None`.
- **`test_question_required_without_repl`** — Call `main()` with no question and no `--repl`. Verify it calls `parser.error()` (i.e. `SystemExit`). Note: the parser itself accepts `question` as `nargs="?"`, so `parse_args([])` succeeds — the validation happens in `main()`, not at parse time.
- **`test_question_with_repl`** — Parsing `["--repl", "initial question"]` works; the initial question is sent as the first user message and run through `run_agent_loop` before entering the interactive REPL.
- **`test_run_agent_loop_returns_answer`** — Mock `call_llm` to return a text-only response. Verify `run_agent_loop` returns `(answer, False)` and appends assistant message to history.
- **`test_run_agent_loop_max_turns`** — Mock `call_llm` to always return tool calls. Verify `run_agent_loop` returns `(last_text, True)` after `max_turns`, where `last_text` is the last assistant content (or `None` if there was none).
- **`test_repl_exit_commands`** — Feed `/exit` into stdin, verify `repl_loop` returns without error.
- **`test_repl_eof`** — Feed empty stdin (EOF), verify clean exit.
- **`test_repl_empty_lines_ignored`** — Feed `"\n\n\nhello\n/exit\n"` into stdin, verify only one `run_agent_loop` call (for "hello").
- **`test_repl_message_history_persists`** — Feed two questions, verify the second `run_agent_loop` call receives messages from the first question's answer in the history.
- **`test_repl_ctrl_c_during_loop`** — Mock `run_agent_loop` to raise `KeyboardInterrupt`. Verify REPL continues (doesn't crash).
- **`test_repl_prompt_on_stderr_not_stdout`** — Capture both stdout and stderr while feeding a question + `/exit` into `repl_loop`. Assert `"swival> "` appears in stderr output and is absent from stdout. Validates the `stderr.write` + bare `input()` approach.
- **`test_compaction_preserves_list_identity`** — Create a messages list, pass it to `run_agent_loop` with `call_llm` mocked to raise `ContextOverflowError` on the first call (then succeed). Verify that the list object returned by `id()` before the call is the same object after — i.e. `messages[:] = ...` was used, not rebinding. Also verify the compacted content is visible through the original reference.

## Design decisions

- **Extract `run_agent_loop` instead of duplicating**: The agent loop logic (context overflow, tool dispatch, finish_reason handling) is ~100 lines. Extracting it to a function avoids duplication and makes it independently testable.
- **In-place list mutation for compaction**: The current loop does `messages = compact_messages(messages)`, which rebinds the local variable. Inside `run_agent_loop` this would silently detach from the caller's list, breaking REPL history persistence. Using `messages[:] = ...` preserves list identity so the caller always sees the current state. Harmless for single-shot mode.
- **`(answer, exhausted)` return tuple**: Returning just `None` for max-turns would lose the last assistant text that single-shot mode currently prints. The tuple preserves both the text and the exhaustion signal, letting each caller handle them appropriately (single-shot exits 2; REPL warns and continues).
- **Prompt via `stderr.write` + bare `input()`**: Writing the prompt to stderr keeps stdout clean. Calling `input()` with no prompt argument (instead of `sys.stdin.readline()`) hooks into the `readline` module for line editing and history. `sys.stdin.readline()` bypasses `readline` entirely and would lose arrow keys / Ctrl-R.
- **Per-question turn limit**: Restarting the turn counter per question prevents early questions from eating the budget of later ones. The user can always raise `--max-turns` if a single question needs more.
- **No `/clear` command initially**: Keeping it simple. The user can exit and restart if they want a fresh context. Can be added later if needed.
- **Initial question support**: If the user passes both `--repl` and a positional question (e.g. `agent --repl "set up the project"`), `main()` runs it through `run_agent_loop` before entering `repl_loop()`. This is handled in `main()`, not inside `repl_loop()`, to keep `repl_loop` focused on interactive input.
- **Validation in `main()`, not the parser**: Since `question` must be `nargs="?"` to be optional for `--repl`, the parser alone can't enforce "question required when not `--repl`". The check lives in `main()` via `parser.error()`.

## What stays the same

- All existing single-shot behavior (no `--repl` flag) is unchanged.
- Tool definitions, sandboxing, output caps, context management.
- Provider setup, model discovery, command resolution, skill discovery.
- The `--quiet` flag suppresses diagnostics in REPL mode the same way as single-shot.
- Exit codes: 0 for clean exit (including REPL session end), 1 for errors, 2 for max-turns (single-shot only; REPL never exits with 2).
