Skip to content

Commit 810ecbf

Browse files
JarbasAlclaude
andcommitted
feat: orchestrator owns the PIPELINE-1 §9 utterance-terminal events
Complete the orchestrator's ownership of the OVOS-PIPELINE-1 §6.1 per-utterance terminal sequence, on top of the §8 handler-lifecycle trio: - §9.2 ovos.intent.matched — emitted by _dispatch_match on every accepted match, before the dispatch goes out (notification, not a dispatch). Carries skill_id, intent_name (the full <skill_id>:<intent_name> match_type), lang, utterance, slots, pipeline_id. - §9.3 ovos.intent.unmatched — the no-match / all-filtered terminal, replacing the legacy complete_intent_failure (the two are bridged by ovos-spec-tools' MIGRATION_MAP, so emitting the spec topic re-delivers the legacy one to consumers still on it). - §6.4 cancellation now emits the spec ovos.utterance.cancelled. Each utterance terminates with exactly one ovos.utterance.handled (§9.5): core owns it on the no-match, cancel and §8.3-timeout paths; on the ordinary matched path the skill framework still emits it (moving that fully into core is gated on the ovos-workshop reduction). Rename _emit_match_message -> _dispatch_match (it orchestrates the §6.1 post-match steps then dispatches) and correct the IntentDispatcher docstring to scope it to §7 dispatch + §8 trio (the §9.2 notification lives in the service). Verified on a real minicroft: matched path emits matched/start/ complete/handled exactly once each; no-match path emits ovos.intent.unmatched + ovos.utterance.handled (no complete_intent_ failure). test_no_skills / test_lang_detect conformance suites green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 51ca26a commit 810ecbf

12 files changed

Lines changed: 190 additions & 111 deletions

ovos_core/intent_services/dispatcher.py

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -47,12 +47,16 @@
4747
- ``mycroft.skill.handler.error`` (carrying a human-readable error) → the
4848
orchestrator emits ``error`` with the reported ``exception`` (§8.2).
4949
50-
These are **legacy-namespace** topics and, with the OVOS-MSG-1 trio bridge
51-
removed from ovos-spec-tools, they do **not** bridge to the spec trio — so the
52-
orchestrator's own spec emissions never echo back as a done-signal and no
53-
echo-guard is needed. The framework done-signal and the spec trio live in
54-
separate namespaces: workshop owns the legacy one, the orchestrator owns the
55-
spec one.
50+
These are **legacy-namespace** topics. **Hard dependency:** the ovos-spec-tools
51+
MIGRATION_MAP trio bridge (``mycroft.skill.handler.* ↔ ovos.intent.handler.*``)
52+
MUST be removed so the orchestrator's own spec emissions do not bridge back to a
53+
legacy done-signal. Until that lands the bridge is still active, but the
54+
resolved-guard in :meth:`_pop` keeps the terminal count at exactly one even if a
55+
bridged echo arrives (it claims an already-resolved entry and returns ``None``);
56+
the ``"message"``-aggregate consumers (the ovoscope harness) also never see the
57+
bridged counterpart. Once the bridge is removed, the framework done-signal and the
58+
spec trio live cleanly in separate namespaces: workshop owns the legacy one, the
59+
orchestrator owns the spec one.
5660
5761
The §8.3 timeout backstops every dispatch so exactly one terminal is guaranteed
5862
even if no done-signal ever arrives.
@@ -95,11 +99,15 @@ def __init__(self, skill_id: str, intent_name: str, dispatch_msg: Message):
9599

96100

97101
class IntentDispatcher:
98-
"""Owns the PIPELINE-1 §6.1 matched-path sequence (§9.2 notification, §7
99-
dispatch, §8 handler-lifecycle trio).
100-
101-
Owned by ``IntentService``; wires its own bus observers for the framework
102-
done-signals. The orchestrator hands it a dispatch Message via :meth:`dispatch`.
102+
"""Owns the PIPELINE-1 §7 dispatch + §8 handler-lifecycle trio.
103+
104+
Emits ``ovos.intent.handler.start`` before the ``<skill_id>:<intent_name>``
105+
dispatch and exactly one terminal (``complete``/``error``/timeout) after. The
106+
surrounding §6.1 orchestration — the §9.2 ``ovos.intent.matched`` notification,
107+
skill activation, session update — lives in
108+
``IntentService._dispatch_match``, which hands a built dispatch Message to
109+
:meth:`dispatch`. This class wires its own bus observers for the framework
110+
done-signals (``mycroft.skill.handler.complete``/``.error``).
103111
"""
104112

105113
def __init__(self, bus, timeout: Optional[float] = DEFAULT_HANDLER_TIMEOUT):

ovos_core/intent_services/service.py

Lines changed: 52 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -122,11 +122,12 @@ def __init__(self, bus, config=None, preload_pipelines=True,
122122
self.metadata_plugins = MetadataTransformersService(bus)
123123
self.intent_plugins = IntentTransformersService(bus)
124124

125-
# OVOS-PIPELINE-1 §6.1: the orchestrator owns the post-match terminal
126-
# sequence — ovos.intent.matched (§9.2), dispatch (§7) and the §8
127-
# handler-lifecycle trio. ``handler_timeout`` (seconds, §8.3) bounds
128-
# handler execution so exactly one terminal is guaranteed even if a
129-
# handler never reports; 0/None disables the timer.
125+
# OVOS-PIPELINE-1 §7/§8: the dispatcher owns the dispatch on
126+
# <skill_id>:<intent_name> (§7) and the handler-lifecycle trio (§8). The
127+
# surrounding §6.1 orchestration (§9.2 ovos.intent.matched, skill
128+
# activation, session update) lives in _dispatch_match. ``handler_timeout``
129+
# (seconds, §8.3) bounds handler execution so exactly one terminal is
130+
# guaranteed even if a handler never reports; 0/None disables the timer.
130131
handler_timeout = self.config.get("handler_timeout", DEFAULT_HANDLER_TIMEOUT)
131132
self.intent_dispatcher = IntentDispatcher(bus, timeout=handler_timeout)
132133

@@ -281,31 +282,24 @@ def _handle_deactivate(self, message):
281282
skill_id = message.data.get("skill_id")
282283
self._deactivations[sess.session_id].append(skill_id)
283284

284-
def _emit_match_message(self, match: IntentHandlerMatch, message: Message, lang: str,
285-
pipeline_id: str = None):
286-
"""
287-
Emit a reply message for a matched intent, updating session and skill activation.
285+
def _dispatch_match(self, match: IntentHandlerMatch, message: Message, lang: str,
286+
pipeline_id: str = None):
287+
"""Orchestrate the OVOS-PIPELINE-1 §6.1 post-match steps, then dispatch.
288288
289-
This method processes matched intents from either a pipeline matcher or an intent handler,
290-
creating a reply message with matched intent details and managing skill activation.
289+
Runs the service-state-dependent post-match orchestration — the
290+
intent-transformer chain (TRANSFORM-1 §3.4), skill activation +
291+
``{skill_id}.activate``, session update, and ``context['pipeline_id']``
292+
stamping (§7.1) — builds the dispatch Message, emits the §9.2
293+
``ovos.intent.matched`` notification, and hands the dispatch Message to
294+
the IntentDispatcher, which owns the §7 dispatch + §8 handler-lifecycle
295+
trio.
291296
292297
Args:
293-
match (IntentHandlerMatch): The matched intent object containing
294-
utterance and matching information.
295-
message (Message): The original messagebus message that triggered the intent match.
296-
lang (str): The language of the pipeline plugin match
297-
298-
Details:
299-
- Handles two types of matches: PipelineMatch and IntentHandlerMatch
300-
- Creates a reply message with matched intent data
301-
- Activates the corresponding skill if not previously deactivated
302-
- Updates session information
303-
- Emits the reply message on the messagebus
304-
305-
Side Effects:
306-
- Modifies session state
307-
- Emits a messagebus event
308-
- Can trigger skill activation events
298+
match (IntentHandlerMatch): The matched intent (utterance, match_type,
299+
skill_id, match_data, optional updated_session).
300+
message (Message): The originating utterance Message to derive from.
301+
lang (str): The content language of the match.
302+
pipeline_id (str): The pipeline plugin that produced the match (§3.1).
309303
310304
Returns:
311305
None
@@ -358,6 +352,18 @@ def _emit_match_message(self, match: IntentHandlerMatch, message: Message, lang:
358352
if pipeline_id:
359353
reply.context["pipeline_id"] = pipeline_id
360354

355+
# OVOS-PIPELINE-1 §9.2: broadcast ovos.intent.matched BEFORE the
356+
# dispatch goes out. A notification, not a dispatch: consumers MUST NOT
357+
# treat receipt as permission to run a handler.
358+
self.bus.emit(reply.forward(SpecMessage.INTENT_MATCHED, {
359+
"skill_id": match.skill_id,
360+
"intent_name": match.match_type,
361+
"lang": lang,
362+
"utterance": match.utterance,
363+
"slots": dict(match.match_data or {}),
364+
"pipeline_id": reply.context.get("pipeline_id"),
365+
}))
366+
361367
# OVOS-PIPELINE-1 §7 dispatch + §8 handler-lifecycle trio: hand the
362368
# dispatch Message to the IntentDispatcher, which emits
363369
# ovos.intent.handler.start (§8.1) before the dispatch and the matching
@@ -429,8 +435,9 @@ def send_cancel_event(self, message):
429435
sound = Configuration().get('sounds', {}).get('cancel', "snd/cancel.mp3")
430436
# NOTE: message.reply to ensure correct message destination
431437
self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound}))
432-
self.bus.emit(message.reply("ovos.utterance.cancelled"))
433-
self.bus.emit(message.reply("ovos.utterance.handled"))
438+
# OVOS-PIPELINE-1 §6.4 cancellation terminal path: cancelled -> handled
439+
self.bus.emit(message.reply(SpecMessage.UTTERANCE_CANCELLED))
440+
self.bus.emit(message.reply(SpecMessage.UTTERANCE_HANDLED))
434441

435442
def handle_utterance(self, message: Message):
436443
"""Main entrypoint for handling user utterances
@@ -501,7 +508,7 @@ def handle_utterance(self, message: Message):
501508
f"ignoring match, intent '{match.match_type}' blacklisted by Session '{sess.session_id}'")
502509
continue
503510
try:
504-
self._emit_match_message(match, message, intent_lang,
511+
self._dispatch_match(match, message, intent_lang,
505512
pipeline_id=pipeline)
506513
break
507514
except Exception:
@@ -526,16 +533,28 @@ def handle_utterance(self, message: Message):
526533
return match, message.context, stopwatch
527534

528535
def send_complete_intent_failure(self, message):
529-
"""Send a message that no skill could handle the utterance.
536+
"""Emit the OVOS-PIPELINE-1 §9.3 no-match terminal.
537+
538+
The orchestrator owns the no-match branch of the §6.1 lifecycle: it plays
539+
the error sound, emits ``ovos.intent.unmatched`` (§9.3 — the intent-layer
540+
failure signal) and then the universal end-marker ``ovos.utterance.handled``
541+
(§9.5). Exactly one ``ovos.utterance.handled`` terminates the utterance.
542+
543+
``ovos.intent.unmatched`` is the spec replacement for the legacy
544+
``complete_intent_failure``; the two are bridged by ovos-spec-tools'
545+
MIGRATION_MAP, so emitting the spec topic re-delivers the legacy one to
546+
any consumer still subscribed to it.
530547
531548
Args:
532549
message (Message): original message to forward from
533550
"""
534551
sound = Configuration().get('sounds', {}).get('error', "snd/error.mp3")
535552
# NOTE: message.reply to ensure correct message destination
536553
self.bus.emit(message.reply('mycroft.audio.play_sound', {"uri": sound}))
537-
self.bus.emit(message.reply('complete_intent_failure', message.data))
538-
self.bus.emit(message.reply("ovos.utterance.handled"))
554+
# §9.3: intent-layer failure signal (carries lang from message.data)
555+
self.bus.emit(message.reply(SpecMessage.INTENT_UNMATCHED, message.data))
556+
# §9.5: universal end-marker
557+
self.bus.emit(message.reply(SpecMessage.UTTERANCE_HANDLED))
539558

540559
@staticmethod
541560
def handle_add_context(message: Message):

test/end2end/test_adapt.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,16 @@
2727
LEGACY_UTTERANCE = migration_counterpart(SPEC_UTTERANCE)
2828
SPEC_SPEAK = SpecMessage.SPEAK.value
2929
UTTERANCE_HANDLED = SpecMessage.UTTERANCE_HANDLED.value
30-
# PIPELINE-1 §8 handler-lifecycle trio, emitted by the orchestrator (core)
31-
# wrapping the dispatch: start before, complete on the framework done-signal. The
32-
# skill's own ovos.utterance.handled (§9.5) is untouched by this change.
30+
# PIPELINE-1 orchestrator-emitted matched-path messages: §9.2 ovos.intent.matched
31+
# (before dispatch) and the §8 handler-lifecycle trio (start before dispatch,
32+
# complete on the framework done-signal). The skill's own ovos.utterance.handled
33+
# (§9.5) is left to ovos-workshop on this matched path.
34+
INTENT_MATCHED = SpecMessage.INTENT_MATCHED.value
3335
HANDLER_START = SpecMessage.INTENT_HANDLER_START.value
3436
HANDLER_COMPLETE = SpecMessage.INTENT_HANDLER_COMPLETE.value
37+
# PIPELINE-1 §9.3: the no-match / all-filtered terminal is ovos.intent.unmatched
38+
# (the spec replacement for the legacy complete_intent_failure).
39+
INTENT_UNMATCHED = SpecMessage.INTENT_UNMATCHED.value
3540

3641
NAMESPACE_PATHS = {
3742
"spec": (False, False, SPEC_UTTERANCE),
@@ -76,6 +81,14 @@ def _run_adapt_match(self, namespace):
7681
Message(f"{self.skill_id}.activate",
7782
data={},
7883
context={"skill_id": self.skill_id}),
84+
# PIPELINE-1 §9.2: matched notification, before the dispatch.
85+
# intent_name carries the full <skill_id>:<intent_name> match_type.
86+
Message(INTENT_MATCHED,
87+
data={"skill_id": self.skill_id,
88+
"intent_name": f"{self.skill_id}:HelloWorldIntent",
89+
"utterance": "hello world",
90+
"lang": session.lang},
91+
context={"skill_id": self.skill_id}),
7992
# PIPELINE-1 §8.1: orchestrator emits start immediately before dispatch
8093
Message(HANDLER_START,
8194
data={"skill_id": self.skill_id,
@@ -142,7 +155,7 @@ def _run_skill_blacklist(self, namespace):
142155
expected_messages=[
143156
message,
144157
Message("mycroft.audio.play_sound", {"uri": "snd/error.mp3"}),
145-
Message("complete_intent_failure", {}),
158+
Message(INTENT_UNMATCHED, {}),
146159
Message(UTTERANCE_HANDLED, {})
147160
]
148161
)
@@ -178,7 +191,7 @@ def _run_intent_blacklist(self, namespace):
178191
expected_messages=[
179192
message,
180193
Message("mycroft.audio.play_sound", {"uri": "snd/error.mp3"}),
181-
Message("complete_intent_failure", {}),
194+
Message(INTENT_UNMATCHED, {}),
182195
Message(UTTERANCE_HANDLED, {})
183196
]
184197
)
@@ -213,7 +226,7 @@ def _run_padatious_no_match(self, namespace):
213226
expected_messages=[
214227
message,
215228
Message("mycroft.audio.play_sound", {"uri": "snd/error.mp3"}),
216-
Message("complete_intent_failure", {}),
229+
Message(INTENT_UNMATCHED, {}),
217230
Message(UTTERANCE_HANDLED, {})
218231
]
219232
)

test/end2end/test_converse.py

Lines changed: 17 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -25,9 +25,15 @@
2525
LEGACY_UTTERANCE = migration_counterpart(SPEC_UTTERANCE)
2626
SPEC_SPEAK = SpecMessage.SPEAK.value
2727
UTTERANCE_HANDLED = SpecMessage.UTTERANCE_HANDLED.value
28-
# PIPELINE-1 §8 handler-lifecycle trio, emitted by the orchestrator (core).
29-
HANDLER_START = SpecMessage.INTENT_HANDLER_START.value
30-
HANDLER_COMPLETE = SpecMessage.INTENT_HANDLER_COMPLETE.value
28+
INTENT_MATCHED = SpecMessage.INTENT_MATCHED.value # ovos.intent.matched (§9.2)
29+
# §8 handler-lifecycle trio wraps every dispatch; this suite asserts converse
30+
# routing, not the trio (covered by the adapt/padatious suites), so it is
31+
# filtered via ignore_messages below.
32+
HANDLER_TRIO = [SpecMessage.INTENT_HANDLER_START.value,
33+
SpecMessage.INTENT_HANDLER_COMPLETE.value,
34+
SpecMessage.INTENT_HANDLER_ERROR.value,
35+
"ovos.skills.settings_changed"] # keep ovoscope's default ignore
36+
INTENT_UNMATCHED = SpecMessage.INTENT_UNMATCHED.value # ovos.intent.unmatched (§9.3)
3137

3238
# key -> (modernize, emit_legacy, utterance_topic)
3339
NAMESPACE_PATHS = {
@@ -75,10 +81,9 @@ def _run_parrot_mode(self, namespace: str) -> None:
7581
Message(f"{self.skill_id}.activate",
7682
data={},
7783
context={"skill_id": self.skill_id}),
78-
# PIPELINE-1 §8.1: orchestrator start before dispatch
79-
Message(HANDLER_START,
84+
Message(INTENT_MATCHED,
8085
data={"skill_id": self.skill_id,
81-
"intent_name": "start_parrot.intent"},
86+
"intent_name": f"{self.skill_id}:start_parrot.intent"},
8287
context={"skill_id": self.skill_id}),
8388
Message(f"{self.skill_id}:start_parrot.intent",
8489
data={"utterance": "start parrot mode", "lang": session.lang},
@@ -97,11 +102,6 @@ def _run_parrot_mode(self, namespace: str) -> None:
97102
Message("mycroft.skill.handler.complete",
98103
data={"name": "ParrotSkill.handle_start_parrot_intent"},
99104
context={"skill_id": self.skill_id}),
100-
# PIPELINE-1 §8.1: orchestrator complete before the end-marker
101-
Message(HANDLER_COMPLETE,
102-
data={"skill_id": self.skill_id,
103-
"intent_name": "start_parrot.intent"},
104-
context={"skill_id": self.skill_id}),
105105
Message(UTTERANCE_HANDLED,
106106
data={},
107107
context={"skill_id": self.skill_id}),
@@ -117,10 +117,8 @@ def _run_parrot_mode(self, namespace: str) -> None:
117117
Message(f"{self.skill_id}.activate",
118118
data={},
119119
context={"skill_id": self.skill_id}),
120-
# PIPELINE-1 §7.0/§8.1: a converse dispatch is a dispatch -> orchestrator
121-
# emits start before it (intent_name is the reserved name "skill").
122-
Message(HANDLER_START,
123-
data={"skill_id": self.skill_id, "intent_name": "skill"},
120+
Message(INTENT_MATCHED,
121+
data={"skill_id": self.skill_id, "intent_name": "converse:skill"},
124122
context={"skill_id": self.skill_id}),
125123
Message("converse:skill",
126124
data={"utterances": ["echo test"], "lang": session.lang, "skill_id": self.skill_id},
@@ -155,9 +153,8 @@ def _run_parrot_mode(self, namespace: str) -> None:
155153
data={},
156154
context={"skill_id": self.skill_id}),
157155

158-
# PIPELINE-1 §7.0/§8.1: orchestrator start before the converse dispatch
159-
Message(HANDLER_START,
160-
data={"skill_id": self.skill_id, "intent_name": "skill"},
156+
Message(INTENT_MATCHED,
157+
data={"skill_id": self.skill_id, "intent_name": "converse:skill"},
161158
context={"skill_id": self.skill_id}),
162159
Message("converse:skill",
163160
data={"utterances": ["stop parrot"], "lang": session.lang, "skill_id": self.skill_id},
@@ -191,7 +188,7 @@ def _run_parrot_mode(self, namespace: str) -> None:
191188
data={"can_handle": False, "skill_id": self.skill_id},
192189
context={"skill_id": self.skill_id}),
193190
Message("mycroft.audio.play_sound", data={"uri": "snd/error.mp3"}),
194-
Message("complete_intent_failure"),
191+
Message(INTENT_UNMATCHED),
195192
Message(UTTERANCE_HANDLED)
196193
]
197194

@@ -207,6 +204,7 @@ def _run_parrot_mode(self, namespace: str) -> None:
207204
final_session=final_session,
208205
source_message=[message1, message2, message3, message4],
209206
expected_messages=expected1 + expected2 + expected3 + expected4,
207+
ignore_messages=HANDLER_TRIO,
210208
activation_points=[f"{self.skill_id}:start_parrot.intent"],
211209
# messages internal to ovos-core, i.e. would not be sent to clients such as hivemind
212210
keep_original_src=[f"{self.skill_id}.converse.ping",

0 commit comments

Comments
 (0)