File size: 22,705 Bytes
a122d72
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
"""parse_config tool β€” extract WorkloadConfig from a user's training script/args.

Three input shapes are supported, dispatched by file extension:

  * ``.py`` β€” Python source. Walks the AST for HuggingFace
    ``TrainingArguments(...)`` calls, ``DataLoader(...)`` calls,
    ``torch.compile(...)`` calls, ``model.gradient_checkpointing_enable()``
    invocations, and ``os.environ["X"] = "..."`` assignments.
  * ``.json`` β€” ``json.load`` of a dict shaped like ``TrainingArguments`` kwargs.
  * ``.yaml`` / ``.yml`` β€” ``yaml.safe_load`` of the same shape.

Before storing the source in ``WorkloadConfig.raw_source`` we run a regex
redaction pass for common secret shapes (HF/OpenAI/GitHub/Bearer tokens,
``$HOME`` paths, ``s3://``/``ws[s]://`` URIs). Each scrubbed pattern is
recorded in ``WorkloadConfig.redactions`` so the report can flag what was
removed.

Anything that doesn't map cleanly onto a ``WorkloadConfig`` field is shoved
into ``extras`` so we don't lose information.
"""

from __future__ import annotations

import ast
import json
import re
from pathlib import Path
from typing import Any

import yaml

from agent.schemas import ToolResult, WorkloadConfig
from agent.tools import Tool


# ---------------------------------------------------------------------------
# Redaction
# ---------------------------------------------------------------------------

# (label, compiled pattern). Order matters: more specific tokens first so we
# don't accidentally swallow them inside a generic Bearer rule.
_REDACTION_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
    ("openai_key", re.compile(r"sk-[A-Za-z0-9]{20,}")),
    ("hf_token", re.compile(r"hf_[A-Za-z0-9]{20,}")),
    ("github_token", re.compile(r"gho_[A-Za-z0-9]{20,}")),
    ("bearer_token", re.compile(r"Bearer\s+[A-Za-z0-9._\-]{8,}")),
    ("home_path", re.compile(r"/home/[^/\s\"'`]+")),
    ("s3_uri", re.compile(r"s3://[^\s\"'`]+")),
    ("ws_uri", re.compile(r"wss?://[^\s\"'`]+")),
]


def _redact(source: str) -> tuple[str, list[str]]:
    """Apply every redaction pattern. Returns (clean_source, labels_hit)."""
    labels: list[str] = []
    cleaned = source
    for label, pattern in _REDACTION_PATTERNS:
        if pattern.search(cleaned):
            labels.append(label)
            cleaned = pattern.sub(f"<REDACTED:{label}>", cleaned)
    return cleaned, labels


def _redact_extras(extras: dict[str, Any]) -> list[str]:
    """Mutate ``extras`` in place to scrub secret-shaped string values.

    Returns the labels that fired so we can merge them into ``redactions``.
    Lists/dicts inside extras are walked recursively. Non-string leaves are
    untouched.
    """
    labels: list[str] = []

    def _scrub(value: Any) -> Any:
        if isinstance(value, str):
            cleaned, hits = _redact(value)
            labels.extend(hits)
            return cleaned
        if isinstance(value, dict):
            return {k: _scrub(v) for k, v in value.items()}
        if isinstance(value, list):
            return [_scrub(v) for v in value]
        return value

    for k, v in list(extras.items()):
        extras[k] = _scrub(v)
    return labels


# ---------------------------------------------------------------------------
# AST helpers
# ---------------------------------------------------------------------------


def _literal(node: ast.AST) -> Any:
    """Best-effort literal evaluation. Returns ``None`` for non-literal expressions."""
    try:
        return ast.literal_eval(node)
    except (ValueError, SyntaxError):
        return None


def _attr_chain(node: ast.AST) -> str:
    """Render ``a.b.c`` from a Name/Attribute chain. Empty string for everything else."""
    parts: list[str] = []
    cur = node
    while isinstance(cur, ast.Attribute):
        parts.append(cur.attr)
        cur = cur.value
    if isinstance(cur, ast.Name):
        parts.append(cur.id)
        return ".".join(reversed(parts))
    return ""


def _call_name(node: ast.Call) -> str:
    """Resolve the dotted name of a Call's target (e.g. ``torch.compile``)."""
    func = node.func
    if isinstance(func, ast.Name):
        return func.id
    if isinstance(func, ast.Attribute):
        return _attr_chain(func)
    return ""


def _kwargs_to_dict(
    node: ast.Call, dict_constants: dict[str, dict[str, Any]] | None = None
) -> dict[str, Any]:
    """Pull literal kwargs out of a Call.

    Also resolves ``**dict_var`` splats when the splat target was assigned a
    literal dict elsewhere in the module (the resolved dict is in
    ``dict_constants``). This is defensive against the common refactor pattern
    ``_ta = dict(...); TrainingArguments(**_ta)`` β€” if the parser doesn't
    follow the splat, every TrainingArguments field disappears and the agent
    reasons over HF defaults instead of the script's actual values.
    """
    out: dict[str, Any] = {}
    dict_constants = dict_constants or {}
    for kw in node.keywords:
        if kw.arg is None:
            # `**something` splat. If something is a Name that resolves to a
            # dict literal we collected in pass 1, lift those entries in.
            if isinstance(kw.value, ast.Name):
                resolved = dict_constants.get(kw.value.id)
                if resolved:
                    for k, v in resolved.items():
                        # Don't override anything an explicit kwarg already set.
                        out.setdefault(k, v)
            continue
        val = _literal(kw.value)
        if val is not None or isinstance(kw.value, ast.Constant):
            out[kw.arg] = val
    return out


def _collect_dict_constants(tree: ast.AST) -> dict[str, dict[str, Any]]:
    """Find module-level ``NAME = dict(k=v, ...)`` and ``NAME = {k: v, ...}``
    assignments where every value is a literal. Returns name β†’ resolved dict.

    Used by ``_kwargs_to_dict`` to follow ``**NAME`` splats.
    """
    constants: dict[str, dict[str, Any]] = {}
    for stmt in getattr(tree, "body", []):
        if not isinstance(stmt, ast.Assign):
            continue
        targets = [t for t in stmt.targets if isinstance(t, ast.Name)]
        if not targets:
            continue
        resolved: dict[str, Any] | None = None
        # `dict(k=v, ...)` form
        if (
            isinstance(stmt.value, ast.Call)
            and isinstance(stmt.value.func, ast.Name)
            and stmt.value.func.id == "dict"
        ):
            resolved = {}
            for kw in stmt.value.keywords:
                if kw.arg is None:
                    continue
                val = _literal(kw.value)
                if val is not None or isinstance(kw.value, ast.Constant):
                    resolved[kw.arg] = val
        # `{"k": v, ...}` form
        elif isinstance(stmt.value, ast.Dict):
            resolved = {}
            for k_node, v_node in zip(stmt.value.keys, stmt.value.values):
                if not isinstance(k_node, ast.Constant) or not isinstance(k_node.value, str):
                    continue
                val = _literal(v_node)
                if val is not None or isinstance(v_node, ast.Constant):
                    resolved[k_node.value] = val
        if resolved:
            for t in targets:
                constants[t.id] = resolved
    return constants


# ---------------------------------------------------------------------------
# Field mapping
# ---------------------------------------------------------------------------

# HF TrainingArguments β†’ WorkloadConfig field name
_TRAINING_ARGS_MAP: dict[str, str] = {
    "per_device_train_batch_size": "batch_size",
    "gradient_accumulation_steps": "grad_accum_steps",
    "max_seq_length": "seq_len",
    "model_max_length": "seq_len",
    "optim": "optimizer",
    "gradient_checkpointing": "gradient_checkpointing",
    "torch_compile": "torch_compile",
    "learning_rate": "lr",
    "warmup_steps": "warmup_steps",
    "dataloader_num_workers": "dataloader_workers",
    "dataloader_pin_memory": "dataloader_pin_memory",
    "dataloader_prefetch_factor": "dataloader_prefetch_factor",
    "dataloader_persistent_workers": "dataloader_persistent_workers",
    "model_name_or_path": "model_name",
    "model_name": "model_name",
}

_DATALOADER_MAP: dict[str, str] = {
    "num_workers": "dataloader_workers",
    "pin_memory": "dataloader_pin_memory",
    "prefetch_factor": "dataloader_prefetch_factor",
    "persistent_workers": "dataloader_persistent_workers",
}

_VALID_PRECISIONS = {"fp32", "fp16", "bf16", "fp8"}
_VALID_ATTENTION = {"sdpa", "flash", "flash_rocm", "eager", "unknown"}


def _coerce_precision(args: dict[str, Any]) -> str | None:
    """Translate HF-style precision flags (bf16=True/fp16=True) into a Precision literal."""
    explicit = args.get("precision")
    if isinstance(explicit, str) and explicit in _VALID_PRECISIONS:
        return explicit
    if args.get("bf16") is True:
        return "bf16"
    if args.get("fp16") is True:
        return "fp16"
    if args.get("tf32") is True:
        return "fp32"
    return None


def _coerce_attention(args: dict[str, Any]) -> str | None:
    impl = args.get("attn_implementation") or args.get("attention_impl")
    if not isinstance(impl, str):
        return None
    impl = impl.lower()
    if impl in _VALID_ATTENTION:
        return impl
    if "flash" in impl and "rocm" in impl:
        return "flash_rocm"
    if "flash" in impl:
        return "flash"
    if impl == "eager":
        return "eager"
    if impl == "sdpa":
        return "sdpa"
    return None


def _apply_kwargs(
    payload: dict[str, Any],
    raw: dict[str, Any],
    extras: dict[str, Any],
    field_map: dict[str, str],
) -> None:
    """Push ``raw`` kwargs into ``payload`` using ``field_map``; unknown keys go to ``extras``."""
    for key, value in raw.items():
        if value is None:
            continue
        target = field_map.get(key)
        if target is None:
            extras[key] = value
            continue
        payload[target] = value


# ---------------------------------------------------------------------------
# Python AST extraction
# ---------------------------------------------------------------------------


def _extract_from_python(source: str) -> tuple[dict[str, Any], dict[str, Any]]:
    """Walk the AST. Returns (mapped_fields, extras_dict).

    Two-pass: first pass collects module-level ``NAME = "literal"`` assignments
    so we can resolve identifiers passed positionally to ``from_pretrained``
    (the common ``MODEL_ID = "..."; from_pretrained(MODEL_ID)`` shape).
    """
    payload: dict[str, Any] = {}
    extras: dict[str, Any] = {}
    env_vars: dict[str, str] = {}
    raw_training_args: dict[str, Any] = {}
    saw_torch_compile_call = False

    tree = ast.parse(source)

    # Pass 1: harvest top-level constants for identifier resolution.
    constants: dict[str, Any] = {}
    for stmt in tree.body:
        if isinstance(stmt, ast.Assign):
            val = _literal(stmt.value)
            if val is None:
                continue
            for target in stmt.targets:
                if isinstance(target, ast.Name):
                    constants[target.id] = val

    # Pass 1b: harvest dict-shaped constants so `**name` splats into Calls
    # can be resolved (e.g. `_ta = dict(...); TrainingArguments(**_ta)`).
    dict_constants = _collect_dict_constants(tree)

    def _arg_value(node: ast.AST) -> Any:
        """Literal eval, falling back to the constants table for bare Names."""
        val = _literal(node)
        if val is not None:
            return val
        if isinstance(node, ast.Name) and node.id in constants:
            return constants[node.id]
        return None

    # Pass 2: walk every node, including nested calls, for tool/library calls.
    for node in ast.walk(tree):
        if isinstance(node, ast.Call):
            name = _call_name(node)
            short = name.rsplit(".", 1)[-1] if name else ""

            if short in ("TrainingArguments", "Seq2SeqTrainingArguments"):
                kw = _kwargs_to_dict(node, dict_constants)
                raw_training_args.update(kw)

            elif short == "DataLoader":
                kw = _kwargs_to_dict(node, dict_constants)
                _apply_kwargs(payload, kw, extras, _DATALOADER_MAP)

            elif name == "torch.compile" or (
                short == "compile" and isinstance(node.func, ast.Attribute)
            ):
                # ``model = torch.compile(model, ...)`` is concrete evidence the
                # workload uses compile, regardless of any later TrainingArguments
                # kwarg. Record it as sticky-True.
                saw_torch_compile_call = True

            elif short == "gradient_checkpointing_enable":
                payload["gradient_checkpointing"] = True

            elif short == "from_pretrained":
                if node.args:
                    val = _arg_value(node.args[0])
                    if isinstance(val, str) and "model_name" not in payload:
                        payload["model_name"] = val
                kw = _kwargs_to_dict(node)
                attn = _coerce_attention(kw)
                if attn:
                    payload["attention_impl"] = attn

            elif short == "LoraConfig":
                kw = _kwargs_to_dict(node)
                if "r" in kw and isinstance(kw["r"], int):
                    payload["lora_rank"] = kw["r"]
                for k, v in kw.items():
                    if k != "r":
                        extras[f"lora.{k}"] = v

        elif isinstance(node, ast.Assign):
            # Catch ``os.environ["FOO"] = "bar"``.
            for target in node.targets:
                if (
                    isinstance(target, ast.Subscript)
                    and _attr_chain(target.value) == "os.environ"
                ):
                    key = _literal(target.slice)
                    val = _literal(node.value)
                    if isinstance(key, str) and val is not None:
                        env_vars[key] = str(val)

    if raw_training_args:
        # precision/attention have to be derived after all kwargs are gathered.
        prec = _coerce_precision(raw_training_args)
        if prec:
            payload["precision"] = prec
        attn = _coerce_attention(raw_training_args)
        if attn:
            payload["attention_impl"] = attn
        _apply_kwargs(payload, raw_training_args, extras, _TRAINING_ARGS_MAP)
        # Drop keys we already consumed for precision/attention.
        for k in ("bf16", "fp16", "tf32", "attn_implementation"):
            extras.pop(k, None)

    # An explicit ``torch.compile(...)`` call wins over a False kwarg in
    # TrainingArguments β€” the call is concrete evidence; the kwarg is just a
    # default the user may have left at False.
    if saw_torch_compile_call:
        payload["torch_compile"] = True

    if env_vars:
        payload["env_vars"] = env_vars

    return payload, extras


# ---------------------------------------------------------------------------
# JSON / YAML extraction
# ---------------------------------------------------------------------------


def _extract_from_dict(data: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
    """Translate a flat TrainingArguments-shaped dict into our schema."""
    payload: dict[str, Any] = {}
    extras: dict[str, Any] = {}

    prec = _coerce_precision(data)
    if prec:
        payload["precision"] = prec
    attn = _coerce_attention(data)
    if attn:
        payload["attention_impl"] = attn

    _apply_kwargs(payload, data, extras, _TRAINING_ARGS_MAP)

    # Pull DataLoader-shaped keys too, in case the config nests them.
    dl = data.get("dataloader") if isinstance(data.get("dataloader"), dict) else None
    if dl:
        _apply_kwargs(payload, dl, extras, _DATALOADER_MAP)
        extras.pop("dataloader", None)

    # env_vars block, if present.
    env = data.get("env_vars") or data.get("env")
    if isinstance(env, dict):
        payload["env_vars"] = {str(k): str(v) for k, v in env.items()}
        extras.pop("env_vars", None)
        extras.pop("env", None)

    # Same precision/attention scrub as the AST path.
    for k in ("bf16", "fp16", "tf32", "attn_implementation"):
        extras.pop(k, None)

    return payload, extras


# ---------------------------------------------------------------------------
# Top-level dispatch
# ---------------------------------------------------------------------------


def _build_config(
    payload: dict[str, Any],
    extras: dict[str, Any],
    raw_source: str,
    redactions: list[str],
) -> WorkloadConfig:
    """Construct a WorkloadConfig from the extracted payload + extras + redacted source."""
    # Schema requires model_name; fall back if neither the script nor the
    # config supplied one.
    payload.setdefault("model_name", "unknown")
    # Coerce types defensively β€” JSON/YAML can hand us strings where ints belong.
    int_fields = (
        "batch_size",
        "grad_accum_steps",
        "seq_len",
        "warmup_steps",
        "dataloader_workers",
        "lora_rank",
        "dataloader_prefetch_factor",
    )
    for field in int_fields:
        if field in payload and payload[field] is not None:
            try:
                payload[field] = int(payload[field])
            except (TypeError, ValueError):
                extras[field] = payload.pop(field)
    if "lr" in payload and payload["lr"] is not None:
        try:
            payload["lr"] = float(payload["lr"])
        except (TypeError, ValueError):
            extras["lr"] = payload.pop("lr")
    bool_fields = (
        "gradient_checkpointing",
        "torch_compile",
        "dataloader_pin_memory",
        "dataloader_persistent_workers",
    )
    for field in bool_fields:
        if field in payload and payload[field] is not None:
            payload[field] = bool(payload[field])

    payload["raw_source"] = raw_source
    payload["redactions"] = redactions
    payload["extras"] = extras
    return WorkloadConfig(**payload)


def _parse_config_full(file_path: str) -> WorkloadConfig | ToolResult:
    """Inner parser that returns the **complete** WorkloadConfig (including
    ``raw_source``). Tests use this to verify redaction. The public
    ``_parse_config`` wraps this and trims ``raw_source`` from the LLM-
    facing tool result.
    """
    try:
        path = Path(file_path)
        if not path.exists():
            return ToolResult(ok=False, error=f"File not found: {file_path}")

        raw = path.read_text(encoding="utf-8")
        clean, redactions = _redact(raw)
        suffix = path.suffix.lower()

        if suffix == ".py":
            payload, extras = _extract_from_python(raw)
        elif suffix == ".json":
            data = json.loads(raw)
            if not isinstance(data, dict):
                return ToolResult(
                    ok=False, error="JSON config must be an object at the top level."
                )
            payload, extras = _extract_from_dict(data)
        elif suffix in (".yaml", ".yml"):
            data = yaml.safe_load(raw)
            if not isinstance(data, dict):
                return ToolResult(
                    ok=False, error="YAML config must be a mapping at the top level."
                )
            payload, extras = _extract_from_dict(data)
        else:
            return ToolResult(
                ok=False,
                error=f"Unsupported config extension '{suffix}'. Expected .py/.json/.yaml/.yml.",
            )

        # Scrub any secret-shaped strings that survived in extras (mostly a
        # JSON/YAML concern β€” values come straight from the dict, not the
        # textual source, so the source-level redact pass missed them).
        extra_labels = _redact_extras(extras)
        for label in extra_labels:
            if label not in redactions:
                redactions.append(label)

        # env_vars come straight from the AST as literal strings β€” the
        # source-level redactor matched the *literal expression* in the source
        # but the value lives in our payload too, so scrub it again.
        env_vars = payload.get("env_vars")
        if isinstance(env_vars, dict):
            env_labels: list[str] = []
            cleaned_env: dict[str, str] = {}
            for k, v in env_vars.items():
                if isinstance(v, str):
                    cleaned, hits = _redact(v)
                    cleaned_env[k] = cleaned
                    env_labels.extend(hits)
                else:
                    cleaned_env[k] = v
            payload["env_vars"] = cleaned_env
            for label in env_labels:
                if label not in redactions:
                    redactions.append(label)

        return _build_config(payload, extras, clean, redactions)
    except SyntaxError as exc:
        return ToolResult(ok=False, error=f"Python parse error: {exc}")
    except json.JSONDecodeError as exc:
        return ToolResult(ok=False, error=f"JSON parse error: {exc}")
    except yaml.YAMLError as exc:
        return ToolResult(ok=False, error=f"YAML parse error: {exc}")
    except Exception as exc:
        return ToolResult(ok=False, error=f"{type(exc).__name__}: {exc}")


def _parse_config(file_path: str) -> ToolResult:
    """Parse a training-config artefact into a WorkloadConfig and return it
    as a tool result.

    Detects format from the path suffix; ``.py`` runs through the AST visitor,
    ``.json`` and ``.yaml``/``.yml`` go through the dict path. Anything else is
    rejected up front rather than guessed.

    The returned dict **omits** ``raw_source`` to keep the LLM's audit
    conversation small enough to fit Qwen's context window. The redacted
    source still lives in ``WorkloadConfig.raw_source`` server-side; tests
    inspect it via ``_parse_config_full``.
    """
    cfg_or_err = _parse_config_full(file_path)
    if isinstance(cfg_or_err, ToolResult):
        return cfg_or_err  # error path
    return ToolResult(ok=True, result=cfg_or_err.model_dump(exclude={"raw_source"}))


PARSE_CONFIG = Tool(
    name="parse_config",
    description=(
        "Parse a user-uploaded training script or HF TrainingArguments JSON/YAML "
        "into a normalized WorkloadConfig. Redacts tokens, keys, and filesystem "
        "paths before returning."
    ),
    input_schema={
        "type": "object",
        "properties": {
            "file_path": {
                "type": "string",
                "description": "Absolute path to the uploaded training script or config file.",
            }
        },
        "required": ["file_path"],
    },
    fn=_parse_config,
)