6. Agent Coordinator#

pycsamt.agents.AgentCoordinator is the explicit workflow runner for the agent layer. Use it when the chain is already known, when every transition between steps should be visible, and when a run must leave enough state for a reviewer to understand what happened without re-reading a notebook. It registers named workflow steps, executes them in order, passes selected outputs forward through input mapping, writes workflow checkpoints, supports dry-run previews, aggregates warnings and LLM cost, and returns one workflow-level AgentResult.

The coordinator is deliberately less magical than WorkflowOrchestratorAgent. The orchestrator reads a natural-language request and decides which chain to build. The coordinator is for the moment after that decision: the project has a known processing order, and the order itself is part of the reproducible record.

6.1. What the coordinator solves#

Many pyCSAMT workflows are staged rather than monolithic:

load data
-> quality control
-> correction
-> tensor diagnostics
-> inversion preparation
-> interpretation and report

Each stage has its own scientific contract. Loading returns station objects, quality control returns warnings and tables, correction returns a new or modified survey object, inversion preparation writes solver files, and reporting consumes the reviewed results. The coordinator does not blur those contracts into one large function. It keeps a dictionary of named AgentResults so the run can be inspected step by step:

\[\begin{split}R_k = A_k(I_k), \qquad I_k = \begin{cases} C, & \text{if no input mapping is supplied},\\ g_k(R_1,\ldots,R_{k-1}), & \text{otherwise}, \end{cases}\end{split}\]

where \(C\) is the top-level workflow configuration, \(A_k\) is the agent at step \(k\), \(g_k\) is the step’s input_fn, and \(R_k\) is the returned AgentResult. This small formulation is the heart of reproducibility: a downstream step should receive exactly the fields you name, not an implicit grab bag of global state.

The coordinator provides:

  • a stable workflow name;

  • an ordered list of named steps;

  • explicit input_fn callbacks for step-to-step data flow;

  • required and optional failure behavior;

  • per-step checkpoints and a workflow summary file;

  • dry-run previews for inspection before data are processed;

  • workflow-level elapsed time, warnings, and LLM cost aggregation.

6.2. Core objects#

WorkflowStep

Internal descriptor for one step. It stores the step name, agent instance, optional input mapping function, description, and whether the step is required.

AgentCoordinator

Public runner that registers steps with add_step(...) and runs them with execute(...) or previews them with preview(...).

AgentResult

Standard result object returned by each step and by the coordinator itself. The coordinator result stores per-step results in result.data.

The workflow-level status is derived from the step statuses:

\[\begin{split}\mathrm{status}_{workflow} = \begin{cases} \mathrm{failed}, & \text{a required step fails},\\ \mathrm{needs\_review}, & \text{at least one recorded step is not success},\\ \mathrm{success}, & \text{all recorded steps succeed}. \end{cases}\end{split}\]

This means success is still an execution statement, not a scientific approval. A workflow can complete cleanly and still require geological, statistical, or deliverable review.

6.3. Minimal workflow#

The first step usually receives the top-level workflow configuration directly. Later steps usually receive selected outputs from earlier results. The example below uses tiny custom agents to expose the coordinator mechanics without mixing in survey loading, plotting, or optional AI dependencies.

 1>>> from pycsamt.agents import AgentCoordinator, AgentResult, BaseAgent
 2>>>
 3>>> class ConstantAgent(BaseAgent):
 4...     def __init__(self, name, **payload):
 5...         super().__init__(name)
 6...         self.payload = payload
 7...
 8...     def execute(self, input_data):
 9...         return AgentResult(
10...             status="success",
11...             summary=f"{self.name} accepted {sorted(input_data)}.",
12...             data=dict(self.payload, received=dict(input_data)),
13...         )
14>>>
15>>> coord = AgentCoordinator(
16...     "doc_demo",
17...     checkpoint_dir="outputs/docs/doc_demo_checkpoints",
18...     verbose=False,
19... )
20>>> coord.add_step(
21...     "load",
22...     ConstantAgent("load", sites="SITES:3", n_stations=3),
23...     description="Load EDI files into a Sites object.",
24... )
25>>> coord.add_step(
26...     "qc",
27...     ConstantAgent("qc", qc_score=0.94),
28...     input_fn=lambda r: {
29...         "sites": r["load"]["sites"],
30...         "min_score": 0.8,
31...     },
32...     description="Compute survey and station QC.",
33... )
34>>> preview = coord.preview({"path": "data/AMT/WILLY_DATA/L18PLT"})
35>>> print(preview.status)
36success
37>>> print(preview.summary)
38Workflow preview: 2 steps.
39>>> print([step["name"] for step in preview["steps"]])
40['load', 'qc']
41>>> result = coord.execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
42>>> print(result.status)
43success
44>>> print(result["qc"].summary)
45qc accepted ['min_score', 'sites'].
46>>> print(result["qc"]["received"])
47{'sites': 'SITES:3', 'min_score': 0.8}

The important detail is not the fake sites value; it is the data flow. The load step received the original config. The qc step received only the two fields returned by its input_fn. That is the pattern to preserve in real chains: keep the first step simple, then make each transition explicit.

6.4. Step registration#

Register steps with AgentCoordinator.add_step.

 1>>> coord.add_step(
 2...     name="static_shift",
 3...     agent=StaticShiftAgent(method="ama"),
 4...     input_fn=lambda results: {
 5...         "sites": results["load"]["sites"],
 6...         "output_dir": "outputs/static_shift",
 7...     },
 8...     description="Detect and correct static-shift effects.",
 9...     required=True,
10... )

The parameters are:

name

Unique identifier for the step. It is used in results[name], checkpoint filenames, logs, preview output, and report labels. Prefer short stable names such as "load", "qc", "static_shift", or "occam2d". Renaming a step changes the resume key.

agent

A BaseAgent instance. Construct the agent before registration so LLM provider, model, plotting preset, and constructor parameters are fixed before the run begins.

input_fn

Optional callable receiving the accumulated previous step results. It must return the input dictionary for the current agent. When omitted, the coordinator passes the original workflow config directly.

description

Human-readable action text shown in dry-run previews and progress output.

required

True by default. If a required step fails, the workflow aborts. If an optional step fails, the coordinator records the failure and continues.

Step names must be unique. Registering the same name twice raises ValueError.

6.5. Input mapping with input_fn#

input_fn is the most important part of a coordinator workflow. It is the bridge between one agent’s output contract and the next agent’s input contract. It should be short enough that a reviewer can read it as a wiring diagram, not a hidden processing step.

1>>> def static_shift_input(results):
2...     return {
3...         "sites": results["load"]["sites"],
4...         "method": "ama",
5...         "output_dir": "outputs/static_shift",
6...     }

If a later step also needs values from the original top-level config, keep that config in the surrounding scope and read the required values inside the mapping function:

 1>>> workflow_config = {
 2...     "path": "data/AMT/WILLY_DATA/L18PLT",
 3...     "output_dir": "outputs/willy",
 4...     "period_range": (0.001, 10.0),
 5... }
 6>>>
 7>>> def qc_input(results):
 8...     return {
 9...         "sites": results["load"]["sites"],
10...         "period_range": workflow_config["period_range"],
11...         "output_dir": f"{workflow_config['output_dir']}/qc",
12...     }
13>>>
14>>> coord.add_step("qc", DataQCAgent(), input_fn=qc_input)

For reproducibility, keep these rules in mind:

  • map from documented output keys, not from private attributes;

  • pass corrected data to later steps only when the correction step is the intended source;

  • keep constants such as period_range and output folders in the workflow config, not scattered across callbacks;

  • inspect result["step_name"].data before wiring a new downstream step.

If an input_fn raises an exception in a required step, the workflow returns a failed AgentResult immediately. For optional steps, the coordinator records a warning and continues.

6.6. Dry-run preview#

Use dry_run=True or call preview(...) to inspect the workflow before running agents.

1>>> preview = coord.execute(
2...     {"path": "data/AMT/WILLY_DATA/L18PLT"},
3...     dry_run=True,
4... )
5>>> print(preview["steps"][0]["name"])
6load
7>>> print(preview["steps"][0]["llm"])
8no-LLM

Equivalent explicit form:

1>>> preview = coord.preview({"path": "data/AMT/WILLY_DATA/L18PLT"})

The preview includes workflow name, number of steps, formatted input config, step order, agent class, LLM provider/model or "no-LLM", whether the step is required, and the step description. No agent execute(...) method is called during preview, so it is safe for expensive, file-writing, or network-dependent chains.

6.7. Checkpoints and resume#

The coordinator writes checkpoints after each executed step. By default, checkpoints go to:

pycsamt_agent_checkpoints/<workflow_name>/

For each step, two files are written when possible:

<step>.pkl

Pickled AgentResult, used by resume=True. The checkpoint copy drops Matplotlib figures and containers of figures before pickling, because figure objects often contain unpicklable display closures. Keep figure files in output_dir for reporting.

<step>.json

Human-readable sidecar containing status, summary, elapsed time, cost, warnings, and error information.

At the end of the workflow, the coordinator also writes:

workflow_state.json

Summary of the workflow and status/cost metadata for all recorded steps.

Set a custom checkpoint directory when constructing the coordinator:

1>>> coord = AgentCoordinator(
2...     "willy_qc",
3...     checkpoint_dir="outputs/willy/checkpoints",
4... )

Resume from existing checkpoints:

1>>> result = coord.execute(
2...     {"path": "data/AMT/WILLY_DATA/L18PLT"},
3...     resume=True,
4... )

Resume is name-based. A step is skipped only when <checkpoint_dir>/<step_name>.pkl exists and can be loaded. If the workflow name, checkpoint directory, or step name changes, the coordinator treats the step as new work.

Clear checkpoints for a workflow:

1>>> coord.reset_checkpoints()

Use checkpoints for long-running or expensive workflows. For quick notebook experiments, leave the default directory or reset it between runs. For formal deliverables, checkpoints are only execution state; archive exported arrays, figures, configuration files, and reports separately.

6.8. Required and optional steps#

Required steps abort the workflow when they fail. The workflow result keeps the partial results produced before the abort, which is important for debugging, review, and restart decisions.

 1>>> from pycsamt.agents import AgentCoordinator, AgentResult, BaseAgent
 2>>>
 3>>> class FakeAgent(BaseAgent):
 4...     def __init__(self, name, result):
 5...         super().__init__(name)
 6...         self.result = result
 7...
 8...     def execute(self, input_data):
 9...         return self.result
10>>>
11>>> ok = AgentResult(
12...     status="success",
13...     summary="load ok",
14...     data={"sites": "SITES:3"},
15... )
16>>> bad = AgentResult.failed(
17...     "qc failed",
18...     hint="inspect station errors",
19... )
20>>> coord = AgentCoordinator(
21...     "doc_required_failure",
22...     checkpoint_dir="outputs/docs/required_failure",
23...     verbose=False,
24... )
25>>> coord.add_step("load", FakeAgent("load", ok))
26>>> coord.add_step(
27...     "qc",
28...     FakeAgent("qc", bad),
29...     input_fn=lambda r: {"sites": r["load"]["sites"]},
30... )
31>>> coord.add_step(
32...     "report",
33...     FakeAgent("report", AgentResult("success", "report ok")),
34... )
35>>> result = coord.execute({"path": "data/edis"})
36>>> print(result.status)
37failed
38>>> print(result.error)
39qc failed
40>>> print(result.error_fix_hint)
41inspect station errors
42>>> print(sorted(result.data))
43['load', 'qc']

Optional steps let the workflow continue, but the workflow is marked needs_review because not all recorded steps succeeded.

 1>>> coord = AgentCoordinator(
 2...     "doc_optional_failure",
 3...     checkpoint_dir="outputs/docs/optional_failure",
 4...     verbose=False,
 5... )
 6>>> coord.add_step(
 7...     "optional_plot",
 8...     FakeAgent("optional", AgentResult.failed("optional plot failed")),
 9...     required=False,
10... )
11>>> coord.add_step(
12...     "report",
13...     FakeAgent("report", AgentResult("success", "report ok")),
14... )
15>>> result = coord.execute({"path": "data/edis"})
16>>> print(result.status)
17needs_review
18>>> print(result["optional_plot"].status)
19failed
20>>> print(result["report"].status)
21success

Use optional steps for reports, secondary figures, extra exports, and experimental analyses. Keep loading, QC, correction, and inversion-input steps required when later steps depend on them.

6.9. Workflow result structure#

execute(...) returns one AgentResult.

 1>>> result = coord.execute({"path": "data/AMT/WILLY_DATA/L18PLT"})
 2>>> print(result.status)
 3success
 4>>> print(result.summary)
 5Workflow 'doc_demo' complete: 2/2 steps succeeded in 0.5s ($0.000000).
 6>>> print(result.elapsed_seconds >= 0)
 7True
 8>>> print(result.cost_estimate_usd)
 90.0
10>>> load_result = result["load"]
11>>> qc_result = result["qc"]

The coordinator result has:

status

"success" when every recorded step succeeded, "needs_review" when at least one optional or non-aborting step did not succeed, and "failed" when a required step aborted the workflow.

summary

Human-readable workflow completion or failure summary.

data

Dictionary of step_name -> AgentResult.

warnings

Combined warnings from step results plus coordinator warnings.

cost_estimate_usd

Sum of per-step LLM cost estimates for steps run in this execution.

The workflow-level elapsed time is wall-clock time around the coordinator execution. Per-step elapsed times remain inside each nested step result.

6.10. Complete QC and correction example#

The example below builds a practical survey workflow with loading, QC, static-shift correction, phase diagnostics, EDI export, and report generation. It is intentionally explicit: every downstream step states whether it consumes raw loaded sites or corrected sites.

 1>>> from pycsamt.agents import (
 2...     AgentCoordinator,
 3...     DataQCAgent,
 4...     EDIExportAgent,
 5...     MTLoaderAgent,
 6...     PhaseAnalysisAgent,
 7...     ReportAgent,
 8...     StaticShiftAgent,
 9... )
10>>>
11>>> config = {
12...     "path": "data/AMT/WILLY_DATA/L18PLT",
13...     "output_dir": "outputs/willy",
14... }
15>>>
16>>> coord = AgentCoordinator(
17...     "willy_qc_correction",
18...     checkpoint_dir=f"{config['output_dir']}/checkpoints",
19... )
20>>> coord.add_step("load", MTLoaderAgent(), description="Load survey files.")
21>>> coord.add_step(
22...     "qc",
23...     DataQCAgent(),
24...     input_fn=lambda r: {
25...         "sites": r["load"]["sites"],
26...         "output_dir": f"{config['output_dir']}/qc",
27...     },
28...     description="Run data quality control.",
29... )
30>>> coord.add_step(
31...     "static_shift",
32...     StaticShiftAgent(method="ama"),
33...     input_fn=lambda r: {
34...         "sites": r["load"]["sites"],
35...         "output_dir": f"{config['output_dir']}/static_shift",
36...     },
37...     description="Correct static-shift effects.",
38... )
39>>> coord.add_step(
40...     "phase",
41...     PhaseAnalysisAgent(),
42...     input_fn=lambda r: {
43...         "sites": r["static_shift"]["corrected_sites"],
44...         "output_dir": f"{config['output_dir']}/phase",
45...     },
46...     description="Compute phase tensor and strike diagnostics.",
47... )
48>>> coord.add_step(
49...     "export_edi",
50...     EDIExportAgent(),
51...     input_fn=lambda r: {
52...         "sites": r["static_shift"]["corrected_sites"],
53...         "output_dir": f"{config['output_dir']}/edis",
54...     },
55...     description="Export corrected EDI files.",
56... )
57>>> coord.add_step(
58...     "report",
59...     ReportAgent(formats=["md", "html"]),
60...     input_fn=lambda r: {
61...         "results": r,
62...         "output_dir": f"{config['output_dir']}/report",
63...     },
64...     description="Assemble workflow report.",
65...     required=False,
66... )
67>>> preview = coord.preview(config)
68>>> print(preview.summary)
69Workflow preview: 6 steps.

On a real survey, inspect the generated QC tables, static-shift warnings, phase-diagnostic figures, exported EDI paths, and report result separately. The coordinator summary tells you whether the chain completed; the nested step results tell you whether each scientific product is suitable for the next stage.

The exact output keys depend on each agent. When composing a new chain, check the agent-specific group pages:

6.11. Inversion preparation example#

Use the same pattern to prepare inversion files after correction and frequency selection.

 1>>> from pycsamt.agents import (
 2...     AgentCoordinator,
 3...     DataQCAgent,
 4...     FrequencyDecimationAgent,
 5...     MTLoaderAgent,
 6...     Occam2DAgent,
 7...     StaticShiftAgent,
 8... )
 9>>>
10>>> config = {
11...     "path": "data/AMT/WILLY_DATA/L18PLT",
12...     "period_range": [0.001, 10.0],
13...     "output_dir": "outputs/occam2d",
14... }
15>>> coord = AgentCoordinator("occam2d_preparation")
16>>> coord.add_step("load", MTLoaderAgent())
17>>> coord.add_step(
18...     "qc",
19...     DataQCAgent(),
20...     input_fn=lambda r: {"sites": r["load"]["sites"]},
21... )
22>>> coord.add_step(
23...     "static_shift",
24...     StaticShiftAgent(method="ama"),
25...     input_fn=lambda r: {"sites": r["load"]["sites"]},
26... )
27>>> coord.add_step(
28...     "decimate",
29...     FrequencyDecimationAgent(),
30...     input_fn=lambda r: {
31...         "sites": r["static_shift"]["corrected_sites"],
32...         "period_range": config["period_range"],
33...         "n_per_decade": 6,
34...     },
35... )
36>>> coord.add_step(
37...     "occam2d",
38...     Occam2DAgent(),
39...     input_fn=lambda r: {
40...         "sites": r["static_shift"]["corrected_sites"],
41...         "period_range": config["period_range"],
42...         "output_dir": config["output_dir"],
43...     },
44... )
45>>> preview = coord.execute(config, dry_run=True)
46>>> print([step["name"] for step in preview["steps"]])
47['load', 'qc', 'static_shift', 'decimate', 'occam2d']

Notice that occam2d uses the corrected sites and the same period range stored in config. If frequency decimation returns a new site object or a selected-period table in your workflow, wire that exact output key into the Occam step instead of relying on the older config value.

6.12. Custom agents#

Custom agents inherit from pycsamt.agents.BaseAgent and return pycsamt.agents.AgentResult. They are useful for project-specific checks, manifests, report annotations, or validation gates that are too local to belong in pyCSAMT core.

 1>>> import time
 2>>> from pycsamt.agents import AgentResult, BaseAgent
 3>>>
 4>>> class SurveyNoteAgent(BaseAgent):
 5...     SYSTEM_PROMPT = "You are a careful MT/CSAMT survey reviewer."
 6...
 7...     def __init__(self, *, api_key=None, model=None, llm_provider="claude"):
 8...         super().__init__(
 9...             "SurveyNoteAgent",
10...             api_key=api_key,
11...             model=model,
12...             llm_provider=llm_provider,
13...             section_preset="pseudosection",
14...         )
15...
16...     def execute(self, input_data):
17...         self._last_cost = 0.0
18...         t0 = time.time()
19...         qc_summary = input_data.get("qc_summary", "")
20...         note = self.query_llm(
21...             f"Write a concise survey QC note: {qc_summary}",
22...             max_tokens=250,
23...         )
24...         return AgentResult(
25...             status="success",
26...             summary="Survey note generated.",
27...             data={"note": note or "LLM note unavailable."},
28...             llm_interpretation=note,
29...             elapsed_seconds=time.time() - t0,
30...             cost_estimate_usd=self._last_cost,
31...         )

Register the custom step like any built-in agent:

1>>> coord.add_step(
2...     "survey_note",
3...     SurveyNoteAgent(),
4...     input_fn=lambda r: {
5...         "qc_summary": r["qc"].summary,
6...     },
7...     description="Generate a concise QC note.",
8...     required=False,
9... )

For production workflows, keep deterministic validation gates separate from optional LLM narrative steps. The workflow can then fail on a numerical or data-quality condition while still treating generated prose as an optional review aid.

6.13. Best practices#

  • Give every step a short, stable, lowercase name such as "load", "qc", "static_shift", or "occam2d".

  • Keep the first step simple. Usually it loads data from the top-level config.

  • Treat input_fn as an explicit contract between steps. Avoid hiding complex processing inside the callback.

  • Preview workflows before long runs or file-writing steps.

  • Use a custom checkpoint_dir for project workflows so outputs are not mixed across surveys.

  • Mark reports, extra figures, and secondary exports as optional when they should not block numerical outputs.

  • Configure pycsamt.agents.AGENT_CONFIG before constructing agents if the workflow should use LLM assistance.

  • Prefer deterministic/no-LLM mode for tests, validation gates, and workflows that must be repeatable without credentials.

  • Preserve output summaries, warnings, generated figure paths, and checkpoint locations in the final project record.

6.14. Common mistakes#

Symptom

Cause

Fix

KeyError in input_fn

The previous agent did not return the expected data key, or the step name is wrong.

Inspect result["previous_step"].data and update the mapping.

Later steps use raw data instead of corrected data

The input_fn still points to results["load"]["sites"].

Point it to the correction step output, for example results["static_shift"]["corrected_sites"].

Workflow aborts at a reporting step

The report step is marked required.

Use required=False for non-critical deliverables.

Resume does not skip a step

The checkpoint directory or workflow name changed.

Reuse the same workflow_name and checkpoint_dir.

Figures are missing after resume

Figures are stripped from pickled checkpoints because they may not be picklable.

Use saved figure paths from the original output directory or regenerate display figures.

Agents do not use the expected LLM provider

Agents were constructed before AGENT_CONFIG was configured.

Configure LLM settings first, then instantiate agents.