Source code for neurocore.runtime.blueprint
"""Blueprint parser and validator for NeuroCore.
A blueprint is a YAML file that defines a FlowEngine flow using
skill names (not Python class paths). The runtime resolves skill
names to classes via the SkillRegistry.
Blueprint format:
.. code-block:: yaml
name: "my-flow"
version: "1.0"
description: "Optional description"
components:
- name: memory
type: neuroweave # Skill name from registry
config:
llm_model: "claude-haiku-4-5-20251001"
- name: search
type: web-search
config:
max_results: 10
flow:
type: sequential
steps:
- component: memory
- component: search
"""
from __future__ import annotations
from pathlib import Path
from typing import Any, Literal
import yaml
from pydantic import BaseModel, Field, field_validator, model_validator
from neurocore.errors import BlueprintError
from neurocore.skills.registry import SkillRegistry
class BlueprintComponent(BaseModel):
"""A component definition in a blueprint.
Unlike FlowEngine's ComponentConfig where `type` is a Python class path,
here `type` is a skill name that gets resolved via the SkillRegistry.
Attributes:
name: Instance name (used in flow steps/nodes).
type: Skill name from the SkillRegistry.
config: Blueprint-level config overlay (merged with neurocore.yaml).
"""
name: str
type: str
config: dict[str, Any] = Field(default_factory=dict)
class FlowStep(BaseModel):
"""A step in a sequential or conditional flow."""
component: str
description: str | None = None
condition: str | None = None
on_error: Literal["fail", "skip", "continue"] = "fail"
class FlowGraph(BaseModel):
"""Graph node definition."""
id: str
component: str
description: str | None = None
on_error: Literal["fail", "skip", "continue"] = "fail"
class FlowEdge(BaseModel):
"""Graph edge definition."""
source: str
target: str
port: str | None = None
class FlowDefinition(BaseModel):
"""Flow structure definition."""
type: Literal["sequential", "conditional", "graph"] = "sequential"
settings: dict[str, Any] = Field(default_factory=dict)
steps: list[FlowStep] | None = None
nodes: list[FlowGraph] | None = None
edges: list[FlowEdge] | None = None
@model_validator(mode="after")
def validate_flow_structure(self) -> FlowDefinition:
if self.type in ("sequential", "conditional"):
if not self.steps:
raise ValueError(
f"'{self.type}' flow requires 'steps'"
)
elif self.type == "graph":
if not self.nodes or not self.edges:
raise ValueError(
"Graph flow requires 'nodes' and 'edges' to be defined."
)
return self
class Blueprint(BaseModel):
"""Complete blueprint model.
Represents a parsed blueprint YAML file. Validated structurally
on load; skill name validation happens separately via `validate()`.
Attributes:
name: Human-readable flow name.
version: Blueprint version string.
description: Optional description.
components: List of component definitions (skill references).
flow: Flow definition (sequential, conditional, or graph).
"""
name: str
version: str = "1.0"
description: str | None = None
components: list[BlueprintComponent] = Field(min_length=1)
flow: FlowDefinition
@field_validator("components")
@classmethod
def validate_unique_names(
cls, v: list[BlueprintComponent]
) -> list[BlueprintComponent]:
names = [c.name for c in v]
if len(names) != len(set(names)):
dupes = {n for n in names if names.count(n) > 1}
raise ValueError(f"Duplicate component names: {dupes}")
return v
@model_validator(mode="after")
def validate_step_references(self) -> Blueprint:
"""Ensure all steps/nodes reference defined components."""
component_names = {c.name for c in self.components}
if self.flow.steps:
for step in self.flow.steps:
if step.component not in component_names:
raise ValueError(
f"Step references undefined component: '{step.component}'"
)
if self.flow.nodes:
node_ids = [n.id for n in self.flow.nodes]
if len(node_ids) != len(set(node_ids)):
dupes = {nid for nid in node_ids if node_ids.count(nid) > 1}
raise ValueError(f"Duplicate node IDs: {dupes}")
for node in self.flow.nodes:
if node.component not in component_names:
raise ValueError(
f"Node '{node.id}' references undefined component: "
f"'{node.component}'"
)
if self.flow.edges:
node_id_set = set(node_ids)
for edge in self.flow.edges:
if edge.source not in node_id_set:
raise ValueError(
f"Edge source '{edge.source}' not in nodes"
)
if edge.target not in node_id_set:
raise ValueError(
f"Edge target '{edge.target}' not in nodes"
)
return self
[docs]
def load_blueprint(path: Path) -> Blueprint:
"""Load and parse a blueprint YAML file.
Args:
path: Path to the blueprint YAML file.
Returns:
Parsed Blueprint model.
Raises:
BlueprintError: If the file cannot be read, parsed, or validated.
"""
path = Path(path)
try:
with open(path) as f:
data = yaml.safe_load(f)
except FileNotFoundError:
raise BlueprintError(f"Blueprint file not found: {path}")
except yaml.YAMLError as e:
raise BlueprintError(f"Invalid YAML in blueprint {path}: {e}")
if not isinstance(data, dict):
raise BlueprintError(f"Blueprint must be a YAML mapping, got {type(data).__name__}")
try:
return Blueprint(**data)
except Exception as e:
raise BlueprintError(f"Invalid blueprint {path}: {e}")
[docs]
def validate_blueprint(blueprint: Blueprint, registry: SkillRegistry) -> list[str]:
"""Validate that all skill references in a blueprint can be resolved.
Checks that every `components[].type` matches a registered skill.
Args:
blueprint: Parsed blueprint.
registry: SkillRegistry with discovered skills.
Returns:
List of validation error messages (empty if valid).
"""
errors: list[str] = []
for comp in blueprint.components:
if comp.type not in registry:
available = ", ".join(registry.list_skills()) or "(none)"
errors.append(
f"Component '{comp.name}' references unknown skill '{comp.type}'. "
f"Available: {available}"
)
return errors