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:
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user