# `PtcRunner.SubAgent.Compaction`
[🔗](https://github.com/andreasronge/ptc_runner/blob/main/lib/ptc_runner/sub_agent/compaction.ex#L1)

Pressure-triggered context compaction for multi-turn agents.

Compaction reduces the LLM-input message list when turn count or estimated
token usage crosses a threshold. Recent turns are preserved verbatim; older
turns are trimmed (Phase 1) or summarized (Phase 2 — not yet implemented).

Phase 1 ships **one** strategy: `:trim`. Custom strategy modules and
`:summarize` are deferred to Phase 2.

## Configuration

    SubAgent.run(prompt, llm: llm, compaction: true)

    SubAgent.run(prompt,
      llm: llm,
      compaction: [
        strategy: :trim,
        trigger: [turns: 8, tokens: 12_000],
        keep_recent_turns: 3,
        keep_initial_user: true,
        token_counter: nil
      ]
    )

Defaults for `compaction: true`:

    [
      strategy: :trim,
      trigger: [turns: 8],
      keep_recent_turns: 3,
      keep_initial_user: true,
      token_counter: nil
    ]

Library default is `false` — compaction is opt-in.

## Token estimation

Default counter is `String.length(content) / 4`, matching the existing
metrics heuristic. Override via `token_counter: fun/1`. This is explicitly
a pressure heuristic and **not** model-accurate.

# `message`

```elixir
@type message() :: %{role: :user | :assistant, content: String.t()}
```

# `normalized`

```elixir
@type normalized() :: {:disabled, []} | {:trim, keyword()}
```

# `stats`

```elixir
@type stats() :: map()
```

# `build_context`

```elixir
@spec build_context(keyword(), keyword()) :: PtcRunner.SubAgent.Compaction.Context.t()
```

Build a `Compaction.Context` for a given turn.

Resolves the token counter from `opts` (or falls back to the default).

# `default_token_counter`

```elixir
@spec default_token_counter(String.t()) :: non_neg_integer()
```

Default token counter — `String.length/1` divided by 4, with a floor of 1
for any non-empty content.

Mirrors `PtcRunner.SubAgent.Loop.Metrics.estimate_tokens/1` so token-pressure
detection still fires on histories made of short messages. Pressure heuristic,
not adapter-accurate.

## Examples

    iex> PtcRunner.SubAgent.Compaction.default_token_counter("hello world")
    2

    iex> PtcRunner.SubAgent.Compaction.default_token_counter("hi")
    1

    iex> PtcRunner.SubAgent.Compaction.default_token_counter("")
    0

# `default_trim_opts`

```elixir
@spec default_trim_opts() :: keyword()
```

Default trim options used when `compaction: true`.

# `maybe_compact`

```elixir
@spec maybe_compact(
  [message()],
  PtcRunner.SubAgent.Compaction.Context.t(),
  normalized()
) ::
  {[message()], stats() | nil}
```

Run compaction for a list of LLM-input messages.

`normalized` is the output of `normalize/1`. Always returns
`{messages, stats | nil}`:

- When the strategy is `:disabled`, returns `{messages, nil}` — no work, no stats.
- Otherwise dispatches to the strategy and returns its `{messages, stats}` result.
  Use `stats.triggered` (boolean) to distinguish a triggered trim from a
  not-triggered pass-through; the stats shape is consistent either way.

# `normalize`

```elixir
@spec normalize(nil | boolean() | keyword()) :: normalized()
```

Normalize compaction configuration.

Accepts:

- `nil` or `false` → `{:disabled, []}`
- `true` → `{:trim, default_opts}`
- `keyword()` with `strategy: :trim` (or unspecified) → `{:trim, merged_opts}`

Raises `ArgumentError` for invalid input. The error message is the source of
truth for what Phase 1 supports.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
