lbagents-eval 0.19.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. lbagents_eval-0.19.0/.gitignore +118 -0
  2. lbagents_eval-0.19.0/PKG-INFO +18 -0
  3. lbagents_eval-0.19.0/pyproject.toml +49 -0
  4. lbagents_eval-0.19.0/scripts/cases_to_scenarios.py +417 -0
  5. lbagents_eval-0.19.0/scripts/threads_to_scenarios.py +256 -0
  6. lbagents_eval-0.19.0/src/lbagents_eval/__init__.py +42 -0
  7. lbagents_eval-0.19.0/src/lbagents_eval/cli.py +379 -0
  8. lbagents_eval-0.19.0/src/lbagents_eval/config.py +43 -0
  9. lbagents_eval-0.19.0/src/lbagents_eval/execution/__init__.py +17 -0
  10. lbagents_eval-0.19.0/src/lbagents_eval/execution/live_tools.py +195 -0
  11. lbagents_eval-0.19.0/src/lbagents_eval/execution/mock_llm.py +60 -0
  12. lbagents_eval-0.19.0/src/lbagents_eval/execution/replay.py +328 -0
  13. lbagents_eval-0.19.0/src/lbagents_eval/execution/results.py +47 -0
  14. lbagents_eval-0.19.0/src/lbagents_eval/execution/runner.py +142 -0
  15. lbagents_eval-0.19.0/src/lbagents_eval/execution/suite.py +522 -0
  16. lbagents_eval-0.19.0/src/lbagents_eval/execution/transient.py +42 -0
  17. lbagents_eval-0.19.0/src/lbagents_eval/manifest.py +188 -0
  18. lbagents_eval-0.19.0/src/lbagents_eval/observability/__init__.py +13 -0
  19. lbagents_eval-0.19.0/src/lbagents_eval/observability/common.py +41 -0
  20. lbagents_eval-0.19.0/src/lbagents_eval/observability/export.py +435 -0
  21. lbagents_eval-0.19.0/src/lbagents_eval/observability/reviews.py +164 -0
  22. lbagents_eval-0.19.0/src/lbagents_eval/profiles/__init__.py +197 -0
  23. lbagents_eval-0.19.0/src/lbagents_eval/profiles/ci_failure/__init__.py +200 -0
  24. lbagents_eval-0.19.0/src/lbagents_eval/profiles/ci_failure/replay.py +352 -0
  25. lbagents_eval-0.19.0/src/lbagents_eval/report.py +510 -0
  26. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/__init__.py +17 -0
  27. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/datasets/scenarios.jsonl +51 -0
  28. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr4950.yaml +392 -0
  29. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr6488_39255.yaml +2225 -0
  30. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr6488_40894.yaml +311 -0
  31. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr6756_40661.yaml +579 -0
  32. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr6860.yaml +1339 -0
  33. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr6965_40576.yaml +741 -0
  34. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7025_39809.yaml +421 -0
  35. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7036_39281.yaml +405 -0
  36. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7103_39501.yaml +420 -0
  37. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7125_39716.yaml +458 -0
  38. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7137_39844.yaml +688 -0
  39. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7179_40267.yaml +522 -0
  40. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7219_40093.yaml +739 -0
  41. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7258_40226.yaml +1843 -0
  42. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7258_40294.yaml +1089 -0
  43. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7260_40224.yaml +436 -0
  44. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7263_40702.yaml +1525 -0
  45. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7276_40327.yaml +236 -0
  46. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7288_40362.yaml +1007 -0
  47. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7298_40397.yaml +1314 -0
  48. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7306_40444.yaml +454 -0
  49. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7343_40747.yaml +1779 -0
  50. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7349_40643.yaml +464 -0
  51. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7350_40652.yaml +874 -0
  52. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7352_40654.yaml +399 -0
  53. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7370_40783.yaml +1373 -0
  54. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7379_40815.yaml +139 -0
  55. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7395_40884.yaml +437 -0
  56. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7398_41072.yaml +725 -0
  57. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7403_40923.yaml +1382 -0
  58. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7404_40924.yaml +662 -0
  59. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7407.yaml +984 -0
  60. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7416_41006.yaml +375 -0
  61. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7422_41060.yaml +365 -0
  62. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7428_41086.yaml +263 -0
  63. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7440_41131.yaml +855 -0
  64. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7449.yaml +537 -0
  65. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7454_41253.yaml +547 -0
  66. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7462.yaml +1155 -0
  67. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7463.yaml +1125 -0
  68. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7466.yaml +826 -0
  69. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_ci_mr7471_41311.yaml +467 -0
  70. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_pg_needs_download_input_data.yaml +240 -0
  71. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_pg_ownpv_fatal_gaudi.yaml +271 -0
  72. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_pg_pilot_not_running.yaml +198 -0
  73. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_pg_xrd_redirect_limit.yaml +205 -0
  74. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_stuck_235832.yaml +389 -0
  75. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_stuck_235838.yaml +170 -0
  76. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_stuck_236121.yaml +345 -0
  77. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_stuck_236517.yaml +192 -0
  78. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/harness/ap_stuck_236988.yaml +277 -0
  79. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/loader.py +125 -0
  80. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/models.py +85 -0
  81. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/recordings/.gitkeep +0 -0
  82. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/recordings.py +454 -0
  83. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/suites/ci.txt +45 -0
  84. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/suites/mr.txt +21 -0
  85. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/suites/triage.txt +13 -0
  86. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/tool_contract.py +84 -0
  87. lbagents_eval-0.19.0/src/lbagents_eval/scenarios/tool_specs.json +1030 -0
  88. lbagents_eval-0.19.0/src/lbagents_eval/scoring/__init__.py +12 -0
  89. lbagents_eval-0.19.0/src/lbagents_eval/scoring/grounding.py +170 -0
  90. lbagents_eval-0.19.0/src/lbagents_eval/scoring/verdict.py +327 -0
  91. lbagents_eval-0.19.0/tests/test_cli.py +49 -0
  92. lbagents_eval-0.19.0/tests/test_export.py +156 -0
  93. lbagents_eval-0.19.0/tests/test_grounding.py +53 -0
  94. lbagents_eval-0.19.0/tests/test_profiles.py +108 -0
  95. lbagents_eval-0.19.0/tests/test_replay.py +288 -0
  96. lbagents_eval-0.19.0/tests/test_report.py +380 -0
  97. lbagents_eval-0.19.0/tests/test_scenarios.py +98 -0
  98. lbagents_eval-0.19.0/tests/test_scenarios_contract.py +115 -0
  99. lbagents_eval-0.19.0/tests/test_triage_scenarios.py +45 -0
@@ -0,0 +1,118 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ share/python-wheels/
24
+ *.egg-info/
25
+ .installed.cfg
26
+ *.egg
27
+ MANIFEST
28
+
29
+ # Installer logs
30
+ pip-log.txt
31
+ pip-delete-this-directory.txt
32
+
33
+ # Unit test / coverage reports
34
+ htmlcov/
35
+ .tox/
36
+ .nox/
37
+ .coverage
38
+ .coverage.*
39
+ .cache
40
+ nosetests.xml
41
+ coverage.xml
42
+ cov.xml
43
+ *.cover
44
+ *.py,cover
45
+ .hypothesis/
46
+ .pytest_cache/
47
+ cover/
48
+
49
+ # Translations
50
+ *.mo
51
+ *.pot
52
+
53
+ # Sphinx documentation
54
+ docs/_build/
55
+
56
+ # PyBuilder
57
+ .pybuilder/
58
+ target/
59
+
60
+ # Jupyter Notebook
61
+ .ipynb_checkpoints
62
+
63
+ # IPython
64
+ profile_default/
65
+ ipython_config.py
66
+
67
+ # pyenv
68
+ # .python-version
69
+
70
+ # PEP 582
71
+ __pypackages__/
72
+
73
+ # Environments
74
+ .env
75
+ .venv
76
+ env/
77
+ venv/
78
+ ENV/
79
+ env.bak/
80
+ venv.bak/
81
+
82
+ # Pixi
83
+ .pixi/
84
+
85
+ # Rope project settings
86
+ .ropeproject
87
+
88
+ # mypy
89
+ .mypy_cache/
90
+ .dmypy.json
91
+ dmypy.json
92
+
93
+ # Pyre type checker
94
+ .pyre/
95
+
96
+ # pytype static type analyzer
97
+ .pytype/
98
+
99
+ # Cython debug symbols
100
+ cython_debug/
101
+
102
+ # Ruff
103
+ .ruff_cache/
104
+
105
+ # OS
106
+ .DS_Store
107
+
108
+ # IDE
109
+ *.code-workspace
110
+ .idea/
111
+ .vscode/
112
+
113
+ # Eval results (runtime artifacts)
114
+ lbagents-eval/results.json
115
+ eval_results.xml
116
+
117
+ # threads from local script
118
+ .threads/
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.5
2
+ Name: lbagents-eval
3
+ Version: 0.19.0
4
+ Summary: Evaluation of LbAgents workflows on replayed real failures
5
+ Project-URL: Homepage, https://gitlab.cern.ch/lhcb-dpa/analysis-productions/lbagents
6
+ Classifier: Intended Audience :: Science/Research
7
+ Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
8
+ Classifier: Programming Language :: Python :: 3
9
+ Classifier: Programming Language :: Python :: 3.12
10
+ Classifier: Topic :: Scientific/Engineering
11
+ Requires-Python: >=3.12
12
+ Requires-Dist: langfuse<6.0,>=5.0.3
13
+ Requires-Dist: lbagents
14
+ Requires-Dist: mcp>=1.0
15
+ Requires-Dist: pyyaml>=6.0
16
+ Requires-Dist: rich>=13.0
17
+ Requires-Dist: scipy>=1.15
18
+ Requires-Dist: typer>=0.12
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "lbagents-eval"
7
+ description = "Evaluation of LbAgents workflows on replayed real failures"
8
+ requires-python = ">=3.12"
9
+ dynamic = ["version"]
10
+ classifiers = [
11
+ "Intended Audience :: Science/Research",
12
+ "License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Topic :: Scientific/Engineering",
16
+ ]
17
+ # One-directional dependency: the eval harness depends on the product, never the
18
+ # reverse. In the pixi workspace `lbagents` resolves to the local editable copy.
19
+ dependencies = [
20
+ "lbagents",
21
+ # `export` reads production traces with the v2 observations API (SDK 5).
22
+ "langfuse>=5.0.3,<6.0",
23
+ # Live MCP tools (live-fallback replay, snapshot, check-tools).
24
+ "mcp>=1.0",
25
+ "typer>=0.12",
26
+ "rich>=13.0",
27
+ "pyyaml>=6.0",
28
+ # Exact sign test and the bootstrap confidence interval of the report.
29
+ "scipy>=1.15",
30
+ ]
31
+
32
+ [project.scripts]
33
+ lbagents-eval = "lbagents_eval.cli:app"
34
+
35
+ [project.urls]
36
+ Homepage = "https://gitlab.cern.ch/lhcb-dpa/analysis-productions/lbagents"
37
+
38
+ # Same git-tag-derived version as `lbagents` (one tag → identical versions).
39
+ [tool.hatch.version]
40
+ source = "vcs"
41
+ raw-options = { root = "..", git_describe_command = ["git", "describe", "--dirty", "--tags", "--long", "--match", "*[0-9]*", "--exclude", "v[0-9]r*", "--exclude", "v[0-9][0-9]r*"] }
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/lbagents_eval"]
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["tests"]
48
+ addopts = "-v --cov=lbagents_eval --cov-report=term-missing"
49
+ junit_family = "xunit1"
@@ -0,0 +1,417 @@
1
+ ###############################################################################
2
+ # (c) Copyright 2026 CERN for the benefit of the LHCb Collaboration #
3
+ # #
4
+ # This software is distributed under the terms of the GNU General Public #
5
+ # Licence version 3 (GPL Version 3), copied verbatim in the file "COPYING". #
6
+ # #
7
+ # In applying this licence, CERN does not waive the privileges and immunities #
8
+ # granted to it by virtue of its status as an Intergovernmental Organization #
9
+ # or submit itself to any jurisdiction. #
10
+ ###############################################################################
11
+ """Turn recorded real CI failures into lbagents-eval scenarios.
12
+
13
+ python cases_to_scenarios.py CASES.json CACHE_DIR --tag harvest-2026-10
14
+
15
+ CASES.json: a list of cases ({"id", "mr_iid", "pipeline_id", "ci_run_name", "sha",
16
+ "reference", "evidence", optional "kind", "confidence", "draft", "reviewed_by",
17
+ "expected_response_contains"}), as produced by the benchmark harness. CACHE_DIR/<id>.json:
18
+ the tool outputs recorded when the case was run live ({key: {"tool", "args", "output"}};
19
+ GitLab tools pinned to the failing commit).
20
+
21
+ For each case it writes scenarios/harness/ap_ci_<id>.yaml (every recorded call as a
22
+ mock response matched on its arguments, a "no output recorded" fallback per tool, and a
23
+ scripted gold path: pipeline -> CI run -> first failed job -> post the expected answer)
24
+ and adds or replaces the case's entry in scenarios/datasets/scenarios.jsonl.
25
+
26
+ Draft cases (expected answer not yet checked by an operator: every case without a
27
+ "reviewed_by", whatever its "draft" says) get no expected_response_contains: those
28
+ keywords are chosen when the answer is confirmed. Proposed keywords are kept in
29
+ metadata.draft_expected_response_contains, where nothing gates on them.
30
+
31
+ Each entry carries metadata: draft, confidence, kind, side (request / outside / either,
32
+ the side the expected cause is on; see SIDE) and needs_review.
33
+
34
+ get_mr_info is recorded live, so it returns the MR as it is now, not as it was when the
35
+ pipeline failed: the fields that describe what happened after the failure (state, merge
36
+ and close data, the current head, labels...) are removed, head_sha is set to the failing
37
+ commit, and sentences of the description that state the fix are cut (REDACT). The title
38
+ and description are still the current ones (GitLab keeps no history of them).
39
+
40
+ python cases_to_scenarios.py CASES.json --update-existing
41
+
42
+ rewrites only the scenarios.jsonl entries of CASES.json (metadata, tags, keywords,
43
+ expected_tool_calls from the gold path of the existing harness) and strips get_mr_info
44
+ in the existing harness YAMLs, without re-reading the recordings.
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ import argparse
50
+ import json
51
+ import pathlib
52
+
53
+ import yaml
54
+
55
+ # Shared with the live-fallback replay, so a recording made later is cleaned the same way.
56
+ from lbagents_eval.scenarios.recordings import FALLBACK, REDACT, at_failure_mr_info, commits_before, trimmed
57
+
58
+ HERE = pathlib.Path(__file__).resolve().parent
59
+ SCENARIOS = HERE.parent / "src" / "lbagents_eval" / "scenarios"
60
+ # References that no benchmark report matched: possibly too narrow or wrong
61
+ # (dataset-eval-review.md, section 1.2). Low confidence until an operator reviews them.
62
+ TO_REVIEW = {"mr7370_40783", "mr7276_40327", "mr7298_40397", "mr7403_40923", "mr7471_41311"}
63
+
64
+ # Side of the expected cause: "request" (the author changes the MR), "outside" (site,
65
+ # DIRAC, storage, CI service, permissions: an owner adds the author, so the 2 process
66
+ # cases are outside), "either" (both remedies are valid: the "insufficient disk replicas"
67
+ # cases, where the data can be staged or the request changed).
68
+ EITHER = {"mr7395_40884", "mr7471_41311", "mr6756_40661"}
69
+
70
+
71
+ MR_INFO_NOTE = (
72
+ "# get_mr_info: post-failure fields removed, head_sha = the failing commit; title and description\n"
73
+ "# are the MR's current text (recorded after the failure), not the text at the failure time.\n"
74
+ )
75
+ REDACT_NOTE = "# The sentence of the current description that states the fix is removed (REDACT).\n"
76
+
77
+
78
+ # Accepted / rejected fixes that the single prose reference does not spell out
79
+ # (dataset-eval-review.md, section 1.2), for the goal judge (metadata.grading_notes).
80
+ # Not reviewed by an operator. Kept for reviewers; the bench judges (scoring/verdict.py) do not read them.
81
+ GRADING_NOTES = {
82
+ "mr7463": "Also accept removing the END_V*/end-vertex variables, as in the similar case mr7466.",
83
+ "mr7462": "Accept any unpacking that takes the last two returned values.",
84
+ "mr7407": "Rejected fix: removing the input_pvs argument (create_dtf requires pvs).",
85
+ "mr7398_41072": "Also accept fixing the import so that it works with the Moore version used.",
86
+ "mr7258_40226": (
87
+ "Accept any change that reduces the combinatorics (tighter cuts, fewer combinations). "
88
+ "Rejected fix: raising the memory limit."
89
+ ),
90
+ "mr7276_40327": (
91
+ "Also accept setting input_process/input_stream/geometry_version in extra_options by hand, "
92
+ "as the error message suggests. Rejected fix: waiting for a SprucingConfig release."
93
+ ),
94
+ "mr7403_40923": "Also accept using other input data or adding a re-merge step.",
95
+ "mr7440_41131": "Also accept initialising D_data or guarding the tuples.",
96
+ "mr7343_40747": "Also accept dropping the cut or applying the track-type cut on the pion.",
97
+ "mr7288_40362": "Accept removing TupleToolTagging, or a tagger list or DaVinci version that has SSKaonTagger.",
98
+ "mr7404_40924": "Accept removing flavour tagging, or configuring the tagging particles.",
99
+ "mr7137_39844": "Accept any fix of the input locations (the job reads HLT2 locations on Spruce input).",
100
+ "mr7395_40884": "Accept reducing n_test_lfns, staging the files to disk, or another sample on disk.",
101
+ "mr7471_41311": "Accept staging the files to disk or another sample that is on disk.",
102
+ "mr6756_40661": "Accept staging the files to disk, fewer test LFNs, or another sample on disk.",
103
+ "mr7025_39809": (
104
+ "Accept start_run/end_run instead of the enumerated runs, with or without reporting the Bookkeeping limit."
105
+ ),
106
+ "mr7125_39716": (
107
+ "Accept start_run/end_run instead of the enumerated runs, with or without reporting the Bookkeeping limit."
108
+ ),
109
+ }
110
+
111
+
112
+ def side(c):
113
+ if c["id"] in EITHER:
114
+ return "either"
115
+ return "outside" if c.get("kind") in ("infrastructure", "process") else "request"
116
+
117
+
118
+ def message(c):
119
+ return (
120
+ f'Diagnose the CI failure for pipeline {c["pipeline_id"]}, CI run "{c["ci_run_name"]}" '
121
+ f"(MR !{c['mr_iid']}). Investigate the logs, identify the root cause, and post a review on the merge request."
122
+ )
123
+
124
+
125
+ def recorded(cache, tool, args):
126
+ for v in cache.values():
127
+ if v["tool"] == tool and v["args"] == args:
128
+ return v["output"]
129
+ return None
130
+
131
+
132
+ def pipeline_created(pipeline_info: str | None) -> str | None:
133
+ """Creation time of the failing pipeline (ISO 8601, UTC) from its get_pipeline_info output."""
134
+ try:
135
+ return json.loads(pipeline_info or "").get("created")
136
+ except (ValueError, AttributeError):
137
+ return None
138
+
139
+
140
+ def first_failed_job(run_info: str | None):
141
+ try:
142
+ jobs = json.loads(run_info or "").get("jobs", [])
143
+ except (ValueError, AttributeError):
144
+ return None
145
+ return next((j["name"] for j in jobs if j.get("status") == "FAILED"), None)
146
+
147
+
148
+ def strip_post_failure_commits(mocks: list, sha: str | None, created: str | None) -> bool:
149
+ """Cut the get_mr_commits recordings at the failure (commits_before) and drop the
150
+ get_commit_diff recordings of the commits cut: a commit pushed after the failure is
151
+ often the fix. Edits *mocks* in place; True if anything changed."""
152
+ removed, changed = set(), False
153
+ for m in mocks:
154
+ if m["tool_name"] == "get_mr_commits" and m.get("match_arguments") is not None:
155
+ new = commits_before(m["response"], sha, created)
156
+ if new != m["response"]:
157
+ try:
158
+ old_shas = {str(x.get("sha", ""))[:12] for x in json.loads(m["response"])}
159
+ removed |= old_shas - {str(x.get("sha", ""))[:12] for x in json.loads(new)}
160
+ except (ValueError, AttributeError):
161
+ pass
162
+ m["response"], changed = new, True
163
+ keep = [
164
+ m
165
+ for m in mocks
166
+ if not (
167
+ m["tool_name"] == "get_commit_diff"
168
+ and m.get("match_arguments")
169
+ and str(m["match_arguments"].get("commit_sha", ""))[:12] in removed
170
+ )
171
+ ]
172
+ if len(keep) != len(mocks):
173
+ mocks[:] = keep
174
+ changed = True
175
+ return changed
176
+
177
+
178
+ def harness(c, cache):
179
+ mocks, tools = [], set()
180
+ for v in cache.values():
181
+ if v["tool"] == "post_mr_review":
182
+ continue
183
+ output = v["output"]
184
+ if v["tool"] == "get_mr_info":
185
+ output = at_failure_mr_info(output, c["id"], c.get("sha"))
186
+ mocks.append(
187
+ {
188
+ "tool_name": v["tool"],
189
+ "arguments": v["args"],
190
+ "response": trimmed(v["tool"], output),
191
+ "match_arguments": v["args"],
192
+ }
193
+ )
194
+ tools.add(v["tool"])
195
+ info = recorded(cache, "get_pipeline_info", {"pipeline_id": c["pipeline_id"]})
196
+ strip_post_failure_commits(mocks, c.get("sha"), c.get("pipeline_created") or pipeline_created(info))
197
+ for t in sorted(tools):
198
+ mocks.append({"tool_name": t, "response": FALLBACK})
199
+ mocks.append({"tool_name": "post_mr_review", "response": '{"status": "ok"}'})
200
+
201
+ pid, run = c["pipeline_id"], c["ci_run_name"]
202
+ path = [("get_pipeline_info", {"pipeline_id": pid}), ("get_ci_run_info", {"pipeline_id": pid, "ci_run_name": run})]
203
+ job = first_failed_job(recorded(cache, "get_ci_run_info", path[1][1]))
204
+ if job:
205
+ path.append(("get_ci_job_info", {"pipeline_id": pid, "ci_run_name": run, "job_name": job}))
206
+ scripted = [
207
+ {"content": "", "tool_calls": [{"tool_name": t, "arguments": a}]}
208
+ for t, a in path
209
+ if recorded(cache, t, a) is not None
210
+ ]
211
+ body = f"## CI failure: pipeline {pid}, run {run}\n\n{c['reference']}\n"
212
+ scripted.append({"content": "", "tool_calls": [{"tool_name": "post_mr_review", "arguments": {"body": body}}]})
213
+ scripted.append({"content": "Review proposed."})
214
+ return {"mock_responses": mocks, "scripted_llm_responses": scripted}
215
+
216
+
217
+ def gold_path_calls(scripted):
218
+ """expected_tool_calls = the tool calls of the scripted gold path (post_mr_review without its body)."""
219
+ calls = []
220
+ for r in scripted:
221
+ for tc in r.get("tool_calls", []):
222
+ name = tc.get("tool_name") or tc.get("name")
223
+ if name == "post_mr_review":
224
+ calls.append({"tool_name": name})
225
+ else:
226
+ calls.append({"tool_name": name, "arguments": tc.get("arguments", {})})
227
+ return calls
228
+
229
+
230
+ def entry(c, tag, scripted):
231
+ kind = c.get("kind", "request")
232
+ draft = not c.get("reviewed_by")
233
+ confidence = "low" if c["id"] in TO_REVIEW else (c.get("confidence") or "unrated")
234
+ tags = ["ci_failure", "real_case", tag, {"request": "request_bug"}.get(kind, kind), f"side_{side(c)}"]
235
+ if draft:
236
+ tags += ["draft", f"confidence_{confidence}"]
237
+ if c["id"] in TO_REVIEW:
238
+ tags.append("needs_review")
239
+ metadata = {
240
+ "case_id": c["id"],
241
+ "draft": draft,
242
+ "confidence": confidence,
243
+ "kind": kind,
244
+ "side": side(c),
245
+ "needs_review": c["id"] in TO_REVIEW,
246
+ }
247
+ if c.get("confidence") and confidence != c["confidence"]:
248
+ metadata["draft_confidence"] = c["confidence"]
249
+ if c["id"] in TO_REVIEW:
250
+ metadata["review_note"] = (
251
+ "No benchmark report (7 setups, Claude included) matched this reference: it may be too narrow or wrong."
252
+ )
253
+ if c["id"] in GRADING_NOTES:
254
+ metadata["grading_notes"] = GRADING_NOTES[c["id"]]
255
+ if c.get("reviewed_by"):
256
+ metadata["reviewed_by"] = c["reviewed_by"]
257
+ # What a live-fallback replay needs to see the failure as it was (lbagents_eval.execution.replay).
258
+ metadata["sha"] = c["sha"]
259
+ if c.get("pipeline_created"):
260
+ metadata["pipeline_created"] = c["pipeline_created"]
261
+ e = {
262
+ "name": f"ap_ci_{c['id']}",
263
+ "description": (
264
+ f"Real failed Analysis Productions CI run (MR !{c['mr_iid']}, pipeline {c['pipeline_id']}, "
265
+ f"run {c['ci_run_name']}, commit {c['sha'][:12]}). {c.get('evidence', '')}"
266
+ + (" Expected answer is a DRAFT, not yet checked by an operator." if draft else "")
267
+ + " Runs with either CI workflow (run --workflow)."
268
+ ),
269
+ "workflow": "ap_debug_ci_failure",
270
+ "user_input": message(c),
271
+ "initial_state": {"pipeline_id": c["pipeline_id"], "mr_iid": c["mr_iid"], "ci_run_name": c["ci_run_name"]},
272
+ "expected_goal": c["reference"],
273
+ "expected_tool_calls": [
274
+ tc
275
+ for tc in gold_path_calls(scripted)
276
+ if tc["tool_name"] in ("get_pipeline_info", "get_ci_run_info", "get_ci_job_info", "post_mr_review")
277
+ ],
278
+ "tags": tags,
279
+ "metadata": metadata,
280
+ }
281
+ keywords = c.get("expected_response_contains")
282
+ if keywords and not draft:
283
+ e["expected_response_contains"] = keywords
284
+ elif keywords:
285
+ metadata["draft_expected_response_contains"] = keywords
286
+ return e
287
+
288
+
289
+ def split_header(text: str) -> tuple[str, str]:
290
+ lines = text.splitlines(True)
291
+ i = 0
292
+ while i < len(lines) and lines[i].startswith("#"):
293
+ i += 1
294
+ return "".join(lines[:i]), "".join(lines[i:])
295
+
296
+
297
+ def strip_mr_info_in_harness(path: pathlib.Path, case_id: str, sha: str | None) -> bool:
298
+ """Apply at_failure_mr_info to the get_mr_info outputs of an existing harness YAML.
299
+
300
+ Files written by this script are re-dumped the same way; hand-written ones (the 7
301
+ pilot cases, with other formatting) get only their response lines replaced, so
302
+ the rest of the file is unchanged. Returns True if the file changed.
303
+ """
304
+ header, body = split_header(path.read_text())
305
+ data = yaml.safe_load(body)
306
+ changed = False
307
+ if yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=100000) == body:
308
+ for m in data["mock_responses"]:
309
+ if m["tool_name"] == "get_mr_info" and m.get("response", "").startswith("{"):
310
+ new = at_failure_mr_info(m["response"], case_id, sha, strict=False)
311
+ changed |= new != m["response"]
312
+ m["response"] = new
313
+ if changed:
314
+ path.write_text(header + yaml.safe_dump(data, sort_keys=False, allow_unicode=True, width=100000))
315
+ return changed
316
+ lines = body.splitlines(True)
317
+ for i, line in enumerate(lines):
318
+ if line.startswith("- tool_name: get_mr_info"):
319
+ j = next(j for j in range(i + 1, len(lines)) if lines[j].startswith(" response: "))
320
+ old = yaml.safe_load(lines[j][len(" response: ") :])
321
+ if not str(old).startswith("{"):
322
+ continue
323
+ new = at_failure_mr_info(old, case_id, sha, strict=False)
324
+ if new != old:
325
+ dumped = yaml.safe_dump({"response": new}, allow_unicode=True, width=100000)
326
+ if dumped.count("\n") != 1:
327
+ raise ValueError(f"{path}: the new get_mr_info response does not fit on one line")
328
+ lines[j] = " " + dumped
329
+ changed = True
330
+ for m in data["mock_responses"]:
331
+ if m["tool_name"] == "get_mr_info" and m.get("response") == old:
332
+ m["response"] = new
333
+ if changed:
334
+ new_body = "".join(lines)
335
+ if yaml.safe_load(new_body) != data: # only the get_mr_info responses may differ
336
+ raise ValueError(f"{path}: rewriting the get_mr_info response changed other content")
337
+ path.write_text(header + new_body)
338
+ return changed
339
+
340
+
341
+ def update_existing(cases, tag_of):
342
+ """Rewrite the dataset entries and strip get_mr_info in the harnesses, from CASES only."""
343
+ jsonl = SCENARIOS / "datasets" / "scenarios.jsonl"
344
+ entries = [json.loads(line) for line in jsonl.read_text().splitlines() if line.strip()]
345
+ by_name = {e["name"]: e for e in entries}
346
+ for c in cases:
347
+ name = f"ap_ci_{c['id']}"
348
+ path = SCENARIOS / "harness" / f"{name}.yaml"
349
+ if name not in by_name or not path.exists():
350
+ print("not in the dataset, skipped:", c["id"])
351
+ continue
352
+ old = by_name[name]
353
+ c = dict(c)
354
+ c.setdefault("expected_response_contains", old.get("expected_response_contains"))
355
+ strip_mr_info_in_harness(path, c["id"], c.get("sha"))
356
+ header, body = split_header(path.read_text())
357
+ h = yaml.safe_load(body)
358
+ info = next((m["response"] for m in h["mock_responses"] if m["tool_name"] == "get_pipeline_info"), None)
359
+ c.setdefault("pipeline_created", pipeline_created(info))
360
+ if strip_post_failure_commits(h["mock_responses"], c.get("sha"), c["pipeline_created"]):
361
+ if yaml.safe_dump(yaml.safe_load(body), sort_keys=False, allow_unicode=True, width=100000) != body:
362
+ raise ValueError(f"{path}: post-failure commits to cut in a hand-written harness: edit it by hand")
363
+ path.write_text(header + yaml.safe_dump(h, sort_keys=False, allow_unicode=True, width=100000))
364
+ print("post-failure commits cut:", c["id"])
365
+ by_name[name] = entry(c, tag_of(old), h.get("scripted_llm_responses", []))
366
+ jsonl.write_text("".join(json.dumps(e) + "\n" for e in by_name.values()))
367
+
368
+
369
+ def main():
370
+ ap = argparse.ArgumentParser()
371
+ ap.add_argument("cases")
372
+ ap.add_argument("cache_dir", nargs="?")
373
+ ap.add_argument("--tag")
374
+ ap.add_argument("--update-existing", action="store_true")
375
+ a = ap.parse_args()
376
+ cases = json.loads(pathlib.Path(a.cases).read_text())
377
+ if a.update_existing:
378
+ known = ("pilot-2026-10", "harvest-2026-10")
379
+ update_existing(cases, lambda e: a.tag or next((t for t in e.get("tags", []) if t in known), "harvest-2026-10"))
380
+ return
381
+ if not (a.cache_dir and a.tag):
382
+ ap.error("cache_dir and --tag are required unless --update-existing")
383
+ jsonl = SCENARIOS / "datasets" / "scenarios.jsonl"
384
+ entries = [json.loads(line) for line in jsonl.read_text().splitlines() if line.strip()]
385
+ by_name = {e["name"]: e for e in entries}
386
+ skipped = []
387
+ for c in cases:
388
+ cache_file = pathlib.Path(a.cache_dir) / f"{c['id']}.json"
389
+ if not cache_file.exists():
390
+ skipped.append(c["id"])
391
+ continue
392
+ cache = json.loads(cache_file.read_text())
393
+ name = f"ap_ci_{c['id']}"
394
+ header = (
395
+ f"# Real case {c['id']} (MR !{c['mr_iid']}, pipeline {c['pipeline_id']}): tool outputs recorded\n"
396
+ f"# live ({a.tag}; LbAPI MCP + GitLab tools pinned to {c['sha'][:12]}).\n"
397
+ "# scripted_llm_responses = a gold trajectory over those outputs (the tests' scripted model).\n"
398
+ + MR_INFO_NOTE
399
+ + (REDACT_NOTE if c["id"] in REDACT else "")
400
+ )
401
+ h = harness(c, cache)
402
+ c = {
403
+ **c,
404
+ "pipeline_created": pipeline_created(
405
+ recorded(cache, "get_pipeline_info", {"pipeline_id": c["pipeline_id"]})
406
+ ),
407
+ }
408
+ (SCENARIOS / "harness" / f"{name}.yaml").write_text(
409
+ header + yaml.safe_dump(h, sort_keys=False, allow_unicode=True, width=100000)
410
+ )
411
+ by_name[name] = entry(c, a.tag, h["scripted_llm_responses"])
412
+ jsonl.write_text("".join(json.dumps(e) + "\n" for e in by_name.values()))
413
+ print(f"{len(cases) - len(skipped)} scenarios written; no recording for: {skipped or 'none'}")
414
+
415
+
416
+ if __name__ == "__main__":
417
+ main()