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
¶
Convenience accessor for the intent label (action / ... ).
default_smart_gate ¶
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
draft_has_claims ¶
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
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
hard_fire ¶
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
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".