chemgraph-loop / src /chemgraph /utils /tool_mapping.py
rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
111 kB
"""Deterministic tool requirements for common ChemGraph tasks.
The LLM can still interpret natural language and choose tool arguments, but
these mappings define the minimum tool outputs required before the graph is
allowed to compose a final structured answer.
"""
from __future__ import annotations
import ast
import json
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Iterable, Optional
@dataclass(frozen=True)
class TaskRequirement:
"""Required tool-output shape for one inferred task type."""
task_type: str
driver: Optional[str]
required_output: str
response_field: str
@dataclass(frozen=True)
class Requirement:
"""One machine-checkable scientific requirement in a workflow ledger."""
id: str
kind: str
species: Optional[str]
property: Optional[str]
status: str
depends_on: list[str]
satisfied_by: list[str]
error: Optional[str] = None
scope_id: Optional[str] = None
run_id: Optional[str] = None
def as_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"kind": self.kind,
"species": self.species,
"property": self.property,
"status": self.status,
"depends_on": self.depends_on,
"satisfied_by": self.satisfied_by,
"error": self.error,
"scope_id": self.scope_id,
"run_id": self.run_id,
}
@dataclass(frozen=True)
class Artifact:
"""One tool-produced evidence item registered for validation."""
id: str
requirement_id: Optional[str]
species: Optional[str]
kind: str
path: Optional[str]
calculator: Optional[str]
driver: Optional[str]
fields: list[str]
parse_ok: bool
valid: bool
error: Optional[str] = None
scope_id: Optional[str] = None
run_id: Optional[str] = None
def as_dict(self) -> dict[str, Any]:
return {
"id": self.id,
"requirement_id": self.requirement_id,
"species": self.species,
"kind": self.kind,
"path": self.path,
"calculator": self.calculator,
"driver": self.driver,
"fields": self.fields,
"parse_ok": self.parse_ok,
"valid": self.valid,
"error": self.error,
"scope_id": self.scope_id,
"run_id": self.run_id,
}
PROPERTY_TOOL_MAPPING: dict[str, TaskRequirement] = {
"smiles": TaskRequirement(
task_type="smiles",
driver=None,
required_output="smiles",
response_field="smiles",
),
"energy": TaskRequirement(
task_type="energy",
driver="energy",
required_output="single_point_energy",
response_field="scalar_answer",
),
"gibbs_free_energy": TaskRequirement(
task_type="gibbs_free_energy",
driver="thermo",
required_output="thermochemistry.gibbs_free_energy",
response_field="scalar_answer",
),
"enthalpy": TaskRequirement(
task_type="enthalpy",
driver="thermo",
required_output="thermochemistry.enthalpy",
response_field="scalar_answer",
),
"dipole": TaskRequirement(
task_type="dipole",
driver="dipole",
required_output="dipole_moment",
response_field="dipole",
),
"vibration": TaskRequirement(
task_type="vibration",
driver="vib",
required_output="vibrational_frequencies.frequencies",
response_field="vibrational_answer",
),
"ir": TaskRequirement(
task_type="ir",
driver="ir",
required_output="ir_spectrum",
response_field="ir_spectrum",
),
"reaction_gibbs_energy": TaskRequirement(
task_type="reaction_gibbs_energy",
driver="thermo",
required_output="per_species_thermochemistry.gibbs_free_energy",
response_field="scalar_answer",
),
"reaction_enthalpy": TaskRequirement(
task_type="reaction_enthalpy",
driver="thermo",
required_output="per_species_thermochemistry.enthalpy",
response_field="scalar_answer",
),
"unknown": TaskRequirement(
task_type="unknown",
driver=None,
required_output="unknown",
response_field="unknown",
),
}
REQUIRED_TOOL_STEPS: dict[str, list[str]] = {
"smiles": ["molecule_name_to_smiles"],
"energy": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"],
"gibbs_free_energy": [
"molecule_name_to_smiles",
"smiles_to_coordinate_file",
"run_ase",
],
"enthalpy": [
"molecule_name_to_smiles",
"smiles_to_coordinate_file",
"run_ase",
],
"dipole": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"],
"vibration": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"],
"ir": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"],
"reaction_gibbs_energy": [
"molecule_name_to_smiles",
"smiles_to_coordinate_file",
"run_ase",
],
"reaction_enthalpy": [
"molecule_name_to_smiles",
"smiles_to_coordinate_file",
"run_ase",
],
"unknown": [],
}
PREFERRED_CALCULATOR_BY_TASK: dict[str, str] = {
"dipole": "TBLite",
"vibration": "TBLite",
"ir": "TBLite",
"gibbs_free_energy": "TBLite",
"enthalpy": "TBLite",
"reaction_gibbs_energy": "TBLite",
"reaction_enthalpy": "TBLite",
}
@dataclass(frozen=True)
class ReactionSpecies:
"""One species in a parsed reaction side."""
name: str
coefficient: float
def infer_requirement(query: str) -> TaskRequirement:
"""Infer the deterministic output requirement from a user query."""
text = " ".join(str(query or "").lower().split())
if ("reaction" in text or "balanced reaction" in text or "combustion" in text) and (
"gibbs" in text or "free energy" in text
):
return PROPERTY_TOOL_MAPPING["reaction_gibbs_energy"]
if ("reaction" in text or "balanced reaction" in text or "combustion" in text) and (
"enthalpy" in text or "heat of reaction" in text
):
return PROPERTY_TOOL_MAPPING["reaction_enthalpy"]
if "dipole" in text:
return PROPERTY_TOOL_MAPPING["dipole"]
if "vibrational" in text or "vibration" in text or "frequencies" in text:
return PROPERTY_TOOL_MAPPING["vibration"]
if (
"infrared spectrum" in text
or "ir spectrum" in text
or "ftir" in text
or "ft-ir" in text
or re.search(r"\bfir\s+(?:of|for)\b", text)
or ("infrared" in text and "spectrum" in text)
):
return PROPERTY_TOOL_MAPPING["ir"]
if "gibbs" in text or "thermochemical" in text or "thermo" in text:
return PROPERTY_TOOL_MAPPING["gibbs_free_energy"]
if "enthalpy" in text:
return PROPERTY_TOOL_MAPPING["enthalpy"]
if "smiles" in text and not any(
token in text
for token in ("energy", "dipole", "vibrational", "gibbs", "enthalpy")
):
return PROPERTY_TOOL_MAPPING["smiles"]
if "single point" in text or "single-point" in text or "energy" in text:
return PROPERTY_TOOL_MAPPING["energy"]
return PROPERTY_TOOL_MAPPING["unknown"]
def validate_completion(query: str, messages: Iterable[Any]) -> dict[str, Any]:
"""Validate whether required deterministic tool outputs are present.
Parameters
----------
query : str
Original user query.
messages : Iterable[Any]
LangGraph message history or message-like dictionaries.
Returns
-------
dict
Completion payload with ``complete``, ``missing``,
``repair_instruction`` and optional ``structured_output``.
"""
message_list = list(messages or [])
requirement = infer_requirement(query)
observations = _collect_observations(message_list)
target_molecules = _infer_target_molecules(query, observations, message_list)
if requirement.task_type == "unknown":
return _complete_result(requirement, reason="No deterministic mapping.")
identity_issue = _identity_needs_clarification(observations)
if identity_issue:
return _incomplete_result(
requirement,
["identity_clarification"],
_repair_identity_clarification(identity_issue),
)
current_failed_ase = _unresolved_current_failed_ase(
observations,
driver=requirement.driver,
)
if current_failed_ase and requirement.driver:
return _incomplete_result(
requirement,
[requirement.required_output],
_repair_run_ase(requirement.driver, current_failed_ase),
)
if requirement.task_type == "smiles":
smiles = _target_smiles(observations, target_molecules)
if smiles:
return _complete_result(
requirement,
structured_output=_empty_structured(smiles=smiles),
)
return _incomplete_result(requirement, ["smiles"], _repair_smiles())
if requirement.task_type == "energy":
energy = _last_matching_target(
observations["energies"], target_molecules, observations
)
if energy is not None:
return _complete_result(
requirement,
structured_output=_empty_structured(
scalar_answer={
"value": float(energy["value"]),
"property": "single_point_energy",
"unit": energy.get("unit", "eV"),
}
),
)
return _incomplete_result(
requirement,
["single_point_energy"],
_repair_run_ase(
"energy",
_last_failed_ase(observations),
_calculator_policy(requirement, query),
),
)
if requirement.task_type == "gibbs_free_energy":
thermo = _last_matching_target(
observations["thermo"], target_molecules, observations
)
if thermo is not None:
return _complete_result(
requirement,
structured_output=_empty_structured(
scalar_answer={
"value": float(thermo["value"]),
"property": "Gibbs free energy",
"unit": thermo.get("unit", "eV"),
}
),
)
return _incomplete_result(
requirement,
["thermochemistry.gibbs_free_energy"],
_repair_run_ase(
"thermo",
_last_failed_ase(observations),
_calculator_policy(requirement, query),
),
)
if requirement.task_type == "enthalpy":
enthalpy = _last_matching_target(
observations["enthalpies"], target_molecules, observations
)
if enthalpy is not None:
return _complete_result(
requirement,
structured_output=_empty_structured(
scalar_answer={
"value": float(enthalpy["value"]),
"property": "Enthalpy",
"unit": enthalpy.get("unit", "eV"),
}
),
)
return _incomplete_result(
requirement,
["thermochemistry.enthalpy"],
_repair_run_ase(
"thermo",
_last_failed_ase(observations),
_calculator_policy(requirement, query),
),
)
if requirement.task_type == "dipole":
dipole = _last_matching_target(
observations["dipoles"], target_molecules, observations
)
if dipole is not None:
return _complete_result(
requirement,
structured_output=_empty_structured(
dipole={
"value": dipole["value"],
"unit": dipole.get("unit", "e * Angstrom"),
}
),
)
return _incomplete_result(
requirement,
["dipole_moment"],
_repair_run_ase(
"dipole",
_last_failed_ase(observations),
_calculator_policy(requirement, query),
),
)
if requirement.task_type == "vibration":
vibration = _last_matching_target(
observations["vibrations"], target_molecules, observations
)
if vibration is not None:
return _complete_result(
requirement,
structured_output=_empty_structured(
vibrational_answer={"frequency_cm1": vibration["frequencies"]}
),
)
return _incomplete_result(
requirement,
["vibrational_frequencies.frequencies"],
_repair_run_ase(
"vib",
_last_failed_ase(observations),
_calculator_policy(requirement, query),
),
)
if requirement.task_type == "ir":
ir_spectrum = _last_matching_target(
observations["ir_spectra"], target_molecules, observations
)
if ir_spectrum is not None:
return _complete_result(
requirement,
structured_output=_empty_structured(
ir_spectrum=_public_property_record(ir_spectrum)
),
)
return _incomplete_result(
requirement,
["ir_spectrum"],
_repair_run_ase(
"ir",
_last_failed_ase(observations),
_calculator_policy(requirement, query),
),
)
if requirement.task_type == "reaction_gibbs_energy":
return _validate_reaction_gibbs(query, requirement, observations, message_list)
if requirement.task_type == "reaction_enthalpy":
return _validate_reaction_enthalpy(query, requirement, observations, message_list)
return _complete_result(requirement, reason="Mapping does not enforce this task yet.")
def validate_tool_call_batch(
query: str,
prior_messages: Iterable[Any],
tool_calls: Iterable[dict[str, Any]],
) -> dict[str, Any]:
"""Validate whether the next LLM tool-call batch may execute.
This is a pre-tool guard. The LLM still chooses actions, but the graph
prevents tools from running when the requested action violates the
deterministic workflow state, for example running ASE before a coordinate
artifact exists or using the wrong driver for the inferred property.
"""
calls = [call for call in tool_calls or [] if isinstance(call, dict)]
if not calls:
return _tool_guard_allowed()
requirement = infer_requirement(query)
if requirement.task_type == "unknown":
return _validate_unknown_tool_call_batch(query, prior_messages, calls)
messages = list(prior_messages or [])
observations = _collect_observations(messages)
validation = validate_completion(query, messages)
workflow_state = build_workflow_state(query, messages, validation=validation)
next_action = workflow_state.get("task_plan", {}).get("next_action")
target_molecules = workflow_state.get("intent", {}).get("target_molecules", [])
allowed_tool = (
next_action
if next_action in REQUIRED_TOOL_STEPS.get(requirement.task_type, [])
else None
)
for call in calls:
name = call.get("name")
args = call.get("args") or {}
if not isinstance(args, dict):
args = {}
if next_action == "clarify_identity" and name != "ask_human":
return _tool_guard_blocked(
blocked_tool=str(name),
expected_next_action="clarify_identity",
repair_instruction=(
"Do not continue with coordinate generation or ASE while "
"molecule identity requires clarification. Ask which component "
"or representative molecule should be modeled, or explicitly "
"state and confirm the representative assumption before using "
"downstream chemistry tools."
),
)
if name == "molecule_name_to_smiles" and next_action not in {
"molecule_name_to_smiles",
"clarify_identity",
} and _is_observed_identity_name(observations, args.get("name")):
return _tool_guard_blocked(
blocked_tool="molecule_name_to_smiles",
expected_next_action=next_action,
repair_instruction=(
"Molecule identity is already available in workflow state. "
"Reuse the observed SMILES and continue with the next required "
f"action: {next_action}."
),
)
if name == "smiles_to_coordinate_file":
explicit_smiles = _query_supplies_smiles(query, args.get("smiles"))
if next_action == "molecule_name_to_smiles" and not explicit_smiles:
return _tool_guard_blocked(
blocked_tool="smiles_to_coordinate_file",
expected_next_action="molecule_name_to_smiles",
repair_instruction=(
"Do not generate coordinates from an unverified SMILES for "
"a named-molecule query. First call molecule_name_to_smiles "
"for the requested molecule, then pass the returned SMILES "
"to smiles_to_coordinate_file."
),
)
continue
if name != "run_ase":
continue
params = _run_ase_params(args)
driver = params.get("driver")
input_file = params.get("input_structure_file")
if requirement.driver and driver != requirement.driver:
return _tool_guard_blocked(
blocked_tool="run_ase",
expected_next_action=next_action,
repair_instruction=(
f"The inferred task requires run_ase driver='{requirement.driver}', "
f"but the requested tool call used driver='{driver}'. "
"Call the correct driver before composing the answer."
),
)
explicit_coordinate = _query_supplies_coordinate_file(query, input_file)
if not explicit_coordinate and not _is_observed_coordinate_file_for_targets(
observations, input_file, target_molecules
):
if next_action == "molecule_name_to_smiles":
repair = (
"Do not run ASE yet. The user named a molecule, but no SMILES "
"or coordinate artifact exists in workflow state. First call "
"molecule_name_to_smiles for the requested molecule, then "
"smiles_to_coordinate_file, then retry run_ase with "
f"driver='{requirement.driver}'."
)
else:
repair = (
"Do not run ASE yet. The specific input_structure_file is not "
"a coordinate artifact in workflow state. Call "
"smiles_to_coordinate_file first, then retry run_ase with "
f"driver='{requirement.driver}' using the generated path."
)
return _tool_guard_blocked(
blocked_tool="run_ase",
expected_next_action=allowed_tool or next_action,
repair_instruction=repair,
)
calculator_policy = _calculator_policy(requirement, query)
selected_calculator = _calculator_family(params.get("calculator"))
explicit_calculator = calculator_policy.get("explicit_request")
if explicit_calculator and selected_calculator != explicit_calculator:
return _tool_guard_blocked(
blocked_tool="run_ase",
expected_next_action="select_calculator",
repair_instruction=(
f"The user explicitly requested {explicit_calculator}, but "
f"the tool call selected "
f"{selected_calculator or 'the environment default'}. "
f"Retry run_ase with calculator "
f"{_calculator_payload_hint(explicit_calculator)} if it is "
"available, or ask the user before substituting a different "
"calculator."
),
)
if (
calculator_policy.get("enforce")
and selected_calculator != calculator_policy.get("preferred")
):
return _tool_guard_blocked(
blocked_tool="run_ase",
expected_next_action="select_calculator",
repair_instruction=(
f"The user did not explicitly request a calculator. For "
f"{requirement.task_type} tasks, use "
f"{calculator_policy['preferred']} by default instead of "
f"{selected_calculator or 'the environment default'}. "
"Retry run_ase with calculator "
"{'calculator_type': 'TBLite', 'method': 'GFN2-xTB'}."
),
)
return _tool_guard_allowed()
def _validate_unknown_tool_call_batch(
query: str,
prior_messages: Iterable[Any],
calls: list[dict[str, Any]],
) -> dict[str, Any]:
"""Apply safety guards when the property intent is not mapped yet.
Unknown intent should not mean unguarded execution. The LLM may still
clarify the task or call lightweight resolver tools, but ASE execution must
have an explicit driver and a real coordinate artifact.
"""
observations = _public_observations(_collect_observations(list(prior_messages or [])))
for call in calls:
if call.get("name") != "run_ase":
continue
args = call.get("args") or {}
if not isinstance(args, dict):
args = {}
params = _run_ase_params(args)
driver = params.get("driver")
input_file = params.get("input_structure_file")
if not driver:
return _tool_guard_blocked(
blocked_tool="run_ase",
expected_next_action="clarify_intent",
repair_instruction=(
"Do not run ASE until the requested property maps to an "
"explicit driver. First infer or ask for the target property "
"(energy, dipole, vibration, IR, or thermochemistry), then "
"retry run_ase with the matching driver."
),
)
explicit_coordinate = _query_supplies_coordinate_file(query, input_file)
if not explicit_coordinate and not _is_observed_coordinate_file(
observations, input_file
):
return _tool_guard_blocked(
blocked_tool="run_ase",
expected_next_action="smiles_to_coordinate_file",
repair_instruction=(
"Do not run ASE yet. Even for an unmapped query, run_ase "
"requires an explicit coordinate artifact. Resolve the "
"molecule identity if needed, generate or reuse an XYZ file, "
"then retry run_ase with the explicit driver and generated "
"input_structure_file."
),
)
return _tool_guard_allowed()
def build_workflow_state(
query: str,
messages: Iterable[Any],
validation: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Build structured workflow state from the message trace.
``messages`` remains the LLM-visible conversation, while this state is the
machine-readable workflow ledger used for validation, UI rendering, and
later eval/article evidence.
"""
message_list = list(messages or [])
requirement = infer_requirement(query)
observations = _collect_observations(message_list)
validation_payload = validation or validate_completion(query, message_list)
target_molecules = _infer_target_molecules(query, observations, message_list)
next_action = _next_action(
requirement, validation_payload, observations, target_molecules
)
log_dir = os.environ.get("CHEMGRAPH_LOG_DIR")
tool_status = _tool_requirement_status(
requirement, observations, target_molecules
)
action_plan = compile_action_plan(
requirement=requirement,
target_molecules=target_molecules,
observations=observations,
validation=validation_payload,
next_action=next_action,
)
identity_resolution = _identity_resolution_summary(observations)
clarification = _clarification_state(identity_resolution, next_action)
scientific_ledger = _scientific_ledger(
requirement=requirement,
target_molecules=target_molecules,
observations=observations,
validation=validation_payload,
action_plan=action_plan,
)
return {
"run_context": {
"raw_query": str(query or ""),
"log_dir": log_dir,
"status": "complete" if validation_payload.get("complete") else "needs_action",
},
"intent": {
"raw_query": str(query or ""),
"task_type": requirement.task_type,
"property": requirement.response_field,
"required_output": requirement.required_output,
"target_molecules": target_molecules,
"calculator": _infer_calculator(query),
"needs_tools": requirement.task_type != "unknown",
},
"task_plan": {
"required_tools": REQUIRED_TOOL_STEPS.get(requirement.task_type, []),
"tool_requirements": _tool_requirements(requirement, target_molecules),
"satisfied_tools": tool_status["satisfied_tools"],
"missing_tools": tool_status["missing_tools"],
"reusable_observations": tool_status["reusable_observations"],
"postprocessing": _postprocessing_steps(requirement),
"required_driver": requirement.driver,
"required_output": requirement.required_output,
"next_action": next_action,
"next_ready_actions": action_plan["next_ready_actions"],
"parallelizable": _parallelization_plan(requirement, target_molecules),
"calculator_policy": _calculator_policy(requirement, query),
},
"action_plan": action_plan,
"action_states": action_plan["actions"],
"identity_resolution": identity_resolution,
"clarification": clarification,
"memory_refs": _memory_refs(observations),
"tool_trace": observations["tool_trace"],
"observations": _public_observations(observations),
"artifacts": observations["artifacts"],
"scientific_ledger": scientific_ledger,
"validation": validation_payload,
}
def compile_action_plan(
*,
requirement: TaskRequirement,
target_molecules: list[str],
observations: dict[str, Any],
validation: dict[str, Any],
next_action: str,
) -> dict[str, Any]:
"""Compile an additive action ledger from the current workflow state.
This is intentionally pure and conservative. It gives the LLM, UI, and
future scheduler a dependency-aware view without replacing the existing
LangGraph execution loop in one step.
"""
if requirement.task_type == "unknown":
return {
"mode": "direct_answer",
"actions": [],
"next_ready_actions": ["compose_final_answer"],
"status": "complete" if validation.get("complete") else "needs_action",
}
targets = target_molecules or ["target_molecule"]
actions: list[dict[str, Any]] = []
if next_action == "clarify_identity":
actions.append(
{
"id": "clarify_identity",
"action_type": "clarification",
"tool": "ask_human",
"target": targets[0],
"depends_on": ["molecule_name_to_smiles"],
"provides": ["identity_resolution.confirmed_component"],
"status": "ready",
"reason": "Identity resolver marked the target as ambiguous or mixture-like.",
}
)
for target in targets:
target_key = _action_target_key(target)
identity_status = _identity_action_status(observations, next_action, target)
actions.append(
{
"id": f"{target_key}:identity",
"action_type": "tool",
"tool": "molecule_name_to_smiles",
"target": target,
"depends_on": [],
"provides": [f"entities.{target_key}.smiles"],
"status": identity_status,
}
)
if requirement.task_type == "smiles":
continue
coordinate_status = _coordinate_action_status(
observations, next_action, target
)
actions.append(
{
"id": f"{target_key}:coordinates",
"action_type": "tool",
"tool": "smiles_to_coordinate_file",
"target": target,
"depends_on": [f"{target_key}:identity"],
"provides": [f"entities.{target_key}.coordinate_file"],
"status": coordinate_status,
}
)
ase_status = _ase_action_status(
requirement, observations, next_action, target
)
actions.append(
{
"id": f"{target_key}:run_ase:{requirement.driver}",
"action_type": "tool",
"tool": "run_ase",
"target": target,
"depends_on": [f"{target_key}:coordinates"],
"provides": [f"outputs.{requirement.task_type}.{target_key}"],
"status": ase_status,
"driver": requirement.driver,
}
)
if requirement.task_type in {"reaction_gibbs_energy", "reaction_enthalpy"}:
actions.append(
{
"id": "aggregate_reaction_property",
"action_type": "postprocess",
"tool": None,
"target": "reaction",
"depends_on": [
action["id"]
for action in actions
if action.get("tool") == "run_ase"
],
"provides": [f"outputs.{requirement.task_type}.reaction"],
"status": "succeeded" if validation.get("complete") else "pending",
}
)
return {
"mode": "action_dag",
"actions": actions,
"next_ready_actions": [
action["id"] for action in actions if action.get("status") == "ready"
],
"status": "complete" if validation.get("complete") else "needs_action",
}
def _scientific_ledger(
*,
requirement: TaskRequirement,
target_molecules: list[str],
observations: dict[str, Any],
validation: dict[str, Any],
action_plan: dict[str, Any],
) -> dict[str, Any]:
"""Build a minimal requirement/artifact ledger for mapped workflows."""
run_id = os.environ.get("CHEMGRAPH_LOG_DIR")
requirements = [
_requirement_from_action(action, requirement, observations, run_id).as_dict()
for action in action_plan.get("actions", [])
]
artifacts = [
artifact.as_dict()
for artifact in _artifact_registry(
requirement,
observations,
target_molecules,
run_id,
)
]
status = "complete" if validation.get("complete") else "blocked"
if any(req.get("status") == "failed" for req in requirements):
status = "failed"
return {
"status": status,
"can_answer": bool(validation.get("complete")),
"run_id": run_id,
"current_scope_id": observations.get("current_scope_id"),
"requirements": requirements,
"artifacts": artifacts,
"missing": list(validation.get("missing", []) or []),
"validated_artifacts": [
artifact for artifact in artifacts if artifact.get("valid")
],
}
def _requirement_from_action(
action: dict[str, Any],
requirement: TaskRequirement,
observations: dict[str, Any],
run_id: Optional[str],
) -> Requirement:
"""Translate an action-plan row into a stable scientific requirement."""
tool = action.get("tool")
action_type = action.get("action_type")
kind = {
"molecule_name_to_smiles": "identity",
"smiles_to_coordinate_file": "xyz",
"run_ase": requirement.driver or "ase",
None: "aggregation" if action_type == "postprocess" else str(action_type),
}.get(tool, str(tool or action_type or "unknown"))
raw_status = str(action.get("status") or "pending")
status = {
"succeeded": "done",
"ready": "pending",
"pending": "pending",
"blocked": "blocked",
"failed": "failed",
}.get(raw_status, raw_status)
target = action.get("target")
species = None if target in {None, "reaction"} else str(target)
error = None
if status == "failed":
failed_ase = _unresolved_current_failed_ase(
observations,
driver=action.get("driver"),
) or _last_failed_ase(observations)
if failed_ase:
error = failed_ase.get("message") or failed_ase.get("error_type")
satisfied_by = (
[]
if status == "failed"
else _satisfied_artifact_ids(kind, species, observations)
)
return Requirement(
id=str(action.get("id") or kind),
kind=kind,
species=species,
property=requirement.task_type if tool == "run_ase" else None,
status=status,
depends_on=[str(item) for item in action.get("depends_on", [])],
satisfied_by=satisfied_by,
error=error,
scope_id=observations.get("current_scope_id"),
run_id=run_id,
)
def _artifact_registry(
requirement: TaskRequirement,
observations: dict[str, Any],
target_molecules: list[str],
run_id: Optional[str],
) -> list[Artifact]:
"""Return compact artifacts produced by resolver, coordinate, and ASE tools."""
artifacts: list[Artifact] = []
for index, record in enumerate(observations.get("identity_resolutions", []), start=1):
species = record.get("input_name") or record.get("resolved_name")
valid = bool(record.get("smiles")) and not (
record.get("requires_clarification")
or record.get("needs_clarification")
or _identity_low_credibility(record)
)
artifacts.append(
Artifact(
id=f"art_identity_{_action_target_key(species or index)}",
requirement_id=(
f"{_action_target_key(species)}:identity" if species else None
),
species=str(species) if species else None,
kind="smiles",
path=None,
calculator=None,
driver=None,
fields=["smiles"] if record.get("smiles") else [],
parse_ok=bool(record),
valid=valid,
error=record.get("warning") if not valid else None,
scope_id=record.get("scope_id"),
run_id=run_id,
)
)
for path in observations.get("coordinate_files", []):
species = _species_for_file_name(observations, path) or _species_from_file_name(path)
artifact_run_id = _run_id_from_path(path, default=run_id)
artifacts.append(
Artifact(
id=f"art_xyz_{_action_target_key(species or Path(str(path)).stem)}",
requirement_id=(
f"{_action_target_key(species)}:coordinates" if species else None
),
species=str(species) if species else None,
kind="xyz",
path=str(path),
calculator=None,
driver=None,
fields=["atomic_coordinates"],
parse_ok=True,
valid=True,
scope_id=None,
run_id=artifact_run_id,
)
)
for index, result in enumerate(observations.get("ase_results", []), start=1):
species = result.get("species")
driver = result.get("driver")
status = str(result.get("status") or "").lower()
failed = status in {"error", "failed", "failure"}
fields = _ase_fields_for_species(requirement, observations, species)
artifact_path = result.get("output_results_file")
artifact_run_id = _run_id_from_path(artifact_path, default=run_id)
artifacts.append(
Artifact(
id=f"art_ase_{_action_target_key(species or index)}_{driver or 'unknown'}",
requirement_id=(
f"{_action_target_key(species)}:run_ase:{driver}"
if species and driver
else None
),
species=str(species) if species else None,
kind="ase_json",
path=artifact_path,
calculator=result.get("calculator"),
driver=str(driver) if driver else None,
fields=fields,
parse_ok=not failed,
valid=(not failed) and bool(fields),
error=result.get("message") if failed else None,
scope_id=result.get("scope_id"),
run_id=artifact_run_id,
)
)
return artifacts
def _satisfied_artifact_ids(
kind: str,
species: Optional[str],
observations: dict[str, Any],
) -> list[str]:
species_key = _action_target_key(species or "")
if kind == "identity" and _target_smiles(observations, [species] if species else []):
return [f"art_identity_{species_key}"]
if kind == "xyz" and _target_coordinate_files(
observations, [species] if species else []
):
return [f"art_xyz_{species_key}"]
if kind in {"energy", "dipole", "thermo", "vib", "ir"} and species:
fields = _ase_fields_for_species(
PROPERTY_TOOL_MAPPING.get(kind, PROPERTY_TOOL_MAPPING["unknown"]),
observations,
species,
)
if fields:
return [f"art_ase_{species_key}_{kind}"]
return []
def _ase_fields_for_species(
requirement: TaskRequirement,
observations: dict[str, Any],
species: Optional[str],
) -> list[str]:
targets = [species] if species else []
fields = []
if _last_matching_target(observations["energies"], targets, observations):
fields.append("single_point_energy")
if _last_matching_target(observations["enthalpies"], targets, observations):
fields.append("enthalpy")
if _last_matching_target(observations["thermo"], targets, observations):
fields.append("gibbs_free_energy")
if _last_matching_target(observations["dipoles"], targets, observations):
fields.append("dipole_moment")
if _last_matching_target(observations["vibrations"], targets, observations):
fields.append("vibrational_frequencies")
if _last_matching_target(observations["ir_spectra"], targets, observations):
fields.append("ir_spectrum")
if requirement.task_type == "reaction_enthalpy" and "enthalpy" in fields:
fields.append("reaction_species_enthalpy")
if requirement.task_type == "reaction_gibbs_energy" and "gibbs_free_energy" in fields:
fields.append("reaction_species_gibbs_free_energy")
return _dedupe_preserve_order(fields)
def extract_query_from_messages(messages: Iterable[Any]) -> str:
"""Return the latest human/user message content from a message history."""
message_list = list(messages or [])
for message in reversed(message_list):
if _message_type(message) in {"human", "user"}:
content = str(_message_content(message))
if _is_internal_repair_message(content):
continue
return content
first = next(iter(message_list), None)
return str(_message_content(first)) if first is not None else ""
def _is_internal_repair_message(content: str) -> bool:
"""Return True for validator-generated repair prompts, not user queries."""
text = str(content or "").lstrip().lower()
return text.startswith("validation failed:")
def _validate_reaction_gibbs(
query: str,
requirement: TaskRequirement,
observations: dict[str, Any],
messages: Iterable[Any],
) -> dict[str, Any]:
reaction = parse_reaction_from_context(query, messages)
if reaction is None:
return _incomplete_result(
requirement,
["balanced_reaction"],
(
"Determine the balanced reaction using chemistry reasoning, then "
"state it exactly as `Balanced reaction: reactants -> products` "
"before calling species tools."
),
)
missing = []
species_gibbs = observations["species_gibbs"]
for side in ("reactants", "products"):
for species in reaction[side]:
key = normalize_species_name(species.name)
if key not in species_gibbs:
missing.append(f"{side}.{species.name}.gibbs_free_energy")
if missing:
return _incomplete_result(
requirement,
missing,
(
"For each missing reaction species, call molecule_name_to_smiles, "
"smiles_to_coordinate_file, then run_ase with driver='thermo'. "
"Do not answer until every reactant and product has a Gibbs free energy."
),
)
reactant_sum = sum(
species.coefficient * species_gibbs[normalize_species_name(species.name)]["value"]
for species in reaction["reactants"]
)
product_sum = sum(
species.coefficient * species_gibbs[normalize_species_name(species.name)]["value"]
for species in reaction["products"]
)
value = float(product_sum - reactant_sum)
unit = _first_unit(species_gibbs.values()) or "eV"
gibbs_scalar = {
"value": value,
"property": "Gibbs free energy of reaction",
"unit": unit,
}
scalar_answers = [gibbs_scalar]
enthalpy_scalar = _reaction_scalar_if_available(
reaction,
observations["species_enthalpy"],
"Reaction enthalpy",
)
if enthalpy_scalar is not None:
scalar_answers.append(enthalpy_scalar)
return _complete_result(
requirement,
structured_output=_empty_structured(
scalar_answer=gibbs_scalar,
scalar_answers=scalar_answers,
),
reason="Computed deterministically as products minus reactants.",
)
def _validate_reaction_enthalpy(
query: str,
requirement: TaskRequirement,
observations: dict[str, Any],
messages: Iterable[Any],
) -> dict[str, Any]:
reaction = parse_reaction_from_context(query, messages)
if reaction is None:
return _incomplete_result(
requirement,
["balanced_reaction"],
(
"Determine the balanced reaction using chemistry reasoning, then "
"state it exactly as `Balanced reaction: reactants -> products` "
"before calling species tools."
),
)
missing = []
species_enthalpy = observations["species_enthalpy"]
for side in ("reactants", "products"):
for species in reaction[side]:
key = normalize_species_name(species.name)
if key not in species_enthalpy:
missing.append(f"{side}.{species.name}.enthalpy")
if missing:
return _incomplete_result(
requirement,
missing,
(
"For each missing reaction species, call molecule_name_to_smiles, "
"smiles_to_coordinate_file, then run_ase with driver='thermo'. "
"Do not answer until every reactant and product has an enthalpy."
),
)
reactant_sum = sum(
species.coefficient
* species_enthalpy[normalize_species_name(species.name)]["value"]
for species in reaction["reactants"]
)
product_sum = sum(
species.coefficient
* species_enthalpy[normalize_species_name(species.name)]["value"]
for species in reaction["products"]
)
value = float(product_sum - reactant_sum)
unit = _first_unit(species_enthalpy.values()) or "eV"
enthalpy_scalar = {
"value": value,
"property": "Reaction enthalpy",
"unit": unit,
}
scalar_answers = [enthalpy_scalar]
gibbs_scalar = _reaction_scalar_if_available(
reaction,
observations["species_gibbs"],
"Gibbs free energy of reaction",
)
if gibbs_scalar is not None:
scalar_answers.append(gibbs_scalar)
return _complete_result(
requirement,
structured_output=_empty_structured(
scalar_answer=enthalpy_scalar,
scalar_answers=scalar_answers,
),
reason="Computed deterministically as products minus reactants.",
)
def _reaction_scalar_if_available(
reaction: dict[str, list[ReactionSpecies]],
species_values: dict[str, dict[str, Any]],
property_name: str,
) -> Optional[dict[str, Any]]:
"""Return a reaction scalar when every species has the needed value."""
if not species_values:
return None
for side in ("reactants", "products"):
for species in reaction[side]:
if normalize_species_name(species.name) not in species_values:
return None
reactant_sum = sum(
species.coefficient
* species_values[normalize_species_name(species.name)]["value"]
for species in reaction["reactants"]
)
product_sum = sum(
species.coefficient
* species_values[normalize_species_name(species.name)]["value"]
for species in reaction["products"]
)
return {
"value": float(product_sum - reactant_sum),
"property": property_name,
"unit": _first_unit(species_values.values()) or "eV",
}
def parse_reaction(query: str) -> Optional[dict[str, list[ReactionSpecies]]]:
"""Parse a simple balanced reaction phrase from a query."""
text = str(query or "")
match = re.search(
r"(?:the\s+)?balanced\s+(?:reaction|equation)(?:\s+is)?\s*:?\s*(.+)",
text,
flags=re.IGNORECASE | re.DOTALL,
)
if match:
reaction_text = match.group(1).strip()
elif "->" in text or "→" in text:
reaction_text = text.strip()
else:
return None
reaction_text = re.split(r"(?:\n|;\s|\.\s)", reaction_text, maxsplit=1)[0].strip()
if "->" in reaction_text:
left, right = reaction_text.split("->", 1)
elif "→" in reaction_text:
left, right = reaction_text.split("→", 1)
else:
return None
reactants = _parse_reaction_side(left)
products = _parse_reaction_side(right)
if not reactants or not products:
return None
return {"reactants": reactants, "products": products}
def parse_reaction_from_context(
query: str,
messages: Iterable[Any],
) -> Optional[dict[str, list[ReactionSpecies]]]:
"""Parse a balanced reaction from the user query or agent-visible context."""
parsed = parse_reaction(query)
if parsed is not None:
return parsed
for message in reversed(list(messages or [])):
if _message_type(message) == "tool":
continue
content = str(_message_content(message) or "")
if _is_internal_repair_message(content):
continue
parsed = parse_reaction(content)
if parsed is not None:
return parsed
return None
def normalize_species_name(name: str) -> str:
"""Normalize species names for matching query text to tool outputs."""
return "".join(ch for ch in str(name).lower() if ch.isalnum())
def _parse_reaction_side(side: str) -> list[ReactionSpecies]:
species = []
for raw_term in re.split(r"\s+\+\s+", side.strip()):
term = raw_term.strip()
if not term:
continue
match = re.match(r"^(?:(\d+(?:\.\d+)?)\s+)?(.+?)\s*$", term)
if not match:
continue
coefficient = float(match.group(1) or 1.0)
name = match.group(2).strip()
species.append(ReactionSpecies(name=name, coefficient=coefficient))
return species
def _collect_observations(messages: Iterable[Any]) -> dict[str, Any]:
observations: dict[str, Any] = {
"identity_resolutions": [],
"smiles": [],
"energies": [],
"thermo": [],
"enthalpies": [],
"dipoles": [],
"vibrations": [],
"ir_spectra": [],
"species_gibbs": {},
"species_enthalpy": {},
"calculator_results": [],
"coordinate_files": [],
"ase_results": [],
"tool_trace": [],
"artifacts": {
"xyz": [],
"output_json": [],
"ir_plot": [],
"frequencies_csv": [],
},
"current_scope_id": None,
"_species_by_smiles": {},
"_species_by_file_key": {},
}
current_species: Optional[str] = None
tool_calls_by_id: dict[str, dict[str, Any]] = {}
current_scope_id: Optional[str] = None
scope_counter = 0
for message in messages or []:
msg_type = _message_type(message)
content = str(_message_content(message) or "")
if msg_type in {"human", "user"} and not _is_internal_repair_message(content):
scope_counter += 1
current_scope_id = f"scope_{scope_counter}"
observations["current_scope_id"] = current_scope_id
for tool_call in _message_tool_calls(message):
call_id = tool_call.get("id")
if call_id:
tool_calls_by_id[str(call_id)] = tool_call
name = _message_name(message)
data = _content_to_data(_message_content(message))
call_args = _tool_call_args_for_message(message, tool_calls_by_id)
tool_call_id = _tool_call_id(message)
if name:
_append_tool_trace(observations, name, data, call_args, tool_call_id)
_collect_artifacts_from_value(observations, data)
_collect_artifacts_from_value(observations, call_args)
if name in {"molecule_name_to_smiles", "resolve_molecule_identity"} and isinstance(
data, dict
):
current_species = str(
data.get("name") or call_args.get("name") or current_species or ""
)
identity_record = _identity_resolution_record(data, call_args)
if identity_record:
identity_record["scope_id"] = current_scope_id
observations["identity_resolutions"].append(identity_record)
if data.get("requires_clarification") or data.get("needs_clarification"):
continue
smiles = data.get("smiles") or call_args.get("smiles")
if smiles and smiles not in observations["smiles"]:
observations["smiles"].append(smiles)
if smiles and current_species:
_remember_smiles_species(observations, str(smiles), current_species)
continue
if name == "smiles_to_coordinate_file" and isinstance(data, dict):
smiles = data.get("smiles") or call_args.get("smiles")
if smiles and smiles not in observations["smiles"]:
observations["smiles"].append(smiles)
species = _species_for_smiles(observations, smiles)
if not species:
output_file = call_args.get("output_file") or data.get("path")
species = _species_for_file_name(observations, output_file)
path = data.get("path") or call_args.get("output_file")
if path and species:
_remember_file_species(observations, str(path), species)
if path and path not in observations["coordinate_files"]:
observations["coordinate_files"].append(str(path))
_remember_artifact(observations, "xyz", str(path))
output_file = call_args.get("output_file")
if output_file and species:
_remember_file_species(observations, str(output_file), species)
if output_file:
_remember_artifact(observations, "xyz", str(output_file))
continue
if name == "run_ase" and isinstance(data, dict):
run_params = _run_ase_params(call_args)
output_results_file = (
run_params.get("output_results_file")
or data.get("output_results_file")
or _first_path_from_value(data, suffix=".json")
)
if output_results_file and not run_params.get("output_results_file"):
run_params = {
**run_params,
"output_results_file": output_results_file,
}
species = _species_for_run_ase(observations, run_params, current_species)
_collect_run_ase_observation(data, observations, species, current_scope_id)
observations["ase_results"].append(
{
"driver": run_params.get("driver"),
"input_structure_file": run_params.get("input_structure_file"),
"output_results_file": output_results_file,
"species": species,
"calculator": _calculator_family(run_params.get("calculator")),
"status": data.get("status"),
"error_type": data.get("error_type"),
"message": data.get("message"),
"scope_id": current_scope_id,
}
)
continue
if name == "calculator":
numeric = _to_float(data)
if numeric is not None:
observations["calculator_results"].append(numeric)
return observations
def _message_tool_calls(message: Any) -> list[dict[str, Any]]:
"""Return normalized AI tool calls from a LangChain message."""
if isinstance(message, dict):
raw_calls = message.get("tool_calls") or (
message.get("additional_kwargs") or {}
).get("tool_calls")
else:
raw_calls = getattr(message, "tool_calls", None) or (
getattr(message, "additional_kwargs", {}) or {}
).get("tool_calls")
calls: list[dict[str, Any]] = []
for raw_call in raw_calls or []:
if not isinstance(raw_call, dict):
continue
if "function" in raw_call:
function = raw_call.get("function") or {}
args = _content_to_data(function.get("arguments") or {}) or {}
calls.append(
{
"id": raw_call.get("id"),
"name": function.get("name"),
"args": args if isinstance(args, dict) else {},
}
)
continue
calls.append(
{
"id": raw_call.get("id"),
"name": raw_call.get("name"),
"args": raw_call.get("args") or {},
}
)
return calls
def _tool_call_id(message: Any) -> Optional[str]:
if isinstance(message, dict):
value = message.get("tool_call_id")
else:
value = getattr(message, "tool_call_id", None)
return str(value) if value else None
def _tool_call_args_for_message(
message: Any,
tool_calls_by_id: dict[str, dict[str, Any]],
) -> dict[str, Any]:
call_id = _tool_call_id(message)
if not call_id:
return {}
call = tool_calls_by_id.get(call_id) or {}
args = call.get("args") or {}
return args if isinstance(args, dict) else {}
def _run_ase_params(call_args: dict[str, Any]) -> dict[str, Any]:
params = call_args.get("params") if isinstance(call_args, dict) else None
if isinstance(params, dict):
return params
return call_args if isinstance(call_args, dict) else {}
def _identity_resolution_record(
data: dict[str, Any],
call_args: dict[str, Any],
) -> dict[str, Any]:
smiles = data.get("smiles") or call_args.get("smiles")
name = data.get("name") or data.get("input_name") or call_args.get("name")
if not smiles and not name:
return {}
return {
"input_name": name,
"resolved_name": data.get("resolved_name") or name,
"smiles": smiles,
"canonical_smiles": data.get("canonical_smiles"),
"isomeric_smiles": data.get("isomeric_smiles"),
"connectivity_smiles": data.get("connectivity_smiles"),
"molecular_formula": data.get("molecular_formula"),
"inchikey": data.get("inchikey"),
"source": data.get("source") or "unknown",
"cid": data.get("cid"),
"confidence_score": data.get("confidence_score"),
"credibility_score": data.get("credibility_score"),
"score_breakdown": data.get("score_breakdown") or {},
"identity_flags": data.get("identity_flags") or {},
"resolver_provenance": data.get("resolver_provenance") or {},
"ambiguity_flag": bool(data.get("ambiguity_flag", False)),
"mixture_flag": bool(data.get("mixture_flag", False)),
"is_mixture": bool(data.get("is_mixture", data.get("mixture_flag", False))),
"requires_clarification": bool(data.get("requires_clarification", False)),
"needs_clarification": bool(
data.get("needs_clarification", data.get("requires_clarification", False))
),
"representative_of": data.get("representative_of"),
"selection_reason": data.get("selection_reason"),
"warning": data.get("warning"),
"warnings": data.get("warnings") or [],
"candidates": _summarize_tool_output(data.get("candidates") or []),
}
def _query_supplies_coordinate_file(query: str, input_file: Any) -> bool:
"""Return True when the user explicitly supplied a coordinate file path."""
if not input_file:
return False
query_text = str(query or "")
input_text = str(input_file)
if input_text and input_text in query_text:
return True
input_name = Path(input_text).name
if input_name and re.search(rf"(?<![A-Za-z0-9_.-]){re.escape(input_name)}(?![A-Za-z0-9_.-])", query_text):
return True
return False
def _query_supplies_smiles(query: str, smiles: Any) -> bool:
"""Return True when the user explicitly supplied the SMILES string."""
if not smiles:
return False
query_text = str(query or "")
smiles_text = str(smiles)
if not smiles_text:
return False
if len(smiles_text) <= 2:
return bool(
re.search(
rf"(?<![A-Za-z0-9]){re.escape(smiles_text)}(?![A-Za-z0-9])",
query_text,
)
)
if smiles_text in query_text:
return True
return False
def _is_observed_coordinate_file(
observations: dict[str, Any],
input_file: Any,
) -> bool:
"""Return True when input_file matches a generated coordinate artifact."""
if not input_file:
return False
input_keys = set(_file_keys(str(input_file)))
for observed in observations.get("coordinate_files", []):
observed_keys = set(_file_keys(str(observed)))
if input_keys & observed_keys:
return True
return False
def _is_observed_coordinate_file_for_targets(
observations: dict[str, Any],
input_file: Any,
target_molecules: Iterable[str],
) -> bool:
"""Return True only when input_file is generated for the current target."""
if not _is_observed_coordinate_file(observations, input_file):
return False
targets = list(target_molecules or [])
if not targets:
return True
species = _species_for_file_name(observations, input_file) or _species_from_file_name(
input_file
)
return _species_matches_targets(species, targets)
def _is_observed_identity_name(
observations: dict[str, Any],
requested_name: Any,
) -> bool:
if not requested_name:
return False
requested = _normalize_label(str(requested_name))
for record in observations.get("identity_resolutions", []):
for value in (
record.get("input_name"),
record.get("resolved_name"),
record.get("representative_of"),
):
if value and _normalize_label(str(value)) == requested:
return True
return False
def _normalize_label(value: str) -> str:
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
def _remember_smiles_species(
observations: dict[str, Any],
smiles: str,
species: str,
) -> None:
species_by_smiles = observations["_species_by_smiles"]
species_set = species_by_smiles.setdefault(str(smiles), set())
species_set.add(str(species))
def _species_for_smiles(
observations: dict[str, Any],
smiles: Any,
) -> Optional[str]:
if not smiles:
return None
species_set = observations["_species_by_smiles"].get(str(smiles), set())
if len(species_set) == 1:
return next(iter(species_set))
return None
def _remember_file_species(
observations: dict[str, Any],
file_path: str,
species: str,
) -> None:
for key in _file_keys(file_path):
observations["_species_by_file_key"][key] = str(species)
def _species_for_file_name(
observations: dict[str, Any],
file_path: Any,
) -> Optional[str]:
if not file_path:
return None
species_by_file_key = observations["_species_by_file_key"]
for key in _file_keys(str(file_path)):
species = species_by_file_key.get(key)
if species:
return species
return None
def _species_for_run_ase(
observations: dict[str, Any],
run_params: dict[str, Any],
fallback_species: Optional[str],
) -> Optional[str]:
input_file = run_params.get("input_structure_file")
species = _species_for_file_name(observations, input_file)
if species:
return species
output_file = run_params.get("output_results_file")
species = _species_for_file_name(observations, output_file)
if species:
return species
inferred = _species_from_file_name(input_file) or _species_from_file_name(output_file)
return inferred or fallback_species
def _file_keys(file_path: str) -> list[str]:
raw = str(file_path)
path = Path(raw)
keys = {
raw,
raw.lower(),
os.path.normpath(raw).lower(),
path.name.lower(),
path.stem.lower(),
normalize_species_name(path.stem),
}
return [key for key in keys if key]
def _species_from_file_name(file_path: Any) -> Optional[str]:
if not file_path:
return None
stem = Path(str(file_path)).stem
if not stem:
return None
normalized = stem.lower()
for prefix in ("output_", "result_", "results_"):
if normalized.startswith(prefix):
normalized = normalized[len(prefix) :]
for suffix in ("_output", "_result", "_results"):
if normalized.endswith(suffix):
normalized = normalized[: -len(suffix)]
normalized = normalized.replace("_", " ").replace("-", " ").strip()
return normalized or None
def _collect_run_ase_observation(
data: dict[str, Any],
observations: dict[str, Any],
current_species: Optional[str],
scope_id: Optional[str] = None,
) -> None:
energy = data.get("single_point_energy")
if energy is not None:
numeric_energy = _to_float(energy)
if numeric_energy is not None:
observations["energies"].append(
_with_species(
{"value": numeric_energy, "unit": data.get("unit", "eV")},
current_species,
scope_id,
)
)
thermo = _nested(data, "result", "thermochemistry") or data.get("thermochemistry")
if isinstance(thermo, dict):
enthalpy = _to_float(thermo.get("enthalpy"))
if enthalpy is not None:
item = _with_species(
{"value": enthalpy, "unit": thermo.get("unit", "eV")},
current_species,
scope_id,
)
observations["enthalpies"].append(item)
if current_species:
observations["species_enthalpy"][
normalize_species_name(current_species)
] = item
gibbs = _to_float(thermo.get("gibbs_free_energy"))
if gibbs is not None:
item = _with_species(
{"value": gibbs, "unit": thermo.get("unit", "eV")},
current_species,
scope_id,
)
observations["thermo"].append(item)
if current_species:
observations["species_gibbs"][normalize_species_name(current_species)] = item
dipole = data.get("dipole_moment")
if isinstance(dipole, list) and any(value is not None for value in dipole):
observations["dipoles"].append(
_with_species(
{"value": dipole, "unit": data.get("dipole_unit", "e * Angstrom")},
current_species,
scope_id,
)
)
vibration = _nested(data, "result", "vibrational_frequencies") or data.get(
"vibrational_frequencies"
)
if isinstance(vibration, dict):
frequencies = vibration.get("frequencies") or vibration.get("frequency_cm1")
if frequencies:
observations["vibrations"].append(
_with_species(
{"frequencies": list(frequencies)},
current_species,
scope_id,
)
)
ir_spectrum = _nested(data, "result", "ir_spectrum") or data.get("ir_spectrum")
if isinstance(ir_spectrum, dict):
frequencies = ir_spectrum.get("frequency_cm1") or ir_spectrum.get(
"frequencies"
)
intensities = ir_spectrum.get("intensity") or ir_spectrum.get("intensities")
plot = ir_spectrum.get("plot")
if frequencies and intensities:
item = {
"frequency_cm1": [str(value) for value in frequencies],
"intensity": [str(value) for value in intensities],
}
item = _with_species(item, current_species, scope_id)
if plot:
item["plot"] = str(plot)
_remember_artifact(observations, "ir_plot", str(plot))
observations["ir_spectra"].append(item)
ir_data = _nested(data, "result", "ir_data") or data.get("ir_data")
if isinstance(ir_data, dict):
plot_text = ir_data.get("IR Plot")
if isinstance(plot_text, str):
for path in _paths_from_text(plot_text):
_remember_artifact(observations, "ir_plot", path)
spectrum_freqs = ir_data.get("mode_frequencies") or ir_data.get(
"spectrum_frequencies"
)
spectrum_ints = ir_data.get("mode_intensities") or ir_data.get(
"spectrum_intensities"
)
if spectrum_freqs and spectrum_ints:
observations["ir_spectra"].append(
_with_species(
{
"frequency_cm1": [str(value) for value in spectrum_freqs],
"intensity": [str(value) for value in spectrum_ints],
},
current_species,
scope_id,
)
)
def _with_species(
item: dict[str, Any],
species: Optional[str],
scope_id: Optional[str] = None,
) -> dict[str, Any]:
"""Attach a target species label to a collected property value."""
if species:
item["species"] = str(species)
if scope_id:
item["scope_id"] = str(scope_id)
return item
def _target_key_set(target_molecules: Iterable[str]) -> set[str]:
return {
normalize_species_name(target)
for target in target_molecules or []
if normalize_species_name(target)
}
def _record_matches_target(record: dict[str, Any], target: str) -> bool:
target_key = normalize_species_name(target)
if not target_key:
return False
for value in (
record.get("input_name"),
record.get("resolved_name"),
record.get("representative_of"),
record.get("name"),
):
if value and normalize_species_name(str(value)) == target_key:
return True
return False
def _target_smiles(
observations: dict[str, Any],
target_molecules: Iterable[str],
) -> list[str]:
"""Return SMILES that belong to the current query targets."""
targets = list(target_molecules or [])
if not targets:
return list(observations.get("smiles", []))
values = []
for record in observations.get("identity_resolutions", []):
if any(_record_matches_target(record, target) for target in targets):
smiles = record.get("smiles")
if smiles:
values.append(str(smiles))
return _dedupe_preserve_order(values)
def _species_matches_targets(species: Any, target_molecules: Iterable[str]) -> bool:
targets = _target_key_set(target_molecules)
if not targets:
return True
if not species:
return False
return normalize_species_name(str(species)) in targets
def _target_coordinate_files(
observations: dict[str, Any],
target_molecules: Iterable[str],
) -> list[str]:
"""Return generated coordinate files that match the current query targets."""
targets = list(target_molecules or [])
if not targets:
return list(observations.get("coordinate_files", []))
matches = []
for path in observations.get("coordinate_files", []):
species = _species_for_file_name(observations, path) or _species_from_file_name(
path
)
if _species_matches_targets(species, targets):
matches.append(str(path))
return _dedupe_preserve_order(matches)
def _target_evidence_is_ambiguous(
observations: dict[str, Any],
target_molecules: Iterable[str],
) -> bool:
"""Return True when unlabelled property values are safe to accept.
This preserves older direct unit tests where a lone run_ase result has no
resolver provenance, while preventing a previous molecule's labelled result
from satisfying a new target.
"""
targets = _target_key_set(target_molecules)
if not targets:
return False
return bool(
observations.get("identity_resolutions")
or observations.get("coordinate_files")
or len(observations.get("ase_results", [])) > 1
)
def _item_matches_targets(
item: dict[str, Any],
target_molecules: Iterable[str],
observations: dict[str, Any],
) -> bool:
targets = list(target_molecules or [])
if not targets:
return True
species = item.get("species") if isinstance(item, dict) else None
if species:
return _species_matches_targets(species, targets)
return not _target_evidence_is_ambiguous(observations, targets)
def _last_matching_target(
items: list[dict[str, Any]],
target_molecules: Iterable[str],
observations: dict[str, Any],
) -> Optional[dict[str, Any]]:
for item in reversed(items or []):
if isinstance(item, dict) and _item_matches_targets(
item, target_molecules, observations
):
return item
return None
def _append_tool_trace(
observations: dict[str, Any],
name: str,
data: Any,
args: dict[str, Any],
tool_call_id: Optional[str],
) -> None:
"""Append one compact, JSON-safe tool trace entry."""
status = "success"
if isinstance(data, str) and data.strip().lower().startswith("error"):
status = "error"
elif isinstance(data, dict) and data.get("status") in {"error", "failed", "failure"}:
status = str(data.get("status"))
observations["tool_trace"].append(
{
"tool_call_id": tool_call_id,
"tool": name,
"args": _json_safe(args),
"status": status,
"output": _summarize_tool_output(data),
}
)
def _summarize_tool_output(data: Any) -> Any:
"""Return a compact JSON-safe summary of a tool output."""
data = _json_safe(data)
if isinstance(data, dict):
summary = {}
for key, value in data.items():
if key in {"final_structure", "simulation_input"}:
continue
summary[key] = _summarize_tool_output(value)
return summary
if isinstance(data, list):
if len(data) > 12:
return {"count": len(data), "sample": data[:5]}
return data
text = str(data)
if len(text) > 1000:
return text[:1000] + "...<truncated>"
return data
def _public_observations(observations: dict[str, Any]) -> dict[str, Any]:
"""Expose observations without private matching indexes."""
return {
"identity_resolutions": observations["identity_resolutions"],
"smiles": observations["smiles"],
"coordinate_files": observations["coordinate_files"],
"energies": observations["energies"],
"thermo": observations["thermo"],
"enthalpies": observations["enthalpies"],
"dipoles": observations["dipoles"],
"vibrations": observations["vibrations"],
"ir_spectra": observations["ir_spectra"],
"species_gibbs": observations["species_gibbs"],
"species_enthalpy": observations["species_enthalpy"],
"calculator_results": observations["calculator_results"],
"ase_results": observations["ase_results"],
}
def _identity_resolution_summary(observations: dict[str, Any]) -> dict[str, Any]:
records = observations.get("identity_resolutions", [])
latest = records[-1] if records else None
return {
"records": records,
"latest": latest,
"requires_clarification": bool(
latest
and (
latest.get("requires_clarification")
or latest.get("needs_clarification")
or _identity_low_credibility(latest)
)
),
}
def _clarification_state(
identity_resolution: dict[str, Any],
next_action: str,
) -> dict[str, Any]:
latest = identity_resolution.get("latest") or {}
needed = next_action == "clarify_identity" or bool(
identity_resolution.get("requires_clarification")
)
questions = []
if needed:
input_name = latest.get("input_name") or "the requested chemical"
questions.append(
f"Which specific component, composition, or representative molecule should be modeled for {input_name}?"
)
return {
"needed": needed,
"reason": latest.get("warning") or latest.get("selection_reason") or "",
"questions": questions,
"blocked_actions": (
["smiles_to_coordinate_file", "run_ase"] if needed else []
),
}
def _memory_refs(observations: dict[str, Any]) -> dict[str, Any]:
"""Return compact reusable references instead of replaying full outputs."""
return {
"smiles": observations.get("smiles", []),
"coordinate_files": observations.get("coordinate_files", []),
"artifacts": observations.get("artifacts", {}),
"ase_results": [
{
"driver": result.get("driver"),
"input_structure_file": result.get("input_structure_file"),
"output_results_file": result.get("output_results_file"),
"species": result.get("species"),
"status": result.get("status"),
}
for result in observations.get("ase_results", [])
],
}
def _next_action(
requirement: TaskRequirement,
validation: dict[str, Any],
observations: dict[str, Any],
target_molecules: Optional[list[str]] = None,
) -> str:
if validation.get("complete"):
return "compose_final_answer"
if requirement.task_type == "unknown":
return "compose_final_answer"
if _identity_needs_clarification(observations):
return "clarify_identity"
if requirement.task_type == "smiles":
return "molecule_name_to_smiles"
target_smiles = _target_smiles(observations, target_molecules or [])
target_coordinates = _target_coordinate_files(
observations, target_molecules or []
)
all_target_identities = (
all(
_target_smiles(observations, [target])
for target in (target_molecules or [])
)
if target_molecules
else bool(target_smiles)
)
all_target_coordinates = (
all(
_target_coordinate_files(observations, [target])
for target in (target_molecules or [])
)
if target_molecules
else bool(target_coordinates)
)
failed_ase = _last_failed_ase(observations)
if failed_ase and failed_ase.get("error_type") == "FileNotFoundError":
if not all_target_identities:
return "molecule_name_to_smiles"
return "smiles_to_coordinate_file"
if not all_target_identities:
return "molecule_name_to_smiles"
if requirement.driver and not all_target_coordinates:
return "smiles_to_coordinate_file"
if requirement.driver:
return "run_ase"
missing = set(validation.get("missing", []))
if "smiles" in missing:
return "molecule_name_to_smiles"
if any("coordinate" in item or item.endswith(".xyz") for item in missing):
return "smiles_to_coordinate_file"
if requirement.driver:
return "run_ase"
return "repair_workflow"
def _infer_calculator(query: str) -> Optional[str]:
text = str(query or "").lower()
if "tblite" in text or "xtb" in text or "gfn" in text:
return "TBLite"
if "emt" in text:
return "EMT"
if "mace" in text:
return "MACE"
if "orca" in text:
return "ORCA"
if "nwchem" in text:
return "NWChem"
return None
def _calculator_policy(requirement: TaskRequirement, query: str) -> dict[str, Any]:
"""Return the default calculator policy for the inferred task."""
explicit = _infer_calculator(query)
preferred = PREFERRED_CALCULATOR_BY_TASK.get(requirement.task_type)
return {
"explicit_request": explicit,
"preferred": explicit or preferred,
"enforce": bool(preferred and not explicit),
"reason": (
"User explicitly requested a calculator."
if explicit
else (
f"Use {preferred} for this molecular property by default."
if preferred
else "No task-specific calculator preference."
)
),
}
def _calculator_family(calculator: Any) -> Optional[str]:
"""Normalize calculator payloads to a coarse family name."""
if calculator is None:
return None
if isinstance(calculator, str):
raw = calculator
elif isinstance(calculator, dict):
raw = str(
calculator.get("calculator_type")
or calculator.get("type")
or calculator.get("name")
or calculator.get("calculator")
or ""
)
else:
raw = calculator.__class__.__name__
text = raw.lower()
if not text:
return None
if "tblite" in text or "xtb" in text or "gfn" in text:
return "TBLite"
if "mace" in text:
return "MACE"
if "emt" in text:
return "EMT"
if "orca" in text:
return "ORCA"
if "nwchem" in text:
return "NWChem"
if "fairchem" in text:
return "FAIRChem"
if "aimnet" in text:
return "AIMNET2"
return raw
def _calculator_payload_hint(calculator_family: str) -> str:
"""Return a compact run_ase calculator payload for repair instructions."""
family = str(calculator_family or "").lower()
if family == "tblite":
return "{'calculator_type': 'TBLite', 'method': 'GFN2-xTB'}"
if family == "mace":
return "{'calculator_type': 'mace_mp'}"
if family == "emt":
return "{'calculator_type': 'emt'}"
if family == "orca":
return "{'calculator_type': 'orca'}"
if family == "nwchem":
return "{'calculator_type': 'nwchem'}"
return f"{{'calculator_type': '{calculator_family}'}}"
def _parallelization_plan(
requirement: TaskRequirement,
target_molecules: list[str],
) -> dict[str, Any]:
"""Describe safe parallelism for the inferred task."""
if requirement.task_type in {"reaction_gibbs_energy", "reaction_enthalpy"}:
return {
"can_parallelize": True,
"unit": "reaction_species",
"rule": (
"For each species, preserve molecule_name_to_smiles -> "
"smiles_to_coordinate_file -> run_ase(driver='thermo') order. "
"Different species can run in parallel, then aggregate products "
"minus reactants."
),
"targets": target_molecules,
}
if len(target_molecules) > 1 and requirement.driver:
return {
"can_parallelize": True,
"unit": "molecule",
"rule": (
"For each molecule, preserve molecule_name_to_smiles -> "
"smiles_to_coordinate_file -> run_ase order. Different molecules "
"can run in parallel."
),
"targets": target_molecules,
}
return {
"can_parallelize": False,
"unit": "single_molecule_chain",
"rule": (
"Within one molecule, do not parallelize dependent steps. Generate "
"or locate coordinates before run_ase."
),
"targets": target_molecules,
}
def _tool_requirements(
requirement: TaskRequirement,
target_molecules: list[str],
) -> list[dict[str, Any]]:
"""Return an explicit tool dependency plan for the task."""
task_type = requirement.task_type
if task_type == "unknown":
return []
requirements = []
if task_type in {
"smiles",
"energy",
"gibbs_free_energy",
"enthalpy",
"dipole",
"vibration",
"ir",
"reaction_gibbs_energy",
"reaction_enthalpy",
}:
requirements.append(
{
"tool": "molecule_name_to_smiles",
"required_when": "query uses molecule names instead of explicit SMILES",
"purpose": "resolve molecule identity to canonical SMILES",
"depends_on": [],
"parallel_group": "identity_resolution",
"targets": target_molecules,
}
)
if task_type == "smiles":
return requirements
if task_type in {
"energy",
"gibbs_free_energy",
"enthalpy",
"dipole",
"vibration",
"ir",
"reaction_gibbs_energy",
"reaction_enthalpy",
}:
requirements.append(
{
"tool": "smiles_to_coordinate_file",
"required_when": "run_ase needs a coordinate file",
"purpose": "generate XYZ coordinates for ASE",
"depends_on": ["molecule_name_to_smiles or explicit SMILES"],
"parallel_group": "coordinate_generation",
"targets": target_molecules,
}
)
requirements.append(
{
"tool": "run_ase",
"required_when": f"task requires {requirement.required_output}",
"purpose": f"calculate {task_type} with driver={requirement.driver}",
"depends_on": ["smiles_to_coordinate_file"],
"parallel_group": "ase_simulation",
"driver": requirement.driver,
"targets": target_molecules,
}
)
return requirements
def _postprocessing_steps(requirement: TaskRequirement) -> list[dict[str, Any]]:
"""Return deterministic non-tool steps needed after tool calls."""
if requirement.task_type == "reaction_gibbs_energy":
return [
{
"name": "aggregate_reaction_gibbs",
"required_when": "all species thermochemistry values are available",
"purpose": "compute reaction Gibbs energy as products minus reactants",
"depends_on": ["run_ase(driver='thermo') for all species"],
}
]
if requirement.task_type == "reaction_enthalpy":
return [
{
"name": "aggregate_reaction_enthalpy",
"required_when": "all species thermochemistry values are available",
"purpose": "compute reaction enthalpy as products minus reactants",
"depends_on": ["run_ase(driver='thermo') for all species"],
}
]
return []
def _tool_requirement_status(
requirement: TaskRequirement,
observations: dict[str, Any],
target_molecules: Optional[list[str]] = None,
) -> dict[str, Any]:
"""Return which required tools already have reusable outputs."""
required = REQUIRED_TOOL_STEPS.get(requirement.task_type, [])
satisfied = []
reusable = {}
target_molecules = target_molecules or []
target_smiles = _target_smiles(observations, target_molecules)
target_coordinates = _target_coordinate_files(observations, target_molecules)
all_target_identities = (
all(_target_smiles(observations, [target]) for target in target_molecules)
if target_molecules
else bool(target_smiles)
)
all_target_coordinates = (
all(
_target_coordinate_files(observations, [target])
for target in target_molecules
)
if target_molecules
else bool(target_coordinates)
)
identity_blocked = bool(_identity_needs_clarification(observations))
if (
"molecule_name_to_smiles" in required
and all_target_identities
and not identity_blocked
):
satisfied.append("molecule_name_to_smiles")
reusable["smiles"] = target_smiles
if (
"smiles_to_coordinate_file" in required
and all_target_coordinates
and not identity_blocked
):
satisfied.append("smiles_to_coordinate_file")
reusable["coordinate_files"] = target_coordinates
if "run_ase" in required and _has_required_ase_result(
requirement, observations, target_molecules
):
satisfied.append("run_ase")
reusable["ase_results"] = observations["ase_results"]
return {
"satisfied_tools": satisfied,
"missing_tools": [tool for tool in required if tool not in satisfied],
"reusable_observations": reusable,
}
def _has_required_ase_result(
requirement: TaskRequirement,
observations: dict[str, Any],
target_molecules: Optional[list[str]] = None,
) -> bool:
if requirement.task_type == "energy":
return _last_matching_target(
observations["energies"], target_molecules or [], observations
) is not None
if requirement.task_type == "gibbs_free_energy":
return _last_matching_target(
observations["thermo"], target_molecules or [], observations
) is not None
if requirement.task_type == "enthalpy":
return _last_matching_target(
observations["enthalpies"], target_molecules or [], observations
) is not None
if requirement.task_type == "dipole":
return _last_matching_target(
observations["dipoles"], target_molecules or [], observations
) is not None
if requirement.task_type == "vibration":
return _last_matching_target(
observations["vibrations"], target_molecules or [], observations
) is not None
if requirement.task_type == "ir":
return _last_matching_target(
observations["ir_spectra"], target_molecules or [], observations
) is not None
if requirement.task_type == "reaction_gibbs_energy":
targets = _target_key_set(target_molecules or [])
if not targets:
return bool(observations["species_gibbs"])
return targets.issubset(set(observations["species_gibbs"]))
if requirement.task_type == "reaction_enthalpy":
targets = _target_key_set(target_molecules or [])
if not targets:
return bool(observations["species_enthalpy"])
return targets.issubset(set(observations["species_enthalpy"]))
return False
def _last_failed_ase(
observations: dict[str, Any],
target_molecules: Optional[Iterable[str]] = None,
driver: Optional[str] = None,
current_scope_only: bool = False,
) -> Optional[dict[str, Any]]:
"""Return the latest ASE result that did not succeed."""
current_scope_id = observations.get("current_scope_id")
for result in reversed(observations.get("ase_results", [])):
status = str(result.get("status") or "").lower()
if not status or status == "success":
continue
if current_scope_only and result.get("scope_id") != current_scope_id:
continue
if driver and result.get("driver") and result.get("driver") != driver:
continue
if target_molecules and not _species_matches_targets(
result.get("species"), target_molecules
):
continue
return result
return None
def _unresolved_current_failed_ase(
observations: dict[str, Any],
driver: Optional[str] = None,
) -> Optional[dict[str, Any]]:
"""Return a current-scope ASE failure that has not been repaired later."""
current_scope_id = observations.get("current_scope_id")
if not current_scope_id:
return None
successful_species: set[str] = set()
saw_any_success = False
for result in reversed(observations.get("ase_results", [])):
if result.get("scope_id") != current_scope_id:
continue
if driver and result.get("driver") and result.get("driver") != driver:
continue
status = str(result.get("status") or "").lower()
if not status:
continue
species = result.get("species")
species_key = normalize_species_name(str(species)) if species else ""
if status == "success":
saw_any_success = True
if species_key:
successful_species.add(species_key)
continue
if species_key and species_key in successful_species:
continue
if not species_key and saw_any_success:
continue
return result
return None
def _action_target_key(target: str) -> str:
key = re.sub(r"[^A-Za-z0-9]+", "_", str(target or "target").strip()).strip("_")
return key.lower() or "target"
def _identity_action_status(
observations: dict[str, Any],
next_action: str,
target: Optional[str] = None,
) -> str:
if _identity_needs_clarification(observations):
return "blocked"
if _target_smiles(observations, [target] if target else []):
return "succeeded"
if next_action == "molecule_name_to_smiles":
return "ready"
return "pending"
def _coordinate_action_status(
observations: dict[str, Any],
next_action: str,
target: Optional[str] = None,
) -> str:
if _identity_needs_clarification(observations):
return "blocked"
if _target_coordinate_files(observations, [target] if target else []):
return "succeeded"
if next_action == "smiles_to_coordinate_file":
return "ready"
return "pending"
def _ase_action_status(
requirement: TaskRequirement,
observations: dict[str, Any],
next_action: str,
target: Optional[str] = None,
) -> str:
if _identity_needs_clarification(observations):
return "blocked"
if _unresolved_current_failed_ase(observations, driver=requirement.driver):
return "failed"
if _has_required_ase_result(
requirement, observations, [target] if target else []
):
return "succeeded"
if _last_failed_ase(observations):
return "failed"
if next_action == "run_ase":
return "ready"
return "pending"
def _identity_needs_clarification(
observations: dict[str, Any],
) -> Optional[dict[str, Any]]:
"""Return the latest identity record that should block downstream tools."""
for record in reversed(observations.get("identity_resolutions", [])):
if (
record.get("requires_clarification")
or record.get("needs_clarification")
or _identity_low_credibility(record)
):
return record
return None
def _identity_low_credibility(record: dict[str, Any]) -> bool:
"""Return whether a resolver record should be confirmed before execution."""
try:
return float(record.get("credibility_score", 1.0)) < 0.5
except (TypeError, ValueError):
return False
def _infer_target_molecules(
query: str,
observations: dict[str, Any],
messages: Iterable[Any] = (),
) -> list[str]:
requirement = infer_requirement(query)
if requirement.task_type in {"reaction_gibbs_energy", "reaction_enthalpy"}:
reaction = parse_reaction_from_context(query, messages)
if reaction is not None:
names = [
species.name
for side in ("reactants", "products")
for species in reaction[side]
]
return _dedupe_preserve_order(names)
query_targets = _target_molecules_from_query(query)
if query_targets:
return query_targets
latest = _latest_identity_target(observations)
if latest:
return [latest]
return []
def _target_molecules_from_query(query: str) -> list[str]:
"""Extract explicit non-reaction molecule names from the current query."""
text = str(query or "").strip()
patterns = [
r"\b(?:of|for)\s+([A-Za-z][A-Za-z0-9 _-]*?)(?:\s+using|\s+with|\s+at|[?.!,]|$)",
r"\bSMILES\s+for\s+([A-Za-z][A-Za-z0-9 _-]*?)(?:[?.!,]|$)",
]
for pattern in patterns:
match = re.search(pattern, text, flags=re.IGNORECASE)
if match:
candidate = _clean_target_name(match.group(1))
if candidate:
return [candidate]
property_phrase = (
r"dipole\s*moment|dipolemoment|dipole|ftir|ft-ir|ir\s+spectrum|"
r"infrared\s+spectrum|vibrational\s+frequenc(?:y|ies)|vibration|"
r"gibbs(?:\s+free\s+energy)?|enthalpy|single[- ]point\s+energy|"
r"energy|geometry|geomery"
)
match = re.match(
rf"\s*([A-Za-z][A-Za-z0-9 _-]*?)\s+(?:{property_phrase})\b",
text,
flags=re.IGNORECASE,
)
if match:
candidate = _clean_target_name(match.group(1))
if candidate and not _looks_like_command_prefix(candidate):
return [candidate]
return []
def _clean_target_name(value: Any) -> str:
return str(value or "").strip(" \t\r\n`*_")
def _looks_like_command_prefix(candidate: str) -> bool:
first = str(candidate or "").strip().split(maxsplit=1)[0].lower()
return first in {
"calculate",
"compute",
"determine",
"find",
"get",
"give",
"report",
"show",
"what",
}
def _latest_identity_target(observations: dict[str, Any]) -> Optional[str]:
"""Return the latest single resolved identity as follow-up context."""
for record in reversed(observations.get("identity_resolutions", [])):
if record.get("requires_clarification") or record.get("needs_clarification"):
continue
name = record.get("input_name") or record.get("resolved_name")
if name:
return str(name)
return None
def _dedupe_preserve_order(values: Iterable[str]) -> list[str]:
result = []
seen = set()
for value in values:
key = normalize_species_name(value)
if key and key not in seen:
seen.add(key)
result.append(str(value))
return result
def _collect_artifacts_from_value(observations: dict[str, Any], value: Any) -> None:
if isinstance(value, dict):
for child in value.values():
_collect_artifacts_from_value(observations, child)
return
if isinstance(value, list):
for child in value:
_collect_artifacts_from_value(observations, child)
return
if not isinstance(value, str):
return
for path in _paths_from_text(value):
suffix = Path(path).suffix.lower()
if suffix == ".xyz":
_remember_artifact(observations, "xyz", path)
elif suffix == ".json":
_remember_artifact(observations, "output_json", path)
elif suffix == ".png" and "ir_spectrum" in Path(path).name:
_remember_artifact(observations, "ir_plot", path)
elif suffix == ".csv" and "frequencies" in Path(path).name:
_remember_artifact(observations, "frequencies_csv", path)
def _first_path_from_value(value: Any, *, suffix: str) -> Optional[str]:
"""Return the first path-like string with the requested suffix."""
if isinstance(value, dict):
for child in value.values():
found = _first_path_from_value(child, suffix=suffix)
if found:
return found
return None
if isinstance(value, list):
for child in value:
found = _first_path_from_value(child, suffix=suffix)
if found:
return found
return None
if not isinstance(value, str):
return None
for path in _paths_from_text(value):
if Path(path).suffix.lower() == suffix.lower():
return path
return None
def _run_id_from_path(path: Any, *, default: Optional[str] = None) -> Optional[str]:
"""Use an artifact path's directory as a stable run scope when available."""
if not path:
return default
try:
return str(Path(str(path)).expanduser().parent)
except Exception:
return default
def _paths_from_text(text: str) -> list[str]:
matches = re.findall(r"(/[^\s'\"`]+?\.(?:json|xyz|png|csv))", str(text))
return [match.rstrip(".,);]") for match in matches]
def _remember_artifact(
observations: dict[str, Any],
artifact_type: str,
path: str,
) -> None:
artifacts = observations["artifacts"].setdefault(artifact_type, [])
path = str(path)
if path not in artifacts:
artifacts.append(path)
def _json_safe(value: Any) -> Any:
if isinstance(value, dict):
return {str(key): _json_safe(val) for key, val in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
if isinstance(value, set):
return sorted(_json_safe(item) for item in value)
if isinstance(value, (str, int, float, bool)) or value is None:
return value
return str(value)
def _message_name(message: Any) -> Optional[str]:
if isinstance(message, dict):
return message.get("name")
return getattr(message, "name", None)
def _message_type(message: Any) -> Optional[str]:
if isinstance(message, dict):
return message.get("type") or message.get("role")
return getattr(message, "type", None)
def _message_content(message: Any) -> Any:
if message is None:
return ""
if isinstance(message, dict):
return message.get("content", "")
return getattr(message, "content", "")
def _content_to_data(content: Any) -> Any:
if isinstance(content, (dict, list, int, float)):
return content
if content is None:
return None
text = str(content).strip()
if not text:
return text
try:
return json.loads(text)
except json.JSONDecodeError:
pass
try:
return ast.literal_eval(text)
except (ValueError, SyntaxError):
return text
def _nested(data: dict[str, Any], *keys: str) -> Any:
value: Any = data
for key in keys:
if not isinstance(value, dict):
return None
value = value.get(key)
return value
def _to_float(value: Any) -> Optional[float]:
if isinstance(value, (int, float)):
return float(value)
try:
return float(str(value).strip())
except (TypeError, ValueError):
return None
def _last(values: list[Any]) -> Any:
return values[-1] if values else None
def _first_unit(items: Iterable[dict[str, Any]]) -> Optional[str]:
for item in items:
unit = item.get("unit")
if unit:
return unit
return None
def _public_property_record(record: dict[str, Any]) -> dict[str, Any]:
"""Return a property record without internal ledger provenance fields."""
return {
key: value
for key, value in record.items()
if key not in {"scope_id", "run_id", "requirement_id", "artifact_id"}
}
def _empty_structured(**updates: Any) -> dict[str, Any]:
structured = {
"smiles": None,
"scalar_answer": None,
"dipole": None,
"vibrational_answer": None,
"ir_spectrum": None,
"atoms_data": None,
}
structured.update(updates)
return structured
def _tool_guard_allowed() -> dict[str, Any]:
return {
"allowed": True,
"blocked_tool": None,
"expected_next_action": None,
"repair_instruction": "",
"reason": "Tool call batch is consistent with workflow state.",
}
def _tool_guard_blocked(
*,
blocked_tool: str,
expected_next_action: Optional[str],
repair_instruction: str,
) -> dict[str, Any]:
return {
"allowed": False,
"blocked_tool": blocked_tool,
"expected_next_action": expected_next_action,
"repair_instruction": repair_instruction,
"reason": "Tool call batch violates the deterministic workflow order.",
}
def _complete_result(
requirement: TaskRequirement,
*,
structured_output: Optional[dict[str, Any]] = None,
reason: str = "",
) -> dict[str, Any]:
return {
"complete": True,
"task_type": requirement.task_type,
"driver": requirement.driver,
"required_output": requirement.required_output,
"response_field": requirement.response_field,
"missing": [],
"repair_instruction": "",
"structured_output": structured_output,
"can_answer": True,
"status": "complete",
"failure": None,
"reason": reason,
}
def _incomplete_result(
requirement: TaskRequirement,
missing: list[str],
repair_instruction: str,
) -> dict[str, Any]:
return {
"complete": False,
"task_type": requirement.task_type,
"driver": requirement.driver,
"required_output": requirement.required_output,
"response_field": requirement.response_field,
"missing": missing,
"repair_instruction": repair_instruction,
"structured_output": None,
"can_answer": False,
"status": "blocked",
"failure": {
"missing": missing,
"repair_instruction": repair_instruction,
},
"reason": "Required deterministic tool outputs are missing.",
}
def build_validation_failure_output(
validation: dict[str, Any],
workflow_state: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""Return a ResponseFormatter-compatible failure payload.
The payload intentionally keeps the normal structured-output fields present
but empty, so downstream UI/eval code can treat it as a final formatter
result while still displaying the explicit validation failure.
"""
workflow_state = workflow_state or {}
observations = workflow_state.get("observations") or {}
failed_tools = [
result
for result in observations.get("ase_results", [])
if str(result.get("status") or "").lower() not in {"", "success"}
]
status = "failed" if failed_tools else "blocked"
missing = list(validation.get("missing", []) or [])
failure = {
"status": status,
"task_type": validation.get("task_type"),
"required_output": validation.get("required_output"),
"missing": missing,
"repair_instruction": validation.get("repair_instruction", ""),
"reason": validation.get("reason", "Validation did not pass."),
"failed_tools": failed_tools,
"can_answer": False,
}
return _empty_structured(
_workflow_status=status,
_failure=failure,
)
def format_validation_failure_message(output: dict[str, Any]) -> str:
"""Create a plain-language failure composer answer from validation output."""
failure = output.get("_failure") if isinstance(output, dict) else None
if not isinstance(failure, dict):
return "I cannot provide the final result because validation did not pass."
status = str(failure.get("status") or "blocked")
task_type = failure.get("task_type") or "the requested task"
missing = ", ".join(str(item) for item in failure.get("missing", []) or [])
repair = str(failure.get("repair_instruction") or "").strip()
failed_tools = failure.get("failed_tools") or []
lines = [
"I cannot provide the final scientific result yet.",
f"Workflow status: {status}.",
f"Task: {task_type}.",
]
if missing:
lines.append(f"Missing required evidence: {missing}.")
if failed_tools:
latest = failed_tools[-1]
reason = latest.get("message") or latest.get("error_type") or "tool failed"
lines.append(f"Latest failed tool: run_ase - {reason}")
if repair:
lines.append(f"Next step: {repair}")
lines.append("No final numerical answer was composed from incomplete artifacts.")
return "\n\n".join(lines)
def _repair_smiles() -> str:
return "Call molecule_name_to_smiles for the requested molecule before answering."
def _repair_identity_clarification(identity_issue: dict[str, Any]) -> str:
name = identity_issue.get("input_name") or "the requested chemical"
warning = identity_issue.get("warning") or (
"The resolver marked this identity as ambiguous or mixture-like."
)
candidates = identity_issue.get("candidates") or []
candidate_hint = ""
if isinstance(candidates, list) and candidates:
top = candidates[0]
if isinstance(top, dict):
candidate_hint = (
f" Top PubChem candidate: {top.get('iupac_name') or top.get('smiles')} "
f"(credibility score {top.get('credibility_score')})."
)
return (
f"Clarify molecule identity before generating coordinates or running ASE. "
f"{name!r} is not a reliable single-molecule target as written. {warning}"
f"{candidate_hint} Ask which component/composition to model, or proceed only "
"if the user explicitly accepts a named representative molecule such as an "
"active ingredient. State that assumption in the final answer."
)
def _repair_run_ase(
driver: str,
failed_ase: Optional[dict[str, Any]] = None,
calculator_policy: Optional[dict[str, Any]] = None,
) -> str:
failure_prefix = ""
if failed_ase:
error_type = failed_ase.get("error_type") or "UnknownError"
message = failed_ase.get("message") or "No error message returned."
failure_prefix = (
f"Previous run_ase failed with {error_type}: {message}. "
"Do not count the failed run as satisfying the requested property. "
)
if error_type == "FileNotFoundError":
failure_prefix += (
"The input coordinate file was missing; generate or reuse a real "
"coordinate artifact before retrying run_ase. "
)
elif driver == "ir":
failure_prefix += (
"For IR/FTIR, if the calculator cannot provide IR intensities, "
"retry with a suitable calculator such as TBLite/xTB when available, "
"or ask the human which calculator to use. "
)
calculator_hint = ""
if calculator_policy:
requested = calculator_policy.get("explicit_request")
preferred = calculator_policy.get("preferred")
if requested:
calculator_hint = (
f" Use the user-requested calculator "
f"{_calculator_payload_hint(requested)}."
)
elif calculator_policy.get("enforce") and preferred:
calculator_hint = (
f" Because the user did not explicitly request a calculator, "
f"include calculator {_calculator_payload_hint(preferred)} "
"when it is available."
)
return (
failure_prefix
+ "Follow the deterministic tool order: if the query names a molecule, "
"first call molecule_name_to_smiles for that exact name and use the "
"returned SMILES; if the query already provides a SMILES, use it "
"directly. Then call smiles_to_coordinate_file, then call run_ase with "
f"driver='{driver}'."
f"{calculator_hint} Do not invent SMILES strings and do not answer "
"until the required run_ase output exists."
)