Merge branch 'feat/recursive-goals-override' into 'dev'
feat: add recursive goal mapping to UserInterruptAgent See merge request ics/sp/2025/n25b/pepperplus-cb!46
This commit was merged in pull request #46.
This commit is contained in:
@@ -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.agent_system import InternalMessage
|
||||||
from control_backend.core.config import settings
|
from control_backend.core.config import settings
|
||||||
from control_backend.schemas.belief_message import Belief, BeliefMessage
|
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 (
|
from control_backend.schemas.ri_message import (
|
||||||
GestureCommand,
|
GestureCommand,
|
||||||
PauseCommand,
|
PauseCommand,
|
||||||
@@ -246,6 +246,16 @@ class UserInterruptAgent(BaseAgent):
|
|||||||
self._cond_norm_map = {}
|
self._cond_norm_map = {}
|
||||||
self._cond_norm_reverse_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)
|
||||||
|
|
||||||
|
for step in goal.plan.steps:
|
||||||
|
if isinstance(step, Goal):
|
||||||
|
_register_goal(step)
|
||||||
|
|
||||||
for phase in program.phases:
|
for phase in program.phases:
|
||||||
for trigger in phase.triggers:
|
for trigger in phase.triggers:
|
||||||
slug = AgentSpeakGenerator.slugify(trigger)
|
slug = AgentSpeakGenerator.slugify(trigger)
|
||||||
@@ -253,8 +263,7 @@ class UserInterruptAgent(BaseAgent):
|
|||||||
self._trigger_reverse_map[slug] = str(trigger.id)
|
self._trigger_reverse_map[slug] = str(trigger.id)
|
||||||
|
|
||||||
for goal in phase.goals:
|
for goal in phase.goals:
|
||||||
self._goal_map[str(goal.id)] = AgentSpeakGenerator.slugify(goal)
|
_register_goal(goal)
|
||||||
self._goal_reverse_map[AgentSpeakGenerator.slugify(goal)] = str(goal.id)
|
|
||||||
|
|
||||||
for goal, id in self._goal_reverse_map.items():
|
for goal, id in self._goal_reverse_map.items():
|
||||||
self.logger.debug(f"Goal mapping: UI ID {goal} -> {id}")
|
self.logger.debug(f"Goal mapping: UI ID {goal} -> {id}")
|
||||||
|
|||||||
@@ -527,3 +527,242 @@ async def test_send_experiment_control_unknown(agent):
|
|||||||
agent.send.assert_awaited()
|
agent.send.assert_awaited()
|
||||||
msg = agent.send.call_args[0][0]
|
msg = agent.send.call_args[0][0]
|
||||||
assert msg.thread == ""
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_setup(agent):
|
||||||
|
"""Test the setup method initializes sockets correctly."""
|
||||||
|
with patch("control_backend.agents.user_interrupt.user_interrupt_agent.Context") as MockContext:
|
||||||
|
mock_ctx_instance = MagicMock()
|
||||||
|
MockContext.instance.return_value = mock_ctx_instance
|
||||||
|
|
||||||
|
mock_sub = MagicMock()
|
||||||
|
mock_pub = MagicMock()
|
||||||
|
mock_ctx_instance.socket.side_effect = [mock_sub, mock_pub]
|
||||||
|
|
||||||
|
# MOCK add_behavior so we don't rely on internal attributes
|
||||||
|
agent.add_behavior = MagicMock()
|
||||||
|
|
||||||
|
await agent.setup()
|
||||||
|
|
||||||
|
# Check sockets
|
||||||
|
mock_sub.connect.assert_called_with(settings.zmq_settings.internal_sub_address)
|
||||||
|
mock_pub.connect.assert_called_with(settings.zmq_settings.internal_pub_address)
|
||||||
|
|
||||||
|
# Verify add_behavior was called
|
||||||
|
agent.add_behavior.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_receive_loop_advanced_scenarios(agent):
|
||||||
|
"""
|
||||||
|
Covers:
|
||||||
|
- JSONDecodeError (lines 86-88)
|
||||||
|
- Override: Trigger found (lines 108-109)
|
||||||
|
- Override: Norm found (lines 114-115)
|
||||||
|
- Override: Nothing found (line 134)
|
||||||
|
- Override Unachieve: Success & Fail (lines 136-145)
|
||||||
|
- Pause: Context true/false logs (lines 150-157)
|
||||||
|
- Next Phase (line 160)
|
||||||
|
"""
|
||||||
|
# 1. Setup Data Maps
|
||||||
|
agent._trigger_map["101"] = "trigger_slug"
|
||||||
|
agent._cond_norm_map["202"] = "norm_slug"
|
||||||
|
|
||||||
|
# 2. Define Payloads
|
||||||
|
# A. Invalid JSON
|
||||||
|
bad_json = b"INVALID{JSON"
|
||||||
|
|
||||||
|
# B. Override -> Trigger
|
||||||
|
override_trigger = json.dumps({"type": "override", "context": "101"}).encode()
|
||||||
|
|
||||||
|
# C. Override -> Norm
|
||||||
|
override_norm = json.dumps({"type": "override", "context": "202"}).encode()
|
||||||
|
|
||||||
|
# D. Override -> Unknown
|
||||||
|
override_fail = json.dumps({"type": "override", "context": "999"}).encode()
|
||||||
|
|
||||||
|
# E. Unachieve -> Success
|
||||||
|
unachieve_success = json.dumps({"type": "override_unachieve", "context": "202"}).encode()
|
||||||
|
|
||||||
|
# F. Unachieve -> Fail
|
||||||
|
unachieve_fail = json.dumps({"type": "override_unachieve", "context": "999"}).encode()
|
||||||
|
|
||||||
|
# G. Pause (True)
|
||||||
|
pause_true = json.dumps({"type": "pause", "context": "true"}).encode()
|
||||||
|
|
||||||
|
# H. Pause (False/Resume)
|
||||||
|
pause_false = json.dumps({"type": "pause", "context": ""}).encode()
|
||||||
|
|
||||||
|
# I. Next Phase
|
||||||
|
next_phase = json.dumps({"type": "next_phase", "context": ""}).encode()
|
||||||
|
|
||||||
|
# 3. Setup Socket
|
||||||
|
agent.sub_socket.recv_multipart.side_effect = [
|
||||||
|
(b"topic", bad_json),
|
||||||
|
(b"topic", override_trigger),
|
||||||
|
(b"topic", override_norm),
|
||||||
|
(b"topic", override_fail),
|
||||||
|
(b"topic", unachieve_success),
|
||||||
|
(b"topic", unachieve_fail),
|
||||||
|
(b"topic", pause_true),
|
||||||
|
(b"topic", pause_false),
|
||||||
|
(b"topic", next_phase),
|
||||||
|
asyncio.CancelledError, # End loop
|
||||||
|
]
|
||||||
|
|
||||||
|
# Mock internal helpers to verify calls
|
||||||
|
agent._send_to_bdi = AsyncMock()
|
||||||
|
agent._send_to_bdi_belief = AsyncMock()
|
||||||
|
agent._send_pause_command = AsyncMock()
|
||||||
|
agent._send_experiment_control_to_bdi_core = AsyncMock()
|
||||||
|
|
||||||
|
# 4. Run Loop
|
||||||
|
try:
|
||||||
|
await agent._receive_button_event()
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 5. Assertions
|
||||||
|
|
||||||
|
# JSON Error
|
||||||
|
agent.logger.error.assert_called_with("Received invalid JSON payload on topic %s", b"topic")
|
||||||
|
|
||||||
|
# Override Trigger
|
||||||
|
agent._send_to_bdi.assert_awaited_with("force_trigger", "trigger_slug")
|
||||||
|
|
||||||
|
# Override Norm
|
||||||
|
# We expect _send_to_bdi_belief to be called for the norm
|
||||||
|
# Note: The loop calls _send_to_bdi_belief(asl_cond_norm, "cond_norm")
|
||||||
|
agent._send_to_bdi_belief.assert_any_call("norm_slug", "cond_norm")
|
||||||
|
|
||||||
|
# Override Fail (Warning log)
|
||||||
|
agent.logger.warning.assert_any_call("Could not determine which element to override.")
|
||||||
|
|
||||||
|
# Unachieve Success
|
||||||
|
# Loop calls _send_to_bdi_belief(asl_cond_norm, "cond_norm", True)
|
||||||
|
agent._send_to_bdi_belief.assert_any_call("norm_slug", "cond_norm", True)
|
||||||
|
|
||||||
|
# Unachieve Fail
|
||||||
|
agent.logger.warning.assert_any_call("Could not determine which conditional norm to unachieve.")
|
||||||
|
|
||||||
|
# Pause Logic
|
||||||
|
agent._send_pause_command.assert_any_call("true")
|
||||||
|
agent.logger.info.assert_any_call("Sent pause command.")
|
||||||
|
|
||||||
|
# Resume Logic
|
||||||
|
agent._send_pause_command.assert_any_call("")
|
||||||
|
agent.logger.info.assert_any_call("Sent resume command.")
|
||||||
|
|
||||||
|
# Next Phase
|
||||||
|
agent._send_experiment_control_to_bdi_core.assert_awaited_with("next_phase")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handle_message_unknown_thread(agent):
|
||||||
|
"""Test handling of an unknown message thread (lines 213-214)."""
|
||||||
|
msg = InternalMessage(to="me", thread="unknown_thread", body="test")
|
||||||
|
await agent.handle_message(msg)
|
||||||
|
|
||||||
|
agent.logger.debug.assert_called_with(
|
||||||
|
"Received internal message on unhandled thread: unknown_thread"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_to_bdi_belief_edge_cases(agent):
|
||||||
|
"""
|
||||||
|
Covers:
|
||||||
|
- Unknown asl_type warning (lines 326-328)
|
||||||
|
- unachieve=True logic (lines 334-337)
|
||||||
|
"""
|
||||||
|
# 1. Unknown Type
|
||||||
|
await agent._send_to_bdi_belief("slug", "unknown_type")
|
||||||
|
|
||||||
|
agent.logger.warning.assert_called_with("Tried to send belief with unknown type")
|
||||||
|
agent.send.assert_not_called()
|
||||||
|
|
||||||
|
# Reset mock for part 2
|
||||||
|
agent.send.reset_mock()
|
||||||
|
|
||||||
|
# 2. Unachieve = True
|
||||||
|
await agent._send_to_bdi_belief("slug", "cond_norm", unachieve=True)
|
||||||
|
|
||||||
|
agent.send.assert_awaited()
|
||||||
|
sent_msg = agent.send.call_args.args[0]
|
||||||
|
|
||||||
|
# Verify it is a delete operation
|
||||||
|
body_obj = BeliefMessage.model_validate_json(sent_msg.body)
|
||||||
|
|
||||||
|
# Verify 'delete' has content
|
||||||
|
assert body_obj.delete is not None
|
||||||
|
assert len(body_obj.delete) == 1
|
||||||
|
assert body_obj.delete[0].name == "force_slug"
|
||||||
|
|
||||||
|
# Verify 'create' is empty (handling both None and [])
|
||||||
|
assert not body_obj.create
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_send_experiment_control_unknown(agent):
|
||||||
|
"""Test sending an unknown experiment control type (lines 366-367)."""
|
||||||
|
await agent._send_experiment_control_to_bdi_core("invalid_command")
|
||||||
|
|
||||||
|
agent.logger.warning.assert_called_with(
|
||||||
|
"Received unknown experiment control type '%s' to send to BDI Core.", "invalid_command"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure it still sends an empty message (as per code logic, though thread is empty)
|
||||||
|
agent.send.assert_awaited()
|
||||||
|
msg = agent.send.call_args[0][0]
|
||||||
|
assert msg.thread == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user