# anima Design Notes

This document captures current CLI/runtime design decisions for:

- `anima rm --all` behavior
- monitor TUI UX fixes
- shortest-unique agent ID display
- testability-driven extraction in CLI modules
- model / base URL / API key runtime flow
- tool-output governance contract and seams

## 1) `anima rm --all` batch deletion

### Source of truth

- CLI entrypoint: `src/anima/shell/cli.py::rm`
- Batch implementation: `src/anima/shell/cli_commands/cleanup_cmds.py::_rm_bulk`
- Single-agent implementation: `src/anima/shell/cli_commands/cleanup_cmds.py::rm_impl`

### Behavior

- Bulk mode is enabled by `--all`.
- `AGENT_ID` and `--all` are mutually exclusive.
- If neither `AGENT_ID` nor `--all` is provided, command exits with error.
- Default bulk deletion scope is terminal agents (`completed`, `failed`, `killed`).
- `--force` extends bulk deletion to non-terminal agents by kill-then-delete flow.
- `--yes` is a confirmation bypass for `--all` mode.
- `--json` emits machine-readable summary envelopes.

### Rationale

- Preserve safe default (terminal-only) while supporting explicit destructive bulk cleanup.
- Keep operator intent explicit via confirmation gating unless `--yes` is supplied.

## 2) Monitor TUI UX fixes

### Source of truth

- Monitor command implementation: `src/anima/shell/cli_commands/monitor_serve.py::monitor_impl`

### Fixes reflected in current design

1. **Adaptive columns (no fixed width constraints)**
   - Monitor table columns are declared without fixed width in Rich table setup.
   - Layout adapts to terminal width rather than clipping to static width presets.

2. **Immediate quit on `q` key (no Enter required)**
   - Monitor enters raw TTY mode (`raw_stdin_mode`) and spawns background input reader.
   - Single-byte key read (`os.read`) stops the loop when `q`/`Q` is pressed.
   - Terminal mode is restored in all normal/error/signal paths.

## 3) Agent ID display: shortest unique prefix

### Source of truth

- Query display helper: `src/anima/shell/cli_commands/query.py::_shortest_unique_agent_id_prefixes`
- Monitor display helper: `src/anima/shell/cli_commands/monitor_serve.py::_shortest_unique_agent_id_prefixes`

### Behavior

- Display ID starts at minimum 12 characters.
- Prefix length grows only for colliding IDs until uniqueness is reached.
- Applied to list/tree/monitor visual output paths.

### Rationale

- Avoid ambiguous 8-char truncation collisions while keeping IDs short and scannable.

## 4) Testability improvements in CLI shell modules

### Source of truth

- Extracted pure helpers in monitor module:
  - `count_agent_states`
  - `format_agent_row`
  - `format_summary_row`
- Dedicated helper tests:
  - `tests/test_cli_extracted_helpers.py`
  - `tests/test_monitor_serve_table_layout.py`
  - `tests/test_monitor_serve_talk_coverage.py`

### Design intent

- Keep command orchestration in CLI command functions.
- Move deterministic formatting/counting logic into pure helpers for direct unit coverage.
- Maintain behavior parity while reducing dependence on full interactive flows for verification.

## 6) SSE Streaming Protocol (talk streaming transport)

### Source of truth

- Transport design: `docs/proposals/talk-streaming-transport.md`
- MCP server implementation: `src/anima/shell/mcp_agent.py` (AgentMCPServer class)
- Agent runner producer: `src/anima/shell/agent.py` (AgentRunner._process_interact_message)
- Talk client consumer: `src/anima/shell/cli_commands/talk_cmd.py`

### Data flow

```
User types message in talk client
        └─> talk_cmd: anima.interact(message)
                └─> AgentRunner: _process_interact_message()
                        ├─> OpenAI/Anthropic model stream
                        ├─> Delta computation (new_text - prev_text)
                        └─> AgentMCPServer.enqueue_stream_chunk(turn, seq, delta)
                                └─> asyncio.Queue[StreamEvent] (maxsize=256)
                                        └─> SSE consumer (GET /stream)
                                                └─> EventSourceResponse
                                                        └─> talk_cmd stdout
```

### SSE endpoint lifecycle

1. **Connection probe** (client → agent):
   - Client probes `GET /stream` on first interact turn
   - If 404/405/connection failure: cache `streaming_supported=false`
   - If successful: establish SSE connection, cache `streaming_supported=true`

2. **Stream active** (during interact turn):
   - Server yields `chunk` events as LLM produces tokens
   - Queue bounded at 256 events (backpressure)
   - Producer blocks on full queue (no dropped chunks)
   - Client consumes events asynchronously

3. **Terminal events** (turn completion):
   - Normal completion: `done` with `stop_reason: "complete"`
   - User stop: `done` with `stop_reason: "stopped"`
   - Error: `error` with message/code
   - Exactly one terminal event per stream

4. **Cleanup** (after terminal event):
   - Server clears `_stream_consumer_active` and `_stream_active` flags
   - Queue drained
   - Client closes SSE connection

### Fallback semantics (404 handling)

**Streaming-capable agents (≥0.1.0)**:
- Expose `/stream` endpoint via FastMCP custom route
- Return SSE events progressively during interact turns
- Handle concurrent stream conflict with 409 status

**Streaming-unsupported agents (<0.1.0)**:
- No `/stream` route registered
- Return 404 on GET request to `/stream`
- Client falls back to blocking `anima.interact` response
- One-time probe per session, result cached

### Stop mid-stream behavior

User types `/stop` during streaming:
1. Talk client calls `anima.stop_turn()` (MCP tool)
2. Agent sets `_stop_turn_event` flag
3. Agent detects stop at next node boundary (cooperative cancellation)
4. Agent preserves already-emitted chunks
5. Agent enqueues terminal `done` with `stop_reason: "stopped"`
6. Agent continues running, accepts new messages

**Agent continues running after `/stop`** — only the current turn is canceled.
User can send new messages or `/quit` to detach session.

### Backpressure and queue sizing

**Queue design**:
- Type: `asyncio.Queue[StreamEvent]`
- Maxsize: 256 events
- Producer: `await queue.put(event)` (blocking on full)
- Consumer: SSE generator drains queue

**Why 256**:
- Bounded memory and bounded producer lead
- Large enough for short client stalls without stuttering
- For typical chunk sizes (10-80 chars), buffered text is few KB to tens of KB

**Terminal guarantees**:
- `done`/`error` events never dropped
- Producer retries terminal event enqueue until accepted or stream canceled
- Client disconnect sets `_stream_cancel` flag, producer stops enqueue attempts

### Conflict handling (409)

When second client attempts `/stream` while stream already active:
- Return 409 Conflict with JSON body:
  ```json
  {
    "error": {
      "code": "STREAM_CONFLICT",
      "message": "Stream already active for this agent"
    }
  }
  ```
- Original stream continues uninterrupted
- Second client receives error response

### Thread safety

- All stream state (`_stream_queue`, `_stream_active`, `_stream_consumer_active`,
  `_stream_cancel`) managed by AgentMCPServer (single asyncio event loop)
- Producer (agent runner) and consumer (SSE endpoint) run in same async context
- No thread synchronization needed — asyncio ensures single-threaded execution

## 5) Model / URL / API key flow

### Source of truth

- Persona resolution: `src/anima/shell/cli_utils.py`
- Run/spawn wiring: `src/anima/shell/cli_commands/run_spawn.py`
- Agent creation: `src/anima/shell/agent.py::create_agent`
- Detached runner: `src/anima/shell/agent_runner.py`
- Serve-mode child propagation: `src/anima/shell/mcp_serve.py`

### Effective model resolution

- For file-backed persona:
  1. CLI `--model` (highest)
  2. `PersonaSpec.model`
- For synthesized persona (`--system-prompt` without persona file):
  1. CLI `--model`
  2. `ANIMA_DEFAULT_MODEL`
  3. Error if absent

### LLM base URL flow

- `--llm-base-url` is validated before runner startup.
- Run path passes URL to `AgentRunner(base_url=...)`.
- `create_agent` maps base URL into process env `OPENAI_BASE_URL` before model client creation.
- Spawn/serve detached paths propagate URL into child runner via `--llm-base-url` and inherited env.

### API key flow

- No dedicated CLI flags for provider API keys.
- Runtime expects provider credentials in environment (for example `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`).
- Model client/provider layer consumes keys from process environment.

## 7) Tool-output governance contract

### Source of truth

- Contract proposal: `docs/proposals/tool-output-governance-contract.md`
- Current adapter seam: `src/anima/shell/mcp_toolset_policy.py::PolicyWrappedToolset.call_tool`
- Current continuation seam: `src/anima/shell/agent_interact.py::process_interact_message`
- Current history degradation seam: `src/anima/core/prune.py::prune_tool_outputs`

### Design intent

- Govern tool results in three layers: adapter normalization, current-turn model
  injection, and historical degradation/pruning.
- Keep current-turn behavior distinct from older-history behavior: active turns
  may preserve summary + excerpt, older history degrades to summary/placeholder
  while preserving structure.
- Resolve caps/defaults/overrides in config/resolver seams, not ad hoc inside
  individual tool-call branches.

## 8) Persona Runtime Switchability

### Overview

Agents can switch personas at runtime using `ctl_switch_persona`, which
rebuilds the agent with a new persona while preserving conversation history.
The `/switch` slash command in the talk session provides the user-facing
interface.

### Source of truth

- App control boundary: `src/anima/app/control_service.py::ControlService`
- Runtime loop and message-history ownership: `src/anima/shell/agent.py::AgentRunner.run`, `src/anima/shell/agent.py::AgentRunner._create_runtime_agent`, `src/anima/shell/agent.py::AgentRunner._run_agent_loop`
- Control-channel pattern: `src/anima/shell/mcp_agent.py::_handle_interact`, `src/anima/shell/mcp_agent.py::get_stop_turn_event`, `src/anima/shell/mcp_agent.py::_handle_stop_turn`
- Agent rebuild path: `src/anima/shell/agent_factory.py::create_agent`
- State persistence for live persona metadata: `src/anima/core/schemas/models.py::AgentStateData`

### Control seam

- `ControlService.request_persona_switch(instance_id, new_persona)` is an app-layer control operation alongside `interact`, `resume`, `kill`, and `spawn_subagent`.
- The control service does not mutate the live runner directly. It writes one pending persona-switch request onto the existing control channel, following the same ownership model that already queues interact requests in `mcp_agent.py` and exposes stop-turn as a control event.
- The kernel consumes that pending switch at the top of each turn, before it starts the next `agent.iter(...)` continuation. This keeps the swap aligned with the existing turn boundary in `AgentRunner._run_agent_loop`, where pending messages are polled and `message_history` is passed into the next continuation.

### Rebuild behavior

- Hot-swap reuses the existing runtime-agent construction path rather than patching the live PydanticAI agent in place.
- Agent creation flows through `AgentRunner.run` -> `AgentRunner._create_runtime_agent(...)` -> `create_agent(...)` in `src/anima/shell/agent_factory.py`.
- The hot-swap seam is modeled as `AgentFactory.create(new_persona)` conceptually, even though the current implementation entrypoint is the module function `create_agent(...)`.
- Rebuild scope:
  - preserve `message_history` so the conversation continues with the same transcript
  - rebuild the runtime `agent` object
  - replace persona-derived runtime fields (`self.persona` and any derived model/system-prompt/tool-policy configuration that `create_agent(...)` resolves from the new persona)

### Runtime persona fields

`AgentStateData` tracks the current persona via these live fields:

| Field | Description | Updated By |
|-------|-------------|------------|
| `persona_name` | Display name of current persona | `ctl_switch_persona` |
| `persona_ref` | Persona reference (`<id>@<digest>`) | `ctl_switch_persona` |
| `spec_id` | Persona spec ID | `ctl_switch_persona` |
| `spec_digest` | Content-addressable hash | `ctl_switch_persona` |


### Preservation boundary

- Preserved across swap: accumulated `message_history`, turn counters, token accounting, MCP server/control-channel state, and other per-session runtime state that belongs to the conversation rather than the persona definition.
- Rebuilt across swap: the configured agent instance plus persona-derived fields, because those are currently established only during `_create_runtime_agent(...)` / `create_agent(...)`.
- This split matches the current loop structure: `message_history` is owned by `_run_agent_loop` as the continuation transcript, while the executable agent object is created once up front and then reused for each turn.

### Switch behavior

**What changes:**
- The PydanticAI `Agent` object is rebuilt via the existing `create_agent(...)` path, conceptually matching `AgentFactory.create(new_persona)`.
- Model, system prompt, and tool configuration are re-resolved from the new persona.
- Persona-derived runtime fields in `AgentStateData` are updated to match the active persona.

**What is preserved:**
- `message_history` (conversation transcript)
- Turn counters and token accounting
- MCP server connections and control channel state
- Session/transport state (streaming, etc.)

**Turn boundary semantics:**
- Switch is cooperative, not immediate.
- Applied at the next turn boundary before `agent.iter(...)`.
- Ongoing tool calls in the current turn complete normally.
- Only one pending switch is allowed at a time.

### Single-pending constraint

- Only one pending persona switch may exist at a time.
- Rationale: persona swap is a state replacement, not an append-only user message. Allowing multiple queued swaps would introduce ambiguous ordering relative to pending interact messages, compaction, and turn accounting in `_run_agent_loop`.
- The control channel therefore behaves like an overwrite-or-reject single slot for persona-switch intent, not an unbounded FIFO. The kernel either applies the pending switch at the next turn boundary or reports that another switch is already pending.

### Usage

```python
# Agent calls this to switch persona mid-session
result = await ctl_switch_persona("personas/new-persona.json")
# Returns: "Persona switched to: new-persona. Will take effect at next turn."
```

The user-facing interface is the `/switch` slash command in the talk session:
```
/switch personas/new-persona.json
```
