Skip to content

Commit 8fec761

Browse files
peterschmidt85Andrey Cheptsovclaude
authored
[Presets] Patching framework and --previous support (#4118)
* Record each preset trial and service attempt in its own directory A trial was one appended line in a shared `trials.jsonl`, mirrored into the session directory by byte offset. An agent rewriting the file moved every offset, and the mirror then committed a torn fragment forever: one verified preset still carries an unparseable trial record. Service attempts had the same shape in `verifications.jsonl`. Every record is now written exactly once and reading is listing a directory: - `trials/<n>/` holds the trial's compiled `task.dstack.yml` and its `trial.json`; the presence of `trial.json` is what marks the trial completed, so in-flight and torn states are visible instead of corrupting. - `service/<k>/` holds each verification attempt's submitted YAML and a `verification.json` written when the attempt ends; an attempt directory without a result is one still in progress. - The byte-offset record mirrors are replaced by a stateless directory mirror that re-lists the source and copies changed files whole, scrubbed, and atomically. A torn read can never be committed; the next pass converges. Trial and attempt directories sort numerically, pinned past 9. The trial contract in the agent prompt is rewritten around the layout, with every per-trial file referenced by its full path and the write order stated where the files are defined. The listing shows the trial being worked on rather than the completed count while trialing, so `trialing (2/3)` cannot read as two finished; `verifying (3/3)` keeps the completed count. Validated end to end: a session on one RTX PRO 4500 produced three contract-exact trial records, a recorded verification attempt, and a saved preset that `dstack preset apply` deploys. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mirror preset record files byte-exactly The directory mirror decoded each file to text and re-encoded it, which rewrote newlines (CRLF became LF on Unix, LF would become CRLF on Windows) and replaced non-UTF-8 bytes. Harmless for the CLI's own JSON and YAML records, but the mirror now also carries patch files, where the recorded copy must be exactly the bytes that ran. Copy bytes verbatim and redact at the byte level. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Support patching and failed-trial verification in preset creation The preset agent may now patch the serving framework source code, generate custom kernels, and patch drivers during trials. Patches are recorded as unified diffs under `trials/<n>/patches`, referenced from the trial's `task.dstack.yml` via `files`, and applied with `patch` from its commands, so the recorded configuration reproduces the trial exactly. The verified service records its patches the same way under `service/<k>/patches`, and the saved preset re-roots `files` onto the mirrored session copies, so `dstack preset apply` reproduces the patched configuration after the agent workspace is deleted. When no trial meets the constraints, the best failed trial that has a benchmark is still verified and saved instead of failing the whole creation: the hardware's best is worth keeping even when it falls short of what was asked. To keep that honest, the final report now records the verified trial, presets store the requested `min_context_length` and `max_ttft` alongside the verified values, and `dstack preset apply` warns, instead of refusing, when the preset's verified context is below the requested one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Mark constraint-breaching presets and scale the trial spark from zero A preset saved from a failed trial shows `*` next to its benchmark, the same mark a running session uses when it has only failed trials to show, computed from the requested constraints the preset now stores. In the trial spark, bars scale from zero so their heights compare as the numbers do, failed trials are red rather than gold, and gold marks the best result only while no trial meets the constraints. While trialing, the `(N/M)` progress counts the trial being worked on rather than the completed ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add if/else/end conditionals to the preset agent prompt The system prompt rendered conditional blocks with a non-nesting `<!--?NAME:CONTENT-->` directive. Replace it with `<!--?if NAME-->` / `<!--?else-->` / `<!--?end-->`: blocks nest, markers may be inline or alone on a possibly indented line, and a branch body is dedented by the one indentation shared by all its lines. Both branches are always parsed, so an unknown variable cannot hide behind a flag combination, and unbalanced or malformed directives fail loudly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Support --previous in preset creation `dstack preset create --previous <ID>` (repeatable, or a `previous` list in the configuration) gives the agent the records of earlier creation sessions: their trials, configurations, patches, and reports are copied into the workspace under `previous/`, and the prompt tells the agent to analyze them and improve on them. With `baseline: true`, the first trial reproduces the best comparable previous result before optimizing further. The IDs are pinned in the session manifest, so a resumed session keeps the same context. A still-running previous session is rejected; a chained session whose parents were not included warns. `constraints.json` is now written as a session record for every creation, not only under `--debug`, so a preset's constraints survive after its workspace is deleted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Wrap the preset listing gracefully on narrow terminals CONSTRAINTS and BENCHMARK now wrap together to keep their full content, STATUS and SUBMITTED wrap at their spaces, and BASE and GPU truncate (capped in the compact view) so a long model name cannot starve the data columns. Previously only BENCHMARK folded while its neighbours clipped, so a narrow terminal rendered one tall column beside single-line ones. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Document preset patching and limitations Note in the concepts intro that the agent optimizes across the serving stack and may patch the framework's source, generate kernels, and patch drivers. Replace the roadmap admonition with a limitations one that also records the random-dataset and runtime-only-patch limits. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Clean up preset comments and docstrings Cut comments that restate the code or explain what it doesn't do, rewrite jargon-heavy ones concretely, and add docstrings only where a method's intent is genuinely non-obvious. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Remove archive-on-delete from the preset store `dstack preset delete` now removes the preset permanently after its confirmation. Git covers committed presets, and a user who wants a copy can move `~/.dstack/presets/<id>/` manually. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Default preset baseline to true Every session gets an anchor by default: the first trial serves the framework's recommended configuration, or reproduces the previous best when the session builds on `--previous`. Set `baseline: false` to spend every trial on optimization. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * Save preset patch files with relative paths A preset directory is now portable: `preset.yaml` references its patch files relative to its own location, resolved at load, so the directory works after being copied to another path or machine. Re-saving a loaded preset (e.g. on name reuse) keeps the paths relative. Presets saved with absolute paths continue to load as before. Verified end to end by applying a patched preset from a relocated copy: all patches uploaded and applied, and the service answered. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Andrey Cheptsov <andrey.cheptsov@github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 4ed4170 commit 8fec761

27 files changed

Lines changed: 1620 additions & 471 deletions

mkdocs/docs/concepts/presets.md

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ A preset configuration lets you use an agent to create a preset: a verified and
99

1010
The value of presets comes from combining two fundamental features: agent-driven model inference optimization and the `dstack` [service](services.md) primitive, which can deploy model inference to any cloud, Kubernetes, or on-prem cluster.
1111

12+
To get the best performance for the given model, hardware, and other constraints, the agent selects the serving framework, quantization, and serving parameters, and can patch the framework's source code, generate custom kernels, and patch drivers.
13+
1214
> The presets feature is experimental and may change.
1315
1416
??? info "Prerequisites"
@@ -167,7 +169,24 @@ prompt: |
167169

168170
### Baseline
169171

170-
Set `baseline: true` to make the first trial a baseline: the agent serves the model the way the chosen serving framework recommends, without tuning it for performance. Later trials are optimization attempts.
172+
By default, the first trial is a baseline: the agent serves the model the way the chosen serving framework recommends, without tuning it for performance. Later trials are optimization attempts. Set `baseline: false` to make every trial an optimization attempt.
173+
174+
### Previous sessions
175+
176+
Set `previous` to a list of preset IDs to give the agent the results of earlier creation sessions. It analyzes what they tried and how it worked, and aims to improve on them instead of rediscovering it.
177+
178+
<div editor-title="preset.dstack.yml">
179+
180+
```yaml
181+
previous:
182+
- c83375b4
183+
```
184+
185+
</div>
186+
187+
Alternatively, pass `--previous` (repeatable) to `dstack preset create`.
188+
189+
In this case, the baseline trial reproduces the best comparable previous result to confirm it still holds before optimizing further.
171190

172191
!!! info "Reference"
173192
The `preset` configuration supports many more options. See the [`.dstack.yml` reference](../reference/dstack.yml/preset.md).
@@ -251,13 +270,11 @@ $ dstack preset delete c83375b4
251270

252271
For command options and agent settings, see the [`dstack preset` CLI reference](../reference/cli/dstack/preset.md).
253272

254-
!!! info "Roadmap and feedback"
255-
Here's what is coming soon:
256-
257-
* Allow the agent to change the source code, compile binaries, etc.
258-
* Support for PD disaggregation
259-
* Allow passing multiple `--previous <preset ID>` to `dstack preset create` to reuse the insights from previous sessions
260-
* Allow passing ranges to `concurrency`
273+
!!! info "Limitations"
274+
* Currently, the agent doesn't upload compiled binaries anywhere; patches compile at runtime
275+
* Doesn't support PD disaggregation (coming soon)
276+
* Doesn't allow a custom dataset; always uses `random`
277+
* Doesn't support ranges for `concurrency`
261278

262279
Report bugs and request features on [GitHub](https://github.com/dstackai/dstack/issues), and ask questions on [Discord](https://discord.gg/u8SmfwPpMd).
263280

src/dstack/_internal/cli/commands/preset.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
plan_preset,
2323
reassign_preset_name,
2424
reconcile_detached_sessions,
25+
resolve_previous_sessions,
2526
show_preset_session_logs,
2627
stop_preset_session,
2728
)
@@ -114,6 +115,13 @@ def _register(self) -> None:
114115
action="store_true",
115116
help="Save the agent prompt and raw trace",
116117
)
118+
create_parser.add_argument(
119+
"--previous",
120+
action="append",
121+
metavar="ID",
122+
help="Give the agent a previous session's results to analyze and improve on."
123+
" Repeat for several",
124+
)
117125
create_parser.add_argument(
118126
"--resume",
119127
metavar="ID",
@@ -286,6 +294,14 @@ def _create(self, args: argparse.Namespace) -> None:
286294
"[warning]--trials is ignored when resuming: "
287295
"the constraints are fixed at creation[/]"
288296
)
297+
if configuration.previous:
298+
console.print(
299+
"[warning]previous is ignored when resuming: "
300+
"the previous sessions are fixed at creation[/]"
301+
)
302+
previous = ()
303+
if resume_session is None and configuration.previous:
304+
previous = resolve_previous_sessions(configuration.previous)
289305
api = Client.from_config(project_name=args.project)
290306
allowed_fleets = None
291307
if resume_session is None:
@@ -310,6 +326,7 @@ def _create(self, args: argparse.Namespace) -> None:
310326
resume_session=resume_session,
311327
user_prompt=user_prompt,
312328
allowed_fleets=allowed_fleets,
329+
previous=previous,
313330
)
314331
except KeyboardInterrupt:
315332
return # the interrupt handler already reported detach / stop
@@ -524,6 +541,8 @@ def _get_effective_configuration(
524541
_apply_name(configuration, args.name, required=require_name)
525542
if getattr(args, "trials", None) is not None:
526543
configuration.trials = args.trials
544+
if getattr(args, "previous", None):
545+
configuration.previous = list(args.previous)
527546
profile = load_profile_from_args(args=args, repo_dir=Path.cwd())
528547
for field in ProfileParams.model_fields:
529548
if getattr(configuration, field) is None:

src/dstack/_internal/cli/models/configurations.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
DEFAULT_INPUT_TOKENS = 1024
1919
DEFAULT_OUTPUT_TOKENS = 1024
20-
DEFAULT_BASELINE = False
20+
DEFAULT_BASELINE = True
2121

2222

2323
class PresetModelRepo(CoreModel):
@@ -154,6 +154,15 @@ class PresetConfiguration(
154154
)
155155
),
156156
] = None
157+
previous: Annotated[
158+
Optional[list[str]],
159+
Field(
160+
description=(
161+
"The IDs of previous presets whose creation results the agent"
162+
" analyzes and improves on"
163+
)
164+
),
165+
] = None
157166
concurrency: Annotated[
158167
Optional[PositiveInt],
159168
Field(
@@ -196,7 +205,7 @@ class PresetConfiguration(
196205
description=(
197206
"Whether the first trial must be a baseline that serves the model with the"
198207
" serving framework's recommended defaults instead of an optimization attempt."
199-
" Defaults to `false`"
208+
" Defaults to `true`"
200209
)
201210
),
202211
] = None

src/dstack/_internal/cli/models/preset_agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
"run_id": {"type": "string"},
8686
"run_name": {"type": "string"},
8787
"service_yaml": {"type": "string"},
88+
"trial": {"type": "integer", "minimum": 1},
8889
"base": {"type": "string"},
8990
"model": {"type": "string"},
9091
"context_length": {"type": "integer", "minimum": 1},
@@ -101,6 +102,7 @@ class AgentFinalReport(CoreModel):
101102
run_id: Optional[uuid.UUID] = None
102103
run_name: Optional[str] = None
103104
service_yaml: Optional[str] = None
105+
trial: Optional[PositiveInt] = None
104106
base: Optional[str] = None
105107
model: Optional[str] = None
106108
context_length: Optional[PositiveInt] = None
@@ -114,6 +116,7 @@ def validate_report(self) -> Self:
114116
"run_id",
115117
"run_name",
116118
"service_yaml",
119+
"trial",
117120
"base",
118121
"model",
119122
"context_length",

src/dstack/_internal/cli/models/presets.py

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,10 @@ class PresetBenchmark(CoreModel):
6767

6868
@property
6969
def effective_output_tok_per_s(self) -> float:
70-
"""Performance as defined in the agent prompt's `## Performance`. Derived
71-
rather than read, so a miscomputed field cannot become the displayed truth."""
7270
return self.metrics.total_output_tokens / self.metrics.duration_seconds
7371

7472
@property
7573
def effective_per_user_tok_per_s(self) -> float:
76-
"""Per-user output speed as the serving literature defines it: the steady
77-
decode rate, `1/TPOT`, which excludes time to first token. Dividing the
78-
aggregate by concurrency instead folds TTFT and the ramp into it."""
7974
return 1000 / self.metrics.tpot_ms.p50
8075

8176
@field_validator("tool", "tool_version", "command")
@@ -113,25 +108,22 @@ def validate_metrics(self) -> Self:
113108

114109
class PresetValidationReplica(CoreModel):
115110
resources: list[ResourcesSpec]
116-
"""Exact resources for each running replica in this service replica group."""
117111

118112

119113
class PresetValidation(CoreModel):
120114
replicas: list[PresetValidationReplica]
121-
"""Ordered to match `ServiceConfiguration.replica_groups`."""
122115
benchmark: PresetBenchmark
123116

124117

125118
class Preset(CoreModel):
126119
base: str
127-
"""Base model used for local preset lookup."""
128120
id: str
129121
name: Optional[str] = None
130-
"""Mutable human name; at most one preset or in-flight session holds it."""
131122
model: str
132-
"""Exact repo/path loaded by the service command."""
133123
context_length: PositiveInt
134-
"""Token context length this preset was verified to support."""
124+
trial: Optional[PositiveInt] = None
125+
min_context_length: Optional[PositiveInt] = None
126+
max_ttft: Optional[PositiveInt] = None
135127
created_at: datetime
136128
service: ServiceConfiguration
137129
validations: list[PresetValidation]

src/dstack/_internal/cli/services/presets/agent.py

Lines changed: 23 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
print_preset_progress,
2424
)
2525
from dstack._internal.cli.services.presets.tail import (
26+
_DirectoryMirror,
2627
_FileLineReader,
2728
_OffsetStore,
2829
_ProgressTailer,
@@ -171,6 +172,9 @@ def build_preset_agent_env(
171172
env[_PROGRESS_ENV] = str(workspace.progress_path)
172173
for name in ["TMPDIR", "TEMP", "TMP"]:
173174
env[name] = str(workspace.temp_path)
175+
# Sandbox the agent's Claude config under the workspace home when we pass our
176+
# own API key; under subscription auth keep the real HOME so it reuses the
177+
# user's existing `claude` login.
174178
if auth.api_key is not None:
175179
env["ANTHROPIC_API_KEY"] = auth.api_key
176180
env["HOME"] = str(workspace.dstack_home)
@@ -222,14 +226,12 @@ async def run_preset_agent(
222226
# failure report from the agent returns immediately.
223227
if output.report_data is not None or output.error is None:
224228
return output
225-
# A failed attempt that produced agent work is a new outage, not a
226-
# continuation of the previous one: restore the full retry budget.
227-
# Attempts that fail without any work drain it, so the loop always
228-
# terminates when the network stays down.
229+
# Only reset the retry budget when the last attempt made progress; a
230+
# run that keeps stalling exhausts its retries instead of retrying a
231+
# stuck agent forever.
229232
if output.made_progress:
230233
retry_delays = list(_RESUME_DELAYS_SECONDS)
231-
# An externally recorded stop is a decision, not an outage: never
232-
# resurrect an agent another CLI just terminated.
234+
# Another process marked this session interrupted; don't restart it.
233235
if agent_session.read_manifest().get("status") == "interrupted":
234236
return output
235237
if not retry_delays:
@@ -276,9 +278,9 @@ async def _run_claude_process(
276278
stdout=stdout_file,
277279
stderr=stderr_file,
278280
start_new_session=not IS_WINDOWS,
279-
# Inherit only the redirected std handles, not the CLI's other fds.
280-
# Without this the untrusted agent inherits our open descriptors, and
281-
# on Windows the broad inheritance flakes CreateProcess (WinError 87).
281+
# So the untrusted agent inherits only the redirected std handles,
282+
# not our other descriptors; broad inheritance also flakes
283+
# CreateProcess on Windows (WinError 87).
282284
close_fds=True,
283285
)
284286
agent_session.update_manifest(
@@ -358,6 +360,8 @@ def _build_claude_command(
358360

359361

360362
def _prepare_subprocess_command(command: list[str]) -> list[str]:
363+
"""On Windows a `.bat`/`.cmd` Claude launcher can't be exec'd directly; wrap
364+
it in `cmd.exe /c`. Every other case is returned unchanged."""
361365
if not IS_WINDOWS or Path(command[0]).suffix.lower() not in {".bat", ".cmd"}:
362366
return command
363367
comspec = os.getenv("COMSPEC") or shutil.which("cmd.exe")
@@ -399,7 +403,6 @@ async def _session_tailers(
399403
redacted_values: Sequence[str],
400404
offset_store: _OffsetStore,
401405
) -> AsyncIterator[None]:
402-
"""Mirrors the session's progress and record files while the body runs."""
403406
progress_tailer = _ProgressTailer(
404407
path=workspace.progress_path,
405408
redacted_values=redacted_values,
@@ -415,20 +418,16 @@ async def _session_tailers(
415418
offset_key="runs",
416419
echo=agent_session.echo,
417420
),
418-
_RecordMirror(
419-
source=workspace.trials_path,
420-
target=agent_session.trials_path,
421+
_DirectoryMirror(
422+
source=workspace.trials_dir,
423+
target=agent_session.trials_dir,
421424
redacted_values=redacted_values,
422-
offset_store=offset_store,
423-
offset_key="trials",
424425
echo=agent_session.echo,
425426
),
426-
_RecordMirror(
427-
source=workspace.verifications_path,
428-
target=agent_session.verifications_path,
427+
_DirectoryMirror(
428+
source=workspace.service_dir,
429+
target=agent_session.service_dir,
429430
redacted_values=redacted_values,
430-
offset_store=offset_store,
431-
offset_key="verifications",
432431
echo=agent_session.echo,
433432
),
434433
]
@@ -456,8 +455,7 @@ async def _collect_agent_output(
456455
is_alive: Callable[[], bool],
457456
offset_store: _OffsetStore,
458457
) -> PresetAgentProcessOutput:
459-
"""Parses the agent's stream files until it exits; safe alongside a live
460-
process or over the remains of a finished one."""
458+
"""Safe to run alongside a live agent or over the stream files a finished one left behind."""
461459
stdout_output, _ = await asyncio.gather(
462460
_read_process_stream(
463461
stream=_FileLineReader(
@@ -495,8 +493,7 @@ async def attach_preset_agent(
495493
redacted_values: Sequence[str],
496494
agent_session: PresetAgentSession,
497495
) -> PresetAgentProcessOutput:
498-
"""Follows a detached session's agent to completion, like
499-
`run_preset_agent` without owning the process."""
496+
"""Like `run_preset_agent`, but tails a detached agent it does not own."""
500497
offset_store = open_session_offsets(agent_session)
501498
async with _session_tailers(
502499
workspace=workspace,
@@ -574,8 +571,7 @@ async def _read_process_stream(
574571

575572

576573
async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
577-
"""SIGTERM, a grace period, then SIGKILL — the same ladder as
578-
`terminate_agent_process`, driven through the owned process handle."""
574+
"""Twin of `terminate_agent_process` for a process this CLI owns, driven through its handle."""
579575
if IS_WINDOWS:
580576
await asyncio.to_thread(_terminate_windows_process_tree, proc.pid)
581577
await proc.wait()
@@ -601,9 +597,7 @@ async def _terminate_process(proc: asyncio.subprocess.Process) -> None:
601597

602598

603599
def terminate_agent_process(manifest: dict[str, Any]) -> None:
604-
"""Terminates the session's agent process tree, if alive. The same
605-
SIGTERM-grace-SIGKILL ladder as `_terminate_process`, driven by pid because
606-
the caller (`preset stop`) never owned the process."""
600+
"""Twin of `_terminate_process` driven by pid, because the caller (`preset stop`) never owned the process."""
607601
agent_pid = manifest.get("agent_pid")
608602
if not isinstance(agent_pid, int) or not _pid_alive(
609603
agent_pid, manifest.get("agent_started_at")

src/dstack/_internal/cli/services/presets/apply.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
format_preset_objective,
1212
)
1313
from dstack._internal.cli.services.presets.store import PresetStore
14+
from dstack._internal.cli.utils.common import warn
1415
from dstack._internal.core.errors import CLIError
1516
from dstack._internal.core.models.configurations import ServiceConfiguration
1617
from dstack._internal.core.models.profiles import ProfileParams
@@ -54,15 +55,15 @@ def apply_preset(
5455

5556

5657
def _validate_preset_matches(preset: Preset, *, configuration: PresetConfiguration) -> None:
57-
"""The referenced preset must serve what the configuration asks for."""
5858
model_name = configuration.model.api_model_name
5959
service_model = preset.service.model
6060
if service_model is None or service_model.name.lower() != model_name.lower():
6161
raise CLIError(f"Preset {preset.id} does not serve {model_name}")
6262
if configuration.min_context_length is not None:
6363
if preset.context_length < configuration.min_context_length:
64-
raise CLIError(
65-
f"Preset {preset.id} does not support context length"
64+
warn(
65+
f"Preset {preset.id} is verified for context length"
66+
f" {preset.context_length}, below the requested"
6667
f" {configuration.min_context_length}"
6768
)
6869
if configuration.model.allows_variant_selection:
@@ -95,7 +96,5 @@ def _format_requested_model(configuration: PresetConfiguration) -> str:
9596

9697

9798
def _format_selected_preset(preset: Preset) -> str:
98-
# The formatter dims its own keys; wrapping it again would flatten that.
99-
# One line, so the objective and the result are joined rather than columned.
10099
details = f"{format_preset_objective(preset)} {format_preset_benchmark(preset, verbose=True)}"
101100
return f"{escape(preset.id)} ({details})"

0 commit comments

Comments
 (0)