# Library API Plan

Swival is currently CLI-only. This plan adds a public Python API so users can
`import swival` and run agent loops programmatically.

## Goals

- One-call convenience: `swival.run("question", base_dir=".")` returns the answer.
- Full control path: construct a `Session` object, configure it, call `.run()` or
  iterate turns manually.
- Zero CLI coupling: no argparse, no `sys.exit`, no mandatory stderr output.
  All `fmt.*` calls inside the agent loop must be gated on a `diagnostics` flag
  so the library path is silent by default.
- Backwards compatible: the CLI remains unchanged and delegates to the same
  internals.

## Non-goals

- Async support (can be added later).
- Streaming token output.
- Changing the tool dispatch or edit engine.

---

## API surface

### Quick path

```python
import swival

answer = swival.run(
    "Summarize README.md",
    base_dir="./my-project",
    provider="openrouter",            # "lmstudio" | "huggingface" | "openrouter"
    model="anthropic/claude-3-haiku", # required for huggingface/openrouter
    api_key="sk-or-...",              # or read from env
)
```

`swival.run()` returns `str` (the final answer) or raises `swival.AgentError`.
Raises `AgentError` if the model exhausts `max_turns` without producing an
answer — callers who want partial output or exhaustion info should use
`Session.run()` instead.

### Full path

```python
from swival import Session

session = Session(
    base_dir="./my-project",
    provider="openrouter",
    model="anthropic/claude-3-haiku",
    api_key="sk-or-...",
)

# Optional configuration (all have sensible defaults)
session.max_turns = 20
session.max_output_tokens = 4096
session.temperature = 0.7
session.allowed_commands = ["ls", "git", "python3"]
session.yolo = False
session.verbose = False
session.system_prompt = None       # None = use default + project instructions
session.no_instructions = False
session.skills_dir = None
session.allowed_dirs = []
session.read_guard = True
session.history = True

# Run a single question
result = session.run("Summarize README.md")
print(result.answer)       # str | None
print(result.exhausted)    # bool — True if max_turns hit
print(result.messages)     # full message list (for inspection/replay)
print(result.report)       # dict | None — same shape as --report JSON

# Multi-turn conversation (share context across questions)
session.ask("Read the config file")
result = session.ask("Now refactor it to use dataclasses")
```

### Result object

```python
@dataclass
class Result:
    answer: str | None
    exhausted: bool
    messages: list[dict]
    report: dict | None
```

### Exceptions

```python
swival.AgentError          # runtime/setup failure (already exists in report.py)
swival.ConfigError         # invalid configuration (new — replaces parser.error in shared code)
```

Both live in `report.py` (the existing home for `AgentError`). This avoids
import cycles: `agent.py` and `session.py` both import from `report.py`, never
from each other.

`ContextOverflowError` is internal — the loop handles it with compaction/retry
and only surfaces it as `AgentError` if all recovery stages fail. Not part of
the public API.

---

## Implementation

### 1. New file: `swival/session.py`

Contains the `Session` class. Responsibilities:

- Store configuration as plain attributes (no argparse namespace).
- `_setup()` — performs discovery, builds tools list, constructs system prompt.
  Produces immutable config (provider info, resolved commands, tools list, system
  prompt text). Does NOT create per-run state like `ThinkingState` or
  `FileAccessTracker`. Called lazily on first `.run()` / `.ask()`, or explicitly.
  Results are cached — changing config attributes after `_setup()` requires
  calling `_setup()` again (or just creating a new Session).
- `run(question: str) -> Result` — single-shot: calls `_setup()` if needed,
  creates fresh per-run state (see below), calls `run_agent_loop()`, returns
  `Result`. Each call is fully independent — no state leaks between runs.
- `ask(question: str) -> Result` — conversational: calls `_setup()` if needed,
  creates per-run state on first call and reuses it across subsequent calls.
  Shares context like the REPL.
- `reset()` — clears messages and per-run state without invalidating the setup.
  Next `.ask()` starts a fresh conversation.

**Per-run state** (created fresh for each `run()`, persisted across `ask()`):
- `ThinkingState` — reasoning history and notes
- `FileAccessTracker` — read-before-write guard
- `skill_read_roots: list[Path]` — mutated by `activate_skill()` at runtime as
  the model loads skills. Must be per-run to avoid leaking activated skill paths
  from one `run()` into the next.
- `messages: list` — the conversation

`_setup()` produces immutable config only: resolved provider info, resolved
commands, tools list, system prompt text, skills catalog. None of these are
mutated at runtime.

Rough size: ~200 lines. Most logic is wiring — the actual work stays in
`agent.py`, `tools.py`, etc.

### 2. Update `swival/__init__.py`

```python
from swival.session import Session, Result
from swival.report import AgentError, ConfigError

def run(question: str, *, base_dir: str = ".", **kwargs) -> str:
    """One-call convenience. Returns the answer string or raises AgentError."""
    session = Session(base_dir=base_dir, **kwargs)
    result = session.run(question)
    if result.answer is None:
        raise AgentError(
            "Agent exhausted max turns without producing an answer"
            if result.exhausted
            else "Agent returned no answer"
        )
    return result.answer
```

### 3. Refactor `_run_main()` in `agent.py`

Extract the setup logic (provider config, model discovery, command resolution,
skill discovery, tools construction, system prompt assembly) into shared helpers
that both `_run_main()` and `Session._setup()` can call. Candidates:

| Helper | What it does | Current location |
|--------|-------------|-----------------|
| `resolve_provider(provider, model, api_key, base_url, verbose)` | Validate provider args, discover model (LM Studio), return `(model_id, api_base, api_key, llm_kwargs)` | `_run_main` lines 846-907 |
| `resolve_commands(allowed_commands, yolo, base_dir)` | Validate commands against PATH, reject commands inside workspace, return `resolved_commands` dict | `_run_main` lines 919-945 |
| `build_tools(resolved_commands, skills_catalog, yolo)` | Construct the tools list from base + conditionals | `_run_main` lines 956-984 |
| `build_system_prompt(base_dir, custom_prompt, no_instructions, skills_catalog, yolo, resolved_commands)` | Assemble full system prompt with instructions, date, skills | `_run_main` lines 986-1028 |

These are plain functions, not methods. They live in `agent.py` and are called by
both the CLI path and `Session._setup()`.

**Validation errors become `ConfigError`.** The current code uses `parser.error()`
for things like missing `--model` or missing API keys. The extracted helpers raise
`ConfigError` (defined in `report.py` alongside `AgentError`) instead. The CLI
wraps calls in a try/except that converts `ConfigError` to `parser.error()`:

```python
# In _run_main():
try:
    model_id, api_base, api_key, llm_kwargs = resolve_provider(...)
except ConfigError as e:
    parser.error(str(e))
```

`ConfigError` is a subclass of `AgentError` so library callers who catch
`AgentError` broadly will still catch config problems. Placing both exceptions in
`report.py` keeps it as the single module imported by `agent.py`, `session.py`,
and `__init__.py` — no cycles.

### 4. Gate all stderr output on a `diagnostics` flag

Two problems to solve:

1. **`fmt.init()` is mandatory before any `fmt.*` call.** Library users shouldn't
   need to call it.
2. **Some `fmt.*` calls inside `run_agent_loop()` are unconditional** — e.g.
   `fmt.warning("context window exceeded, compacting history...")` at line 1258
   and `fmt.warning("still too large, dropping older turns...")` at line 1298.
   These fire regardless of `verbose`.

Solution: thread a `diagnostics: bool` flag through `run_agent_loop()` and gate
ALL `fmt.*` calls on it (both the currently-unconditional warnings and the
already-verbose-gated ones). This replaces the current mix of
`if verbose: fmt.foo()` and bare `fmt.warning()` with a single consistent check.

- `Session` sets `diagnostics=False` by default (silent). Users who want output
  set `session.verbose = True`, which maps to `diagnostics=True`.
- The CLI sets `diagnostics = not args.quiet`. This is a minor behavior change:
  `--quiet` currently suppresses turn headers and tool results but still emits
  warnings. After this change, `--quiet` suppresses everything. This is the right
  call — users who pipe stdout want zero stderr noise, and warnings about context
  compaction are not actionable by the user anyway. Non-quiet mode continues to
  show everything.
- `fmt.init()` is made lazy: auto-initializes with silent defaults on first use.
  The CLI still calls it explicitly at startup to configure color. Library users
  who set `verbose=True` trigger a `fmt.init()` in `Session._setup()` if not
  already initialized.

### 5. Suppress stdout writes

`_run_main()` prints the final answer to stdout. `run_agent_loop()` does not —
it returns the answer. The library path (`Session.run`) never touches stdout.
No changes needed to the loop itself.

`append_history()` is called from `_run_main()` and `repl_loop()`. `Session`
will call it too (controlled by `self.history`).

`append_history()` currently has three unconditional `fmt.warning()` calls (path
escape, capacity, write failure). These must also be gated on `diagnostics`.
Add `diagnostics: bool = True` parameter to `append_history()`. Both the CLI and
`Session` pass through the same `diagnostics` value they use everywhere else
(`not args.quiet` for CLI, `self.verbose` for Session).

---

## File changes summary

| File | Change |
|------|--------|
| `swival/report.py` | Add `ConfigError(AgentError)` class. |
| `swival/session.py` | **New.** `Session` class, `Result` dataclass. |
| `swival/__init__.py` | Export `Session`, `Result`, `ConfigError`, `run`, `AgentError`. |
| `swival/agent.py` | Extract 4 helper functions from `_run_main()`. Validation raises `ConfigError` instead of `parser.error()`. Add `diagnostics` parameter to `run_agent_loop()` and `append_history()`. Gate all `fmt.*` calls on it. `_run_main()` wraps `ConfigError` → `parser.error()` and passes `diagnostics = not args.quiet`. |
| `swival/fmt.py` | Auto-initialize on first use (lazy init with silent defaults). |
| `tests/test_session.py` | **New.** Tests for `Session.run()`, `Session.ask()`, `swival.run()`, state isolation between runs (including `skill_read_roots`), `ConfigError` on bad config. Mock LLM calls like existing tests do. |

---

## Example: using Session in a script

```python
from swival import Session

s = Session(
    base_dir="/tmp/demo",
    provider="openrouter",
    model="anthropic/claude-3-haiku",
    api_key=os.environ["OPENROUTER_API_KEY"],
    allowed_commands=["python3"],
)

# Single question
result = s.run("Write a hello world script and run it")
print(result.answer)

# Multi-turn
s.ask("Now add argument parsing")
result = s.ask("Run it with --name World")
print(result.answer)
print(f"Turns used: {len(result.messages)}")
```

## Example: minimal one-liner

```python
print(swival.run("What is 2+2?", provider="lmstudio"))
```

---

## Open questions

1. **Reviewer support in library mode?** The `--reviewer` feature spawns an
   external process. The library API could accept a callable instead
   (`reviewer: Callable[[str], str | None]`), or skip it for v1. Leaning toward
   skip for v1 — callers who need review loops can wrap `session.ask()`.

## Resolved decisions

1. **Context length auto-detection.** Library mode behaves identically to CLI:
   LM Studio provider auto-detects context via `/api/v1/models`; other providers
   default to None (LiteLLM handles it). Users can override with
   `session.context_length = N`. No divergence between CLI and library.

2. **ContextOverflowError is internal.** The loop already retries through
   compaction stages. If all stages fail, it raises `AgentError`. Library users
   never see `ContextOverflowError` — it stays in `agent.py` as an implementation
   detail.

3. **`swival.run()` raises on exhaustion.** The convenience function raises
   `AgentError("Agent exhausted max turns without producing an answer")` when the
   loop hits `max_turns` with no final answer. Callers who want to inspect
   partial state or differentiate exhaustion from errors use `Session.run()` and
   check `result.exhausted`.

4. **Shared helpers raise `ConfigError`, not `parser.error()`.** The CLI converts
   `ConfigError` to `parser.error()`. Library callers catch `ConfigError` (or its
   parent `AgentError`) directly. Both exception classes live in `report.py` to
   avoid import cycles.

5. **Per-run state isolation.** `run()` creates fresh `ThinkingState` +
   `FileAccessTracker` + `skill_read_roots` + messages each time. `ask()` persists
   them across calls. `skill_read_roots` is per-run because `activate_skill()`
   mutates it, and activated skill paths must not leak across independent runs.

6. **CLI `--quiet` suppresses all stderr.** `diagnostics = not args.quiet`. This
   is a minor behavior change (warnings used to survive `--quiet`) but is the
   correct default — piped output should be clean. The `diagnostics` flag gates
   all `fmt.*` calls uniformly: loop warnings, turn headers, tool results, and
   `append_history` warnings.

7. **`resolve_commands` takes `base_dir`.** The workspace-safety check (reject
   commands that resolve inside the base directory) requires it.
