Coverage for gco_mcp / tools / semantic_progress.py: 100.00%
36 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-14 22:07 +0000
1"""Read-only LLM-as-judge tool that scores Mission progress.
3The single ``metrics_semantic_progress`` tool scores how close a Mission is
4to satisfying its directive and returns that score in the canonical
5``{"metrics": {"progress_score": <number>}}`` shape the Observe_Phase merges,
6so a plain ``metric_threshold`` or ``metric_trend`` criterion can read it by
7dot-path with no special handling.
9The whole tool registration is gated by ``GCO_ENABLE_SEMANTIC_PROGRESS`` so the
10``@mcp.tool`` decorator only fires when the flag (or the umbrella
11``GCO_ENABLE_ALL_TOOLS``) is enabled. With the flag unset this module imports
12cleanly and FastMCP never sees the tool. Each invocation incurs one LLM call
13via the existing sampling seam, which is why the tool is default-off.
15[gated by GCO_ENABLE_SEMANTIC_PROGRESS]
16"""
18from __future__ import annotations
20import sys
21from pathlib import Path
22from typing import Any
24from audit import audit_logged
25from feature_flags import FLAG_SEMANTIC_PROGRESS, is_enabled
26from server import mcp
28# The pure judge package and the sampling seam live under ``gco_mcp/``; the
29# path-injection pattern matches the rest of the MCP module surface so
30# ``import mission_judge.*`` and ``import mission.*`` resolve without making
31# the ``mcp`` directory a package.
32sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
34# The sampling seam — reused, not reconstructed.
35from mission.sampling import ( # noqa: E402
36 SamplingTransportError,
37 select_sampling_backend,
38)
39from mission_judge import prompt as judge_prompt # noqa: E402
40from mission_judge import rubric as judge_rubric # noqa: E402
41from mission_judge import score as judge_score # noqa: E402
42from mission_judge.shape import ( # noqa: E402
43 ErrorCode,
44 JudgeError,
45 error_envelope,
46 metrics_result,
47 validate_output_name,
48)
50# Registration is entirely gated by the feature flag. When the flag is unset,
51# the decorator below never fires and FastMCP never sees the tool, so it does
52# not appear in ``mcp.list_tools()``. The gate is evaluated only through
53# ``feature_flags.is_enabled`` — never by reading ``os.environ`` here.
54if is_enabled(FLAG_SEMANTIC_PROGRESS):
56 @mcp.tool(tags={"safe", "metrics"})
57 @audit_logged
58 async def metrics_semantic_progress(
59 directive: str,
60 recent_context: str | None = None,
61 output_name: str | None = None,
62 model_id: str | None = None,
63 ) -> dict[str, Any]:
64 """[gated by GCO_ENABLE_SEMANTIC_PROGRESS] [read-only] Score Mission progress.
66 Scores how close a Mission is to satisfying ``directive`` against a
67 fixed, versioned rubric via the existing sampling backend, and returns
68 the canonical ``{"metrics": {"progress_score": <float 0.0-1.0>}}`` shape
69 consumable by a ``metric_threshold`` (e.g. ``progress_score >= 0.8``) or
70 ``metric_trend`` (e.g. ``progress_score`` increasing) criterion. Incurs
71 one LLM call per invocation. Mutates nothing — it only reads its inputs
72 and asks the model for a score. Provenance (rationale, source,
73 backend_name, model_id, rubric_version, raw_score) is returned outside
74 the ``metrics`` object.
76 Args:
77 directive: The natural-language objective the Mission is pursuing.
78 Must be non-empty and not whitespace-only.
79 recent_context: Optional recent progress context (recent
80 observations and/or metric-history series the caller selects).
81 Truncated keep-newest to a fixed character budget; omit it to
82 score from the directive alone.
83 output_name: Optional metric key under ``metrics`` (default
84 ``"progress_score"``). Must be a single path segment of 1..128
85 characters with no ``.`` separator and no whitespace.
86 model_id: Optional concrete model identifier forwarded to the
87 sampling seam; ``None`` uses the seam's resolved default.
89 Returns the canonical metrics shape on success, or a structured
90 ``{"code", "details"}`` error envelope (never carrying a top-level
91 ``metrics`` key) on any failure, so the Mission loop keeps running.
92 """
93 try:
94 key = validate_output_name(output_name) if output_name else "progress_score"
95 if not directive or not directive.strip():
96 raise JudgeError(ErrorCode.MISSING_DIRECTIVE)
98 prompt = judge_prompt.build_prompt(
99 directive, recent_context, judge_rubric.RUBRIC_VERSION
100 )
102 # Bedrock is the only sampling transport (MCP client sampling
103 # left the protocol with FastMCP 4), so the judge samples
104 # server-side for CLI and MCP callers alike.
105 backend = select_sampling_backend(model_id)
106 if backend is None: # defensive: seam stubs may return None
107 raise JudgeError(ErrorCode.NO_SAMPLING_BACKEND)
109 try:
110 # The ONLY non-determinism; no retry. Both shipped backends
111 # call only ``prompt.assemble()``, so the duck-typed JudgePrompt
112 # drives either of them — same shim pattern as the sampling
113 # module's own ``_PreRendered`` look-alike.
114 raw_text = await backend.sample(prompt) # type: ignore[arg-type]
115 except SamplingTransportError as err:
116 raise JudgeError(
117 ErrorCode.SAMPLING_TRANSPORT_ERROR,
118 {
119 "transport_code": err.code,
120 "backend_name": backend.backend_name,
121 "model_id": backend.model_id,
122 },
123 ) from err
125 raw_score, rationale = judge_score.parse_score(raw_text) # raises INVALID_MODEL_SCORE
126 value = judge_score.clamp_score(raw_score)
128 return metrics_result(
129 key,
130 value,
131 rationale=rationale[: judge_prompt.MAX_RATIONALE_CHARS],
132 source=f"{backend.backend_name}:{backend.model_id}",
133 backend_name=backend.backend_name,
134 model_id=backend.model_id,
135 rubric_version=judge_rubric.RUBRIC_VERSION,
136 raw_score=raw_score,
137 )
138 except JudgeError as err:
139 return error_envelope(err.code, **err.details)
140 except Exception as err: # noqa: BLE001 - defensive: nothing escapes the tool
141 return error_envelope(
142 ErrorCode.SAMPLING_TRANSPORT_ERROR, reason="unexpected", detail=str(err)
143 )