"""
pycsamt.agents.coordinator
==========================
:class:`AgentCoordinator` orchestrates multi-agent workflows:
* Registers agents by name.
* Executes ordered workflow steps with dependency tracking.
* Checkpoints each step to disk so workflows can be resumed after failure.
* Provides a ``dry_run`` / ``preview`` mode that estimates cost and prints
the execution plan without running any agents.
* Aggregates per-step costs into a workflow-level total.
"""
from __future__ import annotations
import json
import logging
import pickle
import time
from pathlib import Path
from typing import Any
from ._base import AgentResult, BaseAgent
from ._pricing import format_cost
logger = logging.getLogger(__name__)
# ── workflow step descriptor ──────────────────────────────────────────────────
[docs]
class WorkflowStep:
"""One step in an :class:`AgentCoordinator` workflow.
Parameters
----------
name : str
Unique identifier used for checkpointing and logging.
agent : BaseAgent
The agent instance that runs this step.
input_fn : callable or None
``input_fn(prev_results)`` → dict fed to ``agent.execute()``.
When ``None`` the coordinator passes the raw workflow config.
description : str
Human-readable description shown in the dry-run preview.
required : bool
When ``False`` a failure skips the step rather than aborting.
"""
def __init__(
self,
name: str,
agent: BaseAgent,
*,
input_fn=None,
description: str = "",
required: bool = True,
) -> None:
self.name = name
self.agent = agent
self.input_fn = input_fn
self.description = description or f"Run {type(agent).__name__}"
self.required = required
# ── coordinator ───────────────────────────────────────────────────────────────
[docs]
class AgentCoordinator:
"""Orchestrate a sequence of pycsamt agents as a named workflow.
Parameters
----------
workflow_name : str
Name used for log messages and checkpoint directory.
checkpoint_dir : str or Path or None
Where to save step checkpoints. Defaults to
``./pycsamt_agent_checkpoints/<workflow_name>/``.
verbose : bool
Print step-level progress to stdout.
Examples
--------
Build and run a QC workflow::
from pycsamt.agents import AgentCoordinator, MTLoaderAgent, DataQCAgent
coord = AgentCoordinator("mt_qc")
coord.add_step("load", MTLoaderAgent(...))
coord.add_step("qc", DataQCAgent(...),
input_fn=lambda r: {"sites": r["load"]["sites"]})
result = coord.execute({"path": "/data/EDIs"})
print(result.summary)
Dry-run preview::
result = coord.preview({"path": "/data/EDIs"})
print(result["plan"])
"""
def __init__(
self,
workflow_name: str = "pycsamt_workflow",
*,
checkpoint_dir: str | Path | None = None,
verbose: bool = True,
) -> None:
self.workflow_name = workflow_name
self.verbose = verbose
self._steps: list[WorkflowStep] = []
self._agents: dict[str, BaseAgent] = {}
if checkpoint_dir is None:
checkpoint_dir = Path(
f"pycsamt_agent_checkpoints/{workflow_name}"
)
self._ckpt_dir = Path(checkpoint_dir)
self._log = logging.getLogger(
f"pycsamt.agents.coordinator.{workflow_name}"
)
# ── step registration ─────────────────────────────────────────────────────
[docs]
def add_step(
self,
name: str,
agent: BaseAgent,
*,
input_fn=None,
description: str = "",
required: bool = True,
) -> AgentCoordinator:
"""Append a workflow step. Returns ``self`` for chaining.
Parameters
----------
name : str
agent : BaseAgent
input_fn : callable(prev_results_dict) → dict, optional
Maps accumulated results to this step's input dict.
``prev_results_dict`` keys are earlier step names;
each value is the step's :class:`AgentResult`.
description : str
required : bool
Returns
-------
AgentCoordinator
``self`` for method chaining.
"""
if name in {s.name for s in self._steps}:
raise ValueError(f"Step {name!r} already registered.")
self._steps.append(
WorkflowStep(
name,
agent,
input_fn=input_fn,
description=description,
required=required,
)
)
self._agents[name] = agent
return self
# ── preview ───────────────────────────────────────────────────────────────
[docs]
def preview(self, config: dict[str, Any]) -> AgentResult:
"""Return an execution plan and estimated cost without running anything.
Parameters
----------
config : dict
The workflow configuration that would be passed to ``execute()``.
Returns
-------
AgentResult
``data["plan"]`` contains the formatted plan string.
``data["steps"]`` is a list of step metadata dicts.
"""
lines = [
f"Workflow: {self.workflow_name}",
f"Steps : {len(self._steps)}",
f"Config : {json.dumps(config, indent=2, default=str)}",
"",
"─" * 60,
]
step_meta = []
for i, step in enumerate(self._steps, 1):
agent = step.agent
llm_str = (
f"{agent.llm_provider}/{agent.model}"
if agent.api_key
else "no-LLM"
)
req_str = "" if step.required else " [optional]"
lines.append(
f" {i:2d}. [{step.name}]{req_str}\n"
f" Agent : {type(agent).__name__}\n"
f" LLM : {llm_str}\n"
f" Action : {step.description}"
)
step_meta.append(
{
"step": i,
"name": step.name,
"agent": type(agent).__name__,
"llm": llm_str,
"description": step.description,
"required": step.required,
}
)
lines.append("─" * 60)
plan = "\n".join(lines)
self._print(plan)
return AgentResult(
status="success",
summary=f"Workflow preview: {len(self._steps)} steps.",
data={"plan": plan, "steps": step_meta, "config": config},
)
# ── execute ───────────────────────────────────────────────────────────────
[docs]
def execute(
self,
config: dict[str, Any],
*,
dry_run: bool = False,
resume: bool = False,
) -> AgentResult:
"""Run all workflow steps sequentially.
Parameters
----------
config : dict
Top-level workflow configuration forwarded to every step's
``input_fn`` (or directly to ``agent.execute()`` when no
``input_fn`` is set).
dry_run : bool
When ``True`` return a preview without executing any agents.
resume : bool
When ``True`` skip steps whose checkpoint already exists on disk.
Returns
-------
AgentResult
``data`` contains one key per step name with its :class:`AgentResult`.
"""
if dry_run:
return self.preview(config)
self._ckpt_dir.mkdir(parents=True, exist_ok=True)
t_workflow = time.time()
results: dict[str, AgentResult] = {}
total_cost = 0.0
all_warnings: list[str] = []
self._log.info(
"Starting workflow %r (%d steps)",
self.workflow_name,
len(self._steps),
)
for step in self._steps:
# ── resume: skip completed checkpoints ───────────────────────────
ckpt_path = self._ckpt_dir / f"{step.name}.pkl"
if resume and ckpt_path.exists():
try:
results[step.name] = self._load_checkpoint(step.name)
self._print(f" ↩ [{step.name}] resumed from checkpoint")
continue
except Exception as exc:
self._log.warning(
"Failed to load checkpoint for %r: %s", step.name, exc
)
# ── build input ──────────────────────────────────────────────────
try:
if step.input_fn is not None:
step_input = step.input_fn(results)
else:
step_input = config
except Exception as exc:
msg = f"input_fn for step {step.name!r} raised: {exc}"
self._log.error(msg)
if step.required:
return AgentResult.failed(
msg, elapsed=time.time() - t_workflow
)
all_warnings.append(msg)
continue
# ── run agent ────────────────────────────────────────────────────
self._print(f" ▶ [{step.name}] {step.description}")
try:
result = step.agent.execute(step_input)
except Exception as exc:
result = AgentResult.failed(
str(exc),
hint="Check agent configuration and input data.",
elapsed=0.0,
)
results[step.name] = result
total_cost += result.cost_estimate_usd
all_warnings += result.warnings
# ── checkpoint ───────────────────────────────────────────────────
self._save_checkpoint(step.name, result)
# ── status check ─────────────────────────────────────────────────
status_icon = {
"success": "✔",
"failed": "✘",
"needs_review": "⚠",
}.get(result.status, "?")
cost_str = format_cost(result.cost_estimate_usd)
self._print(
f" {status_icon} [{step.name}] {result.summary} "
f"({result.elapsed_seconds:.1f}s, {cost_str})"
)
if result.status == "failed" and step.required:
self._log.error(
"Required step %r failed; aborting workflow.", step.name
)
return AgentResult(
status="failed",
summary=f"Workflow aborted at required step {step.name!r}: {result.error}",
data=results,
warnings=all_warnings,
error=result.error,
error_fix_hint=result.error_fix_hint,
elapsed_seconds=time.time() - t_workflow,
cost_estimate_usd=total_cost,
)
elapsed = time.time() - t_workflow
n_ok = sum(1 for r in results.values() if r.status == "success")
n_tot = len(results)
summary = (
f"Workflow {self.workflow_name!r} complete: "
f"{n_ok}/{n_tot} steps succeeded "
f"in {elapsed:.1f}s ({format_cost(total_cost)})."
)
self._print(f"\n {'━' * 52}\n {summary}\n {'━' * 52}")
self._save_workflow_state(results, summary)
return AgentResult(
status="success" if n_ok == n_tot else "needs_review",
summary=summary,
data=results,
warnings=all_warnings,
elapsed_seconds=elapsed,
cost_estimate_usd=total_cost,
)
# ── checkpoint helpers ────────────────────────────────────────────────────
@staticmethod
def _checkpoint_safe(result: AgentResult) -> AgentResult:
"""Return a copy of *result* whose data is safe to pickle.
Matplotlib figures (and containers of them) hold unpicklable
closures (e.g. ``SecondaryAxis.set_functions.<locals>.<lambda>``)
and are display-only — they are not needed to resume a workflow,
so they are dropped from the checkpoint.
"""
from dataclasses import replace
try:
from matplotlib.figure import Figure
except Exception: # pragma: no cover - matplotlib always present
return result
def _has_fig(v: Any) -> bool:
if isinstance(v, Figure):
return True
if isinstance(v, dict):
return any(isinstance(x, Figure) for x in v.values())
if isinstance(v, (list, tuple)):
return any(isinstance(x, Figure) for x in v)
return False
safe_data = {
k: v for k, v in (result.data or {}).items() if not _has_fig(v)
}
return replace(result, data=safe_data)
def _save_checkpoint(self, step_name: str, result: AgentResult) -> None:
path = self._ckpt_dir / f"{step_name}.pkl"
try:
with open(path, "wb") as f:
pickle.dump(self._checkpoint_safe(result), f)
# JSON sidecar for human inspection
meta = {
"step": step_name,
"status": result.status,
"summary": result.summary,
"elapsed_seconds": result.elapsed_seconds,
"cost_estimate_usd": result.cost_estimate_usd,
"warnings": result.warnings,
"error": result.error,
}
with open(path.with_suffix(".json"), "w") as f:
json.dump(meta, f, indent=2)
except Exception as exc:
# Best-effort: checkpoints are an optimisation, never fatal.
self._log.debug(
"Could not save checkpoint for %r: %s", step_name, exc
)
def _load_checkpoint(self, step_name: str) -> AgentResult:
path = self._ckpt_dir / f"{step_name}.pkl"
with open(path, "rb") as f:
return pickle.load(f)
def _save_workflow_state(
self, results: dict[str, AgentResult], summary: str
) -> None:
state = {
"workflow": self.workflow_name,
"summary": summary,
"steps": {
name: {
"status": r.status,
"elapsed_seconds": r.elapsed_seconds,
"cost_estimate_usd": r.cost_estimate_usd,
"warnings": r.warnings,
}
for name, r in results.items()
},
}
path = self._ckpt_dir / "workflow_state.json"
try:
with open(path, "w") as f:
json.dump(state, f, indent=2)
except Exception as exc:
self._log.warning("Could not save workflow state: %s", exc)
# ── helpers ───────────────────────────────────────────────────────────────
def _print(self, msg: str) -> None:
if not self.verbose:
return
try:
print(msg)
except UnicodeEncodeError:
# Legacy Windows consoles (cp1252) cannot encode the
# progress glyphs (▶ ↩ ━). Re-encode with replacement so
# verbose output never crashes the workflow.
import sys
enc = getattr(sys.stdout, "encoding", None) or "ascii"
print(msg.encode(enc, errors="replace").decode(enc))
[docs]
def reset_checkpoints(self) -> None:
"""Delete all saved checkpoints for this workflow."""
if self._ckpt_dir.exists():
import shutil
shutil.rmtree(self._ckpt_dir)
self._log.info(
"Checkpoints cleared for workflow %r", self.workflow_name
)
def __repr__(self) -> str:
return (
f"AgentCoordinator(name={self.workflow_name!r}, "
f"steps={len(self._steps)})"
)
__all__ = ["AgentCoordinator", "WorkflowStep"]