hsom 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
hsom/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """hsom — Jev, Do. Connects Jev to your shell via a strict allowlist.
2
+
3
+ Python library entry point::
4
+
5
+ from hsom import Harness
6
+
7
+ h = Harness(env="environment.toml")
8
+ result = h.run("brief git history", dry_run=True)
9
+
10
+ The ``hsom`` CLI (``hsom.cli:main``) uses the same machinery.
11
+ """
12
+
13
+ from hsom.api import Harness, OnConfirm, RunResult
14
+ from hsom.config import (
15
+ Command,
16
+ ConfigError,
17
+ EnvConfig,
18
+ Flag,
19
+ PathSlot,
20
+ Subcommand,
21
+ load_config,
22
+ )
23
+ from hsom.dispatcher import LayerDetail, PlannedAction, PlanOutcome, StepOutcome
24
+ from hsom.eval import EvalCase, EvalError, EvalSummary, EvalTestResult, load_eval
25
+ from hsom.executor import (
26
+ CommandNotFound,
27
+ ExecutionError,
28
+ ExecutionResult,
29
+ ExecutionTimeout,
30
+ SpawnError,
31
+ )
32
+
33
+ __all__ = [
34
+ "Harness",
35
+ "OnConfirm",
36
+ "RunResult",
37
+ "PlannedAction",
38
+ "PlanOutcome",
39
+ "StepOutcome",
40
+ "LayerDetail",
41
+ "ExecutionResult",
42
+ "ExecutionError",
43
+ "ExecutionTimeout",
44
+ "CommandNotFound",
45
+ "SpawnError",
46
+ "EnvConfig",
47
+ "Command",
48
+ "Subcommand",
49
+ "Flag",
50
+ "PathSlot",
51
+ "ConfigError",
52
+ "EvalCase",
53
+ "EvalError",
54
+ "EvalSummary",
55
+ "EvalTestResult",
56
+ "load_config",
57
+ "load_eval",
58
+ ]
59
+
60
+ __version__ = "0.1.0"
hsom/api.py ADDED
@@ -0,0 +1,557 @@
1
+ """High-level Python library API for hsom.
2
+
3
+ The :class:`Harness` class wraps config loading, Jev dispatch, argv
4
+ resolution, execution (with confirmation hook), multi-step chaining and
5
+ eval — the same machinery the ``hsom`` CLI uses — in an importable,
6
+ programmatic form.
7
+
8
+ Example:
9
+ from hsom import Harness
10
+
11
+ h = Harness(env="environment.toml")
12
+ outcome = h.plan("brief git history")
13
+ if outcome.ok:
14
+ print(h.resolve(outcome.action))
15
+
16
+ result = h.run("run tests", max_steps=2, dry_run=True)
17
+ print(result.stop_reason, [s.argv for s in result.steps])
18
+
19
+ # Write-risk commands never prompt on stdin here. Pass ``on_confirm``
20
+ # to approve them programmatically; the default is fail-closed
21
+ # (auto-decline), mirroring the CLI's non-tty behaviour.
22
+ result = h.run("stage readme", on_confirm=lambda action, argv: True)
23
+
24
+ ``get_cwd()`` is a method so users can override the working directory
25
+ dynamically — subclass ``Harness`` or assign ``h.get_cwd = lambda: ...``,
26
+ or pass ``get_cwd=...`` at construction. Per-call ``cwd=...`` always wins
27
+ over it.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import dataclasses
33
+ import os
34
+ from dataclasses import dataclass, field
35
+ from typing import Callable
36
+
37
+ from hsom.config import ConfigError, EnvConfig, PROVIDERS, load_config
38
+
39
+ #: Confirmation hook: receives the planned action + resolved argv,
40
+ #: returns True to approve execution, False to decline.
41
+ OnConfirm = Callable[..., bool]
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class RunResult:
46
+ """Outcome of :meth:`Harness.run`."""
47
+
48
+ steps: tuple = field(default_factory=tuple)
49
+ stop_reason: str = ""
50
+ ok: bool = False
51
+ declined: bool = False
52
+ reason: str = ""
53
+ confidence: float = 0.0
54
+
55
+ @property
56
+ def argv_list(self) -> list[list[str]]:
57
+ return [list(s.argv) for s in self.steps]
58
+
59
+ @property
60
+ def returncode(self) -> int:
61
+ if not self.steps:
62
+ return 0
63
+ return self.steps[-1].returncode
64
+
65
+
66
+ def apply_provider_overrides(config: EnvConfig, provider=None,
67
+ base_url=None) -> EnvConfig:
68
+ """Fold provider/base_url overrides into a config. Returns new config."""
69
+ if provider is None and base_url is None:
70
+ return config
71
+ if provider is not None and provider not in PROVIDERS:
72
+ raise ConfigError(f"--provider must be one of {PROVIDERS}")
73
+ if base_url is not None:
74
+ base_url = base_url.strip().rstrip("/")
75
+ if not base_url or not (base_url.startswith("http://")
76
+ or base_url.startswith("https://")):
77
+ raise ConfigError("--base-url must start with http:// or https://")
78
+ return dataclasses.replace(
79
+ config,
80
+ provider=provider if provider is not None else config.provider,
81
+ base_url=base_url if base_url is not None else config.base_url,
82
+ )
83
+
84
+
85
+ def _coerce_config(env) -> EnvConfig:
86
+ """Accept a path, dict, EnvConfig (or None -> default path)."""
87
+ if isinstance(env, EnvConfig):
88
+ return env
89
+ if isinstance(env, dict):
90
+ return EnvConfig.from_dict(env)
91
+ if env is None:
92
+ return load_config("environment.toml")
93
+ # str | PathLike
94
+ return load_config(str(env))
95
+
96
+
97
+ class Harness:
98
+ """Stateful library entry point holding config + run defaults.
99
+
100
+ Parameters mirror the CLI flags plus library hooks:
101
+
102
+ - ``env``: ``environment.toml`` path, :class:`EnvConfig`, TOML-shaped
103
+ ``dict``, or ``None`` (loads ``"environment.toml"``).
104
+ - ``cwd``: default working directory for discovery + execution.
105
+ - ``get_cwd``: optional ``Callable[[], str]`` consulted by
106
+ :meth:`get_cwd`. Assign ``h.get_cwd = ...`` later or subclass to
107
+ change it dynamically.
108
+ - ``min_confidence``, ``max_steps``, ``max_history``,
109
+ ``continue_threshold``, ``temperature``, ``provider``, ``base_url``:
110
+ instance defaults; every per-call argument wins over these.
111
+ - ``api_key``: explicit key forwarded to the client factory.
112
+ - ``client``: persistent Jev client (e.g. a fake in tests). When
113
+ ``None`` a client is created per call and closed afterwards.
114
+ """
115
+
116
+ def __init__(
117
+ self,
118
+ env: str | os.PathLike | EnvConfig | dict | None = "environment.toml",
119
+ *,
120
+ cwd: str | os.PathLike = ".",
121
+ min_confidence: float | None = None,
122
+ max_steps: int | None = None,
123
+ max_history: int | None = None,
124
+ continue_threshold: float | None = None,
125
+ temperature: float | None = None,
126
+ provider: str | None = None,
127
+ base_url: str | None = None,
128
+ api_key: str | None = None,
129
+ timeout: float | None = None,
130
+ client=None,
131
+ get_cwd: Callable[[], str] | None = None,
132
+ ):
133
+ config = _coerce_config(env)
134
+ config = apply_provider_overrides(config, provider, base_url)
135
+ self.config = config
136
+ self._cwd = str(cwd or ".")
137
+ self._cwd_fn = get_cwd
138
+ self._min_confidence = min_confidence
139
+ self._max_steps = max_steps
140
+ self._max_history = max_history
141
+ self._continue_threshold = continue_threshold
142
+ self._temperature = temperature
143
+ self._api_key = api_key
144
+ self._timeout = timeout
145
+ self._client = client
146
+ self._owned_client = None
147
+
148
+ # -- cwd ---------------------------------------------------------
149
+ def get_cwd(self) -> str:
150
+ """Return the working directory for the next call.
151
+
152
+ Override dynamically by passing ``get_cwd=...`` at construction,
153
+ calling :meth:`set_cwd_fn`, assigning ``h.get_cwd = lambda: ...``,
154
+ or subclassing. A per-call ``cwd=...`` argument always wins over
155
+ this.
156
+ """
157
+ fn = self.__dict__.get("_cwd_fn")
158
+ if callable(fn):
159
+ return str(fn())
160
+ return self._cwd
161
+
162
+ def set_cwd(self, cwd: str | os.PathLike) -> None:
163
+ """Update the default working directory."""
164
+ self._cwd = str(cwd or ".")
165
+
166
+ def set_cwd_fn(self, fn: Callable[[], str] | None) -> None:
167
+ """Install/replace the dynamic cwd provider (None clears it)."""
168
+ self._cwd_fn = fn
169
+
170
+ def _resolve_cwd(self, cwd: str | os.PathLike | None) -> str:
171
+ if cwd is not None:
172
+ return str(cwd)
173
+ override = self.__dict__.get("get_cwd")
174
+ if override is not None:
175
+ # User replaced the method: h.get_cwd = lambda: ... (or a string).
176
+ if callable(override):
177
+ return str(override())
178
+ return str(override)
179
+ return str(self.get_cwd())
180
+
181
+ # -- lifecycle ---------------------------------------------------
182
+ def close(self) -> None:
183
+ if self._owned_client is not None:
184
+ try:
185
+ self._owned_client.close()
186
+ except Exception:
187
+ pass
188
+ self._owned_client = None
189
+
190
+ def __enter__(self) -> "Harness":
191
+ if self._client is None and self._owned_client is None:
192
+ from hsom.client import create_client
193
+ try:
194
+ self._owned_client = create_client(
195
+ self.config, api_key=self._api_key,
196
+ timeout=self._timeout)
197
+ except Exception:
198
+ self._owned_client = None
199
+ return self
200
+
201
+ def __exit__(self, *exc) -> None:
202
+ self.close()
203
+
204
+ def _client_for_call(self, override=None):
205
+ """Return ``(client, owned)`` for one plan/run/eval call.
206
+
207
+ Explicit/per-construction/context-managed clients are not owned here.
208
+ Otherwise a client is created with the constructor's ``api_key`` and
209
+ ``timeout`` and closed by the caller after the call.
210
+ """
211
+ if override is not None:
212
+ return override, False
213
+ if self._client is not None:
214
+ return self._client, False
215
+ if self._owned_client is not None:
216
+ return self._owned_client, False
217
+ self._ensure_api_key()
218
+ from hsom.client import create_client
219
+ return create_client(self.config, api_key=self._api_key,
220
+ timeout=self._timeout), True
221
+
222
+ def _ensure_api_key(self) -> None:
223
+ if self._api_key is not None and self._api_key.strip():
224
+ return
225
+ from hsom.client import has_api_key
226
+ if not has_api_key(self.config):
227
+ raise ConfigError(
228
+ "no API key found: set TYPESAFE_API_KEY or OPENROUTER_API_KEY")
229
+
230
+ # -- single-shot -------------------------------------------------
231
+ def plan(self, request: str, *, cwd=None, min_confidence=None,
232
+ temperature=None, client=None):
233
+ """Plan one step. Returns :class:`PlanOutcome` (max_steps=1)."""
234
+ outcome, _q, _c, _r = self.plan_full(
235
+ request, cwd=cwd, min_confidence=min_confidence,
236
+ temperature=temperature, client=client)
237
+ return outcome
238
+
239
+ def plan_full(self, request: str, *, cwd=None, min_confidence=None,
240
+ temperature=None, client=None):
241
+ """Like :meth:`plan` but returns ``(outcome, questions, context,
242
+ response)`` for inspection / logging."""
243
+ from hsom.dispatcher import dispatch
244
+ cwd_s = self._resolve_cwd(cwd)
245
+ eff_conf = min_confidence if min_confidence is not None else self._min_confidence
246
+ eff_temp = temperature if temperature is not None else self._temperature
247
+ if eff_temp is None:
248
+ eff_temp = self.config.temperature
249
+ eff_client, own_client = self._client_for_call(client)
250
+ try:
251
+ return dispatch(
252
+ self.config, request, cwd_s, client=eff_client,
253
+ min_confidence=eff_conf, max_steps=1, temperature=eff_temp)
254
+ finally:
255
+ if own_client:
256
+ try:
257
+ eff_client.close()
258
+ except Exception:
259
+ pass
260
+
261
+ def resolve(self, action, *, cwd=None) -> list[str]:
262
+ """Build argv for a planned action. Raises ExecutionError."""
263
+ from hsom.executor import resolve_argv
264
+ return list(resolve_argv(self.config, action, self._resolve_cwd(cwd)))
265
+
266
+ def execute(self, action, *, cwd=None, dry_run: bool = False,
267
+ timeout: float | None = None):
268
+ """Execute a planned action. Returns ExecutionResult."""
269
+ from hsom.executor import execute
270
+ return execute(self.config, action, self._resolve_cwd(cwd),
271
+ dry_run=dry_run,
272
+ timeout=timeout if timeout is not None else self._timeout)
273
+
274
+ # -- multi-step --------------------------------------------------
275
+ def run(
276
+ self,
277
+ request: str,
278
+ *,
279
+ cwd=None,
280
+ max_steps: int | None = None,
281
+ max_history: int | None = None,
282
+ continue_threshold: float | None = None,
283
+ min_confidence: float | None = None,
284
+ temperature: float | None = None,
285
+ dry_run: bool = False,
286
+ on_confirm: OnConfirm | None = None,
287
+ execute_fn=None,
288
+ stop_on_error: bool = True,
289
+ repeat_guard: bool = True,
290
+ logger=None,
291
+ log_path: str | os.PathLike | None = None,
292
+ client=None,
293
+ ) -> RunResult:
294
+ """Plan (+ optionally execute) up to ``max_steps`` chained steps.
295
+
296
+ - ``dry_run``: resolve argv only, never execute, never prompt.
297
+ - ``on_confirm``: ``(action, argv) -> bool`` gate for
298
+ ``write``/``destructive`` steps. ``None`` (default) is
299
+ fail-closed: those steps are declined without prompting.
300
+ - ``execute_fn``: ``(action, cwd) -> (argv, rc, stdout, stderr)``
301
+ injection for tests; defaults to real subprocess execution.
302
+ - ``stop_on_error``: stop after the first non-zero exit (default).
303
+ - ``repeat_guard``: re-plan once when a step repeats the previous
304
+ argv verbatim instead of executing a duplicate.
305
+ - ``logger`` / ``log_path``: opt-in JSONL run logging.
306
+ """
307
+ from hsom import runlog
308
+ from hsom.config import effective_continue_threshold
309
+ from hsom.dispatcher import (
310
+ continue_info, continue_requested, dispatch,
311
+ history_entry,
312
+ )
313
+ from hsom.executor import ExecutionError, resolve_argv
314
+
315
+ cwd_s = self._resolve_cwd(cwd)
316
+ budget = max_steps if max_steps is not None else self._max_steps
317
+ if budget is None:
318
+ budget = self.config.max_steps
319
+ budget = max(1, min(10, budget))
320
+ eff_conf = min_confidence if min_confidence is not None else self._min_confidence
321
+ eff_temp = temperature if temperature is not None else self._temperature
322
+ if eff_temp is None:
323
+ eff_temp = self.config.temperature
324
+ eff_hist = max_history if max_history is not None else self._max_history
325
+ eff_cont = (continue_threshold if continue_threshold is not None
326
+ else self._continue_threshold)
327
+ bar, bar_source = effective_continue_threshold(
328
+ self.config, cli_override=eff_cont)
329
+ eff_client, own_client = self._client_for_call(client)
330
+
331
+ own_logger = None
332
+ if log_path is not None and logger is None:
333
+ logger = own_logger = runlog.JsonlLogger(str(log_path))
334
+ try:
335
+ from hsom import executor as _executor
336
+
337
+ steps: list = []
338
+ history: list[dict] = []
339
+ for i in range(budget):
340
+ ask_continue = budget > 1 and i + 1 < budget
341
+ outcome, _q, context, response = dispatch(
342
+ self.config, request, cwd_s, client=eff_client,
343
+ min_confidence=eff_conf, history=history or None,
344
+ max_steps=budget, max_history=eff_hist,
345
+ allow_continue=ask_continue, temperature=eff_temp,
346
+ step_index=i)
347
+ if logger is not None:
348
+ logger.log(runlog.plan_event(
349
+ i + 1, request, cwd_s, outcome, context,
350
+ continue_asked=ask_continue,
351
+ continue_value=continue_info(response),
352
+ continue_threshold=bar,
353
+ continue_threshold_source=bar_source,
354
+ max_steps=budget, max_history=eff_hist,
355
+ history_len=len(history), temperature=eff_temp))
356
+ if not outcome.ok or outcome.action is None:
357
+ if logger is not None:
358
+ logger.log(runlog.result_event(
359
+ i + 1, error=outcome.reason,
360
+ stop_reason=outcome.reason))
361
+ if i == 0:
362
+ return RunResult(steps=(), stop_reason=(
363
+ f"abstained: {outcome.reason}"), ok=False)
364
+ ok = all(s.returncode == 0 for s in steps)
365
+ return RunResult(steps=tuple(steps), stop_reason=(
366
+ f"stop: {outcome.reason}"), ok=ok)
367
+ action = outcome.action
368
+ try:
369
+ preview = list(resolve_argv(self.config, action, cwd_s))
370
+ except ExecutionError as e:
371
+ if logger is not None:
372
+ logger.log(runlog.result_event(i + 1, error=str(e)))
373
+ return RunResult(steps=tuple(steps), stop_reason=str(e),
374
+ ok=False)
375
+
376
+ if repeat_guard and history and preview == list(
377
+ history[-1].get("argv") or []):
378
+ retried = self._retry_after_repeat(
379
+ request, cwd_s, history=history, max_steps=budget,
380
+ max_history=eff_hist, min_confidence=eff_conf,
381
+ temperature=eff_temp, step_index=i,
382
+ ask_continue=ask_continue, prev_argv=preview,
383
+ logger=logger, client=eff_client)
384
+ if retried is None:
385
+ if logger is not None:
386
+ logger.log(runlog.result_event(
387
+ i + 1, preview,
388
+ stop_reason=(f"stop: step {i+1} repeats step "
389
+ f"{i} ({' '.join(preview)}); not "
390
+ "executing a duplicate")))
391
+ ok = all(s.returncode == 0 for s in steps)
392
+ # Record the repeated step as not-executed: keep prior
393
+ # steps only, matching dispatch_sequence honesty.
394
+ return RunResult(
395
+ steps=tuple(steps),
396
+ stop_reason=(f"stop: step {i+1} repeats step {i} "
397
+ f"({' '.join(preview)}); not executing "
398
+ "a duplicate"),
399
+ ok=ok)
400
+ outcome, context, response, preview, action = retried
401
+
402
+ if dry_run:
403
+ from hsom.dispatcher import StepOutcome
404
+ steps.append(StepOutcome(action, preview, 0, "", "",
405
+ dry_run=True))
406
+ history.append(history_entry(i + 1, action, preview, 0,
407
+ "", ""))
408
+ if logger is not None:
409
+ logger.log(runlog.result_event(i + 1, preview,
410
+ dry_run=True))
411
+ if ask_continue and not continue_requested(response, bar):
412
+ return RunResult(steps=tuple(steps), ok=True,
413
+ stop_reason=(
414
+ f"done after step {i+1} (dry-run)"))
415
+ continue
416
+
417
+ if action.risk in ("write", "destructive"):
418
+ approved = (False if on_confirm is None
419
+ else bool(on_confirm(action, preview)))
420
+ if not approved:
421
+ if logger is not None:
422
+ logger.log(runlog.result_event(
423
+ i + 1, preview, declined=True))
424
+ return RunResult(steps=tuple(steps), ok=False,
425
+ declined=True,
426
+ stop_reason="declined by user")
427
+
428
+ if execute_fn is not None:
429
+ argv, rc, out, err = execute_fn(action, cwd_s)
430
+ argv = list(argv)
431
+ else:
432
+ try:
433
+ res = _executor.execute(
434
+ self.config, action, cwd_s,
435
+ timeout=self._timeout)
436
+ except ExecutionError as e:
437
+ if logger is not None:
438
+ logger.log(runlog.result_event(
439
+ i + 1, preview, error=str(e)))
440
+ return RunResult(steps=tuple(steps), ok=False,
441
+ stop_reason=str(e))
442
+ argv, rc, out, err = (list(res.argv), res.returncode,
443
+ res.stdout, res.stderr)
444
+ from hsom.dispatcher import StepOutcome
445
+ steps.append(StepOutcome(action, argv, rc, out, err))
446
+ history.append(history_entry(i + 1, action, argv, rc, out,
447
+ err))
448
+ if logger is not None:
449
+ logger.log(runlog.result_event(i + 1, argv, returncode=rc,
450
+ stdout=out, stderr=err))
451
+ if rc != 0 and stop_on_error:
452
+ return RunResult(steps=tuple(steps), ok=False,
453
+ stop_reason=f"stop: step {i+1} exited {rc}")
454
+ if ask_continue and not continue_requested(response, bar):
455
+ ok = all(s.returncode == 0 for s in steps)
456
+ return RunResult(steps=tuple(steps), ok=ok,
457
+ stop_reason=f"done after step {i+1}")
458
+ ok = all(s.returncode == 0 for s in steps) if steps else False
459
+ if dry_run and steps:
460
+ ok = True
461
+ return RunResult(steps=tuple(steps), ok=ok,
462
+ stop_reason="max_steps reached")
463
+ finally:
464
+ if own_client:
465
+ try:
466
+ eff_client.close()
467
+ except Exception:
468
+ pass
469
+ if own_logger is not None:
470
+ own_logger.close()
471
+
472
+ def _retry_after_repeat(self, request, cwd_s, *, history, max_steps,
473
+ max_history, min_confidence, temperature,
474
+ step_index, ask_continue, prev_argv, logger,
475
+ client):
476
+ """Re-plan once with an anti-repeat nudge. Returns
477
+ ``(outcome, context, response, argv, action)`` or None."""
478
+ from hsom import runlog
479
+ from hsom.dispatcher import REPEAT_HINT_STATE, dispatch
480
+ from hsom.executor import resolve_argv
481
+
482
+ outcome, _q, context, response = dispatch(
483
+ self.config, request, cwd_s, client=client,
484
+ min_confidence=min_confidence, history=history or None,
485
+ max_steps=max_steps, max_history=max_history,
486
+ allow_continue=ask_continue, temperature=temperature,
487
+ step_index=step_index, repeat_hint=True,
488
+ hint=REPEAT_HINT_STATE)
489
+ if logger is not None:
490
+ from hsom.dispatcher import continue_info
491
+ logger.log(runlog.plan_event(
492
+ step_index + 1, request, cwd_s, outcome, context,
493
+ continue_asked=ask_continue,
494
+ continue_value=continue_info(response),
495
+ max_steps=max_steps, max_history=max_history,
496
+ history_len=len(history), repeat_retry=True,
497
+ temperature=(self.config.temperature
498
+ if temperature is None else temperature)))
499
+ if not outcome.ok or outcome.action is None:
500
+ return None
501
+ action = outcome.action
502
+ try:
503
+ argv = list(resolve_argv(self.config, action, cwd_s))
504
+ except Exception:
505
+ return None
506
+ if argv == list(prev_argv):
507
+ return None
508
+ return outcome, context, response, argv, action
509
+
510
+ # -- eval --------------------------------------------------------
511
+ def eval(self, eval_file_or_cases, *, cwd=None, min_confidence=None,
512
+ temperature=None, continue_threshold=None, repeat_guard=False,
513
+ client=None):
514
+ """Run eval cases (plans only, never executes). Returns EvalSummary.
515
+
516
+ Accepts an eval TOML path or a pre-loaded ``list[EvalCase]``.
517
+ ``repeat_guard`` defaults to False (canned duplicate steps allowed);
518
+ per-case ``repeat_guard`` overrides it.
519
+ """
520
+ from hsom.eval import load_eval, run_eval
521
+ if isinstance(eval_file_or_cases, (str, os.PathLike)):
522
+ cases = load_eval(str(eval_file_or_cases))
523
+ else:
524
+ cases = list(eval_file_or_cases)
525
+ base_cwd = self._resolve_cwd(cwd)
526
+ eff_conf = (min_confidence if min_confidence is not None
527
+ else self._min_confidence)
528
+ eff_temp = (temperature if temperature is not None
529
+ else self._temperature)
530
+ if eff_temp is None:
531
+ eff_temp = self.config.temperature
532
+ eff_cont = (continue_threshold if continue_threshold is not None
533
+ else self._continue_threshold)
534
+ eff_client, own_client = self._client_for_call(client)
535
+ try:
536
+ return run_eval(self.config, cases, base_cwd, client=eff_client,
537
+ min_confidence=eff_conf, temperature=eff_temp,
538
+ continue_threshold=eff_cont,
539
+ repeat_guard=repeat_guard)
540
+ finally:
541
+ if own_client:
542
+ try:
543
+ eff_client.close()
544
+ except Exception:
545
+ pass
546
+
547
+ # -- misc --------------------------------------------------------
548
+ @property
549
+ def cwd(self) -> str:
550
+ return self._resolve_cwd(None)
551
+
552
+ def __repr__(self) -> str: # pragma: no cover - debug helper
553
+ return (f"Harness(model={self.config.model!r}, "
554
+ f"cwd={self._cwd!r}, commands={len(self.config.commands)})")
555
+
556
+
557
+ __all__ = ["Harness", "RunResult", "OnConfirm", "apply_provider_overrides"]