Skip to content

fix(skill-creator): isolate trigger evals and handle Windows and runtime failures - #1298

Open
MartinCajiao wants to merge 17 commits into
anthropics:mainfrom
MartinCajiao:fix/run-eval-skill-triggering
Open

MartinCajiao wants to merge 17 commits into
anthropics:mainfrom
MartinCajiao:fix/run-eval-skill-triggering

Conversation

@MartinCajiao

@MartinCajiao MartinCajiao commented Jun 10, 2026 •

Copy link
Copy Markdown

Problem

Trigger evaluation can report false misses or invalid scores: per-worker command probes compete, select() on subprocess pipes fails on Windows, and unrelated tools stop the scan. Runtime failures also become non-triggers, incorrectly passing negative examples and misleading optimization.

Fixes #556.

What changes

  • Install one real candidate skill at <temporary project>/.claude/skills/<name>/SKILL.md, preserving its production name and candidate description. Parallel workers share it; concurrent runs use distinct UUID-based roots.
  • Leave caller files untouched. The legacy project_root argument remains accepted but is no longer used.
  • Reject same-name personal skill collisions, including those under CLAUDE_CONFIG_DIR.
  • Resolve the Claude executable, including Windows npm shims. Read stdout/stderr with portable threads instead of select(). Close stdin and cap probes at eight turns.
  • Keep scanning after Bash/Glob and other orientation tools. Match exact Skill names or skill-entrypoint Read paths, including Windows separators.
  • Stop the probe process tree during cleanup: taskkill /T /F on Windows; a separate session/process group on POSIX.
  • Treat error results, timeouts, truncated output and worker failures as invalid evaluations. Abort the batch instead of assigning zero trigger rates. Cancel pending work where possible; active workers finish their bounded cleanup.
  • Preserve successful evaluation JSON. Initial optimization failures propagate; later failures preserve completed iterations. Empty and partial reports render correctly.
  • Write candidate artifacts as UTF-8.

Validation

Latest revision: Windows, Python 3.14.

# From skills/skill-creator
python -m unittest discover -s scripts/tests -v
uvx ruff check scripts/run_eval.py scripts/tests/test_eval_failures.py scripts/tests/test_stream_probe.py
git diff --check
  • 21/21 tests pass. Coverage includes artifact isolation, exact matching, late triggers, configuration collisions, invalid batch rejection, cleanup and partial-result recovery.
  • A real Windows .cmd wrapper launches a Python child; the regression verifies that the child cannot continue after timeout. This test requires process-termination permissions and was run outside the restricted sandbox.
  • Ruff passes for the files changed in the latest revision. Whitespace checks pass.
  • Tests make no model calls and need no credentials. Instructions and limitations are in scripts/tests/README.md.

Scope and limitations

The temporary project excludes caller-project files and configuration. Queries requiring local fixtures need those fixtures made available separately. Personal and managed Claude configuration is not fully isolated.

The original investigation included live routing experiments on Claude Code 2.1.149. Those historical observations are not a claim about every current version. This revision has not rerun live model evaluation or POSIX integration. The candidate uses the standard skill layout independently of legacy command behavior.

Related work: #794, #996, #1027, #1755 and #1757. This PR combines a real-skill artifact with portable stream handling, full-turn detection and explicit failure semantics.

run_eval.py reported 0% recall for every skill description (anthropics#556).
Four independent causes, each sufficient on its own:

- The candidate description was installed as a .claude/commands/ file,
  which claude -p lists under slash_commands but never under the
  model-facing skills list, so it could never auto-trigger. Install it
  as a real skill under .claude/skills/<name>-eval-<id>/ instead.
- Stream reading used select.select() on pipes, which only works on
  sockets on Windows: every query raised WinError 10038, was swallowed
  by the generic exception handler, and silently counted as
  not-triggered. Replace with portable reader threads.
- Detection returned False at the first non-Skill tool_use, counting
  runs where Claude explores (TodoWrite/Glob/Bash) before consulting
  the skill as misses. Only a terminal result event, process exit, or
  timeout decides not-triggered now.
- Each parallel worker created its own UUID-named copy of the
  artifact, so Claude picked one arbitrarily and sibling workers
  missed their triggers. One shared artifact per run, created before
  the pool starts and removed after it drains.

Also: resolve the claude executable with shutil.which() (npm installs
ship a claude.cmd shim that bare Popen cannot find on Windows), capture
stderr and surface it on failures instead of discarding it, warn when
an installed skill with the same name could shadow the candidate, sweep
stale eval artifacts left by crashed runs, and cap conversation length
with --max-turns.

Verified on Windows 11 with Claude Code 2.1.149: a 4-query eval set
went from 0% recall (per-query WinError warnings) to 2/2
should-trigger and 0/2 should-not-trigger. run_eval() signature, CLI
flags, and output JSON are unchanged; run_loop.py works unmodified.

Fixes anthropics#556
- Only sweep stale eval artifacts older than 1h so a sweep never
  deletes the live artifact of a concurrent eval of the same skill.
- Bound the post-EOF process.wait() so a claude process that lingers
  after closing stdout cannot hang a worker past its timeout.
@xg-gh-25

Copy link
Copy Markdown

Brutal diagnosis — four independent causes each sufficient for 0% recall, which explains why partial fixes didn't move the number. The "optimizing against noise" framing is exactly right: a metric that always reads zero might as well not exist.

The cross-platform testing rigor (Windows stream handling, executable resolution) and the systematic verification strategy (stub harness + offline tests before real integration) are both production patterns we recognize. A few observations:

On the artifact installation strategy: The decision to install as .claude/skills/<name>-eval-<id>/SKILL.md instead of swapping the real skill in-place is the safer choice. The tradeoff you note (suffix on the skill name) is acceptable because routing happens on description content, not the name. One risk to flag: if the model's routing logic includes edit distance on skill names as a tie-breaker (some frameworks do this), the suffix could deflate trigger rates slightly. Have you verified that Claude Code's skill picker ignores name similarity entirely?

On cause 3 (exploration counted as miss): The fix — "non-Skill tools are ignored, only terminal events decide not-triggered" — is correct for auto-triggering, but it changes what you're measuring. Before: "did the skill trigger first?" After: "did the skill trigger eventually?" The latter is the right metric for description optimization (you want the model to reach the skill at all), but if you're also evaluating routing efficiency (how many irrelevant tools were used before the right one), you'd want to track that separately. Consider logging steps_to_trigger as optional metadata.

On cause 4 (parallel worker race): The shared artifact solution is clean, but there's a hidden complexity: if multiple runs execute concurrently (e.g., CI pipeline with parallel jobs), they'll still collide on the artifact name. The -eval-<id> suffix mitigates this if <id> is globally unique (UUID), but if it's process-scoped (PID), you can get cross-process races. Suggest documenting the <id> uniqueness guarantee — or add a lockfile if not already present.

On the verification strategy: The offline stub harness is excellent (we use this pattern too — "canned stream-json" for zero-cost iteration). One gap: your offline tests cover Windows stream handling and parallel workers, but they don't cover the actual trigger detection logic against malformed/truncated tool_use events. Consider adding a fuzzer that injects partial JSON chunks mid-event to verify your detection doesn't false-positive.

Minor: The PR description says "Tradeoff kept deliberately: the eval skill's name carries an -eval- suffix while production skills don't." But you also say "the suffix avoids collisions with installed skills." These are compatible, but clarify: does the suffix prevent shadowing (good), or does it introduce name-mismatch risk (potential concern)? The warning about shadowing suggests you're aware of both sides.

Last: The "sweep stale <name>-eval-* artifacts" logic is defensive (we do this too). What's the age threshold? 2 hours? 24 hours? If it's too aggressive, you'll delete artifacts from paused debugging sessions. If too permissive, you'll accumulate cruft. Consider making it configurable or at least documenting the default.


Eval loop signal integrity recognized via SwarmAI. Discussion: T-MEM

@MartinCajiao

MartinCajiao commented Jun 10, 2026 via email

Copy link
Copy Markdown
Author

Addresses review feedback on the eval artifact strategy:

- The candidate skill now keeps its real name. Isolation moved from a
  name suffix to the project directory: each run creates a throwaway
  project under <tmp>/claude-skill-evals/<name>-<uuid4> and claude -p
  executes with it as cwd, so the name-similarity question around the
  -eval-<id> suffix is moot and the caller project is never touched.
- Cross-run collisions are impossible by construction: the project
  directory is keyed by a full UUID4 and scoped to a dedicated per-run
  directory (the previous id was already UUID-derived, not
  process-scoped; now the property is structural).
- The stale sweep threshold (previously hardcoded to 1 hour) is now
  --stale-artifact-hours, defaulting to 12 hours: long enough to
  survive paused debugging sessions, short enough not to accumulate
  cruft. The sweep only ever runs inside the dedicated temp root.
- The shadowing warning now covers user-level skills only; everything
  project-level is isolated by construction.

run_eval() stays backward compatible (project_root retained but
deprecated); run_loop.py works unmodified. Verified on Windows 11 +
Claude Code 2.1.149: offline stub suite 16/16 and 4/4 eval queries,
real end-to-end 2/2 should-trigger and 0/2 should-not with the clean
skill name, zero leftover directories.
@MartinCajiao

MartinCajiao commented Jun 11, 2026 •

Copy link
Copy Markdown
Author

Pushed 79b3df9 delivering the refinements discussed above:

1. Sandboxed eval environment (name-mismatch + shadowing): went one step further than promised. Each run now creates an isolated throwaway project under the OS temp dir (<tmp>/claude-skill-evals/<skill>-<uuid4>/.claude/skills/<real-name>/SKILL.md) and claude -p executes with that project as its working directory. The skill keeps its real name -- isolation comes from the project directory, not from a name suffix -- so the edit-distance question is moot by construction, and the caller's project is never touched by eval artifacts at all. On your original question: empirically the suffixed artifact was already triggering 2/2 on matching queries in the Windows e2e (routing follows the description), but removing the suffix removes the question entirely.

2. Configurable sweep threshold: now --stale-artifact-hours, defaulting to 12h as proposed. One correction to my earlier reply: the previous hardcoded window was 1 hour, not 2. The sweep also only ever operates inside the dedicated temp root now, so it cannot touch anything outside it.

3. Cross-run collisions: to be precise, the previous id was already UUID-derived (a uuid4 hex prefix), not process-scoped -- but the new layout makes the guarantee structural rather than probabilistic: a full 32-hex UUID4 keys a dedicated per-run project directory, so concurrent runs (parallel CI jobs included) cannot collide regardless of id entropy. No lockfile needed.

On steps_to_trigger: agreed -- that is the right metric for routing efficiency as opposed to routing reach. Kept out of this PR to keep the output schema stable for run_loop.py/improve_description.py, but it is a clean additive follow-up.

On fuzzing the detection against malformed/truncated tool_use events: the streaming detector accumulates input_json_delta fragments into a buffer and substring-matches on the accumulated state, so split/partial chunks are handled by construction, and malformed JSON lines are skipped. I would like to land that as in-repo tests, but the scripts currently have no test scaffolding -- verification lives in an external stub harness (canned stream-json, zero API cost) for now. Happy to contribute test scaffolding as a separate PR if maintainers want it.

Re-verified end to end on Windows 11 + Claude Code 2.1.149 after the change: 2/2 should-trigger, 0/2 should-not-trigger, zero leftover directories in temp.

This was referenced Jun 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

run_eval.py: claude -p never triggers skills/commands (0% trigger rate across all queries)

6 participants