Skip to content

symfonic.capabilities.triage

triage

Deterministic pre-hydration intent routing (CORE-399).

A port of agent/triage's zero-LLM action/knowledge/ambiguous classifier as a prompting-stage capability: IntentTriageCapability classifies the turn's prompt and records the verdict on the turn snapshot, where a later wiring task lets memory retrieval skip hydration on pure action turns.

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. Scoring lives in :mod:symfonic._intent_scoring; this package owns the lexicon and hands it over as values.

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 so a single action verb in a question turn does not flip to action.

2
Source code in src/symfonic/capabilities/triage/intent.py
def __init__(
    self,
    *,
    ambiguity_confidence_floor: float = 0.55,
    min_action_signals: int = 2,
) -> None:
    self._scoring = IntentScoring(
        _LEXICON,
        ambiguity_confidence_floor=ambiguity_confidence_floor,
        min_action_signals=min_action_signals,
    )

classify

classify(query: str, tool_keywords: Mapping[str, Sequence[str]] | None = None) -> IntentVerdict

Classify query into action / knowledge / ambiguous.

Never raises. On empty input returns an ambiguous verdict with confidence 0.0. tool_keywords maps tool names to their trigger phrases -- the same overlap legacy reads off the domain template, passed as values so this package imports nothing outside itself and the kernel's contracts.

Source code in src/symfonic/capabilities/triage/intent.py
def classify(
    self,
    query: str,
    tool_keywords: Mapping[str, Sequence[str]] | None = None,
) -> IntentVerdict:
    """Classify ``query`` into action / knowledge / ambiguous.

    Never raises. On empty input returns an ``ambiguous`` verdict
    with confidence ``0.0``. ``tool_keywords`` maps tool names to
    their trigger phrases -- the same overlap legacy reads off the
    domain template, passed as values so this package imports
    nothing outside itself and the kernel's contracts.
    """
    verdict = IntentVerdict(**self._scoring.score(query, tool_keywords).as_fields())
    logger.debug("intent verdict: %s", verdict)
    return verdict

IntentTriageCapability dataclass

IntentTriageCapability(tool_keywords: Mapping[str, Sequence[str]] = dict(), classifier: IntentClassifier = IntentClassifier())

Classify each turn's prompt as action / knowledge / ambiguous.

Usage::

Agent(provider, capabilities=[
    IntentTriageCapability(tool_keywords={"restart": ("restart",)}),
])

No GrantEffects entry: the stage performs no effect, so there is nothing to authorise.

classify

classify(prompt: str) -> IntentLabel

The verdict for prompt under this instance's configuration.

The reader memory retrieval routes on (CORE-406): the same pure call the stage makes, so a retrieval holding the composed instance can ask what the turn is without reading the snapshot -- which STG-7 does not hand to a resolution stage. Pass the composed instance itself and the recorded verdict and the skip cannot disagree; classification is deterministic, so even two same-configured instances agree.

Source code in src/symfonic/capabilities/triage/capability.py
def classify(self, prompt: str) -> IntentLabel:
    """The verdict for ``prompt`` under this instance's configuration.

    The reader memory retrieval routes on (CORE-406): the same pure call
    the stage makes, so a retrieval holding the composed instance can ask
    what the turn is without reading the snapshot -- which STG-7 does not
    hand to a resolution stage. Pass the composed instance itself and the
    recorded verdict and the skip cannot disagree; classification is
    deterministic, so even two same-configured instances agree.
    """
    return self.classifier.classify(str(prompt), self.tool_keywords).label

contribute

contribute(request: CapabilityRequest) -> CapabilityContribution

Declare the triage stage and the handler that answers it.

Source code in src/symfonic/capabilities/triage/capability.py
def contribute(self, request: CapabilityRequest) -> CapabilityContribution:
    """Declare the triage stage and the handler that answers it."""
    del request  # Pure: classification reads the turn, never the grants.
    classifier = self.classifier
    tool_keywords = self.tool_keywords

    async def handle(context: Any) -> StageResult[Any]:
        """Classify the turn's prompt and record the verdict."""
        turn_request = getattr(context, "request", None)
        if turn_request is None:  # pragma: no cover - defensive
            return no_change("no turn request on the stage context")
        prompt = getattr(turn_request, "prompt", "") or ""
        verdict = classifier.classify(str(prompt), tool_keywords)
        return applied(
            ResolvedInput(
                capability=TRIAGE_CAPABILITY,
                value=(
                    {
                        "label": verdict.label,
                        "confidence": verdict.confidence,
                        "matched_tool_keywords": list(
                            verdict.matched_tool_keywords
                        ),
                        "reasoning": verdict.reasoning,
                        "action_score": verdict.action_score,
                        "knowledge_score": verdict.knowledge_score,
                    },
                ),
                provenance="intent-lexicon-v1",
            ),
            counts={verdict.label: 1},
        )

    return CapabilityContribution(
        capability=TRIAGE_CAPABILITY,
        stages=(
            StageDescriptor(
                stage_id=TRIAGE_STAGE,
                phase=Phase.PROMPT_ASSEMBLY,
                capability=TRIAGE_CAPABILITY,
                priority=_TRIAGE_PRIORITY,
                optional_before=("memory.retrieval",),
                effects=frozenset(),
                kind=StageKind.RESOLUTION,
            ),
        ),
        handlers={TRIAGE_STAGE: handle},
        # No effects: classification is a pure function of the prompt.
        effect_grants=frozenset(),
    )

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 the floor are labelled 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.

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 operator event.

Source code in src/symfonic/capabilities/triage/intent.py
def to_event_payload(self) -> dict[str, object]:
    """Return a minimal payload suitable for an operator event."""
    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),
    }