hud-python 0.4.48__py3-none-any.whl → 0.4.49__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/base.py CHANGED
@@ -7,7 +7,7 @@ import fnmatch
7
7
  import json
8
8
  import logging
9
9
  from abc import ABC, abstractmethod
10
- from typing import TYPE_CHECKING, Any, ClassVar, List, Literal
10
+ from typing import TYPE_CHECKING, Any, ClassVar, Literal
11
11
 
12
12
  import mcp.types as types
13
13
 
@@ -97,9 +97,9 @@ class MCPAgent(ABC):
97
97
  self.console.set_verbose(True)
98
98
 
99
99
  # User filtering
100
- self.allowed_tools: List[str] | None = allowed_tools
101
- self.disallowed_tools: List[str] | None = disallowed_tools
102
- self._available_tools: List[types.Tool] | None = None
100
+ self.allowed_tools: list[str] | None = allowed_tools
101
+ self.disallowed_tools: list[str] | None = disallowed_tools
102
+ self._available_tools: list[types.Tool] | None = None
103
103
 
104
104
  # Messages
105
105
  self.system_prompt = system_prompt
@@ -144,28 +144,30 @@ class MCPAgent(ABC):
144
144
  self._handle_connection_error(e)
145
145
 
146
146
  # If task is provided, apply agent_config and add lifecycle tools
147
- if isinstance(task, Task):
148
- # Apply agent_config if present
149
- if task.agent_config:
150
- if "system_prompt" in task.agent_config and task.agent_config["system_prompt"]:
151
- self.system_prompt += "\n\n" + task.agent_config["system_prompt"]
152
- if "append_setup_output" in task.agent_config:
153
- self.append_setup_output = task.agent_config["append_setup_output"]
154
- if "initial_screenshot" in task.agent_config:
155
- self.initial_screenshot = task.agent_config["initial_screenshot"]
156
- if "allowed_tools" in task.agent_config:
157
- # If allowed_tools has already been set, we take the intersection of the two
158
- # If the list had been empty, we were allowing all tools, so we overwrite in this
159
- if isinstance(self.allowed_tools, list) and len(self.allowed_tools) > 0:
160
- self.allowed_tools = [tool for tool in self.allowed_tools if tool in task.agent_config["allowed_tools"]]
161
- else: # If allowed_tools is None, we overwrite it
162
- self.allowed_tools = task.agent_config["allowed_tools"]
163
- if "disallowed_tools" in task.agent_config:
164
- # If disallowed_tools has already been set, we take the union of the two
165
- if isinstance(self.disallowed_tools, list):
166
- self.disallowed_tools.extend(task.agent_config["disallowed_tools"])
167
- else: # If disallowed_tools is None, we overwrite it
168
- self.disallowed_tools = task.agent_config["disallowed_tools"]
147
+ if isinstance(task, Task) and task.agent_config:
148
+ if task.agent_config.get("system_prompt"):
149
+ self.system_prompt += "\n\n" + task.agent_config["system_prompt"]
150
+ if "append_setup_output" in task.agent_config:
151
+ self.append_setup_output = task.agent_config["append_setup_output"]
152
+ if "initial_screenshot" in task.agent_config:
153
+ self.initial_screenshot = task.agent_config["initial_screenshot"]
154
+ if "allowed_tools" in task.agent_config:
155
+ # If allowed_tools has already been set, we take the intersection of the two
156
+ # If the list had been empty, we were allowing all tools, so we overwrite this
157
+ if isinstance(self.allowed_tools, list) and len(self.allowed_tools) > 0:
158
+ self.allowed_tools = [
159
+ tool
160
+ for tool in self.allowed_tools
161
+ if tool in task.agent_config["allowed_tools"]
162
+ ]
163
+ else: # If allowed_tools is None, we overwrite it
164
+ self.allowed_tools = task.agent_config["allowed_tools"]
165
+ if "disallowed_tools" in task.agent_config:
166
+ # If disallowed_tools has already been set, we take the union of the two
167
+ if isinstance(self.disallowed_tools, list):
168
+ self.disallowed_tools.extend(task.agent_config["disallowed_tools"])
169
+ else: # If disallowed_tools is None, we overwrite it
170
+ self.disallowed_tools = task.agent_config["disallowed_tools"]
169
171
 
170
172
  all_tools = await self.mcp_client.list_tools()
171
173
  self._available_tools = []
@@ -174,14 +176,16 @@ class MCPAgent(ABC):
174
176
  # No allowed tools and no disallowed tools -> we accept all tools
175
177
  # No allowed tools and disallowed tools -> we accept all tools except the disallowed ones
176
178
  for tool in all_tools:
177
- if self.allowed_tools is not None:
178
- if not any(fnmatch.fnmatch(tool.name, pattern) for pattern in self.allowed_tools):
179
- continue
180
- if self.disallowed_tools is not None:
181
- if any(fnmatch.fnmatch(tool.name, pattern) for pattern in self.disallowed_tools):
182
- continue
179
+ if self.allowed_tools is not None and not any(
180
+ fnmatch.fnmatch(tool.name, pattern) for pattern in self.allowed_tools
181
+ ):
182
+ continue
183
+ if self.disallowed_tools is not None and any(
184
+ fnmatch.fnmatch(tool.name, pattern) for pattern in self.disallowed_tools
185
+ ):
186
+ continue
183
187
  self._available_tools.append(tool)
184
-
188
+
185
189
  self.console.info(
186
190
  f"Agent initialized with {len(self.get_available_tools())} tools: {', '.join([t.name for t in self.get_available_tools()])}" # noqa: E501
187
191
  )
@@ -622,7 +626,9 @@ class MCPAgent(ABC):
622
626
  def get_available_tools(self) -> list[types.Tool]:
623
627
  """Get list of available MCP tools for LLM use (excludes lifecycle tools)."""
624
628
  if self._available_tools is None:
625
- raise RuntimeError("Tools have not been initialized. Call initialize() before accessing available tools.")
629
+ raise RuntimeError(
630
+ "Tools have not been initialized. Call initialize() before accessing available tools." # noqa: E501
631
+ )
626
632
  return self._available_tools
627
633
 
628
634
  def get_tool_schemas(self) -> list[dict]:
@@ -169,7 +169,7 @@ class GroundedOpenAIChatAgent(GenericOpenAIChatAgent):
169
169
  protected_keys = {"model", "messages", "tools", "parallel_tool_calls"}
170
170
  extra = {k: v for k, v in (self.completion_kwargs or {}).items() if k not in protected_keys}
171
171
 
172
- response = await self.oai.chat.completions.create(
172
+ response = await self.oai.chat.completions.create( # type: ignore
173
173
  model=self.model_name,
174
174
  messages=messages,
175
175
  tools=tool_schemas,
hud/cli/__init__.py CHANGED
@@ -3,7 +3,6 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
- import contextlib
7
6
  import json
8
7
  import sys
9
8
  from pathlib import Path
@@ -39,6 +38,7 @@ app = typer.Typer(
39
38
  help="🚀 HUD CLI for MCP environment analysis and debugging",
40
39
  add_completion=False,
41
40
  rich_markup_mode="rich",
41
+ pretty_exceptions_enable=False, # Disable Rich's verbose tracebacks
42
42
  )
43
43
 
44
44
  console = Console()
@@ -352,76 +352,71 @@ def version() -> None:
352
352
  def dev(
353
353
  params: list[str] = typer.Argument( # type: ignore[arg-type] # noqa: B008
354
354
  None,
355
- help="Environment directory followed by optional Docker arguments (e.g., '. -e KEY=value')",
355
+ help="Module path or extra Docker args (when using --docker)",
356
356
  ),
357
- image: str | None = typer.Option(
358
- None, "--image", "-i", help="Docker image name (overrides auto-detection)"
359
- ),
360
- build: bool = typer.Option(False, "--build", "-b", help="Build image before starting"),
361
- no_cache: bool = typer.Option(False, "--no-cache", help="Force rebuild without cache"),
362
- transport: str = typer.Option(
363
- "http", "--transport", "-t", help="Transport protocol: http (default) or stdio"
357
+ docker: bool = typer.Option(
358
+ False,
359
+ "--docker",
360
+ help="Run in Docker with volume mounts for hot-reload (for complex environments)",
364
361
  ),
365
- port: int = typer.Option(8765, "--port", "-p", help="HTTP server port (ignored for stdio)"),
366
- no_reload: bool = typer.Option(False, "--no-reload", help="Disable hot-reload"),
367
- full_reload: bool = typer.Option(
362
+ stdio: bool = typer.Option(
368
363
  False,
369
- "--full-reload",
370
- help="Restart entire container on file changes (instead of just server process)",
364
+ "--stdio",
365
+ help="Use stdio transport (default: HTTP)",
371
366
  ),
372
- verbose: bool = typer.Option(False, "--verbose", "-v", help="Show server logs"),
367
+ port: int = typer.Option(8765, "--port", "-p", help="HTTP server port (ignored for stdio)"),
368
+ verbose: bool = typer.Option(False, "--verbose", "-v", help="Show detailed logs"),
373
369
  inspector: bool = typer.Option(
374
370
  False, "--inspector", help="Launch MCP Inspector (HTTP mode only)"
375
371
  ),
376
- no_logs: bool = typer.Option(False, "--no-logs", help="Disable streaming Docker logs"),
377
372
  interactive: bool = typer.Option(
378
373
  False, "--interactive", help="Launch interactive testing mode (HTTP mode only)"
379
374
  ),
375
+ watch: list[str] = typer.Option( # noqa: B008
376
+ None,
377
+ "--watch",
378
+ help="Additional directories to watch for changes (default: current directory)",
379
+ ),
380
380
  ) -> None:
381
- """🔥 Development mode - interactive MCP environment.
381
+ """🔥 Development mode - run MCP server with hot-reload.
382
+
383
+ TWO MODES:
384
+
385
+ 1. Python Module:
386
+ hud dev # Auto-detects module
387
+ hud dev server.main # Explicit module
382
388
 
383
- Runs your MCP environment in Docker with mounted source for development.
384
- The container's CMD determines reload behavior.
389
+ 2. Docker with Volume Mounts (Complex environments like 'browser'):
390
+ hud dev --docker # Auto-detects image from hud.lock.yaml
391
+ hud dev --docker -p 8080:8080 # With extra Docker args
392
+
393
+ The server must define 'mcp' in its __init__.py or main.py.
385
394
 
386
395
  Examples:
387
396
  hud dev # Auto-detect in current directory
388
- hud dev environments/browser # Specific directory
389
- hud dev . --build # Build image first
390
- hud dev . --image custom:tag # Use specific image
391
- hud dev . --no-cache # Force clean rebuild
392
- hud dev . --verbose # Show detailed logs
393
- hud dev . --transport stdio # Use stdio proxy for multiple connections
394
- hud dev . --inspector # Launch MCP Inspector (HTTP mode only)
395
- hud dev . --interactive # Launch interactive testing mode (HTTP mode only)
396
- hud dev . --no-logs # Disable Docker log streaming
397
-
398
- # With Docker arguments (after all options):
399
- hud dev . -e BROWSER_PROVIDER=anchorbrowser -e ANCHOR_API_KEY=xxx
400
- hud dev . -e API_KEY=secret -v /tmp/data:/data --network host
401
- hud dev . --build -e DEBUG=true --memory 2g
397
+ hud dev controller # Run specific module
398
+ hud dev --inspector # Launch MCP Inspector
399
+ hud dev --interactive # Launch interactive testing mode
400
+ hud dev --stdio # Use stdio transport
401
+ hud dev --watch ../shared # Watch additional directories
402
+
403
+ For environment backend servers, use uvicorn directly:
404
+ uvicorn server:app --reload
402
405
  """
403
- # Parse directory and Docker arguments
404
- if params:
405
- directory = params[0]
406
- docker_args = params[1:] if len(params) > 1 else []
407
- else:
408
- directory = "."
409
- docker_args = []
406
+ # Extract module from params if provided (first param when not --docker)
407
+ module = params[0] if params and not docker else None
408
+ docker_args = params if docker else []
410
409
 
411
410
  run_mcp_dev_server(
412
- directory,
413
- image,
414
- build,
415
- no_cache,
416
- transport,
411
+ module,
412
+ stdio,
417
413
  port,
418
- no_reload,
419
- full_reload,
420
414
  verbose,
421
415
  inspector,
422
- no_logs,
423
416
  interactive,
424
- docker_args,
417
+ watch,
418
+ docker=docker,
419
+ docker_args=docker_args,
425
420
  )
426
421
 
427
422
 
@@ -429,18 +424,14 @@ def dev(
429
424
  def run(
430
425
  params: list[str] = typer.Argument( # type: ignore[arg-type] # noqa: B008
431
426
  None,
432
- help="Python file/module/package or Docker image followed by optional arguments",
427
+ help="Docker image followed by optional Docker run arguments "
428
+ "(e.g., 'my-image:latest -e KEY=value')",
433
429
  ),
434
430
  local: bool = typer.Option(
435
431
  False,
436
432
  "--local",
437
433
  help="Run locally with Docker (default: remote via mcp.hud.so)",
438
434
  ),
439
- remote: bool = typer.Option(
440
- False,
441
- "--remote",
442
- help="Run remotely via mcp.hud.so (default)",
443
- ),
444
435
  transport: str = typer.Option(
445
436
  "stdio",
446
437
  "--transport",
@@ -474,180 +465,54 @@ def run(
474
465
  "-v",
475
466
  help="Show detailed output",
476
467
  ),
477
- interactive: bool = typer.Option(
478
- False,
479
- "--interactive",
480
- help="Launch interactive testing mode (HTTP transport only)",
481
- ),
482
- reload: bool = typer.Option(
483
- False,
484
- "--reload",
485
- help="Enable auto-reload on file changes (local Python files only)",
486
- ),
487
- watch: list[str] = typer.Option( # noqa: B008
488
- None,
489
- "--watch",
490
- help="Directories to watch for changes (can be used multiple times). Defaults to current directory.", # noqa: E501
491
- ),
492
- cmd: str | None = typer.Option(
493
- None,
494
- "--cmd",
495
- help="Command to run as MCP server (e.g., 'python -m controller')",
496
- ),
497
468
  ) -> None:
498
- """🚀 Run MCP server.
469
+ """🚀 Run Docker image as MCP server.
499
470
 
500
- Modes:
501
- - Python (decorator-based): pass a dotted module path. Example: hud run controller
502
- The module is imported, decorators register implicitly, and the server runs.
503
- Use --reload to watch the module/package directory.
471
+ A simple wrapper around 'docker run' that can launch images locally or remotely.
472
+ By default, runs remotely via mcp.hud.so. Use --local to run with local Docker.
504
473
 
505
- - Command: use --cmd to run any command as an MCP server. Example: hud run --cmd "python -m controller"
506
- Works with Docker, binaries, or any executable. Supports --reload.
474
+ For local Python development with hot-reload, use 'hud dev' instead.
507
475
 
508
- - Docker image: pass a Docker image name (optionally with --local to run locally).
509
- """ # noqa: E501
510
- if not params and not cmd:
511
- typer.echo("❌ Dotted module path, Docker image, or --cmd is required")
476
+ Examples:
477
+ hud run my-image:latest # Run remotely (default)
478
+ hud run my-image:latest --local # Run with local Docker
479
+ hud run my-image:latest -e KEY=value # Remote with env vars
480
+ hud run my-image:latest --local -e KEY=val # Local with env vars
481
+ hud run my-image:latest --transport http # Use HTTP transport
482
+ """
483
+ if not params:
484
+ console.print("[red]❌ Docker image is required[/red]")
485
+ console.print("\nExamples:")
486
+ console.print(" hud run my-image:latest # Run remotely (default)")
487
+ console.print(" hud run my-image:latest --local # Run with local Docker")
488
+ console.print("\n[yellow]For local Python development:[/yellow]")
489
+ console.print(" hud dev # Run with hot-reload")
512
490
  raise typer.Exit(1)
513
491
 
514
- # Handle --cmd mode
515
- if cmd:
516
- import asyncio
517
-
518
- from .utils.package_runner import run_package_as_mcp
519
-
520
- asyncio.run(
521
- run_package_as_mcp(
522
- cmd, # Pass command string
523
- transport=transport,
524
- port=port,
525
- verbose=verbose,
526
- reload=reload,
527
- watch_paths=watch if watch else None,
528
- )
529
- )
530
- return
531
-
532
- first_param = params[0]
533
- extra_args = params[1:] if len(params) > 1 else []
534
-
535
- # Guard: strip accidental nested 'run' token from positional args,
536
- # which can happen with nested invocations or reload wrappers.
537
- if first_param == "run" and extra_args:
538
- first_param, extra_args = extra_args[0], extra_args[1:]
539
-
540
- # Try to interpret first_param as module[:attr] or file[:attr]
541
- target = first_param
542
- server_attr = "mcp"
543
- if ":" in target:
544
- target, server_attr = target.split(":", 1)
545
-
546
- # Only allow dotted import paths or python files for Python mode
547
- import importlib.util as _importlib_util
492
+ image = params[0]
493
+ docker_args = params[1:] if len(params) > 1 else []
548
494
 
549
- # Ensure current working directory is importable for local packages like 'controller'
550
- try:
551
- import sys as _sys
552
- from pathlib import Path as _Path
553
-
554
- cwd_str = str(_Path.cwd())
555
- if cwd_str not in _sys.path:
556
- _sys.path.insert(0, cwd_str)
557
- except Exception: # noqa: S110
558
- pass
559
- try:
560
- # If given a file path, detect and import via file spec
561
- from pathlib import Path as _Path
562
-
563
- if target.endswith(".py") and _Path(target).exists():
564
- spec = _importlib_util.spec_from_file_location("_hud_module", target)
565
- else:
566
- spec = _importlib_util.find_spec(target)
567
- except Exception:
568
- spec = None
569
-
570
- # Fallback: treat a local package directory (e.g. 'controller') as a module target
571
- from pathlib import Path as _Path
572
-
573
- pkg_dir = _Path(target)
574
- is_pkg_dir = pkg_dir.is_dir() and (pkg_dir / "__init__.py").exists()
575
-
576
- is_python_target = (spec is not None) or is_pkg_dir
577
-
578
- if is_python_target and not (local or remote):
579
- # Python file/package mode - use implicit MCP server
580
- import asyncio
581
-
582
- from .utils.package_runner import run_package_as_mcp, run_with_reload
583
-
584
- if reload:
585
- # Run with watchfiles reload
586
- # Use user-provided watch paths or compute from module
587
- if watch:
588
- watch_paths = watch
589
- else:
590
- # Compute a watch path that works for dotted modules as well
591
- watch_paths = [target]
592
- if spec is not None:
593
- origin = getattr(spec, "origin", None)
594
- sublocs = getattr(spec, "submodule_search_locations", None)
595
- if origin:
596
- p = _Path(origin)
597
- # If package __init__.py, watch the package directory
598
- watch_paths = [str(p.parent if p.name == "__init__.py" else p)]
599
- elif sublocs:
600
- with contextlib.suppress(Exception):
601
- watch_paths = [next(iter(sublocs))]
602
-
603
- # Always run as subprocess when using reload to enable proper file watching
604
- # This ensures the parent process can watch files while the child runs the server
605
- run_with_reload(
606
- None, # This forces subprocess mode for both stdio and http
607
- watch_paths,
608
- verbose=verbose,
609
- )
610
- else:
611
- # Run normally (but still pass reload=False for consistency)
612
- asyncio.run(
613
- run_package_as_mcp(
614
- target,
615
- transport=transport,
616
- port=port,
617
- verbose=verbose,
618
- server_attr=server_attr,
619
- reload=False, # Explicitly pass reload state
620
- watch_paths=None,
621
- )
622
- )
623
- return
624
-
625
- # Docker image mode
626
- image = first_param
627
- docker_args = extra_args
495
+ # Check if user accidentally passed a module path
496
+ from pathlib import Path
628
497
 
629
- # Handle conflicting flags
630
- if local and remote:
631
- typer.echo("❌ Cannot use both --local and --remote")
498
+ if not any(c in image for c in [":", "/"]) and (
499
+ Path(image).is_dir() or Path(image).is_file() or "." in image
500
+ ):
501
+ console.print(f"[yellow]⚠️ '{image}' looks like a module path, not a Docker image[/yellow]")
502
+ console.print("\n[green]For local Python development, use:[/green]")
503
+ console.print(f" hud dev {image}")
504
+ console.print("\n[green]For Docker images:[/green]")
505
+ console.print(" hud run my-image:latest")
632
506
  raise typer.Exit(1)
633
507
 
634
508
  # Default to remote if not explicitly local
635
- is_local = local and not remote
636
-
637
- # Check for interactive mode restrictions
638
- if interactive:
639
- if transport != "http":
640
- typer.echo("❌ Interactive mode requires HTTP transport (use --transport http)")
641
- raise typer.Exit(1)
642
- if not is_local:
643
- typer.echo("❌ Interactive mode is only available for local execution (use --local)")
644
- raise typer.Exit(1)
509
+ is_local = local
645
510
 
646
511
  if is_local:
647
512
  # Local Docker execution
648
513
  from .utils.runner import run_mcp_server
649
514
 
650
- run_mcp_server(image, docker_args, transport, port, verbose, interactive)
515
+ run_mcp_server(image, docker_args, transport, port, verbose, interactive=False)
651
516
  else:
652
517
  # Remote execution via proxy
653
518
  from .utils.remote_runner import run_remote_server