Skip to content

Commit 4387f6d

Browse files
committed
version: offer to update for the user instead of printing a command
Add a version-awareness layer so the agent OFFERS to handle an update ("want me to update Kiln for you now?") and, on yes, actually does it — rather than telling the user to run a pip command. - version_policy: one evaluator (ok / available / recommended / required) carrying the offer wording; reused by client and server so both agree on what a version means. - self_update: performs the upgrade safely — defers while a print is active, is honest about the one restart (we never hot-swap code under a live session), and falls back to the one-line command when the environment (pipx / uv / OS-managed) won't allow auto-update. - upgrade_kiln tool: the agent runs it on the user's confirmation (confirm gate; never mid-print). - The update nudge (CLI banner, agent instructions, get_started / kiln_health) now frames an offer and names the tool to call.
1 parent 8f6e99b commit 4387f6d

9 files changed

Lines changed: 657 additions & 2 deletions

kiln/src/kiln/plugins/utility_tools.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class _UtilityToolsPlugin:
2424
2525
Tools:
2626
- get_session_log
27+
- upgrade_kiln
2728
- health_check
2829
- kiln_health
2930
- get_started
@@ -86,6 +87,70 @@ def get_session_log(
8687
code="INTERNAL_ERROR",
8788
)
8889

90+
# ------------------------------------------------------------------
91+
# upgrade_kiln
92+
# ------------------------------------------------------------------
93+
94+
@mcp.tool()
95+
def upgrade_kiln(confirm: bool = False, force: bool = False) -> dict:
96+
"""Update the Kiln package to the latest version — for the user.
97+
98+
The Apple-grade upgrade path. When a newer Kiln is available (or a
99+
hosted call returns an upgrade-required signal), OFFER to handle it:
100+
ask "want me to update Kiln for you now?" and call this with
101+
confirm=True once they agree. Don't make the user run a pip command.
102+
103+
AGENT CONTRACT (important):
104+
* NEVER call this while a print is active — wait until it finishes.
105+
Swapping Kiln mid-print is unsafe.
106+
* Confirm with the user first; this changes their installed
107+
software. Pass confirm=True only after they say yes.
108+
* On success the new version is on disk but the running Kiln still
109+
has the old code loaded — relay the restart instruction from the
110+
result so the user applies it at a safe moment (not mid-print).
111+
112+
Args:
113+
confirm: Set True to actually perform the update. Called without
114+
it, this returns the offer to show the user and changes
115+
nothing.
116+
force: Override the mid-print safety defer — only when the user
117+
explicitly insists.
118+
"""
119+
import kiln.server as _srv
120+
121+
try:
122+
from kiln import self_update
123+
except Exception as exc: # noqa: BLE001
124+
return _srv._error_dict(
125+
f"Upgrade is unavailable in this build: {exc}",
126+
code="INTERNAL_ERROR",
127+
)
128+
129+
current = self_update.current_version()
130+
if not confirm:
131+
return {
132+
"success": True,
133+
"status": "needs_confirmation",
134+
"current": current,
135+
"message": (
136+
"Want me to update Kiln for you now? It takes a few "
137+
"seconds, then one quick restart at a safe moment (not "
138+
"mid-print) and I'll pick up right where we left off. "
139+
"Confirm and I'll run it (upgrade_kiln with confirm=true)."
140+
),
141+
}
142+
143+
try:
144+
result = self_update.perform_upgrade(force=force)
145+
except Exception as exc: # noqa: BLE001 -- never surface a raw traceback
146+
_logger.exception("Unexpected error in upgrade_kiln")
147+
return _srv._error_dict(
148+
f"Update failed unexpectedly: {exc}. You can run it yourself: "
149+
f"{self_update.UPGRADE_COMMAND}",
150+
code="INTERNAL_ERROR",
151+
)
152+
return {"success": bool(result.get("ok")), **result}
153+
89154
# ------------------------------------------------------------------
90155
# health_check
91156
# ------------------------------------------------------------------

kiln/src/kiln/self_update.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Do the upgrade for the user — safely — so the agent can offer, not instruct.
2+
3+
The Apple-grade promise behind ``upgrade_kiln``: when a newer Kiln is needed,
4+
the assistant says "want me to update it for you now?" and, on yes, actually
5+
does it. This module is that action, kept pure and injectable so it tests
6+
without touching pip or a printer.
7+
8+
Three things it gets right:
9+
10+
* **Never mid-print.** ``pip install --upgrade`` rewrites files on disk; the
11+
running process keeps its already-imported code, but a *later* lazy import
12+
could then load new code into an old session — a real hazard during a live
13+
print. So if a print is active we DEFER, with a friendly "right after this
14+
finishes," and touch nothing.
15+
16+
* **Honest about the restart.** We never hot-swap code under a running
17+
session (the long-standing safety rule). The install lands the new version
18+
on disk; the one manual step left is a restart, and we say so plainly.
19+
20+
* **Graceful when the environment won't allow it.** pipx / uv / system-managed
21+
installs may refuse ``pip install --upgrade`` from inside the process. We
22+
try, and on failure hand back the exact command instead of pretending.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import re
28+
import subprocess
29+
import sys
30+
from typing import Callable
31+
32+
PACKAGE_NAME = "kiln3d"
33+
UPGRADE_COMMAND = f"pip install --upgrade {PACKAGE_NAME}"
34+
35+
# A successful upgrade rewrites files but does NOT reload the running process.
36+
RESTART_NOTE = (
37+
"Restart Kiln once (or your MCP client) at a safe moment — not mid-print — "
38+
"to finish; about ten seconds, and I'll pick up right where we left off."
39+
)
40+
41+
_DEFAULT_TIMEOUT_S = 300
42+
43+
# pip prints "Successfully installed kiln3d-1.2.3 ..." — pull the version back
44+
# out so we can name what landed (the in-process __version__ is stale until a
45+
# restart, so we trust pip's own report instead).
46+
_INSTALLED_RE = re.compile(rf"{re.escape(PACKAGE_NAME)}-([0-9][0-9A-Za-z.\-+!]*)")
47+
48+
49+
def current_version() -> str:
50+
"""The version running right now (best-effort)."""
51+
try:
52+
from importlib import metadata
53+
54+
return metadata.version(PACKAGE_NAME)
55+
except Exception: # noqa: BLE001
56+
try:
57+
import kiln
58+
59+
return getattr(kiln, "__version__", "unknown")
60+
except Exception: # noqa: BLE001
61+
return "unknown"
62+
63+
64+
def _parse_installed_version(pip_stdout: str) -> str | None:
65+
"""Pull the just-installed kiln3d version out of pip's success output."""
66+
hits = _INSTALLED_RE.findall(pip_stdout or "")
67+
return hits[-1] if hits else None
68+
69+
70+
def perform_upgrade(
71+
*,
72+
runner: Callable[..., subprocess.CompletedProcess] | None = None,
73+
print_active: Callable[[], bool] | None = None,
74+
force: bool = False,
75+
timeout: int = _DEFAULT_TIMEOUT_S,
76+
) -> dict:
77+
"""Update ``kiln3d`` in place and report what happened.
78+
79+
``runner`` / ``print_active`` are injected in tests; in production they
80+
default to :func:`subprocess.run` and a best-effort printer-state probe.
81+
82+
Returns a structured result with a ``status`` of:
83+
``deferred_active_print`` | ``updated`` | ``already_latest`` |
84+
``failed`` — each carrying a ``message`` written for the user.
85+
"""
86+
before = current_version()
87+
88+
# The mid-print guard is owned by the AGENT (it knows the live print state
89+
# from the session) and reinforced by the restart-timing message below; we
90+
# do NOT poll printer hardware on an upgrade. A caller that holds a cheap
91+
# live-print signal may inject ``print_active`` to have us defer outright.
92+
if not force and print_active is not None:
93+
try:
94+
active = bool(print_active())
95+
except Exception: # noqa: BLE001 -- a flaky probe must never block, nor green-light
96+
active = False
97+
if active:
98+
return {
99+
"ok": False,
100+
"status": "deferred_active_print",
101+
"current": before,
102+
"restart_required": False,
103+
"message": (
104+
"A print is running, so I'll hold off on updating — swapping "
105+
"Kiln mid-print isn't safe. I'll update the moment it finishes "
106+
"(or say 'update now' to override)."
107+
),
108+
}
109+
110+
if runner is None:
111+
runner = subprocess.run
112+
cmd = [sys.executable, "-m", "pip", "install", "--upgrade", PACKAGE_NAME]
113+
try:
114+
proc = runner(cmd, capture_output=True, text=True, timeout=timeout)
115+
except Exception as exc: # noqa: BLE001 -- subprocess/timeout failures are reported, never raised
116+
return {
117+
"ok": False,
118+
"status": "failed",
119+
"current": before,
120+
"restart_required": False,
121+
"command": UPGRADE_COMMAND,
122+
"message": (
123+
f"I couldn't run the update here ({exc}). You can do it in one "
124+
f"line: {UPGRADE_COMMAND}"
125+
),
126+
}
127+
128+
if proc.returncode != 0:
129+
tail = (proc.stderr or proc.stdout or "").strip().splitlines()[-3:]
130+
return {
131+
"ok": False,
132+
"status": "failed",
133+
"current": before,
134+
"restart_required": False,
135+
"command": UPGRADE_COMMAND,
136+
"detail": "\n".join(tail),
137+
"message": (
138+
"I couldn't update automatically — this install may be managed "
139+
f"by another tool (pipx, uv, your OS). Run this and you're set: "
140+
f"{UPGRADE_COMMAND}"
141+
),
142+
}
143+
144+
installed = _parse_installed_version(proc.stdout or "")
145+
if installed is None:
146+
# pip ran clean but reported no install line → already current.
147+
return {
148+
"ok": True,
149+
"status": "already_latest",
150+
"current": before,
151+
"restart_required": False,
152+
"message": f"You're already on the latest Kiln ({before}). Nothing to do.",
153+
}
154+
return {
155+
"ok": True,
156+
"status": "updated",
157+
"current": before,
158+
"installed": installed,
159+
"restart_required": True,
160+
"message": f"Updated Kiln {before}{installed}. {RESTART_NOTE}",
161+
}

kiln/src/kiln/server.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -659,7 +659,9 @@ def _build_instructions() -> str:
659659
if _update_line:
660660
parts.append(
661661
f"UPDATE AVAILABLE: {_update_line} "
662-
"Mention this to the user so they can upgrade when convenient."
662+
"Offer to handle it for them — ask 'want me to update Kiln for "
663+
"you now?' and, on yes, call the upgrade_kiln tool (never while "
664+
"a print is active). Don't just tell them to run a command."
663665
)
664666
except Exception: # noqa: BLE001 -- nudge is best-effort, never fatal
665667
pass

kiln/src/kiln/version_check.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,12 +257,20 @@ def check_for_update(current_version: str | None = None) -> dict[str, Any] | Non
257257
if not isinstance(latest, str) or not is_newer(latest, current):
258258
return None
259259

260+
# Frame it as an offer the agent can act on, not just a command to echo.
261+
# Lazy import keeps the version_policy <-> version_check cycle clean.
262+
from kiln.version_policy import evaluate
263+
264+
verdict = evaluate(current, latest=latest)
260265
return {
261266
"available": True,
262267
"current": current,
263268
"latest": latest,
264269
"command": UPGRADE_COMMAND,
265-
"summary": f"Kiln {latest} is available (you're on {current}).",
270+
"summary": verdict.headline,
271+
"offer": verdict.offer,
272+
# The tool an agent calls once the user says "yes, update it."
273+
"action": "upgrade_kiln",
266274
}
267275

268276

0 commit comments

Comments
 (0)