From d043c543367082aa70db2196fc548d305f0e3a9f Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 16 Dec 2025 10:21:50 +0100 Subject: [PATCH 01/68] refactor: program restructure Also includes some AgentSpeak generation. ref: N25B-376 --- pyproject.toml | 1 + .../agents/bdi/bdi_program_manager.py | 373 ++++++++++++++++-- src/control_backend/schemas/program.py | 204 ++++++++-- uv.lock | 23 ++ 4 files changed, 532 insertions(+), 69 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e57a03c..cdc2ce3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic>=2.12.0", "pydantic-settings>=2.11.0", "python-json-logger>=4.0.0", + "python-slugify>=8.0.4", "pyyaml>=6.0.3", "pyzmq>=27.1.0", "silero-vad>=6.0.0", diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 83dea93..4213cfa 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -1,12 +1,311 @@ import zmq from pydantic import ValidationError +from slugify import slugify from zmq.asyncio import Context from control_backend.agents import BaseAgent -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 Program +from control_backend.schemas.program import ( + Action, + BasicBelief, + BasicNorm, + Belief, + ConditionalNorm, + GestureAction, + Goal, + InferredBelief, + KeywordBelief, + LLMAction, + LogicalOperator, + Phase, + Plan, + Program, + ProgramElement, + SemanticBelief, + SpeechAction, +) + +test_program = Program( + phases=[ + Phase( + norms=[ + BasicNorm(norm="Talk like a pirate"), + ConditionalNorm( + condition=InferredBelief( + left=KeywordBelief(keyword="Arr"), + right=SemanticBelief(description="testing", name="semantic belief"), + operator=LogicalOperator.OR, + name="Talking to a pirate", + ), + norm="Use nautical terms", + ), + ConditionalNorm( + condition=SemanticBelief( + description="We are talking to a child", name="talking to child" + ), + norm="Do not use cuss words", + ), + ], + triggers=[ + # Trigger( + # condition=InferredBelief( + # left=KeywordBelief(keyword="key"), + # right=InferredBelief( + # left=KeywordBelief(keyword="key2"), + # right=SemanticBelief( + # description="Decode this", name="semantic belief 2" + # ), + # operator=LogicalOperator.OR, + # name="test trigger inferred inner", + # ), + # operator=LogicalOperator.OR, + # name="test trigger inferred outer", + # ), + # plan=Plan(steps=[]), + # ) + ], + goals=[ + Goal( + name="Determine user age", + plan=Plan(steps=[LLMAction(goal="Determine the age of the user.")]), + ), + Goal( + name="Find the user's name", + plan=Plan( + steps=[ + Goal( + name="Greet the user", + plan=Plan(steps=[LLMAction(goal="Greet the user.")]), + can_fail=False, + ), + Goal( + name="Ask for name", + plan=Plan(steps=[LLMAction(goal="Obtain the user's name.")]), + ), + ] + ), + ), + Goal(name="Tell a joke", plan=Plan(steps=[LLMAction(goal="Tell a joke.")])), + ], + id=1, + ) + ] +) + + +class AgentSpeakGenerator: + """ + Converts Pydantic representation of behavior programs into AgentSpeak(L) code string. + """ + + def generate(self, program: Program) -> str: + lines = [] + lines.append("") + + lines += self._generate_initial_beliefs(program) + + lines += self._generate_norms(program) + + lines += self._generate_belief_inference(program) + + lines += self._generate_goals(program) + + lines += self._generate_triggers(program) + + return "\n".join(lines) + + def _generate_initial_beliefs(self, program: Program) -> list[str]: + lines = [] + lines.append("// --- Initial beliefs ---") + + lines.append(f"phase({program.phases[0].id}).") + + lines += ["", ""] + + return lines + + def _generate_norms(self, program: Program) -> list[str]: + lines = [] + lines.append("// --- Norms ---") + + for phase in program.phases: + for norm in phase.norms: + if type(norm) is BasicNorm: + lines.append(f"{self._slugify(norm)} :- phase({phase.id}).") + if type(norm) is ConditionalNorm: + lines.append( + f"{self._slugify(norm)} :- phase({phase.id}) & " + f"{self._slugify(norm.condition)}." + ) + + lines += ["", ""] + + return lines + + def _generate_belief_inference(self, program: Program) -> list[str]: + lines = [] + lines.append("// --- Belief inference rules ---") + + for phase in program.phases: + for norm in phase.norms: + if not isinstance(norm, ConditionalNorm): + continue + + lines += self._belief_inference_recursive(norm.condition) + + for trigger in phase.triggers: + lines += self._belief_inference_recursive(trigger.condition) + + lines += ["", ""] + + return lines + + def _belief_inference_recursive(self, belief: Belief) -> list[str]: + lines = [] + + if type(belief) is KeywordBelief: + lines.append( + f"{self._slugify(belief)} :- user_said(Message) & " + f'.substring(Message, "{belief.keyword}", Pos) & Pos >= 0.' + ) + if type(belief) is InferredBelief: + lines.append( + f"{self._slugify(belief)} :- {self._slugify(belief.left)} " + f"{'&' if belief.operator == LogicalOperator.AND else '|'} " + f"{self._slugify(belief.right)}." + ) + lines += self._belief_inference_recursive(belief.left) + lines += self._belief_inference_recursive(belief.right) + + return lines + + def _generate_goals(self, program: Program) -> list[str]: + lines = [] + lines.append("// --- Goals ---") + + for phase in program.phases: + previous_goal: Goal | None = None + for goal in phase.goals: + lines += self._generate_plan_recursive(goal, phase, previous_goal) + previous_goal = goal + + lines += ["", ""] + return lines + + def _generate_plan_recursive( + self, goal: Goal, phase: Phase, previous_goal: Goal | None = None + ) -> list[str]: + lines = [] + lines.append(f"+{self._slugify(goal, include_prefix=True)}") + + # Context + lines.append(f"{' ' * 2}:{' ' * 3}phase({phase.id}) &") + lines.append(f"{' ' * 6}not responded_this_turn &") + lines.append(f"{' ' * 6}not achieved_{self._slugify(goal)} &") + if previous_goal: + lines.append(f"{' ' * 6}achieved_{self._slugify(previous_goal)}") + else: + lines.append(f"{' ' * 6}true") + + extra_goals_to_generate = [] + + steps = goal.plan.steps + first_step = steps[0] + lines.append( + f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" + f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" + ) + if isinstance(first_step, Goal): + extra_goals_to_generate.append(first_step) + + for step in steps[1:-1]: + lines.append(f"{' ' * 6}{self._slugify(step, include_prefix=True)};") + if isinstance(step, Goal): + extra_goals_to_generate.append(step) + + if len(steps) > 1: + last_step = steps[-1] + lines.append( + f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}" + f"{'.' if goal.can_fail else ';'}" + ) + if isinstance(last_step, Goal): + extra_goals_to_generate.append(last_step) + + if not goal.can_fail: + lines.append(f"{' ' * 6}+achieved_{self._slugify(goal)}.") + + lines.append("") + + extra_previous_goal: Goal | None = None + for extra_goal in extra_goals_to_generate: + lines += self._generate_plan_recursive(extra_goal, phase, extra_previous_goal) + extra_previous_goal = extra_goal + + return lines + + def _generate_triggers(self, program: Program) -> list[str]: + lines = [] + lines.append("// --- Triggers ---") + + lines += ["", ""] + return lines + + def _slugify(self, element: ProgramElement, include_prefix: bool = False) -> str: + def base_slugify_call(text: str): + return slugify(text, separator="_", stopwords=["a", "the"]) + + if type(element) is KeywordBelief: + return f'keyword_said("{element.keyword}")' + + if type(element) is SemanticBelief: + name = element.name + return f"semantic_{base_slugify_call(name if name else element.description)}" + + if isinstance(element, BasicNorm): + return f'norm("{element.norm}")' + + if isinstance(element, Goal): + return f"{'!' if include_prefix else ''}{base_slugify_call(element.name)}" + + if isinstance(element, SpeechAction): + return f'.say("{element.text}")' + + if isinstance(element, GestureAction): + return f'.gesture("{element.gesture}")' + + if isinstance(element, LLMAction): + return f'!generate_response_with_goal("{element.goal}")' + + if isinstance(element, Action.__value__): + raise NotImplementedError( + "Have not implemented an ASL string representation for this action." + ) + + if element.name == "": + raise ValueError("Name must be initialized for this type of ProgramElement.") + + return base_slugify_call(element.name) + + def _extract_basic_beliefs_from_program(self, program: Program) -> list[BasicBelief]: + beliefs = [] + + for phase in program.phases: + for norm in phase.norms: + if isinstance(norm, ConditionalNorm): + beliefs += self._extract_basic_beliefs_from_belief(norm.condition) + + for trigger in phase.triggers: + beliefs += self._extract_basic_beliefs_from_belief(trigger.condition) + + return beliefs + + def _extract_basic_beliefs_from_belief(self, belief: Belief) -> list[BasicBelief]: + if isinstance(belief, InferredBelief): + return self._extract_basic_beliefs_from_belief( + belief.left + ) + self._extract_basic_beliefs_from_belief(belief.right) + return [belief] class BDIProgramManager(BaseAgent): @@ -25,40 +324,40 @@ class BDIProgramManager(BaseAgent): super().__init__(**kwargs) self.sub_socket = None - async def _send_to_bdi(self, program: Program): - """ - Convert a received program into BDI beliefs and send them to the BDI Core Agent. - - Currently, it takes the **first phase** of the program and extracts: - - **Norms**: Constraints or rules the agent must follow. - - **Goals**: Objectives the agent must achieve. - - These are sent as a ``BeliefMessage`` with ``replace=True``, meaning they will - overwrite any existing norms/goals of the same name in the BDI agent. - - :param program: The program object received from the API. - """ - first_phase = program.phases[0] - norms_belief = Belief( - name="norms", - arguments=[norm.norm for norm in first_phase.norms], - replace=True, - ) - goals_belief = Belief( - name="goals", - arguments=[goal.description for goal in first_phase.goals], - replace=True, - ) - program_beliefs = BeliefMessage(beliefs=[norms_belief, goals_belief]) - - message = InternalMessage( - to=settings.agent_settings.bdi_core_name, - sender=self.name, - body=program_beliefs.model_dump_json(), - thread="beliefs", - ) - await self.send(message) - self.logger.debug("Sent new norms and goals to the BDI agent.") + # async def _send_to_bdi(self, program: Program): + # """ + # Convert a received program into BDI beliefs and send them to the BDI Core Agent. + # + # Currently, it takes the **first phase** of the program and extracts: + # - **Norms**: Constraints or rules the agent must follow. + # - **Goals**: Objectives the agent must achieve. + # + # These are sent as a ``BeliefMessage`` with ``replace=True``, meaning they will + # overwrite any existing norms/goals of the same name in the BDI agent. + # + # :param program: The program object received from the API. + # """ + # first_phase = program.phases[0] + # norms_belief = Belief( + # name="norms", + # arguments=[norm.norm for norm in first_phase.norms], + # replace=True, + # ) + # goals_belief = Belief( + # name="goals", + # arguments=[goal.description for goal in first_phase.goals], + # replace=True, + # ) + # program_beliefs = BeliefMessage(beliefs=[norms_belief, goals_belief]) + # + # message = InternalMessage( + # to=settings.agent_settings.bdi_core_name, + # sender=self.name, + # body=program_beliefs.model_dump_json(), + # thread="beliefs", + # ) + # await self.send(message) + # self.logger.debug("Sent new norms and goals to the BDI agent.") async def _receive_programs(self): """ diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 28969b9..d02923e 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -1,64 +1,204 @@ +from enum import Enum + from pydantic import BaseModel -class Norm(BaseModel): +class ProgramElement(BaseModel): """ - Represents a behavioral norm. + Represents a basic element of our behavior program. + :ivar name: The researcher-assigned name of the element. :ivar id: Unique identifier. - :ivar label: Human-readable label. - :ivar norm: The actual norm text describing the behavior. """ - id: str - label: str - norm: str + name: str + id: int -class Goal(BaseModel): +class LogicalOperator(Enum): + AND = "AND" + OR = "OR" + + +type Belief = KeywordBelief | SemanticBelief | InferredBelief +type BasicBelief = KeywordBelief | SemanticBelief + + +class KeywordBelief(ProgramElement): """ - Represents an objective to be achieved. + Represents a belief that is set when the user spoken text contains a certain keyword. - :ivar id: Unique identifier. - :ivar label: Human-readable label. - :ivar description: Detailed description of the goal. - :ivar achieved: Status flag indicating if the goal has been met. + :ivar keyword: The keyword on which this belief gets set. """ - id: str - label: str - description: str - achieved: bool - - -class TriggerKeyword(BaseModel): - id: str + name: str = "" + id: int = -1 keyword: str -class KeywordTrigger(BaseModel): - id: str - label: str - type: str - keywords: list[TriggerKeyword] +class SemanticBelief(ProgramElement): + """ + Represents a belief that is set by semantic LLM validation. + + :ivar description: Description of how to form the belief, used by the LLM. + """ + + name: str = "" + id: int = -1 + description: str -class Phase(BaseModel): +class InferredBelief(ProgramElement): + """ + Represents a belief that gets formed by combining two beliefs with a logical AND or OR. + + These beliefs can also be :class:`InferredBelief`, leading to arbitrarily deep nesting. + + :ivar operator: The logical operator to apply. + :ivar left: The left part of the logical expression. + :ivar right: The right part of the logical expression. + """ + + name: str = "" + id: int = -1 + operator: LogicalOperator + left: Belief + right: Belief + + +type Norm = BasicNorm | ConditionalNorm + + +class BasicNorm(ProgramElement): + """ + Represents a behavioral norm. + + :ivar norm: The actual norm text describing the behavior. + :ivar critical: When true, this norm should absolutely not be violated (checked separately). + """ + + name: str = "" + id: int = -1 + norm: str + critical: bool = False + + +class ConditionalNorm(BasicNorm): + """ + Represents a norm that is only active when a condition is met (i.e., a certain belief holds). + + :ivar condition: When to activate this norm. + """ + + name: str = "" + id: int = -1 + condition: Belief + + +type PlanElement = Goal | Action + + +class Plan(ProgramElement): + """ + Represents a list of steps to execute. Each of these steps can be a goal (with its own plan) + or a simple action. + + :ivar steps: The actions or subgoals to execute, in order. + """ + + name: str = "" + id: int = -1 + steps: list[PlanElement] + + +class Goal(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. + + :ivar plan: The plan to execute. + :ivar can_fail: Whether we can fail to achieve the goal after executing the plan. + """ + + id: int = -1 + plan: Plan + can_fail: bool = True + + +type Action = SpeechAction | GestureAction | LLMAction + + +class SpeechAction(ProgramElement): + """ + Represents the action of the robot speaking a literal text. + + :ivar text: The text to speak. + """ + + name: str = "" + id: int = -1 + text: str + + +# TODO: gestures +class Gesture(Enum): + RAISE_HAND = "RAISE_HAND" + + +class GestureAction(ProgramElement): + """ + Represents the action of the robot performing a gesture. + + :ivar gesture: The gesture to perform. + """ + + name: str = "" + id: int = -1 + gesture: Gesture + + +class LLMAction(ProgramElement): + """ + Represents the action of letting an LLM generate a reply based on its chat history + and an additional goal added in the prompt. + + :ivar goal: The extra (temporary) goal to add to the LLM. + """ + + name: str = "" + id: int = -1 + goal: str + + +class Trigger(ProgramElement): + """ + Represents a belief-based trigger. When a belief is set, the corresponding plan is executed. + + :ivar condition: When to activate the trigger. + :ivar plan: The plan to execute. + """ + + name: str = "" + id: int = -1 + condition: Belief + plan: Plan + + +class Phase(ProgramElement): """ A distinct phase within a program, containing norms, goals, and triggers. - :ivar id: Unique identifier. - :ivar label: Human-readable label. :ivar norms: List of norms active in this phase. :ivar goals: List of goals to pursue in this phase. :ivar triggers: List of triggers that define transitions out of this phase. """ - id: str - label: str + name: str = "" norms: list[Norm] goals: list[Goal] - triggers: list[KeywordTrigger] + triggers: list[Trigger] class Program(BaseModel): diff --git a/uv.lock b/uv.lock index ff4b8a7..ce46ceb 100644 --- a/uv.lock +++ b/uv.lock @@ -997,6 +997,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "python-json-logger" }, + { name = "python-slugify" }, { name = "pyyaml" }, { name = "pyzmq" }, { name = "silero-vad" }, @@ -1046,6 +1047,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.12.0" }, { name = "pydantic-settings", specifier = ">=2.11.0" }, { name = "python-json-logger", specifier = ">=4.0.0" }, + { name = "python-slugify", specifier = ">=8.0.4" }, { name = "pyyaml", specifier = ">=6.0.3" }, { name = "pyzmq", specifier = ">=27.1.0" }, { name = "silero-vad", specifier = ">=6.0.0" }, @@ -1341,6 +1343,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/45/58/38b5afbc1a800eeea951b9285d3912613f2603bdf897a4ab0f4bd7f405fc/python_multipart-0.0.20-py3-none-any.whl", hash = "sha256:8a62d3a8335e06589fe01f2a3e178cdcc632f3fbe0d492ad9ee0ec35aab1f104", size = 24546, upload-time = "2024-12-16T19:45:44.423Z" }, ] +[[package]] +name = "python-slugify" +version = "8.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "text-unidecode" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/87/c7/5e1547c44e31da50a460df93af11a535ace568ef89d7a811069ead340c4a/python-slugify-8.0.4.tar.gz", hash = "sha256:59202371d1d05b54a9e7720c5e038f928f45daaffe41dd10822f3907b937c856", size = 10921, upload-time = "2024-02-08T18:32:45.488Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/62/02da182e544a51a5c3ccf4b03ab79df279f9c60c5e82d5e8bec7ca26ac11/python_slugify-8.0.4-py2.py3-none-any.whl", hash = "sha256:276540b79961052b66b7d116620b36518847f52d5fd9e3a70164fc8c50faa6b8", size = 10051, upload-time = "2024-02-08T18:32:43.911Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1864,6 +1878,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5", size = 6299353, upload-time = "2025-04-27T18:04:59.103Z" }, ] +[[package]] +name = "text-unidecode" +version = "1.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/e2/e9a00f0ccb71718418230718b3d900e71a5d16e701a3dae079a21e9cd8f8/text-unidecode-1.3.tar.gz", hash = "sha256:bad6603bb14d279193107714b288be206cac565dfa49aa5b105294dd5c4aab93", size = 76885, upload-time = "2019-08-30T21:36:45.405Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/a5/c0b6468d3824fe3fde30dbb5e1f687b291608f9473681bbf7dabbf5a87d7/text_unidecode-1.3-py2.py3-none-any.whl", hash = "sha256:1311f10e8b895935241623731c2ba64f4c455287888b18189350b67134a822e8", size = 78154, upload-time = "2019-08-30T21:37:03.543Z" }, +] + [[package]] name = "tiktoken" version = "0.12.0" From bab48006981f7a99614690da1fdf1d8e2ffacd4c Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Tue, 16 Dec 2025 12:10:52 +0100 Subject: [PATCH 02/68] feat: add trigger generation ref: N25B-376 --- .../agents/bdi/bdi_program_manager.py | 104 ++++++++++++++---- 1 file changed, 85 insertions(+), 19 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 4213cfa..5b2d484 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -23,6 +23,7 @@ from control_backend.schemas.program import ( ProgramElement, SemanticBelief, SpeechAction, + Trigger, ) test_program = Program( @@ -47,22 +48,30 @@ test_program = Program( ), ], triggers=[ - # Trigger( - # condition=InferredBelief( - # left=KeywordBelief(keyword="key"), - # right=InferredBelief( - # left=KeywordBelief(keyword="key2"), - # right=SemanticBelief( - # description="Decode this", name="semantic belief 2" - # ), - # operator=LogicalOperator.OR, - # name="test trigger inferred inner", - # ), - # operator=LogicalOperator.OR, - # name="test trigger inferred outer", - # ), - # plan=Plan(steps=[]), - # ) + Trigger( + condition=InferredBelief( + left=KeywordBelief(keyword="key"), + right=InferredBelief( + left=KeywordBelief(keyword="key2"), + right=SemanticBelief( + description="Decode this", name="semantic belief 2" + ), + operator=LogicalOperator.OR, + name="test trigger inferred inner", + ), + operator=LogicalOperator.OR, + name="test trigger inferred outer", + ), + plan=Plan( + steps=[ + SpeechAction(text="Testing trigger"), + Goal( + name="Testing trigger", + plan=Plan(steps=[LLMAction(goal="Do something")]), + ), + ] + ), + ) ], goals=[ Goal( @@ -93,6 +102,10 @@ test_program = Program( ) +def do_things(): + print(AgentSpeakGenerator().generate(test_program)) + + class AgentSpeakGenerator: """ Converts Pydantic representation of behavior programs into AgentSpeak(L) code string. @@ -186,13 +199,13 @@ class AgentSpeakGenerator: for phase in program.phases: previous_goal: Goal | None = None for goal in phase.goals: - lines += self._generate_plan_recursive(goal, phase, previous_goal) + lines += self._generate_goal_plan_recursive(goal, phase, previous_goal) previous_goal = goal lines += ["", ""] return lines - def _generate_plan_recursive( + def _generate_goal_plan_recursive( self, goal: Goal, phase: Phase, previous_goal: Goal | None = None ) -> list[str]: lines = [] @@ -210,6 +223,11 @@ class AgentSpeakGenerator: extra_goals_to_generate = [] steps = goal.plan.steps + + if len(steps) == 0: + lines.append(f"{' ' * 2}<-{' ' * 2}true.") + return lines + first_step = steps[0] lines.append( f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" @@ -239,7 +257,7 @@ class AgentSpeakGenerator: extra_previous_goal: Goal | None = None for extra_goal in extra_goals_to_generate: - lines += self._generate_plan_recursive(extra_goal, phase, extra_previous_goal) + lines += self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) extra_previous_goal = extra_goal return lines @@ -248,9 +266,57 @@ class AgentSpeakGenerator: lines = [] lines.append("// --- Triggers ---") + for phase in program.phases: + for trigger in phase.triggers: + lines += self._generate_trigger_plan(trigger, phase) + lines += ["", ""] return lines + def _generate_trigger_plan(self, trigger: Trigger, phase: Phase) -> list[str]: + lines = [] + + belief_name = self._slugify(trigger.condition) + + lines.append(f"+{belief_name}") + lines.append(f"{' ' * 2}:{' ' * 3}phase({phase.id})") + + extra_goals_to_generate = [] + + steps = trigger.plan.steps + + if len(steps) == 0: + lines.append(f"{' ' * 2}<-{' ' * 2}true.") + return lines + + first_step = steps[0] + lines.append( + f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" + f"{'.' if len(steps) == 1 else ';'}" + ) + if isinstance(first_step, Goal): + extra_goals_to_generate.append(first_step) + + for step in steps[1:-1]: + lines.append(f"{' ' * 6}{self._slugify(step, include_prefix=True)};") + if isinstance(step, Goal): + extra_goals_to_generate.append(step) + + if len(steps) > 1: + last_step = steps[-1] + lines.append(f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}.") + if isinstance(last_step, Goal): + extra_goals_to_generate.append(last_step) + + lines.append("") + + extra_previous_goal: Goal | None = None + for extra_goal in extra_goals_to_generate: + lines += self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) + extra_previous_goal = extra_goal + + return lines + def _slugify(self, element: ProgramElement, include_prefix: bool = False) -> str: def base_slugify_call(text: str): return slugify(text, separator="_", stopwords=["a", "the"]) From 4a432a603fbdd2c19690dc253e44ab2e2ffeb715 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Tue, 16 Dec 2025 14:12:04 +0100 Subject: [PATCH 03/68] fix: separate trigger plan generation ref: N25B-376 --- .../agents/bdi/bdi_program_manager.py | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 5b2d484..8f5bf03 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -310,6 +310,54 @@ class AgentSpeakGenerator: lines.append("") + extra_previous_goal: Goal | None = None + for extra_goal in extra_goals_to_generate: + lines += self._generate_trigger_plan_recursive(extra_goal, phase, extra_previous_goal) + extra_previous_goal = extra_goal + + return lines + + def _generate_trigger_plan_recursive( + self, goal: Goal, phase: Phase, previous_goal: Goal | None = None + ) -> list[str]: + lines = [] + lines.append(f"+{self._slugify(goal, include_prefix=True)}") + + extra_goals_to_generate = [] + + steps = goal.plan.steps + + if len(steps) == 0: + lines.append(f"{' ' * 2}<-{' ' * 2}true.") + return lines + + first_step = steps[0] + lines.append( + f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" + f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" + ) + if isinstance(first_step, Goal): + extra_goals_to_generate.append(first_step) + + for step in steps[1:-1]: + lines.append(f"{' ' * 6}{self._slugify(step, include_prefix=True)};") + if isinstance(step, Goal): + extra_goals_to_generate.append(step) + + if len(steps) > 1: + last_step = steps[-1] + lines.append( + f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}" + f"{'.' if goal.can_fail else ';'}" + ) + if isinstance(last_step, Goal): + extra_goals_to_generate.append(last_step) + + if not goal.can_fail: + lines.append(f"{' ' * 6}+achieved_{self._slugify(goal)}.") + + lines.append("") + extra_previous_goal: Goal | None = None for extra_goal in extra_goals_to_generate: lines += self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) @@ -457,3 +505,7 @@ class BDIProgramManager(BaseAgent): self.sub_socket.subscribe("program") self.add_behavior(self._receive_programs()) + + +if __name__ == "__main__": + do_things() From 8cc177041ac5716e48888a3c0a1d8ce38df690c1 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 16 Dec 2025 15:12:22 +0100 Subject: [PATCH 04/68] feat: add a second phase in test_program ref: N25B-376 --- .../agents/bdi/bdi_program_manager.py | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 8f5bf03..2353fcb 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -97,7 +97,67 @@ test_program = Program( Goal(name="Tell a joke", plan=Plan(steps=[LLMAction(goal="Tell a joke.")])), ], id=1, - ) + ), + Phase( + id=2, + norms=[ + BasicNorm(norm="Use very gentle speech."), + ConditionalNorm( + condition=SemanticBelief( + description="We are talking to a child", name="talking to child" + ), + norm="Do not use cuss words", + ), + ], + triggers=[ + Trigger( + condition=InferredBelief( + left=KeywordBelief(keyword="help"), + right=SemanticBelief(description="User is stuck", name="stuck"), + operator=LogicalOperator.OR, + name="help_or_stuck", + ), + plan=Plan( + steps=[ + Goal( + name="Unblock user", + plan=Plan( + steps=[ + LLMAction( + goal="Provide a step-by-step path to " + "resolve the user's issue." + ) + ] + ), + ), + ] + ), + ), + ], + goals=[ + Goal( + name="Clarify intent", + plan=Plan( + steps=[ + LLMAction( + goal="Ask 1-2 targeted questions to clarify the " + "user's intent, then proceed." + ) + ] + ), + ), + Goal( + name="Provide solution", + plan=Plan( + steps=[LLMAction(goal="Deliver a solution to complete the user's goal.")] + ), + ), + Goal( + name="Summarize next steps", + plan=Plan(steps=[LLMAction(goal="Summarize what the user should do next.")]), + ), + ], + ), ] ) From 27f04f09588c21813aeb55100c400a59f5fb4267 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 16 Dec 2025 16:11:01 +0100 Subject: [PATCH 05/68] style: use yield instead of returning arrays ref: N25B-376 --- .../agents/bdi/bdi_program_manager.py | 152 ++++++++---------- 1 file changed, 64 insertions(+), 88 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 2353fcb..0eae52a 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -1,3 +1,5 @@ +from collections.abc import Iterable + import zmq from pydantic import ValidationError from slugify import slugify @@ -187,109 +189,94 @@ class AgentSpeakGenerator: return "\n".join(lines) - def _generate_initial_beliefs(self, program: Program) -> list[str]: - lines = [] - lines.append("// --- Initial beliefs ---") + def _generate_initial_beliefs(self, program: Program) -> Iterable[str]: + yield "// --- Initial beliefs ---" - lines.append(f"phase({program.phases[0].id}).") + yield f"phase({program.phases[0].id})." - lines += ["", ""] + yield from ["", ""] - return lines - - def _generate_norms(self, program: Program) -> list[str]: - lines = [] - lines.append("// --- Norms ---") + def _generate_norms(self, program: Program) -> Iterable[str]: + yield "// --- Norms ---" for phase in program.phases: for norm in phase.norms: if type(norm) is BasicNorm: - lines.append(f"{self._slugify(norm)} :- phase({phase.id}).") + yield f"{self._slugify(norm)} :- phase({phase.id})." if type(norm) is ConditionalNorm: - lines.append( + yield ( f"{self._slugify(norm)} :- phase({phase.id}) & " f"{self._slugify(norm.condition)}." ) - lines += ["", ""] + yield from ["", ""] - return lines - - def _generate_belief_inference(self, program: Program) -> list[str]: - lines = [] - lines.append("// --- Belief inference rules ---") + def _generate_belief_inference(self, program: Program) -> Iterable[str]: + yield "// --- Belief inference rules ---" for phase in program.phases: for norm in phase.norms: if not isinstance(norm, ConditionalNorm): continue - lines += self._belief_inference_recursive(norm.condition) + yield from self._belief_inference_recursive(norm.condition) for trigger in phase.triggers: - lines += self._belief_inference_recursive(trigger.condition) + yield from self._belief_inference_recursive(trigger.condition) - lines += ["", ""] - - return lines - - def _belief_inference_recursive(self, belief: Belief) -> list[str]: - lines = [] + yield from ["", ""] + def _belief_inference_recursive(self, belief: Belief) -> Iterable[str]: if type(belief) is KeywordBelief: - lines.append( + yield ( f"{self._slugify(belief)} :- user_said(Message) & " f'.substring(Message, "{belief.keyword}", Pos) & Pos >= 0.' ) if type(belief) is InferredBelief: - lines.append( + yield ( f"{self._slugify(belief)} :- {self._slugify(belief.left)} " f"{'&' if belief.operator == LogicalOperator.AND else '|'} " f"{self._slugify(belief.right)}." ) - lines += self._belief_inference_recursive(belief.left) - lines += self._belief_inference_recursive(belief.right) - return lines + yield from self._belief_inference_recursive(belief.left) + yield from self._belief_inference_recursive(belief.right) - def _generate_goals(self, program: Program) -> list[str]: - lines = [] - lines.append("// --- Goals ---") + def _generate_goals(self, program: Program) -> Iterable[str]: + yield "// --- Goals ---" for phase in program.phases: previous_goal: Goal | None = None for goal in phase.goals: - lines += self._generate_goal_plan_recursive(goal, phase, previous_goal) + yield from self._generate_goal_plan_recursive(goal, phase, previous_goal) previous_goal = goal - lines += ["", ""] - return lines + yield from ["", ""] def _generate_goal_plan_recursive( self, goal: Goal, phase: Phase, previous_goal: Goal | None = None - ) -> list[str]: - lines = [] - lines.append(f"+{self._slugify(goal, include_prefix=True)}") + ) -> Iterable[str]: + yield f"+{self._slugify(goal, include_prefix=True)}" # Context - lines.append(f"{' ' * 2}:{' ' * 3}phase({phase.id}) &") - lines.append(f"{' ' * 6}not responded_this_turn &") - lines.append(f"{' ' * 6}not achieved_{self._slugify(goal)} &") + yield f"{' ' * 2}:{' ' * 3}phase({phase.id}) &" + yield f"{' ' * 6}not responded_this_turn &" + yield f"{' ' * 6}not achieved_{self._slugify(goal)} &" if previous_goal: - lines.append(f"{' ' * 6}achieved_{self._slugify(previous_goal)}") + yield f"{' ' * 6}achieved_{self._slugify(previous_goal)}" else: - lines.append(f"{' ' * 6}true") + yield f"{' ' * 6}true" extra_goals_to_generate = [] steps = goal.plan.steps if len(steps) == 0: - lines.append(f"{' ' * 2}<-{' ' * 2}true.") - return lines + yield f"{' ' * 2}<-{' ' * 2}true." + return first_step = steps[0] - lines.append( + yield ( f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" ) @@ -297,13 +284,13 @@ class AgentSpeakGenerator: extra_goals_to_generate.append(first_step) for step in steps[1:-1]: - lines.append(f"{' ' * 6}{self._slugify(step, include_prefix=True)};") + yield f"{' ' * 6}{self._slugify(step, include_prefix=True)};" if isinstance(step, Goal): extra_goals_to_generate.append(step) if len(steps) > 1: last_step = steps[-1] - lines.append( + yield ( f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}" f"{'.' if goal.can_fail else ';'}" ) @@ -311,46 +298,40 @@ class AgentSpeakGenerator: extra_goals_to_generate.append(last_step) if not goal.can_fail: - lines.append(f"{' ' * 6}+achieved_{self._slugify(goal)}.") + yield f"{' ' * 6}+achieved_{self._slugify(goal)}." - lines.append("") + yield "" extra_previous_goal: Goal | None = None for extra_goal in extra_goals_to_generate: - lines += self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) + yield from self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) extra_previous_goal = extra_goal - return lines - - def _generate_triggers(self, program: Program) -> list[str]: - lines = [] - lines.append("// --- Triggers ---") + def _generate_triggers(self, program: Program) -> Iterable[str]: + yield "// --- Triggers ---" for phase in program.phases: for trigger in phase.triggers: - lines += self._generate_trigger_plan(trigger, phase) + yield from self._generate_trigger_plan(trigger, phase) - lines += ["", ""] - return lines - - def _generate_trigger_plan(self, trigger: Trigger, phase: Phase) -> list[str]: - lines = [] + yield from ["", ""] + def _generate_trigger_plan(self, trigger: Trigger, phase: Phase) -> Iterable[str]: belief_name = self._slugify(trigger.condition) - lines.append(f"+{belief_name}") - lines.append(f"{' ' * 2}:{' ' * 3}phase({phase.id})") + yield f"+{belief_name}" + yield f"{' ' * 2}:{' ' * 3}phase({phase.id})" extra_goals_to_generate = [] steps = trigger.plan.steps if len(steps) == 0: - lines.append(f"{' ' * 2}<-{' ' * 2}true.") - return lines + yield f"{' ' * 2}<-{' ' * 2}true." + return first_step = steps[0] - lines.append( + yield ( f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" f"{'.' if len(steps) == 1 else ';'}" ) @@ -358,41 +339,38 @@ class AgentSpeakGenerator: extra_goals_to_generate.append(first_step) for step in steps[1:-1]: - lines.append(f"{' ' * 6}{self._slugify(step, include_prefix=True)};") + yield f"{' ' * 6}{self._slugify(step, include_prefix=True)};" if isinstance(step, Goal): extra_goals_to_generate.append(step) if len(steps) > 1: last_step = steps[-1] - lines.append(f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}.") + yield f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}." if isinstance(last_step, Goal): extra_goals_to_generate.append(last_step) - lines.append("") + yield "" extra_previous_goal: Goal | None = None for extra_goal in extra_goals_to_generate: - lines += self._generate_trigger_plan_recursive(extra_goal, phase, extra_previous_goal) + yield from self._generate_trigger_plan_recursive(extra_goal, phase, extra_previous_goal) extra_previous_goal = extra_goal - return lines - def _generate_trigger_plan_recursive( self, goal: Goal, phase: Phase, previous_goal: Goal | None = None - ) -> list[str]: - lines = [] - lines.append(f"+{self._slugify(goal, include_prefix=True)}") + ) -> Iterable[str]: + yield f"+{self._slugify(goal, include_prefix=True)}" extra_goals_to_generate = [] steps = goal.plan.steps if len(steps) == 0: - lines.append(f"{' ' * 2}<-{' ' * 2}true.") - return lines + yield f"{' ' * 2}<-{' ' * 2}true." + return first_step = steps[0] - lines.append( + yield ( f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" ) @@ -400,13 +378,13 @@ class AgentSpeakGenerator: extra_goals_to_generate.append(first_step) for step in steps[1:-1]: - lines.append(f"{' ' * 6}{self._slugify(step, include_prefix=True)};") + yield f"{' ' * 6}{self._slugify(step, include_prefix=True)};" if isinstance(step, Goal): extra_goals_to_generate.append(step) if len(steps) > 1: last_step = steps[-1] - lines.append( + yield ( f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}" f"{'.' if goal.can_fail else ';'}" ) @@ -414,17 +392,15 @@ class AgentSpeakGenerator: extra_goals_to_generate.append(last_step) if not goal.can_fail: - lines.append(f"{' ' * 6}+achieved_{self._slugify(goal)}.") + yield f"{' ' * 6}+achieved_{self._slugify(goal)}." - lines.append("") + yield "" extra_previous_goal: Goal | None = None for extra_goal in extra_goals_to_generate: - lines += self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) + yield from self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) extra_previous_goal = extra_goal - return lines - def _slugify(self, element: ProgramElement, include_prefix: bool = False) -> str: def base_slugify_call(text: str): return slugify(text, separator="_", stopwords=["a", "the"]) From e704ec5ed44b3d645ea86f47715d790c67bda3a1 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Tue, 16 Dec 2025 17:00:32 +0100 Subject: [PATCH 06/68] feat: basic flow and phase transitions ref: N25B-376 --- .../agents/bdi/bdi_program_manager.py | 106 ++++++++++++++---- src/control_backend/schemas/program.py | 2 - 2 files changed, 85 insertions(+), 23 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 0eae52a..11c0b00 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -173,12 +173,20 @@ class AgentSpeakGenerator: Converts Pydantic representation of behavior programs into AgentSpeak(L) code string. """ + arrow_prefix = f"{' ' * 2}<-{' ' * 2}" + colon_prefix = f"{' ' * 2}:{' ' * 3}" + indent_prefix = " " * 6 + def generate(self, program: Program) -> str: lines = [] lines.append("") lines += self._generate_initial_beliefs(program) + lines += self._generate_basic_flow(program) + + lines += self._generate_phase_transitions(program) + lines += self._generate_norms(program) lines += self._generate_belief_inference(program) @@ -192,10 +200,60 @@ class AgentSpeakGenerator: def _generate_initial_beliefs(self, program: Program) -> Iterable[str]: yield "// --- Initial beliefs ---" - yield f"phase({program.phases[0].id})." + yield "phase(start)." yield from ["", ""] + def _generate_basic_flow(self, program: Program) -> Iterable[str]: + yield "// --- Basic flow ---" + + for phase in program.phases: + yield from self._generate_basic_flow_per_phase(phase) + + yield from ["", ""] + + def _generate_basic_flow_per_phase(self, phase: Phase) -> Iterable[str]: + yield "+user_said(Message)" + yield f"{self.colon_prefix}phase({phase.id})" + + goals = phase.goals + if goals: + yield f"{self.arrow_prefix}{self._slugify(goals[0], include_prefix=True)}" + for goal in goals[1:]: + yield f"{self.indent_prefix}{self._slugify(goal, include_prefix=True)}" + + yield f"{self.indent_prefix if goals else self.arrow_prefix}!transition_phase." + + def _generate_phase_transitions(self, program: Program) -> Iterable[str]: + yield "// --- Phase transitions ---" + + if len(program.phases) == 0: + yield from ["", ""] + return + + # TODO: remove outdated things + + for i in range(-1, len(program.phases)): + predecessor = program.phases[i] if i >= 0 else None + successor = program.phases[i + 1] if i < len(program.phases) - 1 else None + yield from self._generate_phase_transition(predecessor, successor) + + yield from self._generate_phase_transition(None, None) # to avoid failing plan + + yield from ["", ""] + + def _generate_phase_transition( + self, phase: Phase | None = None, next_phase: Phase | None = None + ) -> Iterable[str]: + yield "+!transition_phase" + + if phase is None and next_phase is None: # base case true to avoid failing plan + yield f"{self.arrow_prefix}true." + return + + yield f"{self.colon_prefix}phase({phase.id if phase else 'start'})" + yield f"{self.arrow_prefix}-+phase({next_phase.id if next_phase else 'end'})." + def _generate_norms(self, program: Program) -> Iterable[str]: yield "// --- Norms ---" @@ -259,46 +317,49 @@ class AgentSpeakGenerator: yield f"+{self._slugify(goal, include_prefix=True)}" # Context - yield f"{' ' * 2}:{' ' * 3}phase({phase.id}) &" - yield f"{' ' * 6}not responded_this_turn &" - yield f"{' ' * 6}not achieved_{self._slugify(goal)} &" + yield f"{self.colon_prefix}phase({phase.id}) &" + yield f"{self.indent_prefix}not responded_this_turn &" + yield f"{self.indent_prefix}not achieved_{self._slugify(goal)} &" if previous_goal: - yield f"{' ' * 6}achieved_{self._slugify(previous_goal)}" + yield f"{self.indent_prefix}achieved_{self._slugify(previous_goal)}" else: - yield f"{' ' * 6}true" + yield f"{self.indent_prefix}true" extra_goals_to_generate = [] steps = goal.plan.steps if len(steps) == 0: - yield f"{' ' * 2}<-{' ' * 2}true." + yield f"{self.arrow_prefix}true." return first_step = steps[0] yield ( - f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" + f"{self.arrow_prefix}{self._slugify(first_step, include_prefix=True)}" f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" ) if isinstance(first_step, Goal): extra_goals_to_generate.append(first_step) for step in steps[1:-1]: - yield f"{' ' * 6}{self._slugify(step, include_prefix=True)};" + yield f"{self.indent_prefix}{self._slugify(step, include_prefix=True)};" if isinstance(step, Goal): extra_goals_to_generate.append(step) if len(steps) > 1: last_step = steps[-1] yield ( - f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}" + f"{self.indent_prefix}{self._slugify(last_step, include_prefix=True)}" f"{'.' if goal.can_fail else ';'}" ) if isinstance(last_step, Goal): extra_goals_to_generate.append(last_step) if not goal.can_fail: - yield f"{' ' * 6}+achieved_{self._slugify(goal)}." + yield f"{self.indent_prefix}+achieved_{self._slugify(goal)}." + + yield f"+{self._slugify(goal, include_prefix=True)}" + yield f"{self.arrow_prefix}true." yield "" @@ -320,32 +381,32 @@ class AgentSpeakGenerator: belief_name = self._slugify(trigger.condition) yield f"+{belief_name}" - yield f"{' ' * 2}:{' ' * 3}phase({phase.id})" + yield f"{self.colon_prefix}phase({phase.id})" extra_goals_to_generate = [] steps = trigger.plan.steps if len(steps) == 0: - yield f"{' ' * 2}<-{' ' * 2}true." + yield f"{self.arrow_prefix}true." return first_step = steps[0] yield ( - f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" + f"{self.arrow_prefix}{self._slugify(first_step, include_prefix=True)}" f"{'.' if len(steps) == 1 else ';'}" ) if isinstance(first_step, Goal): extra_goals_to_generate.append(first_step) for step in steps[1:-1]: - yield f"{' ' * 6}{self._slugify(step, include_prefix=True)};" + yield f"{self.indent_prefix}{self._slugify(step, include_prefix=True)};" if isinstance(step, Goal): extra_goals_to_generate.append(step) if len(steps) > 1: last_step = steps[-1] - yield f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}." + yield f"{self.indent_prefix}{self._slugify(last_step, include_prefix=True)}." if isinstance(last_step, Goal): extra_goals_to_generate.append(last_step) @@ -366,33 +427,36 @@ class AgentSpeakGenerator: steps = goal.plan.steps if len(steps) == 0: - yield f"{' ' * 2}<-{' ' * 2}true." + yield f"{self.arrow_prefix}true." return first_step = steps[0] yield ( - f"{' ' * 2}<-{' ' * 2}{self._slugify(first_step, include_prefix=True)}" + f"{self.arrow_prefix}{self._slugify(first_step, include_prefix=True)}" f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" ) if isinstance(first_step, Goal): extra_goals_to_generate.append(first_step) for step in steps[1:-1]: - yield f"{' ' * 6}{self._slugify(step, include_prefix=True)};" + yield f"{self.indent_prefix}{self._slugify(step, include_prefix=True)};" if isinstance(step, Goal): extra_goals_to_generate.append(step) if len(steps) > 1: last_step = steps[-1] yield ( - f"{' ' * 6}{self._slugify(last_step, include_prefix=True)}" + f"{self.indent_prefix}{self._slugify(last_step, include_prefix=True)}" f"{'.' if goal.can_fail else ';'}" ) if isinstance(last_step, Goal): extra_goals_to_generate.append(last_step) if not goal.can_fail: - yield f"{' ' * 6}+achieved_{self._slugify(goal)}." + yield f"{self.indent_prefix}+achieved_{self._slugify(goal)}." + + yield f"+{self._slugify(goal, include_prefix=True)}" + yield f"{self.arrow_prefix}true." yield "" diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index d02923e..605694b 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -90,8 +90,6 @@ class ConditionalNorm(BasicNorm): :ivar condition: When to activate this norm. """ - name: str = "" - id: int = -1 condition: Belief From 742e36b94f3d10eebb456145e1b235d17c6f771f Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 17 Dec 2025 14:30:14 +0100 Subject: [PATCH 07/68] chore: non-optional uuid id ref: N25B-376 --- src/control_backend/schemas/program.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 605694b..7c73a6a 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -1,6 +1,6 @@ from enum import Enum -from pydantic import BaseModel +from pydantic import UUID4, BaseModel class ProgramElement(BaseModel): @@ -12,7 +12,7 @@ class ProgramElement(BaseModel): """ name: str - id: int + id: UUID4 class LogicalOperator(Enum): @@ -32,7 +32,6 @@ class KeywordBelief(ProgramElement): """ name: str = "" - id: int = -1 keyword: str @@ -44,7 +43,6 @@ class SemanticBelief(ProgramElement): """ name: str = "" - id: int = -1 description: str @@ -60,7 +58,6 @@ class InferredBelief(ProgramElement): """ name: str = "" - id: int = -1 operator: LogicalOperator left: Belief right: Belief @@ -78,7 +75,6 @@ class BasicNorm(ProgramElement): """ name: str = "" - id: int = -1 norm: str critical: bool = False @@ -105,7 +101,6 @@ class Plan(ProgramElement): """ name: str = "" - id: int = -1 steps: list[PlanElement] @@ -120,7 +115,6 @@ class Goal(ProgramElement): :ivar can_fail: Whether we can fail to achieve the goal after executing the plan. """ - id: int = -1 plan: Plan can_fail: bool = True @@ -136,7 +130,6 @@ class SpeechAction(ProgramElement): """ name: str = "" - id: int = -1 text: str @@ -153,7 +146,6 @@ class GestureAction(ProgramElement): """ name: str = "" - id: int = -1 gesture: Gesture @@ -166,7 +158,6 @@ class LLMAction(ProgramElement): """ name: str = "" - id: int = -1 goal: str @@ -179,7 +170,6 @@ class Trigger(ProgramElement): """ name: str = "" - id: int = -1 condition: Belief plan: Plan From 1d36d2e08951e40ded764860d13214e8135f294c Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 17 Dec 2025 15:33:27 +0100 Subject: [PATCH 08/68] feat: (hopefully) better intermediate representation ref: N25B-376 --- src/control_backend/agents/bdi/asl_ast.py | 172 ++++++++++ src/control_backend/agents/bdi/asl_gen.py | 295 ++++++++++++++++++ .../agents/bdi/bdi_program_manager.py | 131 ++++++-- src/control_backend/schemas/program.py | 15 +- 4 files changed, 581 insertions(+), 32 deletions(-) create mode 100644 src/control_backend/agents/bdi/asl_ast.py create mode 100644 src/control_backend/agents/bdi/asl_gen.py diff --git a/src/control_backend/agents/bdi/asl_ast.py b/src/control_backend/agents/bdi/asl_ast.py new file mode 100644 index 0000000..6543b63 --- /dev/null +++ b/src/control_backend/agents/bdi/asl_ast.py @@ -0,0 +1,172 @@ +import typing +from dataclasses import dataclass, field + +# --- Types --- + + +@dataclass +class BeliefLiteral: + """ + Represents a literal or atom. + Example: phase(1), user_said("hello"), ~started + """ + + functor: str + args: list[str] = field(default_factory=list) + negated: bool = False + + def __str__(self): + # In ASL, 'not' is usually for closed-world assumption (prolog style), + # '~' is for explicit negation in beliefs. + # For simplicity in behavior trees, we often use 'not' for conditions. + prefix = "not " if self.negated else "" + if not self.args: + return f"{prefix}{self.functor}" + + # Clean args to ensure strings are quoted if they look like strings, + # but usually the converter handles the quoting of string literals. + args_str = ", ".join(self.args) + return f"{prefix}{self.functor}({args_str})" + + +@dataclass +class GoalLiteral: + name: str + + def __str__(self): + return f"!{self.name}" + + +@dataclass +class ActionLiteral: + """ + Represents a step in a plan body. + Example: .say("Hello") or !achieve_goal + """ + + code: str + + def __str__(self): + return self.code + + +@dataclass +class BinaryOp: + """ + Represents logical operations. + Example: (A & B) | C + """ + + left: "Expression | str" + operator: typing.Literal["&", "|"] + right: "Expression | str" + + def __str__(self): + l_str = str(self.left) + r_str = str(self.right) + + if isinstance(self.left, BinaryOp): + l_str = f"({l_str})" + if isinstance(self.right, BinaryOp): + r_str = f"({r_str})" + + return f"{l_str} {self.operator} {r_str}" + + +Literal = BeliefLiteral | GoalLiteral | ActionLiteral +Expression = Literal | BinaryOp | str + + +@dataclass +class Rule: + """ + Represents an inference rule. + Example: head :- body. + """ + + head: Expression + body: Expression | None = None + + def __str__(self): + if not self.body: + return f"{self.head}." + return f"{self.head} :- {self.body}." + + +@dataclass +class Plan: + """ + Represents a plan. + Syntax: +trigger : context <- body. + """ + + trigger: BeliefLiteral | GoalLiteral + context: list[Expression] = field(default_factory=list) + body: list[ActionLiteral] = field(default_factory=list) + + def __str__(self): + # Indentation settings + INDENT = " " + ARROW = "\n <- " + COLON = "\n : " + + # Build Header + header = f"+{self.trigger}" + if self.context: + ctx_str = f" &\n{INDENT}".join(str(c) for c in self.context) + header += f"{COLON}{ctx_str}" + + # Case 1: Empty body + if not self.body: + return f"{header}." + + # Case 2: Short body (optional optimization, keeping it uniform usually better) + header += ARROW + + lines = [] + # We start the first action on the same line or next line. + # Let's put it on the next line for readability if there are multiple. + + if len(self.body) == 1: + return f"{header}{self.body[0]}." + + # First item + lines.append(f"{header}{self.body[0]};") + # Middle items + for item in self.body[1:-1]: + lines.append(f"{INDENT}{item};") + # Last item + lines.append(f"{INDENT}{self.body[-1]}.") + + return "\n".join(lines) + + +@dataclass +class AgentSpeakFile: + """ + Root element representing the entire generated file. + """ + + initial_beliefs: list[Rule] = field(default_factory=list) + inference_rules: list[Rule] = field(default_factory=list) + plans: list[Plan] = field(default_factory=list) + + def __str__(self): + sections = [] + + if self.initial_beliefs: + sections.append("// --- Initial Beliefs & Facts ---") + sections.extend(str(rule) for rule in self.initial_beliefs) + sections.append("") + + if self.inference_rules: + sections.append("// --- Inference Rules ---") + sections.extend(str(rule) for rule in self.inference_rules) + sections.append("") + + if self.plans: + sections.append("// --- Plans ---") + # Separate plans by a newline for readability + sections.extend(str(plan) + "\n" for plan in self.plans) + + return "\n".join(sections) diff --git a/src/control_backend/agents/bdi/asl_gen.py b/src/control_backend/agents/bdi/asl_gen.py new file mode 100644 index 0000000..f78108a --- /dev/null +++ b/src/control_backend/agents/bdi/asl_gen.py @@ -0,0 +1,295 @@ +from functools import singledispatchmethod + +from slugify import slugify + +# Import the AST we defined above +from control_backend.agents.bdi.asl_ast import ( + ActionLiteral, + AgentSpeakFile, + BeliefLiteral, + BinaryOp, + Expression, + GoalLiteral, + Plan, + Rule, +) +from control_backend.agents.bdi.bdi_program_manager import test_program + +# Import your Pydantic models (adjust import based on your file structure) +from control_backend.schemas.program import ( + Belief, + ConditionalNorm, + GestureAction, + Goal, + InferredBelief, + KeywordBelief, + LLMAction, + LogicalOperator, + Phase, + Program, + ProgramElement, + SemanticBelief, + SpeechAction, +) + + +def do_things(): + print(AgentSpeakGenerator().generate(test_program)) + + +class AgentSpeakGenerator: + """ + Converts a Pydantic Program behavior model into an AgentSpeak(L) AST, + then renders it to a string. + """ + + def generate(self, program: Program) -> str: + asl = AgentSpeakFile() + + self._generate_startup(program, asl) + + for i, phase in enumerate(program.phases): + next_phase = program.phases[i + 1] if i < len(program.phases) - 1 else None + + self._generate_phase_flow(phase, next_phase, asl) + + self._generate_norms(phase, asl) + + self._generate_goals(phase, asl) + + self._generate_triggers(phase, asl) + + return str(asl) + + # --- Section: Startup & Phase Management --- + + def _generate_startup(self, program: Program, asl: AgentSpeakFile): + if not program.phases: + return + + # Initial belief: phase(start). + asl.initial_beliefs.append(Rule(head=BeliefLiteral("phase", ["start"]))) + + # Startup plan: +started : phase(start) <- -+phase(first_id). + asl.plans.append( + Plan( + trigger=BeliefLiteral("started"), + context=[BeliefLiteral("phase", ["start"])], + body=[ActionLiteral("!transition_phase")], + ) + ) + + def _generate_phase_flow(self, phase: Phase, next_phase: Phase | None, asl: AgentSpeakFile): + """Generates the main loop listener and the transition logic for this phase.""" + + # +user_said(Message) : phase(ID) <- !goal1; !goal2; !transition_phase. + goal_actions = [ActionLiteral(f"!{self._slugify(g)}") for g in phase.goals] + goal_actions.append(ActionLiteral("!transition_phase")) + + asl.plans.append( + Plan( + trigger=BeliefLiteral("user_said", ["Message"]), + context=[BeliefLiteral("phase", [str(phase.id)])], + body=goal_actions, + ) + ) + + # +!transition_phase : phase(ID) <- -+phase(NEXT_ID). + next_id = next_phase.id if next_phase else "end" + + asl.plans.append( + Plan( + trigger=GoalLiteral("transition_phase"), + context=[BeliefLiteral("phase", [str(phase.id)])], + body=[ActionLiteral(f"-+phase({next_id})")], + ) + ) + + # --- Section: Norms & Beliefs --- + + def _generate_norms(self, phase: Phase, asl: AgentSpeakFile): + for norm in phase.norms: + norm_slug = f'"{norm.norm}"' + head = BeliefLiteral("norm", [norm_slug]) + + # Base context is the phase + phase_lit = BeliefLiteral("phase", [str(phase.id)]) + + if isinstance(norm, ConditionalNorm): + self._ensure_belief_inference(norm.condition, asl) + + condition_expr = self._belief_to_expr(norm.condition) + body = BinaryOp(phase_lit, "&", condition_expr) + else: + body = phase_lit + + asl.inference_rules.append(Rule(head=head, body=body)) + + def _ensure_belief_inference(self, belief: Belief, asl: AgentSpeakFile): + """ + Recursively adds rules to infer beliefs. + Checks strictly to avoid duplicates if necessary, + though ASL engines often handle redefinition or we can use a set to track processed IDs. + """ + if isinstance(belief, KeywordBelief): + # Rule: keyword_said("word") :- user_said(M) & .substring(M, "word", P) & P >= 0. + kwd_slug = f'"{belief.keyword}"' + head = BeliefLiteral("keyword_said", [kwd_slug]) + + # Avoid duplicates + if any(str(r.head) == str(head) for r in asl.inference_rules): + return + + body = BinaryOp( + BeliefLiteral("user_said", ["Message"]), + "&", + BinaryOp(f".substring(Message, {kwd_slug}, Pos)", "&", "Pos >= 0"), + ) + + asl.inference_rules.append(Rule(head=head, body=body)) + + elif isinstance(belief, InferredBelief): + self._ensure_belief_inference(belief.left, asl) + self._ensure_belief_inference(belief.right, asl) + + slug = self._slugify(belief) + head = BeliefLiteral(slug) + + if any(str(r.head) == str(head) for r in asl.inference_rules): + return + + op_char = "&" if belief.operator == LogicalOperator.AND else "|" + body = BinaryOp( + self._belief_to_expr(belief.left), op_char, self._belief_to_expr(belief.right) + ) + asl.inference_rules.append(Rule(head=head, body=body)) + + def _belief_to_expr(self, belief: Belief) -> Expression: + if isinstance(belief, KeywordBelief): + return BeliefLiteral("keyword_said", [f'"{belief.keyword}"']) + else: + return BeliefLiteral(self._slugify(belief)) + + # --- Section: Goals --- + + def _generate_goals(self, phase: Phase, asl: AgentSpeakFile): + previous_goal: Goal | None = None + for goal in phase.goals: + self._generate_goal_plan_recursive(goal, str(phase.id), previous_goal, asl) + previous_goal = goal + + def _generate_goal_plan_recursive( + self, goal: Goal, phase_id: str, previous_goal: Goal | None, asl: AgentSpeakFile + ): + goal_slug = self._slugify(goal) + + # phase(ID) & not responded_this_turn & not achieved_goal + context = [ + BeliefLiteral("phase", [phase_id]), + BeliefLiteral("responded_this_turn", negated=True), + BeliefLiteral(f"achieved_{goal_slug}", negated=True), + ] + + if previous_goal: + prev_slug = self._slugify(previous_goal) + context.append(BeliefLiteral(f"achieved_{prev_slug}")) + + body_actions = [] + sub_goals_to_process = [] + + for step in goal.plan.steps: + if isinstance(step, Goal): + sub_slug = self._slugify(step) + body_actions.append(ActionLiteral(f"!{sub_slug}")) + sub_goals_to_process.append(step) + elif isinstance(step, SpeechAction): + body_actions.append(ActionLiteral(f'.say("{step.text}")')) + elif isinstance(step, GestureAction): + body_actions.append(ActionLiteral(f'.gesture("{step.gesture}")')) + elif isinstance(step, LLMAction): + body_actions.append(ActionLiteral(f'!generate_response_with_goal("{step.goal}")')) + + # Mark achievement + if not goal.can_fail: + body_actions.append(ActionLiteral(f"+achieved_{goal_slug}")) + + asl.plans.append(Plan(trigger=GoalLiteral(goal_slug), context=context, body=body_actions)) + + prev_sub = None + for sub_goal in sub_goals_to_process: + self._generate_goal_plan_recursive(sub_goal, phase_id, prev_sub, asl) + prev_sub = sub_goal + + # --- Section: Triggers --- + + def _generate_triggers(self, phase: Phase, asl: AgentSpeakFile): + for trigger in phase.triggers: + self._ensure_belief_inference(trigger.condition, asl) + + trigger_belief_slug = self._belief_to_expr(trigger.condition) + + body_actions = [] + sub_goals = [] + + for step in trigger.plan.steps: + if isinstance(step, Goal): + sub_slug = self._slugify(step) + body_actions.append(ActionLiteral(f"!{sub_slug}")) + sub_goals.append(step) + elif isinstance(step, SpeechAction): + body_actions.append(ActionLiteral(f'.say("{step.text}")')) + elif isinstance(step, GestureAction): + body_actions.append( + ActionLiteral(f'.gesture("{step.gesture.type}", "{step.gesture.name}")') + ) + elif isinstance(step, LLMAction): + body_actions.append( + ActionLiteral(f'!generate_response_with_goal("{step.goal}")') + ) + + asl.plans.append( + Plan( + trigger=BeliefLiteral(trigger_belief_slug), + context=[BeliefLiteral("phase", [str(phase.id)])], + body=body_actions, + ) + ) + + # Recurse for triggered goals + prev_sub = None + for sub_goal in sub_goals: + self._generate_goal_plan_recursive(sub_goal, str(phase.id), prev_sub, asl) + prev_sub = sub_goal + + # --- Helpers --- + + @singledispatchmethod + def _slugify(self, element: ProgramElement) -> str: + if element.name: + raise NotImplementedError("Cannot slugify this element.") + return self._slugify_str(element.name) + + @_slugify.register + def _(self, goal: Goal) -> str: + if goal.name: + return self._slugify_str(goal.name) + return f"goal_{goal.id}" + + @_slugify.register + def _(self, kwb: KeywordBelief) -> str: + return f"keyword_said({kwb.keyword})" + + @_slugify.register + def _(self, sb: SemanticBelief) -> str: + return self._slugify_str(sb.description) + + @_slugify.register + def _(self, ib: InferredBelief) -> str: + return self._slugify_str(ib.name) + + def _slugify_str(self, text: str) -> str: + return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"]) + + +if __name__ == "__main__": + do_things() diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 11c0b00..9925cfb 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -1,3 +1,4 @@ +import uuid from collections.abc import Iterable import zmq @@ -32,53 +33,72 @@ test_program = Program( phases=[ Phase( norms=[ - BasicNorm(norm="Talk like a pirate"), + BasicNorm(norm="Talk like a pirate", id=uuid.uuid4()), ConditionalNorm( condition=InferredBelief( - left=KeywordBelief(keyword="Arr"), - right=SemanticBelief(description="testing", name="semantic belief"), + left=KeywordBelief(keyword="Arr", id=uuid.uuid4()), + right=SemanticBelief( + description="testing", name="semantic belief", id=uuid.uuid4() + ), operator=LogicalOperator.OR, name="Talking to a pirate", + id=uuid.uuid4(), ), norm="Use nautical terms", + id=uuid.uuid4(), ), ConditionalNorm( condition=SemanticBelief( - description="We are talking to a child", name="talking to child" + description="We are talking to a child", + name="talking to child", + id=uuid.uuid4(), ), norm="Do not use cuss words", + id=uuid.uuid4(), ), ], triggers=[ Trigger( condition=InferredBelief( - left=KeywordBelief(keyword="key"), + left=KeywordBelief(keyword="key", id=uuid.uuid4()), right=InferredBelief( - left=KeywordBelief(keyword="key2"), + left=KeywordBelief(keyword="key2", id=uuid.uuid4()), right=SemanticBelief( - description="Decode this", name="semantic belief 2" + description="Decode this", name="semantic belief 2", id=uuid.uuid4() ), operator=LogicalOperator.OR, name="test trigger inferred inner", + id=uuid.uuid4(), ), operator=LogicalOperator.OR, name="test trigger inferred outer", + id=uuid.uuid4(), ), plan=Plan( steps=[ - SpeechAction(text="Testing trigger"), + SpeechAction(text="Testing trigger", id=uuid.uuid4()), Goal( name="Testing trigger", - plan=Plan(steps=[LLMAction(goal="Do something")]), + plan=Plan( + steps=[LLMAction(goal="Do something", id=uuid.uuid4())], + id=uuid.uuid4(), + ), + id=uuid.uuid4(), ), - ] + ], + id=uuid.uuid4(), ), + id=uuid.uuid4(), ) ], goals=[ Goal( name="Determine user age", - plan=Plan(steps=[LLMAction(goal="Determine the age of the user.")]), + plan=Plan( + steps=[LLMAction(goal="Determine the age of the user.", id=uuid.uuid4())], + id=uuid.uuid4(), + ), + id=uuid.uuid4(), ), Goal( name="Find the user's name", @@ -86,38 +106,62 @@ test_program = Program( steps=[ Goal( name="Greet the user", - plan=Plan(steps=[LLMAction(goal="Greet the user.")]), + plan=Plan( + steps=[LLMAction(goal="Greet the user.", id=uuid.uuid4())], + id=uuid.uuid4(), + ), can_fail=False, + id=uuid.uuid4(), ), Goal( name="Ask for name", - plan=Plan(steps=[LLMAction(goal="Obtain the user's name.")]), + plan=Plan( + steps=[ + LLMAction(goal="Obtain the user's name.", id=uuid.uuid4()) + ], + id=uuid.uuid4(), + ), + id=uuid.uuid4(), ), - ] + ], + id=uuid.uuid4(), ), + id=uuid.uuid4(), + ), + Goal( + name="Tell a joke", + plan=Plan( + steps=[LLMAction(goal="Tell a joke.", id=uuid.uuid4())], id=uuid.uuid4() + ), + id=uuid.uuid4(), ), - Goal(name="Tell a joke", plan=Plan(steps=[LLMAction(goal="Tell a joke.")])), ], - id=1, + id=uuid.uuid4(), ), Phase( - id=2, + id=uuid.uuid4(), norms=[ - BasicNorm(norm="Use very gentle speech."), + BasicNorm(norm="Use very gentle speech.", id=uuid.uuid4()), ConditionalNorm( condition=SemanticBelief( - description="We are talking to a child", name="talking to child" + description="We are talking to a child", + name="talking to child", + id=uuid.uuid4(), ), norm="Do not use cuss words", + id=uuid.uuid4(), ), ], triggers=[ Trigger( condition=InferredBelief( - left=KeywordBelief(keyword="help"), - right=SemanticBelief(description="User is stuck", name="stuck"), + left=KeywordBelief(keyword="help", id=uuid.uuid4()), + right=SemanticBelief( + description="User is stuck", name="stuck", id=uuid.uuid4() + ), operator=LogicalOperator.OR, name="help_or_stuck", + id=uuid.uuid4(), ), plan=Plan( steps=[ @@ -127,13 +171,18 @@ test_program = Program( steps=[ LLMAction( goal="Provide a step-by-step path to " - "resolve the user's issue." + "resolve the user's issue.", + id=uuid.uuid4(), ) - ] + ], + id=uuid.uuid4(), ), + id=uuid.uuid4(), ), - ] + ], + id=uuid.uuid4(), ), + id=uuid.uuid4(), ), ], goals=[ @@ -143,20 +192,38 @@ test_program = Program( steps=[ LLMAction( goal="Ask 1-2 targeted questions to clarify the " - "user's intent, then proceed." + "user's intent, then proceed.", + id=uuid.uuid4(), ) - ] + ], + id=uuid.uuid4(), ), + id=uuid.uuid4(), ), Goal( name="Provide solution", plan=Plan( - steps=[LLMAction(goal="Deliver a solution to complete the user's goal.")] + steps=[ + LLMAction( + goal="Deliver a solution to complete the user's goal.", + id=uuid.uuid4(), + ) + ], + id=uuid.uuid4(), ), + id=uuid.uuid4(), ), Goal( name="Summarize next steps", - plan=Plan(steps=[LLMAction(goal="Summarize what the user should do next.")]), + plan=Plan( + steps=[ + LLMAction( + goal="Summarize what the user should do next.", id=uuid.uuid4() + ) + ], + id=uuid.uuid4(), + ), + id=uuid.uuid4(), ), ], ), @@ -198,10 +265,16 @@ class AgentSpeakGenerator: return "\n".join(lines) def _generate_initial_beliefs(self, program: Program) -> Iterable[str]: - yield "// --- Initial beliefs ---" + yield "// --- Initial beliefs and agent startup ---" yield "phase(start)." + yield "" + + yield "+started" + yield f"{self.colon_prefix}phase(start)" + yield f"{self.arrow_prefix}phase({program.phases[0].id if program.phases else 'end'})." + yield from ["", ""] def _generate_basic_flow(self, program: Program) -> Iterable[str]: diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 7c73a6a..529a23d 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Literal from pydantic import UUID4, BaseModel @@ -133,9 +134,17 @@ class SpeechAction(ProgramElement): text: str -# TODO: gestures -class Gesture(Enum): - RAISE_HAND = "RAISE_HAND" +class Gesture(BaseModel): + """ + Represents a gesture to be performed. Can be either a single gesture, + or a random gesture from a category (tag). + + :ivar type: The type of the gesture, "tag" or "single". + :ivar name: The name of the single gesture or tag. + """ + + type: Literal["tag", "single"] + name: str class GestureAction(ProgramElement): From 28262eb27e2650a8fe959a8905a6c9ffb659b8d9 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 17 Dec 2025 16:20:37 +0100 Subject: [PATCH 09/68] fix: default case for plans ref: N25B-376 --- src/control_backend/agents/bdi/asl_gen.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/control_backend/agents/bdi/asl_gen.py b/src/control_backend/agents/bdi/asl_gen.py index f78108a..7d0fa77 100644 --- a/src/control_backend/agents/bdi/asl_gen.py +++ b/src/control_backend/agents/bdi/asl_gen.py @@ -214,6 +214,9 @@ class AgentSpeakGenerator: body_actions.append(ActionLiteral(f"+achieved_{goal_slug}")) asl.plans.append(Plan(trigger=GoalLiteral(goal_slug), context=context, body=body_actions)) + asl.plans.append( + Plan(trigger=GoalLiteral(goal_slug), context=[], body=[ActionLiteral("true")]) + ) prev_sub = None for sub_goal in sub_goals_to_process: From f91cec670854a28ce42ea1a3b39a7806e73188c1 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 18 Dec 2025 11:50:16 +0100 Subject: [PATCH 10/68] fix: things in AgentSpeak, add custom actions ref: N25B-376 --- src/control_backend/agents/bdi/asl_gen.py | 105 ++++++++++++++---- .../agents/bdi/bdi_core_agent.py | 82 ++++++++++++-- 2 files changed, 153 insertions(+), 34 deletions(-) diff --git a/src/control_backend/agents/bdi/asl_gen.py b/src/control_backend/agents/bdi/asl_gen.py index 7d0fa77..845b4e3 100644 --- a/src/control_backend/agents/bdi/asl_gen.py +++ b/src/control_backend/agents/bdi/asl_gen.py @@ -1,7 +1,11 @@ +import asyncio +import time from functools import singledispatchmethod from slugify import slugify +from control_backend.agents.bdi import BDICoreAgent + # Import the AST we defined above from control_backend.agents.bdi.asl_ast import ( ActionLiteral, @@ -33,8 +37,20 @@ from control_backend.schemas.program import ( ) -def do_things(): - print(AgentSpeakGenerator().generate(test_program)) +async def do_things(): + res = input("Wanna generate") + if res == "y": + program = AgentSpeakGenerator().generate(test_program) + filename = f"{int(time.time())}.asl" + with open(filename, "w") as f: + f.write(program) + else: + # filename = "0test.asl" + filename = "1766053943.asl" + bdi_agent = BDICoreAgent("BDICoreAgent", filename) + flag = asyncio.Event() + await bdi_agent.start() + await flag.wait() class AgentSpeakGenerator: @@ -59,6 +75,8 @@ class AgentSpeakGenerator: self._generate_triggers(phase, asl) + self._generate_fallbacks(program, asl) + return str(asl) # --- Section: Startup & Phase Management --- @@ -68,14 +86,30 @@ class AgentSpeakGenerator: return # Initial belief: phase(start). - asl.initial_beliefs.append(Rule(head=BeliefLiteral("phase", ["start"]))) + asl.initial_beliefs.append(Rule(head=BeliefLiteral("phase", ['"start"']))) - # Startup plan: +started : phase(start) <- -+phase(first_id). + # Startup plan: +started : phase(start) <- -phase(start); +phase(first_id). asl.plans.append( Plan( trigger=BeliefLiteral("started"), - context=[BeliefLiteral("phase", ["start"])], - body=[ActionLiteral("!transition_phase")], + context=[BeliefLiteral("phase", ['"start"'])], + body=[ + ActionLiteral('-phase("start")'), + ActionLiteral(f'+phase("{program.phases[0].id}")'), + ], + ) + ) + + # Initial plans: + asl.plans.append( + Plan( + trigger=GoalLiteral("generate_response_with_goal(Goal)"), + context=[BeliefLiteral("user_said", ["Message"])], + body=[ + ActionLiteral("+responded_this_turn"), + ActionLiteral(".findall(Norm, norm(Norm), Norms)"), + ActionLiteral(".reply_with_goal(Message, Norms, Goal)"), + ], ) ) @@ -83,25 +117,33 @@ class AgentSpeakGenerator: """Generates the main loop listener and the transition logic for this phase.""" # +user_said(Message) : phase(ID) <- !goal1; !goal2; !transition_phase. - goal_actions = [ActionLiteral(f"!{self._slugify(g)}") for g in phase.goals] + goal_actions = [ActionLiteral("-responded_this_turn")] + goal_actions += [ActionLiteral(f"!{self._slugify(g)}") for g in phase.goals] goal_actions.append(ActionLiteral("!transition_phase")) asl.plans.append( Plan( trigger=BeliefLiteral("user_said", ["Message"]), - context=[BeliefLiteral("phase", [str(phase.id)])], + context=[BeliefLiteral("phase", [f'"{phase.id}"'])], body=goal_actions, ) ) - # +!transition_phase : phase(ID) <- -+phase(NEXT_ID). - next_id = next_phase.id if next_phase else "end" + # +!transition_phase : phase(ID) <- -phase(ID); +(NEXT_ID). + next_id = str(next_phase.id) if next_phase else "end" + + transition_context = [BeliefLiteral("phase", [f'"{phase.id}"'])] + if phase.goals: + transition_context.append(BeliefLiteral(f"achieved_{self._slugify(phase.goals[-1])}")) asl.plans.append( Plan( trigger=GoalLiteral("transition_phase"), - context=[BeliefLiteral("phase", [str(phase.id)])], - body=[ActionLiteral(f"-+phase({next_id})")], + context=transition_context, + body=[ + ActionLiteral(f'-phase("{phase.id}")'), + ActionLiteral(f'+phase("{next_id}")'), + ], ) ) @@ -113,7 +155,7 @@ class AgentSpeakGenerator: head = BeliefLiteral("norm", [norm_slug]) # Base context is the phase - phase_lit = BeliefLiteral("phase", [str(phase.id)]) + phase_lit = BeliefLiteral("phase", [f'"{phase.id}"']) if isinstance(norm, ConditionalNorm): self._ensure_belief_inference(norm.condition, asl) @@ -132,7 +174,7 @@ class AgentSpeakGenerator: though ASL engines often handle redefinition or we can use a set to track processed IDs. """ if isinstance(belief, KeywordBelief): - # Rule: keyword_said("word") :- user_said(M) & .substring(M, "word", P) & P >= 0. + # Rule: keyword_said("word") :- user_said(M) & .substring("word", M, P) & P >= 0. kwd_slug = f'"{belief.keyword}"' head = BeliefLiteral("keyword_said", [kwd_slug]) @@ -143,7 +185,7 @@ class AgentSpeakGenerator: body = BinaryOp( BeliefLiteral("user_said", ["Message"]), "&", - BinaryOp(f".substring(Message, {kwd_slug}, Pos)", "&", "Pos >= 0"), + BinaryOp(f".substring({kwd_slug}, Message, Pos)", "&", "Pos >= 0"), ) asl.inference_rules.append(Rule(head=head, body=body)) @@ -185,7 +227,7 @@ class AgentSpeakGenerator: # phase(ID) & not responded_this_turn & not achieved_goal context = [ - BeliefLiteral("phase", [phase_id]), + BeliefLiteral("phase", [f'"{phase_id}"']), BeliefLiteral("responded_this_turn", negated=True), BeliefLiteral(f"achieved_{goal_slug}", negated=True), ] @@ -214,9 +256,6 @@ class AgentSpeakGenerator: body_actions.append(ActionLiteral(f"+achieved_{goal_slug}")) asl.plans.append(Plan(trigger=GoalLiteral(goal_slug), context=context, body=body_actions)) - asl.plans.append( - Plan(trigger=GoalLiteral(goal_slug), context=[], body=[ActionLiteral("true")]) - ) prev_sub = None for sub_goal in sub_goals_to_process: @@ -253,7 +292,7 @@ class AgentSpeakGenerator: asl.plans.append( Plan( trigger=BeliefLiteral(trigger_belief_slug), - context=[BeliefLiteral("phase", [str(phase.id)])], + context=[BeliefLiteral("phase", [f'"{phase.id}"'])], body=body_actions, ) ) @@ -264,6 +303,28 @@ class AgentSpeakGenerator: self._generate_goal_plan_recursive(sub_goal, str(phase.id), prev_sub, asl) prev_sub = sub_goal + # --- Section: Fallbacks --- + + def _generate_fallbacks(self, program: Program, asl: AgentSpeakFile): + for phase in program.phases: + for goal in phase.goals: + self._generate_goal_fallbacks_recursive(goal, asl) + + asl.plans.append( + Plan(trigger=GoalLiteral("transition_phase"), context=[], body=[ActionLiteral("true")]) + ) + + def _generate_goal_fallbacks_recursive(self, goal: Goal, asl: AgentSpeakFile): + goal_slug = self._slugify(goal) + asl.plans.append( + Plan(trigger=GoalLiteral(goal_slug), context=[], body=[ActionLiteral("true")]) + ) + + for step in goal.plan.steps: + if not isinstance(step, Goal): + continue + self._generate_goal_fallbacks_recursive(step, asl) + # --- Helpers --- @singledispatchmethod @@ -276,7 +337,7 @@ class AgentSpeakGenerator: def _(self, goal: Goal) -> str: if goal.name: return self._slugify_str(goal.name) - return f"goal_{goal.id}" + return f"goal_{goal.id.hex}" @_slugify.register def _(self, kwb: KeywordBelief) -> str: @@ -295,4 +356,4 @@ class AgentSpeakGenerator: if __name__ == "__main__": - do_things() + asyncio.run(do_things()) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index f056e09..9408ff8 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -160,7 +160,7 @@ class BDICoreAgent(BaseAgent): self._remove_all_with_name(belief.name) self._add_belief(belief.name, belief.arguments) - def _add_belief(self, name: str, args: Iterable[str] = []): + def _add_belief(self, name: str, args: list[str] = None): """ Add a single belief to the BDI agent. @@ -168,9 +168,12 @@ class BDICoreAgent(BaseAgent): :param args: Arguments for the belief. """ # new_args = (agentspeak.Literal(arg) for arg in args) # TODO: Eventually support multiple - merged_args = DELIMITER.join(arg for arg in args) - new_args = (agentspeak.Literal(merged_args),) - term = agentspeak.Literal(name, new_args) + if args: + merged_args = DELIMITER.join(arg for arg in args) + new_args = (agentspeak.Literal(merged_args),) + term = agentspeak.Literal(name, new_args) + else: + term = agentspeak.Literal(name) self.bdi_agent.call( agentspeak.Trigger.addition, @@ -238,8 +241,7 @@ class BDICoreAgent(BaseAgent): @self.actions.add(".reply", 3) def _reply(agent: "BDICoreAgent", term, intention): """ - Sends text to the LLM (AgentSpeak action). - Example: .reply("Hello LLM!", "Some norm", "Some goal") + Let the LLM generate a response to a user's utterance with the current norms and goals. """ message_text = agentspeak.grounded(term.args[0], intention.scope) norms = agentspeak.grounded(term.args[1], intention.scope) @@ -252,15 +254,71 @@ class BDICoreAgent(BaseAgent): asyncio.create_task(self._send_to_llm(str(message_text), str(norms), str(goals))) yield - async def _send_to_llm(self, text: str, norms: str = None, goals: str = None): + @self.actions.add(".reply_with_goal", 3) + def _reply_with_goal(agent: "BDICoreAgent", term, intention): + """ + Let the LLM generate a response to a user's utterance with the current norms and a + specific goal. + """ + message_text = agentspeak.grounded(term.args[0], intention.scope) + norms = agentspeak.grounded(term.args[1], intention.scope) + goal = agentspeak.grounded(term.args[2], intention.scope) + + self.logger.debug( + '"reply_with_goal" action called with message=%s, norms=%s, goal=%s', + message_text, + norms, + goal, + ) + # asyncio.create_task(self._send_to_llm(str(message_text), norms, str(goal))) + yield + + @self.actions.add(".say", 1) + def _say(agent: "BDICoreAgent", term, intention): + """ + Make the robot say the given text instantly. + """ + message_text = agentspeak.grounded(term.args[0], intention.scope) + + self.logger.debug('"say" action called with text=%s', message_text) + + # speech_command = SpeechCommand(data=message_text) + # speech_message = InternalMessage( + # to=settings.agent_settings.robot_speech_name, + # sender=settings.agent_settings.bdi_core_name, + # body=speech_command.model_dump_json(), + # ) + # asyncio.create_task(agent.send(speech_message)) + yield + + @self.actions.add(".gesture", 2) + def _gesture(agent: "BDICoreAgent", term, intention): + """ + Make the robot perform the given gesture instantly. + """ + gesture_type = agentspeak.grounded(term.args[0], intention.scope) + gesture_name = agentspeak.grounded(term.args[1], intention.scope) + + self.logger.debug( + '"gesture" action called with type=%s, name=%s', + gesture_type, + gesture_name, + ) + + # gesture = Gesture(type=gesture_type, name=gesture_name) + # gesture_message = InternalMessage( + # to=settings.agent_settings.robot_gesture_name, + # sender=settings.agent_settings.bdi_core_name, + # body=gesture.model_dump_json(), + # ) + # asyncio.create_task(agent.send(gesture_message)) + yield + + async def _send_to_llm(self, text: str, norms: str, goals: str): """ Sends a text query to the LLM agent asynchronously. """ - prompt = LLMPromptMessage( - text=text, - norms=norms.split("\n") if norms else [], - goals=goals.split("\n") if norms else [], - ) + prompt = LLMPromptMessage(text=text, norms=norms.split("\n"), goals=goals.split("\n")) msg = InternalMessage( to=settings.agent_settings.llm_name, sender=self.name, From 756e1f0dc5b59b2e8584b29c98a6ee28737e3227 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 18 Dec 2025 14:33:42 +0100 Subject: [PATCH 11/68] feat: persistent rules and stuff So ugly ref: N25B-376 --- src/control_backend/agents/bdi/asl_ast.py | 35 ++++- src/control_backend/agents/bdi/asl_gen.py | 146 +++++++++++++----- .../agents/bdi/bdi_core_agent.py | 7 +- 3 files changed, 143 insertions(+), 45 deletions(-) diff --git a/src/control_backend/agents/bdi/asl_ast.py b/src/control_backend/agents/bdi/asl_ast.py index 6543b63..104570b 100644 --- a/src/control_backend/agents/bdi/asl_ast.py +++ b/src/control_backend/agents/bdi/asl_ast.py @@ -93,6 +93,33 @@ class Rule: return f"{self.head} :- {self.body}." +@dataclass +class PersistentRule: + """ + Represents an inference rule, where the inferred belief is persistent when formed. + """ + + head: Expression + body: Expression + + def __str__(self): + if not self.body: + raise Exception("Rule without body should not be persistent.") + + lines = [] + + if isinstance(self.body, BinaryOp): + lines.append(f"+{self.body.left}") + if self.body.operator == "&": + lines.append(f" : {self.body.right}") + lines.append(f" <- +{self.head}.") + if self.body.operator == "|": + lines.append(f"+{self.body.right}") + lines.append(f" <- +{self.head}.") + + return "\n".join(lines) + + @dataclass class Plan: """ @@ -148,7 +175,7 @@ class AgentSpeakFile: """ initial_beliefs: list[Rule] = field(default_factory=list) - inference_rules: list[Rule] = field(default_factory=list) + inference_rules: list[Rule | PersistentRule] = field(default_factory=list) plans: list[Plan] = field(default_factory=list) def __str__(self): @@ -161,7 +188,11 @@ class AgentSpeakFile: if self.inference_rules: sections.append("// --- Inference Rules ---") - sections.extend(str(rule) for rule in self.inference_rules) + sections.extend(str(rule) for rule in self.inference_rules if isinstance(rule, Rule)) + sections.append("") + sections.extend( + str(rule) for rule in self.inference_rules if isinstance(rule, PersistentRule) + ) sections.append("") if self.plans: diff --git a/src/control_backend/agents/bdi/asl_gen.py b/src/control_backend/agents/bdi/asl_gen.py index 845b4e3..8233a36 100644 --- a/src/control_backend/agents/bdi/asl_gen.py +++ b/src/control_backend/agents/bdi/asl_gen.py @@ -5,8 +5,6 @@ from functools import singledispatchmethod from slugify import slugify from control_backend.agents.bdi import BDICoreAgent - -# Import the AST we defined above from control_backend.agents.bdi.asl_ast import ( ActionLiteral, AgentSpeakFile, @@ -14,13 +12,13 @@ from control_backend.agents.bdi.asl_ast import ( BinaryOp, Expression, GoalLiteral, + PersistentRule, Plan, Rule, ) from control_backend.agents.bdi.bdi_program_manager import test_program - -# Import your Pydantic models (adjust import based on your file structure) from control_backend.schemas.program import ( + BasicBelief, Belief, ConditionalNorm, GestureAction, @@ -46,13 +44,17 @@ async def do_things(): f.write(program) else: # filename = "0test.asl" - filename = "1766053943.asl" + filename = "1766062491.asl" bdi_agent = BDICoreAgent("BDICoreAgent", filename) flag = asyncio.Event() await bdi_agent.start() await flag.wait() +def do_other_things(): + print(AgentSpeakGenerator().generate(test_program)) + + class AgentSpeakGenerator: """ Converts a Pydantic Program behavior model into an AgentSpeak(L) AST, @@ -118,6 +120,10 @@ class AgentSpeakGenerator: # +user_said(Message) : phase(ID) <- !goal1; !goal2; !transition_phase. goal_actions = [ActionLiteral("-responded_this_turn")] + goal_actions += [ + ActionLiteral(f"!check_{self._slugify_str(keyword)}") + for keyword in self._get_keyword_conditionals(phase) + ] goal_actions += [ActionLiteral(f"!{self._slugify(g)}") for g in phase.goals] goal_actions.append(ActionLiteral("!transition_phase")) @@ -143,10 +149,20 @@ class AgentSpeakGenerator: body=[ ActionLiteral(f'-phase("{phase.id}")'), ActionLiteral(f'+phase("{next_id}")'), + ActionLiteral("user_said(Anything)"), + ActionLiteral("-+user_said(Anything)"), ], ) ) + def _get_keyword_conditionals(self, phase: Phase) -> list[str]: + res = [] + for belief in self._extract_basic_beliefs_from_phase(phase): + if isinstance(belief, KeywordBelief): + res.append(belief.keyword) + + return res + # --- Section: Norms & Beliefs --- def _generate_norms(self, phase: Phase, asl: AgentSpeakFile): @@ -174,21 +190,22 @@ class AgentSpeakGenerator: though ASL engines often handle redefinition or we can use a set to track processed IDs. """ if isinstance(belief, KeywordBelief): - # Rule: keyword_said("word") :- user_said(M) & .substring("word", M, P) & P >= 0. - kwd_slug = f'"{belief.keyword}"' - head = BeliefLiteral("keyword_said", [kwd_slug]) - - # Avoid duplicates - if any(str(r.head) == str(head) for r in asl.inference_rules): - return - - body = BinaryOp( - BeliefLiteral("user_said", ["Message"]), - "&", - BinaryOp(f".substring({kwd_slug}, Message, Pos)", "&", "Pos >= 0"), - ) - - asl.inference_rules.append(Rule(head=head, body=body)) + pass + # # Rule: keyword_said("word") :- user_said(M) & .substring("word", M, P) & P >= 0. + # kwd_slug = f'"{belief.keyword}"' + # head = BeliefLiteral("keyword_said", [kwd_slug]) + # + # # Avoid duplicates + # if any(str(r.head) == str(head) for r in asl.inference_rules): + # return + # + # body = BinaryOp( + # BeliefLiteral("user_said", ["Message"]), + # "&", + # BinaryOp(f".substring({kwd_slug}, Message, Pos)", "&", "Pos >= 0"), + # ) + # + # asl.inference_rules.append(Rule(head=head, body=body)) elif isinstance(belief, InferredBelief): self._ensure_belief_inference(belief.left, asl) @@ -204,7 +221,7 @@ class AgentSpeakGenerator: body = BinaryOp( self._belief_to_expr(belief.left), op_char, self._belief_to_expr(belief.right) ) - asl.inference_rules.append(Rule(head=head, body=body)) + asl.inference_rules.append(PersistentRule(head=head, body=body)) def _belief_to_expr(self, belief: Belief) -> Expression: if isinstance(belief, KeywordBelief): @@ -221,17 +238,26 @@ class AgentSpeakGenerator: previous_goal = goal def _generate_goal_plan_recursive( - self, goal: Goal, phase_id: str, previous_goal: Goal | None, asl: AgentSpeakFile + self, + goal: Goal, + phase_id: str, + previous_goal: Goal | None, + asl: AgentSpeakFile, + responded_needed: bool = True, + can_fail: bool = True, ): goal_slug = self._slugify(goal) # phase(ID) & not responded_this_turn & not achieved_goal context = [ BeliefLiteral("phase", [f'"{phase_id}"']), - BeliefLiteral("responded_this_turn", negated=True), - BeliefLiteral(f"achieved_{goal_slug}", negated=True), ] + if responded_needed: + context.append(BeliefLiteral("responded_this_turn", negated=True)) + if can_fail: + context.append(BeliefLiteral(f"achieved_{goal_slug}", negated=True)) + if previous_goal: prev_slug = self._slugify(previous_goal) context.append(BeliefLiteral(f"achieved_{prev_slug}")) @@ -256,6 +282,9 @@ class AgentSpeakGenerator: body_actions.append(ActionLiteral(f"+achieved_{goal_slug}")) asl.plans.append(Plan(trigger=GoalLiteral(goal_slug), context=context, body=body_actions)) + asl.plans.append( + Plan(trigger=GoalLiteral(goal_slug), context=[], body=[ActionLiteral("true")]) + ) prev_sub = None for sub_goal in sub_goals_to_process: @@ -265,6 +294,28 @@ class AgentSpeakGenerator: # --- Section: Triggers --- def _generate_triggers(self, phase: Phase, asl: AgentSpeakFile): + for keyword in self._get_keyword_conditionals(phase): + asl.plans.append( + Plan( + trigger=GoalLiteral(f"check_{self._slugify_str(keyword)}"), + context=[ + ActionLiteral( + f'user_said(Message) & .substring("{keyword}", Message, Pos) & Pos >= 0' + ) + ], + body=[ + ActionLiteral(f'+keyword_said("{keyword}")'), + ActionLiteral(f'-keyword_said("{keyword}")'), + ], + ) + ) + asl.plans.append( + Plan( + trigger=GoalLiteral(f"check_{self._slugify_str(keyword)}"), + body=[ActionLiteral("true")], + ) + ) + for trigger in phase.triggers: self._ensure_belief_inference(trigger.condition, asl) @@ -300,31 +351,18 @@ class AgentSpeakGenerator: # Recurse for triggered goals prev_sub = None for sub_goal in sub_goals: - self._generate_goal_plan_recursive(sub_goal, str(phase.id), prev_sub, asl) + self._generate_goal_plan_recursive( + sub_goal, str(phase.id), prev_sub, asl, False, False + ) prev_sub = sub_goal # --- Section: Fallbacks --- def _generate_fallbacks(self, program: Program, asl: AgentSpeakFile): - for phase in program.phases: - for goal in phase.goals: - self._generate_goal_fallbacks_recursive(goal, asl) - asl.plans.append( Plan(trigger=GoalLiteral("transition_phase"), context=[], body=[ActionLiteral("true")]) ) - def _generate_goal_fallbacks_recursive(self, goal: Goal, asl: AgentSpeakFile): - goal_slug = self._slugify(goal) - asl.plans.append( - Plan(trigger=GoalLiteral(goal_slug), context=[], body=[ActionLiteral("true")]) - ) - - for step in goal.plan.steps: - if not isinstance(step, Goal): - continue - self._generate_goal_fallbacks_recursive(step, asl) - # --- Helpers --- @singledispatchmethod @@ -354,6 +392,34 @@ class AgentSpeakGenerator: def _slugify_str(self, text: str) -> str: return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"]) + def _extract_basic_beliefs_from_program(self, program: Program) -> list[BasicBelief]: + beliefs = [] + + for phase in program.phases: + beliefs.extend(self._extract_basic_beliefs_from_phase(phase)) + + return beliefs + + def _extract_basic_beliefs_from_phase(self, phase: Phase) -> list[BasicBelief]: + beliefs = [] + + for norm in phase.norms: + if isinstance(norm, ConditionalNorm): + beliefs += self._extract_basic_beliefs_from_belief(norm.condition) + + for trigger in phase.triggers: + beliefs += self._extract_basic_beliefs_from_belief(trigger.condition) + + return beliefs + + def _extract_basic_beliefs_from_belief(self, belief: Belief) -> list[BasicBelief]: + if isinstance(belief, InferredBelief): + return self._extract_basic_beliefs_from_belief( + belief.left + ) + self._extract_basic_beliefs_from_belief(belief.right) + return [belief] + if __name__ == "__main__": asyncio.run(do_things()) + # do_other_things()y diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 9408ff8..8ff271c 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -89,9 +89,9 @@ class BDICoreAgent(BaseAgent): the agent has deferred intentions (deadlines). """ while self._running: - await ( - self._wake_bdi_loop.wait() - ) # gets set whenever there's an update to the belief base + # await ( + # self._wake_bdi_loop.wait() + # ) # gets set whenever there's an update to the belief base # Agent knows when it's expected to have to do its next thing maybe_more_work = True @@ -168,6 +168,7 @@ class BDICoreAgent(BaseAgent): :param args: Arguments for the belief. """ # new_args = (agentspeak.Literal(arg) for arg in args) # TODO: Eventually support multiple + args = args or [] if args: merged_args = DELIMITER.join(arg for arg in args) new_args = (agentspeak.Literal(merged_args),) From 33501093a1ea467acb243263b12ea027c4ff8447 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:09:58 +0100 Subject: [PATCH 12/68] feat: extract semantic beliefs from conversation ref: N25B-380 --- .../agents/bdi/text_belief_extractor_agent.py | 325 ++++++++++++++++-- src/control_backend/agents/llm/llm_agent.py | 22 +- src/control_backend/core/config.py | 11 + src/control_backend/schemas/chat_history.py | 10 + src/control_backend/schemas/program.py | 203 +++++++++-- 5 files changed, 508 insertions(+), 63 deletions(-) create mode 100644 src/control_backend/schemas/chat_history.py 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 0f2db01..0324573 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -1,8 +1,23 @@ +import asyncio import json +import httpx +from pydantic import ValidationError +from slugify import slugify + from control_backend.agents.base import BaseAgent from control_backend.core.agent_system import InternalMessage from control_backend.core.config import settings +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 ( + Belief, + ConditionalNorm, + InferredBelief, + Program, + SemanticBelief, +) class TextBeliefExtractorAgent(BaseAgent): @@ -12,46 +27,110 @@ class TextBeliefExtractorAgent(BaseAgent): This agent is responsible for processing raw text (e.g., from speech transcription) and extracting semantic beliefs from it. - In the current demonstration version, it performs a simple wrapping of the user's input - into a ``user_said`` belief. In a full implementation, this agent would likely interact - with an LLM or NLU engine to extract intent, entities, and other structured information. + It uses the available beliefs received from the program manager to try to extract beliefs from a + user's message, sends and updated beliefs to the BDI core, and forms a ``user_said`` belief from + the message itself. """ + def __init__(self, name: str): + super().__init__(name) + self.beliefs = {} + self.available_beliefs = [] + self.conversation = ChatHistory(messages=[]) + async def setup(self): """ Initialize the agent and its resources. """ - self.logger.info("Settting up %s.", self.name) - # Setup LLM belief context if needed (currently demo is just passthrough) - self.beliefs = {"mood": ["X"], "car": ["Y"]} + self.logger.info("Setting up %s.", self.name) async def handle_message(self, msg: InternalMessage): """ - Handle incoming messages, primarily from the Transcription Agent. + Handle incoming messages. Expect messages from the Transcriber agent, LLM agent, and the + Program manager agent. - :param msg: The received message containing transcribed text. + :param msg: The received message. """ sender = msg.sender - if sender == settings.agent_settings.transcription_name: - self.logger.debug("Received text from transcriber: %s", msg.body) - await self._process_transcription_demo(msg.body) - else: - self.logger.info("Discarding message from %s", sender) - async def _process_transcription_demo(self, txt: str): + match sender: + case settings.agent_settings.transcription_name: + self.logger.debug("Received text from transcriber: %s", msg.body) + self._apply_conversation_message(ChatMessage(role="user", content=msg.body)) + await self._infer_new_beliefs() + await self._user_said(msg.body) + case settings.agent_settings.llm_name: + self.logger.debug("Received text from LLM: %s", msg.body) + self._apply_conversation_message(ChatMessage(role="assistant", content=msg.body)) + case settings.agent_settings.bdi_program_manager_name: + self._handle_program_manager_message(msg) + case _: + self.logger.info("Discarding message from %s", sender) + return + + def _apply_conversation_message(self, message: ChatMessage): """ - Process the transcribed text and generate beliefs. + Save the chat message to our conversation history, taking into account the conversation + length limit. - **Demo Implementation:** - Currently, this method takes the raw text ``txt`` and wraps it into a belief structure: - ``user_said("txt")``. - - This belief is then sent to the :class:`BDIBeliefCollectorAgent`. - - :param txt: The raw transcribed text string. + :param message: The chat message to add to the conversation history. """ - # For demo, just wrapping user text as user_said belief - belief = {"beliefs": {"user_said": [txt]}, "type": "belief_extraction_text"} + length_limit = settings.behaviour_settings.conversation_history_length_limit + self.conversation.messages = (self.conversation.messages + [message])[-length_limit:] + + def _handle_program_manager_message(self, msg: InternalMessage): + """ + Handle a message from the program manager: extract available beliefs from it. + + :param msg: The received message from the program manager. + """ + try: + program = Program.model_validate_json(msg.body) + except ValidationError: + self.logger.warning( + "Received message from program manager but it is not a valid program." + ) + return + + self.logger.debug("Received a program from the program manager.") + + self.available_beliefs = self._extract_basic_beliefs_from_program(program) + + # TODO Copied from an incomplete version of the program manager. Use that one instead. + @staticmethod + def _extract_basic_beliefs_from_program(program: Program) -> list[SemanticBelief]: + beliefs = [] + + for phase in program.phases: + for norm in phase.norms: + if isinstance(norm, ConditionalNorm): + beliefs += TextBeliefExtractorAgent._extract_basic_beliefs_from_belief( + norm.condition + ) + + for trigger in phase.triggers: + beliefs += TextBeliefExtractorAgent._extract_basic_beliefs_from_belief( + trigger.condition + ) + + return beliefs + + # TODO Copied from an incomplete version of the program manager. Use that one instead. + @staticmethod + def _extract_basic_beliefs_from_belief(belief: Belief) -> list[SemanticBelief]: + if isinstance(belief, InferredBelief): + return TextBeliefExtractorAgent._extract_basic_beliefs_from_belief( + belief.left + ) + TextBeliefExtractorAgent._extract_basic_beliefs_from_belief(belief.right) + return [belief] + + async def _user_said(self, text: str): + """ + Create a belief for the user's full speech. + + :param text: User's transcribed text. + """ + belief = {"beliefs": {"user_said": [text]}, "type": "belief_extraction_text"} payload = json.dumps(belief) belief_msg = InternalMessage( @@ -60,6 +139,200 @@ class TextBeliefExtractorAgent(BaseAgent): body=payload, thread="beliefs", ) - await self.send(belief_msg) - self.logger.info("Sent %d beliefs to the belief collector.", len(belief["beliefs"])) + + async def _infer_new_beliefs(self): + """ + Process conversation history to extract beliefs, semantically. Any changed beliefs are sent + to the BDI core. + """ + # Return instantly if there are no beliefs to infer + if not self.available_beliefs: + return + + candidate_beliefs = await self._infer_turn() + new_beliefs: list[InternalBelief] = [] + for belief_key, belief_value in candidate_beliefs.items(): + if belief_value is None: + continue + old_belief_value = self.beliefs.get(belief_key) + # TODO: Do we need this check? Can we send the same beliefs multiple times? + if belief_value == old_belief_value: + continue + self.beliefs[belief_key] = belief_value + new_beliefs.append( + InternalBelief(name=belief_key, arguments=[belief_value], replace=True), + ) + + beliefs_message = InternalMessage( + to=settings.agent_settings.bdi_core_name, + sender=self.name, + body=BeliefMessage(beliefs=new_beliefs).model_dump_json(), + thread="beliefs", + ) + await self.send(beliefs_message) + + @staticmethod + def _split_into_chunks[T](items: list[T], n: int) -> list[list[T]]: + k, m = divmod(len(items), n) + return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] + + async def _infer_turn(self) -> dict: + """ + Process the stored conversation history to extract semantic beliefs. Returns a list of + beliefs that have been set to ``True``, ``False`` or ``None``. + + :return: A dict mapping belief names to a value ``True``, ``False`` or ``None``. + """ + n_parallel = min(settings.llm_settings.n_parallel - 1, len(self.available_beliefs)) + all_beliefs = await asyncio.gather( + *[ + self._infer_beliefs(self.conversation, beliefs) + for beliefs in self._split_into_chunks(self.available_beliefs, n_parallel) + ] + ) + retval = {} + for beliefs in all_beliefs: + if beliefs is None: + continue + retval.update(beliefs) + return retval + + @staticmethod + def _create_belief_schema(belief: SemanticBelief) -> tuple[str, dict]: + # TODO: use real belief names + return belief.name or slugify(belief.description), { + "type": ["boolean", "null"], + "description": belief.description, + } + + @staticmethod + def _create_beliefs_schema(beliefs: list[SemanticBelief]) -> dict: + belief_schemas = [ + TextBeliefExtractorAgent._create_belief_schema(belief) for belief in beliefs + ] + + return { + "type": "object", + "properties": dict(belief_schemas), + "required": [name for name, _ in belief_schemas], + } + + @staticmethod + def _format_message(message: ChatMessage): + return f"{message.role.upper()}:\n{message.content}" + + @staticmethod + def _format_conversation(conversation: ChatHistory): + return "\n\n".join( + [TextBeliefExtractorAgent._format_message(message) for message in conversation.messages] + ) + + @staticmethod + def _format_beliefs(beliefs: list[SemanticBelief]): + # TODO: use real belief names + return "\n".join( + [ + f"- {belief.name or slugify(belief.description)}: {belief.description}" + for belief in beliefs + ] + ) + + async def _infer_beliefs( + self, + conversation: ChatHistory, + beliefs: list[SemanticBelief], + ) -> dict | None: + """ + Infer given beliefs based on the given conversation. + :param conversation: The conversation to infer beliefs from. + :param beliefs: The beliefs to infer. + :return: A dict containing belief names and a boolean whether they hold, or None if the + belief cannot be inferred based on the given conversation. + """ + example = { + "example_belief": True, + } + + prompt = f"""{self._format_conversation(conversation)} + +Given the above conversation, what beliefs can be inferred? +If there is no relevant information about a belief belief, give null. +In case messages conflict, prefer using the most recent messages for inference. + +Choose from the following list of beliefs, formatted as (belief_name, description): +{self._format_beliefs(beliefs)} + +Respond with a JSON similar to the following, but with the property names as given above: +{json.dumps(example, indent=2)} +""" + + schema = self._create_beliefs_schema(beliefs) + + return await self._retry_query_llm(prompt, schema) + + async def _retry_query_llm(self, prompt: str, schema: dict, tries: int = 3) -> dict | None: + """ + Query the LLM with the given prompt and schema, return an instance of a dict conforming + to this schema. Try ``tries`` times, or return None. + + :param prompt: Prompt to be queried. + :param schema: Schema to be queried. + :return: An instance of a dict conforming to this schema, or None if failed. + """ + try_count = 0 + while try_count < tries: + try_count += 1 + + try: + return await self._query_llm(prompt, schema) + except (httpx.HTTPStatusError, json.JSONDecodeError, KeyError) as e: + if try_count < tries: + continue + self.logger.exception( + "Failed to get LLM response after %d tries.", + try_count, + exc_info=e, + ) + + return None + + @staticmethod + async def _query_llm(prompt: str, schema: dict) -> dict: + """ + Query an LLM with the given prompt and schema, return an instance of a dict conforming to + that schema. + + :param prompt: The prompt to be queried. + :param schema: Schema to use during response. + :return: A dict conforming to this schema. + :raises httpx.HTTPStatusError: If the LLM server responded with an error. + :raises json.JSONDecodeError: If the LLM response was not valid JSON. May happen if the + response was cut off early due to length limitations. + :raises KeyError: If the LLM server responded with no error, but the response was invalid. + """ + async with httpx.AsyncClient() as client: + response = await client.post( + settings.llm_settings.local_llm_url, + json={ + "model": settings.llm_settings.local_llm_model, + "messages": [{"role": "user", "content": prompt}], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "Beliefs", + "strict": True, + "schema": schema, + }, + }, + "reasoning_effort": "low", + "temperature": settings.llm_settings.code_temperature, + "stream": False, + }, + timeout=None, + ) + response.raise_for_status() + + response_json = response.json() + json_message = response_json["choices"][0]["message"]["content"] + return json.loads(json_message) diff --git a/src/control_backend/agents/llm/llm_agent.py b/src/control_backend/agents/llm/llm_agent.py index 55099e2..17edec9 100644 --- a/src/control_backend/agents/llm/llm_agent.py +++ b/src/control_backend/agents/llm/llm_agent.py @@ -64,11 +64,12 @@ class LLMAgent(BaseAgent): :param message: The parsed prompt message containing text, norms, and goals. """ + full_message = "" async for chunk in self._query_llm(message.text, message.norms, message.goals): await self._send_reply(chunk) - self.logger.debug( - "Finished processing BDI message. Response sent in chunks to BDI core." - ) + full_message += chunk + self.logger.debug("Finished processing BDI message. Response sent in chunks to BDI core.") + await self._send_full_reply(full_message) async def _send_reply(self, msg: str): """ @@ -83,6 +84,19 @@ class LLMAgent(BaseAgent): ) await self.send(reply) + async def _send_full_reply(self, msg: str): + """ + Sends a response message (full) to agents that need it. + + :param msg: The text content of the message. + """ + message = InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=self.name, + body=msg, + ) + await self.send(message) + async def _query_llm( self, prompt: str, norms: list[str], goals: list[str] ) -> AsyncGenerator[str]: @@ -172,7 +186,7 @@ class LLMAgent(BaseAgent): json={ "model": settings.llm_settings.local_llm_model, "messages": messages, - "temperature": 0.3, + "temperature": settings.llm_settings.chat_temperature, "stream": True, }, ) as response: diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 927985b..1a2560a 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -65,6 +65,7 @@ class BehaviourSettings(BaseModel): :ivar transcription_words_per_minute: Estimated words per minute for transcription timing. :ivar transcription_words_per_token: Estimated words per token for transcription timing. :ivar transcription_token_buffer: Buffer for transcription tokens. + :ivar conversation_history_length_limit: The maximum amount of messages to extract beliefs from. """ sleep_s: float = 1.0 @@ -82,6 +83,9 @@ class BehaviourSettings(BaseModel): transcription_words_per_token: float = 0.75 # (3 words = 4 tokens) transcription_token_buffer: int = 10 + # Text belief extractor settings + conversation_history_length_limit = 10 + class LLMSettings(BaseModel): """ @@ -89,10 +93,17 @@ class LLMSettings(BaseModel): :ivar local_llm_url: URL for the local LLM API. :ivar local_llm_model: Name of the local LLM model to use. + :ivar chat_temperature: The temperature to use while generating chat responses. + :ivar code_temperature: The temperature to use while generating code-like responses like during + belief inference. + :ivar n_parallel: The number of parallel calls allowed to be made to the LLM. """ local_llm_url: str = "http://localhost:1234/v1/chat/completions" local_llm_model: str = "gpt-oss" + chat_temperature = 1.0 + code_temperature = 0.3 + n_parallel: int = 4 class VADSettings(BaseModel): diff --git a/src/control_backend/schemas/chat_history.py b/src/control_backend/schemas/chat_history.py new file mode 100644 index 0000000..52fc224 --- /dev/null +++ b/src/control_backend/schemas/chat_history.py @@ -0,0 +1,10 @@ +from pydantic import BaseModel + + +class ChatMessage(BaseModel): + role: str + content: str + + +class ChatHistory(BaseModel): + messages: list[ChatMessage] diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 28969b9..529a23d 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -1,64 +1,201 @@ -from pydantic import BaseModel +from enum import Enum +from typing import Literal + +from pydantic import UUID4, BaseModel -class Norm(BaseModel): +class ProgramElement(BaseModel): """ - Represents a behavioral norm. + Represents a basic element of our behavior program. + :ivar name: The researcher-assigned name of the element. :ivar id: Unique identifier. - :ivar label: Human-readable label. - :ivar norm: The actual norm text describing the behavior. """ - id: str - label: str - norm: str + name: str + id: UUID4 -class Goal(BaseModel): +class LogicalOperator(Enum): + AND = "AND" + OR = "OR" + + +type Belief = KeywordBelief | SemanticBelief | InferredBelief +type BasicBelief = KeywordBelief | SemanticBelief + + +class KeywordBelief(ProgramElement): """ - Represents an objective to be achieved. + Represents a belief that is set when the user spoken text contains a certain keyword. - :ivar id: Unique identifier. - :ivar label: Human-readable label. - :ivar description: Detailed description of the goal. - :ivar achieved: Status flag indicating if the goal has been met. + :ivar keyword: The keyword on which this belief gets set. """ - id: str - label: str - description: str - achieved: bool - - -class TriggerKeyword(BaseModel): - id: str + name: str = "" keyword: str -class KeywordTrigger(BaseModel): - id: str - label: str - type: str - keywords: list[TriggerKeyword] +class SemanticBelief(ProgramElement): + """ + Represents a belief that is set by semantic LLM validation. + + :ivar description: Description of how to form the belief, used by the LLM. + """ + + name: str = "" + description: str -class Phase(BaseModel): +class InferredBelief(ProgramElement): + """ + Represents a belief that gets formed by combining two beliefs with a logical AND or OR. + + These beliefs can also be :class:`InferredBelief`, leading to arbitrarily deep nesting. + + :ivar operator: The logical operator to apply. + :ivar left: The left part of the logical expression. + :ivar right: The right part of the logical expression. + """ + + name: str = "" + operator: LogicalOperator + left: Belief + right: Belief + + +type Norm = BasicNorm | ConditionalNorm + + +class BasicNorm(ProgramElement): + """ + Represents a behavioral norm. + + :ivar norm: The actual norm text describing the behavior. + :ivar critical: When true, this norm should absolutely not be violated (checked separately). + """ + + name: str = "" + norm: str + critical: bool = False + + +class ConditionalNorm(BasicNorm): + """ + Represents a norm that is only active when a condition is met (i.e., a certain belief holds). + + :ivar condition: When to activate this norm. + """ + + condition: Belief + + +type PlanElement = Goal | Action + + +class Plan(ProgramElement): + """ + Represents a list of steps to execute. Each of these steps can be a goal (with its own plan) + or a simple action. + + :ivar steps: The actions or subgoals to execute, in order. + """ + + name: str = "" + steps: list[PlanElement] + + +class Goal(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. + + :ivar plan: The plan to execute. + :ivar can_fail: Whether we can fail to achieve the goal after executing the plan. + """ + + plan: Plan + can_fail: bool = True + + +type Action = SpeechAction | GestureAction | LLMAction + + +class SpeechAction(ProgramElement): + """ + Represents the action of the robot speaking a literal text. + + :ivar text: The text to speak. + """ + + name: str = "" + text: str + + +class Gesture(BaseModel): + """ + Represents a gesture to be performed. Can be either a single gesture, + or a random gesture from a category (tag). + + :ivar type: The type of the gesture, "tag" or "single". + :ivar name: The name of the single gesture or tag. + """ + + type: Literal["tag", "single"] + name: str + + +class GestureAction(ProgramElement): + """ + Represents the action of the robot performing a gesture. + + :ivar gesture: The gesture to perform. + """ + + name: str = "" + gesture: Gesture + + +class LLMAction(ProgramElement): + """ + Represents the action of letting an LLM generate a reply based on its chat history + and an additional goal added in the prompt. + + :ivar goal: The extra (temporary) goal to add to the LLM. + """ + + name: str = "" + goal: str + + +class Trigger(ProgramElement): + """ + Represents a belief-based trigger. When a belief is set, the corresponding plan is executed. + + :ivar condition: When to activate the trigger. + :ivar plan: The plan to execute. + """ + + name: str = "" + condition: Belief + plan: Plan + + +class Phase(ProgramElement): """ A distinct phase within a program, containing norms, goals, and triggers. - :ivar id: Unique identifier. - :ivar label: Human-readable label. :ivar norms: List of norms active in this phase. :ivar goals: List of goals to pursue in this phase. :ivar triggers: List of triggers that define transitions out of this phase. """ - id: str - label: str + name: str = "" norms: list[Norm] goals: list[Goal] - triggers: list[KeywordTrigger] + triggers: list[Trigger] class Program(BaseModel): From 71cefdfef3c29aa98453fdb0dac22d466b5c095c Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:14:49 +0100 Subject: [PATCH 13/68] fix: add types to all config properties ref: N25B-380 --- src/control_backend/core/config.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 1a2560a..8a7267c 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -84,7 +84,7 @@ class BehaviourSettings(BaseModel): transcription_token_buffer: int = 10 # Text belief extractor settings - conversation_history_length_limit = 10 + conversation_history_length_limit: int = 10 class LLMSettings(BaseModel): @@ -101,8 +101,8 @@ class LLMSettings(BaseModel): local_llm_url: str = "http://localhost:1234/v1/chat/completions" local_llm_model: str = "gpt-oss" - chat_temperature = 1.0 - code_temperature = 0.3 + chat_temperature: float = 1.0 + code_temperature: float = 0.3 n_parallel: int = 4 From 3253760ef195306e4131b03b01c596c0e9e97b20 Mon Sep 17 00:00:00 2001 From: Kasper Date: Tue, 23 Dec 2025 17:30:35 +0100 Subject: [PATCH 14/68] feat: new AST representation File names will be changed eventually. ref: N25B-376 --- src/control_backend/agents/bdi/astv2.py | 272 ++++++++++++++++++++++++ src/control_backend/agents/bdi/gen.py | 0 src/control_backend/agents/bdi/test.asl | 0 src/control_backend/schemas/program.py | 13 +- 4 files changed, 279 insertions(+), 6 deletions(-) create mode 100644 src/control_backend/agents/bdi/astv2.py create mode 100644 src/control_backend/agents/bdi/gen.py create mode 100644 src/control_backend/agents/bdi/test.asl diff --git a/src/control_backend/agents/bdi/astv2.py b/src/control_backend/agents/bdi/astv2.py new file mode 100644 index 0000000..f88fb6a --- /dev/null +++ b/src/control_backend/agents/bdi/astv2.py @@ -0,0 +1,272 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from enum import StrEnum + + +class AstNode(ABC): + """ + Abstract base class for all elements of an AgentSpeak program. + """ + + @abstractmethod + def _to_agentspeak(self) -> str: + """ + Generates the AgentSpeak code string. + """ + pass + + def __str__(self) -> str: + return self._to_agentspeak() + + +class AstExpression(AstNode, ABC): + """ + Intermediate class for anything that can be used in a logical expression. + """ + + def __and__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.AND, _coalesce_expr(other)) + + def __or__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.OR, _coalesce_expr(other)) + + def __invert__(self) -> AstLogicalExpression: + if isinstance(self, AstLogicalExpression): + self.negated = not self.negated + return self + return AstLogicalExpression(self, negated=True) + + +type ExprCoalescible = AstExpression | str | int | float + + +def _coalesce_expr(value: ExprCoalescible) -> AstExpression: + if isinstance(value, AstExpression): + return value + if isinstance(value, str): + return AstString(value) + if isinstance(value, (int, float)): + return AstNumber(value) + raise TypeError(f"Cannot coalesce type {type(value)} into an AstTerm.") + + +@dataclass +class AstTerm(AstExpression, ABC): + """ + Base class for terms appearing inside literals. + """ + + def __ge__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.GREATER_EQUALS, _coalesce_expr(other)) + + def __gt__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.GREATER_THAN, _coalesce_expr(other)) + + def __le__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.LESS_EQUALS, _coalesce_expr(other)) + + def __lt__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.LESS_THAN, _coalesce_expr(other)) + + def __eq__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.EQUALS, _coalesce_expr(other)) + + def __ne__(self, other: ExprCoalescible) -> AstBinaryOp: + return AstBinaryOp(self, BinaryOperatorType.NOT_EQUALS, _coalesce_expr(other)) + + +@dataclass +class AstAtom(AstTerm): + """ + Grounded expression in all lowercase. + """ + + value: str + + def _to_agentspeak(self) -> str: + return self.value.lower() + + +@dataclass +class AstVar(AstTerm): + """ + Ungrounded variable expression. First letter capitalized. + """ + + name: str + + def _to_agentspeak(self) -> str: + return self.name.capitalize() + + +@dataclass +class AstNumber(AstTerm): + value: int | float + + def _to_agentspeak(self) -> str: + return str(self.value) + + +@dataclass +class AstString(AstTerm): + value: str + + def _to_agentspeak(self) -> str: + return f'"{self.value}"' + + +@dataclass +class AstLiteral(AstTerm): + functor: str + terms: list[AstTerm] = field(default_factory=list) + + def _to_agentspeak(self) -> str: + if not self.terms: + return self.functor + args = ", ".join(map(str, self.terms)) + return f"{self.functor}({args})" + + +class BinaryOperatorType(StrEnum): + AND = "&" + OR = "|" + GREATER_THAN = ">" + LESS_THAN = "<" + EQUALS = "==" + NOT_EQUALS = "\\==" + GREATER_EQUALS = ">=" + LESS_EQUALS = "<=" + + +@dataclass +class AstBinaryOp(AstExpression): + left: AstExpression + operator: BinaryOperatorType + right: AstExpression + + def __post_init__(self): + self.left = _as_logical(self.left) + self.right = _as_logical(self.right) + + def _to_agentspeak(self) -> str: + l_str = str(self.left) + r_str = str(self.right) + + assert isinstance(self.left, AstLogicalExpression) + assert isinstance(self.right, AstLogicalExpression) + + if isinstance(self.left.expression, AstBinaryOp) or self.left.negated: + l_str = f"({l_str})" + if isinstance(self.right.expression, AstBinaryOp) or self.right.negated: + r_str = f"({r_str})" + + return f"{l_str} {self.operator.value} {r_str}" + + +@dataclass +class AstLogicalExpression(AstExpression): + expression: AstExpression + negated: bool = False + + def _to_agentspeak(self) -> str: + expr_str = str(self.expression) + if isinstance(self.expression, AstBinaryOp) and self.negated: + expr_str = f"({expr_str})" + return f"{'not ' if self.negated else ''}{expr_str}" + + +def _as_logical(expr: AstExpression) -> AstLogicalExpression: + if isinstance(expr, AstLogicalExpression): + return expr + return AstLogicalExpression(expr) + + +class StatementType(StrEnum): + EMPTY = "" + DO_ACTION = "." + ACHIEVE_GOAL = "!" + # TEST_GOAL = "?" # TODO + ADD_BELIEF = "+" + REMOVE_BELIEF = "-" + + +@dataclass +class AstStatement(AstNode): + """ + A statement that can appear inside a plan. + """ + + type: StatementType + expression: AstExpression + + def _to_agentspeak(self) -> str: + return f"{self.type.value}{self.expression}" + + +@dataclass +class AstRule(AstNode): + result: AstExpression + condition: AstExpression | None = None + + def __post_init__(self): + if self.condition is not None: + self.condition = _as_logical(self.condition) + + def _to_agentspeak(self) -> str: + if not self.condition: + return f"{self.result}." + return f"{self.result} :- {self.condition}." + + +class TriggerType(StrEnum): + ADDED_BELIEF = "+" + # REMOVED_BELIEF = "-" # TODO + # MODIFIED_BELIEF = "^" # TODO + ADDED_GOAL = "+!" + # REMOVED_GOAL = "-!" # TODO + + +@dataclass +class AstPlan(AstNode): + type: TriggerType + trigger_literal: AstExpression + context: list[AstExpression] + body: list[AstStatement] + + def _to_agentspeak(self) -> str: + assert isinstance(self.trigger_literal, AstLiteral) + + indent = " " * 6 + colon = " : " + arrow = " <- " + + lines = [] + + lines.append(f"{self.type.value}{self.trigger_literal}") + + if self.context: + lines.append(colon + f" &\n{indent}".join(str(c) for c in self.context)) + + if self.body: + lines.append(arrow + f";\n{indent}".join(str(s) for s in self.body) + ".") + + lines.append("") + + return "\n".join(lines) + + +@dataclass +class AstProgram(AstNode): + rules: list[AstRule] = field(default_factory=list) + plans: list[AstPlan] = field(default_factory=list) + + def _to_agentspeak(self) -> str: + lines = [] + lines.extend(map(str, self.rules)) + + lines.extend(["", ""]) + lines.extend(map(str, self.plans)) + + return "\n".join(lines) diff --git a/src/control_backend/agents/bdi/gen.py b/src/control_backend/agents/bdi/gen.py new file mode 100644 index 0000000..e69de29 diff --git a/src/control_backend/agents/bdi/test.asl b/src/control_backend/agents/bdi/test.asl new file mode 100644 index 0000000..e69de29 diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 529a23d..5a8caa9 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -64,10 +64,13 @@ class InferredBelief(ProgramElement): right: Belief -type Norm = BasicNorm | ConditionalNorm +class Norm(ProgramElement): + name: str = "" + norm: str + critical: bool = False -class BasicNorm(ProgramElement): +class BasicNorm(Norm): """ Represents a behavioral norm. @@ -75,12 +78,10 @@ class BasicNorm(ProgramElement): :ivar critical: When true, this norm should absolutely not be violated (checked separately). """ - name: str = "" - norm: str - critical: bool = False + pass -class ConditionalNorm(BasicNorm): +class ConditionalNorm(Norm): """ Represents a norm that is only active when a condition is met (i.e., a certain belief holds). From 57b1276cb5f569dd5a6a17f9ade8f1035922ce7d Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Mon, 29 Dec 2025 12:31:51 +0100 Subject: [PATCH 15/68] test: make tests work again after changing Program schema ref: N25B-380 --- .../agents/bdi/bdi_core_agent.py | 6 +- .../agents/bdi/test_bdi_program_manager.py | 54 ++++++++++------ test/unit/agents/bdi/test_text_extractor.py | 11 +--- test/unit/agents/llm/test_llm_agent.py | 2 +- .../api/v1/endpoints/test_program_endpoint.py | 56 ++++++++++------- test/unit/schemas/test_ui_program_message.py | 62 +++++++++++-------- 6 files changed, 110 insertions(+), 81 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 8ff271c..427e024 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -89,9 +89,9 @@ class BDICoreAgent(BaseAgent): the agent has deferred intentions (deadlines). """ while self._running: - # await ( - # self._wake_bdi_loop.wait() - # ) # gets set whenever there's an update to the belief base + await ( + self._wake_bdi_loop.wait() + ) # gets set whenever there's an update to the belief base # Agent knows when it's expected to have to do its next thing maybe_more_work = True diff --git a/test/unit/agents/bdi/test_bdi_program_manager.py b/test/unit/agents/bdi/test_bdi_program_manager.py index a54360c..d16bc43 100644 --- a/test/unit/agents/bdi/test_bdi_program_manager.py +++ b/test/unit/agents/bdi/test_bdi_program_manager.py @@ -1,6 +1,6 @@ import asyncio -import json import sys +import uuid from unittest.mock import AsyncMock import pytest @@ -8,31 +8,45 @@ import pytest from control_backend.agents.bdi.bdi_program_manager import BDIProgramManager from control_backend.core.agent_system import InternalMessage from control_backend.schemas.belief_message import BeliefMessage -from control_backend.schemas.program import Program +from control_backend.schemas.program import BasicNorm, Goal, Phase, Plan, Program # Fix Windows Proactor loop for zmq if sys.platform.startswith("win"): asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) -def make_valid_program_json(norm="N1", goal="G1"): - return json.dumps( - { - "phases": [ - { - "id": "phase1", - "label": "Phase 1", - "triggers": [], - "norms": [{"id": "n1", "label": "Norm 1", "norm": norm}], - "goals": [ - {"id": "g1", "label": "Goal 1", "description": goal, "achieved": False} - ], - } - ] - } - ) +def make_valid_program_json(norm="N1", goal="G1") -> str: + return Program( + phases=[ + Phase( + id=uuid.uuid4(), + name="Basic Phase", + norms=[ + BasicNorm( + id=uuid.uuid4(), + name=norm, + norm=norm, + ), + ], + goals=[ + Goal( + id=uuid.uuid4(), + name=goal, + plan=Plan( + id=uuid.uuid4(), + name="Goal Plan", + steps=[], + ), + can_fail=False, + ), + ], + triggers=[], + ), + ], + ).model_dump_json() +@pytest.mark.skip(reason="Functionality being rebuilt.") @pytest.mark.asyncio async def test_send_to_bdi(): manager = BDIProgramManager(name="program_manager_test") @@ -73,5 +87,5 @@ async def test_receive_programs_valid_and_invalid(): # Only valid Program should have triggered _send_to_bdi assert manager._send_to_bdi.await_count == 1 forwarded: Program = manager._send_to_bdi.await_args[0][0] - assert forwarded.phases[0].norms[0].norm == "N1" - assert forwarded.phases[0].goals[0].description == "G1" + assert forwarded.phases[0].norms[0].name == "N1" + assert forwarded.phases[0].goals[0].name == "G1" diff --git a/test/unit/agents/bdi/test_text_extractor.py b/test/unit/agents/bdi/test_text_extractor.py index 895fef0..c51571a 100644 --- a/test/unit/agents/bdi/test_text_extractor.py +++ b/test/unit/agents/bdi/test_text_extractor.py @@ -45,10 +45,10 @@ async def test_handle_message_from_transcriber(agent, mock_settings): @pytest.mark.asyncio -async def test_process_transcription_demo(agent, mock_settings): +async def test_process_user_said(agent, mock_settings): transcription = "this is a test" - await agent._process_transcription_demo(transcription) + await agent._user_said(transcription) agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. sent: InternalMessage = agent.send.call_args.args[0] # noqa @@ -56,10 +56,3 @@ async def test_process_transcription_demo(agent, mock_settings): assert sent.thread == "beliefs" parsed = json.loads(sent.body) assert parsed["beliefs"]["user_said"] == [transcription] - - -@pytest.mark.asyncio -async def test_setup_initializes_beliefs(agent): - """Covers the setup method and ensures beliefs are initialized.""" - await agent.setup() - assert agent.beliefs == {"mood": ["X"], "car": ["Y"]} diff --git a/test/unit/agents/llm/test_llm_agent.py b/test/unit/agents/llm/test_llm_agent.py index 5e84d8d..6cda4da 100644 --- a/test/unit/agents/llm/test_llm_agent.py +++ b/test/unit/agents/llm/test_llm_agent.py @@ -66,7 +66,7 @@ async def test_llm_processing_success(mock_httpx_client, mock_settings): # "Hello world." constitutes one sentence/chunk based on punctuation split # The agent should call send once with the full sentence assert agent.send.called - args = agent.send.call_args[0][0] + args = agent.send.call_args_list[0][0][0] assert args.to == mock_settings.agent_settings.bdi_core_name assert "Hello world." in args.body diff --git a/test/unit/api/v1/endpoints/test_program_endpoint.py b/test/unit/api/v1/endpoints/test_program_endpoint.py index 178159c..379767a 100644 --- a/test/unit/api/v1/endpoints/test_program_endpoint.py +++ b/test/unit/api/v1/endpoints/test_program_endpoint.py @@ -1,4 +1,5 @@ import json +import uuid from unittest.mock import AsyncMock import pytest @@ -6,7 +7,7 @@ from fastapi import FastAPI from fastapi.testclient import TestClient from control_backend.api.v1.endpoints import program -from control_backend.schemas.program import Program +from control_backend.schemas.program import BasicNorm, Goal, Phase, Plan, Program @pytest.fixture @@ -25,29 +26,37 @@ def client(app): def make_valid_program_dict(): """Helper to create a valid Program JSON structure.""" - return { - "phases": [ - { - "id": "phase1", - "label": "basephase", - "norms": [{"id": "n1", "label": "norm", "norm": "be nice"}], - "goals": [ - {"id": "g1", "label": "goal", "description": "test goal", "achieved": False} + # Converting to JSON using Pydantic because it knows how to convert a UUID object + program_json_str = Program( + phases=[ + Phase( + id=uuid.uuid4(), + name="Basic Phase", + norms=[ + BasicNorm( + id=uuid.uuid4(), + name="Some norm", + norm="Do normal.", + ), ], - "triggers": [ - { - "id": "t1", - "label": "trigger", - "type": "keywords", - "keywords": [ - {"id": "kw1", "keyword": "keyword1"}, - {"id": "kw2", "keyword": "keyword2"}, - ], - }, + goals=[ + Goal( + id=uuid.uuid4(), + name="Some goal", + plan=Plan( + id=uuid.uuid4(), + name="Goal Plan", + steps=[], + ), + can_fail=False, + ), ], - } - ] - } + triggers=[], + ), + ], + ).model_dump_json() + # Converting back to a dict because that's what's expected + return json.loads(program_json_str) def test_receive_program_success(client): @@ -71,7 +80,8 @@ def test_receive_program_success(client): sent_bytes = args[0][1] sent_obj = json.loads(sent_bytes.decode()) - expected_obj = Program.model_validate(program_dict).model_dump() + # Converting to JSON using Pydantic because it knows how to handle UUIDs + expected_obj = json.loads(Program.model_validate(program_dict).model_dump_json()) assert sent_obj == expected_obj diff --git a/test/unit/schemas/test_ui_program_message.py b/test/unit/schemas/test_ui_program_message.py index 7ed544e..a9f96dd 100644 --- a/test/unit/schemas/test_ui_program_message.py +++ b/test/unit/schemas/test_ui_program_message.py @@ -1,49 +1,61 @@ +import uuid + import pytest from pydantic import ValidationError from control_backend.schemas.program import ( + BasicNorm, Goal, - KeywordTrigger, - Norm, + KeywordBelief, Phase, + Plan, Program, - TriggerKeyword, + Trigger, ) -def base_norm() -> Norm: - return Norm( - id="norm1", - label="testNorm", +def base_norm() -> BasicNorm: + return BasicNorm( + id=uuid.uuid4(), + name="testNormName", norm="testNormNorm", + critical=False, ) def base_goal() -> Goal: return Goal( - id="goal1", - label="testGoal", - description="testGoalDescription", - achieved=False, + id=uuid.uuid4(), + name="testGoalName", + plan=Plan( + id=uuid.uuid4(), + name="testGoalPlanName", + steps=[], + ), + can_fail=False, ) -def base_trigger() -> KeywordTrigger: - return KeywordTrigger( - id="trigger1", - label="testTrigger", - type="keywords", - keywords=[ - TriggerKeyword(id="keyword1", keyword="testKeyword1"), - TriggerKeyword(id="keyword1", keyword="testKeyword2"), - ], +def base_trigger() -> Trigger: + return Trigger( + id=uuid.uuid4(), + name="testTriggerName", + condition=KeywordBelief( + id=uuid.uuid4(), + name="testTriggerKeywordBeliefTriggerName", + keyword="Keyword", + ), + plan=Plan( + id=uuid.uuid4(), + name="testTriggerPlanName", + steps=[], + ), ) def base_phase() -> Phase: return Phase( - id="phase1", - label="basephase", + id=uuid.uuid4(), norms=[base_norm()], goals=[base_goal()], triggers=[base_trigger()], @@ -58,7 +70,7 @@ def invalid_program() -> dict: # wrong types inside phases list (not Phase objects) return { "phases": [ - {"id": "phase1"}, # incomplete + {"id": uuid.uuid4()}, # incomplete {"not_a_phase": True}, ] } @@ -77,8 +89,8 @@ def test_valid_deepprogram(): # validate nested components directly phase = validated.phases[0] assert isinstance(phase.goals[0], Goal) - assert isinstance(phase.triggers[0], KeywordTrigger) - assert isinstance(phase.norms[0], Norm) + assert isinstance(phase.triggers[0], Trigger) + assert isinstance(phase.norms[0], BasicNorm) def test_invalid_program(): From 42ee5c76d8401898308b384d3f48b6f1b74216ba Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:12:02 +0100 Subject: [PATCH 16/68] test: create tests for belief extractor agent Includes changes in schemas. Change type of `norms` in `Program` imperceptibly, big changes in schema of `BeliefMessage` to support deleting beliefs. ref: N25B-380 --- .../agents/bdi/bdi_core_agent.py | 27 +- .../agents/bdi/belief_collector_agent.py | 2 +- .../agents/bdi/text_belief_extractor_agent.py | 27 +- src/control_backend/schemas/belief_message.py | 21 +- src/control_backend/schemas/program.py | 2 +- test/unit/agents/bdi/test_bdi_core_agent.py | 32 +- test/unit/agents/bdi/test_belief_collector.py | 2 +- .../agents/bdi/test_text_belief_extractor.py | 346 ++++++++++++++++++ test/unit/agents/bdi/test_text_extractor.py | 58 --- test/unit/schemas/test_ui_program_message.py | 105 ++++++ 10 files changed, 530 insertions(+), 92 deletions(-) create mode 100644 test/unit/agents/bdi/test_text_belief_extractor.py delete mode 100644 test/unit/agents/bdi/test_text_extractor.py diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 427e024..23c2808 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -11,7 +11,7 @@ from pydantic import ValidationError from control_backend.agents.base import BaseAgent 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.belief_message import BeliefMessage from control_backend.schemas.llm_prompt_message import LLMPromptMessage from control_backend.schemas.ri_message import SpeechCommand @@ -124,8 +124,8 @@ class BDICoreAgent(BaseAgent): if msg.thread == "beliefs": try: - beliefs = BeliefMessage.model_validate_json(msg.body).beliefs - self._apply_beliefs(beliefs) + belief_changes = BeliefMessage.model_validate_json(msg.body) + self._apply_belief_changes(belief_changes) except ValidationError: self.logger.exception("Error processing belief.") return @@ -145,21 +145,28 @@ class BDICoreAgent(BaseAgent): ) await self.send(out_msg) - def _apply_beliefs(self, beliefs: list[Belief]): + def _apply_belief_changes(self, belief_changes: BeliefMessage): """ Update the belief base with a list of new beliefs. - If ``replace=True`` is set on a belief, it removes all existing beliefs with that name - before adding the new one. + For beliefs in ``belief_changes.replace``, it removes all existing beliefs with that name + before adding one new one. + + :param belief_changes: The changes in beliefs to apply. """ - if not beliefs: + if not belief_changes.create and not belief_changes.replace and not belief_changes.delete: return - for belief in beliefs: - if belief.replace: - self._remove_all_with_name(belief.name) + for belief in belief_changes.create: self._add_belief(belief.name, belief.arguments) + for belief in belief_changes.replace: + self._remove_all_with_name(belief.name) + self._add_belief(belief.name, belief.arguments) + + for belief in belief_changes.delete: + self._remove_belief(belief.name, belief.arguments) + def _add_belief(self, name: str, args: list[str] = None): """ Add a single belief to the BDI agent. diff --git a/src/control_backend/agents/bdi/belief_collector_agent.py b/src/control_backend/agents/bdi/belief_collector_agent.py index 788cff1..ac0e2e5 100644 --- a/src/control_backend/agents/bdi/belief_collector_agent.py +++ b/src/control_backend/agents/bdi/belief_collector_agent.py @@ -144,7 +144,7 @@ class BDIBeliefCollectorAgent(BaseAgent): msg = InternalMessage( to=settings.agent_settings.bdi_core_name, sender=self.name, - body=BeliefMessage(beliefs=beliefs).model_dump_json(), + body=BeliefMessage(create=beliefs).model_dump_json(), thread="beliefs", ) 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 0324573..5cc75d8 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -34,8 +34,8 @@ class TextBeliefExtractorAgent(BaseAgent): def __init__(self, name: str): super().__init__(name) - self.beliefs = {} - self.available_beliefs = [] + self.beliefs: dict[str, bool] = {} + self.available_beliefs: list[SemanticBelief] = [] self.conversation = ChatHistory(messages=[]) async def setup(self): @@ -151,23 +151,30 @@ class TextBeliefExtractorAgent(BaseAgent): return candidate_beliefs = await self._infer_turn() - new_beliefs: list[InternalBelief] = [] + belief_changes = BeliefMessage() for belief_key, belief_value in candidate_beliefs.items(): if belief_value is None: continue old_belief_value = self.beliefs.get(belief_key) - # TODO: Do we need this check? Can we send the same beliefs multiple times? if belief_value == old_belief_value: continue + self.beliefs[belief_key] = belief_value - new_beliefs.append( - InternalBelief(name=belief_key, arguments=[belief_value], replace=True), - ) + + belief = InternalBelief(name=belief_key, arguments=None) + if belief_value: + belief_changes.create.append(belief) + else: + belief_changes.delete.append(belief) + + # Return if there were no changes in beliefs + if not belief_changes.has_values(): + return beliefs_message = InternalMessage( to=settings.agent_settings.bdi_core_name, sender=self.name, - body=BeliefMessage(beliefs=new_beliefs).model_dump_json(), + body=belief_changes.model_dump_json(), thread="beliefs", ) await self.send(beliefs_message) @@ -184,7 +191,7 @@ class TextBeliefExtractorAgent(BaseAgent): :return: A dict mapping belief names to a value ``True``, ``False`` or ``None``. """ - n_parallel = min(settings.llm_settings.n_parallel - 1, len(self.available_beliefs)) + n_parallel = max(1, min(settings.llm_settings.n_parallel - 1, len(self.available_beliefs))) all_beliefs = await asyncio.gather( *[ self._infer_beliefs(self.conversation, beliefs) @@ -286,7 +293,7 @@ Respond with a JSON similar to the following, but with the property names as giv try: return await self._query_llm(prompt, schema) - except (httpx.HTTPStatusError, json.JSONDecodeError, KeyError) as e: + except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e: if try_count < tries: continue self.logger.exception( diff --git a/src/control_backend/schemas/belief_message.py b/src/control_backend/schemas/belief_message.py index deb1152..56a8a4a 100644 --- a/src/control_backend/schemas/belief_message.py +++ b/src/control_backend/schemas/belief_message.py @@ -6,18 +6,27 @@ class Belief(BaseModel): Represents a single belief in the BDI system. :ivar name: The functor or name of the belief (e.g., 'user_said'). - :ivar arguments: A list of string arguments for the belief. - :ivar replace: If True, existing beliefs with this name should be replaced by this one. + :ivar arguments: A list of string arguments for the belief, or None if the belief has no + arguments. """ name: str - arguments: list[str] - replace: bool = False + arguments: list[str] | None class BeliefMessage(BaseModel): """ - A container for transporting a list of beliefs between agents. + A container for communicating beliefs between agents. + + :ivar create: Beliefs to create. + :ivar delete: Beliefs to delete. + :ivar replace: Beliefs to replace. Deletes all beliefs with the same name, replacing them with + one new belief. """ - beliefs: list[Belief] + create: list[Belief] = [] + delete: list[Belief] = [] + replace: list[Belief] = [] + + def has_values(self) -> bool: + return len(self.create) > 0 or len(self.delete) > 0 or len(self.replace) > 0 diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 5a8caa9..be538b0 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -194,7 +194,7 @@ class Phase(ProgramElement): """ name: str = "" - norms: list[Norm] + norms: list[BasicNorm | ConditionalNorm] goals: list[Goal] triggers: list[Trigger] diff --git a/test/unit/agents/bdi/test_bdi_core_agent.py b/test/unit/agents/bdi/test_bdi_core_agent.py index 8d004fc..2325a57 100644 --- a/test/unit/agents/bdi/test_bdi_core_agent.py +++ b/test/unit/agents/bdi/test_bdi_core_agent.py @@ -51,7 +51,7 @@ async def test_handle_belief_collector_message(agent, mock_settings): msg = InternalMessage( to="bdi_agent", sender=mock_settings.agent_settings.bdi_belief_collector_name, - body=BeliefMessage(beliefs=beliefs).model_dump_json(), + body=BeliefMessage(create=beliefs).model_dump_json(), thread="beliefs", ) @@ -64,6 +64,26 @@ async def test_handle_belief_collector_message(agent, mock_settings): assert args[2] == agentspeak.Literal("user_said", (agentspeak.Literal("Hello"),)) +@pytest.mark.asyncio +async def test_handle_delete_belief_message(agent, mock_settings): + """Test that incoming beliefs to be deleted are removed from the BDI agent""" + beliefs = [Belief(name="user_said", arguments=["Hello"])] + + msg = InternalMessage( + to="bdi_agent", + sender=mock_settings.agent_settings.bdi_belief_collector_name, + body=BeliefMessage(delete=beliefs).model_dump_json(), + thread="beliefs", + ) + await agent.handle_message(msg) + + # Expect bdi_agent.call to be triggered to remove belief + args = agent.bdi_agent.call.call_args.args + assert args[0] == agentspeak.Trigger.removal + assert args[1] == agentspeak.GoalType.belief + assert args[2] == agentspeak.Literal("user_said", (agentspeak.Literal("Hello"),)) + + @pytest.mark.asyncio async def test_incorrect_belief_collector_message(agent, mock_settings): """Test that incorrect message format triggers an exception.""" @@ -128,7 +148,8 @@ def test_add_belief_sets_event(agent): agent._wake_bdi_loop = MagicMock() belief = Belief(name="test_belief", arguments=["a", "b"]) - agent._apply_beliefs([belief]) + belief_changes = BeliefMessage(replace=[belief]) + agent._apply_belief_changes(belief_changes) assert agent.bdi_agent.call.called agent._wake_bdi_loop.set.assert_called() @@ -137,7 +158,7 @@ def test_add_belief_sets_event(agent): def test_apply_beliefs_empty_returns(agent): """Line: if not beliefs: return""" agent._wake_bdi_loop = MagicMock() - agent._apply_beliefs([]) + agent._apply_belief_changes(BeliefMessage()) agent.bdi_agent.call.assert_not_called() agent._wake_bdi_loop.set.assert_not_called() @@ -220,8 +241,9 @@ def test_replace_belief_calls_remove_all(agent): agent._remove_all_with_name = MagicMock() agent._wake_bdi_loop = MagicMock() - belief = Belief(name="user_said", arguments=["Hello"], replace=True) - agent._apply_beliefs([belief]) + belief = Belief(name="user_said", arguments=["Hello"]) + belief_changes = BeliefMessage(replace=[belief]) + agent._apply_belief_changes(belief_changes) agent._remove_all_with_name.assert_called_with("user_said") diff --git a/test/unit/agents/bdi/test_belief_collector.py b/test/unit/agents/bdi/test_belief_collector.py index 67b2ed5..69db269 100644 --- a/test/unit/agents/bdi/test_belief_collector.py +++ b/test/unit/agents/bdi/test_belief_collector.py @@ -86,7 +86,7 @@ async def test_send_beliefs_to_bdi(agent): sent: InternalMessage = agent.send.call_args.args[0] assert sent.to == settings.agent_settings.bdi_core_name assert sent.thread == "beliefs" - assert json.loads(sent.body)["beliefs"] == [belief.model_dump() for belief in beliefs] + assert json.loads(sent.body)["create"] == [belief.model_dump() for belief in beliefs] @pytest.mark.asyncio diff --git a/test/unit/agents/bdi/test_text_belief_extractor.py b/test/unit/agents/bdi/test_text_belief_extractor.py new file mode 100644 index 0000000..827adbc --- /dev/null +++ b/test/unit/agents/bdi/test_text_belief_extractor.py @@ -0,0 +1,346 @@ +import json +import uuid +from unittest.mock import AsyncMock, MagicMock, patch + +import httpx +import pytest + +from control_backend.agents.bdi import TextBeliefExtractorAgent +from control_backend.core.agent_system import InternalMessage +from control_backend.core.config import settings +from control_backend.schemas.belief_message import BeliefMessage +from control_backend.schemas.program import ( + ConditionalNorm, + LLMAction, + Phase, + Plan, + Program, + SemanticBelief, + Trigger, +) + + +@pytest.fixture +def agent(): + agent = TextBeliefExtractorAgent("text_belief_agent") + agent.send = AsyncMock() + agent._query_llm = AsyncMock() + return agent + + +@pytest.fixture +def sample_program(): + return Program( + phases=[ + Phase( + name="Some phase", + id=uuid.uuid4(), + norms=[ + ConditionalNorm( + name="Some norm", + id=uuid.uuid4(), + norm="Use nautical terms.", + critical=False, + condition=SemanticBelief( + name="is_pirate", + id=uuid.uuid4(), + description="The user is a pirate. Perhaps because they say " + "they are, or because they speak like a pirate " + 'with terms like "arr".', + ), + ), + ], + goals=[], + triggers=[ + Trigger( + name="Some trigger", + id=uuid.uuid4(), + condition=SemanticBelief( + name="no_more_booze", + id=uuid.uuid4(), + description="There is no more alcohol.", + ), + plan=Plan( + name="Some plan", + id=uuid.uuid4(), + steps=[ + LLMAction( + name="Some action", + id=uuid.uuid4(), + goal="Suggest eating chocolate instead.", + ), + ], + ), + ), + ], + ), + ], + ) + + +def make_msg(sender: str, body: str, thread: str | None = None) -> InternalMessage: + return InternalMessage(to="unused", sender=sender, body=body, thread=thread) + + +@pytest.mark.asyncio +async def test_handle_message_ignores_other_agents(agent): + msg = make_msg("unknown", "some data", None) + + await agent.handle_message(msg) + + agent.send.assert_not_called() # noqa # `agent.send` has no such property, but we mock it. + + +@pytest.mark.asyncio +async def test_handle_message_from_transcriber(agent, mock_settings): + transcription = "hello world" + msg = make_msg(mock_settings.agent_settings.transcription_name, transcription, None) + + await agent.handle_message(msg) + + agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. + sent: InternalMessage = agent.send.call_args.args[0] # noqa + assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name + assert sent.thread == "beliefs" + parsed = json.loads(sent.body) + assert parsed == {"beliefs": {"user_said": [transcription]}, "type": "belief_extraction_text"} + + +@pytest.mark.asyncio +async def test_process_user_said(agent, mock_settings): + transcription = "this is a test" + + await agent._user_said(transcription) + + agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. + sent: InternalMessage = agent.send.call_args.args[0] # noqa + assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name + assert sent.thread == "beliefs" + parsed = json.loads(sent.body) + assert parsed["beliefs"]["user_said"] == [transcription] + + +@pytest.mark.asyncio +async def test_query_llm(): + mock_response = MagicMock() + mock_response.json.return_value = { + "choices": [ + { + "message": { + "content": "null", + } + } + ] + } + mock_client = AsyncMock() + mock_client.post.return_value = mock_response + mock_async_client = MagicMock() + mock_async_client.__aenter__.return_value = mock_client + mock_async_client.__aexit__.return_value = None + + with patch( + "control_backend.agents.bdi.text_belief_extractor_agent.httpx.AsyncClient", + return_value=mock_async_client, + ): + agent = TextBeliefExtractorAgent("text_belief_agent") + + res = await agent._query_llm("hello world", {"type": "null"}) + # Response content was set as "null", so should be deserialized as None + assert res is None + + +@pytest.mark.asyncio +async def test_retry_query_llm_success(agent): + agent._query_llm.return_value = None + res = await agent._retry_query_llm("hello world", {"type": "null"}) + + agent._query_llm.assert_called_once() + assert res is None + + +@pytest.mark.asyncio +async def test_retry_query_llm_success_after_failure(agent): + agent._query_llm.side_effect = [KeyError(), "real value"] + res = await agent._retry_query_llm("hello world", {"type": "string"}) + + assert agent._query_llm.call_count == 2 + assert res == "real value" + + +@pytest.mark.asyncio +async def test_retry_query_llm_failures(agent): + agent._query_llm.side_effect = [KeyError(), KeyError(), KeyError(), "real value"] + res = await agent._retry_query_llm("hello world", {"type": "string"}) + + assert agent._query_llm.call_count == 3 + assert res is None + + +@pytest.mark.asyncio +async def test_retry_query_llm_fail_immediately(agent): + agent._query_llm.side_effect = [KeyError(), "real value"] + res = await agent._retry_query_llm("hello world", {"type": "string"}, tries=1) + + assert agent._query_llm.call_count == 1 + assert res is None + + +@pytest.mark.asyncio +async def test_extracting_beliefs_from_program(agent, sample_program): + assert len(agent.available_beliefs) == 0 + await agent.handle_message( + InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=settings.agent_settings.bdi_program_manager_name, + body=sample_program.model_dump_json(), + ), + ) + assert len(agent.available_beliefs) == 2 + + +@pytest.mark.asyncio +async def test_handle_invalid_program(agent, sample_program): + agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + assert len(agent.available_beliefs) == 2 + + await agent.handle_message( + InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=settings.agent_settings.bdi_program_manager_name, + body=json.dumps({"phases": "Invalid"}), + ), + ) + + assert len(agent.available_beliefs) == 2 + + +@pytest.mark.asyncio +async def test_handle_robot_response(agent): + initial_length = len(agent.conversation.messages) + response = "Hi, I'm Pepper. What's your name?" + + await agent.handle_message( + InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=settings.agent_settings.llm_name, + body=response, + ), + ) + + assert len(agent.conversation.messages) == initial_length + 1 + assert agent.conversation.messages[-1].role == "assistant" + assert agent.conversation.messages[-1].content == response + + +@pytest.mark.asyncio +async def test_simulated_real_turn_with_beliefs(agent, sample_program): + """Test sending user message to extract beliefs from.""" + agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + + # Send a user message with the belief that there's no more booze + agent._query_llm.return_value = {"is_pirate": None, "no_more_booze": True} + assert len(agent.conversation.messages) == 0 + await agent.handle_message( + InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=settings.agent_settings.transcription_name, + body="We're all out of schnaps.", + ), + ) + assert len(agent.conversation.messages) == 1 + + # There should be a belief set and sent to the BDI core, as well as the user_said belief + assert agent.send.call_count == 2 + + # First should be the beliefs message + message: InternalMessage = agent.send.call_args_list[0].args[0] + beliefs = BeliefMessage.model_validate_json(message.body) + assert len(beliefs.create) == 1 + assert beliefs.create[0].name == "no_more_booze" + + +@pytest.mark.asyncio +async def test_simulated_real_turn_no_beliefs(agent, sample_program): + """Test a user message to extract beliefs from, but no beliefs are formed.""" + agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + + # Send a user message with no new beliefs + agent._query_llm.return_value = {"is_pirate": None, "no_more_booze": None} + await agent.handle_message( + InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=settings.agent_settings.transcription_name, + body="Hello there!", + ), + ) + + # Only the user_said belief should've been sent + agent.send.assert_called_once() + + +@pytest.mark.asyncio +async def test_simulated_real_turn_no_new_beliefs(agent, sample_program): + """ + Test a user message to extract beliefs from, but no new beliefs are formed because they already + existed. + """ + agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + agent.beliefs["is_pirate"] = True + + # Send a user message with the belief the user is a pirate, still + agent._query_llm.return_value = {"is_pirate": True, "no_more_booze": None} + await agent.handle_message( + InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=settings.agent_settings.transcription_name, + body="Arr, nice to meet you, matey.", + ), + ) + + # Only the user_said belief should've been sent, as no beliefs have changed + agent.send.assert_called_once() + + +@pytest.mark.asyncio +async def test_simulated_real_turn_remove_belief(agent, sample_program): + """ + Test a user message to extract beliefs from, but an existing belief is determined no longer to + hold. + """ + agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + agent.beliefs["no_more_booze"] = True + + # Send a user message with the belief the user is a pirate, still + agent._query_llm.return_value = {"is_pirate": None, "no_more_booze": False} + await agent.handle_message( + InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=settings.agent_settings.transcription_name, + body="I found an untouched barrel of wine!", + ), + ) + + # Both user_said and belief change should've been sent + assert agent.send.call_count == 2 + + # Agent's current beliefs should've changed + assert not agent.beliefs["no_more_booze"] + + +@pytest.mark.asyncio +async def test_llm_failure_handling(agent, sample_program): + """ + Check that the agent handles failures gracefully without crashing. + """ + agent._query_llm.side_effect = httpx.HTTPError("") + agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + + belief_changes = await agent._infer_turn() + + assert len(belief_changes) == 0 diff --git a/test/unit/agents/bdi/test_text_extractor.py b/test/unit/agents/bdi/test_text_extractor.py deleted file mode 100644 index c51571a..0000000 --- a/test/unit/agents/bdi/test_text_extractor.py +++ /dev/null @@ -1,58 +0,0 @@ -import json -from unittest.mock import AsyncMock - -import pytest - -from control_backend.agents.bdi import ( - TextBeliefExtractorAgent, -) -from control_backend.core.agent_system import InternalMessage - - -@pytest.fixture -def agent(): - agent = TextBeliefExtractorAgent("text_belief_agent") - agent.send = AsyncMock() - return agent - - -def make_msg(sender: str, body: str, thread: str | None = None) -> InternalMessage: - return InternalMessage(to="unused", sender=sender, body=body, thread=thread) - - -@pytest.mark.asyncio -async def test_handle_message_ignores_other_agents(agent): - msg = make_msg("unknown", "some data", None) - - await agent.handle_message(msg) - - agent.send.assert_not_called() # noqa # `agent.send` has no such property, but we mock it. - - -@pytest.mark.asyncio -async def test_handle_message_from_transcriber(agent, mock_settings): - transcription = "hello world" - msg = make_msg(mock_settings.agent_settings.transcription_name, transcription, None) - - await agent.handle_message(msg) - - agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. - sent: InternalMessage = agent.send.call_args.args[0] # noqa - assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name - assert sent.thread == "beliefs" - parsed = json.loads(sent.body) - assert parsed == {"beliefs": {"user_said": [transcription]}, "type": "belief_extraction_text"} - - -@pytest.mark.asyncio -async def test_process_user_said(agent, mock_settings): - transcription = "this is a test" - - await agent._user_said(transcription) - - agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. - sent: InternalMessage = agent.send.call_args.args[0] # noqa - assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name - assert sent.thread == "beliefs" - parsed = json.loads(sent.body) - assert parsed["beliefs"]["user_said"] == [transcription] diff --git a/test/unit/schemas/test_ui_program_message.py b/test/unit/schemas/test_ui_program_message.py index a9f96dd..6014db7 100644 --- a/test/unit/schemas/test_ui_program_message.py +++ b/test/unit/schemas/test_ui_program_message.py @@ -5,11 +5,15 @@ from pydantic import ValidationError from control_backend.schemas.program import ( BasicNorm, + ConditionalNorm, Goal, + InferredBelief, KeywordBelief, + LogicalOperator, Phase, Plan, Program, + SemanticBelief, Trigger, ) @@ -97,3 +101,104 @@ def test_invalid_program(): bad = invalid_program() with pytest.raises(ValidationError): Program.model_validate(bad) + + +def test_conditional_norm_parsing(): + """ + Check that pydantic is able to preserve the type of the norm, that it doesn't lose its + "condition" field when serializing and deserializing. + """ + norm = ConditionalNorm( + name="testNormName", + id=uuid.uuid4(), + norm="testNormNorm", + critical=False, + condition=KeywordBelief( + name="testKeywordBelief", + id=uuid.uuid4(), + keyword="testKeywordBelief", + ), + ) + program = Program( + phases=[ + Phase( + name="Some phase", + id=uuid.uuid4(), + norms=[norm], + goals=[], + triggers=[], + ), + ], + ) + + parsed_program = Program.model_validate_json(program.model_dump_json()) + parsed_norm = parsed_program.phases[0].norms[0] + + assert hasattr(parsed_norm, "condition") + assert isinstance(parsed_norm, ConditionalNorm) + + +def test_belief_type_parsing(): + """ + Check that pydantic is able to discern between the different types of beliefs when serializing + and deserializing. + """ + keyword_belief = KeywordBelief( + name="testKeywordBelief", + id=uuid.uuid4(), + keyword="something", + ) + semantic_belief = SemanticBelief( + name="testSemanticBelief", + id=uuid.uuid4(), + description="something", + ) + inferred_belief = InferredBelief( + name="testInferredBelief", + id=uuid.uuid4(), + operator=LogicalOperator.OR, + left=keyword_belief, + right=semantic_belief, + ) + + program = Program( + phases=[ + Phase( + name="Some phase", + id=uuid.uuid4(), + norms=[], + goals=[], + triggers=[ + Trigger( + name="testTriggerKeywordTrigger", + id=uuid.uuid4(), + condition=keyword_belief, + plan=Plan(name="testTriggerPlanName", id=uuid.uuid4(), steps=[]), + ), + Trigger( + name="testTriggerSemanticTrigger", + id=uuid.uuid4(), + condition=semantic_belief, + plan=Plan(name="testTriggerPlanName", id=uuid.uuid4(), steps=[]), + ), + Trigger( + name="testTriggerInferredTrigger", + id=uuid.uuid4(), + condition=inferred_belief, + plan=Plan(name="testTriggerPlanName", id=uuid.uuid4(), steps=[]), + ), + ], + ), + ], + ) + + parsed_program = Program.model_validate_json(program.model_dump_json()) + + parsed_keyword_belief = parsed_program.phases[0].triggers[0].condition + assert isinstance(parsed_keyword_belief, KeywordBelief) + + parsed_semantic_belief = parsed_program.phases[0].triggers[1].condition + assert isinstance(parsed_semantic_belief, SemanticBelief) + + parsed_inferred_belief = parsed_program.phases[0].triggers[2].condition + assert isinstance(parsed_inferred_belief, InferredBelief) From 9eea4ee3454881e5a846b9eb775647d46513cab9 Mon Sep 17 00:00:00 2001 From: Kasper Date: Fri, 2 Jan 2026 12:08:20 +0100 Subject: [PATCH 17/68] feat: new ASL generation ref: N25B-376 --- src/control_backend/agents/bdi/astv2.py | 3 +- src/control_backend/agents/bdi/gen.py | 0 src/control_backend/agents/bdi/genv2.py | 354 ++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 1 deletion(-) delete mode 100644 src/control_backend/agents/bdi/gen.py create mode 100644 src/control_backend/agents/bdi/genv2.py diff --git a/src/control_backend/agents/bdi/astv2.py b/src/control_backend/agents/bdi/astv2.py index f88fb6a..188b4f3 100644 --- a/src/control_backend/agents/bdi/astv2.py +++ b/src/control_backend/agents/bdi/astv2.py @@ -187,9 +187,10 @@ class StatementType(StrEnum): EMPTY = "" DO_ACTION = "." ACHIEVE_GOAL = "!" - # TEST_GOAL = "?" # TODO + TEST_GOAL = "?" ADD_BELIEF = "+" REMOVE_BELIEF = "-" + REPLACE_BELIEF = "-+" @dataclass diff --git a/src/control_backend/agents/bdi/gen.py b/src/control_backend/agents/bdi/gen.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/control_backend/agents/bdi/genv2.py b/src/control_backend/agents/bdi/genv2.py new file mode 100644 index 0000000..61980e4 --- /dev/null +++ b/src/control_backend/agents/bdi/genv2.py @@ -0,0 +1,354 @@ +import asyncio +import time +from functools import singledispatchmethod + +from slugify import slugify + +from control_backend.agents.bdi import BDICoreAgent +from control_backend.agents.bdi.astv2 import ( + AstBinaryOp, + AstExpression, + AstLiteral, + AstPlan, + AstProgram, + AstRule, + AstStatement, + AstString, + AstVar, + BinaryOperatorType, + StatementType, + TriggerType, +) +from control_backend.agents.bdi.bdi_program_manager import test_program +from control_backend.schemas.program import ( + BasicNorm, + ConditionalNorm, + GestureAction, + Goal, + InferredBelief, + KeywordBelief, + LLMAction, + LogicalOperator, + Norm, + Phase, + PlanElement, + Program, + ProgramElement, + SemanticBelief, + SpeechAction, + Trigger, +) + + +def do_things(): + program = AgentSpeakGenerator().generate(test_program) + print(program) + + +async def do_other_things(): + res = input("Wanna generate") + if res == "y": + program = AgentSpeakGenerator().generate(test_program) + filename = f"{int(time.time())}.asl" + with open(filename, "w") as f: + f.write(program) + else: + filename = "temp.asl" + bdi_agent = BDICoreAgent("BDICoreAgent", filename) + flag = asyncio.Event() + await bdi_agent.start() + await flag.wait() + + +class AgentSpeakGenerator: + _asp: AstProgram + + def generate(self, program: Program) -> str: + self._asp = AstProgram() + + self._asp.rules.append(AstRule(AstLiteral("phase", [AstString("start")]))) + self._add_keyword_inference() + self._add_response_goal() + + self._process_phases(program.phases) + + self._add_fallbacks() + + return str(self._asp) + + def _add_keyword_inference(self) -> None: + keyword = AstVar("Keyword") + message = AstVar("Message") + position = AstVar("Pos") + + self._asp.rules.append( + AstRule( + AstLiteral("keyword_said", [keyword]), + AstLiteral("user_said", [message]) + & AstLiteral(".substring", [keyword, message, position]) + & (position >= 0), + ) + ) + + def _add_response_goal(self): + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("generate_response_with_goal", [AstVar("Goal")]), + [AstLiteral("user_said", [AstVar("Message")])], + [ + AstStatement(StatementType.ADD_BELIEF, AstLiteral("responded_this_turn")), + AstStatement( + StatementType.DO_ACTION, + AstLiteral( + "findall", + [AstVar("Norm"), AstLiteral("norm", [AstVar("Norm")]), AstVar("Norms")], + ), + ), + AstStatement( + StatementType.DO_ACTION, + AstLiteral( + "reply_with_goal", [AstVar("Message"), AstVar("Norms"), AstVar("Goal")] + ), + ), + ], + ) + ) + + def _process_phases(self, phases: list[Phase]) -> None: + for curr_phase, next_phase in zip([None] + phases, phases + [None], strict=True): + if curr_phase: + self._process_phase(curr_phase) + self._add_phase_transition(curr_phase, next_phase) + + def _process_phase(self, phase: Phase) -> None: + for norm in phase.norms: + self._process_norm(norm, phase) + + self._add_default_loop(phase) + + previous_goal = None + for goal in phase.goals: + self._process_goal(goal, phase, previous_goal) + previous_goal = goal + + for trigger in phase.triggers: + self._process_trigger(trigger, phase) + + def _add_phase_transition(self, from_phase: Phase | None, to_phase: Phase | None) -> None: + from_phase_ast = ( + self._astify(from_phase) if from_phase else AstLiteral("phase", [AstString("start")]) + ) + to_phase_ast = ( + self._astify(to_phase) if to_phase else AstLiteral("phase", [AstString("end")]) + ) + + context = [from_phase_ast] + if from_phase and from_phase.goals: + context.append(self._astify(from_phase.goals[-1], achieved=True)) + + body = [ + AstStatement(StatementType.REMOVE_BELIEF, from_phase_ast), + AstStatement(StatementType.ADD_BELIEF, to_phase_ast), + ] + + if from_phase: + body.extend( + [ + AstStatement( + StatementType.TEST_GOAL, AstLiteral("user_said", [AstVar("Message")]) + ), + AstStatement( + StatementType.REPLACE_BELIEF, AstLiteral("user_said", [AstVar("Message")]) + ), + ] + ) + + self._asp.plans.append( + AstPlan(TriggerType.ADDED_GOAL, AstLiteral("transition_phase"), context, body) + ) + + def _process_norm(self, norm: Norm, phase: Phase) -> None: + rule: AstRule | None = None + + match norm: + case ConditionalNorm(condition=cond): + rule = AstRule(self._astify(norm), self._astify(phase) & self._astify(cond)) + case BasicNorm(): + rule = AstRule(self._astify(norm), self._astify(phase)) + + if not rule: + return + + self._asp.rules.append(rule) + + def _add_default_loop(self, phase: Phase) -> None: + actions = [] + + actions.append(AstStatement(StatementType.REMOVE_BELIEF, AstLiteral("responded_this_turn"))) + actions.append(AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("check_triggers"))) + + for goal in phase.goals: + actions.append(AstStatement(StatementType.ACHIEVE_GOAL, self._astify(goal))) + + actions.append(AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("transition_phase"))) + + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_BELIEF, + AstLiteral("user_said", [AstVar("Message")]), + [self._astify(phase)], + actions, + ) + ) + + def _process_goal( + self, + goal: Goal, + phase: Phase, + previous_goal: Goal | None = None, + continues_response: bool = False, + ) -> None: + context: list[AstExpression] = [self._astify(phase)] + context.append(~self._astify(goal, achieved=True)) + if previous_goal and previous_goal.can_fail: + context.append(self._astify(previous_goal, achieved=True)) + if not continues_response: + context.append(~AstLiteral("responded_this_turn")) + + body = [] + + subgoals = [] + for step in goal.plan.steps: + body.append(self._step_to_statement(step)) + if isinstance(step, Goal): + subgoals.append(step) + + if not goal.can_fail and not continues_response: + body.append(AstStatement(StatementType.ADD_BELIEF, self._astify(goal, achieved=True))) + + self._asp.plans.append(AstPlan(TriggerType.ADDED_GOAL, self._astify(goal), context, body)) + + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + self._astify(goal), + context=[], + body=[AstStatement(StatementType.EMPTY, AstLiteral("true"))], + ) + ) + + prev_goal = None + for subgoal in subgoals: + self._process_goal(subgoal, phase, prev_goal) + prev_goal = subgoal + + def _step_to_statement(self, step: PlanElement) -> AstStatement: + match step: + case Goal() as g: + return AstStatement(StatementType.ACHIEVE_GOAL, self._astify(g)) + case SpeechAction() | GestureAction() as a: + return AstStatement(StatementType.DO_ACTION, self._astify(a)) + case LLMAction() as la: + return AstStatement( + StatementType.ACHIEVE_GOAL, self._astify(la) + ) # LLM action is a goal in ASL + + # TODO: separate handling of keyword and others + def _process_trigger(self, trigger: Trigger, phase: Phase) -> None: + body = [] + subgoals = [] + + for step in trigger.plan.steps: + body.append(self._step_to_statement(step)) + if isinstance(step, Goal): + step.can_fail = False # triggers are continuous sequence + subgoals.append(step) + + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("check_triggers"), + [self._astify(phase), self._astify(trigger.condition)], + body, + ) + ) + + for subgoal in subgoals: + self._process_goal(subgoal, phase, continues_response=True) + + def _add_fallbacks(self): + # Trigger fallback + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("check_triggers"), + [], + [AstStatement(StatementType.EMPTY, AstLiteral("true"))], + ) + ) + + # Phase transition fallback + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("transition_phase"), + [], + [AstStatement(StatementType.EMPTY, AstLiteral("true"))], + ) + ) + + @singledispatchmethod + def _astify(self, element: ProgramElement) -> AstExpression: + raise NotImplementedError(f"Cannot convert element {element} to an AgentSpeak expression.") + + @_astify.register + def _(self, kwb: KeywordBelief) -> AstExpression: + return AstLiteral("keyword_said", [AstString(kwb.keyword)]) + + @_astify.register + def _(self, sb: SemanticBelief) -> AstExpression: + return AstLiteral(f"semantic_{self._slugify_str(sb.description)}") + + @_astify.register + def _(self, ib: InferredBelief) -> AstExpression: + return AstBinaryOp( + self._astify(ib.left), + BinaryOperatorType.AND if ib.operator == LogicalOperator.AND else BinaryOperatorType.OR, + self._astify(ib.right), + ) + + @_astify.register + def _(self, norm: Norm) -> AstExpression: + functor = "critical_norm" if norm.critical else "norm" + return AstLiteral(functor, [AstString(norm.norm)]) + + @_astify.register + def _(self, phase: Phase) -> AstExpression: + return AstLiteral("phase", [AstString(str(phase.id))]) + + @_astify.register + def _(self, goal: Goal, achieved: bool = False) -> AstExpression: + return AstLiteral(f"{'achieved_' if achieved else ''}{self._slugify_str(goal.name)}") + + @_astify.register + def _(self, sa: SpeechAction) -> AstExpression: + return AstLiteral("say", [AstString(sa.text)]) + + @_astify.register + def _(self, ga: GestureAction) -> AstExpression: + gesture = ga.gesture + return AstLiteral("gesture", [AstString(gesture.type), AstString(gesture.name)]) + + @_astify.register + def _(self, la: LLMAction) -> AstExpression: + return AstLiteral("generate_response_with_goal", [AstString(la.goal)]) + + @staticmethod + def _slugify_str(text: str) -> str: + return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"]) + + +if __name__ == "__main__": + # do_things() + asyncio.run(do_other_things()) From a357b6990b67a5a0f4b69050dbfb9836fea17ecc Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Tue, 6 Jan 2026 12:11:37 +0100 Subject: [PATCH 18/68] feat: send program to bdi core ref: N25B-376 --- .../bdi/{astv2.py => agentspeak_ast.py} | 0 .../bdi/{genv2.py => agentspeak_generator.py} | 31 +- .../agents/bdi/bdi_core_agent.py | 23 +- .../agents/bdi/bdi_program_manager.py | 662 +----------------- src/control_backend/main.py | 1 - .../agents/bdi/test_bdi_program_manager.py | 8 +- 6 files changed, 54 insertions(+), 671 deletions(-) rename src/control_backend/agents/bdi/{astv2.py => agentspeak_ast.py} (100%) rename src/control_backend/agents/bdi/{genv2.py => agentspeak_generator.py} (93%) diff --git a/src/control_backend/agents/bdi/astv2.py b/src/control_backend/agents/bdi/agentspeak_ast.py similarity index 100% rename from src/control_backend/agents/bdi/astv2.py rename to src/control_backend/agents/bdi/agentspeak_ast.py diff --git a/src/control_backend/agents/bdi/genv2.py b/src/control_backend/agents/bdi/agentspeak_generator.py similarity index 93% rename from src/control_backend/agents/bdi/genv2.py rename to src/control_backend/agents/bdi/agentspeak_generator.py index 61980e4..4f892e1 100644 --- a/src/control_backend/agents/bdi/genv2.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -1,11 +1,8 @@ -import asyncio -import time from functools import singledispatchmethod from slugify import slugify -from control_backend.agents.bdi import BDICoreAgent -from control_backend.agents.bdi.astv2 import ( +from control_backend.agents.bdi.agentspeak_ast import ( AstBinaryOp, AstExpression, AstLiteral, @@ -19,7 +16,6 @@ from control_backend.agents.bdi.astv2 import ( StatementType, TriggerType, ) -from control_backend.agents.bdi.bdi_program_manager import test_program from control_backend.schemas.program import ( BasicNorm, ConditionalNorm, @@ -40,26 +36,6 @@ from control_backend.schemas.program import ( ) -def do_things(): - program = AgentSpeakGenerator().generate(test_program) - print(program) - - -async def do_other_things(): - res = input("Wanna generate") - if res == "y": - program = AgentSpeakGenerator().generate(test_program) - filename = f"{int(time.time())}.asl" - with open(filename, "w") as f: - f.write(program) - else: - filename = "temp.asl" - bdi_agent = BDICoreAgent("BDICoreAgent", filename) - flag = asyncio.Event() - await bdi_agent.start() - await flag.wait() - - class AgentSpeakGenerator: _asp: AstProgram @@ -347,8 +323,3 @@ class AgentSpeakGenerator: @staticmethod def _slugify_str(text: str) -> str: return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"]) - - -if __name__ == "__main__": - # do_things() - asyncio.run(do_other_things()) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 8ff271c..7da6708 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -42,9 +42,8 @@ class BDICoreAgent(BaseAgent): bdi_agent: agentspeak.runtime.Agent - def __init__(self, name: str, asl: str): + def __init__(self, name: str): super().__init__(name) - self.asl_file = asl self.env = agentspeak.runtime.Environment() # Deep copy because we don't actually want to modify the standard actions globally self.actions = copy.deepcopy(agentspeak.stdlib.actions) @@ -69,15 +68,18 @@ class BDICoreAgent(BaseAgent): self._wake_bdi_loop.set() self.logger.debug("Setup complete.") - async def _load_asl(self): + async def _load_asl(self, file_name: str | None = None) -> None: """ Load and parse the AgentSpeak source file. """ + file_name = file_name or "src/control_backend/agents/bdi/rules.asl" + try: - with open(self.asl_file) as source: + with open(file_name) as source: self.bdi_agent = self.env.build_agent(source, self.actions) + self.logger.info(f"Loaded new ASL from {file_name}.") except FileNotFoundError: - self.logger.warning(f"Could not find the specified ASL file at {self.asl_file}.") + self.logger.warning(f"Could not find the specified ASL file at {file_name}.") self.bdi_agent = agentspeak.runtime.Agent(self.env, self.name) async def _bdi_loop(self): @@ -89,9 +91,9 @@ class BDICoreAgent(BaseAgent): the agent has deferred intentions (deadlines). """ while self._running: - # await ( - # self._wake_bdi_loop.wait() - # ) # gets set whenever there's an update to the belief base + await ( + self._wake_bdi_loop.wait() + ) # gets set whenever there's an update to the belief base # Agent knows when it's expected to have to do its next thing maybe_more_work = True @@ -116,6 +118,7 @@ class BDICoreAgent(BaseAgent): Handle incoming messages. - **Beliefs**: Updates the internal belief base. + - **Program**: Updates the internal agentspeak file to match the current program. - **LLM Responses**: Forwards the generated text to the Robot Speech Agent (actuation). :param msg: The received internal message. @@ -130,6 +133,10 @@ class BDICoreAgent(BaseAgent): self.logger.exception("Error processing belief.") return + # New agentspeak file + if msg.thread == "new_program": + await self._load_asl(msg.body) + # The message was not a belief, handle special cases based on sender match msg.sender: case settings.agent_settings.llm_name: diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 9925cfb..f8715a7 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -1,598 +1,12 @@ -import uuid -from collections.abc import Iterable - import zmq from pydantic import ValidationError -from slugify import slugify from zmq.asyncio import Context from control_backend.agents import BaseAgent +from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator from control_backend.core.config import settings -from control_backend.schemas.program import ( - Action, - BasicBelief, - BasicNorm, - Belief, - ConditionalNorm, - GestureAction, - Goal, - InferredBelief, - KeywordBelief, - LLMAction, - LogicalOperator, - Phase, - Plan, - Program, - ProgramElement, - SemanticBelief, - SpeechAction, - Trigger, -) - -test_program = Program( - phases=[ - Phase( - norms=[ - BasicNorm(norm="Talk like a pirate", id=uuid.uuid4()), - ConditionalNorm( - condition=InferredBelief( - left=KeywordBelief(keyword="Arr", id=uuid.uuid4()), - right=SemanticBelief( - description="testing", name="semantic belief", id=uuid.uuid4() - ), - operator=LogicalOperator.OR, - name="Talking to a pirate", - id=uuid.uuid4(), - ), - norm="Use nautical terms", - id=uuid.uuid4(), - ), - ConditionalNorm( - condition=SemanticBelief( - description="We are talking to a child", - name="talking to child", - id=uuid.uuid4(), - ), - norm="Do not use cuss words", - id=uuid.uuid4(), - ), - ], - triggers=[ - Trigger( - condition=InferredBelief( - left=KeywordBelief(keyword="key", id=uuid.uuid4()), - right=InferredBelief( - left=KeywordBelief(keyword="key2", id=uuid.uuid4()), - right=SemanticBelief( - description="Decode this", name="semantic belief 2", id=uuid.uuid4() - ), - operator=LogicalOperator.OR, - name="test trigger inferred inner", - id=uuid.uuid4(), - ), - operator=LogicalOperator.OR, - name="test trigger inferred outer", - id=uuid.uuid4(), - ), - plan=Plan( - steps=[ - SpeechAction(text="Testing trigger", id=uuid.uuid4()), - Goal( - name="Testing trigger", - plan=Plan( - steps=[LLMAction(goal="Do something", id=uuid.uuid4())], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ) - ], - goals=[ - Goal( - name="Determine user age", - plan=Plan( - steps=[LLMAction(goal="Determine the age of the user.", id=uuid.uuid4())], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - Goal( - name="Find the user's name", - plan=Plan( - steps=[ - Goal( - name="Greet the user", - plan=Plan( - steps=[LLMAction(goal="Greet the user.", id=uuid.uuid4())], - id=uuid.uuid4(), - ), - can_fail=False, - id=uuid.uuid4(), - ), - Goal( - name="Ask for name", - plan=Plan( - steps=[ - LLMAction(goal="Obtain the user's name.", id=uuid.uuid4()) - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - Goal( - name="Tell a joke", - plan=Plan( - steps=[LLMAction(goal="Tell a joke.", id=uuid.uuid4())], id=uuid.uuid4() - ), - id=uuid.uuid4(), - ), - ], - id=uuid.uuid4(), - ), - Phase( - id=uuid.uuid4(), - norms=[ - BasicNorm(norm="Use very gentle speech.", id=uuid.uuid4()), - ConditionalNorm( - condition=SemanticBelief( - description="We are talking to a child", - name="talking to child", - id=uuid.uuid4(), - ), - norm="Do not use cuss words", - id=uuid.uuid4(), - ), - ], - triggers=[ - Trigger( - condition=InferredBelief( - left=KeywordBelief(keyword="help", id=uuid.uuid4()), - right=SemanticBelief( - description="User is stuck", name="stuck", id=uuid.uuid4() - ), - operator=LogicalOperator.OR, - name="help_or_stuck", - id=uuid.uuid4(), - ), - plan=Plan( - steps=[ - Goal( - name="Unblock user", - plan=Plan( - steps=[ - LLMAction( - goal="Provide a step-by-step path to " - "resolve the user's issue.", - id=uuid.uuid4(), - ) - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - ], - goals=[ - Goal( - name="Clarify intent", - plan=Plan( - steps=[ - LLMAction( - goal="Ask 1-2 targeted questions to clarify the " - "user's intent, then proceed.", - id=uuid.uuid4(), - ) - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - Goal( - name="Provide solution", - plan=Plan( - steps=[ - LLMAction( - goal="Deliver a solution to complete the user's goal.", - id=uuid.uuid4(), - ) - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - Goal( - name="Summarize next steps", - plan=Plan( - steps=[ - LLMAction( - goal="Summarize what the user should do next.", id=uuid.uuid4() - ) - ], - id=uuid.uuid4(), - ), - id=uuid.uuid4(), - ), - ], - ), - ] -) - - -def do_things(): - print(AgentSpeakGenerator().generate(test_program)) - - -class AgentSpeakGenerator: - """ - Converts Pydantic representation of behavior programs into AgentSpeak(L) code string. - """ - - arrow_prefix = f"{' ' * 2}<-{' ' * 2}" - colon_prefix = f"{' ' * 2}:{' ' * 3}" - indent_prefix = " " * 6 - - def generate(self, program: Program) -> str: - lines = [] - lines.append("") - - lines += self._generate_initial_beliefs(program) - - lines += self._generate_basic_flow(program) - - lines += self._generate_phase_transitions(program) - - lines += self._generate_norms(program) - - lines += self._generate_belief_inference(program) - - lines += self._generate_goals(program) - - lines += self._generate_triggers(program) - - return "\n".join(lines) - - def _generate_initial_beliefs(self, program: Program) -> Iterable[str]: - yield "// --- Initial beliefs and agent startup ---" - - yield "phase(start)." - - yield "" - - yield "+started" - yield f"{self.colon_prefix}phase(start)" - yield f"{self.arrow_prefix}phase({program.phases[0].id if program.phases else 'end'})." - - yield from ["", ""] - - def _generate_basic_flow(self, program: Program) -> Iterable[str]: - yield "// --- Basic flow ---" - - for phase in program.phases: - yield from self._generate_basic_flow_per_phase(phase) - - yield from ["", ""] - - def _generate_basic_flow_per_phase(self, phase: Phase) -> Iterable[str]: - yield "+user_said(Message)" - yield f"{self.colon_prefix}phase({phase.id})" - - goals = phase.goals - if goals: - yield f"{self.arrow_prefix}{self._slugify(goals[0], include_prefix=True)}" - for goal in goals[1:]: - yield f"{self.indent_prefix}{self._slugify(goal, include_prefix=True)}" - - yield f"{self.indent_prefix if goals else self.arrow_prefix}!transition_phase." - - def _generate_phase_transitions(self, program: Program) -> Iterable[str]: - yield "// --- Phase transitions ---" - - if len(program.phases) == 0: - yield from ["", ""] - return - - # TODO: remove outdated things - - for i in range(-1, len(program.phases)): - predecessor = program.phases[i] if i >= 0 else None - successor = program.phases[i + 1] if i < len(program.phases) - 1 else None - yield from self._generate_phase_transition(predecessor, successor) - - yield from self._generate_phase_transition(None, None) # to avoid failing plan - - yield from ["", ""] - - def _generate_phase_transition( - self, phase: Phase | None = None, next_phase: Phase | None = None - ) -> Iterable[str]: - yield "+!transition_phase" - - if phase is None and next_phase is None: # base case true to avoid failing plan - yield f"{self.arrow_prefix}true." - return - - yield f"{self.colon_prefix}phase({phase.id if phase else 'start'})" - yield f"{self.arrow_prefix}-+phase({next_phase.id if next_phase else 'end'})." - - def _generate_norms(self, program: Program) -> Iterable[str]: - yield "// --- Norms ---" - - for phase in program.phases: - for norm in phase.norms: - if type(norm) is BasicNorm: - yield f"{self._slugify(norm)} :- phase({phase.id})." - if type(norm) is ConditionalNorm: - yield ( - f"{self._slugify(norm)} :- phase({phase.id}) & " - f"{self._slugify(norm.condition)}." - ) - - yield from ["", ""] - - def _generate_belief_inference(self, program: Program) -> Iterable[str]: - yield "// --- Belief inference rules ---" - - for phase in program.phases: - for norm in phase.norms: - if not isinstance(norm, ConditionalNorm): - continue - - yield from self._belief_inference_recursive(norm.condition) - - for trigger in phase.triggers: - yield from self._belief_inference_recursive(trigger.condition) - - yield from ["", ""] - - def _belief_inference_recursive(self, belief: Belief) -> Iterable[str]: - if type(belief) is KeywordBelief: - yield ( - f"{self._slugify(belief)} :- user_said(Message) & " - f'.substring(Message, "{belief.keyword}", Pos) & Pos >= 0.' - ) - if type(belief) is InferredBelief: - yield ( - f"{self._slugify(belief)} :- {self._slugify(belief.left)} " - f"{'&' if belief.operator == LogicalOperator.AND else '|'} " - f"{self._slugify(belief.right)}." - ) - - yield from self._belief_inference_recursive(belief.left) - yield from self._belief_inference_recursive(belief.right) - - def _generate_goals(self, program: Program) -> Iterable[str]: - yield "// --- Goals ---" - - for phase in program.phases: - previous_goal: Goal | None = None - for goal in phase.goals: - yield from self._generate_goal_plan_recursive(goal, phase, previous_goal) - previous_goal = goal - - yield from ["", ""] - - def _generate_goal_plan_recursive( - self, goal: Goal, phase: Phase, previous_goal: Goal | None = None - ) -> Iterable[str]: - yield f"+{self._slugify(goal, include_prefix=True)}" - - # Context - yield f"{self.colon_prefix}phase({phase.id}) &" - yield f"{self.indent_prefix}not responded_this_turn &" - yield f"{self.indent_prefix}not achieved_{self._slugify(goal)} &" - if previous_goal: - yield f"{self.indent_prefix}achieved_{self._slugify(previous_goal)}" - else: - yield f"{self.indent_prefix}true" - - extra_goals_to_generate = [] - - steps = goal.plan.steps - - if len(steps) == 0: - yield f"{self.arrow_prefix}true." - return - - first_step = steps[0] - yield ( - f"{self.arrow_prefix}{self._slugify(first_step, include_prefix=True)}" - f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" - ) - if isinstance(first_step, Goal): - extra_goals_to_generate.append(first_step) - - for step in steps[1:-1]: - yield f"{self.indent_prefix}{self._slugify(step, include_prefix=True)};" - if isinstance(step, Goal): - extra_goals_to_generate.append(step) - - if len(steps) > 1: - last_step = steps[-1] - yield ( - f"{self.indent_prefix}{self._slugify(last_step, include_prefix=True)}" - f"{'.' if goal.can_fail else ';'}" - ) - if isinstance(last_step, Goal): - extra_goals_to_generate.append(last_step) - - if not goal.can_fail: - yield f"{self.indent_prefix}+achieved_{self._slugify(goal)}." - - yield f"+{self._slugify(goal, include_prefix=True)}" - yield f"{self.arrow_prefix}true." - - yield "" - - extra_previous_goal: Goal | None = None - for extra_goal in extra_goals_to_generate: - yield from self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) - extra_previous_goal = extra_goal - - def _generate_triggers(self, program: Program) -> Iterable[str]: - yield "// --- Triggers ---" - - for phase in program.phases: - for trigger in phase.triggers: - yield from self._generate_trigger_plan(trigger, phase) - - yield from ["", ""] - - def _generate_trigger_plan(self, trigger: Trigger, phase: Phase) -> Iterable[str]: - belief_name = self._slugify(trigger.condition) - - yield f"+{belief_name}" - yield f"{self.colon_prefix}phase({phase.id})" - - extra_goals_to_generate = [] - - steps = trigger.plan.steps - - if len(steps) == 0: - yield f"{self.arrow_prefix}true." - return - - first_step = steps[0] - yield ( - f"{self.arrow_prefix}{self._slugify(first_step, include_prefix=True)}" - f"{'.' if len(steps) == 1 else ';'}" - ) - if isinstance(first_step, Goal): - extra_goals_to_generate.append(first_step) - - for step in steps[1:-1]: - yield f"{self.indent_prefix}{self._slugify(step, include_prefix=True)};" - if isinstance(step, Goal): - extra_goals_to_generate.append(step) - - if len(steps) > 1: - last_step = steps[-1] - yield f"{self.indent_prefix}{self._slugify(last_step, include_prefix=True)}." - if isinstance(last_step, Goal): - extra_goals_to_generate.append(last_step) - - yield "" - - extra_previous_goal: Goal | None = None - for extra_goal in extra_goals_to_generate: - yield from self._generate_trigger_plan_recursive(extra_goal, phase, extra_previous_goal) - extra_previous_goal = extra_goal - - def _generate_trigger_plan_recursive( - self, goal: Goal, phase: Phase, previous_goal: Goal | None = None - ) -> Iterable[str]: - yield f"+{self._slugify(goal, include_prefix=True)}" - - extra_goals_to_generate = [] - - steps = goal.plan.steps - - if len(steps) == 0: - yield f"{self.arrow_prefix}true." - return - - first_step = steps[0] - yield ( - f"{self.arrow_prefix}{self._slugify(first_step, include_prefix=True)}" - f"{'.' if len(steps) == 1 and goal.can_fail else ';'}" - ) - if isinstance(first_step, Goal): - extra_goals_to_generate.append(first_step) - - for step in steps[1:-1]: - yield f"{self.indent_prefix}{self._slugify(step, include_prefix=True)};" - if isinstance(step, Goal): - extra_goals_to_generate.append(step) - - if len(steps) > 1: - last_step = steps[-1] - yield ( - f"{self.indent_prefix}{self._slugify(last_step, include_prefix=True)}" - f"{'.' if goal.can_fail else ';'}" - ) - if isinstance(last_step, Goal): - extra_goals_to_generate.append(last_step) - - if not goal.can_fail: - yield f"{self.indent_prefix}+achieved_{self._slugify(goal)}." - - yield f"+{self._slugify(goal, include_prefix=True)}" - yield f"{self.arrow_prefix}true." - - yield "" - - extra_previous_goal: Goal | None = None - for extra_goal in extra_goals_to_generate: - yield from self._generate_goal_plan_recursive(extra_goal, phase, extra_previous_goal) - extra_previous_goal = extra_goal - - def _slugify(self, element: ProgramElement, include_prefix: bool = False) -> str: - def base_slugify_call(text: str): - return slugify(text, separator="_", stopwords=["a", "the"]) - - if type(element) is KeywordBelief: - return f'keyword_said("{element.keyword}")' - - if type(element) is SemanticBelief: - name = element.name - return f"semantic_{base_slugify_call(name if name else element.description)}" - - if isinstance(element, BasicNorm): - return f'norm("{element.norm}")' - - if isinstance(element, Goal): - return f"{'!' if include_prefix else ''}{base_slugify_call(element.name)}" - - if isinstance(element, SpeechAction): - return f'.say("{element.text}")' - - if isinstance(element, GestureAction): - return f'.gesture("{element.gesture}")' - - if isinstance(element, LLMAction): - return f'!generate_response_with_goal("{element.goal}")' - - if isinstance(element, Action.__value__): - raise NotImplementedError( - "Have not implemented an ASL string representation for this action." - ) - - if element.name == "": - raise ValueError("Name must be initialized for this type of ProgramElement.") - - return base_slugify_call(element.name) - - def _extract_basic_beliefs_from_program(self, program: Program) -> list[BasicBelief]: - beliefs = [] - - for phase in program.phases: - for norm in phase.norms: - if isinstance(norm, ConditionalNorm): - beliefs += self._extract_basic_beliefs_from_belief(norm.condition) - - for trigger in phase.triggers: - beliefs += self._extract_basic_beliefs_from_belief(trigger.condition) - - return beliefs - - def _extract_basic_beliefs_from_belief(self, belief: Belief) -> list[BasicBelief]: - if isinstance(belief, InferredBelief): - return self._extract_basic_beliefs_from_belief( - belief.left - ) + self._extract_basic_beliefs_from_belief(belief.right) - return [belief] +from control_backend.schemas.internal_message import InternalMessage +from control_backend.schemas.program import Program class BDIProgramManager(BaseAgent): @@ -611,40 +25,36 @@ class BDIProgramManager(BaseAgent): super().__init__(**kwargs) self.sub_socket = None - # async def _send_to_bdi(self, program: Program): - # """ - # Convert a received program into BDI beliefs and send them to the BDI Core Agent. - # - # Currently, it takes the **first phase** of the program and extracts: - # - **Norms**: Constraints or rules the agent must follow. - # - **Goals**: Objectives the agent must achieve. - # - # These are sent as a ``BeliefMessage`` with ``replace=True``, meaning they will - # overwrite any existing norms/goals of the same name in the BDI agent. - # - # :param program: The program object received from the API. - # """ - # first_phase = program.phases[0] - # norms_belief = Belief( - # name="norms", - # arguments=[norm.norm for norm in first_phase.norms], - # replace=True, - # ) - # goals_belief = Belief( - # name="goals", - # arguments=[goal.description for goal in first_phase.goals], - # replace=True, - # ) - # program_beliefs = BeliefMessage(beliefs=[norms_belief, goals_belief]) - # - # message = InternalMessage( - # to=settings.agent_settings.bdi_core_name, - # sender=self.name, - # body=program_beliefs.model_dump_json(), - # thread="beliefs", - # ) - # await self.send(message) - # self.logger.debug("Sent new norms and goals to the BDI agent.") + async def _create_agentspeak_and_send_to_bdi(self, program: Program): + """ + Convert a received program into BDI beliefs and send them to the BDI Core Agent. + + Currently, it takes the **first phase** of the program and extracts: + - **Norms**: Constraints or rules the agent must follow. + - **Goals**: Objectives the agent must achieve. + + These are sent as a ``BeliefMessage`` with ``replace=True``, meaning they will + overwrite any existing norms/goals of the same name in the BDI agent. + + :param program: The program object received from the API. + """ + asg = AgentSpeakGenerator() + + asl_str = asg.generate(program) + + file_name = "src/control_backend/agents/bdi/agentspeak.asl" + + with open(file_name, "w") as f: + f.write(asl_str) + + msg = InternalMessage( + sender=self.name, + to=settings.agent_settings.bdi_core_name, + body=file_name, + thread="new_program", + ) + + await self.send(msg) async def _receive_programs(self): """ @@ -662,7 +72,7 @@ class BDIProgramManager(BaseAgent): self.logger.exception("Received an invalid program.") continue - await self._send_to_bdi(program) + await self._create_agentspeak_and_send_to_bdi(program) async def setup(self): """ @@ -678,7 +88,3 @@ class BDIProgramManager(BaseAgent): self.sub_socket.subscribe("program") self.add_behavior(self._receive_programs()) - - -if __name__ == "__main__": - do_things() diff --git a/src/control_backend/main.py b/src/control_backend/main.py index 2c8b766..d14d467 100644 --- a/src/control_backend/main.py +++ b/src/control_backend/main.py @@ -117,7 +117,6 @@ async def lifespan(app: FastAPI): BDICoreAgent, { "name": settings.agent_settings.bdi_core_name, - "asl": "src/control_backend/agents/bdi/rules.asl", }, ), "BeliefCollectorAgent": ( diff --git a/test/unit/agents/bdi/test_bdi_program_manager.py b/test/unit/agents/bdi/test_bdi_program_manager.py index a54360c..968b995 100644 --- a/test/unit/agents/bdi/test_bdi_program_manager.py +++ b/test/unit/agents/bdi/test_bdi_program_manager.py @@ -39,7 +39,7 @@ async def test_send_to_bdi(): manager.send = AsyncMock() program = Program.model_validate_json(make_valid_program_json()) - await manager._send_to_bdi(program) + await manager._create_agentspeak_and_send_to_bdi(program) assert manager.send.await_count == 1 msg: InternalMessage = manager.send.await_args[0][0] @@ -62,7 +62,7 @@ async def test_receive_programs_valid_and_invalid(): manager = BDIProgramManager(name="program_manager_test") manager.sub_socket = sub - manager._send_to_bdi = AsyncMock() + manager._create_agentspeak_and_send_to_bdi = AsyncMock() try: # Will give StopAsyncIteration when the predefined `sub.recv_multipart` side-effects run out @@ -71,7 +71,7 @@ async def test_receive_programs_valid_and_invalid(): pass # Only valid Program should have triggered _send_to_bdi - assert manager._send_to_bdi.await_count == 1 - forwarded: Program = manager._send_to_bdi.await_args[0][0] + assert manager._create_agentspeak_and_send_to_bdi.await_count == 1 + forwarded: Program = manager._create_agentspeak_and_send_to_bdi.await_args[0][0] assert forwarded.phases[0].norms[0].norm == "N1" assert forwarded.phases[0].goals[0].description == "G1" From 3406e9ac2f468b9ad2e575378007d5e0736c0ef7 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Tue, 6 Jan 2026 15:26:44 +0100 Subject: [PATCH 19/68] feat: make the pipeline work with Program and AgentSpeak ref: N25B-429 --- .../agents/bdi/agentspeak_generator.py | 80 +++++++++++++++---- .../agents/bdi/bdi_core_agent.py | 31 +++---- .../agents/bdi/belief_collector_agent.py | 2 +- .../agents/bdi/default_behavior.asl | 5 ++ src/control_backend/agents/bdi/rules.asl | 6 -- src/control_backend/agents/bdi/test.asl | 0 src/control_backend/schemas/program.py | 2 +- 7 files changed, 88 insertions(+), 38 deletions(-) create mode 100644 src/control_backend/agents/bdi/default_behavior.asl delete mode 100644 src/control_backend/agents/bdi/rules.asl delete mode 100644 src/control_backend/agents/bdi/test.asl diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 4f892e1..a446f13 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -42,9 +42,9 @@ class AgentSpeakGenerator: def generate(self, program: Program) -> str: self._asp = AstProgram() - self._asp.rules.append(AstRule(AstLiteral("phase", [AstString("start")]))) + self._asp.rules.append(AstRule(self._astify(program.phases[0]))) self._add_keyword_inference() - self._add_response_goal() + self._add_default_plans() self._process_phases(program.phases) @@ -66,11 +66,16 @@ class AgentSpeakGenerator: ) ) - def _add_response_goal(self): + def _add_default_plans(self): + self._add_reply_with_goal_plan() + self._add_say_plan() + self._add_reply_plan() + + def _add_reply_with_goal_plan(self): self._asp.plans.append( AstPlan( TriggerType.ADDED_GOAL, - AstLiteral("generate_response_with_goal", [AstVar("Goal")]), + AstLiteral("reply_with_goal", [AstVar("Goal")]), [AstLiteral("user_said", [AstVar("Message")])], [ AstStatement(StatementType.ADD_BELIEF, AstLiteral("responded_this_turn")), @@ -91,12 +96,59 @@ class AgentSpeakGenerator: ) ) + def _add_say_plan(self): + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("say", [AstVar("Text")]), + [], + [ + AstStatement(StatementType.ADD_BELIEF, AstLiteral("responded_this_turn")), + AstStatement(StatementType.DO_ACTION, AstLiteral("say", [AstVar("Text")])), + ], + ) + ) + + def _add_reply_plan(self): + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("reply"), + [AstLiteral("user_said", [AstVar("Message")])], + [ + AstStatement(StatementType.ADD_BELIEF, AstLiteral("responded_this_turn")), + AstStatement( + StatementType.DO_ACTION, + AstLiteral( + "findall", + [AstVar("Norm"), AstLiteral("norm", [AstVar("Norm")]), AstVar("Norms")], + ), + ), + AstStatement( + StatementType.DO_ACTION, + AstLiteral("reply", [AstVar("Message"), AstVar("Norms")]), + ), + ], + ) + ) + def _process_phases(self, phases: list[Phase]) -> None: for curr_phase, next_phase in zip([None] + phases, phases + [None], strict=True): if curr_phase: self._process_phase(curr_phase) self._add_phase_transition(curr_phase, next_phase) + # End phase behavior + # When deleting this, the entire `reply` plan and action can be deleted + self._asp.plans.append( + AstPlan( + type=TriggerType.ADDED_BELIEF, + trigger_literal=AstLiteral("user_said", [AstVar("Message")]), + context=[AstLiteral("phase", [AstString("end")])], + body=[AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("reply"))], + ) + ) + def _process_phase(self, phase: Phase) -> None: for norm in phase.norms: self._process_norm(norm, phase) @@ -112,14 +164,14 @@ class AgentSpeakGenerator: self._process_trigger(trigger, phase) def _add_phase_transition(self, from_phase: Phase | None, to_phase: Phase | None) -> None: - from_phase_ast = ( - self._astify(from_phase) if from_phase else AstLiteral("phase", [AstString("start")]) - ) + if from_phase is None: + return + from_phase_ast = self._astify(from_phase) to_phase_ast = ( self._astify(to_phase) if to_phase else AstLiteral("phase", [AstString("end")]) ) - context = [from_phase_ast] + context = [from_phase_ast, ~AstLiteral("responded_this_turn")] if from_phase and from_phase.goals: context.append(self._astify(from_phase.goals[-1], achieved=True)) @@ -221,14 +273,10 @@ class AgentSpeakGenerator: def _step_to_statement(self, step: PlanElement) -> AstStatement: match step: - case Goal() as g: - return AstStatement(StatementType.ACHIEVE_GOAL, self._astify(g)) - case SpeechAction() | GestureAction() as a: + case Goal() | SpeechAction() | LLMAction() as a: + return AstStatement(StatementType.ACHIEVE_GOAL, self._astify(a)) + case GestureAction() as a: return AstStatement(StatementType.DO_ACTION, self._astify(a)) - case LLMAction() as la: - return AstStatement( - StatementType.ACHIEVE_GOAL, self._astify(la) - ) # LLM action is a goal in ASL # TODO: separate handling of keyword and others def _process_trigger(self, trigger: Trigger, phase: Phase) -> None: @@ -318,7 +366,7 @@ class AgentSpeakGenerator: @_astify.register def _(self, la: LLMAction) -> AstExpression: - return AstLiteral("generate_response_with_goal", [AstString(la.goal)]) + return AstLiteral("reply_with_goal", [AstString(la.goal)]) @staticmethod def _slugify_str(text: str) -> str: diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 7da6708..249b6ee 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -48,6 +48,7 @@ class BDICoreAgent(BaseAgent): # Deep copy because we don't actually want to modify the standard actions globally self.actions = copy.deepcopy(agentspeak.stdlib.actions) self._wake_bdi_loop = asyncio.Event() + self._bdi_loop_task = None async def setup(self) -> None: """ @@ -64,7 +65,7 @@ class BDICoreAgent(BaseAgent): await self._load_asl() # Start the BDI cycle loop - self.add_behavior(self._bdi_loop()) + self._bdi_loop_task = self.add_behavior(self._bdi_loop()) self._wake_bdi_loop.set() self.logger.debug("Setup complete.") @@ -72,7 +73,7 @@ class BDICoreAgent(BaseAgent): """ Load and parse the AgentSpeak source file. """ - file_name = file_name or "src/control_backend/agents/bdi/rules.asl" + file_name = file_name or "src/control_backend/agents/bdi/default_behavior.asl" try: with open(file_name) as source: @@ -135,7 +136,10 @@ class BDICoreAgent(BaseAgent): # New agentspeak file if msg.thread == "new_program": + if self._bdi_loop_task: + self._bdi_loop_task.cancel() await self._load_asl(msg.body) + self.add_behavior(self._bdi_loop()) # The message was not a belief, handle special cases based on sender match msg.sender: @@ -246,20 +250,18 @@ class BDICoreAgent(BaseAgent): the function expects (which will be located in `term.args`). """ - @self.actions.add(".reply", 3) + @self.actions.add(".reply", 2) def _reply(agent: "BDICoreAgent", term, intention): """ Let the LLM generate a response to a user's utterance with the current norms and goals. """ message_text = agentspeak.grounded(term.args[0], intention.scope) norms = agentspeak.grounded(term.args[1], intention.scope) - goals = agentspeak.grounded(term.args[2], intention.scope) self.logger.debug("Norms: %s", norms) - self.logger.debug("Goals: %s", goals) self.logger.debug("User text: %s", message_text) - asyncio.create_task(self._send_to_llm(str(message_text), str(norms), str(goals))) + self.add_behavior(self._send_to_llm(str(message_text), str(norms), "")) yield @self.actions.add(".reply_with_goal", 3) @@ -278,7 +280,7 @@ class BDICoreAgent(BaseAgent): norms, goal, ) - # asyncio.create_task(self._send_to_llm(str(message_text), norms, str(goal))) + self.add_behavior(self._send_to_llm(str(message_text), str(norms), str(goal))) yield @self.actions.add(".say", 1) @@ -290,13 +292,14 @@ class BDICoreAgent(BaseAgent): self.logger.debug('"say" action called with text=%s', message_text) - # speech_command = SpeechCommand(data=message_text) - # speech_message = InternalMessage( - # to=settings.agent_settings.robot_speech_name, - # sender=settings.agent_settings.bdi_core_name, - # body=speech_command.model_dump_json(), - # ) - # asyncio.create_task(agent.send(speech_message)) + speech_command = SpeechCommand(data=message_text) + speech_message = InternalMessage( + to=settings.agent_settings.robot_speech_name, + sender=settings.agent_settings.bdi_core_name, + body=speech_command.model_dump_json(), + ) + # TODO: add to conversation history + self.add_behavior(self.send(speech_message)) yield @self.actions.add(".gesture", 2) diff --git a/src/control_backend/agents/bdi/belief_collector_agent.py b/src/control_backend/agents/bdi/belief_collector_agent.py index 788cff1..81c5ab2 100644 --- a/src/control_backend/agents/bdi/belief_collector_agent.py +++ b/src/control_backend/agents/bdi/belief_collector_agent.py @@ -101,7 +101,7 @@ class BDIBeliefCollectorAgent(BaseAgent): :return: A Belief object if the input is valid or None. """ try: - return Belief(name=name, arguments=arguments) + return Belief(name=name, arguments=arguments, replace=name == "user_said") except ValidationError: return None diff --git a/src/control_backend/agents/bdi/default_behavior.asl b/src/control_backend/agents/bdi/default_behavior.asl new file mode 100644 index 0000000..249689a --- /dev/null +++ b/src/control_backend/agents/bdi/default_behavior.asl @@ -0,0 +1,5 @@ +norms(""). + ++user_said(Message) : norms(Norms) <- + -user_said(Message); + .reply(Message, Norms). diff --git a/src/control_backend/agents/bdi/rules.asl b/src/control_backend/agents/bdi/rules.asl deleted file mode 100644 index cc9b4ef..0000000 --- a/src/control_backend/agents/bdi/rules.asl +++ /dev/null @@ -1,6 +0,0 @@ -norms(""). -goals(""). - -+user_said(Message) : norms(Norms) & goals(Goals) <- - -user_said(Message); - .reply(Message, Norms, Goals). diff --git a/src/control_backend/agents/bdi/test.asl b/src/control_backend/agents/bdi/test.asl deleted file mode 100644 index e69de29..0000000 diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 5a8caa9..be538b0 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -194,7 +194,7 @@ class Phase(ProgramElement): """ name: str = "" - norms: list[Norm] + norms: list[BasicNorm | ConditionalNorm] goals: list[Goal] triggers: list[Trigger] From cabe35cdbd6d29122f03c439eeaa11b7af8abb45 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 7 Jan 2026 11:44:48 +0100 Subject: [PATCH 20/68] feat: integrate AgentSpeak with semantic belief extraction ref: N25B-429 --- .../agents/bdi/agentspeak_generator.py | 8 ++- .../agents/bdi/bdi_program_manager.py | 49 ++++++++++++- .../agents/bdi/text_belief_extractor_agent.py | 70 +++++-------------- src/control_backend/schemas/belief_list.py | 14 ++++ src/control_backend/schemas/program.py | 3 +- test/unit/agents/bdi/test_bdi_core_agent.py | 6 +- .../agents/bdi/test_bdi_program_manager.py | 3 + .../agents/bdi/test_text_belief_extractor.py | 24 ++++++- .../api/v1/endpoints/test_program_endpoint.py | 2 + test/unit/schemas/test_ui_program_message.py | 1 + 10 files changed, 120 insertions(+), 60 deletions(-) create mode 100644 src/control_backend/schemas/belief_list.py diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index a446f13..8ec21df 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -332,7 +332,13 @@ class AgentSpeakGenerator: @_astify.register def _(self, sb: SemanticBelief) -> AstExpression: - return AstLiteral(f"semantic_{self._slugify_str(sb.description)}") + return AstLiteral(self.get_semantic_belief_slug(sb)) + + @staticmethod + def get_semantic_belief_slug(sb: SemanticBelief) -> str: + # If you need a method like this for other types, make a public slugify singledispatch for + # all types. + return f"semantic_{AgentSpeakGenerator._slugify_str(sb.name)}" @_astify.register def _(self, ib: InferredBelief) -> AstExpression: diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index f8715a7..54e7196 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -1,3 +1,5 @@ +import asyncio + import zmq from pydantic import ValidationError from zmq.asyncio import Context @@ -5,8 +7,9 @@ from zmq.asyncio import Context from control_backend.agents import BaseAgent from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator from control_backend.core.config import settings +from control_backend.schemas.belief_list import BeliefList from control_backend.schemas.internal_message import InternalMessage -from control_backend.schemas.program import Program +from control_backend.schemas.program import Belief, ConditionalNorm, InferredBelief, Program class BDIProgramManager(BaseAgent): @@ -56,6 +59,45 @@ class BDIProgramManager(BaseAgent): await self.send(msg) + @staticmethod + def _extract_beliefs_from_program(program: Program) -> list[Belief]: + beliefs: list[Belief] = [] + + for phase in program.phases: + for norm in phase.norms: + if isinstance(norm, ConditionalNorm): + beliefs += BDIProgramManager._extract_beliefs_from_belief(norm.condition) + + for trigger in phase.triggers: + beliefs += BDIProgramManager._extract_beliefs_from_belief(trigger.condition) + + return beliefs + + @staticmethod + def _extract_beliefs_from_belief(belief: Belief) -> list[Belief]: + if isinstance(belief, InferredBelief): + return BDIProgramManager._extract_beliefs_from_belief( + belief.left + ) + BDIProgramManager._extract_beliefs_from_belief(belief.right) + return [belief] + + async def _send_beliefs_to_semantic_belief_extractor(self, program: Program): + """ + Extract beliefs from the program and send them to the Semantic Belief Extractor Agent. + + :param program: The program received from the API. + """ + beliefs = BeliefList(beliefs=self._extract_beliefs_from_program(program)) + + message = InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=self.name, + body=beliefs.model_dump_json(), + thread="beliefs", + ) + + await self.send(message) + async def _receive_programs(self): """ Continuous loop that receives program updates from the HTTP endpoint. @@ -72,7 +114,10 @@ class BDIProgramManager(BaseAgent): self.logger.exception("Received an invalid program.") continue - await self._create_agentspeak_and_send_to_bdi(program) + await asyncio.gather( + self._create_agentspeak_and_send_to_bdi(program), + self._send_beliefs_to_semantic_belief_extractor(program), + ) async def setup(self): """ 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 5cc75d8..c532040 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -3,21 +3,16 @@ import json import httpx from pydantic import ValidationError -from slugify import slugify from control_backend.agents.base import BaseAgent +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_list import BeliefList 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 ( - Belief, - ConditionalNorm, - InferredBelief, - Program, - SemanticBelief, -) +from control_backend.schemas.program import SemanticBelief class TextBeliefExtractorAgent(BaseAgent): @@ -32,11 +27,12 @@ class TextBeliefExtractorAgent(BaseAgent): the message itself. """ - def __init__(self, name: str): + def __init__(self, name: str, temperature: float = settings.llm_settings.code_temperature): super().__init__(name) self.beliefs: dict[str, bool] = {} self.available_beliefs: list[SemanticBelief] = [] self.conversation = ChatHistory(messages=[]) + self.temperature = temperature async def setup(self): """ @@ -85,44 +81,18 @@ class TextBeliefExtractorAgent(BaseAgent): :param msg: The received message from the program manager. """ try: - program = Program.model_validate_json(msg.body) + belief_list = BeliefList.model_validate_json(msg.body) except ValidationError: self.logger.warning( - "Received message from program manager but it is not a valid program." + "Received message from program manager but it is not a valid list of beliefs." ) return - self.logger.debug("Received a program from the program manager.") - - self.available_beliefs = self._extract_basic_beliefs_from_program(program) - - # TODO Copied from an incomplete version of the program manager. Use that one instead. - @staticmethod - def _extract_basic_beliefs_from_program(program: Program) -> list[SemanticBelief]: - beliefs = [] - - for phase in program.phases: - for norm in phase.norms: - if isinstance(norm, ConditionalNorm): - beliefs += TextBeliefExtractorAgent._extract_basic_beliefs_from_belief( - norm.condition - ) - - for trigger in phase.triggers: - beliefs += TextBeliefExtractorAgent._extract_basic_beliefs_from_belief( - trigger.condition - ) - - return beliefs - - # TODO Copied from an incomplete version of the program manager. Use that one instead. - @staticmethod - def _extract_basic_beliefs_from_belief(belief: Belief) -> list[SemanticBelief]: - if isinstance(belief, InferredBelief): - return TextBeliefExtractorAgent._extract_basic_beliefs_from_belief( - belief.left - ) + TextBeliefExtractorAgent._extract_basic_beliefs_from_belief(belief.right) - return [belief] + self.available_beliefs = [b for b in belief_list.beliefs if isinstance(b, SemanticBelief)] + self.logger.debug( + "Received %d beliefs from the program manager.", + len(self.available_beliefs), + ) async def _user_said(self, text: str): """ @@ -207,8 +177,7 @@ class TextBeliefExtractorAgent(BaseAgent): @staticmethod def _create_belief_schema(belief: SemanticBelief) -> tuple[str, dict]: - # TODO: use real belief names - return belief.name or slugify(belief.description), { + return AgentSpeakGenerator.get_semantic_belief_slug(belief), { "type": ["boolean", "null"], "description": belief.description, } @@ -237,10 +206,9 @@ class TextBeliefExtractorAgent(BaseAgent): @staticmethod def _format_beliefs(beliefs: list[SemanticBelief]): - # TODO: use real belief names return "\n".join( [ - f"- {belief.name or slugify(belief.description)}: {belief.description}" + f"- {AgentSpeakGenerator.get_semantic_belief_slug(belief)}: {belief.description}" for belief in beliefs ] ) @@ -267,7 +235,7 @@ Given the above conversation, what beliefs can be inferred? If there is no relevant information about a belief belief, give null. In case messages conflict, prefer using the most recent messages for inference. -Choose from the following list of beliefs, formatted as (belief_name, description): +Choose from the following list of beliefs, formatted as `- : `: {self._format_beliefs(beliefs)} Respond with a JSON similar to the following, but with the property names as given above: @@ -304,8 +272,7 @@ Respond with a JSON similar to the following, but with the property names as giv return None - @staticmethod - async def _query_llm(prompt: str, schema: dict) -> dict: + async def _query_llm(self, prompt: str, schema: dict) -> dict: """ Query an LLM with the given prompt and schema, return an instance of a dict conforming to that schema. @@ -333,7 +300,7 @@ Respond with a JSON similar to the following, but with the property names as giv }, }, "reasoning_effort": "low", - "temperature": settings.llm_settings.code_temperature, + "temperature": self.temperature, "stream": False, }, timeout=None, @@ -342,4 +309,5 @@ Respond with a JSON similar to the following, but with the property names as giv response_json = response.json() json_message = response_json["choices"][0]["message"]["content"] - return json.loads(json_message) + beliefs = json.loads(json_message) + return beliefs diff --git a/src/control_backend/schemas/belief_list.py b/src/control_backend/schemas/belief_list.py new file mode 100644 index 0000000..ec6a7a1 --- /dev/null +++ b/src/control_backend/schemas/belief_list.py @@ -0,0 +1,14 @@ +from pydantic import BaseModel + +from control_backend.schemas.program import Belief as ProgramBelief + + +class BeliefList(BaseModel): + """ + Represents a list of beliefs, separated from a program. Useful in agents which need to + communicate beliefs. + + :ivar: beliefs: The list of beliefs. + """ + + beliefs: list[ProgramBelief] diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index be538b0..df20954 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -43,7 +43,6 @@ class SemanticBelief(ProgramElement): :ivar description: Description of how to form the belief, used by the LLM. """ - name: str = "" description: str @@ -113,10 +112,12 @@ class Goal(ProgramElement): 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. + :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 diff --git a/test/unit/agents/bdi/test_bdi_core_agent.py b/test/unit/agents/bdi/test_bdi_core_agent.py index 2325a57..64f2ca7 100644 --- a/test/unit/agents/bdi/test_bdi_core_agent.py +++ b/test/unit/agents/bdi/test_bdi_core_agent.py @@ -20,7 +20,7 @@ def mock_agentspeak_env(): @pytest.fixture def agent(): - agent = BDICoreAgent("bdi_agent", "dummy.asl") + agent = BDICoreAgent("bdi_agent") agent.send = AsyncMock() agent.bdi_agent = MagicMock() return agent @@ -133,14 +133,14 @@ async def test_custom_actions(agent): # Invoke action mock_term = MagicMock() - mock_term.args = ["Hello", "Norm", "Goal"] + mock_term.args = ["Hello", "Norm"] mock_intention = MagicMock() # Run generator gen = action_fn(agent, mock_term, mock_intention) next(gen) # Execute - agent._send_to_llm.assert_called_with("Hello", "Norm", "Goal") + agent._send_to_llm.assert_called_with("Hello", "Norm", "") def test_add_belief_sets_event(agent): diff --git a/test/unit/agents/bdi/test_bdi_program_manager.py b/test/unit/agents/bdi/test_bdi_program_manager.py index c24b2d6..a20b058 100644 --- a/test/unit/agents/bdi/test_bdi_program_manager.py +++ b/test/unit/agents/bdi/test_bdi_program_manager.py @@ -32,6 +32,8 @@ def make_valid_program_json(norm="N1", goal="G1") -> str: Goal( id=uuid.uuid4(), name=goal, + description="This description can be used to determine whether the goal " + "has been achieved.", plan=Plan( id=uuid.uuid4(), name="Goal Plan", @@ -75,6 +77,7 @@ async def test_receive_programs_valid_and_invalid(): ] manager = BDIProgramManager(name="program_manager_test") + manager._internal_pub_socket = AsyncMock() manager.sub_socket = sub manager._create_agentspeak_and_send_to_bdi = AsyncMock() diff --git a/test/unit/agents/bdi/test_text_belief_extractor.py b/test/unit/agents/bdi/test_text_belief_extractor.py index 827adbc..176afd2 100644 --- a/test/unit/agents/bdi/test_text_belief_extractor.py +++ b/test/unit/agents/bdi/test_text_belief_extractor.py @@ -8,9 +8,11 @@ import pytest from control_backend.agents.bdi import TextBeliefExtractorAgent from control_backend.core.agent_system import InternalMessage from control_backend.core.config import settings +from control_backend.schemas.belief_list import BeliefList from control_backend.schemas.belief_message import BeliefMessage from control_backend.schemas.program import ( ConditionalNorm, + KeywordBelief, LLMAction, Phase, Plan, @@ -186,13 +188,31 @@ async def test_retry_query_llm_fail_immediately(agent): @pytest.mark.asyncio -async def test_extracting_beliefs_from_program(agent, sample_program): +async def test_extracting_semantic_beliefs(agent): + """ + The Program Manager sends beliefs to this agent. Test whether the agent handles them correctly. + """ assert len(agent.available_beliefs) == 0 + beliefs = BeliefList( + beliefs=[ + KeywordBelief( + id=uuid.uuid4(), + name="keyword_hello", + keyword="hello", + ), + SemanticBelief( + id=uuid.uuid4(), name="semantic_hello_1", description="Some semantic belief 1" + ), + SemanticBelief( + id=uuid.uuid4(), name="semantic_hello_2", description="Some semantic belief 2" + ), + ] + ) await agent.handle_message( InternalMessage( to=settings.agent_settings.text_belief_extractor_name, sender=settings.agent_settings.bdi_program_manager_name, - body=sample_program.model_dump_json(), + body=beliefs.model_dump_json(), ), ) assert len(agent.available_beliefs) == 2 diff --git a/test/unit/api/v1/endpoints/test_program_endpoint.py b/test/unit/api/v1/endpoints/test_program_endpoint.py index 379767a..c1a3fd9 100644 --- a/test/unit/api/v1/endpoints/test_program_endpoint.py +++ b/test/unit/api/v1/endpoints/test_program_endpoint.py @@ -43,6 +43,8 @@ def make_valid_program_dict(): Goal( id=uuid.uuid4(), name="Some goal", + description="This description can be used to determine whether the goal " + "has been achieved.", plan=Plan( id=uuid.uuid4(), name="Goal Plan", diff --git a/test/unit/schemas/test_ui_program_message.py b/test/unit/schemas/test_ui_program_message.py index 6014db7..6f6d5fd 100644 --- a/test/unit/schemas/test_ui_program_message.py +++ b/test/unit/schemas/test_ui_program_message.py @@ -31,6 +31,7 @@ def base_goal() -> Goal: return Goal( id=uuid.uuid4(), name="testGoalName", + description="This description can be used to determine whether the goal has been achieved.", plan=Plan( id=uuid.uuid4(), name="testGoalPlanName", From af832980c850dd87e800bb1c56c59775cdef696f Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 7 Jan 2026 12:24:46 +0100 Subject: [PATCH 21/68] feat: general slugify method ref: N25B-429 --- .../agents/bdi/agentspeak_generator.py | 24 +++++++++++++++++++ .../agents/bdi/text_belief_extractor_agent.py | 7 ++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 8ec21df..f2d7319 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -361,6 +361,10 @@ class AgentSpeakGenerator: def _(self, goal: Goal, achieved: bool = False) -> AstExpression: return AstLiteral(f"{'achieved_' if achieved else ''}{self._slugify_str(goal.name)}") + @_astify.register + def _(self, trigger: Trigger) -> AstExpression: + return AstLiteral(self.slugify(trigger)) + @_astify.register def _(self, sa: SpeechAction) -> AstExpression: return AstLiteral("say", [AstString(sa.text)]) @@ -374,6 +378,26 @@ class AgentSpeakGenerator: def _(self, la: LLMAction) -> AstExpression: return AstLiteral("reply_with_goal", [AstString(la.goal)]) + @staticmethod + @singledispatchmethod + def slugify(element: ProgramElement) -> str: + raise NotImplementedError(f"Cannot convert element {element} to a slug.") + + @staticmethod + @slugify.register + def _(sb: SemanticBelief) -> str: + return f"semantic_{AgentSpeakGenerator._slugify_str(sb.name)}" + + @staticmethod + @slugify.register + def _(g: Goal) -> str: + return AgentSpeakGenerator._slugify_str(g.name) + + @staticmethod + @slugify.register + def _(t: Trigger): + return f"trigger_{AgentSpeakGenerator._slugify_str(t.name)}" + @staticmethod def _slugify_str(text: str) -> str: return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"]) 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 c532040..37af8b4 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -177,7 +177,7 @@ class TextBeliefExtractorAgent(BaseAgent): @staticmethod def _create_belief_schema(belief: SemanticBelief) -> tuple[str, dict]: - return AgentSpeakGenerator.get_semantic_belief_slug(belief), { + return AgentSpeakGenerator.slugify(belief), { "type": ["boolean", "null"], "description": belief.description, } @@ -207,10 +207,7 @@ class TextBeliefExtractorAgent(BaseAgent): @staticmethod def _format_beliefs(beliefs: list[SemanticBelief]): return "\n".join( - [ - f"- {AgentSpeakGenerator.get_semantic_belief_slug(belief)}: {belief.description}" - for belief in beliefs - ] + [f"- {AgentSpeakGenerator.slugify(belief)}: {belief.description}" for belief in beliefs] ) async def _infer_beliefs( From 07d70cb781cecd6cc509228343cbfed91f0bcb13 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 7 Jan 2026 13:02:23 +0100 Subject: [PATCH 22/68] fix: single dispatch order ref: N25B-429 --- src/control_backend/agents/bdi/agentspeak_generator.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index f2d7319..1c313ce 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -378,23 +378,23 @@ class AgentSpeakGenerator: def _(self, la: LLMAction) -> AstExpression: return AstLiteral("reply_with_goal", [AstString(la.goal)]) - @staticmethod @singledispatchmethod + @staticmethod def slugify(element: ProgramElement) -> str: raise NotImplementedError(f"Cannot convert element {element} to a slug.") - @staticmethod @slugify.register + @staticmethod def _(sb: SemanticBelief) -> str: return f"semantic_{AgentSpeakGenerator._slugify_str(sb.name)}" - @staticmethod @slugify.register + @staticmethod def _(g: Goal) -> str: return AgentSpeakGenerator._slugify_str(g.name) - @staticmethod @slugify.register + @staticmethod def _(t: Trigger): return f"trigger_{AgentSpeakGenerator._slugify_str(t.name)}" From 324a63e5cc60437aa7cd9f868bfe30a381070614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 7 Jan 2026 14:45:42 +0100 Subject: [PATCH 23/68] chore: add styles to user_interrupt_agent --- .../user_interrupt/user_interrupt_agent.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) 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 b2efc41..8a4d2a2 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -71,6 +71,16 @@ class UserInterruptAgent(BaseAgent): "Forwarded button press (override) with context '%s' to BDIProgramManager.", event_context, ) + + elif event_type == "next_phase": + _ = 1 + + elif event_type == "reset_phase": + _ = 1 + + elif event_type == " reset_experiment": + _ = 1 + else: self.logger.warning( "Received button press with unknown type '%s' (context: '%s').", @@ -78,6 +88,15 @@ class UserInterruptAgent(BaseAgent): event_context, ) + async def _send_experiment_control_to_bdi_core(self, type): + out_msg = InternalMessage( + to=settings.agent_settings.bdi_core_name, + sender=self.name, + thread=type, + body="", + ) + await self.send(out_msg) + async def _send_to_speech_agent(self, text_to_say: str): """ method to send prioritized speech command to RobotSpeechAgent. From 34afca6652dde4fbb4aa993d97454f11225ef5a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 7 Jan 2026 15:07:33 +0100 Subject: [PATCH 24/68] chore: automatically send the experiment controls to the bdi core in the user interupt agent. --- .../user_interrupt/user_interrupt_agent.py | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 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 8a4d2a2..e58a42b 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -72,14 +72,8 @@ class UserInterruptAgent(BaseAgent): event_context, ) - elif event_type == "next_phase": - _ = 1 - - elif event_type == "reset_phase": - _ = 1 - - elif event_type == " reset_experiment": - _ = 1 + elif event_type in ["next_phase", "reset_phase", "reset_experiment"]: + await self._send_experiment_control_to_bdi_core(event_type) else: self.logger.warning( @@ -89,12 +83,33 @@ class UserInterruptAgent(BaseAgent): ) async def _send_experiment_control_to_bdi_core(self, type): + """ + method to send experiment control buttons to bdi core. + + :param type: the type of control button we should send to the bdi core. + """ + # Switch which thread we should send to bdi core + thread = "" + match type: + case "next_phase": + thread = "force_next_phase" + case "reset_phase": + thread = "reset_current_phase" + case "reset_experiment": + thread = "reset_experiment" + case _: + self.logger.warning( + "Received unknown experiment control type '%s' to send to BDI Core.", + type, + ) + out_msg = InternalMessage( to=settings.agent_settings.bdi_core_name, sender=self.name, - thread=type, + thread=thread, body="", ) + self.logger.debug("Sending experiment control '%s' to BDI Core.", thread) await self.send(out_msg) async def _send_to_speech_agent(self, text_to_say: str): From 3189b9fee34ec373779256755c7c6fc4067f1774 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 7 Jan 2026 15:19:23 +0100 Subject: [PATCH 25/68] fix: let belief extractor send user_said belief ref: N25B-429 --- .../agents/bdi/text_belief_extractor_agent.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) 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 37af8b4..800d5e4 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -100,13 +100,12 @@ class TextBeliefExtractorAgent(BaseAgent): :param text: User's transcribed text. """ - belief = {"beliefs": {"user_said": [text]}, "type": "belief_extraction_text"} - payload = json.dumps(belief) - belief_msg = InternalMessage( - to=settings.agent_settings.bdi_belief_collector_name, + to=settings.agent_settings.bdi_core_name, sender=self.name, - body=payload, + body=BeliefMessage( + replace=[InternalBelief(name="user_said", arguments=[text])], + ).model_dump_json(), thread="beliefs", ) await self.send(belief_msg) From 76dfcb23ef3f5777877eeb2bcaf4a73a0858297d Mon Sep 17 00:00:00 2001 From: Storm Date: Wed, 7 Jan 2026 16:03:49 +0100 Subject: [PATCH 26/68] feat: added pause functionality ref: N25B-350 --- .../agents/perception/vad_agent.py | 41 ++++++++++++++++ .../user_interrupt/user_interrupt_agent.py | 47 ++++++++++++++++++- src/control_backend/schemas/ri_message.py | 11 +++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/control_backend/agents/perception/vad_agent.py b/src/control_backend/agents/perception/vad_agent.py index 8ccff0a..320a849 100644 --- a/src/control_backend/agents/perception/vad_agent.py +++ b/src/control_backend/agents/perception/vad_agent.py @@ -7,6 +7,7 @@ import zmq.asyncio as azmq from control_backend.agents import BaseAgent from control_backend.core.config import settings +from control_backend.schemas.internal_message import InternalMessage from ...schemas.program_status import PROGRAM_STATUS, ProgramStatus from .transcription_agent.transcription_agent import TranscriptionAgent @@ -86,6 +87,12 @@ class VADAgent(BaseAgent): self.audio_buffer = np.array([], dtype=np.float32) self.i_since_speech = settings.behaviour_settings.vad_initial_since_speech self._ready = asyncio.Event() + + # Pause control + self._reset_needed = False + self._paused = asyncio.Event() + self._paused.set() # Not paused at start + self.model = None async def setup(self): @@ -213,6 +220,16 @@ class VADAgent(BaseAgent): """ await self._ready.wait() while self._running: + await self._paused.wait() + + # After being unpaused, reset stream and buffers + if self._reset_needed: + self.logger.debug("Resuming: resetting stream and buffers.") + await self._reset_stream() + self.audio_buffer = np.array([], dtype=np.float32) + self.i_since_speech = settings.behaviour_settings.vad_initial_since_speech + self._reset_needed = False + assert self.audio_in_poller is not None data = await self.audio_in_poller.poll() if data is None: @@ -254,3 +271,27 @@ class VADAgent(BaseAgent): # At this point, we know that the speech has ended. # Prepend the last chunk that had no speech, for a more fluent boundary self.audio_buffer = chunk + +async def handle_message(self, msg: InternalMessage): + """ + Handle incoming messages. + + Expects messages to pause or resume the VAD processing from User Interrupt Agent. + + :param msg: The received internal message. + """ + sender = msg.sender + + if sender == settings.agent_settings.user_interrupt_name: + if msg.body == "PAUSE": + self.logger.info("Pausing VAD processing.") + self._paused.clear() + # If the robot needs to pick up speaking where it left off, do not set _reset_needed + self._reset_needed = True + elif msg.body == "RESUME": + self.logger.info("Resuming VAD processing.") + self._paused.set() + else: + self.logger.warning(f"Unknown command from User Interrupt Agent: {msg.body}") + else: + self.logger.debug(f"Ignoring message from unknown sender: {sender}") 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 e58a42b..842231a 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -6,7 +6,12 @@ from zmq.asyncio import Context from control_backend.agents import BaseAgent from control_backend.core.agent_system import InternalMessage from control_backend.core.config import settings -from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, SpeechCommand +from control_backend.schemas.ri_message import ( + GestureCommand, + PauseCommand, + RIEndpoint, + SpeechCommand, +) class UserInterruptAgent(BaseAgent): @@ -71,7 +76,12 @@ class UserInterruptAgent(BaseAgent): "Forwarded button press (override) with context '%s' to BDIProgramManager.", event_context, ) - + elif event_type == "pause": + await self._send_pause_command(event_context) + if event_context: + self.logger.info("Sent pause command.") + else: + self.logger.info("Sent resume command.") elif event_type in ["next_phase", "reset_phase", "reset_experiment"]: await self._send_experiment_control_to_bdi_core(event_type) @@ -163,6 +173,39 @@ class UserInterruptAgent(BaseAgent): "Sent button_override belief with id '%s' to Program manager.", belief_id, ) + + async def _send_pause_command(self, pause : bool): + """ + Send a pause command to the Robot Interface via the RI Communication Agent. + Send a pause command to the other internal agents; for now just VAD agent. + """ + cmd = PauseCommand(data=pause) + message = InternalMessage( + to=settings.agent_settings.ri_communication_name, + sender=self.name, + body=cmd.model_dump_json(), + ) + await self.send(message) + + if pause: + # Send pause to VAD agent + vad_message = InternalMessage( + to=settings.agent_settings.vad_name, + sender=self.name, + body="PAUSE", + ) + await self.send(vad_message) + self.logger.info("Sent pause command to VAD Agent and RI Communication Agent.") + else: + # Send resume to VAD agent + vad_message = InternalMessage( + to=settings.agent_settings.vad_name, + sender=self.name, + body="RESUME", + ) + await self.send(vad_message) + self.logger.info("Sent resume command to VAD Agent and RI Communication Agent.") + async def setup(self): """ diff --git a/src/control_backend/schemas/ri_message.py b/src/control_backend/schemas/ri_message.py index a48dec6..7c1ef22 100644 --- a/src/control_backend/schemas/ri_message.py +++ b/src/control_backend/schemas/ri_message.py @@ -64,3 +64,14 @@ class GestureCommand(RIMessage): if self.endpoint not in allowed: raise ValueError("endpoint must be GESTURE_SINGLE or GESTURE_TAG") return self + +class PauseCommand(RIMessage): + """ + A specific command to pause or unpause the robot's actions. + + :ivar endpoint: Fixed to ``RIEndpoint.PAUSE``. + :ivar data: A boolean indicating whether to pause (True) or unpause (False). + """ + + endpoint: RIEndpoint = RIEndpoint(RIEndpoint.PAUSE) + data: bool From aa5b386f658415e52f22b4f0390e67974f9770d1 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:08:23 +0100 Subject: [PATCH 27/68] feat: semantically determine goal completion ref: N25B-432 --- .../agents/bdi/bdi_core_agent.py | 13 +- .../agents/bdi/bdi_program_manager.py | 64 ++- .../agents/bdi/belief_collector_agent.py | 2 +- .../agents/bdi/text_belief_extractor_agent.py | 443 ++++++++++++------ src/control_backend/core/agent_system.py | 11 +- src/control_backend/schemas/belief_list.py | 5 + src/control_backend/schemas/belief_message.py | 3 + src/control_backend/schemas/program.py | 2 +- 8 files changed, 380 insertions(+), 163 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 58ece29..3baa493 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -205,12 +205,15 @@ class BDICoreAgent(BaseAgent): self.logger.debug(f"Added belief {self.format_belief_string(name, args)}") - def _remove_belief(self, name: str, args: Iterable[str]): + def _remove_belief(self, name: str, args: Iterable[str] | None): """ Removes a specific belief (with arguments), if it exists. """ - new_args = (agentspeak.Literal(arg) for arg in args) - term = agentspeak.Literal(name, new_args) + if args is None: + term = agentspeak.Literal(name) + else: + new_args = (agentspeak.Literal(arg) for arg in args) + term = agentspeak.Literal(name, new_args) result = self.bdi_agent.call( agentspeak.Trigger.removal, @@ -346,8 +349,8 @@ class BDICoreAgent(BaseAgent): self.logger.info("Message sent to LLM agent: %s", text) @staticmethod - def format_belief_string(name: str, args: Iterable[str] = []): + def format_belief_string(name: str, args: Iterable[str] | None = []): """ Given a belief's name and its args, return a string of the form "name(*args)" """ - return f"{name}{'(' if args else ''}{','.join(args)}{')' if args else ''}" + return f"{name}{'(' if args else ''}{','.join(args or [])}{')' if args else ''}" diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 54e7196..96d924d 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -7,9 +7,9 @@ from zmq.asyncio import Context from control_backend.agents import BaseAgent from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator from control_backend.core.config import settings -from control_backend.schemas.belief_list import BeliefList +from control_backend.schemas.belief_list import BeliefList, GoalList from control_backend.schemas.internal_message import InternalMessage -from control_backend.schemas.program import Belief, ConditionalNorm, InferredBelief, Program +from control_backend.schemas.program import Belief, ConditionalNorm, Goal, InferredBelief, Program class BDIProgramManager(BaseAgent): @@ -63,24 +63,23 @@ class BDIProgramManager(BaseAgent): def _extract_beliefs_from_program(program: Program) -> list[Belief]: beliefs: list[Belief] = [] + def extract_beliefs_from_belief(belief: Belief) -> list[Belief]: + if isinstance(belief, InferredBelief): + return extract_beliefs_from_belief(belief.left) + extract_beliefs_from_belief( + belief.right + ) + return [belief] + for phase in program.phases: for norm in phase.norms: if isinstance(norm, ConditionalNorm): - beliefs += BDIProgramManager._extract_beliefs_from_belief(norm.condition) + beliefs += extract_beliefs_from_belief(norm.condition) for trigger in phase.triggers: - beliefs += BDIProgramManager._extract_beliefs_from_belief(trigger.condition) + beliefs += extract_beliefs_from_belief(trigger.condition) return beliefs - @staticmethod - def _extract_beliefs_from_belief(belief: Belief) -> list[Belief]: - if isinstance(belief, InferredBelief): - return BDIProgramManager._extract_beliefs_from_belief( - belief.left - ) + BDIProgramManager._extract_beliefs_from_belief(belief.right) - return [belief] - async def _send_beliefs_to_semantic_belief_extractor(self, program: Program): """ Extract beliefs from the program and send them to the Semantic Belief Extractor Agent. @@ -98,6 +97,46 @@ class BDIProgramManager(BaseAgent): await self.send(message) + @staticmethod + def _extract_goals_from_program(program: Program) -> list[Goal]: + """ + Extract all goals from the program, including subgoals. + + :param program: The program received from the API. + :return: A list of Goal objects. + """ + goals: list[Goal] = [] + + def extract_goals_from_goal(goal_: Goal) -> list[Goal]: + goals_: list[Goal] = [goal] + for plan in goal_.plan: + if isinstance(plan, Goal): + goals_.extend(extract_goals_from_goal(plan)) + return goals_ + + for phase in program.phases: + for goal in phase.goals: + goals.extend(extract_goals_from_goal(goal)) + + return goals + + async def _send_goals_to_semantic_belief_extractor(self, program: Program): + """ + Extract goals from the program and send them to the Semantic Belief Extractor Agent. + + :param program: The program received from the API. + """ + goals = GoalList(goals=self._extract_goals_from_program(program)) + + message = InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + sender=self.name, + body=goals.model_dump_json(), + thread="goals", + ) + + await self.send(message) + async def _receive_programs(self): """ Continuous loop that receives program updates from the HTTP endpoint. @@ -117,6 +156,7 @@ class BDIProgramManager(BaseAgent): await asyncio.gather( self._create_agentspeak_and_send_to_bdi(program), self._send_beliefs_to_semantic_belief_extractor(program), + self._send_goals_to_semantic_belief_extractor(program), ) async def setup(self): diff --git a/src/control_backend/agents/bdi/belief_collector_agent.py b/src/control_backend/agents/bdi/belief_collector_agent.py index 6f89d2a..ac0e2e5 100644 --- a/src/control_backend/agents/bdi/belief_collector_agent.py +++ b/src/control_backend/agents/bdi/belief_collector_agent.py @@ -101,7 +101,7 @@ class BDIBeliefCollectorAgent(BaseAgent): :return: A Belief object if the input is valid or None. """ try: - return Belief(name=name, arguments=arguments, replace=name == "user_said") + return Belief(name=name, arguments=arguments) except ValidationError: return None 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 800d5e4..7e3570f 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -2,17 +2,45 @@ import asyncio import json import httpx -from pydantic import ValidationError +from pydantic import BaseModel, ValidationError from control_backend.agents.base import BaseAgent 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_list import BeliefList +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 SemanticBelief +from control_backend.schemas.program import Goal, SemanticBelief + +type JSONLike = None | bool | int | float | str | list["JSONLike"] | dict[str, "JSONLike"] + + +class BeliefState(BaseModel): + true: set[InternalBelief] = set() + false: set[InternalBelief] = set() + + def difference(self, other: "BeliefState") -> "BeliefState": + return BeliefState( + true=self.true - other.true, + false=self.false - other.false, + ) + + def union(self, other: "BeliefState") -> "BeliefState": + return BeliefState( + true=self.true | other.true, + false=self.false | other.false, + ) + + def __sub__(self, other): + return self.difference(other) + + def __or__(self, other): + return self.union(other) + + def __bool__(self): + return bool(self.true) or bool(self.false) class TextBeliefExtractorAgent(BaseAgent): @@ -27,12 +55,14 @@ class TextBeliefExtractorAgent(BaseAgent): the message itself. """ - def __init__(self, name: str, temperature: float = settings.llm_settings.code_temperature): + def __init__(self, name: str): super().__init__(name) - self.beliefs: dict[str, bool] = {} - self.available_beliefs: list[SemanticBelief] = [] + self._llm = self.LLM(self, settings.llm_settings.n_parallel) + self.belief_inferrer = SemanticBeliefInferrer(self._llm) + self.goal_inferrer = GoalAchievementInferrer(self._llm) + self._current_beliefs = BeliefState() + self._current_goal_completions: dict[str, bool] = {} self.conversation = ChatHistory(messages=[]) - self.temperature = temperature async def setup(self): """ @@ -53,8 +83,9 @@ class TextBeliefExtractorAgent(BaseAgent): case settings.agent_settings.transcription_name: self.logger.debug("Received text from transcriber: %s", msg.body) self._apply_conversation_message(ChatMessage(role="user", content=msg.body)) - await self._infer_new_beliefs() await self._user_said(msg.body) + await self._infer_new_beliefs() + await self._infer_goal_completions() case settings.agent_settings.llm_name: self.logger.debug("Received text from LLM: %s", msg.body) self._apply_conversation_message(ChatMessage(role="assistant", content=msg.body)) @@ -76,10 +107,19 @@ class TextBeliefExtractorAgent(BaseAgent): def _handle_program_manager_message(self, msg: InternalMessage): """ - Handle a message from the program manager: extract available beliefs from it. + Handle a message from the program manager: extract available beliefs and goals from it. :param msg: The received message from the program manager. """ + match msg.thread: + case "beliefs": + self._handle_beliefs_message(msg) + case "goals": + self._handle_goals_message(msg) + case _: + self.logger.warning("Received unexpected message from %s", msg.sender) + + def _handle_beliefs_message(self, msg: InternalMessage): try: belief_list = BeliefList.model_validate_json(msg.body) except ValidationError: @@ -88,10 +128,28 @@ class TextBeliefExtractorAgent(BaseAgent): ) return - self.available_beliefs = [b for b in belief_list.beliefs if isinstance(b, SemanticBelief)] + available_beliefs = [b for b in belief_list.beliefs if isinstance(b, SemanticBelief)] + self.belief_inferrer.available_beliefs = available_beliefs self.logger.debug( - "Received %d beliefs from the program manager.", - len(self.available_beliefs), + "Received %d semantic beliefs from the program manager.", + len(available_beliefs), + ) + + def _handle_goals_message(self, msg: InternalMessage): + try: + goals_list = GoalList.model_validate_json(msg.body) + except ValidationError: + self.logger.warning( + "Received message from program manager but it is not a valid list of goals." + ) + 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] + self.goal_inferrer.goals = available_goals + self.logger.debug( + "Received %d failable goals from the program manager.", + len(available_goals), ) async def _user_said(self, text: str): @@ -111,109 +169,199 @@ class TextBeliefExtractorAgent(BaseAgent): await self.send(belief_msg) async def _infer_new_beliefs(self): - """ - Process conversation history to extract beliefs, semantically. Any changed beliefs are sent - to the BDI core. - """ - # Return instantly if there are no beliefs to infer - if not self.available_beliefs: + conversation_beliefs = await self.belief_inferrer.infer_from_conversation(self.conversation) + + new_beliefs = conversation_beliefs - self._current_beliefs + if not new_beliefs: return - candidate_beliefs = await self._infer_turn() - belief_changes = BeliefMessage() - for belief_key, belief_value in candidate_beliefs.items(): - if belief_value is None: - continue - old_belief_value = self.beliefs.get(belief_key) - if belief_value == old_belief_value: - continue + self._current_beliefs |= new_beliefs - self.beliefs[belief_key] = belief_value + belief_changes = BeliefMessage( + create=list(new_beliefs.true), + delete=list(new_beliefs.false), + ) - belief = InternalBelief(name=belief_key, arguments=None) - if belief_value: - belief_changes.create.append(belief) - else: - belief_changes.delete.append(belief) - - # Return if there were no changes in beliefs - if not belief_changes.has_values(): - return - - beliefs_message = InternalMessage( + message = InternalMessage( to=settings.agent_settings.bdi_core_name, sender=self.name, body=belief_changes.model_dump_json(), thread="beliefs", ) - await self.send(beliefs_message) + await self.send(message) - @staticmethod - def _split_into_chunks[T](items: list[T], n: int) -> list[list[T]]: - k, m = divmod(len(items), n) - return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] + async def _infer_goal_completions(self): + goal_completions = await self.goal_inferrer.infer_from_conversation(self.conversation) - async def _infer_turn(self) -> dict: + new_achieved = [ + InternalBelief(name=goal, arguments=None) + for goal, achieved in goal_completions.items() + if achieved and self._current_goal_completions.get(goal) != achieved + ] + new_not_achieved = [ + InternalBelief(name=goal, arguments=None) + for goal, achieved in goal_completions.items() + if not achieved and self._current_goal_completions.get(goal) != achieved + ] + for goal, achieved in goal_completions.items(): + self._current_goal_completions[goal] = achieved + + if not new_achieved and not new_not_achieved: + return + + belief_changes = BeliefMessage( + create=new_achieved, + delete=new_not_achieved, + ) + message = InternalMessage( + to=settings.agent_settings.bdi_core_name, + sender=self.name, + body=belief_changes.model_dump_json(), + thread="beliefs", + ) + await self.send(message) + + class LLM: """ - Process the stored conversation history to extract semantic beliefs. Returns a list of - beliefs that have been set to ``True``, ``False`` or ``None``. - - :return: A dict mapping belief names to a value ``True``, ``False`` or ``None``. + Class that handles sending structured generation requests to an LLM. """ + + def __init__(self, agent: "TextBeliefExtractorAgent", n_parallel: int): + self._agent = agent + self._semaphore = asyncio.Semaphore(n_parallel) + + async def query(self, prompt: str, schema: dict, tries: int = 3) -> JSONLike | None: + """ + Query the LLM with the given prompt and schema, return an instance of a dict conforming + to this schema. Try ``tries`` times, or return None. + + :param prompt: Prompt to be queried. + :param schema: Schema to be queried. + :param tries: Number of times to try to query the LLM. + :return: An instance of a dict conforming to this schema, or None if failed. + """ + try_count = 0 + while try_count < tries: + try_count += 1 + + try: + return await self._query_llm(prompt, schema) + except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e: + if try_count < tries: + continue + self._agent.logger.exception( + "Failed to get LLM response after %d tries.", + try_count, + exc_info=e, + ) + + return None + + async def _query_llm(self, prompt: str, schema: dict) -> JSONLike: + """ + Query an LLM with the given prompt and schema, return an instance of a dict conforming + to that schema. + + :param prompt: The prompt to be queried. + :param schema: Schema to use during response. + :return: A dict conforming to this schema. + :raises httpx.HTTPStatusError: If the LLM server responded with an error. + :raises json.JSONDecodeError: If the LLM response was not valid JSON. May happen if the + response was cut off early due to length limitations. + :raises KeyError: If the LLM server responded with no error, but the response was + invalid. + """ + async with self._semaphore: + async with httpx.AsyncClient() as client: + response = await client.post( + settings.llm_settings.local_llm_url, + json={ + "model": settings.llm_settings.local_llm_model, + "messages": [{"role": "user", "content": prompt}], + "response_format": { + "type": "json_schema", + "json_schema": { + "name": "Beliefs", + "strict": True, + "schema": schema, + }, + }, + "reasoning_effort": "low", + "temperature": settings.llm_settings.code_temperature, + "stream": False, + }, + timeout=30.0, + ) + response.raise_for_status() + + response_json = response.json() + json_message = response_json["choices"][0]["message"]["content"] + return json.loads(json_message) + + +class SemanticBeliefInferrer: + """ + Class that handles only prompting an LLM for semantic beliefs. + """ + + def __init__( + self, + llm: "TextBeliefExtractorAgent.LLM", + available_beliefs: list[SemanticBelief] | None = None, + ): + self._llm = llm + self.available_beliefs: list[SemanticBelief] = available_beliefs or [] + + async def infer_from_conversation(self, conversation: ChatHistory) -> BeliefState: + """ + Process conversation history to extract beliefs, semantically. The result is an object that + describes all beliefs that hold or don't hold based on the full conversation. + + :param conversation: The conversation history to be processed. + :return: An object that describes beliefs. + """ + # Return instantly if there are no beliefs to infer + if not self.available_beliefs: + return BeliefState() + n_parallel = max(1, min(settings.llm_settings.n_parallel - 1, len(self.available_beliefs))) - all_beliefs = await asyncio.gather( + all_beliefs: list[dict[str, bool | None] | None] = await asyncio.gather( *[ - self._infer_beliefs(self.conversation, beliefs) + self._infer_beliefs(conversation, beliefs) for beliefs in self._split_into_chunks(self.available_beliefs, n_parallel) ] ) - retval = {} + retval = BeliefState() for beliefs in all_beliefs: if beliefs is None: continue - retval.update(beliefs) + for belief_name, belief_holds in beliefs.items(): + if belief_holds is None: + continue + belief = InternalBelief(name=belief_name, arguments=None) + if belief_holds: + retval.true.add(belief) + else: + retval.false.add(belief) return retval @staticmethod - def _create_belief_schema(belief: SemanticBelief) -> tuple[str, dict]: - return AgentSpeakGenerator.slugify(belief), { - "type": ["boolean", "null"], - "description": belief.description, - } + def _split_into_chunks[T](items: list[T], n: int) -> list[list[T]]: + """ + Split a list into ``n`` chunks, making each chunk approximately ``len(items) / n`` long. - @staticmethod - def _create_beliefs_schema(beliefs: list[SemanticBelief]) -> dict: - belief_schemas = [ - TextBeliefExtractorAgent._create_belief_schema(belief) for belief in beliefs - ] - - return { - "type": "object", - "properties": dict(belief_schemas), - "required": [name for name, _ in belief_schemas], - } - - @staticmethod - def _format_message(message: ChatMessage): - return f"{message.role.upper()}:\n{message.content}" - - @staticmethod - def _format_conversation(conversation: ChatHistory): - return "\n\n".join( - [TextBeliefExtractorAgent._format_message(message) for message in conversation.messages] - ) - - @staticmethod - def _format_beliefs(beliefs: list[SemanticBelief]): - return "\n".join( - [f"- {AgentSpeakGenerator.slugify(belief)}: {belief.description}" for belief in beliefs] - ) + :param items: The list of items to split. + :param n: The number of desired chunks. + :return: A list of chunks each approximately ``len(items) / n`` long. + """ + k, m = divmod(len(items), n) + return [items[i * k + min(i, m) : (i + 1) * k + min(i + 1, m)] for i in range(n)] async def _infer_beliefs( self, conversation: ChatHistory, beliefs: list[SemanticBelief], - ) -> dict | None: + ) -> dict[str, bool | None] | None: """ Infer given beliefs based on the given conversation. :param conversation: The conversation to infer beliefs from. @@ -240,70 +388,79 @@ Respond with a JSON similar to the following, but with the property names as giv schema = self._create_beliefs_schema(beliefs) - return await self._retry_query_llm(prompt, schema) + return await self._llm.query(prompt, schema) - async def _retry_query_llm(self, prompt: str, schema: dict, tries: int = 3) -> dict | None: + @staticmethod + def _create_belief_schema(belief: SemanticBelief) -> tuple[str, dict]: + return AgentSpeakGenerator.slugify(belief), { + "type": ["boolean", "null"], + "description": belief.description, + } + + @staticmethod + def _create_beliefs_schema(beliefs: list[SemanticBelief]) -> dict: + belief_schemas = [ + SemanticBeliefInferrer._create_belief_schema(belief) for belief in beliefs + ] + + return { + "type": "object", + "properties": dict(belief_schemas), + "required": [name for name, _ in belief_schemas], + } + + @staticmethod + def _format_message(message: ChatMessage): + return f"{message.role.upper()}:\n{message.content}" + + @staticmethod + def _format_conversation(conversation: ChatHistory): + return "\n\n".join( + [SemanticBeliefInferrer._format_message(message) for message in conversation.messages] + ) + + @staticmethod + def _format_beliefs(beliefs: list[SemanticBelief]): + return "\n".join( + [f"- {AgentSpeakGenerator.slugify(belief)}: {belief.description}" for belief in beliefs] + ) + + +class GoalAchievementInferrer(SemanticBeliefInferrer): + def __init__(self, llm: TextBeliefExtractorAgent.LLM): + super().__init__(llm) + self.goals = [] + + async def infer_from_conversation(self, conversation: ChatHistory) -> dict[str, bool]: """ - Query the LLM with the given prompt and schema, return an instance of a dict conforming - to this schema. Try ``tries`` times, or return None. + Determine which goals have been achieved based on the given conversation. - :param prompt: Prompt to be queried. - :param schema: Schema to be queried. - :return: An instance of a dict conforming to this schema, or None if failed. + :param conversation: The conversation to infer goal completion from. + :return: A mapping of goals and a boolean whether they have been achieved. """ - try_count = 0 - while try_count < tries: - try_count += 1 + if not self.goals: + return {} - try: - return await self._query_llm(prompt, schema) - except (httpx.HTTPError, json.JSONDecodeError, KeyError) as e: - if try_count < tries: - continue - self.logger.exception( - "Failed to get LLM response after %d tries.", - try_count, - exc_info=e, - ) + goals_achieved = await asyncio.gather( + *[self._infer_goal(conversation, g) for g in self.goals] + ) + return { + f"achieved_{AgentSpeakGenerator.slugify(goal)}": achieved + for goal, achieved in zip(self.goals, goals_achieved, strict=True) + } - return None + async def _infer_goal(self, conversation: ChatHistory, goal: Goal) -> bool: + prompt = f"""{self._format_conversation(conversation)} - async def _query_llm(self, prompt: str, schema: dict) -> dict: - """ - Query an LLM with the given prompt and schema, return an instance of a dict conforming to - that schema. +Given the above conversation, what has the following goal been achieved? - :param prompt: The prompt to be queried. - :param schema: Schema to use during response. - :return: A dict conforming to this schema. - :raises httpx.HTTPStatusError: If the LLM server responded with an error. - :raises json.JSONDecodeError: If the LLM response was not valid JSON. May happen if the - response was cut off early due to length limitations. - :raises KeyError: If the LLM server responded with no error, but the response was invalid. - """ - async with httpx.AsyncClient() as client: - response = await client.post( - settings.llm_settings.local_llm_url, - json={ - "model": settings.llm_settings.local_llm_model, - "messages": [{"role": "user", "content": prompt}], - "response_format": { - "type": "json_schema", - "json_schema": { - "name": "Beliefs", - "strict": True, - "schema": schema, - }, - }, - "reasoning_effort": "low", - "temperature": self.temperature, - "stream": False, - }, - timeout=None, - ) - response.raise_for_status() +The name of the goal: {goal.name} +Description of the goal: {goal.description} - response_json = response.json() - json_message = response_json["choices"][0]["message"]["content"] - beliefs = json.loads(json_message) - return beliefs +Answer with literally only `true` or `false` (without backticks).""" + + schema = { + "type": "boolean", + } + + return await self._llm.query(prompt, schema) diff --git a/src/control_backend/core/agent_system.py b/src/control_backend/core/agent_system.py index 9d7a47f..e12a6b2 100644 --- a/src/control_backend/core/agent_system.py +++ b/src/control_backend/core/agent_system.py @@ -192,7 +192,16 @@ class BaseAgent(ABC): :param coro: The coroutine to execute as a task. """ - task = asyncio.create_task(coro) + + async def try_coro(coro_: Coroutine): + try: + await coro_ + except asyncio.CancelledError: + self.logger.debug("A behavior was canceled successfully: %s", coro_) + except Exception: + self.logger.warning("An exception occurred in a behavior.", exc_info=True) + + task = asyncio.create_task(try_coro(coro)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) return task diff --git a/src/control_backend/schemas/belief_list.py b/src/control_backend/schemas/belief_list.py index ec6a7a1..b79247d 100644 --- a/src/control_backend/schemas/belief_list.py +++ b/src/control_backend/schemas/belief_list.py @@ -1,6 +1,7 @@ from pydantic import BaseModel from control_backend.schemas.program import Belief as ProgramBelief +from control_backend.schemas.program import Goal class BeliefList(BaseModel): @@ -12,3 +13,7 @@ class BeliefList(BaseModel): """ beliefs: list[ProgramBelief] + + +class GoalList(BaseModel): + goals: list[Goal] diff --git a/src/control_backend/schemas/belief_message.py b/src/control_backend/schemas/belief_message.py index 56a8a4a..51411b3 100644 --- a/src/control_backend/schemas/belief_message.py +++ b/src/control_backend/schemas/belief_message.py @@ -13,6 +13,9 @@ class Belief(BaseModel): name: str arguments: list[str] | None + # To make it hashable + model_config = {"frozen": True} + class BeliefMessage(BaseModel): """ diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index df20954..82c017e 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -117,7 +117,7 @@ class Goal(ProgramElement): :ivar can_fail: Whether we can fail to achieve the goal after executing the plan. """ - description: str + description: str = "" plan: Plan can_fail: bool = True From 3d49e44cf7c4e877e612d52919c44abf3e977706 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 7 Jan 2026 17:13:58 +0100 Subject: [PATCH 28/68] fix: complete pipeline working User interrupts still need to be tested. ref: N25B-429 --- .../agents/bdi/agentspeak_generator.py | 52 ++++- .../agents/bdi/bdi_core_agent.py | 177 ++++++++++++++++-- .../agents/bdi/bdi_program_manager.py | 74 +++++--- .../communication/ri_communication_agent.py | 3 +- src/control_backend/agents/llm/llm_agent.py | 24 ++- src/control_backend/core/agent_system.py | 1 + src/control_backend/core/config.py | 2 +- .../schemas/internal_message.py | 2 +- 8 files changed, 276 insertions(+), 59 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 1c313ce..17248a8 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -157,7 +157,7 @@ class AgentSpeakGenerator: previous_goal = None for goal in phase.goals: - self._process_goal(goal, phase, previous_goal) + self._process_goal(goal, phase, previous_goal, main_goal=True) previous_goal = goal for trigger in phase.triggers: @@ -192,6 +192,20 @@ class AgentSpeakGenerator: ] ) + # Notify outside world about transition + body.append( + AstStatement( + StatementType.DO_ACTION, + AstLiteral( + "notify_transition_phase", + [ + AstString(str(from_phase.id)), + AstString(str(to_phase.id) if to_phase else "end"), + ], + ), + ) + ) + self._asp.plans.append( AstPlan(TriggerType.ADDED_GOAL, AstLiteral("transition_phase"), context, body) ) @@ -213,6 +227,11 @@ class AgentSpeakGenerator: def _add_default_loop(self, phase: Phase) -> None: actions = [] + actions.append( + AstStatement( + StatementType.DO_ACTION, AstLiteral("notify_user_said", [AstVar("Message")]) + ) + ) actions.append(AstStatement(StatementType.REMOVE_BELIEF, AstLiteral("responded_this_turn"))) actions.append(AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("check_triggers"))) @@ -236,6 +255,7 @@ class AgentSpeakGenerator: phase: Phase, previous_goal: Goal | None = None, continues_response: bool = False, + main_goal: bool = False, ) -> None: context: list[AstExpression] = [self._astify(phase)] context.append(~self._astify(goal, achieved=True)) @@ -245,6 +265,13 @@ class AgentSpeakGenerator: context.append(~AstLiteral("responded_this_turn")) body = [] + if main_goal: # UI only needs to know about the main goals + body.append( + AstStatement( + StatementType.DO_ACTION, + AstLiteral("notify_goal_start", [AstString(self.slugify(goal))]), + ) + ) subgoals = [] for step in goal.plan.steps: @@ -283,11 +310,23 @@ class AgentSpeakGenerator: body = [] subgoals = [] + body.append( + AstStatement( + StatementType.DO_ACTION, + AstLiteral("notify_trigger_start", [AstString(self.slugify(trigger))]), + ) + ) for step in trigger.plan.steps: body.append(self._step_to_statement(step)) if isinstance(step, Goal): step.can_fail = False # triggers are continuous sequence subgoals.append(step) + body.append( + AstStatement( + StatementType.DO_ACTION, + AstLiteral("notify_trigger_end", [AstString(self.slugify(trigger))]), + ) + ) self._asp.plans.append( AstPlan( @@ -298,6 +337,9 @@ class AgentSpeakGenerator: ) ) + # Force trigger (from UI) + self._asp.plans.append(AstPlan(TriggerType.ADDED_GOAL, self._astify(trigger), [], body)) + for subgoal in subgoals: self._process_goal(subgoal, phase, continues_response=True) @@ -332,13 +374,7 @@ class AgentSpeakGenerator: @_astify.register def _(self, sb: SemanticBelief) -> AstExpression: - return AstLiteral(self.get_semantic_belief_slug(sb)) - - @staticmethod - def get_semantic_belief_slug(sb: SemanticBelief) -> str: - # If you need a method like this for other types, make a public slugify singledispatch for - # all types. - return f"semantic_{AgentSpeakGenerator._slugify_str(sb.name)}" + return AstLiteral(self.slugify(sb)) @_astify.register def _(self, ib: InferredBelief) -> AstExpression: diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 58ece29..aec8343 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -1,5 +1,6 @@ import asyncio import copy +import json import time from collections.abc import Iterable @@ -13,7 +14,7 @@ from control_backend.core.agent_system import InternalMessage from control_backend.core.config import settings from control_backend.schemas.belief_message import BeliefMessage from control_backend.schemas.llm_prompt_message import LLMPromptMessage -from control_backend.schemas.ri_message import SpeechCommand +from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, SpeechCommand DELIMITER = ";\n" # TODO: temporary until we support lists in AgentSpeak @@ -155,6 +156,17 @@ class BDICoreAgent(BaseAgent): body=cmd.model_dump_json(), ) await self.send(out_msg) + case settings.agent_settings.user_interrupt_name: + content = msg.body + self.logger.debug("Received user interruption: %s", content) + + match msg.thread: + case "force_phase_transition": + self._set_goal("transition_phase") + case "force_trigger": + self._force_trigger(msg.body) + case _: + self.logger.warning("Received unknow user interruption: %s", msg) def _apply_belief_changes(self, belief_changes: BeliefMessage): """ @@ -250,6 +262,37 @@ class BDICoreAgent(BaseAgent): self.logger.debug(f"Removed {removed_count} beliefs.") + def _set_goal(self, name: str, args: Iterable[str] | None = None): + args = args or [] + + if args: + merged_args = DELIMITER.join(arg for arg in args) + new_args = (agentspeak.Literal(merged_args),) + term = agentspeak.Literal(name, new_args) + else: + term = agentspeak.Literal(name) + + self.bdi_agent.call( + agentspeak.Trigger.addition, + agentspeak.GoalType.achievement, + term, + agentspeak.runtime.Intention(), + ) + + self._wake_bdi_loop.set() + + self.logger.debug(f"Set goal !{self.format_belief_string(name, args)}.") + + def _force_trigger(self, name: str): + self.bdi_agent.call( + agentspeak.Trigger.addition, + agentspeak.GoalType.achievement, + agentspeak.Literal(name), + agentspeak.runtime.Intention(), + ) + + self.logger.info("Manually forced trigger %s.", name) + def _add_custom_actions(self) -> None: """ Add any custom actions here. Inside `@self.actions.add()`, the first argument is @@ -258,7 +301,7 @@ class BDICoreAgent(BaseAgent): """ @self.actions.add(".reply", 2) - def _reply(agent: "BDICoreAgent", term, intention): + def _reply(agent, term, intention): """ Let the LLM generate a response to a user's utterance with the current norms and goals. """ @@ -291,7 +334,7 @@ class BDICoreAgent(BaseAgent): yield @self.actions.add(".say", 1) - def _say(agent: "BDICoreAgent", term, intention): + def _say(agent, term, intention): """ Make the robot say the given text instantly. """ @@ -305,12 +348,21 @@ class BDICoreAgent(BaseAgent): sender=settings.agent_settings.bdi_core_name, body=speech_command.model_dump_json(), ) - # TODO: add to conversation history + self.add_behavior(self.send(speech_message)) + + chat_history_message = InternalMessage( + to=settings.agent_settings.llm_name, + thread="assistant_message", + body=str(message_text), + ) + + self.add_behavior(self.send(chat_history_message)) + yield @self.actions.add(".gesture", 2) - def _gesture(agent: "BDICoreAgent", term, intention): + def _gesture(agent, term, intention): """ Make the robot perform the given gesture instantly. """ @@ -323,13 +375,113 @@ class BDICoreAgent(BaseAgent): gesture_name, ) - # gesture = Gesture(type=gesture_type, name=gesture_name) - # gesture_message = InternalMessage( - # to=settings.agent_settings.robot_gesture_name, - # sender=settings.agent_settings.bdi_core_name, - # body=gesture.model_dump_json(), - # ) - # asyncio.create_task(agent.send(gesture_message)) + if str(gesture_type) == "single": + endpoint = RIEndpoint.GESTURE_SINGLE + elif str(gesture_type) == "tag": + endpoint = RIEndpoint.GESTURE_TAG + else: + self.logger.warning("Gesture type %s could not be resolved.", gesture_type) + endpoint = RIEndpoint.GESTURE_SINGLE + + gesture_command = GestureCommand(endpoint=endpoint, data=gesture_name) + gesture_message = InternalMessage( + to=settings.agent_settings.robot_gesture_name, + sender=settings.agent_settings.bdi_core_name, + body=gesture_command.model_dump_json(), + ) + self.add_behavior(self.send(gesture_message)) + yield + + @self.actions.add(".notify_user_said", 1) + def _notify_user_said(agent, term, intention): + user_said = agentspeak.grounded(term.args[0], intention.scope) + + msg = InternalMessage( + to=settings.agent_settings.llm_name, thread="user_message", body=str(user_said) + ) + + self.add_behavior(self.send(msg)) + + yield + + @self.actions.add(".notify_trigger_start", 1) + def _notify_trigger_start(agent, term, intention): + """ + Notify the UI about the trigger we just started doing. + """ + trigger_name = agentspeak.grounded(term.args[0], intention.scope) + + self.logger.debug("Started trigger %s", trigger_name) + + msg = InternalMessage( + to=settings.agent_settings.user_interrupt_name, + sender=self.name, + thread="trigger_start", + body=str(trigger_name), + ) + + # TODO: check with Pim + self.add_behavior(self.send(msg)) + + yield + + @self.actions.add(".notify_trigger_end", 1) + def _notify_trigger_end(agent, term, intention): + """ + Notify the UI about the trigger we just started doing. + """ + trigger_name = agentspeak.grounded(term.args[0], intention.scope) + + self.logger.debug("Finished trigger %s", trigger_name) + + msg = InternalMessage( + to=settings.agent_settings.user_interrupt_name, + sender=self.name, + thread="trigger_end", + body=str(trigger_name), + ) + + # TODO: check with Pim + self.add_behavior(self.send(msg)) + + yield + + @self.actions.add(".notify_goal_start", 1) + def _notify_goal_start(agent, term, intention): + """ + Notify the UI about the goal we just started chasing. + """ + goal_name = agentspeak.grounded(term.args[0], intention.scope) + + self.logger.debug("Started chasing goal %s", goal_name) + + msg = InternalMessage( + to=settings.agent_settings.user_interrupt_name, + sender=self.name, + thread="goal_start", + body=str(goal_name), + ) + + self.add_behavior(self.send(msg)) + + yield + + @self.actions.add(".notify_transition_phase", 2) + def _notify_transition_phase(agent, term, intention): + """ + Notify the BDI program manager about a phase transition. + """ + old = agentspeak.grounded(term.args[0], intention.scope) + new = agentspeak.grounded(term.args[1], intention.scope) + + msg = InternalMessage( + to=settings.agent_settings.bdi_program_manager_name, + thread="transition_phase", + body=json.dumps({"old": str(old), "new": str(new)}), + ) + + self.add_behavior(self.send(msg)) + yield async def _send_to_llm(self, text: str, norms: str, goals: str): @@ -341,6 +493,7 @@ class BDICoreAgent(BaseAgent): to=settings.agent_settings.llm_name, sender=self.name, body=prompt.model_dump_json(), + thread="prompt_message", ) await self.send(msg) self.logger.info("Message sent to LLM agent: %s", text) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 54e7196..ba000de 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -1,4 +1,5 @@ import asyncio +import json import zmq from pydantic import ValidationError @@ -9,7 +10,7 @@ from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator from control_backend.core.config import settings from control_backend.schemas.belief_list import BeliefList from control_backend.schemas.internal_message import InternalMessage -from control_backend.schemas.program import Belief, ConditionalNorm, InferredBelief, Program +from control_backend.schemas.program import Belief, ConditionalNorm, InferredBelief, Phase, Program class BDIProgramManager(BaseAgent): @@ -24,20 +25,20 @@ class BDIProgramManager(BaseAgent): :ivar sub_socket: The ZMQ SUB socket used to receive program updates. """ + _program: Program + _phase: Phase | None + def __init__(self, **kwargs): super().__init__(**kwargs) self.sub_socket = None + def _initialize_internal_state(self, program: Program): + self._program = program + self._phase = program.phases[0] # start in first phase + async def _create_agentspeak_and_send_to_bdi(self, program: Program): """ - Convert a received program into BDI beliefs and send them to the BDI Core Agent. - - Currently, it takes the **first phase** of the program and extracts: - - **Norms**: Constraints or rules the agent must follow. - - **Goals**: Objectives the agent must achieve. - - These are sent as a ``BeliefMessage`` with ``replace=True``, meaning they will - overwrite any existing norms/goals of the same name in the BDI agent. + Convert a received program into an AgentSpeak file and send it to the BDI Core Agent. :param program: The program object received from the API. """ @@ -59,17 +60,44 @@ class BDIProgramManager(BaseAgent): await self.send(msg) - @staticmethod - def _extract_beliefs_from_program(program: Program) -> list[Belief]: + def handle_message(self, msg: InternalMessage): + match msg.thread: + case "transition_phase": + phases = json.loads(msg.body) + + self._transition_phase(phases["old"], phases["new"]) + + def _transition_phase(self, old: str, new: str): + assert old == str(self._phase.id) + + if new == "end": + self._phase = None + return + + for phase in self._program.phases: + if str(phase.id) == new: + self._phase = phase + + self._send_beliefs_to_semantic_belief_extractor() + + # Notify user interaction agent + msg = InternalMessage( + to=settings.agent_settings.user_interrupt_name, + thread="transition_phase", + body=str(self._phase.id), + ) + + self.add_behavior(self.send(msg)) + + def _extract_current_beliefs(self) -> list[Belief]: beliefs: list[Belief] = [] - for phase in program.phases: - for norm in phase.norms: - if isinstance(norm, ConditionalNorm): - beliefs += BDIProgramManager._extract_beliefs_from_belief(norm.condition) + for norm in self._phase.norms: + if isinstance(norm, ConditionalNorm): + beliefs += self._extract_beliefs_from_belief(norm.condition) - for trigger in phase.triggers: - beliefs += BDIProgramManager._extract_beliefs_from_belief(trigger.condition) + for trigger in self._phase.triggers: + beliefs += self._extract_beliefs_from_belief(trigger.condition) return beliefs @@ -81,13 +109,11 @@ class BDIProgramManager(BaseAgent): ) + BDIProgramManager._extract_beliefs_from_belief(belief.right) return [belief] - async def _send_beliefs_to_semantic_belief_extractor(self, program: Program): + async def _send_beliefs_to_semantic_belief_extractor(self): """ Extract beliefs from the program and send them to the Semantic Belief Extractor Agent. - - :param program: The program received from the API. """ - beliefs = BeliefList(beliefs=self._extract_beliefs_from_program(program)) + beliefs = BeliefList(beliefs=self._extract_current_beliefs()) message = InternalMessage( to=settings.agent_settings.text_belief_extractor_name, @@ -111,12 +137,14 @@ class BDIProgramManager(BaseAgent): try: program = Program.model_validate_json(body) except ValidationError: - self.logger.exception("Received an invalid program.") + self.logger.warning("Received an invalid program.") continue + self._initialize_internal_state(program) + await asyncio.gather( self._create_agentspeak_and_send_to_bdi(program), - self._send_beliefs_to_semantic_belief_extractor(program), + self._send_beliefs_to_semantic_belief_extractor(), ) async def setup(self): diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index 34e5b25..34d3a5a 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -248,7 +248,8 @@ class RICommunicationAgent(BaseAgent): self._req_socket.recv_json(), timeout=seconds_to_wait_total / 2 ) - self.logger.debug(f'Received message "{message}" from RI.') + if message["endpoint"] and message["endpoint"] != "ping": + self.logger.debug(f'Received message "{message}" from RI.') if "endpoint" not in message: self.logger.warning("No received endpoint in message, expected ping endpoint.") continue diff --git a/src/control_backend/agents/llm/llm_agent.py b/src/control_backend/agents/llm/llm_agent.py index 17edec9..3e19c49 100644 --- a/src/control_backend/agents/llm/llm_agent.py +++ b/src/control_backend/agents/llm/llm_agent.py @@ -46,12 +46,17 @@ class LLMAgent(BaseAgent): :param msg: The received internal message. """ if msg.sender == settings.agent_settings.bdi_core_name: - self.logger.debug("Processing message from BDI core.") - try: - prompt_message = LLMPromptMessage.model_validate_json(msg.body) - await self._process_bdi_message(prompt_message) - except ValidationError: - self.logger.debug("Prompt message from BDI core is invalid.") + match msg.thread: + case "prompt_message": + try: + prompt_message = LLMPromptMessage.model_validate_json(msg.body) + await self._process_bdi_message(prompt_message) + except ValidationError: + self.logger.debug("Prompt message from BDI core is invalid.") + case "assistant_message": + self.history.append({"role": "assistant", "content": msg.body}) + case "user_message": + self.history.append({"role": "user", "content": msg.body}) else: self.logger.debug("Message ignored (not from BDI core.") @@ -114,13 +119,6 @@ class LLMAgent(BaseAgent): :param goals: Goals the LLM should achieve. :yield: Fragments of the LLM-generated content (e.g., sentences/phrases). """ - self.history.append( - { - "role": "user", - "content": prompt, - } - ) - instructions = LLMInstructions(norms if norms else None, goals if goals else None) messages = [ { diff --git a/src/control_backend/core/agent_system.py b/src/control_backend/core/agent_system.py index 9d7a47f..fc418bb 100644 --- a/src/control_backend/core/agent_system.py +++ b/src/control_backend/core/agent_system.py @@ -131,6 +131,7 @@ class BaseAgent(ABC): :param message: The message to send. """ target = AgentDirectory.get(message.to) + message.sender = self.name if target: await target.inbox.put(message) self.logger.debug(f"Sent message {message.body} to {message.to} via regular inbox.") diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 8a7267c..353a408 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -75,7 +75,7 @@ class BehaviourSettings(BaseModel): # VAD settings vad_prob_threshold: float = 0.5 vad_initial_since_speech: int = 100 - vad_non_speech_patience_chunks: int = 3 + vad_non_speech_patience_chunks: int = 15 # transcription behaviour transcription_max_concurrent_tasks: int = 3 diff --git a/src/control_backend/schemas/internal_message.py b/src/control_backend/schemas/internal_message.py index 071d884..14278c0 100644 --- a/src/control_backend/schemas/internal_message.py +++ b/src/control_backend/schemas/internal_message.py @@ -12,6 +12,6 @@ class InternalMessage(BaseModel): """ to: str - sender: str + sender: str | None = None body: str thread: str | None = None From 8a77e8e1c756dfc9347e09cd7642a82216401410 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 7 Jan 2026 17:31:24 +0100 Subject: [PATCH 29/68] feat: check goals only for this phase Since conversation history still remains we can still check at a later point. ref: N25B-429 --- .../agents/bdi/bdi_program_manager.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index fefd6a7..12d8c6a 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -86,6 +86,7 @@ class BDIProgramManager(BaseAgent): self._phase = phase self._send_beliefs_to_semantic_belief_extractor() + self._send_goals_to_semantic_belief_extractor() # Notify user interaction agent msg = InternalMessage( @@ -131,12 +132,10 @@ class BDIProgramManager(BaseAgent): await self.send(message) - @staticmethod - def _extract_goals_from_program(program: Program) -> list[Goal]: + def _extract_current_goals(self) -> list[Goal]: """ Extract all goals from the program, including subgoals. - :param program: The program received from the API. :return: A list of Goal objects. """ goals: list[Goal] = [] @@ -148,19 +147,16 @@ class BDIProgramManager(BaseAgent): goals_.extend(extract_goals_from_goal(plan)) return goals_ - for phase in program.phases: - for goal in phase.goals: - goals.extend(extract_goals_from_goal(goal)) + for goal in self._phase.goals: + goals.extend(extract_goals_from_goal(goal)) return goals - async def _send_goals_to_semantic_belief_extractor(self, program: Program): + async def _send_goals_to_semantic_belief_extractor(self): """ - Extract goals from the program and send them to the Semantic Belief Extractor Agent. - - :param program: The program received from the API. + Extract goals for the current phase and send them to the Semantic Belief Extractor Agent. """ - goals = GoalList(goals=self._extract_goals_from_program(program)) + goals = GoalList(goals=self._extract_current_goals()) message = InternalMessage( to=settings.agent_settings.text_belief_extractor_name, @@ -192,7 +188,7 @@ class BDIProgramManager(BaseAgent): await asyncio.gather( self._create_agentspeak_and_send_to_bdi(program), self._send_beliefs_to_semantic_belief_extractor(), - self._send_goals_to_semantic_belief_extractor(program), + self._send_goals_to_semantic_belief_extractor(), ) async def setup(self): From be6bbbb849a8dd4dd6ca02af7c298b38183d9b21 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Wed, 7 Jan 2026 17:42:54 +0100 Subject: [PATCH 30/68] feat: added endpoint userinterrupt to userinterrupt ref: N25B-400 --- src/control_backend/agents/bdi/agentspeak.asl | 45 +++++++++++++ .../user_interrupt/user_interrupt_agent.py | 63 ++++++++++++----- .../api/v1/endpoints/button_pressed.py | 31 --------- .../api/v1/endpoints/user_interact.py | 67 +++++++++++++++++++ src/control_backend/api/v1/router.py | 4 +- 5 files changed, 162 insertions(+), 48 deletions(-) create mode 100644 src/control_backend/agents/bdi/agentspeak.asl delete mode 100644 src/control_backend/api/v1/endpoints/button_pressed.py create mode 100644 src/control_backend/api/v1/endpoints/user_interact.py diff --git a/src/control_backend/agents/bdi/agentspeak.asl b/src/control_backend/agents/bdi/agentspeak.asl new file mode 100644 index 0000000..7f71fbd --- /dev/null +++ b/src/control_backend/agents/bdi/agentspeak.asl @@ -0,0 +1,45 @@ +phase("9922935f-ec70-4792-9a61-37a129e1ec14"). +keyword_said(Keyword) :- (user_said(Message) & .substring(Keyword, Message, Pos)) & (Pos >= 0). + + ++!reply_with_goal(Goal) + : user_said(Message) + <- +responded_this_turn; + .findall(Norm, norm(Norm), Norms); + .reply_with_goal(Message, Norms, Goal). + ++!say(Text) + <- +responded_this_turn; + .say(Text). + ++!reply + : user_said(Message) + <- +responded_this_turn; + .findall(Norm, norm(Norm), Norms); + .reply(Message, Norms). + ++user_said(Message) + : phase("9922935f-ec70-4792-9a61-37a129e1ec14") + <- .notify_user_said(Message); + -responded_this_turn; + !check_triggers; + !transition_phase. + ++!transition_phase + : phase("9922935f-ec70-4792-9a61-37a129e1ec14") & + not responded_this_turn + <- -phase("9922935f-ec70-4792-9a61-37a129e1ec14"); + +phase("end"); + ?user_said(Message); + -+user_said(Message); + .notify_transition_phase("9922935f-ec70-4792-9a61-37a129e1ec14", "end"). + ++user_said(Message) + : phase("end") + <- !reply. + ++!check_triggers + <- true. + ++!transition_phase + <- true. 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 b2efc41..af00a7b 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -1,3 +1,4 @@ +import asyncio import json import zmq @@ -30,6 +31,26 @@ class UserInterruptAgent(BaseAgent): def __init__(self, **kwargs): super().__init__(**kwargs) self.sub_socket = None + self.pub_socket = None + + async def setup(self): + """ + Initialize the agent. + + Connects the internal ZMQ SUB socket and subscribes to the 'button_pressed' topic. + Starts the background behavior to receive the user interrupts. + """ + context = Context.instance() + + self.sub_socket = context.socket(zmq.SUB) + self.sub_socket.connect(settings.zmq_settings.internal_sub_address) + self.sub_socket.subscribe("button_pressed") + + self.pub_socket = context.socket(zmq.PUB) + self.pub_socket.connect(settings.zmq_settings.internal_pub_address) + + self.add_behavior(self._receive_button_event()) + self.add_behavior(self.test_sending_behaviour()) async def _receive_button_event(self): """ @@ -78,6 +99,33 @@ class UserInterruptAgent(BaseAgent): event_context, ) + async def test_sending_behaviour(self): + self.logger.info("Starting simple test sending behaviour...") + + while True: + try: + test_data = {"type": "heartbeat", "status": "ok"} + + await self._send_experiment_update(test_data) + + except zmq.ZMQError as ze: + self.logger.error(f"ZMQ error: {ze}") + except Exception as e: + self.logger.error(f"Error: {e}") + + await asyncio.sleep(2) + + async def _send_experiment_update(self, data): + """ + Sends an update to the 'experiment' topic. + The SSE endpoint will pick this up and push it to the UI. + """ + if self.pub_socket: + topic = b"experiment" + body = json.dumps(data).encode("utf-8") + await self.pub_socket.send_multipart([topic, body]) + self.logger.debug(f"Sent experiment update: {data}") + async def _send_to_speech_agent(self, text_to_say: str): """ method to send prioritized speech command to RobotSpeechAgent. @@ -129,18 +177,3 @@ class UserInterruptAgent(BaseAgent): "Sent button_override belief with id '%s' to Program manager.", belief_id, ) - - async def setup(self): - """ - Initialize the agent. - - Connects the internal ZMQ SUB socket and subscribes to the 'button_pressed' topic. - Starts the background behavior to receive the user interrupts. - """ - context = Context.instance() - - self.sub_socket = context.socket(zmq.SUB) - self.sub_socket.connect(settings.zmq_settings.internal_sub_address) - self.sub_socket.subscribe("button_pressed") - - self.add_behavior(self._receive_button_event()) diff --git a/src/control_backend/api/v1/endpoints/button_pressed.py b/src/control_backend/api/v1/endpoints/button_pressed.py deleted file mode 100644 index 5a94a53..0000000 --- a/src/control_backend/api/v1/endpoints/button_pressed.py +++ /dev/null @@ -1,31 +0,0 @@ -import logging - -from fastapi import APIRouter, Request - -from control_backend.schemas.events import ButtonPressedEvent - -logger = logging.getLogger(__name__) -router = APIRouter() - - -@router.post("/button_pressed", status_code=202) -async def receive_button_event(event: ButtonPressedEvent, request: Request): - """ - Endpoint to handle external button press events. - - Validates the event payload and publishes it to the internal 'button_pressed' topic. - Subscribers (in this case user_interrupt_agent) will pick this up to trigger - specific behaviors or state changes. - - :param event: The parsed ButtonPressedEvent object. - :param request: The FastAPI request object. - """ - logger.debug("Received button event: %s | %s", event.type, event.context) - - topic = b"button_pressed" - body = event.model_dump_json().encode() - - pub_socket = request.app.state.endpoints_pub_socket - await pub_socket.send_multipart([topic, body]) - - return {"status": "Event received"} diff --git a/src/control_backend/api/v1/endpoints/user_interact.py b/src/control_backend/api/v1/endpoints/user_interact.py new file mode 100644 index 0000000..3d3406e --- /dev/null +++ b/src/control_backend/api/v1/endpoints/user_interact.py @@ -0,0 +1,67 @@ +import asyncio +import logging + +import zmq +import zmq.asyncio +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse +from zmq.asyncio import Context + +from control_backend.core.config import settings +from control_backend.schemas.events import ButtonPressedEvent + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/button_pressed", status_code=202) +async def receive_button_event(event: ButtonPressedEvent, request: Request): + """ + Endpoint to handle external button press events. + + Validates the event payload and publishes it to the internal 'button_pressed' topic. + Subscribers (in this case user_interrupt_agent) will pick this up to trigger + specific behaviors or state changes. + + :param event: The parsed ButtonPressedEvent object. + :param request: The FastAPI request object. + """ + logger.debug("Received button event: %s | %s", event.type, event.context) + + topic = b"button_pressed" + body = event.model_dump_json().encode() + + pub_socket = request.app.state.endpoints_pub_socket + await pub_socket.send_multipart([topic, body]) + + return {"status": "Event received"} + + +@router.get("/experiment_stream") +async def experiment_stream(request: Request): + # Use the asyncio-compatible context + context = Context.instance() + socket = context.socket(zmq.SUB) + + # Connect and subscribe + socket.connect(settings.zmq_settings.internal_sub_address) + socket.subscribe(b"experiment") + + async def gen(): + try: + while True: + # Check if client closed the tab + if await request.is_disconnected(): + logger.info("Client disconnected from experiment stream.") + break + + try: + parts = await asyncio.wait_for(socket.recv_multipart(), timeout=1.0) + _, message = parts + yield f"data: {message.decode().strip()}\n\n" + except TimeoutError: + continue + finally: + socket.close() + + return StreamingResponse(gen(), media_type="text/event-stream") diff --git a/src/control_backend/api/v1/router.py b/src/control_backend/api/v1/router.py index ebba0db..c130ad3 100644 --- a/src/control_backend/api/v1/router.py +++ b/src/control_backend/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi.routing import APIRouter -from control_backend.api.v1.endpoints import button_pressed, logs, message, program, robot, sse +from control_backend.api.v1.endpoints import logs, message, program, robot, sse, user_interact api_router = APIRouter() @@ -14,4 +14,4 @@ api_router.include_router(logs.router, tags=["Logs"]) api_router.include_router(program.router, tags=["Program"]) -api_router.include_router(button_pressed.router, tags=["Button Pressed Events"]) +api_router.include_router(user_interact.router, tags=["Button Pressed Events"]) From 93d67ccb66ace8386b5ef9286af4761d31bd5344 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Wed, 7 Jan 2026 17:50:47 +0100 Subject: [PATCH 31/68] feat: add reset functionality to semantic belief extractor ref: N25B-432 --- .../agents/bdi/text_belief_extractor_agent.py | 10 ++++++++++ 1 file changed, 10 insertions(+) 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 7e3570f..feabf40 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -116,9 +116,19 @@ class TextBeliefExtractorAgent(BaseAgent): self._handle_beliefs_message(msg) case "goals": self._handle_goals_message(msg) + case "conversation_history": + if msg.body == "reset": + self._reset() case _: self.logger.warning("Received unexpected message from %s", msg.sender) + def _reset(self): + self.conversation = ChatHistory(messages=[]) + self.belief_inferrer.available_beliefs.clear() + self._current_beliefs = BeliefState() + self.goal_inferrer.goals.clear() + self._current_goal_completions = {} + def _handle_beliefs_message(self, msg: InternalMessage): try: belief_list = BeliefList.model_validate_json(msg.body) From 5a61225c6f40d45718723a7bf559e607974361a3 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 7 Jan 2026 18:10:13 +0100 Subject: [PATCH 32/68] feat: reset extractor history ref: N25B-429 --- .../agents/bdi/bdi_program_manager.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 1ee3e3c..8b8c68f 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -175,13 +175,19 @@ class BDIProgramManager(BaseAgent): """ message = InternalMessage( to=settings.agent_settings.llm_name, - sender=self.name, body="clear_history", - threads="clear history message", ) await self.send(message) self.logger.debug("Sent message to LLM agent to clear history.") + extractor_msg = InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + thread="conversation_history", + body="reset", + ) + await self.send(extractor_msg) + self.logger.debug("Sent message to extractor agent to clear history.") + async def _receive_programs(self): """ Continuous loop that receives program updates from the HTTP endpoint. @@ -201,11 +207,12 @@ class BDIProgramManager(BaseAgent): self._initialize_internal_state(program) + await self._send_clear_llm_history() + await asyncio.gather( self._create_agentspeak_and_send_to_bdi(program), self._send_beliefs_to_semantic_belief_extractor(), self._send_goals_to_semantic_belief_extractor(), - self._send_clear_llm_history(), ) async def setup(self): From be88323cf76333f0b2dfcb98c2d93b1723977a19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Wed, 7 Jan 2026 18:24:35 +0100 Subject: [PATCH 33/68] chore: add one endpoint fo avoid errors --- .../agents/user_interrupt/user_interrupt_agent.py | 8 ++++---- src/control_backend/schemas/ri_message.py | 2 ++ 2 files changed, 6 insertions(+), 4 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 842231a..50df979 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -82,6 +82,7 @@ class UserInterruptAgent(BaseAgent): self.logger.info("Sent pause command.") else: self.logger.info("Sent resume command.") + elif event_type in ["next_phase", "reset_phase", "reset_experiment"]: await self._send_experiment_control_to_bdi_core(event_type) @@ -173,11 +174,11 @@ class UserInterruptAgent(BaseAgent): "Sent button_override belief with id '%s' to Program manager.", belief_id, ) - - async def _send_pause_command(self, pause : bool): + + async def _send_pause_command(self, pause: bool): """ Send a pause command to the Robot Interface via the RI Communication Agent. - Send a pause command to the other internal agents; for now just VAD agent. + Send a pause command to the other internal agents; for now just VAD agent. """ cmd = PauseCommand(data=pause) message = InternalMessage( @@ -206,7 +207,6 @@ class UserInterruptAgent(BaseAgent): await self.send(vad_message) self.logger.info("Sent resume command to VAD Agent and RI Communication Agent.") - async def setup(self): """ Initialize the agent. diff --git a/src/control_backend/schemas/ri_message.py b/src/control_backend/schemas/ri_message.py index 7c1ef22..e6eafa3 100644 --- a/src/control_backend/schemas/ri_message.py +++ b/src/control_backend/schemas/ri_message.py @@ -14,6 +14,7 @@ class RIEndpoint(str, Enum): GESTURE_TAG = "actuate/gesture/tag" PING = "ping" NEGOTIATE_PORTS = "negotiate/ports" + PAUSE = "" class RIMessage(BaseModel): @@ -65,6 +66,7 @@ class GestureCommand(RIMessage): raise ValueError("endpoint must be GESTURE_SINGLE or GESTURE_TAG") return self + class PauseCommand(RIMessage): """ A specific command to pause or unpause the robot's actions. From 365d449666e30ce7def496e394998bab95e5de56 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Wed, 7 Jan 2026 22:41:59 +0100 Subject: [PATCH 34/68] feat: commit before I can merge new changes ref: N25B-400 --- src/control_backend/agents/bdi/agentspeak.asl | 26 +++++++++++++++---- .../agents/bdi/bdi_program_manager.py | 1 + .../user_interrupt/user_interrupt_agent.py | 19 +++++++++++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak.asl b/src/control_backend/agents/bdi/agentspeak.asl index 7f71fbd..e6e1fb0 100644 --- a/src/control_backend/agents/bdi/agentspeak.asl +++ b/src/control_backend/agents/bdi/agentspeak.asl @@ -1,4 +1,4 @@ -phase("9922935f-ec70-4792-9a61-37a129e1ec14"). +phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49"). keyword_said(Keyword) :- (user_said(Message) & .substring(Keyword, Message, Pos)) & (Pos >= 0). @@ -19,20 +19,36 @@ keyword_said(Keyword) :- (user_said(Message) & .substring(Keyword, Message, Pos) .reply(Message, Norms). +user_said(Message) - : phase("9922935f-ec70-4792-9a61-37a129e1ec14") + : phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49") <- .notify_user_said(Message); -responded_this_turn; !check_triggers; !transition_phase. +!transition_phase - : phase("9922935f-ec70-4792-9a61-37a129e1ec14") & + : phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49") & not responded_this_turn - <- -phase("9922935f-ec70-4792-9a61-37a129e1ec14"); + <- -phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49"); + +phase("1fc60869-86db-483d-b475-b8ecdec4bba8"); + ?user_said(Message); + -+user_said(Message); + .notify_transition_phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49", "1fc60869-86db-483d-b475-b8ecdec4bba8"). + ++user_said(Message) + : phase("1fc60869-86db-483d-b475-b8ecdec4bba8") + <- .notify_user_said(Message); + -responded_this_turn; + !check_triggers; + !transition_phase. + ++!transition_phase + : phase("1fc60869-86db-483d-b475-b8ecdec4bba8") & + not responded_this_turn + <- -phase("1fc60869-86db-483d-b475-b8ecdec4bba8"); +phase("end"); ?user_said(Message); -+user_said(Message); - .notify_transition_phase("9922935f-ec70-4792-9a61-37a129e1ec14", "end"). + .notify_transition_phase("1fc60869-86db-483d-b475-b8ecdec4bba8", "end"). +user_said(Message) : phase("end") diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index fefd6a7..29ff859 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -93,6 +93,7 @@ class BDIProgramManager(BaseAgent): thread="transition_phase", body=str(self._phase.id), ) + self.logger.info(f"Transitioned to phase {new}, notifying UserInterruptAgent.") self.add_behavior(self.send(msg)) 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 af00a7b..cfb6d2f 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -50,7 +50,7 @@ class UserInterruptAgent(BaseAgent): self.pub_socket.connect(settings.zmq_settings.internal_pub_address) self.add_behavior(self._receive_button_event()) - self.add_behavior(self.test_sending_behaviour()) + # self.add_behavior(self.test_sending_behaviour()) async def _receive_button_event(self): """ @@ -99,6 +99,23 @@ class UserInterruptAgent(BaseAgent): event_context, ) + async def handle_message(self, msg: InternalMessage): + """ + Handle commands received from other internal Python agents. + """ + match msg.thread: + case "transition_phase": + new_phase_id = msg.body + self.logger.info(f"Phase transition detected: {new_phase_id}") + + payload = {"type": "phase_update", "phase_id": new_phase_id} + + await self._send_experiment_update(payload) + + case _: + self.logger.debug(f"Received internal message on unhandled thread: {msg.thread}") + + # moet weg!!!!! async def test_sending_behaviour(self): self.logger.info("Starting simple test sending behaviour...") From 4bf2be63599998ef8d198abb5854d30ff708d2e7 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 8 Jan 2026 09:56:10 +0100 Subject: [PATCH 35/68] feat: added a functionality for monitoring page ref: N25B-400 --- src/control_backend/agents/bdi/agentspeak.asl | 35 +++--- .../agents/bdi/agentspeak_generator.py | 24 ++-- .../agents/bdi/bdi_core_agent.py | 9 ++ .../agents/bdi/bdi_program_manager.py | 23 +++- .../user_interrupt/user_interrupt_agent.py | 110 +++++++++++++++++- 5 files changed, 161 insertions(+), 40 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak.asl b/src/control_backend/agents/bdi/agentspeak.asl index e6e1fb0..399566c 100644 --- a/src/control_backend/agents/bdi/agentspeak.asl +++ b/src/control_backend/agents/bdi/agentspeak.asl @@ -1,5 +1,6 @@ -phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49"). +phase("db4c68c3-0316-4905-a8db-22dd5bec7abf"). keyword_said(Keyword) :- (user_said(Message) & .substring(Keyword, Message, Pos)) & (Pos >= 0). +norm("do nothing and make a little dance, do a little laugh") :- phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") & keyword_said("hi"). +!reply_with_goal(Goal) @@ -19,36 +20,30 @@ keyword_said(Keyword) :- (user_said(Message) & .substring(Keyword, Message, Pos) .reply(Message, Norms). +user_said(Message) - : phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49") + : phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") <- .notify_user_said(Message); -responded_this_turn; !check_triggers; !transition_phase. -+!transition_phase - : phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49") & - not responded_this_turn - <- -phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49"); - +phase("1fc60869-86db-483d-b475-b8ecdec4bba8"); - ?user_said(Message); - -+user_said(Message); - .notify_transition_phase("0e0f239c-efe9-442c-bdd7-3aabfccd1c49", "1fc60869-86db-483d-b475-b8ecdec4bba8"). ++!check_triggers + : phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") & + semantic_hello + <- .notify_trigger_start("trigger_"); + .notify_trigger_end("trigger_"). -+user_said(Message) - : phase("1fc60869-86db-483d-b475-b8ecdec4bba8") - <- .notify_user_said(Message); - -responded_this_turn; - !check_triggers; - !transition_phase. ++!trigger_ + <- .notify_trigger_start("trigger_"); + .notify_trigger_end("trigger_"). +!transition_phase - : phase("1fc60869-86db-483d-b475-b8ecdec4bba8") & + : phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") & not responded_this_turn - <- -phase("1fc60869-86db-483d-b475-b8ecdec4bba8"); + <- .notify_transition_phase("db4c68c3-0316-4905-a8db-22dd5bec7abf", "end"); + -phase("db4c68c3-0316-4905-a8db-22dd5bec7abf"); +phase("end"); ?user_said(Message); - -+user_said(Message); - .notify_transition_phase("1fc60869-86db-483d-b475-b8ecdec4bba8", "end"). + -+user_said(Message). +user_said(Message) : phase("end") diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 17248a8..18cb794 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -176,6 +176,16 @@ class AgentSpeakGenerator: context.append(self._astify(from_phase.goals[-1], achieved=True)) body = [ + AstStatement( + StatementType.DO_ACTION, + AstLiteral( + "notify_transition_phase", + [ + AstString(str(from_phase.id)), + AstString(str(to_phase.id) if to_phase else "end"), + ], + ), + ), AstStatement(StatementType.REMOVE_BELIEF, from_phase_ast), AstStatement(StatementType.ADD_BELIEF, to_phase_ast), ] @@ -192,20 +202,6 @@ class AgentSpeakGenerator: ] ) - # Notify outside world about transition - body.append( - AstStatement( - StatementType.DO_ACTION, - AstLiteral( - "notify_transition_phase", - [ - AstString(str(from_phase.id)), - AstString(str(to_phase.id) if to_phase else "end"), - ], - ), - ) - ) - self._asp.plans.append( AstPlan(TriggerType.ADDED_GOAL, AstLiteral("transition_phase"), context, body) ) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 99bea80..94232e4 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -311,6 +311,15 @@ class BDICoreAgent(BaseAgent): message_text = agentspeak.grounded(term.args[0], intention.scope) norms = agentspeak.grounded(term.args[1], intention.scope) + norm_update_message = InternalMessage( + to=settings.agent_settings.user_interrupt_name, + sender=self.name, + thread="active_norms_update", + body=str(norms), + ) + + self.add_behavior(self.send(norm_update_message)) + self.logger.debug("Norms: %s", norms) self.logger.debug("User text: %s", message_text) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index ba022eb..7899e3c 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -75,7 +75,12 @@ class BDIProgramManager(BaseAgent): self._transition_phase(phases["old"], phases["new"]) def _transition_phase(self, old: str, new: str): - assert old == str(self._phase.id) + if old != str(self._phase.id): + self.logger.warning( + f"Phase transition desync detected! ASL requested move from '{old}', " + f"but Python is currently in '{self._phase.id}'. Request ignored." + ) + return if new == "end": self._phase = None @@ -208,6 +213,7 @@ class BDIProgramManager(BaseAgent): self._initialize_internal_state(program) + await self._send_program_to_user_interrupt(program) await self._send_clear_llm_history() await asyncio.gather( @@ -216,6 +222,21 @@ class BDIProgramManager(BaseAgent): self._send_goals_to_semantic_belief_extractor(), ) + async def _send_program_to_user_interrupt(self, program: Program): + """ + Send the received program to the User Interrupt Agent. + + :param program: The program object received from the API. + """ + msg = InternalMessage( + sender=self.name, + to=settings.agent_settings.user_interrupt_name, + body=program.model_dump_json(), + thread="new_program", + ) + + await self.send(msg) + async def setup(self): """ Initialize the agent. 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 cfb6d2f..b762e68 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -5,8 +5,10 @@ import zmq from zmq.asyncio import Context from control_backend.agents import BaseAgent +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.program import Program from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, SpeechCommand @@ -32,6 +34,16 @@ class UserInterruptAgent(BaseAgent): super().__init__(**kwargs) self.sub_socket = None self.pub_socket = None + self._trigger_map = {} + self._trigger_reverse_map = {} + + self._goal_map = {} + self._goal_reverse_map = {} + + self._cond_norm_map = {} + self._cond_norm_reverse_map = {} + + self._belief_condition_map = {} async def setup(self): """ @@ -87,11 +99,19 @@ class UserInterruptAgent(BaseAgent): event_context, ) elif event_type == "override": - await self._send_to_program_manager(event_context) - self.logger.info( - "Forwarded button press (override) with context '%s' to BDIProgramManager.", - event_context, - ) + ui_id = str(event_context) + if asl_trigger := self._trigger_map.get(ui_id): + await self._send_to_bdi("force_trigger", asl_trigger) + self.logger.info( + "Forwarded button press (override) with context '%s' to BDI Core.", + event_context, + ) + else: + await self._send_to_program_manager(event_context) + self.logger.info( + "Forwarded button press (override) with context '%s' to BDIProgramManager.", + event_context, + ) else: self.logger.warning( "Received button press with unknown type '%s' (context: '%s').", @@ -104,6 +124,26 @@ class UserInterruptAgent(BaseAgent): Handle commands received from other internal Python agents. """ match msg.thread: + case "new_program": + self._create_mapping(msg.body) + case "trigger_start": + # msg.body is the sluggified trigger + asl_slug = msg.body + ui_id = self._trigger_reverse_map.get(asl_slug) + + if ui_id: + payload = {"type": "trigger_update", "id": ui_id, "achieved": True} + await self._send_experiment_update(payload) + self.logger.info(f"UI Update: Trigger {asl_slug} started (ID: {ui_id})") + + case "trigger_end": + asl_slug = msg.body + ui_id = self._trigger_reverse_map.get(asl_slug) + + if ui_id: + payload = {"type": "trigger_update", "id": ui_id, "achieved": False} + await self._send_experiment_update(payload) + self.logger.info(f"UI Update: Trigger {asl_slug} ended (ID: {ui_id})") case "transition_phase": new_phase_id = msg.body self.logger.info(f"Phase transition detected: {new_phase_id}") @@ -111,6 +151,10 @@ class UserInterruptAgent(BaseAgent): payload = {"type": "phase_update", "phase_id": new_phase_id} await self._send_experiment_update(payload) + case "active_norms_update": + asl_slugs = [s.strip() for s in msg.body.split(";")] + + await self._broadcast_cond_norms(asl_slugs) case _: self.logger.debug(f"Received internal message on unhandled thread: {msg.thread}") @@ -132,6 +176,54 @@ class UserInterruptAgent(BaseAgent): await asyncio.sleep(2) + async def _broadcast_cond_norms(self, active_slugs: list[str]): + """ + Sends the current state of all conditional norms to the UI. + :param active_slugs: A list of slugs (strings) currently active in the BDI core. + """ + updates = [] + + for asl_slug, ui_id in self._cond_norm_reverse_map.items(): + is_active = asl_slug in active_slugs + updates.append({"id": ui_id, "name": asl_slug, "active": is_active}) + + payload = {"type": "cond_norms_state_update", "norms": updates} + + await self._send_experiment_update(payload) + self.logger.debug(f"Broadcasted state for {len(updates)} conditional norms.") + + def _create_mapping(self, program_json: str): + try: + program = Program.model_validate_json(program_json) + self._trigger_map = {} + self._trigger_reverse_map = {} + self._goal_map = {} + self._cond_norm_map = {} + self._cond_norm_reverse_map = {} + + for phase in program.phases: + for trigger in phase.triggers: + slug = AgentSpeakGenerator.slugify(trigger) + self._trigger_map[str(trigger.id)] = slug + self._trigger_reverse_map[slug] = str(trigger.id) + + for goal in phase.goals: + self._goal_map[str(goal.id)] = AgentSpeakGenerator.slugify(goal) + + for norm in phase.conditional_norms: + if norm.condition: + asl_slug = AgentSpeakGenerator.slugify(norm) + belief_id = str(norm.condition) + + self._cond_norm_map[belief_id] = asl_slug + self._cond_norm_reverse_map[asl_slug] = belief_id + + self.logger.info( + f"Mapped {len(self._trigger_map)} triggers and {len(self._goal_map)} goals." + ) + except Exception as e: + self.logger.error(f"Mapping failed: {e}") + async def _send_experiment_update(self, data): """ Sends an update to the 'experiment' topic. @@ -194,3 +286,11 @@ class UserInterruptAgent(BaseAgent): "Sent button_override belief with id '%s' to Program manager.", belief_id, ) + + async def _send_to_bdi(self, thread: str, body: str): + """Send slug of trigger to BDI""" + msg = InternalMessage( + to=settings.agent_settings.bdi_core_name, sender=self.name, thread=thread, body=body + ) + await self.send(msg) + self.logger.info(f"Directly forced {thread} in BDI: {body}") From 45719c580bbfaf054932e36ee52997d2828372bb Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Thu, 8 Jan 2026 10:49:13 +0100 Subject: [PATCH 36/68] feat: prepend more silence before speech audio for better transcription beginnings ref: N25B-429 --- .env.example | 2 +- src/control_backend/agents/perception/vad_agent.py | 12 +++++++----- src/control_backend/core/config.py | 2 ++ 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index d498054..41a382a 100644 --- a/.env.example +++ b/.env.example @@ -10,7 +10,7 @@ LLM_SETTINGS__LOCAL_LLM_URL="http://localhost:1234/v1/chat/completions" LLM_SETTINGS__LOCAL_LLM_MODEL="gpt-oss" # Number of non-speech chunks to wait before speech ended. A chunk is approximately 31 ms. Increasing this number allows longer pauses in speech, but also increases response time. -BEHAVIOUR_SETTINGS__VAD_NON_SPEECH_PATIENCE_CHUNKS=3 +BEHAVIOUR_SETTINGS__VAD_NON_SPEECH_PATIENCE_CHUNKS=15 # Timeout in milliseconds for socket polling. Increase this number if network latency/jitter is high, often the case when using Wi-Fi. Perhaps 500 ms. A symptom of this issue is transcriptions getting cut off. BEHAVIOUR_SETTINGS__SOCKET_POLLER_TIMEOUT_MS=100 diff --git a/src/control_backend/agents/perception/vad_agent.py b/src/control_backend/agents/perception/vad_agent.py index 70fa9e1..e47b27a 100644 --- a/src/control_backend/agents/perception/vad_agent.py +++ b/src/control_backend/agents/perception/vad_agent.py @@ -229,10 +229,11 @@ class VADAgent(BaseAgent): assert self.model is not None prob = self.model(torch.from_numpy(chunk), settings.vad_settings.sample_rate_hz).item() non_speech_patience = settings.behaviour_settings.vad_non_speech_patience_chunks + begin_silence_length = settings.behaviour_settings.vad_begin_silence_chunks prob_threshold = settings.behaviour_settings.vad_prob_threshold if prob > prob_threshold: - if self.i_since_speech > non_speech_patience: + if self.i_since_speech > non_speech_patience + begin_silence_length: self.logger.debug("Speech started.") self.audio_buffer = np.append(self.audio_buffer, chunk) self.i_since_speech = 0 @@ -246,11 +247,12 @@ class VADAgent(BaseAgent): continue # Speech probably ended. Make sure we have a usable amount of data. - if len(self.audio_buffer) >= 3 * len(chunk): + if len(self.audio_buffer) > begin_silence_length * len(chunk): self.logger.debug("Speech ended.") assert self.audio_out_socket is not None await self.audio_out_socket.send(self.audio_buffer[: -2 * len(chunk)].tobytes()) - # At this point, we know that the speech has ended. - # Prepend the last chunk that had no speech, for a more fluent boundary - self.audio_buffer = chunk + # At this point, we know that there is no speech. + # Prepend the last few chunks that had no speech, for a more fluent boundary. + self.audio_buffer = np.append(self.audio_buffer, chunk) + self.audio_buffer = self.audio_buffer[-begin_silence_length * len(chunk) :] diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 2ed5c04..02018ee 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -73,6 +73,7 @@ class BehaviourSettings(BaseModel): :ivar vad_prob_threshold: Probability threshold for Voice Activity Detection. :ivar vad_initial_since_speech: Initial value for 'since speech' counter in VAD. :ivar vad_non_speech_patience_chunks: Number of non-speech chunks to wait before speech ended. + :ivar vad_begin_silence_chunks: The number of chunks of silence to prepend to speech chunks. :ivar transcription_max_concurrent_tasks: Maximum number of concurrent transcription tasks. :ivar transcription_words_per_minute: Estimated words per minute for transcription timing. :ivar transcription_words_per_token: Estimated words per token for transcription timing. @@ -90,6 +91,7 @@ class BehaviourSettings(BaseModel): vad_prob_threshold: float = 0.5 vad_initial_since_speech: int = 100 vad_non_speech_patience_chunks: int = 15 + vad_begin_silence_chunks: int = 3 # transcription behaviour transcription_max_concurrent_tasks: int = 3 From 6b34f4b82cf39bf0e1a55a721f6567af177751f9 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 8 Jan 2026 10:59:24 +0100 Subject: [PATCH 37/68] fix: small bugfix ref: N25B-400 --- .../agents/user_interrupt/user_interrupt_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b762e68..3585209 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -210,7 +210,7 @@ class UserInterruptAgent(BaseAgent): for goal in phase.goals: self._goal_map[str(goal.id)] = AgentSpeakGenerator.slugify(goal) - for norm in phase.conditional_norms: + for norm in phase.norms: if norm.condition: asl_slug = AgentSpeakGenerator.slugify(norm) belief_id = str(norm.condition) From b27e5180c46c094fb53236bff34ab312ae6b7918 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 8 Jan 2026 11:25:53 +0100 Subject: [PATCH 38/68] feat: small implementation change ref: N25B-400 --- .../agents/user_interrupt/user_interrupt_agent.py | 4 ++-- test/unit/agents/bdi/test_bdi_program_manager.py | 4 +++- 2 files changed, 5 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 3585209..465125d 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -106,8 +106,8 @@ class UserInterruptAgent(BaseAgent): "Forwarded button press (override) with context '%s' to BDI Core.", event_context, ) - else: - await self._send_to_program_manager(event_context) + elif asl_cond_norm := self._cond_norm_map.get(ui_id): + await self._send_to_bdi("force_norm", asl_cond_norm) self.logger.info( "Forwarded button press (override) with context '%s' to BDIProgramManager.", event_context, diff --git a/test/unit/agents/bdi/test_bdi_program_manager.py b/test/unit/agents/bdi/test_bdi_program_manager.py index 50dc4ed..644c23b 100644 --- a/test/unit/agents/bdi/test_bdi_program_manager.py +++ b/test/unit/agents/bdi/test_bdi_program_manager.py @@ -95,7 +95,9 @@ async def test_receive_programs_valid_and_invalid(): assert forwarded.phases[0].goals[0].name == "G1" # Verify history clear was triggered - assert manager._send_clear_llm_history.await_count == 1 + assert ( + manager._send_clear_llm_history.await_count == 2 + ) # first sends program to UserInterrupt, then clears LLM @pytest.mark.asyncio From 3a8d1730a1cb2bf205ba2eabaa936bcea5461dd0 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 8 Jan 2026 12:29:16 +0100 Subject: [PATCH 39/68] fix: made mapping for conditional norms only ref: N25B-400 --- src/control_backend/agents/bdi/agentspeak_generator.py | 5 +++++ .../agents/user_interrupt/user_interrupt_agent.py | 10 ++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 18cb794..15a5120 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -415,6 +415,11 @@ class AgentSpeakGenerator: def slugify(element: ProgramElement) -> str: raise NotImplementedError(f"Cannot convert element {element} to a slug.") + @slugify.register + @staticmethod + def _(n: Norm) -> str: + return f"norm_{AgentSpeakGenerator._slugify_str(n.norm)}" + @slugify.register @staticmethod def _(sb: SemanticBelief) -> str: 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 465125d..925e10e 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 import BaseAgent 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.program import Program +from control_backend.schemas.program import BasicNorm, Program from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, SpeechCommand @@ -211,15 +211,17 @@ class UserInterruptAgent(BaseAgent): self._goal_map[str(goal.id)] = AgentSpeakGenerator.slugify(goal) for norm in phase.norms: - if norm.condition: + if not isinstance(norm, BasicNorm): asl_slug = AgentSpeakGenerator.slugify(norm) - belief_id = str(norm.condition) + + belief_id = str(norm.condition.id) self._cond_norm_map[belief_id] = asl_slug self._cond_norm_reverse_map[asl_slug] = belief_id self.logger.info( - f"Mapped {len(self._trigger_map)} triggers and {len(self._goal_map)} goals." + f"Mapped {len(self._trigger_map)} triggers and {len(self._goal_map)} goals " + f"and {len(self._cond_norm_map)} conditional norms for UserInterruptAgent." ) except Exception as e: self.logger.error(f"Mapping failed: {e}") From cc0d5af28c200eb7feec05a2bcb3c6b3a71b5dd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 8 Jan 2026 12:56:22 +0100 Subject: [PATCH 40/68] chore: fixing bugs --- .../agents/communication/ri_communication_agent.py | 12 ++++++++++++ .../agents/user_interrupt/user_interrupt_agent.py | 7 +++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index 34e5b25..45e3511 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -8,6 +8,7 @@ from zmq.asyncio import Context from control_backend.agents import BaseAgent from control_backend.agents.actuation.robot_gesture_agent import RobotGestureAgent from control_backend.core.config import settings +from control_backend.schemas.internal_message import InternalMessage from ..actuation.robot_speech_agent import RobotSpeechAgent from ..perception import VADAgent @@ -298,3 +299,14 @@ class RICommunicationAgent(BaseAgent): self.logger.debug("Restarting communication negotiation.") if await self._negotiate_connection(max_retries=1): self.connected = True + + async def handle_message(self, msg: InternalMessage): + """ + Handle an incoming message. + + Currently not implemented for this agent. + + :param msg: The received message. + :raises NotImplementedError: Always, since this method is not implemented. + """ + self.logger.warning("custom warning for handle msg in ri coms %s", self.name) 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 50df979..f48e14b 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -77,6 +77,9 @@ class UserInterruptAgent(BaseAgent): event_context, ) elif event_type == "pause": + self.logger.debug( + "Received pause/resume button press with context '%s'.", event_context + ) await self._send_pause_command(event_context) if event_context: self.logger.info("Sent pause command.") @@ -175,7 +178,7 @@ class UserInterruptAgent(BaseAgent): belief_id, ) - async def _send_pause_command(self, pause: bool): + async def _send_pause_command(self, pause): """ Send a pause command to the Robot Interface via the RI Communication Agent. Send a pause command to the other internal agents; for now just VAD agent. @@ -188,7 +191,7 @@ class UserInterruptAgent(BaseAgent): ) await self.send(message) - if pause: + if pause == "true": # Send pause to VAD agent vad_message = InternalMessage( to=settings.agent_settings.vad_name, From 13605678209b23c46ff026bc56f3cefc63cba0d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 8 Jan 2026 13:01:38 +0100 Subject: [PATCH 41/68] chore: indenting --- src/control_backend/agents/perception/vad_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/control_backend/agents/perception/vad_agent.py b/src/control_backend/agents/perception/vad_agent.py index 320a849..9c5a3ef 100644 --- a/src/control_backend/agents/perception/vad_agent.py +++ b/src/control_backend/agents/perception/vad_agent.py @@ -272,7 +272,7 @@ class VADAgent(BaseAgent): # Prepend the last chunk that had no speech, for a more fluent boundary self.audio_buffer = chunk -async def handle_message(self, msg: InternalMessage): + async def handle_message(self, msg: InternalMessage): """ Handle incoming messages. From b88758fa7666b0b0e0c31f6c5f96908fc7acde28 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 8 Jan 2026 13:33:37 +0100 Subject: [PATCH 42/68] feat: phase transition independent of response ref: N25B-429 --- src/control_backend/agents/bdi/agentspeak_generator.py | 2 +- src/control_backend/agents/bdi/bdi_core_agent.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 17248a8..d02888a 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -171,7 +171,7 @@ class AgentSpeakGenerator: self._astify(to_phase) if to_phase else AstLiteral("phase", [AstString("end")]) ) - context = [from_phase_ast, ~AstLiteral("responded_this_turn")] + context = [from_phase_ast] if from_phase and from_phase.goals: context.append(self._astify(from_phase.goals[-1], achieved=True)) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 99bea80..74a747d 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -213,6 +213,14 @@ class BDICoreAgent(BaseAgent): agentspeak.runtime.Intention(), ) + # Check for transitions + self.bdi_agent.call( + agentspeak.Trigger.addition, + agentspeak.GoalType.achievement, + agentspeak.Literal("transition_phase"), + agentspeak.runtime.Intention(), + ) + self._wake_bdi_loop.set() self.logger.debug(f"Added belief {self.format_belief_string(name, args)}") From 625ef0c36543474f44a91c8641172871af255b28 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 8 Jan 2026 13:36:03 +0100 Subject: [PATCH 43/68] feat: phase transition waits for all goals ref: N25B-429 --- src/control_backend/agents/bdi/agentspeak_generator.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index d02888a..22b0b8e 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -172,8 +172,9 @@ class AgentSpeakGenerator: ) context = [from_phase_ast] - if from_phase and from_phase.goals: - context.append(self._astify(from_phase.goals[-1], achieved=True)) + if from_phase: + for goal in from_phase.goals: + context.append(self._astify(goal, achieved=True)) body = [ AstStatement(StatementType.REMOVE_BELIEF, from_phase_ast), From 4d0ba6944300a14525c0aef5ab1be60cecc42d1d Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 8 Jan 2026 13:44:25 +0100 Subject: [PATCH 44/68] fix: don't re-add user_said upon phase transition ref: N25B-429 --- .../agents/bdi/agentspeak_generator.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 22b0b8e..51d3a63 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -181,17 +181,17 @@ class AgentSpeakGenerator: AstStatement(StatementType.ADD_BELIEF, to_phase_ast), ] - if from_phase: - body.extend( - [ - AstStatement( - StatementType.TEST_GOAL, AstLiteral("user_said", [AstVar("Message")]) - ), - AstStatement( - StatementType.REPLACE_BELIEF, AstLiteral("user_said", [AstVar("Message")]) - ), - ] - ) + # if from_phase: + # body.extend( + # [ + # AstStatement( + # StatementType.TEST_GOAL, AstLiteral("user_said", [AstVar("Message")]) + # ), + # AstStatement( + # StatementType.REPLACE_BELIEF, AstLiteral("user_said", [AstVar("Message")]) + # ), + # ] + # ) # Notify outside world about transition body.append( From 133019a92823646ace45a9cc9a3bf338c95af088 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 8 Jan 2026 14:04:44 +0100 Subject: [PATCH 45/68] feat: trigger name and trigger checks on belief update ref: N25B-429 --- src/control_backend/agents/bdi/bdi_core_agent.py | 8 ++++++++ src/control_backend/schemas/program.py | 1 - 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 74a747d..1658ccd 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -221,6 +221,14 @@ class BDICoreAgent(BaseAgent): agentspeak.runtime.Intention(), ) + # Check triggers + self.bdi_agent.call( + agentspeak.Trigger.addition, + agentspeak.GoalType.achievement, + agentspeak.Literal("check_triggers"), + agentspeak.runtime.Intention(), + ) + self._wake_bdi_loop.set() self.logger.debug(f"Added belief {self.format_belief_string(name, args)}") diff --git a/src/control_backend/schemas/program.py b/src/control_backend/schemas/program.py index 82c017e..3c8c7b4 100644 --- a/src/control_backend/schemas/program.py +++ b/src/control_backend/schemas/program.py @@ -180,7 +180,6 @@ class Trigger(ProgramElement): :ivar plan: The plan to execute. """ - name: str = "" condition: Belief plan: Plan From 500bbc2d82f7d4dea89c9202bf5014e9cf010264 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 8 Jan 2026 14:52:55 +0100 Subject: [PATCH 46/68] feat: added goal start sending functionality ref: N25B-400 --- .../agents/bdi/bdi_program_manager.py | 1 - .../user_interrupt/user_interrupt_agent.py | 31 ++++++++----------- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 7899e3c..f50fcf0 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -212,7 +212,6 @@ class BDIProgramManager(BaseAgent): continue self._initialize_internal_state(program) - await self._send_program_to_user_interrupt(program) await self._send_clear_llm_history() 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 925e10e..462d94f 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -1,4 +1,3 @@ -import asyncio import json import zmq @@ -151,6 +150,15 @@ class UserInterruptAgent(BaseAgent): payload = {"type": "phase_update", "phase_id": new_phase_id} await self._send_experiment_update(payload) + case "goal_start": + goal_name = msg.body + ui_id = self._goal_reverse_map.get(goal_name) + if ui_id: + payload = {"type": "goal_update", "id": ui_id, "active": True} + await self._send_experiment_update(payload) + self.logger.info(f"UI Update: Goal {goal_name} started (ID: {ui_id})") + else: + self.logger.warning(f"Goal start received for unknown goal : {goal_name}") case "active_norms_update": asl_slugs = [s.strip() for s in msg.body.split(";")] @@ -159,23 +167,6 @@ class UserInterruptAgent(BaseAgent): case _: self.logger.debug(f"Received internal message on unhandled thread: {msg.thread}") - # moet weg!!!!! - async def test_sending_behaviour(self): - self.logger.info("Starting simple test sending behaviour...") - - while True: - try: - test_data = {"type": "heartbeat", "status": "ok"} - - await self._send_experiment_update(test_data) - - except zmq.ZMQError as ze: - self.logger.error(f"ZMQ error: {ze}") - except Exception as e: - self.logger.error(f"Error: {e}") - - await asyncio.sleep(2) - async def _broadcast_cond_norms(self, active_slugs: list[str]): """ Sends the current state of all conditional norms to the UI. @@ -209,6 +200,10 @@ class UserInterruptAgent(BaseAgent): for goal in phase.goals: self._goal_map[str(goal.id)] = AgentSpeakGenerator.slugify(goal) + self._goal_reverse_map[AgentSpeakGenerator.slugify(goal)] = str(goal.id) + + for goal, id in self._goal_reverse_map.items(): + self.logger.debug(f"Goal mapping: UI ID {goal} -> {id}") for norm in phase.norms: if not isinstance(norm, BasicNorm): From 5e2126fc21f10b2c8cfeb0af0aefa46c07663532 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Thu, 8 Jan 2026 15:05:43 +0100 Subject: [PATCH 47/68] chore: code cleanup ref: N25B-400 --- .../user_interrupt/user_interrupt_agent.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 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 462d94f..90f4e7a 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -36,13 +36,11 @@ class UserInterruptAgent(BaseAgent): self._trigger_map = {} self._trigger_reverse_map = {} - self._goal_map = {} - self._goal_reverse_map = {} + self._goal_map = {} # id -> sluggified goal + self._goal_reverse_map = {} # sluggified goal -> id - self._cond_norm_map = {} - self._cond_norm_reverse_map = {} - - self._belief_condition_map = {} + self._cond_norm_map = {} # id -> sluggified cond norm + self._cond_norm_reverse_map = {} # sluggified cond norm -> id async def setup(self): """ @@ -61,7 +59,6 @@ class UserInterruptAgent(BaseAgent): self.pub_socket.connect(settings.zmq_settings.internal_pub_address) self.add_behavior(self._receive_button_event()) - # self.add_behavior(self.test_sending_behaviour()) async def _receive_button_event(self): """ @@ -157,13 +154,10 @@ class UserInterruptAgent(BaseAgent): payload = {"type": "goal_update", "id": ui_id, "active": True} await self._send_experiment_update(payload) self.logger.info(f"UI Update: Goal {goal_name} started (ID: {ui_id})") - else: - self.logger.warning(f"Goal start received for unknown goal : {goal_name}") case "active_norms_update": asl_slugs = [s.strip() for s in msg.body.split(";")] await self._broadcast_cond_norms(asl_slugs) - case _: self.logger.debug(f"Received internal message on unhandled thread: {msg.thread}") @@ -184,6 +178,9 @@ class UserInterruptAgent(BaseAgent): self.logger.debug(f"Broadcasted state for {len(updates)} conditional norms.") def _create_mapping(self, program_json: str): + """ + Create mappings between UI IDs and ASL slugs for triggers, goals, and conditional norms + """ try: program = Program.model_validate_json(program_json) self._trigger_map = {} From 866d7c4958b716c773a745d433bf4f6264bf855e Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Thu, 8 Jan 2026 15:13:12 +0100 Subject: [PATCH 48/68] fix: end phase loop correctly notifies about user_said ref: N25B-429 --- src/control_backend/agents/bdi/agentspeak_generator.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 51d3a63..7c70a28 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -145,7 +145,10 @@ class AgentSpeakGenerator: type=TriggerType.ADDED_BELIEF, trigger_literal=AstLiteral("user_said", [AstVar("Message")]), context=[AstLiteral("phase", [AstString("end")])], - body=[AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("reply"))], + body=[ + AstStatement(StatementType.DO_ACTION, AstLiteral("notify_user_said")), + AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("reply")), + ], ) ) From c91b9991048ae0840f54e5dbb847333295958d14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Thu, 8 Jan 2026 15:31:44 +0100 Subject: [PATCH 49/68] chore: fix bugs and make sure connected robots work --- .../agents/actuation/robot_gesture_agent.py | 2 ++ .../communication/ri_communication_agent.py | 20 ++++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/control_backend/agents/actuation/robot_gesture_agent.py b/src/control_backend/agents/actuation/robot_gesture_agent.py index 4f5dd79..5e40c04 100644 --- a/src/control_backend/agents/actuation/robot_gesture_agent.py +++ b/src/control_backend/agents/actuation/robot_gesture_agent.py @@ -83,6 +83,8 @@ class RobotGestureAgent(BaseAgent): self.subsocket.close() if self.pubsocket: self.pubsocket.close() + if self.repsocket: + self.repsocket.close() await super().stop() async def handle_message(self, msg: InternalMessage): diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index 45e3511..7fcd07b 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -48,6 +48,8 @@ class RICommunicationAgent(BaseAgent): self._req_socket: azmq.Socket | None = None self.pub_socket: azmq.Socket | None = None self.connected = False + self.gesture_agent: RobotGestureAgent | None = None + self.speech_agent: RobotSpeechAgent | None = None async def setup(self): """ @@ -141,6 +143,7 @@ class RICommunicationAgent(BaseAgent): # At this point, we have a valid response try: + self.logger.debug("Negotiation successful. Handling rn") await self._handle_negotiation_response(received_message) # Let UI know that we're connected topic = b"ping" @@ -189,6 +192,7 @@ class RICommunicationAgent(BaseAgent): address=addr, bind=bind, ) + self.speech_agent = robot_speech_agent robot_gesture_agent = RobotGestureAgent( settings.agent_settings.robot_gesture_name, address=addr, @@ -196,6 +200,7 @@ class RICommunicationAgent(BaseAgent): gesture_data=gesture_data, single_gesture_data=single_gesture_data, ) + self.gesture_agent = robot_gesture_agent await robot_speech_agent.start() await asyncio.sleep(0.1) # Small delay await robot_gesture_agent.start() @@ -226,6 +231,7 @@ class RICommunicationAgent(BaseAgent): while self._running: if not self.connected: await asyncio.sleep(settings.behaviour_settings.sleep_s) + self.logger.debug("Not connected, skipping ping loop iteration.") continue # We need to listen and send pings. @@ -289,15 +295,27 @@ class RICommunicationAgent(BaseAgent): # Tell UI we're disconnected. topic = b"ping" data = json.dumps(False).encode() + self.logger.debug("1") if self.pub_socket: try: + self.logger.debug("2") await asyncio.wait_for(self.pub_socket.send_multipart([topic, data]), 5) except TimeoutError: + self.logger.debug("3") self.logger.warning("Connection ping for router timed out.") # Try to reboot/renegotiate + if self.gesture_agent is not None: + await self.gesture_agent.stop() + + if self.speech_agent is not None: + await self.speech_agent.stop() + + if self.pub_socket is not None: + self.pub_socket.close() + self.logger.debug("Restarting communication negotiation.") - if await self._negotiate_connection(max_retries=1): + if await self._negotiate_connection(max_retries=2): self.connected = True async def handle_message(self, msg: InternalMessage): From 4b71981a3e7e7d70fd7cfdd1026907544d0aa700 Mon Sep 17 00:00:00 2001 From: Twirre Meulenbelt <43213592+TwirreM@users.noreply.github.com> Date: Mon, 12 Jan 2026 09:00:50 +0100 Subject: [PATCH 50/68] fix: some bugs and some tests ref: N25B-429 --- .../agents/bdi/bdi_core_agent.py | 1 - .../agents/bdi/bdi_program_manager.py | 10 +- .../agents/bdi/default_behavior.asl | 1 + .../agents/bdi/text_belief_extractor_agent.py | 14 +- .../communication/ri_communication_agent.py | 2 +- src/control_backend/core/config.py | 2 +- .../agents/bdi/test_bdi_program_manager.py | 4 +- .../agents/bdi/test_text_belief_extractor.py | 156 ++++++++++-------- 8 files changed, 103 insertions(+), 87 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 1658ccd..0b6fb46 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -101,7 +101,6 @@ class BDICoreAgent(BaseAgent): maybe_more_work = True while maybe_more_work: maybe_more_work = False - self.logger.debug("Stepping BDI.") if self.bdi_agent.step(): maybe_more_work = True diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 8b8c68f..13bce95 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -67,14 +67,14 @@ class BDIProgramManager(BaseAgent): await self.send(msg) - def handle_message(self, msg: InternalMessage): + async def handle_message(self, msg: InternalMessage): match msg.thread: case "transition_phase": phases = json.loads(msg.body) - self._transition_phase(phases["old"], phases["new"]) + await self._transition_phase(phases["old"], phases["new"]) - def _transition_phase(self, old: str, new: str): + async def _transition_phase(self, old: str, new: str): assert old == str(self._phase.id) if new == "end": @@ -85,8 +85,8 @@ class BDIProgramManager(BaseAgent): if str(phase.id) == new: self._phase = phase - self._send_beliefs_to_semantic_belief_extractor() - self._send_goals_to_semantic_belief_extractor() + await self._send_beliefs_to_semantic_belief_extractor() + await self._send_goals_to_semantic_belief_extractor() # Notify user interaction agent msg = InternalMessage( diff --git a/src/control_backend/agents/bdi/default_behavior.asl b/src/control_backend/agents/bdi/default_behavior.asl index 249689a..f7ae16d 100644 --- a/src/control_backend/agents/bdi/default_behavior.asl +++ b/src/control_backend/agents/bdi/default_behavior.asl @@ -1,5 +1,6 @@ norms(""). +user_said(Message) : norms(Norms) <- + .notify_user_said(Message); -user_said(Message); .reply(Message, Norms). 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 feabf40..ebd9a65 100644 --- a/src/control_backend/agents/bdi/text_belief_extractor_agent.py +++ b/src/control_backend/agents/bdi/text_belief_extractor_agent.py @@ -90,7 +90,7 @@ class TextBeliefExtractorAgent(BaseAgent): self.logger.debug("Received text from LLM: %s", msg.body) self._apply_conversation_message(ChatMessage(role="assistant", content=msg.body)) case settings.agent_settings.bdi_program_manager_name: - self._handle_program_manager_message(msg) + await self._handle_program_manager_message(msg) case _: self.logger.info("Discarding message from %s", sender) return @@ -105,7 +105,7 @@ class TextBeliefExtractorAgent(BaseAgent): length_limit = settings.behaviour_settings.conversation_history_length_limit self.conversation.messages = (self.conversation.messages + [message])[-length_limit:] - def _handle_program_manager_message(self, msg: InternalMessage): + async def _handle_program_manager_message(self, msg: InternalMessage): """ Handle a message from the program manager: extract available beliefs and goals from it. @@ -114,8 +114,10 @@ class TextBeliefExtractorAgent(BaseAgent): match msg.thread: case "beliefs": self._handle_beliefs_message(msg) + await self._infer_new_beliefs() case "goals": self._handle_goals_message(msg) + await self._infer_goal_completions() case "conversation_history": if msg.body == "reset": self._reset() @@ -141,8 +143,9 @@ class TextBeliefExtractorAgent(BaseAgent): available_beliefs = [b for b in belief_list.beliefs if isinstance(b, SemanticBelief)] self.belief_inferrer.available_beliefs = available_beliefs self.logger.debug( - "Received %d semantic beliefs from the program manager.", + "Received %d semantic beliefs from the program manager: %s", len(available_beliefs), + ", ".join(b.name for b in available_beliefs), ) def _handle_goals_message(self, msg: InternalMessage): @@ -158,8 +161,9 @@ class TextBeliefExtractorAgent(BaseAgent): available_goals = [g for g in goals_list.goals if g.can_fail] self.goal_inferrer.goals = available_goals self.logger.debug( - "Received %d failable goals from the program manager.", + "Received %d failable goals from the program manager: %s", len(available_goals), + ", ".join(g.name for g in available_goals), ) async def _user_said(self, text: str): @@ -183,6 +187,7 @@ class TextBeliefExtractorAgent(BaseAgent): new_beliefs = conversation_beliefs - self._current_beliefs if not new_beliefs: + self.logger.debug("No new beliefs detected.") return self._current_beliefs |= new_beliefs @@ -217,6 +222,7 @@ class TextBeliefExtractorAgent(BaseAgent): self._current_goal_completions[goal] = achieved if not new_achieved and not new_not_achieved: + self.logger.debug("No goal achievement changes detected.") return belief_changes = BeliefMessage( diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index 443e87c..b12bac6 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -248,7 +248,7 @@ class RICommunicationAgent(BaseAgent): self._req_socket.recv_json(), timeout=seconds_to_wait_total / 2 ) - if message["endpoint"] and message["endpoint"] != "ping": + if "endpoint" in message and message["endpoint"] != "ping": self.logger.debug(f'Received message "{message}" from RI.') if "endpoint" not in message: self.logger.warning("No received endpoint in message, expected ping endpoint.") diff --git a/src/control_backend/core/config.py b/src/control_backend/core/config.py index 02018ee..6deb1b8 100644 --- a/src/control_backend/core/config.py +++ b/src/control_backend/core/config.py @@ -91,7 +91,7 @@ class BehaviourSettings(BaseModel): vad_prob_threshold: float = 0.5 vad_initial_since_speech: int = 100 vad_non_speech_patience_chunks: int = 15 - vad_begin_silence_chunks: int = 3 + vad_begin_silence_chunks: int = 6 # transcription behaviour transcription_max_concurrent_tasks: int = 3 diff --git a/test/unit/agents/bdi/test_bdi_program_manager.py b/test/unit/agents/bdi/test_bdi_program_manager.py index 50dc4ed..217e814 100644 --- a/test/unit/agents/bdi/test_bdi_program_manager.py +++ b/test/unit/agents/bdi/test_bdi_program_manager.py @@ -108,8 +108,8 @@ async def test_send_clear_llm_history(mock_settings): await manager._send_clear_llm_history() - assert manager.send.await_count == 1 - msg: InternalMessage = manager.send.await_args[0][0] + assert manager.send.await_count == 2 + msg: InternalMessage = manager.send.await_args_list[0][0][0] # Verify the content and recipient assert msg.body == "clear_history" diff --git a/test/unit/agents/bdi/test_text_belief_extractor.py b/test/unit/agents/bdi/test_text_belief_extractor.py index 176afd2..6782ba1 100644 --- a/test/unit/agents/bdi/test_text_belief_extractor.py +++ b/test/unit/agents/bdi/test_text_belief_extractor.py @@ -6,10 +6,13 @@ import httpx import pytest from control_backend.agents.bdi import TextBeliefExtractorAgent +from control_backend.agents.bdi.text_belief_extractor_agent import BeliefState from control_backend.core.agent_system import InternalMessage from control_backend.core.config import settings from control_backend.schemas.belief_list import BeliefList +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 ( ConditionalNorm, KeywordBelief, @@ -23,11 +26,21 @@ from control_backend.schemas.program import ( @pytest.fixture -def agent(): - agent = TextBeliefExtractorAgent("text_belief_agent") - agent.send = AsyncMock() - agent._query_llm = AsyncMock() - return agent +def llm(): + llm = TextBeliefExtractorAgent.LLM(MagicMock(), 4) + llm._query_llm = AsyncMock() + return llm + + +@pytest.fixture +def agent(llm): + with patch( + "control_backend.agents.bdi.text_belief_extractor_agent.TextBeliefExtractorAgent.LLM", + return_value=llm, + ): + agent = TextBeliefExtractorAgent("text_belief_agent") + agent.send = AsyncMock() + return agent @pytest.fixture @@ -102,24 +115,12 @@ async def test_handle_message_from_transcriber(agent, mock_settings): agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. sent: InternalMessage = agent.send.call_args.args[0] # noqa - assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name + assert sent.to == mock_settings.agent_settings.bdi_core_name assert sent.thread == "beliefs" - parsed = json.loads(sent.body) - assert parsed == {"beliefs": {"user_said": [transcription]}, "type": "belief_extraction_text"} - - -@pytest.mark.asyncio -async def test_process_user_said(agent, mock_settings): - transcription = "this is a test" - - await agent._user_said(transcription) - - agent.send.assert_awaited_once() # noqa # `agent.send` has no such property, but we mock it. - sent: InternalMessage = agent.send.call_args.args[0] # noqa - assert sent.to == mock_settings.agent_settings.bdi_belief_collector_name - assert sent.thread == "beliefs" - parsed = json.loads(sent.body) - assert parsed["beliefs"]["user_said"] == [transcription] + parsed = BeliefMessage.model_validate_json(sent.body) + replaced_last = parsed.replace.pop() + assert replaced_last.name == "user_said" + assert replaced_last.arguments == [transcription] @pytest.mark.asyncio @@ -144,46 +145,46 @@ async def test_query_llm(): "control_backend.agents.bdi.text_belief_extractor_agent.httpx.AsyncClient", return_value=mock_async_client, ): - agent = TextBeliefExtractorAgent("text_belief_agent") + llm = TextBeliefExtractorAgent.LLM(MagicMock(), 4) - res = await agent._query_llm("hello world", {"type": "null"}) + res = await llm._query_llm("hello world", {"type": "null"}) # Response content was set as "null", so should be deserialized as None assert res is None @pytest.mark.asyncio -async def test_retry_query_llm_success(agent): - agent._query_llm.return_value = None - res = await agent._retry_query_llm("hello world", {"type": "null"}) +async def test_retry_query_llm_success(llm): + llm._query_llm.return_value = None + res = await llm.query("hello world", {"type": "null"}) - agent._query_llm.assert_called_once() + llm._query_llm.assert_called_once() assert res is None @pytest.mark.asyncio -async def test_retry_query_llm_success_after_failure(agent): - agent._query_llm.side_effect = [KeyError(), "real value"] - res = await agent._retry_query_llm("hello world", {"type": "string"}) +async def test_retry_query_llm_success_after_failure(llm): + llm._query_llm.side_effect = [KeyError(), "real value"] + res = await llm.query("hello world", {"type": "string"}) - assert agent._query_llm.call_count == 2 + assert llm._query_llm.call_count == 2 assert res == "real value" @pytest.mark.asyncio -async def test_retry_query_llm_failures(agent): - agent._query_llm.side_effect = [KeyError(), KeyError(), KeyError(), "real value"] - res = await agent._retry_query_llm("hello world", {"type": "string"}) +async def test_retry_query_llm_failures(llm): + llm._query_llm.side_effect = [KeyError(), KeyError(), KeyError(), "real value"] + res = await llm.query("hello world", {"type": "string"}) - assert agent._query_llm.call_count == 3 + assert llm._query_llm.call_count == 3 assert res is None @pytest.mark.asyncio -async def test_retry_query_llm_fail_immediately(agent): - agent._query_llm.side_effect = [KeyError(), "real value"] - res = await agent._retry_query_llm("hello world", {"type": "string"}, tries=1) +async def test_retry_query_llm_fail_immediately(llm): + llm._query_llm.side_effect = [KeyError(), "real value"] + res = await llm.query("hello world", {"type": "string"}, tries=1) - assert agent._query_llm.call_count == 1 + assert llm._query_llm.call_count == 1 assert res is None @@ -192,7 +193,7 @@ async def test_extracting_semantic_beliefs(agent): """ The Program Manager sends beliefs to this agent. Test whether the agent handles them correctly. """ - assert len(agent.available_beliefs) == 0 + assert len(agent.belief_inferrer.available_beliefs) == 0 beliefs = BeliefList( beliefs=[ KeywordBelief( @@ -213,26 +214,28 @@ async def test_extracting_semantic_beliefs(agent): to=settings.agent_settings.text_belief_extractor_name, sender=settings.agent_settings.bdi_program_manager_name, body=beliefs.model_dump_json(), + thread="beliefs", ), ) - assert len(agent.available_beliefs) == 2 + assert len(agent.belief_inferrer.available_beliefs) == 2 @pytest.mark.asyncio -async def test_handle_invalid_program(agent, sample_program): - agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) - agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) - assert len(agent.available_beliefs) == 2 +async def test_handle_invalid_beliefs(agent, sample_program): + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + assert len(agent.belief_inferrer.available_beliefs) == 2 await agent.handle_message( InternalMessage( to=settings.agent_settings.text_belief_extractor_name, sender=settings.agent_settings.bdi_program_manager_name, body=json.dumps({"phases": "Invalid"}), + thread="beliefs", ), ) - assert len(agent.available_beliefs) == 2 + assert len(agent.belief_inferrer.available_beliefs) == 2 @pytest.mark.asyncio @@ -254,13 +257,13 @@ async def test_handle_robot_response(agent): @pytest.mark.asyncio -async def test_simulated_real_turn_with_beliefs(agent, sample_program): +async def test_simulated_real_turn_with_beliefs(agent, llm, sample_program): """Test sending user message to extract beliefs from.""" - agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) - agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].triggers[0].condition) # Send a user message with the belief that there's no more booze - agent._query_llm.return_value = {"is_pirate": None, "no_more_booze": True} + llm._query_llm.return_value = {"is_pirate": None, "no_more_booze": True} assert len(agent.conversation.messages) == 0 await agent.handle_message( InternalMessage( @@ -275,20 +278,20 @@ async def test_simulated_real_turn_with_beliefs(agent, sample_program): assert agent.send.call_count == 2 # First should be the beliefs message - message: InternalMessage = agent.send.call_args_list[0].args[0] + message: InternalMessage = agent.send.call_args_list[1].args[0] beliefs = BeliefMessage.model_validate_json(message.body) assert len(beliefs.create) == 1 assert beliefs.create[0].name == "no_more_booze" @pytest.mark.asyncio -async def test_simulated_real_turn_no_beliefs(agent, sample_program): +async def test_simulated_real_turn_no_beliefs(agent, llm, sample_program): """Test a user message to extract beliefs from, but no beliefs are formed.""" - agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) - agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].triggers[0].condition) # Send a user message with no new beliefs - agent._query_llm.return_value = {"is_pirate": None, "no_more_booze": None} + llm._query_llm.return_value = {"is_pirate": None, "no_more_booze": None} await agent.handle_message( InternalMessage( to=settings.agent_settings.text_belief_extractor_name, @@ -302,17 +305,17 @@ async def test_simulated_real_turn_no_beliefs(agent, sample_program): @pytest.mark.asyncio -async def test_simulated_real_turn_no_new_beliefs(agent, sample_program): +async def test_simulated_real_turn_no_new_beliefs(agent, llm, sample_program): """ Test a user message to extract beliefs from, but no new beliefs are formed because they already existed. """ - agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) - agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) - agent.beliefs["is_pirate"] = True + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + agent._current_beliefs = BeliefState(true={InternalBelief(name="is_pirate", arguments=None)}) # Send a user message with the belief the user is a pirate, still - agent._query_llm.return_value = {"is_pirate": True, "no_more_booze": None} + llm._query_llm.return_value = {"is_pirate": True, "no_more_booze": None} await agent.handle_message( InternalMessage( to=settings.agent_settings.text_belief_extractor_name, @@ -326,17 +329,19 @@ async def test_simulated_real_turn_no_new_beliefs(agent, sample_program): @pytest.mark.asyncio -async def test_simulated_real_turn_remove_belief(agent, sample_program): +async def test_simulated_real_turn_remove_belief(agent, llm, sample_program): """ Test a user message to extract beliefs from, but an existing belief is determined no longer to hold. """ - agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) - agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) - agent.beliefs["no_more_booze"] = True + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + agent._current_beliefs = BeliefState( + true={InternalBelief(name="no_more_booze", arguments=None)}, + ) # Send a user message with the belief the user is a pirate, still - agent._query_llm.return_value = {"is_pirate": None, "no_more_booze": False} + llm._query_llm.return_value = {"is_pirate": None, "no_more_booze": False} await agent.handle_message( InternalMessage( to=settings.agent_settings.text_belief_extractor_name, @@ -349,18 +354,23 @@ async def test_simulated_real_turn_remove_belief(agent, sample_program): assert agent.send.call_count == 2 # Agent's current beliefs should've changed - assert not agent.beliefs["no_more_booze"] + assert any(b.name == "no_more_booze" for b in agent._current_beliefs.false) @pytest.mark.asyncio -async def test_llm_failure_handling(agent, sample_program): +async def test_llm_failure_handling(agent, llm, sample_program): """ Check that the agent handles failures gracefully without crashing. """ - agent._query_llm.side_effect = httpx.HTTPError("") - agent.available_beliefs.append(sample_program.phases[0].norms[0].condition) - agent.available_beliefs.append(sample_program.phases[0].triggers[0].condition) + llm._query_llm.side_effect = httpx.HTTPError("") + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].norms[0].condition) + agent.belief_inferrer.available_beliefs.append(sample_program.phases[0].triggers[0].condition) - belief_changes = await agent._infer_turn() + belief_changes = await agent.belief_inferrer.infer_from_conversation( + ChatHistory( + messages=[ChatMessage(role="user", content="Good day!")], + ), + ) - assert len(belief_changes) == 0 + assert len(belief_changes.true) == 0 + assert len(belief_changes.false) == 0 From d202abcd1bd87d31b18ec24f7d920836645ed4ea Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Mon, 12 Jan 2026 12:51:24 +0100 Subject: [PATCH 51/68] fix: phases update correctly there was a bug where phases would not update without restarting cb ref: N25B-400 --- .../agents/bdi/bdi_program_manager.py | 10 ++++----- .../user_interrupt/user_interrupt_agent.py | 21 ------------------- 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index f50fcf0..092a2c6 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -67,14 +67,14 @@ class BDIProgramManager(BaseAgent): await self.send(msg) - def handle_message(self, msg: InternalMessage): + async def handle_message(self, msg: InternalMessage): match msg.thread: case "transition_phase": phases = json.loads(msg.body) - self._transition_phase(phases["old"], phases["new"]) + await self._transition_phase(phases["old"], phases["new"]) - def _transition_phase(self, old: str, new: str): + async def _transition_phase(self, old: str, new: str): if old != str(self._phase.id): self.logger.warning( f"Phase transition desync detected! ASL requested move from '{old}', " @@ -90,8 +90,8 @@ class BDIProgramManager(BaseAgent): if str(phase.id) == new: self._phase = phase - self._send_beliefs_to_semantic_belief_extractor() - self._send_goals_to_semantic_belief_extractor() + await self._send_beliefs_to_semantic_belief_extractor() + await self._send_goals_to_semantic_belief_extractor() # Notify user interaction agent msg = InternalMessage( 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 90f4e7a..c42449a 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -260,27 +260,6 @@ class UserInterruptAgent(BaseAgent): ) await self.send(out_msg) - async def _send_to_program_manager(self, belief_id: str): - """ - Send a button_override belief to the BDIProgramManager. - - :param belief_id: The belief_id that overrides the goal/trigger/conditional norm. - this id can belong to a basic belief or an inferred belief. - See also: https://utrechtuniversity.youtrack.cloud/articles/N25B-A-27/UI-components - """ - data = {"belief": belief_id} - message = InternalMessage( - to=settings.agent_settings.bdi_program_manager_name, - sender=self.name, - body=json.dumps(data), - thread="belief_override_id", - ) - await self.send(message) - self.logger.info( - "Sent button_override belief with id '%s' to Program manager.", - belief_id, - ) - async def _send_to_bdi(self, thread: str, body: str): """Send slug of trigger to BDI""" msg = InternalMessage( From 54c835cc0fc4d53320dc2ffa8d09b101b4f92f65 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Mon, 12 Jan 2026 15:37:04 +0100 Subject: [PATCH 52/68] feat: added force_norm handling in BDI core agent ref: N25B-400 --- src/control_backend/agents/bdi/bdi_core_agent.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 7f961ec..572786c 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -164,6 +164,8 @@ class BDICoreAgent(BaseAgent): self._set_goal("transition_phase") case "force_trigger": self._force_trigger(msg.body) + case "force_norm": + self._force_norm(msg.body) case _: self.logger.warning("Received unknow user interruption: %s", msg) @@ -311,6 +313,17 @@ class BDICoreAgent(BaseAgent): self.logger.info("Manually forced trigger %s.", name) + # TODO: make this compatible for critical norms + def _force_norm(self, name: str): + self.bdi_agent.call( + agentspeak.Trigger.addition, + agentspeak.GoalType.belief, + agentspeak.Literal("force_norm", (agentspeak.Literal(agentspeak.asl_repr(name)),)), + agentspeak.runtime.Intention(), + ) + + self.logger.info("Manually forced norm %s.", name) + def _add_custom_actions(self) -> None: """ Add any custom actions here. Inside `@self.actions.add()`, the first argument is From 4e113c2d5c2b6f2440f5b96f2489870de0a9f6e6 Mon Sep 17 00:00:00 2001 From: Kasper Date: Mon, 12 Jan 2026 16:20:24 +0100 Subject: [PATCH 53/68] fix: default plan and norm force ref: N25B-400 --- .../agents/bdi/bdi_core_agent.py | 2 +- .../agents/bdi/default_behavior.asl | 17 +++++++++++----- .../user_interrupt/user_interrupt_agent.py | 20 +++++++++++-------- 3 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 572786c..44e9a63 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -318,7 +318,7 @@ class BDICoreAgent(BaseAgent): self.bdi_agent.call( agentspeak.Trigger.addition, agentspeak.GoalType.belief, - agentspeak.Literal("force_norm", (agentspeak.Literal(agentspeak.asl_repr(name)),)), + agentspeak.Literal(f"force_{name}"), agentspeak.runtime.Intention(), ) diff --git a/src/control_backend/agents/bdi/default_behavior.asl b/src/control_backend/agents/bdi/default_behavior.asl index f7ae16d..f7d1f95 100644 --- a/src/control_backend/agents/bdi/default_behavior.asl +++ b/src/control_backend/agents/bdi/default_behavior.asl @@ -1,6 +1,13 @@ -norms(""). +norm("Be friendly"). -+user_said(Message) : norms(Norms) <- - .notify_user_said(Message); - -user_said(Message); - .reply(Message, Norms). ++!reply + : user_said(Message) + <- .findall(Norm, norm(Norm), Norms); + .reply(Message, Norms). + ++user_said(Message) + <- .notify_user_said(Message); + !reply. + ++!transition_phase <- true. ++!check_triggers <- true. \ No newline at end of file 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 c42449a..e6ba463 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -7,7 +7,7 @@ from control_backend.agents import BaseAgent 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.program import BasicNorm, Program +from control_backend.schemas.program import ConditionalNorm, Program from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, SpeechCommand @@ -82,6 +82,8 @@ class UserInterruptAgent(BaseAgent): self.logger.error("Received invalid JSON payload on topic %s", topic) continue + self.logger.debug("Received event type %s", event_type) + if event_type == "speech": await self._send_to_speech_agent(event_context) self.logger.info( @@ -108,6 +110,8 @@ class UserInterruptAgent(BaseAgent): "Forwarded button press (override) with context '%s' to BDIProgramManager.", event_context, ) + else: + self.logger.warning("Could not determine which element to override.") else: self.logger.warning( "Received button press with unknown type '%s' (context: '%s').", @@ -203,13 +207,15 @@ class UserInterruptAgent(BaseAgent): self.logger.debug(f"Goal mapping: UI ID {goal} -> {id}") for norm in phase.norms: - if not isinstance(norm, BasicNorm): + if isinstance(norm, ConditionalNorm): asl_slug = AgentSpeakGenerator.slugify(norm) - belief_id = str(norm.condition.id) + norm_id = str(norm.id) - self._cond_norm_map[belief_id] = asl_slug - self._cond_norm_reverse_map[asl_slug] = belief_id + self._cond_norm_map[norm_id] = asl_slug + self._cond_norm_reverse_map[asl_slug] = norm_id + self._cond_norm_reverse_map[asl_slug] = norm_id + self.logger.debug("Added conditional norm %s", asl_slug) self.logger.info( f"Mapped {len(self._trigger_map)} triggers and {len(self._goal_map)} goals " @@ -262,8 +268,6 @@ class UserInterruptAgent(BaseAgent): async def _send_to_bdi(self, thread: str, body: str): """Send slug of trigger to BDI""" - msg = InternalMessage( - to=settings.agent_settings.bdi_core_name, sender=self.name, thread=thread, body=body - ) + msg = InternalMessage(to=settings.agent_settings.bdi_core_name, thread=thread, body=body) await self.send(msg) self.logger.info(f"Directly forced {thread} in BDI: {body}") From 0f0927647701a81c4faa7a6d2c069649b9ddbd0e Mon Sep 17 00:00:00 2001 From: Kasper Date: Mon, 12 Jan 2026 17:02:39 +0100 Subject: [PATCH 54/68] fix: send norms back to UI ref: N25B-400 --- src/control_backend/agents/bdi/agentspeak_generator.py | 7 ++++++- src/control_backend/agents/bdi/bdi_core_agent.py | 9 ++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index f90e63e..9ab409d 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -3,6 +3,7 @@ from functools import singledispatchmethod from slugify import slugify from control_backend.agents.bdi.agentspeak_ast import ( + AstAtom, AstBinaryOp, AstExpression, AstLiteral, @@ -215,7 +216,11 @@ class AgentSpeakGenerator: match norm: case ConditionalNorm(condition=cond): - rule = AstRule(self._astify(norm), self._astify(phase) & self._astify(cond)) + rule = AstRule( + self._astify(norm), + self._astify(phase) & self._astify(cond) + | AstAtom(f"force_{self.slugify(norm)}"), + ) case BasicNorm(): rule = AstRule(self._astify(norm), self._astify(phase)) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 44e9a63..206e411 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -341,7 +341,6 @@ class BDICoreAgent(BaseAgent): norm_update_message = InternalMessage( to=settings.agent_settings.user_interrupt_name, - sender=self.name, thread="active_norms_update", body=str(norms), ) @@ -364,6 +363,14 @@ class BDICoreAgent(BaseAgent): norms = agentspeak.grounded(term.args[1], intention.scope) goal = agentspeak.grounded(term.args[2], intention.scope) + norm_update_message = InternalMessage( + to=settings.agent_settings.user_interrupt_name, + thread="active_norms_update", + body=str(norms), + ) + + self.add_behavior(self.send(norm_update_message)) + self.logger.debug( '"reply_with_goal" action called with message=%s, norms=%s, goal=%s', message_text, From c45a258b2283341a9c0b0e666e0937961bfe7f42 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Mon, 12 Jan 2026 19:07:05 +0100 Subject: [PATCH 55/68] fix: fixed a bug where norms where not updated Now in UserInterruptAgent we store the norm.norm and not the slugified norm ref: N25B-400 --- src/control_backend/agents/bdi/bdi_core_agent.py | 3 +-- .../agents/user_interrupt/user_interrupt_agent.py | 8 ++++---- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 206e411..5b24c5d 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -342,7 +342,7 @@ class BDICoreAgent(BaseAgent): norm_update_message = InternalMessage( to=settings.agent_settings.user_interrupt_name, thread="active_norms_update", - body=str(norms), + body=norms, ) self.add_behavior(self.send(norm_update_message)) @@ -362,7 +362,6 @@ class BDICoreAgent(BaseAgent): message_text = agentspeak.grounded(term.args[0], intention.scope) norms = agentspeak.grounded(term.args[1], intention.scope) goal = agentspeak.grounded(term.args[2], intention.scope) - norm_update_message = InternalMessage( to=settings.agent_settings.user_interrupt_name, thread="active_norms_update", 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 e6ba463..28ddeca 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -159,9 +159,10 @@ class UserInterruptAgent(BaseAgent): await self._send_experiment_update(payload) self.logger.info(f"UI Update: Goal {goal_name} started (ID: {ui_id})") case "active_norms_update": - asl_slugs = [s.strip() for s in msg.body.split(";")] + self.logger.info(f"msg.bodyy{msg.body}") + norm_list = [s.strip("() '\",") for s in msg.body.split(",") if s.strip("() '\",")] - await self._broadcast_cond_norms(asl_slugs) + await self._broadcast_cond_norms(norm_list) case _: self.logger.debug(f"Received internal message on unhandled thread: {msg.thread}") @@ -213,8 +214,7 @@ class UserInterruptAgent(BaseAgent): norm_id = str(norm.id) self._cond_norm_map[norm_id] = asl_slug - self._cond_norm_reverse_map[asl_slug] = norm_id - self._cond_norm_reverse_map[asl_slug] = norm_id + self._cond_norm_reverse_map[norm.norm] = norm_id self.logger.debug("Added conditional norm %s", asl_slug) self.logger.info( From 72c2c57f260c9bf05d2c88822774e206d967013f Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Mon, 12 Jan 2026 19:31:50 +0100 Subject: [PATCH 56/68] chore: merged button functionality and fix bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit merged björns branch that has the following button functionality -Pause/resume -Next phase -Restart phase -reset experiment fix bug where norms where not properly sent to the user interrupt agent ref: N25B-400 --- .../user_interrupt/user_interrupt_agent.py | 84 ++++++++++++++++++- 1 file changed, 83 insertions(+), 1 deletion(-) 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 28ddeca..4dee823 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,12 @@ 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.program import ConditionalNorm, Program -from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, SpeechCommand +from control_backend.schemas.ri_message import ( + GestureCommand, + PauseCommand, + RIEndpoint, + SpeechCommand, +) class UserInterruptAgent(BaseAgent): @@ -70,6 +75,9 @@ class UserInterruptAgent(BaseAgent): - type: "speech", context: string that the robot has to say. - type: "gesture", context: single gesture name that the robot has to perform. - type: "override", context: belief_id that overrides the goal/trigger/conditional norm. + - type: "pause", context: boolean indicating whether to pause + - type: "reset_phase", context: None, indicates to the BDI Core to + - type: "reset_experiment", context: None, indicates to the BDI Core to """ while True: topic, body = await self.sub_socket.recv_multipart() @@ -112,6 +120,18 @@ class UserInterruptAgent(BaseAgent): ) else: self.logger.warning("Could not determine which element to override.") + elif event_type == "pause": + self.logger.debug( + "Received pause/resume button press with context '%s'.", event_context + ) + await self._send_pause_command(event_context) + if event_context: + self.logger.info("Sent pause command.") + else: + self.logger.info("Sent resume command.") + + elif event_type in ["next_phase", "reset_phase", "reset_experiment"]: + await self._send_experiment_control_to_bdi_core(event_type) else: self.logger.warning( "Received button press with unknown type '%s' (context: '%s').", @@ -271,3 +291,65 @@ class UserInterruptAgent(BaseAgent): msg = InternalMessage(to=settings.agent_settings.bdi_core_name, thread=thread, body=body) await self.send(msg) self.logger.info(f"Directly forced {thread} in BDI: {body}") + + async def _send_experiment_control_to_bdi_core(self, type): + """ + method to send experiment control buttons to bdi core. + + :param type: the type of control button we should send to the bdi core. + """ + # Switch which thread we should send to bdi core + thread = "" + match type: + case "next_phase": + thread = "force_next_phase" + case "reset_phase": + thread = "reset_current_phase" + case "reset_experiment": + thread = "reset_experiment" + case _: + self.logger.warning( + "Received unknown experiment control type '%s' to send to BDI Core.", + type, + ) + + out_msg = InternalMessage( + to=settings.agent_settings.bdi_core_name, + sender=self.name, + thread=thread, + body="", + ) + self.logger.debug("Sending experiment control '%s' to BDI Core.", thread) + await self.send(out_msg) + + async def _send_pause_command(self, pause): + """ + Send a pause command to the Robot Interface via the RI Communication Agent. + Send a pause command to the other internal agents; for now just VAD agent. + """ + cmd = PauseCommand(data=pause) + message = InternalMessage( + to=settings.agent_settings.ri_communication_name, + sender=self.name, + body=cmd.model_dump_json(), + ) + await self.send(message) + + if pause == "true": + # Send pause to VAD agent + vad_message = InternalMessage( + to=settings.agent_settings.vad_name, + sender=self.name, + body="PAUSE", + ) + await self.send(vad_message) + self.logger.info("Sent pause command to VAD Agent and RI Communication Agent.") + else: + # Send resume to VAD agent + vad_message = InternalMessage( + to=settings.agent_settings.vad_name, + sender=self.name, + body="RESUME", + ) + await self.send(vad_message) + self.logger.info("Sent resume command to VAD Agent and RI Communication Agent.") From d499111ea45ee6590149eebf8293379569409945 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Tue, 13 Jan 2026 00:52:04 +0100 Subject: [PATCH 57/68] feat: added pause functionality Storms code wasnt fully included in Bjorns branch ref: N25B-400 --- .../communication/ri_communication_agent.py | 17 ++++++++--------- .../user_interrupt/user_interrupt_agent.py | 1 - 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/control_backend/agents/communication/ri_communication_agent.py b/src/control_backend/agents/communication/ri_communication_agent.py index 2377421..719053c 100644 --- a/src/control_backend/agents/communication/ri_communication_agent.py +++ b/src/control_backend/agents/communication/ri_communication_agent.py @@ -3,12 +3,14 @@ import json import zmq import zmq.asyncio as azmq +from pydantic import ValidationError from zmq.asyncio import Context from control_backend.agents import BaseAgent from control_backend.agents.actuation.robot_gesture_agent import RobotGestureAgent from control_backend.core.config import settings from control_backend.schemas.internal_message import InternalMessage +from control_backend.schemas.ri_message import PauseCommand from ..actuation.robot_speech_agent import RobotSpeechAgent from ..perception import VADAgent @@ -320,12 +322,9 @@ class RICommunicationAgent(BaseAgent): self.connected = True async def handle_message(self, msg: InternalMessage): - """ - Handle an incoming message. - - Currently not implemented for this agent. - - :param msg: The received message. - :raises NotImplementedError: Always, since this method is not implemented. - """ - self.logger.warning("custom warning for handle msg in ri coms %s", self.name) + try: + pause_command = PauseCommand.model_validate_json(msg.body) + self._req_socket.send_json(pause_command.model_dump()) + self.logger.debug(self._req_socket.recv_json()) + except ValidationError: + self.logger.warning("Incorrect message format for PauseCommand.") 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 4dee823..05af28a 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -179,7 +179,6 @@ class UserInterruptAgent(BaseAgent): await self._send_experiment_update(payload) self.logger.info(f"UI Update: Goal {goal_name} started (ID: {ui_id})") case "active_norms_update": - self.logger.info(f"msg.bodyy{msg.body}") norm_list = [s.strip("() '\",") for s in msg.body.split(",") if s.strip("() '\",")] await self._broadcast_cond_norms(norm_list) From 0df60404449e8ee925e9c95b04c4f03c402df5d3 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Tue, 13 Jan 2026 11:24:35 +0100 Subject: [PATCH 58/68] feat: added sending goal overwrites in Userinter. ref: N25B-400 --- .../agents/user_interrupt/user_interrupt_agent.py | 6 ++++++ 1 file changed, 6 insertions(+) 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 05af28a..708e3e5 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -118,6 +118,12 @@ class UserInterruptAgent(BaseAgent): "Forwarded button press (override) with context '%s' to BDIProgramManager.", event_context, ) + elif asl_goal := self._goal_map.get(ui_id): + await self._send_to_bdi("complete_goal", asl_goal) + self.logger.info( + "Forwarded button press (override) with context '%s' to BDI Core.", + event_context, + ) else: self.logger.warning("Could not determine which element to override.") elif event_type == "pause": From 177e844349df6b2ebebe2f4408616befcd926f2b Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Tue, 13 Jan 2026 11:46:17 +0100 Subject: [PATCH 59/68] feat: send achieved goal from interrupt->manager->semantic ref: N25B-400 --- .../agents/bdi/bdi_program_manager.py | 54 ++++++++++++++++--- .../user_interrupt/user_interrupt_agent.py | 8 +++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 092a2c6..7e6dfc0 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -42,6 +42,16 @@ class BDIProgramManager(BaseAgent): def _initialize_internal_state(self, program: Program): self._program = program self._phase = program.phases[0] # start in first phase + self._goal_mapping: dict[str, Goal] = {} + for phase in program.phases: + for goal in phase.goals: + self._populate_goal_mapping_with_goal(goal) + + def _populate_goal_mapping_with_goal(self, goal: Goal): + self._goal_mapping[str(goal.id)] = goal + for step in goal.plan.steps: + if isinstance(step, Goal): + self._populate_goal_mapping_with_goal(step) async def _create_agentspeak_and_send_to_bdi(self, program: Program): """ @@ -73,6 +83,9 @@ class BDIProgramManager(BaseAgent): phases = json.loads(msg.body) await self._transition_phase(phases["old"], phases["new"]) + case "achieve_goal": + goal_id = msg.body + self._send_achieved_goal_to_semantic_belief_extractor(goal_id) async def _transition_phase(self, old: str, new: str): if old != str(self._phase.id): @@ -138,6 +151,19 @@ class BDIProgramManager(BaseAgent): await self.send(message) + @staticmethod + def _extract_goals_from_goal(goal: Goal) -> list[Goal]: + """ + Extract all goals from a given goal, that is: the goal itself and any subgoals. + + :return: All goals within and including the given goal. + """ + goals: list[Goal] = [goal] + for plan in goal.plan: + if isinstance(plan, Goal): + goals.extend(BDIProgramManager._extract_goals_from_goal(plan)) + return goals + def _extract_current_goals(self) -> list[Goal]: """ Extract all goals from the program, including subgoals. @@ -146,15 +172,8 @@ class BDIProgramManager(BaseAgent): """ goals: list[Goal] = [] - def extract_goals_from_goal(goal_: Goal) -> list[Goal]: - goals_: list[Goal] = [goal] - for plan in goal_.plan: - if isinstance(plan, Goal): - goals_.extend(extract_goals_from_goal(plan)) - return goals_ - for goal in self._phase.goals: - goals.extend(extract_goals_from_goal(goal)) + goals.extend(self._extract_goals_from_goal(goal)) return goals @@ -173,6 +192,25 @@ class BDIProgramManager(BaseAgent): await self.send(message) + async def _send_achieved_goal_to_semantic_belief_extractor(self, achieved_goal_id: str): + """ + Inform the semantic belief extractor when a goal is marked achieved. + + :param achieved_goal_id: The id of the achieved goal. + """ + goal = self._goal_mapping.get(achieved_goal_id) + if goal is None: + self.logger.debug(f"Goal with ID {achieved_goal_id} marked achieved but was not found.") + return + + goals = self._extract_goals_from_goal(goal) + message = InternalMessage( + to=settings.agent_settings.text_belief_extractor_name, + body=GoalList(goals=goals).model_dump_json(), + thread="achieved_goals", + ) + await self.send(message) + async def _send_clear_llm_history(self): """ Clear the LLM Agent's conversation history. 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 708e3e5..d92a071 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -124,6 +124,14 @@ class UserInterruptAgent(BaseAgent): "Forwarded button press (override) with context '%s' to BDI Core.", event_context, ) + + goal_achieve_msg = InternalMessage( + to=settings.agent_settings.bdi_program_manager_name, + thread="achieve_goal", + body=ui_id, + ) + + await self.send(goal_achieve_msg) else: self.logger.warning("Could not determine which element to override.") elif event_type == "pause": From 65e0b2d250cd4a68fe0b5e2b8e7a9fadf3f150c4 Mon Sep 17 00:00:00 2001 From: Pim Hutting Date: Tue, 13 Jan 2026 12:05:20 +0100 Subject: [PATCH 60/68] feat: added correct message ref: N25B-400 --- .../user_interrupt/user_interrupt_agent.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) 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 d92a071..58d2024 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -7,6 +7,7 @@ from control_backend.agents import BaseAgent 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.ri_message import ( GestureCommand, @@ -119,7 +120,7 @@ class UserInterruptAgent(BaseAgent): event_context, ) elif asl_goal := self._goal_map.get(ui_id): - await self._send_to_bdi("complete_goal", asl_goal) + await self._send_to_bdi_belief(asl_goal) self.logger.info( "Forwarded button press (override) with context '%s' to BDI Core.", event_context, @@ -134,6 +135,9 @@ class UserInterruptAgent(BaseAgent): await self.send(goal_achieve_msg) else: self.logger.warning("Could not determine which element to override.") + self.logger.warning(self._goal_map) + self.loger.warning(ui_id) + elif event_type == "pause": self.logger.debug( "Received pause/resume button press with context '%s'.", event_context @@ -305,6 +309,20 @@ class UserInterruptAgent(BaseAgent): await self.send(msg) self.logger.info(f"Directly forced {thread} in BDI: {body}") + 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) + self.logger.debug(f"Sending belief to BDI Core: {belief_name}") + belief_message = BeliefMessage(create=[belief]) + msg = InternalMessage( + to=settings.agent_settings.bdi_core_name, + thread="belief_update", + body=belief_message.model_dump_json(), + ) + await self.send(msg) + self.logger.info(f"Sent belief to BDI Core: {msg}") + async def _send_experiment_control_to_bdi_core(self, type): """ method to send experiment control buttons to bdi core. From f87651f691f0b813122decf6725d264623fb13d4 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Tue, 13 Jan 2026 12:26:18 +0100 Subject: [PATCH 61/68] fix: achieved goal in bdi core ref: N25B-400 --- src/control_backend/agents/bdi/bdi_program_manager.py | 2 +- .../agents/user_interrupt/user_interrupt_agent.py | 7 ++----- src/control_backend/api/v1/endpoints/robot.py | 1 - src/control_backend/schemas/belief_message.py | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 7e6dfc0..25b7364 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -85,7 +85,7 @@ class BDIProgramManager(BaseAgent): await self._transition_phase(phases["old"], phases["new"]) case "achieve_goal": goal_id = msg.body - self._send_achieved_goal_to_semantic_belief_extractor(goal_id) + await self._send_achieved_goal_to_semantic_belief_extractor(goal_id) async def _transition_phase(self, old: str, new: str): if old != str(self._phase.id): 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 58d2024..4bf681a 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -32,7 +32,7 @@ class UserInterruptAgent(BaseAgent): Prioritized actions clear the current RI queue before inserting the new item, ensuring they are executed immediately after Pepper's current action has been fulfilled. - :ivar sub_socket: The ZMQ SUB socket used to receive user intterupts. + :ivar sub_socket: The ZMQ SUB socket used to receive user interrupts. """ def __init__(self, **kwargs): @@ -135,8 +135,6 @@ class UserInterruptAgent(BaseAgent): await self.send(goal_achieve_msg) else: self.logger.warning("Could not determine which element to override.") - self.logger.warning(self._goal_map) - self.loger.warning(ui_id) elif event_type == "pause": self.logger.debug( @@ -317,11 +315,10 @@ class UserInterruptAgent(BaseAgent): belief_message = BeliefMessage(create=[belief]) msg = InternalMessage( to=settings.agent_settings.bdi_core_name, - thread="belief_update", + thread="beliefs", body=belief_message.model_dump_json(), ) await self.send(msg) - self.logger.info(f"Sent belief to BDI Core: {msg}") async def _send_experiment_control_to_bdi_core(self, type): """ diff --git a/src/control_backend/api/v1/endpoints/robot.py b/src/control_backend/api/v1/endpoints/robot.py index afbf1ac..95a9c40 100644 --- a/src/control_backend/api/v1/endpoints/robot.py +++ b/src/control_backend/api/v1/endpoints/robot.py @@ -137,7 +137,6 @@ async def ping_stream(request: Request): logger.info("Client disconnected from SSE") break - logger.debug(f"Yielded new connection event in robot ping router: {str(connected)}") connectedJson = json.dumps(connected) yield (f"data: {connectedJson}\n\n") diff --git a/src/control_backend/schemas/belief_message.py b/src/control_backend/schemas/belief_message.py index 51411b3..226833e 100644 --- a/src/control_backend/schemas/belief_message.py +++ b/src/control_backend/schemas/belief_message.py @@ -11,7 +11,7 @@ class Belief(BaseModel): """ name: str - arguments: list[str] | None + arguments: list[str] | None = None # To make it hashable model_config = {"frozen": True} From 2a94a45b34b8277cd0e701e2eb020022a30cf1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Otgaar?= Date: Tue, 13 Jan 2026 14:03:37 +0100 Subject: [PATCH 62/68] chore: adjust 'phase_id' to 'id' for correct payload --- .../agents/user_interrupt/user_interrupt_agent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 05af28a..108e821 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -168,7 +168,7 @@ class UserInterruptAgent(BaseAgent): new_phase_id = msg.body self.logger.info(f"Phase transition detected: {new_phase_id}") - payload = {"type": "phase_update", "phase_id": new_phase_id} + payload = {"type": "phase_update", "id": new_phase_id} await self._send_experiment_update(payload) case "goal_start": 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 63/68] 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 From 43ac8ad69faaddda61b049e170e196ca4832d036 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 14 Jan 2026 10:58:41 +0100 Subject: [PATCH 64/68] chore: delete outdated files ref: N25B-446 --- src/control_backend/agents/bdi/asl_ast.py | 203 ----------- src/control_backend/agents/bdi/asl_gen.py | 425 ---------------------- 2 files changed, 628 deletions(-) delete mode 100644 src/control_backend/agents/bdi/asl_ast.py delete mode 100644 src/control_backend/agents/bdi/asl_gen.py diff --git a/src/control_backend/agents/bdi/asl_ast.py b/src/control_backend/agents/bdi/asl_ast.py deleted file mode 100644 index 104570b..0000000 --- a/src/control_backend/agents/bdi/asl_ast.py +++ /dev/null @@ -1,203 +0,0 @@ -import typing -from dataclasses import dataclass, field - -# --- Types --- - - -@dataclass -class BeliefLiteral: - """ - Represents a literal or atom. - Example: phase(1), user_said("hello"), ~started - """ - - functor: str - args: list[str] = field(default_factory=list) - negated: bool = False - - def __str__(self): - # In ASL, 'not' is usually for closed-world assumption (prolog style), - # '~' is for explicit negation in beliefs. - # For simplicity in behavior trees, we often use 'not' for conditions. - prefix = "not " if self.negated else "" - if not self.args: - return f"{prefix}{self.functor}" - - # Clean args to ensure strings are quoted if they look like strings, - # but usually the converter handles the quoting of string literals. - args_str = ", ".join(self.args) - return f"{prefix}{self.functor}({args_str})" - - -@dataclass -class GoalLiteral: - name: str - - def __str__(self): - return f"!{self.name}" - - -@dataclass -class ActionLiteral: - """ - Represents a step in a plan body. - Example: .say("Hello") or !achieve_goal - """ - - code: str - - def __str__(self): - return self.code - - -@dataclass -class BinaryOp: - """ - Represents logical operations. - Example: (A & B) | C - """ - - left: "Expression | str" - operator: typing.Literal["&", "|"] - right: "Expression | str" - - def __str__(self): - l_str = str(self.left) - r_str = str(self.right) - - if isinstance(self.left, BinaryOp): - l_str = f"({l_str})" - if isinstance(self.right, BinaryOp): - r_str = f"({r_str})" - - return f"{l_str} {self.operator} {r_str}" - - -Literal = BeliefLiteral | GoalLiteral | ActionLiteral -Expression = Literal | BinaryOp | str - - -@dataclass -class Rule: - """ - Represents an inference rule. - Example: head :- body. - """ - - head: Expression - body: Expression | None = None - - def __str__(self): - if not self.body: - return f"{self.head}." - return f"{self.head} :- {self.body}." - - -@dataclass -class PersistentRule: - """ - Represents an inference rule, where the inferred belief is persistent when formed. - """ - - head: Expression - body: Expression - - def __str__(self): - if not self.body: - raise Exception("Rule without body should not be persistent.") - - lines = [] - - if isinstance(self.body, BinaryOp): - lines.append(f"+{self.body.left}") - if self.body.operator == "&": - lines.append(f" : {self.body.right}") - lines.append(f" <- +{self.head}.") - if self.body.operator == "|": - lines.append(f"+{self.body.right}") - lines.append(f" <- +{self.head}.") - - return "\n".join(lines) - - -@dataclass -class Plan: - """ - Represents a plan. - Syntax: +trigger : context <- body. - """ - - trigger: BeliefLiteral | GoalLiteral - context: list[Expression] = field(default_factory=list) - body: list[ActionLiteral] = field(default_factory=list) - - def __str__(self): - # Indentation settings - INDENT = " " - ARROW = "\n <- " - COLON = "\n : " - - # Build Header - header = f"+{self.trigger}" - if self.context: - ctx_str = f" &\n{INDENT}".join(str(c) for c in self.context) - header += f"{COLON}{ctx_str}" - - # Case 1: Empty body - if not self.body: - return f"{header}." - - # Case 2: Short body (optional optimization, keeping it uniform usually better) - header += ARROW - - lines = [] - # We start the first action on the same line or next line. - # Let's put it on the next line for readability if there are multiple. - - if len(self.body) == 1: - return f"{header}{self.body[0]}." - - # First item - lines.append(f"{header}{self.body[0]};") - # Middle items - for item in self.body[1:-1]: - lines.append(f"{INDENT}{item};") - # Last item - lines.append(f"{INDENT}{self.body[-1]}.") - - return "\n".join(lines) - - -@dataclass -class AgentSpeakFile: - """ - Root element representing the entire generated file. - """ - - initial_beliefs: list[Rule] = field(default_factory=list) - inference_rules: list[Rule | PersistentRule] = field(default_factory=list) - plans: list[Plan] = field(default_factory=list) - - def __str__(self): - sections = [] - - if self.initial_beliefs: - sections.append("// --- Initial Beliefs & Facts ---") - sections.extend(str(rule) for rule in self.initial_beliefs) - sections.append("") - - if self.inference_rules: - sections.append("// --- Inference Rules ---") - sections.extend(str(rule) for rule in self.inference_rules if isinstance(rule, Rule)) - sections.append("") - sections.extend( - str(rule) for rule in self.inference_rules if isinstance(rule, PersistentRule) - ) - sections.append("") - - if self.plans: - sections.append("// --- Plans ---") - # Separate plans by a newline for readability - sections.extend(str(plan) + "\n" for plan in self.plans) - - return "\n".join(sections) diff --git a/src/control_backend/agents/bdi/asl_gen.py b/src/control_backend/agents/bdi/asl_gen.py deleted file mode 100644 index 8233a36..0000000 --- a/src/control_backend/agents/bdi/asl_gen.py +++ /dev/null @@ -1,425 +0,0 @@ -import asyncio -import time -from functools import singledispatchmethod - -from slugify import slugify - -from control_backend.agents.bdi import BDICoreAgent -from control_backend.agents.bdi.asl_ast import ( - ActionLiteral, - AgentSpeakFile, - BeliefLiteral, - BinaryOp, - Expression, - GoalLiteral, - PersistentRule, - Plan, - Rule, -) -from control_backend.agents.bdi.bdi_program_manager import test_program -from control_backend.schemas.program import ( - BasicBelief, - Belief, - ConditionalNorm, - GestureAction, - Goal, - InferredBelief, - KeywordBelief, - LLMAction, - LogicalOperator, - Phase, - Program, - ProgramElement, - SemanticBelief, - SpeechAction, -) - - -async def do_things(): - res = input("Wanna generate") - if res == "y": - program = AgentSpeakGenerator().generate(test_program) - filename = f"{int(time.time())}.asl" - with open(filename, "w") as f: - f.write(program) - else: - # filename = "0test.asl" - filename = "1766062491.asl" - bdi_agent = BDICoreAgent("BDICoreAgent", filename) - flag = asyncio.Event() - await bdi_agent.start() - await flag.wait() - - -def do_other_things(): - print(AgentSpeakGenerator().generate(test_program)) - - -class AgentSpeakGenerator: - """ - Converts a Pydantic Program behavior model into an AgentSpeak(L) AST, - then renders it to a string. - """ - - def generate(self, program: Program) -> str: - asl = AgentSpeakFile() - - self._generate_startup(program, asl) - - for i, phase in enumerate(program.phases): - next_phase = program.phases[i + 1] if i < len(program.phases) - 1 else None - - self._generate_phase_flow(phase, next_phase, asl) - - self._generate_norms(phase, asl) - - self._generate_goals(phase, asl) - - self._generate_triggers(phase, asl) - - self._generate_fallbacks(program, asl) - - return str(asl) - - # --- Section: Startup & Phase Management --- - - def _generate_startup(self, program: Program, asl: AgentSpeakFile): - if not program.phases: - return - - # Initial belief: phase(start). - asl.initial_beliefs.append(Rule(head=BeliefLiteral("phase", ['"start"']))) - - # Startup plan: +started : phase(start) <- -phase(start); +phase(first_id). - asl.plans.append( - Plan( - trigger=BeliefLiteral("started"), - context=[BeliefLiteral("phase", ['"start"'])], - body=[ - ActionLiteral('-phase("start")'), - ActionLiteral(f'+phase("{program.phases[0].id}")'), - ], - ) - ) - - # Initial plans: - asl.plans.append( - Plan( - trigger=GoalLiteral("generate_response_with_goal(Goal)"), - context=[BeliefLiteral("user_said", ["Message"])], - body=[ - ActionLiteral("+responded_this_turn"), - ActionLiteral(".findall(Norm, norm(Norm), Norms)"), - ActionLiteral(".reply_with_goal(Message, Norms, Goal)"), - ], - ) - ) - - def _generate_phase_flow(self, phase: Phase, next_phase: Phase | None, asl: AgentSpeakFile): - """Generates the main loop listener and the transition logic for this phase.""" - - # +user_said(Message) : phase(ID) <- !goal1; !goal2; !transition_phase. - goal_actions = [ActionLiteral("-responded_this_turn")] - goal_actions += [ - ActionLiteral(f"!check_{self._slugify_str(keyword)}") - for keyword in self._get_keyword_conditionals(phase) - ] - goal_actions += [ActionLiteral(f"!{self._slugify(g)}") for g in phase.goals] - goal_actions.append(ActionLiteral("!transition_phase")) - - asl.plans.append( - Plan( - trigger=BeliefLiteral("user_said", ["Message"]), - context=[BeliefLiteral("phase", [f'"{phase.id}"'])], - body=goal_actions, - ) - ) - - # +!transition_phase : phase(ID) <- -phase(ID); +(NEXT_ID). - next_id = str(next_phase.id) if next_phase else "end" - - transition_context = [BeliefLiteral("phase", [f'"{phase.id}"'])] - if phase.goals: - transition_context.append(BeliefLiteral(f"achieved_{self._slugify(phase.goals[-1])}")) - - asl.plans.append( - Plan( - trigger=GoalLiteral("transition_phase"), - context=transition_context, - body=[ - ActionLiteral(f'-phase("{phase.id}")'), - ActionLiteral(f'+phase("{next_id}")'), - ActionLiteral("user_said(Anything)"), - ActionLiteral("-+user_said(Anything)"), - ], - ) - ) - - def _get_keyword_conditionals(self, phase: Phase) -> list[str]: - res = [] - for belief in self._extract_basic_beliefs_from_phase(phase): - if isinstance(belief, KeywordBelief): - res.append(belief.keyword) - - return res - - # --- Section: Norms & Beliefs --- - - def _generate_norms(self, phase: Phase, asl: AgentSpeakFile): - for norm in phase.norms: - norm_slug = f'"{norm.norm}"' - head = BeliefLiteral("norm", [norm_slug]) - - # Base context is the phase - phase_lit = BeliefLiteral("phase", [f'"{phase.id}"']) - - if isinstance(norm, ConditionalNorm): - self._ensure_belief_inference(norm.condition, asl) - - condition_expr = self._belief_to_expr(norm.condition) - body = BinaryOp(phase_lit, "&", condition_expr) - else: - body = phase_lit - - asl.inference_rules.append(Rule(head=head, body=body)) - - def _ensure_belief_inference(self, belief: Belief, asl: AgentSpeakFile): - """ - Recursively adds rules to infer beliefs. - Checks strictly to avoid duplicates if necessary, - though ASL engines often handle redefinition or we can use a set to track processed IDs. - """ - if isinstance(belief, KeywordBelief): - pass - # # Rule: keyword_said("word") :- user_said(M) & .substring("word", M, P) & P >= 0. - # kwd_slug = f'"{belief.keyword}"' - # head = BeliefLiteral("keyword_said", [kwd_slug]) - # - # # Avoid duplicates - # if any(str(r.head) == str(head) for r in asl.inference_rules): - # return - # - # body = BinaryOp( - # BeliefLiteral("user_said", ["Message"]), - # "&", - # BinaryOp(f".substring({kwd_slug}, Message, Pos)", "&", "Pos >= 0"), - # ) - # - # asl.inference_rules.append(Rule(head=head, body=body)) - - elif isinstance(belief, InferredBelief): - self._ensure_belief_inference(belief.left, asl) - self._ensure_belief_inference(belief.right, asl) - - slug = self._slugify(belief) - head = BeliefLiteral(slug) - - if any(str(r.head) == str(head) for r in asl.inference_rules): - return - - op_char = "&" if belief.operator == LogicalOperator.AND else "|" - body = BinaryOp( - self._belief_to_expr(belief.left), op_char, self._belief_to_expr(belief.right) - ) - asl.inference_rules.append(PersistentRule(head=head, body=body)) - - def _belief_to_expr(self, belief: Belief) -> Expression: - if isinstance(belief, KeywordBelief): - return BeliefLiteral("keyword_said", [f'"{belief.keyword}"']) - else: - return BeliefLiteral(self._slugify(belief)) - - # --- Section: Goals --- - - def _generate_goals(self, phase: Phase, asl: AgentSpeakFile): - previous_goal: Goal | None = None - for goal in phase.goals: - self._generate_goal_plan_recursive(goal, str(phase.id), previous_goal, asl) - previous_goal = goal - - def _generate_goal_plan_recursive( - self, - goal: Goal, - phase_id: str, - previous_goal: Goal | None, - asl: AgentSpeakFile, - responded_needed: bool = True, - can_fail: bool = True, - ): - goal_slug = self._slugify(goal) - - # phase(ID) & not responded_this_turn & not achieved_goal - context = [ - BeliefLiteral("phase", [f'"{phase_id}"']), - ] - - if responded_needed: - context.append(BeliefLiteral("responded_this_turn", negated=True)) - if can_fail: - context.append(BeliefLiteral(f"achieved_{goal_slug}", negated=True)) - - if previous_goal: - prev_slug = self._slugify(previous_goal) - context.append(BeliefLiteral(f"achieved_{prev_slug}")) - - body_actions = [] - sub_goals_to_process = [] - - for step in goal.plan.steps: - if isinstance(step, Goal): - sub_slug = self._slugify(step) - body_actions.append(ActionLiteral(f"!{sub_slug}")) - sub_goals_to_process.append(step) - elif isinstance(step, SpeechAction): - body_actions.append(ActionLiteral(f'.say("{step.text}")')) - elif isinstance(step, GestureAction): - body_actions.append(ActionLiteral(f'.gesture("{step.gesture}")')) - elif isinstance(step, LLMAction): - body_actions.append(ActionLiteral(f'!generate_response_with_goal("{step.goal}")')) - - # Mark achievement - if not goal.can_fail: - body_actions.append(ActionLiteral(f"+achieved_{goal_slug}")) - - asl.plans.append(Plan(trigger=GoalLiteral(goal_slug), context=context, body=body_actions)) - asl.plans.append( - Plan(trigger=GoalLiteral(goal_slug), context=[], body=[ActionLiteral("true")]) - ) - - prev_sub = None - for sub_goal in sub_goals_to_process: - self._generate_goal_plan_recursive(sub_goal, phase_id, prev_sub, asl) - prev_sub = sub_goal - - # --- Section: Triggers --- - - def _generate_triggers(self, phase: Phase, asl: AgentSpeakFile): - for keyword in self._get_keyword_conditionals(phase): - asl.plans.append( - Plan( - trigger=GoalLiteral(f"check_{self._slugify_str(keyword)}"), - context=[ - ActionLiteral( - f'user_said(Message) & .substring("{keyword}", Message, Pos) & Pos >= 0' - ) - ], - body=[ - ActionLiteral(f'+keyword_said("{keyword}")'), - ActionLiteral(f'-keyword_said("{keyword}")'), - ], - ) - ) - asl.plans.append( - Plan( - trigger=GoalLiteral(f"check_{self._slugify_str(keyword)}"), - body=[ActionLiteral("true")], - ) - ) - - for trigger in phase.triggers: - self._ensure_belief_inference(trigger.condition, asl) - - trigger_belief_slug = self._belief_to_expr(trigger.condition) - - body_actions = [] - sub_goals = [] - - for step in trigger.plan.steps: - if isinstance(step, Goal): - sub_slug = self._slugify(step) - body_actions.append(ActionLiteral(f"!{sub_slug}")) - sub_goals.append(step) - elif isinstance(step, SpeechAction): - body_actions.append(ActionLiteral(f'.say("{step.text}")')) - elif isinstance(step, GestureAction): - body_actions.append( - ActionLiteral(f'.gesture("{step.gesture.type}", "{step.gesture.name}")') - ) - elif isinstance(step, LLMAction): - body_actions.append( - ActionLiteral(f'!generate_response_with_goal("{step.goal}")') - ) - - asl.plans.append( - Plan( - trigger=BeliefLiteral(trigger_belief_slug), - context=[BeliefLiteral("phase", [f'"{phase.id}"'])], - body=body_actions, - ) - ) - - # Recurse for triggered goals - prev_sub = None - for sub_goal in sub_goals: - self._generate_goal_plan_recursive( - sub_goal, str(phase.id), prev_sub, asl, False, False - ) - prev_sub = sub_goal - - # --- Section: Fallbacks --- - - def _generate_fallbacks(self, program: Program, asl: AgentSpeakFile): - asl.plans.append( - Plan(trigger=GoalLiteral("transition_phase"), context=[], body=[ActionLiteral("true")]) - ) - - # --- Helpers --- - - @singledispatchmethod - def _slugify(self, element: ProgramElement) -> str: - if element.name: - raise NotImplementedError("Cannot slugify this element.") - return self._slugify_str(element.name) - - @_slugify.register - def _(self, goal: Goal) -> str: - if goal.name: - return self._slugify_str(goal.name) - return f"goal_{goal.id.hex}" - - @_slugify.register - def _(self, kwb: KeywordBelief) -> str: - return f"keyword_said({kwb.keyword})" - - @_slugify.register - def _(self, sb: SemanticBelief) -> str: - return self._slugify_str(sb.description) - - @_slugify.register - def _(self, ib: InferredBelief) -> str: - return self._slugify_str(ib.name) - - def _slugify_str(self, text: str) -> str: - return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"]) - - def _extract_basic_beliefs_from_program(self, program: Program) -> list[BasicBelief]: - beliefs = [] - - for phase in program.phases: - beliefs.extend(self._extract_basic_beliefs_from_phase(phase)) - - return beliefs - - def _extract_basic_beliefs_from_phase(self, phase: Phase) -> list[BasicBelief]: - beliefs = [] - - for norm in phase.norms: - if isinstance(norm, ConditionalNorm): - beliefs += self._extract_basic_beliefs_from_belief(norm.condition) - - for trigger in phase.triggers: - beliefs += self._extract_basic_beliefs_from_belief(trigger.condition) - - return beliefs - - def _extract_basic_beliefs_from_belief(self, belief: Belief) -> list[BasicBelief]: - if isinstance(belief, InferredBelief): - return self._extract_basic_beliefs_from_belief( - belief.left - ) + self._extract_basic_beliefs_from_belief(belief.right) - return [belief] - - -if __name__ == "__main__": - asyncio.run(do_things()) - # do_other_things()y From ff24ab7a27a4de7581a57d48c259dce69ff706d0 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 14 Jan 2026 11:24:19 +0100 Subject: [PATCH 65/68] fix: default behavior and end phase ref: N25B-448 --- .../agents/bdi/agentspeak_generator.py | 25 ++++++++++++++-- .../agents/bdi/bdi_core_agent.py | 12 +------- .../agents/bdi/bdi_program_manager.py | 4 ++- .../agents/bdi/default_behavior.asl | 29 ++++++++++++++++--- 4 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 21dc479..68d1393 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -7,6 +7,7 @@ from control_backend.agents.bdi.agentspeak_ast import ( AstBinaryOp, AstExpression, AstLiteral, + AstNumber, AstPlan, AstProgram, AstRule, @@ -44,7 +45,11 @@ class AgentSpeakGenerator: def generate(self, program: Program) -> str: self._asp = AstProgram() - self._asp.rules.append(AstRule(self._astify(program.phases[0]))) + if program.phases: + self._asp.rules.append(AstRule(self._astify(program.phases[0]))) + else: + self._asp.rules.append(AstRule(AstLiteral("phase", [AstString("end")]))) + self._add_keyword_inference() self._add_default_plans() @@ -72,6 +77,7 @@ class AgentSpeakGenerator: self._add_reply_with_goal_plan() self._add_say_plan() self._add_reply_plan() + self._add_notify_cycle_plan() def _add_reply_with_goal_plan(self): self._asp.plans.append( @@ -134,6 +140,19 @@ class AgentSpeakGenerator: ) ) + def _add_notify_cycle_plan(self): + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("notify_cycle"), + [], + [ + AstStatement(StatementType.DO_ACTION, AstLiteral("notify_ui")), + AstStatement(StatementType.DO_ACTION, AstLiteral("wait", [AstNumber(1)])), + ], + ) + ) + def _process_phases(self, phases: list[Phase]) -> None: for curr_phase, next_phase in zip([None] + phases, phases + [None], strict=True): if curr_phase: @@ -148,7 +167,9 @@ class AgentSpeakGenerator: trigger_literal=AstLiteral("user_said", [AstVar("Message")]), context=[AstLiteral("phase", [AstString("end")])], body=[ - AstStatement(StatementType.DO_ACTION, AstLiteral("notify_user_said")), + AstStatement( + StatementType.DO_ACTION, AstLiteral("notify_user_said", [AstVar("Message")]) + ), AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("reply")), ], ) diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 5b24c5d..9f8e2e4 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -342,14 +342,11 @@ class BDICoreAgent(BaseAgent): norm_update_message = InternalMessage( to=settings.agent_settings.user_interrupt_name, thread="active_norms_update", - body=norms, + body=str(norms), ) self.add_behavior(self.send(norm_update_message)) - self.logger.debug("Norms: %s", norms) - self.logger.debug("User text: %s", message_text) - self.add_behavior(self._send_to_llm(str(message_text), str(norms), "")) yield @@ -369,13 +366,6 @@ class BDICoreAgent(BaseAgent): ) self.add_behavior(self.send(norm_update_message)) - - self.logger.debug( - '"reply_with_goal" action called with message=%s, norms=%s, goal=%s', - message_text, - norms, - goal, - ) self.add_behavior(self._send_to_llm(str(message_text), str(norms), str(goal))) yield diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 25b7364..75ea757 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -279,8 +279,10 @@ class BDIProgramManager(BaseAgent): Initialize the agent. Connects the internal ZMQ SUB socket and subscribes to the 'program' topic. - Starts the background behavior to receive programs. + Starts the background behavior to receive programs. Initializes a default program. """ + await self._create_agentspeak_and_send_to_bdi(Program(phases=[])) + context = Context.instance() self.sub_socket = context.socket(zmq.SUB) diff --git a/src/control_backend/agents/bdi/default_behavior.asl b/src/control_backend/agents/bdi/default_behavior.asl index f7d1f95..b4d6682 100644 --- a/src/control_backend/agents/bdi/default_behavior.asl +++ b/src/control_backend/agents/bdi/default_behavior.asl @@ -1,13 +1,34 @@ -norm("Be friendly"). +phase("end"). +keyword_said(Keyword) :- (user_said(Message) & .substring(Keyword, Message, Pos)) & (Pos >= 0). + + ++!reply_with_goal(Goal) + : user_said(Message) + <- +responded_this_turn; + .findall(Norm, norm(Norm), Norms); + .reply_with_goal(Message, Norms, Goal). + ++!say(Text) + <- +responded_this_turn; + .say(Text). +!reply : user_said(Message) - <- .findall(Norm, norm(Norm), Norms); + <- +responded_this_turn; + .findall(Norm, norm(Norm), Norms); .reply(Message, Norms). ++!notify_cycle + <- .notify_ui; + .wait(1). + +user_said(Message) + : phase("end") <- .notify_user_said(Message); !reply. -+!transition_phase <- true. -+!check_triggers <- true. \ No newline at end of file ++!check_triggers + <- true. + ++!transition_phase + <- true. From 0794c549a8ccf570697eab10e1e3fe3c670df9ac Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 14 Jan 2026 11:27:29 +0100 Subject: [PATCH 66/68] chore: remove agentspeak file from tracking --- .gitignore | 2 + src/control_backend/agents/bdi/agentspeak.asl | 56 ------------------- 2 files changed, 2 insertions(+), 56 deletions(-) delete mode 100644 src/control_backend/agents/bdi/agentspeak.asl diff --git a/.gitignore b/.gitignore index f58719a..b6490a9 100644 --- a/.gitignore +++ b/.gitignore @@ -222,6 +222,8 @@ __marimo__/ docs/* !docs/conf.py +# Generated files +agentspeak.asl diff --git a/src/control_backend/agents/bdi/agentspeak.asl b/src/control_backend/agents/bdi/agentspeak.asl deleted file mode 100644 index 399566c..0000000 --- a/src/control_backend/agents/bdi/agentspeak.asl +++ /dev/null @@ -1,56 +0,0 @@ -phase("db4c68c3-0316-4905-a8db-22dd5bec7abf"). -keyword_said(Keyword) :- (user_said(Message) & .substring(Keyword, Message, Pos)) & (Pos >= 0). -norm("do nothing and make a little dance, do a little laugh") :- phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") & keyword_said("hi"). - - -+!reply_with_goal(Goal) - : user_said(Message) - <- +responded_this_turn; - .findall(Norm, norm(Norm), Norms); - .reply_with_goal(Message, Norms, Goal). - -+!say(Text) - <- +responded_this_turn; - .say(Text). - -+!reply - : user_said(Message) - <- +responded_this_turn; - .findall(Norm, norm(Norm), Norms); - .reply(Message, Norms). - -+user_said(Message) - : phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") - <- .notify_user_said(Message); - -responded_this_turn; - !check_triggers; - !transition_phase. - -+!check_triggers - : phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") & - semantic_hello - <- .notify_trigger_start("trigger_"); - .notify_trigger_end("trigger_"). - -+!trigger_ - <- .notify_trigger_start("trigger_"); - .notify_trigger_end("trigger_"). - -+!transition_phase - : phase("db4c68c3-0316-4905-a8db-22dd5bec7abf") & - not responded_this_turn - <- .notify_transition_phase("db4c68c3-0316-4905-a8db-22dd5bec7abf", "end"); - -phase("db4c68c3-0316-4905-a8db-22dd5bec7abf"); - +phase("end"); - ?user_said(Message); - -+user_said(Message). - -+user_said(Message) - : phase("end") - <- !reply. - -+!check_triggers - <- true. - -+!transition_phase - <- true. From 8f6662e64a72376298ff0c87fbf0d7b0df721423 Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 14 Jan 2026 13:22:51 +0100 Subject: [PATCH 67/68] feat: phase transitions ref: N25B-446 --- .../agents/bdi/agentspeak_generator.py | 23 +++++++++++++-- .../agents/bdi/bdi_core_agent.py | 28 +++++++++---------- 2 files changed, 34 insertions(+), 17 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 68d1393..11bb2c8 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -197,10 +197,12 @@ class AgentSpeakGenerator: self._astify(to_phase) if to_phase else AstLiteral("phase", [AstString("end")]) ) - context = [from_phase_ast] + check_context = [from_phase_ast] if from_phase: for goal in from_phase.goals: - context.append(self._astify(goal, achieved=True)) + check_context.append(self._astify(goal, achieved=True)) + + force_context = [from_phase_ast] body = [ AstStatement( @@ -229,8 +231,23 @@ class AgentSpeakGenerator: # ] # ) + # Check self._asp.plans.append( - AstPlan(TriggerType.ADDED_GOAL, AstLiteral("transition_phase"), context, body) + AstPlan( + TriggerType.ADDED_GOAL, + AstLiteral("transition_phase"), + check_context, + [ + AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("force_transition_phase")), + ], + ) + ) + + # Force + self._asp.plans.append( + AstPlan( + TriggerType.ADDED_GOAL, AstLiteral("force_transition_phase"), force_context, body + ) ) def _process_norm(self, norm: Norm, phase: Phase) -> None: diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 9f8e2e4..8eb4d23 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -156,8 +156,7 @@ class BDICoreAgent(BaseAgent): ) await self.send(out_msg) case settings.agent_settings.user_interrupt_name: - content = msg.body - self.logger.debug("Received user interruption: %s", content) + self.logger.debug("Received user interruption: %s", msg) match msg.thread: case "force_phase_transition": @@ -166,6 +165,8 @@ class BDICoreAgent(BaseAgent): self._force_trigger(msg.body) case "force_norm": self._force_norm(msg.body) + case "force_next_phase": + self._force_next_phase() case _: self.logger.warning("Received unknow user interruption: %s", msg) @@ -304,26 +305,21 @@ class BDICoreAgent(BaseAgent): self.logger.debug(f"Set goal !{self.format_belief_string(name, args)}.") def _force_trigger(self, name: str): - self.bdi_agent.call( - agentspeak.Trigger.addition, - agentspeak.GoalType.achievement, - agentspeak.Literal(name), - agentspeak.runtime.Intention(), - ) + self._set_goal(name) self.logger.info("Manually forced trigger %s.", name) # TODO: make this compatible for critical norms def _force_norm(self, name: str): - self.bdi_agent.call( - agentspeak.Trigger.addition, - agentspeak.GoalType.belief, - agentspeak.Literal(f"force_{name}"), - agentspeak.runtime.Intention(), - ) + self._add_belief(f"force_{name}") self.logger.info("Manually forced norm %s.", name) + def _force_next_phase(self): + self._set_goal("force_transition_phase") + + self.logger.info("Manually forced phase transition.") + def _add_custom_actions(self) -> None: """ Add any custom actions here. Inside `@self.actions.add()`, the first argument is @@ -520,6 +516,10 @@ class BDICoreAgent(BaseAgent): yield + @self.actions.add(".notify_ui", 0) + def _notify_ui(agent, term, intention): + pass + async def _send_to_llm(self, text: str, norms: str, goals: str): """ Sends a text query to the LLM agent asynchronously. From 39e1bb1ead28c7acfa870b53fa3bbf1725400f2a Mon Sep 17 00:00:00 2001 From: Kasper Marinus Date: Wed, 14 Jan 2026 15:28:29 +0100 Subject: [PATCH 68/68] fix: sync issues ref: N25B-447 --- .../agents/bdi/agentspeak_generator.py | 20 +++++++++++++++++-- .../agents/bdi/bdi_core_agent.py | 20 ++++++++----------- .../agents/bdi/bdi_program_manager.py | 9 +++++++++ .../user_interrupt/user_interrupt_agent.py | 9 +++++---- src/control_backend/core/agent_system.py | 11 ++++++---- 5 files changed, 47 insertions(+), 22 deletions(-) diff --git a/src/control_backend/agents/bdi/agentspeak_generator.py b/src/control_backend/agents/bdi/agentspeak_generator.py index 11bb2c8..ed6f787 100644 --- a/src/control_backend/agents/bdi/agentspeak_generator.py +++ b/src/control_backend/agents/bdi/agentspeak_generator.py @@ -50,6 +50,8 @@ class AgentSpeakGenerator: else: self._asp.rules.append(AstRule(AstLiteral("phase", [AstString("end")]))) + self._asp.rules.append(AstRule(AstLiteral("!notify_cycle"))) + self._add_keyword_inference() self._add_default_plans() @@ -147,8 +149,18 @@ class AgentSpeakGenerator: AstLiteral("notify_cycle"), [], [ - AstStatement(StatementType.DO_ACTION, AstLiteral("notify_ui")), - AstStatement(StatementType.DO_ACTION, AstLiteral("wait", [AstNumber(1)])), + AstStatement( + StatementType.DO_ACTION, + AstLiteral( + "findall", + [AstVar("Norm"), AstLiteral("norm", [AstVar("Norm")]), AstVar("Norms")], + ), + ), + AstStatement( + StatementType.DO_ACTION, AstLiteral("notify_norms", [AstVar("Norms")]) + ), + AstStatement(StatementType.DO_ACTION, AstLiteral("wait", [AstNumber(100)])), + AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("notify_cycle")), ], ) ) @@ -365,6 +377,10 @@ class AgentSpeakGenerator: if isinstance(step, Goal): step.can_fail = False # triggers are continuous sequence subgoals.append(step) + + # Arbitrary wait for UI to display nicely + body.append(AstStatement(StatementType.DO_ACTION, AstLiteral("wait", [AstNumber(2000)]))) + body.append( AstStatement( StatementType.DO_ACTION, diff --git a/src/control_backend/agents/bdi/bdi_core_agent.py b/src/control_backend/agents/bdi/bdi_core_agent.py index 8eb4d23..0c217dc 100644 --- a/src/control_backend/agents/bdi/bdi_core_agent.py +++ b/src/control_backend/agents/bdi/bdi_core_agent.py @@ -107,7 +107,6 @@ class BDICoreAgent(BaseAgent): if not maybe_more_work: deadline = self.bdi_agent.shortest_deadline() if deadline: - self.logger.debug("Sleeping until %s", deadline) await asyncio.sleep(deadline - time.time()) maybe_more_work = True else: @@ -335,14 +334,6 @@ class BDICoreAgent(BaseAgent): message_text = agentspeak.grounded(term.args[0], intention.scope) norms = agentspeak.grounded(term.args[1], intention.scope) - norm_update_message = InternalMessage( - to=settings.agent_settings.user_interrupt_name, - thread="active_norms_update", - body=str(norms), - ) - - self.add_behavior(self.send(norm_update_message)) - self.add_behavior(self._send_to_llm(str(message_text), str(norms), "")) yield @@ -355,14 +346,20 @@ class BDICoreAgent(BaseAgent): message_text = agentspeak.grounded(term.args[0], intention.scope) norms = agentspeak.grounded(term.args[1], intention.scope) goal = agentspeak.grounded(term.args[2], intention.scope) + self.add_behavior(self._send_to_llm(str(message_text), str(norms), str(goal))) + yield + + @self.actions.add(".notify_norms", 1) + def _notify_norms(agent, term, intention): + norms = agentspeak.grounded(term.args[0], intention.scope) + norm_update_message = InternalMessage( to=settings.agent_settings.user_interrupt_name, thread="active_norms_update", body=str(norms), ) - self.add_behavior(self.send(norm_update_message)) - self.add_behavior(self._send_to_llm(str(message_text), str(norms), str(goal))) + self.add_behavior(self.send(norm_update_message, should_log=False)) yield @self.actions.add(".say", 1) @@ -473,7 +470,6 @@ class BDICoreAgent(BaseAgent): body=str(trigger_name), ) - # TODO: check with Pim self.add_behavior(self.send(msg)) yield diff --git a/src/control_backend/agents/bdi/bdi_program_manager.py b/src/control_backend/agents/bdi/bdi_program_manager.py index 75ea757..730c8e5 100644 --- a/src/control_backend/agents/bdi/bdi_program_manager.py +++ b/src/control_backend/agents/bdi/bdi_program_manager.py @@ -97,6 +97,15 @@ class BDIProgramManager(BaseAgent): if new == "end": self._phase = None + # Notify user interaction agent + msg = InternalMessage( + to=settings.agent_settings.user_interrupt_name, + thread="transition_phase", + body="end", + ) + self.logger.info("Transitioned to end phase, notifying UserInterruptAgent.") + + self.add_behavior(self.send(msg)) return for phase in self._program.phases: 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 4f12b34..deddbba 100644 --- a/src/control_backend/agents/user_interrupt/user_interrupt_agent.py +++ b/src/control_backend/agents/user_interrupt/user_interrupt_agent.py @@ -214,8 +214,8 @@ class UserInterruptAgent(BaseAgent): payload = {"type": "cond_norms_state_update", "norms": updates} - await self._send_experiment_update(payload) - self.logger.debug(f"Broadcasted state for {len(updates)} conditional norms.") + await self._send_experiment_update(payload, should_log=False) + # self.logger.debug(f"Broadcasted state for {len(updates)} conditional norms.") def _create_mapping(self, program_json: str): """ @@ -259,7 +259,7 @@ class UserInterruptAgent(BaseAgent): except Exception as e: self.logger.error(f"Mapping failed: {e}") - async def _send_experiment_update(self, data): + async def _send_experiment_update(self, data, should_log: bool = True): """ Sends an update to the 'experiment' topic. The SSE endpoint will pick this up and push it to the UI. @@ -268,7 +268,8 @@ class UserInterruptAgent(BaseAgent): topic = b"experiment" body = json.dumps(data).encode("utf-8") await self.pub_socket.send_multipart([topic, body]) - self.logger.debug(f"Sent experiment update: {data}") + if should_log: + self.logger.debug(f"Sent experiment update: {data}") async def _send_to_speech_agent(self, text_to_say: str): """ diff --git a/src/control_backend/core/agent_system.py b/src/control_backend/core/agent_system.py index 2d8492a..e3c8dc4 100644 --- a/src/control_backend/core/agent_system.py +++ b/src/control_backend/core/agent_system.py @@ -120,7 +120,7 @@ class BaseAgent(ABC): task.cancel() self.logger.info(f"Agent {self.name} stopped") - async def send(self, message: InternalMessage): + async def send(self, message: InternalMessage, should_log: bool = True): """ Send a message to another agent. @@ -142,13 +142,17 @@ class BaseAgent(ABC): if target: await target.inbox.put(message) - self.logger.debug(f"Sent message {message.body} to {message.to} via regular inbox.") + if should_log: + self.logger.debug( + f"Sent message {message.body} to {message.to} via regular inbox." + ) else: # Apparently target agent is on a different process, send via ZMQ topic = f"internal/{receiver}".encode() body = message.model_dump_json().encode() await self._internal_pub_socket.send_multipart([topic, body]) - self.logger.debug(f"Sent message {message.body} to {message.to} via ZMQ.") + if should_log: + self.logger.debug(f"Sent message {message.body} to {message.to} via ZMQ.") async def _process_inbox(self): """ @@ -158,7 +162,6 @@ class BaseAgent(ABC): """ while self._running: msg = await self.inbox.get() - self.logger.debug(f"Received message from {msg.sender}.") await self.handle_message(msg) async def _receive_internal_zmq_loop(self):