# Plan: outbound LLM filter scripts

## Goal

Add an optional outbound filter hook that runs a user-defined local script before Swival sends prompt data to an LLM. This lets users redact or block sensitive text such as internal URLs, project names, or customer identifiers without modifying the local conversation history.

The feature should cover every Swival-managed LLM call path:

- normal agent turns
- REPL turns
- `Session.run()` / `Session.ask()`
- proactive compaction summaries and other internal summary calls
- `provider=command` (the command is acting as the LLM backend, so its stdin should be filtered too)

## Current behavior

Today, all outbound provider calls funnel through `call_llm()` in `swival/agent.py`.

That makes `call_llm()` the right insertion point because it already sits in front of:

- the main agent loop (`run_agent_loop()`)
- the Session API (`swival/session.py`)
- summary calls (`_call_summarize_llm()`)
- the command-provider path (`provider == "command"`)

Important existing details:

- secret encryption also happens in `call_llm()` today (`secret_shield.encrypt_messages(...)`)
- the cache lookup also happens in `call_llm()`
- command provider requests are rendered from `messages` into a transcript inside `_call_command()` / `_call_command_with_tools()`

## Proposed behavior

Add a new optional command string, `llm_filter`, configurable from CLI, config files, and the Session API.

If configured, Swival runs the command before each outbound LLM request.

### Script contract

Swival sends JSON to the script on stdin. Initial payload shape:

```json
{
  "provider": "openrouter",
  "model": "openai/gpt-5",
  "call_kind": "agent",
  "messages": [...],
  "tools": [...]
}
```

Notes:

- `messages` is the exact request message list, normalized to plain dicts
- `tools` is included as read-only context so the filter can make better decisions
- `call_kind` is `"agent"` for normal calls and `"summary"` for compaction/summary calls

The script writes JSON to stdout. Supported success shapes:

```json
{"messages": [...]} 
```

or

```json
{"allow": false, "reason": "blocked internal hostname corp.example"}
```

Behavior rules:

- exit `0` + `{"messages": [...]}` => use the returned messages
- exit `0` + `{"allow": false, ...}` => abort the LLM call and show the reason
- non-zero exit => abort the LLM call
- malformed JSON => abort the LLM call
- timeout => abort the LLM call
- stderr from the script is forwarded to stderr so the user can see warnings/debug output

### Failure policy

Fail closed.

If the filter script errors or rejects the request, Swival must not send anything to the provider. This is the safer default for a privacy feature.

### Mutation policy

The filter operates on a copy of the outbound message list.

Swival should not rewrite the canonical local conversation history, history log, or continue file with redacted text. The user still sees the original local conversation; the filter only changes what leaves the process.

### Ordering

Run the filter before secret encryption.

Why:

- users want to match/redact human-readable text such as URLs and project names
- if encryption ran first, the filter would only see encrypted placeholders
- after filtering, the existing secret-encryption layer can still protect credential-like tokens in the filtered copy

### Cache behavior

Disable cache when `llm_filter` is active.

Reason: cache keys currently depend on request payloads, but a user script is an external mutable dependency. Disabling cache is the simplest correct behavior for the first version and avoids stale responses after the filter script changes.

### Scope boundary

Initial version filters messages only.

- included: system prompt, user messages, tool results, memory injection, snapshot summaries, auto-injected skill content, command-provider transcripts (indirectly, because they are rendered from filtered messages)
- not included: tool schema rewriting, reviewer subprocess input, assistant responses coming back from the model

Self-review still benefits indirectly because reviewer-mode is another Swival invocation and can use the same configured filter.

## Changes

### 1. `swival/config.py`

Add a new string config key:

- `llm_filter`

Implementation notes:

- add it to `CONFIG_KEYS`
- let it flow through `apply_config_to_args()`
- let it flow through `args_to_session_kwargs()` / `config_to_session_kwargs()`
- resolve relative command paths exactly like `reviewer` and `provider=command model` via `_resolve_command_string(...)`
- add a commented example to `generate_config()`

Example config:

```toml
llm_filter = "./scripts/redact-before-send.py"
```

### 2. `swival/session.py`

Add a new constructor argument:

```python
llm_filter: str | None = None
```

Then:

- store it as `self.llm_filter`
- pass it into `self._llm_kwargs` during `_setup()` when configured
- make sure both `run()` and `ask()` inherit the same behavior through `_build_loop_kwargs()`

This keeps the feature available to library users, not just the CLI.

### 3. `swival/agent.py` CLI plumbing

Add a new CLI flag:

```text
--llm-filter COMMAND
```

Recommended placement: the existing prompt/instructions group, since this changes outbound prompt contents rather than sandbox permissions.

Startup validation should mirror the reviewer validation pattern:

- shell-split the command string
- reject empty commands
- verify the executable exists (via `shutil.which()` or an executable path)

If helpful, factor reviewer and filter validation into one shared helper so the behavior stays consistent.

### 4. `swival/agent.py` outbound filter helper

Add a dedicated helper, e.g. `_run_llm_filter(...)`, responsible for:

- serializing dict-or-namespace messages into plain dicts using the `_msg_*` helpers
- preserving structured content such as image-content arrays
- building the JSON payload
- running the subprocess with `subprocess.run(..., input=..., capture_output=True, text=True, timeout=10)`
- forwarding stderr to stderr
- parsing stdout JSON
- validating the returned message list shape before using it

Validation should be strict enough to avoid sending junk to providers:

- top-level result must be a dict
- returned `messages` must be a list of dicts
- every message must have a valid `role`
- optional fields like `content`, `tool_calls`, `tool_call_id`, and `name` may pass through if already provider-compatible

If validation fails, raise `AgentError` with a clear message.

### 5. `swival/agent.py` `call_llm()` hook point

Extend `call_llm()` with a new optional kwarg:

```python
llm_filter=None
```

Hook order inside `call_llm()`:

1. if `llm_filter` is set, run `_run_llm_filter(...)` on a copy of `messages`
2. replace the local outbound `messages` variable with the filtered copy
3. disable cache for this request
4. continue with the existing provider flow
5. if `secret_shield` is active, encrypt the filtered copy afterward

This hook must run before the `provider == "command"` early return so command-provider requests are filtered too.

### 6. `swival/agent.py` summary calls

Thread a `call_kind` marker into internal summarization calls.

Specifically:

- `_call_summarize_llm(...)` should call `call_llm(..., call_kind="summary", ...)`
- normal agent calls should default to `call_kind="agent"`

This gives filter scripts a clean way to distinguish user-facing prompts from compaction summaries without adding multiple different filter entry points.

### 7. Keep command-provider behavior centralized

Do not add a second filter path inside `_call_command()` or `_render_transcript()`.

Filtering at `call_llm()` is enough and avoids divergent behavior between regular providers and `provider=command`.

## Test plan

### `tests/test_config.py`

Add coverage for config plumbing:

- `llm_filter` loads from global config
- `llm_filter` loads from project config
- project config overrides global config
- relative `llm_filter` paths resolve against the config file directory

### `tests/test_session.py`

Add Session coverage:

- `Session(llm_filter=...)` stores the value
- `_setup()` threads it into `_llm_kwargs`
- `run()` and `ask()` both call the LLM through the filter path

### New file: `tests/test_llm_filter.py`

Core behavior tests:

1. happy path: filter rewrites a user message and provider sees the rewritten text
2. block path: script returns `{"allow": false, "reason": ...}` and provider is never called
3. non-zero exit aborts the call
4. malformed stdout JSON aborts the call
5. timeout aborts the call
6. stderr from the filter is shown to the user
7. canonical local `messages` list is not mutated by filtering
8. command provider path is filtered before transcript rendering
9. summary path passes `call_kind="summary"`
10. secret encryption runs after filtering, not before
11. cache is bypassed when a filter is configured
12. structured message content (for example image/text arrays) survives round-trip serialization unless the script changes it

Testing approach:

- use tiny temp scripts under `tmp_path`
- monkeypatch provider calls to capture the final outbound `messages`
- for command-provider coverage, monkeypatch `_run_command_once()` or `_call_command()` capture points
- use `types.SimpleNamespace` message objects where useful so dict-or-namespace compatibility is exercised

### `tests/test_instructions.py` or integration-style coverage

Add one end-to-end CLI test showing that `--llm-filter` affects the system message + user prompt that reaches `call_llm()`.

## Docs

Update docs to explain:

- what `--llm-filter` does
- the stdin/stdout JSON contract
- the fail-closed behavior
- an example redaction script
- the fact that this filters outbound prompt messages, not model responses

The config template should also include a short commented example.

## Example script

Document a tiny example like:

```python
#!/usr/bin/env python3
import json
import re
import sys

payload = json.load(sys.stdin)
text = json.dumps(payload["messages"])
if "corp.example" in text:
    for msg in payload["messages"]:
        content = msg.get("content")
        if isinstance(content, str):
            msg["content"] = re.sub(r"https://[^\s]*corp\.example[^\s]*", "[internal-url]", content)
json.dump({"messages": payload["messages"]}, sys.stdout)
```

## Not in scope

- chaining multiple filters
- filtering tool schemas themselves
- filtering reviewer subprocess input
- filtering model responses after they come back
- networked filter services or MCP-based filters

A single local command hook is enough for the first version and fits the repository's existing subprocess-based patterns.
