# Plan: Add Agent Tool Calling to swival-agent

## Goal

Extend the existing single-turn CLI agent into a multi-turn agentic loop that can
call tools autonomously. The model receives tool definitions, decides when to call
them, executes them locally, and feeds results back until it produces a final
text answer.

Three file tools to implement: **read_file**, **write_file**, **patch_file**.

## Current State

- `agent.py` does a single `litellm.completion()` call and prints the response.
- No tool definitions, no loop, no tool dispatch.

## Design Decisions

### Tool format

Use the OpenAI function-calling convention that LiteLLM normalizes across providers:

```python
tools = [
    {
        "type": "function",
        "function": {
            "name": "read_file",
            "description": "...",
            "parameters": { ... }   # JSON Schema
        }
    },
    ...
]
```

Pass `tools=tools, tool_choice="auto"` to `litellm.completion()`.

### Agent loop

Replace the single completion call with a loop:

```
messages = [system?, user]
turns = 0
while turns < max_turns:
    turns += 1
    response = litellm.completion(..., tools=tools, tool_choice="auto")
    msg = response.choices[0].message
    messages.append(msg)

    if not msg.tool_calls:
        # Model produced a final text answer (handles None and empty list)
        print(msg.content or "")
        break

    for tool_call in msg.tool_calls:
        try:
            args = json.loads(tool_call.function.arguments)
            result = dispatch(tool_call.function.name, args, base_dir)
        except json.JSONDecodeError as e:
            result = f"Error: invalid JSON in tool arguments: {e}"
        except Exception as e:
            result = f"Error: tool execution failed: {e}"
        messages.append({
            "role": "tool",
            "tool_call_id": tool_call.id,
            "content": result
        })
else:
    # max_turns exhausted
    # Walk backwards to find the last assistant message (skip tool results)
    last_text = None
    for m in reversed(messages):
        role = m.get("role") if isinstance(m, dict) else getattr(m, "role", None)
        if role == "assistant":
            last_text = m.get("content") if isinstance(m, dict) else getattr(m, "content", None)
            break
    if last_text:
        print(last_text)
    print("Warning: max turns reached, agent stopped.", file=sys.stderr)
    sys.exit(2)
```

Every tool error is caught and returned as a plain-text error string in the
tool result message. This lets the model see the error and recover (retry,
try a different approach) instead of crashing the whole run.

Add `--max-turns` (default 50) to cap iterations. Exit code 2 on exhaustion
(distinct from 1 for connection/setup errors).

### File scoping

All file paths the model supplies must be resolved against a **base directory**
(defaults to cwd). The sandbox check uses `os.path.realpath()` to resolve
symlinks before checking containment — this prevents both `..` traversal and
symlink-based escapes.

```python
from pathlib import Path

def safe_resolve(file_path, base_dir):
    base = Path(base_dir).resolve()
    resolved = (base / file_path).resolve()
    if not (resolved == base or resolved.is_relative_to(base)):
        raise ValueError(f"path escapes base directory: {file_path}")
    return str(resolved)
```

Add `--base-dir` CLI option (default: `.`).

### Encoding policy

All file operations are UTF-8 only. `read_file` detects binary files by
checking for null bytes in the first 8 KB and returns an error instead of
garbled content. `write_file` and `patch_file` write UTF-8 text. Non-UTF-8
files produce a decode error returned to the model as a tool error.

### Tool result size limits

Individual tool results are capped at 50 KB. If a `read_file` result exceeds
this (even after line/offset limiting), it is truncated with a
`[truncated at 50KB]` suffix so the model knows the output is incomplete.
This bounds worst-case context growth to roughly `50KB * max_turns`.

## Tool Specifications

### 1. read_file

**Purpose:** Read a file's contents (or list a directory).

**Parameters (JSON Schema):**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file_path` | string | yes | Absolute or relative path to read |
| `offset` | integer | no | 1-based line number to start from (default: 1) |
| `limit` | integer | no | Max lines to return (default: 2000) |

**Behavior:**
- If path is a directory, return a listing (one entry per line, `/` suffix for subdirs).
- If path is a file, return lines prefixed with line numbers: `<n>: <content>`.
- Truncate individual lines longer than 2000 characters.
- Return an error string if the path doesn't exist or is outside the base dir.
- Detect binary files (null bytes in first 8 KB) and return an error.
- Cap total output at 50 KB, append `[truncated at 50KB]` if exceeded.

### 2. write_file

**Purpose:** Create or overwrite a file with the given content.

**Parameters (JSON Schema):**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `file_path` | string | yes | Path to write to |
| `content` | string | yes | Full file content |

**Behavior:**
- Create parent directories if they don't exist.
- Overwrite the file entirely. Write as UTF-8.
- Return a confirmation message with the number of bytes written.
- Reject paths outside the base dir.

### 3. patch_file

**Purpose:** Apply a patch to an existing file. Uses a stripped-down patch
format (from opencode) that is simpler than unified diff and easier for LLMs
to produce correctly.

**Parameters (JSON Schema):**

| Name | Type | Required | Description |
|------|------|----------|-------------|
| `patch_text` | string | yes | The full patch text |

**Patch format:**

```
*** Begin Patch
*** Add File: path/to/new_file.py
+line 1
+line 2
*** Update File: path/to/existing.py
@@ context line from original @@
-old line
+new line
 unchanged line
+inserted line
*** Delete File: path/to/obsolete.py
*** End Patch
```

Rules:
- `*** Add File:` — every following line must start with `+`.
- `*** Update File:` — uses `@@ context @@` anchors to locate hunks, then
  `-` for removals, `+` for additions, ` ` (space) for context.
- `*** Delete File:` — removes the file.
- All paths in the patch are resolved against the base dir (with symlink check).

**Hunk matching disambiguation:**

When a `@@ context @@` line appears multiple times in the file, hunks are
applied sequentially top-to-bottom. Each hunk searches for its context line
starting from the position after the previous hunk's last matched line
(or from line 0 for the first hunk). This guarantees deterministic ordering
and prevents double-matching. If no match is found from the search position
onward, the patch fails with a clear error naming the context line and
search offset.

**Behavior:**
- Parse the patch into per-file operations.
- For `Update File`: apply hunks sequentially with position-advancing matching.
- Return a summary of files added/updated/deleted and lines changed.
- Fail with a clear error if a hunk context line can't be found.

## Implementation Steps

### Step 1 — Refactor agent.py into modules

Split into:
- `agent.py` — CLI entrypoint, arg parsing, main().
- `tools.py` — Tool definitions, dispatch function, tool implementations.
- `patch.py` — Patch parser and applicator (non-trivial logic, deserves its own file).

### Step 2 — Implement the three tools in tools.py

Define a `TOOLS` list with the OpenAI function-calling schema for each tool.
Implement each tool function. Implement `dispatch(name, args, base_dir)` that
routes a tool call name to the right function.

`safe_resolve()` in every tool:
- Use `Path(base_dir).resolve()` and `(base / file_path).resolve()` to
  resolve both `..` components and symlinks.
- Check containment with `is_relative_to()`.
- Reject with a clear error otherwise.

### Step 3 — Implement the patch parser in patch.py

Parse the patch text line by line into a list of operations:
- `AddFile(path, lines)`
- `UpdateFile(path, hunks)` where each hunk has `context_line` and a list
  of `(action, line)` tuples (`-` remove, `+` add, ` ` context).
- `DeleteFile(path)`

Apply each operation:
- `AddFile` → write new file (UTF-8).
- `DeleteFile` → remove file.
- `UpdateFile` → for each hunk, search for the context line starting from
  `search_pos` (initially 0, advanced after each hunk). Apply the changes
  at the matched position. If context line not found, fail with an error
  message including the context line text and the search offset.

### Step 4 — Convert the single call into an agent loop in agent.py

- Replace `call_llm` with a loop (see pseudocode above).
- Wrap all tool dispatch in try/except — errors become tool result strings
  so the model can recover.
- Check `not msg.tool_calls` (covers both `None` and empty list).
- On `--max-turns` exhaustion: print last text content (if any) to stdout,
  print warning to stderr, exit with code 2.
- Log each tool call name, arguments, and result to stderr when `--verbose`.

### Step 5 — Add new CLI options

- `--max-turns` (default: 50) — max agent loop iterations.
- `--base-dir` (default: `.`) — sandbox directory for file tools.

### Step 6 — Update README.md

Add documentation for:
- New agentic behavior.
- Tool descriptions and examples.
- New CLI options.
- Exit codes (0 = success, 1 = error, 2 = max turns exhausted).

### Step 7 — Test

Positive paths:
- Read an existing file, verify line-numbered output.
- Write a new file, verify content on disk.
- Patch an existing file, verify changes applied.
- Full agent loop: ask the model to create a file, verify it does.

Error handling:
- Tool dispatch with invalid JSON arguments → model gets error, doesn't crash.
- Unknown tool name → model gets error.
- read_file on binary file → error message, not garbled output.
- read_file on non-existent path → error message.
- Non-UTF-8 file → decode error returned to model.

Sandbox:
- Path with `..` escaping base dir → rejected.
- Symlink inside base dir pointing outside → rejected (realpath check).

Patch edge cases:
- Context line appears multiple times → sequential matching picks correct one.
- Context line not found → clear error with line text and search offset.
- Malformed patch (missing header, missing `+` prefix) → parse error.

Loop behavior:
- `--max-turns 1` with a task requiring tools → exhaustion warning, exit 2.
- Model responds with empty `tool_calls` list → loop terminates correctly.

## New CLI Contract

```
agent <question> [options]

Positional:
  question                     The question or task for the model.

Options (existing):
  --base-url URL               LM Studio server URL (default: http://127.0.0.1:1234)
  --max-context-tokens N       Requested context length
  --max-output-tokens N        Max tokens per response (default: 512)
  --temperature F              Sampling temperature (default: 0.2)
  --system-prompt TEXT         System message
  --no-system-prompt           Omit system message
  --model ID                   Override model discovery
  -v, --verbose                Diagnostics to stderr

Options (new):
  --max-turns N                Max agent loop iterations (default: 50)
  --base-dir PATH              Base directory for file tools (default: .)
```

Exit codes:
- 0: success (model produced a final answer).
- 1: error (connection failure, no model, setup problem).
- 2: max turns exhausted (partial output may have been printed).

## File Layout After Implementation

```
swival/
├── pyproject.toml
├── README.md
├── agent.py          # CLI entrypoint, arg parsing, agent loop
├── tools.py          # Tool definitions, dispatch, read/write implementations
└── patch.py          # Patch format parser and applicator
```

## Acceptance Criteria

- The model can autonomously read files, write files, and patch files.
- Tool calls and results appear in stderr with `--verbose`.
- Only the final text answer appears on stdout.
- File operations are sandboxed to `--base-dir` (including symlink resolution).
- Tool errors are returned to the model as result strings (never crash the loop).
- The loop terminates when the model produces text without tool calls,
  or when `--max-turns` is reached (exit code 2).
- Malformed patches produce clear error messages (not crashes).
- Path traversal and symlink escape attempts are rejected.
- Binary and non-UTF-8 files are detected and reported as errors.
- Patch hunk matching is deterministic (sequential position-advancing).
