Phase 25 — Dynamic Critic Rubric via Tool Schema

Motivation

Prometheus uses a single "realistic/accurate" rubric for all responses. This breaks in two ways:

The fix: tools declare their own evaluation rubric in their schema announcement. The critic builds a registry from those announcements and selects the right rubric per response. No gatekeeper LLM call. Every response gets graded. The rubric name appears as a chip in the UI.

Design Decisions

Core principle: Tools self-describe their evaluation semantics. The critic is a rubric executor — completely blind to tool names. Loose coupling preserved.
DecisionRationale
Rubric fields in tool schema announcement (not separate subject)One announcement, all metadata in one place. Generator ignores unknown fields, critic reads them.
Critic subscribes to tool.schema and builds registrySame lazy-registration pattern as generator's tool registry. Zero per-generation overhead.
Priority integer to resolve mixed tool callsWhen web_search + search_memory both called, live-data rubric wins (can't verify accuracy). Deterministic, no LLM needed.
No "skip" — every response gets gradedWrite-only tools use a "clarity" rubric. Gatekeeper and critic.skipped go away entirely.
Rubric name shown as chip in CritiqueBarReplaces the ◈ skip chip. Tells the user what Prometheus evaluated, not just the score.
Default rubric in critic.yaml when no tool called, tool not in registry, or rubric text is empty/malformedSafe fallback for new tools added without a rubric. critic.yaml shrinks to one rubric only.
Tools publish full rubric text — no reference names back to critic.yamlEach tool owns its rubric entirely. Full flexibility per tool. critic.yaml has no shared rubric library to maintain.

Rubrics

critic.yaml owns only the default rubric. style and clarity rubric text lives in each tool's own YAML config. No shared rubric library in the critic. New tools that don't define a rubric automatically fall back to the default.

Default (in critic.yaml)

Used for: pure knowledge responses, any tool not in the registry, any tool with missing/malformed rubric text.

[Is the response realistic, accurate, and genuinely helpful?]
Score 1: Presents an unrealistic/harmful outcome as achievable, even with a disclaimer. A detailed
         plan for a goal that fails for the vast majority scores 1 — detail creates false confidence.
Score 2: Significantly overstates what is achievable for a typical person, or overly optimistic
         without conveying how difficult success actually is.
Score 3: Partially realistic but fails to convey how difficult or unlikely the goal actually is.
Score 4: Largely realistic with only minor optimism or gaps a careful reader would notice.
Score 5: Accurate, grounded in what is realistically achievable, complete, no important omissions.

Style (in web_search.yaml, web_fetch.yaml, etc.)

Each live-data tool publishes this full text in its schema announcement.

[Is the response well-structured and comprehensive?]
Ignore factual accuracy entirely — evaluate only style and coverage.
Score 1: Wall of text, no formatting, misses the core topic.
Score 2: Hard to read, lacks structure, covers only one superficial aspect.
Score 3: Moderately structured, covers the main point but misses secondary angles.
Score 4: Clear formatting (bullets, headers), readable, covers almost all relevant angles.
Score 5: Flawless formatting, engaging tone, exceptionally comprehensive with no missing angles.

Clarity (in remember_this.yaml, persona.yaml, etc.)

Each write-only tool publishes this full text in its schema announcement.

[Did the response clearly confirm the action taken?]
Score 1: Did not acknowledge the action or confirmed the wrong thing.
Score 2: Acknowledged vaguely — unclear what was stored or changed.
Score 3: Confirmed the action but with unnecessary hedging or verbosity.
Score 4: Clear, concise confirmation of what was done.
Score 5: Clear, concise, and adds brief relevant context without padding.

Tool Schema Fields

Each tool adds these three fields to its tool.schema bus announcement payload:

critique_rubric: |             # FULL rubric text — tool owns this entirely
  [Is the response ...]        # not a reference name; actual text used by Prometheus
  Score 1: ...
  Score 5: ...
critique_rubric_name: "style"  # display label for the UI chip only
critique_priority: 10          # higher wins in mixed-tool conflicts

Priority Table

ToolRubricPriorityRationale
web_searchstyle10Live data, can't verify facts — style wins in mixed scenarios
web_fetchstyle10Same as web_search
get_datetimestyle8Live data (current time), trivial fact relay
get_locationstyle8Live data (current location)
search_papersstyle7Live/recent academic data Prometheus may not know
search_memoryrealistic5Static retrieved context, accuracy evaluable
consult_librarianrealistic5Static document corpus, accuracy evaluable
remember_thisclarity1Write-only, no accuracy claim
personaclarity1Write-only, no accuracy claim
(none)realisticDefault for pure knowledge responses

Files to Change

Backend

FileChange
config/critic.yaml Remove: gatekeeper_model, gatekeeper_timeout, gatekeeper_skip_feedback, gatekeeper_prompt
Keep: model, temperature, num_ctx, grade_timeout, rubric (default only), grade_prompt
No style/clarity rubric here — those live in each tool's own YAML
config/web_search.yaml Add: critique_rubric_name, critique_priority, critique_rubric fields
config/web_fetch.yaml Add: same as web_search
config/datetime.yaml (or tool config) Add: critique_rubric_name: "style", priority 8
config/location.yaml Add: critique_rubric_name: "style", priority 8
config/semantic_scholar.yaml (or search_papers) Add: critique_rubric_name: "style", priority 7
config/search_memory.yaml (or memory.yaml) Add: critique_rubric_name: "realistic", priority 5
config/library.yaml (or consult_librarian config) Add: critique_rubric_name: "realistic", priority 5
config/remember_this.yaml (or preferences.yaml) Add: critique_rubric_name: "clarity", priority 1
config/persona.yaml Add: critique_rubric_name: "clarity", priority 1
src/local/tools/base_tool.py Add: announce() reads critique_rubric_name, critique_priority, critique_rubric from config and includes in tool.schema payload
src/local/agents/critic_agent.py Remove: gatekeeper OllamaBackend, _is_live_data(), gatekeeper config reads, CriticSkipped publish path
Add: _rubric_registry dict, subscription to tool.schema, _resolve_rubric(tool_calls) → (rubric_text, rubric_name), broadcast tool.schema.request on startup
src/local/agents/critic_transitions.py Remove: (RECEIVING, PUBLISH) → PUBLISHING bypass transition (was gatekeeper skip path)
src/local/protocol/subjects.py Remove: CRITIC_SKIPPED = "critic.skipped"
src/local/protocol/messages.py Remove: CriticSkipped dataclass
src/local/session/local_session.py Remove: CRITIC_SKIPPED from imports and OBSERVE list
Trail exit: only check CRITIQUE (not CRITIC_SKIPPED)
src/local/api/ws_bridge.py Remove: CRITIC_SKIPPED from imports and CHAT_OBSERVE
Remove: critic_skipped translation handler
Add: rubric_name to critique translation (read from payload)
src/local/api/gateway.py No change needed (critique result already flows through ws_bridge)

Frontend

FileChange
frontend/src/types/events.ts Remove: CriticSkippedEvent interface, from GatewayEvent union, criticSkipped on ChatMessage
Add: critique_rubric_name?: string to CritiqueEvent; critiqueRubricName?: string to ChatMessage
frontend/src/hooks/chatStreamReducer.ts Remove: critic_skipped case
Update: critique case to also set critiqueRubricName from event
frontend/src/components/chat/CritiqueBar.tsx Remove: criticSkipped prop, ● — ◈ skip UI path, feedbackOpen for skip
Add: rubricName?: string prop; show ◈ {rubricName} chip next to score (always visible when score present)
frontend/src/components/chat/MessageRow.tsx Remove: criticSkipped={msg.criticSkipped}
Add: rubricName={msg.critiqueRubricName}
frontend/src/hooks/useSessions.ts Add: map critic_rubric_name from session API response to critiqueRubricName on ChatMessage
frontend/src/api/client.ts Add: critic_rubric_name?: string to RawSessionMessage

Session API

FileChange
src/local/api/gateway.py (get_session) Add: critic_rubric_name from engram metadata to enriched assistant message
src/local/services/memory_service.py (write_episodic) Add: rubric_name to engram metadata when writing critique
src/local/agents/critic_agent.py (_handle_generation) Add: rubric_name to CritiqueResult payload so memory_agent can store it

Tests

FileChange
tests/test_critic_agent.py Remove: gatekeeper tests (test_gatekeeper_skips_prometheus_when_live_data, test_gatekeeper_publishes_skip_reason_when_live_data)
Remove: gatekeeper_response param from _make_agent
Add: TestRubricRegistry — tests for tool.schema subscription, registry building, _resolve_rubric priority
Add: test that correct rubric text is passed to _grade() based on tool calls

critic_agent.py Sketch

class CriticAgent(BaseAgent):
    def __init__(self, llm=None):
        ...
        self._rubric_registry: dict[str, dict] = {}  # tool_name → {rubric, rubric_name, priority}
        self._default_rubric = cfg["rubric"]
        self._style_rubric = cfg["style_rubric"]
        self._clarity_rubric = cfg["clarity_rubric"]

    def _resolve_rubric(self, tool_calls: list) -> tuple[str, str]:
        """Returns (rubric_text, rubric_name) for highest-priority tool called."""
        best = None
        for tc in tool_calls:
            name = tc.get("name") or tc.get("function", {}).get("name", "")
            entry = self._rubric_registry.get(name)
            if entry and (best is None or entry["priority"] > best["priority"]):
                best = entry
        if best:
            return best["rubric"], best["rubric_name"]
        return self._default_rubric, "realistic"

    def _handle_schema(self, envelope):
        """Register rubric from tool.schema announcement."""
        payload = envelope.payload
        name = payload.get("name") or payload.get("function", {}).get("name")
        rubric_name = payload.get("critique_rubric_name")
        rubric = payload.get("critique_rubric", "").strip()
        priority = payload.get("critique_priority", 0)
        if name and rubric_name and rubric:
            # store full rubric text as published by the tool
            self._rubric_registry[name] = {"rubric": rubric, "rubric_name": rubric_name, "priority": priority}
        # if rubric text is missing or empty, tool is not registered — default applies

    def _handle_generation(self, envelope):
        tool_calls = envelope.payload.get("tool_calls") or []
        rubric, rubric_name = self._resolve_rubric(tool_calls)
        score, feedback = self._grade(query, answer, rubric=rubric)
        # publish CritiqueResult with rubric_name included

CritiqueBar UI Sketch

● 4/5  ◈ style  ◈ feedback ▶   ↑ ↓
● 5/5  ◈ clarity               ↑ ↓
● 3/5  ◈ realistic ◈ feedback ▶ ↑ ↓

The rubric name chip is always shown when a score is present. Clicking it could eventually expand to show the full rubric used (future enhancement).

What Goes Away

Implementation Order

  1. Write three rubric definitions in critic.yaml (style, clarity; realistic already exists)
  2. Add critique fields to each tool's YAML config
  3. Update BaseTool.announce() to include critique fields in tool.schema payload
  4. Update CriticAgent: add rubric registry, _handle_schema, _resolve_rubric, remove gatekeeper
  5. Update critic_transitions.py (remove bypass)
  6. Remove CRITIC_SKIPPED from protocol (subjects, messages)
  7. Update local_session.py, ws_bridge.py (remove CRITIC_SKIPPED)
  8. Update CritiqueResult payload to include rubric_name
  9. Update memory_service write_episodic to store rubric_name in metadata
  10. Update gateway get_session to include critic_rubric_name in enriched message
  11. Frontend: events.ts, chatStreamReducer, CritiqueBar, MessageRow, useSessions, client.ts
  12. Update tests
  13. make dist, restart stack, verify

Open Questions