Skip to content

symfonic.agent.middleware.critic_gate

critic_gate

Selective metacognition critic gate (v8.2.0).

The metacognition critic (MetacognitiveMiddleware) runs a SERIAL, blocking Opus round-trip after every draft when its gate fires. On Jarvio-like cold traffic the legacy gate fired 20/20 turns because compute_confidence returns 0.5 on sparse activation, which is below the 0.6 threshold every turn (see confidence.py).

This module adds a deterministic (no-LLM) smart gate that can SKIP the round-trip on provably-trivial acknowledgment turns -- short, claim-free, mutating-action-free, recognized-language acks -- while keeping the full critic on every turn that carries any risk signal.

Conservative contract (see docs/concepts/selective-metacognition-gating.md): - The smart gate is OPT-IN via FrameworkConfig.metacognition_selective_gate. With the flag OFF (default), trivial_skip is always False and the caller's behaviour is byte-identical to v8.1.0. - With the flag ON, the HARD floors always fire: sensitive_hit, fabrication_findings, a MUTATING tool action, action/ambiguous intent, or a draft that carries claims. ONLY a short, claim-free, action-free, recognized-language ack may skip. - Low graph-confidence is DELIBERATELY NOT a hard floor (that was the bug: the cold acks ARE the low-confidence turns). Every ambiguity -- including an unrecognized-language ack -- FIRES.

CriticGateContext dataclass

CriticGateContext(
    draft: str,
    tool_calls_this_turn: tuple[dict[str, Any], ...],
    fabrication_findings: tuple[FabricationFinding, ...],
    intent_verdict: IntentVerdict | None,
    confidence: float,
    sensitive_hit: str | None,
    read_only_tools: frozenset[str],
    selective_gate_enabled: bool,
    trivial_ack_patterns: tuple[str, ...],
    scope: Any,
    run_id: str,
)

Immutable signals passed to the selective-metacognition gate.

Every field is computed deterministically BEFORE the critic LLM call, so the gate adds ~zero marginal cost. This is a data-carrying frozen dataclass (the dataclass pattern is correct here -- distinct from the Any config HOOK, which mirrors role_model_resolver).

intent_label property

intent_label: str | None

Convenience accessor for the intent label (action / ... ).

default_smart_gate

default_smart_gate(ctx: CriticGateContext) -> bool

Return True to FIRE the critic, False to SKIP.

Implements the v8.2.0 corrected predicate (§3 of the plan of record):

trivial_skip is True ONLY when the selective flag is ON, no hard floor fires, and the draft is a provably-trivial ack. Otherwise FIRE.

With the flag OFF, trivial_skip is unconditionally False -> the gate fires whenever the legacy _gate_fires would (byte-identical), because the caller subordinates this whole predicate to the legacy confidence/ sensitive check when the flag is off (see MetacognitiveMiddleware).

Source code in src/symfonic/agent/middleware/critic_gate.py
def default_smart_gate(ctx: CriticGateContext) -> bool:
    """Return True to FIRE the critic, False to SKIP.

    Implements the v8.2.0 corrected predicate (§3 of the plan of record):

    ``trivial_skip`` is True ONLY when the selective flag is ON, no hard floor
    fires, and the draft is a provably-trivial ack.  Otherwise FIRE.

    With the flag OFF, ``trivial_skip`` is unconditionally False -> the gate
    fires whenever the legacy ``_gate_fires`` would (byte-identical), because
    the caller subordinates this whole predicate to the legacy confidence/
    sensitive check when the flag is off (see ``MetacognitiveMiddleware``).
    """
    trivial_skip = (
        ctx.selective_gate_enabled
        and not hard_fire(ctx)
        and draft_is_trivial_ack(ctx.draft, ctx.trivial_ack_patterns)
    )
    return not trivial_skip

draft_has_claims

draft_has_claims(draft: str) -> bool

True when the draft carries numbers / entities / citations / IDs.

Deterministic regex. Overlaps fabrication findings but also catches grounded numeric claims, so a short draft asserting any data never skips.

Source code in src/symfonic/agent/middleware/critic_gate.py
def draft_has_claims(draft: str) -> bool:
    """True when the draft carries numbers / entities / citations / IDs.

    Deterministic regex.  Overlaps fabrication findings but also catches
    *grounded* numeric claims, so a short draft asserting any data never skips.
    """
    if not draft:
        return False
    return any(p.search(draft) for p in _CLAIM_PATTERNS)

draft_is_trivial_ack

draft_is_trivial_ack(
    draft: str,
    ack_patterns: tuple[str, ...] | list[str] | None = None,
) -> bool

True when the draft is a provably-trivial acknowledgment (skippable).

A draft is a trivial ack when ALL hold
  • it is short (<= _TRIVIAL_ACK_MAX_CHARS),
  • it carries no claim signals (no digits / entities / IDs), and
  • it matches a recognized acknowledgment pattern.

An ack in an UNRECOGNIZED language matches no pattern -> returns False -> FIRES (conservative). Over-firing on an unmatched-language ack is the correct failure direction.

Source code in src/symfonic/agent/middleware/critic_gate.py
def draft_is_trivial_ack(
    draft: str,
    ack_patterns: tuple[str, ...] | list[str] | None = None,
) -> bool:
    """True when the draft is a provably-trivial acknowledgment (skippable).

    A draft is a trivial ack when ALL hold:
      - it is short (<= ``_TRIVIAL_ACK_MAX_CHARS``),
      - it carries no claim signals (no digits / entities / IDs), and
      - it matches a recognized acknowledgment pattern.

    An ack in an UNRECOGNIZED language matches no pattern -> returns False ->
    FIRES (conservative).  Over-firing on an unmatched-language ack is the
    correct failure direction.
    """
    if not draft:
        # An empty draft is not a "claim" -- but the engine only invokes the
        # gate when ``final_response`` is truthy, so this is defensive.
        return False
    stripped = draft.strip()
    if len(stripped) > _TRIVIAL_ACK_MAX_CHARS:
        return False
    if draft_has_claims(stripped):
        return False
    patterns = tuple(ack_patterns) if ack_patterns else _DEFAULT_ACK_PATTERNS
    lowered = stripped.lower()
    return any(re.search(p, lowered, flags=re.IGNORECASE) for p in patterns)

hard_fire

hard_fire(ctx: CriticGateContext) -> bool

The hard floors -- fire in BOTH flag states (never relaxed).

Deliberately does NOT include confidence < threshold: low confidence is not a hard floor (that was the bug -- cold acks ARE the low-confidence turns). Low confidence still fires by default because, with the flag off, trivial_skip is always False (see default_smart_gate).

Source code in src/symfonic/agent/middleware/critic_gate.py
def hard_fire(ctx: CriticGateContext) -> bool:
    """The hard floors -- fire in BOTH flag states (never relaxed).

    Deliberately does NOT include ``confidence < threshold``: low confidence
    is not a hard floor (that was the bug -- cold acks ARE the low-confidence
    turns).  Low confidence still fires by default because, with the flag off,
    ``trivial_skip`` is always False (see ``default_smart_gate``).
    """
    return (
        ctx.sensitive_hit is not None
        or bool(ctx.fabrication_findings)
        or took_tool_action(ctx.tool_calls_this_turn, ctx.read_only_tools)
        or ctx.intent_label in {"action", "ambiguous"}
        or draft_has_claims(ctx.draft)
    )

took_tool_action

took_tool_action(
    tool_calls_this_turn: tuple[dict[str, Any], ...]
    | list[dict[str, Any]]
    | None,
    read_only_tools: frozenset[str],
) -> bool

True when the turn called a MUTATING tool (not in read_only_tools).

A call whose name is in read_only_tools does NOT count as a mutating action. NOTE: this relaxes ONLY the tool-action signal -- a read tool whose RESULT the model then asserts claims about STILL fires via draft_has_claims / fabrication_findings.

Empty read_only_tools (the default) means ANY tool call is treated as mutating, preserving "any tool call fires".

Source code in src/symfonic/agent/middleware/critic_gate.py
def took_tool_action(
    tool_calls_this_turn: tuple[dict[str, Any], ...] | list[dict[str, Any]] | None,
    read_only_tools: frozenset[str],
) -> bool:
    """True when the turn called a MUTATING tool (not in ``read_only_tools``).

    A call whose name is in ``read_only_tools`` does NOT count as a mutating
    action.  NOTE: this relaxes ONLY the tool-action signal -- a read tool
    whose RESULT the model then asserts claims about STILL fires via
    ``draft_has_claims`` / ``fabrication_findings``.

    Empty ``read_only_tools`` (the default) means ANY tool call is treated as
    mutating, preserving "any tool call fires".
    """
    if not tool_calls_this_turn:
        return False
    for call in tool_calls_this_turn:
        name = call.get("name") if isinstance(call, dict) else None
        if not name or name not in read_only_tools:
            # Unnamed call or a name we don't recognize as read-only -> mutating.
            return True
    return False