feat(brain): Listen-API-Pagination strukturell loesen + seed-rule
Stefan-Reproduktion vom 31.05.2026: bei 'Such Playlist Prodigy raus'
hat ARIA die Spotify-Pagination drei Mal hintereinander laufen lassen,
jedes Mal eine andere Playlist-ID gefunden, am Ende falsche abgespielt.
Spotify sortiert /v1/me/playlists nach recently-played — die Reihen-
folge aendert sich zwischen Calls wenn parallel was laeuft, also
liefern aufeinanderfolgende paginierte Runs inkonsistente Snapshots.
Loesungen:
1. **spotify-Skill _all=true** (via skill_update angewendet, lebt nur
in /data/skills/spotify/ im Container, nicht in git): Skill prueft
_all=true im URL-Query, paginiert dann intern ueber Spotifys
next-Field bis MAX_PAGES (20) oder fertig. Liefert konsolidiertes
JSON {items, total, fetched_count, fetched_pages}. EIN Tool-Call,
konsistenter Snapshot.
2. **skills.py: Stdout-Truncation entkoppeln**. Vorher: 8000-char-Cap
sowohl fuer Disk-Log als auch fuer Return-Value an Agent.
Konsequenz: _all=true Output (50 KB JSON) wurde fuer ARIA auf 8 KB
gekuerzt, sie sah nur die ersten ~20 Playlists. Jetzt:
- Disk-Log: weiterhin 8 KB pro stdout (Disk-Schoner)
- Return-Value: ungekuerzt, agent.py macht 50 KB downstream-Cap
Skills.py:687 — record-Dict aufgesplittet in log_record + record.
3. **seed_rule list-api-pagination-snapshot**: dokumentiert das
Pattern fuer ARIA — bei Pagination-Resultaten IMMER vollstaendig
laden bevor Entscheidung; _all=true bevorzugen wo verfuegbar;
bei inkonsistenten Match-Resultaten ehrlich nachfragen statt
raten. Mit konkreter Antipattern-Sammlung aus Stefans Test.
Deployment: brain restart noetig damit (2) und (3) greifen. Skill-
Code (1) ist schon via PATCH /skills/spotify aktiv.
This commit is contained in:
@@ -602,6 +602,61 @@ SEED_RULES: List[dict] = [
|
||||
"'API Key' im Auth-Kapitel). Nicht raten."
|
||||
),
|
||||
},
|
||||
{
|
||||
"migration_key": "seed/skill-rule/list-api-pagination-snapshot",
|
||||
"type": "rule",
|
||||
"title": "Listen-API: einmal vollstaendig laden, DANN entscheiden",
|
||||
"category": "verhalten",
|
||||
"content": (
|
||||
"Wenn ein Tool-Resultat ein Pagination-Schema hat (limit/offset/"
|
||||
"next oder total > limit): ALLE Seiten in EINEM Tool-Call holen, "
|
||||
"in EINEM Snapshot durchsuchen, ERST DANN handeln.\n"
|
||||
"\n"
|
||||
"Antipattern (31.05.2026, Stefan reproduziert mit 'Playlist Prodigy "
|
||||
"raussuchen'):\n"
|
||||
" - run_spotify path=/v1/me/playlists?limit=50\n"
|
||||
" → 'nicht dabei'\n"
|
||||
" - run_spotify path=/v1/me/playlists?limit=50&offset=50\n"
|
||||
" → 'gefunden, ID=X' (46 Tracks)\n"
|
||||
" - run_spotify path=/v1/me/player/play body={context_uri: ...:X}\n"
|
||||
" → spielt aber FALSCHE Playlist\n"
|
||||
" - Neue Suche, wieder paginiert → drittes Match ID=Y (15 Tracks)\n"
|
||||
" - Insgesamt drei verschiedene IDs fuer dieselbe gesuchte Playlist\n"
|
||||
" generiert, am Ende die falsche gespielt.\n"
|
||||
"\n"
|
||||
"Wurzel: Spotify sortiert /v1/me/playlists nach recently-played. "
|
||||
"Zwischen aufeinanderfolgenden paginierten Calls AENDERT SICH die "
|
||||
"Reihenfolge wenn parallel was abgespielt wird. Teilresultate aus "
|
||||
"verschiedenen Calls vergleichen → inkonsistent.\n"
|
||||
"\n"
|
||||
"Richtig fuer Spotify (seit 31.05.2026 unterstuetzt):\n"
|
||||
" run_spotify path=/v1/me/playlists?limit=50&_all=true\n"
|
||||
" → Skill paginiert intern, liefert {items, total, fetched_count}.\n"
|
||||
" → In items[] suchen, EINE ID waehlen, sofort handeln.\n"
|
||||
" → Match-Logik: bevorzugt exakter Name (case-insensitive). "
|
||||
"Wenn mehrere Substring-Matches: explizit nachfragen statt raten.\n"
|
||||
"\n"
|
||||
"Wann _all=true sinnvoll:\n"
|
||||
" - /v1/me/playlists (alle eigenen Playlists)\n"
|
||||
" - /v1/playlists/{id}/tracks (alle Tracks einer Playlist)\n"
|
||||
" - /v1/me/tracks (Liked Songs)\n"
|
||||
" - /v1/search?type=playlist&q=... (Such-Ergebnisse mit next)\n"
|
||||
" - Andere Endpunkte mit items+next-Schema.\n"
|
||||
"\n"
|
||||
"Wann NICHT _all=true:\n"
|
||||
" - /v1/me/player/currently-playing (kein Listen-Endpunkt)\n"
|
||||
" - /v1/me/player/devices (kurze Liste, kein next)\n"
|
||||
" - Wenn Du explizit nur 'die ersten 10' willst.\n"
|
||||
"\n"
|
||||
"Fuer andere Skills (yt-dlp, andere APIs) die noch kein _all "
|
||||
"unterstuetzen: manuell paginieren bis total erreicht, ALLES in "
|
||||
"EINEM mentalen Snapshot mergen, NIEMALS auf Teilresultaten "
|
||||
"Entscheidungen treffen. Wenn zwei Pagination-Runs unterschiedliche "
|
||||
"Matches liefern: ehrlich melden ('zwei verschiedene Playlists "
|
||||
"namens X gefunden — welche meinst Du?') statt sich auf eine "
|
||||
"festzulegen."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
|
||||
+21
-5
@@ -683,8 +683,13 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
||||
timed_out = True
|
||||
duration = time.time() - t0
|
||||
|
||||
# Log schreiben (gekuerzt damit es nicht explodiert)
|
||||
record = {
|
||||
# Log auf der Disk wird gekuerzt (8000 chars) — sonst sammeln sich
|
||||
# logs/*.json mit MBs an grossen Skill-Outputs an. Der Return-Value
|
||||
# an den Caller (Agent) bekommt aber den vollen Output, dort wird
|
||||
# nochmal in agent.py auf 50000 gecappt. Stefan-Fall: spotify-Skill
|
||||
# mit _all=true liefert 50+ KB JSON, das hier wurde vorher auf 8 KB
|
||||
# gekappt → ARIA sah immer nur den Anfang der Liste.
|
||||
log_record = {
|
||||
"ts": _now(),
|
||||
"args": args or {},
|
||||
"exit_code": exit_code,
|
||||
@@ -694,7 +699,7 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
||||
"timed_out": timed_out,
|
||||
}
|
||||
try:
|
||||
log_path.write_text(json.dumps(record, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
log_path.write_text(json.dumps(log_record, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -703,8 +708,19 @@ def run_skill(name: str, args: Optional[dict] = None, timeout_sec: int = 300) ->
|
||||
manifest["use_count"] = int(manifest.get("use_count", 0)) + 1
|
||||
write_manifest(name, manifest)
|
||||
|
||||
record["ok"] = exit_code == 0
|
||||
record["log_path"] = str(log_path)
|
||||
# Return-Value: nicht kuerzen (Agent kuerzt downstream selbst). Nur
|
||||
# die Disk-Log-Variante war beschnitten.
|
||||
record = {
|
||||
"ts": log_record["ts"],
|
||||
"args": log_record["args"],
|
||||
"exit_code": exit_code,
|
||||
"duration_sec": log_record["duration_sec"],
|
||||
"stdout": out_text or "",
|
||||
"stderr": err_text or "",
|
||||
"timed_out": timed_out,
|
||||
"ok": exit_code == 0,
|
||||
"log_path": str(log_path),
|
||||
}
|
||||
return record
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user