From b6839619872e71e41a5e961d86974f501bee8a5e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:14:49 +0200 Subject: [PATCH 01/30] feat(facade): add ToolResult/ToolDescriptor/Subscription/CommandCategory types --- tests/test_facade_types.py | 39 ++++++++++++++++++++++++++ wingmen/facade.py | 56 +++++++++++++++++++++++++++++++++++++- 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 tests/test_facade_types.py diff --git a/tests/test_facade_types.py b/tests/test_facade_types.py new file mode 100644 index 00000000..f1688b58 --- /dev/null +++ b/tests/test_facade_types.py @@ -0,0 +1,39 @@ +"""Standalone verification for facade value types. Run: + PYTHONPATH=. venv/bin/python -m tests.test_facade_types +""" +from wingmen.facade import ToolResult, ToolDescriptor, Subscription, CommandCategory + + +def test_tool_result(): + r = ToolResult(response="hi", instant_response="", skill="Timer", label="set_timer") + assert r.response == "hi" and r.skill == "Timer" + print("PASS: ToolResult") + + +def test_tool_descriptor(): + d = ToolDescriptor(name="set_timer", source="Timer", description="d", parameters={"type": "object"}) + assert d.name == "set_timer" and d.parameters["type"] == "object" + print("PASS: ToolDescriptor") + + +def test_subscription(): + calls = [] + sub = Subscription(lambda: calls.append(1)) + sub.unsubscribe() + sub.unsubscribe() # idempotent + assert calls == [1], calls + print("PASS: Subscription") + + +def test_command_category(): + cat = CommandCategory(id="abc", name="Timers") + assert cat.id == "abc" and cat.name == "Timers" + print("PASS: CommandCategory") + + +if __name__ == "__main__": + test_tool_result() + test_tool_descriptor() + test_subscription() + test_command_category() + print("ALL OK") diff --git a/wingmen/facade.py b/wingmen/facade.py index 5dcf07e4..db9666dc 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -7,8 +7,9 @@ (e.g. ``ctx.tts.set_voice(...)``) instead of mutating config by reference. """ +from dataclasses import dataclass from types import MappingProxyType -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, Callable, Optional from pydantic import BaseModel @@ -24,6 +25,59 @@ class FacadeError(Exception): """ +@dataclass +class ToolResult: + """Result of ctx.tools.invoke(). `response` is fed to the AI; `instant_response` + is spoken verbatim if present; `skill`/`label` identify what ran.""" + response: str + instant_response: str = "" + skill: Optional[str] = None + label: Optional[str] = None + + +@dataclass +class ToolDescriptor: + """Describes one callable function available to the wingman (skill tool, MCP tool, + or command). `parameters` is the JSON-schema object for its arguments.""" + name: str + source: Optional[str] + description: Optional[str] + parameters: dict + + +class Subscription: + """Handle returned by ctx.audio.on_playback_*; call unsubscribe() to detach.""" + + __slots__ = ("_off", "_done") + + def __init__(self, off: Callable[[], None]) -> None: + self._off = off + self._done = False + + def unsubscribe(self) -> None: + """Detach the callback. Safe to call more than once.""" + if not self._done: + self._done = True + self._off() + + +class CommandCategory: + """A command category (group) the user sees. Wraps a CommandCategoryConfig.""" + + __slots__ = ("id", "name", "_commands") + + def __init__(self, id: str, name: str, commands: Optional[list] = None) -> None: + self.id = id + self.name = name + self._commands = commands if commands is not None else [] + + def add(self, command) -> None: + """Put a command in this category (sets its category_id).""" + command.category_id = self.id + if command not in self._commands: + self._commands.append(command) + + class ReadOnlyConfigView: """A recursive, read-only proxy over a pydantic model (e.g. ``WingmanConfig``). From b67609c85fd4b757ff761e8e9215f4a06b4f336c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:15:29 +0200 Subject: [PATCH 02/30] feat(facade): ctx.ai gains converse + summarize + generate_image --- tests/test_skill_ai.py | 49 ++++++++++++++++++++++++++++++++++++++++++ wingmen/facade.py | 23 ++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/tests/test_skill_ai.py b/tests/test_skill_ai.py index 19201f45..9de441c1 100644 --- a/tests/test_skill_ai.py +++ b/tests/test_skill_ai.py @@ -93,8 +93,57 @@ async def main(): assert w.last_messages[0] == {"role": "system", "content": "be terse"} assert w.last_messages[1]["content"] == "hello" + await test_ai_has_converse_summarize_image() + print("ALL OK") +class FakeConversation: + def __init__(self): + self.messages = [] + self.assistant = [] + + async def add_assistant_message(self, content): + self.assistant.append(content) + + +class FakeConverseWingman(FakeWingman): + """Extends FakeWingman with the bits converse()/generate_image() need.""" + + def __init__(self, provider, condense=True, skill_cap=16000): + super().__init__(provider, condense=condense, skill_cap=skill_cap) + self.conversation = FakeConversation() + self.added_user = [] + + async def add_user_message(self, content): + self.added_user.append(content) + self.conversation.messages.append({"role": "user", "content": content}) + + async def generate_image(self, t): + return f"IMG:{t}" + + +async def test_ai_has_converse_summarize_image(): + w = FakeConverseWingman(ConversationProvider.OPENAI) + ai = SkillAi(w) + assert hasattr(ai, "converse") and hasattr(ai, "summarize") and hasattr(ai, "generate_image") + + # generate_image delegates to wingman.generate_image + out = await ai.generate_image("a cat") + assert out == "IMG:a cat", out + + # converse appends user + assistant turns and returns the model text + reply = await ai.converse("hi there") + assert reply == "RESULT", reply + assert w.added_user == ["hi there"] + assert w.conversation.assistant == ["RESULT"] + + # summarize routes through generate() (capped side-call) and returns text + summary = await ai.summarize("some long text") + assert summary == "RESULT", summary + + print("PASS: ai converse/summarize/generate_image present + delegate") + + if __name__ == "__main__": asyncio.run(main()) diff --git a/wingmen/facade.py b/wingmen/facade.py index db9666dc..16012e5c 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -349,6 +349,29 @@ async def generate( return completion.choices[0].message.content or "" return "" + async def converse(self, user_message: str) -> str: + """Conversation-aware reply: uses the wingman's own system prompt + live history + and is subject to the normal auto-condensation. Use generate() for off-topic + side work that should NOT join the conversation.""" + await self._wingman.add_user_message(user_message) + messages = list(self._wingman.conversation.messages) + completion = await self._wingman.actual_llm_call(messages) + text = "" + if completion and completion.choices: + text = completion.choices[0].message.content or "" + if text: + await self._wingman.conversation.add_assistant_message(text) + return text + + async def summarize(self, text: str, *, system: str | None = None) -> str: + """Summarize text via the main CLOUD model (capped like generate). For bulk/cheap + summarization prefer ctx.local_ai.summarize() (free, local).""" + return await self.generate(text, system=system or "Summarize the following concisely.") + + async def generate_image(self, prompt: str) -> str: + """Generate an image from a prompt; returns the generated file path/URL.""" + return await self._wingman.generate_image(prompt) + class SkillRegistryView: """Sanctioned read + invoke over the wingman's tools/commands. From 9abfd76401c01005f0542451a05dabaaeed34101 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:16:12 +0200 Subject: [PATCH 03/30] feat(facade): SkillLocalAiView (support->generate) + SkillMemory --- tests/test_local_ai_view.py | 51 +++++++++++++++++++++++++ wingmen/facade.py | 75 +++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/test_local_ai_view.py diff --git a/tests/test_local_ai_view.py b/tests/test_local_ai_view.py new file mode 100644 index 00000000..23d48cd6 --- /dev/null +++ b/tests/test_local_ai_view.py @@ -0,0 +1,51 @@ +"""Run: PYTHONPATH=. venv/bin/python -m tests.test_local_ai_view""" +import asyncio +from wingmen.facade import SkillLocalAiView, SkillMemory + + +class _Resp: + def __init__(self, text): self.text = text + + +class _FakeLocalAI: + available = True + async def support(self, text, **kw): return _Resp(f"gen:{text}") + def support_sync(self, text, **kw): return _Resp(f"gens:{text}") + async def summarize(self, text, **kw): return _Resp(f"sum:{text}") + def summarize_sync(self, text, **kw): return _Resp(f"sums:{text}") + async def embed(self, texts): return [[0.0]] + def embed_sync(self, texts): return [[0.0]] + async def remember_fact(self, content, **kw): return 1 + async def recall_memory(self, query, **kw): return ["x"] + + +class _FakeWingman: + def __init__(self): self._la = _FakeLocalAI() + local_ai_service = True + @property + def _local_ai_facade(self): return self._la + + +def test_localai_generate(): + la = SkillLocalAiView(_FakeLocalAI()) + out = asyncio.get_event_loop().run_until_complete(la.generate("hi")) + assert out == "gen:hi", out + assert la.generate_sync("hi") == "gens:hi" + assert asyncio.get_event_loop().run_until_complete(la.summarize("t")) == "sum:t" + assert la.available is True + print("PASS: local_ai view generate/summarize") + + +def test_memory(): + mem = SkillMemory(_FakeLocalAI()) + out = asyncio.get_event_loop().run_until_complete(mem.remember("fact")) + assert out == 1 + out2 = asyncio.get_event_loop().run_until_complete(mem.recall("q")) + assert out2 == ["x"] + print("PASS: memory remember/recall") + + +if __name__ == "__main__": + test_localai_generate() + test_memory() + print("ALL OK") diff --git a/wingmen/facade.py b/wingmen/facade.py index 16012e5c..6c23d8f2 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -373,6 +373,81 @@ async def generate_image(self, prompt: str) -> str: return await self._wingman.generate_image(prompt) +def _text_or_empty(resp) -> str: + return resp.text if resp is not None and getattr(resp, "text", None) else "" + + +class SkillLocalAiView: + """Free, local model. generate()/summarize() return plain strings ("" if the local + model is unavailable — check `available`). Tune with a SamplingPreset or temperature/top_p.""" + + def __init__(self, local_ai) -> None: + self._la = local_ai + + @property + def available(self) -> bool: + return bool(self._la.available) + + async def generate(self, text: str, *, system: str = "", preset=None, + temperature=None, top_p=None, top_k=None) -> str: + resp = await self._la.support(text, system_prompt=system, preset=preset, + temperature=temperature, top_p=top_p, top_k=top_k) + return _text_or_empty(resp) + + def generate_sync(self, text: str, *, system: str = "", preset=None, + temperature=None, top_p=None, top_k=None) -> str: + resp = self._la.support_sync(text, system_prompt=system, preset=preset, + temperature=temperature, top_p=top_p, top_k=top_k) + return _text_or_empty(resp) + + async def summarize(self, text: str, *, instruction: str = "", preset=None, + temperature=None, top_p=None) -> str: + resp = await self._la.summarize(text, instruction=instruction, preset=preset, + temperature=temperature, top_p=top_p) + return _text_or_empty(resp) + + def summarize_sync(self, text: str, *, instruction: str = "", preset=None, + temperature=None, top_p=None) -> str: + resp = self._la.summarize_sync(text, instruction=instruction, preset=preset, + temperature=temperature, top_p=top_p) + return _text_or_empty(resp) + + async def embed(self, texts: list[str]): + return await self._la.embed(texts) + + def embed_sync(self, texts: list[str]): + return self._la.embed_sync(texts) + + +class SkillMemory: + """Local persistent memory (free). Returns None/empty when unavailable (check `available`).""" + + def __init__(self, local_ai) -> None: + self._la = local_ai + + @property + def available(self) -> bool: + return bool(getattr(self._la, "memory_available", False)) + + async def remember(self, content: str, **kw): + return await self._la.remember_fact(content, **kw) + + async def recall(self, query: str, **kw): + return await self._la.recall_memory(query, **kw) + + async def context(self, query: str, max_tokens: int = 500) -> str: + return await self._la.memory_context(query, max_tokens=max_tokens) + + async def update(self, entry_id: int, new_content: str) -> bool: + return await self._la.update_memory(entry_id, new_content) + + async def forget(self, query: str) -> bool: + return await self._la.memory_forget(query) + + async def forget_by_id(self, entry_id: int) -> bool: + return await self._la.forget_memory_by_id(entry_id) + + class SkillRegistryView: """Sanctioned read + invoke over the wingman's tools/commands. From 6cdcade180cf3e78419730e42f0d29755cfe0874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:17:00 +0200 Subject: [PATCH 04/30] feat(facade): ctx.tts gains voice/voices()/speak() (interrupt-positive) --- tests/test_skill_tts.py | 37 ++++++++++++++++++++ wingmen/facade.py | 77 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/tests/test_skill_tts.py b/tests/test_skill_tts.py index 30f37607..631655e1 100644 --- a/tests/test_skill_tts.py +++ b/tests/test_skill_tts.py @@ -95,8 +95,45 @@ def main(): assert cfg.inworld.voice_id == "iw" and cfg.inworld.output_streaming is False assert label == "Wingman Pro / Inworld" + test_tts_speak_and_voice() + print("ALL OK") +def test_tts_speak_and_voice(): + """SkillTts.speak() flips interrupt->no_interrupt and delegates to play_to_user; + voice/voices are present. Build a tiny fake wingman inline (this file has no helper).""" + import asyncio + from wingmen.facade import SkillTts + + # voice reads config.features.tts_provider + per-provider fields; use OpenAI here. + config = SimpleNamespace( + features=SimpleNamespace(tts_provider=TtsProvider.OPENAI), + openai=SimpleNamespace(tts_voice="nova"), + ) + + class _FakeWingman: + pass + + w = _FakeWingman() + w.config = config + + spoken = {} + + async def _p2u(text, no_interrupt=False, sound_config=None): + spoken["text"], spoken["no_interrupt"] = text, no_interrupt + + w.play_to_user = _p2u + tts = SkillTts(w) + + asyncio.get_event_loop().run_until_complete(tts.speak("hello", interrupt=False)) + assert spoken["text"] == "hello" and spoken["no_interrupt"] is True, spoken + + # voice reads the configured OpenAI voice; voices() is a coroutine method. + assert tts.voice == "nova", tts.voice + assert hasattr(tts, "voices") and hasattr(type(tts), "voice") + print("PASS: tts speak (interrupt->no_interrupt) + voice/voices present") + + if __name__ == "__main__": main() diff --git a/wingmen/facade.py b/wingmen/facade.py index 6c23d8f2..804cf894 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -594,6 +594,83 @@ class SkillTts: def __init__(self, wingman: "Wingman") -> None: self._wingman = wingman + @property + def voice(self): + """The voice configured on the current TTS provider (read).""" + from api.enums import TtsProvider + + config = self._wingman.config + provider = config.features.tts_provider + mapping = { + TtsProvider.OPENAI: lambda: config.openai.tts_voice, + TtsProvider.ELEVENLABS: lambda: config.elevenlabs.voice, + TtsProvider.AZURE: lambda: config.azure.tts.voice, + TtsProvider.EDGE_TTS: lambda: config.edge_tts.voice, + TtsProvider.XVASYNTH: lambda: config.xvasynth.voice, + TtsProvider.HUME: lambda: config.hume.voice, + TtsProvider.INWORLD: lambda: config.inworld.voice_id, + TtsProvider.POCKET_TTS: lambda: config.pocket_tts.voice, + TtsProvider.OPENAI_COMPATIBLE: lambda: config.openai_compatible_tts.voice, + } + getter = mapping.get(provider) + return getter() if getter else None + + async def voices(self) -> list: + """ALL voices available on the current provider (not just the user-picked ones). + + Best-effort: providers that need a secret/network round-trip or that aren't + cheaply enumerable here return ``[]`` rather than raising. Providers whose live + TTS instance exposes a cached/static voice list are read from it. Full + per-provider enumeration lives in the VoiceService HTTP API; skills that need the + exhaustive list should call that. (Correctness is smoke-checked at boot.) + """ + from api.enums import TtsProvider + + config = self._wingman.config + provider = config.features.tts_provider + + # Prefer the live TTS instance if it advertises a voice list (e.g. static providers + # like Edge / Pocket cache their catalogue). + tts = getattr(self._wingman, "tts", None) + for attr in ("available_voices", "voices", "get_available_voices"): + candidate = getattr(tts, attr, None) if tts is not None else None + if candidate is None: + continue + try: + if callable(candidate): + result = candidate() + if hasattr(result, "__await__"): + result = await result + else: + result = candidate + if result: + return list(result) + except Exception: + pass + + # Pocket TTS can enumerate its local voices without a secret/network call. + if provider == TtsProvider.POCKET_TTS: + pocket = getattr(self._wingman, "pocket_tts", None) or getattr(tts, "pocket_tts", None) + getter = getattr(pocket, "get_available_voices", None) + if getter is not None: + try: + result = getter() + if hasattr(result, "__await__"): + result = await result + return list(result or []) + except Exception: + return [] + + # Everything else (OpenAI, ElevenLabs, Azure, Hume, Inworld, OpenAI-compatible, + # XVASynth) needs a secret and/or network call we don't make here. + return [] + + async def speak(self, text: str, *, interrupt: bool = True, sound_config=None) -> None: + """Say text in the wingman's voice. interrupt=True (default) speaks immediately, + cutting off current playback; interrupt=False waits for it to finish.""" + await self._wingman.play_to_user(text, no_interrupt=(not interrupt), + sound_config=sound_config) + async def set_voice(self, voice: Any, errors: list | None = None) -> str: """Set the voice on the wingman's current TTS provider and rebuild the TTS instance so it takes effect immediately. From fc4707280df05c636ffaf0da2536a75f54b6cffd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:17:38 +0200 Subject: [PATCH 05/30] feat(facade): ctx.audio param rename (volume/fade_out) + Subscription events --- tests/test_skill_audio.py | 52 +++++++++++++++++++++++++++++++++++++++ wingmen/facade.py | 32 +++++++++++------------- 2 files changed, 67 insertions(+), 17 deletions(-) create mode 100644 tests/test_skill_audio.py diff --git a/tests/test_skill_audio.py b/tests/test_skill_audio.py new file mode 100644 index 00000000..b12d0401 --- /dev/null +++ b/tests/test_skill_audio.py @@ -0,0 +1,52 @@ +"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_audio""" +import asyncio +from wingmen.facade import SkillAudio, Subscription + + +class _Events: + def __init__(self): self.subs = {"started": [], "finished": []} + def subscribe(self, ev, cb): self.subs[ev].append(cb) + def unsubscribe(self, ev, cb): + if cb in self.subs[ev]: self.subs[ev].remove(cb) + + +class _Player: + def __init__(self): self.is_playing = False; self.playback_events = _Events() + + +class _Lib: + def __init__(self): self.played = None; self.stopped = None + async def start_playback(self, cfg, vol): self.played = (cfg, vol) + async def stop_playback(self, cfg, fade): self.stopped = (cfg, fade) + + +class _W: + def __init__(self): self.audio_player = _Player(); self.audio_library = _Lib() + settings = type("S", (), {"audio": None})() + settings_service = None + + +def test_play_stop_param_names(): + w = _W(); a = SkillAudio(w) + asyncio.get_event_loop().run_until_complete(a.play("cfg", volume=0.5)) + asyncio.get_event_loop().run_until_complete(a.stop("cfg", fade_out=1.0)) + assert w.audio_library.played == ("cfg", 0.5) + assert w.audio_library.stopped == ("cfg", 1.0) + print("PASS: play/stop volume + fade_out") + + +def test_subscription(): + w = _W(); a = SkillAudio(w) + cb = lambda name: None + sub = a.on_playback_started(cb) + assert isinstance(sub, Subscription) and cb in w.audio_player.playback_events.subs["started"] + sub.unsubscribe() + assert cb not in w.audio_player.playback_events.subs["started"] + assert not hasattr(a, "off_playback_started") + print("PASS: on_* returns Subscription, off_* removed") + + +if __name__ == "__main__": + test_play_stop_param_names() + test_subscription() + print("ALL OK") diff --git a/wingmen/facade.py b/wingmen/facade.py index 804cf894..f74e337e 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -524,29 +524,27 @@ def is_playing(self) -> bool: """True while the wingman is currently playing TTS/audio.""" return bool(self._wingman.audio_player.is_playing) - async def play(self, audio_config: Any, volume_modifier: float = 1.0) -> None: - """Start playback of a skill-owned audio file (``AudioFile``/``AudioFileConfig``).""" - await self._wingman.audio_library.start_playback(audio_config, volume_modifier) + async def play(self, audio_config: Any, *, volume: float = 1.0) -> None: + """Start playback of a skill-owned audio file.""" + await self._wingman.audio_library.start_playback(audio_config, volume) - async def stop(self, audio_config: Any, fade_out_time: float = 0.5) -> None: + async def stop(self, audio_config: Any, *, fade_out: float = 0.5) -> None: """Stop playback of a skill-owned audio file (optionally fading out).""" - await self._wingman.audio_library.stop_playback(audio_config, fade_out_time) + await self._wingman.audio_library.stop_playback(audio_config, fade_out) - def on_playback_started(self, callback: Any) -> None: - """Subscribe to playback-started events. Callback receives the wingman name.""" + def on_playback_started(self, callback: Any) -> "Subscription": + """Observe playback start. Returns a Subscription — call .unsubscribe() to detach.""" self._wingman.audio_player.playback_events.subscribe("started", callback) + return Subscription( + lambda: self._wingman.audio_player.playback_events.unsubscribe("started", callback) + ) - def on_playback_finished(self, callback: Any) -> None: - """Subscribe to playback-finished events. Callback receives the wingman name.""" + def on_playback_finished(self, callback: Any) -> "Subscription": + """Observe playback finish. Returns a Subscription — call .unsubscribe() to detach.""" self._wingman.audio_player.playback_events.subscribe("finished", callback) - - def off_playback_started(self, callback: Any) -> None: - """Unsubscribe a previously-registered playback-started callback.""" - self._wingman.audio_player.playback_events.unsubscribe("started", callback) - - def off_playback_finished(self, callback: Any) -> None: - """Unsubscribe a previously-registered playback-finished callback.""" - self._wingman.audio_player.playback_events.unsubscribe("finished", callback) + return Subscription( + lambda: self._wingman.audio_player.playback_events.unsubscribe("finished", callback) + ) # --- output/input device control (in-process; replaces HTTP-to-backend hacks) --- From 36f269f2140997bf0bf7df15b97721863158f447 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:18:51 +0200 Subject: [PATCH 06/30] feat(facade): SkillTools (was SkillRegistryView) + source/describe/all/servers + ToolResult --- tests/test_skill_tools.py | 76 +++++++++++++++++++++++++++ wingmen/facade.py | 107 +++++++++++++++++++++++++++++--------- 2 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 tests/test_skill_tools.py diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py new file mode 100644 index 00000000..e1e6d5fc --- /dev/null +++ b/tests/test_skill_tools.py @@ -0,0 +1,76 @@ +"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_tools""" +import asyncio +from wingmen.facade import SkillTools, ToolResult, ToolDescriptor + + +class _Skill: + name = "Timer" + + +class _Manifest: + display_name = "Weather MCP" + + +class _ManifestObj: + def __init__(self, name, display): + self.name = name; self.display_name = display; self.is_connected = True + + +class _ToolInfo: + def __init__(self, n): self.prefixed_name = n + + +class _Mcp: + _tool_to_server = {"get_weather": "weather"} + _manifests = {"weather": _Manifest()} + def get_connected_servers(self): return [_ManifestObj("weather", "Weather MCP")] + def get_server_tools(self, name): return [_ToolInfo("get_weather")] if name == "weather" else [] + + +class _W: + tool_skills = {"set_timer": _Skill()} + mcp_registry = _Mcp() + def build_tools(self): + return [ + {"function": {"name": "set_timer", "description": "d1", "parameters": {"type": "object"}}}, + {"function": {"name": "get_weather", "description": "d2", "parameters": {"type": "object"}}}, + ] + async def execute_command_by_function_call(self, name, args): + return (f"resp:{name}", "instant", "Timer", "Set Timer") + + +def test_names_has_source(): + t = SkillTools(_W()) + assert t.names() == {"set_timer", "get_weather"} + assert t.has("set_timer") and not t.has("nope") + assert t.source("set_timer") == "Timer" + assert t.source("get_weather") == "Weather MCP" + print("PASS: names/has/source") + + +def test_describe_all_invoke(): + t = SkillTools(_W()) + d = t.describe("set_timer") + assert isinstance(d, ToolDescriptor) and d.source == "Timer" and d.description == "d1" + alld = t.all() + assert len(alld) == 2 and all(isinstance(x, ToolDescriptor) for x in alld) + r = asyncio.get_event_loop().run_until_complete(t.invoke("set_timer", {"m": 5})) + assert isinstance(r, ToolResult) and r.response == "resp:set_timer" and r.skill == "Timer" + print("PASS: describe/all/invoke->ToolResult") + + +def test_servers(): + t = SkillTools(_W()) + servers = t.servers() + assert len(servers) == 1, servers + assert servers[0]["display_name"] == "Weather MCP" + assert servers[0]["tools"] == ["get_weather"] + assert servers[0]["connected"] is True + print("PASS: servers()") + + +if __name__ == "__main__": + test_names_has_source() + test_describe_all_invoke() + test_servers() + print("ALL OK") diff --git a/wingmen/facade.py b/wingmen/facade.py index f74e337e..39d8f56f 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -448,37 +448,96 @@ async def forget_by_id(self, entry_id: int) -> bool: return await self._la.forget_memory_by_id(entry_id) -class SkillRegistryView: - """Sanctioned read + invoke over the wingman's tools/commands. - - Lets a skill discover which tool functions exist and invoke one (or a command) - by name — without reaching into build_tools()/the renamed internal dispatcher. - The invoke surface is deliberately scoped to enumerable tools/commands, not - arbitrary attribute calls. - """ +class SkillTools: + """Discover and invoke the wingman's callable functions (skill @tools, MCP tools, + commands) by name. Scoped to enumerable tools — not arbitrary attribute access.""" def __init__(self, wingman: "Wingman") -> None: self._wingman = wingman + def _tool_defs(self) -> dict: + return {t.get("function", {}).get("name"): t.get("function", {}) + for t in self._wingman.build_tools() + if t.get("function", {}).get("name")} + + def names(self) -> set[str]: + return set(self._tool_defs().keys()) + + def has(self, name: str) -> bool: + return name in self._tool_defs() + + # --- backward-compatible aliases (pre-v3 callers used tool_names/has_tool) --- def tool_names(self) -> set[str]: - """Names of all currently-available tool functions.""" - names = set() - for tool in self._wingman.build_tools(): - name = tool.get("function", {}).get("name") - if name: - names.add(name) - return names + """Deprecated alias for names().""" + return self.names() def has_tool(self, name: str) -> bool: - """True if a tool function with this name is currently available.""" - return name in self.tool_names() - - async def invoke(self, function_name: str, arguments: dict | None = None): - """Invoke a tool/command by name. Returns - ``(function_response, instant_response, used_skill, tool_label)``.""" - return await self._wingman.execute_command_by_function_call( - function_name, arguments or {} - ) + """Deprecated alias for has().""" + return self.has(name) + + def source(self, name: str) -> str | None: + """Human-readable origin of a tool: the owning skill's name, or the MCP server's + display name. Prefers mcp_registry PUBLIC accessors; falls back to internals.""" + skill = (self._wingman.tool_skills or {}).get(name) + if skill is not None: + return getattr(skill, "name", None) + mcp = self._wingman.mcp_registry + if not mcp: + return None + try: + for manifest in mcp.get_connected_servers(): + sname = getattr(manifest, "name", None) + tools = mcp.get_server_tools(sname) if sname else [] + tool_names = {getattr(t, "prefixed_name", None) or getattr(t, "name", None) for t in tools} + if name in tool_names: + return getattr(manifest, "display_name", sname) + except Exception: + pass + # Fallback to internals if the public shapes differ. + server = getattr(mcp, "_tool_to_server", {}).get(name) + manifests = getattr(mcp, "_manifests", {}) + if server and server in manifests: + return getattr(manifests[server], "display_name", server) + return None + + def describe(self, name: str) -> "ToolDescriptor | None": + fn = self._tool_defs().get(name) + if not fn: + return None + return ToolDescriptor(name=name, source=self.source(name), + description=fn.get("description"), + parameters=fn.get("parameters", {})) + + def all(self) -> tuple: + return tuple(self.describe(n) for n in self._tool_defs()) + + def servers(self) -> tuple: + """Active MCP servers as dicts: name, display_name, connected, tools (prefixed names).""" + mcp = self._wingman.mcp_registry + if not mcp: + return () + out = [] + for manifest in mcp.get_connected_servers(): + sname = getattr(manifest, "name", None) + tools = mcp.get_server_tools(sname) if sname else [] + out.append({ + "name": sname, + "display_name": getattr(manifest, "display_name", sname), + "connected": bool(getattr(manifest, "is_connected", True)), + "tools": [getattr(t, "prefixed_name", None) or getattr(t, "name", None) for t in tools], + }) + return tuple(out) + + async def invoke(self, name: str, arguments: dict | None = None) -> "ToolResult": + result = await self._wingman.execute_command_by_function_call(name, arguments or {}) + func_resp, instant_resp, used_skill, label = (list(result) + [None, None, None, None])[:4] + return ToolResult(response=func_resp or "", instant_response=instant_resp or "", + skill=used_skill, label=label) + + +# Backward-compatible alias: wingman_context.py still imports SkillRegistryView and +# exposes ctx.registry. SkillTools is the v3 name (ctx.tools). +SkillRegistryView = SkillTools class SkillCommands: From 5ceca9d43a9a45d3918ce58dbe5c56d247258960 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:19:36 +0200 Subject: [PATCH 07/30] feat(facade): ctx.commands editable + categories + register_function + add_skill_command --- tests/test_skill_commands.py | 65 ++++++++++++++++++++++++++++ wingmen/facade.py | 83 ++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 tests/test_skill_commands.py diff --git a/tests/test_skill_commands.py b/tests/test_skill_commands.py new file mode 100644 index 00000000..763248e1 --- /dev/null +++ b/tests/test_skill_commands.py @@ -0,0 +1,65 @@ +"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_commands""" +import asyncio +from wingmen.facade import SkillCommands, CommandCategory + + +class _Cmd: + def __init__(self, name): self.name = name; self.category_id = None + + +class _Cat: + def __init__(self, id, name): self.id = id; self.name = name + + +class _Features: pass + + +class _Config: + def __init__(self): + self.commands = [] + self.command_categories = [] + + +class _Exec: + def __init__(self, cfg): self.cfg = cfg + def get_command(self, name): + return next((c for c in self.cfg.commands if c.name == name), None) + + +class _Tower: + def save_wingman_commands(self, name): return True + + +class _W: + name = "TestWingman" + def __init__(self): + self.config = _Config() + self.command_executor = _Exec(self.config) + self.tower = _Tower() + + +def test_add_remove(): + w = _W(); c = SkillCommands(w) + c.add(_Cmd("Jump")) + assert len(c.all()) == 1 and c.get("Jump") is not None + c.remove("Jump") + assert c.get("Jump") is None + print("PASS: add/remove/get/all") + + +def test_categories(): + w = _W(); c = SkillCommands(w) + cat = c.add_category("Combat") + assert isinstance(cat, CommandCategory) and len(w.config.command_categories) == 1 + cat2 = c.add_category("Combat") # idempotent by name + assert cat2.id == cat.id and len(w.config.command_categories) == 1 + cmd = _Cmd("Jump") + c.add(cmd, category=cat) + assert cmd.category_id == cat.id + print("PASS: categories idempotent + add(category=)") + + +if __name__ == "__main__": + test_add_remove() + test_categories() + print("ALL OK") diff --git a/wingmen/facade.py b/wingmen/facade.py index 39d8f56f..dbcbe869 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -566,6 +566,89 @@ async def save(self) -> bool: return False return self._wingman.tower.save_wingman_commands(self._wingman.name) + def add(self, command, *, category=None) -> None: + """Add a command (optionally into a category). Call save() to persist.""" + if self._wingman.config.commands is None: + self._wingman.config.commands = [] + if category is not None: + command.category_id = category.id if isinstance(category, CommandCategory) else category + self._wingman.config.commands.append(command) + + def remove(self, name: str) -> None: + """Remove a command by name. Call save() to persist.""" + cmds = self._wingman.config.commands or [] + self._wingman.config.commands = [c for c in cmds if c.name != name] + + def add_category(self, name: str) -> "CommandCategory": + """Create (or return the existing) category with this name. Idempotent by name.""" + from api.interface import CommandCategoryConfig + import uuid + cats = self._wingman.config.command_categories + if cats is None: + cats = self._wingman.config.command_categories = [] + for cfg in cats: + if cfg.name == name: + return CommandCategory(id=cfg.id, name=cfg.name, commands=self._commands_in(cfg.id)) + cfg = CommandCategoryConfig(id=str(uuid.uuid4()), name=name) + cats.append(cfg) + return CommandCategory(id=cfg.id, name=cfg.name) + + def update_category(self, category: "CommandCategory") -> None: + for cfg in (self._wingman.config.command_categories or []): + if cfg.id == category.id: + cfg.name = category.name + return + + def delete_category(self, id_or_name: str) -> None: + cats = self._wingman.config.command_categories or [] + self._wingman.config.command_categories = [ + c for c in cats if c.id != id_or_name and c.name != id_or_name + ] + + def categories(self) -> tuple: + return tuple( + CommandCategory(id=c.id, name=c.name, commands=self._commands_in(c.id)) + for c in (self._wingman.config.command_categories or []) + ) + + def _commands_in(self, category_id: str) -> list: + return [c for c in (self._wingman.config.commands or []) if getattr(c, "category_id", None) == category_id] + + def register_function(self, func, *, label=None, description=None, + respond="ai", parameters=None) -> str: + """Register a live skill method as a bindable command function at runtime — the + dynamic equivalent of @command_action. Returns the registered function name.""" + from skills.skill_base import CommandActionDefinition + skill = getattr(func, "__self__", None) + if skill is None: + raise FacadeError("register_function requires a bound skill method (func.__self__).") + cad = CommandActionDefinition(func=func.__func__, label=label, + description=description, respond=respond) + skill._command_actions[cad.name] = cad + self._wingman.skill_manager.command_action_skills[(skill.name, cad.name)] = skill + return cad.name + + def unregister_function(self, name: str) -> None: + registry = self._wingman.skill_manager.command_action_skills + for key in [k for k in registry if k[1] == name]: + skill = registry.pop(key) + skill._command_actions.pop(name, None) + + def add_skill_command(self, name: str, func, *, category=None, + instant_phrases=None, respond="ai") -> None: + """One call: register `func` as a command function, build a command named `name` + bound to it (with optional instant-activation phrases), categorize it. Call save().""" + from api.interface import CommandConfig, CommandActionConfig, CommandSkillActionConfig + fn_name = self.register_function(func, label=name, respond=respond) + skill = func.__self__ + action = CommandActionConfig( + skill_action=CommandSkillActionConfig(skill_name=skill.name, function_name=fn_name) + ) + command = CommandConfig(name=name, actions=[action]) + if instant_phrases: + command.instant_activation = list(instant_phrases) + self.add(command, category=category) + class SkillAudio: """Sanctioned audio capabilities for skills. From e7bfb405a3b6b4db408b0c3403ae22a9b0a05525 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:20:58 +0200 Subject: [PATCH 08/30] feat(facade): add SkillConversation/SkillSecrets/SkillSkills/SkillSettings --- tests/test_facade_misc.py | 67 ++++++++++++++++++++++++++++ wingmen/facade.py | 92 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 159 insertions(+) create mode 100644 tests/test_facade_misc.py diff --git a/tests/test_facade_misc.py b/tests/test_facade_misc.py new file mode 100644 index 00000000..230b4a1b --- /dev/null +++ b/tests/test_facade_misc.py @@ -0,0 +1,67 @@ +"""Run: PYTHONPATH=. venv/bin/python -m tests.test_facade_misc""" +import asyncio +from wingmen.facade import SkillConversation, SkillSecrets, SkillSkills, SkillSettings, FacadeError + + +class _Conv: + def __init__(self): self.messages = [{"role": "user", "content": "hi"}]; self.asst = [] + async def add_assistant_message(self, c): self.asst.append(c) + + +class _Cond: summary = "the summary" + + +class _SM: + skills = [type("S", (), {"name": "Timer", "config": type("C", (), {"display_name": "Timer"})()})()] + + +class _Audio: output = "out"; input = "in" + + +class _Settings: audio = _Audio() + + +class _W: + def __init__(self): + self.conversation = _Conv(); self.condenser = _Cond() + self.skill_manager = _SM(); self.settings = _Settings() + self.added_user = None + async def add_user_message(self, c): self.added_user = c + async def reset_conversation_history(self): self.conversation.messages = [] + async def retrieve_secret(self, name, errors, is_required=True): return f"secret:{name}" + + +def test_conversation(): + w = _W(); c = SkillConversation(w) + assert c.history() == [{"role": "user", "content": "hi"}] + assert c.summary == "the summary" + asyncio.get_event_loop().run_until_complete(c.add_user("yo")) + assert w.added_user == "yo" + asyncio.get_event_loop().run_until_complete(c.add_assistant("ok")) + assert w.conversation.asst == ["ok"] + print("PASS: conversation") + + +def test_secrets_and_skills_and_settings(): + w = _W() + s = SkillSecrets(w) + assert asyncio.get_event_loop().run_until_complete(s.retrieve("API")) == "secret:API" + sk = SkillSkills(w) + active = sk.active() + assert active[0]["name"] == "Timer" + assert sk.has("Timer") and not sk.has("Nope") + st = SkillSettings(w) + assert st.output_device == "out" and st.input_device == "in" + try: + st.audio = 1 + raised = False + except FacadeError: + raised = True + assert raised, "settings write must raise FacadeError" + print("PASS: secrets/skills/settings") + + +if __name__ == "__main__": + test_conversation() + test_secrets_and_skills_and_settings() + print("ALL OK") diff --git a/wingmen/facade.py b/wingmen/facade.py index dbcbe869..a25faf0a 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -844,3 +844,95 @@ async def set_voice(self, voice: Any, errors: list | None = None) -> str: return "Voice change failed while reinitializing the TTS provider." self._wingman.tts = new_tts return f"Switched {self._wingman.name}'s voice to {voice_name} ({provider_label})." + + +class SkillConversation: + """Read + append to the live conversation, and summarize it (free, local).""" + + def __init__(self, wingman: "Wingman") -> None: + self._wingman = wingman + + def history(self) -> list[dict]: + """Shallow copy of the live history. Don't mutate individual messages.""" + return list(self._wingman.conversation.messages) + + @property + def summary(self) -> str: + return self._wingman.condenser.summary or "" + + async def add_user(self, content: str) -> None: + await self._wingman.add_user_message(content) + + async def add_assistant(self, content: str) -> None: + await self._wingman.conversation.add_assistant_message(content) + + async def reset(self) -> None: + await self._wingman.reset_conversation_history() + + async def summarize(self) -> str: + """Summarize the live conversation via the FREE local model. '' if unavailable.""" + from services.skill_local_ai import SkillLocalAI + text = "\n".join( + f"{m.get('role','')}: {m.get('content','')}" + for m in self._wingman.conversation.messages + if isinstance(m.get("content"), str) + ) + return await SkillLocalAiView(SkillLocalAI(self._wingman)).summarize(text) + + +class SkillSecrets: + """Fetch stored secrets (prompts the user if missing).""" + + def __init__(self, wingman: "Wingman") -> None: + self._wingman = wingman + + async def retrieve(self, name: str, errors: list | None = None) -> str | None: + return await self._wingman.retrieve_secret(name, errors if errors is not None else []) + + +class SkillSkills: + """Read which skills are currently loaded on this wingman.""" + + def __init__(self, wingman: "Wingman") -> None: + self._wingman = wingman + + def active(self) -> tuple: + out = [] + for s in self._wingman.skill_manager.skills: + out.append({"name": getattr(s, "name", None), + "display_name": getattr(getattr(s, "config", None), "display_name", None)}) + return tuple(out) + + def has(self, name: str) -> bool: + """Is a skill with this name currently loaded? (symmetric with ctx.tools.has)""" + return any(getattr(s, "name", None) == name for s in self._wingman.skill_manager.skills) + + +class SkillSettings: + """Read-only view of app settings + the one sanctioned mutation (audio devices).""" + + __slots__ = ("_wingman",) + + def __init__(self, wingman: "Wingman") -> None: + object.__setattr__(self, "_wingman", wingman) + + def __getattr__(self, name: str) -> Any: + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + return _wrap(getattr(object.__getattribute__(self, "_wingman").settings, name)) + + def __setattr__(self, name: str, value: Any) -> None: + raise FacadeError( + f"Settings are read-only for skills — cannot set '{name}'. " + f"Use ctx.audio.set_output_device(...) to change devices." + ) + + @property + def output_device(self): + audio = object.__getattribute__(self, "_wingman").settings.audio + return audio.output if audio else None + + @property + def input_device(self): + audio = object.__getattribute__(self, "_wingman").settings.audio + return audio.input if audio else None From 6e9910bbb213502a33165658115ae496f4d87b65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:24:32 +0200 Subject: [PATCH 09/30] refactor(facade): remove SkillRegistryView aliases (clean break, no shims) --- wingmen/facade.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/wingmen/facade.py b/wingmen/facade.py index a25faf0a..38e4f485 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -466,15 +466,6 @@ def names(self) -> set[str]: def has(self, name: str) -> bool: return name in self._tool_defs() - # --- backward-compatible aliases (pre-v3 callers used tool_names/has_tool) --- - def tool_names(self) -> set[str]: - """Deprecated alias for names().""" - return self.names() - - def has_tool(self, name: str) -> bool: - """Deprecated alias for has().""" - return self.has(name) - def source(self, name: str) -> str | None: """Human-readable origin of a tool: the owning skill's name, or the MCP server's display name. Prefers mcp_registry PUBLIC accessors; falls back to internals.""" @@ -535,11 +526,6 @@ async def invoke(self, name: str, arguments: dict | None = None) -> "ToolResult" skill=used_skill, label=label) -# Backward-compatible alias: wingman_context.py still imports SkillRegistryView and -# exposes ctx.registry. SkillTools is the v3 name (ctx.tools). -SkillRegistryView = SkillTools - - class SkillCommands: """Sanctioned access to the wingman's commands. From feef6096400a8311e716a22808c2967bc78ff6fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:26:40 +0200 Subject: [PATCH 10/30] feat(facade): rewrite WingmanContext as a closed feature-driven surface (A1) --- tests/test_wingman_context_closed.py | 30 ++++ wingmen/wingman_context.py | 258 ++++++++++----------------- 2 files changed, 120 insertions(+), 168 deletions(-) create mode 100644 tests/test_wingman_context_closed.py diff --git a/tests/test_wingman_context_closed.py b/tests/test_wingman_context_closed.py new file mode 100644 index 00000000..dfda37c1 --- /dev/null +++ b/tests/test_wingman_context_closed.py @@ -0,0 +1,30 @@ +"""Run: PYTHONPATH=. venv/bin/python -m tests.test_wingman_context_closed""" +from wingmen.wingman_context import WingmanContext + + +class _W: + name = "W" + config = type("C", (), {})() + settings = type("S", (), {"audio": None})() + + +def test_closed_surface(): + ctx = WingmanContext(_W()) + # sub-facades present + for ns in ("ai", "local_ai", "tts", "audio", "commands", "tools", + "conversation", "memory", "secrets", "skills"): + assert hasattr(ctx, ns), f"missing {ns}" + # leaks removed + for leak in ("llm_call", "audio_player", "tool_skills", "mcp_registry", + "skill_registry", "tower", "secret_keeper", "messages", + "get_command", "get_context", "local_ai_service", + "persistent_memory_service"): + assert not hasattr(ctx, leak), f"LEAK still present: {leak}" + # raw wingman not reachable via _wingman (name-mangled) + assert not hasattr(ctx, "_wingman"), "raw _wingman must be name-mangled" + print("PASS: closed surface — sub-facades present, leaks gone, _wingman mangled") + + +if __name__ == "__main__": + test_closed_surface() + print("ALL OK") diff --git a/wingmen/wingman_context.py b/wingmen/wingman_context.py index 204798aa..748b0d0a 100644 --- a/wingmen/wingman_context.py +++ b/wingmen/wingman_context.py @@ -1,217 +1,139 @@ -"""Controlled interface for skills. Limits what plugins can access.""" +"""Controlled interface for skills (the `self.wingman` a Skill receives). -from typing import TYPE_CHECKING, Optional +A fully closed, feature-driven surface: skills read everything through curated +capabilities and can only change what is safe. The underlying Wingman is private +(name-mangled) — there is no raw passthrough. This is API hygiene + guidance, not a +security sandbox (skills run in-process with full Python). +""" + +from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from openai.types.chat import ChatCompletion - from api.interface import SoundConfig, WingmanConfig, SettingsConfig - from services.audio_player import AudioPlayer + from api.interface import WingmanConfig, SettingsConfig from wingmen.facade import ( - SkillAi, - SkillAudio, - SkillCommands, - SkillRegistryView, - SkillTts, + SkillAi, SkillAudio, SkillCommands, SkillTools, SkillTts, + SkillLocalAiView, SkillMemory, SkillConversation, SkillSecrets, SkillSkills, + SkillSettings, ) from wingmen.wingman import Wingman class WingmanContext: - """What skills see. Controlled API surface — no internal leaking.""" + """What skills see (`self.wingman`). Feature namespaces only — nothing raw.""" def __init__(self, wingman: "Wingman"): - self._wingman = wingman - self._tts = None - self._audio = None - self._commands = None - self._registry = None - self._ai = None - - # --- Properties --- + # Name-mangled: skills cannot reach the raw Wingman via ctx._wingman by accident. + self.__wingman = wingman + self.__ai = None + self.__local_ai = None + self.__tts = None + self.__audio = None + self.__commands = None + self.__tools = None + self.__conversation = None + self.__memory = None + self.__secrets = None + self.__skills = None + self.__settings = None + + # --- identity + config (read-only) --- @property def name(self) -> str: - return self._wingman.name + return self.__wingman.name @property def config(self) -> "WingmanConfig": - """Read-only view of the live wingman config. - - Reads pass through to the live config (always current); any write raises - FacadeError. To change something, use a sanctioned capability — - ctx.tts.set_voice(...), ctx.audio.*, ctx.commands.*. - """ + """Live, read-only view of the wingman config. Writing raises FacadeError — + use a sanctioned capability (ctx.tts.set_voice, ctx.commands.*, ...).""" from wingmen.facade import ReadOnlyConfigView - - return ReadOnlyConfigView(self._wingman.config) + return ReadOnlyConfigView(self.__wingman.config) @property - def settings(self) -> "SettingsConfig": - return self._wingman.settings + def settings(self) -> "SkillSettings": + if self.__settings is None: + from wingmen.facade import SkillSettings + self.__settings = SkillSettings(self.__wingman) + return self.__settings - @property - def audio_player(self) -> "AudioPlayer": - return self._wingman.audio_player + # --- feature sub-facades --- @property - def tower(self): - return self._wingman.tower + def ai(self) -> "SkillAi": + if self.__ai is None: + from wingmen.facade import SkillAi + self.__ai = SkillAi(self.__wingman) + return self.__ai @property - def secret_keeper(self): - return self._wingman.secret_keeper - - # --- Conversation --- - - async def llm_call(self, messages: list[dict], tools: list[dict] | None = None) -> "ChatCompletion | None": - """Make an LLM call. Replaces actual_llm_call().""" - return await self._wingman.actual_llm_call(messages, tools) - - def get_conversation_history(self) -> list[dict]: - """Get a shallow copy of the conversation history. - - Note: message objects are shared with the live conversation state. - Do not mutate individual messages. - """ - return list(self._wingman.conversation.messages) - - async def add_user_message(self, content: str): - await self._wingman.add_user_message(content) - - async def add_assistant_message(self, content: str): - await self._wingman.conversation.add_assistant_message(content) - - async def reset_conversation_history(self): - await self._wingman.reset_conversation_history() - - # --- Audio --- - - async def play_to_user(self, text: str, no_interrupt: bool = False, - sound_config: "Optional[SoundConfig]" = None): - await self._wingman.play_to_user(text, no_interrupt, sound_config) - - # --- Image generation --- - - async def generate_image(self, text: str) -> str: - return await self._wingman.generate_image(text) - - # --- Secrets --- - - async def retrieve_secret(self, secret_name: str, errors: list = None) -> str | None: - return await self._wingman.retrieve_secret(secret_name, errors or []) - - # --- Utilities --- - - def threaded_execution(self, func, *args): - self._wingman.threaded_execution(func, *args) - - async def get_context(self) -> str: - return await self._wingman.context_builder.build( - skills=self._wingman.skills, - skill_registry=self._wingman.skill_registry, - conversation_summary=self._wingman.condenser.summary, - persistent_memory_service=self._wingman.persistent_memory_service, - messages=self._wingman.conversation.messages, - config_dir_name=self._wingman.tower.config_dir.name if self._wingman.tower and self._wingman.tower.config_dir and self._wingman.tower.config_dir.name else None, - ) - - # --- TTS (sanctioned voice control) --- + def local_ai(self) -> "SkillLocalAiView": + if self.__local_ai is None: + from wingmen.facade import SkillLocalAiView + from services.skill_local_ai import SkillLocalAI + # SkillLocalAI only reads local_ai_service/name/persistent_memory_service, + # all present on the raw Wingman — so the context stays fully closed. + self.__local_ai = SkillLocalAiView(SkillLocalAI(self.__wingman)) + return self.__local_ai @property def tts(self) -> "SkillTts": - """Sanctioned TTS capabilities (set the voice on the current provider). - - Skills should use ``ctx.tts.set_voice(...)`` instead of mutating config or - switching the provider. - """ - if self._tts is None: + if self.__tts is None: from wingmen.facade import SkillTts - - self._tts = SkillTts(self._wingman) - return self._tts - - # --- Audio (sanctioned playback control) --- + self.__tts = SkillTts(self.__wingman) + return self.__tts @property def audio(self) -> "SkillAudio": - """Sanctioned audio capabilities (play/stop skill audio, observe playback, - read is_playing). Use instead of the raw audio_player/audio_library.""" - if self._audio is None: + if self.__audio is None: from wingmen.facade import SkillAudio - - self._audio = SkillAudio(self._wingman) - return self._audio - - # --- Commands (sanctioned read + edit + persist) --- + self.__audio = SkillAudio(self.__wingman) + return self.__audio @property def commands(self) -> "SkillCommands": - """Sanctioned access to the wingman's commands (get/all/save).""" - if self._commands is None: + if self.__commands is None: from wingmen.facade import SkillCommands - - self._commands = SkillCommands(self._wingman) - return self._commands - - # --- Registry (sanctioned tool/command discovery + invoke) --- - - @property - def registry(self) -> "SkillRegistryView": - """Sanctioned access to discover tools/commands and invoke one by name.""" - if self._registry is None: - from wingmen.facade import SkillRegistryView - - self._registry = SkillRegistryView(self._wingman) - return self._registry - - # --- AI (sanctioned main-model access) --- + self.__commands = SkillCommands(self.__wingman) + return self.__commands @property - def ai(self) -> "SkillAi": - """Sanctioned main-model access. ``ctx.ai.generate(...)`` is a capped, - single-turn side-call. Use instead of the raw self.llm_call.""" - if self._ai is None: - from wingmen.facade import SkillAi - - self._ai = SkillAi(self._wingman) - return self._ai - - # NOTE: runtime TTS *provider* switching is intentionally NOT offered to skills. - # Skills may only change the voice on the current provider via ctx.tts.set_voice(...). - - # --- Commands --- - - def get_command(self, command_name: str): - """Delegate to command_executor for skills that need direct command lookup.""" - return self._wingman.command_executor.get_command(command_name) - - # --- Backward compatibility (temporary) --- - # These provide access to registries that some skills currently use. - # They should be replaced with proper facade methods in a future iteration. + def tools(self) -> "SkillTools": + if self.__tools is None: + from wingmen.facade import SkillTools + self.__tools = SkillTools(self.__wingman) + return self.__tools @property - def tool_skills(self) -> dict: - # tool_skills lives directly on the Wingman instance, not on tool_executor - return self._wingman.tool_skills + def conversation(self) -> "SkillConversation": + if self.__conversation is None: + from wingmen.facade import SkillConversation + self.__conversation = SkillConversation(self.__wingman) + return self.__conversation @property - def mcp_registry(self): - return self._wingman.mcp_registry + def memory(self) -> "SkillMemory": + if self.__memory is None: + from wingmen.facade import SkillMemory + from services.skill_local_ai import SkillLocalAI + self.__memory = SkillMemory(SkillLocalAI(self.__wingman)) + return self.__memory @property - def skill_registry(self): - return self._wingman.skill_registry + def secrets(self) -> "SkillSecrets": + if self.__secrets is None: + from wingmen.facade import SkillSecrets + self.__secrets = SkillSecrets(self.__wingman) + return self.__secrets - # Expose messages property for backward compat (quick_commands reads it) @property - def messages(self) -> list: - return self._wingman.conversation.messages + def skills(self) -> "SkillSkills": + if self.__skills is None: + from wingmen.facade import SkillSkills + self.__skills = SkillSkills(self.__wingman) + return self.__skills - # Expose local AI services for SkillLocalAI facade - @property - def local_ai_service(self): - return self._wingman.local_ai_service + # --- utility --- - @property - def persistent_memory_service(self): - return self._wingman.persistent_memory_service + def run_in_thread(self, function, *args): + """Run a blocking callable off the event loop.""" + return self.__wingman.threaded_execution(function, *args) From adb42711780c4bf14fb0811cb7be5dda59e5deb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:29:22 +0200 Subject: [PATCH 11/30] refactor(skills): seal skill_base (remove local_ai/retrieve_secret/threaded_execution/llm_call, private secret_keeper, read-only settings, add self.log) --- skills/skill_base.py | 90 ++++++++++---------------------- tests/test_skill_base_surface.py | 28 ++++++++++ 2 files changed, 57 insertions(+), 61 deletions(-) create mode 100644 tests/test_skill_base_surface.py diff --git a/skills/skill_base.py b/skills/skill_base.py index 560ebb7b..446856f9 100644 --- a/skills/skill_base.py +++ b/skills/skill_base.py @@ -29,6 +29,24 @@ from wingmen.wingman_context import WingmanContext +class SkillLog: + """Friendly logging for skills. Wraps Printr. server_only=True keeps a line out of the + client toast/log and only in the server console.""" + + def __init__(self, name: str) -> None: + self._printr = Printr() + self._name = name + + def info(self, message: str, server_only: bool = False) -> None: + self._printr.print(f"[{self._name}] {message}", LogType.INFO, server_only=server_only) + + def warning(self, message: str, server_only: bool = False) -> None: + self._printr.print(f"[{self._name}] {message}", LogType.WARNING, server_only=server_only) + + def error(self, message: str, server_only: bool = False) -> None: + self._printr.print(f"[{self._name}] {message}", LogType.ERROR, server_only=server_only) + + # Type mapping from Python types to JSON Schema types _TYPE_MAP = { str: "string", @@ -396,13 +414,18 @@ def __init__( wingman: "WingmanContext", ) -> None: self.config = config - self.settings = settings self.wingman = wingman + self.name = self.__class__.__name__ + + # Read-only view of app settings; change devices via self.wingman.audio.set_output_device(...) + from wingmen.facade import ReadOnlyConfigView + self.settings = ReadOnlyConfigView(settings) - self.secret_keeper = SecretKeeper() + # Private — skills retrieve via self.wingman.secrets.retrieve(...) # Note: secret_events subscription moved to prepare() to avoid listener accumulation - self.name = self.__class__.__name__ - self.printr = Printr() + self.__secret_keeper = SecretKeeper() + self.log = SkillLog(self.name) + self.printr = Printr() # internal/back-compat for base-class logging only self.execution_start: None | float = None """Used for benchmarking executon times. The timer is (re-)started whenever the process function starts.""" @@ -416,9 +439,6 @@ def __init__( self.is_unloaded: bool = False """Whether unload() has been called. Check this in __del__ before calling unload().""" - # Lazy facade for local AI capabilities (support model, embeddings, memory) - self._local_ai: "SkillLocalAI | None" = None - # Collect @tool decorated methods self._decorated_tools: dict[str, ToolDefinition] = {} self._collect_decorated_tools() @@ -426,18 +446,6 @@ def __init__( self._command_actions: dict[str, CommandActionDefinition] = {} self._collect_command_actions() - @property - def local_ai(self) -> "SkillLocalAI": - """Stable facade for local AI capabilities (support model, embeddings, memory). - - Lazily instantiated on first access. Zero cost for skills that don't use it. - """ - if self._local_ai is None: - from services.skill_local_ai import SkillLocalAI - - self._local_ai = SkillLocalAI(self.wingman) - return self._local_ai - def needs_activation(self) -> bool: """Check if this skill still needs validation and preparation. @@ -618,7 +626,7 @@ def __del__(self): # Safely unsubscribe - the handler may not be subscribed if prepare() was never called try: - self.secret_keeper.secret_events.unsubscribe( + self.__secret_keeper.secret_events.unsubscribe( "secrets_saved", self.secret_changed ) except ValueError: @@ -632,7 +640,7 @@ async def prepare(self) -> None: Subscribe to events here, not in __init__. """ # Subscribe to secret changes - will be unsubscribed in unload() - self.secret_keeper.secret_events.subscribe("secrets_saved", self.secret_changed) + self.__secret_keeper.secret_events.subscribe("secrets_saved", self.secret_changed) def get_tools(self) -> list[tuple[str, dict]]: """ @@ -829,41 +837,6 @@ async def is_waiting_response_needed(self, tool_name: str) -> bool: return self._decorated_tools[tool_name].wait_response return False - async def llm_call(self, messages, tools: list[dict] = None) -> any: - from wingmen.facade import FacadeError - - raise FacadeError( - "self.llm_call(...) has been removed. Use the sanctioned, capped call " - "instead: `await self.wingman.ai.generate(prompt, system=..., data=..., " - "image=...)` for a single-turn side-call. For bulk summarization use the " - "local model via `self.local_ai.summarize(...)`." - ) - - async def retrieve_secret( - self, - secret_name: str, - errors: list[WingmanInitializationError], - hint: str = None, - ): - """Use this method to retrieve secrets like API keys from the SecretKeeper. - If the key is missing, the user will be prompted to enter it. - """ - secret = await self.secret_keeper.retrieve( - requester=self.name, - key=secret_name, - prompt_if_missing=True, - ) - if not secret: - errors.append( - WingmanInitializationError( - wingman_name=self.name, - message=f"Missing secret '{secret_name}'. {hint or ''}", - error_type=WingmanInitializationErrorType.MISSING_SECRET, - secret_name=secret_name, - ) - ) - return secret - def retrieve_custom_property_value( self, property_id: str, @@ -886,11 +859,6 @@ def retrieve_custom_property_value( return None return p.value - def threaded_execution(self, function, *args) -> threading.Thread: - """Execute a function in a separate thread.""" - self.printr.print(f"[{self.__class__.__name__}] Threaded execution called before it was ready.", LogType.WARNING, server_only=True) - pass - def get_generated_files_dir(self) -> str: """Get the path to this skill's generated files directory. diff --git a/tests/test_skill_base_surface.py b/tests/test_skill_base_surface.py new file mode 100644 index 00000000..4d51b8fd --- /dev/null +++ b/tests/test_skill_base_surface.py @@ -0,0 +1,28 @@ +"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_base_surface""" +import inspect +from skills.skill_base import Skill + + +def test_removed_members(): + for gone in ("local_ai", "retrieve_secret", "threaded_execution", "llm_call"): + assert not hasattr(Skill, gone), f"{gone} must be removed from Skill" + print("PASS: duplicates removed from Skill") + + +def test_kept_members(): + for keep in ("retrieve_custom_property_value", "get_generated_files_dir", "update_config"): + assert hasattr(Skill, keep), f"{keep} must stay on Skill" + print("PASS: skill-owned members kept") + + +def test_log_present(): + src = inspect.getsource(Skill.__init__) + assert "self.log" in src, "self.log wrapper must be set in __init__" + print("PASS: self.log present") + + +if __name__ == "__main__": + test_removed_members() + test_kept_members() + test_log_present() + print("ALL OK") From 66099cbe42c580cfa3a5f7aa46ed141fdcb3b784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:32:36 +0200 Subject: [PATCH 12/30] docs(skills): add v2->v3 migration guide (users + coding agents) --- skills/MIGRATING-TO-V3.md | 251 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 skills/MIGRATING-TO-V3.md diff --git a/skills/MIGRATING-TO-V3.md b/skills/MIGRATING-TO-V3.md new file mode 100644 index 00000000..3572f968 --- /dev/null +++ b/skills/MIGRATING-TO-V3.md @@ -0,0 +1,251 @@ +# Migrating a Skill to the v3 Skill API + +## If you're a user + +If a skill shows an **"Incompatible (legacy)"** badge in Wingman AI, it was written for an +older Skill API and won't load until its author updates it. **Bundled skills are already +updated.** For a third-party skill, ask its author to migrate it (point them at this guide), +or remove it. Nothing on your side is broken — incompatible skills are simply skipped so the +app always boots. + +## If you're a developer or coding agent + +v3 makes the skill–runtime boundary explicit. Your skill talks to the runtime ONLY through +`self.wingman` (a controlled facade) and keeps its own concerns on `self`. Port mechanically +using the checklist + mapping table below. + +**The one rule:** `self.` is *this skill* (its identity, config, storage, decorators, +lifecycle hooks). `self.wingman` is *the runtime* (everything about the wingman/app, grouped +into feature namespaces). No capability lives on both. If you reach for a runtime capability, +it is under a feature noun on `self.wingman`: `ai`, `local_ai`, `tts`, `audio`, `commands`, +`tools`, `conversation`, `memory`, `secrets`, `skills`. + +### The 5 breaking changes + +1. **Declare the API version.** Add `api_version: 3` to your `default_config.yaml`. Skills + without it are treated as legacy and are not loaded. +2. **No work at import time.** No network, file I/O, or global side effects at module scope — + the catalog import-probes your module without instantiating it. Do setup in `__init__` / + `validate()` / `prepare()`. +3. **No raw LLM calls.** `self.llm_call(...)` and `self.wingman.actual_llm_call(...)` are gone. + Use `self.wingman.ai.generate(...)` (a single-turn, **capped** side-call), + `self.wingman.ai.converse(...)` (conversation-aware), or the free local model + `self.wingman.local_ai.generate(...)` / `.summarize(...)`. +4. **Config is read-only.** `self.wingman.config` reads live values; writing raises + `FacadeError`. Change things through capabilities (`self.wingman.tts.set_voice(...)`, the + `self.wingman.commands.*` editors, `self.wingman.audio.set_output_device(...)`). Runtime + TTS *provider* switching is removed — you may only change the voice on the current provider. +5. **No raw runtime access.** `audio_player`, `audio_library`, the registries + (`tool_skills`, `mcp_registry`, `skill_registry`), `messages`, `secret_keeper`, `tower`, + `local_ai_service`, `persistent_memory_service` are all gone from the facade. Use the + feature namespaces. + +### Old → new mapping + +Everything you might reach for, and its v3 replacement. `await` where the v3 form is async +(noted by `await` in the cell). + +#### LLM & local model + +| v2 (removed) | v3 | +|---|---| +| `await self.llm_call(msgs)` | `await self.wingman.ai.generate(prompt, system=..., data=..., image=..., messages=...)` | +| `await self.wingman.llm_call(msgs)` | `await self.wingman.ai.generate(messages=msgs)` | +| `...get_wingman().actual_llm_call(msgs)` | `await self.wingman.ai.generate(messages=msgs)` | +| `await self.local_ai.support(text, sys)` | `await self.wingman.local_ai.generate(text, system=sys)` | +| `self.local_ai.support_sync(...)` | `self.wingman.local_ai.generate_sync(...)` | +| `await self.local_ai.summarize(...)` | `await self.wingman.local_ai.summarize(...)` | +| `await self.local_ai.embed(texts)` | `await self.wingman.local_ai.embed(texts)` | +| `self.local_ai.available` | `self.wingman.local_ai.available` | + +> **`ai.generate` returns a `str`, not a completion object.** The old `actual_llm_call` +> returned a `ChatCompletion`; you used to read `completion.choices[0].message.content`. +> `ai.generate(...)` already gives you that text. If your code parsed JSON out of the +> completion, parse it straight from the returned string. + +> **`ai.generate` is capped.** When conversation condensation is on, the combined input +> (system + prompt + data, plus a flat estimate per image) is limited (Wingman Pro: a fixed +> 8,000 tokens; own provider: `features.skill_max_input_tokens`, default 16,000). Over the cap +> it raises `FacadeError`, or truncates if you pass `auto_shorten=True`. For bulk text, reduce +> it first with the free `self.wingman.local_ai.summarize(...)`. + +#### Memory (now its own namespace) + +| v2 (removed) | v3 | +|---|---| +| `self.local_ai.memory_available` | `self.wingman.memory.available` | +| `await self.local_ai.remember_fact(c)` | `await self.wingman.memory.remember(c)` | +| `await self.local_ai.recall_memory(q)` | `await self.wingman.memory.recall(q)` | +| `await self.local_ai.memory_context(q)` | `await self.wingman.memory.context(q)` | +| `await self.local_ai.update_memory(id, c)` | `await self.wingman.memory.update(id, c)` | +| `await self.local_ai.memory_forget(q)` | `await self.wingman.memory.forget(q)` | +| `await self.local_ai.forget_memory_by_id(id)` | `await self.wingman.memory.forget_by_id(id)` | + +#### Speech, audio & devices + +| v2 (removed) | v3 | +|---|---| +| `await self.wingman.play_to_user(t)` | `await self.wingman.tts.speak(t)` | +| `await self.wingman.play_to_user(t, no_interrupt=True)` | `await self.wingman.tts.speak(t, interrupt=False)` | +| `await self.wingman.play_to_user(t, True, sound_config=sc)` | `await self.wingman.tts.speak(t, interrupt=False, sound_config=sc)` | +| `self.wingman.switch_tts_provider(...)` | removed — use `self.wingman.tts.set_voice(...)` | +| `self.wingman.audio_player.is_playing` | `self.wingman.audio.is_playing` | +| `await self.wingman.audio_library.start_playback(c, v)` | `await self.wingman.audio.play(c, volume=v)` | +| `await self.wingman.audio_library.stop_playback(c, f)` | `await self.wingman.audio.stop(c, fade_out=f)` | +| `audio_player.playback_events.subscribe("started", cb)` | `sub = self.wingman.audio.on_playback_started(cb)` | +| `audio_player.playback_events.subscribe("finished", cb)` | `sub = self.wingman.audio.on_playback_finished(cb)` | +| `audio_player.playback_events.unsubscribe(ev, cb)` | `sub.unsubscribe()` (keep the returned `Subscription`) | +| device read / change (HTTP hack) | `self.wingman.audio.output_device` / `.set_output_device(id)` (+ `input` variants) | + +> **`tts.speak`'s `interrupt` is keyword-only and inverted from the old `no_interrupt`.** +> `play_to_user(t, no_interrupt=True)` → `tts.speak(t, interrupt=False)`. `interrupt=True` +> (the default) cuts off current playback immediately; `interrupt=False` waits for it. + +#### Conversation + +| v2 (removed) | v3 | +|---|---| +| `self.wingman.get_conversation_history()` | `self.wingman.conversation.history()` | +| `self.wingman.messages` | `self.wingman.conversation.history()` | +| `await self.wingman.add_user_message(c)` | `await self.wingman.conversation.add_user(c)` | +| `await self.wingman.add_assistant_message(c)` | `await self.wingman.conversation.add_assistant(c)` | +| `await self.wingman.reset_conversation_history()` | `await self.wingman.conversation.reset()` | +| condenser summary | `self.wingman.conversation.summary` | +| summarize the live convo | `await self.wingman.conversation.summarize()` (free, local) | + +#### Tools, commands & other skills + +| v2 (removed) | v3 | +|---|---| +| `self.wingman.registry.has_tool(name)` | `self.wingman.tools.has(name)` | +| `self.wingman.registry.tool_names()` | `self.wingman.tools.names()` | +| `await self.wingman.registry.invoke(name, args)` | `await self.wingman.tools.invoke(name, args)` → returns a `ToolResult` | +| `self.wingman.tool_skills[name]` | `self.wingman.tools.source(name)` (human origin: skill / MCP server name) | +| `self.wingman.mcp_registry._tool_to_server` / `._manifests` | `self.wingman.tools.source(name)` / `.servers()` / `.describe(name)` | +| `self.wingman.skill_registry` | `self.wingman.skills.active()` / `self.wingman.skills.has(name)` | +| `self.wingman.get_command(name)` | `self.wingman.commands.get(name)` | +| `self.wingman.tower` (save commands) | `await self.wingman.commands.save()` | + +> **`tools.invoke(...)` returns a `ToolResult`, not a 4-tuple.** The old call returned +> `(function_response, instant_response, used_skill, tool_label)`. Now: +> ```python +> result = await self.wingman.tools.invoke(name, args) +> result.response # was function_response +> result.instant_response # was instant_response +> result.skill # was used_skill +> result.label # was tool_label +> ``` + +#### Secrets, threading, image, settings, logging + +| v2 (removed) | v3 | +|---|---| +| `await self.retrieve_secret(name, errors)` | `await self.wingman.secrets.retrieve(name, errors)` | +| `self.wingman.secret_keeper` | `self.wingman.secrets.retrieve(...)` | +| `self.threaded_execution(fn, *a)` | `self.wingman.run_in_thread(fn, *a)` | +| `await self.wingman.generate_image(p)` | `await self.wingman.ai.generate_image(p)` | +| writing to `self.wingman.config.X` | a capability (e.g. `self.wingman.tts.set_voice(...)`) | +| `self.settings.X = ...` | read-only now; change devices via `self.wingman.audio.set_output_device(...)` | +| `self.printr.print(msg, ...)` | `self.log.info(msg)` / `self.log.warning(msg)` / `self.log.error(msg)` (pass `server_only=True` to keep a line out of the client toast) | + +> **`self.log`** is the friendly logger. `self.log.info/warning/error(message, +> server_only=False)`. Use it instead of `self.printr`. + +### Gotchas that bite during migration + +**Threaded TTS / async calls passed to `run_in_thread`.** `run_in_thread(fn, *args)` (like the +old `threaded_execution`) runs `fn` in a fresh thread, and if `fn` is a coroutine function it +spins up an event loop and runs `fn(*args)`. Two consequences: + +- It calls `fn(*args)` **positionally**, so you cannot pass `tts.speak`'s keyword-only + `interrupt`/`sound_config` through it. `run_in_thread(self.wingman.tts.speak, text, False)` + fails — `speak` takes one positional arg. +- A `lambda`/`functools.partial` wrapper does NOT work: a `lambda` returning a coroutine isn't + detected as a coroutine function (its result is never awaited), and `partial` has no + `__name__` (the helper names the thread after `fn.__name__`). + +The clean fix is a tiny async helper method on your skill, then thread *that*: + +```python +# v2 +self.threaded_execution(self.wingman.play_to_user, response, True) # no_interrupt=True + +# v3 +async def _speak(self, text, sound_config=None): + await self.wingman.tts.speak(text, interrupt=False, sound_config=sound_config) +... +self.wingman.run_in_thread(self._speak, response) +``` + +(If your threaded call had no special kwargs — e.g. `threaded_execution(self._loop)` — it's a +straight rename to `self.wingman.run_in_thread(self._loop)`.) + +**Stale comments and strings.** Search for the old names in comments/docstrings too (e.g. a +comment mentioning `switch_tts_provider`). Update or delete them — leftover references read as +"still using the old API." + +**`self.settings` is now a read-only view.** Reads still work +(`self.wingman.settings` is preferred); any assignment raises `FacadeError`. + +### Migration checklist (run top to bottom) + +1. Add `api_version: 3` to `default_config.yaml`. +2. Move any import-time work into `__init__`/`prepare`. +3. Grep your skill for each v2 form in the tables above and replace it. Don't forget submodules + (`*/api/*.py`, helpers), not just `main.py`. +4. Fix the gotchas: threaded TTS helpers; `tools.invoke` unpacking → `ToolResult` attributes; + `ai.generate` returns a string. +5. Remove any writes to `self.wingman.config` / `self.settings`; clean stale comments. +6. Boot Wingman, confirm your skill shows **no** incompatibility badge. +7. Exercise every capability path your skill uses (speak, audio, LLM, commands, secrets, tools). + +### Before / after example + +```python +# v2 +class MySkill(Skill): + async def react(self, data): + text = await self.llm_call([{"role": "user", "content": data}]) + await self.wingman.play_to_user(text, no_interrupt=True) + self.threaded_execution(self._bg) + +# v3 (default_config.yaml: api_version: 3) +class MySkill(Skill): + async def react(self, data): + text = await self.wingman.ai.generate(data, system="React in character.") + await self.wingman.tts.speak(text, interrupt=False) + self.wingman.run_in_thread(self._bg) +``` + +### Calling other skills & MCP servers + +Cross-skill / MCP invocation is a first-class, supported use case. Discover what's callable +right now, guard with `has(...)` / `servers()`, then `invoke`; degrade gracefully if the +skill/MCP the user needs isn't active. + +```python +# Discover everything callable right now (your tools + other skills' + all MCP tools) +for tool in self.wingman.tools.all(): + self.log.info(f"{tool.name} (from {tool.source}) — {tool.description}", server_only=True) + +# Call another ACTIVE skill's tool by name, guarding first +if self.wingman.tools.has("take_screenshot"): + result = await self.wingman.tools.invoke("take_screenshot", {}) + self.log.info(f"{result.response} (from {result.skill})") + +# Call your own MCP server's tool (devs often ship an MCP for their datasource) +servers = {s["display_name"] for s in self.wingman.tools.servers()} +if "My Data MCP" in servers and self.wingman.tools.has("mydata_query"): + res = await self.wingman.tools.invoke("mydata_query", {"q": "ships"}) + data = res.response +else: + self.log.warning("My Data MCP not active; skipping enriched lookup") +``` + +> MCP tool names are prefixed by the registry — use the name exactly as it appears in +> `self.wingman.tools.names()` / `.all()`, not the bare tool name. + +### What's available to call + +See `skills/README.md` → "The `self.wingman` facade API" for the full reference, and use +`self.wingman.tools.all()` at runtime to enumerate every callable function (with params). From 190e560b9aab46620d14b0f8f045cd9ff06f8686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:38:30 +0200 Subject: [PATCH 13/30] refactor(hud): tool source via ctx.tools.source (drop registry privates) --- skills/hud/main.py | 35 +++++++---------------------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/skills/hud/main.py b/skills/hud/main.py index 5a73d150..3eb26deb 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -12,7 +12,6 @@ """ import asyncio -import inspect import json import os import threading @@ -906,33 +905,13 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None tool_name = tc.function.name source = "System" source_type = "system" - icon_path = None - # Check if skill - if self.wingman.tool_skills and tool_name in self.wingman.tool_skills: - skill = self.wingman.tool_skills[tool_name] - source = skill.name - source_type = "skill" - try: - skill_file = inspect.getfile(skill.__class__) - skill_dir = os.path.dirname(skill_file) - logo_path = os.path.join(skill_dir, "logo.png") - if os.path.exists(logo_path): - icon_path = logo_path - except Exception: - pass - - # Check if MCP tool - elif (self.wingman.mcp_registry and - hasattr(self.wingman.mcp_registry, '_tool_to_server')): - server_name = self.wingman.mcp_registry._tool_to_server.get(tool_name) - if server_name: - if (hasattr(self.wingman.mcp_registry, '_manifests') and - server_name in self.wingman.mcp_registry._manifests): - source = self.wingman.mcp_registry._manifests[server_name].display_name - else: - source = server_name - source_type = "mcp" + # Resolve human-readable source via v3 facade + origin = self.wingman.tools.source(tool_name) + if origin is not None: + source = origin + mcp_display_names = {s["display_name"] for s in self.wingman.tools.servers()} + source_type = "mcp" if origin in mcp_display_names else "skill" # Use tool name if configured if display_tool_names: @@ -942,7 +921,7 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None 'name': tool_name, 'source': source, 'type': source_type, - 'icon': icon_path + 'icon': None }) if message: From 605b0cf0855e08630200ac50dc0f606f9e418eb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:38:30 +0200 Subject: [PATCH 14/30] refactor(timer): migrate to v3 facade (tools/conversation/tts namespaces) --- skills/timer/main.py | 30 +++++++++--------------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/skills/timer/main.py b/skills/timer/main.py index 7336d798..9801a53d 100644 --- a/skills/timer/main.py +++ b/skills/timer/main.py @@ -5,10 +5,6 @@ import time from typing import TYPE_CHECKING, Optional from api.interface import SettingsConfig, SkillConfig -from api.enums import ( - LogSource, - LogType, -) from services.benchmark import Benchmark from skills.skill_base import Skill, tool @@ -134,7 +130,7 @@ def __init__( async def prepare(self) -> None: await super().prepare() self.active = True - self.threaded_execution(self.start_timer_worker) + self.wingman.run_in_thread(self.start_timer_worker) async def unload(self) -> None: await super().unload() @@ -175,7 +171,7 @@ async def set_timer( function_name = function_name.split(".")[1] # check if a tool with this name exists - is_known = self.wingman.registry.has_tool(function_name) + is_known = self.wingman.tools.has(function_name) # if not a tool, it might be a command if not is_known and self.wingman.commands.get(function_name): @@ -316,25 +312,17 @@ async def execute_timer(self, timer_id: str) -> None: return timer = self.timers[timer_id] - function_response, instant_response, used_skill, tool_label = ( - await self.wingman.registry.invoke( - timer.function_name, timer.function_arguments - ) + result = await self.wingman.tools.invoke( + timer.function_name, timer.function_arguments ) - response = instant_response or function_response + response = result.instant_response or result.response if response: summary = await self._summarize_timer_execution(timer, response) if summary: - await self.wingman.add_assistant_message(summary) - await self.printr.print_async( - f"{summary}", - color=LogType.POSITIVE, - source=LogSource.WINGMAN, - source_name=self.wingman.name, - skill_name=self.name, - ) - await self.wingman.play_to_user(summary, True) + await self.wingman.conversation.add_assistant(summary) + self.log.info(summary) + await self.wingman.tts.speak(summary, interrupt=False) if not timer.is_loop or timer.loops == 1: # we cant delete it here, because we are iterating over the timers in a separate thread @@ -350,7 +338,7 @@ async def _summarize_timer_execution( ) -> str | None: if timer.silent: return None - history = self.wingman.get_conversation_history() + history = self.wingman.conversation.history() conversation = "\n".join( f"{message.get('role', '')}: {message.get('content', '')}" for message in history From b3cb3c9054cb347c49c2906c478c6ba65bf36733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 00:38:31 +0200 Subject: [PATCH 15/30] refactor(radio_chatter): migrate to v3 facade (threaded tts helper, conversation.add_assistant) --- skills/radio_chatter/main.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/skills/radio_chatter/main.py b/skills/radio_chatter/main.py index 36619ba8..b9354dbd 100644 --- a/skills/radio_chatter/main.py +++ b/skills/radio_chatter/main.py @@ -114,8 +114,7 @@ async def validate(self) -> list[WingmanInitializationError]: ) ) - # Provider initialization is handled by ProviderFactory via - # switch_tts_provider() at voice-switch time. No pre-init needed. + # Provider initialization is handled at voice-switch time via tts.set_voice(). return errors @@ -204,7 +203,7 @@ async def prepare(self) -> None: await super().prepare() self.loaded = True if self._get_auto_start() and not self.radio_status: - self.threaded_execution(self._init_chatter) + self.wingman.run_in_thread(self._init_chatter) async def unload(self) -> None: await super().unload() @@ -231,7 +230,7 @@ def turn_on_radio(self) -> str: if self.radio_status: return "Radio is already on." else: - self.threaded_execution(self._init_chatter) + self.wingman.run_in_thread(self._init_chatter) return "Radio is now on." @tool( @@ -264,6 +263,10 @@ def get_radio_status(self) -> str: else: return "Radio is off." + async def _speak(self, text: str, sound_config=None) -> None: + """Async helper for threaded TTS with interrupt=False and optional sound_config.""" + await self.wingman.tts.speak(text, interrupt=False, sound_config=sound_config) + async def _init_chatter(self) -> None: """Start the radio chatter.""" @@ -396,9 +399,9 @@ async def _generate_chatter(self): color=LogType.INFO, source_name=self.wingman.name, ) - self.threaded_execution(self.wingman.play_to_user, text, True, sound_config) + self.wingman.run_in_thread(self._speak, text, sound_config) if self._get_radio_knowledge(): - await self.wingman.add_assistant_message( + await self.wingman.conversation.add_assistant( f"Background radio chatter: {text}" ) max_wait = 10 From 4583327522d601147d081dca0ecbadb006d63086 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 08:56:18 +0200 Subject: [PATCH 16/30] feat(facade): ctx.ai.generate accepts a prebuilt messages= list (capped) --- tests/test_skill_ai.py | 31 +++++++++++++++++++++++++ wingmen/facade.py | 51 +++++++++++++++++++++++++++++++++++++++--- 2 files changed, 79 insertions(+), 3 deletions(-) diff --git a/tests/test_skill_ai.py b/tests/test_skill_ai.py index 9de441c1..1fb848c2 100644 --- a/tests/test_skill_ai.py +++ b/tests/test_skill_ai.py @@ -94,10 +94,41 @@ async def main(): assert w.last_messages[1]["content"] == "hello" await test_ai_has_converse_summarize_image() + await test_ai_messages_path() print("ALL OK") +async def test_ai_messages_path(): + # messages= sends a prebuilt list directly (prompt optional) and returns the content + w = FakeWingman(ConversationProvider.OPENAI, condense=True, skill_cap=16000) + ai = SkillAi(w) + msgs = [ + {"role": "system", "content": "be terse"}, + {"role": "user", "content": "hello there"}, + ] + out = await ai.generate(messages=msgs) + assert out == "RESULT", out + assert w.last_messages is msgs, "messages must be passed through as-is" + + # the messages path is also capped + huge = [{"role": "user", "content": "word " * 20000}] + w = FakeWingman(ConversationProvider.OPENAI, condense=True, skill_cap=16000) + ai = SkillAi(w) + try: + await ai.generate(messages=huge) + raise AssertionError("expected FacadeError for oversized messages list") + except FacadeError as e: + assert "16000" in str(e), str(e) + + # condense OFF -> messages path is uncapped + w = FakeWingman(ConversationProvider.OPENAI, condense=False) + ai = SkillAi(w) + assert await ai.generate(messages=huge) == "RESULT" + + print("PASS: ai.generate(messages=) path + cap") + + class FakeConversation: def __init__(self): self.messages = [] diff --git a/wingmen/facade.py b/wingmen/facade.py index 38e4f485..06b31e7e 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -274,6 +274,27 @@ def skill_input_cap(config: Any) -> int: return getattr(features, "skill_max_input_tokens", 16000) +def _count_message_tokens(messages: list) -> int: + """Token count of a prebuilt message list — string contents are counted directly; + multimodal image parts are charged a flat IMAGE_TOKEN_ESTIMATE (never the base64).""" + from services.token_utils import count_tokens + + total = 0 + for message in messages: + content = message.get("content") if isinstance(message, dict) else None + if isinstance(content, str): + total += count_tokens(content) + elif isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") == "text": + total += count_tokens(part.get("text", "")) + elif part.get("type") == "image_url": + total += IMAGE_TOKEN_ESTIMATE + return total + + class SkillAi: """Sanctioned access to the main (cloud) AI model for skills. @@ -291,23 +312,47 @@ def _max_input_tokens(self) -> int: async def generate( self, - prompt: str, + prompt: str = "", *, system: str | None = None, data: str | None = None, image: str | None = None, + messages: list | None = None, auto_shorten: bool = False, ) -> str: """Single-turn generation on the main model. Returns the response text. ``prompt`` is the instruction; ``data`` is an optional larger payload appended - to it; ``image`` is an optional data-URL for vision. When conversation + to it; ``image`` is an optional data-URL for vision. Pass ``messages`` (a prebuilt + OpenAI-style message list) to send your own turns directly — it is sent as-is and + ``prompt``/``system``/``data``/``image`` are ignored. When conversation condensation is enabled the combined input is capped (see class docstring): - over the cap raises :class:`FacadeError`, or truncates if ``auto_shorten``. + over the cap raises :class:`FacadeError`, or (for the prompt/data path) truncates + if ``auto_shorten``. The ``messages`` path can't be auto-shortened — it raises. """ from services.token_utils import count_tokens, truncate_to_tokens features = self._wingman.config.features + + # Prebuilt message-list path: send the skill's own turns directly (still capped). + if messages is not None: + if features.condense_conversation: + cap = self._max_input_tokens() + total = _count_message_tokens(messages) + if total > cap: + raise FacadeError( + f"Skill tried to send ~{total} tokens to the main model, but the " + f"limit is {cap}. Reduce the messages or pre-summarize them cheaply " + f"with self.local_ai.summarize(...). (A structured message list can't " + f"be auto-shortened — trim it yourself. On your own AI provider you can " + f"raise features.skill_max_input_tokens or turn off condensation; on " + f"Wingman Pro the limit is fixed.)" + ) + completion = await self._wingman.actual_llm_call(messages) + if completion and completion.choices: + return completion.choices[0].message.content or "" + return "" + user_text = prompt if not data else f"{prompt}\n\n{data}" if features.condense_conversation: From 446db8e0bbcb3e11f1758f275e1e487bed6b695b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 08:56:19 +0200 Subject: [PATCH 17/30] docs(skills): refine v3 migration guide from dogfooding (messages, positional interrupt, source_type/logo, helper threading) --- skills/MIGRATING-TO-V3.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/skills/MIGRATING-TO-V3.md b/skills/MIGRATING-TO-V3.md index 3572f968..8c81f24f 100644 --- a/skills/MIGRATING-TO-V3.md +++ b/skills/MIGRATING-TO-V3.md @@ -61,7 +61,11 @@ Everything you might reach for, and its v3 replacement. `await` where the v3 for > **`ai.generate` returns a `str`, not a completion object.** The old `actual_llm_call` > returned a `ChatCompletion`; you used to read `completion.choices[0].message.content`. > `ai.generate(...)` already gives you that text. If your code parsed JSON out of the -> completion, parse it straight from the returned string. +> completion, parse it straight from the returned string. It returns `""` (empty string) +> when the model gives nothing back — never `None` — and raises `FacadeError` only when the +> input is over the cap. So a v2 retry loop that re-called on a bad/empty completion should +> now: wrap the call in `try/except FacadeError` (cap errors won't fix themselves on retry — +> shorten instead) and treat an empty string as the "no answer, retry" case. > **`ai.generate` is capped.** When conversation condensation is on, the combined input > (system + prompt + data, plus a flat estimate per image) is limited (Wingman Pro: a fixed @@ -100,6 +104,8 @@ Everything you might reach for, and its v3 replacement. `await` where the v3 for > **`tts.speak`'s `interrupt` is keyword-only and inverted from the old `no_interrupt`.** > `play_to_user(t, no_interrupt=True)` → `tts.speak(t, interrupt=False)`. `interrupt=True` > (the default) cuts off current playback immediately; `interrupt=False` waits for it. +> Watch for the **positional** form: old code often called `play_to_user(text, True)` — that +> `True` is the second positional arg `no_interrupt`, so it maps to `tts.speak(text, interrupt=False)`. #### Conversation @@ -136,6 +142,16 @@ Everything you might reach for, and its v3 replacement. `await` where the v3 for > result.label # was tool_label > ``` +> **`tools.source(name)` returns a name string, not the skill/server object.** It gives the +> human origin (the owning skill's name, or the MCP server's display name), or `None`. Two +> things the old raw registries gave you that `source()` does NOT: +> - **Skill-vs-MCP discrimination:** if you need to know whether a tool came from a skill or an +> MCP server, compare against the MCP display names: `mcp = {s["display_name"] for s in +> self.wingman.tools.servers()}; is_mcp = source in mcp`. +> - **The skill's directory / logo path is intentionally not exposed.** There is no v3 path to +> a skill's files (the old code reaching `inspect.getfile(skill.__class__)` for a `logo.png` +> has no replacement). Drop that lookup; use the source name as the label. + #### Secrets, threading, image, settings, logging | v2 (removed) | v3 | @@ -180,6 +196,14 @@ self.wingman.run_in_thread(self._speak, response) (If your threaded call had no special kwargs — e.g. `threaded_execution(self._loop)` — it's a straight rename to `self.wingman.run_in_thread(self._loop)`.) +**Skills that pass their threading function into a helper/dependency.** If your skill handed +the old `self.threaded_execution` to a helper object (which then called it later), pass +`self.wingman.run_in_thread` instead — and check the helper's own call signature. `run_in_thread` +takes `(fn, *args)` (args spread positionally); a helper that stored args as a tuple and called +`stored_fn(fn, args_tuple)` must be updated to `stored_fn(fn, *args_tuple)`. Also rename any +helper method still literally called `threaded_execution` (the lockdown gate flags that name +anywhere in `skills/**`). + **Stale comments and strings.** Search for the old names in comments/docstrings too (e.g. a comment mentioning `switch_tts_provider`). Update or delete them — leftover references read as "still using the old API." From d1c29fccb422a78f4a10df4e9bf73046999a09dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 08:56:19 +0200 Subject: [PATCH 18/30] fix(uexcorp): capped ctx.ai.generate, rename helper threaded_execution->run_in_thread, conversation.add_assistant --- skills/uexcorp/main.py | 8 ++++---- skills/uexcorp/uexcorp/api/llm.py | 7 ++++--- skills/uexcorp/uexcorp/helper.py | 8 ++++---- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/skills/uexcorp/main.py b/skills/uexcorp/main.py index 9d0a910a..42a92fe5 100644 --- a/skills/uexcorp/main.py +++ b/skills/uexcorp/main.py @@ -29,10 +29,10 @@ def __init__( self.random_seed = uuid.uuid4() super().__init__(config=config, settings=settings, wingman=wingman) self.__helper: Helper = Helper.get_instance() - self.__helper.prepare(self.threaded_execution, self.wingman) + self.__helper.prepare(self.wingman.run_in_thread, self.wingman) self.__invalid_session = False self.__initialized = False - wingman.threaded_execution(self.threaded_prepare, True) + wingman.run_in_thread(self.threaded_prepare, True) async def validate(self) -> list[WingmanInitializationError]: errors = await super().validate() @@ -76,8 +76,8 @@ async def prepare(self) -> None: "Skill is still in preload phase, skipping initial import on load and removing preload flag.", ) else: - self.threaded_execution(self.threaded_prepare) - self.threaded_execution(self.loop_master) + self.wingman.run_in_thread(self.threaded_prepare) + self.wingman.run_in_thread(self.loop_master) async def unload(self) -> None: await super().unload() diff --git a/skills/uexcorp/uexcorp/api/llm.py b/skills/uexcorp/uexcorp/api/llm.py index 2a7e7316..18385639 100644 --- a/skills/uexcorp/uexcorp/api/llm.py +++ b/skills/uexcorp/uexcorp/api/llm.py @@ -53,17 +53,18 @@ def __init__( self.__cache_search = {} async def call(self, message_history: MessageHistory, expect_json: bool = False) -> str | dict[str, any] | list | None: - completion = await self.__helper.get_handler_config().get_wingman().actual_llm_call(message_history.get_messages()) answer = None request_count = 0 while answer is None and request_count < Llm.MAX_RETRIES: request_count += 1 try: - answer = completion.choices[0].message.content + answer = await self.__helper.get_handler_config().get_wingman().ai.generate( + messages=message_history.get_messages() + ) except Exception as e: self.__helper.get_handler_debug().write( - f"Error while parsing OpenAI response: {e}", True + f"Error while calling ai.generate: {e}", True ) self.__helper.get_handler_error().write( "Llm.call", [message_history.get_messages(), expect_json], e diff --git a/skills/uexcorp/uexcorp/helper.py b/skills/uexcorp/uexcorp/helper.py index d9a11f6d..99b26212 100644 --- a/skills/uexcorp/uexcorp/helper.py +++ b/skills/uexcorp/uexcorp/helper.py @@ -215,13 +215,13 @@ def set_ready(self, ready: bool = True): self.__is_ready = ready async def add_loaded_message(): - await self.get_wingmen().add_assistant_message( + await self.get_wingmen().conversation.add_assistant( "UEX skill is now loaded and ready to use." ) if ready and self.get_request_while_not_ready(): self.__handler_debug.write("UEX functions are available now.", True) - self.threaded_execution(add_loaded_message) + self.run_in_thread(add_loaded_message) self.set_request_while_not_loaded(False) def is_loaded(self) -> bool: @@ -314,10 +314,10 @@ def get_context(self) -> str: context += "\n\n" + "\n".join(self.__additional_context) return context - def threaded_execution(self, function, *args) -> threading.Thread: + def run_in_thread(self, function, *args) -> threading.Thread: if not self.__threaded_execution: raise Exception("Threaded execution not prepared") - return self.__threaded_execution(function, args) + return self.__threaded_execution(function, *args) def get_llm(self) -> Llm: return self.__llm From 0258cf31d2dc1fec771b79b8e3ed779e31e49724 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:04:26 +0200 Subject: [PATCH 19/30] refactor(skills): migrate ats_telemetry/msfs2020/quick_commands/voice_changer/file_manager/image_generation/spotify/thinking_sound to v3 facade --- skills/ats_telemetry/main.py | 107 +++++++++----------------------- skills/file_manager/main.py | 7 +-- skills/image_generation/main.py | 11 +--- skills/msfs2020_control/main.py | 86 ++++++++++--------------- skills/quick_commands/main.py | 16 ++--- skills/spotify/main.py | 2 +- skills/thinking_sound/main.py | 31 +++------ skills/voice_changer/main.py | 18 ++---- 8 files changed, 91 insertions(+), 187 deletions(-) diff --git a/skills/ats_telemetry/main.py b/skills/ats_telemetry/main.py index 9684613b..488176f7 100644 --- a/skills/ats_telemetry/main.py +++ b/skills/ats_telemetry/main.py @@ -16,7 +16,6 @@ SkillConfig, WingmanInitializationError, ) -from api.enums import LogType from skills.skill_base import Skill, tool @@ -169,9 +168,8 @@ async def check_and_install_telemetry_dlls(self): # - Release: loaded from _internal/skills/ats_telemetry/scs-telemetry.dll sdk_dll_filepath = Path(__file__).resolve().parent / "scs-telemetry.dll" if not sdk_dll_filepath.exists(): - await self.printr.print_async( - f"Missing scs telemetry dll at '{sdk_dll_filepath}'. Cannot auto-install telemetry plugin.", - color=LogType.ERROR, + self.log.error( + f"Missing scs telemetry dll at '{sdk_dll_filepath}'. Cannot auto-install telemetry plugin." ) return @@ -196,28 +194,21 @@ async def check_and_install_telemetry_dlls(self): os.makedirs(plugins_dir, exist_ok=True) shutil.copy2(str(sdk_dll_filepath), plugins_dir) except OSError as exc: - await self.printr.print_async( - f"Could not install scs telemetry dll to {label} plugins directory: {plugins_dir}. Error: {exc}", - color=LogType.ERROR, + self.log.error( + f"Could not install scs telemetry dll to {label} plugins directory: {plugins_dir}. Error: {exc}" ) # Start telemetry module connection with in-game telemetry SDK async def initialize_telemetry(self) -> bool: if self.settings.debug_mode: - await self.printr.print_async( - "Starting ATS / ETS telemetry module", - color=LogType.INFO, - ) + self.log.info("Starting ATS / ETS telemetry module", server_only=True) # truck_telemetry.init() requires the user to have installed the proper SDK DLL from https://github.com/RenCloud/scs-sdk-plugin/releases/tag/V.1.12.1 # into the proper folder of their truck sim install (https://github.com/RenCloud/scs-sdk-plugin#installation), if they do not this step will fail, so need to catch the error. try: truck_telemetry.init() return True except Exception: - await self.printr.print_async( - "Initialize ATSTelemetry function failed.", - color=LogType.ERROR, - ) + self.log.error("Initialize ATSTelemetry function failed.") return False # Initiate separate thread for constant checking of changes to key telemetry data points @@ -226,11 +217,8 @@ async def initialize_telemetry_cache_loop(self, loop_time: int = 10): return if self.settings.debug_mode: - await self.printr.print_async( - "Starting ATS / ETS telemetry cache loop", - color=LogType.INFO, - ) - self.threaded_execution(self.start_telemetry_loop, loop_time) + self.log.info("Starting ATS / ETS telemetry cache loop", server_only=True) + self.wingman.run_in_thread(self.start_telemetry_loop, loop_time) # Loop every designated number of seconds to retrieve telemetry data and run query function to determine if any tracked data points have changed async def start_telemetry_loop(self, loop_time: int): @@ -255,10 +243,7 @@ async def query_and_compare_data(self, data_points: list): data = truck_telemetry.get_data() filtered_data = await self.filter_data(data) if self.settings.debug_mode: - await self.printr.print_async( - "Querying and comparing telemetry data", - color=LogType.INFO, - ) + self.log.info("Querying and comparing telemetry data", server_only=True) for point in data_points: current_data = filtered_data.get(point) if ( @@ -283,17 +268,11 @@ async def query_and_compare_data(self, data_points: list): self.telemetry_loop_cached_data = copy.deepcopy(filtered_data) if data_changed == default: if self.settings.debug_mode: - await self.printr.print_async( - "No changed telemetry data found.", - color=LogType.INFO, - ) + self.log.info("No changed telemetry data found.", server_only=True) return None else: if self.settings.debug_mode: - await self.printr.print_async( - data_changed, - color=LogType.INFO, - ) + self.log.info(data_changed, server_only=True) return data_changed except Exception: return None @@ -303,10 +282,11 @@ async def stop_telemetry_loop(self): self.telemetry_loop_running = False self.telemetry_loop_cached_data = {} if self.settings.debug_mode: - await self.printr.print_async( - "Stopping ATS / ETS telemetry cache loop", - color=LogType.INFO, - ) + self.log.info("Stopping ATS / ETS telemetry cache loop", server_only=True) + + async def _speak(self, text: str) -> None: + """Async helper for threaded TTS with interrupt=False.""" + await self.wingman.tts.speak(text, interrupt=False) # If telemetry data changed, get LLM to provide a verbal response to the user, without requiring the user to initiate a communication with the LLM async def initiate_llm_call_with_changed_data(self, changed_data): @@ -328,14 +308,10 @@ async def initiate_llm_call_with_changed_data(self, changed_data): if not response: return - await self.printr.print_async( - text=f"Dispatch: {response}", - color=LogType.INFO, - source_name=self.wingman.name, - ) + self.log.info(f"Dispatch: {response}", server_only=True) - self.threaded_execution(self.wingman.play_to_user, response, True) - await self.wingman.add_assistant_message(response) + self.wingman.run_in_thread(self._speak, response) + await self.wingman.conversation.add_assistant(response) @tool( description="Retrieve telemetry from ATS/ETS2. Common variables: truckSpeed, speedLimit, gear, engineRpm, fuel*, cargo*, city*, job*, truckBrand, coordinates, damage/wear values, event flags (fined, tollgate, ferry). Tool returns error if variable doesn't exist." @@ -359,10 +335,7 @@ async def get_game_state(self, variable: str) -> str: data = truck_telemetry.get_data() filtered_data = await self.filter_data(data) if self.settings.debug_mode: - await self.printr.print_async( - f"Received telemetry data: {filtered_data}", - color=LogType.INFO, - ) + self.log.info(f"Received telemetry data: {filtered_data}", server_only=True) # Try exact match first if variable in filtered_data: value = filtered_data[variable] @@ -372,17 +345,11 @@ async def get_game_state(self, variable: str) -> str: string_value = "value could not be found." if self.settings.debug_mode: - await self.printr.print_async( - f"Found variable result in telemetry for {variable}, {string_value}", - color=LogType.INFO, - ) + self.log.info(f"Found variable result in telemetry for {variable}, {string_value}", server_only=True) return f"The current value of '{variable}' is {string_value}." else: if self.settings.debug_mode: - await self.printr.print_async( - f"Could not locate variable result in telemetry for {variable}.", - color=LogType.INFO, - ) + self.log.info(f"Could not locate variable result in telemetry for {variable}.", server_only=True) # Try fuzzy match as fallback before failing MATCH_THRESHOLD = 60 choices = list(filtered_data.keys()) @@ -406,10 +373,7 @@ async def get_game_state(self, variable: str) -> str: matches_formatted = ", ".join(results_list) if self.settings.debug_mode: - await self.printr.print_async( - f"Fuzzy matches for '{variable}': {matches_formatted}", - color=LogType.INFO, - ) + self.log.info(f"Fuzzy matches for '{variable}': {matches_formatted}", server_only=True) return (f"Exact variable '{variable}' not found. " f"Found the following close matches instead {matches_formatted}") @@ -445,9 +409,9 @@ async def get_information_about_current_location(self) -> str: data["coordinateX"], data["coordinateZ"] ) if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Executing get_information_about_current_location function (game is ets2: {is_ets2}) with coordinateX as {x} and coordinateZ as {y}, latitude returned was {latitude}, longitude returned was {longitude}.", - color=LogType.INFO, + server_only=True, ) place_info = await self.convert_lat_long_data_into_place_data( latitude, longitude @@ -473,10 +437,7 @@ async def start_or_activate_dispatch_telemetry_loop(self) -> str: if self.telemetry_loop_running: if self.settings.debug_mode: - await self.printr.print_async( - "Attempted to start dispatch communications loop but loop is already running", - color=LogType.INFO, - ) + self.log.info("Attempted to start dispatch communications loop but loop is already running", server_only=True) return "Dispatch communications already open." if not self.telemetry_loop_running: @@ -513,7 +474,7 @@ async def prepare(self) -> None: await super().prepare() self.loaded = True if self._get_autostart_dispatch_mode(): - self.threaded_execution(self.autostart_dispatcher_mode) + self.wingman.run_in_thread(self.autostart_dispatcher_mode) # Unload telemetry module and stop any ongoing loop when config / program unloads async def unload(self) -> None: @@ -845,10 +806,7 @@ async def filter_data(self, data): return enhanced_data except Exception as e: - await self.printr.print_async( - f"There was a problem with the filter_data function: {e}. Returning original data.", - color=LogType.ERROR, - ) + self.log.error(f"There was a problem with the filter_data function: {e}. Returning original data.") return data # Convert an array of days, hours, minutes into clock time @@ -1060,9 +1018,9 @@ async def convert_lat_long_data_into_place_data( zoom = 15 if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Attempting query of OpenStreetMap Nominatum with parameters: {latitude}, {longitude}, zoom level: {zoom}", - color=LogType.INFO, + server_only=True, ) # Request data from openstreetmap nominatum api for reverse geocoding @@ -1072,8 +1030,5 @@ async def convert_lat_long_data_into_place_data( if response.status_code == 200: return response.json() else: - await self.printr.print_async( - f"API request failed to {url}, status code: {response.status_code}.", - color=LogType.ERROR, - ) + self.log.error(f"API request failed to {url}, status code: {response.status_code}.") return None diff --git a/skills/file_manager/main.py b/skills/file_manager/main.py index 8ebcc022..c24b4d4d 100644 --- a/skills/file_manager/main.py +++ b/skills/file_manager/main.py @@ -3,7 +3,6 @@ import zipfile from typing import TYPE_CHECKING from api.interface import SettingsConfig, SkillConfig, WingmanInitializationError -from api.enums import LogType from services.benchmark import Benchmark from skills.skill_base import Skill, tool from showinfm import show_in_file_manager @@ -334,7 +333,7 @@ async def read_file_or_text_content_aloud( # First check if there's text content, if so, just play that as the user just wants the AI to say something in its TTS voice if text_content: - await self.wingman.play_to_user(text_content) + await self.wingman.tts.speak(text_content) return "Provided text read aloud." # Otherwise, check to see if a valid file has been passed, if so, read its text as long as it does not exceed max content length # If not a valid file location, double check whether the AI accidentally put text content in file name and play that @@ -344,7 +343,7 @@ async def read_file_or_text_content_aloud( else: file_path = os.path.join(directory_path, file_name) if not os.path.isfile(file_path): - await self.wingman.play_to_user(file_path) + await self.wingman.tts.speak(file_path) return "Provided text read aloud." else: file_extension = file_name.split(".")[-1] @@ -360,7 +359,7 @@ async def read_file_or_text_content_aloud( elif len(file_content) > self.max_text_size: return f"File content at {file_path} exceeds the maximum allowed size so could not read it aloud." else: - await self.wingman.play_to_user(file_content) + await self.wingman.tts.speak(file_content) return f"File content from {file_path} read aloud." except Exception as e: return f"There was an error trying to read aloud '{file_name}' in '{directory_path}'. The error was {str(e)}." diff --git a/skills/image_generation/main.py b/skills/image_generation/main.py index 8f9d5c69..1108adf6 100644 --- a/skills/image_generation/main.py +++ b/skills/image_generation/main.py @@ -51,11 +51,9 @@ async def generate_image(self, prompt: str) -> str: prompt: The image generation prompt describing what to create. """ if self.settings.debug_mode: - await self.printr.print_async( - f"Generate image with prompt: {prompt}.", color=LogType.INFO - ) + self.log.info(f"Generate image with prompt: {prompt}.") - image = await self.wingman.generate_image(prompt) + image = await self.wingman.ai.generate_image(prompt) await self.printr.print_async( "", color=LogType.INFO, @@ -85,9 +83,6 @@ async def generate_image(self, prompt: str) -> str: f" The image has also been stored to {image_path}." ) if self.settings.debug_mode: - await self.printr.print_async( - f"Image displayed and saved at {image_path}.", - color=LogType.INFO, - ) + self.log.info(f"Image displayed and saved at {image_path}.") return function_response diff --git a/skills/msfs2020_control/main.py b/skills/msfs2020_control/main.py index 3c3f4bb8..39508b22 100644 --- a/skills/msfs2020_control/main.py +++ b/skills/msfs2020_control/main.py @@ -10,8 +10,6 @@ SkillConfig, WingmanInitializationError, ) -from api.enums import LogType -from services.benchmark import Benchmark from skills.skill_base import Skill, tool @@ -101,9 +99,9 @@ async def get_potential_matching_commands(self, user_intent: str) -> str: matches = self.command_matcher.find_matches(user_intent) matches_string = self.command_matcher.matches_as_string(matches) if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Found potential matching commands for intent {user_intent}: {matches_string}", - color=LogType.INFO, + server_only=True, ) return matches_string @@ -120,9 +118,9 @@ async def get_data_from_sim(self, data_point: str) -> str: value = self.aq.get(data_point) if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Retrieving data point from sim: {data_point}; value returned: {value}", - color=LogType.INFO, + server_only=True, ) return f"{data_point} value is: {value}" @@ -140,9 +138,9 @@ async def set_data_or_perform_action_in_sim( argument: The argument to pass for the action, if any. """ if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Attempting to perform action/set data in sim: {action} with argument: {argument}", - color=LogType.INFO, + server_only=True, ) try: if argument is not None: @@ -151,9 +149,9 @@ async def set_data_or_perform_action_in_sim( event_to_trigger = self.ae.find(action) event_to_trigger() except Exception: - await self.printr.print_async( + self.log.info( f"Tried to perform action {action} with argument {argument} using aq.set, now going to try ae.event_to_trigger.", - color=LogType.INFO, + server_only=True, ) try: @@ -161,9 +159,9 @@ async def set_data_or_perform_action_in_sim( event_to_trigger = self.ae.find(action) event_to_trigger(argument) except Exception: - await self.printr.print_async( + self.log.info( f"Neither aq.set nor ae.event_to_trigger worked with {action} and {argument}. Command failed.", - color=LogType.INFO, + server_only=True, ) return "Error: Command failed." @@ -209,19 +207,13 @@ async def start_simconnect(self): while self.loaded and not self.already_initialized_simconnect: try: if self.settings.debug_mode: - await self.printr.print_async( - "Attempting to find MSFS2020....", - color=LogType.INFO, - ) + self.log.info("Attempting to find MSFS2020....", server_only=True) self.sm = SimConnect() self.aq = AircraftRequests(self.sm, _time=2000) self.ae = AircraftEvents(self.sm) self.already_initialized_simconnect = True if self.settings.debug_mode: - await self.printr.print_async( - "Initialized SimConnect with MSFS2020.", - color=LogType.INFO, - ) + self.log.info("Initialized SimConnect with MSFS2020.", server_only=True) if self._get_autostart_data_monitoring_loop_mode(): await self.initialize_data_monitoring_loop() except Exception: @@ -233,12 +225,9 @@ async def initialize_data_monitoring_loop(self): return if self.settings.debug_mode: - await self.printr.print_async( - "Starting threaded data monitoring loop", - color=LogType.INFO, - ) + self.log.info("Starting threaded data monitoring loop", server_only=True) - self.threaded_execution(self.start_data_monitoring_loop) + self.wingman.run_in_thread(self.start_data_monitoring_loop) async def start_data_monitoring_loop(self): if not self.data_monitoring_loop_running: @@ -255,19 +244,16 @@ async def start_data_monitoring_loop(self): ) ) # Gets random number from min to max in increments of 15 if self.settings.debug_mode: - await self.printr.print_async( - "Attempting looped monitoring check.", - color=LogType.INFO, - ) + self.log.info("Attempting looped monitoring check.", server_only=True) try: place_data = await self.convert_lat_long_data_into_place_data() if place_data: await self.initiate_llm_call_with_plane_data(place_data) except Exception as e: if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Something failed in looped monitoring check. Could not return data or send to llm: {e}.", - color=LogType.INFO, + server_only=True, ) time.sleep(random_time) @@ -275,10 +261,7 @@ async def stop_data_monitoring_loop(self): self.data_monitoring_loop_running = False if self.settings.debug_mode: - await self.printr.print_async( - "Stopping data monitoring loop", - color=LogType.INFO, - ) + self.log.info("Stopping data monitoring loop", server_only=True) async def convert_lat_long_data_into_place_data( self, latitude=None, longitude=None, altitude=None @@ -321,9 +304,9 @@ async def convert_lat_long_data_into_place_data( zoom = 8 if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Attempting query of OpenStreetMap Nominatum with parameters: {latitude}, {longitude}, {altitude}, zoom level: {zoom}", - color=LogType.INFO, + server_only=True, ) # Request data from openstreetmap nominatum api for reverse geocoding @@ -334,12 +317,16 @@ async def convert_lat_long_data_into_place_data( return response.json() else: if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"API request failed to {url}, status code: {response.status_code}.", - color=LogType.INFO, + server_only=True, ) return None + async def _speak(self, text: str) -> None: + """Async helper for threaded TTS — speaks without interrupting current playback.""" + await self.wingman.tts.speak(text, interrupt=False) + # Get LLM to provide a verbal response to the user, without requiring the user to initiate a communication with the LLM async def initiate_llm_call_with_plane_data(self, data): on_ground = self.aq.get("SIM_ON_GROUND") @@ -352,9 +339,9 @@ async def initiate_llm_call_with_plane_data(self, data): {backstory} """ if self.settings.debug_mode: - await self.printr.print_async( + self.log.info( f"Attempting LLM call with parameters: {backstory}, {user_content}.", - color=LogType.INFO, + server_only=True, ) response = await self.wingman.ai.generate( user_content, system=system_content, auto_shorten=True @@ -362,20 +349,13 @@ async def initiate_llm_call_with_plane_data(self, data): if not response: if self.settings.debug_mode: - await self.printr.print_async( - "LLM call returned no response.", - color=LogType.INFO, - ) + self.log.info("LLM call returned no response.", server_only=True) return - await self.printr.print_async( - text=f"Data monitoring response: {response}", - color=LogType.INFO, - source_name=self.wingman.name, - ) + self.log.info(f"Data monitoring response: {response}") - self.threaded_execution(self.wingman.play_to_user, response, True) - await self.wingman.add_assistant_message(response) + self.wingman.run_in_thread(self._speak, response) + await self.wingman.conversation.add_assistant(response) async def is_waiting_response_needed(self, tool_name: str) -> bool: return True @@ -384,7 +364,7 @@ async def prepare(self) -> None: """Load the skill by trying to connect to the sim""" await super().prepare() self.loaded = True - self.threaded_execution(self.start_simconnect) + self.wingman.run_in_thread(self.start_simconnect) async def unload(self) -> None: """Unload the skill.""" diff --git a/skills/quick_commands/main.py b/skills/quick_commands/main.py index 89e72a29..8ff6196d 100644 --- a/skills/quick_commands/main.py +++ b/skills/quick_commands/main.py @@ -3,7 +3,6 @@ import datetime from typing import TYPE_CHECKING from api.interface import SettingsConfig, SkillConfig, WingmanInitializationError -from api.enums import LogType from skills.skill_base import Skill if TYPE_CHECKING: @@ -36,7 +35,7 @@ async def validate(self) -> list[WingmanInitializationError]: "quick_commands_learning_rule_count", errors ) - self.threaded_execution(self._init_skill) + self.wingman.run_in_thread(self._init_skill) return errors def _get_rule_count(self) -> int: @@ -84,8 +83,8 @@ async def _add_instant_activation_phrase( async def on_add_assistant_message(self, message: str, tool_calls: list) -> None: """Hook to start learning process.""" if tool_calls: - self.threaded_execution( - self._process_messages, tool_calls, self.wingman.get_conversation_history()[-1] + self.wingman.run_in_thread( + self._process_messages, tool_calls, self.wingman.conversation.history()[-1] ) async def _process_messages(self, tool_calls, last_message) -> None: @@ -226,9 +225,8 @@ async def _finish_learning(self, phrase: str) -> None: ) answer = response or "" if answer.lower() == "yes": - await self.printr.print_async( + self.log.info( f"Instant activation phrase for '{', '.join(commands)}' learned.", - color=LogType.INFO, ) self.learning_learned[phrase] = commands self.learning_data.pop(phrase) @@ -240,9 +238,8 @@ async def _finish_learning(self, phrase: str) -> None: async def _add_to_blacklist(self, phrase: str) -> None: """Add a phrase to the blacklist.""" - await self.printr.print_async( + self.log.info( f"Added phrase to blacklist: '{phrase if len(phrase) <= 25 else phrase[:25]+'...'}'", - color=LogType.INFO, ) self.learning_blacklist.append(phrase) self.learning_data.pop(phrase) @@ -266,9 +263,8 @@ async def _load_learning_data(self): try: data = json.load(file) except json.JSONDecodeError: - await self.printr.print_async( + self.log.error( "Could not read learning data file. Resetting learning data..", - color=LogType.ERROR, ) # if file wasnt empty, save it as backup if file.read(): diff --git a/skills/spotify/main.py b/skills/spotify/main.py index 1e6c7a84..23313dd4 100644 --- a/skills/spotify/main.py +++ b/skills/spotify/main.py @@ -36,7 +36,7 @@ async def secret_changed(self, secrets: dict[str, any]): async def validate(self) -> list[WingmanInitializationError]: errors = await super().validate() - self.secret = await self.retrieve_secret("spotify_client_secret", errors) + self.secret = await self.wingman.secrets.retrieve("spotify_client_secret", errors) client_id: str = self.retrieve_custom_property_value( "spotify_client_id", errors ).strip() diff --git a/skills/thinking_sound/main.py b/skills/thinking_sound/main.py index 9bea4b8a..40e8b9c3 100644 --- a/skills/thinking_sound/main.py +++ b/skills/thinking_sound/main.py @@ -1,6 +1,5 @@ from typing import TYPE_CHECKING -from api.enums import LogType from api.interface import ( AudioFileConfig, SettingsConfig, @@ -27,9 +26,9 @@ def __init__( self.stop_duration = 1 self.is_playing = False - # Subscribe to playback events - self.wingman.audio.on_playback_started(self.on_playback_started) - self.wingman.audio.on_playback_finished(self.on_playback_finished) + # Subscribe to playback events (keep the Subscription handles to detach on unload) + self._sub_started = self.wingman.audio.on_playback_started(self.on_playback_started) + self._sub_finished = self.wingman.audio.on_playback_finished(self.on_playback_finished) async def validate(self) -> list[WingmanInitializationError]: errors = await super().validate() @@ -42,23 +41,15 @@ async def unload(self) -> None: await self.stop_playback() # Unsubscribe from playback events - self.wingman.audio.off_playback_started(self.on_playback_started) - self.wingman.audio.off_playback_finished(self.on_playback_finished) + self._sub_started.unsubscribe() + self._sub_finished.unsubscribe() - self.printr.print( - "Thinking Sound Skill unloaded.", - color=LogType.INFO, - server_only=True, - ) + self.log.info("Thinking Sound Skill unloaded.", server_only=True) async def on_playback_started(self, _): """Called when main TTS playback starts - stop the thinking sound.""" if self.is_playing: - self.printr.print( - "Thinking Sound: Stopping (TTS playback started).", - color=LogType.INFO, - server_only=True, - ) + self.log.info("Thinking Sound: Stopping (TTS playback started).", server_only=True) await self.stop_playback() async def on_playback_finished(self, _): @@ -83,13 +74,9 @@ async def on_add_user_message(self, message: str) -> None: # Stop any existing playback first await self.wingman.audio.stop(audio_config, 0) - self.printr.print( - "Thinking Sound: Starting playback.", - color=LogType.INFO, - server_only=True, - ) + self.log.info("Thinking Sound: Starting playback.", server_only=True) - self.threaded_execution(self.start_playback) + self.wingman.run_in_thread(self.start_playback) async def start_playback(self): """Start playing the thinking sound.""" diff --git a/skills/voice_changer/main.py b/skills/voice_changer/main.py index a644af18..7c8b758e 100644 --- a/skills/voice_changer/main.py +++ b/skills/voice_changer/main.py @@ -7,7 +7,6 @@ VoiceSelection, WingmanInitializationError, ) -from api.enums import LogType from skills.skill_base import Skill, command_action if TYPE_CHECKING: @@ -80,7 +79,7 @@ async def prepare(self) -> None: # prepare first personality if self._get_context_prompt(): - self.threaded_execution(self._generate_new_context) + self.wingman.run_in_thread(self._generate_new_context) async def unload(self) -> None: await super().unload() @@ -125,17 +124,13 @@ async def _initiate_change(self): if self._get_context_prompt(): messages.append(self._switch_personality()) if self._get_clear_history(): - await self.wingman.reset_conversation_history() + await self.wingman.conversation.reset() # sort out empty messages messages = [await message for message in messages if message] if messages: - await self.printr.print_async( - text="\n".join(messages), - color=LogType.INFO, - source_name=self.wingman.name, - ) + self.log.info("\n".join(messages)) async def _switch_voice(self, voices: list[VoiceSelection]) -> str: """Pick a (different) configured voice and set it on the current provider. @@ -163,10 +158,7 @@ async def _switch_voice(self, voices: list[VoiceSelection]) -> str: break if not voice_setting: - await self.printr.print_async( - "Voice switching failed due to missing voice settings.", - LogType.ERROR, - ) + self.log.error("Voice switching failed due to missing voice settings.") return "Voice switching failed due to missing voice settings." return await self.wingman.tts.set_voice(voice_setting.voice) @@ -179,7 +171,7 @@ async def _switch_personality(self) -> str: self.context_personality = self.context_personality_next self.context_personality_next = "" - self.threaded_execution(self._generate_new_context) + self.wingman.run_in_thread(self._generate_new_context) return "Switched personality context." From b4b619bd1bd7b234b16c4af2f4e88ac3afe689cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:04:26 +0200 Subject: [PATCH 20/30] fix(audio_device_changer): unsubscribe via Subscription handle (off_playback_* removed) --- skills/audio_device_changer/main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/skills/audio_device_changer/main.py b/skills/audio_device_changer/main.py index bf729c53..4bfc4f70 100644 --- a/skills/audio_device_changer/main.py +++ b/skills/audio_device_changer/main.py @@ -31,7 +31,7 @@ def __init__( self.original_audio_device = settings.audio.output self.current_audio_device = settings.audio.output self.backend_port = 49111 - self.wingman.audio.on_playback_finished(self.playback_finished) + self._sub_finished = self.wingman.audio.on_playback_finished(self.playback_finished) async def validate(self) -> list[WingmanInitializationError]: errors = await super().validate() @@ -108,7 +108,7 @@ async def unload(self) -> None: await super().unload() await self.reset_audio_device() - self.wingman.audio.off_playback_finished(self.playback_finished) + self._sub_finished.unsubscribe() self.printr.print( text="Audio Device Changer Skill unloaded.", From fed4e9b45c474bae0a8171360f750eb8925f5428 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:04:26 +0200 Subject: [PATCH 21/30] docs(skills): cover off_playback removal, printr/log boundary, unused imports in v3 guide --- skills/MIGRATING-TO-V3.md | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/skills/MIGRATING-TO-V3.md b/skills/MIGRATING-TO-V3.md index 8c81f24f..74604138 100644 --- a/skills/MIGRATING-TO-V3.md +++ b/skills/MIGRATING-TO-V3.md @@ -99,6 +99,7 @@ Everything you might reach for, and its v3 replacement. `await` where the v3 for | `audio_player.playback_events.subscribe("started", cb)` | `sub = self.wingman.audio.on_playback_started(cb)` | | `audio_player.playback_events.subscribe("finished", cb)` | `sub = self.wingman.audio.on_playback_finished(cb)` | | `audio_player.playback_events.unsubscribe(ev, cb)` | `sub.unsubscribe()` (keep the returned `Subscription`) | +| `self.wingman.audio.off_playback_started(cb)` / `off_playback_finished(cb)` | **removed** — capture the `Subscription` returned by `on_playback_started/finished(cb)` (e.g. `self._sub = ...`) and call `self._sub.unsubscribe()` in `unload()` | | device read / change (HTTP hack) | `self.wingman.audio.output_device` / `.set_output_device(id)` (+ `input` variants) | > **`tts.speak`'s `interrupt` is keyword-only and inverted from the old `no_interrupt`.** @@ -164,8 +165,13 @@ Everything you might reach for, and its v3 replacement. `await` where the v3 for | `self.settings.X = ...` | read-only now; change devices via `self.wingman.audio.set_output_device(...)` | | `self.printr.print(msg, ...)` | `self.log.info(msg)` / `self.log.warning(msg)` / `self.log.error(msg)` (pass `server_only=True` to keep a line out of the client toast) | -> **`self.log`** is the friendly logger. `self.log.info/warning/error(message, -> server_only=False)`. Use it instead of `self.printr`. +> **`self.log` vs `self.printr`.** `self.log.info/warning/error(message, +> server_only=False)` is the friendly logger — prefer it for plain status/debug/error +> messages (including the async `printr.print_async(msg, color=...)` calls: drop the +> `color`/`source`/`source_name`/`skill_name` kwargs and use `self.log.*`). BUT `self.printr` +> is **not removed** — keep using `self.printr.print_async(...)` for the cases `self.log` +> can't express, specifically anything passing **`additional_data=`** (e.g. shipping an +> `image_url`/`image_base64` payload to the client UI). Don't downgrade those to `self.log`. ### Gotchas that bite during migration @@ -208,6 +214,10 @@ anywhere in `skills/**`). comment mentioning `switch_tts_provider`). Update or delete them — leftover references read as "still using the old API." +**Unused imports.** Removing the last use of `self.printr`/`play_to_user`/etc. often orphans an +import (`from api.enums import LogType`, `Benchmark`, …). Grep won't flag those — delete any +import your migration made dead so the module stays clean. + **`self.settings` is now a read-only view.** Reads still work (`self.wingman.settings` is preferred); any assignment raises `FacadeError`. From a1ca924672c0a0de65979d1c4b76157ad19d6ff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:04:40 +0200 Subject: [PATCH 22/30] test(facade): grep gate for forbidden skill side-doors --- tests/test_facade_grep_gate.py | 70 ++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 tests/test_facade_grep_gate.py diff --git a/tests/test_facade_grep_gate.py b/tests/test_facade_grep_gate.py new file mode 100644 index 00000000..fcdb84c8 --- /dev/null +++ b/tests/test_facade_grep_gate.py @@ -0,0 +1,70 @@ +"""Fails if any forbidden side-door survives in skills/**. Run: + PYTHONPATH=. venv/bin/python -m tests.test_facade_grep_gate + +This is the lockdown gate: after the v3 migration, no bundled skill may reach the +runtime through a removed/raw path. skill_base.py is exempt (it defines the facade +plumbing and the FacadeError messages that name the old members). +""" +import os +import re +import sys + +FORBIDDEN = [ + r"actual_llm_call", + r"self\.wingman\.llm_call", + r"self\.local_ai\b", + r"self\.retrieve_secret\(", + r"self\.threaded_execution\(", + r"self\.wingman\.tool_skills", + r"self\.wingman\.mcp_registry", + r"self\.wingman\.skill_registry", + r"self\.wingman\.registry\b", + r"self\.wingman\.tower\b", + r"self\.wingman\.secret_keeper", + r"self\.wingman\.messages\b", + r"self\.wingman\.get_command\(", + r"self\.wingman\.get_context\(", + r"self\.wingman\.get_conversation_history\(", + r"self\.wingman\.play_to_user\(", + r"self\.wingman\.generate_image\(", + r"self\.wingman\.add_user_message\(", + r"self\.wingman\.add_assistant_message\(", + r"self\.wingman\.reset_conversation_history\(", + r"self\.wingman\.audio_player\b", + r"self\.wingman\.audio_library\b", + r"self\.wingman\.local_ai_service", + r"self\.wingman\.persistent_memory_service", + r"_tool_to_server", + r"_manifests\b", + r"switch_tts_provider", +] +PATS = [re.compile(p) for p in FORBIDDEN] + + +def test_no_side_doors(): + hits = [] + for root, dirs, files in os.walk("skills"): + if "venv" in root or "/dependencies" in root or "__pycache__" in root: + continue + for fn in files: + if not fn.endswith(".py"): + continue + path = os.path.join(root, fn) + # skip skill_base.py (defines the FacadeError messages mentioning old names) + if path.endswith("skill_base.py"): + continue + with open(path, encoding="utf-8", errors="ignore") as f: + for i, line in enumerate(f, 1): + for pat in PATS: + if pat.search(line): + hits.append(f"{path}:{i}: {line.strip()}") + if hits: + print("FORBIDDEN side-doors found:") + print("\n".join(hits)) + sys.exit(1) + print("PASS: no forbidden side-doors in skills/**") + + +if __name__ == "__main__": + test_no_side_doors() + print("ALL OK") From e1470b2cfbb4a4eb3f29350d300ceae99375e4fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:05:11 +0200 Subject: [PATCH 23/30] refactor(skills): drop orphaned SkillLocalAI type import from skill_base --- skills/skill_base.py | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/skill_base.py b/skills/skill_base.py index 446856f9..1e2f739c 100644 --- a/skills/skill_base.py +++ b/skills/skill_base.py @@ -25,7 +25,6 @@ from services.secret_keeper import SecretKeeper if TYPE_CHECKING: - from services.skill_local_ai import SkillLocalAI from wingmen.wingman_context import WingmanContext From 50f26b76aa61700e7ad4927d500add4b9c2a1d60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:13:14 +0200 Subject: [PATCH 24/30] docs(skills): document the v3 facade + require api_version in templates --- skills/AGENTS.md | 72 +++++-- skills/README.md | 515 +++++++++++++++++++++++++++-------------------- 2 files changed, 350 insertions(+), 237 deletions(-) diff --git a/skills/AGENTS.md b/skills/AGENTS.md index 64cb2426..60455746 100644 --- a/skills/AGENTS.md +++ b/skills/AGENTS.md @@ -2,6 +2,8 @@ Before creating or modifying a skill, **read [README.md](README.md)** in this directory for full documentation including custom property types, discovery metadata guidelines, dependency bundling, and example skills. +**Migrating an existing skill?** See [MIGRATING-TO-V3.md](MIGRATING-TO-V3.md). + ## STOP — Before You Start Implementing **You MUST ask the user these questions before writing any code:** @@ -187,6 +189,7 @@ class YourSkill(Skill): ```yaml module: skills.your_skill_name.main +api_version: 3 # REQUIRED for v3 — without it the skill is treated as legacy and won't load name: YourSkillName # Must match class name exactly display_name: Your Skill Name author: Your Name @@ -223,9 +226,9 @@ async def unload(self) -> None ```python self.retrieve_custom_property_value(property_id, errors) # Config value (just-in-time!) -await self.retrieve_secret(secret_name, errors, hint) # Secrets via SecretKeeper +await self.wingman.secrets.retrieve(secret_name, errors) # Secrets via SecretKeeper self.wingman.config # READ-ONLY view of the config -self.printr.print() / await self.printr.print_async() # Logging +self.log.info(msg) / self.log.warning(msg) / self.log.error(msg) # Logging (server_only=True skips the toast) self.get_generated_files_dir() # Persistent storage directory ``` @@ -241,28 +244,67 @@ but you may only **change** things through sanctioned capabilities. Writing to c # CHANGE (sanctioned capabilities only): await self.wingman.tts.set_voice(voice) # voice on the CURRENT provider (no switching) +await self.wingman.tts.speak(text, interrupt=True) # say text; interrupt=False waits for current playback self.wingman.audio.is_playing # read playback state await self.wingman.audio.play(cfg) / .stop(cfg) # play/stop your own audio -self.wingman.audio.on_playback_started(cb) / .on_playback_finished(cb) # + off_* to unsubscribe +sub = self.wingman.audio.on_playback_started(cb) # returns a Subscription; sub.unsubscribe() in unload() await self.wingman.audio.set_output_device(device_id) # switch output device in-process self.wingman.commands.get(name) / .all() / await .save() # read/edit/persist commands -self.wingman.registry.has_tool(name) / await .invoke(name, args) # discover + invoke tools/commands - -# MAIN AI — two clearly-different calls (both replace the removed self.llm_call): -text = await self.wingman.ai.generate(prompt, system=..., data=..., image=..., auto_shorten=False) -# single-turn side-call, NOT added to the conversation. Input is CAPPED when conversation -# condensation is on (Wingman Pro hardcoded; own providers config.features.skill_max_input_tokens). +self.wingman.tools.has(name) / await .invoke(name, args) # discover + invoke tools/commands -> ToolResult +self.wingman.tools.source(name) / .all() / .servers() # tool origin + enumerate callable functions / MCP servers + +# CONVERSATION: +self.wingman.conversation.history() / .summary # read the live conversation +await self.wingman.conversation.add_user(c) / .add_assistant(c) / .reset() +await self.wingman.conversation.summarize() # summarize the live convo (free, local) + +# SECRETS / MEMORY: +await self.wingman.secrets.retrieve(name, errors) # stored secret (prompts user if missing) +self.wingman.memory.available # persistent memory ready? +await self.wingman.memory.remember(c) / .recall(q) / .context(q) / .update(id, c) / .forget(q) / .forget_by_id(id) + +# MAIN AI — two clearly-different calls (both replace the removed raw LLM call): +text = await self.wingman.ai.generate(prompt, system=..., data=..., image=..., messages=..., auto_shorten=False) +# single-turn side-call, NOT added to the conversation; returns a str (""). Input is CAPPED when +# conversation condensation is on (Wingman Pro hardcoded; own providers config.features.skill_max_input_tokens). # Over the cap -> FacadeError (or truncates if auto_shorten=True). Images are charged a flat -# estimate, never the base64 length. -summary = await self.local_ai.summarize(...) # bulk reduction on the FREE local model +# estimate, never the base64 length. Pass messages= to send a prebuilt message list directly. +summary = await self.wingman.local_ai.summarize(...) # bulk reduction on the FREE local model +text2 = await self.wingman.local_ai.generate(t, system=...) # free local single-turn -> str +``` + +**Removed (do NOT use):** the raw LLM call (`self.llm_call(...)` / `actual_llm_call` — use `self.wingman.ai.generate`), +`self.wingman.switch_tts_provider(...)` (runtime provider switching is not allowed; use `tts.set_voice`), +the raw registries (`self.wingman.registry.*` — use `self.wingman.tools.*`), and writing to +`self.wingman.config` / `self.settings` (read-only; use the capabilities above). + +### Calling other skills & MCP servers + +```python +# Discover everything callable right now (with origin + params) +for tool in self.wingman.tools.all(): + self.log.info(f"{tool.name} (from {tool.source})", server_only=True) + +# Call another ACTIVE skill's tool by name +if self.wingman.tools.has("take_screenshot"): + result = await self.wingman.tools.invoke("take_screenshot", {}) + self.log.info(f"{result.response} (from {result.skill})") + +# Call your own MCP server's tool (many skills ship an MCP for their datasource) +servers = {s["display_name"] for s in self.wingman.tools.servers()} +if "My Data MCP" in servers and self.wingman.tools.has("mydata_query"): + res = await self.wingman.tools.invoke("mydata_query", {"q": "ships"}) + data = res.response +else: + self.log.warning("My Data MCP not active; skipping enriched lookup") ``` -**Removed (do NOT use):** `self.llm_call(...)` (use `ctx.ai.generate`), `self.wingman.switch_tts_provider(...)` -(runtime provider switching is not allowed), and writing to `self.wingman.config` (use the capabilities above). +MCP tool names are prefixed by the registry — use the name exactly as it appears in +`self.wingman.tools.names()` / `.all()`. -## Local Support Model — Sampling Parameters +## Local Model — Sampling Parameters -Global defaults are tuned for summarization (low temperature). **Override for creative tasks.** Use `SamplingPreset` from `services/skill_local_ai.py` or pass `temperature` / `top_p` directly to `support()`, `support_sync()`, and `summarize()`. Manual values override presets. See `SamplingPreset` docstring for available presets and values. +Global defaults are tuned for summarization (low temperature). **Override for creative tasks.** Use `SamplingPreset` from `services/skill_local_ai.py` or pass `temperature` / `top_p` directly to `self.wingman.local_ai.generate()`, `.generate_sync()`, and `.summarize()`. Manual values override presets. See `SamplingPreset` docstring for available presets and values. ## Example Skills diff --git a/skills/README.md b/skills/README.md index fe4184f8..72218c72 100644 --- a/skills/README.md +++ b/skills/README.md @@ -2,6 +2,8 @@ This guide explains how skills work in Wingman AI and how to create your own custom skills. +> Migrating an older skill? See [MIGRATING-TO-V3.md](MIGRATING-TO-V3.md). + ## Table of Contents - [What is a Skill?](#what-is-a-skill) @@ -26,14 +28,15 @@ This guide explains how skills work in Wingman AI and how to create your own cus - [Bundling Dependencies](#bundling-dependencies) - [Skill Directory Structure](#skill-directory-structure) - [AI Agent Bootstrap Checklist](#ai-agent-bootstrap-checklist) -- [Local AI API](#local-ai-api-selflocal_ai) +- [The `self.wingman` facade API](#the-selfwingman-facade-api) + - [Calling other skills & MCP servers](#calling-other-skills--mcp-servers) +- [Local AI API](#local-ai-api-selfwingmanlocal_ai) - [Overview](#overview) - [Checking Availability](#checking-availability) - - [Support Model](#support-model) + - [The Local Model](#the-local-model) - [Summarizing Large Text](#summarizing-large-text) - [Embeddings](#embeddings) - [Persistent Memory](#persistent-memory) - - [Token Budget (Advanced)](#token-budget-advanced) - [Complete API Reference](#complete-api-reference) - [Full Example: Game Stats Tracker](#full-example-game-stats-tracker) - [Additional Resources](#additional-resources) @@ -510,10 +513,8 @@ async def is_waiting_response_needed(self, tool_name: str) -> bool: class AudioDeviceChanger(Skill): def __init__(self, config, settings, wingman): super().__init__(config, settings, wingman) - # Subscribe to audio events - self.wingman.audio_player.playback_events.subscribe( - "finished", self.playback_finished - ) + # Subscribe to audio events — keep the returned Subscription + self._sub = self.wingman.audio.on_playback_finished(self.playback_finished) async def on_play_to_user(self, text: str, sound_config: SoundConfig) -> str: """Automatically change audio device before TTS playback.""" @@ -525,6 +526,10 @@ class AudioDeviceChanger(Skill): async def playback_finished(self, _): """Reset audio device after playback.""" await self.reset_audio_device() + + async def unload(self) -> None: + await super().unload() + self._sub.unsubscribe() # detach the callback ``` ### Tool-Based Skills @@ -555,7 +560,7 @@ class ImageGeneration(Skill): Args: prompt: The image generation prompt describing what to create. """ - image = await self.wingman.generate_image(prompt) + image = await self.wingman.ai.generate_image(prompt) return "Here is your generated image." @tool(description="Set a timer with specific duration and behavior") @@ -676,6 +681,7 @@ class YourSkillName(Skill): ```yaml module: skills.your_skill_name.main # Python import path +api_version: 3 # REQUIRED for v3 — skills without it are treated as legacy and won't load name: YourSkillName # Class name (must match main.py) display_name: Your Skill Name # Human-readable name (shown in UI) author: Your Name # Your name or organization @@ -736,10 +742,9 @@ Custom properties allow your skill to be configured by users through the Wingman > errors = await super().validate() > > # Use SecretKeeper for sensitive data -> api_key = await self.retrieve_secret( -> secret_name="your_service_api_key", -> errors=errors, -> hint="Get your API key from https://your-service.com/api-keys" +> api_key = await self.wingman.secrets.retrieve( +> "your_service_api_key", +> errors, > ) > > return errors @@ -1357,26 +1362,192 @@ If you're using an AI agent to create a skill, use this checklist to ensure ever --- -## Local AI API (`self.local_ai`) +## The `self.wingman` facade API + +In v3, a skill talks to the runtime **only** through `self.wingman` — a controlled facade +grouped into feature namespaces. `self.` is *your skill* (its identity, config, storage, +decorators, lifecycle hooks); `self.wingman` is *the runtime* (everything about the +wingman/app). No capability lives on both. There is no raw passthrough to the underlying +Wingman. + +> Porting an older skill from the pre-v3 surface (`self.llm_call`, `self.wingman.audio_player`, +> `self.local_ai`, `self.retrieve_secret`, the registries …)? See +> [MIGRATING-TO-V3.md](MIGRATING-TO-V3.md) for the full old → new mapping. + +The reference below lists every member, one line each. Gotchas (cap, interrupt, read-only, +`ToolResult`) are called out inline. + +### Top level — `self.wingman` + +| Member | Description | +| --- | --- | +| `.name` | This wingman's name (`str`). | +| `.config` | **Read-only** live view of the wingman config. Reads pass through to live values; any write raises `FacadeError`. Change things through a capability (`tts.set_voice`, `commands.*`, `audio.set_output_device`). | +| `.settings` | **Read-only** view of app settings. Writing raises `FacadeError`; change devices via `audio.set_output_device(...)`. | +| `.run_in_thread(fn, *args)` | Run a blocking callable off the event loop (args spread **positionally**). If `fn` is a coroutine function it's run in a fresh event loop. | + +### `self.wingman.ai` — main (cloud) model + +Single-turn side-calls on the user's main model. Results are NOT added to the conversation. + +| Member | Description | +| --- | --- | +| `await .generate(prompt="", *, system=None, data=None, image=None, messages=None, auto_shorten=False)` | Single-turn generation. Returns the response **`str`** (`""` if empty, never `None`). **Capped:** when condensation is on, combined input is limited (Wingman Pro: fixed 8,000 tokens; own provider: `features.skill_max_input_tokens`, default 16,000) — over the cap raises `FacadeError` (or truncates the prompt/data path if `auto_shorten=True`). Pass `messages=` (a prebuilt OpenAI-style list) to send your own turns directly — then `prompt`/`system`/`data`/`image` are ignored and it can't auto-shorten. | +| `await .converse(user_message)` | Conversation-aware reply using the wingman's system prompt + live history, subject to normal condensation. Appends both turns to the conversation. | +| `await .summarize(text, *, system=None)` | Summarize via the main **cloud** model (capped like `generate`). For bulk/cheap work prefer `local_ai.summarize`. | +| `await .generate_image(prompt)` | Generate an image; returns the file path/URL (`str`). | + +### `self.wingman.local_ai` — free local model + +Runs on the user's machine. Returns `""` when the local model is unavailable — check `.available`. + +| Member | Description | +| --- | --- | +| `.available` | `bool` — local model loaded and ready. | +| `await .generate(text, *, system="", preset=None, temperature=None, top_p=None, top_k=None)` | Local single-turn generation → `str`. | +| `.generate_sync(...)` | Synchronous variant of `generate`. | +| `await .summarize(text, *, instruction="", preset=None, temperature=None, top_p=None)` | Local (free) summarization → `str`; chunks large input automatically. | +| `await .embed(texts)` | Vector embeddings for a list of strings. (`.embed_sync(...)` for the sync variant.) | + +### `self.wingman.tts` — speech + +| Member | Description | +| --- | --- | +| `.voice` | The voice configured on the current provider (read). | +| `await .voices()` | All voices on the current provider (best-effort; `[]` if not cheaply enumerable). | +| `await .set_voice(voice, errors=None)` | Set the voice on the **current** provider (no provider switch) and rebuild TTS so it takes effect. Returns a human-readable result string. | +| `await .speak(text, *, interrupt=True, sound_config=None)` | Say text in the wingman's voice. `interrupt=True` (default) cuts off current playback; `interrupt=False` waits for it. `interrupt` is **keyword-only** and inverted from the old `no_interrupt`. | + +### `self.wingman.audio` — playback & devices + +| Member | Description | +| --- | --- | +| `.is_playing` | `bool` — true while the wingman is playing TTS/audio. | +| `await .play(audio_config, *, volume=1.0)` | Start playback of a skill-owned audio file. | +| `await .stop(audio_config, *, fade_out=0.5)` | Stop playback (optionally fading out). | +| `.on_playback_started(cb)` → `Subscription` | Observe playback start. Keep the returned `Subscription` and call `.unsubscribe()` in `unload()`. | +| `.on_playback_finished(cb)` → `Subscription` | Observe playback finish (same `Subscription` contract). | +| `.output_device` / `.input_device` | Currently selected audio device settings (read-only). | +| `await .set_output_device(id)` / `await .set_input_device(id)` | Switch the system audio device (in-process). Returns `False` if unavailable. | + +### `self.wingman.commands` — user commands + +| Member | Description | +| --- | --- | +| `.get(name)` | Live `CommandConfig` with this name, or `None`. | +| `.all()` | All configured commands (live objects, read-only tuple). | +| `.add(command, *, category=None)` | Add a command (optionally into a category). Call `save()` to persist. | +| `.remove(name)` | Remove a command by name. Call `save()`. | +| `.add_category(name)` → `CommandCategory` | Create/return a category (idempotent by name). | +| `.categories()` | All categories as `CommandCategory` objects. | +| `.register_function(func, *, label=None, description=None, respond="ai", parameters=None)` | Register a bound skill method as a runtime command function (dynamic `@command_action`). | +| `.add_skill_command(name, func, *, category=None, instant_phrases=None, respond="ai")` | One call: register `func`, build a command bound to it, categorize it. Call `save()`. | +| `await .save()` | Persist the commands section to disk. Returns `True` on success. | + +### `self.wingman.tools` — discover & invoke functions + +Every callable function the wingman has: your `@tool`s, other active skills' tools, MCP tools, and commands. + +| Member | Description | +| --- | --- | +| `.names()` | `set[str]` of all callable function names. | +| `.has(name)` | `bool` — is this function callable right now? | +| `.source(name)` | Human origin of a tool: the owning skill's name, or the MCP server's display name (`None` if unknown). It's a **name string**, not the skill/server object. | +| `.describe(name)` → `ToolDescriptor` | `name`, `source`, `description`, `parameters` (JSON-schema) — or `None`. | +| `.all()` | Tuple of `ToolDescriptor` for every callable function (with params). | +| `.servers()` | Active MCP servers as dicts (`name`, `display_name`, `connected`, `tools`). | +| `await .invoke(name, arguments=None)` → `ToolResult` | Call a function by name. Returns a **`ToolResult`** (`.response`, `.instant_response`, `.skill`, `.label`) — not a 4-tuple. | + +### `self.wingman.conversation` — the live conversation + +| Member | Description | +| --- | --- | +| `.history()` | Shallow copy of the live history (`list[dict]`). Don't mutate individual messages. | +| `.summary` | The condenser's running summary (`str`). | +| `await .add_user(content)` | Append a user turn. | +| `await .add_assistant(content)` | Append an assistant turn. | +| `await .summarize()` | Summarize the live conversation via the **free local** model (`""` if unavailable). | +| `await .reset()` | Reset the conversation history. | + +### `self.wingman.memory` — local persistent memory + +Free, runs locally. Returns `None`/empty when unavailable — check `.available`. + +| Member | Description | +| --- | --- | +| `.available` | `bool` — persistent memory ready (needs local AI + config). | +| `await .remember(content, **kw)` | Store a fact; auto-dedupes against similar entries. Returns the entry ID. | +| `await .recall(query, **kw)` | Semantic search; returns matches sorted by relevance. | +| `await .context(query, max_tokens=500)` | Pre-formatted memory string ready to inject into a prompt. | +| `await .update(entry_id, new_content)` | Update an entry by ID (re-embeds). | +| `await .forget(query)` | Fuzzy delete: removes the closest semantic match. | +| `await .forget_by_id(entry_id)` | Deterministic delete by ID. | + +### `self.wingman.secrets` — stored secrets + +| Member | Description | +| --- | --- | +| `await .retrieve(name, errors=None)` | Fetch a stored secret (prompts the user if missing). | + +### `self.wingman.skills` — loaded skills + +| Member | Description | +| --- | --- | +| `.active()` | Tuple of `{name, display_name}` for every loaded skill. | +| `.has(name)` | `bool` — is a skill with this name loaded? (symmetric with `tools.has`). | + +### Calling other skills & MCP servers + +Cross-skill / MCP invocation is a first-class, supported use case. Discover what's callable, +guard with `has(...)` / `servers()`, then `invoke`: + +```python +# Discover everything callable right now (with origin + params) +for tool in self.wingman.tools.all(): + self.log.info(f"{tool.name} (from {tool.source})", server_only=True) + +# Call another ACTIVE skill's tool by name +if self.wingman.tools.has("take_screenshot"): + result = await self.wingman.tools.invoke("take_screenshot", {}) + self.log.info(f"{result.response} (from {result.skill})") + +# Call your own MCP server's tool (many skills ship an MCP for their datasource) +servers = {s["display_name"] for s in self.wingman.tools.servers()} +if "My Data MCP" in servers and self.wingman.tools.has("mydata_query"): + res = await self.wingman.tools.invoke("mydata_query", {"q": "ships"}) + data = res.response +else: + self.log.warning("My Data MCP not active; skipping enriched lookup") +``` + +MCP tool names are prefixed by the registry — use the name exactly as it appears in +`self.wingman.tools.names()` / `.all()`. -Every skill has access to a `self.local_ai` facade that provides a stable, safe interface to the local AI capabilities: the support model (small local LLM), embeddings, and persistent memory. +--- + +## Local AI API (`self.wingman.local_ai`) + +Every skill can reach the local AI capabilities through `self.wingman` — the free local model +(`self.wingman.local_ai`), embeddings, and persistent memory (`self.wingman.memory`). These +run entirely on the user's machine and provide a stable, safe interface. ### Overview -The local AI features run entirely on the user's machine via llama.cpp. Users enable and configure them in Settings (model selection, context window size, GPU backend). Your skill doesn't need to worry about any of that — `self.local_ai` handles everything internally. +The local AI features run entirely on the user's machine via llama.cpp. Users enable and configure them in Settings (model selection, context window size, GPU backend). Your skill doesn't need to worry about any of that — `self.wingman.local_ai` and `self.wingman.memory` handle everything internally. **Key principles:** -- **Always available on `self`** — no imports needed, lazily initialized -- **Safe by default** — all methods handle errors internally, log them to the client, and return `None` or empty results. Skills never need `try/except` around these calls. -- **Both async and sync** — async is preferred, sync variants have a `_sync` suffix +- **Reached through `self.wingman`** — `self.wingman.local_ai` for the model/embeddings, `self.wingman.memory` for persistent memory +- **Safe by default** — methods handle errors internally, log them to the client, and return `""`/empty results. Skills never need `try/except` around these calls. +- **Both async and sync** — async is preferred; sync variants have a `_sync` suffix +- **Plain strings** — `generate`/`summarize` return a `str` (`""` when the local model is unavailable), not a response object - **Stable contract** — the internal implementation may change across versions, but this API won't break ```python # Quick taste — that's really all it takes: -result = await self.local_ai.support("Summarize this text", "You are a summarizer.") -if result: - print(result.text) +text = await self.wingman.local_ai.generate("Summarize this text", system="You are a summarizer.") +if text: + print(text) ``` ### Checking Availability @@ -1386,75 +1557,58 @@ Local AI is optional — users may not have it enabled or models may not be down ```python @tool() async def my_tool(self, query: str) -> str: - if not self.local_ai.available: + if not self.wingman.local_ai.available: return "Local AI is not enabled. Please enable it in Settings." - result = await self.local_ai.support(query, "Answer concisely.") - return result.text if result else "Processing failed." + text = await self.wingman.local_ai.generate(query, system="Answer concisely.") + return text or "Processing failed." ``` **Availability properties:** ```python -self.local_ai.available # Support model is loaded and ready -self.local_ai.embed_available # Embedding model is loaded and ready -self.local_ai.memory_available # Persistent memory is available (requires local AI + wingman config) +self.wingman.local_ai.available # Local model is loaded and ready +self.wingman.memory.available # Persistent memory is available (requires local AI + wingman config) ``` -> **Tip:** `memory_available` implies `embed_available` (memory needs embeddings). `available` and `embed_available` are independent — both models load separately. +> **Tip:** `self.wingman.memory.available` implies the embedding model is loaded (memory needs embeddings). The local model (`local_ai.available`) loads separately. -### Support Model +### The Local Model -The support model is a small local LLM (e.g., Qwen 3.5 2B) that runs on the user's machine. Use it for text processing tasks like extraction, classification, summarization, or reformatting. It's fast, free, and private — no API calls leave the machine. +The local model is a small LLM (e.g., Qwen 3.5 2B) that runs on the user's machine. Use it for text processing tasks like extraction, classification, summarization, or reformatting. It's fast, free, and private — no API calls leave the machine. ```python -async def support(text: str, system_prompt: str = "") -> SupportResponse | None -def support_sync(text: str, system_prompt: str = "") -> SupportResponse | None +async def generate(text: str, *, system: str = "", preset=None, + temperature=None, top_p=None, top_k=None) -> str +def generate_sync(text: str, *, system: str = "", ...) -> str ``` **Parameters:** -| Parameter | Type | Description | -| --------------- | ----- | -------------------------------------------------------------- | -| `text` | `str` | The input text / user prompt | -| `system_prompt` | `str` | Instructions for the model. If empty, a default prompt is used | - -**Returns `SupportResponse`:** +| Parameter | Type | Description | +| --------- | ----- | -------------------------------------------------------------- | +| `text` | `str` | The input text / user prompt | +| `system` | `str` | Instructions for the model. If empty, a default prompt is used | -```python -@dataclass(frozen=True) -class SupportResponse: - text: str | None # The model's response - prompt_tokens: int # Tokens used by the input - completion_tokens: int # Tokens generated - truncated: bool # True if output was cut off by context limit -``` - -Returns `None` if local AI is unavailable or an error occurs (error is logged to client automatically). +**Returns** a plain `str` — the model's response, or `""` if local AI is unavailable or an error occurs (error is logged to the client automatically). **Examples:** ```python # Simple text processing -result = await self.local_ai.support( - text=user_message, - system_prompt="Extract the player name and ship type from this message. Return as JSON." +text = await self.wingman.local_ai.generate( + user_message, + system="Extract the player name and ship type from this message. Return as JSON.", ) -if result and result.text: - data = json.loads(result.text) - -# Check if output was truncated -result = await self.local_ai.support(text=very_long_input, system_prompt="Summarize.") -if result and result.truncated: - # The model ran out of context — consider using summarize() instead - pass +if text: + data = json.loads(text) ``` -> **Important:** The support model has a limited context window (user-configurable, default 4096 tokens). If your input is too large, the model silently loses data beyond its context limit. For potentially large inputs, use `summarize()` instead. +> **Important:** The local model has a limited context window (user-configurable, default 4096 tokens). If your input is too large, the model silently loses data beyond its context limit. For potentially large inputs, use `summarize()` instead. #### Prompt Writing Guidelines for Small Models -The support model is a 2B-parameter model with limited instruction-following ability. Prompts that work well with large cloud models (GPT-4, Claude) will often fail here. Follow these rules when writing `system_prompt` strings: +The local model is a 2B-parameter model with limited instruction-following ability. Prompts that work well with large cloud models (GPT-4, Claude) will often fail here. Follow these rules when writing `system` strings: - **Be direct and literal.** Use short, imperative sentences. Avoid nuance, hedging, or nested clauses. - **Use labeled sections** (`Backstory:`, `Input:`, `Rules:`) instead of prose paragraphs. The model parses structured prompts more reliably. @@ -1468,8 +1622,9 @@ The support model is a 2B-parameter model with limited instruction-following abi When you have text that might exceed the model's context window (e.g., API responses, large documents), use `summarize()`. It automatically chunks the text, summarizes each chunk, and merges the results. ```python -async def summarize(text: str, instruction: str = "") -> SupportResponse | None -def summarize_sync(text: str, instruction: str = "") -> SupportResponse | None +async def summarize(text: str, *, instruction: str = "", preset=None, + temperature=None, top_p=None) -> str +def summarize_sync(text: str, *, instruction: str = "", ...) -> str ``` **Parameters:** @@ -1479,7 +1634,7 @@ def summarize_sync(text: str, instruction: str = "") -> SupportResponse | None | `text` | `str` | The text to summarize. Can be arbitrarily large | | `instruction` | `str` | Optional focus instruction (e.g., "Focus on combat stats and ship loadout") | -If the text fits in the context window, it's processed in a single call (same as `support()`). If it's too large, chunking and merging happen automatically. +If the text fits in the context window, it's processed in a single call (same as `generate()`). If it's too large, chunking and merging happen automatically. Returns a plain `str` (`""` if unavailable). **Common pattern — API call → summarize → remember:** @@ -1487,7 +1642,7 @@ If the text fits in the context window, it's processed in a single call (same as @tool(wait_response=True) async def fetch_player_stats(self, player_name: str) -> str: """Fetch and remember player statistics.""" - if not self.local_ai.available: + if not self.wingman.local_ai.available: return "Local AI is not enabled." # 1. Fetch (could be huge) @@ -1496,25 +1651,23 @@ async def fetch_player_stats(self, player_name: str) -> str: raw = await resp.text() # 2. Summarize — handles any size automatically - summary = await self.local_ai.summarize( - text=raw, - instruction="Extract key stats: rank, wins, losses, favorite loadout." + summary = await self.wingman.local_ai.summarize( + raw, + instruction="Extract key stats: rank, wins, losses, favorite loadout.", ) - if not summary or not summary.text: + if not summary: return "Failed to process stats." # 3. Remember for future conversations - if self.local_ai.memory_available: - await self.local_ai.remember_fact( - f"{player_name}'s stats: {summary.text}" - ) + if self.wingman.memory.available: + await self.wingman.memory.remember(f"{player_name}'s stats: {summary}") - return summary.text + return summary ``` -**When to use `support()` vs `summarize()`:** +**When to use `generate()` vs `summarize()`:** -| Use `support()` when | Use `summarize()` when | +| Use `generate()` when | Use `summarize()` when | | -------------------------------------------- | ------------------------------------------ | | Input size is predictable and small | Input size is unknown or potentially large | | You need precise control over the prompt | You want a hands-off summary | @@ -1533,7 +1686,7 @@ def embed_sync(texts: list[str]) -> list[list[float]] | None ```python # Generate embeddings for custom similarity search -embeddings = await self.local_ai.embed([ +embeddings = await self.wingman.local_ai.embed([ "The user prefers stealth gameplay", "The user likes aggressive combat tactics", ]) @@ -1547,35 +1700,22 @@ if embeddings: ### Persistent Memory -Persistent memory lets your skill remember facts about the user across conversations. Memories are stored locally in a SQLite database with vector embeddings for semantic search. +Persistent memory lets your skill remember facts about the user across conversations, through the `self.wingman.memory` namespace. Memories are stored locally in a SQLite database with vector embeddings for semantic search. #### Remembering Facts ```python -async def remember_fact( - content: str, - entry_type: MemoryType = MemoryType.FACT, -) -> int | None -def remember_fact_sync(...) -> int | None +async def remember(content: str, **kw) -> int | None ``` -**Automatic deduplication:** When you save a fact that's semantically similar (>90%) to an existing one, it **updates** the existing entry instead of creating a duplicate. This means you can safely call `remember_fact()` repeatedly without worrying about duplicates. +**Automatic deduplication:** When you save a fact that's semantically similar (>90%) to an existing one, it **updates** the existing entry instead of creating a duplicate. This means you can safely call `remember()` repeatedly without worrying about duplicates. ```python # First call: creates a new entry -await self.local_ai.remember_fact("Player rank: Gold 3") +await self.wingman.memory.remember("Player rank: Gold 3") # Later: player ranks up — this UPDATES the existing entry (>90% similar) -await self.local_ai.remember_fact("Player rank: Platinum 1") -``` - -**Memory types:** - -```python -from services.skill_local_ai import MemoryType - -MemoryType.FACT # Durable facts about the user (default) -MemoryType.SESSION_SUMMARY # Conversation session summaries +await self.wingman.memory.remember("Player rank: Platinum 1") ``` **Returns** the entry ID (`int`) on success, or `None` on failure. @@ -1583,12 +1723,7 @@ MemoryType.SESSION_SUMMARY # Conversation session summaries #### Recalling Memories ```python -async def recall_memory( - query: str, - limit: int = 5, - entry_type: MemoryType | None = None, -) -> list[MemorySearchResult] -def recall_memory_sync(...) -> list[MemorySearchResult] +async def recall(query: str, **kw) -> list[MemorySearchResult] ``` Search memories by semantic similarity. Returns results sorted by relevance. @@ -1607,22 +1742,15 @@ class MemorySearchResult: ```python # Search for relevant memories -results = await self.local_ai.recall_memory("player's ship and loadout", limit=3) +results = await self.wingman.memory.recall("player's ship and loadout", limit=3) for memory in results: print(f"[{memory.entry_type}] {memory.content}") - -# Filter by type -facts_only = await self.local_ai.recall_memory( - "combat preferences", - entry_type=MemoryType.FACT -) ``` #### Getting Memory Context for Prompts ```python -async def memory_context(query: str, max_tokens: int = 500) -> str -def memory_context_sync(...) -> str +async def context(query: str, max_tokens: int = 500) -> str ``` Returns a **pre-formatted string** of relevant memories, ready to inject into a system prompt. Combines facts and recent session summaries, formatted with headers. @@ -1633,10 +1761,10 @@ Returns a **pre-formatted string** of relevant memories, ready to inject into a # Build context-aware prompts async def get_prompt(self) -> str | None: """Inject relevant memories into the wingman's system prompt.""" - if not self.local_ai.memory_available: + if not self.wingman.memory.available: return None - context = await self.local_ai.memory_context("user preferences and history") + context = await self.wingman.memory.context("user preferences and history") if context: return f"What you remember about this user:\n{context}" return None @@ -1646,114 +1774,59 @@ async def get_prompt(self) -> str | None: ```python # Update a specific memory (re-embeds automatically) -async def update_memory(entry_id: int, new_content: str) -> bool -def update_memory_sync(...) -> bool +async def update(entry_id: int, new_content: str) -> bool # Delete by ID (deterministic) -async def forget_memory_by_id(entry_id: int) -> bool -def forget_memory_by_id_sync(...) -> bool +async def forget_by_id(entry_id: int) -> bool # Delete by semantic search (fuzzy — finds closest match) -async def memory_forget(query: str) -> bool -def memory_forget_sync(...) -> bool +async def forget(query: str) -> bool ``` **Two deletion strategies:** ```python -# Strategy 1: Track the ID from remember_fact() — deterministic -entry_id = await self.local_ai.remember_fact("Player owns a Cutlass Black") +# Strategy 1: Track the ID from remember() — deterministic +entry_id = await self.wingman.memory.remember("Player owns a Cutlass Black") # ... later ... -await self.local_ai.forget_memory_by_id(entry_id) # Exactly this entry +await self.wingman.memory.forget_by_id(entry_id) # Exactly this entry # Strategy 2: Fuzzy search — deletes the closest semantic match -await self.local_ai.memory_forget("Cutlass Black") # Finds and deletes closest match +await self.wingman.memory.forget("Cutlass Black") # Finds and deletes closest match ``` -> **When to use which:** Use `forget_memory_by_id()` when you tracked the ID. Use `memory_forget()` when you want to delete "anything about X" without tracking IDs. Deduplication in `remember_fact()` often makes explicit deletion unnecessary — just save the updated fact and the old one gets replaced. - -### Token Budget (Advanced) - -Most skills don't need this. Use it only if you're sending large amounts of text and need to chunk it yourself (e.g., processing documents in batches). - -```python -def get_support_model_token_budget(system_prompt: str = "") -> TokenBudget | None -``` - -```python -@dataclass(frozen=True) -class TokenBudget: - n_ctx: int # Raw context window from user settings - safe_ctx: int # Usable context after 10% safety margin - system_tokens: int # Tokens consumed by the system prompt - max_input_tokens: int # Max user-text tokens (with MIN_OUTPUT_TOKENS reserved) - min_output_tokens: int # Minimum guaranteed output tokens (256) -``` - -#### Example: Custom Chunking - -```python -budget = self.local_ai.get_support_model_token_budget("You are a summarizer.") -if not budget: - return "Local AI not available." - -# budget.max_input_tokens tells you how much text fits per call -chunk_size = budget.max_input_tokens -for chunk in split_text(document, chunk_size): - result = await self.local_ai.support(chunk, "You are a summarizer.") - # ... process result -``` - -> **Tip:** If you just want to summarize large text, use `summarize()` instead — it handles all of this automatically. +> **When to use which:** Use `forget_by_id()` when you tracked the ID. Use `forget()` when you want to delete "anything about X" without tracking IDs. Deduplication in `remember()` often makes explicit deletion unnecessary — just save the updated fact and the old one gets replaced. ### Complete API Reference ```text -self.local_ai -│ -├── Properties -│ ├── .available → bool # Support model ready? -│ ├── .embed_available → bool # Embedding model ready? -│ └── .memory_available → bool # Persistent memory ready? -│ -├── Support Model -│ ├── .support(text, system_prompt) → SupportResponse | None -│ ├── .support_sync(text, system_prompt) → SupportResponse | None -│ ├── .summarize(text, instruction) → SupportResponse | None -│ └── .summarize_sync(text, instruction) → SupportResponse | None -│ -├── Embeddings -│ ├── .embed(texts) → list[list[float]] | None -│ └── .embed_sync(texts) → list[list[float]] | None -│ -├── Memory -│ ├── .remember_fact(content, entry_type) → int | None -│ ├── .remember_fact_sync(content, entry_type) → int | None -│ ├── .recall_memory(query, limit, entry_type) → list[MemorySearchResult] -│ ├── .recall_memory_sync(query, limit, entry_type) → list[MemorySearchResult] -│ ├── .memory_context(query, max_tokens) → str -│ ├── .memory_context_sync(query, max_tokens) → str -│ ├── .update_memory(entry_id, new_content) → bool -│ ├── .update_memory_sync(entry_id, new_content) → bool -│ ├── .forget_memory_by_id(entry_id) → bool -│ ├── .forget_memory_by_id_sync(entry_id) → bool -│ ├── .memory_forget(query) → bool -│ └── .memory_forget_sync(query) → bool -│ -└── Advanced - └── .get_support_model_token_budget(system_prompt) → TokenBudget | None +self.wingman.local_ai # free local model + embeddings +│ ├── .available → bool # local model ready? +│ ├── .generate(text, *, system, preset, ...) → str +│ ├── .generate_sync(text, *, system, ...) → str +│ ├── .summarize(text, *, instruction, ...) → str +│ ├── .summarize_sync(text, *, instruction, ...) → str +│ ├── .embed(texts) → list[list[float]] | None +│ └── .embed_sync(texts) → list[list[float]] | None + +self.wingman.memory # local persistent memory + ├── .available → bool # persistent memory ready? + ├── .remember(content, **kw) → int | None + ├── .recall(query, **kw) → list[MemorySearchResult] + ├── .context(query, max_tokens) → str + ├── .update(entry_id, new_content) → bool + ├── .forget(query) → bool + └── .forget_by_id(entry_id) → bool ``` **Return type conventions:** | Return type | On failure | | ----------------- | --------------------- | -| `SupportResponse` | `None` (error logged) | -| `list[...]` | Empty list `[]` | | `str` | Empty string `""` | +| `list[...]` | Empty list `[]` | | `bool` | `False` | | `int` (entry ID) | `None` | -| `TokenBudget` | `None` | ### Full Example: Game Stats Tracker @@ -1763,7 +1836,6 @@ A complete skill that fetches player stats from an API, summarizes them with the from typing import TYPE_CHECKING from api.interface import SettingsConfig, SkillConfig, WingmanInitializationError from skills.skill_base import Skill, tool -from services.skill_local_ai import MemoryType if TYPE_CHECKING: from wingmen.wingman_context import WingmanContext @@ -1791,10 +1863,10 @@ class GameStatsTracker(Skill): async def get_prompt(self) -> str | None: """Inject remembered player facts into every conversation.""" - if not self.local_ai.memory_available: + if not self.wingman.memory.available: return None - context = await self.local_ai.memory_context("player stats and preferences") + context = await self.wingman.memory.context("player stats and preferences") if context: return f"What you remember about this player:\n{context}" return None @@ -1813,7 +1885,7 @@ class GameStatsTracker(Skill): Args: player_name: The in-game player name to look up. """ - if not self.local_ai.available: + if not self.wingman.local_ai.available: return "Local AI is not enabled. Enable it in Settings to use this feature." # 1. Fetch from API @@ -1826,21 +1898,19 @@ class GameStatsTracker(Skill): raw = await resp.text() # 2. Summarize (handles large responses automatically) - summary = await self.local_ai.summarize( - text=raw, + summary = await self.wingman.local_ai.summarize( + raw, instruction="Extract: player rank, win/loss ratio, favorite loadout, " "notable achievements. Be concise.", ) - if not summary or not summary.text: + if not summary: return "Failed to process the stats response." # 3. Remember for future conversations - if self.local_ai.memory_available: - await self.local_ai.remember_fact( - f"{player_name}: {summary.text}" - ) + if self.wingman.memory.available: + await self.wingman.memory.remember(f"{player_name}: {summary}") - return summary.text + return summary @tool( description="""Recall what you remember about a player. @@ -1855,10 +1925,10 @@ class GameStatsTracker(Skill): Args: query: What to search for (e.g., "player rank", "combat stats"). """ - if not self.local_ai.memory_available: + if not self.wingman.memory.available: return "Memory is not available. Enable Local AI and Persistent Memory in Settings." - results = await self.local_ai.recall_memory(query, limit=5) + results = await self.wingman.memory.recall(query, limit=5) if not results: return "No matching memories found." @@ -1878,10 +1948,10 @@ class GameStatsTracker(Skill): Args: query: What to forget (e.g., "player stats for Marcus"). """ - if not self.local_ai.memory_available: + if not self.wingman.memory.available: return "Memory is not available." - deleted = await self.local_ai.memory_forget(query) + deleted = await self.wingman.memory.forget(query) return "Done, I've forgotten that." if deleted else "No matching memory found." ``` @@ -1920,36 +1990,37 @@ class GameStatsTracker(Skill): ### Key APIs -**Wingman Access:** +**Wingman Access (`self.wingman` facade — see [the full reference above](#the-selfwingman-facade-api)):** ```python -self.wingman.config # Wingman configuration -self.wingman.name # Wingman name -self.wingman.generate_image() # Generate image -self.wingman.audio_player # Audio player instance +self.wingman.config # Wingman configuration (read-only) +self.wingman.name # Wingman name +await self.wingman.ai.generate(...) # Main-model side-call (capped) +await self.wingman.ai.generate_image(prompt) # Generate image +self.wingman.audio.is_playing # Is the wingman speaking? ``` -**Settings:** +**Settings (read-only):** ```python -self.settings.debug_mode # Is debug mode enabled? -self.settings.audio.output # Output device config +self.wingman.settings.debug_mode # Is debug mode enabled? +self.wingman.settings.audio.output # Output device config ``` **Utilities:** ```python -self.printr.print() # Log to console -await self.printr.print_async() # Async logging -self.get_generated_files_dir() # Get persistent storage dir +self.log.info(msg) # Friendly logging (pass server_only=True to skip the toast) +self.log.warning(msg) / self.log.error(msg) +self.get_generated_files_dir() # Get persistent storage dir +self.wingman.run_in_thread(fn, *args) # Run a blocking callable off the event loop ``` **Configuration & Secrets:** ```python self.retrieve_custom_property_value(property_id, errors) # Get config value (just-in-time!) -await self.retrieve_secret(secret_name, errors, hint) # Get secret from SecretKeeper -self.secret_keeper.retrieve() # Direct SecretKeeper access +await self.wingman.secrets.retrieve(secret_name, errors) # Get secret from SecretKeeper ``` ### Best Practices From b1ebc3d3ca8f75ed1b7f5115d9ebac01c1ada08e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:21:02 +0200 Subject: [PATCH 25/30] fix(facade): ToolResult.skill returns name not object; helpful FacadeError on removed v2 members; drop dead imports --- skills/skill_base.py | 1 - tests/test_skill_tools.py | 4 ++- tests/test_wingman_context_closed.py | 23 ++++++++++--- wingmen/facade.py | 5 ++- wingmen/wingman_context.py | 49 +++++++++++++++++++++++++++- 5 files changed, 73 insertions(+), 9 deletions(-) diff --git a/skills/skill_base.py b/skills/skill_base.py index 1e2f739c..5e440cdb 100644 --- a/skills/skill_base.py +++ b/skills/skill_base.py @@ -1,6 +1,5 @@ import asyncio import inspect -import threading from typing import ( TYPE_CHECKING, Any, diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py index e1e6d5fc..73a37998 100644 --- a/tests/test_skill_tools.py +++ b/tests/test_skill_tools.py @@ -36,7 +36,9 @@ def build_tools(self): {"function": {"name": "get_weather", "description": "d2", "parameters": {"type": "object"}}}, ] async def execute_command_by_function_call(self, name, args): - return (f"resp:{name}", "instant", "Timer", "Set Timer") + # Slot 3 is the owning Skill OBJECT in production (not a string) — return a real + # _Skill so the test verifies ToolResult.skill coerces it to the name. + return (f"resp:{name}", "instant", _Skill(), "Set Timer") def test_names_has_source(): diff --git a/tests/test_wingman_context_closed.py b/tests/test_wingman_context_closed.py index dfda37c1..08ec4d49 100644 --- a/tests/test_wingman_context_closed.py +++ b/tests/test_wingman_context_closed.py @@ -1,5 +1,6 @@ """Run: PYTHONPATH=. venv/bin/python -m tests.test_wingman_context_closed""" from wingmen.wingman_context import WingmanContext +from wingmen.facade import FacadeError class _W: @@ -14,15 +15,27 @@ def test_closed_surface(): for ns in ("ai", "local_ai", "tts", "audio", "commands", "tools", "conversation", "memory", "secrets", "skills"): assert hasattr(ctx, ns), f"missing {ns}" - # leaks removed + # removed v2 leaks raise a helpful FacadeError (spec §8), naming the replacement for leak in ("llm_call", "audio_player", "tool_skills", "mcp_registry", "skill_registry", "tower", "secret_keeper", "messages", "get_command", "get_context", "local_ai_service", - "persistent_memory_service"): - assert not hasattr(ctx, leak), f"LEAK still present: {leak}" - # raw wingman not reachable via _wingman (name-mangled) + "persistent_memory_service", "registry", "play_to_user", + "generate_image", "add_assistant_message", "retrieve_secret", + "threaded_execution"): + try: + getattr(ctx, leak) + raise AssertionError(f"LEAK still present: {leak}") + except FacadeError as e: + assert "MIGRATING-TO-V3" in str(e), f"{leak}: error must point to the guide" + # an unknown attribute still raises a plain AttributeError (not FacadeError) + try: + getattr(ctx, "totally_unknown_attr") + raise AssertionError("unknown attr should raise AttributeError") + except AttributeError: + pass + # raw wingman not reachable via _wingman (name-mangled, not in the removed map) assert not hasattr(ctx, "_wingman"), "raw _wingman must be name-mangled" - print("PASS: closed surface — sub-facades present, leaks gone, _wingman mangled") + print("PASS: closed surface — sub-facades present, removed leaks raise FacadeError, _wingman mangled") if __name__ == "__main__": diff --git a/wingmen/facade.py b/wingmen/facade.py index 06b31e7e..e7cdd9d6 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -567,8 +567,11 @@ def servers(self) -> tuple: async def invoke(self, name: str, arguments: dict | None = None) -> "ToolResult": result = await self._wingman.execute_command_by_function_call(name, arguments or {}) func_resp, instant_resp, used_skill, label = (list(result) + [None, None, None, None])[:4] + # execute_command_by_function_call returns the owning Skill object in slot 3; + # ToolResult.skill is the skill NAME (str | None) per the public contract. + skill_name = getattr(used_skill, "name", used_skill) return ToolResult(response=func_resp or "", instant_response=instant_resp or "", - skill=used_skill, label=label) + skill=skill_name, label=label) class SkillCommands: diff --git a/wingmen/wingman_context.py b/wingmen/wingman_context.py index 748b0d0a..d9b68fec 100644 --- a/wingmen/wingman_context.py +++ b/wingmen/wingman_context.py @@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: - from api.interface import WingmanConfig, SettingsConfig + from api.interface import WingmanConfig from wingmen.facade import ( SkillAi, SkillAudio, SkillCommands, SkillTools, SkillTts, SkillLocalAiView, SkillMemory, SkillConversation, SkillSecrets, SkillSkills, @@ -18,6 +18,36 @@ from wingmen.wingman import Wingman +# Removed v2 members -> the sanctioned v3 capability to use instead (spec §8). Touching +# any of these on self.wingman raises a FacadeError naming the replacement. +_REMOVED_MEMBERS = { + "llm_call": "Use self.wingman.ai.generate(...).", + "actual_llm_call": "Use self.wingman.ai.generate(...).", + "audio_player": "Use self.wingman.audio.* (is_playing, play, stop, on_playback_*).", + "audio_library": "Use self.wingman.audio.play(...) / .stop(...).", + "tool_skills": "Use self.wingman.tools.* (source/has/invoke) or self.wingman.skills.active().", + "mcp_registry": "Use self.wingman.tools.servers() / .source(name) / .invoke(name, args).", + "skill_registry": "Use self.wingman.skills.active() / .has(name).", + "registry": "Use self.wingman.tools.* (names/has/source/describe/all/servers/invoke).", + "tower": "Use self.wingman.commands.save().", + "secret_keeper": "Use self.wingman.secrets.retrieve(name).", + "messages": "Use self.wingman.conversation.history().", + "get_command": "Use self.wingman.commands.get(name).", + "get_context": "Removed (internal, no consumer).", + "local_ai_service": "Use self.wingman.local_ai.", + "persistent_memory_service": "Use self.wingman.memory.", + "play_to_user": "Use self.wingman.tts.speak(text, interrupt=...).", + "generate_image": "Use self.wingman.ai.generate_image(prompt).", + "get_conversation_history": "Use self.wingman.conversation.history().", + "add_user_message": "Use self.wingman.conversation.add_user(content).", + "add_assistant_message": "Use self.wingman.conversation.add_assistant(content).", + "reset_conversation_history": "Use self.wingman.conversation.reset().", + "retrieve_secret": "Use self.wingman.secrets.retrieve(name).", + "threaded_execution": "Use self.wingman.run_in_thread(fn, *args).", + "switch_tts_provider": "Removed; use self.wingman.tts.set_voice(...) (no provider switching).", +} + + class WingmanContext: """What skills see (`self.wingman`). Feature namespaces only — nothing raw.""" @@ -36,6 +66,23 @@ def __init__(self, wingman: "Wingman"): self.__skills = None self.__settings = None + # --- guided failure for removed v2 members (spec §8: helpful, not silent) --- + + def __getattr__(self, name: str) -> Any: + # __getattr__ only fires for names not found normally (the sub-facades are real + # properties, so they never land here). A v2 skill touching a removed member gets + # a FacadeError naming the replacement instead of a bare AttributeError. + if name.startswith("__") and name.endswith("__"): + raise AttributeError(name) + replacement = _REMOVED_MEMBERS.get(name) + if replacement is not None: + from wingmen.facade import FacadeError + raise FacadeError( + f"`self.wingman.{name}` was removed in the v3 Skill API. {replacement} " + f"See skills/MIGRATING-TO-V3.md." + ) + raise AttributeError(name) + # --- identity + config (read-only) --- @property From 3c1d6359ef3d8fa32b4aac8a4c85c86a31a0f44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:55:35 +0200 Subject: [PATCH 26/30] feat(facade): add ctx.tools.icon() + set_output_device(None) reset; restore HUD tool icons via facade --- skills/MIGRATING-TO-V3.md | 7 ++++--- skills/README.md | 1 + skills/hud/main.py | 6 ++++-- tests/test_skill_tools.py | 11 +++++++++++ wingmen/facade.py | 28 ++++++++++++++++++++++++---- 5 files changed, 44 insertions(+), 9 deletions(-) diff --git a/skills/MIGRATING-TO-V3.md b/skills/MIGRATING-TO-V3.md index 74604138..73737f62 100644 --- a/skills/MIGRATING-TO-V3.md +++ b/skills/MIGRATING-TO-V3.md @@ -149,9 +149,10 @@ Everything you might reach for, and its v3 replacement. `await` where the v3 for > - **Skill-vs-MCP discrimination:** if you need to know whether a tool came from a skill or an > MCP server, compare against the MCP display names: `mcp = {s["display_name"] for s in > self.wingman.tools.servers()}; is_mcp = source in mcp`. -> - **The skill's directory / logo path is intentionally not exposed.** There is no v3 path to -> a skill's files (the old code reaching `inspect.getfile(skill.__class__)` for a `logo.png` -> has no replacement). Drop that lookup; use the source name as the label. +> - **Skill icon/logo:** if you reached into the skill object for its `logo.png` (e.g. old code +> doing `inspect.getfile(skill.__class__)`), use `self.wingman.tools.icon(name)` — it returns +> the owning skill's `logo.png` path, or `None` for MCP tools / skills without a logo. There +> is still no general path to a skill's other files. #### Secrets, threading, image, settings, logging diff --git a/skills/README.md b/skills/README.md index 72218c72..4030af4a 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1455,6 +1455,7 @@ Every callable function the wingman has: your `@tool`s, other active skills' too | `.source(name)` | Human origin of a tool: the owning skill's name, or the MCP server's display name (`None` if unknown). It's a **name string**, not the skill/server object. | | `.describe(name)` → `ToolDescriptor` | `name`, `source`, `description`, `parameters` (JSON-schema) — or `None`. | | `.all()` | Tuple of `ToolDescriptor` for every callable function (with params). | +| `.icon(name)` | Path to the owning skill's `logo.png`, or `None` (MCP tools / no logo). For UIs that show a per-tool icon. | | `.servers()` | Active MCP servers as dicts (`name`, `display_name`, `connected`, `tools`). | | `await .invoke(name, arguments=None)` → `ToolResult` | Call a function by name. Returns a **`ToolResult`** (`.response`, `.instant_response`, `.skill`, `.label`) — not a 4-tuple. | diff --git a/skills/hud/main.py b/skills/hud/main.py index 3eb26deb..f494f919 100644 --- a/skills/hud/main.py +++ b/skills/hud/main.py @@ -906,12 +906,14 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None source = "System" source_type = "system" - # Resolve human-readable source via v3 facade + # Resolve human-readable source + icon via v3 facade + icon_path = None origin = self.wingman.tools.source(tool_name) if origin is not None: source = origin mcp_display_names = {s["display_name"] for s in self.wingman.tools.servers()} source_type = "mcp" if origin in mcp_display_names else "skill" + icon_path = self.wingman.tools.icon(tool_name) # Use tool name if configured if display_tool_names: @@ -921,7 +923,7 @@ async def on_add_assistant_message(self, message: str, tool_calls: list) -> None 'name': tool_name, 'source': source, 'type': source_type, - 'icon': None + 'icon': icon_path }) if message: diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py index 73a37998..c9c4d18a 100644 --- a/tests/test_skill_tools.py +++ b/tests/test_skill_tools.py @@ -61,6 +61,16 @@ def test_describe_all_invoke(): print("PASS: describe/all/invoke->ToolResult") +def test_icon(): + t = SkillTools(_W()) + # unknown tool / MCP tool (not in tool_skills) -> None + assert t.icon("nope") is None + assert t.icon("get_weather") is None + # a skill tool whose module dir has no logo.png -> None (no crash) + assert t.icon("set_timer") is None + print("PASS: icon() -> None when no logo / not a skill tool") + + def test_servers(): t = SkillTools(_W()) servers = t.servers() @@ -74,5 +84,6 @@ def test_servers(): if __name__ == "__main__": test_names_has_source() test_describe_all_invoke() + test_icon() test_servers() print("ALL OK") diff --git a/wingmen/facade.py b/wingmen/facade.py index e7cdd9d6..fe9be2da 100644 --- a/wingmen/facade.py +++ b/wingmen/facade.py @@ -547,6 +547,23 @@ def describe(self, name: str) -> "ToolDescriptor | None": def all(self) -> tuple: return tuple(self.describe(n) for n in self._tool_defs()) + def icon(self, name: str) -> str | None: + """Filesystem path to the owning skill's ``logo.png`` for a tool, or ``None`` (MCP + tools, commands, or skills without a logo). Lets a UI show a per-tool icon without + touching the skill object.""" + skill = (self._wingman.tool_skills or {}).get(name) + if skill is None: + return None + import inspect + import os + + try: + skill_dir = os.path.dirname(inspect.getfile(skill.__class__)) + logo_path = os.path.join(skill_dir, "logo.png") + return logo_path if os.path.exists(logo_path) else None + except Exception: + return None + def servers(self) -> tuple: """Active MCP servers as dicts: name, display_name, connected, tools (prefixed names).""" mcp = self._wingman.mcp_registry @@ -736,12 +753,15 @@ def input_device(self): audio = self._wingman.settings.audio return audio.input if audio else None - async def set_output_device(self, device_id: int) -> bool: - """Switch the system audio OUTPUT device. Returns False if unavailable.""" + async def set_output_device(self, device_id: int | None) -> bool: + """Switch the system audio OUTPUT device (in-process; persists + re-routes playback). + Pass None to reset to the system default. Returns False if device control is + unavailable (no settings service).""" return await self._set_devices(output_device=device_id) - async def set_input_device(self, device_id: int) -> bool: - """Switch the system audio INPUT device. Returns False if unavailable.""" + async def set_input_device(self, device_id: int | None) -> bool: + """Switch the system audio INPUT device (in-process). Pass None to reset to the + system default. Returns False if device control is unavailable.""" return await self._set_devices(input_device=device_id) async def _set_devices(self, input_device: int | None = None, From b86e08a630c1ee2c6f286a69a543ee597ef7da09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:55:35 +0200 Subject: [PATCH 27/30] refactor(audio_device_changer): switch devices in-process via ctx.audio.set_output_device (drop HTTP hack + backend_port) --- .../audio_device_changer/default_config.yaml | 6 -- skills/audio_device_changer/main.py | 87 +++++-------------- 2 files changed, 22 insertions(+), 71 deletions(-) diff --git a/skills/audio_device_changer/default_config.yaml b/skills/audio_device_changer/default_config.yaml index c6ebbb8a..fbf23b30 100644 --- a/skills/audio_device_changer/default_config.yaml +++ b/skills/audio_device_changer/default_config.yaml @@ -17,9 +17,3 @@ custom_properties: value: null required: false property_type: audio_device - - id: backend_port - name: Backend Port - hint: If you are running the backend on a different port, set it here. - value: 49111 - required: true - property_type: number diff --git a/skills/audio_device_changer/main.py b/skills/audio_device_changer/main.py index 4bfc4f70..f4318456 100644 --- a/skills/audio_device_changer/main.py +++ b/skills/audio_device_changer/main.py @@ -1,17 +1,11 @@ -import asyncio from typing import TYPE_CHECKING -import aiohttp -from aiohttp import ClientError - from api.interface import ( - AudioDeviceSettings, SettingsConfig, SkillConfig, SoundConfig, WingmanInitializationError, ) -from api.enums import LogType from skills.skill_base import Skill if TYPE_CHECKING: @@ -30,59 +24,29 @@ def __init__( super().__init__(config=config, settings=settings, wingman=wingman) self.original_audio_device = settings.audio.output self.current_audio_device = settings.audio.output - self.backend_port = 49111 self._sub_finished = self.wingman.audio.on_playback_finished(self.playback_finished) async def validate(self) -> list[WingmanInitializationError]: - errors = await super().validate() - - backend_port = self.retrieve_custom_property_value("backend_port", errors) - if backend_port is not None: - self.backend_port = backend_port - - return errors + return await super().validate() - async def _change_audio_device(self, device_id: int | AudioDeviceSettings) -> bool: - """Change the audio output device via HTTP request to the backend.""" + async def _change_audio_device(self, device_id: int | None) -> bool: + """Change the audio output device in-process via the facade. Pass None to reset to + the system default. Returns False if device control is unavailable.""" try: - async with aiohttp.ClientSession() as session: - url = f"http://127.0.0.1:{self.backend_port}/settings/audio-devices" - if device_id is not None: - url = f"{url}?output_device={device_id}" - - async with session.post( - url, - timeout=aiohttp.ClientTimeout(total=10), - ) as response: - if response.status == 200: - self.printr.print( - f"Audio Device Changer: changed audio device to {device_id}", - LogType.INFO, - server_only=True, - ) - return True - else: - await self.printr.print_async( - f"Audio Device Changer: Failed to change audio device. Status: {response.status}", - LogType.ERROR, - ) - return False - except ClientError as e: - await self.printr.print_async( - f"Audio Device Changer: HTTP error changing audio device: {e}", - LogType.ERROR, - ) - return False - except asyncio.TimeoutError: - await self.printr.print_async( - "Audio Device Changer: Timeout while changing audio device", - LogType.ERROR, - ) - return False + ok = await self.wingman.audio.set_output_device(device_id) + if ok: + self.log.info( + f"Audio Device Changer: changed audio device to {device_id}", + server_only=True, + ) + else: + self.log.error( + "Audio Device Changer: audio device control unavailable." + ) + return ok except Exception as e: - await self.printr.print_async( - f"Audio Device Changer: Unexpected error changing audio device: {e}", - LogType.ERROR, + self.log.error( + f"Audio Device Changer: error changing audio device: {e}" ) return False @@ -92,9 +56,8 @@ async def on_play_to_user(self, text: str, sound_config: SoundConfig): "audio_changer_device", errors ) if len(errors) > 0: - await self.printr.print_async( - f"Audio Device Changer: Error retrieving audio device settings: {errors[0].message}", - LogType.ERROR, + self.log.error( + f"Audio Device Changer: Error retrieving audio device settings: {errors[0].message}" ) elif audio_device is not None and audio_device != self.original_audio_device: self.current_audio_device = audio_device @@ -110,11 +73,7 @@ async def unload(self) -> None: self._sub_finished.unsubscribe() - self.printr.print( - text="Audio Device Changer Skill unloaded.", - color=LogType.INFO, - server_only=True, - ) + self.log.info("Audio Device Changer Skill unloaded.", server_only=True) async def reset_audio_device(self) -> None: """Resets the audio device to the original one""" @@ -122,8 +81,6 @@ async def reset_audio_device(self) -> None: if self.current_audio_device == self.original_audio_device: return await self._change_audio_device(self.original_audio_device) - self.printr.print( - text="Audio Device Changer: Reset audio device to original.", - color=LogType.INFO, - server_only=True, + self.log.info( + "Audio Device Changer: Reset audio device to original.", server_only=True ) From 0ebfe124ed92197b3735c3c1d25718686b799a17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 9 Jun 2026 09:56:28 +0200 Subject: [PATCH 28/30] chore(audio_device_changer): drop now-unused aiohttp requirement + vendored deps --- skills/audio_device_changer/requirements.txt | Bin 38 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 skills/audio_device_changer/requirements.txt diff --git a/skills/audio_device_changer/requirements.txt b/skills/audio_device_changer/requirements.txt deleted file mode 100644 index 8dd496ccb7396c6a698f2e65d0f6109eff50fb5d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 38 mcmezWFOeaWA)g_Gp#%sE7;J&im_d)h5C}n}5d$v+7Xtvjjt1NS From f56d9afb7ce9c6a8dbbdf1fd0999e75656c1a913 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 16 Jun 2026 14:45:02 +0200 Subject: [PATCH 29/30] docs(skills): complete commands/audio reference rows; tidy stale secrets comment --- skills/AGENTS.md | 2 +- skills/README.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/skills/AGENTS.md b/skills/AGENTS.md index 60455746..340672b6 100644 --- a/skills/AGENTS.md +++ b/skills/AGENTS.md @@ -226,7 +226,7 @@ async def unload(self) -> None ```python self.retrieve_custom_property_value(property_id, errors) # Config value (just-in-time!) -await self.wingman.secrets.retrieve(secret_name, errors) # Secrets via SecretKeeper +await self.wingman.secrets.retrieve(secret_name, errors) # stored secret (prompts user if missing) self.wingman.config # READ-ONLY view of the config self.log.info(msg) / self.log.warning(msg) / self.log.error(msg) # Logging (server_only=True skips the toast) self.get_generated_files_dir() # Persistent storage directory diff --git a/skills/README.md b/skills/README.md index 4030af4a..ebdc3fbe 100644 --- a/skills/README.md +++ b/skills/README.md @@ -1428,7 +1428,7 @@ Runs on the user's machine. Returns `""` when the local model is unavailable — | `.on_playback_started(cb)` → `Subscription` | Observe playback start. Keep the returned `Subscription` and call `.unsubscribe()` in `unload()`. | | `.on_playback_finished(cb)` → `Subscription` | Observe playback finish (same `Subscription` contract). | | `.output_device` / `.input_device` | Currently selected audio device settings (read-only). | -| `await .set_output_device(id)` / `await .set_input_device(id)` | Switch the system audio device (in-process). Returns `False` if unavailable. | +| `await .set_output_device(id)` / `await .set_input_device(id)` | Switch the system audio device (in-process). Pass `None` to reset to the system default. Returns `False` if unavailable. | ### `self.wingman.commands` — user commands @@ -1439,8 +1439,10 @@ Runs on the user's machine. Returns `""` when the local model is unavailable — | `.add(command, *, category=None)` | Add a command (optionally into a category). Call `save()` to persist. | | `.remove(name)` | Remove a command by name. Call `save()`. | | `.add_category(name)` → `CommandCategory` | Create/return a category (idempotent by name). | +| `.update_category(category)` / `.delete_category(id_or_name)` | Rename / remove a category. | | `.categories()` | All categories as `CommandCategory` objects. | | `.register_function(func, *, label=None, description=None, respond="ai", parameters=None)` | Register a bound skill method as a runtime command function (dynamic `@command_action`). | +| `.unregister_function(name)` | Remove a previously registered runtime command function. | | `.add_skill_command(name, func, *, category=None, instant_phrases=None, respond="ai")` | One call: register `func`, build a command bound to it, categorize it. Call `save()`. | | `await .save()` | Persist the commands section to disk. Returns `True` on success. | From 4265ec0e112832f389937e56eaf8d3c4568a64c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Hopst=C3=A4tter?= Date: Tue, 16 Jun 2026 15:10:13 +0200 Subject: [PATCH 30/30] chore: gitignore tests/ (local-only dev scripts, not shipped or CI'd) --- .gitignore | 2 + tests/memory_benchmark.py | 284 -------------------- tests/test_command_actions.py | 234 ---------------- tests/test_facade_grep_gate.py | 70 ----- tests/test_facade_misc.py | 67 ----- tests/test_facade_types.py | 39 --- tests/test_local_ai_view.py | 51 ---- tests/test_persistent_memory_concurrency.py | 115 -------- tests/test_pocket_tts_event_loop.py | 94 ------- tests/test_readonly_config.py | 116 -------- tests/test_skill_ai.py | 180 ------------- tests/test_skill_audio.py | 52 ---- tests/test_skill_base_surface.py | 28 -- tests/test_skill_catalog.py | 143 ---------- tests/test_skill_commands.py | 65 ----- tests/test_skill_tools.py | 89 ------ tests/test_skill_tts.py | 139 ---------- tests/test_wingman_context_closed.py | 43 --- 18 files changed, 2 insertions(+), 1809 deletions(-) delete mode 100644 tests/memory_benchmark.py delete mode 100644 tests/test_command_actions.py delete mode 100644 tests/test_facade_grep_gate.py delete mode 100644 tests/test_facade_misc.py delete mode 100644 tests/test_facade_types.py delete mode 100644 tests/test_local_ai_view.py delete mode 100644 tests/test_persistent_memory_concurrency.py delete mode 100644 tests/test_pocket_tts_event_loop.py delete mode 100644 tests/test_readonly_config.py delete mode 100644 tests/test_skill_ai.py delete mode 100644 tests/test_skill_audio.py delete mode 100644 tests/test_skill_base_surface.py delete mode 100644 tests/test_skill_catalog.py delete mode 100644 tests/test_skill_commands.py delete mode 100644 tests/test_skill_tools.py delete mode 100644 tests/test_skill_tts.py delete mode 100644 tests/test_wingman_context_closed.py diff --git a/.gitignore b/.gitignore index d8b2564d..5936bf52 100644 --- a/.gitignore +++ b/.gitignore @@ -170,6 +170,8 @@ frontmatter.json # Wingman AI: skills/**/dependencies +# Standalone dev test scripts (house convention: run locally with PYTHONPATH=. — not shipped/CI'd) +/tests/ whispercpp/ whispercpp-cuda/ whispercpp-models/ diff --git a/tests/memory_benchmark.py b/tests/memory_benchmark.py deleted file mode 100644 index 066d7747..00000000 --- a/tests/memory_benchmark.py +++ /dev/null @@ -1,284 +0,0 @@ -#!/usr/bin/env python3 -"""Benchmark memory extraction against representative conversations.""" - -import json -import sys -import requests - -BASE_URL = "http://localhost:49111" -WINGMAN = "ATC" - -# ── Test Conversations ───────────────────────────────────────────── - -CONVERSATIONS = { - # 1. Short casual chat (~2 min play session) - "short_casual": { - "description": "Short casual exchange, user mentions personal details", - "messages": [ - {"role": "user", "content": "Hey, I just jumped into my Cutlass Black at Port Olisar."}, - {"role": "assistant", "content": "Welcome aboard, Commander! Port Olisar is a solid starting point. Where are you headed today?"}, - {"role": "user", "content": "Thinking about heading to Hurston for some bounty missions. I've been doing those a lot lately."}, - {"role": "assistant", "content": "Good choice! Hurston has plenty of bounty contracts. Watch out for the comm arrays though — if they go down, you might pick up a crimestat accidentally."}, - {"role": "user", "content": "Yeah I learned that the hard way last week. Lost my ship and 50k credits."}, - {"role": "assistant", "content": "Ouch, that's a tough lesson. At least now you know to check the comm array status before engaging."}, - ], - "expect_summary": True, - "expect_facts": ["Cutlass Black", "bounty"], - }, - - # 2. Medium gameplay session (~10 min, mixed topics) - "medium_gameplay": { - "description": "Medium session with gameplay, lore questions, and personal info", - "messages": [ - {"role": "user", "content": "I'm at Lorville trying to find the armor shop. This city is so confusing."}, - {"role": "assistant", "content": "Lorville can be tricky to navigate! The armor shop is in the L19 district of the CBD. Take the Metro line to the CBD terminal and follow signs for Tammany and Sons."}, - {"role": "user", "content": "Found it, thanks! I need heavy armor because I keep dying in cave missions."}, - {"role": "assistant", "content": "Heavy armor is a good call for caves. I'd recommend the Overlord set — it has great ballistic protection. Make sure to bring a Pyro RYT multi-tool with a mining attachment too."}, - {"role": "user", "content": "I already have the multi-tool. My org mates told me about it. We're in an org called Stellar Dynamics, about 20 members."}, - {"role": "assistant", "content": "Nice! Stellar Dynamics sounds like a solid crew. Having org mates for cave missions makes things much safer — someone can watch the entrance while others mine."}, - {"role": "user", "content": "Exactly. We usually run 3-4 person squads. Hey, what's the lore behind Hurston Dynamics? I see their name everywhere."}, - {"role": "assistant", "content": "Hurston Dynamics is one of the oldest companies in the UEE. They manufacture weapons and are known for questionable labor practices. The Hurston family basically owns the entire planet Hurston — it's a corporate-run world where the company IS the government."}, - {"role": "user", "content": "That's wild. Reminds me of some real-world megacorps. Anyway, I'm heading into the cave now. Wish me luck!"}, - {"role": "assistant", "content": "Good luck in there, Commander! Stay close to your squad and watch your oxygen levels. Those caves can get deep."}, - {"role": "user", "content": "We made it out! Got about 150k worth of hadanite. Not bad for 30 minutes of work."}, - {"role": "assistant", "content": "That's an excellent haul! 150k in hadanite from a single run is well above average. Your squad coordination must be on point."}, - ], - "expect_summary": True, - "expect_facts": ["Stellar Dynamics", "cave", "squad"], - }, - - # 3. Long immersive session (~20 min, narrative arc) - "long_narrative": { - "description": "Extended session with a story arc — combat encounter, near-death, rescue", - "messages": [ - {"role": "user", "content": "I'm doing a cargo run from Area 18 to Microtech. Got my C2 Hercules loaded with laranite."}, - {"role": "assistant", "content": "A laranite run in a C2 — that's a high-value haul! The Area 18 to Microtech route is fairly safe, but keep an eye on your quantum fuel. That's a long jump."}, - {"role": "user", "content": "Yeah, I've done this route dozens of times. It's my main money maker. Usually pull about 200k profit per run."}, - {"role": "assistant", "content": "Consistent income! With that kind of margin, you must have a good feel for the commodity market fluctuations."}, - {"role": "user", "content": "I track prices in a spreadsheet actually. My buddy Marcus and I share it. He runs the same route in his Caterpillar."}, - {"role": "assistant", "content": "Smart approach! Having a shared price tracker gives you both an edge. The Cat is a solid hauler too, though the C2 has better combat survivability if things go south."}, - {"role": "user", "content": "Speaking of which... I just got interdicted. Two Eclipses and a Hammerhead. This doesn't look good."}, - {"role": "assistant", "content": "That's a serious ambush! With a loaded C2, your best bet is to boost toward a clear vector and spool your quantum drive. Don't try to fight a Hammerhead — just run."}, - {"role": "user", "content": "Spooling now... taking heavy fire. Shields are down to 20%. Come on come on..."}, - {"role": "assistant", "content": "Stay calm! Divert all power to shields and keep that nose pointed at your QT marker. The C2 can take a beating."}, - {"role": "user", "content": "I made it! Quantum jumped out with 5% hull. My hands are literally shaking right now."}, - {"role": "assistant", "content": "What a close call! That's the kind of moment that makes Star Citizen unforgettable. You saved a 200k cargo run with nerves of steel."}, - {"role": "user", "content": "I need to repair before I get to Microtech. Is there a station on the way?"}, - {"role": "assistant", "content": "CRU-L1 is along that route — it has repair, refuel, and rearm services. I'd stop there to patch up before the final leg."}, - {"role": "user", "content": "Good call. Heading there now. That ambush was at the Stanton-Microtech L1 point, I think pirates camp there a lot."}, - {"role": "assistant", "content": "You're right — the Lagrange points are notorious pirate camping spots, especially on high-traffic trade routes. Some traders travel with escorts for exactly this reason."}, - {"role": "user", "content": "Maybe I should hire some of my org mates as escorts. The profit margin is big enough to split."}, - {"role": "assistant", "content": "That's a smart business decision. Even splitting 200k three ways gives everyone a decent payout, and the protection virtually eliminates the risk of losing your cargo."}, - {"role": "user", "content": "Alright, I'm repaired and heading to Microtech now. Sold the laranite for 210k profit. Best run yet despite the drama!"}, - {"role": "assistant", "content": "210k profit AND a great story — can't ask for more than that! The pirate encounter probably got your heart rate up more than any FPS mission."}, - {"role": "user", "content": "For sure. I'm gonna log off after this. Thanks for the help today — you're the best wingman!"}, - {"role": "assistant", "content": "Thanks, Commander! Great flying today, especially that escape. Fly safe and see you next time!"}, - ], - "expect_summary": True, - "expect_facts": ["C2 Hercules", "laranite", "Marcus", "spreadsheet"], - }, - - # 4. Technical / ship-fitting session - "technical_loadout": { - "description": "User asking detailed ship loadout questions, reveals playstyle preferences", - "messages": [ - {"role": "user", "content": "I want to upgrade my Gladius for PvP. Right now I have the stock loadout and it's not cutting it."}, - {"role": "assistant", "content": "The stock Gladius is decent but definitely needs upgrades for PvP. What's your playstyle — do you prefer getting in close with repeaters or staying at range with cannons?"}, - {"role": "user", "content": "I like getting up close and personal. I used to fly DCS World competitively so I'm comfortable with close-range dogfighting."}, - {"role": "assistant", "content": "With a DCS background, you've got the stick skills for it! For aggressive close-range PvP, I'd recommend swapping to three Mantis GT-220 gatling guns. They have excellent DPS at close range and are very forgiving with aim."}, - {"role": "user", "content": "What about shields? I run FR-66 right now."}, - {"role": "assistant", "content": "FR-66 is solid for general use, but for PvP I'd switch to Sukoran shields. They have a unique mechanic where they don't regenerate but have massive HP pools, which suits aggressive playstyles that don't disengage."}, - {"role": "user", "content": "Interesting. And power plant?"}, - {"role": "assistant", "content": "The Centurion is the go-to for the Gladius. It provides enough power headroom for three repeaters and the Sukoran shield without any issues. You might also want to look at overclocking your coolers."}, - {"role": "user", "content": "I fly with a Virpil constellation alpha stick and throttle by the way. Dual stick setup."}, - {"role": "assistant", "content": "Excellent hardware! The Virpil Alpha is one of the best sticks for space combat. With dual stick (HOSAS), you have full 6DOF control which is a massive advantage in close-range fights."}, - {"role": "user", "content": "Yeah, I can't go back to mouse and keyboard after this. Alright, I'll try the GT-220 + Sukoran setup tonight. Thanks!"}, - {"role": "assistant", "content": "You're going to love it! With your flight sim background and that HOSAS setup, the Gladius will be a monster. Let me know how it goes!"}, - ], - "expect_summary": True, - "expect_facts": ["Gladius", "DCS", "Virpil", "PvP"], - }, - - # 5. Minimal / low-signal conversation (should extract very little) - "minimal_noise": { - "description": "Very short, low-information exchange — should produce minimal extraction", - "messages": [ - {"role": "user", "content": "Hello"}, - {"role": "assistant", "content": "Hey there, Commander! How can I help you today?"}, - {"role": "user", "content": "Just testing things out"}, - {"role": "assistant", "content": "No problem! Let me know if you need anything."}, - ], - "expect_summary": False, - "expect_facts": [], - }, - - # 6. Multi-topic session with emotional beats - "multi_topic_emotional": { - "description": "Session covering exploration, frustration, discovery, and personal goals", - "messages": [ - {"role": "user", "content": "I've been trying to find a good mining spot on Aberdeen for hours and I'm getting frustrated."}, - {"role": "assistant", "content": "Aberdeen mining can be hit or miss. The best deposits tend to spawn in the rocky valleys near the equator. Are you looking for quantanium or something more common?"}, - {"role": "user", "content": "Quantanium. I want to save up for a Reclaimer. That's my dream ship. I've been grinding for weeks."}, - {"role": "assistant", "content": "The Reclaimer is an awesome goal! It's around 14.5 million aUEC in-game. How far along are you in your savings?"}, - {"role": "user", "content": "About 8 million. So I'm over halfway there. I mine in my Prospector with a Lancet head."}, - {"role": "assistant", "content": "You're making great progress! The Lancet is perfect for quantanium — its precision mode helps with those unstable rocks. At your current rate, you might want to consider selling at the TDD on New Babbage for the best prices."}, - {"role": "user", "content": "Wait, I've been selling at Lorville this whole time. Is New Babbage better?"}, - {"role": "assistant", "content": "For quantanium specifically, New Babbage's TDD often pays 10-15% more than Lorville. That could shave days off your grind."}, - {"role": "user", "content": "Oh man, I wish I'd known that earlier. That's probably hundreds of thousands I've left on the table."}, - {"role": "assistant", "content": "Don't worry about the past runs — from here on out, you'll be earning more per load. Think of it as a lesson learned!"}, - {"role": "user", "content": "You're right. Okay, I found a big quantanium rock! 5800 mass, 22% concentration. This is a good one!"}, - {"role": "assistant", "content": "That's an excellent find! 22% concentration on a 5800 mass rock could net you around 200k+ if you mine it clean. Take your time with the fracturing — quantanium is volatile."}, - {"role": "user", "content": "Got it! Clean extraction. Heading to New Babbage now. Today was worth it after all."}, - {"role": "assistant", "content": "From frustration to a perfect rock — that's the mining life! Let me know what price you get at New Babbage. That Reclaimer fund is growing!"}, - ], - "expect_summary": True, - "expect_facts": ["Reclaimer", "Prospector", "8 million"], - }, -} - - -def run_test(name: str, convo: dict) -> dict: - """Run a single extraction test and return results.""" - url = f"{BASE_URL}/memories/{WINGMAN}/test-extraction" - resp = requests.post(url, json=convo["messages"], timeout=120) - if resp.status_code != 200: - return {"name": name, "error": f"HTTP {resp.status_code}: {resp.text}"} - - data = resp.json() - parsed = data.get("parsed") - return { - "name": name, - "description": convo["description"], - "message_count": data.get("message_count"), - "conversation_length": data.get("conversation_length"), - "raw_response": data.get("raw_response"), - "parsed": parsed, - "summary": parsed.get("summary", "") if parsed else None, - "facts": parsed.get("facts", []) if parsed else None, - } - - -def evaluate(result: dict, convo: dict) -> dict: - """Evaluate extraction quality.""" - issues = [] - scores = {} - - summary = result.get("summary") or "" - facts = result.get("facts") or [] - - # Summary quality - if convo["expect_summary"]: - if not summary: - issues.append("MISSING: Expected a summary but got none") - scores["summary"] = 0 - elif len(summary) < 40: - issues.append(f"WEAK: Summary too short ({len(summary)} chars)") - scores["summary"] = 0.3 - elif len(summary) > 500: - issues.append(f"VERBOSE: Summary too long ({len(summary)} chars)") - scores["summary"] = 0.5 - else: - scores["summary"] = 1.0 - else: - if summary and len(summary) > 20: - issues.append("NOISE: Got summary for minimal conversation") - scores["summary"] = 0.5 - else: - scores["summary"] = 1.0 - - # Facts quality — keyword matching (all keywords in expect must appear) - expected = convo["expect_facts"] - if expected: - matched = 0 - for exp in expected: - keywords = exp.lower().split() - if any( - all(kw in f.lower() for kw in keywords) - for f in facts - ): - matched += 1 - scores["fact_recall"] = matched / len(expected) if expected else 1.0 - if matched < len(expected): - missed = [ - e for e in expected - if not any( - all(kw in f.lower() for kw in e.lower().split()) - for f in facts - ) - ] - issues.append(f"MISSED FACTS: {missed}") - else: - if len(facts) > 1: - issues.append(f"NOISE: Got {len(facts)} facts for minimal conversation") - scores["fact_recall"] = 0.5 - else: - scores["fact_recall"] = 1.0 - - # Fact quality: check for non-durable / noisy facts - noise_words = ["conversation", "asked about", "the assistant", "this session", "currently at", "heading to"] - noisy = [f for f in facts if any(nw in f.lower() for nw in noise_words)] - if noisy: - issues.append(f"NOISY FACTS: {noisy}") - scores["fact_precision"] = max(0, 1.0 - len(noisy) / max(len(facts), 1)) - else: - scores["fact_precision"] = 1.0 - - overall = sum(scores.values()) / len(scores) if scores else 0 - return {"scores": scores, "overall": overall, "issues": issues} - - -def main(): - print("=" * 70) - print("MEMORY EXTRACTION BENCHMARK") - print("=" * 70) - - results = [] - for name, convo in CONVERSATIONS.items(): - print(f"\n{'─' * 60}") - print(f"TEST: {name}") - print(f" {convo['description']}") - print(f" Messages: {len(convo['messages'])}") - print(f" Running...", end=" ", flush=True) - - result = run_test(name, convo) - - if "error" in result: - print(f"ERROR: {result['error']}") - results.append(result) - continue - - print("OK") - eval_result = evaluate(result, convo) - result["evaluation"] = eval_result - results.append(result) - - s = result.get("summary") or "" - print(f"\n SUMMARY ({len(s)} chars):") - print(f" {s[:200]}{'...' if len(s) > 200 else ''}") - print(f"\n FACTS ({len(result.get('facts') or [])}):") - for f in (result.get("facts") or []): - print(f" - {f}") - print(f"\n SCORES: {eval_result['scores']}") - print(f" OVERALL: {eval_result['overall']:.2f}") - if eval_result["issues"]: - print(f" ISSUES:") - for issue in eval_result["issues"]: - print(f" ! {issue}") - - # Overall summary - print(f"\n{'=' * 70}") - print("OVERALL RESULTS") - print(f"{'=' * 70}") - scored = [r for r in results if "evaluation" in r] - if scored: - avg = sum(r["evaluation"]["overall"] for r in scored) / len(scored) - print(f" Average score: {avg:.2f}") - for r in scored: - e = r["evaluation"] - status = "PASS" if e["overall"] >= 0.8 else "WARN" if e["overall"] >= 0.5 else "FAIL" - print(f" [{status}] {r['name']:30s} {e['overall']:.2f} {', '.join(e['issues']) if e['issues'] else 'clean'}") - print() - - -if __name__ == "__main__": - main() diff --git a/tests/test_command_actions.py b/tests/test_command_actions.py deleted file mode 100644 index 4c580750..00000000 --- a/tests/test_command_actions.py +++ /dev/null @@ -1,234 +0,0 @@ -"""Standalone, hermetic verification for the @command_action machinery in -skills.skill_base. - -No pytest. Run from the project root: - - venv/bin/python -m tests.test_command_actions - -Exits non-zero on the first failed assertion; prints "ALL OK" on success. - -What this exercises: -- The @command_action decorator + CommandActionDefinition (label, respond, the - derived `speaks` UI hint, parameters_schema shape incl. Literal -> enum). -- Rejection of unsupported parameter types and invalid `respond` at decoration time. -- A real Skill subclass: _collect_command_actions(), execute_command_action() - routing by `respond` (ai vs speak), stale-param filtering, and list_command_actions(). - -The Skill is instantiated for real. Only the `settings` and `wingman` -constructor args are passed as lightweight stubs (a SimpleNamespace), because -command-action collection and execution never touch them. The `config` arg is a -genuine SkillConfig so nothing about the real construction path is faked away. -""" - -import asyncio -import types -from typing import Literal - -from api.interface import SkillConfig -from skills.skill_base import ( - Skill, - command_action, - CommandActionDefinition, -) - - -# --------------------------------------------------------------------------- -# 1. Decorator + schema + respond/speaks (definition level, no Skill needed) -# --------------------------------------------------------------------------- -def check_definition_level() -> None: - @command_action(label="Set Timer") - def set_timer(self, minutes: int, mode: Literal["a", "b"] = "a") -> str: - return f"timer {minutes} {mode}" - - cad: CommandActionDefinition = set_timer._command_action_definition - assert isinstance(cad, CommandActionDefinition), "expected a CommandActionDefinition" - assert cad.label == "Set Timer", f"label mismatch: {cad.label!r}" - assert cad.name == "set_timer", f"name mismatch: {cad.name!r}" - assert cad.respond == "ai", f"default respond should be 'ai', got {cad.respond!r}" - assert cad.speaks is False, f"respond='ai' should set speaks False, got {cad.speaks!r}" - - props = cad.parameters_schema["properties"] - assert props["minutes"]["type"] == "integer", ( - f"minutes type mismatch: {props['minutes']!r}" - ) - assert props["mode"]["enum"] == ["a", "b"], ( - f"mode enum mismatch: {props['mode']!r}" - ) - assert props["mode"]["type"] == "string", ( - f"mode (Literal[str]) should be json type string: {props['mode']!r}" - ) - # `minutes` has no default -> required; `mode` has a default -> not required. - required = cad.parameters_schema.get("required", []) - assert "minutes" in required, f"minutes should be required: {required!r}" - assert "mode" not in required, f"mode should NOT be required: {required!r}" - - # respond="speak" -> the `speaks` UI hint is True. - @command_action(label="Say It", respond="speak") - def say_it(self, phrase: str) -> str: - return phrase - - cad_speak: CommandActionDefinition = say_it._command_action_definition - assert cad_speak.respond == "speak", f"respond mismatch: {cad_speak.respond!r}" - assert cad_speak.speaks is True, ( - f"respond='speak' should set speaks True, got {cad_speak.speaks!r}" - ) - - # Default label falls back to the function name; default respond is 'ai'. - @command_action() - def silent(self) -> None: - return None - - cad_silent: CommandActionDefinition = silent._command_action_definition - assert cad_silent.label == "silent", f"default label mismatch: {cad_silent.label!r}" - assert cad_silent.speaks is False, f"default speaks should be False: {cad_silent.speaks!r}" - - -# --------------------------------------------------------------------------- -# 2. Invalid declarations rejected at decoration time -# --------------------------------------------------------------------------- -def check_invalid_declarations_rejected() -> None: - # Unsupported parameter type. - raised = False - try: - - @command_action() - def bad_param(self, payload: dict) -> str: - return "nope" - - except ValueError as exc: - raised = True - assert "unsupported type" in str(exc), ( - f"ValueError should mention 'unsupported type', got: {exc}" - ) - assert raised, "a dict param should raise ValueError" - - # Invalid respond mode. - raised = False - try: - - @command_action(respond="bogus") - def bad_respond(self) -> str: - return "" - - except ValueError as exc: - raised = True - assert "respond must be" in str(exc), ( - f"ValueError should mention 'respond must be', got: {exc}" - ) - assert raised, "an invalid respond value should raise ValueError" - - -# --------------------------------------------------------------------------- -# 3. Real Skill subclass: collection + execution routing + listing -# --------------------------------------------------------------------------- -class FakeSkill(Skill): - @command_action(label="Set Timer") - def set_timer(self, minutes: int, mode: Literal["a", "b"] = "a") -> str: - return f"timer {minutes} {mode}" - - @command_action(label="Silent", description="A silent fire-and-forget action.") - def silent(self) -> None: - return None - - @command_action(label="Speak Action", respond="speak") - def speak_action(self, text: str) -> str: - return text - - # An async command action to exercise the await path (respond defaults to "ai"). - @command_action(label="Async Action") - async def async_action(self, value: int) -> str: - return f"async {value}" - - -def _build_skill() -> FakeSkill: - config = SkillConfig( - module="skills.x.main", - name="Fake", - display_name="Fake", - description={"en": "x"}, - ) - settings = types.SimpleNamespace() # never touched by command-action machinery - wingman = types.SimpleNamespace() - return FakeSkill(config=config, settings=settings, wingman=wingman) - - -async def check_real_skill() -> None: - skill = _build_skill() - - # _collect_command_actions populated the registry. - assert set(skill._command_actions.keys()) == { - "set_timer", - "silent", - "speak_action", - "async_action", - }, f"unexpected collected actions: {sorted(skill._command_actions)}" - - # respond="ai" str return -> (function_response, '') : AI gets it, nothing spoken verbatim. - res = await skill.execute_command_action("set_timer", {"minutes": 5, "mode": "b"}) - assert res == ("timer 5 b", ""), f"set_timer (respond=ai) result mismatch: {res!r}" - - # respond="speak" str return -> (text, text) : spoken verbatim AND given to the AI. - res = await skill.execute_command_action("speak_action", {"text": "Boom"}) - assert res == ("Boom", "Boom"), f"speak_action (respond=speak) result mismatch: {res!r}" - - # -> None method -> ('', '') : fire-and-forget, command falls through to "OK". - res = await skill.execute_command_action("silent", {}) - assert res == ("", ""), f"silent result mismatch: {res!r}" - - # Unknown function name -> ('', ''). - res = await skill.execute_command_action("does_not_exist", {}) - assert res == ("", ""), f"unknown action result mismatch: {res!r}" - - # Async action awaited correctly (respond=ai). - res = await skill.execute_command_action("async_action", {"value": 7}) - assert res == ("async 7", ""), f"async result mismatch: {res!r}" - - # Stale/extra params (e.g. left over after switching the selected function in the - # editor) are dropped, not forwarded — no "unexpected keyword argument" crash. - res = await skill.execute_command_action( - "set_timer", {"minutes": 9, "mode": "a", "reason": "leftover", "junk": 1} - ) - assert res == ("timer 9 a", ""), f"stale-param filtering failed: {res!r}" - - # list_command_actions: list of dicts with the required keys. - listed = skill.list_command_actions() - assert isinstance(listed, list), f"list_command_actions should return a list: {type(listed)}" - assert len(listed) == 4, f"expected 4 listed actions, got {len(listed)}" - required_keys = { - "skill_name", - "function_name", - "label", - "description", - "speaks", - "parameters_schema", - } - for entry in listed: - assert isinstance(entry, dict), f"each entry must be a dict: {entry!r}" - assert required_keys.issubset(entry.keys()), ( - f"entry missing keys {required_keys - set(entry.keys())}: {entry!r}" - ) - assert entry["skill_name"] == "FakeSkill", ( - f"skill_name should be the class name: {entry['skill_name']!r}" - ) - - by_name = {e["function_name"]: e for e in listed} - assert by_name["set_timer"]["label"] == "Set Timer" - assert by_name["set_timer"]["speaks"] is False # respond="ai" - assert by_name["speak_action"]["speaks"] is True # respond="speak" - assert by_name["silent"]["speaks"] is False - assert by_name["silent"]["description"] == "A silent fire-and-forget action." - assert ( - by_name["set_timer"]["parameters_schema"]["properties"]["mode"]["enum"] - == ["a", "b"] - ) - - -def main() -> None: - check_definition_level() - check_invalid_declarations_rejected() - asyncio.run(check_real_skill()) - print("ALL OK") - - -if __name__ == "__main__": - main() diff --git a/tests/test_facade_grep_gate.py b/tests/test_facade_grep_gate.py deleted file mode 100644 index fcdb84c8..00000000 --- a/tests/test_facade_grep_gate.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Fails if any forbidden side-door survives in skills/**. Run: - PYTHONPATH=. venv/bin/python -m tests.test_facade_grep_gate - -This is the lockdown gate: after the v3 migration, no bundled skill may reach the -runtime through a removed/raw path. skill_base.py is exempt (it defines the facade -plumbing and the FacadeError messages that name the old members). -""" -import os -import re -import sys - -FORBIDDEN = [ - r"actual_llm_call", - r"self\.wingman\.llm_call", - r"self\.local_ai\b", - r"self\.retrieve_secret\(", - r"self\.threaded_execution\(", - r"self\.wingman\.tool_skills", - r"self\.wingman\.mcp_registry", - r"self\.wingman\.skill_registry", - r"self\.wingman\.registry\b", - r"self\.wingman\.tower\b", - r"self\.wingman\.secret_keeper", - r"self\.wingman\.messages\b", - r"self\.wingman\.get_command\(", - r"self\.wingman\.get_context\(", - r"self\.wingman\.get_conversation_history\(", - r"self\.wingman\.play_to_user\(", - r"self\.wingman\.generate_image\(", - r"self\.wingman\.add_user_message\(", - r"self\.wingman\.add_assistant_message\(", - r"self\.wingman\.reset_conversation_history\(", - r"self\.wingman\.audio_player\b", - r"self\.wingman\.audio_library\b", - r"self\.wingman\.local_ai_service", - r"self\.wingman\.persistent_memory_service", - r"_tool_to_server", - r"_manifests\b", - r"switch_tts_provider", -] -PATS = [re.compile(p) for p in FORBIDDEN] - - -def test_no_side_doors(): - hits = [] - for root, dirs, files in os.walk("skills"): - if "venv" in root or "/dependencies" in root or "__pycache__" in root: - continue - for fn in files: - if not fn.endswith(".py"): - continue - path = os.path.join(root, fn) - # skip skill_base.py (defines the FacadeError messages mentioning old names) - if path.endswith("skill_base.py"): - continue - with open(path, encoding="utf-8", errors="ignore") as f: - for i, line in enumerate(f, 1): - for pat in PATS: - if pat.search(line): - hits.append(f"{path}:{i}: {line.strip()}") - if hits: - print("FORBIDDEN side-doors found:") - print("\n".join(hits)) - sys.exit(1) - print("PASS: no forbidden side-doors in skills/**") - - -if __name__ == "__main__": - test_no_side_doors() - print("ALL OK") diff --git a/tests/test_facade_misc.py b/tests/test_facade_misc.py deleted file mode 100644 index 230b4a1b..00000000 --- a/tests/test_facade_misc.py +++ /dev/null @@ -1,67 +0,0 @@ -"""Run: PYTHONPATH=. venv/bin/python -m tests.test_facade_misc""" -import asyncio -from wingmen.facade import SkillConversation, SkillSecrets, SkillSkills, SkillSettings, FacadeError - - -class _Conv: - def __init__(self): self.messages = [{"role": "user", "content": "hi"}]; self.asst = [] - async def add_assistant_message(self, c): self.asst.append(c) - - -class _Cond: summary = "the summary" - - -class _SM: - skills = [type("S", (), {"name": "Timer", "config": type("C", (), {"display_name": "Timer"})()})()] - - -class _Audio: output = "out"; input = "in" - - -class _Settings: audio = _Audio() - - -class _W: - def __init__(self): - self.conversation = _Conv(); self.condenser = _Cond() - self.skill_manager = _SM(); self.settings = _Settings() - self.added_user = None - async def add_user_message(self, c): self.added_user = c - async def reset_conversation_history(self): self.conversation.messages = [] - async def retrieve_secret(self, name, errors, is_required=True): return f"secret:{name}" - - -def test_conversation(): - w = _W(); c = SkillConversation(w) - assert c.history() == [{"role": "user", "content": "hi"}] - assert c.summary == "the summary" - asyncio.get_event_loop().run_until_complete(c.add_user("yo")) - assert w.added_user == "yo" - asyncio.get_event_loop().run_until_complete(c.add_assistant("ok")) - assert w.conversation.asst == ["ok"] - print("PASS: conversation") - - -def test_secrets_and_skills_and_settings(): - w = _W() - s = SkillSecrets(w) - assert asyncio.get_event_loop().run_until_complete(s.retrieve("API")) == "secret:API" - sk = SkillSkills(w) - active = sk.active() - assert active[0]["name"] == "Timer" - assert sk.has("Timer") and not sk.has("Nope") - st = SkillSettings(w) - assert st.output_device == "out" and st.input_device == "in" - try: - st.audio = 1 - raised = False - except FacadeError: - raised = True - assert raised, "settings write must raise FacadeError" - print("PASS: secrets/skills/settings") - - -if __name__ == "__main__": - test_conversation() - test_secrets_and_skills_and_settings() - print("ALL OK") diff --git a/tests/test_facade_types.py b/tests/test_facade_types.py deleted file mode 100644 index f1688b58..00000000 --- a/tests/test_facade_types.py +++ /dev/null @@ -1,39 +0,0 @@ -"""Standalone verification for facade value types. Run: - PYTHONPATH=. venv/bin/python -m tests.test_facade_types -""" -from wingmen.facade import ToolResult, ToolDescriptor, Subscription, CommandCategory - - -def test_tool_result(): - r = ToolResult(response="hi", instant_response="", skill="Timer", label="set_timer") - assert r.response == "hi" and r.skill == "Timer" - print("PASS: ToolResult") - - -def test_tool_descriptor(): - d = ToolDescriptor(name="set_timer", source="Timer", description="d", parameters={"type": "object"}) - assert d.name == "set_timer" and d.parameters["type"] == "object" - print("PASS: ToolDescriptor") - - -def test_subscription(): - calls = [] - sub = Subscription(lambda: calls.append(1)) - sub.unsubscribe() - sub.unsubscribe() # idempotent - assert calls == [1], calls - print("PASS: Subscription") - - -def test_command_category(): - cat = CommandCategory(id="abc", name="Timers") - assert cat.id == "abc" and cat.name == "Timers" - print("PASS: CommandCategory") - - -if __name__ == "__main__": - test_tool_result() - test_tool_descriptor() - test_subscription() - test_command_category() - print("ALL OK") diff --git a/tests/test_local_ai_view.py b/tests/test_local_ai_view.py deleted file mode 100644 index 23d48cd6..00000000 --- a/tests/test_local_ai_view.py +++ /dev/null @@ -1,51 +0,0 @@ -"""Run: PYTHONPATH=. venv/bin/python -m tests.test_local_ai_view""" -import asyncio -from wingmen.facade import SkillLocalAiView, SkillMemory - - -class _Resp: - def __init__(self, text): self.text = text - - -class _FakeLocalAI: - available = True - async def support(self, text, **kw): return _Resp(f"gen:{text}") - def support_sync(self, text, **kw): return _Resp(f"gens:{text}") - async def summarize(self, text, **kw): return _Resp(f"sum:{text}") - def summarize_sync(self, text, **kw): return _Resp(f"sums:{text}") - async def embed(self, texts): return [[0.0]] - def embed_sync(self, texts): return [[0.0]] - async def remember_fact(self, content, **kw): return 1 - async def recall_memory(self, query, **kw): return ["x"] - - -class _FakeWingman: - def __init__(self): self._la = _FakeLocalAI() - local_ai_service = True - @property - def _local_ai_facade(self): return self._la - - -def test_localai_generate(): - la = SkillLocalAiView(_FakeLocalAI()) - out = asyncio.get_event_loop().run_until_complete(la.generate("hi")) - assert out == "gen:hi", out - assert la.generate_sync("hi") == "gens:hi" - assert asyncio.get_event_loop().run_until_complete(la.summarize("t")) == "sum:t" - assert la.available is True - print("PASS: local_ai view generate/summarize") - - -def test_memory(): - mem = SkillMemory(_FakeLocalAI()) - out = asyncio.get_event_loop().run_until_complete(mem.remember("fact")) - assert out == 1 - out2 = asyncio.get_event_loop().run_until_complete(mem.recall("q")) - assert out2 == ["x"] - print("PASS: memory remember/recall") - - -if __name__ == "__main__": - test_localai_generate() - test_memory() - print("ALL OK") diff --git a/tests/test_persistent_memory_concurrency.py b/tests/test_persistent_memory_concurrency.py deleted file mode 100644 index aaec7f6e..00000000 --- a/tests/test_persistent_memory_concurrency.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Concurrency stress test for services.persistent_memory.PersistentMemoryService. - -No pytest. Run from the project root: - - venv/bin/python -m tests.test_persistent_memory_concurrency - -Exits non-zero on the first failed assertion or crash; prints "ALL OK" on success. - -Why this exists ---------------- -The service opens its SQLite connection with ``check_same_thread=False`` and the -async API fans every operation out onto the default thread pool via -``asyncio.to_thread``. A single ``sqlite3.Connection`` is NOT safe for concurrent -use across threads: two threads inside ``execute()``/``commit()`` at the same time -corrupt SQLite's internal heap and the process dies with a native SIGSEGV -(EXC_BAD_ACCESS) -- no Python traceback, no graceful shutdown, just a stray -"leaked semaphore" warning from the multiprocessing resource_tracker. - -Against the UNFIXED code this stress loop reliably segfaults or raises sqlite3 -errors ("database is locked", "Recursive use of cursors not allowed", etc.). -With per-instance locking it runs clean and the row count stays consistent. -""" - -import os -import tempfile -import threading - -import services.persistent_memory as pm -from services.persistent_memory import PersistentMemoryService, EMBEDDING_DIMENSIONS - - -class _FakeLocalAI: - """Minimal stand-in: deterministic, cheap embeddings, no model needed. - - Each content string starts with a unique integer index (": ..."). We - return a one-hot vector at that index, so every fact is orthogonal to every - other (cosine 0) and the service's fact-dedup (threshold 0.9) never collapses - them -- the final row count is then an exact, order-independent invariant. - """ - - def embed(self, texts): - out = [] - for text in texts: - idx = int(text.split(":", 1)[0]) - vec = [0.0] * EMBEDDING_DIMENSIONS - vec[idx % EMBEDDING_DIMENSIONS] = 1.0 - out.append(vec) - return out - - -def check_concurrent_access() -> None: - threads_count = 16 - iterations = 40 - - tmp_dir = tempfile.mkdtemp(prefix="wingman_mem_test_") - # Redirect the service's db directory to our temp dir. - pm.get_persistent_memory_dir = lambda: tmp_dir - - service = PersistentMemoryService("StressTester", _FakeLocalAI()) - service.initialize() - - errors: list[BaseException] = [] - barrier = threading.Barrier(threads_count) - - def worker(worker_id: int) -> None: - # Maximise the chance of overlapping execute()/commit() on the shared - # connection by releasing all threads at the same instant. - barrier.wait() - try: - for i in range(iterations): - # Globally unique index -> orthogonal embedding -> never deduped. - unique_idx = worker_id * iterations + i - service.add_memory_sync( - entry_type="fact", - content=f"{unique_idx}: worker {worker_id} fact {i}", - ) - service.search_sync("0: probe query", limit=5, entry_type="fact") - service.get_all(entry_type="fact") - except BaseException as exc: # noqa: BLE001 - we want everything - errors.append(exc) - - workers = [ - threading.Thread(target=worker, args=(w,)) for w in range(threads_count) - ] - for t in workers: - t.start() - for t in workers: - t.join() - - assert not errors, f"Concurrent access raised: {errors[:3]}" - - # Every add was a unique fact (dedup can't collapse them), so the row count - # must be exactly threads_count * iterations -- proof no writes were lost or - # double-counted by a corrupted/raced transaction. - rows = service.get_all(entry_type="fact") - expected = threads_count * iterations - assert len(rows) == expected, f"Expected {expected} rows, got {len(rows)}" - - service.close() - # Best-effort cleanup of the temp db. - try: - for name in os.listdir(tmp_dir): - os.remove(os.path.join(tmp_dir, name)) - os.rmdir(tmp_dir) - except OSError: - pass - - -def main() -> None: - check_concurrent_access() - print("ALL OK") - - -if __name__ == "__main__": - main() diff --git a/tests/test_pocket_tts_event_loop.py b/tests/test_pocket_tts_event_loop.py deleted file mode 100644 index ba572c66..00000000 --- a/tests/test_pocket_tts_event_loop.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Regression test for the PocketTTS synthesis lock across event loops. - -No pytest. Run from the project root: - - venv/bin/python -m tests.test_pocket_tts_event_loop - -Exits non-zero on the first failed assertion; prints "ALL OK" on success. - -Why this exists ---------------- -PocketTTS is a long-lived singleton, but synthesis is awaited from different -event loops over the app's lifetime -- each push-to-talk interaction runs on its -own loop (see the asyncio.new_event_loop() handlers in wingman_core). A plain -asyncio.Lock() created in __init__ binds to the loop it is first used on and -raises " is bound to a different event loop" when awaited on any other -loop, which broke TTS on the second/third interaction. _gen_lock() returns a -lock bound to the *current* running loop instead. - -This test builds a bare PocketTTS (bypassing the heavy model-loading __init__), -drives _gen_lock() under real contention across two separate loops, and asserts -no cross-loop error -- and, as a control, that the old single-lock pattern really -does fail that way. - -Note: an asyncio.Lock only binds to a loop on the *contended* path (when a -coroutine actually has to wait), which is why the real failure log shows the -lock as "[locked, waiters:1]". So every round below runs a holder + a waiter to -force that contention -- an uncontended acquire would never reproduce the bug. -""" - -import asyncio - - -def _make_bare_shared(): - """A PocketTTS with only the lock fields initialised (no model load).""" - from providers.pocket_tts import PocketTTS - - obj = object.__new__(PocketTTS) - obj._async_gen_lock = None - obj._async_gen_lock_loop = None - return obj - - -async def _contended_round(get_lock) -> None: - """Run a holder + a waiter so the lock takes its contended (binding) path.""" - held = asyncio.Event() - - async def holder() -> None: - async with get_lock(): - held.set() - await asyncio.sleep(0.05) - - async def waiter() -> None: - await held.wait() - async with get_lock(): # must wait -> hits _get_loop() -> loop binding - pass - - await asyncio.gather(holder(), waiter()) - - -def check_lock_works_across_loops() -> None: - shared = _make_bare_shared() - - # Loop A, then loop B (a brand new loop) -- the exact sequence that crashed. - asyncio.run(_contended_round(shared._gen_lock)) - lock_a = shared._async_gen_lock - asyncio.run(_contended_round(shared._gen_lock)) # must NOT raise - lock_b = shared._async_gen_lock - - assert lock_a is not lock_b, "a new running loop should get a fresh lock" - - -def check_old_pattern_was_broken() -> None: - """Control: prove a single shared asyncio.Lock really does fail across loops, - so the test above is actually exercising the failure mode it claims to.""" - lock = asyncio.Lock() - - asyncio.run(_contended_round(lambda: lock)) # binds the lock to loop A - - raised = False - try: - asyncio.run(_contended_round(lambda: lock)) # loop B -> should blow up - except RuntimeError as exc: - raised = "different event loop" in str(exc) - assert raised, "expected the single-lock pattern to fail across loops" - - -def main() -> None: - check_old_pattern_was_broken() - check_lock_works_across_loops() - print("ALL OK") - - -if __name__ == "__main__": - main() diff --git a/tests/test_readonly_config.py b/tests/test_readonly_config.py deleted file mode 100644 index 5c43120f..00000000 --- a/tests/test_readonly_config.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Standalone test for ReadOnlyConfigView + FacadeError. - -Run: PYTHONPATH=. venv/bin/python tests/test_readonly_config.py -Expected last line: ALL OK -""" - -from types import MappingProxyType - -from pydantic import BaseModel - -from wingmen.facade import FacadeError, ReadOnlyConfigView, _ReadOnlyList - - -# --- a small nested model hierarchy mirroring the real config shape --- - -class Inner(BaseModel): - voice: str = "alloy" - streaming: bool = False - - -class VoiceEntry(BaseModel): - provider: str - voice: str - - -class Outer(BaseModel): - name: str = "Computer" - openai: Inner = Inner() - voices: list[VoiceEntry] = [] - flags: dict = {} - - -def expect_facade_error(fn, what): - try: - fn() - except FacadeError: - return - raise AssertionError(f"expected FacadeError when {what}, but no error was raised") - - -def main(): - model = Outer( - openai=Inner(voice="nova", streaming=True), - voices=[VoiceEntry(provider="openai", voice="echo"), - VoiceEntry(provider="azure", voice="jane")], - flags={"x": 1, "nested": Inner(voice="shimmer")}, - ) - view = ReadOnlyConfigView(model) - - # 1. scalar reads pass through - assert view.name == "Computer", view.name - - # 2. nested model reads pass through (and stay live) - assert view.openai.voice == "nova" - assert view.openai.streaming is True - model.openai.voice = "fable" # mutate underlying directly - assert view.openai.voice == "fable", "view should reflect live config, not a snapshot" - - # 3. top-level write raises - expect_facade_error(lambda: setattr(view, "name", "Hacked"), "setting a top-level field") - assert model.name == "Computer", "underlying must be untouched" - - # 4. nested write raises (the key hole: config.openai.tts_voice = ...) - expect_facade_error(lambda: setattr(view.openai, "voice", "Hacked"), - "setting a nested field") - assert model.openai.voice == "fable" - - # 5. delete raises - expect_facade_error(lambda: delattr(view, "name"), "deleting a field") - - # 6. list is read-only but iterable/indexable - assert len(view.voices) == 2 - assert view.voices[0].voice == "echo" - assert [v.provider for v in view.voices] == ["openai", "azure"] - expect_facade_error(lambda: view.voices.__setitem__(0, VoiceEntry(provider="x", voice="y")), - "assigning a list element") - # 6b. and elements grabbed from the list are themselves read-only - expect_facade_error(lambda: setattr(view.voices[0], "voice", "Hacked"), - "mutating a model fetched from a read-only list") - assert model.voices[0].voice == "echo" - - # 7. dict becomes a read-only mapping; nested models inside stay wrapped - assert isinstance(view.flags, MappingProxyType) - assert view.flags["x"] == 1 - assert isinstance(view.flags["nested"], ReadOnlyConfigView) - expect_facade_error(lambda: setattr(view.flags["nested"], "voice", "Hacked"), - "mutating a model nested inside a dict") - try: - view.flags["x"] = 2 # MappingProxyType blocks writes with TypeError - raise AssertionError("expected the read-only mapping to block writes") - except TypeError: - pass - - # 8. methods still callable for reads (model_dump returns a plain copy) - dumped = view.openai.model_dump() - assert dumped["voice"] == "fable" - dumped["voice"] = "whatever" # mutating the copy must not touch live config - assert model.openai.voice == "fable" - - # 9. equality compares the underlying model - assert view == model - assert view.openai == model.openai - - # 10. deepcopy of a read-only view yields a real MUTABLE detached model copy - import copy - snap = copy.deepcopy(view.openai) - assert isinstance(snap, Inner) and not isinstance(snap, ReadOnlyConfigView) - snap.voice = "changed" # must be mutable - assert snap.voice == "changed" - assert model.openai.voice == "fable" # live config untouched - - print("ALL OK") - - -if __name__ == "__main__": - main() diff --git a/tests/test_skill_ai.py b/tests/test_skill_ai.py deleted file mode 100644 index 1fb848c2..00000000 --- a/tests/test_skill_ai.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Standalone test for ctx.ai.generate cap logic (SkillAi). - -Run: PYTHONPATH=. venv/bin/python tests/test_skill_ai.py -Expected last line: ALL OK -""" - -import asyncio -from types import SimpleNamespace - -from api.enums import ConversationProvider -from wingmen.facade import FacadeError, SkillAi, WINGMAN_PRO_MAX_INPUT_TOKENS - - -class FakeChoice: - def __init__(self, content): - self.message = SimpleNamespace(content=content) - - -class FakeCompletion: - def __init__(self, content): - self.choices = [FakeChoice(content)] - - -class FakeWingman: - def __init__(self, provider, condense=True, skill_cap=16000): - self.config = SimpleNamespace( - features=SimpleNamespace( - conversation_provider=provider, - condense_conversation=condense, - skill_max_input_tokens=skill_cap, - ) - ) - self.last_messages = None - - async def actual_llm_call(self, messages, tools=None): - self.last_messages = messages - return FakeCompletion("RESULT") - - -async def main(): - big = "word " * 20000 # ~20k tokens, over any cap - - # 1. own provider, condense ON, oversized -> FacadeError naming the limit - w = FakeWingman(ConversationProvider.OPENAI, condense=True, skill_cap=16000) - ai = SkillAi(w) - try: - await ai.generate("summarize", data=big) - raise AssertionError("expected FacadeError for oversized side-call") - except FacadeError as e: - assert "16000" in str(e), str(e) - - # 2. Wingman Pro -> hardcoded 8000 cap regardless of skill_max_input_tokens - w = FakeWingman(ConversationProvider.WINGMAN_PRO, condense=True, skill_cap=999999) - ai = SkillAi(w) - assert ai._max_input_tokens() == WINGMAN_PRO_MAX_INPUT_TOKENS - try: - await ai.generate("summarize", data=big) - raise AssertionError("expected FacadeError on Pro") - except FacadeError as e: - assert "8000" in str(e), str(e) - - # 3. condense OFF -> no cap, request goes through even when huge - w = FakeWingman(ConversationProvider.OPENAI, condense=False) - ai = SkillAi(w) - out = await ai.generate("summarize", data=big) - assert out == "RESULT" - - # 4. auto_shorten -> truncates instead of raising; call succeeds - w = FakeWingman(ConversationProvider.OPENAI, condense=True, skill_cap=500) - ai = SkillAi(w) - out = await ai.generate("summarize", data=big, auto_shorten=True) - assert out == "RESULT" - # the user message must have been shortened to roughly the cap - user_msg = w.last_messages[-1]["content"] - from services.token_utils import count_tokens - assert count_tokens(user_msg) <= 500, count_tokens(user_msg) - - # 5. image is charged a flat estimate, NOT the base64 length (small prompt + huge - # base64 string must still pass under a generous cap) - fake_b64 = "data:image/jpeg;base64," + ("A" * 100000) - w = FakeWingman(ConversationProvider.OPENAI, condense=True, skill_cap=4000) - ai = SkillAi(w) - out = await ai.generate("what is this?", image=fake_b64) - assert out == "RESULT", "image side-call must not be rejected by base64 length" - # and the message carries the image content block - content = w.last_messages[-1]["content"] - assert isinstance(content, list) and content[1]["type"] == "image_url" - - # 6. system + prompt assembled correctly - w = FakeWingman(ConversationProvider.OPENAI, condense=True) - ai = SkillAi(w) - await ai.generate("hello", system="be terse") - assert w.last_messages[0] == {"role": "system", "content": "be terse"} - assert w.last_messages[1]["content"] == "hello" - - await test_ai_has_converse_summarize_image() - await test_ai_messages_path() - - print("ALL OK") - - -async def test_ai_messages_path(): - # messages= sends a prebuilt list directly (prompt optional) and returns the content - w = FakeWingman(ConversationProvider.OPENAI, condense=True, skill_cap=16000) - ai = SkillAi(w) - msgs = [ - {"role": "system", "content": "be terse"}, - {"role": "user", "content": "hello there"}, - ] - out = await ai.generate(messages=msgs) - assert out == "RESULT", out - assert w.last_messages is msgs, "messages must be passed through as-is" - - # the messages path is also capped - huge = [{"role": "user", "content": "word " * 20000}] - w = FakeWingman(ConversationProvider.OPENAI, condense=True, skill_cap=16000) - ai = SkillAi(w) - try: - await ai.generate(messages=huge) - raise AssertionError("expected FacadeError for oversized messages list") - except FacadeError as e: - assert "16000" in str(e), str(e) - - # condense OFF -> messages path is uncapped - w = FakeWingman(ConversationProvider.OPENAI, condense=False) - ai = SkillAi(w) - assert await ai.generate(messages=huge) == "RESULT" - - print("PASS: ai.generate(messages=) path + cap") - - -class FakeConversation: - def __init__(self): - self.messages = [] - self.assistant = [] - - async def add_assistant_message(self, content): - self.assistant.append(content) - - -class FakeConverseWingman(FakeWingman): - """Extends FakeWingman with the bits converse()/generate_image() need.""" - - def __init__(self, provider, condense=True, skill_cap=16000): - super().__init__(provider, condense=condense, skill_cap=skill_cap) - self.conversation = FakeConversation() - self.added_user = [] - - async def add_user_message(self, content): - self.added_user.append(content) - self.conversation.messages.append({"role": "user", "content": content}) - - async def generate_image(self, t): - return f"IMG:{t}" - - -async def test_ai_has_converse_summarize_image(): - w = FakeConverseWingman(ConversationProvider.OPENAI) - ai = SkillAi(w) - assert hasattr(ai, "converse") and hasattr(ai, "summarize") and hasattr(ai, "generate_image") - - # generate_image delegates to wingman.generate_image - out = await ai.generate_image("a cat") - assert out == "IMG:a cat", out - - # converse appends user + assistant turns and returns the model text - reply = await ai.converse("hi there") - assert reply == "RESULT", reply - assert w.added_user == ["hi there"] - assert w.conversation.assistant == ["RESULT"] - - # summarize routes through generate() (capped side-call) and returns text - summary = await ai.summarize("some long text") - assert summary == "RESULT", summary - - print("PASS: ai converse/summarize/generate_image present + delegate") - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/tests/test_skill_audio.py b/tests/test_skill_audio.py deleted file mode 100644 index b12d0401..00000000 --- a/tests/test_skill_audio.py +++ /dev/null @@ -1,52 +0,0 @@ -"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_audio""" -import asyncio -from wingmen.facade import SkillAudio, Subscription - - -class _Events: - def __init__(self): self.subs = {"started": [], "finished": []} - def subscribe(self, ev, cb): self.subs[ev].append(cb) - def unsubscribe(self, ev, cb): - if cb in self.subs[ev]: self.subs[ev].remove(cb) - - -class _Player: - def __init__(self): self.is_playing = False; self.playback_events = _Events() - - -class _Lib: - def __init__(self): self.played = None; self.stopped = None - async def start_playback(self, cfg, vol): self.played = (cfg, vol) - async def stop_playback(self, cfg, fade): self.stopped = (cfg, fade) - - -class _W: - def __init__(self): self.audio_player = _Player(); self.audio_library = _Lib() - settings = type("S", (), {"audio": None})() - settings_service = None - - -def test_play_stop_param_names(): - w = _W(); a = SkillAudio(w) - asyncio.get_event_loop().run_until_complete(a.play("cfg", volume=0.5)) - asyncio.get_event_loop().run_until_complete(a.stop("cfg", fade_out=1.0)) - assert w.audio_library.played == ("cfg", 0.5) - assert w.audio_library.stopped == ("cfg", 1.0) - print("PASS: play/stop volume + fade_out") - - -def test_subscription(): - w = _W(); a = SkillAudio(w) - cb = lambda name: None - sub = a.on_playback_started(cb) - assert isinstance(sub, Subscription) and cb in w.audio_player.playback_events.subs["started"] - sub.unsubscribe() - assert cb not in w.audio_player.playback_events.subs["started"] - assert not hasattr(a, "off_playback_started") - print("PASS: on_* returns Subscription, off_* removed") - - -if __name__ == "__main__": - test_play_stop_param_names() - test_subscription() - print("ALL OK") diff --git a/tests/test_skill_base_surface.py b/tests/test_skill_base_surface.py deleted file mode 100644 index 4d51b8fd..00000000 --- a/tests/test_skill_base_surface.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_base_surface""" -import inspect -from skills.skill_base import Skill - - -def test_removed_members(): - for gone in ("local_ai", "retrieve_secret", "threaded_execution", "llm_call"): - assert not hasattr(Skill, gone), f"{gone} must be removed from Skill" - print("PASS: duplicates removed from Skill") - - -def test_kept_members(): - for keep in ("retrieve_custom_property_value", "get_generated_files_dir", "update_config"): - assert hasattr(Skill, keep), f"{keep} must stay on Skill" - print("PASS: skill-owned members kept") - - -def test_log_present(): - src = inspect.getsource(Skill.__init__) - assert "self.log" in src, "self.log wrapper must be set in __init__" - print("PASS: self.log present") - - -if __name__ == "__main__": - test_removed_members() - test_kept_members() - test_log_present() - print("ALL OK") diff --git a/tests/test_skill_catalog.py b/tests/test_skill_catalog.py deleted file mode 100644 index 3ce20ec8..00000000 --- a/tests/test_skill_catalog.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Standalone, hermetic verification for services.skill_catalog.SkillCatalog. - -No pytest. Run from the project root: - - venv/bin/python -m tests.test_skill_catalog - -Exits non-zero on the first failed assertion; prints "ALL OK" on success. - -The test stubs ModuleManager so it never touches real skill folders: -- read_available_skill_configs -> a controlled list of (folder, config_path, is_custom, is_local) -- read_config -> a dict per config_path -- probe_import -> no-op (all probes succeed; the import-failure path is covered elsewhere) -""" - -from services.module_manager import ModuleManager -from services import skill_catalog -from services.skill_catalog import ( - SkillCatalog, - SkillVerdict, - _id_hash, -) - - -def _base_manifest(**overrides) -> dict: - """A minimal but valid SkillConfig dict. Spread + override per case.""" - manifest = { - "module": "skills.example.main", - "name": "ExampleSkill", - "display_name": "Example Skill", - "description": {"en": "An example skill."}, - } - manifest.update(overrides) - return manifest - - -# folder -> config_path mapping for the controlled fixture set. -_CONFIGS: dict[str, dict] = { - "good": _base_manifest(api_version=3), - "legacy_missing": _base_manifest(), # no api_version - "legacy_v2": _base_manifest(api_version=2), # unsupported - # Windows-only skill: its probe would RAISE on a non-matching platform. - "windows_only": _base_manifest(api_version=3, platforms=["windows"]), -} - - -def _raise_probe(config) -> None: - raise RuntimeError("module 'ctypes' has no attribute 'windll'") - - -def _install_stubs() -> None: - available = [ - # (folder, config_path, is_custom, is_local) - ("good", "good", False, False), - ("legacy_missing", "legacy_missing", False, False), - ("legacy_v2", "legacy_v2", True, False), - ("windows_only", "windows_only", False, False), - ] - ModuleManager.read_available_skill_configs = staticmethod(lambda: list(available)) - ModuleManager.read_config = staticmethod(lambda config_path: dict(_CONFIGS[config_path])) - - # Probe raises for the platform-mismatched skill, so reaching it would fail the - # test. It succeeds (no-op) for any other skill. - def _probe(config): - if "windows" in (config.platforms or []): - return _raise_probe(config) - return None - - ModuleManager.probe_import = staticmethod(_probe) - - # Force a deterministic, non-matching platform so windows_only is skipped. - skill_catalog.normalize_platform = lambda *a, **k: "darwin" - - -def main() -> None: - _install_stubs() - - catalog = SkillCatalog() - # Singleton: scan() resets _entries and _runtime_outcomes, giving us a clean slate. - entries = catalog.scan() - - by_folder = {e.folder: e for e in entries} - assert set(by_folder) == {"good", "legacy_missing", "legacy_v2", "windows_only"}, ( - f"unexpected folders scanned: {set(by_folder)}" - ) - - # 1. api_version: 3 -> OK, eligible. - good = by_folder["good"] - assert good.verdict == SkillVerdict.OK, f"expected OK, got {good.verdict}" - assert good.outcome == "ok", f"expected outcome 'ok', got {good.outcome!r}" - assert good.api_version == 3, f"expected api_version 3, got {good.api_version}" - - # 2. no api_version -> LEGACY, outcome legacy_v2, NOT eligible. - legacy_missing = by_folder["legacy_missing"] - assert legacy_missing.verdict == SkillVerdict.LEGACY, ( - f"expected LEGACY, got {legacy_missing.verdict}" - ) - assert legacy_missing.outcome == "legacy_v2", ( - f"expected outcome 'legacy_v2', got {legacy_missing.outcome!r}" - ) - - # 3. api_version: 2 (unsupported) -> LEGACY, NOT eligible. - legacy_v2 = by_folder["legacy_v2"] - assert legacy_v2.verdict == SkillVerdict.LEGACY, ( - f"expected LEGACY, got {legacy_v2.verdict}" - ) - assert legacy_v2.api_version == 2, ( - f"expected api_version 2, got {legacy_v2.api_version}" - ) - - # 4. Windows-only skill on a non-matching platform (darwin) -> OK, eligible. - # The import probe MUST be skipped; if it ran it would raise and quarantine. - windows_only = by_folder["windows_only"] - assert windows_only.verdict == SkillVerdict.OK, ( - f"expected OK (probe skipped), got {windows_only.verdict}: {windows_only.reason}" - ) - assert "probe skipped" in windows_only.reason, ( - f"expected probe-skipped reason, got {windows_only.reason!r}" - ) - - # 5. eligible_folders() == exactly the set of OK folders. - eligible = catalog.eligible_folders() - assert eligible == {"good", "windows_only"}, ( - f"expected eligible == {{'good', 'windows_only'}}, got {eligible}" - ) - - # 5. Runtime-failure dedup. - record = catalog.record_runtime_failure("good", "boom") - assert record is not None, "first record_runtime_failure should return a record" - assert record["outcome"] == "failed", ( - f"expected outcome 'failed', got {record['outcome']!r}" - ) - assert record["id_hash"] == _id_hash("good"), ( - f"id_hash mismatch: {record['id_hash']} != {_id_hash('good')}" - ) - - dup = catalog.record_runtime_failure("good", "boom again") - assert dup is None, f"second record_runtime_failure should dedup to None, got {dup}" - - print("ALL OK") - - -if __name__ == "__main__": - main() diff --git a/tests/test_skill_commands.py b/tests/test_skill_commands.py deleted file mode 100644 index 763248e1..00000000 --- a/tests/test_skill_commands.py +++ /dev/null @@ -1,65 +0,0 @@ -"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_commands""" -import asyncio -from wingmen.facade import SkillCommands, CommandCategory - - -class _Cmd: - def __init__(self, name): self.name = name; self.category_id = None - - -class _Cat: - def __init__(self, id, name): self.id = id; self.name = name - - -class _Features: pass - - -class _Config: - def __init__(self): - self.commands = [] - self.command_categories = [] - - -class _Exec: - def __init__(self, cfg): self.cfg = cfg - def get_command(self, name): - return next((c for c in self.cfg.commands if c.name == name), None) - - -class _Tower: - def save_wingman_commands(self, name): return True - - -class _W: - name = "TestWingman" - def __init__(self): - self.config = _Config() - self.command_executor = _Exec(self.config) - self.tower = _Tower() - - -def test_add_remove(): - w = _W(); c = SkillCommands(w) - c.add(_Cmd("Jump")) - assert len(c.all()) == 1 and c.get("Jump") is not None - c.remove("Jump") - assert c.get("Jump") is None - print("PASS: add/remove/get/all") - - -def test_categories(): - w = _W(); c = SkillCommands(w) - cat = c.add_category("Combat") - assert isinstance(cat, CommandCategory) and len(w.config.command_categories) == 1 - cat2 = c.add_category("Combat") # idempotent by name - assert cat2.id == cat.id and len(w.config.command_categories) == 1 - cmd = _Cmd("Jump") - c.add(cmd, category=cat) - assert cmd.category_id == cat.id - print("PASS: categories idempotent + add(category=)") - - -if __name__ == "__main__": - test_add_remove() - test_categories() - print("ALL OK") diff --git a/tests/test_skill_tools.py b/tests/test_skill_tools.py deleted file mode 100644 index c9c4d18a..00000000 --- a/tests/test_skill_tools.py +++ /dev/null @@ -1,89 +0,0 @@ -"""Run: PYTHONPATH=. venv/bin/python -m tests.test_skill_tools""" -import asyncio -from wingmen.facade import SkillTools, ToolResult, ToolDescriptor - - -class _Skill: - name = "Timer" - - -class _Manifest: - display_name = "Weather MCP" - - -class _ManifestObj: - def __init__(self, name, display): - self.name = name; self.display_name = display; self.is_connected = True - - -class _ToolInfo: - def __init__(self, n): self.prefixed_name = n - - -class _Mcp: - _tool_to_server = {"get_weather": "weather"} - _manifests = {"weather": _Manifest()} - def get_connected_servers(self): return [_ManifestObj("weather", "Weather MCP")] - def get_server_tools(self, name): return [_ToolInfo("get_weather")] if name == "weather" else [] - - -class _W: - tool_skills = {"set_timer": _Skill()} - mcp_registry = _Mcp() - def build_tools(self): - return [ - {"function": {"name": "set_timer", "description": "d1", "parameters": {"type": "object"}}}, - {"function": {"name": "get_weather", "description": "d2", "parameters": {"type": "object"}}}, - ] - async def execute_command_by_function_call(self, name, args): - # Slot 3 is the owning Skill OBJECT in production (not a string) — return a real - # _Skill so the test verifies ToolResult.skill coerces it to the name. - return (f"resp:{name}", "instant", _Skill(), "Set Timer") - - -def test_names_has_source(): - t = SkillTools(_W()) - assert t.names() == {"set_timer", "get_weather"} - assert t.has("set_timer") and not t.has("nope") - assert t.source("set_timer") == "Timer" - assert t.source("get_weather") == "Weather MCP" - print("PASS: names/has/source") - - -def test_describe_all_invoke(): - t = SkillTools(_W()) - d = t.describe("set_timer") - assert isinstance(d, ToolDescriptor) and d.source == "Timer" and d.description == "d1" - alld = t.all() - assert len(alld) == 2 and all(isinstance(x, ToolDescriptor) for x in alld) - r = asyncio.get_event_loop().run_until_complete(t.invoke("set_timer", {"m": 5})) - assert isinstance(r, ToolResult) and r.response == "resp:set_timer" and r.skill == "Timer" - print("PASS: describe/all/invoke->ToolResult") - - -def test_icon(): - t = SkillTools(_W()) - # unknown tool / MCP tool (not in tool_skills) -> None - assert t.icon("nope") is None - assert t.icon("get_weather") is None - # a skill tool whose module dir has no logo.png -> None (no crash) - assert t.icon("set_timer") is None - print("PASS: icon() -> None when no logo / not a skill tool") - - -def test_servers(): - t = SkillTools(_W()) - servers = t.servers() - assert len(servers) == 1, servers - assert servers[0]["display_name"] == "Weather MCP" - assert servers[0]["tools"] == ["get_weather"] - assert servers[0]["connected"] is True - print("PASS: servers()") - - -if __name__ == "__main__": - test_names_has_source() - test_describe_all_invoke() - test_icon() - test_servers() - print("ALL OK") diff --git a/tests/test_skill_tts.py b/tests/test_skill_tts.py deleted file mode 100644 index 631655e1..00000000 --- a/tests/test_skill_tts.py +++ /dev/null @@ -1,139 +0,0 @@ -"""Standalone test for the ctx.tts voice mapping (apply_voice_to_current_provider). - -Run: PYTHONPATH=. venv/bin/python tests/test_skill_tts.py -Expected last line: ALL OK - -Uses lightweight fake config objects so we test the field-mapping logic without the -real provider stack (TTS rebuild is exercised at runtime/boot, not here). -""" - -from types import SimpleNamespace - -from api.enums import TtsProvider, WingmanProTtsProvider -from wingmen.facade import apply_voice_to_current_provider - - -def make_config(provider, wp_sub=None): - """A fake config exposing only the fields the mapping touches.""" - return SimpleNamespace( - features=SimpleNamespace(tts_provider=provider), - wingman_pro=SimpleNamespace(tts_provider=wp_sub), - openai=SimpleNamespace(tts_voice=None), - azure=SimpleNamespace(tts=SimpleNamespace(voice=None)), - elevenlabs=SimpleNamespace(voice=None, output_streaming=True), - xvasynth=SimpleNamespace(voice=None), - edge_tts=SimpleNamespace(voice=None), - hume=SimpleNamespace(voice=None), - inworld=SimpleNamespace(voice_id=None, output_streaming=True), - pocket_tts=SimpleNamespace(voice=None, output_streaming=True), - openai_compatible_tts=SimpleNamespace(voice=None, output_streaming=True), - ) - - -def main(): - # OpenAI: voice with .value (enum-like) -> openai.tts_voice, name from .value - cfg = make_config(TtsProvider.OPENAI) - enum_voice = SimpleNamespace(value="nova") - name, label = apply_voice_to_current_provider(cfg, enum_voice) - assert cfg.openai.tts_voice is enum_voice, "openai field not set" - assert name == "nova" and label == "OpenAI", (name, label) - - # ElevenLabs: object voice (.name) -> elevenlabs.voice, streaming forced off - cfg = make_config(TtsProvider.ELEVENLABS) - el_voice = SimpleNamespace(name="Rachel", id="abc") - name, label = apply_voice_to_current_provider(cfg, el_voice) - assert cfg.elevenlabs.voice is el_voice - assert cfg.elevenlabs.output_streaming is False, "streaming must be forced off" - assert name == "Rachel" and label == "Elevenlabs" - - # ElevenLabs fallback to .id when no name - cfg = make_config(TtsProvider.ELEVENLABS) - name, _ = apply_voice_to_current_provider(cfg, SimpleNamespace(name=None, id="xyz")) - assert name == "xyz", name - - # Azure: nested azure.tts.voice - cfg = make_config(TtsProvider.AZURE) - name, label = apply_voice_to_current_provider(cfg, "en-US-JennyNeural") - assert cfg.azure.tts.voice == "en-US-JennyNeural" - assert label == "Azure TTS" - - # XVASynth: name from .voice_name - cfg = make_config(TtsProvider.XVASYNTH) - name, label = apply_voice_to_current_provider(cfg, SimpleNamespace(voice_name="Ada")) - assert cfg.xvasynth.voice.voice_name == "Ada" - assert name == "Ada" and label == "XVASynth" - - # Edge / Hume: plain string voices - cfg = make_config(TtsProvider.EDGE_TTS) - _, label = apply_voice_to_current_provider(cfg, "en-GB-RyanNeural") - assert cfg.edge_tts.voice == "en-GB-RyanNeural" and label == "Edge TTS" - - cfg = make_config(TtsProvider.HUME) - _, label = apply_voice_to_current_provider(cfg, "voice-id-1") - assert cfg.hume.voice == "voice-id-1" and label == "Hume" - - # Inworld / PocketTTS / OpenAI-compatible: voice_id/voice + streaming off - cfg = make_config(TtsProvider.INWORLD) - apply_voice_to_current_provider(cfg, "Ashley") - assert cfg.inworld.voice_id == "Ashley" and cfg.inworld.output_streaming is False - - cfg = make_config(TtsProvider.POCKET_TTS) - apply_voice_to_current_provider(cfg, "pck") - assert cfg.pocket_tts.voice == "pck" and cfg.pocket_tts.output_streaming is False - - cfg = make_config(TtsProvider.OPENAI_COMPATIBLE) - apply_voice_to_current_provider(cfg, "v") - assert cfg.openai_compatible_tts.voice == "v" and cfg.openai_compatible_tts.output_streaming is False - - # Wingman Pro subproviders route to the right underlying field (Azure/Inworld only) - cfg = make_config(TtsProvider.WINGMAN_PRO, WingmanProTtsProvider.AZURE) - _, label = apply_voice_to_current_provider(cfg, "az") - assert cfg.azure.tts.voice == "az" and label == "Wingman Pro / Azure TTS" - - cfg = make_config(TtsProvider.WINGMAN_PRO, WingmanProTtsProvider.INWORLD) - _, label = apply_voice_to_current_provider(cfg, "iw") - assert cfg.inworld.voice_id == "iw" and cfg.inworld.output_streaming is False - assert label == "Wingman Pro / Inworld" - - test_tts_speak_and_voice() - - print("ALL OK") - - -def test_tts_speak_and_voice(): - """SkillTts.speak() flips interrupt->no_interrupt and delegates to play_to_user; - voice/voices are present. Build a tiny fake wingman inline (this file has no helper).""" - import asyncio - from wingmen.facade import SkillTts - - # voice reads config.features.tts_provider + per-provider fields; use OpenAI here. - config = SimpleNamespace( - features=SimpleNamespace(tts_provider=TtsProvider.OPENAI), - openai=SimpleNamespace(tts_voice="nova"), - ) - - class _FakeWingman: - pass - - w = _FakeWingman() - w.config = config - - spoken = {} - - async def _p2u(text, no_interrupt=False, sound_config=None): - spoken["text"], spoken["no_interrupt"] = text, no_interrupt - - w.play_to_user = _p2u - tts = SkillTts(w) - - asyncio.get_event_loop().run_until_complete(tts.speak("hello", interrupt=False)) - assert spoken["text"] == "hello" and spoken["no_interrupt"] is True, spoken - - # voice reads the configured OpenAI voice; voices() is a coroutine method. - assert tts.voice == "nova", tts.voice - assert hasattr(tts, "voices") and hasattr(type(tts), "voice") - print("PASS: tts speak (interrupt->no_interrupt) + voice/voices present") - - -if __name__ == "__main__": - main() diff --git a/tests/test_wingman_context_closed.py b/tests/test_wingman_context_closed.py deleted file mode 100644 index 08ec4d49..00000000 --- a/tests/test_wingman_context_closed.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Run: PYTHONPATH=. venv/bin/python -m tests.test_wingman_context_closed""" -from wingmen.wingman_context import WingmanContext -from wingmen.facade import FacadeError - - -class _W: - name = "W" - config = type("C", (), {})() - settings = type("S", (), {"audio": None})() - - -def test_closed_surface(): - ctx = WingmanContext(_W()) - # sub-facades present - for ns in ("ai", "local_ai", "tts", "audio", "commands", "tools", - "conversation", "memory", "secrets", "skills"): - assert hasattr(ctx, ns), f"missing {ns}" - # removed v2 leaks raise a helpful FacadeError (spec §8), naming the replacement - for leak in ("llm_call", "audio_player", "tool_skills", "mcp_registry", - "skill_registry", "tower", "secret_keeper", "messages", - "get_command", "get_context", "local_ai_service", - "persistent_memory_service", "registry", "play_to_user", - "generate_image", "add_assistant_message", "retrieve_secret", - "threaded_execution"): - try: - getattr(ctx, leak) - raise AssertionError(f"LEAK still present: {leak}") - except FacadeError as e: - assert "MIGRATING-TO-V3" in str(e), f"{leak}: error must point to the guide" - # an unknown attribute still raises a plain AttributeError (not FacadeError) - try: - getattr(ctx, "totally_unknown_attr") - raise AssertionError("unknown attr should raise AttributeError") - except AttributeError: - pass - # raw wingman not reachable via _wingman (name-mangled, not in the removed map) - assert not hasattr(ctx, "_wingman"), "raw _wingman must be name-mangled" - print("PASS: closed surface — sub-facades present, removed leaks raise FacadeError, _wingman mangled") - - -if __name__ == "__main__": - test_closed_surface() - print("ALL OK")