Context¶
In a nutshell
Normally each thing you say to your assistant is treated on its own, with no memory of the last sentence. Conversational context is the short-term memory that lets you ask a follow-up like "where's he from?" right after "how tall is John Cleese?" The assistant remembers you were talking about Cleese and fills in the blank. Skill authors mark which details to remember. That memory is kept separate for each ongoing conversation, so different people or devices don't get mixed up. See Skill design guidelines or the Glossary.
๐ Formal specification
Intent context is specified by OVOS-CONTEXT-1 โ Intent Context, the declarative gating primitive over OVOS-PIPELINE-1 (its imperative complement is the converse plugin, OVOS-CONVERSE-1, see Converse Pipeline). See the spec index.
The spec model: a decaying per-session store that gates matching. In CONTEXT-1 terms, intent context is the field session.intent_context, a flat map of entries, each {value, expires_at?, turns_remaining?}. Every entry decays. The orchestrator prunes dead entries before each match round and decrements turns_remaining after it (CONTEXT-1 ยง4), so a confirmation flag set with turns_remaining: 1 lives for exactly the next utterance. An intent gates on context by declaring requires_context (match only while a key is live) and/or excludes_context (match only while a key is absent). These are normative across every intent engine (CONTEXT-1 ยง6/ยง6.1). Keys are scoped by shape: a bare key like person is shared (cross-skill), and a prefixed key <skill_id>:flag is private to that owner. Bare-string requires_context entries default to private scope, the safe default, so a foreign skill's shared person can never accidentally satisfy a private gate. You must write {key: person, scope: shared} to read across skills.
Four different things called 'context': do not conflate them. CONTEXT-1 ยง1.1 is explicit that the word context names four unrelated things:
| Name | What it is | JSON path |
|---|---|---|
Message.context |
the bus-envelope metadata on every Message (routing keys, the session carrier) |
context |
session.intent_context |
the field inside the session that holds context entries | context.session.intent_context |
| intent context (the term) | the decaying key/value state itself โ the entries in that field | (entries of the above) |
Match.slots |
the slot map produced at match time for one dispatch | data.slots |
This page is about the third (and the field that holds it). It is not Message.context, and a context entry is not a Match.slot. CONTEXT-1 ยง7's context-supplied-slot rule is the bridge: when a requires_context key also names a slot, its value fills that slot if the utterance did not. This is exactly the "remember which person" mechanism below.
A capped store. CONTEXT-1 ยง2 says the orchestrator SHOULD enforce a maximum
live-entry count per session, evicting the entry closest to natural expiry when a write
would exceed it. ovos-spec-tools ships the eviction helper with
DEFAULT_MAX_ENTRIES = 1024, but the shipped core does not call it yet โ today the only
bound on the store is entry expiry. Either way this is a safety bound, not a limit a
skill author needs to plan around.
When context and the utterance both fill a slot, the utterance wins. CONTEXT-1 ยง7 only fills a slot from context when the utterance itself left it empty; a value the matcher already extracted from what the user said is never overridden by a stored context entry.
Conversational context makes voice interactions feel more natural. The assistant keeps track of the subject you are discussing, so you do not have to repeat it.
What works in a skill today: the declarative requires_context=/excludes_context=
kwargs on @intent_handler (file-registered/Padatious intents, see below), a blocking prompt,
ask_yesno()/get_response() (see Statements and Prompts), keeping the skill in
the conversation with converse(), or the Adapt path: self.set_context() plus
IntentBuilder().require() (the TeaSkill example below).
requires_context and excludes_context gate matching in both the
Adapt and Padatious pipelines, as CONTEXT-1 ยง6
requires of every intent engine. Adapt gates keyword intents. Padatious gates template intents,
carrying the two declarations on the intent registration payload and dropping any candidate
whose gate is not satisfied against session.intent_context at match time:
{
"skill_id": "context.skill",
"intent_name": "guarded",
"lang": "en-US",
"samples": ["do the guarded thing"],
"requires_context": ["confirming"],
"excludes_context": ["muted"]
}
The private/shared scoping rules are identical on both sides: a bare key gates privately
against the registering skill_id, and reading a shared key needs the explicit
{"key": "person", "scope": "shared"} form.
Since ovos-workshop 9.6.0a1, @intent_handler carries the declaration side too, on any
intent, adapt or file-registered:
from ovos_workshop.decorators import intent_handler
@intent_handler("guarded.intent",
requires_context=["confirming"],
excludes_context=["muted"])
def handle_guarded(self, message):
...
The declaration reaches every engine, but each engine gates on it independently: the template
engines (Padatious, Padacioso, M2V) filter candidates against session.intent_context at match
time, and so does Adapt, whose keyword matcher stores each intent's declared
requires_context/excludes_context at registration and admits a candidate only when the gate
is satisfied (OVOS-CONTEXT-1 ยง6/ยง6.1). Adapt also keeps its own separate legacy mechanism
(.require() on a context keyword, the TeaSkill example below) for gating today, so a skill can
use either the declarative kwargs or .require().
A bare-string entry silently never matches a shared entry
A bare string like requires_context=["prev_dialog"] resolves to private scope, keyed
to the declaring skill (the safe default). If the context entry was stored with
scope: "shared", or by a different skill's private key, the gate never sees it. This is
not a crash or a warning. The intent just never matches. Use the long form,
requires_context=[{"key": "prev_dialog", "scope": "shared"}], to read an entry another
skill wrote as shared.
Context lives on the per-conversation Session, in Session.intent_context. It
is session-scoped, not a single global store, so concurrent users and devices keep
separate context.
Session.context still resolves as a read/write view projected over intent_context, kept
for older code: reads project from the canonical map and legacy writes
(inject_context, update_context, remove_context, clear_context) fold back into it,
so old mutation calls still take effect. Touching it warns. New code writes to
intent_context directly.
Follow-up questions¶
Keyword Contexts¶
How tall is John Cleese?
"John Cleese is 196 centimeters"
Where's he from?
"He's from England"
Context is added manually by the Skill creator using either the self.set_context() method or the @adds_context() decorator.
Consider the following intent handlers:
@intent_handler(IntentBuilder().require('PythonPerson').require('Length'))
def handle_length(self, message):
python = message.data.get('PythonPerson')
self.speak(f'{python} is {length_dict[python]} cm tall')
@intent_handler(IntentBuilder().require('PythonPerson').require('WhereFrom'))
def handle_from(self, message):
python = message.data.get('PythonPerson')
self.speak(f'{python} is from {from_dict[python]}')
To interact with the above handlers the user would need to say
User: How tall is John Cleese?
OVOS: John Cleese is 196 centimeters
User: Where is John Cleese from?
OVOS: He's from England
To get a more natural response the functions can be changed to let OVOS know which PythonPerson we're talking about by using the self.set_context() method to give context:
@intent_handler(IntentBuilder().require('PythonPerson').require('Length'))
def handle_length(self, message):
# PythonPerson can be any of the Monty Python members
python = message.data.get('PythonPerson')
self.speak(f'{python} is {length_dict[python]} cm tall')
self.set_context('PythonPerson', python)
@intent_handler(IntentBuilder().require('PythonPerson').require('WhereFrom'))
def handle_from(self, message):
# PythonPerson can be any of the Monty Python members
python = message.data.get('PythonPerson')
self.speak(f'He is from {from_dict[python]}')
self.set_context('PythonPerson', python)
When either method is called, OVOS adds the PythonPerson keyword to its context. If there is a match with Length but PythonPerson is missing, OVOS assumes the last mention of that keyword. The interaction can now become the one described at the top of the page.
User: How tall is John Cleese?
OVOS detects the Length keyword and the PythonPerson keyword
OVOS: 196 centimeters
John Cleese is added to the current context
User: Where's he from?
OVOS detects the WhereFrom keyword but not any PythonPerson keyword. The Context Manager activates and returns the latest entry of PythonPerson, which is John Cleese
OVOS: He's from England
Cross Skill Context¶
The context is limited by the keywords provided by the current Skill.
There is also self.set_cross_skill_context / self.remove_cross_skill_context, intended
to share a keyword with other Skills as well.
set_cross_skill_context emits the mycroft.skill.set_cross_context bus
message. Every loaded OVOSSkill subscribes to it (and to the matching
mycroft.skill.remove_cross_context) and re-applies the keyword under its
own namespace. This is how it becomes visible to other skills' context
gates.
@intent_handler(IntentBuilder().require('PythonPerson').require('WhereFrom'))
def handle_from(self, message):
# PythonPerson can be any of the Monty Python members
python = message.data.get('PythonPerson')
self.speak(f'He is from {from_dict[python]}')
self.set_context('PythonPerson', python) # context for this skill only
self.set_cross_skill_context('Location', from_dict[python]) # context for ALL skills
Generic shared names are effectively global
A shared-scope context entry is stored under its bare, un-namespaced key. A plain,
generic name (prev_dialog, date, person, sleeping_state) can satisfy any
other legacy skill's require()/requires_context gate of that same name, because
the legacy (pre-CONTEXT-1) Adapt lookup also probes the plain, un-munged keyword
alongside its own skill-munged one. Nothing stops a different skill from writing the
same generic name for an unrelated purpose. Prefer a distinctive, skill-specific
keyword (WhereFromLocation rather than Location) for anything you don't
deliberately intend to share, or use the declarative {"key": ..., "scope": "shared"}
form only when cross-skill visibility is exactly what you want.
In this example Location keyword is shared with the WeatherSkill
User: Where is John Cleese from?
OVOS: He's from England
User: What's the weather like over there?
OVOS: Raining and 14 degrees...
Hint Keyword contexts¶
Context does not need a value. Its presence alone can indicate a previous interaction happened.
In this case, context can also be implemented with decorators instead of calling self.set_context.
.require('MilkContext') below needs no matching MilkContext.voc file. A require()
keyword with no backing vocabulary is not matched against the utterance at all. It can
only ever be satisfied by a context entry of the same name, so it behaves as a pure
context gate: present in the current context, or the intent does not match.
from ovos_workshop.decorators import adds_context, removes_context
class TeaSkill(OVOSSkill):
@intent_handler(IntentBuilder('TeaIntent').require("TeaKeyword"))
@adds_context('MilkContext')
def handle_tea_intent(self, message):
self.milk = False
self.speak('Of course, would you like Milk with that?',
expect_response=True)
The full, worked-through version of this TeaSkill, with every handler, is in
Using context to enable Intents below.
NOTE: cross skill context is not yet exposed via decorators
Using context to enable Intents¶
Context can create "bubbles" of available intent handlers, so certain intents can't trigger unless some previous stage in a conversation has occurred.
This is the idea behind the
requires_contextgate of CONTEXT-1 ยง6, expressed through the legacy mechanism that works today:MilkContextis a private flag (the@adds_contextdecorator stores it under the skill's own prefix), and an intent that.require('MilkContext')declares it as an Adapt keyword precondition, so theyes/nointents are invisible except in the narrow window between the question and the reply, with no skill-side state machine. Note that.require()keyword matching and the declarativerequires_context=/excludes_context=intent kwargs are different code paths: the declarative gate looks entries up under the resolved<skill_id>:<key>shape, whichset_context/@adds_contextalso write (see "Both spellings are written, with one decay policy" below). The complementaryexcludes_contextgate (CONTEXT-1 ยง6.1) handles fire-once intents (for example, "greet only once per session").
User: Hey Mycroft, bring me some Tea
OVOS: Of course, would you like Milk with that?
User: No
OVOS: How about some Honey?
User: All right then
OVOS: Here you go, here's your Tea with Honey
from ovos_workshop.decorators import adds_context, removes_context
class TeaSkill(OVOSSkill):
@intent_handler(IntentBuilder('TeaIntent').require("TeaKeyword"))
@adds_context('MilkContext')
def handle_tea_intent(self, message):
self.milk = False
self.speak('Of course, would you like Milk with that?',
expect_response=True)
@intent_handler(IntentBuilder('NoMilkIntent').require("NoKeyword").
require('MilkContext').build())
@removes_context('MilkContext')
@adds_context('HoneyContext')
def handle_no_milk_intent(self, message):
self.speak('all right, any Honey?', expect_response=True)
@intent_handler(IntentBuilder('YesMilkIntent').require("YesKeyword").
require('MilkContext').build())
@removes_context('MilkContext')
@adds_context('HoneyContext')
def handle_yes_milk_intent(self, message):
self.milk = True
self.speak('What about Honey?', expect_response=True)
@intent_handler(IntentBuilder('NoHoneyIntent').require("NoKeyword").
require('HoneyContext').build())
@removes_context('HoneyContext')
def handle_no_honey_intent(self, message):
if self.milk:
self.speak('Heres your Tea with a dash of Milk')
else:
self.speak('Heres your Tea, straight up')
@intent_handler(IntentBuilder('YesHoneyIntent').require("YesKeyword").
require('HoneyContext').build())
@removes_context('HoneyContext')
def handle_yes_honey_intent(self, message):
if self.milk:
self.speak('Heres your Tea with Milk and Honey')
else:
self.speak('Heres your Tea with Honey')
At startup, only the TeaIntent is available. Once it triggers and adds MilkContext, the MilkYesIntent and MilkNoIntent become available, since MilkContext is set. When a yes or no is received, MilkContext is removed and can't be accessed. In its place, HoneyContext is added, making the YesHoneyIntent and NoHoneyIntent available.
You can find an example Tea Skill using conversational context on Github.
As you can see, Conversational Context lends itself well to implementing a dialog tree or conversation tree.
Under the hood¶
set_context / remove_context are thin wrappers. They prefix the keyword with the
skill id (the legacy Adapt dialect: alphanumeric_skill_id + context, no separator)
and emit bus messages that ovos-core handles on the active Session:
| Message | Effect |
|---|---|
add_context |
inject a keyword (and optional value) into Session.context |
remove_context |
drop a single keyword |
clear_context |
wipe all context for the session |
Alongside the legacy munged context field, add_context / remove_context also
carry the original, unmunged keyword as an additive key field on the message data.
This lets a consumer resolve the same call under either spelling: the legacy
Adapt dialect (context) or the colon-separated <skill_id>:<key> shape that
CONTEXT-1's declarative requires_context / excludes_context gate (above) expects.
set_cross_skill_context and the deprecated set_adapt_context /
remove_adapt_context shortcuts carry the same additive key.
Two distinct mechanisms, not one dual-write
set_context writes intent_context directly into the session bound to the dispatch
message (SessionManager.get(message), CONTEXT-1 ยง5.3). Because that write lands on
the live session object the handler is already running against, context set inside a
handler is visible to the dispatch that immediately follows it, in the same handler
invocation, with no race. It writes two entries there: the legacy munged
skillidkey spelling and the resolved <skill_id>:<key> spelling the declarative gate
looks up. One computed expires_at applies to both, so set_context entries decay
normally (context.timeout, minutes, default 2) and a re-set refreshes the expiry of
both keys.
Separately, the legacy add_context bus message is also emitted, for pre-spec
orchestrators that only understand Session.context. This is a different consumer, not
a second write path into intent_context. The two live in different session fields.
The legacy emit now logs a deprecation warning once per process; it is scheduled for
removal at ovos-workshop 10.0.0.
Writing a key replaces it wholesale; there is no read-back
Writing an entry under a key that already exists replaces the whole entry, value and
decay timer both. It does not merge onto the old one. A re-set refreshes the decay
window, which is what naptime-style "keep this alive while we talk" flows rely on.
(Entries carry an explicit expires_at computed from the context.timeout config value
in minutes, default 2, which is where Session's "~2 minutes" figure
comes from; spec-shaped writes may instead carry turns_remaining.) There is no API
to read a
context entry's current value or remaining decay back out. A skill that wants to know
"what did I set this to, and how long ago" must keep that in its own state, not rely on
reading it back from context. A flag-style call with no explicit value,
self.set_context('MilkContext'), still needs something to store: the underlying wire
message stores the keyword name itself as the value ({"value": word or context}), not
an empty or null value.
The decorators are equivalent to calling these methods:
@adds_context('MilkContext')callsset_context('MilkContext')after the handler runs.@removes_context('MilkContext')callsremove_context('MilkContext').
Because context is attached to the Session, each handler receives the message that triggered it.
The Adapt pipeline reads Session.context when scoring intents. This is why missing keywords
fall back to the most recent matching context entry.
Read next: Intent Layers Related: Asking the User for Responses in OVOS Skills ยท Session Aware Skills ยท Intent Design ยท Converse