# Plan: Detailed stderr logging of agent activity

## Goal

Surface what the agent is doing on stderr so the operator can follow along: tool calls with their parameters, tool results (with sensible truncation), and every intermediate LLM response (the model's "thinking" text between tool calls).

## Current state

- `agent.py` already has a `log(msg, verbose)` helper and a `-v` flag.
- It already logs turn numbers, tool call names+raw args, and a 500-char prefix of each tool result.
- It does **not** log:
  - Intermediate assistant text (the model's reasoning/questions between tool calls).
  - Structured or readable formatting of tool parameters.
  - Duration of LLM calls or tool executions.
  - A summary line when the loop ends (total turns, reason for stopping).

## Design decisions

- **Final assistant text** is already printed to stdout; it is not duplicated to stderr. Only intermediate text (text that accompanies tool calls, or text from truncated responses before a continuation prompt) goes to stderr.
- **Tool argument logging** is truncated to 1000 chars to avoid flooding stderr with large patches or file contents.
- **Exit reasons** are limited to `"ok"` and `"max_turns"`. There is no `"truncated"` reason — `finish_reason=="length"` triggers a continuation prompt, not termination, so it's not a loop exit state.
- **JSON parsing** of tool args happens once; the parsed dict is reused for both logging and dispatch.

## Changes

All work is in `agent.py` (plus a new test file `tmp/test_logging.py`). No new production dependencies (just `import time`).

### 1. Log intermediate assistant text

After `messages.append(msg)`, log assistant text in two cases:

- `msg.content` is non-empty **and** there are tool calls (model reasoning before acting).
- `msg.content` is non-empty **and** `finish_reason == "length"` (truncated response that will get a continuation prompt).

The final-answer case (`msg.content` with no tool calls and `finish_reason != "length"`) is not logged because it already goes to stdout.

```python
if msg.content and (msg.tool_calls or finish_reason == "length"):
    log(f"[assistant] {msg.content}", verbose)
```

### 2. Pretty-print tool call parameters (with truncation)

Replace the raw JSON dump with formatted, truncated output. Parse args once, reuse for dispatch.

```python
MAX_ARG_LOG = 1000

for tool_call in msg.tool_calls:
    name = tool_call.function.name
    raw_args = tool_call.function.arguments

    try:
        parsed_args = json.loads(raw_args)
    except (json.JSONDecodeError, TypeError) as e:
        # log the parse failure, append error result, continue
        log(f"Tool call: {name} (invalid JSON: {e})", verbose)
        messages.append(...)
        continue

    pretty = json.dumps(parsed_args, indent=2)
    if len(pretty) > MAX_ARG_LOG:
        pretty = pretty[:MAX_ARG_LOG] + "\n... (truncated)"
    log(f"Tool call: {name}\n{pretty}", verbose)

    result = dispatch(name, parsed_args, base_dir)
    # ... rest unchanged, but no second json.loads()
```

This removes the duplicate `json.loads` — args are parsed once and reused for both logging and `dispatch()`.

### 3. Add timing to LLM calls and tool executions

Use `time.monotonic()` around `call_llm()` and `dispatch()`. Log elapsed seconds.

```python
t0 = time.monotonic()
msg, finish_reason = call_llm(...)
elapsed = time.monotonic() - t0
log(f"LLM responded in {elapsed:.1f}s (finish_reason={finish_reason})", verbose)
```

```python
t0 = time.monotonic()
result = dispatch(name, parsed_args, base_dir)
elapsed = time.monotonic() - t0
log(f"Tool {name} completed in {elapsed:.1f}s", verbose)
```

Requires adding `import time` at the top of `agent.py`.

### 4. Log the finish_reason on every turn

After receiving the LLM response, log metadata so the operator sees `stop` vs `tool_calls` vs `length`:

```python
log(f"finish_reason={finish_reason}, tool_calls={len(msg.tool_calls or [])}", verbose)
```

This is logged right after the timing line from step 3, so the two naturally pair up.

### 5. Log a summary when the loop ends

Two exit paths, two summary lines:

- Normal exit (model gave a final answer): `log(f"Agent finished: {turns} turns, exit=ok", verbose)`
- Max turns exhausted: `log(f"Agent finished: {turns} turns, exit=max_turns", verbose)`

No `"truncated"` reason — that case is handled mid-loop by the continuation prompt and never causes loop exit.

## What stays the same

- The `-v` / `--verbose` flag controls everything; no new flags.
- stdout remains clean (final answer only).
- No changes to `tools.py` or `patch.py`.
- Tool result truncation stays at 500 chars in the log (already done).

## Testing

Add a smoke test in `tmp/test_logging.py` that runs the agent loop logic with a mock LLM, captures stderr, and asserts that key log markers appear. Three test cases:

1. **Happy path (exit=ok):** Mock returns a tool call then a final text answer. Assert:
   - `--- Turn` prefix
   - `Tool call:` with truncated args
   - `LLM responded in`
   - `Agent finished:` with `exit=ok`

2. **Max turns exhausted (exit=max_turns):** Mock always returns tool calls, loop runs with `max_turns=2`. Assert:
   - `Agent finished:` with `exit=max_turns`
   - Process exits with code 2

3. **Truncated response (finish_reason=length):** Mock returns `finish_reason="length"` with non-empty content and no tool calls on the first call, then a normal final answer on the second call. Assert:
   - `[assistant]` marker appears in stderr (intermediate text logged before continuation prompt)
   - A second LLM call is made (continuation turn triggered), proving the control flow path end-to-end

This covers all logging paths without needing a live LM Studio instance.
