feat: server-side ui_meta on profiles.list/configure (#85440)

* feat: server-side ui_meta on profiles.list/configure

Roster UIs built on profiles.* have per-profile presentation state
(avatar, accent color, display title, pet) with nowhere server-side to
live — client plugin storage paints a different roster on every
machine. profiles.configure now accepts ui_meta (merged key-wise into
profile.yaml's ui_meta block via the existing atomic_yaml_write path,
null deletes a key, 64KB cap since it rides every roster paint) and
profiles.list returns the block per row. Consumers namespace under
their own key. No new files or config; profiles without the block are
unchanged.

* test: stop primary-runtime-restore tests probing live endpoints

_make_agent left the compressor's lazy context-length resolution
unmocked; for reachable base_urls (the nous portal test) the endpoint's
32K answer for the empty test model trips agent_init's 64K floor and
fails the suite on network behavior. Pin get_model_context_length in
the fixture.
This commit is contained in:
Teknium 2026-08-13 10:07:39 -07:00 committed by GitHub
parent 8d4b1e4b0e
commit 2ffed55c32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 70 additions and 0 deletions

View File

@ -36,6 +36,15 @@ def _make_agent(fallback_model=None, provider="custom", base_url="https://my-llm
patch("run_agent.get_tool_definitions", return_value=_make_tool_defs("web_search")),
patch("run_agent.check_toolset_requirements", return_value={}),
patch("run_agent.OpenAI"),
# Unit tests must not probe live endpoints. The compressor resolves
# context length lazily via a real network call against base_url; for
# reachable hosts (the nous portal case) the endpoint's answer for the
# empty test model (32K) trips agent_init's 64K floor and fails the
# test on network behavior, not code under test.
patch(
"agent.context_compressor.get_model_context_length",
return_value=200_000,
),
):
agent = AIAgent(
api_key="test-key-12345678",

View File

@ -89,6 +89,23 @@ def _(rid, params: dict) -> dict:
}
if include_sessions:
row["last_session"] = _latest_profile_session_row(p.path)
# Client-agnostic UI metadata (avatars, accent colors, pinned
# order, …) — stored server-side in profile.yaml so every
# machine connecting to this gateway paints the same roster.
try:
import yaml as _yaml
from pathlib import Path as _Path
meta_path = _Path(str(p.path)) / "profile.yaml"
if meta_path.is_file():
with open(meta_path, "r", encoding="utf-8") as f:
raw_meta = _yaml.safe_load(f) or {}
ui_meta = raw_meta.get("ui_meta")
if isinstance(ui_meta, dict) and ui_meta:
row["ui_meta"] = ui_meta
except Exception:
pass
out.append(row)
return _ok(rid, {"profiles": out})
except Exception as e:
@ -382,6 +399,50 @@ def _(rid, params: dict) -> dict:
applied = {}
if isinstance(params.get("ui_meta"), dict):
# Client-agnostic UI metadata (avatar/pet/etc.), merged key-wise
# into profile.yaml's ui_meta block. A key set to None deletes it.
# Size-capped: this rides profiles.list on every roster paint, so
# large blobs (e.g. raw base64 images) are rejected — persist big
# assets elsewhere and store a reference.
try:
import json as _json
incoming = params["ui_meta"]
if len(_json.dumps(incoming)) > 65536:
applied["ui_meta"] = False
else:
import yaml as _yaml
meta_path = profile_dir / "profile.yaml"
existing = {}
if meta_path.is_file():
try:
with open(meta_path, "r", encoding="utf-8") as f:
loaded = _yaml.safe_load(f) or {}
if isinstance(loaded, dict):
existing = loaded
except Exception:
existing = {}
current = existing.get("ui_meta")
if not isinstance(current, dict):
current = {}
for key, value in incoming.items():
if value is None:
current.pop(key, None)
else:
current[key] = value
if current:
existing["ui_meta"] = current
else:
existing.pop("ui_meta", None)
from utils import atomic_yaml_write
atomic_yaml_write(meta_path, existing, sort_keys=False)
applied["ui_meta"] = True
except Exception:
applied["ui_meta"] = False
if isinstance(params.get("soul"), str):
try:
(profile_dir / "SOUL.md").write_text(params["soul"], encoding="utf-8")