feat(local_agents): tier-3 index + picker merge (v0.8.0)

Worldtree's GET /agents doesn't return consumer-defined (tier-3)
agents — the public list excludes them by design. Confirmed live in
v0.7.0's smoke. Without server-side knowledge, ratatoskr's picker
couldn't show tier-3 agents the operator had defined; the workflow
was "remember the agent_id, pass --agent ratatoskr:<name>
explicitly." Friction grows with every tier-3 agent.

## Fix: client-side index, merged at picker time

New module `ratatoskr.local_agents` maintains a JSON-backed index at
$XDG_CONFIG_HOME/ratatoskr/local_agents.json (override via
$RATATOSKR_LOCAL_AGENTS). `tier3` CLI define / patch / delete update
the index as side-effects. `tui._resolve_then_run` loads the index
after `list_agents(client)` and appends entries not already in the
remote list (dedup by agent_id; remote wins on conflict).

Library-level `tier3.define_agent` / `patch_agent` / `delete_agent`
stay pure — local persistence lives in the CLI layer (`_run_define`
etc.), not in the library functions. Tests of the library don't
touch the filesystem.

## Public surface

  ratatoskr.local_agents:
    LocalAgentEntry (frozen dataclass)
    load_local_agents() -> list[LocalAgentEntry]
    add_local_agent(entry)
    update_local_agent(entry)  # same semantics as add (agent_id key)
    remove_local_agent(agent_id)
    make_description(system_prompt) -> str  # synthetic picker label

Failure modes are lenient: missing file → empty index; corrupt JSON
or schema mismatch → empty index (no crash). The picker continues
to show foundational agents either way; tier-3 surface degrades to
the pre-v0.8.0 workflow.

## Picker integration

Local entries convert to ratatoskr.sessions.AgentInfo with synthetic
fields:
  name        = agent_name (from LocalAgentEntry)
  description = "(tier 3) <first non-empty line of system prompt>"
  version, capabilities, supported_models, persona_traits, ui_hints
    = None / [] / [] / {} / {}

If Worldtree later starts returning tier-3 in GET /agents, this
module's role narrows to redundant local cache; can be removed
cleanly since the dedup-by-agent-id keeps remote-wins behavior.

## Tests

286/286 GREEN (was 265, +21: 20 local_agents + 1 picker-merge
integration). Ruff clean. Tests isolate the index via
$RATATOSKR_LOCAL_AGENTS pointed at pytest's tmp_path — no pollution
of operator's real ~/.config/ratatoskr/.

## Manual smoke

Sindra-like define against personal Worldtree:
  python -m ratatoskr.tier3 define --name foo --system-prompt "..." --model X
  cat ~/.config/ratatoskr/local_agents.json
  # ratatoskr --new picker now shows ratatoskr:foo alongside mimir et al.

Cross-machine: the file is per-host. Operator can sync via dotfiles
if needed; out of scope for this commit.

Minor bump (v0.7.1 → v0.8.0) — new public module + new picker
behavior (more agents shown). No caller-side breaking changes.
This commit is contained in:
vh
2026-05-24 21:13:30 -07:00
parent 9918c10acf
commit 9fade55901
9 changed files with 512 additions and 11 deletions
+67
View File
@@ -1,5 +1,6 @@
"""Tests for ratatoskr.tui per docs/contracts/issues/4.contract.md."""
from pathlib import Path
from unittest.mock import MagicMock
import httpx
@@ -2121,6 +2122,72 @@ class TestResolveThenRunWithPicker:
assert agents_route.call_count == 0
assert sessions_route.call_count == 1
@respx.mock
def test_picker_merges_local_tier3_agents(
self,
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
"""picker_merges_local_tier3_agents [v0.8.0]: local index entries
appear in the picker's agent list alongside remote agents."""
from ratatoskr.local_agents import LocalAgentEntry, add_local_agent
# Isolate the local index in a tmp file.
monkeypatch.setenv(
"RATATOSKR_LOCAL_AGENTS", str(tmp_path / "local_agents.json")
)
add_local_agent(LocalAgentEntry(
agent_id="ratatoskr:wizard",
agent_name="wizard",
model="qwen3.6-35-a3b",
description="(tier 3) test wizard",
defined_at="2026-05-25T00:00:00+00:00",
))
respx.get("https://w.example/agents").mock(
return_value=httpx.Response(200, json=_AGENTS_RESP)
)
respx.post("https://w.example/sessions").mock(
return_value=httpx.Response(201, json=_CREATE_OK_RESP)
)
from ratatoskr.tui import AgentPickerApp
captured: list = []
async def capture_picker_init(self, *a, **kw):
captured.append(list(self.agents))
return "mimir" # auto-pick something so the rest succeeds
# Patch __init__ to capture the agent list passed to the picker.
orig_init = AgentPickerApp.__init__
def init_spy(self, agents):
captured.append(list(agents))
orig_init(self, agents)
monkeypatch.setattr(AgentPickerApp, "__init__", init_spy)
async def picker_returns_mimir(self):
return "mimir"
monkeypatch.setattr(AgentPickerApp, "run_async", picker_returns_mimir)
async def fake_main(self, *a, **kw):
return 0
monkeypatch.setattr(RatatoskrApp, "run_async", fake_main)
from ratatoskr.tui import run_tui
rc = run_tui(_args_new_no_agent())
assert rc == 0
# The local tier-3 agent should appear in the picker's agents list.
assert captured, "AgentPickerApp.__init__ was never called"
agent_ids = {a.agent_id for a in captured[0]}
assert "ratatoskr:wizard" in agent_ids
# Plus the remote agents.
assert "mimir" in agent_ids
assert "lofn" in agent_ids
@respx.mock
def test_picker_skipped_when_session_mode(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""picker_skipped_when_session_mode: --session s-1 → no list_agents, no create_session."""