9fade55901
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.
188 lines
6.5 KiB
Python
188 lines
6.5 KiB
Python
"""Tests for ratatoskr.local_agents.
|
|
|
|
Use ``$RATATOSKR_LOCAL_AGENTS`` env-var override + pytest tmp_path to
|
|
isolate from the operator's real ``~/.config/ratatoskr/local_agents.json``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from ratatoskr.local_agents import (
|
|
LocalAgentEntry,
|
|
_local_agents_path,
|
|
add_local_agent,
|
|
load_local_agents,
|
|
make_description,
|
|
remove_local_agent,
|
|
update_local_agent,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
"""Point $RATATOSKR_LOCAL_AGENTS at a fresh tmp file for the test."""
|
|
path = tmp_path / "local_agents.json"
|
|
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(path))
|
|
return path
|
|
|
|
|
|
def _entry(
|
|
agent_id: str = "ratatoskr:wizard",
|
|
agent_name: str = "wizard",
|
|
model: str = "qwen3.6-35-a3b",
|
|
description: str = "(tier 3) test agent",
|
|
defined_at: str = "2026-05-25T00:00:00+00:00",
|
|
) -> LocalAgentEntry:
|
|
return LocalAgentEntry(
|
|
agent_id=agent_id,
|
|
agent_name=agent_name,
|
|
model=model,
|
|
description=description,
|
|
defined_at=defined_at,
|
|
)
|
|
|
|
|
|
class TestPathResolution:
|
|
def test_env_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", "/tmp/custom-agents.json")
|
|
assert _local_agents_path() == Path("/tmp/custom-agents.json")
|
|
|
|
def test_xdg_config_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
|
|
monkeypatch.setenv("XDG_CONFIG_HOME", "/tmp/xdg-config")
|
|
assert (
|
|
_local_agents_path()
|
|
== Path("/tmp/xdg-config/ratatoskr/local_agents.json")
|
|
)
|
|
|
|
def test_default_home(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("RATATOSKR_LOCAL_AGENTS", raising=False)
|
|
monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
|
|
path = _local_agents_path()
|
|
assert path == Path.home() / ".config" / "ratatoskr" / "local_agents.json"
|
|
|
|
|
|
class TestLoadEmpty:
|
|
def test_missing_file_returns_empty(self, local_path: Path) -> None:
|
|
assert not local_path.exists()
|
|
assert load_local_agents() == []
|
|
|
|
def test_corrupt_json_returns_empty(self, local_path: Path) -> None:
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
local_path.write_text("not json at all")
|
|
assert load_local_agents() == []
|
|
|
|
def test_wrong_schema_version_returns_empty(self, local_path: Path) -> None:
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
local_path.write_text(json.dumps({"version": 999, "agents": []}))
|
|
assert load_local_agents() == []
|
|
|
|
def test_missing_version_key_returns_empty(self, local_path: Path) -> None:
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
local_path.write_text(json.dumps({"agents": []}))
|
|
assert load_local_agents() == []
|
|
|
|
def test_malformed_row_skipped(self, local_path: Path) -> None:
|
|
local_path.parent.mkdir(parents=True, exist_ok=True)
|
|
local_path.write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"agents": [
|
|
{"agent_id": "incomplete"}, # missing required fields
|
|
{
|
|
"agent_id": "ratatoskr:good",
|
|
"agent_name": "good",
|
|
"model": "m",
|
|
"description": "d",
|
|
"defined_at": "t",
|
|
},
|
|
],
|
|
}
|
|
)
|
|
)
|
|
entries = load_local_agents()
|
|
assert len(entries) == 1
|
|
assert entries[0].agent_id == "ratatoskr:good"
|
|
|
|
|
|
class TestAdd:
|
|
def test_add_one(self, local_path: Path) -> None:
|
|
add_local_agent(_entry())
|
|
entries = load_local_agents()
|
|
assert len(entries) == 1
|
|
assert entries[0].agent_id == "ratatoskr:wizard"
|
|
|
|
def test_add_two_different(self, local_path: Path) -> None:
|
|
add_local_agent(_entry(agent_id="ratatoskr:a", agent_name="a"))
|
|
add_local_agent(_entry(agent_id="ratatoskr:b", agent_name="b"))
|
|
ids = {e.agent_id for e in load_local_agents()}
|
|
assert ids == {"ratatoskr:a", "ratatoskr:b"}
|
|
|
|
def test_add_replaces_same_id(self, local_path: Path) -> None:
|
|
add_local_agent(_entry(model="old-model"))
|
|
add_local_agent(_entry(model="new-model"))
|
|
entries = load_local_agents()
|
|
assert len(entries) == 1
|
|
assert entries[0].model == "new-model"
|
|
|
|
def test_creates_parent_dirs(
|
|
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
nested = tmp_path / "deep" / "nested" / "path" / "agents.json"
|
|
monkeypatch.setenv("RATATOSKR_LOCAL_AGENTS", str(nested))
|
|
add_local_agent(_entry())
|
|
assert nested.exists()
|
|
|
|
|
|
class TestUpdate:
|
|
def test_update_changes_existing(self, local_path: Path) -> None:
|
|
add_local_agent(_entry(model="v1"))
|
|
update_local_agent(_entry(model="v2"))
|
|
entries = load_local_agents()
|
|
assert len(entries) == 1
|
|
assert entries[0].model == "v2"
|
|
|
|
|
|
class TestRemove:
|
|
def test_remove_existing(self, local_path: Path) -> None:
|
|
add_local_agent(_entry())
|
|
remove_local_agent("ratatoskr:wizard")
|
|
assert load_local_agents() == []
|
|
|
|
def test_remove_missing_is_noop(self, local_path: Path) -> None:
|
|
add_local_agent(_entry())
|
|
remove_local_agent("ratatoskr:doesnotexist")
|
|
assert len(load_local_agents()) == 1
|
|
|
|
|
|
class TestMakeDescription:
|
|
def test_first_nonempty_line(self) -> None:
|
|
prompt = "\n\n# IDENTITY\nYou are a test agent..."
|
|
desc = make_description(prompt)
|
|
assert desc.startswith("(tier 3) IDENTITY")
|
|
|
|
def test_strips_heading_markers(self) -> None:
|
|
prompt = "# A nice heading\nMore prompt..."
|
|
desc = make_description(prompt)
|
|
assert "(tier 3) A nice heading" == desc
|
|
|
|
def test_truncates_long(self) -> None:
|
|
prompt = "x" * 200
|
|
desc = make_description(prompt)
|
|
# 80 char cap including the prefix
|
|
assert len(desc) == 81 # 80 + ellipsis char
|
|
assert desc.endswith("…")
|
|
|
|
def test_empty_prompt_fallback(self) -> None:
|
|
desc = make_description("")
|
|
assert desc == "(tier 3) custom system prompt"
|
|
|
|
def test_whitespace_only_fallback(self) -> None:
|
|
desc = make_description(" \n\n ")
|
|
assert desc == "(tier 3) custom system prompt"
|