Skip to content

symfonic.agent.triage.intent

intent

IntentClassifier -- deterministic pre-hydration intent routing.

Routes each turn into one of three lanes so the hydration path can skip semantic retrieval on pure action turns:

  • action — the user is asking the agent to do something (run a tool, call an API, perform a mutation). Full hydration wastes tokens.
  • knowledge — the user wants information or recall. Hydration is load-bearing.
  • ambiguous — the signals are mixed; treat as knowledge to stay safe (full hydration is the conservative fallback).

The classifier is deterministic by design in v7.0:

  • English verb lexicon seeded from operational agent corpora (see lexicon.py).
  • Overlap against DomainTemplate.tool_trigger_keywords — the same map v6.1.8 uses for tool-manifest gating, so adopters already have it populated.
  • Zero LLM calls. An LLM fallback for ambiguous turns is tracked for v7.1 (v7.0-plan.md § 7).

The classifier emits an ExtensionEvent with type= "intent_classified" through the engine's callback manager so operators running in observe mode can tune before flipping to enforce.

IntentClassifier

IntentClassifier(
    *,
    ambiguity_confidence_floor: float = 0.55,
    min_action_signals: int = 2,
)

Deterministic verb+keyword intent classifier.

Thread-safe and stateless. Safe to reuse across turns. Callers wire this into the hydration path via FrameworkConfig.intent_filter_mode (see engine.py).

Parameters:

Name Type Description Default
ambiguity_confidence_floor float

Verdicts below this confidence value are labelled ambiguous regardless of which lane won. Default 0.55 — chosen so pure verb-lexicon hits (~0.6) stay decisive while contested turns fall through.

0.55
min_action_signals int

Minimum number of distinct signals required before action can win outright. Default 2 implements the "2-of-3" rule from risk register item T06 so a single action verb in a question turn does not flip to action.

2
Source code in src/symfonic/agent/triage/intent.py
def __init__(
    self,
    *,
    ambiguity_confidence_floor: float = 0.55,
    min_action_signals: int = 2,
) -> None:
    if not 0.0 <= ambiguity_confidence_floor <= 1.0:
        raise ValueError("ambiguity_confidence_floor must be in [0, 1]")
    if min_action_signals < 1:
        raise ValueError("min_action_signals must be >= 1")
    self._floor = ambiguity_confidence_floor
    self._min_action_signals = min_action_signals

classify

classify(
    query: str, domain: DomainTemplate | None = None
) -> IntentVerdict

Classify query into action / knowledge / ambiguous.

Never raises. On empty input returns an ambiguous verdict with confidence 0.0.

Source code in src/symfonic/agent/triage/intent.py
def classify(
    self,
    query: str,
    domain: DomainTemplate | None = None,
) -> IntentVerdict:
    """Classify ``query`` into action / knowledge / ambiguous.

    Never raises. On empty input returns an ``ambiguous`` verdict
    with confidence ``0.0``.
    """
    text = (query or "").strip()
    if not text:
        return IntentVerdict(
            label="ambiguous",
            confidence=0.0,
            reasoning="empty query",
        )

    tokens = tokenize(text)
    if not tokens:
        return IntentVerdict(
            label="ambiguous",
            confidence=0.0,
            reasoning="no tokens",
        )

    action_verbs_hit = sorted(set(tokens) & ACTION_VERBS)
    knowledge_verbs_hit = sorted(set(tokens) & KNOWLEDGE_VERBS)
    is_question = bool(QUESTION_PATTERN.search(text))
    matched_tools = _match_tool_keywords(text, domain)

    first_token = tokens[0]
    first_is_action = first_token in ACTION_VERBS
    first_is_knowledge = first_token in KNOWLEDGE_VERBS

    action_score = 0.0
    if action_verbs_hit:
        action_score += 0.4 + 0.1 * min(len(action_verbs_hit) - 1, 3)
    if first_is_action:
        action_score += 0.2
    if matched_tools:
        action_score += 0.3

    knowledge_score = 0.0
    if knowledge_verbs_hit:
        knowledge_score += 0.4 + 0.1 * min(len(knowledge_verbs_hit) - 1, 3)
    if first_is_knowledge:
        knowledge_score += 0.2
    if is_question:
        knowledge_score += 0.3

    # Signal count for the action lane (verb-hit + first-token + tool
    # overlap). Independent of score so we can enforce the "2-of-3"
    # guard regardless of weight tuning.
    action_signals = sum(
        1
        for hit in (
            bool(action_verbs_hit),
            first_is_action,
            bool(matched_tools),
        )
        if hit
    )

    reasoning = _format_reasoning(
        action_verbs_hit,
        knowledge_verbs_hit,
        is_question,
        matched_tools,
        first_token,
    )

    label, confidence = self._resolve(
        action_score=action_score,
        knowledge_score=knowledge_score,
        action_signals=action_signals,
        is_question=is_question,
    )

    verdict = IntentVerdict(
        label=label,
        confidence=confidence,
        matched_tool_keywords=matched_tools,
        reasoning=reasoning,
        action_score=action_score,
        knowledge_score=knowledge_score,
    )
    logger.debug("intent verdict: %s", verdict)
    return verdict

IntentVerdict dataclass

IntentVerdict(
    label: IntentLabel,
    confidence: float,
    matched_tool_keywords: list[str] = list(),
    reasoning: str = "",
    action_score: float = 0.0,
    knowledge_score: float = 0.0,
)

Classifier verdict for a single turn.

Attributes:

Name Type Description
label IntentLabel

One of action / knowledge / ambiguous.

confidence float

0.0–1.0 heuristic confidence. Values below 0.55 are coerced to ambiguous regardless of the winning lane so downstream consumers can gate on a single axis.

matched_tool_keywords list[str]

Tool names whose trigger keywords overlap with the current query (per DomainTemplate).

reasoning str

Short human-readable note — which signals fired.

action_score float

Raw action-lane score prior to normalisation.

knowledge_score float

Raw knowledge-lane score prior to normalisation.

to_event_payload

to_event_payload() -> dict[str, object]

Return a minimal payload suitable for an ExtensionEvent.

Source code in src/symfonic/agent/triage/intent.py
def to_event_payload(self) -> dict[str, object]:
    """Return a minimal payload suitable for an ``ExtensionEvent``."""
    return {
        "label": self.label,
        "confidence": round(self.confidence, 3),
        "matched_tool_keywords": list(self.matched_tool_keywords),
        "reasoning": self.reasoning,
        "action_score": round(self.action_score, 3),
        "knowledge_score": round(self.knowledge_score, 3),
    }