# RFD 026: Agent Loop Extraction

- **Status**: Discussion
- **Category**: Design
- **Authors**: Jean Mertz <git@jeanmertz.com>
- **Date**: 2025-07-19
- **Required by**: [RFD 027]

## Summary

This RFD extracts the turn loop and its supporting components from `jp_cli` into
a new `jp_agent` crate.
The crate provides a self-contained agent execution engine with trait-based
hooks for I/O.
This separates the core agent logic from CLI-specific concerns like terminal
rendering, interactive prompts, and process lifecycle.

## Motivation

The turn loop (`run_turn_loop`) and its supporting components —
`TurnCoordinator`, `ToolCoordinator`, `ChatResponseRenderer`,
`InterruptHandler`, `StreamRetryState`, and the inquiry backend — currently
live inside `jp_cli::cmd::query`.
They are `pub(crate)` and tightly coupled to the CLI binary.

This creates two problems:

**The agent loop cannot be called from outside `jp_cli`.** Any consumer that
wants to run the turn loop with different I/O backends (different output sinks,
non-terminal prompt backends, programmatic signal injection) cannot do so.
The code is locked inside the CLI crate with `pub(crate)` visibility.

**The boundary between "run the agent" and "interact with the user" is
implicit.** The turn loop directly uses `TerminalPromptBackend`, references
`crate::error::Error`, and calls `crate::signals::SignalTo`.
These are CLI concerns baked into the agent logic.
The `PromptBackend` trait exists as a seam for testing, but the turn loop still
assumes a specific signal type and error type.

Extracting the agent loop forces a clean API surface: here is a function that
runs a conversation turn given a provider, tools, config, and a set of I/O
hooks.
The caller decides what those hooks do — write to a terminal, discard, or
something else entirely.

> [!TIP]
> [RFD 027] is the primary consumer of `jp_agent`, using `run_turn_loop()` as
> the server-side execution engine in its client-server query architecture.

## Design

### What Moves

The following modules move from `jp_cli::cmd::query` into `jp_agent`:

| Current location       | New location                   | What it is                 |
| ---------------------- | ------------------------------ | -------------------------- |
| `turn_loop.rs`         | `jp_agent::turn_loop`          | The main agent loop        |
| `turn/coordinator.rs`  | `jp_agent::turn::coordinator`  | Turn state machine         |
| `turn/state.rs`        | `jp_agent::turn::state`        | Per-turn accumulated state |
| `stream/retry.rs`      | `jp_agent::stream::retry`      | Stream retry logic         |
| `tool/coordinator.rs`  | `jp_agent::tool::coordinator`  | Parallel tool execution    |
| `tool/inquiry.rs`      | `jp_agent::tool::inquiry`      | LLM inquiry backend        |
| `interrupt/handler.rs` | `jp_agent::interrupt::handler` | Interrupt menu logic       |
| `interrupt/signals.rs` | `jp_agent::interrupt::signals` | Signal dispatch            |

### What Stays in `jp_cli`

| Module                          | Why it stays                            |
| ------------------------------- | --------------------------------------- |
| `query.rs`                      | Arg parsing, config resolution, editor, |
|                                 | conversation selection                  |
| `stream/renderer.rs`            | `ChatResponseRenderer` — implements     |
|                                 | `ResponseRenderer` using `jp_md`.       |
|                                 | Presentation concern.                   |
| `stream/structured_renderer.rs` | `StructuredRenderer` — same pattern.    |
| `tool/executor.rs`              | `TerminalExecutorSource` — creates real |
|                                 | executors from resolved definitions.    |
|                                 | CLI-specific because it references      |
|                                 | workspace tool resolution.              |
| `tool/prompter.rs`              | `ToolPrompter` — renders                |
|                                 | permission/question prompts to the      |
|                                 | terminal. The trait boundary is         |
|                                 | `PromptBackend`; the implementation     |
|                                 | stays in CLI.                           |
| `tool/renderer.rs`              | `ToolRenderer` — renders tool call      |
|                                 | progress/headers. Display-only, tied to |
|                                 | terminal UX.                            |
| `tool/builtins.rs`              | Builtin tool registration (e.g.         |
|                                 | `describe_tools`).                      |

### The Boundary

The key insight: the turn loop's external dependencies are already mostly
trait-based.

| Dependency      | Current type                      | Abstraction                            |
| --------------- | --------------------------------- | -------------------------------------- |
| LLM provider    | `Arc<dyn Provider>`               | Already a trait                        |
| Prompt backend  | `Arc<dyn PromptBackend>`          | Already a trait                        |
| Tool execution  | `Box<dyn ExecutorSource>`         | Already a trait                        |
| Inquiry backend | `Arc<dyn InquiryBackend>`         | Already a trait                        |
| Output          | `Arc<Printer>`                    | Concrete type, but writer-agnostic via |
|                 |                                   | `swap_writers`                         |
| Signals         | `SignalRx`                        | Concrete type                          |
|                 | (`broadcast::Receiver<SignalTo>`) |                                        |
| Persistence     | `&mut Workspace`                  | Concrete type                          |
| Error type      | `crate::error::Error`             | CLI-specific enum                      |

The concrete dependencies that need abstraction or relocation:

**`SignalTo`** — currently defined in `jp_cli::signals`.
This is a simple enum (`Shutdown`, `Quit`).
It moves to `jp_agent` since the agent loop needs to react to signals regardless
of where they originate.

**`crate::error::Error`** — the turn loop returns `crate::error::Error`, which
is CLI-specific.
`jp_agent` defines its own error type that the CLI wraps.

**`Workspace`** — the turn loop calls `workspace.get_events()`,
`workspace.get_events_mut()`, `workspace.persist_active_conversation()`.
These are the persistence operations.
Rather than depending on `jp_workspace` directly, `jp_agent` uses a
`ConversationStore` trait defined in `jp_conversation`:

```rust
pub trait ConversationStore {
    fn get_events(&self, id: &ConversationId) -> Option<&ConversationStream>;
    fn get_events_mut(&mut self, id: &ConversationId) -> Option<&mut ConversationStream>;
    fn persist(&mut self) -> Result<()>;
}
```

`jp_workspace::Workspace` implements the trait.
Other callers can provide their own implementation.

**`ToolRenderer`** and **`ToolPrompter`** — these are passed into the turn loop
as parameters.
They stay concrete (defined in `jp_cli`) and passed in by the caller.
The turn loop accepts them via traits or generic parameters:

`ToolRenderer` is used for display (progress indicators, tool call headers).
Rather than making `ToolRenderer` a trait, the turn loop accepts an
`Arc<Printer>` (which it already does) and the renderer is constructed inside
the loop from the printer.
The printer's writers determine where output goes.

`ToolPrompter` wraps `PromptBackend` and adds CLI-specific formatting
(permission prompts with colored output, editor integration for result editing).
The turn loop already accepts `Arc<dyn PromptBackend>` — the `ToolPrompter` is
constructed inside the loop from this backend.
The CLI passes `TerminalPromptBackend`; other callers provide their own
implementation.

### `run_turn_loop` Signature

The extracted function signature:

```rust
pub async fn run_turn_loop(
    provider: Arc<dyn Provider>,
    model: &ModelDetails,
    cfg: &AppConfig,
    signals: &SignalRx,
    store: &mut dyn ConversationStore,
    conversation_id: ConversationId,
    printer: Arc<Printer>,
    prompt_backend: Arc<dyn PromptBackend>,
    inquiry_backend: Arc<dyn InquiryBackend>,
    tool_coordinator: ToolCoordinator,
    chat_request: ChatRequest,
    // Context that varies by caller
    root: &Utf8Path,
    is_tty: bool,
    mcp_client: &jp_mcp::Client,
    attachments: &[Attachment],
) -> Result<(), AgentError>
```

This is close to the current signature.
The main changes:

- `&mut Workspace` → `&mut dyn ConversationStore`
- `crate::error::Error` → `AgentError`
- `SignalRx` and `SignalTo` come from `jp_agent`

### `ToolRenderer` Stays Internal

`ToolRenderer` is constructed inside `run_turn_loop` today:

```rust
let mut tool_renderer = ToolRenderer::new(
    if cfg.style.tool_call.show && !printer.format().is_json() {
        printer.clone()
    } else {
        Printer::sink().into()
    },
    cfg.style.clone(),
    root.to_path_buf(),
    is_tty,
);
```

This construction stays inside the extracted `run_turn_loop`.
`ToolRenderer` moves to `jp_agent` alongside the turn loop.
Its output goes through the `Printer`, which the caller controls via writer
injection.

### Response Rendering

The `TurnCoordinator` currently owns a `ChatResponseRenderer` (which depends on
`jp_md`) and calls it on every streamed `ChatResponse` chunk.
The agent loop controls *when* rendering happens (on each chunk, flush before
tool calls, reset on continuation), but *how* rendering works is a presentation
concern.

`jp_agent` defines a rendering trait:

```rust
pub trait ResponseRenderer {
    /// Process a new response chunk from the LLM.
    fn render(&mut self, response: &ChatResponse);

    /// Emit any buffered content immediately.
    ///
    /// Called before tool calls (so buffered content appears before the
    /// tool output), on stream finish, and on interrupts.
    fn flush(&mut self);

    /// Discard all state, preparing for a fresh streaming cycle.
    ///
    /// Called when the turn loop restarts streaming after an interrupt
    /// (e.g. Continue or Reply action). The partial content has already
    /// been captured by the event builder.
    fn reset(&mut self);
}
```

Three methods, each with clear semantics for any implementation:

- A terminal renderer (`ChatResponseRenderer`) buffers markdown, formats with
  ANSI styling, and writes to the printer.
- A no-op renderer discards everything (e.g. non-interactive execution).
- A test renderer captures events for assertions.

The current `reset_content_kind()` call in `TurnCoordinator` — which exists
because the markdown renderer tracks reasoning vs. message transitions —
becomes an internal concern of the `ChatResponseRenderer` implementation.
The agent loop just calls `flush()` when a tool call arrives; the renderer
decides what bookkeeping that entails.

`StructuredRenderer` follows the same pattern, either folded into
`ResponseRenderer` or as a parallel trait.

This removes `jp_md` from `jp_agent`'s dependency graph entirely.

### Crate Dependencies

`jp_agent` depends on:

- `jp_config` (for `AppConfig`, `StyleConfig`, tool config types)
- `jp_conversation` (for `ConversationStream`, events, `ConversationId`,
  `ConversationStore`)
- `jp_llm` (for `Provider`, `Event`, `ToolDefinition`, `Executor`)
- `jp_printer` (for `Printer`)
- `jp_inquire` (for `PromptBackend`)
- `jp_mcp` (for `Client`)
- `jp_attachment` (for `Attachment`)
- `jp_tool` (for `Question`, `AnswerType`)

`jp_cli` depends on `jp_agent` and provides:

- `TerminalPromptBackend` (via `jp_inquire`)
- `TerminalExecutorSource` (creates real executors)
- `Workspace` as `ConversationStore`
- OS signal setup
- Arg parsing, editor, config resolution

### What `jp_cli::cmd::query` Becomes

After extraction, `query.rs` is the orchestration layer:

1. Parse args, resolve config, select conversation
2. Open editor, build `ChatRequest`
3. Resolve tools, set up MCP, register attachments
4. Create a `ToolCoordinator` with `TerminalExecutorSource`
5. Create the `LlmInquiryBackend`
6. Call `jp_agent::run_turn_loop(...)` with terminal-backed I/O
7. Handle the result, persist, clean up

This is essentially what `Query::run()` does today, minus the turn loop itself.

## Drawbacks

**More crates.** The workspace gains another crate.
Build times increase slightly.
The dependency graph gets one more node.

**Parameter proliferation.** `run_turn_loop` already has 16 parameters.
Adding a trait for persistence doesn't reduce this.
A builder or context struct could help, but that's a separate refactor.

**Incremental move.** Moving modules between crates requires updating every
import path.
Tests that reference internal types need adjustment.
This is mechanical but tedious.

## Alternatives

### Move only the turn loop, keep renderers in CLI

Move `run_turn_loop` and the coordinators, but leave `ChatResponseRenderer`,
`ToolRenderer`, and the interrupt handler in `jp_cli`.
Pass them to the turn loop as trait objects or closures.

Rejected because the renderers depend on `jp_md` for markdown formatting, which
is a presentation concern.
Moving them into `jp_agent` would give the agent crate a dependency on markdown
rendering.
The `ResponseRenderer` trait provides a clean boundary: the agent loop calls
`render`/`flush`/`reset`, and the caller provides the implementation.

### Use `jp_llm` instead of a new crate

Put the agent loop in `jp_llm` since it already hosts the provider traits and
tool execution types.

Rejected because `jp_llm` is a provider abstraction layer.
The agent loop depends on `jp_printer`, `jp_config` and `jp_conversation` -
concerns that don't belong in the LLM layer.
A separate crate keeps the dependency graph clean.

### Trait for everything (full DI)

Define traits for `Printer`, `ToolRenderer`, signal handling, and every other
concrete dependency.
Make the turn loop fully generic.

Rejected as over-engineering.
The `Printer` already supports writer injection.
`ToolRenderer` is an internal detail of the loop, not an extension point.
Signals are a simple enum.
Full DI adds type parameters and complexity without enabling use cases beyond
what `ConversationStore` + `PromptBackend` provide.

## Non-Goals

- **Public SDK.** `jp_agent` is an internal crate for use within the JP
  workspace.
  Its API is not stable and not documented for external consumers.
  A public SDK can be built on top of it later, but that's a separate effort
  with different design constraints.

- **Plugin system.** The trait-based hooks enable different I/O backends, not
  arbitrary agent behavior customization.
  Hooks for tool filtering, response post-processing, or custom turn logic are
  out of scope.

- **Async trait simplification.** Some traits (`InquiryBackend`,
  `ExecutorSource`) use `#[async_trait]`.
  This RFD does not change their signatures or migrate to native async traits.

## Risks and Open Questions

### `ToolPrompter` and editor integration

`ToolPrompter` currently handles permission prompts with editor integration (the
user can edit tool arguments before approving).
The editor is a CLI concern — it opens `$EDITOR` and reads the result.
Other callers would need a different mechanism for argument editing.

For the initial extraction, `ToolPrompter` is constructed in the turn loop from
the injected `PromptBackend`.
The `PromptBackend` trait may need extension to support argument editing, or
`ToolPrompter` needs its own trait.
This can be resolved during implementation.

### MCP client dependency

The turn loop takes `&jp_mcp::Client` for tool execution (some tools are
MCP-backed).
This is a direct dependency that flows through to `ToolCoordinator` and
`Executor`.
For now, `jp_mcp::Client` stays as a parameter.
Whether this should be abstracted behind a trait is left for future work.

### `is_tty` semantics

The turn loop uses `is_tty` to control progress indicators and `ToolRenderer`
behavior.
This is a property of the output consumer, not the process.
For the initial extraction, `is_tty` stays as a `bool` parameter.
If a future caller needs this to change at runtime (e.g. output consumer
capabilities change mid-turn), the parameter would need to become a dynamic
check.

## Implementation Plan

### Phase 1: Create `jp_agent` Crate, Move Types

Create the `jp_agent` crate.
Move `SignalTo` (and the signal-related types) and `TurnState` first — these
have no internal dependencies on CLI types.
Define `AgentError`.

`jp_cli` re-exports or wraps as needed.
All existing tests pass.

Add `ConversationStore` trait to `jp_conversation`.

Can be merged independently.

### Phase 2: Move Turn Coordinator and Stream Retry

Move `TurnCoordinator` and `StreamRetryState` into `jp_agent`.
Add the `ResponseRenderer` trait.
`TurnCoordinator` takes `Box<dyn ResponseRenderer>` instead of owning
`ChatResponseRenderer` directly.

`ChatResponseRenderer` and `StructuredRenderer` stay in `jp_cli` and implement
the new trait.

Update `jp_cli` imports.

Can be merged independently.

### Phase 3: Move Tool Coordinator and Interrupt Handler

Move `ToolCoordinator` and `InterruptHandler` into `jp_agent`.
These are the components that interact with `PromptBackend` and signals — the
I/O boundary.

Replace `crate::Error` references in the moved code with `AgentError`.

Can be merged independently.

### Phase 4: Move Turn Loop

Move `run_turn_loop` into `jp_agent`.
Replace `&mut Workspace` with `&mut dyn ConversationStore`.
Update `jp_cli::cmd::query` to call `jp_agent::run_turn_loop(...)` with the
workspace as the store and terminal backends for I/O.

This is the final step.
After this, `jp_cli::cmd::query::run()` is purely orchestration — config
resolution, conversation selection, and calling the extracted agent loop.

Depends on Phases 1–3.

## References

- `run_turn_loop` (`crates/jp_cli/src/cmd/query/turn_loop.rs` — the current
  turn loop implementation being extracted.
- `PromptBackend` (`crates/jp_inquire/src/prompt.rs`) — the existing trait that
  enables I/O injection for prompts.
- `ExecutorSource` (`crates/jp_llm/src/tool/executor.rs`) — the existing trait
  for tool executor creation.
- [yoagent] — prior art for a standalone Rust agent loop library.

[RFD 027]: 027-client-server-query-architecture.md
[yoagent]: https://github.com/yologdev/yoagent
