Compare commits

...

9 Commits

Author SHA1 Message Date
ff93356a9a feat: stop experiment 2026-02-02 14:31:26 +01:00
c938345fc2 feat: no more warning on no video input 2026-02-02 12:50:43 +01:00
5d90ff4c44 feat: do nothing in end phase 2026-02-02 12:47:59 +01:00
b71ec5c76a chore: add tornado on Windows 2026-02-02 12:39:26 +01:00
b3caff0f90 feat: case insensitive keywords 2026-02-02 12:35:13 +01:00
2e717ec277 Merge branch 'fix/trigger-subgoal-error' into 'main'
Allow subgoals in triggers and empty plan

See merge request ics/sp/2025/n25b/pepperplus-cb!50
2026-01-30 19:53:51 +00:00
b53bf872a5 chore: better name checks for ProgramElement 2026-01-30 19:30:40 +01:00
1337b1f06b Merge branch 'main' into fix/trigger-subgoal-error 2026-01-30 18:19:31 +01:00
f79b65a6fa fix: allow subgoals in triggers and empty plan
Copies the goal and changes can_fail to false. Also add a warning for empty plans in goals.

ref: N25B-460
2026-01-29 15:18:09 +01:00
7 changed files with 137 additions and 64 deletions

View File

@@ -24,6 +24,7 @@ dependencies = [
"sphinx-rtd-theme>=3.0.2", "sphinx-rtd-theme>=3.0.2",
"tf-keras>=2.20.1", "tf-keras>=2.20.1",
"torch>=2.8.0", "torch>=2.8.0",
"tornado ; sys_platform == 'win32'",
"uvicorn>=0.37.0", "uvicorn>=0.37.0",
] ]

View File

@@ -4,6 +4,7 @@ University within the Software Project course.
© Copyright Utrecht University (Department of Information and Computing Sciences) © Copyright Utrecht University (Department of Information and Computing Sciences)
""" """
import logging
from functools import singledispatchmethod from functools import singledispatchmethod
from slugify import slugify from slugify import slugify
@@ -66,6 +67,7 @@ class AgentSpeakGenerator:
""" """
_asp: AstProgram _asp: AstProgram
logger = logging.getLogger(__name__)
def generate(self, program: Program) -> str: def generate(self, program: Program) -> str:
""" """
@@ -105,7 +107,7 @@ class AgentSpeakGenerator:
check if a keyword is a substring of the user's message. check if a keyword is a substring of the user's message.
The generated rule has the form: The generated rule has the form:
keyword_said(Keyword) :- user_said(Message) & .substring(Keyword, Message, Pos) & Pos >= 0 keyword_said(Keyword) :- user_said(Message) & .substring_case_insensitive(Keyword, Message, Pos) & Pos >= 0
This enables the system to trigger behaviors based on keyword detection. This enables the system to trigger behaviors based on keyword detection.
""" """
@@ -117,7 +119,7 @@ class AgentSpeakGenerator:
AstRule( AstRule(
AstLiteral("keyword_said", [keyword]), AstLiteral("keyword_said", [keyword]),
AstLiteral("user_said", [message]) AstLiteral("user_said", [message])
& AstLiteral(".substring", [keyword, message, position]) & AstLiteral(".substring_case_insensitive", [keyword, message, position])
& (position >= 0), & (position >= 0),
) )
) )
@@ -133,7 +135,6 @@ class AgentSpeakGenerator:
""" """
self._add_reply_with_goal_plan() self._add_reply_with_goal_plan()
self._add_say_plan() self._add_say_plan()
self._add_reply_plan()
self._add_notify_cycle_plan() self._add_notify_cycle_plan()
def _add_reply_with_goal_plan(self): def _add_reply_with_goal_plan(self):
@@ -197,40 +198,6 @@ class AgentSpeakGenerator:
) )
) )
def _add_reply_plan(self):
"""
Adds a plan for general reply actions.
This plan handles general reply actions where the agent needs to respond
to user input without a specific conversational goal. It:
1. Marks that the agent has responded this turn
2. Gathers all active norms
3. Generates a reply based on the user message and norms
Trigger: +!reply
Context: user_said(Message)
"""
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 _add_notify_cycle_plan(self): def _add_notify_cycle_plan(self):
""" """
@@ -268,6 +235,39 @@ class AgentSpeakGenerator:
) )
) )
def _add_stop_plan(self, phase: Phase):
"""
Adds a plan to stop the program. This just skips to the end phase,
where there is no behavior defined.
"""
self._asp.plans.append(
AstPlan(
TriggerType.ADDED_GOAL,
AstLiteral("stop"),
[AstLiteral("phase", [AstString(phase.id)])],
[
AstStatement(
StatementType.DO_ACTION,
AstLiteral(
"notify_transition_phase",
[
AstString(phase.id),
AstString("end")
]
)
),
AstStatement(
StatementType.REMOVE_BELIEF,
AstLiteral("phase", [AstVar("Phase")]),
),
AstStatement(
StatementType.ADD_BELIEF,
AstLiteral("phase", [AstString("end")])
)
]
)
)
def _process_phases(self, phases: list[Phase]) -> None: def _process_phases(self, phases: list[Phase]) -> None:
""" """
Processes all phases in the program and their transitions. Processes all phases in the program and their transitions.
@@ -284,21 +284,6 @@ class AgentSpeakGenerator:
self._process_phase(curr_phase) self._process_phase(curr_phase)
self._add_phase_transition(curr_phase, next_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.DO_ACTION, AstLiteral("notify_user_said", [AstVar("Message")])
),
AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("reply")),
],
)
)
def _process_phase(self, phase: Phase) -> None: def _process_phase(self, phase: Phase) -> None:
""" """
@@ -325,6 +310,9 @@ class AgentSpeakGenerator:
for trigger in phase.triggers: for trigger in phase.triggers:
self._process_trigger(trigger, phase) self._process_trigger(trigger, phase)
# Add force transition to end phase
self._add_stop_plan(phase)
def _add_phase_transition(self, from_phase: Phase | None, to_phase: Phase | None) -> None: def _add_phase_transition(self, from_phase: Phase | None, to_phase: Phase | None) -> None:
""" """
Adds plans for transitioning between phases. Adds plans for transitioning between phases.
@@ -479,7 +467,8 @@ class AgentSpeakGenerator:
:param main_goal: Whether this is a main goal (for UI notification purposes). :param main_goal: Whether this is a main goal (for UI notification purposes).
""" """
context: list[AstExpression] = [self._astify(phase)] context: list[AstExpression] = [self._astify(phase)]
context.append(~self._astify(goal, achieved=True)) if goal.can_fail:
context.append(~self._astify(goal, achieved=True))
if previous_goal and previous_goal.can_fail: if previous_goal and previous_goal.can_fail:
context.append(self._astify(previous_goal, achieved=True)) context.append(self._astify(previous_goal, achieved=True))
if not continues_response: if not continues_response:
@@ -503,6 +492,10 @@ class AgentSpeakGenerator:
if not goal.can_fail and not continues_response: if not goal.can_fail and not continues_response:
body.append(AstStatement(StatementType.ADD_BELIEF, self._astify(goal, achieved=True))) body.append(AstStatement(StatementType.ADD_BELIEF, self._astify(goal, achieved=True)))
if len(body) == 0:
self.logger.warning("Goal with no plan detected: %s", goal.name)
body.append(AstStatement(StatementType.EMPTY, AstLiteral("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))
self._asp.plans.append( self._asp.plans.append(
@@ -563,10 +556,10 @@ class AgentSpeakGenerator:
) )
) )
for step in trigger.plan.steps: for step in trigger.plan.steps:
body.append(self._step_to_statement(step))
if isinstance(step, Goal): if isinstance(step, Goal):
step.can_fail = False # triggers are continuous sequence new_step = step.model_copy(update={"can_fail": False}) # triggers are sequence
subgoals.append(step) subgoals.append(new_step)
body.append(self._step_to_statement(step))
# Arbitrary wait for UI to display nicely # Arbitrary wait for UI to display nicely
body.append( body.append(
@@ -610,6 +603,7 @@ class AgentSpeakGenerator:
- check_triggers: When no triggers are applicable - check_triggers: When no triggers are applicable
- transition_phase: When phase transition conditions aren't met - transition_phase: When phase transition conditions aren't met
- force_transition_phase: When forced transitions aren't possible - force_transition_phase: When forced transitions aren't possible
- stop: When we are already in the end phase
""" """
# Trigger fallback # Trigger fallback
self._asp.plans.append( self._asp.plans.append(
@@ -641,6 +635,16 @@ class AgentSpeakGenerator:
) )
) )
# Stop fallback
self._asp.plans.append(
AstPlan(
TriggerType.ADDED_GOAL,
AstLiteral("stop"),
[],
[AstStatement(StatementType.EMPTY, AstLiteral("true"))],
)
)
@singledispatchmethod @singledispatchmethod
def _astify(self, element: ProgramElement) -> AstExpression: def _astify(self, element: ProgramElement) -> AstExpression:
""" """

View File

@@ -176,6 +176,8 @@ class BDICoreAgent(BaseAgent):
self._force_norm(msg.body) self._force_norm(msg.body)
case "force_next_phase": case "force_next_phase":
self._force_next_phase() self._force_next_phase()
case "stop":
self._stop()
case _: case _:
self.logger.warning("Received unknown user interruption: %s", msg) self.logger.warning("Received unknown user interruption: %s", msg)
@@ -335,6 +337,11 @@ class BDICoreAgent(BaseAgent):
self.logger.info("Manually forced phase transition.") self.logger.info("Manually forced phase transition.")
def _stop(self):
self._set_goal("stop")
self.logger.info("Stopped the program (skipped to end phase).")
def _add_custom_actions(self) -> None: def _add_custom_actions(self) -> None:
""" """
Add any custom actions here. Inside `@self.actions.add()`, the first argument is Add any custom actions here. Inside `@self.actions.add()`, the first argument is
@@ -342,6 +349,28 @@ class BDICoreAgent(BaseAgent):
the function expects (which will be located in `term.args`). the function expects (which will be located in `term.args`).
""" """
@self.actions.add(".substring_case_insensitive", 3)
@agentspeak.optimizer.function_like
def _substring(agent, term, intention):
"""
Find out if a string is a substring of another (case insensitive). Copied mostly from
the agentspeak library method .substring.
"""
needle = agentspeak.asl_str(agentspeak.grounded(term.args[0], intention.scope)).lower()
haystack = agentspeak.asl_str(agentspeak.grounded(term.args[1], intention.scope)).lower()
choicepoint = object()
pos = haystack.find(needle)
while pos != -1:
intention.stack.append(choicepoint)
if agentspeak.unify(term.args[2], pos, intention.scope, intention.stack):
yield
agentspeak.reroll(intention.scope, intention.stack, choicepoint)
pos = haystack.find(needle, pos + 1)
@self.actions.add(".reply", 2) @self.actions.add(".reply", 2)
def _reply(agent, term, intention): def _reply(agent, term, intention):
""" """
@@ -467,7 +496,6 @@ class BDICoreAgent(BaseAgent):
body=str(trigger_name), body=str(trigger_name),
) )
# TODO: check with Pim
self.add_behavior(self.send(msg)) self.add_behavior(self.send(msg))
yield yield

View File

@@ -128,7 +128,7 @@ class VisualEmotionRecognitionAgent(BaseAgent):
next_window_time = time.time() + self.window_duration next_window_time = time.time() + self.window_duration
except zmq.Again: except zmq.Again:
self.logger.warning("No video frame received within timeout.") pass
except Exception as e: except Exception as e:
self.logger.error(f"Error in emotion recognition loop: {e}") self.logger.error(f"Error in emotion recognition loop: {e}")

View File

@@ -164,6 +164,12 @@ class UserInterruptAgent(BaseAgent):
else: else:
self.logger.info("Sent resume command.") self.logger.info("Sent resume command.")
case "stop":
self.logger.debug(
"Received stop command."
)
await self._send_stop_command()
case "next_phase" | "reset_phase": case "next_phase" | "reset_phase":
await self._send_experiment_control_to_bdi_core(event_type) await self._send_experiment_control_to_bdi_core(event_type)
case _: case _:
@@ -423,3 +429,15 @@ class UserInterruptAgent(BaseAgent):
await self.send(vad_message) await self.send(vad_message)
# Voice Activity Detection and Visual Emotion Recognition agents # Voice Activity Detection and Visual Emotion Recognition agents
self.logger.info("Sent resume command to VAD and VED agents.") self.logger.info("Sent resume command to VAD and VED agents.")
async def _send_stop_command(self):
"""
Send a command to the BDI to stop the program (i.e., skip to end phase).
"""
msg = InternalMessage(
to=settings.agent_settings.bdi_core_name,
body="",
thread="stop"
)
await self.send(msg)

View File

@@ -7,7 +7,7 @@ University within the Software Project course.
from enum import Enum from enum import Enum
from typing import Literal from typing import Literal
from pydantic import UUID4, BaseModel from pydantic import UUID4, BaseModel, field_validator
class ProgramElement(BaseModel): class ProgramElement(BaseModel):
@@ -24,6 +24,13 @@ class ProgramElement(BaseModel):
# To make program elements hashable # To make program elements hashable
model_config = {"frozen": True} model_config = {"frozen": True}
@field_validator("name")
@classmethod
def name_must_not_start_with_number(cls, v: str) -> str:
if v and v[0].isdigit():
raise ValueError('Field "name" must not start with a number.')
return v
class LogicalOperator(Enum): class LogicalOperator(Enum):
""" """
@@ -105,6 +112,7 @@ class InferredBelief(ProgramElement):
left: Belief left: Belief
right: Belief right: Belief
class EmotionBelief(ProgramElement): class EmotionBelief(ProgramElement):
""" """
Represents a belief that is set when a certain emotion is detected. Represents a belief that is set when a certain emotion is detected.
@@ -115,6 +123,7 @@ class EmotionBelief(ProgramElement):
name: str = "" name: str = ""
emotion: str emotion: str
class Norm(ProgramElement): class Norm(ProgramElement):
""" """
Base class for behavioral norms that guide the robot's interactions. Base class for behavioral norms that guide the robot's interactions.

15
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 3 revision = 2
requires-python = ">=3.13" requires-python = ">=3.13"
resolution-markers = [ resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'", "python_full_version >= '3.14' and sys_platform == 'darwin'",
@@ -1524,6 +1524,7 @@ dependencies = [
{ name = "sphinx-rtd-theme" }, { name = "sphinx-rtd-theme" },
{ name = "tf-keras" }, { name = "tf-keras" },
{ name = "torch" }, { name = "torch" },
{ name = "tornado", marker = "sys_platform == 'win32'" },
{ name = "uvicorn" }, { name = "uvicorn" },
] ]
@@ -1579,6 +1580,7 @@ requires-dist = [
{ name = "sphinx-rtd-theme", specifier = ">=3.0.2" }, { name = "sphinx-rtd-theme", specifier = ">=3.0.2" },
{ name = "tf-keras", specifier = ">=2.20.1" }, { name = "tf-keras", specifier = ">=2.20.1" },
{ name = "torch", specifier = ">=2.8.0" }, { name = "torch", specifier = ">=2.8.0" },
{ name = "tornado", marker = "sys_platform == 'win32'" },
{ name = "uvicorn", specifier = ">=0.37.0" }, { name = "uvicorn", specifier = ">=0.37.0" },
] ]
@@ -2724,6 +2726,17 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/52/27/7fc2d7435af044ffbe0b9b8e98d99eac096d43f128a5cde23c04825d5dcf/torchaudio-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d4a715d09ac28c920d031ee1e60ecbc91e8a5079ad8c61c0277e658436c821a6", size = 2549553, upload-time = "2025-08-06T14:59:00.019Z" }, { url = "https://files.pythonhosted.org/packages/52/27/7fc2d7435af044ffbe0b9b8e98d99eac096d43f128a5cde23c04825d5dcf/torchaudio-2.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:d4a715d09ac28c920d031ee1e60ecbc91e8a5079ad8c61c0277e658436c821a6", size = 2549553, upload-time = "2025-08-06T14:59:00.019Z" },
] ]
[[package]]
name = "tornado"
version = "6.5.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/37/1d/0a336abf618272d53f62ebe274f712e213f5a03c0b2339575430b8362ef2/tornado-6.5.4.tar.gz", hash = "sha256:a22fa9047405d03260b483980635f0b041989d8bcc9a313f8fe18b411d84b1d7", size = 513632, upload-time = "2025-12-15T19:21:03.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/1a/d7592328d037d36f2d2462f4bc1fbb383eec9278bc786c1b111cbbd44cfa/tornado-6.5.4-cp39-abi3-win32.whl", hash = "sha256:1768110f2411d5cd281bac0a090f707223ce77fd110424361092859e089b38d1", size = 446481, upload-time = "2025-12-15T19:21:00.008Z" },
{ url = "https://files.pythonhosted.org/packages/d6/6d/c69be695a0a64fd37a97db12355a035a6d90f79067a3cf936ec2b1dc38cd/tornado-6.5.4-cp39-abi3-win_amd64.whl", hash = "sha256:fa07d31e0cd85c60713f2b995da613588aa03e1303d75705dca6af8babc18ddc", size = 446886, upload-time = "2025-12-15T19:21:01.287Z" },
{ url = "https://files.pythonhosted.org/packages/50/49/8dc3fd90902f70084bd2cd059d576ddb4f8bb44c2c7c0e33a11422acb17e/tornado-6.5.4-cp39-abi3-win_arm64.whl", hash = "sha256:053e6e16701eb6cbe641f308f4c1a9541f91b6261991160391bfc342e8a551a1", size = 445910, upload-time = "2025-12-15T19:21:02.571Z" },
]
[[package]] [[package]]
name = "tqdm" name = "tqdm"
version = "4.67.1" version = "4.67.1"