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.
Before you start
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#
# 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: NutritionNutrition is a field on Recipe. Because get_nutrition returns Nutrition
and several other primitives use Recipe, the framework is aware of both.
The agent#
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#
# 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()uv run main.pyOutput:
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_recipeWhat 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.
Nutritionis never used directly as a primitive param or return in most signatures, but because it appears as a field type onRecipe, the framework picks it up. - Dot notation works across nesting levels. A plan could write
recipe.nutrition.caloriesdirectly. Track 1 usedget_nutritionas an intermediate step, but neither is required by the framework. list[Model]is not special. It follows the same rules aslist[float]from Track 22: the list lives in the plan namespace and passes between primitives as a variable.