Repository navigation
Expand file tree
/
Copy pathreview_tools.py
More file actions
1394 lines (1198 loc) · 54.6 KB
/
Copy pathreview_tools.py
File metadata and controls
1394 lines (1198 loc) · 54.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# Copyright © 2026 MIH AI B.V.
# Licensed under the Apache License, Version 2.0
# See LICENSE file in the project root
"""Review tools — briefing builder, directive outcomes, and query tools."""
import json
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import Context
from mcp.server.fastmcp.exceptions import ToolError
from compass.agents.pipeline.dispatch import (
clear_review_dispatched,
record_review_dispatched,
)
from compass.agents.prompt_builder.search import RoundSummary, SearchState
from compass.agents.prompt_builder.search_ops import (
_load_pending,
_load_state,
_save_loop_signal,
_save_state,
)
from compass.agents.prompt_builder.search_ops import (
set_loop_phase as _set_loop_phase,
)
from compass.eval.models import EvalResult, RunReport, ScoreReport
from compass.mcp._render import render_review_briefing_md, render_score_report_md
from compass.mcp.server import mcp
from compass.project_dir import resolve_project_dir as _resolve_project_dir
def _load_score_report_dict(
report_path: Path,
results_path: Path | None = None,
) -> dict[str, Any]:
"""Load a report.json and return a ScoreReport-shaped dict.
If report_path already contains a ScoreReport-shaped dict (i.e., all of
``errors``, ``diff``, ``report_path``, and ``results_path`` are present),
it is returned as-is (idempotent). Otherwise the file is parsed as a
``RunReport`` and converted via ``ScoreReport.from_run_report``.
Args:
report_path: Path to the report JSON file.
results_path: Path to the corresponding results JSONL file. When
``None``, derived by convention as ``report_path.parent /
"results.jsonl"``.
Returns:
A dict that satisfies ``ScoreReport.model_validate(...)``.
"""
if results_path is None:
results_path = report_path.parent / "results.jsonl"
raw: dict[str, Any] = json.loads(report_path.read_text(encoding="utf-8"))
# Idempotency check: already ScoreReport-shaped
_score_report_keys = {"errors", "diff", "report_path", "results_path"}
if _score_report_keys.issubset(raw.keys()):
return raw
# Convert RunReport → ScoreReport
run_report = RunReport.model_validate(raw)
return ScoreReport.from_run_report(
report=run_report,
report_path=str(report_path),
results_path=str(results_path),
previous_report=None,
).model_dump(mode="json")
def _select_confusion_candidates(state: SearchState) -> list[str]:
"""Select candidate versions for confusion analysis.
Defaults to the strategy's elite_set. Tests can monkey-patch this
helper to override selection without changing the tool signature.
"""
return [c.prompt_version for c in (state.elite_set or [])]
_DATASET_QUERY_MAX_LIMIT = 50
_DEPRECATED_REVIEW_HISTORY_MESSAGE = (
"Deprecated: {tool_name} no longer returns historical data.\n\n"
"The source it depended on (`outputs/<run_id>/search/round_reports/`) is no longer written by any "
"production code path.\n\n"
"Current context:\n"
"- directive outcomes appear in `build_review_briefing` under the section starting with "
"`## Last round directives & outcomes`\n"
"- per-version score detail is available via `get_score_report`\n\n"
"This tool is retained as a surface placeholder and may be removed in a future release."
)
def _query_jsonl_dataset(
dataset_path: Path,
*,
route: str | None,
example_ids: list[str] | None,
offset: int,
limit: int,
) -> dict[str, Any]:
"""Return a page of dataset rows, filtered by ids or expected.route."""
if offset < 0:
raise ToolError("offset must be >= 0")
if limit <= 0:
raise ToolError("limit must be > 0")
if limit > _DATASET_QUERY_MAX_LIMIT:
raise ToolError(f"limit must be <= {_DATASET_QUERY_MAX_LIMIT}")
example_id_set = set(example_ids) if example_ids else None
examples: list[dict[str, Any]] = []
skipped = 0
with dataset_path.open(encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
example = json.loads(line)
expected_route = example.get("expected", {}).get("route", "")
if example_id_set is not None:
if example.get("id") not in example_id_set:
continue
elif route is not None and expected_route != route:
continue
if skipped < offset:
skipped += 1
continue
examples.append(example)
if len(examples) >= limit:
break
return {"examples": examples}
def _truncate_input_excerpt(text: str, max_chars: int = 300) -> str:
"""Render a single-line input excerpt capped at max_chars characters."""
single_line = " ".join(text.split())
if len(single_line) <= max_chars:
return single_line
if max_chars <= 3:
return single_line[:max_chars]
return single_line[: max_chars - 3] + "..."
@mcp.tool()
async def build_review_briefing(
ctx: Context,
run_id: str,
candidate_versions: list[str] | None = None,
parent_versions: dict[str, str | None] | None = None,
report_paths: dict[str, str] | None = None,
output_dir: str = "outputs",
trajectory_id: int | None = None,
) -> str:
"""[Stage 4: Refinement Loop -- Review] Build a ReviewBriefing with pre-computed metrics for the Review Agent.
Args:
run_id: Pipeline run identifier.
candidate_versions: Versions evaluated this round (auto-discovered if omitted).
parent_versions: Mapping of candidate -> parent version (auto-discovered if omitted).
report_paths: Mapping of version -> report.json path (auto-discovered if omitted).
output_dir: Output directory (default "outputs").
trajectory_id: EMOSA only — selects per-trajectory fields; ignored otherwise.
Returns:
Markdown progressive-disclosure ReviewBriefing summary.
"""
from compass.agents.prompt_builder.search_ops import get_search_state
from compass.agents.review.ops import (
load_cell_attempt_history,
load_child_variants,
load_historical_eval_reports,
update_cell_attempt_history,
)
from compass.agents.review.preprocessor import build_review_briefing as _build_review_briefing_impl
from compass.agents.review.preprocessor import parse_user_targets
from compass.prompts.manager import FilePromptManager
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
# Load search state — must exist (pre-initialised by _ensure_stage4_search_state
# at Stage 4 entry). Missing state is a hard error so regressions surface immediately.
try:
state = get_search_state(run_id=run_id, output_dir=out)
except FileNotFoundError as exc:
raise ToolError("search_state.json missing — Stage 4 not initialised") from exc
# EMOSA-specific: auto-fire calibration on first review entry.
# Calibration is pure computation (no LLM), so it lives in the backend
# transition rather than as a separate user-facing step.
if state.algorithm == "emosa" and not state.elite_set:
pending_for_calib = _load_pending(run_id, out)
scored_for_calib = [c for c in pending_for_calib if c.eval_status in ("complete", None)]
num_traj = state.algorithm_state.get("num_trajectories", 0)
if num_traj and len(scored_for_calib) >= num_traj:
from compass.agents.prompt_builder import search_ops as _search_ops
calibration_complete = getattr(_search_ops, "_calibration_complete", None)
if calibration_complete is not None:
calibration_complete(run_id, state, out)
state = get_search_state(run_id=run_id, output_dir=out)
# Auto-discover pending candidates
pending_candidates_list = _load_pending(run_id, out)
# Auto-populate candidate_versions and parent_versions from pending candidates
if candidate_versions is None:
candidate_versions = [c.prompt_version for c in pending_candidates_list]
if parent_versions is None:
parent_versions = {c.prompt_version: c.parent_version for c in pending_candidates_list}
# Load score reports for current candidates + front + parents
all_versions: set[str] = set(candidate_versions)
for c in state.elite_set:
all_versions.add(c.prompt_version)
for parent in parent_versions.values():
if parent is not None:
all_versions.add(parent)
# Load historical round reports
historical = load_historical_eval_reports(run_id, state, output_dir=out)
# Auto-discover report_paths from disk if not provided
if report_paths is None:
report_paths = {}
for version in candidate_versions:
default_path = out / run_id / "eval" / version / "report.json"
if default_path.exists():
report_paths[version] = str(default_path)
# Load current round reports via report_paths param; fall back to historical for front members
score_reports: dict[str, Any] = {}
for version in all_versions:
if version in report_paths:
rp = Path(report_paths[version])
if rp.exists():
results_path = rp.parent / "results.jsonl"
score_reports[version] = _load_score_report_dict(rp, results_path)
elif version not in score_reports:
for round_data in historical.values():
if version in round_data:
synthetic_report_path = out / run_id / "eval" / version / "report.json"
synthetic_results_path = out / run_id / "eval" / version / "results.jsonl"
historical_dict = round_data[version]
_score_report_keys = {"errors", "diff", "report_path", "results_path"}
if _score_report_keys.issubset(historical_dict.keys()):
score_reports[version] = historical_dict
else:
run_report = RunReport.model_validate(historical_dict)
score_reports[version] = ScoreReport.from_run_report(
report=run_report,
report_path=str(synthetic_report_path),
results_path=str(synthetic_results_path),
previous_report=None,
).model_dump(mode="json")
break
# Load prompt texts (used internally for diversity metrics, not surfaced in briefing)
run_prompts_dir = out / run_id / "prompts"
project_prompts_dir = project_dir / "prompts"
prompt_texts: dict[str, str] = {}
for version in all_versions:
for prompts_dir in (run_prompts_dir, project_prompts_dir):
try:
prompt_texts[version] = FilePromptManager(prompts_dir).load(version)
break
except FileNotFoundError:
continue
# Load child variants for batch outcome tracking.
search_dir_for_load = out / run_id / "search"
if search_dir_for_load.exists() and any(search_dir_for_load.glob("child_variants_t*.json")):
from compass.agents.review.ops import (
load_all_trajectory_child_variants, # pyright: ignore[reportAttributeAccessIssue]
)
child_variants = load_all_trajectory_child_variants(run_id, output_dir=out) or None
else:
child_variants = load_child_variants(run_id, output_dir=out) or None
# Load routing context
routing_context = None
routing_context_path = out / run_id / "validation" / "routing_context.json"
if routing_context_path.exists():
from compass.agents.routing_context import RoutingContext
routing_context = RoutingContext.model_validate_json(routing_context_path.read_text(encoding="utf-8"))
# Load user targets from validated input report
user_targets = None
input_report_path = out / run_id / "validation" / "input_report.md"
if input_report_path.exists():
user_targets = parse_user_targets(input_report_path.read_text(encoding="utf-8"))
# Load full-dataset oracle if available
full_dataset_oracle: dict[str, float] | None = None
oracle_path = out / run_id / "eval" / "oracle_metrics.json"
if oracle_path.exists():
full_dataset_oracle = json.loads(oracle_path.read_text(encoding="utf-8"))
# Load dev-set oracle if available
dev_oracle: dict[str, float] | None = None
dev_oracle_path = out / run_id / "eval" / "dev_oracle_metrics.json"
if dev_oracle_path.exists():
dev_oracle = json.loads(dev_oracle_path.read_text(encoding="utf-8"))
# Load raw eval results and examples for confusion analysis
eval_results_for_confusion = None
examples_for_confusion = None
selected_versions = _select_confusion_candidates(state)
if selected_versions:
from compass.eval.models import EvalResult, Example
all_eval_results: list[EvalResult] = []
for version in selected_versions:
results_path = out / run_id / "eval" / version / "results.jsonl"
if results_path.exists():
for line in results_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
row = json.loads(stripped)
except json.JSONDecodeError:
continue
if row.get("__meta__"):
continue
try:
all_eval_results.append(EvalResult.model_validate(row))
except Exception:
continue
if all_eval_results:
eval_results_for_confusion = all_eval_results
# Load dataset examples (dev set)
dev_path = out / run_id / "analysis" / "dev.jsonl"
if dev_path.exists():
loaded_examples: list[Example] = []
for line in dev_path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if not stripped:
continue
try:
loaded_examples.append(Example.model_validate_json(stripped))
except Exception:
continue
if loaded_examples:
examples_for_confusion = loaded_examples
# Load cell attempt history for confusion cell exhaustion tracking
cell_attempt_history = load_cell_attempt_history(run_id, output_dir=out)
# Build briefing
briefing = _build_review_briefing_impl(
search_state=state,
score_reports=score_reports,
historical_reports=historical,
prompt_texts=prompt_texts,
candidate_versions=candidate_versions,
parent_versions=parent_versions,
routing_context=routing_context,
child_variants=child_variants,
pending_candidates=pending_candidates_list,
user_targets=user_targets,
full_dataset_oracle=full_dataset_oracle,
dev_oracle=dev_oracle,
eval_results=eval_results_for_confusion,
examples=examples_for_confusion,
run_dir=out / run_id,
cell_attempt_history=cell_attempt_history or None,
emosa_trajectory_id=trajectory_id,
)
# Update cell attempt history from batch outcomes (links prior child variants to outcomes)
if briefing.batch_outcomes and child_variants:
update_cell_attempt_history(
run_id,
briefing.batch_outcomes,
child_variants,
briefing.confusion_analysis,
current_round=state.round,
output_dir=out,
)
# Record that the Review Agent sub-agent is now in-flight for this round.
record_review_dispatched(run_id, round=state.round, output_dir=out)
return render_review_briefing_md(briefing)
@mcp.tool()
async def record_directive_outcomes(
ctx: Context,
run_id: str,
loop_signal: dict[str, Any] | None = None,
child_variants: list[dict[str, Any]] | None = None,
review_result: dict[str, Any] | None = None,
candidate_ranking: list[dict[str, Any]] | None = None,
promotion_decisions: list[dict[str, Any]] | None = None,
regression_guards: list[dict[str, Any]] | None = None,
trajectory_id: int | None = None,
output_dir: str = "outputs",
) -> str:
"""[Stage 4: Refinement Loop -- Review] Persist Review Agent outputs and advance the loop to build phase.
Args:
run_id: Run identifier.
loop_signal: "exit" converges; "refine" persists for advance_round.
child_variants: ChildVariant dicts to save.
review_result: Legacy full ReviewResult dict (prefer decomposed params).
candidate_ranking: Ranked candidates.
promotion_decisions: Promotion decisions.
regression_guards: Regression guards.
trajectory_id: EMOSA only — writes per-trajectory file.
output_dir: Output directory (default "outputs").
Returns:
JSON with child_variants_saved, variants_summary, loop_signal status.
"""
import contextlib
from compass.agents.review.models import (
INITIAL_PARENT_VERSION,
ChildVariant,
LoopSignal,
PromotionDecision,
RankedCandidate,
RegressionFlag,
)
from compass.agents.review.ops import (
save_child_variants,
)
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
# --- Validate all inputs before touching state or disk ---
parsed_variants: list[ChildVariant] = []
if child_variants is not None:
parsed_variants = [ChildVariant.model_validate(v) for v in child_variants]
if loop_signal is not None:
LoopSignal.model_validate(loop_signal)
if candidate_ranking is not None:
[RankedCandidate.model_validate(r) for r in candidate_ranking]
if promotion_decisions is not None:
[PromotionDecision.model_validate(p) for p in promotion_decisions]
if regression_guards is not None:
[RegressionFlag.model_validate(r) for r in regression_guards]
# When decomposed params are provided, assemble and persist an audit ReviewResult.
# When only review_result is provided, persist it as-is (legacy path).
if review_result is None and any([candidate_ranking, promotion_decisions, regression_guards]):
from compass.agents.review.ops import save_review_result
reconstructed: dict[str, Any] = {
"candidate_ranking": candidate_ranking or [],
"promotion_decisions": promotion_decisions or [],
"regression_guards": regression_guards or [],
}
if child_variants is not None:
reconstructed["child_variants"] = child_variants
if loop_signal is not None:
reconstructed["loop_signal"] = loop_signal
save_review_result(run_id, reconstructed, output_dir=out)
elif review_result is not None:
from compass.agents.review.ops import save_review_result
save_review_result(run_id, review_result, output_dir=out)
result: dict[str, Any] = {}
# Persist child variants.
# EMOSA path (trajectory_id is not None): write per-trajectory file and record dispatch.
# Single-slot path (trajectory_id is None): write the canonical child_variants.json sentinel.
if child_variants is not None:
# Assign stable variant_ids using the global monotonic counter stored in
# SearchState so that ids are sequential across all rounds (v1, v2, …).
current_round = 0
with contextlib.suppress(FileNotFoundError):
state = _load_state(run_id, out)
current_round = state.round
next_seq = state.next_variant_seq
for i, v in enumerate(parsed_variants):
if v.variant_id is None:
parsed_variants[i] = v.model_copy(update={"variant_id": f"v{next_seq}"})
next_seq += 1
_save_state(run_id, state.model_copy(update={"next_variant_seq": next_seq}), out)
# Cold-start: parent_version is infrastructure, not agent responsibility.
# Coerce unconditionally so agents need not set it on round 0.
if current_round == 0:
parsed_variants = [v.model_copy(update={"parent_version": INITIAL_PARENT_VERSION}) for v in parsed_variants]
if loop_signal is None:
loop_signal = {"action": "refine", "reason": "cold_start_default"}
if trajectory_id is not None:
# EMOSA K-way fanout: use per-trajectory variant ids and file.
from compass.agents.review.ops import ( # type: ignore[attr-defined] # noqa: PLC0415
record_trajectory_dispatched, # pyright: ignore[reportAttributeAccessIssue]
save_trajectory_child_variants, # pyright: ignore[reportAttributeAccessIssue]
)
# EMOSA K-way fanout: use per-trajectory variant ids and file.
for i, v in enumerate(parsed_variants):
if v.variant_id is None:
parsed_variants[i] = v.model_copy(update={"variant_id": f"cv-{current_round}-t{trajectory_id}-{i}"})
parsed_variants = [v.model_copy(update={"trajectory_id": trajectory_id}) for v in parsed_variants]
save_trajectory_child_variants(run_id, trajectory_id, parsed_variants, output_dir=out)
record_trajectory_dispatched(run_id, trajectory_id, output_dir=out)
else:
# Single-slot path (hill-climb).
for i, v in enumerate(parsed_variants):
if v.variant_id is None:
parsed_variants[i] = v.model_copy(update={"variant_id": f"cv-{current_round}-{i}"})
save_child_variants(run_id, parsed_variants, output_dir=out)
# Write the shared child_variants.json sentinel so review_fanout_status
# can confirm the fanout is complete without knowledge of directive format.
child_variants_path = out / run_id / "search" / "child_variants.json"
child_variants_path.parent.mkdir(parents=True, exist_ok=True)
child_variants_path.write_text(
json.dumps([v.model_dump(mode="json") for v in parsed_variants], indent=2),
encoding="utf-8",
)
result["child_variants_saved"] = len(parsed_variants)
result["variants_summary"] = [
{"variant_id": v.variant_id, "hypothesis": v.hypothesis[:80]} for v in parsed_variants
]
# Handle loop signal from Review Agent
if loop_signal is not None:
parsed_signal = LoopSignal.model_validate(loop_signal)
if parsed_signal.action == "exit":
# Terminate the loop — record final round summary, set converged=true
with contextlib.suppress(FileNotFoundError):
state = _load_state(run_id, out)
pending = _load_pending(run_id, out)
summary = RoundSummary(
round=state.round,
candidates_evaluated=[c.prompt_version for c in pending],
new_elite_entries=0,
elite_size=len(state.elite_set),
mutation_mode=state.mutation_mode,
stagnation_count=state.stagnation_count,
converged=True,
convergence_reason="review_exit",
)
updated = state.model_copy(
update={
"converged": True,
"round_history": [*state.round_history, summary],
}
)
_save_state(run_id, updated, out)
result["loop_signal_applied"] = "exit"
return json.dumps(result)
# action == "refine": persist signal for advance_round to consume
_save_loop_signal(run_id, parsed_signal, out)
result["loop_signal_applied"] = "refine"
# Transition search loop to build phase so orchestrator spawns Prompt Builder next.
with contextlib.suppress(FileNotFoundError):
_set_loop_phase(run_id, "build", output_dir=out)
# For EMOSA K-way fanout (trajectory_id is not None), preserve review_dispatched.json
# so trajectory_fanout_missing can track in-flight vs completed slots across N sub-agent
# calls. For single-slot algorithms, clear the marker so complete_stage("review") can
# proceed.
if trajectory_id is None:
clear_review_dispatched(run_id, output_dir=out)
return json.dumps(result)
@mcp.tool()
async def query_holdout_examples(
ctx: Context,
run_id: str,
route: str | None = None,
example_ids: list[str] | None = None,
offset: int = 0,
limit: int = 20,
output_dir: str = "outputs",
) -> str:
"""[Stage 4: Refinement Loop -- Review] Query holdout examples by route or by ids.
Returns full example objects from the holdout dataset including input text
and per-route cost/quality data. Use this to find examples for specific
routes when crafting few-shot example directives.
Args:
run_id: Pipeline run identifier.
route: Filter by expected route name. Ignored when example_ids is set.
example_ids: Filter to these specific example ids. Takes precedence over route.
offset: Skip the first N matching examples (default 0). Use with limit for pagination.
limit: Maximum number of examples to return (default 20).
output_dir: Output directory (default "outputs").
Returns:
JSON object with an examples list.
"""
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
holdout_path = out / run_id / "analysis" / "holdout.jsonl"
if not holdout_path.exists():
return json.dumps({"examples": [], "error": "holdout.jsonl not found"})
return json.dumps(
_query_jsonl_dataset(holdout_path, route=route, example_ids=example_ids, offset=offset, limit=limit)
)
@mcp.tool()
async def query_dev_examples(
ctx: Context,
run_id: str,
route: str | None = None,
example_ids: list[str] | None = None,
offset: int = 0,
limit: int = 20,
output_dir: str = "outputs",
) -> str:
"""[Stage 4: Refinement Loop -- Review] Query dev examples by route or by ids.
Returns full example objects from the dev dataset including input text
and per-route cost/quality data. Use this when you need concrete dev-set
rows without assuming dataset content is already in context.
Args:
run_id: Pipeline run identifier.
route: Filter by expected route name. Ignored when example_ids is set.
example_ids: Filter to these specific example ids. Takes precedence over route.
offset: Skip the first N matching examples (default 0). Use with limit for pagination.
limit: Maximum number of examples to return (default 20, capped server-side).
output_dir: Output directory (default "outputs").
Returns:
JSON object with an examples list.
"""
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
dev_path = out / run_id / "analysis" / "dev.jsonl"
if not dev_path.exists():
return json.dumps({"examples": [], "error": "dev.jsonl not found"})
return json.dumps(_query_jsonl_dataset(dev_path, route=route, example_ids=example_ids, offset=offset, limit=limit))
@mcp.tool()
async def query_eval_results(
ctx: Context,
run_id: str,
version: str,
true_route: str | None = None,
predicted_route: str | None = None,
example_ids: list[str] | None = None,
misroutes_only: bool = False,
limit: int = 20,
output_dir: str = "outputs",
) -> str:
"""[Stage 4: Review] Return per-example eval rows joined with dev inputs.
Args:
run_id: Pipeline run identifier.
version: Prompt version whose results.jsonl should be inspected.
true_route: Filter by oracle route when paired with predicted_route.
predicted_route: Filter by predicted route when paired with true_route.
example_ids: Filter to these specific example ids. Takes precedence over other filters.
misroutes_only: When true, include only rows where predicted_route != oracle_route.
limit: Maximum number of rows to render (default 20, capped server-side at 50).
output_dir: Output directory (default "outputs").
Returns:
Markdown detail for matching eval rows, joined with dev-set input text.
"""
if limit <= 0:
raise ToolError("limit must be > 0")
if limit > _DATASET_QUERY_MAX_LIMIT:
raise ToolError(f"limit must be <= {_DATASET_QUERY_MAX_LIMIT}")
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
results_path = out / run_id / "eval" / version / "results.jsonl"
if not results_path.exists():
return (
f"## Eval results — `{run_id}` / `{version}`\n\n"
f"`results.jsonl` not found at `{results_path}`. "
"Per-example eval inspection is only available after Stage 4 evaluation has completed."
)
dev_path = out / run_id / "analysis" / "dev.jsonl"
if not dev_path.exists():
return (
f"## Eval results — `{run_id}` / `{version}`\n\n"
f"`dev.jsonl` not found at `{dev_path}`. "
"Per-example eval inspection requires Stage 2 (Data Analysis) artifacts."
)
dev_index: dict[str, dict[str, Any]] = {}
for raw_line in dev_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line:
continue
try:
row = json.loads(line)
except json.JSONDecodeError:
continue
row_id = row.get("id")
expected = row.get("expected", {})
if not isinstance(row_id, str) or not isinstance(expected, dict):
continue
dev_index[row_id] = {
"input": row.get("input", ""),
"oracle_route": expected.get("route"),
"routes": expected.get("routes", {}),
}
example_id_set = set(example_ids) if example_ids else None
rows: list[dict[str, Any]] = []
with results_path.open(encoding="utf-8") as handle:
for raw_line in handle:
line = raw_line.strip()
if not line:
continue
try:
payload = json.loads(line)
except json.JSONDecodeError:
continue
if payload.get("__meta__"):
continue
try:
result = EvalResult.model_validate(payload)
except Exception:
continue
dev_row = dev_index.get(result.example_id)
if dev_row is None or not isinstance(result.output, dict):
continue
oracle_route = dev_row.get("oracle_route")
predicted = result.output.get("route")
if not isinstance(oracle_route, str) or not isinstance(predicted, str):
continue
if example_id_set is not None:
if result.example_id not in example_id_set:
continue
elif true_route is not None and predicted_route is not None:
if oracle_route != true_route or predicted != predicted_route:
continue
elif misroutes_only and predicted == oracle_route:
continue
routes = dev_row.get("routes")
predicted_metrics = routes.get(predicted, {}) if isinstance(routes, dict) else {}
rows.append(
{
"example_id": result.example_id,
"input": dev_row.get("input", ""),
"oracle_route": oracle_route,
"predicted_route": predicted,
"cost": result.cost,
"quality_score": predicted_metrics.get("quality_score"),
}
)
if len(rows) >= limit:
break
lines = [f"## Eval results — `{run_id}` / `{version}`", "", f"### Rows ({len(rows)} shown)", ""]
if not rows:
lines.append("No matching eval results.")
return "\n".join(lines)
for row in rows:
lines.append(f"**{row['example_id']}**")
lines.append(f"- input: {_truncate_input_excerpt(str(row['input']))}")
cost = row["cost"]
quality = row["quality_score"]
cost_str = f"{cost:.4f}" if isinstance(cost, float) else "—"
quality_str = f"{quality:.4f}" if isinstance(quality, float) else "—"
lines.append(
f"- oracle_route: `{row['oracle_route']}` | predicted_route: `{row['predicted_route']}`"
f" | cost: {cost_str} | quality_score: {quality_str}"
)
lines.append("")
return "\n".join(lines)
@mcp.tool()
async def get_prompt_text(
ctx: Context,
version: str,
run_id: str,
output_dir: str = "outputs",
) -> str:
"""[Stage 4: Refinement Loop -- Review] Retrieve the full text of a prompt version.
Use this to inspect the actual prompt content for a specific version
when analyzing misrouting patterns or reviewing directive effects.
Args:
version: Prompt version string (e.g., "v3").
run_id: Pipeline run identifier. Looks in outputs/<run_id>/prompts/ first,
then falls back to the project-level prompts/ directory.
output_dir: Output directory (default "outputs").
Returns:
The full prompt text.
"""
from compass.prompts.manager import FilePromptManager
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
for prompts_dir in (out / run_id / "prompts", project_dir / "prompts"):
try:
return FilePromptManager(prompts_dir).load(version)
except FileNotFoundError:
continue
return json.dumps({"error": f"Prompt version '{version}' not found"})
@mcp.tool()
async def get_score_report(
ctx: Context,
run_id: str,
version: str,
top_k_errors: int = 30,
output_dir: str = "outputs",
) -> str:
"""[Stage 4: Review] Return a markdown ScoreReport for a candidate version.
Args:
run_id: Pipeline run identifier.
version: Prompt version (e.g. "v3").
top_k_errors: Max error rows to include (default 30).
output_dir: Output directory (default "outputs").
Returns:
Markdown with metrics table, summary line, top-K errors, and diff if present.
"""
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
report_path = out / run_id / "eval" / version / "report.json"
if not report_path.exists():
return f"No report found for version `{version}` at `{report_path}`."
results_path = report_path.parent / "results.jsonl"
try:
data = _load_score_report_dict(report_path, results_path)
except Exception as exc:
return f"Failed to load report: {exc}"
score_report = ScoreReport.model_validate(data)
if top_k_errors < len(score_report.errors):
score_report = score_report.model_copy(update={"errors": score_report.errors[:top_k_errors]})
return render_score_report_md(score_report)
@mcp.tool()
async def get_confusion_cell(
ctx: Context,
run_id: str,
true_route: str,
predicted_route: str,
output_dir: str = "outputs",
) -> str:
"""[Stage 4: Review] Return markdown detail for a single confusion cell.
Args:
run_id: Pipeline run identifier.
true_route: The ground-truth route label.
predicted_route: The predicted route label.
output_dir: Output directory (default "outputs").
Returns:
Markdown with cell-level confusion impact fields.
"""
# Recompute via full briefing builder; slice out the matching cell.
from compass.agents.prompt_builder.search_ops import get_search_state
from compass.agents.review.ops import (
load_cell_attempt_history,
load_child_variants,
load_historical_eval_reports,
)
from compass.agents.review.preprocessor import build_review_briefing as _build_review_briefing_impl
from compass.agents.review.preprocessor import parse_user_targets
from compass.prompts.manager import FilePromptManager
project_dir = await _resolve_project_dir(ctx)
out = Path(output_dir) if Path(output_dir).is_absolute() else project_dir / output_dir
try:
state = get_search_state(run_id=run_id, output_dir=out)
except FileNotFoundError:
return "search_state.json not found — Stage 4 not initialised."
from compass.agents.prompt_builder.search_ops import _load_pending
pending = _load_pending(run_id, out)
candidate_versions = [c.prompt_version for c in pending]
parent_versions = {c.prompt_version: c.parent_version for c in pending}
all_versions: set[str] = set(candidate_versions)
for c in state.elite_set:
all_versions.add(c.prompt_version)
for p in parent_versions.values():
if p:
all_versions.add(p)
historical = load_historical_eval_reports(run_id, state, output_dir=out)
report_paths: dict[str, str] = {}
for v in candidate_versions:
rp = out / run_id / "eval" / v / "report.json"
if rp.exists():
report_paths[v] = str(rp)
score_reports: dict[str, Any] = {}
for v in all_versions:
if v in report_paths:
rp = Path(report_paths[v])
if rp.exists():
score_reports[v] = _load_score_report_dict(rp, rp.parent / "results.jsonl")
elif v not in score_reports:
for rd in historical.values():
if v in rd:
synth = out / run_id / "eval" / v / "report.json"
synth_r = synth.parent / "results.jsonl"
row = rd[v]
_sk = {"errors", "diff", "report_path", "results_path"}
if _sk.issubset(row.keys()):
score_reports[v] = row
else:
from compass.eval.models import RunReport
rr = RunReport.model_validate(row)
from compass.eval.models import ScoreReport
score_reports[v] = ScoreReport.from_run_report(
report=rr, report_path=str(synth), results_path=str(synth_r), previous_report=None
).model_dump(mode="json")
break
run_prompts_dir = out / run_id / "prompts"
project_prompts_dir = project_dir / "prompts"
prompt_texts: dict[str, str] = {}
for v in all_versions:
for pd in (run_prompts_dir, project_prompts_dir):
try:
prompt_texts[v] = FilePromptManager(pd).load(v)
break
except FileNotFoundError:
continue
cv_list = load_child_variants(run_id, output_dir=out) or None
routing_context = None
rc_path = out / run_id / "validation" / "routing_context.json"
if rc_path.exists():
from compass.agents.routing_context import RoutingContext
routing_context = RoutingContext.model_validate_json(rc_path.read_text(encoding="utf-8"))
user_targets = None
ir_path = out / run_id / "validation" / "input_report.md"
if ir_path.exists():
user_targets = parse_user_targets(ir_path.read_text(encoding="utf-8"))
full_oracle: dict[str, float] | None = None
op = out / run_id / "eval" / "oracle_metrics.json"
if op.exists():
full_oracle = json.loads(op.read_text(encoding="utf-8"))
dev_oracle: dict[str, float] | None = None
dop = out / run_id / "eval" / "dev_oracle_metrics.json"
if dop.exists():
dev_oracle = json.loads(dop.read_text(encoding="utf-8"))
eval_results = None
examples = None
selected = _select_confusion_candidates(state)
if selected:
from compass.eval.models import EvalResult, Example
all_er: list[EvalResult] = []
for v in selected:
rsp = out / run_id / "eval" / v / "results.jsonl"