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 +60 -0
- hsom/api.py +557 -0
- hsom/baselines.py +438 -0
- hsom/benchmark.py +1211 -0
- hsom/cli.py +475 -0
- hsom/client.py +100 -0
- hsom/config.py +728 -0
- hsom/discovery.py +183 -0
- hsom/dispatcher.py +701 -0
- hsom/eval.py +455 -0
- hsom/executor.py +195 -0
- hsom/questions.py +216 -0
- hsom/runlog.py +229 -0
- hsom/stats.py +149 -0
- hsom-1.0.0.dist-info/METADATA +245 -0
- hsom-1.0.0.dist-info/RECORD +19 -0
- hsom-1.0.0.dist-info/WHEEL +4 -0
- hsom-1.0.0.dist-info/entry_points.txt +3 -0
- hsom-1.0.0.dist-info/licenses/LICENSE +21 -0
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"]
|