"""
pycsamt.agents.orchestrator
============================
:class:`WorkflowOrchestratorAgent` — Intelligent workflow dispatcher.
Given a natural-language MT processing request the agent:
1. Uses the LLM (or a rule-based fallback) to classify the workflow type.
2. Builds an :class:`~pycsamt.agents.coordinator.AgentCoordinator` with the
appropriate agent chain.
3. Executes (or previews) the full workflow, returning a consolidated result.
Supported workflow types
------------------------
``"qc"``
QC-only pipeline: load → QC → static-shift → report
``"phase_analysis"``
Tensor analysis: load → QC → static-shift → phase analysis → report
``"pre_inversion"``
Pre-inversion: load → QC → static-shift → phase analysis → Occam2D prep → codegen
``"ai_inversion"``
AI 1-D end-to-end: load → QC → denoising → AI inversion → interpretation → report
``"inv3d"``
GCN 3-D spatial: load → QC → static-shift → Inv3DAgent → interpretation → report
``"inv2d"``
U-Net 2-D: load → QC → denoising → Inv2DAgent → interpretation → report
``"ensemble_inversion"``
Ensemble uncertainty: load → QC → denoising → EnsembleAgent → interpretation → report
``"joint_inversion"``
Multi-modal DRCNN: load → QC → static-shift → JointInversionAgent → interpretation → report
``"modem"``
ModEM prep: load → QC → static-shift → ModEM file → report
``"full"``
Everything: load → QC → static-shift → phase analysis →
denoising → AI inversion → Occam2D prep → report
``"denoise"``
Denoising only: load → QC → denoising → report
``"static_shift"``
Static-shift only: load → QC → static-shift → report
``"inversion_eval"``
Evaluate result: load → QC → static-shift → eval → report
``"code_gen"``
Code generation: load → QC → code generation → report
``"report"``
Report only: load → QC → report
``"forward"``
Forward modelling: load → QC → forward model → report
``"interpretation"``
Interpretation: load → QC → static-shift →
geological interpretation → report
"""
from __future__ import annotations
import json
import os
import platform
import sys
import time
from importlib.metadata import version as _pkg_version
from typing import Any
from ..api.agents import AGENT_CONFIG
from ._base import AgentResult, BaseAgent
_SYSTEM_PROMPT = """\
You are a pycsamt MT workflow routing expert.
Given a natural-language MT processing request,
return a JSON object with:
{
"workflow_type": one of:
"qc", "phase_analysis", "pre_inversion",
"ai_inversion", "inv2d", "inv3d",
"ensemble_inversion", "joint_inversion",
"modem", "mare2dem", "full", "full_ai_workflow",
"pinn_inversion", "hybrid_inversion",
"tipper", "sensitivity", "rotation",
"freq_decimation", "batch", "comparison",
"denoise", "static_shift", "inversion_eval",
"code_gen", "report", "forward",
"interpretation",
"reasoning": one sentence explaining the choice
}
Rules:
- "qc" if quality, cleaning, or flagging only.
- "denoise" if denoising or noise removal only.
- "static_shift" if static-shift or galvanic
distortion only (no full inversion).
- "phase_analysis" if phase tensor, strike,
dimensionality, or Mohr circles.
- "pre_inversion" if Occam2D, mesh preparation.
- "inversion_eval" if evaluating an existing
inversion result, RMS, misfit, or residuals.
- "ai_inversion" if 1-D AI or neural inversion.
- "inv3d" if GCN or 3-D AI inversion.
- "inv2d" if U-Net or 2-D profile AI inversion.
- "ensemble_inversion" if ensemble or uncertainty.
- "joint_inversion" if multi-modal or TEM+MT.
- "modem" if ModEM or 3-D conventional inversion.
- "mare2dem" if MARE2DEM or 2.5-D FEM inversion.
- "pinn_inversion" if PINN or physics-informed.
- "hybrid_inversion" if two-stage or AI warm-start.
- "tipper" if tipper or induction arrows.
- "sensitivity" if sensitivity kernels or DOI.
- "rotation" if tensor rotation or strike frame.
- "freq_decimation" if period selection/decimation.
- "batch" if batch processing multiple profiles.
- "comparison" if comparing inversion results.
- "code_gen" if generating a Python script.
- "report" if generating a survey report only.
- "forward" if forward modelling or synthetic data.
- "interpretation" if geological interpretation.
- "full" if complete pipeline or multiple methods.
Default to "qc" when uncertain.
Return ONLY the JSON.
"""
# ── keyword-based fallback ────────────────────────────────────────────────────
# Workflow keyword table is the single source of truth in
# pycsamt.agents._workflows (shared with ContextInputAgent). Kept under the
# old private name so any external references keep working.
from ._workflows import ( # noqa: E402
classify_workflow as _classify_workflow,
)
# ── input-function builders (replaces unsafe eval) ────────────────────────────
# Each function receives the running results dict and returns the
# input dict for the next agent step. Keyed by (workflow, step_name).
def _require(r: dict, step: str, key: str) -> Any:
"""Return r[step][key], raising a clear RuntimeError
if the step result is absent (agent was skipped)."""
if step not in r:
raise RuntimeError(
f"Step {step!r} result is missing — the "
f"responsible agent may have failed to "
f"initialise. Check orchestrator logs."
)
return r[step][key]
def _ifn_load_sites(r):
return {"sites": _require(r, "load", "sites")}
def _ifn_qc_sites(r):
return {"sites": _require(r, "qc", "sites")}
def _ifn_ss_corrected(r):
return {"sites": _require(r, "static_shift", "corrected_sites")}
def _ifn_denoise_sites(r):
return {"sites": _require(r, "denoise", "denoised_sites")}
def _ifn_pa_corrected(r):
return {"sites": _require(r, "static_shift", "corrected_sites")}
def _ifn_ai_inv_model(r):
ai_r = r["ai_inv"]
bm = ai_r.get("best_model") or {}
if not bm.get("resistivity"):
# best_model empty — try first prediction
preds = ai_r.get("predictions") or {}
if preds:
nm, log_r = next(iter(preds.items()))
n = len(log_r)
ths = [10.0 * (1.5**i) for i in range(n - 1)]
bm = {
"station": nm,
"resistivity": [float(10.0**v) for v in log_r],
"thickness": ths,
}
return {"model": bm or None}
def _ifn_ensemble_model(r):
return {"model": r["ensemble"].get("best_model", {})}
def _ifn_empty_model(r):
return {"model": {}}
def _ifn_inv2d_model(r):
sec = r["inv2d"].get("pred_section", [])
return {
"model": {
"resistivity": (sec.tolist() if hasattr(sec, "tolist") else sec)
}
}
def _ifn_results(r):
return {"results": r}
def _ifn_codegen(r):
return {"workflow_config": {}, "results": r}
def _ifn_rotate(r):
return {
"sites": _require(r, "qc", "sites"),
"strike_deg": r["phase_analysis"].get("strike_consensus", 0.0),
}
def _ifn_pinn_from_qc(r):
return {"sites": _require(r, "qc", "sites")}
def _ifn_pinn_model(r):
return {"model": r["pinn_inv"].get("section", {})}
def _ifn_hybrid_model(r):
return {"model": r["hybrid_inv"].get("section", {})}
_WORKFLOW_STEPS = {
"qc": [
(
"load",
"MTLoaderAgent",
None,
"Load EDI files and scan per-station quality",
),
(
"qc",
"DataQCAgent",
_ifn_load_sites,
"Frequency confidence assessment and QC flags",
),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift detection and AMA correction",
),
("report", "ReportAgent", _ifn_results, "Generate QC report"),
],
"phase_analysis": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"phase_analysis",
"PhaseAnalysisAgent",
_ifn_ss_corrected,
"Phase tensor, strike, and dimensionality analysis",
),
("report", "ReportAgent", _ifn_results, "Generate survey report"),
],
"pre_inversion": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"phase_analysis",
"PhaseAnalysisAgent",
_ifn_ss_corrected,
"Phase tensor and strike analysis",
),
(
"occam2d",
"Occam2DAgent",
_ifn_ss_corrected,
"Write Occam2D data + mesh + startup files",
),
(
"code_gen",
"CodeGenerationAgent",
_ifn_codegen,
"Generate reproducible Python script",
),
],
"ai_inversion": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"denoise",
"DenoisingAgent",
_ifn_qc_sites,
"RPCA denoising + optional AI denoising",
),
(
"ai_inv",
"AIInversionAgent",
_ifn_denoise_sites,
"AI 1-D inversion (EMInverter1D)",
),
(
"interpret",
"InterpretationAgent",
_ifn_ai_inv_model,
"Geological interpretation of predicted models",
False,
), # optional: model may be empty
(
"report",
"ReportAgent",
_ifn_results,
"Generate AI inversion report",
),
],
"inv1d": [ # alias for ai_inversion
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
("denoise", "DenoisingAgent", _ifn_qc_sites, "RPCA denoising"),
(
"ai_inv",
"AIInversionAgent",
_ifn_denoise_sites,
"AI 1-D inversion (EMInverter1D)",
),
(
"interpret",
"InterpretationAgent",
_ifn_ai_inv_model,
"Geological interpretation",
False,
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate AI inversion report",
),
],
"modem": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"modem",
"ModEmAgent",
_ifn_ss_corrected,
"Write ModEM data + model + covariance + control files",
),
("report", "ReportAgent", _ifn_results, "Generate report"),
],
"mare2dem": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"mare2dem",
"Mare2DEMAgent",
_ifn_ss_corrected,
"Write MARE2DEM emdata + resistivity + settings files",
),
("report", "ReportAgent", _ifn_results, "Generate report"),
],
"inv3d": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"inv3d",
"Inv3DAgent",
_ifn_ss_corrected,
"GCN 3-D spatial AI inversion",
),
(
"interpret",
"InterpretationAgent",
_ifn_empty_model,
"Geological interpretation of 3-D volume",
False,
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate 3-D inversion report",
),
],
"inv2d": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
("denoise", "DenoisingAgent", _ifn_qc_sites, "RPCA denoising"),
(
"inv2d",
"Inv2DAgent",
_ifn_denoise_sites,
"U-Net 2-D profile AI inversion",
),
(
"interpret",
"InterpretationAgent",
_ifn_inv2d_model,
"Geological interpretation of 2-D section",
False,
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate 2-D inversion report",
),
],
"ensemble_inversion": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
("denoise", "DenoisingAgent", _ifn_qc_sites, "RPCA denoising"),
(
"ensemble",
"EnsembleAgent",
_ifn_denoise_sites,
"Ensemble 1-D inversion with uncertainty bands",
),
(
"interpret",
"InterpretationAgent",
_ifn_ensemble_model,
"Geological interpretation with uncertainty",
False,
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate ensemble inversion + uncertainty report",
),
],
"joint_inversion": [
("load", "MTLoaderAgent", None, "Load primary MT EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"joint",
"JointInversionAgent",
_ifn_ss_corrected,
"DRCNN multi-modal joint inversion",
),
(
"interpret",
"InterpretationAgent",
_ifn_empty_model,
"Geological interpretation of joint section",
False,
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate joint inversion report",
),
],
"tipper": [
("load", "MTLoaderAgent", None, "Load EDI files"),
(
"tipper",
"TipperAnalysisAgent",
_ifn_load_sites,
"Tipper analysis — induction arrows and amplitude maps",
),
("report", "ReportAgent", _ifn_results, "Generate tipper report"),
],
"sensitivity": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"sensitivity",
"SensitivityAgent",
_ifn_qc_sites,
"Bostick sensitivity kernels and DOI analysis",
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate sensitivity report",
),
],
"rotation": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"phase_analysis",
"PhaseAnalysisAgent",
_ifn_qc_sites,
"Strike estimation from phase tensor",
),
(
"rotate",
"TensorRotationAgent",
_ifn_rotate,
"Rotate tensors and write corrected EDIs",
),
],
"freq_decimation": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"decimate",
"FrequencyDecimationAgent",
_ifn_qc_sites,
"Frequency decimation / period selection",
),
("report", "ReportAgent", _ifn_results, "Generate decimation report"),
],
"batch": [
("load", "MTLoaderAgent", None, "Load all survey EDIs in batch"),
(
"batch",
"BatchSurveyAgent",
_ifn_load_sites,
"Batch processing across profiles",
),
("report", "ReportAgent", _ifn_results, "Generate batch report"),
],
"comparison": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"compare",
"InversionComparisonAgent",
_ifn_qc_sites,
"Compare inversion results",
),
("report", "ReportAgent", _ifn_results, "Generate comparison report"),
],
# ── standalone single-agent workflows ────────────
"denoise": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"denoise",
"DenoisingAgent",
_ifn_qc_sites,
"RPCA denoising and noise filtering",
),
("report", "ReportAgent", _ifn_results, "Generate denoising report"),
],
"static_shift": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift detection and AMA correction",
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate static-shift report",
),
],
"inversion_eval": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"inversion_eval",
"InversionEvaluationAgent",
_ifn_ss_corrected,
"Evaluate inversion: RMS, misfit, residuals",
),
("report", "ReportAgent", _ifn_results, "Generate evaluation report"),
],
"code_gen": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"code_gen",
"CodeGenerationAgent",
_ifn_codegen,
"Generate reproducible Python script",
),
("report", "ReportAgent", _ifn_results, "Generate report"),
],
"report": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
("report", "ReportAgent", _ifn_results, "Generate survey report"),
],
"forward": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"forward",
"ForwardModelAgent",
_ifn_qc_sites,
"MT forward modelling and synthetic response",
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate forward model report",
),
],
"interpretation": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"interpret",
"InterpretationAgent",
_ifn_empty_model,
"Geological and lithological interpretation",
False,
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate interpretation report",
),
],
"full": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"phase_analysis",
"PhaseAnalysisAgent",
_ifn_ss_corrected,
"Phase tensor and strike analysis",
),
("denoise", "DenoisingAgent", _ifn_ss_corrected, "RPCA denoising"),
(
"ai_inv",
"AIInversionAgent",
_ifn_denoise_sites,
"AI 1-D inversion",
),
(
"occam2d",
"Occam2DAgent",
_ifn_ss_corrected,
"Write Occam2D input files",
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate full survey report",
),
],
"full_ai_workflow": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"static_shift",
"StaticShiftAgent",
_ifn_qc_sites,
"Static-shift correction",
),
(
"phase_analysis",
"PhaseAnalysisAgent",
_ifn_ss_corrected,
"Phase tensor and dimensionality",
),
("denoise", "DenoisingAgent", _ifn_ss_corrected, "RPCA denoising"),
(
"ai_inv",
"AIInversionAgent",
_ifn_denoise_sites,
"AI 1-D inversion (EMInverter1D)",
),
(
"inv2d",
"Inv2DAgent",
_ifn_denoise_sites,
"U-Net 2-D profile inversion",
),
(
"code_gen",
"CodeGenerationAgent",
_ifn_codegen,
"Generate reproducible Python script",
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate full AI workflow report",
),
],
"pinn_inversion": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"pinn_inv",
"PINNInversionAgent",
_ifn_pinn_from_qc,
"Physics-informed MT inversion",
),
(
"interpret",
"InterpretationAgent",
_ifn_pinn_model,
"Geological interpretation",
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate PINN inversion report",
),
],
"hybrid_inversion": [
("load", "MTLoaderAgent", None, "Load EDI files"),
("qc", "DataQCAgent", _ifn_load_sites, "Data quality control"),
(
"hybrid_inv",
"HybridInversionAgent",
_ifn_pinn_from_qc,
"Two-stage AI + physics inversion",
),
(
"interpret",
"InterpretationAgent",
_ifn_hybrid_model,
"Geological interpretation",
),
(
"report",
"ReportAgent",
_ifn_results,
"Generate hybrid inversion report",
),
],
}
# Steps whose deliverable is an inversion input file set on disk. These
# receive the run's ``output_dir`` (in a per-code subfolder) even though
# their ``input_fn`` chain only carries the upstream ``sites``.
_PREP_FILE_STEPS = frozenset({"occam2d", "modem", "mare2dem"})
[docs]
class WorkflowOrchestratorAgent(BaseAgent):
"""Intelligently route an NL request to the correct agent chain.
Parameters
----------
api_key, model, llm_provider : str
default_workflow : str
Fallback when NL classification is ambiguous (default ``"qc"``).
Input keys
----------
``request`` : str — natural-language processing request
``config`` : dict, optional — pre-built config (skips NL parsing)
``dry_run`` : bool — preview without executing (default False)
``output_dir`` : str
``data_path`` : str — EDI path (overrides extracted path)
Output data keys
----------------
``workflow_type`` str
``reasoning`` str
``coordinator`` AgentCoordinator instance
``result`` AgentResult from the coordinator
``steps`` list of step metadata
Examples
--------
Dry-run preview::
agent = WorkflowOrchestratorAgent()
r = agent.execute({
"request": "Load L22PLT EDIs, run full phase tensor analysis",
"dry_run": True,
})
print(r["workflow_type"]) # "phase_analysis"
Full run with LLM::
agent = WorkflowOrchestratorAgent(api_key="sk-ant-…")
r = agent.execute({
"request": "Clean and denoise the WILLY data, then run AI inversion",
"data_path": "/data/WILLY_DATA",
})
"""
SYSTEM_PROMPT = _SYSTEM_PROMPT
def __init__(
self,
*,
api_key: str | None = None,
model: str | None = None,
llm_provider: str = "claude",
default_workflow: str = "qc",
) -> None:
super().__init__(
"WorkflowOrchestratorAgent",
api_key=api_key,
model=model,
llm_provider=llm_provider,
)
self.default_workflow = default_workflow
[docs]
def execute(self, input_data: dict[str, Any]) -> AgentResult:
self._last_cost = 0.0
t0 = time.time()
warnings: list[str] = []
request = str(input_data.get("request", ""))
config = input_data.get("config") or {}
dry_run = bool(input_data.get("dry_run", False))
output_dir = input_data.get("output_dir", "pycsamt_workflow_output")
data_path = input_data.get("data_path") or config.get("data_path", "")
# ── classify workflow ─────────────────────────────────────────────────
workflow_type = config.get("workflow", "")
reasoning = ""
if not workflow_type and request:
if self.api_key:
raw = self.query_llm(request, max_tokens=120)
parsed = self.extract_json(raw or "")
if parsed and isinstance(parsed, dict):
workflow_type = str(parsed.get("workflow_type", ""))
reasoning = str(parsed.get("reasoning", ""))
if not workflow_type:
workflow_type, reasoning = _keyword_classify(request)
available = sorted(_WORKFLOW_STEPS)
if workflow_type not in _WORKFLOW_STEPS:
if self.default_workflow in _WORKFLOW_STEPS:
return AgentResult.failed(
f"Workflow {workflow_type!r} is recognised by "
f"the classifier but has no registered step "
f"chain. Available workflows: "
f"{available}",
elapsed=time.time() - t0,
)
return AgentResult.failed(
f"Unknown workflow {workflow_type!r}. "
f"Available: {available}",
elapsed=time.time() - t0,
)
steps_spec = _WORKFLOW_STEPS[workflow_type]
# ── if no data_path yet, try to extract from request ─────────────────
if not data_path and request:
from .context import _regex_extract
extracted = _regex_extract(request)
data_path = extracted.get("data_path", "")
# ── build coordinator ─────────────────────────────────────────────────
from .coordinator import AgentCoordinator
coord = AgentCoordinator(
f"orchestrated_{workflow_type}",
checkpoint_dir=f"{output_dir}/.checkpoints",
verbose=True,
)
# Extract per-workflow constructor params
pinn_init = _extract_inv_init(
config.get("pinn_params", {}),
[
"dim",
"n_layers",
"depth_max",
"smoothness_weight",
"lateral_weight",
"graph_weight",
"radius",
"epochs",
"lr",
"solver",
"comp",
],
)
hybrid_init = _extract_inv_init(
config.get(
"hybrid_params",
config.get("pinn_params", {}),
),
[
"dim",
"max_iter",
"smoothness_weight",
"lateral_weight",
"graph_weight",
"radius",
"lr",
"solver",
"comp",
"n_freqs",
],
)
ai_inv_init = _extract_inv_init(
config.get("ai_inv_params", {}),
["n_layers", "depth_max", "epochs", "lr"],
)
checkpoint = config.get("checkpoint", "")
# Push resolved LLM config into AGENT_CONFIG so every
# sub-agent in the registry inherits it automatically.
with AGENT_CONFIG.using(
provider=self.llm_provider if self.api_key else None,
api_key=self.api_key,
model=self.model if self.api_key else None,
):
agent_registry, reg_failures = _build_registry(
pinn_init=pinn_init,
hybrid_init=hybrid_init,
ai_inv_init=ai_inv_init,
)
# Per-step params from the smart modal
_step_params_cfg = config.get("step_params", {})
step_meta = []
for _item in steps_spec:
step_name = _item[0]
agent_class_name = _item[1]
input_fn = _item[2]
desc = _item[3]
step_required = _item[4] if len(_item) > 4 else True
agent_obj = agent_registry.get(agent_class_name)
if agent_obj is None:
# Root steps (input_fn=None) have no
# upstream dependency — every downstream
# step depends on their output. If a root
# step agent is missing, abort immediately
# with a diagnostic rather than letting a
# cryptic KeyError surface later.
if input_fn is None:
fail_reason = reg_failures.get(
agent_class_name,
"unknown import error",
)
return AgentResult.failed(
f"Agent {agent_class_name!r} "
f"could not be loaded and is "
f"required as the first step of "
f"workflow {workflow_type!r}. "
f"Reason: {fail_reason}",
elapsed=time.time() - t0,
)
warnings.append(
f"Agent {agent_class_name!r} not "
f"available; skipping step."
)
continue
# Inject checkpoint into any
# AI/PINN/Hybrid inversion agent
# step via a closure over input_fn.
_CKPT_AGENTS = {
"HybridInversionAgent",
"AIInversionAgent",
"PINNInversionAgent",
"Inv2DAgent",
"Inv3DAgent",
}
step_fn = input_fn
if agent_class_name in _CKPT_AGENTS and checkpoint:
def _make_ckpt_fn(fn, ckpt):
def _wrapped(r):
base = fn(r) if fn else {}
base.setdefault("checkpoint", ckpt)
return base
return _wrapped
step_fn = _make_ckpt_fn(input_fn, checkpoint)
# Inject per-step params from modal
# for non-root steps only (root steps
# receive exec_config directly below).
_s_extra = _step_params_cfg.get(step_name)
if _s_extra and step_fn is not None:
def _make_step_injector(fn, ex):
def _injected(r):
base = fn(r)
base.update(ex)
return base
return _injected
step_fn = _make_step_injector(step_fn, _s_extra)
# Inversion-prep steps write a file set
# to disk — give them the run's
# output_dir (input_fn chains carry only
# the upstream sites), in a per-code
# subfolder so provenance JSONs and
# solver inputs don't mix.
if step_name in _PREP_FILE_STEPS and step_fn is not None:
def _make_outdir_injector(fn, od):
def _injected(r):
base = fn(r)
base.setdefault("output_dir", od)
return base
return _injected
step_fn = _make_outdir_injector(
step_fn,
os.path.join(
output_dir,
f"pycsamt_{step_name}",
),
)
coord.add_step(
step_name,
agent_obj,
input_fn=step_fn,
description=desc,
required=step_required,
)
step_meta.append(
{
"name": step_name,
"agent": agent_class_name,
"description": desc,
}
)
# ── build and validate WorkflowPlan ──────────────────────
from ._workflow_plan import WorkflowPlan
plan_config = dict(config)
plan_config.setdefault("workflow", workflow_type)
plan_config.setdefault("data_path", data_path)
plan_config.setdefault("output_dir", output_dir)
plan = WorkflowPlan.from_config(
plan_config,
request=request,
provider=(self.llm_provider if self.api_key else "offline"),
)
warnings.extend(plan.risk_flags)
# ── run (or preview) ──────────────────────────────────────
exec_config = {
"path": data_path,
"output_dir": output_dir,
"request": request,
}
# Merge "load" step params into exec_config
# so the root load agent sees period range,
# component, etc. without an input_fn.
_load_p = _step_params_cfg.get("load", {})
if _load_p:
exec_config.update(_load_p)
exec_result = coord.execute(exec_config, dry_run=dry_run)
elapsed = time.time() - t0
# ── write provenance trail ────────────────────────────────
if not dry_run:
_write_provenance(
output_dir=output_dir,
plan=plan,
step_meta=step_meta,
exec_result=exec_result,
elapsed=elapsed,
all_warnings=warnings + exec_result.warnings,
)
return AgentResult(
status=exec_result.status,
summary=(
f"Orchestrator routed to {workflow_type!r} "
f"workflow ({len(step_meta)} steps). "
f"{exec_result.summary}"
),
data={
"workflow_type": workflow_type,
"reasoning": reasoning,
"workflow_plan": plan,
"coordinator": coord,
"result": exec_result,
"steps": step_meta,
},
warnings=warnings + exec_result.warnings,
elapsed_seconds=elapsed,
cost_estimate_usd=(
self._last_cost + exec_result.cost_estimate_usd
),
)
# ── helpers ───────────────────────────────────────────────────────────────────
def _write_provenance(
output_dir: str,
plan: Any,
step_meta: list,
exec_result: Any,
elapsed: float,
all_warnings: list,
) -> None:
"""
Write provenance artefacts to *output_dir*.
Produces:
* ``workflow_plan.json`` — validated :class:`WorkflowPlan`
* ``agent_trace.json`` — execution trace
* ``environment.json`` — Python / package versions
"""
try:
os.makedirs(output_dir, exist_ok=True)
# workflow_plan.json
plan_path = os.path.join(output_dir, "workflow_plan.json")
plan.save(plan_path)
# environment.json
env: dict[str, Any] = {
"python": sys.version,
"platform": platform.platform(),
"packages": {},
}
for pkg in (
"pycsamt",
"numpy",
"scipy",
"matplotlib",
"torch",
"tensorflow",
):
try:
env["packages"][pkg] = _pkg_version(pkg)
except Exception:
env["packages"][pkg] = "not installed"
env_path = os.path.join(output_dir, "environment.json")
with open(env_path, "w", encoding="utf-8") as fh:
json.dump(env, fh, indent=2)
# agent_trace.json
trace: dict[str, Any] = {
"user_request": plan.request,
"parsed_workflow": plan.workflow_type,
"provider": plan.provider,
"validation_status": ("passed" if plan.is_valid() else "failed"),
"executed_agents": [s["agent"] for s in step_meta],
"steps": step_meta,
"parameters": plan.parameters,
"warnings": all_warnings,
"human_review_required": plan.requires_human_review,
"expected_outputs": plan.expected_outputs,
"elapsed_seconds": round(elapsed, 3),
"exec_status": exec_result.status,
}
trace_path = os.path.join(output_dir, "agent_trace.json")
with open(trace_path, "w", encoding="utf-8") as fh:
json.dump(trace, fh, indent=2, default=str)
except Exception as exc:
import logging
logging.getLogger("pycsamt.agents.orchestrator").warning(
"Could not write provenance: %s",
exc,
)
def _keyword_classify(text: str) -> tuple[str, str]:
"""Rule-based workflow classification from *text*.
Delegates to the shared, ordered keyword table in
:mod:`pycsamt.agents._workflows` so the orchestrator and
:class:`ContextInputAgent` can never drift apart.
"""
wf = _classify_workflow(text, default=None)
if wf:
return wf, f"Matched keywords for workflow {wf!r}."
return "qc", "No specific keywords matched; defaulted to QC."
def _build_registry(
pinn_init: dict | None = None,
hybrid_init: dict | None = None,
ai_inv_init: dict | None = None,
) -> tuple[dict[str, Any], dict[str, str]]:
r"""Instantiate all known agent classes.
Each agent resolves its LLM config via
:data:`~pycsamt.api.agents.AGENT_CONFIG` automatically.
Call inside an ``AGENT_CONFIG.using()`` context.
Parameters
----------
pinn_init : dict, optional
Extra keyword arguments forwarded to
:class:`~pycsamt.agents.PINNInversionAgent`.
hybrid_init : dict, optional
Extra keyword arguments forwarded to
:class:`~pycsamt.agents.HybridInversionAgent`.
Returns
-------
registry : dict[str, agent]
failures : dict[str, str]
Agents that could not be instantiated, mapped
to their exception message.
"""
registry: dict[str, Any] = {}
failures: dict[str, str] = {}
_pi = pinn_init or {}
_hi = hybrid_init or {}
_ai = ai_inv_init or {}
def _try(name: str, factory):
try:
registry[name] = factory()
except Exception as exc:
import logging
failures[name] = str(exc)
logging.getLogger("pycsamt.agents.orchestrator").warning(
"Could not instantiate %s: %s",
name,
exc,
)
_try(
"ContextInputAgent", lambda: _import("context", "ContextInputAgent")()
)
_try("MTLoaderAgent", lambda: _import("loader", "MTLoaderAgent")())
_try("DataQCAgent", lambda: _import("qc", "DataQCAgent")())
_try(
"StaticShiftAgent",
lambda: _import("static_shift", "StaticShiftAgent")(),
)
_try(
"PhaseAnalysisAgent",
lambda: _import("phase_analysis", "PhaseAnalysisAgent")(),
)
_try(
"ForwardModelAgent", lambda: _import("forward", "ForwardModelAgent")()
)
_try(
"InversionPrepAgent",
lambda: _import("inversion_prep", "InversionPrepAgent")(),
)
_try(
"InversionEvaluationAgent",
lambda: _import("inversion_eval", "InversionEvaluationAgent")(),
)
_try(
"InterpretationAgent",
lambda: _import("interpretation", "InterpretationAgent")(),
)
_try("ReportAgent", lambda: _import("report", "ReportAgent")())
_try(
"CodeGenerationAgent",
lambda: _import("code_gen", "CodeGenerationAgent")(),
)
_try("DenoisingAgent", lambda: _import("denoising", "DenoisingAgent")())
_try(
"AIInversionAgent",
lambda: _import("ai_inversion", "AIInversionAgent")(
n_layers=int(_ai.get("n_layers", 5)),
epochs=int(_ai.get("epochs", 30)),
),
)
_try("Occam2DAgent", lambda: _import("occam2d_agent", "Occam2DAgent")())
_try("ModEmAgent", lambda: _import("modem_agent", "ModEmAgent")())
_try(
"Mare2DEMAgent", lambda: _import("mare2dem_agent", "Mare2DEMAgent")()
)
_try(
"AnomalyDetectionAgent",
lambda: _import("anomaly_agent", "AnomalyDetectionAgent")(),
)
_try("Inv3DAgent", lambda: _import("inv3d_agent", "Inv3DAgent")())
_try("Inv2DAgent", lambda: _import("inv2d_agent", "Inv2DAgent")())
_try(
"EnsembleAgent", lambda: _import("ensemble_agent", "EnsembleAgent")()
)
_try(
"JointInversionAgent",
lambda: _import("joint_agent", "JointInversionAgent")(),
)
_try(
"ModelZooAgent", lambda: _import("model_zoo_agent", "ModelZooAgent")()
)
_try(
"TensorRotationAgent",
lambda: _import("tensor_rotation", "TensorRotationAgent")(),
)
_try("EDIExportAgent", lambda: _import("edi_export", "EDIExportAgent")())
_try(
"TipperAnalysisAgent",
lambda: _import("tipper_analysis", "TipperAnalysisAgent")(),
)
_try(
"SensitivityAgent",
lambda: _import("sensitivity", "SensitivityAgent")(),
)
_try(
"FrequencyDecimationAgent",
lambda: _import("freq_decimation", "FrequencyDecimationAgent")(),
)
_try(
"InversionComparisonAgent",
lambda: _import("inversion_comparison", "InversionComparisonAgent")(),
)
_try(
"ResistivityMapAgent",
lambda: _import("resistivity_map", "ResistivityMapAgent")(),
)
_try(
"BatchSurveyAgent",
lambda: _import("batch_survey", "BatchSurveyAgent")(),
)
_try(
"InversionBackendAgent",
lambda: _import("inversion_backend", "InversionBackendAgent")(),
)
_try(
"PINNInversionAgent",
lambda: _import("pinn_agent", "PINNInversionAgent")(**_pi),
)
_try(
"HybridInversionAgent",
lambda: _import("hybrid_agent", "HybridInversionAgent")(**_hi),
)
return registry, failures
def _extract_inv_init(params: dict, allowed: list) -> dict:
"""Return only the *allowed* keys from *params*."""
return {k: v for k, v in params.items() if k in allowed}
def _import(module: str, cls: str) -> type:
import importlib
mod = importlib.import_module(f".{module}", package="pycsamt.agents")
return getattr(mod, cls)
__all__ = ["WorkflowOrchestratorAgent"]