feat(skills): Skill entscheidet selbst ob vorgelesen wird (manifest.speak)
Verallgemeinert das "run_* → stumm"-Heuristik: jeder Skill deklariert im Manifest ein speak-Flag. - speak=false (Default) = Steuerbefehl (Spotify, Licht) → kein TTS, App beendet direkt (STOP), wie bisher. - speak=true = Antwort-Skill (Info/Ergebnis) → Antwort wird vorgelesen + Gespraechs-Fenster bleibt offen. Gilt für BEIDE Pfade: Fast-Path (_fast_path_speak aus skill.speak) und lokale Skill-Ausführung (_local_turn_speak = _skill_speak_flag(run_*)). Info-Tools (web_search/memory_search/trigger_timer) bleiben gesprochen. - skills.py: speak in create_skill + update_skill-allowed. - agent.py: skill_create/skill_update Tool-Schema dokumentiert speak (damit ARIA es beim Skill-Bau setzen kann), Dispatch reicht es durch. - App: _isSilent nur noch speak===false (kein answeredBy=fast-path-Fallback mehr, sonst waere ein speak=true-Fast-Path faelschlich stumm). Bestehende Skills ohne Feld = false = stumm (kein Verhaltenswechsel). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1209,8 +1209,11 @@ const ChatScreen: React.FC = () => {
|
|||||||
// grau haengen (Reproduktion Stefan im Auto). Hier dieselbe Logik wie
|
// grau haengen (Reproduktion Stefan im Auto). Hier dieselbe Logik wie
|
||||||
// bei TTS-Ende anstossen: aktiv → Konversations-Fenster (resume),
|
// bei TTS-Ende anstossen: aktiv → Konversations-Fenster (resume),
|
||||||
// sonst → Konversation beenden (re-arm).
|
// sonst → Konversation beenden (re-arm).
|
||||||
const _isSilent = (message.payload as any).speak === false
|
// Stumm = die Bridge sagt speak=false (Steuerbefehl-Skill via Fast-Path
|
||||||
|| ((message.payload as any).answeredBy === 'fast-path');
|
// ODER local). Ein Antwort-Skill (speak=true) faellt NICHT hierunter —
|
||||||
|
// der wird vorgelesen + haelt das Gespraech offen. Kein answeredBy-
|
||||||
|
// Fallback mehr: die Bridge schickt speak zuverlaessig mit.
|
||||||
|
const _isSilent = (message.payload as any).speak === false;
|
||||||
if (_isSilent && wakeWordService.isConversing()) {
|
if (_isSilent && wakeWordService.isConversing()) {
|
||||||
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
// Klarer Steuerbefehl (Liedersteuerung etc.) = KEINE Konversation →
|
||||||
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
|
// STOP: direkt zurueck aufs Wake-Word. Kein Gong, keine Aufnahme,
|
||||||
|
|||||||
+61
-15
@@ -234,6 +234,24 @@ META_TOOLS = [
|
|||||||
"\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]"
|
"\"method\":\"PUT\"},\"reply\":\"Spotify: pausiert ⏸\"}]"
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"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."
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["name", "description", "entry_code"],
|
"required": ["name", "description", "entry_code"],
|
||||||
},
|
},
|
||||||
@@ -310,6 +328,13 @@ META_TOOLS = [
|
|||||||
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
|
"wieder durch Claude). Wenn nicht angegeben: bleibt unberuehrt."
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
"speak": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": (
|
||||||
|
"Vorlesen ja/nein (siehe skill_create). false = "
|
||||||
|
"Steuerbefehl/stumm, true = Antwort-Skill/vorlesen."
|
||||||
|
),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": ["name"],
|
"required": ["name"],
|
||||||
},
|
},
|
||||||
@@ -1154,6 +1179,8 @@ class Agent:
|
|||||||
continue
|
continue
|
||||||
args = pat.get("args") or {}
|
args = pat.get("args") or {}
|
||||||
reply = pat.get("reply") or f"{skill_name}: ok"
|
reply = pat.get("reply") or f"{skill_name}: ok"
|
||||||
|
# Vorlesen? Folgt dem Skill-Manifest (Default False=Steuerbefehl).
|
||||||
|
self._fast_path_speak = bool(skill.get("speak", False))
|
||||||
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
logger.info("[fast-path] match skill=%s pattern=%r msg=%r",
|
||||||
skill_name, rx, user_message[:60])
|
skill_name, rx, user_message[:60])
|
||||||
try:
|
try:
|
||||||
@@ -1221,16 +1248,31 @@ class Agent:
|
|||||||
break
|
break
|
||||||
return tools
|
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)."""
|
||||||
|
if not tname.startswith("run_"):
|
||||||
|
return True
|
||||||
|
suffix = tname[len("run_"):]
|
||||||
|
m = skills_mod.read_manifest(suffix)
|
||||||
|
if m is None:
|
||||||
|
for cand in skills_mod.list_skills(active_only=False):
|
||||||
|
cn = cand.get("name") or ""
|
||||||
|
if re.sub(r"[^a-zA-Z0-9_]", "_", cn) == suffix:
|
||||||
|
m = cand
|
||||||
|
break
|
||||||
|
return bool((m or {}).get("speak", False))
|
||||||
|
|
||||||
def _try_local_fast_lane(self, user_message: str,
|
def _try_local_fast_lane(self, user_message: str,
|
||||||
active_project_id: str) -> Optional[str]:
|
active_project_id: str) -> Optional[str]:
|
||||||
cfg = router_mod.load_config()
|
cfg = router_mod.load_config()
|
||||||
if not router_mod.should_try_local(user_message, cfg):
|
if not router_mod.should_try_local(user_message, cfg):
|
||||||
return None
|
return None
|
||||||
# Merker: hat dieser lokale Turn einen echten Skill (run_*) ausgefuehrt?
|
# Vorlesen ja/nein fuer diesen lokalen Turn. Default True (Info-Antwort).
|
||||||
# Dann ist es ein Steuerbefehl (z.B. Spotify) → NICHT vorlesen (speak=
|
# Fuehrt local einen Skill (run_*) aus, uebernimmt dessen speak-Flag aus
|
||||||
# False), wie beim Fast-Path. Info-Tools (web_search/memory_search/
|
# dem Manifest (Steuerbefehl-Skill=False → stumm, Antwort-Skill=True).
|
||||||
# trigger_timer) zaehlen NICHT — deren Antwort/Bestaetigung wird gesprochen.
|
# Info-Tools (web_search/memory_search/trigger_timer) lassen es bei True.
|
||||||
self._local_turn_executed_skill = False
|
self._local_turn_speak = True
|
||||||
local_only = bool(cfg.get("localOnly"))
|
local_only = bool(cfg.get("localOnly"))
|
||||||
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
|
local_model = cfg.get("localLlmModel") or "qwen3-8b" # B0.5: llama-swap-Key
|
||||||
tools = self._build_local_tools() # B1b: kuratierte Tools
|
tools = self._build_local_tools() # B1b: kuratierte Tools
|
||||||
@@ -1278,10 +1320,11 @@ class Agent:
|
|||||||
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
|
logger.info("[router] lokal Tool-Call: %s(%s)", tname,
|
||||||
", ".join(targs.keys()))
|
", ".join(targs.keys()))
|
||||||
tresult = self._dispatch_tool(tname, targs)
|
tresult = self._dispatch_tool(tname, targs)
|
||||||
# Echter Skill (run_*) ausgefuehrt = Steuerbefehl → nachher
|
# Echter Skill (run_*) erfolgreich? Dann uebernimmt dieser
|
||||||
# nicht vorlesen. Nur bei Erfolg (Fehler → eskaliert eh).
|
# 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):
|
if tname.startswith("run_") and not self._local_tool_failed(tname, tresult):
|
||||||
self._local_turn_executed_skill = True
|
self._local_turn_speak = self._skill_speak_flag(tname)
|
||||||
if self._local_tool_failed(tname, tresult):
|
if self._local_tool_failed(tname, tresult):
|
||||||
had_error = True
|
had_error = True
|
||||||
messages.append({"role": "tool",
|
messages.append({"role": "tool",
|
||||||
@@ -1376,9 +1419,10 @@ class Agent:
|
|||||||
metrics.log_fast_path(fast_reply)
|
metrics.log_fast_path(fast_reply)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
# Fast-Path = reiner Steuerbefehl → NICHT vorlesen (speak=False).
|
# Vorlesen folgt dem Skill-Manifest (speak): Steuerbefehl=stumm,
|
||||||
# System-Flag statt <voice>-Tag: robust, unabhaengig vom Skill-Inhalt.
|
# Antwort-Skill=vorlesen. Default False (reiner Steuerbefehl).
|
||||||
return fast_reply, "fast-path", False
|
speak = bool(getattr(self, "_fast_path_speak", False))
|
||||||
|
return fast_reply, "fast-path", speak
|
||||||
|
|
||||||
# 1. User-Turn an die Konversation
|
# 1. User-Turn an die Konversation
|
||||||
self.conversation.add("user", user_message, source=source,
|
self.conversation.add("user", user_message, source=source,
|
||||||
@@ -1392,10 +1436,9 @@ class Agent:
|
|||||||
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
|
# teure Claude-Aufbau + Tool-Loop wird uebersprungen. Sonst None → Claude.
|
||||||
local_reply = self._try_local_fast_lane(user_message, active_project_id)
|
local_reply = self._try_local_fast_lane(user_message, active_project_id)
|
||||||
if local_reply is not None:
|
if local_reply is not None:
|
||||||
# Hat local einen echten Skill (run_*) ausgefuehrt → Steuerbefehl,
|
# speak folgt dem Skill: Steuerbefehl-Skill (speak=false) → stumm +
|
||||||
# NICHT vorlesen (kein TTS, App beendet direkt statt 30s-Gespraech) —
|
# App beendet direkt; Antwort-Skill/Info-Tool → vorlesen + Gespraech.
|
||||||
# genau wie Fast-Path. Sonst normale gesprochene Antwort.
|
speak = getattr(self, "_local_turn_speak", True)
|
||||||
speak = not getattr(self, "_local_turn_executed_skill", False)
|
|
||||||
return local_reply, "local", speak
|
return local_reply, "local", speak
|
||||||
|
|
||||||
# 2. Hot Memory (alle pinned Punkte)
|
# 2. Hot Memory (alle pinned Punkte)
|
||||||
@@ -1618,6 +1661,7 @@ class Agent:
|
|||||||
pip_packages=arguments.get("pip_packages", []),
|
pip_packages=arguments.get("pip_packages", []),
|
||||||
config_schema=arguments.get("config_schema") or None,
|
config_schema=arguments.get("config_schema") or None,
|
||||||
fast_patterns=arguments.get("fast_patterns") or None,
|
fast_patterns=arguments.get("fast_patterns") or None,
|
||||||
|
speak=bool(arguments.get("speak", False)),
|
||||||
author="aria",
|
author="aria",
|
||||||
)
|
)
|
||||||
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
# Side-Channel-Event: Stefan soll sehen wenn ARIA was anlegt
|
||||||
@@ -1677,6 +1721,8 @@ class Agent:
|
|||||||
for k in ("entry_code", "readme", "description", "args", "active"):
|
for k in ("entry_code", "readme", "description", "args", "active"):
|
||||||
if k in arguments and arguments[k] is not None:
|
if k in arguments and arguments[k] is not None:
|
||||||
patch[k] = arguments[k]
|
patch[k] = arguments[k]
|
||||||
|
if "speak" in arguments and arguments["speak"] is not None:
|
||||||
|
patch["speak"] = bool(arguments["speak"])
|
||||||
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
if "pip_packages" in arguments and isinstance(arguments["pip_packages"], list):
|
||||||
patch["pip_packages"] = arguments["pip_packages"]
|
patch["pip_packages"] = arguments["pip_packages"]
|
||||||
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
if "config_schema" in arguments and isinstance(arguments["config_schema"], list):
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ def create_skill(
|
|||||||
author: str = "aria",
|
author: str = "aria",
|
||||||
config_schema: Optional[list] = None,
|
config_schema: Optional[list] = None,
|
||||||
fast_patterns: Optional[list] = None,
|
fast_patterns: Optional[list] = None,
|
||||||
|
speak: bool = False,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
"""Legt einen neuen Skill an. Wirft ValueError bei ungueltigen Inputs.
|
||||||
|
|
||||||
@@ -215,6 +216,11 @@ def create_skill(
|
|||||||
"author": author,
|
"author": author,
|
||||||
"config_schema": _normalize_config_schema(config_schema),
|
"config_schema": _normalize_config_schema(config_schema),
|
||||||
"fast_patterns": _normalize_fast_patterns(fast_patterns),
|
"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.
|
||||||
|
"speak": bool(speak),
|
||||||
"version_history": [],
|
"version_history": [],
|
||||||
}
|
}
|
||||||
write_manifest(name, manifest)
|
write_manifest(name, manifest)
|
||||||
@@ -335,10 +341,12 @@ def update_skill(name: str, patch: dict) -> dict:
|
|||||||
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
# nach archive_current_version manifest neu laden (version_history geupdatet)
|
||||||
manifest = read_manifest(name) or manifest
|
manifest = read_manifest(name) or manifest
|
||||||
|
|
||||||
allowed = {"description", "args", "requires", "active", "version", "entry"}
|
allowed = {"description", "args", "requires", "active", "version", "entry", "speak"}
|
||||||
for k, v in patch.items():
|
for k, v in patch.items():
|
||||||
if k in allowed:
|
if k in allowed:
|
||||||
manifest[k] = v
|
manifest[k] = v
|
||||||
|
if "speak" in patch:
|
||||||
|
manifest["speak"] = bool(patch["speak"])
|
||||||
if "config_schema" in patch:
|
if "config_schema" in patch:
|
||||||
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
manifest["config_schema"] = _normalize_config_schema(patch["config_schema"])
|
||||||
if "fast_patterns" in patch:
|
if "fast_patterns" in patch:
|
||||||
|
|||||||
Reference in New Issue
Block a user