From f7669c021b097f5aa3e786dd0c4410d7fba5ed51 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 13 Jan 2026 17:04:44 +0100 Subject: [PATCH] feat: support force completed goals in semantic belief agent ref: N25B-427 --- .../agents/bdi/agentspeak_generator.py | 3 +- .../agents/bdi/text_belief_extractor_agent.py | 33 +++++++++++++++---- .../user_interrupt/user_interrupt_agent.py | 2 +- src/control_backend/schemas/belief_list.py | 4 +-- src/control_backend/schemas/program.py | 27 +++++++++++---- 5 files changed, 52 insertions(+), 17 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 9ab409d..21dc479 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -18,6 +18,7 @@ from control_backend.agents.bdi.agentspeak_ast import ( TriggerType, ) from control_backend.schemas.program import ( + BaseGoal, BasicNorm, ConditionalNorm, GestureAction, @@ -436,7 +437,7 @@ class AgentSpeakGenerator: @slugify.register @staticmethod - def _(g: Goal) -> str: + def _(g: BaseGoal) -> str: return AgentSpeakGenerator._slugify_str(g.name) @slugify.register diff --git a/src/control_backend/agents/bdi/text_belief_extractor_agent.py b/src/control_backend/agents/bdi/text_belief_extractor_agent.py index ebd9a65..b5fd266 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -12,7 +12,7 @@ from control_backend.schemas.belief_list import BeliefList, GoalList from control_backend.schemas.belief_message import Belief as InternalBelief from control_backend.schemas.belief_message import BeliefMessage from control_backend.schemas.chat_history import ChatHistory, ChatMessage -from control_backend.schemas.program import Goal, SemanticBelief +from control_backend.schemas.program import BaseGoal, SemanticBelief type JSONLike = None | bool | int | float | str | list["JSONLike"] | dict[str, "JSONLike"] @@ -62,6 +62,7 @@ class TextBeliefExtractorAgent(BaseAgent): self.goal_inferrer = GoalAchievementInferrer(self._llm) self._current_beliefs = BeliefState() self._current_goal_completions: dict[str, bool] = {} + self._force_completed_goals: set[BaseGoal] = set() self.conversation = ChatHistory(messages=[]) async def setup(self): @@ -118,13 +119,15 @@ class TextBeliefExtractorAgent(BaseAgent): case "goals": self._handle_goals_message(msg) await self._infer_goal_completions() + case "achieved_goals": + self._handle_goal_achieved_message(msg) case "conversation_history": if msg.body == "reset": - self._reset() + self._reset_phase() case _: self.logger.warning("Received unexpected message from %s", msg.sender) - def _reset(self): + def _reset_phase(self): self.conversation = ChatHistory(messages=[]) self.belief_inferrer.available_beliefs.clear() self._current_beliefs = BeliefState() @@ -158,7 +161,8 @@ class TextBeliefExtractorAgent(BaseAgent): return # Use only goals that can fail, as the others are always assumed to be completed - available_goals = [g for g in goals_list.goals if g.can_fail] + available_goals = {g for g in goals_list.goals if g.can_fail} + available_goals -= self._force_completed_goals self.goal_inferrer.goals = available_goals self.logger.debug( "Received %d failable goals from the program manager: %s", @@ -166,6 +170,23 @@ class TextBeliefExtractorAgent(BaseAgent): ", ".join(g.name for g in available_goals), ) + def _handle_goal_achieved_message(self, msg: InternalMessage): + # NOTE: When goals can be marked unachieved, remember to re-add them to the goal_inferrer + try: + goals_list = GoalList.model_validate_json(msg.body) + except ValidationError: + self.logger.warning( + "Received goal achieved message from the program manager, " + "but it is not a valid list of goals." + ) + return + + for goal in goals_list.goals: + self._force_completed_goals.add(goal) + self._current_goal_completions[f"achieved_{AgentSpeakGenerator.slugify(goal)}"] = True + + self.goal_inferrer.goals -= self._force_completed_goals + async def _user_said(self, text: str): """ Create a belief for the user's full speech. @@ -445,7 +466,7 @@ Respond with a JSON similar to the following, but with the property names as giv class GoalAchievementInferrer(SemanticBeliefInferrer): def __init__(self, llm: TextBeliefExtractorAgent.LLM): super().__init__(llm) - self.goals = [] + self.goals: set[BaseGoal] = set() async def infer_from_conversation(self, conversation: ChatHistory) -> dict[str, bool]: """ @@ -465,7 +486,7 @@ class GoalAchievementInferrer(SemanticBeliefInferrer): for goal, achieved in zip(self.goals, goals_achieved, strict=True) } - async def _infer_goal(self, conversation: ChatHistory, goal: Goal) -> bool: + async def _infer_goal(self, conversation: ChatHistory, goal: BaseGoal) -> bool: prompt = f"""{self._format_conversation(conversation)} Given the above conversation, what has the following goal been achieved? 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 d994121..4f12b34 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -310,7 +310,7 @@ class UserInterruptAgent(BaseAgent): async def _send_to_bdi_belief(self, asl_goal: str): """Send belief to BDI Core""" belief_name = f"achieved_{asl_goal}" - belief = Belief(name=belief_name) + belief = Belief(name=belief_name, arguments=None) self.logger.debug(f"Sending belief to BDI Core: {belief_name}") belief_message = BeliefMessage(create=[belief]) msg = InternalMessage( diff --git a/src/control_backend/schemas/belief_list.py b/src/control_backend/schemas/belief_list.py index b79247d..f3d6818 100644 --- a/src/control_backend/schemas/belief_list.py +++ b/src/control_backend/schemas/belief_list.py @@ -1,7 +1,7 @@ from pydantic import BaseModel +from control_backend.schemas.program import BaseGoal from control_backend.schemas.program import Belief as ProgramBelief -from control_backend.schemas.program import Goal class BeliefList(BaseModel): @@ -16,4 +16,4 @@ class BeliefList(BaseModel): class GoalList(BaseModel): - goals: list[Goal] + goals: list[BaseGoal] diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 3c8c7b4..d04abbb 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -15,6 +15,9 @@ class ProgramElement(BaseModel): name: str id: UUID4 + # To make program elements hashable + model_config = {"frozen": True} + class LogicalOperator(Enum): AND = "AND" @@ -105,23 +108,33 @@ class Plan(ProgramElement): steps: list[PlanElement] -class Goal(ProgramElement): +class BaseGoal(ProgramElement): """ - Represents an objective to be achieved. To reach the goal, we should execute - the corresponding plan. If we can fail to achieve a goal after executing the plan, - for example when the achieving of the goal is dependent on the user's reply, this means - that the achieved status will be set from somewhere else in the program. + Represents an objective to be achieved. This base version does not include a plan to achieve + this goal, and is used in semantic belief extraction. :ivar description: A description of the goal, used to determine if it has been achieved. - :ivar plan: The plan to execute. :ivar can_fail: Whether we can fail to achieve the goal after executing the plan. """ description: str = "" - plan: Plan can_fail: bool = True +class Goal(BaseGoal): + """ + Represents an objective to be achieved. To reach the goal, we should execute the corresponding + plan. It inherits from the BaseGoal a variable `can_fail`, which if true will cause the + completion to be determined based on the conversation. + + Instances of this goal are not hashable because a plan is not hashable. + + :ivar plan: The plan to execute. + """ + + plan: Plan + + type Action = SpeechAction | GestureAction | LLMAction