Coverage for little_loops / cli / harness.py: 19%
160 statements
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
« prev ^ index » next coverage.py v7.12.0, created at 2026-06-04 12:21 -0500
1"""ll-harness: One-shot runner evaluation CLI (FEAT-1851)."""
3from __future__ import annotations
5import argparse
6import json
7import subprocess
8import sys
9import threading
10from dataclasses import dataclass
11from typing import Any
13from little_loops.cli.output import configure_output, print_json, status_block, use_color_enabled
14from little_loops.fsm.evaluators import evaluate_llm_structured
15from little_loops.host_runner import resolve_host
16from little_loops.logger import Logger
17from little_loops.mcp_call import call_mcp_tool
18from little_loops.session_store import DEFAULT_DB_PATH, cli_event_context
21@dataclass
22class RunnerResult:
23 """Captured output from a runner invocation."""
25 stdout: str
26 stderr: str
27 exit_code: int
28 timed_out: bool = False
29 error: str | None = None
32def _build_harness_parser() -> argparse.ArgumentParser:
33 """Build the ll-harness argument parser (exposed for testing)."""
34 parser = argparse.ArgumentParser(
35 prog="ll-harness",
36 description="One-shot runner evaluation for little-loops skills and commands",
37 formatter_class=argparse.RawDescriptionHelpFormatter,
38 epilog="""
39Examples:
40 ll-harness skill check-code
41 ll-harness cmd "echo hello" --exit-code 0
42 ll-harness mcp my-server:my-tool --args '{"key": "val"}' --semantic "tool returned results"
43 ll-harness prompt "What is 2+2?" --semantic "response contains a number"
45Exit codes:
46 0 PASS
47 1 FAIL
48 2 Internal error / timeout
49""",
50 )
52 subparsers = parser.add_subparsers(dest="runner", metavar="RUNNER")
53 subparsers.required = True
55 def _add_evaluator_flags(p: argparse.ArgumentParser) -> None:
56 p.add_argument(
57 "--exit-code",
58 dest="exit_code",
59 type=int,
60 default=None,
61 metavar="INT",
62 help="Expected exit code (default: not checked)",
63 )
64 p.add_argument(
65 "--semantic",
66 type=str,
67 default=None,
68 metavar="TEXT",
69 help="Natural-language criterion for output evaluation",
70 )
71 p.add_argument(
72 "--timeout",
73 type=int,
74 default=120,
75 metavar="SECONDS",
76 help="Runner timeout in seconds (default: 120)",
77 )
78 p.add_argument(
79 "--output",
80 choices=["text", "json"],
81 default="text",
82 help="Output format (default: text)",
83 )
84 p.add_argument(
85 "--verbose",
86 action="store_true",
87 help="Show full captured output even on pass",
88 )
90 skill_p = subparsers.add_parser(
91 "skill",
92 help="Invoke a little-loops skill",
93 description="Invoke a little-loops skill via the active host CLI",
94 )
95 skill_p.add_argument("target", help="Skill name (e.g. check-code, refine-issue)")
96 skill_p.add_argument(
97 "runner_args",
98 nargs="*",
99 help="Additional arguments passed to the skill",
100 )
101 _add_evaluator_flags(skill_p)
103 cmd_p = subparsers.add_parser(
104 "cmd",
105 help="Run a shell command",
106 description="Run a shell command and capture its output",
107 )
108 cmd_p.add_argument("target", help="Shell command to execute")
109 _add_evaluator_flags(cmd_p)
111 mcp_p = subparsers.add_parser(
112 "mcp",
113 help="Call an MCP tool",
114 description="Call an MCP tool via JSON-RPC",
115 )
116 mcp_p.add_argument("target", help="MCP server and tool (format: server:tool)")
117 mcp_p.add_argument(
118 "--args",
119 dest="mcp_args",
120 type=str,
121 default="{}",
122 metavar="JSON",
123 help="JSON arguments to pass to the MCP tool (default: {})",
124 )
125 _add_evaluator_flags(mcp_p)
127 prompt_p = subparsers.add_parser(
128 "prompt",
129 help="Send a raw prompt to Claude",
130 description="Send a raw prompt to Claude via the active host CLI",
131 )
132 prompt_p.add_argument("target", help="Prompt text to send")
133 _add_evaluator_flags(prompt_p)
135 return parser
138def _parse_harness_args(argv: list[str] | None) -> argparse.Namespace:
139 """Parse argv into a Namespace (exposed for testing)."""
140 return _build_harness_parser().parse_args(argv)
143def _evaluate_and_report(
144 runner_label: str,
145 result: RunnerResult,
146 args: argparse.Namespace,
147) -> int:
148 """Evaluate result against criteria and print the report. Returns exit code."""
149 if result.timed_out:
150 _report(runner_label, result, args, error_msg="timeout")
151 return 2
152 if result.error is not None:
153 _report(runner_label, result, args, error_msg=result.error)
154 return 2
156 passed = True
157 exit_code_display = str(result.exit_code)
158 semantic_display = "[not checked]"
160 if args.exit_code is not None:
161 if result.exit_code != args.exit_code:
162 passed = False
163 exit_code_display = f"{result.exit_code} (expected {args.exit_code})"
165 if args.semantic is not None:
166 eval_result = evaluate_llm_structured(output=result.stdout, prompt=args.semantic)
167 semantic_display = eval_result.verdict
168 if eval_result.verdict != "yes":
169 passed = False
171 overall = "PASS" if passed else "FAIL"
172 show_output = not passed or args.verbose
174 if args.output == "json":
175 print_json(
176 {
177 "runner": runner_label,
178 "exit_code": result.exit_code,
179 "exit_code_check": exit_code_display,
180 "semantic": semantic_display,
181 "result": overall,
182 "stdout": result.stdout,
183 "stderr": result.stderr,
184 }
185 )
186 else:
187 print(
188 status_block(
189 {
190 "Runner": runner_label,
191 "Exit": exit_code_display,
192 "Semantic": semantic_display,
193 "Result": overall,
194 }
195 )
196 )
197 if show_output and result.stdout:
198 print("---")
199 sys.stdout.write(result.stdout)
200 if not result.stdout.endswith("\n"):
201 print()
203 return 0 if passed else 1
206def _report(
207 runner_label: str,
208 result: RunnerResult,
209 args: argparse.Namespace,
210 error_msg: str,
211) -> None:
212 """Print an error/timeout report."""
213 if args.output == "json":
214 print_json(
215 {
216 "runner": runner_label,
217 "result": "ERROR",
218 "error": error_msg,
219 "exit_code": result.exit_code,
220 "stdout": result.stdout,
221 "stderr": result.stderr,
222 }
223 )
224 else:
225 print(status_block({"Runner": runner_label, "Result": f"ERROR ({error_msg})"}))
228def cmd_skill(args: argparse.Namespace) -> int:
229 """Invoke a little-loops skill via the active host CLI."""
230 runner_args: list[str] = getattr(args, "runner_args", None) or []
231 parts = [f"/ll:{args.target}"] + runner_args
232 prompt = " ".join(parts)
234 inv = resolve_host().build_streaming(prompt=prompt)
235 runner_label = f"skill {args.target}"
237 try:
238 proc = subprocess.run(
239 [inv.binary, *inv.args],
240 capture_output=True,
241 text=True,
242 timeout=args.timeout,
243 )
244 result = RunnerResult(stdout=proc.stdout, stderr=proc.stderr, exit_code=proc.returncode)
245 except subprocess.TimeoutExpired:
246 result = RunnerResult(stdout="", stderr="", exit_code=2, timed_out=True)
247 except FileNotFoundError as e:
248 result = RunnerResult(stdout="", stderr="", exit_code=2, error=str(e))
250 return _evaluate_and_report(runner_label, result, args)
253def cmd_cmd(args: argparse.Namespace) -> int:
254 """Run a shell command with deadlock-safe stderr draining."""
255 runner_label = f"cmd {args.target}"
257 process = subprocess.Popen(
258 ["bash", "-c", args.target],
259 stdout=subprocess.PIPE,
260 stderr=subprocess.PIPE,
261 text=True,
262 )
264 stdout_chunks: list[str] = []
265 stderr_chunks: list[str] = []
267 def _drain_stderr() -> None:
268 assert process.stderr is not None
269 for line in process.stderr:
270 stderr_chunks.append(line)
272 stderr_thread = threading.Thread(target=_drain_stderr, daemon=True)
273 stderr_thread.start()
275 try:
276 assert process.stdout is not None
277 for line in process.stdout:
278 stdout_chunks.append(line)
279 process.wait(timeout=args.timeout)
280 except subprocess.TimeoutExpired:
281 process.kill()
282 process.wait()
283 stderr_thread.join(timeout=5)
284 return _evaluate_and_report(
285 runner_label,
286 RunnerResult(
287 stdout="".join(stdout_chunks),
288 stderr="".join(stderr_chunks),
289 exit_code=2,
290 timed_out=True,
291 ),
292 args,
293 )
295 stderr_thread.join(timeout=5)
296 return _evaluate_and_report(
297 runner_label,
298 RunnerResult(
299 stdout="".join(stdout_chunks),
300 stderr="".join(stderr_chunks),
301 exit_code=process.returncode,
302 ),
303 args,
304 )
307def cmd_mcp(args: argparse.Namespace) -> int:
308 """Call an MCP tool and evaluate the result."""
309 if ":" not in args.target:
310 print(
311 f"Error: MCP target must be 'server:tool', got: {args.target!r}",
312 file=sys.stderr,
313 )
314 return 2
316 server, tool = args.target.split(":", 1)
317 runner_label = f"mcp {args.target}"
319 try:
320 params: dict[str, Any] = json.loads(args.mcp_args)
321 except json.JSONDecodeError as e:
322 print(f"Error: --args is not valid JSON: {e}", file=sys.stderr)
323 return 2
325 response, exit_code = call_mcp_tool(server, tool, params, timeout=args.timeout)
326 result = RunnerResult(stdout=json.dumps(response), stderr="", exit_code=exit_code)
327 return _evaluate_and_report(runner_label, result, args)
330def cmd_prompt(args: argparse.Namespace) -> int:
331 """Send a raw prompt to Claude and evaluate the response."""
332 label_text = args.target[:40] + ("..." if len(args.target) > 40 else "")
333 runner_label = f"prompt {label_text}"
334 inv = resolve_host().build_blocking_json(prompt=args.target)
336 try:
337 proc = subprocess.run(
338 [inv.binary, *inv.args],
339 capture_output=True,
340 text=True,
341 timeout=args.timeout,
342 )
343 result = RunnerResult(stdout=proc.stdout, stderr=proc.stderr, exit_code=proc.returncode)
344 except subprocess.TimeoutExpired:
345 result = RunnerResult(stdout="", stderr="", exit_code=2, timed_out=True)
346 except FileNotFoundError as e:
347 result = RunnerResult(stdout="", stderr="", exit_code=2, error=str(e))
349 return _evaluate_and_report(runner_label, result, args)
352def main_harness(argv: list[str] | None = None) -> int:
353 """Entry point for ll-harness CLI."""
354 with cli_event_context(DEFAULT_DB_PATH, "ll-harness", sys.argv[1:]):
355 args = _parse_harness_args(argv)
356 configure_output()
357 Logger(use_color=use_color_enabled())
359 if args.runner == "skill":
360 return cmd_skill(args)
361 elif args.runner == "cmd":
362 return cmd_cmd(args)
363 elif args.runner == "mcp":
364 return cmd_mcp(args)
365 elif args.runner == "prompt":
366 return cmd_prompt(args)
367 else:
368 print(f"Unknown runner: {args.runner}", file=sys.stderr)
369 return 2