Repository navigation
feat(catalog): known-positive guard for catalog trigger-probe #320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
b64cb67
99d7347
48cbd0f
c60be7d
ca1849a
89285d7
e38ba84
66cb163
5a0e2b0
75bfb9c
c8769ea
6248d03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| ### Added | ||
|
|
||
| - **`catalog trigger-probe` known-positive guard** — before the labelled queries, one | ||
| explicit run (`Use the <skill> skill.`, for `--target` or the alphabetically first skill name of the catalog) | ||
| must load that skill. If it does not, the probe writes `"status": "invalid: skill not | ||
| loaded"` (Claude Code listed it) or `"invalid: skill not visible"` (it did not), with | ||
| `known_positive` holding the guard run, the probe's `max_turns_hit`/`not_listed`/`skill_selection_summary` keys | ||
| at 0, `[]` and `{}`, no answers, no stale `<out>.partial`, prints the same status on stdout, and | ||
| exits 1. A skill call whose result is an error, or another plugin's `plugin:skill`, does | ||
| not count as a load. So a skill the harness never shows cannot read as a 0% trigger rate. A guard run | ||
| that ended in an error writes `"status": "error: known-positive run failed"` instead. An | ||
| empty `--catalog` is refused (exit 2) before the guard runs, unless `--no-known-positive` is passed. Pass | ||
| `--no-known-positive` to skip the check; the output then says `"status": "unchecked"` (or `invalid: skill not listed` when a | ||
| catalog skill is never listed). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,7 +145,13 @@ def parse_stream(stream: str) -> Dict[str, Any]: | |
| # share message.id. Subagent events (parent_tool_use_id set) are not the choice. | ||
| by_turn: Dict[str, List[str]] = {} | ||
| other_tools: List[str] = [] | ||
| ok_ids: set = set() # tool_use ids whose tool_result arrived and is not an error | ||
| calls: List[tuple] = [] # (tool_use id, raw skill name) of top-level Skill calls | ||
| for n, ev in enumerate(_events(stream)): | ||
| if ev.get("type") == "user": | ||
| for c in (ev.get("message") or {}).get("content") or []: | ||
| if isinstance(c, dict) and c.get("type") == "tool_result" and not c.get("is_error"): | ||
| ok_ids.add(c.get("tool_use_id")) | ||
| if ev.get("type") == "system" and ev.get("subtype") == "init": | ||
| listed = [str(s) for s in ev.get("skills") or []] | ||
| version = ev.get("claude_code_version") | ||
|
|
@@ -154,6 +160,8 @@ def parse_stream(stream: str) -> Dict[str, Any]: | |
| message = ev.get("message") or {} | ||
| uses = [c for c in message.get("content") or [] if isinstance(c, dict) and c.get("type") == "tool_use"] | ||
| skills = [_skill_name((c.get("input") or {}).get("skill")) for c in uses if c.get("name") == "Skill"] | ||
| calls += [(c.get("id"), str((c.get("input") or {}).get("skill") or "")) | ||
| for c in uses if c.get("name") == "Skill"] | ||
| if not by_turn: # tools after the choice are not "before" it | ||
| other_tools += [str(c.get("name")) for c in uses if c.get("name") != "Skill"] | ||
| if skills: | ||
|
|
@@ -167,6 +175,9 @@ def parse_stream(stream: str) -> Dict[str, Any]: | |
| "selected": first[0] if first else None, | ||
| "first_turn_loaded": first, | ||
| "loaded": [s for t in turns for s in t], | ||
| # Raw names (plugin namespace kept) of Skill calls with an observed result that was not an error. | ||
| # A call whose result never arrived (a hang, a kill) did not load anything. | ||
| "loaded_ok": [name for tid, name in calls if tid is not None and tid in ok_ids], | ||
| "tools_before_choice": other_tools, | ||
| "claude_code_version": version, | ||
| } | ||
|
|
@@ -186,22 +197,48 @@ def run_query(query: str, catalog: Path, cfg: ProbeConfig, *, runner: process.Ru | |
| skill = parse_stream(stream) | ||
| result = result_event(stream) or {} | ||
| error = None | ||
| # The failure as it is, before the Skill-call suppression below (the guard reports it). | ||
| terminal = None | ||
| if not result: | ||
| terminal = "timeout" if done.timed_out else f"no result event (exit {done.returncode})" | ||
| elif result.get("is_error") and result.get("subtype") != "error_max_turns": | ||
| terminal = str(result.get("subtype") or "error") | ||
|
Comment on lines
+204
to
+205
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| # A Skill call seen is the choice, whatever came after it (a hang, a timeout, an | ||
| # error). A result event read decides the rest, even if the CLI then hung. | ||
| if skill["first_turn_loaded"]: | ||
| pass | ||
| elif not result: | ||
| error = "timeout" if done.timed_out else f"no result event (exit {done.returncode})" | ||
| # Out of turns without a Skill call is an observation (nothing triggered), not a failure. | ||
| elif result.get("is_error") and result.get("subtype") != "error_max_turns": | ||
| error = str(result.get("subtype") or "error") | ||
| else: # out of turns without a Skill call is an observation (nothing triggered), not a failure | ||
| error = terminal | ||
| return {"skill": skill, "cost_usd": result.get("total_cost_usd"), | ||
| "num_turns": result.get("num_turns"), "result_subtype": result.get("subtype"), "error": error} | ||
| "num_turns": result.get("num_turns"), "result_subtype": result.get("subtype"), "error": error, | ||
| "terminal_error": terminal} | ||
| finally: | ||
| kill_strays(ws.root) | ||
| ws.cleanup() | ||
|
|
||
|
|
||
| def known_positive(skill: str, catalog: Path, cfg: ProbeConfig, *, runner: process.Runner = process.run, | ||
| source_env: Optional[Dict[str, str]] = None) -> Dict[str, Any]: | ||
| """One run of an explicit request for *skill*, through the same harness; ``passed`` if it loaded it. | ||
|
|
||
| A harness that never shows the skill scores every labelled query 0%, which reads as a | ||
| finding. This run is the check that the skill can be loaded at all, before those queries. | ||
| """ | ||
| query = f"Use the {skill} skill." | ||
| try: | ||
| r = run_query(query, catalog, cfg, runner=runner, source_env=source_env) | ||
| except (OSError, RuntimeError) as exc: | ||
| return {"skill": skill, "query": query, "passed": False, "loaded": [], "listed": [], "cost_usd": None, | ||
| "claude_code_version": None, "error": f"{type(exc).__name__}: {exc}"} | ||
| # The guard passes on an observed, successful call of exactly this skill (not another plugin's) with no | ||
| # terminal error; ``listed`` records whether the harness showed it. | ||
| passed = skill in r["skill"]["loaded_ok"] and r["terminal_error"] is None | ||
| return {"skill": skill, "query": query, "passed": passed, | ||
| "loaded": r["skill"]["loaded"], "listed": r["skill"]["listed"], "cost_usd": r["cost_usd"], | ||
| "claude_code_version": r["skill"]["claude_code_version"], | ||
| "error": None if passed else r["terminal_error"]} | ||
|
|
||
|
|
||
| def kill_strays(root: Path) -> List[int]: | ||
| """SIGKILL every process whose cwd is under *root*; return their pids (Linux only). | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Must-fix:
namescan be empty here. For example, use an empty existing--catalogdirectory and a trigger set containing{"questions":[{"id":"a","question":"Hello","expected_skill":null}]}with no--target. Discovery returns no skills, and_labelled_queriesaccepts the explicitly labelled negative query because its acceptable-skill list is empty. The new default guard then raisesIndexErroratsorted(names)[0], instead of reporting a controlled input error. Reject an empty catalog before selecting the guard skill, and add a regression test for this negative-only set.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in 1375814. An empty
--catalogis now refused with--catalog holds no skillsbefore the known-positive guard picks its skill, so a negative-only set no longer reachessorted(names)[0]. Runs with--no-known-positiveare unchanged.Regression tests:
test_cli_refuses_an_empty_catalog_before_the_guard_picks_a_skill(red before the fix, IndexError atcatalog_probe.py:123), andtest_no_known_positive_still_runs_a_negative_only_set_on_an_empty_catalog.