From 2404c847aec1f104268cc0abc0adaad8e4728583 Mon Sep 17 00:00:00 2001
From: Pim Hutting
Date: Tue, 27 Jan 2026 11:25:25 +0100
Subject: [PATCH] feat: added recursive goal mapping and tests
ref: N25B-400
---
.../user_interrupt/user_interrupt_agent.py | 17 +++++--
.../user_interrupt/test_user_interrupt.py | 50 +++++++++++++++++++
2 files changed, 64 insertions(+), 3 deletions(-)
diff --git a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py
index 117f83c..2046564 100644
--- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py
+++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py
@@ -8,7 +8,7 @@ from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator
from control_backend.core.agent_system import InternalMessage
from control_backend.core.config import settings
from control_backend.schemas.belief_message import Belief, BeliefMessage
-from control_backend.schemas.program import ConditionalNorm, Program
+from control_backend.schemas.program import ConditionalNorm, Goal, Program
from control_backend.schemas.ri_message import (
GestureCommand,
PauseCommand,
@@ -246,6 +246,18 @@ class UserInterruptAgent(BaseAgent):
self._cond_norm_map = {}
self._cond_norm_reverse_map = {}
+ def _register_goal(goal: Goal):
+ """Recursively register goals and their subgoals."""
+ slug = AgentSpeakGenerator.slugify(goal)
+ self._goal_map[str(goal.id)] = slug
+ self._goal_reverse_map[slug] = str(goal.id)
+
+ # Recursively check steps for subgoals
+ if goal.plan and goal.plan.steps:
+ for step in goal.plan.steps:
+ if isinstance(step, Goal):
+ _register_goal(step)
+
for phase in program.phases:
for trigger in phase.triggers:
slug = AgentSpeakGenerator.slugify(trigger)
@@ -253,8 +265,7 @@ class UserInterruptAgent(BaseAgent):
self._trigger_reverse_map[slug] = str(trigger.id)
for goal in phase.goals:
- self._goal_map[str(goal.id)] = AgentSpeakGenerator.slugify(goal)
- self._goal_reverse_map[AgentSpeakGenerator.slugify(goal)] = str(goal.id)
+ _register_goal(goal)
for goal, id in self._goal_reverse_map.items():
self.logger.debug(f"Goal mapping: UI ID {goal} -> {id}")
diff --git a/test/unit/agents/user_interrupt/test_user_interrupt.py b/test/unit/agents/user_interrupt/test_user_interrupt.py
index a69a830..9f325f3 100644
--- a/test/unit/agents/user_interrupt/test_user_interrupt.py
+++ b/test/unit/agents/user_interrupt/test_user_interrupt.py
@@ -527,3 +527,53 @@ async def test_send_experiment_control_unknown(agent):
agent.send.assert_awaited()
msg = agent.send.call_args[0][0]
assert msg.thread == ""
+
+
+@pytest.mark.asyncio
+async def test_create_mapping_recursive_goals(agent):
+ """Verify that nested subgoals are correctly registered in the mapping."""
+ import uuid
+
+ # 1. Setup IDs
+ parent_goal_id = uuid.uuid4()
+ child_goal_id = uuid.uuid4()
+
+ # 2. Create the child goal
+ child_goal = Goal(
+ id=child_goal_id,
+ name="child_goal",
+ description="I am a subgoal",
+ plan=Plan(id=uuid.uuid4(), name="p_child", steps=[]),
+ )
+
+ # 3. Create the parent goal and put the child goal inside its plan steps
+ parent_goal = Goal(
+ id=parent_goal_id,
+ name="parent_goal",
+ description="I am a parent",
+ plan=Plan(id=uuid.uuid4(), name="p_parent", steps=[child_goal]), # Nested here
+ )
+
+ # 4. Build the program
+ phase = Phase(
+ id=uuid.uuid4(),
+ name="phase1",
+ norms=[],
+ goals=[parent_goal], # Only the parent is top-level
+ triggers=[],
+ )
+ prog = Program(phases=[phase])
+
+ # 5. Execute mapping
+ msg = InternalMessage(to="me", thread="new_program", body=prog.model_dump_json())
+ await agent.handle_message(msg)
+
+ # 6. Assertions
+ # Check parent
+ assert str(parent_goal_id) in agent._goal_map
+ assert agent._goal_map[str(parent_goal_id)] == "parent_goal"
+
+ # Check child (This confirms the recursion worked)
+ assert str(child_goal_id) in agent._goal_map
+ assert agent._goal_map[str(child_goal_id)] == "child_goal"
+ assert agent._goal_reverse_map["child_goal"] == str(child_goal_id)