"""
pycsamt.agents.code_gen
========================
:class:`CodeGenerationAgent` — Emit a reproducible Python script from a
completed workflow configuration and agent results.
The generated script imports pycsamt, replicates every processing step that
was executed, and is self-contained (no agent infrastructure needed to run
it). It is the "save your workflow as code" button.
"""
from __future__ import annotations
import json
import os
import re
import time
from datetime import datetime
from pathlib import Path
from typing import Any
from ._base import AgentResult, BaseAgent
from ._generation import (
GenerationInput,
clarification_for,
exact_artifact_edit,
excludes_correction,
forward_parameters,
)
#: Folder used when no ``output_dir`` is given. Matches the Agent Master
#: default so generated scripts never land in the current working
#: directory (e.g. a repository root).
DEFAULT_OUTPUT_DIR = "pycsamt_agent_output"
_SYSTEM_PROMPT = """\
You are an expert Python developer specialising in geophysics scripting.
Generate or edit a clean Python script for the user's complete request.
Workflow configurations and templates are references, not fixed plans.
Preserve requested selections, quantities, units, filenames, formats, and
exclusions. For an edit, preserve the prior script except for requested changes.
Use pycsamt v2 public API only. Add a one-line comment above each major
block. Do not use the agents/ subpackage — call emtools, forward, and
site functions directly. Never invent missing APIs or scientific parameters.
Output only valid Python code, or CLARIFY: and one focused question when
required information is missing. Do not claim to have executed the script.
"""
# ── static script template fragments ─────────────────────────────────────────
_HEADER = """\
#!/usr/bin/env python
# -*- coding: utf-8 -*-
\"\"\"
{title}
Generated by pycsamt CodeGenerationAgent — {date}
This script reproduces the processing workflow automatically.
\"\"\"
from __future__ import annotations
import numpy as np
import matplotlib.pyplot as plt
"""
_LOAD_BLOCK = """\
# ── load MT data ──────────────────────────────────────────────
from pycsamt.emtools._core import ensure_sites
sites = ensure_sites({path!r}, verbose=0)
print(f"Loaded {{len(list(sites))}} stations")
"""
_QC_BLOCK = """\
# ── quality control ───────────────────────────────────────────
from pycsamt.emtools.qc import (
build_qc_table,
qc_flags,
plot_frequency_confidence_psection,
)
qc_table = build_qc_table(sites)
flags = qc_flags(sites, min_frac_ok=0.6, min_snr_med=2.0)
fig_qc = (
plot_frequency_confidence_psection(sites).get_figure()
)
fig_qc.savefig(
{out!r} + "/qc_confidence.png",
dpi=150, bbox_inches="tight",
)
"""
_SS_ESTIMATE_BLOCK = """\
# Estimate and export factors; do not apply correction.
from pycsamt.emtools.ss import estimate_ss_ama
ss_table = estimate_ss_ama(sites)
if ss_table.empty:
print("No applicable static-shift factors were estimated.")
else:
ss_table.to_csv({out!r} + "/static_shift_factors.csv", index=False)
sites_corr = sites
"""
_SS_BLOCK = """\
# ── static-shift correction ───────────────────────────────────
from pycsamt.emtools.ss import (
estimate_ss_ama,
correct_ss_ama,
plot_ss_summary,
plot_ss_1d_curves,
)
from pycsamt.emtools._core import (
_get_z_block, _name, _iter_items,
)
ss_table = estimate_ss_ama(
sites,
half_window={hw},
sort_by="lon",
weights="tri",
max_skew=6.0,
)
print(
ss_table[
["station", "delta_log10_rho", "fac_z"]
]
)
sites_corr = correct_ss_ama(
sites,
half_window={hw},
sort_by="lon",
)
def _collect_logRho(S):
rows, freqs = [], None
for i, ed in enumerate(_iter_items(S)):
Z, z, fr = _get_z_block(ed)
if Z is None:
continue
rxy = (
0.2 * np.abs(z[:, 0, 1]) ** 2
/ (fr + 1e-24)
)
ryx = (
0.2 * np.abs(z[:, 1, 0]) ** 2
/ (fr + 1e-24)
)
rows.append(
np.log10(np.sqrt(rxy * ryx) + 1e-24)
)
freqs = fr
return np.array(rows), freqs
logRho_b, ss_freqs = _collect_logRho(sites)
logRho_a, _ = _collect_logRho(sites_corr)
ss_labels = [
_name(ed, i)
for i, ed in enumerate(_iter_items(sites))
]
fig_sum = plot_ss_summary(
logRho_b, logRho_a,
freqs=ss_freqs,
station_labels=ss_labels,
)
fig_sum.savefig(
{out!r} + "/ss_summary.png",
dpi=150, bbox_inches="tight",
)
fig_1d = plot_ss_1d_curves(
logRho_b, logRho_a,
freqs=ss_freqs,
station_labels=ss_labels,
)
fig_1d.savefig(
{out!r} + "/ss_1d_curves.png",
dpi=150, bbox_inches="tight",
)
"""
_PT_BLOCK = """\
# ── phase tensor analysis ─────────────────────────────────────
from pycsamt.emtools.tensor import (
build_phase_tensor_table,
plot_phase_tensor_psection,
)
from pycsamt.emtools.strike import (
estimate_strike_consensus,
plot_strike_analysis,
)
from pycsamt.emtools.dimensionality import classify_dimensionality
pt_table = build_phase_tensor_table(sites_corr)
dim_table = classify_dimensionality(
sites_corr, skew_th={skew_th}, ellipt_th={ellipt_th},
)
st_result = estimate_strike_consensus(sites_corr)
strike_angle = st_result.get("angle_deg")
try:
strike_text = f"{{float(strike_angle):.1f}} deg"
except (TypeError, ValueError):
strike_text = "n/a"
print(f"Consensus strike: {{strike_text}}")
fig_pt = plot_phase_tensor_psection(sites_corr).get_figure()
fig_pt.savefig(
{out!r} + "/pt_psection.png",
dpi=150, bbox_inches="tight",
)
fig_strike = plot_strike_analysis(sites_corr)
fig_strike.savefig(
{out!r} + "/strike_analysis.png",
dpi=150, bbox_inches="tight",
)
"""
_FWD_BLOCK = """\
# ── 1-D MT forward model ──────────────────────────────────────
from pycsamt.forward import (
MT1DForward,
LayeredModel,
plot_response_and_model_1d,
)
layered = LayeredModel(
resistivity={rhos},
thickness={ths},
)
freqs = {freqs}
fwd = MT1DForward(freqs=freqs)
resp = fwd.run(layered)
fig_fwd = plot_response_and_model_1d(resp, layered)
fig_fwd.savefig(
{out!r} + "/forward_response.png",
dpi=150, bbox_inches="tight",
)
"""
_PRE_INV_BLOCK = """\
# Occam2D pre-inversion export
from pycsamt.models.occam2d import InputBuilder, OccamConfig
occam_workdir = os.path.join({out!r}, "occam2d_inputs")
os.makedirs(occam_workdir, exist_ok=True)
occam_config = OccamConfig()
occam_config.to_template(os.path.join(occam_workdir, "occam2d.yml"))
occam_builder = InputBuilder(
sites_corr,
workdir=occam_workdir,
config=occam_config,
)
# Expected Occam2D artifacts: OccamDataFile.dat, Occam2DMesh,
# Occam2DModel, and OccamStartup. The builder records the public API
# used for pre-inversion preparation.
print(f"Occam2D pre-inversion configuration written to {{occam_workdir}}")
"""
_TIPPER_BLOCK = """\
# Tipper and induction-arrow analysis
from pycsamt.emtools.tf import (
plot_induction_arrows,
plot_tipper_hodograms,
plot_tipper_polar,
)
try:
ax_tip = plot_induction_arrows(
sites_corr,
periods=({period},),
convention={convention!r},
)
ax_tip.get_figure().savefig(
{out!r} + "/induction_arrows.png",
dpi=150, bbox_inches="tight",
)
except Exception as exc:
print(f"No plottable induction arrows: {{exc}}")
try:
ax_hodo = plot_tipper_hodograms(sites_corr)
ax_hodo.get_figure().savefig(
{out!r} + "/tipper_hodograms.png",
dpi=150, bbox_inches="tight",
)
except Exception as exc:
print(f"Tipper hodogram skipped: {{exc}}")
try:
ax_polar = plot_tipper_polar(sites_corr, component={component!r})
ax_polar.get_figure().savefig(
{out!r} + "/tipper_polar.png",
dpi=150, bbox_inches="tight",
)
except Exception as exc:
print(f"Tipper polar plot skipped: {{exc}}")
"""
_SENSITIVITY_BLOCK = """\
# Depth-of-investigation and sensitivity analysis
from pycsamt.emtools.csumt import (
depth_coverage_table,
plot_depth_section,
)
doi_table = depth_coverage_table(sites_corr)
doi_table.to_csv(
{out!r} + "/depth_of_investigation.csv",
index=False,
)
ax_doi = plot_depth_section(sites_corr)
ax_doi.get_figure().savefig(
{out!r} + "/depth_of_investigation.png",
dpi=150, bbox_inches="tight",
)
print("Depth-of-investigation and sensitivity outputs written.")
"""
_AI_INV_BLOCK = """\
# ── AI 1-D inversion ──────────────────────────────────────────
from pycsamt.ai.inversion import EMInverter1D
from pycsamt.forward.batch import generate_dataset
freqs_ai = {freqs!r}
n_layers = {n_layers}
n_samples = {n_samples}
epochs = {epochs}
ds = generate_dataset(
solver="mt1d",
n_samples=n_samples,
freqs=freqs_ai,
n_layers=n_layers,
noise_level=0.03,
seed=42,
n_jobs=1,
verbose=False,
)
inv1d = EMInverter1D(
arch={arch!r},
n_layers=n_layers,
solver="mt1d",
)
inv1d.fit(ds.X, ds.y, epochs=epochs, batch_size=32, verbose=False)
import numpy as np
obs_features = {{
f"synthetic_{{i:02d}}": feat
for i, feat in enumerate(ds.X[: min(3, len(ds.X))])
}}
predictions = {{}}
for site_name, feat in obs_features.items():
pred = inv1d.predict(feat.reshape(1, -1))
predictions[site_name] = pred[0]
print(f"AI 1-D inversion done: {{len(predictions)}} stations")
"""
_ENSEMBLE_BLOCK = """\
# ── ensemble 1-D inversion with uncertainty ───────────────────
from pycsamt.ai.inversion import EnsembleInverter
ens = EnsembleInverter(
n_members={n_members},
arch={arch!r},
n_layers={n_layers},
solver="mt1d",
)
ens.fit(ds.X, ds.y, epochs={epochs}, verbose=False)
ensemble_preds = {{}}
ensemble_unc = {{}}
for site_name, feat in obs_features.items():
mean, std = ens.predict_with_uncertainty(
feat.reshape(1, -1)
)
ensemble_preds[site_name] = mean[0]
ensemble_unc[site_name] = std[0]
print(
f"Ensemble inversion done: "
f"{{len(ensemble_preds)}} stations"
)
"""
_INV2D_BLOCK = """\
# ── U-Net 2-D profile inversion ───────────────────────────────
from pycsamt.ai.inversion import EMInverter2D
from scipy.ndimage import zoom
n_depth = {n_depth}
n_freqs_2d = {n_freqs}
n_components = {n_components}
n_sta = len(obs_features)
X_obs_list = list(obs_features.values())
X_obs = np.stack(X_obs_list, axis=2) # (n_freqs, n_comp, n_sta)
X_obs = X_obs.transpose(1, 0, 2) # (n_comp, n_freqs, n_sta)
X_obs_4d = X_obs[None, ...]
inv2d = EMInverter2D(
n_components=n_components,
n_depth=n_depth,
n_stations=n_sta,
n_freqs=n_freqs_2d,
arch={arch!r},
)
inv2d.fit(
X_train_2d, y_train_2d,
epochs={epochs},
batch_size=8,
verbose=False,
)
pred_section = inv2d.predict(X_obs_4d)[0] # (n_depth, n_sta)
print(
f"2-D inversion section shape: "
f"{{pred_section.shape}}"
)
"""
_FULL_AI_BLOCK = """\
# ── full AI workflow summary ───────────────────────────────────
import json, hashlib, platform, sys
from importlib.metadata import version as _v
provenance = {{
"workflow": "full_ai_workflow",
"data_path": {path!r},
"output_dir": {out!r},
"python": sys.version,
"platform": platform.platform(),
"packages": {{
"pycsamt": _v("pycsamt"),
"numpy": _v("numpy"),
}},
}}
prov_path = {out!r} + "/provenance.json"
with open(prov_path, "w") as fh:
json.dump(provenance, fh, indent=2)
print(f"Provenance written to {{prov_path}}")
"""
_FOOTER = """\
plt.show()
print("Workflow complete.")
"""
[docs]
class CodeGenerationAgent(BaseAgent):
"""Generate a reproducible Python script from a completed workflow.
Parameters
----------
api_key, model, llm_provider : str
When an API key is provided the LLM refines and annotates the
generated code. Otherwise the agent uses static templates.
script_title : str
Input keys
----------
``workflow_config`` : dict
The config dict produced by :class:`ContextInputAgent`.
``results`` : dict
The agent results dict from :class:`AgentCoordinator`.
``output_dir`` : str, optional
Folder for the script and its ``.validation.json`` report;
defaults to :data:`DEFAULT_OUTPUT_DIR` (``pycsamt_agent_output``).
Output data keys
----------------
``code`` str — Python source code
``script_path`` str or None — path to saved .py file
Examples
--------
>>> agent = CodeGenerationAgent()
>>> result = agent.execute(
... {
... "workflow_config": cfg,
... "results": coord_results,
... "output_dir": "/out",
... }
... )
>>> print(result["script_path"])
/out/workflow_script.py
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
script_title: str = "pycsamt MT Processing Workflow",
) -> None:
super().__init__(
"CodeGenerationAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
)
self.script_title = script_title
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
validation_started = time.monotonic()
warnings: list[str] = []
generation_data = input_data.get("generation_input")
generation = (
generation_data
if isinstance(generation_data, GenerationInput)
else GenerationInput.from_mapping(generation_data)
if generation_data is not None
else None
)
cfg = dict(
generation.workflow_config
if generation
else input_data.get("workflow_config") or {}
)
results = input_data.get("results") or {}
output_dir = input_data.get("output_dir") or DEFAULT_OUTPUT_DIR
if generation:
cfg.setdefault("output_dir", output_dir)
if cfg.get("workflow") == "forward":
cfg.update(forward_parameters(generation.task_text))
generation.workflow_config = cfg
title = input_data.get("title", self.script_title)
# Optional RAG context (real symbols / recipe) to ground the LLM
# refinement pass; ignored offline.
rag_context = (
generation.retrieved_evidence
if generation
else input_data.get("rag_context") or ""
)
exact_edit = exact_artifact_edit(generation) if generation else None
if generation:
question = clarification_for(generation)
if question:
return AgentResult(
"needs_review",
question,
data={
"clarification": question,
"generation_input": generation.to_dict(),
},
)
if (
not cfg.get("data_path")
and cfg.get("workflow") not in (None, "custom", "forward")
and not generation.previous_code
):
generation.assumptions.append(
"/path/to/EDIs is a placeholder; replace it with your data directory."
)
if generation.omitted_history_turns:
warnings.append(
"Older conversation turns were omitted from the bounded generation context."
)
data_path = cfg.get("data_path") or "/path/to/EDIs"
workflow = cfg.get("workflow") or ("custom" if generation else "qc")
out_dir = cfg.get("output_dir") or output_dir
supported = {
"qc",
"full",
"static_shift",
"phase_analysis",
"forward",
"pre_inversion",
"occam2d",
"tipper",
"tipper_plot",
"sensitivity",
"ai_inversion",
"inv1d",
"ensemble_inversion",
"inv2d",
"full_ai_workflow",
}
if (
generation
and not self.llm_available
and not exact_edit
and (workflow not in supported or generation.previous_code)
):
message = "This request needs a configured local or cloud model to compose or edit the script. Offline templates cannot apply it reliably."
return AgentResult(
"needs_review",
message,
data={
"clarification": message,
"generation_input": generation.to_dict(),
},
)
if generation and self.llm_available and not exact_edit:
from pycsamt.assistant.tools.api_evidence import (
generation_api_evidence,
)
generation.api_evidence = generation_api_evidence(
generation.task_text, workflow, generation.api_symbols
)
if (
workflow not in supported
and not generation.previous_code
and not rag_context
and len(generation.api_evidence) <= 1
):
question = "Which pyCSAMT operation or API should this script use? I could not find enough verified evidence to compose it."
return AgentResult(
"needs_review",
question,
data={
"clarification": question,
"generation_input": generation.to_dict(),
},
)
# ── build script from templates ───────────────────────────────────────
code = _HEADER.format(
title=title,
date=datetime.now().strftime("%Y-%m-%d"),
)
code += f"import os\nos.makedirs({out_dir!r}, exist_ok=True)\n\n"
needs_sites = workflow != "forward" or any(
k in results for k in ("qc", "static_shift", "phase_analysis")
)
if needs_sites:
code += _LOAD_BLOCK.format(path=data_path)
# add blocks based on workflow + available results
if workflow in ("qc", "full") or "qc" in results:
code += _QC_BLOCK.format(out=out_dir)
if generation:
csv = re.search(
r"\b[\w.-]+\.csv\b", generation.task_text, re.I
)
if (
csv
and re.search(
r"\b(save|export)\b", generation.task_text, re.I
)
and not re.search(
r"(?:do not|don't|never)\s+(?:save|export|write)",
generation.task_text,
re.I,
)
):
code += f"qc_table.to_csv({out_dir!r} + '/{csv[0]}', index=False)\n\n"
if workflow in ("static_shift", "full") or "static_shift" in results:
hw = 3
results.get("static_shift")
code += (
_SS_ESTIMATE_BLOCK.format(out=out_dir)
if generation and excludes_correction(generation.task_text)
else _SS_BLOCK.format(hw=hw, out=out_dir)
)
elif needs_sites:
code += "sites_corr = sites # no static-shift correction\n\n"
if (
workflow in ("phase_analysis", "full")
or "phase_analysis" in results
):
results.get("phase_analysis")
skew_th = 5.0
ellipt_th = 0.1
code += _PT_BLOCK.format(
skew_th=skew_th,
ellipt_th=ellipt_th,
out=out_dir,
)
if "forward" in results or workflow == "forward":
fwd_r = results.get("forward")
rhos = cfg.get("resistivity", [100, 10, 1000, 100])
ths = cfg.get("thickness", [500, 1000, 2000])
if fwd_r is not None:
lm = fwd_r.get("layered_model")
if lm is not None:
rhos = list(
getattr(
lm,
"resistivity",
getattr(lm, "resistivities", rhos),
)
)
ths = list(
getattr(
lm,
"thickness",
getattr(lm, "thicknesses", ths),
)
)
grid = cfg.get("frequency_grid")
freq_expr = (
f"np.geomspace({grid[0]!r}, {grid[1]!r}, {grid[2]!r})"
if grid
else "np.logspace(-4, 3, 60)"
)
code += _FWD_BLOCK.format(
rhos=rhos, ths=ths, out=out_dir, freqs=freq_expr
)
if (
workflow in ("pre_inversion", "occam2d")
or "pre_inversion" in results
):
code += _PRE_INV_BLOCK.format(out=out_dir)
if workflow in ("tipper", "tipper_plot") or "tipper" in results:
code += _TIPPER_BLOCK.format(
out=out_dir,
period=float(cfg.get("period", 1.0)),
component=str(cfg.get("tipper_component", "real")),
convention=str(cfg.get("tipper_convention", "park")),
)
if workflow == "sensitivity" or "sensitivity" in results:
code += _SENSITIVITY_BLOCK.format(out=out_dir)
# ── AI inversion blocks ───────────────────────────────────
ai_wf = {
"ai_inversion",
"inv1d",
"ensemble_inversion",
"inv2d",
"full_ai_workflow",
"full",
}
if workflow in ai_wf or "ai_inv" in results:
inv_r = results.get("ai_inv") or {}
n_layers = int(inv_r.get("n_layers", cfg.get("n_layers", 5)))
n_samples = int(cfg.get("n_train_samples", 2000))
epochs = int(cfg.get("epochs", 50))
arch = str(cfg.get("arch", "cnn1d"))
import numpy as np
freqs_ai = list(np.logspace(-4, 3, 32).round(6).tolist())
code += _AI_INV_BLOCK.format(
freqs=freqs_ai,
n_layers=n_layers,
n_samples=n_samples,
epochs=epochs,
arch=arch,
)
if workflow in ("ensemble_inversion",) or "ensemble" in results:
results.get("ensemble") or {}
n_layers = int(cfg.get("n_layers", 5))
arch = str(cfg.get("arch", "cnn1d"))
code += _ENSEMBLE_BLOCK.format(
n_members=int(cfg.get("n_members", 5)),
arch=arch,
n_layers=n_layers,
epochs=int(cfg.get("epochs", 50)),
)
if workflow in ("inv2d", "full_ai_workflow"):
code += _INV2D_BLOCK.format(
n_depth=int(cfg.get("n_depth", 40)),
n_freqs=int(cfg.get("n_freqs", 32)),
n_components=4,
arch=str(cfg.get("arch", "unet")),
epochs=int(cfg.get("epochs", 30)),
)
if workflow in ("full_ai_workflow",):
code += _FULL_AI_BLOCK.format(
path=data_path,
out=out_dir,
)
code += _FOOTER
# ── optional LLM refinement ───────────────────────────────────────────
if exact_edit:
code, updated_output = exact_edit
out_dir = (
updated_output or generation.previous_output_dir or out_dir
)
elif self.llm_available:
grounding = ""
if rag_context:
grounding = (
"Use ONLY the real pyCSAMT symbols and patterns from "
"this retrieved context (do not invent functions):\n"
f"{rag_context}\n\n"
)
prompt = (
f"Refine and annotate the following pycsamt Python script. "
f"Fix any issues, add helpful inline comments, ensure "
f"all imports are correct. Return only valid Python code.\n\n"
f"{grounding}"
f"```python\n{code}\n```"
)
if generation:
template_allowed = workflow in supported and (
workflow != "forward"
or all(
k in cfg
for k in ("resistivity", "thickness", "frequency_grid")
)
)
prompt = generation.prompt(code if template_allowed else "")
if self.llm_provider == "ollama":
from ._local import LocalSettings, current_request
request = current_request()
settings = request.settings if request else LocalSettings()
needed = (
len((self.SYSTEM_PROMPT + prompt).encode("utf-8"))
+ min(2000, settings.output_tokens)
+ 128
)
if needed > settings.context_tokens:
# Drop the optional example, never the request or prior script.
prompt = generation.prompt()
needed = (
len((self.SYSTEM_PROMPT + prompt).encode("utf-8"))
+ min(2000, settings.output_tokens)
+ 128
)
if needed > settings.context_tokens:
generation.compact_evidence()
prompt = generation.prompt()
llm_code = self.query_llm(prompt, max_tokens=2000)
if generation and not llm_code:
return AgentResult(
"failed",
"No generated script received.",
error="The configured model returned no code.",
)
if llm_code:
if generation and llm_code.strip().startswith("CLARIFY:"):
question = (
llm_code.strip()
.removeprefix("CLARIFY:")
.split("```", 1)[0]
.strip()[:800]
)
return AgentResult(
"needs_review",
question,
data={
"clarification": question,
"generation_input": generation.to_dict(),
},
)
# strip markdown fences if present
if "```python" in llm_code:
llm_code = llm_code.split("```python", 1)[1]
if "```" in llm_code:
llm_code = llm_code.rsplit("```", 1)[0]
code = llm_code.strip()
from ._code_validation import validate_and_repair
if generation and generation.assumptions:
code = (
"".join(
"# Assumption: " + a.replace("\n", " ") + "\n"
for a in generation.assumptions
)
+ code
)
code, validation = validate_and_repair(
self,
code,
generation,
started=validation_started,
allow_repair=not exact_edit,
max_repairs=input_data.get("max_repairs", 2),
execute_fixture=bool(input_data.get("execute_fixture", False)),
fixture_image=input_data.get("fixture_image"),
fixture_outputs=input_data.get("fixture_outputs"),
)
if not validation["ok"]:
message = (
"Generated draft failed validation; no script was saved. "
+ " ".join(validation["errors"][:4])
)
return AgentResult(
"needs_review" if validation["syntax_ok"] else "failed",
message,
error=None if validation["syntax_ok"] else message,
data={
"code": code,
"review_reason": message,
"script_path": None,
"validation": validation,
"generation_input": generation.to_dict()
if generation
else None,
"generation": {
"mode": "exact_edit"
if exact_edit
else "model"
if self.llm_available
else "template",
"requirements_verified": False,
"executed": False,
},
},
warnings=validation["warnings"],
)
# ── write file ────────────────────────────────────────────────────────
script_path: str | None = None
from ._request import checkpoint
checkpoint()
validation_path = None
try:
os.makedirs(output_dir, exist_ok=True)
filename = "workflow_script.py"
if generation:
requested = re.search(
r"\b(?:save(?:\s+(?:it|the script|code))?\s+as|name(?:\s+it)?|called|write)\s+[`\"']?([\w.-]+\.py)\b",
generation.task_text,
re.I,
)
filename = requested[1] if requested else filename
# Version every artifact, including the legacy template interface.
destination = Path(output_dir) / filename
suffix = 2
while (
destination.exists()
or destination.with_suffix(".validation.json").exists()
):
destination = (
Path(output_dir) / f"{Path(filename).stem}_{suffix}.py"
)
suffix += 1
with destination.open("x", encoding="utf-8", newline="\n") as stream:
stream.write(code)
script_path = str(destination)
report_path = destination.with_suffix(".validation.json")
with report_path.open("x", encoding="utf-8") as stream:
stream.write(json.dumps(validation, indent=2))
validation_path = str(report_path)
except Exception as exc:
warnings.append(f"Could not write script: {exc}")
script_path = None
validation_path = None
elapsed = time.time() - t0
n_lines = code.count("\n")
tailored = bool(exact_edit or self.llm_available or not generation)
if generation and not tailored:
warnings.append(
"Offline template only: request-specific constraints have not been applied. Configure a language model for a tailored script."
)
return AgentResult(
status="success" if tailored else "needs_review",
summary=(
f"Generated {n_lines}-line Python script for "
f"workflow={workflow!r}. "
+ (f"Saved to {script_path}" if script_path else "")
),
data={
"code": code,
"script_path": script_path,
"validation": validation,
"validation_path": validation_path,
"generation_input": generation.to_dict()
if generation
else None,
"generation": {
"output_dir": out_dir,
"workflow": workflow,
"mode": "exact_edit"
if exact_edit
else "model"
if self.llm_available
else "template",
"requirements_verified": bool(exact_edit),
"executed": False,
},
},
warnings=warnings,
elapsed_seconds=elapsed,
cost_estimate_usd=self._last_cost,
)
__all__ = ["CodeGenerationAgent"]