hud-python 0.4.36__py3-none-any.whl → 0.4.38__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.

Files changed (44) hide show
  1. hud/agents/__init__.py +2 -0
  2. hud/agents/lite_llm.py +72 -0
  3. hud/agents/openai_chat_generic.py +21 -7
  4. hud/cli/__init__.py +19 -4
  5. hud/cli/build.py +17 -2
  6. hud/cli/dev.py +1 -1
  7. hud/cli/eval.py +93 -13
  8. hud/cli/flows/tasks.py +197 -65
  9. hud/cli/init.py +1 -1
  10. hud/cli/push.py +9 -0
  11. hud/cli/rl/__init__.py +14 -4
  12. hud/cli/rl/celebrate.py +187 -0
  13. hud/cli/rl/config.py +15 -8
  14. hud/cli/rl/local_runner.py +44 -20
  15. hud/cli/rl/remote_runner.py +164 -87
  16. hud/cli/rl/viewer.py +141 -0
  17. hud/cli/rl/wait_utils.py +89 -0
  18. hud/cli/utils/env_check.py +196 -0
  19. hud/cli/utils/source_hash.py +108 -0
  20. hud/clients/base.py +1 -1
  21. hud/clients/fastmcp.py +1 -1
  22. hud/otel/config.py +1 -1
  23. hud/otel/context.py +2 -2
  24. hud/rl/vllm_adapter.py +1 -1
  25. hud/server/server.py +84 -13
  26. hud/server/tests/test_add_tool.py +60 -0
  27. hud/server/tests/test_context.py +128 -0
  28. hud/server/tests/test_mcp_server_handlers.py +44 -0
  29. hud/server/tests/test_mcp_server_integration.py +405 -0
  30. hud/server/tests/test_mcp_server_more.py +247 -0
  31. hud/server/tests/test_run_wrapper.py +53 -0
  32. hud/server/tests/test_server_extra.py +166 -0
  33. hud/server/tests/test_sigterm_runner.py +78 -0
  34. hud/shared/hints.py +1 -1
  35. hud/telemetry/job.py +2 -2
  36. hud/types.py +9 -2
  37. hud/utils/tasks.py +32 -24
  38. hud/utils/tests/test_version.py +1 -1
  39. hud/version.py +1 -1
  40. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/METADATA +14 -12
  41. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/RECORD +44 -30
  42. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/WHEEL +0 -0
  43. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/entry_points.txt +0 -0
  44. {hud_python-0.4.36.dist-info → hud_python-0.4.38.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,247 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import socket
5
+ from contextlib import asynccontextmanager, suppress
6
+
7
+ import anyio
8
+ import pytest
9
+
10
+ from hud.clients import MCPClient
11
+ from hud.server import MCPServer
12
+ from hud.server import server as server_mod # to toggle _sigterm_received
13
+
14
+
15
+ def _free_port() -> int:
16
+ """Get a free port for testing."""
17
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
18
+ s.bind(("127.0.0.1", 0))
19
+ return s.getsockname()[1]
20
+
21
+
22
+ @asynccontextmanager
23
+ async def _fake_stdio_server():
24
+ """
25
+ Stand-in for mcp.server.stdio.stdio_server that avoids reading real stdin.
26
+
27
+ It yields a pair of in-memory streams (receive, send) so the low-level server
28
+ can start and idle without touching sys.stdin/sys.stdout.
29
+ """
30
+ # Server reads from recv_in and writes to send_out
31
+ send_in, recv_in = anyio.create_memory_object_stream(100) # stdin → server
32
+ send_out, recv_out = anyio.create_memory_object_stream(100) # server → stdout
33
+ try:
34
+ yield recv_in, send_out
35
+ finally:
36
+ # Best effort close; methods exist across anyio versions
37
+ for s in (send_in, recv_in, send_out, recv_out):
38
+ close = getattr(s, "close", None) or getattr(s, "aclose", None)
39
+ try:
40
+ if close is not None:
41
+ res = close()
42
+ if asyncio.iscoroutine(res):
43
+ await res
44
+ except Exception:
45
+ pass
46
+
47
+
48
+ @pytest.fixture(autouse=True)
49
+ def _patch_stdio(monkeypatch: pytest.MonkeyPatch):
50
+ """Patch stdio server for all tests to avoid stdin reading issues."""
51
+ monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
52
+ # Patch both the source and the bound symbol FastMCP uses
53
+ monkeypatch.setattr("mcp.server.stdio.stdio_server", _fake_stdio_server)
54
+ monkeypatch.setattr("fastmcp.server.server.stdio_server", _fake_stdio_server)
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_stdio_shutdown_handler_on_sigterm_flag() -> None:
59
+ """@mcp.shutdown runs on stdio transport when the SIGTERM flag is set."""
60
+ mcp = MCPServer(name="StdIOShutdown")
61
+ calls = {"n": 0}
62
+
63
+ @mcp.shutdown
64
+ async def _on_shutdown() -> None:
65
+ calls["n"] += 1
66
+
67
+ task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
68
+ try:
69
+ await asyncio.sleep(0.05)
70
+ # Simulate SIGTERM path
71
+ server_mod._sigterm_received = True # type: ignore[attr-defined]
72
+ finally:
73
+ with suppress(asyncio.CancelledError):
74
+ task.cancel()
75
+ await task
76
+
77
+ assert calls["n"] == 1
78
+ assert not getattr(server_mod, "_sigterm_received")
79
+
80
+
81
+ @pytest.mark.asyncio
82
+ async def test_stdio_shutdown_handler_not_called_without_sigterm() -> None:
83
+ """@mcp.shutdown must NOT run on stdio cancel when no SIGTERM flag."""
84
+ mcp = MCPServer(name="StdIONoSigterm")
85
+ called = {"n": 0}
86
+
87
+ @mcp.shutdown
88
+ async def _on_shutdown() -> None:
89
+ called["n"] += 1
90
+
91
+ task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
92
+ try:
93
+ await asyncio.sleep(0.05)
94
+ # no SIGTERM flag
95
+ finally:
96
+ with suppress(asyncio.CancelledError):
97
+ task.cancel()
98
+ await task
99
+
100
+ assert called["n"] == 0
101
+
102
+
103
+ @pytest.mark.asyncio
104
+ async def test_last_initialize_handler_wins_and_ctx_shape_exists() -> None:
105
+ """If multiple @initialize decorators are applied, only the last one should execute.
106
+ Also sanity-check that ctx has the expected core attributes in a version-tolerant way.
107
+ """
108
+ port = _free_port()
109
+
110
+ mcp = MCPServer(name="InitOverride")
111
+ seen = {"a": False, "b": False, "has_session": False, "has_request": False}
112
+
113
+ @mcp.initialize
114
+ async def _init_a(ctx) -> None: # type: ignore[override]
115
+ # This one should get overridden and never run
116
+ seen["a"] = True
117
+
118
+ @mcp.initialize
119
+ async def _init_b(ctx) -> None: # type: ignore[override]
120
+ # This is the one that should actually run
121
+ seen["b"] = True
122
+ seen["has_session"] = hasattr(ctx, "session") and ctx.session is not None
123
+ seen["has_request"] = hasattr(ctx, "request") and ctx.request is not None
124
+
125
+ # A simple echo tool so we can verify the server works post-init
126
+ @mcp.tool()
127
+ async def echo(text: str = "ok") -> str: # type: ignore[override]
128
+ return f"echo:{text}"
129
+
130
+ # Start HTTP transport (quickest way to use a real client)
131
+ task = asyncio.create_task(
132
+ mcp.run_async(
133
+ transport="http",
134
+ host="127.0.0.1",
135
+ port=port,
136
+ path="/mcp",
137
+ log_level="ERROR",
138
+ show_banner=False,
139
+ )
140
+ )
141
+ await asyncio.sleep(0.05)
142
+
143
+ try:
144
+ cfg = {"srv": {"url": f"http://127.0.0.1:{port}/mcp"}}
145
+ c = MCPClient(mcp_config=cfg, auto_trace=False, verbose=False)
146
+ await c.initialize()
147
+
148
+ # Call a tool to ensure init didn't break anything
149
+ res = await c.call_tool(name="echo", arguments={"text": "ping"})
150
+ text = getattr(res, "content", None)
151
+ if isinstance(text, list) and text and hasattr(text[0], "text"):
152
+ text = text[0].text
153
+ assert text == "echo:ping"
154
+
155
+ await c.shutdown()
156
+ finally:
157
+ with suppress(asyncio.CancelledError):
158
+ task.cancel()
159
+ await task
160
+
161
+ # Only the last initializer should have run
162
+ assert seen["a"] is False
163
+ assert seen["b"] is True
164
+ # And the ctx had the key attributes (shape may vary by lib version; just presence)
165
+ assert seen["has_session"] is True
166
+ assert seen["has_request"] is True
167
+
168
+
169
+ @pytest.mark.asyncio
170
+ async def test_stdio_shutdown_handler_runs_once_when_both_paths_fire() -> None:
171
+ """Even on stdio, when SIGTERM is set, ensure shutdown runs exactly once."""
172
+ mcp = MCPServer(name="StdIOOnce")
173
+ calls = {"n": 0}
174
+
175
+ @mcp.shutdown
176
+ async def _on_shutdown() -> None:
177
+ calls["n"] += 1
178
+
179
+ task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
180
+ try:
181
+ await asyncio.sleep(0.05)
182
+ # Make both the lifespan.finally and run_async.finally want to execute
183
+ server_mod._sigterm_received = True # type: ignore[attr-defined]
184
+ finally:
185
+ with suppress(asyncio.CancelledError):
186
+ task.cancel()
187
+ await task
188
+
189
+ assert calls["n"] == 1
190
+ # Reset global flag always
191
+ server_mod._sigterm_received = False # type: ignore[attr-defined]
192
+
193
+
194
+ @pytest.mark.asyncio
195
+ async def test_run_async_defaults_to_stdio_and_uses_patched_stdio(monkeypatch: pytest.MonkeyPatch):
196
+ """transport=None should default to stdio and use our patched stdio server."""
197
+ entered = {"v": False}
198
+
199
+ @asynccontextmanager
200
+ async def tracking_stdio():
201
+ entered["v"] = True
202
+ async with _fake_stdio_server() as streams:
203
+ yield streams
204
+
205
+ # Override the autouse fixture for this test to track entry
206
+ monkeypatch.setattr("fastmcp.server.server.stdio_server", tracking_stdio)
207
+
208
+ mcp = MCPServer(name="DefaultStdio")
209
+ task = asyncio.create_task(mcp.run_async(transport=None, show_banner=False))
210
+ try:
211
+ await asyncio.sleep(0.05)
212
+ assert entered["v"] is True, "Expected stdio transport to be used by default"
213
+ finally:
214
+ with suppress(asyncio.CancelledError):
215
+ task.cancel()
216
+ await task
217
+
218
+
219
+ @pytest.mark.asyncio
220
+ async def test_custom_lifespan_relies_on_run_async_fallback_for_sigterm() -> None:
221
+ """When a custom lifespan is supplied, run_async's finally path must still call
222
+ @shutdown on SIGTERM."""
223
+
224
+ @asynccontextmanager
225
+ async def custom_lifespan(_):
226
+ # No shutdown call here on purpose
227
+ yield {}
228
+
229
+ mcp = MCPServer(name="CustomLS", lifespan=custom_lifespan)
230
+ calls = {"n": 0}
231
+
232
+ @mcp.shutdown
233
+ async def _s() -> None:
234
+ calls["n"] += 1
235
+
236
+ task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
237
+ try:
238
+ await asyncio.sleep(0.05)
239
+ # Ensure finalizer believes SIGTERM happened
240
+ server_mod._sigterm_received = True # type: ignore[attr-defined]
241
+ finally:
242
+ with suppress(asyncio.CancelledError):
243
+ task.cancel()
244
+ await task
245
+
246
+ assert calls["n"] == 1
247
+ assert not getattr(server_mod, "_sigterm_received")
@@ -0,0 +1,53 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from typing import TYPE_CHECKING
5
+
6
+ from hud.server import MCPServer
7
+
8
+ if TYPE_CHECKING:
9
+ import pytest
10
+
11
+
12
+ def test_run_uses_sigterm_wrapper(monkeypatch: pytest.MonkeyPatch) -> None:
13
+ """MCPServer.run should delegate through _run_with_sigterm (don't actually start a server)."""
14
+ called = {"hit": False, "args": None, "kwargs": None}
15
+
16
+ def fake_wrapper(coro_fn, *args, **kwargs):
17
+ called["hit"] = True
18
+ called["args"] = args
19
+ called["kwargs"] = kwargs
20
+ # Do not execute the bootstrap coroutine; this is unit wiring only.
21
+
22
+ monkeypatch.setattr("hud.server.server._run_with_sigterm", fake_wrapper)
23
+
24
+ mcp = MCPServer(name="RunWrapper")
25
+ # Should immediately return after calling our fake wrapper
26
+ mcp.run(transport="http", host="127.0.0.1", port=9999, path="/mcp", show_banner=False)
27
+
28
+ assert called["hit"] is True
29
+
30
+
31
+ def test_run_defaults_to_stdio(monkeypatch: pytest.MonkeyPatch) -> None:
32
+ """transport=None in .run should resolve to 'stdio' and forward to run_async."""
33
+ seen = {}
34
+
35
+ async def fake_run_async(self, *, transport, show_banner, **kwargs):
36
+ seen["transport"] = transport
37
+ seen["show_banner"] = show_banner
38
+ seen["kwargs"] = kwargs
39
+
40
+ # Replace bound method on the instance class
41
+ monkeypatch.setattr(MCPServer, "run_async", fake_run_async, raising=False)
42
+
43
+ # Execute the bootstrap coroutine immediately (no real server)
44
+ def fake_wrapper(coro_fn, *args, **kwargs):
45
+ asyncio.run(coro_fn())
46
+
47
+ monkeypatch.setattr("hud.server.server._run_with_sigterm", fake_wrapper)
48
+
49
+ mcp = MCPServer(name="RunDefaultTransport")
50
+ mcp.run(transport=None, show_banner=False)
51
+
52
+ assert seen["transport"] == "stdio"
53
+ assert seen["show_banner"] is False
@@ -0,0 +1,166 @@
1
+ # filename: hud/server/tests/test_server_extra.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ from contextlib import asynccontextmanager, suppress
6
+
7
+ import anyio
8
+ import pytest
9
+
10
+ from hud.server import MCPServer
11
+ from hud.server import server as server_mod
12
+
13
+
14
+ @asynccontextmanager
15
+ async def _fake_stdio_server():
16
+ """
17
+ Stand-in for stdio_server that avoids reading real stdin.
18
+
19
+ It yields a pair of in-memory streams (receive, send) so the low-level server
20
+ can start and idle without touching sys.stdin/sys.stdout.
21
+ """
22
+ send_in, recv_in = anyio.create_memory_object_stream(100)
23
+ send_out, recv_out = anyio.create_memory_object_stream(100)
24
+ try:
25
+ yield recv_in, send_out
26
+ finally:
27
+ # best-effort close across anyio versions
28
+ for s in (send_in, recv_in, send_out, recv_out):
29
+ close = getattr(s, "close", None) or getattr(s, "aclose", None)
30
+ try:
31
+ if close is not None:
32
+ res = close()
33
+ if asyncio.iscoroutine(res):
34
+ await res
35
+ except Exception:
36
+ pass
37
+
38
+
39
+ @pytest.fixture
40
+ def patch_stdio(monkeypatch: pytest.MonkeyPatch):
41
+ """Patch stdio server to avoid stdin issues during tests."""
42
+ monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
43
+ monkeypatch.setattr("mcp.server.stdio.stdio_server", _fake_stdio_server)
44
+ monkeypatch.setattr("fastmcp.server.server.stdio_server", _fake_stdio_server)
45
+
46
+
47
+ @pytest.mark.asyncio
48
+ async def test_sigterm_flag_remains_true_without_shutdown_handler(patch_stdio):
49
+ """
50
+ When no @mcp.shutdown is registered, neither the lifespan.finally nor run_async.finally
51
+ should reset the global SIGTERM flag. This exercises the 'no handler' branches.
52
+ """
53
+ mcp = MCPServer(name="NoShutdownHandler")
54
+
55
+ task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
56
+ try:
57
+ await asyncio.sleep(0.05)
58
+ # Simulate SIGTERM path
59
+ server_mod._sigterm_received = True # type: ignore[attr-defined]
60
+ finally:
61
+ with suppress(asyncio.CancelledError):
62
+ task.cancel()
63
+ await task
64
+
65
+ # Flag must remain set since no shutdown handler was installed
66
+ assert getattr(server_mod, "_sigterm_received") is True
67
+
68
+ # Always reset for other tests
69
+ server_mod._sigterm_received = False # type: ignore[attr-defined]
70
+
71
+
72
+ @pytest.mark.asyncio
73
+ async def test_last_shutdown_handler_wins(patch_stdio):
74
+ """
75
+ If multiple @mcp.shutdown decorators are applied, the last one should be the one that runs.
76
+ """
77
+ mcp = MCPServer(name="ShutdownOverride")
78
+ calls: list[str] = []
79
+
80
+ @mcp.shutdown
81
+ async def _first() -> None:
82
+ calls.append("first")
83
+
84
+ @mcp.shutdown
85
+ async def _second() -> None:
86
+ calls.append("second")
87
+
88
+ task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
89
+ try:
90
+ await asyncio.sleep(0.05)
91
+ server_mod._sigterm_received = True # type: ignore[attr-defined]
92
+ finally:
93
+ with suppress(asyncio.CancelledError):
94
+ task.cancel()
95
+ await task
96
+
97
+ assert calls == ["second"], "Only the last registered shutdown handler should run"
98
+ server_mod._sigterm_received = False # type: ignore[attr-defined]
99
+
100
+
101
+ def test__run_with_sigterm_registers_handlers_when_enabled(monkeypatch: pytest.MonkeyPatch):
102
+ """
103
+ Verify that _run_with_sigterm attempts to register SIGTERM/SIGINT handlers
104
+ when the env var does NOT disable the handler. We stub AnyIO's TaskGroup so
105
+ the watcher doesn't block and the test returns immediately.
106
+ """
107
+ # Ensure handler is enabled
108
+ monkeypatch.delenv("FASTMCP_DISABLE_SIGTERM_HANDLER", raising=False)
109
+
110
+ # Record what the server tries to register
111
+ added_signals: list[int] = []
112
+
113
+ import asyncio as _asyncio
114
+
115
+ orig_get_running_loop = _asyncio.get_running_loop
116
+
117
+ def proxy_get_running_loop():
118
+ real = orig_get_running_loop()
119
+
120
+ class _LoopProxy:
121
+ __slots__ = ("_inner",)
122
+
123
+ def __init__(self, inner):
124
+ self._inner = inner
125
+
126
+ def add_signal_handler(self, signum, callback, *args):
127
+ added_signals.append(signum) # don't actually install
128
+ # no-op: skip calling inner.add_signal_handler to avoid OS constraints
129
+
130
+ def __getattr__(self, name):
131
+ # delegate everything else (create_task, call_soon, etc.)
132
+ return getattr(self._inner, name)
133
+
134
+ return _LoopProxy(real)
135
+
136
+ # Patch globally so both the test and hud.server.server see the proxy
137
+ monkeypatch.setattr(_asyncio, "get_running_loop", proxy_get_running_loop)
138
+
139
+ # Dummy TaskGroup that runs the work but skips _watch
140
+ class _DummyTG:
141
+ async def __aenter__(self):
142
+ return self
143
+
144
+ async def __aexit__(self, exc_type, exc, tb):
145
+ return False
146
+
147
+ def start_soon(self, fn, *args, **kwargs):
148
+ if getattr(fn, "__name__", "") == "_watch":
149
+ return
150
+ _asyncio.get_running_loop().create_task(fn(*args, **kwargs))
151
+
152
+ monkeypatch.setattr("anyio.create_task_group", lambda: _DummyTG())
153
+
154
+ # Simple coroutine that should run to completion
155
+ hit = {"v": False}
156
+
157
+ async def work():
158
+ hit["v"] = True
159
+
160
+ server_mod._run_with_sigterm(work)
161
+ assert hit["v"] is True
162
+
163
+ import signal as _signal
164
+
165
+ assert _signal.SIGTERM in added_signals
166
+ assert _signal.SIGINT in added_signals
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from contextlib import asynccontextmanager, suppress
5
+
6
+ import anyio
7
+ import pytest
8
+
9
+ from hud.server import MCPServer
10
+ from hud.server import server as server_mod
11
+
12
+
13
+ def test__run_with_sigterm_executes_coro_when_handler_disabled(monkeypatch: pytest.MonkeyPatch):
14
+ """With FASTMCP_DISABLE_SIGTERM_HANDLER=1, _run_with_sigterm should just run the task."""
15
+ monkeypatch.setenv("FASTMCP_DISABLE_SIGTERM_HANDLER", "1")
16
+
17
+ hit = {"v": False}
18
+
19
+ async def work(arg, *, kw=None):
20
+ assert arg == 123 and kw == "ok"
21
+ hit["v"] = True
22
+
23
+ # Wrapper to exercise kwargs since TaskGroup.start_soon only accepts positional args
24
+ async def wrapper(arg):
25
+ await work(arg, kw="ok")
26
+
27
+ # Should return cleanly and mark hit
28
+ server_mod._run_with_sigterm(wrapper, 123)
29
+ assert hit["v"] is True
30
+
31
+
32
+ @asynccontextmanager
33
+ async def _fake_stdio_server():
34
+ """Stand-in for stdio_server that avoids reading real stdin."""
35
+ send_in, recv_in = anyio.create_memory_object_stream(100)
36
+ send_out, recv_out = anyio.create_memory_object_stream(100)
37
+ try:
38
+ yield recv_in, send_out
39
+ finally:
40
+ for s in (send_in, recv_in, send_out, recv_out):
41
+ close = getattr(s, "close", None) or getattr(s, "aclose", None)
42
+ try:
43
+ if close is not None:
44
+ res = close()
45
+ if asyncio.iscoroutine(res):
46
+ await res
47
+ except Exception:
48
+ pass
49
+
50
+
51
+ @pytest.fixture
52
+ def patch_stdio(monkeypatch: pytest.MonkeyPatch):
53
+ """Patch stdio server to avoid stdin issues during tests."""
54
+ monkeypatch.setenv("FASTMCP_DISABLE_BANNER", "1")
55
+ monkeypatch.setattr("mcp.server.stdio.stdio_server", _fake_stdio_server)
56
+ monkeypatch.setattr("fastmcp.server.server.stdio_server", _fake_stdio_server)
57
+
58
+
59
+ @pytest.mark.asyncio
60
+ async def test_shutdown_handler_exception_does_not_crash_and_resets_flag(patch_stdio):
61
+ """If @shutdown raises, run_async must swallow it and still reset the SIGTERM flag."""
62
+ mcp = MCPServer(name="ShutdownRaises")
63
+
64
+ @mcp.shutdown
65
+ async def _boom() -> None:
66
+ raise RuntimeError("kaboom")
67
+
68
+ task = asyncio.create_task(mcp.run_async(transport="stdio", show_banner=False))
69
+ try:
70
+ await asyncio.sleep(0.05)
71
+ server_mod._sigterm_received = True # trigger shutdown path
72
+ finally:
73
+ with suppress(asyncio.CancelledError):
74
+ task.cancel()
75
+ await task
76
+
77
+ # No exception propagated; flag must be reset
78
+ assert not getattr(server_mod, "_sigterm_received")
hud/shared/hints.py CHANGED
@@ -38,7 +38,7 @@ HUD_API_KEY_MISSING = Hint(
38
38
  message="Missing or invalid HUD_API_KEY.",
39
39
  tips=[
40
40
  "Set HUD_API_KEY in your environment or run: hud set HUD_API_KEY=your-key-here",
41
- "Get a key at https://app.hud.so",
41
+ "Get a key at https://hud.so",
42
42
  "Check for whitespace or truncation",
43
43
  ],
44
44
  docs_url=None,
hud/telemetry/job.py CHANGED
@@ -143,7 +143,7 @@ def _print_job_url(job_id: str, job_name: str) -> None:
143
143
  if not (settings.telemetry_enabled and settings.api_key):
144
144
  return
145
145
 
146
- url = f"https://app.hud.so/jobs/{job_id}"
146
+ url = f"https://hud.so/jobs/{job_id}"
147
147
  header = f"🚀 Job '{job_name}' started:"
148
148
 
149
149
  # ANSI color codes
@@ -182,7 +182,7 @@ def _print_job_complete_url(job_id: str, job_name: str, error_occurred: bool = F
182
182
  if not (settings.telemetry_enabled and settings.api_key):
183
183
  return
184
184
 
185
- url = f"https://app.hud.so/jobs/{job_id}"
185
+ url = f"https://hud.so/jobs/{job_id}"
186
186
 
187
187
  # ANSI color codes
188
188
  GREEN = "\033[92m"
hud/types.py CHANGED
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import contextlib
3
4
  import json
4
5
  import logging
5
6
  import uuid
@@ -107,7 +108,13 @@ class Task(BaseModel):
107
108
 
108
109
  # Start with current environment variables
109
110
  mapping = dict(os.environ)
110
- mapping.update(settings.model_dump())
111
+ # Include settings (from process env, project .env, and user .env)
112
+ settings_dict = settings.model_dump()
113
+ mapping.update(settings_dict)
114
+ # Add UPPERCASE aliases for settings keys
115
+ for _key, _val in settings_dict.items():
116
+ with contextlib.suppress(Exception):
117
+ mapping[_key.upper()] = _val
111
118
 
112
119
  if settings.api_key:
113
120
  mapping["HUD_API_KEY"] = settings.api_key
@@ -208,7 +215,7 @@ class AgentResponse(BaseModel):
208
215
  tool_calls: list[MCPToolCall] = Field(default_factory=list)
209
216
  done: bool = Field(default=False)
210
217
 
211
- # --- TELEMETRY [app.hud.so] ---
218
+ # --- TELEMETRY [hud.so] ---
212
219
  # Responses
213
220
  content: str | None = Field(default=None)
214
221
  reasoning: str | None = Field(default=None)