Setting the file. One moment.
Run Real Eval · Assessing Test Coverage · bitwarden/ai-plugins · Skills Docs
ContentsBack to the top of the page Running Work Transitions
evals/run_real_eval.py
evals/ run_real_eval.py
Python · 206 lines · 8 KB
15 import os
16 import select
17 import subprocess
18 import sys
19 import time
20 from concurrent.futures import ProcessPoolExecutor, as_completed
21 from pathlib import Path
22
23 TARGET_SKILL_TOKEN = "assessing-test-coverage"
24
25 # Requesting one of these means the model chose real work over the target skill;
26 # we bail on it (see run_query) to avoid the heavy child processes it would spawn.
27 EXEC_TOOLS = { "Bash" , "Task" }
28
29 # Read-only Bash lookups scanned past instead of counted as real work.
30 READ_ONLY_BASH = ( "gh pr view" , "gh pr list" , "gh search" , "gh api" , "git rev-parse" , "git remote" )
31
32 # A read-only prefix only earns the carve-out if it's a single command; any
33 # shell operator could chain heavy work onto it (`gh api ... && npm test`).
34 SHELL_CHAINS = ( ";" , "|" , "&" , "`" , "$(" , " \n " )
35
36
37 def run_query (query: str , timeout: int , model: str ) -> dict :
38 cmd = [
39 "claude" ,
40 "-p" , query,
41 "--output-format" , "stream-json" ,
42 "--verbose" ,
43 "--model" , model,
44 ]
45 env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE" }
46 process = subprocess.Popen(
47 cmd,
48 stdout = subprocess. PIPE ,
49 stderr = subprocess. DEVNULL ,
50 env = env,
51 )
52
53 first_skill_seen = None
54 start = time.time()
55 buffer = ""
56 # Assume a timeout until we see a decisive event or a clean EOF; the caller
57 # uses this to distinguish a slow run from a genuine non-trigger.
58 timed_out = True
59
60 def scan ():
61 # Parse complete lines out of `buffer`, returning a terminal result dict
62 # once the target skill triggers or a real-work tool is reached, else
63 # None. Mutates `buffer`, leaving any trailing partial line in place.
64 nonlocal buffer, first_skill_seen
65 while " \n " in buffer:
66 line, buffer = buffer.split( " \n " , 1 )
67 line = line.strip()
68 if not line:
69 continue
70 try :
71 event = json.loads(line)
72 except json.JSONDecodeError:
73 continue
74
75 if event.get( "type" ) == "assistant" :
76 msg = event.get( "message" , {})
77 for item in msg.get( "content" , []):
78 if item.get( "type" ) != "tool_use" :
79 continue
80 name = item.get( "name" )
81 inp = item.get( "input" , {})
82 if name == "Skill" and TARGET_SKILL_TOKEN in inp.get( "skill" , "" ):
83 return { "triggered" : True , "first_skill" : inp.get( "skill" )}
84 fp = inp.get( "file_path" , "" )
85 # Count a Read only when it opens the skill's own SKILL.md,
86 # not any file that merely has the token in its path.
87 if name == "Read" and TARGET_SKILL_TOKEN in fp and fp.rstrip().endswith( "SKILL.md" ):
88 return { "triggered" : True , "first_skill" : fp}
89 # A real-work tool without the target skill first → no
90 # trigger. Bail so the finally block kills the child before
91 # its tool_use spawns anything. (Cheap read-only tools are
92 # scanned past; the model may inspect files first.)
93 if name == "Bash" :
94 cmd = inp.get( "command" , "" ).strip()
95 if cmd.startswith( READ_ONLY_BASH ) and not any (op in cmd for op in SHELL_CHAINS ):
96 continue
97 if name in EXEC_TOOLS :
98 if first_skill_seen is None :
99 first_skill_seen = f " { name } (bailed: real-work tool)"
100 return { "triggered" : False , "first_skill" : first_skill_seen}
101 elif event.get( "type" ) == "result" :
102 return { "triggered" : False , "first_skill" : first_skill_seen}
103 return None
104
105 try :
106 while time.time() - start < timeout:
107 if process.poll() is not None :
108 rest = process.stdout.read()
109 if rest:
110 buffer += rest.decode( "utf-8" , errors = "replace" )
111 # Child exited — parse the final buffer before giving up so a
112 # trigger event in the last chunk isn't dropped as a non-trigger.
113 result = scan()
114 if result is not None :
115 return result
116 timed_out = False
117 break
118 ready, _, _ = select.select([process.stdout], [], [], 1.0 )
119 if not ready:
120 continue
121 chunk = os.read(process.stdout.fileno(), 8192 )
122 if not chunk:
123 timed_out = False
124 break
125 buffer += chunk.decode( "utf-8" , errors = "replace" )
126
127 result = scan()
128 if result is not None :
129 return result
130 finally :
131 if process.poll() is None :
132 process.kill()
133 process.wait()
134 return { "triggered" : False , "first_skill" : first_skill_seen, "timed_out" : timed_out}
135
136
137 def runs_for (query, should_trigger, runs, timeout, model):
138 triggers = 0
139 timeouts = 0
140 samples = []
141 for _ in range (runs):
142 r = run_query(query, timeout, model)
143 if r[ "triggered" ]:
144 triggers += 1
145 if r.get( "timed_out" ):
146 timeouts += 1
147 samples.append(r.get( "first_skill" ))
148 rate = triggers / runs
149 # Print samples to stderr only on unexpected outcomes — keeps env-specific
150 # paths out of the persisted result used for regression diffs.
151 if (rate >= 0.5 ) != should_trigger:
152 for s in samples:
153 print ( f " sample: { s } " , file = sys.stderr)
154 # A timeout is counted as a non-trigger, so warn (stderr only, not persisted)
155 # to keep a slow should-trigger run from silently reading as a real failure.
156 if timeouts:
157 print ( f " warning: { timeouts } / { runs } run(s) timed out (counted as non-trigger): { query[: 80 ] } " , file = sys.stderr)
158 return {
159 "query" : query,
160 "should_trigger" : should_trigger,
161 "triggers" : triggers,
162 "runs" : runs,
163 "trigger_rate" : rate,
164 }
165
166
167 def main ():
168 parser = argparse.ArgumentParser()
169 parser.add_argument( "--eval-set" , required = True )
170 parser.add_argument( "--runs-per-query" , type = int , default = 3 )
171 parser.add_argument( "--num-workers" , type = int , default = 5 )
172 parser.add_argument( "--timeout" , type = int , default = 45 )
173 parser.add_argument( "--model" , default = "claude-opus-4-8" )
174 args = parser.parse_args()
175
176 eval_set = json.loads(Path(args.eval_set).read_text())
177 results = [ None ] * len (eval_set)
178 with ProcessPoolExecutor( max_workers = args.num_workers) as pool:
179 futures = {
180 pool.submit(runs_for, e[ "query" ], e[ "should_trigger" ], args.runs_per_query, args.timeout, args.model): i
181 for i, e in enumerate (eval_set)
182 }
183 for fut in as_completed(futures):
184 i = futures[fut]
185 results[i] = fut.result()
186 r = results[i]
187 tag = "PASS" if (r[ "trigger_rate" ] >= 0.5 ) == r[ "should_trigger" ] else "FAIL"
188 print ( f " [ { tag } ] rate= { r[ 'triggers' ] } / { r[ 'runs' ] } expected= { r[ 'should_trigger' ] } : { r[ 'query' ][: 80 ] } " , file = sys.stderr)
189
190 triggers_pass = sum ( 1 for r in results if r[ "should_trigger" ] and r[ "trigger_rate" ] >= 0.5 )
191 triggers_total = sum ( 1 for r in results if r[ "should_trigger" ])
192 no_trigger_pass = sum ( 1 for r in results if not r[ "should_trigger" ] and r[ "trigger_rate" ] < 0.5 )
193 no_trigger_total = sum ( 1 for r in results if not r[ "should_trigger" ])
194
195 summary = {
196 "should_trigger_pass_rate" : triggers_pass / triggers_total if triggers_total else None ,
197 "should_not_trigger_pass_rate" : no_trigger_pass / no_trigger_total if no_trigger_total else None ,
198 "should_trigger_pass" : f " { triggers_pass } / { triggers_total } " ,
199 "should_not_trigger_pass" : f " { no_trigger_pass } / { no_trigger_total } " ,
200 "results" : results,
201 }
202 print (json.dumps(summary, indent = 2 , default = str ))
203
204
205 if __name__ == "__main__" :
206 main()