hud-python 0.4.36__py3-none-any.whl → 0.4.37__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.
Potentially problematic release.
This version of hud-python might be problematic. Click here for more details.
- hud/agents/__init__.py +2 -0
- hud/agents/lite_llm.py +72 -0
- hud/agents/openai_chat_generic.py +21 -7
- hud/cli/__init__.py +19 -4
- hud/cli/build.py +17 -2
- hud/cli/dev.py +1 -1
- hud/cli/eval.py +93 -13
- hud/cli/flows/tasks.py +197 -65
- hud/cli/push.py +9 -0
- hud/cli/rl/__init__.py +14 -4
- hud/cli/rl/celebrate.py +187 -0
- hud/cli/rl/config.py +15 -8
- hud/cli/rl/local_runner.py +44 -20
- hud/cli/rl/remote_runner.py +163 -86
- hud/cli/rl/viewer.py +141 -0
- hud/cli/rl/wait_utils.py +89 -0
- hud/cli/utils/env_check.py +196 -0
- hud/cli/utils/source_hash.py +108 -0
- hud/clients/base.py +1 -1
- hud/clients/fastmcp.py +1 -1
- hud/otel/config.py +1 -1
- hud/otel/context.py +2 -2
- hud/rl/vllm_adapter.py +1 -1
- hud/server/server.py +84 -13
- hud/server/tests/test_add_tool.py +60 -0
- hud/server/tests/test_context.py +128 -0
- hud/server/tests/test_mcp_server_handlers.py +44 -0
- hud/server/tests/test_mcp_server_integration.py +405 -0
- hud/server/tests/test_mcp_server_more.py +247 -0
- hud/server/tests/test_run_wrapper.py +53 -0
- hud/server/tests/test_server_extra.py +166 -0
- hud/server/tests/test_sigterm_runner.py +78 -0
- hud/shared/hints.py +1 -1
- hud/telemetry/job.py +2 -2
- hud/types.py +9 -2
- hud/utils/tasks.py +32 -24
- hud/utils/tests/test_version.py +1 -1
- hud/version.py +1 -1
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/METADATA +14 -12
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/RECORD +43 -29
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/WHEEL +0 -0
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/entry_points.txt +0 -0
- {hud_python-0.4.36.dist-info → hud_python-0.4.37.dist-info}/licenses/LICENSE +0 -0
hud/cli/rl/wait_utils.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contextlib
|
|
4
|
+
import os
|
|
5
|
+
import select
|
|
6
|
+
import sys
|
|
7
|
+
import threading
|
|
8
|
+
import time as _time
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
from watchfiles import watch
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def wait_for_enter_cancel_or_change(file_path: Path) -> tuple[bool, bool, bool]:
|
|
18
|
+
"""Block until Enter (start), 'q' (cancel), or file change.
|
|
19
|
+
|
|
20
|
+
Returns (start_training, cancelled, changed).
|
|
21
|
+
- start_training: True if Enter (or any non-'q' line on POSIX) was received
|
|
22
|
+
- cancelled: True if 'q' was received or Ctrl-C
|
|
23
|
+
- changed: True if the file changed on disk
|
|
24
|
+
"""
|
|
25
|
+
start_training = False
|
|
26
|
+
cancelled = False
|
|
27
|
+
changed = False
|
|
28
|
+
|
|
29
|
+
stop_evt: threading.Event = threading.Event()
|
|
30
|
+
changed_evt: threading.Event = threading.Event()
|
|
31
|
+
|
|
32
|
+
def _watcher() -> None:
|
|
33
|
+
with contextlib.suppress(Exception):
|
|
34
|
+
for _ in watch(file_path, stop_event=stop_evt, debounce=200):
|
|
35
|
+
changed_evt.set()
|
|
36
|
+
break
|
|
37
|
+
|
|
38
|
+
t = threading.Thread(target=_watcher, daemon=True)
|
|
39
|
+
t.start()
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
if os.name == "nt":
|
|
43
|
+
import msvcrt # type: ignore[attr-defined]
|
|
44
|
+
|
|
45
|
+
while True:
|
|
46
|
+
if changed_evt.is_set():
|
|
47
|
+
changed = True
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
if msvcrt.kbhit():
|
|
51
|
+
ch = msvcrt.getwch()
|
|
52
|
+
if ch in ("\r", "\n"):
|
|
53
|
+
start_training = True
|
|
54
|
+
break
|
|
55
|
+
if ch.lower() == "q":
|
|
56
|
+
cancelled = True
|
|
57
|
+
break
|
|
58
|
+
_time.sleep(0.15)
|
|
59
|
+
else:
|
|
60
|
+
while True:
|
|
61
|
+
if changed_evt.is_set():
|
|
62
|
+
changed = True
|
|
63
|
+
break
|
|
64
|
+
|
|
65
|
+
rlist, _, _ = select.select([sys.stdin], [], [], 0.25)
|
|
66
|
+
if rlist:
|
|
67
|
+
line = sys.stdin.readline()
|
|
68
|
+
if line is None:
|
|
69
|
+
continue
|
|
70
|
+
stripped = line.strip().lower()
|
|
71
|
+
if stripped == "q":
|
|
72
|
+
cancelled = True
|
|
73
|
+
break
|
|
74
|
+
# Any other (including empty) => start
|
|
75
|
+
start_training = True
|
|
76
|
+
break
|
|
77
|
+
_time.sleep(0.05)
|
|
78
|
+
|
|
79
|
+
except KeyboardInterrupt:
|
|
80
|
+
cancelled = True
|
|
81
|
+
finally:
|
|
82
|
+
stop_evt.set()
|
|
83
|
+
with contextlib.suppress(Exception):
|
|
84
|
+
t.join(timeout=1)
|
|
85
|
+
|
|
86
|
+
return start_training, cancelled, changed
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
__all__ = ["wait_for_enter_cancel_or_change"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Environment build checks and discovery helpers.
|
|
2
|
+
|
|
3
|
+
Shared utilities to:
|
|
4
|
+
- locate an environment directory related to a tasks file
|
|
5
|
+
- ensure the environment is built and up-to-date via source hash comparison
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from hud.utils.hud_console import hud_console
|
|
19
|
+
|
|
20
|
+
from .docker import require_docker_running
|
|
21
|
+
from .source_hash import compute_source_hash, list_source_files
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _parse_generated_at(lock_data: dict[str, Any]) -> float | None:
|
|
25
|
+
"""Parse build.generatedAt into a POSIX timestamp (seconds).
|
|
26
|
+
|
|
27
|
+
Returns None if missing or unparsable.
|
|
28
|
+
"""
|
|
29
|
+
try:
|
|
30
|
+
generated_at = (lock_data.get("build") or {}).get("generatedAt")
|
|
31
|
+
if not isinstance(generated_at, str):
|
|
32
|
+
return None
|
|
33
|
+
# Support ...Z and offsets
|
|
34
|
+
iso = generated_at.replace("Z", "+00:00")
|
|
35
|
+
dt = datetime.fromisoformat(iso)
|
|
36
|
+
if dt.tzinfo is None:
|
|
37
|
+
dt = dt.replace(tzinfo=UTC)
|
|
38
|
+
return dt.timestamp()
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _collect_source_diffs(env_dir: Path, lock_data: dict[str, Any]) -> dict[str, list[str]]:
|
|
44
|
+
"""Compute added/removed/modified files since last build using names + mtimes.
|
|
45
|
+
|
|
46
|
+
- added/removed are based on the stored build.sourceFiles list vs current file list
|
|
47
|
+
- modified is based on mtime newer than build.generatedAt for files present now
|
|
48
|
+
"""
|
|
49
|
+
try:
|
|
50
|
+
stored_files = (
|
|
51
|
+
(lock_data.get("build") or {}).get("sourceFiles") if isinstance(lock_data, dict) else []
|
|
52
|
+
)
|
|
53
|
+
stored_set = set(str(p) for p in (stored_files or []))
|
|
54
|
+
except Exception:
|
|
55
|
+
stored_set = set()
|
|
56
|
+
|
|
57
|
+
current_paths = list_source_files(env_dir)
|
|
58
|
+
# Normalize to POSIX-style relative strings
|
|
59
|
+
current_list = [str(p.resolve().relative_to(env_dir)).replace("\\", "/") for p in current_paths]
|
|
60
|
+
current_set = set(current_list)
|
|
61
|
+
|
|
62
|
+
added = sorted(current_set - stored_set)
|
|
63
|
+
removed = sorted(stored_set - current_set)
|
|
64
|
+
|
|
65
|
+
# Modified: mtime newer than build.generatedAt
|
|
66
|
+
modified: list[str] = []
|
|
67
|
+
built_ts = _parse_generated_at(lock_data)
|
|
68
|
+
if built_ts is not None:
|
|
69
|
+
for rel in sorted(current_set & (stored_set or current_set)):
|
|
70
|
+
with contextlib.suppress(Exception):
|
|
71
|
+
p = env_dir / rel
|
|
72
|
+
if p.exists() and p.stat().st_mtime > built_ts:
|
|
73
|
+
modified.append(rel)
|
|
74
|
+
|
|
75
|
+
return {"added": added, "removed": removed, "modified": modified}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_environment_dir(tasks_path: Path) -> Path | None:
|
|
79
|
+
"""Best-effort discovery of a nearby environment directory.
|
|
80
|
+
|
|
81
|
+
Preference order:
|
|
82
|
+
- directory with hud.lock.yaml
|
|
83
|
+
- directory that looks like an environment (Dockerfile + pyproject.toml)
|
|
84
|
+
- searches tasks dir, CWD, and a couple of parents
|
|
85
|
+
"""
|
|
86
|
+
from .environment import is_environment_directory # local import to avoid cycles
|
|
87
|
+
|
|
88
|
+
candidates: list[Path] = []
|
|
89
|
+
cwd = Path.cwd()
|
|
90
|
+
candidates.extend([tasks_path.parent, cwd])
|
|
91
|
+
|
|
92
|
+
# Add parents (up to 2 levels for each)
|
|
93
|
+
for base in list(candidates):
|
|
94
|
+
p = base
|
|
95
|
+
for _ in range(2):
|
|
96
|
+
p = p.parent
|
|
97
|
+
if p not in candidates:
|
|
98
|
+
candidates.append(p)
|
|
99
|
+
|
|
100
|
+
# Prefer those with hud.lock.yaml
|
|
101
|
+
for d in candidates:
|
|
102
|
+
if (d / "hud.lock.yaml").exists():
|
|
103
|
+
return d
|
|
104
|
+
|
|
105
|
+
# Otherwise, find a plausible environment dir
|
|
106
|
+
for d in candidates:
|
|
107
|
+
try:
|
|
108
|
+
if is_environment_directory(d):
|
|
109
|
+
return d
|
|
110
|
+
except Exception as e:
|
|
111
|
+
hud_console.debug(f"Skipping path {d}: {e}")
|
|
112
|
+
continue
|
|
113
|
+
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def ensure_built(env_dir: Path, *, interactive: bool = True) -> dict[str, Any]:
|
|
118
|
+
"""Ensure env has a lock and matches current sources via source hash.
|
|
119
|
+
|
|
120
|
+
If interactive is True, prompts to build/rebuild as needed. If False, only warns.
|
|
121
|
+
Returns the loaded lock data (empty dict if unreadable/missing).
|
|
122
|
+
"""
|
|
123
|
+
from hud.cli.build import build_environment # local import to avoid import cycles
|
|
124
|
+
|
|
125
|
+
lock_path = env_dir / "hud.lock.yaml"
|
|
126
|
+
if not lock_path.exists():
|
|
127
|
+
if interactive:
|
|
128
|
+
hud_console.warning("No hud.lock.yaml found. The environment hasn't been built.")
|
|
129
|
+
ok = hud_console.confirm("Build the environment now (runs 'hud build')?", default=True)
|
|
130
|
+
if not ok:
|
|
131
|
+
raise typer.Exit(1)
|
|
132
|
+
require_docker_running()
|
|
133
|
+
build_environment(str(env_dir), platform="linux/amd64")
|
|
134
|
+
else:
|
|
135
|
+
hud_console.dim_info(
|
|
136
|
+
"Info",
|
|
137
|
+
"No hud.lock.yaml found nearby; skipping environment change checks.",
|
|
138
|
+
)
|
|
139
|
+
return {}
|
|
140
|
+
|
|
141
|
+
# Load lock file
|
|
142
|
+
try:
|
|
143
|
+
with open(lock_path) as f:
|
|
144
|
+
lock_data: dict[str, Any] = yaml.safe_load(f) or {}
|
|
145
|
+
except Exception:
|
|
146
|
+
lock_data = {}
|
|
147
|
+
|
|
148
|
+
# Fast change detection: recompute source hash and compare
|
|
149
|
+
try:
|
|
150
|
+
current_hash = compute_source_hash(env_dir)
|
|
151
|
+
stored_hash = (
|
|
152
|
+
(lock_data.get("build") or {}).get("sourceHash")
|
|
153
|
+
if isinstance(lock_data, dict)
|
|
154
|
+
else None
|
|
155
|
+
)
|
|
156
|
+
if stored_hash and current_hash and stored_hash != current_hash:
|
|
157
|
+
hud_console.warning("Environment sources changed since last build.")
|
|
158
|
+
|
|
159
|
+
# Show a brief diff summary to help users understand changes
|
|
160
|
+
diffs = _collect_source_diffs(env_dir, lock_data)
|
|
161
|
+
|
|
162
|
+
def _print_section(name: str, items: list[str]) -> None:
|
|
163
|
+
if not items:
|
|
164
|
+
return
|
|
165
|
+
# Limit output to avoid flooding the console
|
|
166
|
+
preview = items[:20]
|
|
167
|
+
more = len(items) - len(preview)
|
|
168
|
+
hud_console.section_title(name)
|
|
169
|
+
for rel in preview:
|
|
170
|
+
hud_console.dim_info("", rel)
|
|
171
|
+
if more > 0:
|
|
172
|
+
hud_console.dim_info("", f"... and {more} more")
|
|
173
|
+
|
|
174
|
+
_print_section("Modified files", diffs.get("modified", []))
|
|
175
|
+
_print_section("Added files", diffs.get("added", []))
|
|
176
|
+
_print_section("Removed files", diffs.get("removed", []))
|
|
177
|
+
|
|
178
|
+
if interactive:
|
|
179
|
+
if hud_console.confirm("Rebuild now (runs 'hud build')?", default=True):
|
|
180
|
+
require_docker_running()
|
|
181
|
+
build_environment(str(env_dir), platform="linux/amd64")
|
|
182
|
+
with open(lock_path) as f:
|
|
183
|
+
lock_data = yaml.safe_load(f) or {}
|
|
184
|
+
else:
|
|
185
|
+
hud_console.hint("Continuing without rebuild; this may use an outdated image.")
|
|
186
|
+
else:
|
|
187
|
+
hud_console.hint("Run 'hud build' to update the image before proceeding.")
|
|
188
|
+
elif not stored_hash:
|
|
189
|
+
hud_console.dim_info(
|
|
190
|
+
"Info",
|
|
191
|
+
"No source hash in lock; rebuild to enable change checks.",
|
|
192
|
+
)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
hud_console.debug(f"Source hash check skipped: {e}")
|
|
195
|
+
|
|
196
|
+
return lock_data
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Utilities to compute a fast, deterministic source hash for environments.
|
|
2
|
+
|
|
3
|
+
This intentionally focuses on the typical HUD environment layout and aims to be fast:
|
|
4
|
+
- Always include: Dockerfile, pyproject.toml
|
|
5
|
+
- Include directories: controller/, environment/, src/
|
|
6
|
+
- Exclude common build/runtime caches and lock files
|
|
7
|
+
|
|
8
|
+
Note: This is not a full Docker build context hash and does not parse .dockerignore.
|
|
9
|
+
It is sufficient to detect meaningful changes for HUD environments quickly.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import Iterable
|
|
21
|
+
|
|
22
|
+
EXCLUDE_DIRS = {
|
|
23
|
+
".git",
|
|
24
|
+
".venv",
|
|
25
|
+
"dist",
|
|
26
|
+
"build",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"__pycache__",
|
|
29
|
+
".mypy_cache",
|
|
30
|
+
".pytest_cache",
|
|
31
|
+
".ruff_cache",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
EXCLUDE_FILE_SUFFIXES = {
|
|
35
|
+
".pyc",
|
|
36
|
+
".log",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
EXCLUDE_FILES = {
|
|
40
|
+
"hud.lock.yaml",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
INCLUDE_FILES = {"Dockerfile", "pyproject.toml"}
|
|
44
|
+
INCLUDE_DIRS = {"controller", "environment"}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def iter_source_files(root: Path) -> Iterable[Path]:
|
|
48
|
+
"""Yield files to include in the source hash.
|
|
49
|
+
|
|
50
|
+
The order is not guaranteed; callers should sort for deterministic hashing.
|
|
51
|
+
"""
|
|
52
|
+
# Always include top-level files if present
|
|
53
|
+
for name in INCLUDE_FILES:
|
|
54
|
+
p = root / name
|
|
55
|
+
if p.is_file():
|
|
56
|
+
yield p
|
|
57
|
+
|
|
58
|
+
# Include known directories
|
|
59
|
+
for d in INCLUDE_DIRS:
|
|
60
|
+
dp = root / d
|
|
61
|
+
if not dp.exists():
|
|
62
|
+
continue
|
|
63
|
+
for dirpath, dirnames, filenames in os.walk(dp):
|
|
64
|
+
# prune excluded dirs in-place
|
|
65
|
+
dirnames[:] = [dn for dn in dirnames if dn not in EXCLUDE_DIRS]
|
|
66
|
+
for fn in filenames:
|
|
67
|
+
if fn in EXCLUDE_FILES:
|
|
68
|
+
continue
|
|
69
|
+
if any(fn.endswith(suf) for suf in EXCLUDE_FILE_SUFFIXES):
|
|
70
|
+
continue
|
|
71
|
+
yield Path(dirpath) / fn
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def list_source_files(root: Path) -> list[Path]:
|
|
75
|
+
"""Return a sorted list of files used for the source hash.
|
|
76
|
+
|
|
77
|
+
Sorting is by relative path to ensure deterministic ordering.
|
|
78
|
+
"""
|
|
79
|
+
root = root.resolve()
|
|
80
|
+
files = list(iter_source_files(root))
|
|
81
|
+
files.sort(key=lambda p: str(p.resolve().relative_to(root)).replace("\\", "/"))
|
|
82
|
+
return files
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def compute_source_hash(directory: str | Path) -> str:
|
|
86
|
+
"""Compute a deterministic SHA-256 hash over relevant source files.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
directory: Environment directory root.
|
|
90
|
+
|
|
91
|
+
Returns:
|
|
92
|
+
Hex digest string.
|
|
93
|
+
"""
|
|
94
|
+
root = Path(directory).resolve()
|
|
95
|
+
files = list_source_files(root)
|
|
96
|
+
|
|
97
|
+
hasher = hashlib.sha256()
|
|
98
|
+
for p in files:
|
|
99
|
+
rel = str(p.resolve().relative_to(root)).replace("\\", "/")
|
|
100
|
+
hasher.update(rel.encode("utf-8"))
|
|
101
|
+
with open(p, "rb") as f:
|
|
102
|
+
while True:
|
|
103
|
+
chunk = f.read(8192)
|
|
104
|
+
if not chunk:
|
|
105
|
+
break
|
|
106
|
+
hasher.update(chunk)
|
|
107
|
+
|
|
108
|
+
return hasher.hexdigest()
|
hud/clients/base.py
CHANGED
|
@@ -139,7 +139,7 @@ class BaseHUDClient(AgentMCPClient):
|
|
|
139
139
|
raise HudAuthenticationError(
|
|
140
140
|
f'Sending authorization "{headers.get("Authorization", "")}", which may'
|
|
141
141
|
" be incomplete. Ensure HUD_API_KEY environment variable is set or send it"
|
|
142
|
-
" as a header. You can get an API key at https://
|
|
142
|
+
" as a header. You can get an API key at https://hud.so"
|
|
143
143
|
)
|
|
144
144
|
# Subclasses implement connection
|
|
145
145
|
await self._connect(self._mcp_config)
|
hud/clients/fastmcp.py
CHANGED
|
@@ -95,7 +95,7 @@ class FastMCPHUDClient(BaseHUDClient):
|
|
|
95
95
|
raise RuntimeError(
|
|
96
96
|
"Authentication failed for HUD API. "
|
|
97
97
|
"Please ensure your HUD_API_KEY environment variable is set correctly." # noqa: E501
|
|
98
|
-
"You can get an API key at https://
|
|
98
|
+
"You can get an API key at https://hud.so"
|
|
99
99
|
) from e
|
|
100
100
|
# Generic 401 error
|
|
101
101
|
raise RuntimeError(
|
hud/otel/config.py
CHANGED
|
@@ -111,7 +111,7 @@ def configure_telemetry(
|
|
|
111
111
|
# Error if no exporters are configured
|
|
112
112
|
raise ValueError(
|
|
113
113
|
"No telemetry backend configured. Either:\n"
|
|
114
|
-
"1. Set HUD_API_KEY environment variable for HUD telemetry (https://
|
|
114
|
+
"1. Set HUD_API_KEY environment variable for HUD telemetry (https://hud.so)\n"
|
|
115
115
|
"2. Use enable_otlp=True with configure_telemetry() for alternative backends (e.g., Jaeger)\n" # noqa: E501
|
|
116
116
|
)
|
|
117
117
|
elif not settings.telemetry_enabled:
|
hud/otel/context.py
CHANGED
|
@@ -376,7 +376,7 @@ def _print_trace_url(task_run_id: str) -> None:
|
|
|
376
376
|
if not (settings.telemetry_enabled and settings.api_key):
|
|
377
377
|
return
|
|
378
378
|
|
|
379
|
-
url = f"https://
|
|
379
|
+
url = f"https://hud.so/trace/{task_run_id}"
|
|
380
380
|
header = "🚀 See your agent live at:"
|
|
381
381
|
|
|
382
382
|
# ANSI color codes
|
|
@@ -415,7 +415,7 @@ def _print_trace_complete_url(task_run_id: str, error_occurred: bool = False) ->
|
|
|
415
415
|
if not (settings.telemetry_enabled and settings.api_key):
|
|
416
416
|
return
|
|
417
417
|
|
|
418
|
-
url = f"https://
|
|
418
|
+
url = f"https://hud.so/trace/{task_run_id}"
|
|
419
419
|
|
|
420
420
|
# ANSI color codes
|
|
421
421
|
GREEN = "\033[92m"
|
hud/rl/vllm_adapter.py
CHANGED
|
@@ -36,7 +36,7 @@ class VLLMAdapter:
|
|
|
36
36
|
headers = {"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"}
|
|
37
37
|
payload = {"lora_name": adapter_name, "lora_path": adapter_path}
|
|
38
38
|
# Implement exponential backoff for retrying the adapter load request.
|
|
39
|
-
max_retries =
|
|
39
|
+
max_retries = 8
|
|
40
40
|
backoff_factor = 2
|
|
41
41
|
delay = 1 # initial delay in seconds
|
|
42
42
|
|
hud/server/server.py
CHANGED
|
@@ -20,7 +20,6 @@ from hud.server.low_level import LowLevelServerWithInit
|
|
|
20
20
|
if TYPE_CHECKING:
|
|
21
21
|
from collections.abc import AsyncGenerator, Callable
|
|
22
22
|
|
|
23
|
-
from mcp.shared.context import RequestContext
|
|
24
23
|
from starlette.requests import Request
|
|
25
24
|
|
|
26
25
|
__all__ = ["MCPServer"]
|
|
@@ -37,6 +36,31 @@ def _run_with_sigterm(coro_fn: Callable[..., Any], *args: Any, **kwargs: Any) ->
|
|
|
37
36
|
|
|
38
37
|
sys.stderr.flush()
|
|
39
38
|
|
|
39
|
+
# Check if we're already in an event loop
|
|
40
|
+
try:
|
|
41
|
+
loop = asyncio.get_running_loop()
|
|
42
|
+
logger.warning(
|
|
43
|
+
"HUD server is running in an existing event loop. "
|
|
44
|
+
"SIGTERM handling may be limited. "
|
|
45
|
+
"Consider using await hub.run_async() instead of hub.run() in async contexts."
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
task = loop.create_task(coro_fn(*args, **kwargs))
|
|
49
|
+
|
|
50
|
+
# Try to handle SIGTERM if possible
|
|
51
|
+
if sys.platform != "win32":
|
|
52
|
+
|
|
53
|
+
def handle_sigterm(signum: Any, frame: Any) -> None:
|
|
54
|
+
logger.info("SIGTERM received in async context, cancelling task...")
|
|
55
|
+
loop.call_soon_threadsafe(task.cancel)
|
|
56
|
+
|
|
57
|
+
signal.signal(signal.SIGTERM, handle_sigterm)
|
|
58
|
+
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
except RuntimeError:
|
|
62
|
+
pass
|
|
63
|
+
|
|
40
64
|
async def _runner() -> None:
|
|
41
65
|
stop_evt: asyncio.Event | None = None
|
|
42
66
|
if sys.platform != "win32" and os.getenv("FASTMCP_DISABLE_SIGTERM_HANDLER") != "1":
|
|
@@ -127,7 +151,11 @@ class MCPServer(FastMCP):
|
|
|
127
151
|
# Force flush logs to ensure they're visible
|
|
128
152
|
sys.stderr.flush()
|
|
129
153
|
|
|
130
|
-
if
|
|
154
|
+
if (
|
|
155
|
+
self._shutdown_fn is not None
|
|
156
|
+
and _sigterm_received
|
|
157
|
+
and not self._shutdown_has_run
|
|
158
|
+
):
|
|
131
159
|
logger.info("SIGTERM detected! Calling @mcp.shutdown handler...")
|
|
132
160
|
sys.stderr.flush()
|
|
133
161
|
try:
|
|
@@ -137,7 +165,9 @@ class MCPServer(FastMCP):
|
|
|
137
165
|
except Exception as e:
|
|
138
166
|
logger.error("Error during @mcp.shutdown: %s", e)
|
|
139
167
|
sys.stderr.flush()
|
|
140
|
-
|
|
168
|
+
finally:
|
|
169
|
+
self._shutdown_has_run = True
|
|
170
|
+
_sigterm_received = False
|
|
141
171
|
elif self._shutdown_fn is not None:
|
|
142
172
|
logger.info(
|
|
143
173
|
"No SIGTERM. This is a hot reload (SIGINT) or normal exit. Skipping @mcp.shutdown handler." # noqa: E501
|
|
@@ -153,27 +183,53 @@ class MCPServer(FastMCP):
|
|
|
153
183
|
self._initializer_fn: Callable | None = None
|
|
154
184
|
self._did_init = False
|
|
155
185
|
self._replaced_server = False
|
|
186
|
+
self._shutdown_has_run = False # Guard against double-execution of shutdown hook
|
|
156
187
|
|
|
157
188
|
def _replace_with_init_server(self) -> None:
|
|
158
189
|
"""Replace the low-level server with init version when needed."""
|
|
159
190
|
if self._replaced_server:
|
|
160
191
|
return
|
|
161
192
|
|
|
162
|
-
def _run_init(ctx:
|
|
193
|
+
async def _run_init(ctx: object | None = None) -> None:
|
|
194
|
+
"""Run the user initializer exactly once, with stdout redirected."""
|
|
163
195
|
if self._initializer_fn is not None and not self._did_init:
|
|
164
196
|
self._did_init = True
|
|
165
|
-
#
|
|
166
|
-
# any library prints from corrupting the MCP protocol
|
|
197
|
+
# Prevent stdout from polluting the MCP protocol on stdio/HTTP
|
|
167
198
|
with contextlib.redirect_stdout(sys.stderr):
|
|
168
|
-
# Check if function accepts ctx parameter
|
|
169
199
|
import inspect
|
|
170
200
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
201
|
+
fn = self._initializer_fn
|
|
202
|
+
sig = inspect.signature(fn)
|
|
203
|
+
params = sig.parameters
|
|
204
|
+
|
|
205
|
+
ctx_param = params.get("ctx") or params.get("_ctx")
|
|
206
|
+
if ctx_param is not None:
|
|
207
|
+
if ctx_param.kind == inspect.Parameter.KEYWORD_ONLY:
|
|
208
|
+
result = fn(**{ctx_param.name: ctx})
|
|
209
|
+
else:
|
|
210
|
+
result = fn(ctx)
|
|
174
211
|
else:
|
|
175
|
-
|
|
176
|
-
|
|
212
|
+
required_params = [
|
|
213
|
+
p
|
|
214
|
+
for p in params.values()
|
|
215
|
+
if p.default is inspect._empty
|
|
216
|
+
and p.kind
|
|
217
|
+
in (
|
|
218
|
+
inspect.Parameter.POSITIONAL_ONLY,
|
|
219
|
+
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
|
220
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
221
|
+
)
|
|
222
|
+
]
|
|
223
|
+
if required_params:
|
|
224
|
+
param_list = ", ".join(p.name for p in required_params)
|
|
225
|
+
raise TypeError(
|
|
226
|
+
"Initializer must accept no args or a single `ctx` argument; "
|
|
227
|
+
f"received required parameters: {param_list}"
|
|
228
|
+
)
|
|
229
|
+
result = fn()
|
|
230
|
+
if inspect.isawaitable(result):
|
|
231
|
+
await result
|
|
232
|
+
return None
|
|
177
233
|
return None
|
|
178
234
|
|
|
179
235
|
# Save the old server's handlers before replacing it
|
|
@@ -258,7 +314,22 @@ class MCPServer(FastMCP):
|
|
|
258
314
|
self._register_hud_helpers()
|
|
259
315
|
logger.info("Registered HUD helper endpoints at /hud/*")
|
|
260
316
|
|
|
261
|
-
|
|
317
|
+
try:
|
|
318
|
+
await super().run_async(
|
|
319
|
+
transport=transport, show_banner=show_banner, **transport_kwargs
|
|
320
|
+
)
|
|
321
|
+
finally:
|
|
322
|
+
# Fallback: ensure SIGTERM-triggered shutdown runs even when a custom
|
|
323
|
+
# lifespan bypasses our default fastmcp shutdown path.
|
|
324
|
+
global _sigterm_received
|
|
325
|
+
if self._shutdown_fn is not None and _sigterm_received and not self._shutdown_has_run:
|
|
326
|
+
try:
|
|
327
|
+
await self._shutdown_fn()
|
|
328
|
+
except Exception as e: # pragma: no cover - defensive logging
|
|
329
|
+
logger.error("Error during @mcp.shutdown (fallback): %s", e)
|
|
330
|
+
finally:
|
|
331
|
+
self._shutdown_has_run = True
|
|
332
|
+
_sigterm_received = False
|
|
262
333
|
|
|
263
334
|
# Tool registration helper -- appends BaseTool to FastMCP
|
|
264
335
|
def add_tool(self, obj: Any, **kwargs: Any) -> None:
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
import types
|
|
5
|
+
from typing import Any, cast
|
|
6
|
+
|
|
7
|
+
from hud.server import MCPServer
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_add_tool_accepts_base_tool(monkeypatch):
|
|
11
|
+
"""If obj is BaseTool, its `.mcp` gets passed through to FastMCP.add_tool."""
|
|
12
|
+
# Stub hud.tools.base.BaseTool and capture FastMCP.add_tool calls
|
|
13
|
+
mod = types.ModuleType("hud.tools.base")
|
|
14
|
+
|
|
15
|
+
class FakeBaseTool:
|
|
16
|
+
"""Stub type checked by isinstance() inside add_tool."""
|
|
17
|
+
|
|
18
|
+
# Tell the type checker we're mutating a dynamic module
|
|
19
|
+
mod_any = cast("Any", mod)
|
|
20
|
+
mod_any.BaseTool = FakeBaseTool
|
|
21
|
+
monkeypatch.setitem(sys.modules, "hud.tools.base", mod)
|
|
22
|
+
|
|
23
|
+
calls: dict[str, object | None] = {"obj": None, "kwargs": None}
|
|
24
|
+
|
|
25
|
+
def fake_super_add(self, obj: object, **kwargs: object) -> None: # keep runtime the same
|
|
26
|
+
calls["obj"] = obj
|
|
27
|
+
calls["kwargs"] = kwargs
|
|
28
|
+
|
|
29
|
+
monkeypatch.setattr("hud.server.server.FastMCP.add_tool", fake_super_add, raising=True)
|
|
30
|
+
|
|
31
|
+
mcp = MCPServer(name="AddTool")
|
|
32
|
+
sentinel = object()
|
|
33
|
+
|
|
34
|
+
class MyTool(FakeBaseTool):
|
|
35
|
+
def __init__(self) -> None:
|
|
36
|
+
self.mcp = sentinel
|
|
37
|
+
|
|
38
|
+
mcp.add_tool(MyTool(), extra="yes")
|
|
39
|
+
assert calls["obj"] is sentinel
|
|
40
|
+
assert isinstance(calls["kwargs"], dict)
|
|
41
|
+
assert calls["kwargs"]["extra"] == "yes"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_add_tool_plain_falls_back_to_super(monkeypatch):
|
|
45
|
+
"""Non-BaseTool objects are passed unchanged to FastMCP.add_tool."""
|
|
46
|
+
calls = []
|
|
47
|
+
|
|
48
|
+
def fake_super_add(self, obj, **kwargs):
|
|
49
|
+
calls.append((obj, kwargs))
|
|
50
|
+
|
|
51
|
+
monkeypatch.setattr("hud.server.server.FastMCP.add_tool", fake_super_add, raising=True)
|
|
52
|
+
|
|
53
|
+
mcp = MCPServer(name="AddToolPlain")
|
|
54
|
+
|
|
55
|
+
async def fn(): # pragma: no cover - never awaited by FastMCP here
|
|
56
|
+
return "ok"
|
|
57
|
+
|
|
58
|
+
mcp.add_tool(fn, desc="x")
|
|
59
|
+
assert calls and calls[0][0] is fn
|
|
60
|
+
assert calls[0][1]["desc"] == "x"
|