vmware-debug 1.8.2__tar.gz → 1.8.4__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 (43) hide show
  1. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/PKG-INFO +2 -2
  2. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/RELEASE_NOTES.md +75 -0
  3. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/pyproject.toml +8 -2
  4. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/server.json +2 -2
  5. vmware_debug-1.8.4/tests/eval/capability/__init__.py +0 -0
  6. vmware_debug-1.8.4/tests/eval/capability/_scores.json +184 -0
  7. vmware_debug-1.8.4/tests/eval/capability/_scoring.py +221 -0
  8. vmware_debug-1.8.4/tests/eval/capability/_skill.py +62 -0
  9. vmware_debug-1.8.4/tests/eval/capability/conftest.py +80 -0
  10. vmware_debug-1.8.4/tests/eval/capability/test_entity_reachability.py +400 -0
  11. vmware_debug-1.8.4/tests/eval/capability/test_error_actionability.py +873 -0
  12. vmware_debug-1.8.4/tests/eval/capability/test_tool_description_quality.py +194 -0
  13. vmware_debug-1.8.4/tests/eval/capability/test_tool_manifest_budget.py +170 -0
  14. vmware_debug-1.8.4/tests/eval/regression/__init__.py +0 -0
  15. vmware_debug-1.8.4/tests/eval/regression/test_capability_grader.py +265 -0
  16. vmware_debug-1.8.4/tests/test_safe_error_passthrough.py +84 -0
  17. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/uv.lock +71 -6
  18. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/__init__.py +1 -1
  19. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/envelope.py +39 -6
  20. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/mcp_server/server.py +85 -26
  21. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/ops/timeline.py +6 -2
  22. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/.gitignore +0 -0
  23. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/README-CN.md +0 -0
  24. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/README.md +0 -0
  25. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/SECURITY.md +0 -0
  26. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/skills/vmware-debug/SKILL.md +0 -0
  27. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/skills/vmware-debug/references/agent-guardrails.md +0 -0
  28. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/skills/vmware-debug/references/capabilities.md +0 -0
  29. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/skills/vmware-debug/references/cli-reference.md +0 -0
  30. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/skills/vmware-debug/references/event-envelope.md +0 -0
  31. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/skills/vmware-debug/references/routing.md +0 -0
  32. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/skills/vmware-debug/references/setup-guide.md +0 -0
  33. {vmware_debug-1.8.2/tests/eval/regression → vmware_debug-1.8.4/tests/eval}/__init__.py +0 -0
  34. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/tests/eval/regression/test_debug_regressions.py +0 -0
  35. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/tests/eval/regression/test_declared_environment.py +0 -0
  36. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/tests/eval/regression/test_read_only_mode.py +0 -0
  37. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/tests/eval/regression/test_result_envelope.py +0 -0
  38. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/tests/test_timeline.py +0 -0
  39. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/cli.py +0 -0
  40. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/mcp/__init__.py +0 -0
  41. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/mcp/tools.py +0 -0
  42. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/mcp_server/__init__.py +0 -0
  43. {vmware_debug-1.8.2 → vmware_debug-1.8.4}/vmware_debug/ops/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: vmware-debug
3
- Version: 1.8.2
3
+ Version: 1.8.4
4
4
  Summary: VMware diagnostic brain — read-only incident triage, log/event correlation, and root-cause routing across the VMware skill family
5
5
  Author-email: Wei Zhou <wei-wz.zhou@broadcom.com>
6
6
  License-Expression: MIT
@@ -13,7 +13,7 @@ Requires-Python: >=3.10
13
13
  Requires-Dist: mcp[cli]<2.0,>=1.10
14
14
  Requires-Dist: rich<15.0,>=13.0
15
15
  Requires-Dist: typer<1.0,>=0.12
16
- Requires-Dist: vmware-policy<2.0,>=1.8.0
16
+ Requires-Dist: vmware-policy<2.0,>=1.8.4
17
17
  Description-Content-Type: text/markdown
18
18
 
19
19
  <!-- mcp-name: io.github.zw008/vmware-debug -->
@@ -1,3 +1,78 @@
1
+ ## v1.8.4 (2026-07-20) — errors that teach, and tool descriptions a small model can route from
2
+
3
+ A capability eval was rolled out across the family and asked two open questions:
4
+ when a call fails, is the model told enough to fix it, and can it pick the right
5
+ tool from the description alone? Both answers were worse than anyone thought, and
6
+ in several places the reason was that the measurement was looking somewhere other
7
+ than where the model reads.
8
+
9
+ ### Fixed — teaching messages were being discarded on the way to the agent
10
+
11
+ `_safe_error` reduces unrecognised exceptions to `"<Class>: operation failed."`
12
+ so raw API text, credentials in URLs and internal paths cannot reach an agent.
13
+ Its allowlist held only the builtin validation errors — so this skill's **own**
14
+ domain exceptions, the ones that exist precisely to carry a corrected next step,
15
+ had their messages replaced by their class names.
16
+
17
+ The effect was invisible from the CLI, which prints those messages in full.
18
+
19
+ The worst case was shared by nine skills: `config.py` raises exactly one
20
+ `OSError`, the missing-password error, whose entire remedy is the environment
21
+ variable name it names. An agent hitting an unconfigured target received
22
+ `OSError: operation failed.` and had nothing to act on. That is the family's most
23
+ common first-run failure, and it landed one release after the documented variable
24
+ names were corrected — so the message that would have unstuck the operator was
25
+ the one being thrown away.
26
+
27
+ The rule is now the property it always meant: **every exception this skill raises
28
+ on purpose passes through**, and only genuinely unplanned ones are reduced.
29
+ `RuntimeError` stays reduced — it is the generic catch-all and in several skills
30
+ carries raw upstream text.
31
+
32
+ ### Fixed — error messages now carry the correction
33
+
34
+ Every message that reported a failure without saying how to recover was
35
+ rewritten: it names the offending value, gives an imperative remedy, and names
36
+ something concrete to act on — a tool that exists, a real CLI command, a config
37
+ file, an environment variable. Recovery becomes an instruction-following problem
38
+ rather than an inference one, which is what a weak model can still do.
39
+
40
+ Three classes of defect surfaced while doing it:
41
+
42
+ - **Remedies that were never delivered.** `_safe_error` truncates with no
43
+ ellipsis, so a message longer than the cap loses its closing sentence
44
+ silently. One message had been shipping at 396 characters against a 300-char
45
+ cap — its remedy had never once reached an agent. Messages now lead with the
46
+ remedy so a long interpolated value truncates the expendable detail instead.
47
+ - **Commands that do not exist.** One skill's error hints named a `doctor`
48
+ subcommand it does not have.
49
+ - **Tools that do not exist.** A tool description pointed at two sibling-skill
50
+ tools that had been renamed, and another named a tool that had moved to a
51
+ different skill entirely.
52
+
53
+ ### Improved — tool descriptions state when to use them and what to call next
54
+
55
+ The description is the API for a small model: an unstated routing rule is a
56
+ routing rule that does not exist, and a tool with no stated next hop is one the
57
+ model stops at. Descriptions now say when to prefer this tool over a sibling,
58
+ what shape comes back, the caveat that bites, and which tool to call after.
59
+
60
+ **Manifest size did not grow.** Descriptions load into every session, so the
61
+ routing clauses were paid for by cutting duplicated reference material —
62
+ repeated boilerplate, examples that restated the parameter list, and prose
63
+ copies of the pagination contract.
64
+
65
+ ### Note
66
+
67
+ Every tool and CLI command named anywhere in this release was verified against
68
+ the live MCP registry and the live command tree, not against documentation.
69
+
70
+ ## v1.8.3 (2026-07-20) — credentials resolve as a pair; documented env vars now exist
71
+
72
+ ### Changed — version alignment
73
+
74
+ No functional change in this skill. The family release adds an env-var override for the per-target username in the credential-bearing skills; this package has no per-target credentials.
75
+
1
76
  ## v1.8.2 (2026-07-20) — the MCP server moves into the package namespace
2
77
 
3
78
  ### Fixed — co-installing two skills broke all but the last one
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "vmware-debug"
7
- version = "1.8.2"
7
+ version = "1.8.4"
8
8
  description = "VMware diagnostic brain — read-only incident triage, log/event correlation, and root-cause routing across the VMware skill family"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "typer>=0.12,<1.0",
22
22
  "rich>=13.0,<15.0",
23
23
  "mcp[cli]>=1.10,<2.0",
24
- "vmware-policy>=1.8.0,<2.0",
24
+ "vmware-policy>=1.8.4,<2.0",
25
25
  ]
26
26
 
27
27
  [project.scripts]
@@ -31,6 +31,12 @@ vmware-debug-mcp = "vmware_debug.mcp_server.server:main"
31
31
  [tool.hatch.build.targets.wheel]
32
32
  packages = ["vmware_debug"]
33
33
 
34
+ [tool.pytest.ini_options]
35
+ testpaths = ["tests"]
36
+ markers = [
37
+ "capability: Capability evals — scored trends, not pass/fail gates. Excluded from the default run (they measure quality, so <100% is expected and must not read as a broken build). Run them with: pytest -m capability",
38
+ ]
39
+ addopts = "-m 'not capability'"
34
40
  [dependency-groups]
35
41
  dev = [
36
42
  "pytest>=8.0,<10.0",
@@ -7,12 +7,12 @@
7
7
  "url": "https://github.com/zw008/VMware-Debug",
8
8
  "source": "github"
9
9
  },
10
- "version": "1.8.2",
10
+ "version": "1.8.4",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "pypi",
14
14
  "identifier": "vmware-debug",
15
- "version": "1.8.2",
15
+ "version": "1.8.4",
16
16
  "transport": {
17
17
  "type": "stdio"
18
18
  }
File without changes
@@ -0,0 +1,184 @@
1
+ {
2
+ "_comment": "Capability eval scores. Regenerate with: pytest -m capability. These are tracked trends, not pass/fail gates \u2014 see _scoring.py.",
3
+ "scores": {
4
+ "entity_reachability_full": {
5
+ "detail": {
6
+ "broken_chains": [],
7
+ "entry_points": [
8
+ "incident_timeline",
9
+ "list_symptom_categories"
10
+ ],
11
+ "reachable_on_surface": 0,
12
+ "reachable_only_via_description": 0,
13
+ "required_entity_params": 0,
14
+ "required_params_classified": "0/1",
15
+ "tool_count": 2
16
+ },
17
+ "maximum": 1,
18
+ "pct": 100.0,
19
+ "unit": "required entity params",
20
+ "value": 1
21
+ },
22
+ "entity_reachability_read_only": {
23
+ "detail": {
24
+ "broken_chains": [],
25
+ "entry_points": [
26
+ "incident_timeline",
27
+ "list_symptom_categories"
28
+ ],
29
+ "reachable_on_surface": 0,
30
+ "reachable_only_via_description": 0,
31
+ "required_entity_params": 0,
32
+ "required_params_classified": "0/1",
33
+ "tool_count": 2
34
+ },
35
+ "maximum": 1,
36
+ "pct": 100.0,
37
+ "unit": "required entity params",
38
+ "value": 1
39
+ },
40
+ "entry_point_availability": {
41
+ "detail": {
42
+ "full_entry_points": 2,
43
+ "read_only_entry_points": 2
44
+ },
45
+ "maximum": 2,
46
+ "pct": 100.0,
47
+ "unit": "entry points",
48
+ "value": 2
49
+ },
50
+ "error_actionability": {
51
+ "detail": {
52
+ "composed_hint_sites": [],
53
+ "dead_end_count": 0,
54
+ "dead_end_errors": [],
55
+ "per_dimension_pct": {
56
+ "names_artifact": 100.0,
57
+ "names_input": 100.0,
58
+ "states_remedy": 100.0
59
+ },
60
+ "raise_sites": 7
61
+ },
62
+ "maximum": 21,
63
+ "pct": 100.0,
64
+ "unit": "points",
65
+ "value": 21
66
+ },
67
+ "manifest_context_headroom": {
68
+ "detail": {
69
+ "heaviest_tools": {
70
+ "incident_timeline": 329,
71
+ "list_symptom_categories": 121
72
+ },
73
+ "manifest_tokens": 450,
74
+ "mean_tokens_per_tool": 225.0,
75
+ "reference_context": 16384,
76
+ "tool_count": 2
77
+ },
78
+ "maximum": 16384,
79
+ "pct": 97.3,
80
+ "unit": "tokens",
81
+ "value": 15934
82
+ },
83
+ "parameter_documentation_coverage": {
84
+ "detail": {
85
+ "undocumented_by_tool": {}
86
+ },
87
+ "maximum": 4,
88
+ "pct": 100.0,
89
+ "unit": "parameters",
90
+ "value": 4
91
+ },
92
+ "per_tool_token_discipline": {
93
+ "detail": {
94
+ "ceiling": 350,
95
+ "over_ceiling": {}
96
+ },
97
+ "maximum": 2,
98
+ "pct": 100.0,
99
+ "unit": "tools",
100
+ "value": 2
101
+ },
102
+ "read_only_manifest_saving": {
103
+ "detail": {
104
+ "full_manifest_tokens": 450,
105
+ "full_tool_count": 2,
106
+ "gated_manifest_tokens": 450,
107
+ "gated_tool_count": 2,
108
+ "withheld_tools": []
109
+ },
110
+ "maximum": 450,
111
+ "pct": 0.0,
112
+ "unit": "tokens",
113
+ "value": 0
114
+ },
115
+ "read_write_marker_coverage": {
116
+ "detail": {
117
+ "unmarked": []
118
+ },
119
+ "maximum": 2,
120
+ "pct": 100.0,
121
+ "unit": "points",
122
+ "value": 2
123
+ },
124
+ "remedy_survives_truncation": {
125
+ "detail": {
126
+ "budget_chars": 500,
127
+ "over_budget": [],
128
+ "remedy_after_interpolation": [
129
+ "envelope.py:135",
130
+ "envelope.py:95",
131
+ "envelope.py:160",
132
+ "envelope.py:107",
133
+ "envelope.py:200",
134
+ "timeline.py:119"
135
+ ]
136
+ },
137
+ "maximum": 7,
138
+ "pct": 100.0,
139
+ "unit": "messages",
140
+ "value": 7
141
+ },
142
+ "teaching_error_rate": {
143
+ "detail": {},
144
+ "maximum": 7,
145
+ "pct": 100.0,
146
+ "unit": "messages",
147
+ "value": 7
148
+ },
149
+ "tool_description_quality": {
150
+ "detail": {
151
+ "per_dimension_pct": {
152
+ "args": 100.0,
153
+ "gotcha": 100.0,
154
+ "marker": 100.0,
155
+ "next_hop": 100.0,
156
+ "what": 100.0,
157
+ "when": 100.0
158
+ },
159
+ "tools_graded": 2,
160
+ "weakest_tools": {
161
+ "incident_timeline": [],
162
+ "list_symptom_categories": []
163
+ }
164
+ },
165
+ "maximum": 12,
166
+ "pct": 100.0,
167
+ "unit": "points",
168
+ "value": 12
169
+ },
170
+ "tool_failure_payload_quality": {
171
+ "detail": {
172
+ "bare_string_error_lines": [],
173
+ "carries_hint": 1,
174
+ "dict_shaped": 1,
175
+ "error_return_sites": 1,
176
+ "hint_names_artifact": 1
177
+ },
178
+ "maximum": 3,
179
+ "pct": 100.0,
180
+ "unit": "checks",
181
+ "value": 3
182
+ }
183
+ }
184
+ }
@@ -0,0 +1,221 @@
1
+ """Scoring primitives shared by this skill's capability evals.
2
+
3
+ Why this exists
4
+ ---------------
5
+ A regression eval answers a yes/no question ("does bug #31 still bite?") and
6
+ must sit at 100%. A capability eval answers a *how well* question ("can a small
7
+ model actually drive this tool surface?") and is expected to sit below 100%
8
+ forever — the number is the product, not the pass/fail.
9
+
10
+ So every capability eval here does two things:
11
+
12
+ 1. **records a score** into ``_scores.json`` next to this file, so the next
13
+ release can diff against it rather than re-deriving a feeling; and
14
+ 2. **asserts a floor**, deliberately set well under the current score. The floor
15
+ is a ratchet against collapse, not a quality bar. A test going red here means
16
+ something fell off a cliff, not that the surface is imperfect.
17
+
18
+ Do not raise a floor to match a score. The floor's job is to stay boring.
19
+
20
+ Token estimation
21
+ ----------------
22
+ ``estimate_tokens`` is a BPE approximation (word/punct segmentation × 0.75), not
23
+ a real tokenizer — none of the family venvs carry ``tiktoken`` and a capability
24
+ eval must not add a dependency to be runnable. It lands within roughly ±15% of
25
+ cl100k on this kind of English-plus-JSON text, which is ample: every budget here
26
+ is a *trend* measurement compared against the same estimator in the previous
27
+ release, and against thresholds chosen with the error bar already in mind.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import re
34
+ from dataclasses import dataclass, field, replace
35
+ from pathlib import Path
36
+ from typing import Any
37
+
38
+ SCORES_PATH = Path(__file__).with_name("_scores.json")
39
+
40
+ _WORDISH = re.compile(r"\w+|[^\w\s]")
41
+
42
+
43
+ def estimate_tokens(text: str) -> int:
44
+ """Approximate BPE token count for ``text``. See module docstring for error bar."""
45
+ if not text:
46
+ return 0
47
+ return int(len(_WORDISH.findall(text)) * 0.75)
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class Score:
52
+ """One recorded capability measurement.
53
+
54
+ ``value``/``maximum`` are the raw numbers; ``pct`` is what release-to-release
55
+ comparison actually reads. ``detail`` carries the per-item breakdown so a
56
+ regression in the aggregate can be traced to the item that caused it without
57
+ re-running anything.
58
+ """
59
+
60
+ name: str
61
+ value: float
62
+ maximum: float
63
+ unit: str = "points"
64
+ detail: dict[str, Any] = field(default_factory=dict)
65
+
66
+ @property
67
+ def pct(self) -> float:
68
+ if self.maximum == 0:
69
+ return 0.0
70
+ return round(100.0 * self.value / self.maximum, 1)
71
+
72
+ def as_dict(self) -> dict[str, Any]:
73
+ return {
74
+ "value": round(self.value, 2),
75
+ "maximum": round(self.maximum, 2),
76
+ "unit": self.unit,
77
+ "pct": self.pct,
78
+ "detail": self.detail,
79
+ }
80
+
81
+
82
+ @dataclass
83
+ class ScoreBoard:
84
+ """Session-scoped collector.
85
+
86
+ The dataclass itself is mutable by necessity — pytest hands results in one
87
+ test at a time — but ``add`` never mutates a :class:`Score`, and ``records``
88
+ is replaced rather than appended in place, so no caller can observe a
89
+ half-updated board.
90
+ """
91
+
92
+ records: tuple[Score, ...] = ()
93
+
94
+ def add(self, score: Score) -> Score:
95
+ self.records = (*self.records, score)
96
+ return score
97
+
98
+ def as_dict(self) -> dict[str, Any]:
99
+ return {s.name: s.as_dict() for s in sorted(self.records, key=lambda s: s.name)}
100
+
101
+ def write(self, path: Path = SCORES_PATH) -> None:
102
+ if not self.records:
103
+ return
104
+ payload = {
105
+ "_comment": (
106
+ "Capability eval scores. Regenerate with: pytest -m capability. "
107
+ "These are tracked trends, not pass/fail gates — see _scoring.py."
108
+ ),
109
+ "scores": self.as_dict(),
110
+ }
111
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n")
112
+
113
+
114
+ def previous_scores(path: Path = SCORES_PATH) -> dict[str, Any]:
115
+ """Load the last recorded run, or ``{}`` on a first run / unreadable file."""
116
+ try:
117
+ return json.loads(path.read_text()).get("scores", {})
118
+ except (OSError, ValueError):
119
+ return {}
120
+
121
+
122
+ # ---------------------------------------------------------------------------
123
+ # Text probes reused by several evals
124
+ # ---------------------------------------------------------------------------
125
+
126
+ #: Phrases that signal a description told the agent *when* to reach for this
127
+ #: tool rather than a sibling. Routing is the single hardest thing for a small
128
+ #: model to infer, because inferring it requires holding the whole tool list.
129
+ WHEN_MARKERS = (
130
+ "use this",
131
+ "use it",
132
+ "use for",
133
+ "use when",
134
+ "instead of",
135
+ " instead",
136
+ "prefer ",
137
+ "before ",
138
+ "first",
139
+ "start here",
140
+ "rather than",
141
+ "for detail",
142
+ "drill into",
143
+ "follow up",
144
+ "then ",
145
+ # "Use after a storage array presents new LUNs" is a complete when-clause.
146
+ # Without these the rubric scored it zero, and the only way to earn the
147
+ # point was to reword it to "Use this when ..." — identical meaning, no
148
+ # information added. A rubric that pays for phrasing buys churn.
149
+ "use after",
150
+ "after ",
151
+ "once ",
152
+ "whenever ",
153
+ )
154
+
155
+ #: Phrases that signal the description stated what comes back.
156
+ WHAT_MARKERS = ("returns", "return ", "yields", "reports", "->", "→")
157
+
158
+ #: Phrases that signal a caveat — the class of information a strong model infers
159
+ #: from experience and a weak model simply never learns.
160
+ GOTCHA_MARKERS = (
161
+ "note",
162
+ "only",
163
+ "requires",
164
+ "does not",
165
+ "do not",
166
+ "cannot",
167
+ "never",
168
+ "always",
169
+ "may ",
170
+ "must ",
171
+ "caution",
172
+ "warning",
173
+ "n/a",
174
+ "point-in-time",
175
+ "no trending",
176
+ "not supported",
177
+ "unavailable",
178
+ "beware",
179
+ "careful",
180
+ "irreversible",
181
+ "cannot be undone",
182
+ "double",
183
+ "confirm",
184
+ "dry-run",
185
+ "dry run",
186
+ "skip",
187
+ "fall back",
188
+ "fallback",
189
+ "if the",
190
+ "when the",
191
+ "unless",
192
+ "except",
193
+ )
194
+
195
+
196
+ def has_any(text: str, markers: tuple[str, ...]) -> bool:
197
+ """Does ``text`` contain any marker, ignoring how the source happens to wrap?
198
+
199
+ Whitespace is collapsed first. Markers carry trailing spaces (``"before "``,
200
+ ``"then "``), so a docstring that wrapped at exactly that word scored zero
201
+ for content it plainly contained -- ``"...check the rule count before\\n
202
+ deleting"`` missed ``"before "`` on a line break. Two of one skill's
203
+ forty-two apparent gaps were this, which means the rubric was reporting
204
+ formatting as absence and inviting a rewrite that changes nothing.
205
+ """
206
+ low = " ".join(text.lower().split())
207
+ return any(m in low for m in markers)
208
+
209
+
210
+ def documented_args(description: str, schema: dict[str, Any]) -> tuple[int, int]:
211
+ """Return ``(documented, total)`` schema properties named in ``description``.
212
+
213
+ An undocumented parameter is one a model must guess the meaning of from its
214
+ name alone. That is survivable for ``target`` and not survivable for
215
+ ``top_n`` or ``folder_filter``.
216
+ """
217
+ props = tuple((schema or {}).get("properties", {}))
218
+ if not props:
219
+ return (0, 0)
220
+ low = description.lower()
221
+ return (sum(1 for p in props if p.lower() in low), len(props))
@@ -0,0 +1,62 @@
1
+ """The only repo-specific facts in this capability suite.
2
+
3
+ Every ``test_*.py`` file in this directory is identical across the family repos;
4
+ they differ only through this module. Keeping the difference in one small file is
5
+ what makes a rubric change portable — edit the eval once, copy it, and the scores
6
+ stay comparable between skills.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ #: Import path of the Python package under test.
12
+ PACKAGE = "vmware_debug"
13
+
14
+ #: Module holding the FastMCP server.
15
+ SERVER_MODULE = "vmware_debug.mcp_server.server"
16
+
17
+ #: CLI entry point name, used when scoring whether an error names something
18
+ #: concrete for the operator to run.
19
+ CLI_NAME = "vmware-debug"
20
+
21
+ #: Companion skills this one legitimately routes to. A required entity name that
22
+ #: this surface cannot produce is not a dead end *if* the description says which
23
+ #: sibling skill produces it — that is a documented hand-off rather than a gap.
24
+ COMPANION_SKILLS = (
25
+ "vmware-aiops",
26
+ "vmware-monitor",
27
+ "vmware-storage",
28
+ "vmware-vks",
29
+ "vmware-nsx",
30
+ "vmware-nsx-security",
31
+ "vmware-aria",
32
+ "vmware-avi",
33
+ "vmware-harden",
34
+ "vmware-pilot",
35
+ )
36
+
37
+ #: Entity tokens this skill's tools name, mapped to the words its listing tools
38
+ #: use. Authored from the registry's own required parameters, not from the
39
+ #: domain in the abstract. Drives ``test_entity_reachability``: a stem that is
40
+ #: not here is invisible to that eval, which is why the suite asserts coverage.
41
+ #: Deliberately empty: neither tool takes a discoverable identifier —
42
+ #: ``incident_timeline`` takes an event payload and
43
+ #: ``list_symptom_categories`` takes nothing. The suite asserts that an
44
+ #: empty result comes from an empty map rather than from a map that
45
+ #: simply failed to fit, so this emptiness has to be stated.
46
+ ENTITY_WORDS = {
47
+ }
48
+
49
+ #: Skill-specific parameters that end in an entity suffix but are supplied by the
50
+ #: operator rather than discovered from an API. Universal exclusions (``target``,
51
+ #: paths, filters) live in the eval itself.
52
+ NOT_AN_ENTITY: frozenset[str] = frozenset()
53
+
54
+ def get_server(module):
55
+ """Return the FastMCP instance ``SERVER_MODULE`` exposes.
56
+
57
+ This skill builds its server in a factory rather than at import time, so the
58
+ read-only gate is applied per call. Declared here rather than probed with a
59
+ try/except chain — a fallback would let a server that stops exposing what
60
+ this file says silently resolve to the other shape.
61
+ """
62
+ return module.build_server()
@@ -0,0 +1,80 @@
1
+ """Session plumbing for the capability evals.
2
+
3
+ The ``board`` fixture is session-scoped so that one ``pytest -m capability`` run
4
+ produces one ``_scores.json`` holding every measurement taken. Running a single
5
+ capability file rewrites the file with only that file's scores — intentional, so
6
+ a partial run is never mistaken for a full one; regenerate with the full
7
+ ``-m capability`` selection before committing a score change.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import importlib
14
+ import os
15
+ import sys
16
+ from typing import Any
17
+
18
+ import pytest
19
+
20
+ from ._scoring import ScoreBoard
21
+ from ._skill import SERVER_MODULE, get_server
22
+
23
+ #: Prefix of the modules the read-only gate affects at import time.
24
+ _SERVER_PREFIX = SERVER_MODULE.split(".")[0] + ".mcp_server"
25
+
26
+ _READ_ONLY_ENV = "VMWARE_READ_ONLY"
27
+
28
+
29
+ def load_tools(read_only: bool = False) -> tuple[Any, ...]:
30
+ """Import the MCP server fresh and return the tools it registers.
31
+
32
+ Re-imports rather than reusing the loaded module because the read-only gate
33
+ runs at import time. The original module objects are restored afterwards —
34
+ deleting them would leave other test files monkeypatching a module nobody
35
+ imports any more, and their patches would silently stop applying.
36
+ """
37
+ saved = {n: m for n, m in sys.modules.items() if n.startswith(_SERVER_PREFIX)}
38
+ prior = os.environ.get(_READ_ONLY_ENV)
39
+ try:
40
+ if read_only:
41
+ os.environ[_READ_ONLY_ENV] = "true"
42
+ else:
43
+ os.environ.pop(_READ_ONLY_ENV, None)
44
+ for name in list(saved):
45
+ del sys.modules[name]
46
+ mod = importlib.import_module(SERVER_MODULE)
47
+ return tuple(asyncio.run(get_server(mod).list_tools()))
48
+ finally:
49
+ if prior is None:
50
+ os.environ.pop(_READ_ONLY_ENV, None)
51
+ else:
52
+ os.environ[_READ_ONLY_ENV] = prior
53
+ for name in [n for n in sys.modules if n.startswith(_SERVER_PREFIX)]:
54
+ del sys.modules[name]
55
+ sys.modules.update(saved)
56
+
57
+
58
+ @pytest.fixture(scope="session")
59
+ def board() -> Any:
60
+ b = ScoreBoard()
61
+ yield b
62
+ b.write()
63
+
64
+
65
+ @pytest.fixture(scope="session")
66
+ def tools() -> tuple[Any, ...]:
67
+ """Every tool the real FastMCP registry exposes, as the agent would see it.
68
+
69
+ Read from ``mcp.list_tools()`` rather than from the source, because the
70
+ registry is the only thing that reflects decorators, gating, and the schema
71
+ FastMCP actually derives from each signature — which is what lands in the
72
+ model's context, not the docstring as written.
73
+ """
74
+ return load_tools(read_only=False)
75
+
76
+
77
+ @pytest.fixture(scope="session")
78
+ def gated_tools() -> tuple[Any, ...]:
79
+ """The surface an operator gets under ``VMWARE_READ_ONLY=true``."""
80
+ return load_tools(read_only=True)