"""
HPC cluster agent for SLURM job management and training-code workflows.
In addition to the original SLURM job-management tools (submit / monitor / cancel /
download / status), the agent owns the end-to-end training-code-generation
workflow for benchmarks like ``training_code_beams2d``: authoring the candidate
training file, validating it locally, transferring it to the cluster, and running
it through dryrun / submit / monitor / diagnose / evaluate.
The training-code tools are imported from their submodules rather than from
``src.tools`` (its package ``__init__`` deliberately does NOT re-export them --
see the note there -- because they pull in the benchmark ``contract_validator``
and we don't want unrelated agents to drag that in via the tools package init).
"""
from langchain_core.messages import AIMessage, AnyMessage, ToolMessage
from src.agents.base_agent import BaseAgent
from src.models.state import MessagesState
from src.tools.engiopt import evaluate_model
from src.tools.hpc import (
SUBMISSION_BUDGET_EXHAUSTED_MARKER,
cancel_slurm_job,
download_job_outputs,
get_slurm_job_status,
submit_slurm_job,
test_hpc_connection,
)
from src.tools.job_monitor import (
check_job_status_change,
get_active_jobs_summary,
monitor_job_until_complete,
)
from src.tools.training_code_gen import (
validate_training_script,
write_training_script,
)
from src.tools.training_code_hpc import (
diagnose_job_failure,
dryrun_training_script,
generate_training_slurm,
validate_slurm_script,
)
from src.utils.prompts import get_hpc_agent_system_prompt
[docs]
class HPCAgent(BaseAgent):
"""Agent specialized in HPC job management and training-code workflows."""
[docs]
def __init__(
self,
model_name: str | None = None,
temperature: float | None = None,
seed: int | None = None,
):
"""Initialize the HPC agent.
Args:
model_name: Name of the LLM model to use (defaults to config.llm_model)
temperature: Model temperature (defaults to config.llm_temperature)
seed: Random seed for model (defaults to config.llm_seed)
"""
super().__init__(model_name=model_name, temperature=temperature, seed=seed)
def _create_tools(self) -> list:
"""Create the list of HPC tools.
Returns:
List of LangChain tools for HPC operations
"""
return [
# Generic SLURM job management.
test_hpc_connection,
submit_slurm_job,
get_slurm_job_status,
cancel_slurm_job,
download_job_outputs,
monitor_job_until_complete,
check_job_status_change,
get_active_jobs_summary,
# Training-code workflow (write -> validate -> generate SLURM ->
# validate SLURM -> dryrun -> submit -> monitor -> diagnose -> eval).
# Every step lives on this agent (submit / monitor reuse the generic
# block above), so the efficiency scorer sees the full sequence on one
# agent rather than a fragmented multi-agent trace.
write_training_script,
validate_training_script,
generate_training_slurm,
validate_slurm_script,
dryrun_training_script,
diagnose_job_failure,
evaluate_model,
]
def _get_system_prompt(self) -> str:
"""Get the system prompt for the HPC agent.
Returns:
System prompt string
"""
return get_hpc_agent_system_prompt()
# Churn turns allowed after the submission budget is exhausted before the loop is
# hard-stopped, so the agent still has room to finalize (see _FINALIZE_TOOLS).
_POST_BUDGET_TURNS = 3
# Tool calls that *finalize* a trial rather than churn it. A turn issuing one of
# these is the agent wrapping up: it is always let through and never consumes the
# post-budget window -- otherwise the loop could END exactly as the agent emits its
# evaluate_model call, scoring a genuinely completed job as a failure.
_FINALIZE_TOOLS = frozenset({"evaluate_model", "download_job_outputs"})
def _is_finalize_turn(self, msg: AnyMessage) -> bool:
"""True if ``msg`` is an AIMessage issuing a finalize tool call."""
return (
isinstance(msg, AIMessage)
and bool(msg.tool_calls)
and any(tc.get("name") in self._FINALIZE_TOOLS for tc in msg.tool_calls)
)
def _should_force_end(self, state: MessagesState) -> bool:
"""End the loop once the submission budget is exhausted and the agent has
churned past its grace window.
``submit_slurm_job`` returns a ToolMessage containing
``SUBMISSION_BUDGET_EXHAUSTED_MARKER`` once the per-trial cap is hit. Without
this stop a self-correcting agent keeps churning (rewrite -> validate ->
blocked submit -> diagnose ...) until the LangGraph recursion limit kills the
whole trial. After the marker, allow ``_POST_BUDGET_TURNS`` *churn* turns to
wrap up, then force END -- but a finalize turn (evaluate_model / output
download) is always let through and never counts against the window, so the
agent can always run its scoring call on a completed job.
"""
messages = state["messages"]
budget_hit_index: int | None = None
for i, msg in enumerate(messages):
if (
isinstance(msg, ToolMessage)
and isinstance(msg.content, str)
and SUBMISSION_BUDGET_EXHAUSTED_MARKER in msg.content
):
budget_hit_index = i
break
if budget_hit_index is None:
return False
# Never cut off the agent's own finalize call -- if it is wrapping up right
# now, let that tool run regardless of how many churn turns preceded it.
if self._is_finalize_turn(messages[-1]):
return False
# Count only churn turns after the marker; finalize turns don't consume budget.
churn_turns = sum(
1
for msg in messages[budget_hit_index + 1 :]
if isinstance(msg, AIMessage) and not self._is_finalize_turn(msg)
)
return churn_turns >= self._POST_BUDGET_TURNS