Coverage for integrations / agent_engine / escalation_reasons.py: 100.0%
19 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""Canonical taxonomy of *why* the draft-first dispatcher escalated a turn
2to the expert background path.
4Design intent
5=============
7`dispatch_draft_first` runs several short-circuit guards that promote a
8turn from "draft answered → done" to "draft was a standby, expert will
9take over": refusal pattern in the reply, low-confidence ``delegate=none``,
10agent-bound prompt, classifier-surfaced actionable intent, or the draft's
11own explicit ``delegate=local/hive``.
13Today each guard logs its decision and that's it — once we leave the
14function, callers / observers / telemetry / WorldModelBridge can't tell
15*why* a given speculation_id ended up in expert-pending state. That
16matters for three downstream consumers:
18 1. **WorldModelBridge / continual learning** — distillation should weight
19 refusal-overridden draft replies very differently from
20 parse-failure-defaulted ones. Without the reason, the bridge has to
21 re-derive the heuristic, which is exactly the parallel-path
22 anti-pattern Gate 4 warns against.
23 2. **Telemetry / admin diag** — calibration of the draft model's
24 classifier needs the breakdown of *which* guard fires most often.
25 Without persisted reasons, calibration has to be re-run against logs.
26 3. **The collapsed expert path** (next commit) — when we route to the
27 full langchain path, the system prompt needs to know whether to
28 bind the full tool registry (actionable intent / agent-bound) or
29 reason in-band (refusal override / low-confidence verifier).
31This module is the single source of truth. Everywhere downstream that
32mentions an escalation reason imports from here — no string-literal
33parallel paths.
34"""
35from __future__ import annotations
37from enum import Enum
38from typing import Optional, Union
41class EscalationReason(str, Enum):
42 """Why dispatch_draft_first escalated this turn to expert.
44 Inherits ``str`` so the value round-trips cleanly through JSON
45 serialisation (telemetry, WorldModelBridge, SSE) and ``==`` works
46 against both the enum member and the raw string. Matches the
47 pattern ``ModelTier`` already uses in this package.
48 """
50 #: The draft's own classifier returned ``delegate='local'`` or
51 #: ``delegate='hive'`` — the model itself decided to hand off. This
52 #: is the baseline / "expected" reason. All other reasons describe a
53 #: *forced* escalation where the dispatcher overrode a
54 #: ``delegate='none'`` decision.
55 CLASSIFIER_DELEGATE = 'classifier_delegate'
57 #: The draft emitted a refusal pattern ("I can't…", "Sorry, I'm
58 #: unable…") which violates the role contract. Standby reply
59 #: replaces it and expert (with full tool registry) takes the turn.
60 REFUSAL_OVERRIDE = 'refusal_override'
62 #: Draft said ``delegate='none'`` but its self-reported confidence
63 #: fell below ``_DRAFT_CONFIDENCE_FLOOR``. Promote to a local
64 #: verifier rather than ship an uncertain answer as final.
65 LOW_CONFIDENCE = 'low_confidence'
67 #: The prompt_id binds this turn to a specific agent (recipe on
68 #: disk, not request-id fallback). The user picked a specialist;
69 #: even trivial replies must pass through that specialist's
70 #: persona / system prompt / tool registry.
71 AGENT_BOUND = 'agent_bound'
73 #: Classifier surfaced an actionable-intent flag (channel_connect,
74 #: is_create_agent, language_change, invite_intent, join_room_intent,
75 #: memory_query). Answering in-band would orphan the action — only
76 #: the expert path has the matching tools bound.
77 ACTIONABLE_INTENT = 'actionable_intent'
79 #: Draft envelope failed to parse as JSON. Defaults to a local
80 #: escalation so we don't ship an unparseable draft as final. Often
81 #: indicates the draft model needs re-prompting or replacement.
82 PARSE_FAILURE = 'parse_failure'
85def coerce(value: Union[None, str, EscalationReason]) -> Optional[EscalationReason]:
86 """Coerce a possibly-string value to ``EscalationReason``.
88 Returns ``None`` when ``value`` is None / empty / unrecognised so
89 callers can use this in legacy paths that may have stamped a raw
90 string in a dict. Never raises — escalation_reason is observability
91 metadata; a bad value must not break the chat path.
92 """
93 if value is None or value == '':
94 return None
95 if isinstance(value, EscalationReason):
96 return value
97 try:
98 return EscalationReason(str(value))
99 except ValueError:
100 return None