feat(skills): Skill steuert Ausgabe selbst — speak + converse, pro Aufruf
Verallgemeinert die Ausgabesteuerung, dynamisch statt hardcoded:
- Zwei Flags: speak (vorlesen?) + converse (danach 30s weiterlauschen?).
Getrennt, weil 'vorlesen' und 'Dialog offen halten' verschiedene Dinge sind
('was laeuft gerade' → vorlesen JA, aber keine 30s).
- Statischer Manifest-Default (speak/converse) PLUS: der Skill kann beide im
JSON-Output PRO AUFRUF setzen und den Default ueberschreiben — so kann EIN
Skill gemischt sein (Spotify: 'next' stumm/stop, 'was laeuft' vorlesen/stop).
- chat() gibt jetzt (reply, answered_by, speak, converse) zurueck; gilt fuer
Fast-Path (converse immer False), local UND Claude (run_*-Skill setzt beide).
- Brain: _last_skill_flags aus Skill-stdout-JSON, _skill_response_flags mergt
Output > Manifest > False. main.py ChatOut.converse, background.py angepasst.
- Bridge: converse aus /chat gelesen + in Chat-Payload + _process_core_response.
- App: converseRef aus der Payload; onPlaybackFinished endet mit skipPassive
wenn converse=false (vorlesen ohne 30s).
- Skill-Bau-Anleitung (skill_create/update-Schema): speak+converse dokumentiert
MIT dem WARUM, damit ARIA sie beim Bauen sinnvoll setzt (nicht nur mechanisch).
- Prompt-Hardcode fuer Spotify-Faehigkeiten raus → Skills beschreiben sich selbst.
Bestehende Skills ohne Flags = false/false = stumm/stop (kein Verhaltenswechsel).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+103
-28
@@ -237,19 +237,38 @@ META_TOOLS = [
|
||||
"speak": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Soll die Antwort dieses Skills VORGELESEN werden (TTS)? "
|
||||
"Default false. \n"
|
||||
"- false = reiner STEUERBEFEHL (Spotify next/pause, Licht, "
|
||||
"Rollade): es gibt nichts vorzulesen, die App beendet direkt "
|
||||
"und lauscht wieder aufs Wake-Word (knackig, wie ein "
|
||||
"Kommando).\n"
|
||||
"- true = ANTWORT-Skill: das Ergebnis ist eine Information, "
|
||||
"die Stefan HOEREN will (z.B. ein Wuerfelergebnis, ein "
|
||||
"Nachschlage-Wert, ein Status). Dann wird die Antwort "
|
||||
"vorgelesen und das Gespraechs-Fenster bleibt offen.\n"
|
||||
"Gilt fuer Fast-Path UND wenn das lokale LLM den Skill "
|
||||
"aufruft. Im Zweifel bei Kommandos false, bei "
|
||||
"Frage-Antwort-Skills true."
|
||||
"STATISCHER Default fuers Vorlesen dieses Skills (TTS). "
|
||||
"Default false.\n\n"
|
||||
"WARUM es das gibt: ARIA ist voice-first. Ein reiner "
|
||||
"STEUERBEFEHL (Spotify next/pause, Licht an) muss NICHT "
|
||||
"vorgelesen werden — die Aktion selbst ist die Bestaetigung, "
|
||||
"Vorlesen waere nervig. Ein ANTWORT-Skill dagegen liefert "
|
||||
"eine Info, die Stefan HOEREN will (Wuerfelergebnis, Status, "
|
||||
"'was laeuft gerade'). Also: Kommando-Skill=false, "
|
||||
"Antwort-Skill=true.\n\n"
|
||||
"PRO AUFRUF (wichtig fuer gemischte Skills!): Wenn EIN Skill "
|
||||
"beides kann (z.B. Spotify: 'next' = stumm, 'was laeuft' = "
|
||||
"vorlesen), kann dein run.py im JSON-Output pro Aufruf "
|
||||
"`speak` (und `converse`, s.u.) setzen — das ueberschreibt "
|
||||
"diesen statischen Default. Beispiel-Output:\n"
|
||||
" next -> {\"ok\":true,\"speak\":false,\"converse\":false}\n"
|
||||
" was_laeuft -> {\"ok\":true,\"speak\":true,\"converse\":false,"
|
||||
"\"reply\":\"Laeuft: …\"}"
|
||||
),
|
||||
},
|
||||
"converse": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Soll ARIA NACH dieser Skill-Antwort 30s WEITERLAUSCHEN "
|
||||
"(Dialog), oder direkt zurueck aufs Wake-Word? Default false.\n\n"
|
||||
"WARUM getrennt von speak: 'vorlesen' und 'weiterreden "
|
||||
"koennen' sind zwei verschiedene Dinge. 'was laeuft gerade' "
|
||||
"soll vorgelesen werden (speak=true), aber es ist eine "
|
||||
"abgeschlossene Einzel-Info — Stefan will danach NICHT ins "
|
||||
"30s-Mikrofon-Fenster gezwungen werden (converse=false). Nur "
|
||||
"wenn eine echte Rueckfrage/ein Dialog sinnvoll ist "
|
||||
"(converse=true). Am besten pro Aufruf im run.py-Output "
|
||||
"setzen (dynamisch), nicht statisch."
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -331,8 +350,18 @@ META_TOOLS = [
|
||||
"speak": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Vorlesen ja/nein (siehe skill_create). false = "
|
||||
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen."
|
||||
"Statischer Vorlese-Default (siehe skill_create). false = "
|
||||
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen. Pro "
|
||||
"Aufruf via run.py-JSON-Output ueberschreibbar."
|
||||
),
|
||||
},
|
||||
"converse": {
|
||||
"type": "boolean",
|
||||
"description": (
|
||||
"Statischer Default: nach der Antwort 30s weiterlauschen "
|
||||
"(Dialog=true) oder direkt zurueck aufs Wake-Word (false, "
|
||||
"Default). Siehe skill_create. Pro Aufruf via Output "
|
||||
"ueberschreibbar."
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -1265,11 +1294,10 @@ class Agent:
|
||||
break
|
||||
return tools
|
||||
|
||||
def _skill_speak_flag(self, tname: str) -> bool:
|
||||
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False =
|
||||
Steuerbefehl/stumm). Loest run_-Name → Skill fuzzy auf (Bindestriche)."""
|
||||
def _resolve_skill_manifest(self, tname: str) -> Optional[dict]:
|
||||
"""run_-Toolname → Skill-Manifest (fuzzy, Bindestrich-Mapping)."""
|
||||
if not tname.startswith("run_"):
|
||||
return True
|
||||
return None
|
||||
suffix = tname[len("run_"):]
|
||||
m = skills_mod.read_manifest(suffix)
|
||||
if m is None:
|
||||
@@ -1278,7 +1306,28 @@ class Agent:
|
||||
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
|
||||
m = cand
|
||||
break
|
||||
return bool((m or {}).get("speak", False))
|
||||
return m
|
||||
|
||||
def _skill_speak_flag(self, tname: str) -> bool:
|
||||
"""speak-Flag eines run_*-Skills aus dem Manifest (Default False)."""
|
||||
if not tname.startswith("run_"):
|
||||
return True
|
||||
return bool((self._resolve_skill_manifest(tname) or {}).get("speak", False))
|
||||
|
||||
def _skill_response_flags(self, tname: str) -> tuple:
|
||||
"""(speak, converse) fuer einen gerade ausgefuehrten run_*-Skill.
|
||||
Prioritaet: JSON-Output des Skills (self._last_skill_flags, pro Aufruf) >
|
||||
Manifest-Default > False. So kann EIN Skill pro Operation unterschiedlich
|
||||
sein — 'next' stumm/stop, 'was laeuft' vorlesen aber trotzdem stop."""
|
||||
flags = getattr(self, "_last_skill_flags", None) or {}
|
||||
m = self._resolve_skill_manifest(tname) or {}
|
||||
speak = bool(m.get("speak", False))
|
||||
converse = bool(m.get("converse", False))
|
||||
if isinstance(flags.get("speak"), bool):
|
||||
speak = flags["speak"]
|
||||
if isinstance(flags.get("converse"), bool):
|
||||
converse = flags["converse"]
|
||||
return speak, converse
|
||||
|
||||
def _try_local_fast_lane(self, user_message: str,
|
||||
active_project_id: str) -> Optional[str]:
|
||||
@@ -1290,6 +1339,10 @@ class Agent:
|
||||
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
|
||||
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
|
||||
self._local_turn_speak = True
|
||||
# Nach der Antwort 30s weiterlauschen? Default True (Gespraech/Info via
|
||||
# web_search). Ein run_*-Skill setzt es auf seinen Output-Wert (Default
|
||||
# False = Einzelaktion).
|
||||
self._local_turn_converse = True
|
||||
# Hat local in diesem Turn ueberhaupt einen echten Skill (run_*) gerufen?
|
||||
# Fuer den Anti-Halluzinations-Guard: behauptet local eine Spotify-/Skill-
|
||||
# Aktion OHNE das Tool zu rufen → eskalieren statt eine erfundene
|
||||
@@ -1346,7 +1399,8 @@ class Agent:
|
||||
# Turn das speak-Flag des Skills (Steuerbefehl=stumm,
|
||||
# Antwort-Skill=vorlesen). Letzter Skill gewinnt.
|
||||
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
|
||||
self._local_turn_speak = self._skill_speak_flag(tname)
|
||||
self._local_turn_speak, self._local_turn_converse = \
|
||||
self._skill_response_flags(tname)
|
||||
self._local_ran_skill = True
|
||||
if self._local_tool_failed(tname, tresult):
|
||||
had_error = True
|
||||
@@ -1453,7 +1507,8 @@ class Agent:
|
||||
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
|
||||
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
|
||||
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||
return fast_reply, "fast-path", speak
|
||||
# Fast-Path = reiner Steuerbefehl → nie ins 30s-Gespraech (converse=False).
|
||||
return fast_reply, "fast-path", speak, False
|
||||
|
||||
# 1. User-Turn an die Konversation
|
||||
self.conversation.add("user", user_message, source=source,
|
||||
@@ -1467,10 +1522,11 @@ class Agent:
|
||||
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
|
||||
local_reply = self._try_local_fast_lane(user_message, active_project_id)
|
||||
if local_reply is not None:
|
||||
# speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm +
|
||||
# App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech.
|
||||
# speak = vorlesen? converse = danach 30s weiterlauschen? Beides folgt
|
||||
# dem Skill (bzw. Default: Info/Gespraech = vorlesen + 30s).
|
||||
speak = getattr(self, "_local_turn_speak", True)
|
||||
return local_reply, "local", speak
|
||||
converse = getattr(self, "_local_turn_converse", True)
|
||||
return local_reply, "local", speak, converse
|
||||
|
||||
# 2. Hot Memory (alle pinned Punkte)
|
||||
hot = self.store.list_pinned()
|
||||
@@ -1592,6 +1648,7 @@ class Agent:
|
||||
# der Bubble (Stefans Wunsch). Antwort-Skills (speak=true) + reine
|
||||
# Konversation bleiben gesprochen.
|
||||
self._claude_turn_speak = True
|
||||
self._claude_turn_converse = True # Default Gespraech; run_*-Skill setzt es
|
||||
try:
|
||||
for iteration in range(self.MAX_TOOL_ITERATIONS):
|
||||
result = self.proxy.chat_full(messages, tools=tools,
|
||||
@@ -1613,7 +1670,8 @@ class Agent:
|
||||
# ausgefuehrt → Antwort nicht vorlesen (letzter Skill gewinnt).
|
||||
_tn = tc.get("name") or ""
|
||||
if _tn.startswith("run_") and not self._local_tool_failed(_tn, tool_result):
|
||||
self._claude_turn_speak = self._skill_speak_flag(_tn)
|
||||
self._claude_turn_speak, self._claude_turn_converse = \
|
||||
self._skill_response_flags(_tn)
|
||||
# Cap auf 50 KB — passt zur Cap in _dispatch_tool fuer
|
||||
# Skill-Outputs (siehe agent.py weiter unten). 8 KB war
|
||||
# viel zu wenig: Spotify _all=true mit 90 Playlists
|
||||
@@ -1682,8 +1740,10 @@ class Agent:
|
||||
# 7. Assistant-Turn (final reply) in die Conversation
|
||||
self.conversation.add("assistant", final_reply,
|
||||
project_id=active_project_id)
|
||||
# speak folgt dem ausgefuehrten Skill (Steuerbefehl=stumm), sonst True.
|
||||
return final_reply, "claude", bool(getattr(self, "_claude_turn_speak", True))
|
||||
# speak/converse folgen dem ausgefuehrten Skill (sonst Default: Gespraech).
|
||||
return (final_reply, "claude",
|
||||
bool(getattr(self, "_claude_turn_speak", True)),
|
||||
bool(getattr(self, "_claude_turn_converse", True)))
|
||||
|
||||
# ── Tool-Dispatcher ───────────────────────────────────────
|
||||
|
||||
@@ -1705,6 +1765,7 @@ class Agent:
|
||||
config_schema=arguments.get("config_schema") or None,
|
||||
fast_patterns=arguments.get("fast_patterns") or None,
|
||||
speak=bool(arguments.get("speak", False)),
|
||||
converse=bool(arguments.get("converse", False)),
|
||||
author="aria",
|
||||
)
|
||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||
@@ -1766,6 +1827,8 @@ class Agent:
|
||||
patch[k] = arguments[k]
|
||||
if "speak" in arguments and arguments["speak"] is not None:
|
||||
patch["speak"] = bool(arguments["speak"])
|
||||
if "converse" in arguments and arguments["converse"] is not None:
|
||||
patch["converse"] = bool(arguments["converse"])
|
||||
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||
patch["pip_packages"] = arguments["pip_packages"]
|
||||
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||
@@ -1867,6 +1930,18 @@ class Agent:
|
||||
skill_name = cand_name
|
||||
break
|
||||
res = skills_mod.run_skill(skill_name, args=arguments)
|
||||
# Steuer-Flags aus dem Skill-JSON-Output (speak/converse) fuer
|
||||
# DIESEN Aufruf merken. Der Aufrufer (local/claude/fast-path)
|
||||
# liest sie und steuert damit Vorlesen (speak) + 30s-Weiter-
|
||||
# lauschen (converse) — pro Aufruf, dynamisch. Kein JSON / nicht
|
||||
# gesetzt → Aufrufer nimmt Manifest-Default (speak) bzw. False.
|
||||
self._last_skill_flags = None
|
||||
if res.get("ok"):
|
||||
try:
|
||||
_j = json.loads((res.get("stdout") or "").strip())
|
||||
self._last_skill_flags = _j if isinstance(_j, dict) else None
|
||||
except Exception:
|
||||
self._last_skill_flags = None
|
||||
# 2000 Zeichen war viel zu wenig — Spotify-JSON ist 5-15 KB,
|
||||
# da wurde der Track-Name regelmaessig abgeschnitten und ARIA
|
||||
# hat aus dem Album-Kontext halluziniert. Claude kann hunderte
|
||||
|
||||
@@ -150,7 +150,7 @@ async def _fire(trigger: dict, agent_factory) -> None:
|
||||
|
||||
try:
|
||||
agent = agent_factory()
|
||||
reply, _, _ = agent.chat(prompt, source="trigger")
|
||||
reply, _, _, _ = agent.chat(prompt, source="trigger")
|
||||
events = agent.pop_events()
|
||||
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
|
||||
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
|
||||
|
||||
+5
-1
@@ -636,6 +636,9 @@ class ChatOut(BaseModel):
|
||||
# Soll die Antwort vorgelesen werden? Fast-Path (reiner Steuerbefehl) = False;
|
||||
# ARIA-Antworten (local/claude) = True. System-Flag statt <voice>-Tag.
|
||||
speak: bool = True
|
||||
# Soll die App nach der Antwort 30s weiterlauschen (Gespraech)? Einzelaktionen/
|
||||
# Skills = False (direkt zurueck aufs Wake-Word), Konversation = True.
|
||||
converse: bool = True
|
||||
# Echo der project_id die dieser Turn hatte. Bridge nutzt sie damit die
|
||||
# ausgehende Chat-Bubble sauber getaggt in der richtigen Thread-Bahn der
|
||||
# UI landet.
|
||||
@@ -719,7 +722,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
# Sync-Aufruf im Executor damit wir den Event-Loop nicht blocken —
|
||||
# chat() macht HTTP-Calls (Proxy) die 30-60s dauern koennen.
|
||||
loop = asyncio.get_running_loop()
|
||||
reply, answered_by, speak = await loop.run_in_executor(
|
||||
reply, answered_by, speak, converse = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: a.chat(
|
||||
body.message, source=body.source, project_id=pid,
|
||||
@@ -743,6 +746,7 @@ async def chat(body: ChatIn, background: BackgroundTasks):
|
||||
project_id=pid,
|
||||
answered_by=answered_by,
|
||||
speak=speak,
|
||||
converse=converse,
|
||||
)
|
||||
finally:
|
||||
_project_pending[pid] = [
|
||||
|
||||
+10
-3
@@ -166,6 +166,7 @@ def create_skill(
|
||||
config_schema: Optional[list] = None,
|
||||
fast_patterns: Optional[list] = None,
|
||||
speak: bool = False,
|
||||
converse: bool = False,
|
||||
) -> dict:
|
||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||
|
||||
@@ -218,9 +219,12 @@ def create_skill(
|
||||
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
||||
# speak: soll die Antwort dieses Skills vorgelesen werden (TTS)?
|
||||
# False (Default) = reiner Steuerbefehl (Spotify, Licht) → stumm, App
|
||||
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen +
|
||||
# Gespraechs-Fenster. Gilt fuer Fast-Path UND local-Skill-Ausfuehrung.
|
||||
# beendet direkt. True = Antwort-Skill (Info/Ergebnis) → vorlesen.
|
||||
# converse: nach der Antwort 30s weiterlauschen (Dialog)? Default False
|
||||
# (Einzelaktion). Beide sind STATISCHE Defaults — der Skill kann sie im
|
||||
# JSON-Output pro Aufruf ueberschreiben (gemischte Skills).
|
||||
"speak": bool(speak),
|
||||
"converse": bool(converse),
|
||||
"version_history": [],
|
||||
}
|
||||
write_manifest(name, manifest)
|
||||
@@ -341,12 +345,15 @@ def update_skill(name: str, patch: dict) -> dict:
|
||||
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
||||
manifest = read_manifest(name) or manifest
|
||||
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry", "speak"}
|
||||
allowed = {"description", "args", "requires", "active", "version", "entry",
|
||||
"speak", "converse"}
|
||||
for k, v in patch.items():
|
||||
if k in allowed:
|
||||
manifest[k] = v
|
||||
if "speak" in patch:
|
||||
manifest["speak"] = bool(patch["speak"])
|
||||
if "converse" in patch:
|
||||
manifest["converse"] = bool(patch["converse"])
|
||||
if "config_schema" in patch:
|
||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||
if "fast_patterns" in patch:
|
||||
|
||||
Reference in New Issue
Block a user