Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 47 additions & 20 deletions ovos_workshop/intents.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,9 @@ def register_adapt_intent(self, name: str, intent_parser: object):
self._iface.register_intent(name, intent_parser)

def set_context(self, context: str, word: str, origin: str,
original_key: Optional[str] = None):
original_key: Optional[str] = None,
turns_remaining: Optional[int] = None,
expires_at: Optional[float] = None):
"""Add adapt-engine context (adapt-engine only).

`context` is the munged (alphanumeric_skill_id + context) legacy
Expand All @@ -231,33 +233,54 @@ def set_context(self, context: str, word: str, origin: str,
`session` bound to `msg` (`SessionManager.get(msg)`, ovos-spec-tools
>=1.10.3a1) via `Session.set_intent_context` - `forward`/`reply`
derived from `msg` stamp from that same bound object (CONTEXT-1
§5.3), so the write rides out on whatever this call emits next. The
legacy `add_context` message below is a *different* mechanism (the
adapt-engine `session.context` field, not `intent_context`) kept for
cores that still consume it, and warns once per process via
§5.3), so the write rides out on whatever this call emits next.

With `original_key` unset no session write happens at all. That is
the path the legacy `mycroft.skill.set_cross_context` listener takes
(`OVOSSkill.handle_set_cross_context`): a Message that announces a
context change must not cause a `session.intent_context` write in
every receiver (§5.0), so that path carries the write to the
adapt-engine `session.context` field only, through the legacy
`add_context` message below. That message warns once per process via
`_legacy_warn_add_context_once` (see that helper).

`turns_remaining` and `expires_at` are CONTEXT-1 §2 decay fields,
passed straight through to `Session.set_intent_context`. Omitted,
they keep the pre-existing behaviour: `expires_at` defaults to
`now + context.timeout` (minutes, default 2) **read from the process
making this call**, which on a satellite is not the orchestrator's
configuration, so the §5.3 orchestrator-side default decay never
applies to an entry written here; `turns_remaining` is unset (the
one-turn gate `{"value": null, "turns_remaining": 1}` is §3.2's
flag-context worked example, which §1.2 describes in prose, and it
is reachable by passing `turns_remaining=1` explicitly).
"""
msg = dig_for_message() or Message("")
if "skill_id" not in msg.context:
msg.context["skill_id"] = self.skill_id
if original_key is not None:
session = SessionManager.get(msg)
# OVOS-CONTEXT-1: mirror ovos-core's decay policy
# (`context.timeout`, minutes, default 2) so a skill-side write
# folds into the registry with the SAME `expires_at` a core-side
# write would carry - an omitted `expires_at` here produced
# immortal entries that stripped core's decay stamp for that
# key. One decay policy on both write paths.
context_cfg = Configuration().get('context', {})
timeout_s = context_cfg.get('timeout', 2) * 60
expires_at = time.time() + timeout_s if timeout_s > 0 else None
if expires_at is None:
# OVOS-CONTEXT-1: mirror ovos-core's decay policy
# (`context.timeout`, minutes, default 2) so a skill-side
# write folds into the registry with the SAME `expires_at`
# a core-side write would carry - an omitted `expires_at`
# here produced immortal entries that stripped core's decay
# stamp for that key. One decay policy on both write paths.
context_cfg = Configuration().get('context', {})
timeout_s = context_cfg.get('timeout', 2) * 60
expires_at = time.time() + timeout_s if timeout_s > 0 else None
session.set_intent_context(original_key, word,
scope="private",
owner_id=self.skill_id,
expires_at=expires_at)
# `add_context` mutates the adapt-engine `session.context` field, a
# different mechanism than `intent_context` above, kept for
# orchestrators that predate OVOS-CONTEXT-1 and still consume it.
expires_at=expires_at,
turns_remaining=turns_remaining)
# `add_context` carries the write to the adapt-engine
# `session.context` field, kept for orchestrators that predate
# OVOS-CONTEXT-1 and still consume it. A modern core folds the same
# key back into `session.intent_context`, so for a modern core this
# is a write-through of the session write above, not a second
# mutation.
_legacy_warn_add_context_once()
data = {'context': context, 'word': word, 'origin': origin}
if original_key is not None:
Expand Down Expand Up @@ -853,14 +876,18 @@ def register_adapt_intent(self, name: str, intent_parser: object):
return self._adapt.register_adapt_intent(name, intent_parser)

def _set_context(self, context: str, word: str, origin: str,
original_key: Optional[str] = None):
original_key: Optional[str] = None,
turns_remaining: Optional[int] = None,
expires_at: Optional[float] = None):
"""Non-warning implementation shared by the deprecated public facade
(`set_context`) and OVOSSkill's own supported `set_context`/
`remove_context` API (ovos_workshop/skills/ovos.py), which delegates
here so the SUPPORTED base-class path does not itself trigger the
facade's external-caller deprecation warning."""
return self._adapt.set_context(context, word, origin,
original_key=original_key)
original_key=original_key,
turns_remaining=turns_remaining,
expires_at=expires_at)

def set_context(self, context: str, word: str, origin: str,
original_key: Optional[str] = None):
Expand Down
138 changes: 125 additions & 13 deletions ovos_workshop/skills/ovos.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,11 @@
from ovos_workshop.settings import PrivateSettings
from ovos_workshop.skills.capabilities import get_skill_capabilities
from ovos_workshop.skills.util import join_word_list, simple_trace
from ovos_workshop.version import VERSION_MAJOR

# mycroft.skill.set_cross_context/remove_cross_context are pre-OVOS-CONTEXT-1
# compat broadcasts; deprecated shims are removed in the next MAJOR release.
_CROSS_CONTEXT_REMOVAL_VERSION = f"{VERSION_MAJOR + 1}.0.0"


def _typed_slots_map(message: Message) -> Dict[str, Any]:
Expand Down Expand Up @@ -1796,21 +1801,51 @@ def handle_disable_intent(self, message: Message):
def handle_set_cross_context(self, message: Message):
"""
Add global context to the intent service.

CONTEXT-1 §5.0: "There is no context-mutation topic: no participant
emits a Message whose purpose is to announce a context change to the
orchestrator or to another component." So this listener MUST NOT
write `session.intent_context`: the shared entry is already on the
session that `set_cross_skill_context` wrote, and a second write per
receiving skill would make one logical mutation into N+1 session
pushes that race each other at handler completion (SESSION-2 §2.6).

It therefore reaches the `original_key is None` path, which touches
only the pre-CONTEXT-1 adapt `session.context` field through the
legacy `add_context` topic, and never `Session.set_intent_context`.
A skill's own `set_context` call keeps its private session write;
only this broadcast-driven path gives it up.

@param message: `mycroft.skill.set_cross_context` Message
"""
context = message.data.get('context')
word = message.data.get('word')
origin = message.data.get('origin')

self.set_context(context, word, origin)
if not isinstance(context, str):
raise ValueError('Context should be a string')
if not isinstance(word, str):
raise ValueError('Word should be a string')
# munged exactly as `set_context` munges it, so the adapt-engine key
# is unchanged; `original_key` is left unset, which is what keeps
# this off the session.
self.intent_service._set_context(
self.alphanumeric_skill_id + context, word, origin)

def handle_remove_cross_context(self, message: Message):
"""
Remove global context from the intent service.

The mirror of :meth:`handle_set_cross_context`: adapt
`session.context` only, never `session.intent_context`.

@param message: `mycroft.skill.remove_cross_context` Message
"""
context = message.data.get('context')
self.remove_context(context)
if not isinstance(context, str):
raise ValueError('context should be a string')
self.intent_service._remove_context(
self.alphanumeric_skill_id + context)

def _on_event_start(self, message: Message, handler_info: str,
skill_data: dict, activation: Optional[bool] = None):
Expand Down Expand Up @@ -3263,22 +3298,39 @@ def skill_will_match(self, utterance: str, lang: Optional[str] = None,
return False
return intent.get("skill_id") == self.skill_id

def set_context(self, context: str, word: str = '', origin: str = ''):
def set_context(self, context: str, word: str = '', origin: str = '',
turns_remaining: Optional[int] = None,
expires_at: Optional[float] = None):
"""
Add context to intent service.

CONTEXT-1 §5.0: writes directly into the session bound to the
current dispatch message (`Session.intent_context`, private scope
owned by this skill) via `IntentServiceInterface`/`_AdaptIntentApi`,
so the mutation rides forward on whatever Message this handler
emits next (§5.3). The legacy `add_context` bus message - a
different mechanism, the adapt-engine `session.context` field - is
also emitted, for pre-spec orchestrators only.
emits next (§5.3). The legacy `add_context` bus message is also
emitted, for pre-spec orchestrators only: it carries the write to
the adapt-engine `session.context` field. A core that predates
§5.0 reads it there; a modern core folds the same key back into
`session.intent_context`, so the topic is a write-through of the
session write above and not an independent mutation.

`turns_remaining`/`expires_at` are the CONTEXT-1 §2 decay fields.
Passing `turns_remaining=1` is the one-turn confirmation-branch
gate; the literal `{"value": ..., "turns_remaining": 1}` is §3.2's
flag-context worked example, and §1.2 describes the same branch in
prose. Left unset, decay stays time-based only, as before this
parameter existed.

Args:
context: Keyword
word: word connected to keyword
origin: origin of context
turns_remaining: number of intent matches this entry survives,
per CONTEXT-1 §2/§4. `None` (default) means no
turn-based decay.
expires_at: absolute unix timestamp this entry decays at. `None`
(default) falls back to `context.timeout` config.
"""
if not isinstance(context, str):
raise ValueError('Context should be a string')
Expand All @@ -3288,7 +3340,9 @@ def set_context(self, context: str, word: str = '', origin: str = ''):
original_context = context
context = self.alphanumeric_skill_id + context
self.intent_service._set_context(context, word, origin,
original_key=original_context)
original_key=original_context,
turns_remaining=turns_remaining,
expires_at=expires_at)

def remove_context(self, context: str):
"""
Expand All @@ -3304,30 +3358,88 @@ def remove_context(self, context: str):
self.intent_service._remove_context(context,
original_key=original_context)

def set_cross_skill_context(self, context: str, word: str = ''):
"""
Tell all skills to add a context to the intent service
def set_cross_skill_context(self, context: str, word: str = '',
turns_remaining: Optional[int] = None,
expires_at: Optional[float] = None):
"""
Add a context entry visible to every skill's intents.

CONTEXT-1 §3/§5.0: a shared entry is a bare (owner-less) key in
`session.intent_context`, so writing it directly into the session
bound to the current dispatch message (§5.3) already makes it
visible to every other skill's intents reading that same session -
no bus round trip is needed for the mutation itself. The session
write above is the only `session.intent_context` mutation this call
causes, and it is the authoritative one.

The legacy `mycroft.skill.set_cross_context` broadcast below is kept
for orchestrators that still consume it. Its listener in every
receiving skill (`handle_set_cross_context`) writes only that
skill's adapt `session.context` field and never
`session.intent_context`, so the broadcast adds no second writer of
the shared entry — §5.0 removes the class of context-mutation topic
rather than replacing it, and this emit is a compat surface with a
removal version, not a mechanism.

Args:
context: Keyword
word: word connected to keyword
context: Keyword
word: word connected to keyword
turns_remaining: number of intent matches this entry survives,
per CONTEXT-1 §2/§4. `None` (default) means no
turn-based decay.
expires_at: absolute unix timestamp this entry decays at. `None`
(default) falls back to the `context.timeout`
configuration **of the process making this call**,
which on a satellite is not the orchestrator's
configuration. §5.3 gives a deployer-configurable
default decay to the orchestrator, for entries
written without an explicit `turns_remaining` or
`expires_at`; because this path always stamps one,
that orchestrator-side default never reaches a
shared entry. Pass `expires_at` explicitly when the
window matters.
"""
msg = dig_for_message() or Message("")
if "skill_id" not in msg.context:
msg.context["skill_id"] = self.skill_id
session = SessionManager.get(msg)
if expires_at is None:
# OVOS-CONTEXT-1: same decay policy as the private set_context
# path (`context.timeout`, minutes, default 2).
context_cfg = Configuration().get('context', {})
timeout_s = context_cfg.get('timeout', 2) * 60
expires_at = time.time() + timeout_s if timeout_s > 0 else None
session.set_intent_context(context, word, scope="shared",
expires_at=expires_at,
turns_remaining=turns_remaining)
log_deprecation(
"mycroft.skill.set_cross_context is a pre-OVOS-CONTEXT-1 "
"compat broadcast kept for orchestrators that still consume "
"it; the shared session write above is now the authoritative "
"mutation", _CROSS_CONTEXT_REMOVAL_VERSION)
self.bus.emit(msg.forward('mycroft.skill.set_cross_context',
{'context': context, 'word': word,
'origin': self.skill_id}))

def remove_cross_skill_context(self, context: str):
"""
Tell all skills to remove a keyword from the context manager.
Remove a shared context entry from every skill's intents.

CONTEXT-1 §3/§5.0: same session-delegation + legacy compat emit
as `set_cross_skill_context` above.
"""
if not isinstance(context, str):
raise ValueError('context should be a string')
msg = dig_for_message() or Message("")
if "skill_id" not in msg.context:
msg.context["skill_id"] = self.skill_id
session = SessionManager.get(msg)
session.remove_intent_context(context, scope="shared")
log_deprecation(
"mycroft.skill.remove_cross_context is a pre-OVOS-CONTEXT-1 "
"compat broadcast kept for orchestrators that still consume "
"it; the shared session removal above is now the authoritative "
"mutation", _CROSS_CONTEXT_REMOVAL_VERSION)
self.bus.emit(msg.forward('mycroft.skill.remove_cross_context',
{'context': context}))

Expand Down
43 changes: 43 additions & 0 deletions test/end2end/context1_reader_test_skill/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Copyright 2026 OpenVoiceOS
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Fixture skill for the OVOS-CONTEXT-1 §5.0 cross-skill-context
end-to-end test.

Registers one template intent gated by a §6 ``requires_context``
declaration on the SHARED (bare, owner-less) key ``person`` - a DIFFERENT
skill (``context1_setter_test_skill``) is the one that publishes it.
"""
from threading import Event

from ovos_workshop.skills.ovos import OVOSSkill


class Context1ReaderTestSkill(OVOSSkill):
"""Registers one template intent that only matches while a different
skill's shared context entry is live."""

def initialize(self):
self.handled = Event()
self.last_message = None
self.register_intent_file(
"height.intent", self.handle_height,
requires_context=[{"key": "person", "scope": "shared"}])

def handle_height(self, message):
self.last_message = message
self.handled.set()


def create_skill():
return Context1ReaderTestSkill()
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
tall he
Loading
Loading