Skip to content

Commit 0ce90f5

Browse files
JarbasAlclaude
andauthored
refactor: intent layers gate via intent context, not enable/disable (#427)
* feat!: intent layers gate via intent context, not enable/disable Redesign IntentLayers so a layer maps to an intent-context token instead of mutating the global enabled/disabled intent set. - activate_layer -> skill.set_context(<layer token>) - deactivate_layer -> skill.remove_context(<layer token>) - reset() (kept aliased as disable()) removes every active layer context `@layer_intent` now injects the layer context token as a `.require()` on the IntentBuilder, so the intent only validates while its layer is active. Intents stay registered for the skill lifetime; gating is per-session via adapt context, with no detach/attach churn. resets_layers() now calls reset(); the enable_intent/disable_intent paths are gone from the layer machinery. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: game skills converse via context-gated layers + skill_will_match probe Make ConversationalGameSkill work with the new context-gated layers: - OVOSGameSkill now also inherits ConversationalSkill so the converse ping/request bus handlers are actually wired for game skills (they were not, so converse never ran for games). - Add OVOSSkill.skill_will_match(): a read-only probe over intent.service.intent.get that reports whether one of THIS skill's intents would match an utterance under the current session context. Supports exclude_pipeline so a conversing skill can skip the converse stage and avoid re-entrancy. - ConversationalGameSkill.can_converse() returns True only while playing AND no context-gated layer intent would match (so layer intents are handled by adapt, not swallowed by converse). converse() uses the same probe. calc_intent() now ignores non-intent-parser pipeline results. - on_play_game restart semantics doc fix in the game base. Add a 4-layer demo skill e2e (ovoscope MiniCroft / FakeBus) proving the layer mechanism: utterances advance layer0->layer3, only the active layer's intent matches at each step (others are gated off via missing context), always-on intents match throughout, and reset removes the context so layer intents stop matching. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: session-aware playback + per-session layer gating (OCP is session aware) OCP tracks player state per session, so game/playback skills must too. - OVOSCommonPlaybackSkill: _playing/_paused Events -> per-session sets (_playing_sessions/_paused_sessions) keyed by SessionManager.get(msg) .session_id. New get_session_id(), is_playing_in/is_paused_in, playing_sessions; is_playing/is_paused properties now resolve the current session. OCP play/pause/resume/stop handlers key state by session and reply with message.reply so the session round-trips. - OVOSGameSkill.stop_game is per-session; on_pause_game/on_resume_game no longer mutate paused state (the framework does, per session); on_game_command(self, utterance, lang, message=None) - the message (and thus session) is now passed so games can key per-session state. - IntentLayers.is_active(layer, session=None) reads the layer's context token from the session (the per-session source of truth), falling back to the skill-level active_layers when no session is resolvable. This makes layer gating correct under concurrent multi-session play. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test: adapt layer tests to context gating - common_play: assert per-session playback state (no playing/paused sessions on init) instead of the removed _playing/_paused Event objects - intent_layers_e2e: restore the global SessionManager.bus in tearDownClass so the stopped MiniCroft bus does not leak into get_response/killable intent tests sharing the class-level SessionManager Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: add ovos-adapt-parser to test deps for layer e2e pipeline The intent-layers e2e test drives a live ADAPT pipeline via MiniCroft; CI installs only the declared test extras, where ovos-adapt-parser (which provides the ovos-adapt-pipeline-plugin matcher) was missing, so the pipeline matched nothing ('Unknown pipeline matcher'). Add it to both the [test] extra and requirements/test.txt. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix: bump ovos-adapt-parser test dep to >=1.3.1a1 for workshop 8.x Stable ovos-adapt-parser 1.0.9 caps ovos-workshop<8.0.0, which conflicts with this branch (8.3.0a1) and made the install unresolvable in CI. 1.3.1a1 relaxes the cap to <9.0.0; pin it as the floor (prerelease floor pin) so pip resolves it without --pre. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor: keep #427 to IntentLayers only; game-skill + OCP playback move to a stacked PR IntentLayers stays standalone and backwards-compatible for decorator consumers (disable() aliases reset(); all decorators + methods preserved). The session-aware game-skill + OCPCommonPlayback changes move to feat/gameskill-and-ocp-deprecation. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: skill_will_match accepts a Session to probe under that session's context Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9234a41 commit 0ce90f5

6 files changed

Lines changed: 517 additions & 49 deletions

File tree

ovos_workshop/decorators/layers.py

Lines changed: 160 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,20 @@
1+
"""Intent layers gated by intent context.
2+
3+
An "intent layer" is a named group of intents that should only be matchable
4+
while the layer is active. Instead of mutating the global enabled/disabled
5+
intent set (detaching/re-attaching intents from the intent service), each layer
6+
is mapped to a synthetic **intent context** token. Every intent belonging to a
7+
layer additionally *requires* that context token, so adapt only validates it
8+
while the context is set on the session.
9+
10+
- activating a layer -> `skill.set_context(<layer token>)`
11+
- deactivating a layer -> `skill.remove_context(<layer token>)`
12+
13+
The intents themselves stay registered for the lifetime of the skill; what
14+
changes is whether their required context is present. This keeps the intent
15+
service stable (no detach/attach churn) and makes layer gating per-session,
16+
matching how the rest of OVOS contextual intents behave.
17+
"""
118
import inspect
219
from functools import wraps
320
from typing import Optional, List
@@ -6,6 +23,17 @@
623
from ovos_utils.log import LOG
724

825

26+
def layer_context_token(layer_name: str) -> str:
27+
"""Return the intent-context keyword token used to gate a layer.
28+
29+
The token is a plain keyword (no skill_id prefix); `set_context` and the
30+
adapt intent muging both prefix it with the alphanumeric skill_id, so the
31+
injected context and the intent requirement line up automatically.
32+
@param layer_name: bare layer name (without skill_id prefix)
33+
"""
34+
return f"layer_{layer_name}"
35+
36+
937
def dig_for_skill(max_records: int = 10) -> Optional[object]:
1038
"""
1139
Dig through the call stack to locate a Skill object
@@ -30,19 +58,24 @@ def dig_for_skill(max_records: int = 10) -> Optional[object]:
3058
return None
3159

3260

61+
def _bound_layers(skill: object) -> "IntentLayers":
62+
skill.intent_layers = skill.intent_layers or IntentLayers().bind(skill)
63+
return skill.intent_layers
64+
65+
3366
def enables_layer(layer_name: str):
3467
"""
35-
Decorator to enable an intent layer when a method is called
36-
@param layer_name: name of intent layer to enable
68+
Decorator to activate an intent layer (set its context) after the
69+
decorated method runs.
70+
@param layer_name: name of intent layer to activate
3771
"""
3872
def layer_handler(func):
3973
@wraps(func)
4074
def call_function(*args, **kwargs):
4175
skill = dig_for_skill()
42-
skill.intent_layers = skill.intent_layers or \
43-
IntentLayers().bind(skill)
76+
layers = _bound_layers(skill)
4477
func(*args, **kwargs)
45-
skill.intent_layers.activate_layer(layer_name)
78+
layers.activate_layer(layer_name)
4679

4780
return call_function
4881

@@ -51,17 +84,17 @@ def call_function(*args, **kwargs):
5184

5285
def disables_layer(layer_name: str):
5386
"""
54-
Decorator to disable an intent layer when a method is called
55-
@param layer_name: name of intent layer to disable
87+
Decorator to deactivate an intent layer (remove its context) after the
88+
decorated method runs.
89+
@param layer_name: name of intent layer to deactivate
5690
"""
5791
def layer_handler(func):
5892
@wraps(func)
5993
def call_function(*args, **kwargs):
6094
skill = dig_for_skill()
61-
skill.intent_layers = skill.intent_layers or \
62-
IntentLayers().bind(skill)
95+
layers = _bound_layers(skill)
6396
func(*args, **kwargs)
64-
skill.intent_layers.deactivate_layer(layer_name)
97+
layers.deactivate_layer(layer_name)
6598

6699
return call_function
67100

@@ -78,10 +111,9 @@ def layer_handler(func):
78111
@wraps(func)
79112
def call_function(*args, **kwargs):
80113
skill = dig_for_skill()
81-
skill.intent_layers = skill.intent_layers or \
82-
IntentLayers().bind(skill)
114+
layers = _bound_layers(skill)
83115
func(*args, **kwargs)
84-
skill.intent_layers.replace_layer(layer_name, intent_list)
116+
layers.replace_layer(layer_name, intent_list)
85117

86118
return call_function
87119

@@ -90,17 +122,17 @@ def call_function(*args, **kwargs):
90122

91123
def removes_layer(layer_name: str):
92124
"""
93-
Decorator to remove an intent layer when a method is called
125+
Decorator to remove an intent layer (forget it and drop its context) after
126+
the decorated method runs.
94127
@param layer_name: name of intent layer to remove
95128
"""
96129
def layer_handler(func):
97130
@wraps(func)
98131
def call_function(*args, **kwargs):
99132
skill = dig_for_skill()
100-
skill.intent_layers = skill.intent_layers or \
101-
IntentLayers().bind(skill)
133+
layers = _bound_layers(skill)
102134
func(*args, **kwargs)
103-
skill.intent_layers.remove_layer(layer_name)
135+
layers.remove_layer(layer_name)
104136

105137
return call_function
106138

@@ -109,16 +141,15 @@ def call_function(*args, **kwargs):
109141

110142
def resets_layers():
111143
"""
112-
Decorator to reset and disable intent layers
144+
Decorator to reset intent layers (deactivate all layer contexts)
113145
"""
114146
def layer_handler(func):
115147
@wraps(func)
116148
def call_function(*args, **kwargs):
117149
skill = dig_for_skill()
118-
skill.intent_layers = skill.intent_layers or \
119-
IntentLayers().bind(skill)
150+
layers = _bound_layers(skill)
120151
func(*args, **kwargs)
121-
skill.intent_layers.disable()
152+
layers.reset()
122153

123154
return call_function
124155

@@ -129,18 +160,32 @@ def layer_intent(intent_parser: callable, layer_name: str):
129160
"""
130161
Decorator for adding a method as an intent handler belonging to an
131162
intent layer.
163+
164+
The intent is augmented to *require* the layer's context token, so it can
165+
only match while the layer is active. For adapt intents (IntentBuilder /
166+
Intent) the requirement is injected directly; for padatious `.intent` files
167+
the gating is enforced at registration time via `register_intent_layer`.
168+
132169
@param intent_parser: intent parser method
133170
@param layer_name: name of intent layer intent is associated with
134171
"""
135172

136173
def real_decorator(func):
174+
nonlocal intent_parser
137175
# Store the intent_parser inside the function
138176
# This will be used later to call register_intent
139177
if not hasattr(func, 'intents'):
140178
func.intents = []
141179
if not hasattr(func, 'intent_layers'):
142180
func.intent_layers = {}
143181

182+
token = layer_context_token(layer_name)
183+
184+
# gate the intent on the layer context token
185+
if hasattr(intent_parser, "require"):
186+
# IntentBuilder - require the layer context token
187+
intent_parser = intent_parser.require(token)
188+
144189
func.intents.append(intent_parser)
145190
if layer_name not in func.intent_layers:
146191
func.intent_layers[layer_name] = []
@@ -153,16 +198,24 @@ def real_decorator(func):
153198
intent_name = intent_parser.name
154199
else:
155200
intent_name = intent_parser
156-
201+
157202
func.intent_layers[layer_name].append(intent_name)
158203
return func
159204

160205
return real_decorator
161206

162207

163208
class IntentLayers:
209+
"""Manage intent layers via intent context.
210+
211+
Each layer maps to a single context token. Activating a layer sets that
212+
context on the intent service; deactivating removes it. The layer's intents
213+
require the token (see `layer_intent`), so they only match while active.
214+
"""
215+
164216
def __init__(self):
165217
self._skill = None
218+
# layer_name (skill-id-prefixed) -> list of intent names (informational)
166219
self._layers = {}
167220
self._active_layers = []
168221

@@ -187,51 +240,54 @@ def skill_id(self) -> str:
187240
def active_layers(self) -> List[str]:
188241
return self._active_layers
189242

190-
def disable(self):
191-
LOG.info("Disabling layers")
192-
# disable all layers
193-
for layer_name, intents in self._layers.items():
194-
self.deactivate_layer(layer_name)
243+
def _full_name(self, layer_name: str) -> str:
244+
if not layer_name.startswith(f"{self.skill_id}:"):
245+
layer_name = f"{self.skill_id}:{layer_name}"
246+
return layer_name
247+
248+
@staticmethod
249+
def _bare_name(full_name: str) -> str:
250+
return full_name.split(":")[-1]
195251

196252
def update_layer(self, layer_name: str,
197253
intent_list: Optional[List[str]] = None):
198-
if not layer_name.startswith(f"{self.skill_id}:"):
199-
layer_name = f"{self.skill_id}:{layer_name}"
254+
"""Register intents under a (possibly new) layer."""
255+
layer_name = self._full_name(layer_name)
200256
intent_list = intent_list or []
201257
if layer_name not in self._layers:
202258
self._layers[layer_name] = []
203-
self._layers[layer_name] += intent_list or []
259+
self._layers[layer_name] += intent_list
204260
LOG.info(f"Adding {intent_list} to {layer_name}")
205261

206262
def activate_layer(self, layer_name: str):
207-
if not layer_name.startswith(f"{self.skill_id}:"):
208-
layer_name = f"{self.skill_id}:{layer_name}"
263+
"""Activate a layer by setting its intent context.
264+
265+
Intents in the layer require this context token, so they become
266+
matchable only after this call.
267+
"""
268+
layer_name = self._full_name(layer_name)
209269
if layer_name in self._layers:
210270
LOG.info("activating layer named: " + layer_name)
211271
if layer_name not in self._active_layers:
212272
self._active_layers.append(layer_name)
213-
for intent in self._layers[layer_name]:
214-
intent_name = intent.split(f"{self.skill_id}:")[-1]
215-
self.skill.enable_intent(intent_name)
273+
self._set_context(layer_name)
216274
else:
217275
LOG.debug("no layer named: " + layer_name)
218276

219277
def deactivate_layer(self, layer_name: str):
220-
if not layer_name.startswith(f"{self.skill_id}:"):
221-
layer_name = f"{self.skill_id}:{layer_name}"
278+
"""Deactivate a layer by removing its intent context."""
279+
layer_name = self._full_name(layer_name)
222280
if layer_name in self._layers:
223281
LOG.info("deactivating layer named: " + layer_name)
224282
if layer_name in self._active_layers:
225283
self._active_layers.remove(layer_name)
226-
for intent in self._layers[layer_name]:
227-
intent_name = intent.split(f"{self.skill_id}:")[-1]
228-
self.skill.disable_intent(intent_name)
284+
self._remove_context(layer_name)
229285
else:
230286
LOG.debug("no layer named: " + layer_name)
231287

232288
def remove_layer(self, layer_name: str):
233-
if not layer_name.startswith(f"{self.skill_id}:"):
234-
layer_name = f"{self.skill_id}:{layer_name}"
289+
"""Forget a layer entirely (deactivating it first)."""
290+
layer_name = self._full_name(layer_name)
235291
if layer_name in self._layers:
236292
self.deactivate_layer(layer_name)
237293
LOG.info("removing layer named: " + layer_name)
@@ -241,16 +297,73 @@ def remove_layer(self, layer_name: str):
241297

242298
def replace_layer(self, layer_name: str,
243299
intent_list: Optional[List[str]] = None):
244-
if not layer_name.startswith(f"{self.skill_id}:"):
245-
layer_name = f"{self.skill_id}:{layer_name}"
300+
"""Replace the intent names tracked for a layer."""
301+
layer_name = self._full_name(layer_name)
246302
if layer_name in self._layers:
247303
LOG.info("replacing layer named: " + layer_name)
248304
self._layers[layer_name] = intent_list or []
249305
else:
250306
self.update_layer(layer_name, intent_list)
251307

252-
def is_active(self, layer_name: str):
253-
if not layer_name.startswith(f"{self.skill_id}:"):
254-
layer_name = f"{self.skill_id}:{layer_name}"
308+
def reset(self):
309+
"""Deactivate every layer (remove all layer contexts)."""
310+
LOG.info("Resetting layers")
311+
for layer_name in list(self._active_layers):
312+
self.deactivate_layer(layer_name)
313+
314+
# back-compat-free alias kept because the public verb "disable" reads well
315+
# for "turn all layers off"; it is reset(), not the old enable/disable model
316+
disable = reset
317+
318+
def is_active(self, layer_name: str, session=None) -> bool:
319+
"""True if the layer is active.
320+
321+
Layer gating is per-session (the layer's context token lives in the
322+
session). When a `session` (or the current message's session) is
323+
available, this checks that session's context - the source of truth for
324+
concurrent multi-session play. Otherwise it falls back to the
325+
skill-level bookkeeping in `active_layers`.
326+
327+
@param session: a Session to check; if None, the current message's
328+
session is used when one can be resolved.
329+
"""
330+
layer_name = self._full_name(layer_name)
331+
sess = session or self._current_session()
332+
if sess is not None:
333+
token = layer_context_token(self._bare_name(layer_name))
334+
full_token = self._alnum_id() + token
335+
return any(c.get("key") == token or
336+
full_token in [d[1] for d in c.get("data", [])]
337+
for c in sess.context.get_context())
255338
return layer_name in self.active_layers
256339

340+
# context plumbing -------------------------------------------------------
341+
def _alnum_id(self) -> str:
342+
if self.skill and hasattr(self.skill, "alphanumeric_skill_id"):
343+
return self.skill.alphanumeric_skill_id
344+
return ""
345+
346+
def _current_session(self):
347+
"""Resolve the current message's session, if any."""
348+
try:
349+
from ovos_bus_client.message import dig_for_message
350+
from ovos_bus_client.session import SessionManager
351+
msg = dig_for_message()
352+
if msg is not None:
353+
return SessionManager.get(msg)
354+
except Exception:
355+
pass
356+
return None
357+
358+
def _set_context(self, full_layer_name: str):
359+
if not self.skill:
360+
return
361+
token = layer_context_token(self._bare_name(full_layer_name))
362+
# word is the token itself so adapt has a value to inject
363+
self.skill.set_context(token, token)
364+
365+
def _remove_context(self, full_layer_name: str):
366+
if not self.skill:
367+
return
368+
token = layer_context_token(self._bare_name(full_layer_name))
369+
self.skill.remove_context(token)

0 commit comments

Comments
 (0)