Setting the file. One moment.
Run Real Eval · Filing Breakdown Tasks · bitwarden/ai-plugins · Skills Docs
ContentsBack to the top of the page evals/run_real_eval.py
evals/ run_real_eval.py
Python · 198 lines · 8 KB
15 installed plugins.
16 """
17
18 import argparse
19 import json
20 import os
21 import select
22 import subprocess
23 import sys
24 import time
25 from concurrent.futures import ProcessPoolExecutor, as_completed
26 from pathlib import Path
27
28 TARGET_SKILL_TOKEN = "filing-breakdown-tasks"
29
30
31 def run_query (query: str , timeout: int , model: str , plugin_dirs = ()) -> dict :
32 cmd = [
33 "claude" ,
34 "-p" , query,
35 "--output-format" , "stream-json" ,
36 "--verbose" ,
37 "--include-partial-messages" ,
38 "--model" , model,
39 ]
40 for d in plugin_dirs:
41 cmd += [ "--plugin-dir" , d]
42 env = {k: v for k, v in os.environ.items() if k != "CLAUDECODE" }
43 process = subprocess.Popen(
44 cmd,
45 stdout = subprocess. PIPE ,
46 stderr = subprocess. DEVNULL ,
47 env = env,
48 )
49
50 triggered = False
51 first_skill_seen = None
52 start = time.time()
53 buffer = ""
54 pending = None
55 accum = ""
56
57 try :
58 while time.time() - start < timeout:
59 if process.poll() is not None :
60 rest = process.stdout.read()
61 if rest:
62 buffer += rest.decode( "utf-8" , errors = "replace" )
63 break
64 ready, _, _ = select.select([process.stdout], [], [], 1.0 )
65 if not ready:
66 continue
67 chunk = os.read(process.stdout.fileno(), 8192 )
68 if not chunk:
69 break
70 buffer += chunk.decode( "utf-8" , errors = "replace" )
71
72 while " \n " in buffer:
73 line, buffer = buffer.split( " \n " , 1 )
74 line = line.strip()
75 if not line:
76 continue
77 try :
78 event = json.loads(line)
79 except json.JSONDecodeError:
80 continue
81
82 if event.get( "type" ) == "stream_event" :
83 se = event.get( "event" , {})
84 if se.get( "type" ) == "content_block_start" :
85 cb = se.get( "content_block" , {})
86 if cb.get( "type" ) == "tool_use" and cb.get( "name" ) in ( "Skill" , "Read" ):
87 pending = cb.get( "name" )
88 accum = ""
89 # Other tool types are ignored — we only care whether the
90 # target skill is invoked at some point in the response.
91 elif se.get( "type" ) == "content_block_delta" and pending:
92 delta = se.get( "delta" , {})
93 if delta.get( "type" ) == "input_json_delta" :
94 accum += delta.get( "partial_json" , "" )
95 if TARGET_SKILL_TOKEN in accum:
96 return { "triggered" : True , "first_skill" : accum}
97 elif se.get( "type" ) == "content_block_stop" and pending:
98 if first_skill_seen is None :
99 first_skill_seen = accum
100 # Keep scanning past unrelated Skill/Read invocations so
101 # the eval is portable across accounts that auto-fire
102 # session-init or workflow skills before the task skill.
103 pending = None
104 accum = ""
105 elif event.get( "type" ) == "assistant" :
106 msg = event.get( "message" , {})
107 for item in msg.get( "content" , []):
108 if item.get( "type" ) != "tool_use" :
109 continue
110 name = item.get( "name" )
111 inp = item.get( "input" , {})
112 if name == "Skill" and TARGET_SKILL_TOKEN in inp.get( "skill" , "" ):
113 return { "triggered" : True , "first_skill" : inp.get( "skill" )}
114 if name == "Read" and TARGET_SKILL_TOKEN in inp.get( "file_path" , "" ):
115 return { "triggered" : True , "first_skill" : inp.get( "file_path" )}
116 elif event.get( "type" ) == "result" :
117 return { "triggered" : triggered, "first_skill" : first_skill_seen}
118 finally :
119 if process.poll() is None :
120 process.kill()
121 process.wait()
122 return { "triggered" : triggered, "first_skill" : first_skill_seen}
123
124
125 def runs_for (query, should_trigger, runs, timeout, model, plugin_dirs = ()):
126 triggers = 0
127 samples = []
128 for _ in range (runs):
129 r = run_query(query, timeout, model, plugin_dirs)
130 if r[ "triggered" ]:
131 triggers += 1
132 samples.append(r.get( "first_skill" ))
133 rate = triggers / runs
134 # Surface samples to stderr only when the per-query outcome disagrees with
135 # `should_trigger`, so debugging info is available without baking
136 # environment-specific tool inputs (absolute paths, etc.) into the
137 # persisted result that the README diffs for regression checks.
138 if (rate >= 0.5 ) != should_trigger:
139 for s in samples:
140 print ( f " sample: { s } " , file = sys.stderr)
141 return {
142 "query" : query,
143 "should_trigger" : should_trigger,
144 "triggers" : triggers,
145 "runs" : runs,
146 "trigger_rate" : rate,
147 }
148
149
150 def main ():
151 parser = argparse.ArgumentParser()
152 parser.add_argument( "--eval-set" , required = True )
153 parser.add_argument( "--runs-per-query" , type = int , default = 3 )
154 parser.add_argument( "--num-workers" , type = int , default = 8 )
155 parser.add_argument( "--timeout" , type = int , default = 60 )
156 parser.add_argument( "--model" , default = "claude-opus-5" )
157 # Repeatable: every plugin whose skills compete for these queries must be
158 # loaded, or a near-miss scores as a pass against a skill that wasn't there.
159 parser.add_argument( "--plugin-dir" , action = "append" , default = [], dest = "plugin_dirs" )
160 args = parser.parse_args()
161
162 eval_set = json.loads(Path(args.eval_set).read_text())
163 results = [ None ] * len (eval_set)
164 with ProcessPoolExecutor( max_workers = args.num_workers) as pool:
165 futures = {
166 pool.submit(runs_for, e[ "query" ], e[ "should_trigger" ], args.runs_per_query, args.timeout, args.model, tuple (args.plugin_dirs)): i
167 for i, e in enumerate (eval_set)
168 }
169 for fut in as_completed(futures):
170 i = futures[fut]
171 results[i] = fut.result()
172 r = results[i]
173 tag = "PASS" if (r[ "trigger_rate" ] >= 0.5 ) == r[ "should_trigger" ] else "FAIL"
174 print ( f " [ { tag } ] rate= { r[ 'triggers' ] } / { r[ 'runs' ] } expected= { r[ 'should_trigger' ] } : { r[ 'query' ][: 80 ] } " , file = sys.stderr)
175
176 triggers_pass = sum ( 1 for r in results if r[ "should_trigger" ] and r[ "trigger_rate" ] >= 0.5 )
177 triggers_total = sum ( 1 for r in results if r[ "should_trigger" ])
178 no_trigger_pass = sum ( 1 for r in results if not r[ "should_trigger" ] and r[ "trigger_rate" ] < 0.5 )
179 no_trigger_total = sum ( 1 for r in results if not r[ "should_trigger" ])
180
181 # Run-invariant only — a timestamp here would make every regression diff
182 # non-empty. A model or plugin-set change SHOULD fail the diff: the run it
183 # produced is not comparable to the baseline.
184 summary = {
185 "model" : args.model,
186 "plugin_dirs" : sorted ( str (Path(d).resolve().name) for d in args.plugin_dirs),
187 "runs_per_query" : args.runs_per_query,
188 "should_trigger_pass_rate" : triggers_pass / triggers_total if triggers_total else None ,
189 "should_not_trigger_pass_rate" : no_trigger_pass / no_trigger_total if no_trigger_total else None ,
190 "should_trigger_pass" : f " { triggers_pass } / { triggers_total } " ,
191 "should_not_trigger_pass" : f " { no_trigger_pass } / { no_trigger_total } " ,
192 "results" : results,
193 }
194 print (json.dumps(summary, indent = 2 , default = str ))
195
196
197 if __name__ == "__main__" :
198 main()