All tutorials
Track 25·Data & Types

Nested models and list[Model] as primitive types

A model that holds another model. Both appear under Type Definitions automatically. Plans read nested fields with dot notation and work with list[Recipe] from a search primitive.

intermediate8 min
Video coming soon
Browse this tutorial's folder in tutorials-pygithub.com/OpenSymbolicAI/tutorials-py/tree/main/25-nested-models

Track 24 used a single flat model. The one new thing here: when one model holds another, the framework is aware of both and the plan can reach nested fields with standard dot notation. The same applies to list[Model] -- a primitive that returns a list of models is treated the same way as one that returns a scalar.

Two models, one nested inside the other#

python
# recipes.py
from pydantic import BaseModel
from opensymbolicai.blueprints import PlanExecute
from opensymbolicai.core import primitive


class Nutrition(BaseModel):
    calories: int
    protein_g: float
    carbs_g: float


class Recipe(BaseModel):
    title: str
    servings: int
    nutrition: Nutrition

Nutrition is a field on Recipe. Because get_nutrition returns Nutrition and several other primitives use Recipe, the framework is aware of both.

The agent#

python
class RecipeAgent(PlanExecute):

    @primitive(read_only=True)
    def get_recipe(self, name: str) -> Recipe:
        """Return the Recipe for a given name (case-insensitive)."""
        return _RECIPES[name.lower()]

    @primitive(read_only=True)
    def scale_servings(self, recipe: Recipe, factor: float) -> Recipe:
        """Return a new Recipe scaled to factor x the original serving count."""
        n = recipe.nutrition
        return Recipe(
            title=recipe.title,
            servings=round(recipe.servings * factor),
            nutrition=Nutrition(
                calories=round(n.calories * factor),
                protein_g=round(n.protein_g * factor, 1),
                carbs_g=round(n.carbs_g * factor, 1),
            ),
        )

    @primitive(read_only=True)
    def get_nutrition(self, recipe: Recipe) -> Nutrition:
        """Return the Nutrition data for a recipe."""
        return recipe.nutrition

    @primitive(read_only=True)
    def search_recipes(self, keyword: str) -> list[Recipe]:
        """Return all recipes whose title contains keyword (case-insensitive)."""
        kw = keyword.lower()
        return [r for r in _RECIPES.values() if kw in r.title.lower()]

    @primitive(read_only=True)
    def highest_protein(self, recipes: list[Recipe]) -> Recipe:
        """Return the Recipe with the most protein per serving."""
        return max(recipes, key=lambda r: r.nutrition.protein_g)

    @primitive(read_only=True)
    def fmt_recipe(self, recipe: Recipe) -> str:
        """Format a recipe as a human-readable string."""
        n = recipe.nutrition
        return (
            f"{recipe.title} ({recipe.servings} serving(s)) -- "
            f"{n.calories} kcal, {n.protein_g}g protein, {n.carbs_g}g carbs"
        )

Run three tasks#

python
# main.py
from recipes import RecipeAgent
from opensymbolicai.llm import LLMConfig

TASKS = [
    "How many calories are in a serving of oatmeal?",
    "Scale the pasta recipe to 4 servings and return the formatted result.",
    "Which recipe has the highest protein per serving?",
]

llm = LLMConfig(provider="ollama", model="qwen2.5-coder:7b")

for task in TASKS:
    agent = RecipeAgent(llm=llm)
    result = agent.run(task)
    print(f"Task:   {task}")
    print(f"Result: {result.result}")
    print(f"Plan:")
    for line in result.plan.splitlines():
        print(f"  {line}")
    print()
bash
uv run main.py

Output:

text
Task:   How many calories are in a serving of oatmeal?
Result: 150
Plan:
  oatmeal_recipe = get_recipe("Oatmeal")
  nutrition_data = get_nutrition(oatmeal_recipe)
  calories_per_serving = nutrition_data.calories
  return calories_per_serving

Task:   Scale the pasta recipe to 4 servings and return the formatted result.
Result: Pasta Bolognese (8 serving(s)) -- 1920 kcal, 88.0g protein, 260.0g carbs
Plan:
  pasta_recipe = get_recipe("Pasta")
  scaled_recipe = scale_servings(pasta_recipe, 4)
  formatted_recipe = fmt_recipe(scaled_recipe)
  return formatted_recipe

Task:   Which recipe has the highest protein per serving?
Result: title='Chicken Salad' servings=1 nutrition=Nutrition(calories=320, protein_g=35.0, carbs_g=12.0)
Plan:
  highest_protein_recipe = highest_protein(search_recipes(""))
  return highest_protein_recipe

What to read in each plan#

Task 1 shows nested field access. get_nutrition returns a Nutrition object. The plan then reads .calories off that object. It knows calories is an int from the Nutrition definition.

Task 2 shows a whole model passing through unchanged. get_recipe returns a Recipe. scale_servings takes that Recipe and returns a new one. fmt_recipe takes that result. The model never destructures the object; it threads it through as a variable.

Task 3 shows list[Recipe]. search_recipes returns a list of all recipes when the keyword is empty. highest_protein takes that list and returns the best match. The model treats a list of models the same way it treats any other value: assign it to a variable, pass it to the next primitive.

What to notice#

  • Both models are understood without any registration. Nutrition is never used directly as a primitive param or return in most signatures, but because it appears as a field type on Recipe, the framework picks it up.
  • Dot notation works across nesting levels. A plan could write recipe.nutrition.calories directly. Track 1 used get_nutrition as an intermediate step, but neither is required by the framework.
  • list[Model] is not special. It follows the same rules as list[float] from Track 22: the list lives in the plan namespace and passes between primitives as a variable.