gpu4agents 0.5.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.
- gpu4agents/__init__.py +5 -0
- gpu4agents/__main__.py +4 -0
- gpu4agents/agent/__init__.py +1 -0
- gpu4agents/agent/install.py +173 -0
- gpu4agents/agent/jobs.py +193 -0
- gpu4agents/agent/mcp.py +646 -0
- gpu4agents/agent/skill/SKILL.md +68 -0
- gpu4agents/cli/__init__.py +0 -0
- gpu4agents/cli/_common.py +131 -0
- gpu4agents/cli/account.py +330 -0
- gpu4agents/cli/agent.py +74 -0
- gpu4agents/cli/inspect.py +207 -0
- gpu4agents/cli/lifecycle.py +127 -0
- gpu4agents/cli/main.py +163 -0
- gpu4agents/cli/providers.py +96 -0
- gpu4agents/cli/run.py +38 -0
- gpu4agents/cli/setup.py +164 -0
- gpu4agents/cli/status.py +224 -0
- gpu4agents/cli/timer.py +143 -0
- gpu4agents/cli/validate.py +57 -0
- gpu4agents/core/__init__.py +0 -0
- gpu4agents/core/beacon.py +133 -0
- gpu4agents/core/config.py +647 -0
- gpu4agents/core/credentials.py +95 -0
- gpu4agents/core/durations.py +47 -0
- gpu4agents/core/engine.py +1335 -0
- gpu4agents/core/errors.py +94 -0
- gpu4agents/core/events.py +117 -0
- gpu4agents/core/execution.py +169 -0
- gpu4agents/core/health.py +62 -0
- gpu4agents/core/hosts.py +58 -0
- gpu4agents/core/model_inspect.py +368 -0
- gpu4agents/core/models.py +313 -0
- gpu4agents/core/paths.py +42 -0
- gpu4agents/core/policy.py +264 -0
- gpu4agents/core/state.py +406 -0
- gpu4agents/core/sweep.py +478 -0
- gpu4agents/core/tool_parsers.py +63 -0
- gpu4agents/providers/__init__.py +4 -0
- gpu4agents/providers/base.py +137 -0
- gpu4agents/providers/registry.py +63 -0
- gpu4agents/providers/vast/__init__.py +3 -0
- gpu4agents/providers/vast/client.py +255 -0
- gpu4agents/providers/vast/config.py +56 -0
- gpu4agents/providers/vast/mapper.py +292 -0
- gpu4agents/providers/vast/provider.py +438 -0
- gpu4agents/runtime/__init__.py +6 -0
- gpu4agents/runtime/base.py +184 -0
- gpu4agents/runtime/vllm.py +295 -0
- gpu4agents/watchdog/__init__.py +3 -0
- gpu4agents/watchdog/agent_body.py +959 -0
- gpu4agents/watchdog/builder.py +248 -0
- gpu4agents-0.5.0.dist-info/METADATA +157 -0
- gpu4agents-0.5.0.dist-info/RECORD +59 -0
- gpu4agents-0.5.0.dist-info/WHEEL +4 -0
- gpu4agents-0.5.0.dist-info/entry_points.txt +2 -0
- gpu4agents-0.5.0.dist-info/licenses/LICENSE +21 -0
- jambu_gpu/__init__.py +9 -0
- jambu_gpu/__main__.py +4 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: gpu
|
|
3
|
+
description: Rent a temporary cloud GPU, serve an open-weights model on it behind an OpenAI-compatible API, run experiments or evals against it, and shut it down so it stops billing. Use when the user wants to run, test, benchmark or compare a HuggingFace model (Qwen, Llama, Mistral, ...) on a GPU, needs an LLM endpoint for an experiment, or asks about GPU instances gpu/gpu4agents created. Works through the `gpu` MCP tools (gpu_setup, gpu_run, ...) or the `gpu` CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# gpu: temporary GPUs for experiments
|
|
7
|
+
|
|
8
|
+
`gpu` (the `gpu4agents` package) rents a machine on Vast.ai, serves a model with vLLM behind an OpenAI-compatible API, and installs a watchdog on the machine that stops it once nothing has used it for `idle_timeout` (15 min by default). A machine you forget stops by itself, so billing ends even if this conversation does.
|
|
9
|
+
|
|
10
|
+
Each project directory has a `jambu.yaml` (what to rent and serve) and a `.gpu/` directory (what was rented). One project, one machine.
|
|
11
|
+
|
|
12
|
+
## Two ways to drive it
|
|
13
|
+
|
|
14
|
+
- **MCP tools** (`gpu_*`), when the `gpu` MCP server is connected. Prefer them.
|
|
15
|
+
- **CLI**, from a shell: `gpu --json <command>` prints one JSON document on stdout; errors go to stderr as `error: ...` with a non-zero exit code. Run it from the project directory.
|
|
16
|
+
|
|
17
|
+
| Step | MCP tool | CLI |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| create jambu.yaml | `gpu_init(model)` | `gpu init --model ORG/NAME` |
|
|
20
|
+
| check config and credentials | `gpu_validate` | `gpu --json validate` |
|
|
21
|
+
| see prices, rent nothing | `gpu_offers` | `gpu --json offers` |
|
|
22
|
+
| start / ensure ready | `gpu_setup` | `gpu --json setup` |
|
|
23
|
+
| endpoint without starting | `gpu_connection` | `gpu --json env` |
|
|
24
|
+
| run a job against it | `gpu_run(command=[...])` | `gpu run python job.py` |
|
|
25
|
+
| state, cost, idle time | `gpu_status` | `gpu --json status` |
|
|
26
|
+
| boot / server logs | `gpu_logs(source)` | `gpu --json logs --source onstart` |
|
|
27
|
+
| stop (keep disk) | `gpu_stop` | `gpu --json stop` |
|
|
28
|
+
| delete | `gpu_destroy(confirm=true)` | `gpu --json destroy --yes` |
|
|
29
|
+
| everything billing in the account | `gpu_ls` | `gpu --json ls` |
|
|
30
|
+
|
|
31
|
+
## Rules: this spends the user's money
|
|
32
|
+
|
|
33
|
+
1. **Ask before renting.** Before the first `gpu_setup` / `gpu setup` / `gpu run` in a conversation, show the model, the GPU and the price (`gpu_offers`) and the cap (`budget.max_hourly_cost_usd`), and get a yes. Never raise the budget in `jambu.yaml` without asking.
|
|
34
|
+
2. **Setup is slow, and that is normal.** The first setup takes 5–15 minutes (image pull, model download); a bad host is replaced automatically. The MCP `gpu_setup` returns `status: "running"` while it works: call it again to keep waiting. From a shell, run `gpu --json setup` in the background with no timeout. Never start a second project, `--recreate`, or destroy because setup is slow. `gpu_status` (it shows `busy: true` during setup) and `gpu_logs(source="onstart")` show progress. If the user wants to give up, `gpu_stop` or `gpu_destroy` cancel the setup and destroy the half-booted machine.
|
|
35
|
+
3. **Never handle the provider key.** Do not print, echo or ask for `VAST_API_KEY`. It is read from the environment, the project `.env` or `~/.gpu/credentials`. If it is missing (exit code 5), tell the user where to put it.
|
|
36
|
+
4. **Clean up.** When the work is done, offer `gpu_stop` (cheap to resume: about a minute, same key) or `gpu_destroy` (nothing billed; next time rents and downloads again). Destroy only with the user's explicit yes. Before finishing, run `gpu_ls` and report anything still running.
|
|
37
|
+
|
|
38
|
+
## A typical experiment
|
|
39
|
+
|
|
40
|
+
1. If there is no `jambu.yaml`: `gpu_init(model=..., max_hourly_cost_usd=...)`. Size the GPU for the model: about 2 GB of VRAM per billion parameters in bf16, plus room for the KV cache (a 7–9B model wants 24 GB; a 0.5–3B model fits on 16–24 GB). `gpu inspect-model ORG/NAME` suggests VRAM and a tool-call parser.
|
|
41
|
+
2. `gpu_validate`, then `gpu_offers`. Show the user the price; ask.
|
|
42
|
+
3. `gpu_setup` until `status: "ready"`. You get `base_url`, `api_key`, `model`.
|
|
43
|
+
4. Run the work:
|
|
44
|
+
- a script: `gpu_run(command=["python", "eval.py"])` (or `gpu run python eval.py`). It gets `OPENAI_BASE_URL`, `OPENAI_API_KEY` and `JAMBU_MODEL_ID` in its environment, which any OpenAI SDK reads, and the watchdog keeps the machine up while it runs. Write the script to read those variables instead of pasting the key into code.
|
|
45
|
+
- single requests: `curl "$base_url/chat/completions" -H "Authorization: Bearer $api_key"` with `"model": model`. Every request counts as activity.
|
|
46
|
+
5. Report results and cost (`gpu_status` has `session_cost_usd`), then step 4 of the rules.
|
|
47
|
+
|
|
48
|
+
## When something fails
|
|
49
|
+
|
|
50
|
+
Exit codes are the same for the CLI and the MCP tools (`exit_code` in the error):
|
|
51
|
+
|
|
52
|
+
| Code | Meaning | Do |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| 2 | config error, or jambu.yaml changed since setup | fix it; `gpu_setup(recreate=true)` replaces a machine built from the old config |
|
|
55
|
+
| 3 | no machine / no endpoint | `gpu_setup` |
|
|
56
|
+
| 4, 8 | provider API error or timeout | retry with backoff |
|
|
57
|
+
| 5 | provider auth failed | the user fixes `VAST_API_KEY` |
|
|
58
|
+
| 6 | no capacity matches | retry later, or relax `compute.gpu` / the budget (ask first) |
|
|
59
|
+
| 7 | provisioning failed | retry; the failed machine is already destroyed |
|
|
60
|
+
| 10 | over budget | ask the user before raising it |
|
|
61
|
+
| 11 | model server did not start | the error carries the vLLM log tail: usually too little VRAM, a gated model without `HF_TOKEN`, or a bad `runtime` option. The machine was destroyed |
|
|
62
|
+
| 12 | another gpu command (a setup) holds the project | follow it with `gpu_setup`; `gpu_stop` / `gpu_destroy` cancel it |
|
|
63
|
+
|
|
64
|
+
- Requests fail with connection refused or a timeout: the machine was stopped for being idle. Call `gpu_setup` again (about a minute), take the **new** `base_url` from its output, and retry once.
|
|
65
|
+
- `401`: a new machine was rented and the key changed; take it from `gpu_connection`.
|
|
66
|
+
- A long job with quiet stretches longer than `idle_timeout`: `gpu_lock(duration="2h")`, then `gpu_unlock`.
|
|
67
|
+
|
|
68
|
+
Full contract: https://github.com/jambuai/gpu4agents/blob/main/docs/INTEGRATION.md
|
|
File without changes
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""Shared CLI plumbing: config loading, output and error rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json as jsonlib
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Optional
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from ..core.config import RuntimeConfig, load_config
|
|
16
|
+
from ..core.engine import Engine
|
|
17
|
+
from ..core.errors import JambuError
|
|
18
|
+
from ..core.state import to_iso
|
|
19
|
+
|
|
20
|
+
console = Console()
|
|
21
|
+
err_console = Console(stderr=True)
|
|
22
|
+
|
|
23
|
+
STATE_STYLES = {
|
|
24
|
+
"ready": "bold green",
|
|
25
|
+
"busy": "bold cyan",
|
|
26
|
+
"idle": "yellow",
|
|
27
|
+
"starting": "cyan",
|
|
28
|
+
"provisioning": "cyan",
|
|
29
|
+
"stopped": "dim",
|
|
30
|
+
"destroyed": "dim",
|
|
31
|
+
"failed": "bold red",
|
|
32
|
+
"unknown": "magenta",
|
|
33
|
+
"none": "dim",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Context:
|
|
38
|
+
"""Per-invocation state carried on the typer context object."""
|
|
39
|
+
|
|
40
|
+
def __init__(self) -> None:
|
|
41
|
+
self.config_path: Optional[Path] = None
|
|
42
|
+
self.json: bool = False
|
|
43
|
+
self.verbose: bool = False
|
|
44
|
+
self._config: Optional[RuntimeConfig] = None
|
|
45
|
+
self._engine: Optional[Engine] = None
|
|
46
|
+
|
|
47
|
+
def config(self) -> RuntimeConfig:
|
|
48
|
+
if self._config is None:
|
|
49
|
+
self._config = load_config(self.config_path)
|
|
50
|
+
return self._config
|
|
51
|
+
|
|
52
|
+
def engine(self) -> Engine:
|
|
53
|
+
if self._engine is None:
|
|
54
|
+
self._engine = Engine(self.config(), echo=self.echo)
|
|
55
|
+
return self._engine
|
|
56
|
+
|
|
57
|
+
def echo(self, message: str) -> None:
|
|
58
|
+
if self.json:
|
|
59
|
+
return
|
|
60
|
+
style = ""
|
|
61
|
+
if message.startswith("!"):
|
|
62
|
+
style = "yellow"
|
|
63
|
+
elif message.startswith("+"):
|
|
64
|
+
style = "green"
|
|
65
|
+
elif message.startswith("-"):
|
|
66
|
+
style = "red"
|
|
67
|
+
elif message.startswith(">"):
|
|
68
|
+
style = "cyan"
|
|
69
|
+
console.print(Text(message, style=style) if style else message)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def get_context(ctx: typer.Context) -> Context:
|
|
73
|
+
if ctx.obj is None:
|
|
74
|
+
ctx.obj = Context()
|
|
75
|
+
return ctx.obj
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fail(message: str, code: int = 1) -> None:
|
|
79
|
+
err_console.print(Text(f"error: {message}", style="bold red"))
|
|
80
|
+
raise typer.Exit(code)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def handle(exc: Exception) -> None:
|
|
84
|
+
"""Render a JambuError the same way regardless of which provider raised it."""
|
|
85
|
+
if isinstance(exc, JambuError):
|
|
86
|
+
err_console.print(Text(f"error: {exc}", style="bold red"))
|
|
87
|
+
raise typer.Exit(getattr(exc, "exit_code", 1))
|
|
88
|
+
raise exc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def print_json(payload: Any) -> None:
|
|
92
|
+
sys.stdout.write(jsonlib.dumps(payload, indent=2, default=str) + "\n")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def state_text(state: str) -> Text:
|
|
96
|
+
return Text(state.upper(), style=STATE_STYLES.get(state, "white"))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def kv_table(title: str, rows: list[tuple[str, Any]]) -> Table:
|
|
100
|
+
table = Table(title=title, show_header=False, box=None, title_justify="left", pad_edge=False)
|
|
101
|
+
table.add_column("field", style="dim", no_wrap=True)
|
|
102
|
+
table.add_column("value")
|
|
103
|
+
for key, value in rows:
|
|
104
|
+
if value is None:
|
|
105
|
+
continue
|
|
106
|
+
table.add_row(key, value if isinstance(value, Text) else str(value))
|
|
107
|
+
return table
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fmt_time(ts: Optional[float]) -> Optional[str]:
|
|
111
|
+
return to_iso(ts) if ts else None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fmt_money(value: Optional[float], suffix: str = "") -> Optional[str]:
|
|
115
|
+
if value is None:
|
|
116
|
+
return None
|
|
117
|
+
return f"${value:.3f}{suffix}"
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def register_project(context: Context) -> None:
|
|
121
|
+
"""Put this project in $GPU_HOME/projects.json so `gpu sweep` guards it.
|
|
122
|
+
|
|
123
|
+
Done before provisioning: a setup that dies halfway must still leave an
|
|
124
|
+
instance the sweep judges by this project's policy, not as an orphan.
|
|
125
|
+
"""
|
|
126
|
+
from ..core.sweep import register_project as _register
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
_register(context.config())
|
|
130
|
+
except OSError as exc:
|
|
131
|
+
context.echo(f"! could not register the project for `gpu sweep`: {exc}")
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""`gpu ls` / `gpu sweep` - every jambu instance in the account, not just this project's."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.table import Table
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from ..core import sweep as sweep_mod
|
|
13
|
+
from ..core.config import RuntimeConfig, find_config
|
|
14
|
+
from ..core.credentials import CredentialResolver
|
|
15
|
+
from ..core.durations import format_duration, parse_duration
|
|
16
|
+
from ..core.engine import Engine
|
|
17
|
+
from ..core.errors import ConfigError, JambuError
|
|
18
|
+
from ..core.events import EventLog
|
|
19
|
+
from ..providers.registry import load_builtin_providers
|
|
20
|
+
from . import timer
|
|
21
|
+
from ._common import Context, console, fmt_money, get_context, handle, print_json, state_text
|
|
22
|
+
|
|
23
|
+
DEFAULT_PROVIDER = "vast"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Account:
|
|
27
|
+
"""A provider handle that works with or without a project around it."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, context: Context, provider_name: Optional[str] = None) -> None:
|
|
30
|
+
self.config: Optional[RuntimeConfig] = None
|
|
31
|
+
if context.config_path is not None or find_config() is not None:
|
|
32
|
+
self.config = context.config()
|
|
33
|
+
if self.config is not None:
|
|
34
|
+
project_dir = self.config.project_dir
|
|
35
|
+
profile = self.config.provider.profile
|
|
36
|
+
name = provider_name or self.config.provider.name
|
|
37
|
+
config = self.config
|
|
38
|
+
else:
|
|
39
|
+
# No project: a placeholder config only carries the provider name.
|
|
40
|
+
project_dir = Path.cwd()
|
|
41
|
+
profile = "default"
|
|
42
|
+
name = provider_name or DEFAULT_PROVIDER
|
|
43
|
+
config = RuntimeConfig.model_validate(
|
|
44
|
+
{"provider": {"name": name}, "model": {"id": "jambu/account"}}
|
|
45
|
+
)
|
|
46
|
+
self.credentials = account_credentials(project_dir, profile)
|
|
47
|
+
self.provider = make_provider(config, self.credentials, name)
|
|
48
|
+
|
|
49
|
+
def project_paths(self) -> list[Path]:
|
|
50
|
+
"""Registered projects, plus the one this command runs in (ad hoc)."""
|
|
51
|
+
paths = sweep_mod.ProjectRegistry().paths()
|
|
52
|
+
if self.config is not None and self.config.source_path is not None:
|
|
53
|
+
here = Path(self.config.source_path).resolve()
|
|
54
|
+
if here not in paths:
|
|
55
|
+
paths.append(here)
|
|
56
|
+
return paths
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def account_credentials(project_dir: Path, profile: str = "default") -> CredentialResolver:
|
|
60
|
+
"""The environment, ./.env, ~/.gpu/credentials, then registered projects' .env.
|
|
61
|
+
|
|
62
|
+
The timer runs from ~ without the shell's environment; a key kept only in
|
|
63
|
+
a project's .env must still reach the account-wide orphan sweep.
|
|
64
|
+
"""
|
|
65
|
+
extra = [
|
|
66
|
+
("registered project .env", path.parent / ".env")
|
|
67
|
+
for path in sweep_mod.ProjectRegistry().paths()
|
|
68
|
+
]
|
|
69
|
+
return CredentialResolver(project_dir, profile=profile, extra_sources=extra)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def make_provider(config: RuntimeConfig, credentials: CredentialResolver, name: str):
|
|
73
|
+
"""Seam for tests: they swap in a FakeProvider here."""
|
|
74
|
+
return load_builtin_providers().get(config, credentials, name=name)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def make_engine(config: RuntimeConfig, echo=None) -> Engine:
|
|
78
|
+
"""A project's own engine: its config, its credentials, its state."""
|
|
79
|
+
credentials = CredentialResolver(config.project_dir, profile=config.provider.profile)
|
|
80
|
+
provider = make_provider(config, credentials, config.provider.name)
|
|
81
|
+
return Engine(config, provider=provider, echo=echo, credentials=credentials)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def ls_command(
|
|
85
|
+
ctx: typer.Context,
|
|
86
|
+
provider: Optional[str] = typer.Option(
|
|
87
|
+
None, "--provider", help="Provider to list (default: jambu.yaml's, else vast)."
|
|
88
|
+
),
|
|
89
|
+
) -> None:
|
|
90
|
+
"""List every jambu instance in the provider account."""
|
|
91
|
+
context = get_context(ctx)
|
|
92
|
+
try:
|
|
93
|
+
account = Account(context, provider)
|
|
94
|
+
instances = sweep_mod.list_jambu_instances(account.provider)
|
|
95
|
+
owners = sweep_mod.tracked_by_projects(account.project_paths())
|
|
96
|
+
except JambuError as exc:
|
|
97
|
+
handle(exc)
|
|
98
|
+
return
|
|
99
|
+
name = account.provider.name
|
|
100
|
+
tracked = {iid: path for (pname, iid), path in owners.items() if pname == name}
|
|
101
|
+
rows = sweep_mod.account_rows(instances, tracked)
|
|
102
|
+
|
|
103
|
+
if context.json:
|
|
104
|
+
print_json({"provider": name, "instances": rows})
|
|
105
|
+
return
|
|
106
|
+
if not rows:
|
|
107
|
+
console.print(Text(f"no jambu instances on {account.provider.display_name}", style="dim"))
|
|
108
|
+
return
|
|
109
|
+
table = Table(title=f"{account.provider.display_name} instances", title_justify="left", box=None)
|
|
110
|
+
for column in ("ID", "LABEL", "STATE", "GPU", "$/H", "AGE", "PROJECT"):
|
|
111
|
+
table.add_column(column)
|
|
112
|
+
for row in rows:
|
|
113
|
+
table.add_row(
|
|
114
|
+
row["instance_id"],
|
|
115
|
+
row["label"],
|
|
116
|
+
state_text(row["state"]),
|
|
117
|
+
row["gpu"] or "-",
|
|
118
|
+
fmt_money(row["hourly_cost_usd"]) or "-",
|
|
119
|
+
format_duration(row["age_seconds"]),
|
|
120
|
+
Text(row["project"], style="green") if row["tracked"] else Text("orphan", style="yellow"),
|
|
121
|
+
)
|
|
122
|
+
console.print(table)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _seconds(flag: Optional[str], name: str) -> Optional[float]:
|
|
126
|
+
try:
|
|
127
|
+
return parse_duration(flag)
|
|
128
|
+
except ValueError as exc:
|
|
129
|
+
raise ConfigError(f"{name}: {exc}") from exc
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def sweep_command(
|
|
133
|
+
ctx: typer.Context,
|
|
134
|
+
apply: bool = typer.Option(False, "--apply", help="Act. Without it the sweep only reports."),
|
|
135
|
+
orphan_max_age: str = typer.Option(
|
|
136
|
+
"24h", "--orphan-max-age", help="Stop an untracked running instance older than this."
|
|
137
|
+
),
|
|
138
|
+
stopped_ttl: str = typer.Option(
|
|
139
|
+
"24h", "--stopped-ttl", help="Destroy an untracked instance stopped longer than this."
|
|
140
|
+
),
|
|
141
|
+
provider: Optional[str] = typer.Option(
|
|
142
|
+
None, "--provider", help="Provider to look for orphans on (default: jambu.yaml's, else vast)."
|
|
143
|
+
),
|
|
144
|
+
config: Optional[Path] = typer.Option(
|
|
145
|
+
None, "--config", "-c", help="Also guard this project for this run, registered or not."
|
|
146
|
+
),
|
|
147
|
+
install_timer: bool = typer.Option(
|
|
148
|
+
False, "--install-timer", help="Write a systemd user timer that runs the sweep."
|
|
149
|
+
),
|
|
150
|
+
every: str = typer.Option("5m", "--every", help="Timer period for --install-timer."),
|
|
151
|
+
uninstall_timer: bool = typer.Option(
|
|
152
|
+
False, "--uninstall-timer", help="Remove the systemd user timer."
|
|
153
|
+
),
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Guard every registered project, then clean up orphans (dry run by default).
|
|
156
|
+
|
|
157
|
+
Each project registered by `gpu setup` is guarded with its own jambu.yaml
|
|
158
|
+
and state, exactly like `gpu guard` in that directory. An instance no
|
|
159
|
+
project tracks is an orphan: it is stopped (never destroyed) once it has
|
|
160
|
+
run longer than --orphan-max-age, and destroyed once it has been seen
|
|
161
|
+
stopped longer than --stopped-ttl. Instances without a jambu- label are
|
|
162
|
+
never touched.
|
|
163
|
+
"""
|
|
164
|
+
context = get_context(ctx)
|
|
165
|
+
if config is not None:
|
|
166
|
+
context.config_path = config
|
|
167
|
+
try:
|
|
168
|
+
if install_timer or uninstall_timer:
|
|
169
|
+
_timer(context, install_timer, every)
|
|
170
|
+
return
|
|
171
|
+
_sweep(context, apply, orphan_max_age, stopped_ttl, provider)
|
|
172
|
+
except JambuError as exc:
|
|
173
|
+
handle(exc)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _require_file_credentials(context: Context) -> None:
|
|
177
|
+
"""The unit runs without the shell's environment: refuse what it cannot see."""
|
|
178
|
+
checks: list[tuple[str, CredentialResolver, str]] = []
|
|
179
|
+
if context.config_path is not None or find_config() is not None:
|
|
180
|
+
cfg = context.config()
|
|
181
|
+
checks.append(
|
|
182
|
+
(
|
|
183
|
+
cfg.provider.name,
|
|
184
|
+
CredentialResolver(cfg.project_dir, profile=cfg.provider.profile),
|
|
185
|
+
str(cfg.source_path),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
# The unit runs from ~ with no project: the orphan sweep's own lookup.
|
|
189
|
+
checks.append((DEFAULT_PROVIDER, account_credentials(Path.home()), "the orphan sweep"))
|
|
190
|
+
registry = load_builtin_providers()
|
|
191
|
+
for provider_name, resolver, who in checks:
|
|
192
|
+
for name in registry.get_class(provider_name).required_credentials:
|
|
193
|
+
if resolver.file_source(name):
|
|
194
|
+
continue
|
|
195
|
+
where = "only in your shell environment" if resolver.get(name) else "not set anywhere"
|
|
196
|
+
raise ConfigError(
|
|
197
|
+
f"{name} is {where}, so the timer could not use it for {who}: a systemd "
|
|
198
|
+
f"user unit does not inherit your shell. Put `{name}=...` in "
|
|
199
|
+
"~/.gpu/credentials (chmod 600), then re-run --install-timer."
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _timer(context: Context, install: bool, every: str) -> None:
|
|
204
|
+
if not install:
|
|
205
|
+
result = timer.uninstall_timer()
|
|
206
|
+
if context.json:
|
|
207
|
+
print_json(result)
|
|
208
|
+
return
|
|
209
|
+
for path in result["removed"]:
|
|
210
|
+
console.print(Text(f"- removed {path}", style="red"))
|
|
211
|
+
if not result["removed"]:
|
|
212
|
+
console.print(Text("= no timer installed", style="dim"))
|
|
213
|
+
console.print(f" then run: {result['disable']}")
|
|
214
|
+
return
|
|
215
|
+
|
|
216
|
+
every_s = _seconds(every, "--every")
|
|
217
|
+
if not every_s:
|
|
218
|
+
raise ConfigError("--every must be a positive duration, e.g. 5m")
|
|
219
|
+
registry = sweep_mod.ProjectRegistry()
|
|
220
|
+
project = None
|
|
221
|
+
if context.config_path is not None or find_config() is not None:
|
|
222
|
+
project = registry.register(context.config().source_path)
|
|
223
|
+
_require_file_credentials(context)
|
|
224
|
+
result = timer.install_timer(every_s)
|
|
225
|
+
# Units written before the registry carried one guard line per project.
|
|
226
|
+
for legacy in result["legacy_guard_configs"]:
|
|
227
|
+
if Path(legacy).is_file():
|
|
228
|
+
registry.register(Path(legacy))
|
|
229
|
+
result["project_config"] = str(project) if project else None
|
|
230
|
+
result["projects"] = [str(p) for p in registry.paths()]
|
|
231
|
+
if context.json:
|
|
232
|
+
print_json(result)
|
|
233
|
+
return
|
|
234
|
+
console.print(Text(f"+ wrote {result['service']}", style="green"))
|
|
235
|
+
console.print(Text(f"+ wrote {result['timer']}", style="green"))
|
|
236
|
+
if project:
|
|
237
|
+
console.print(Text(f"+ registered {project}", style="green"))
|
|
238
|
+
console.print(
|
|
239
|
+
f" every {format_duration(result['every_s'])}: `gpu sweep --apply`, guarding "
|
|
240
|
+
f"{len(result['projects'])} registered project(s) and cleaning up orphans"
|
|
241
|
+
)
|
|
242
|
+
console.print(Text(f"> enable it: {result['enable']}", style="cyan"))
|
|
243
|
+
console.print(
|
|
244
|
+
Text(
|
|
245
|
+
" user timers stop at logout unless lingering is on: loginctl enable-linger $USER",
|
|
246
|
+
style="dim",
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _sweep(
|
|
252
|
+
context: Context,
|
|
253
|
+
apply: bool,
|
|
254
|
+
orphan_max_age: str,
|
|
255
|
+
stopped_ttl: str,
|
|
256
|
+
provider_name: Optional[str],
|
|
257
|
+
) -> None:
|
|
258
|
+
orphan_max_age_s = _seconds(orphan_max_age, "--orphan-max-age")
|
|
259
|
+
stopped_ttl_s = _seconds(stopped_ttl, "--stopped-ttl")
|
|
260
|
+
account = Account(context, provider_name)
|
|
261
|
+
registry = sweep_mod.ProjectRegistry()
|
|
262
|
+
registered = registry.paths()
|
|
263
|
+
# A project whose jambu.yaml is gone has nothing left to guard it by.
|
|
264
|
+
gone = [p for p in registered if not p.is_file()]
|
|
265
|
+
registry.remove(gone)
|
|
266
|
+
for path in gone:
|
|
267
|
+
context.echo(f"- unregistered {path} (config file is gone)")
|
|
268
|
+
paths = [p for p in account.project_paths() if p not in gone]
|
|
269
|
+
|
|
270
|
+
if not context.json and paths:
|
|
271
|
+
context.echo(f"> guarding {len(paths)} project(s)" + ("" if apply else " (dry run)"))
|
|
272
|
+
guards, owned = sweep_mod.guard_projects(
|
|
273
|
+
paths, lambda cfg: make_engine(cfg, echo=context.echo), apply=apply, echo=context.echo
|
|
274
|
+
)
|
|
275
|
+
name = account.provider.name
|
|
276
|
+
tracked = {iid for (pname, iid) in owned if pname == name}
|
|
277
|
+
|
|
278
|
+
if not context.json:
|
|
279
|
+
context.echo(
|
|
280
|
+
f"> sweeping {account.provider.display_name} for orphans: running > "
|
|
281
|
+
f"{format_duration(orphan_max_age_s) if orphan_max_age_s else 'never'} is stopped, "
|
|
282
|
+
f"stopped > {format_duration(stopped_ttl_s) if stopped_ttl_s else 'never'} is destroyed"
|
|
283
|
+
+ ("" if apply else " (dry run)")
|
|
284
|
+
)
|
|
285
|
+
try:
|
|
286
|
+
result = sweep_mod.sweep(
|
|
287
|
+
account.provider,
|
|
288
|
+
orphan_max_age_s=orphan_max_age_s,
|
|
289
|
+
stopped_ttl_s=stopped_ttl_s,
|
|
290
|
+
apply=apply,
|
|
291
|
+
tracked=tracked,
|
|
292
|
+
events=[EventLog(sweep_mod.jambu_home() / "logs").bind(provider=name)],
|
|
293
|
+
echo=context.echo,
|
|
294
|
+
)
|
|
295
|
+
except JambuError as exc:
|
|
296
|
+
# The projects were guarded already; report that, then fail the run.
|
|
297
|
+
if context.json:
|
|
298
|
+
print_json(
|
|
299
|
+
{
|
|
300
|
+
"provider": name,
|
|
301
|
+
"error": str(exc),
|
|
302
|
+
"projects": [g.to_dict() for g in guards],
|
|
303
|
+
"unregistered": [str(p) for p in gone],
|
|
304
|
+
}
|
|
305
|
+
)
|
|
306
|
+
raise typer.Exit(getattr(exc, "exit_code", 1)) from exc
|
|
307
|
+
raise
|
|
308
|
+
failed = any(a.error for a in result.actions) or any(g.error for g in guards)
|
|
309
|
+
if context.json:
|
|
310
|
+
payload = result.to_dict()
|
|
311
|
+
payload["projects"] = [g.to_dict() for g in guards]
|
|
312
|
+
payload["unregistered"] = [str(p) for p in gone]
|
|
313
|
+
print_json(payload)
|
|
314
|
+
if failed:
|
|
315
|
+
raise typer.Exit(1)
|
|
316
|
+
return
|
|
317
|
+
pending = [a for a in result.actions if a.action != "none"] + [
|
|
318
|
+
g for g in guards if (g.decision or {}).get("action", "none") != "none"
|
|
319
|
+
]
|
|
320
|
+
if not pending:
|
|
321
|
+
console.print(
|
|
322
|
+
Text(
|
|
323
|
+
f"= {result.seen} jambu instance(s), {result.tracked} tracked, nothing to do",
|
|
324
|
+
style="green",
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
elif not apply:
|
|
328
|
+
console.print(Text(f"! {len(pending)} action(s) pending; re-run with --apply", style="yellow"))
|
|
329
|
+
if failed:
|
|
330
|
+
raise typer.Exit(1)
|
gpu4agents/cli/agent.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""`gpu mcp` and `gpu agent` - let coding agents drive gpu."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from ..core.errors import JambuError
|
|
12
|
+
from ._common import console, get_context, handle, print_json
|
|
13
|
+
|
|
14
|
+
agent_app = typer.Typer(
|
|
15
|
+
help="Give Claude Code, Cursor or Claude Desktop the gpu skill and MCP tools.",
|
|
16
|
+
no_args_is_help=True,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def mcp_command(
|
|
21
|
+
project: Optional[Path] = typer.Option(
|
|
22
|
+
None,
|
|
23
|
+
"--project",
|
|
24
|
+
"-p",
|
|
25
|
+
help="Default project directory for the tools (default: $GPU_PROJECT_DIR, else the cwd).",
|
|
26
|
+
),
|
|
27
|
+
) -> None:
|
|
28
|
+
"""Run the local MCP server on stdio (started by the agent's client, not by hand)."""
|
|
29
|
+
from ..agent.mcp import Server
|
|
30
|
+
|
|
31
|
+
Server(project=project).serve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@agent_app.command("install")
|
|
35
|
+
def install_command(
|
|
36
|
+
ctx: typer.Context,
|
|
37
|
+
target: List[str] = typer.Option(
|
|
38
|
+
["claude"],
|
|
39
|
+
"--target",
|
|
40
|
+
"-t",
|
|
41
|
+
help="claude (Claude Code), cursor, desktop (Claude Desktop). Repeat for several.",
|
|
42
|
+
),
|
|
43
|
+
global_scope: bool = typer.Option(
|
|
44
|
+
False, "--global", "-g", help="For every project of this user, not just this directory."
|
|
45
|
+
),
|
|
46
|
+
directory: Path = typer.Option(
|
|
47
|
+
Path("."), "--dir", help="Project directory (default: the current one)."
|
|
48
|
+
),
|
|
49
|
+
) -> None:
|
|
50
|
+
"""Install the gpu skill and register the gpu MCP server."""
|
|
51
|
+
from ..agent.install import install
|
|
52
|
+
|
|
53
|
+
context = get_context(ctx)
|
|
54
|
+
try:
|
|
55
|
+
result = install(list(target), directory, global_scope=global_scope)
|
|
56
|
+
except JambuError as exc:
|
|
57
|
+
handle(exc)
|
|
58
|
+
return
|
|
59
|
+
if context.json:
|
|
60
|
+
print_json(result)
|
|
61
|
+
return
|
|
62
|
+
for item in result["installed"]:
|
|
63
|
+
style = "dim" if item["action"] == "unchanged" else "green"
|
|
64
|
+
console.print(Text(f"+ {item['what']}: {item['path']} ({item['action']})", style=style))
|
|
65
|
+
for note in result["notes"]:
|
|
66
|
+
console.print(Text(f" {note}", style="cyan"))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@agent_app.command("skill")
|
|
70
|
+
def skill_command() -> None:
|
|
71
|
+
"""Print the bundled SKILL.md (to copy into another agent's skill directory)."""
|
|
72
|
+
from ..agent.install import skill_text
|
|
73
|
+
|
|
74
|
+
print(skill_text(), end="")
|