Skip to content

symfonic.agent.triage

triage

Intent triage — pre-hydration routing primitives.

Provides the IntentClassifier (v7.0 T06) used by the hydration path to route turns into action / knowledge / ambiguous lanes. The default deterministic heuristic runs without any LLM call; an LLM fallback is tracked for v7.1.

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),
    }

estimate_dropped_tokens

estimate_dropped_tokens(
    dropped_tools: Sequence[Any],
) -> int

Rough token estimate for the tools we chose not to bind.

Used for the tool_routing_decision telemetry event so operators can correlate classifier behaviour with token savings without instrumenting the provider payload themselves. Uses the standard chars // 4 rule of thumb against description + name. Schemas are ignored: the estimate is a floor, not a ceiling.

Source code in src/symfonic/agent/triage/tool_routing.py
def estimate_dropped_tokens(dropped_tools: Sequence[Any]) -> int:
    """Rough token estimate for the tools we chose not to bind.

    Used for the ``tool_routing_decision`` telemetry event so
    operators can correlate classifier behaviour with token savings
    without instrumenting the provider payload themselves. Uses the
    standard ``chars // 4`` rule of thumb against ``description`` +
    ``name``. Schemas are ignored: the estimate is a floor, not a
    ceiling.
    """
    total = 0
    for tool in dropped_tools:
        desc = getattr(tool, "description", "") or ""
        name = _tool_name(tool) or ""
        total += (len(desc) + len(name)) // 4
    return total

narrow_tools_for_intent

narrow_tools_for_intent(
    verdict: IntentVerdict | None,
    all_tools: Sequence[Any],
    domain: DomainTemplate | None,
    *,
    always_include: Sequence[str] = DEFAULT_ALWAYS_INCLUDE,
) -> list[Any]

Return the subset of all_tools to bind for the current turn.

Decision matrix:

  • verdict is None or the verdict is ambiguous — return all_tools unchanged. Conservative fallback preserves recall whenever the signal is noisy.
  • verdict.label == "knowledge" — return only the always_include tools that exist in all_tools. The classifier is confident the turn is a lookup; the LLM should not need action tools.
  • verdict.label == "action" — keep any tool whose name appears in verdict.matched_tool_keywords (per the domain's tool_trigger_keywords map). Tools with no keyword list declared in the domain are always kept for back-compat (v6.1.8 semantics: absent entry means "always include"). Always add the always_include tools.

Parameters:

Name Type Description Default
verdict IntentVerdict | None

Output of IntentClassifier.classify for the current query. None triggers the fallback.

required
all_tools Sequence[Any]

The full tool manifest registered on the agent. Each element must expose a name attribute (satisfied by langchain_core.tools.BaseTool and by the demo stubs used in tests).

required
domain DomainTemplate | None

The active DomainTemplate. Used to read tool_trigger_keywords. None collapses to the "no trigger keywords declared" case — every tool is considered for action turns.

required
always_include Sequence[str]

Tool names that must appear in every narrowed set regardless of verdict. Defaults to ("hydrate_context",).

DEFAULT_ALWAYS_INCLUDE

Returns:

Type Description
list[Any]

A list of tools preserving the input order. Never raises;

list[Any]

unknown tool names, missing name attributes, and empty

list[Any]

all_tools all return the same conservative fallback.

Source code in src/symfonic/agent/triage/tool_routing.py
def narrow_tools_for_intent(
    verdict: IntentVerdict | None,
    all_tools: Sequence[Any],
    domain: DomainTemplate | None,
    *,
    always_include: Sequence[str] = DEFAULT_ALWAYS_INCLUDE,
) -> list[Any]:
    """Return the subset of ``all_tools`` to bind for the current turn.

    Decision matrix:

    - ``verdict`` is ``None`` or the verdict is ``ambiguous`` —
      return ``all_tools`` unchanged. Conservative fallback preserves
      recall whenever the signal is noisy.
    - ``verdict.label == "knowledge"`` — return only the
      ``always_include`` tools that exist in ``all_tools``. The
      classifier is confident the turn is a lookup; the LLM should
      not need action tools.
    - ``verdict.label == "action"`` — keep any tool whose name
      appears in ``verdict.matched_tool_keywords`` (per the domain's
      ``tool_trigger_keywords`` map). Tools with no keyword list
      declared in the domain are always kept for back-compat (v6.1.8
      semantics: absent entry means "always include"). Always add
      the ``always_include`` tools.

    Args:
        verdict: Output of ``IntentClassifier.classify`` for the
            current query. ``None`` triggers the fallback.
        all_tools: The full tool manifest registered on the agent.
            Each element must expose a ``name`` attribute (satisfied
            by ``langchain_core.tools.BaseTool`` and by the demo
            stubs used in tests).
        domain: The active ``DomainTemplate``. Used to read
            ``tool_trigger_keywords``. ``None`` collapses to the
            "no trigger keywords declared" case — every tool is
            considered for action turns.
        always_include: Tool names that must appear in every
            narrowed set regardless of verdict. Defaults to
            ``("hydrate_context",)``.

    Returns:
        A list of tools preserving the input order. Never raises;
        unknown tool names, missing ``name`` attributes, and empty
        ``all_tools`` all return the same conservative fallback.
    """
    if not all_tools:
        return []

    always_set = {name for name in always_include if name}

    # Fallback for the None / ambiguous / low-confidence path.
    if verdict is None or verdict.label == "ambiguous":
        return list(all_tools)

    if verdict.label == "knowledge":
        return _filter_by_names(all_tools, always_set)

    # verdict.label == "action"
    trigger_map: dict[str, list[str]] = {}
    if domain is not None:
        trigger_map = dict(getattr(domain, "tool_trigger_keywords", None) or {})

    # Union of: always_include + tools explicitly matched by the
    # verdict + tools with no trigger_keywords declared (back-compat).
    keep_names: set[str] = set(always_set)
    keep_names.update(verdict.matched_tool_keywords or [])

    # Include tools with no keyword list -- pre-v6.1.8 behaviour demands
    # "absent entry means always include".
    for tool in all_tools:
        name = _tool_name(tool)
        if name is None:
            continue
        if name not in trigger_map or not trigger_map[name]:
            keep_names.add(name)

    return _filter_by_names(all_tools, keep_names)