Compare commits

...

29 Commits
demo ... dev

Author SHA1 Message Date
Pim Hutting
f2953fb1de Merge branch 'feat/use-experiment-logs' into 'dev'
Add useful experiment logs

See merge request ics/sp/2025/n25b/pepperplus-cb!48
2026-01-27 17:45:41 +00:00
Twirre Meulenbelt
4b6980a26e Merge remote-tracking branch 'origin/dev' into feat/use-experiment-logs
# Conflicts:
#	src/control_backend/agents/llm/llm_agent.py
2026-01-27 18:42:18 +01:00
Twirre
0413e0f710 Merge branch 'feat/longer-pauses-possible' into 'dev'
Stop LLM response when user adds something to their message

See merge request ics/sp/2025/n25b/pepperplus-cb!47
2026-01-27 17:34:28 +00:00
Twirre Meulenbelt
941aa00b7b chore: re-addd more silence before speech audio 2026-01-27 18:19:20 +01:00
Twirre
cc09c1b289 Merge branch 'feat/recursive-goals-override' into 'dev'
feat: add recursive goal mapping to UserInterruptAgent

See merge request ics/sp/2025/n25b/pepperplus-cb!46
2026-01-27 17:04:48 +00:00
Twirre Meulenbelt
82aa7c76df test: fix tests
ref: N25B-401
2026-01-27 17:06:13 +01:00
Pim Hutting
bc9045c977 chore: applied feedback 2026-01-27 17:03:36 +01:00
Twirre Meulenbelt
43d81002ec feat: add useful experiment logs
ref: N25B-401
2026-01-27 16:52:18 +01:00
Pim Hutting
1e7c2ba229 chore: added missing tests 2026-01-27 12:05:55 +01:00
Pim Hutting
2404c847ae feat: added recursive goal mapping and tests
ref: N25B-400
2026-01-27 12:05:27 +01:00
Björn Otgaar
7c8a56dfcc Merge branch 'chore/add-missing-tests' into 'dev'
chore: add missing tests

See merge request ics/sp/2025/n25b/pepperplus-cb!45
2026-01-27 10:58:11 +00:00
Pim Hutting
9b040ffc62 chore: applied feedback 2026-01-27 11:51:40 +01:00
Pim Hutting
27f91150e1 fix: look for goals in steps rather than plans
small bugfix, we used to look for goals in plans, but they are part of
a plan.

ref: N25B-400
2026-01-27 11:51:40 +01:00
Pim Hutting
215bafe27f chore: added missing tests 2026-01-27 11:50:26 +01:00
da0f48e96d Merge branch 'feat/reset-experiment-and-phase' into 'dev'
The Big One

See merge request ics/sp/2025/n25b/pepperplus-cb!43
2026-01-26 19:20:44 +00:00
Twirre Meulenbelt
7dd47c9de8 Merge remote-tracking branch 'origin/feat/reset-experiment-and-phase' into feat/reset-experiment-and-phase 2026-01-26 19:55:24 +01:00
Twirre Meulenbelt
4f927bc025 fix: make DOS from other agents impossible
There were some missing value checks. Other agents could cause errors in the User Interrupt agent or the Program Manager agent by sending malformed messages.

ref: N25B-453
2026-01-26 19:51:14 +01:00
bc1fa2ea35 Merge branch 'dev' into feat/reset-experiment-and-phase 2026-01-26 19:44:46 +01:00
650050fa0f chore: move magic numbers to env and cleanup 2026-01-26 19:28:16 +01:00
Twirre Meulenbelt
d8dc558d3e docs: update existing docstrings and add new docs
ref: N25B-453
2026-01-26 16:04:01 +01:00
Björn Otgaar
e7e305c4a3 Merge branch 'feat/experiment-logging' into 'dev'
Experiment log stream, to console, file and UI

See merge request ics/sp/2025/n25b/pepperplus-cb!44
2026-01-26 13:39:58 +00:00
b9df47b7d1 docs: add docstrings to AgentSpeak stuff
ref: N25B-449
2026-01-26 12:21:04 +01:00
Twirre Meulenbelt
a74ecc6c45 docs: add docstrings to dated file handler
ref: N25B-401
2026-01-22 11:48:02 +01:00
Twirre Meulenbelt
58881b5914 test: add test cases
ref: N25B-401
2026-01-19 12:47:59 +01:00
Twirre Meulenbelt
ba79d09c5d feat: log download endpoints
ref: N25B-401
2026-01-16 16:32:51 +01:00
Twirre Meulenbelt
4cda4e5e70 feat: experiment log stream, to file and UI
Adds a few new logging utility classes. One to save to files with a date, one to support optional fields in formats, last to filter partial log messages.

ref: N25B-401
2026-01-15 17:07:49 +01:00
Luijkx,S.O.H. (Storm)
a9df9208bc Merge branch 'feat/multiple-receivers' into 'dev'
feat: able to send to multiple receivers

See merge request ics/sp/2025/n25b/pepperplus-cb!42
2026-01-15 09:26:12 +00:00
Twirre Meulenbelt
d7d697b293 docs: update to docstring
ref: N25B-441
2026-01-13 17:09:26 +01:00
Twirre Meulenbelt
9a55067a13 fix: set sender for internal messages
ref: N25B-441
2026-01-13 17:07:17 +01:00
36 changed files with 2111 additions and 84 deletions

3
.gitignore vendored
View File

@@ -223,7 +223,8 @@ docs/*
!docs/conf.py !docs/conf.py
# Generated files # Generated files
agentspeak.asl *.asl
experiment-*.log

View File

@@ -1,36 +1,57 @@
version: 1 version: 1
custom_levels: custom_levels:
OBSERVATION: 25 OBSERVATION: 24
ACTION: 26 ACTION: 25
CHAT: 26
LLM: 9 LLM: 9
formatters: formatters:
# Console output # Console output
colored: colored:
(): "colorlog.ColoredFormatter" class: colorlog.ColoredFormatter
format: "{log_color}{asctime}.{msecs:03.0f} | {levelname:11} | {name:70} | {message}" format: "{log_color}{asctime}.{msecs:03.0f} | {levelname:11} | {name:70} | {message}"
style: "{" style: "{"
datefmt: "%H:%M:%S" datefmt: "%H:%M:%S"
# User-facing UI (structured JSON) # User-facing UI (structured JSON)
json_experiment: json:
(): "pythonjsonlogger.jsonlogger.JsonFormatter" class: pythonjsonlogger.jsonlogger.JsonFormatter
format: "{name} {levelname} {levelno} {message} {created} {relativeCreated}" format: "{name} {levelname} {levelno} {message} {created} {relativeCreated}"
style: "{" style: "{"
# Experiment stream for console and file output, with optional `role` field
experiment:
class: control_backend.logging.OptionalFieldFormatter
format: "%(asctime)s %(levelname)s %(role?)s %(message)s"
defaults:
role: "-"
filters:
# Filter out any log records that have the extra field "partial" set to True, indicating that they
# will be replaced later.
partial:
(): control_backend.logging.PartialFilter
handlers: handlers:
console: console:
class: logging.StreamHandler class: logging.StreamHandler
level: DEBUG level: DEBUG
formatter: colored formatter: colored
filters: [partial]
stream: ext://sys.stdout stream: ext://sys.stdout
ui: ui:
class: zmq.log.handlers.PUBHandler class: zmq.log.handlers.PUBHandler
level: LLM level: LLM
formatter: json_experiment formatter: json
file:
class: control_backend.logging.DatedFileHandler
formatter: experiment
filters: [partial]
# Directory must match config.logging_settings.experiment_log_directory
file_prefix: experiment_logs/experiment
# Level of external libraries # Level for external libraries
root: root:
level: WARN level: WARN
handlers: [console] handlers: [console]
@@ -39,3 +60,6 @@ loggers:
control_backend: control_backend:
level: LLM level: LLM
handlers: [ui] handlers: [ui]
experiment: # This name must match config.logging_settings.experiment_logger_name
level: DEBUG
handlers: [ui, file]

View File

@@ -1,4 +1,5 @@
import json import json
import logging
import zmq import zmq
import zmq.asyncio as azmq import zmq.asyncio as azmq
@@ -8,6 +9,8 @@ from control_backend.core.agent_system import InternalMessage
from control_backend.core.config import settings from control_backend.core.config import settings
from control_backend.schemas.ri_message import GestureCommand, RIEndpoint from control_backend.schemas.ri_message import GestureCommand, RIEndpoint
experiment_logger = logging.getLogger(settings.logging_settings.experiment_logger_name)
class RobotGestureAgent(BaseAgent): class RobotGestureAgent(BaseAgent):
""" """
@@ -111,6 +114,7 @@ class RobotGestureAgent(BaseAgent):
gesture_command.data, gesture_command.data,
) )
return return
experiment_logger.action("Gesture: %s", gesture_command.data)
await self.pubsocket.send_json(gesture_command.model_dump()) await self.pubsocket.send_json(gesture_command.model_dump())
except Exception: except Exception:
self.logger.exception("Error processing internal message.") self.logger.exception("Error processing internal message.")

View File

@@ -1,9 +1,10 @@
import logging import logging
from abc import ABC
from control_backend.core.agent_system import BaseAgent as CoreBaseAgent from control_backend.core.agent_system import BaseAgent as CoreBaseAgent
class BaseAgent(CoreBaseAgent): class BaseAgent(CoreBaseAgent, ABC):
""" """
The primary base class for all implementation agents. The primary base class for all implementation agents.

View File

@@ -8,31 +8,78 @@ from enum import StrEnum
class AstNode(ABC): class AstNode(ABC):
""" """
Abstract base class for all elements of an AgentSpeak program. Abstract base class for all elements of an AgentSpeak program.
This class serves as the foundation for all AgentSpeak abstract syntax tree (AST) nodes.
It defines the core interface that all AST nodes must implement to generate AgentSpeak code.
""" """
@abstractmethod @abstractmethod
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
""" """
Generates the AgentSpeak code string. Generates the AgentSpeak code string.
This method converts the AST node into its corresponding
AgentSpeak source code representation.
:return: The AgentSpeak code string representation of this node.
""" """
pass pass
def __str__(self) -> str: def __str__(self) -> str:
"""
Returns the string representation of this AST node.
This method provides a convenient way to get the AgentSpeak code representation
by delegating to the _to_agentspeak method.
:return: The AgentSpeak code string representation of this node.
"""
return self._to_agentspeak() return self._to_agentspeak()
class AstExpression(AstNode, ABC): class AstExpression(AstNode, ABC):
""" """
Intermediate class for anything that can be used in a logical expression. Intermediate class for anything that can be used in a logical expression.
This class extends AstNode to provide common functionality for all expressions
that can be used in logical operations within AgentSpeak programs.
""" """
def __and__(self, other: ExprCoalescible) -> AstBinaryOp: def __and__(self, other: ExprCoalescible) -> AstBinaryOp:
"""
Creates a logical AND operation between this expression and another.
This method allows for operator overloading of the & operator to create
binary logical operations in a more intuitive syntax.
:param other: The right-hand side expression to combine with this one.
:return: A new AstBinaryOp representing the logical AND operation.
"""
return AstBinaryOp(self, BinaryOperatorType.AND, _coalesce_expr(other)) return AstBinaryOp(self, BinaryOperatorType.AND, _coalesce_expr(other))
def __or__(self, other: ExprCoalescible) -> AstBinaryOp: def __or__(self, other: ExprCoalescible) -> AstBinaryOp:
"""
Creates a logical OR operation between this expression and another.
This method allows for operator overloading of the | operator to create
binary logical operations in a more intuitive syntax.
:param other: The right-hand side expression to combine with this one.
:return: A new AstBinaryOp representing the logical OR operation.
"""
return AstBinaryOp(self, BinaryOperatorType.OR, _coalesce_expr(other)) return AstBinaryOp(self, BinaryOperatorType.OR, _coalesce_expr(other))
def __invert__(self) -> AstLogicalExpression: def __invert__(self) -> AstLogicalExpression:
"""
Creates a logical negation of this expression.
This method allows for operator overloading of the ~ operator to create
negated expressions. If the expression is already a logical expression,
it toggles the negation flag. Otherwise, it wraps the expression in a
new AstLogicalExpression with negation set to True.
:return: An AstLogicalExpression representing the negated form of this expression.
"""
if isinstance(self, AstLogicalExpression): if isinstance(self, AstLogicalExpression):
self.negated = not self.negated self.negated = not self.negated
return self return self
@@ -81,11 +128,25 @@ class AstTerm(AstExpression, ABC):
class AstAtom(AstTerm): class AstAtom(AstTerm):
""" """
Represents a grounded atom in AgentSpeak (e.g., lowercase constants). Represents a grounded atom in AgentSpeak (e.g., lowercase constants).
Atoms are the simplest form of terms in AgentSpeak, representing concrete,
unchanging values. They are typically used as constants in logical expressions.
:ivar value: The string value of this atom, which will be converted to lowercase
in the AgentSpeak representation.
""" """
value: str value: str
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this atom to its AgentSpeak string representation.
Atoms are represented in lowercase in AgentSpeak to distinguish them
from variables (which are capitalized).
:return: The lowercase string representation of this atom.
"""
return self.value.lower() return self.value.lower()
@@ -93,11 +154,25 @@ class AstAtom(AstTerm):
class AstVar(AstTerm): class AstVar(AstTerm):
""" """
Represents an ungrounded variable in AgentSpeak (e.g., capitalized names). Represents an ungrounded variable in AgentSpeak (e.g., capitalized names).
Variables in AgentSpeak are placeholders that can be bound to specific values
during execution. They are distinguished from atoms by their capitalization.
:ivar name: The name of this variable, which will be capitalized in the
AgentSpeak representation.
""" """
name: str name: str
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this variable to its AgentSpeak string representation.
Variables are represented with capitalized names in AgentSpeak to distinguish
them from atoms (which are lowercase).
:return: The capitalized string representation of this variable.
"""
return self.name.capitalize() return self.name.capitalize()
@@ -105,11 +180,21 @@ class AstVar(AstTerm):
class AstNumber(AstTerm): class AstNumber(AstTerm):
""" """
Represents a numeric constant in AgentSpeak. Represents a numeric constant in AgentSpeak.
Numeric constants can be either integers or floating-point numbers and are
used in logical expressions and comparisons.
:ivar value: The numeric value of this constant (can be int or float).
""" """
value: int | float value: int | float
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this numeric constant to its AgentSpeak string representation.
:return: The string representation of the numeric value.
"""
return str(self.value) return str(self.value)
@@ -117,11 +202,23 @@ class AstNumber(AstTerm):
class AstString(AstTerm): class AstString(AstTerm):
""" """
Represents a string literal in AgentSpeak. Represents a string literal in AgentSpeak.
String literals are used to represent textual data and are enclosed in
double quotes in the AgentSpeak representation.
:ivar value: The string content of this literal.
""" """
value: str value: str
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this string literal to its AgentSpeak string representation.
String literals are enclosed in double quotes in AgentSpeak.
:return: The string literal enclosed in double quotes.
"""
return f'"{self.value}"' return f'"{self.value}"'
@@ -129,12 +226,26 @@ class AstString(AstTerm):
class AstLiteral(AstTerm): class AstLiteral(AstTerm):
""" """
Represents a literal (functor and terms) in AgentSpeak. Represents a literal (functor and terms) in AgentSpeak.
Literals are the fundamental building blocks of AgentSpeak programs, consisting
of a functor (predicate name) and an optional list of terms (arguments).
:ivar functor: The name of the predicate or function.
:ivar terms: A list of terms (arguments) for this literal. Defaults to an empty list.
""" """
functor: str functor: str
terms: list[AstTerm] = field(default_factory=list) terms: list[AstTerm] = field(default_factory=list)
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this literal to its AgentSpeak string representation.
If the literal has no terms, it returns just the functor name.
Otherwise, it returns the functor followed by the terms in parentheses.
:return: The AgentSpeak string representation of this literal.
"""
if not self.terms: if not self.terms:
return self.functor return self.functor
args = ", ".join(map(str, self.terms)) args = ", ".join(map(str, self.terms))
@@ -142,6 +253,13 @@ class AstLiteral(AstTerm):
class BinaryOperatorType(StrEnum): class BinaryOperatorType(StrEnum):
"""
Enumeration of binary operator types used in AgentSpeak expressions.
These operators are used to create binary operations between expressions,
including logical operations (AND, OR) and comparison operations.
"""
AND = "&" AND = "&"
OR = "|" OR = "|"
GREATER_THAN = ">" GREATER_THAN = ">"
@@ -156,6 +274,13 @@ class BinaryOperatorType(StrEnum):
class AstBinaryOp(AstExpression): class AstBinaryOp(AstExpression):
""" """
Represents a binary logical or relational operation in AgentSpeak. Represents a binary logical or relational operation in AgentSpeak.
Binary operations combine two expressions using a logical or comparison operator.
They are used to create complex logical conditions in AgentSpeak programs.
:ivar left: The left-hand side expression of the operation.
:ivar operator: The binary operator type (AND, OR, comparison operators, etc.).
:ivar right: The right-hand side expression of the operation.
""" """
left: AstExpression left: AstExpression
@@ -163,10 +288,25 @@ class AstBinaryOp(AstExpression):
right: AstExpression right: AstExpression
def __post_init__(self): def __post_init__(self):
"""
Post-initialization processing to ensure proper expression types.
This method wraps the left and right expressions in AstLogicalExpression
instances if they aren't already, ensuring consistent handling throughout
the AST.
"""
self.left = _as_logical(self.left) self.left = _as_logical(self.left)
self.right = _as_logical(self.right) self.right = _as_logical(self.right)
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this binary operation to its AgentSpeak string representation.
The method handles proper parenthesization of sub-expressions to maintain
correct operator precedence and readability.
:return: The AgentSpeak string representation of this binary operation.
"""
l_str = str(self.left) l_str = str(self.left)
r_str = str(self.right) r_str = str(self.right)
@@ -185,12 +325,27 @@ class AstBinaryOp(AstExpression):
class AstLogicalExpression(AstExpression): class AstLogicalExpression(AstExpression):
""" """
Represents a logical expression, potentially negated, in AgentSpeak. Represents a logical expression, potentially negated, in AgentSpeak.
Logical expressions can be either positive or negated and form the basis
of conditions and beliefs in AgentSpeak programs.
:ivar expression: The underlying expression being evaluated.
:ivar negated: Boolean flag indicating whether this expression is negated.
""" """
expression: AstExpression expression: AstExpression
negated: bool = False negated: bool = False
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this logical expression to its AgentSpeak string representation.
If the expression is negated, it prepends 'not ' to the expression string.
For complex expressions (binary operations), it adds parentheses when negated
to maintain correct logical interpretation.
:return: The AgentSpeak string representation of this logical expression.
"""
expr_str = str(self.expression) expr_str = str(self.expression)
if isinstance(self.expression, AstBinaryOp) and self.negated: if isinstance(self.expression, AstBinaryOp) and self.negated:
expr_str = f"({expr_str})" expr_str = f"({expr_str})"
@@ -198,31 +353,76 @@ class AstLogicalExpression(AstExpression):
def _as_logical(expr: AstExpression) -> AstLogicalExpression: def _as_logical(expr: AstExpression) -> AstLogicalExpression:
"""
Converts an expression to a logical expression if it isn't already.
This helper function ensures that expressions are properly wrapped in
AstLogicalExpression instances, which is necessary for consistent handling
of logical operations in the AST.
:param expr: The expression to convert.
:return: The expression wrapped in an AstLogicalExpression if it wasn't already.
"""
if isinstance(expr, AstLogicalExpression): if isinstance(expr, AstLogicalExpression):
return expr return expr
return AstLogicalExpression(expr) return AstLogicalExpression(expr)
class StatementType(StrEnum): class StatementType(StrEnum):
"""
Enumeration of statement types that can appear in AgentSpeak plans.
These statement types define the different kinds of actions and operations
that can be performed within the body of an AgentSpeak plan.
"""
EMPTY = "" EMPTY = ""
"""Empty statement (no operation, used when evaluating a plan to true)."""
DO_ACTION = "." DO_ACTION = "."
"""Execute an action defined in Python."""
ACHIEVE_GOAL = "!" ACHIEVE_GOAL = "!"
"""Achieve a goal (add a goal to be accomplished)."""
TEST_GOAL = "?" TEST_GOAL = "?"
"""Test a goal (check if a goal can be achieved)."""
ADD_BELIEF = "+" ADD_BELIEF = "+"
"""Add a belief to the belief base."""
REMOVE_BELIEF = "-" REMOVE_BELIEF = "-"
"""Remove a belief from the belief base."""
REPLACE_BELIEF = "-+" REPLACE_BELIEF = "-+"
"""Replace a belief in the belief base."""
@dataclass @dataclass
class AstStatement(AstNode): class AstStatement(AstNode):
""" """
A statement that can appear inside a plan. A statement that can appear inside a plan.
Statements are the executable units within AgentSpeak plans. They consist
of a statement type (defining the operation) and an expression (defining
what to operate on).
:ivar type: The type of statement (action, goal, belief operation, etc.).
:ivar expression: The expression that this statement operates on.
""" """
type: StatementType type: StatementType
expression: AstExpression expression: AstExpression
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this statement to its AgentSpeak string representation.
The representation consists of the statement type prefix followed by
the expression.
:return: The AgentSpeak string representation of this statement.
"""
return f"{self.type.value}{self.expression}" return f"{self.type.value}{self.expression}"
@@ -230,26 +430,59 @@ class AstStatement(AstNode):
class AstRule(AstNode): class AstRule(AstNode):
""" """
Represents an inference rule in AgentSpeak. If there is no condition, it always holds. Represents an inference rule in AgentSpeak. If there is no condition, it always holds.
Rules define logical implications in AgentSpeak programs. They consist of a
result (conclusion) and an optional condition (premise). When the condition
holds, the result is inferred to be true.
:ivar result: The conclusion or result of this rule.
:ivar condition: The premise or condition for this rule (optional).
""" """
result: AstExpression result: AstExpression
condition: AstExpression | None = None condition: AstExpression | None = None
def __post_init__(self): def __post_init__(self):
"""
Post-initialization processing to ensure proper expression types.
If a condition is provided, this method wraps it in an AstLogicalExpression
to ensure consistent handling throughout the AST.
"""
if self.condition is not None: if self.condition is not None:
self.condition = _as_logical(self.condition) self.condition = _as_logical(self.condition)
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this rule to its AgentSpeak string representation.
If no condition is specified, the rule is represented as a simple fact.
If a condition is specified, it's represented as an implication (result :- condition).
:return: The AgentSpeak string representation of this rule.
"""
if not self.condition: if not self.condition:
return f"{self.result}." return f"{self.result}."
return f"{self.result} :- {self.condition}." return f"{self.result} :- {self.condition}."
class TriggerType(StrEnum): class TriggerType(StrEnum):
"""
Enumeration of trigger types for AgentSpeak plans.
Trigger types define what kind of events can activate an AgentSpeak plan.
Currently, the system supports triggers for added beliefs and added goals.
"""
ADDED_BELIEF = "+" ADDED_BELIEF = "+"
"""Trigger when a belief is added to the belief base."""
# REMOVED_BELIEF = "-" # TODO # REMOVED_BELIEF = "-" # TODO
# MODIFIED_BELIEF = "^" # TODO # MODIFIED_BELIEF = "^" # TODO
ADDED_GOAL = "+!" ADDED_GOAL = "+!"
"""Trigger when a goal is added to be achieved."""
# REMOVED_GOAL = "-!" # TODO # REMOVED_GOAL = "-!" # TODO
@@ -257,6 +490,14 @@ class TriggerType(StrEnum):
class AstPlan(AstNode): class AstPlan(AstNode):
""" """
Represents a plan in AgentSpeak, consisting of a trigger, context, and body. Represents a plan in AgentSpeak, consisting of a trigger, context, and body.
Plans define the reactive behavior of agents in AgentSpeak. They specify what
actions to take when certain conditions are met (trigger and context).
:ivar type: The type of trigger that activates this plan.
:ivar trigger_literal: The specific event or condition that triggers this plan.
:ivar context: A list of conditions that must hold for this plan to be applicable.
:ivar body: A list of statements to execute when this plan is triggered.
""" """
type: TriggerType type: TriggerType
@@ -265,6 +506,16 @@ class AstPlan(AstNode):
body: list[AstStatement] body: list[AstStatement]
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this plan to its AgentSpeak string representation.
The representation follows the standard AgentSpeak plan format:
trigger_type + trigger_literal
: context_conditions
<- body_statements.
:return: The AgentSpeak string representation of this plan.
"""
assert isinstance(self.trigger_literal, AstLiteral) assert isinstance(self.trigger_literal, AstLiteral)
indent = " " * 6 indent = " " * 6
@@ -290,12 +541,26 @@ class AstPlan(AstNode):
class AstProgram(AstNode): class AstProgram(AstNode):
""" """
Represents a full AgentSpeak program, consisting of rules and plans. Represents a full AgentSpeak program, consisting of rules and plans.
This is the root node of the AgentSpeak AST, containing all the rules
and plans that define the agent's behavior.
:ivar rules: A list of inference rules in this program.
:ivar plans: A list of reactive plans in this program.
""" """
rules: list[AstRule] = field(default_factory=list) rules: list[AstRule] = field(default_factory=list)
plans: list[AstPlan] = field(default_factory=list) plans: list[AstPlan] = field(default_factory=list)
def _to_agentspeak(self) -> str: def _to_agentspeak(self) -> str:
"""
Converts this program to its AgentSpeak string representation.
The representation consists of all rules followed by all plans,
separated by blank lines for readability.
:return: The complete AgentSpeak source code for this program.
"""
lines = [] lines = []
lines.extend(map(str, self.rules)) lines.extend(map(str, self.rules))

View File

@@ -18,6 +18,7 @@ from control_backend.agents.bdi.agentspeak_ast import (
StatementType, StatementType,
TriggerType, TriggerType,
) )
from control_backend.core.config import settings
from control_backend.schemas.program import ( from control_backend.schemas.program import (
BaseGoal, BaseGoal,
BasicNorm, BasicNorm,
@@ -46,6 +47,15 @@ class AgentSpeakGenerator:
It handles the conversion of phases, norms, goals, and triggers into AgentSpeak rules and plans, It handles the conversion of phases, norms, goals, and triggers into AgentSpeak rules and plans,
ensuring the robot follows the defined behavioral logic. ensuring the robot follows the defined behavioral logic.
The generator follows a systematic approach:
1. Sets up initial phase and cycle notification rules
2. Adds keyword inference capabilities for natural language processing
3. Creates default plans for common operations
4. Processes each phase with its norms, goals, and triggers
5. Adds fallback plans for robust execution
:ivar _asp: The internal AgentSpeak program representation being built.
""" """
_asp: AstProgram _asp: AstProgram
@@ -54,6 +64,10 @@ class AgentSpeakGenerator:
""" """
Translates a Program object into an AgentSpeak source string. Translates a Program object into an AgentSpeak source string.
This is the main entry point for the code generation process. It initializes
the AgentSpeak program structure and orchestrates the conversion of all
program elements into their AgentSpeak representations.
:param program: The behavior program to translate. :param program: The behavior program to translate.
:return: The generated AgentSpeak code as a string. :return: The generated AgentSpeak code as a string.
""" """
@@ -76,6 +90,18 @@ class AgentSpeakGenerator:
return str(self._asp) return str(self._asp)
def _add_keyword_inference(self) -> None: def _add_keyword_inference(self) -> None:
"""
Adds inference rules for keyword detection in user messages.
This method creates rules that allow the system to detect when specific
keywords are mentioned in user messages. It uses string operations to
check if a keyword is a substring of the user's message.
The generated rule has the form:
keyword_said(Keyword) :- user_said(Message) & .substring(Keyword, Message, Pos) & Pos >= 0
This enables the system to trigger behaviors based on keyword detection.
"""
keyword = AstVar("Keyword") keyword = AstVar("Keyword")
message = AstVar("Message") message = AstVar("Message")
position = AstVar("Pos") position = AstVar("Pos")
@@ -90,12 +116,32 @@ class AgentSpeakGenerator:
) )
def _add_default_plans(self): def _add_default_plans(self):
"""
Adds default plans for common operations.
This method sets up the standard plans that handle fundamental operations
like replying with goals, simple speech actions, general replies, and
cycle notifications. These plans provide the basic infrastructure for
the agent's reactive behavior.
"""
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_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):
"""
Adds a plan for replying with a specific conversational goal.
This plan handles the case where the agent needs to respond to user input
while pursuing a specific conversational goal. It:
1. Marks that the agent has responded this turn
2. Gathers all active norms
3. Generates a reply that considers both the user message and the goal
Trigger: +!reply_with_goal(Goal)
Context: user_said(Message)
"""
self._asp.plans.append( self._asp.plans.append(
AstPlan( AstPlan(
TriggerType.ADDED_GOAL, TriggerType.ADDED_GOAL,
@@ -121,6 +167,17 @@ class AgentSpeakGenerator:
) )
def _add_say_plan(self): def _add_say_plan(self):
"""
Adds a plan for simple speech actions.
This plan handles direct speech actions where the agent needs to say
a specific text. It:
1. Marks that the agent has responded this turn
2. Executes the speech action
Trigger: +!say(Text)
Context: None (can be executed anytime)
"""
self._asp.plans.append( self._asp.plans.append(
AstPlan( AstPlan(
TriggerType.ADDED_GOAL, TriggerType.ADDED_GOAL,
@@ -134,6 +191,18 @@ class AgentSpeakGenerator:
) )
def _add_reply_plan(self): 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( self._asp.plans.append(
AstPlan( AstPlan(
TriggerType.ADDED_GOAL, TriggerType.ADDED_GOAL,
@@ -157,6 +226,19 @@ class AgentSpeakGenerator:
) )
def _add_notify_cycle_plan(self): def _add_notify_cycle_plan(self):
"""
Adds a plan for cycle notification.
This plan handles the periodic notification cycle that allows the system
to monitor and report on the current state. It:
1. Gathers all active norms
2. Notifies the system about the current norms
3. Waits briefly to allow processing
4. Recursively triggers the next cycle
Trigger: +!notify_cycle
Context: None (can be executed anytime)
"""
self._asp.plans.append( self._asp.plans.append(
AstPlan( AstPlan(
TriggerType.ADDED_GOAL, TriggerType.ADDED_GOAL,
@@ -180,6 +262,16 @@ class AgentSpeakGenerator:
) )
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.
This method iterates through each phase and:
1. Processes the current phase (norms, goals, triggers)
2. Sets up transitions between phases
3. Adds special handling for the end phase
:param phases: The list of phases to process.
"""
for curr_phase, next_phase in zip([None] + phases, phases + [None], strict=True): for curr_phase, next_phase in zip([None] + phases, phases + [None], strict=True):
if curr_phase: if curr_phase:
self._process_phase(curr_phase) self._process_phase(curr_phase)
@@ -202,6 +294,17 @@ class AgentSpeakGenerator:
) )
def _process_phase(self, phase: Phase) -> None: def _process_phase(self, phase: Phase) -> None:
"""
Processes a single phase, including its norms, goals, and triggers.
This method handles the complete processing of a phase by:
1. Processing all norms in the phase
2. Setting up the default execution loop for the phase
3. Processing all goals in sequence
4. Processing all triggers for reactive behavior
:param phase: The phase to process.
"""
for norm in phase.norms: for norm in phase.norms:
self._process_norm(norm, phase) self._process_norm(norm, phase)
@@ -216,6 +319,21 @@ class AgentSpeakGenerator:
self._process_trigger(trigger, phase) self._process_trigger(trigger, 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.
This method creates two plans for each phase transition:
1. A check plan that verifies if transition conditions are met
2. A force plan that actually performs the transition (can be forced externally)
The transition involves:
- Notifying the system about the phase change
- Removing the current phase belief
- Adding the next phase belief
:param from_phase: The phase being transitioned from (or None for initial setup).
:param to_phase: The phase being transitioned to (or None for end phase).
"""
if from_phase is None: if from_phase is None:
return return
from_phase_ast = self._astify(from_phase) from_phase_ast = self._astify(from_phase)
@@ -245,18 +363,6 @@ class AgentSpeakGenerator:
AstStatement(StatementType.ADD_BELIEF, to_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")])
# ),
# ]
# )
# Check # Check
self._asp.plans.append( self._asp.plans.append(
AstPlan( AstPlan(
@@ -277,6 +383,17 @@ class AgentSpeakGenerator:
) )
def _process_norm(self, norm: Norm, phase: Phase) -> None: def _process_norm(self, norm: Norm, phase: Phase) -> None:
"""
Processes a norm and adds it as an inference rule.
This method converts norms into AgentSpeak rules that define when
the norm should be active. It handles both basic norms (always active
in their phase) and conditional norms (active only when their condition
is met).
:param norm: The norm to process.
:param phase: The phase this norm belongs to.
"""
rule: AstRule | None = None rule: AstRule | None = None
match norm: match norm:
@@ -295,6 +412,18 @@ class AgentSpeakGenerator:
self._asp.rules.append(rule) self._asp.rules.append(rule)
def _add_default_loop(self, phase: Phase) -> None: def _add_default_loop(self, phase: Phase) -> None:
"""
Adds the default execution loop for a phase.
This method creates the main reactive loop that runs when the agent
receives user input during a phase. The loop:
1. Notifies the system about the user input
2. Resets the response tracking
3. Executes all phase goals
4. Attempts phase transition
:param phase: The phase to create the loop for.
"""
actions = [] actions = []
actions.append( actions.append(
@@ -303,7 +432,6 @@ class AgentSpeakGenerator:
) )
) )
actions.append(AstStatement(StatementType.REMOVE_BELIEF, AstLiteral("responded_this_turn"))) actions.append(AstStatement(StatementType.REMOVE_BELIEF, AstLiteral("responded_this_turn")))
actions.append(AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("check_triggers")))
for goal in phase.goals: for goal in phase.goals:
actions.append(AstStatement(StatementType.ACHIEVE_GOAL, self._astify(goal))) actions.append(AstStatement(StatementType.ACHIEVE_GOAL, self._astify(goal)))
@@ -327,6 +455,22 @@ class AgentSpeakGenerator:
continues_response: bool = False, continues_response: bool = False,
main_goal: bool = False, main_goal: bool = False,
) -> None: ) -> None:
"""
Processes a goal and creates plans for achieving it.
This method creates two plans for each goal:
1. A main plan that executes the goal's steps when conditions are met
2. A fallback plan that provides a default empty implementation (prevents crashes)
The method also recursively processes any subgoals contained within
the goal's plan.
:param goal: The goal to process.
:param phase: The phase this goal belongs to.
:param previous_goal: The previous goal in sequence (for dependency tracking).
:param continues_response: Whether this goal continues an existing response.
: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)) context.append(~self._astify(goal, achieved=True))
if previous_goal and previous_goal.can_fail: if previous_goal and previous_goal.can_fail:
@@ -369,14 +513,39 @@ class AgentSpeakGenerator:
prev_goal = subgoal prev_goal = subgoal
def _step_to_statement(self, step: PlanElement) -> AstStatement: def _step_to_statement(self, step: PlanElement) -> AstStatement:
"""
Converts a plan step to an AgentSpeak statement.
This method transforms different types of plan elements into their
corresponding AgentSpeak statements. Goals and speech-related actions
become achieve-goal statements, while gesture actions become do-action
statements.
:param step: The plan element to convert.
:return: The corresponding AgentSpeak statement.
"""
match step: match step:
# Note that SpeechAction gets included in the ACHIEVE_GOAL, since it's a goal internally
case Goal() | SpeechAction() | LLMAction() as a: case Goal() | SpeechAction() | LLMAction() as a:
return AstStatement(StatementType.ACHIEVE_GOAL, self._astify(a)) return AstStatement(StatementType.ACHIEVE_GOAL, self._astify(a))
case GestureAction() as a: case GestureAction() as a:
return AstStatement(StatementType.DO_ACTION, self._astify(a)) return AstStatement(StatementType.DO_ACTION, self._astify(a))
# TODO: separate handling of keyword and others
def _process_trigger(self, trigger: Trigger, phase: Phase) -> None: def _process_trigger(self, trigger: Trigger, phase: Phase) -> None:
"""
Processes a trigger and creates plans for its execution.
This method creates plans that execute when trigger conditions are met.
It handles both automatic triggering (when conditions are detected) and
manual forcing (from UI). The trigger execution includes:
1. Notifying the system about trigger start
2. Executing all trigger steps
3. Waiting briefly for UI display
4. Notifying the system about trigger end
:param trigger: The trigger to process.
:param phase: The phase this trigger belongs to.
"""
body = [] body = []
subgoals = [] subgoals = []
@@ -393,7 +562,12 @@ class AgentSpeakGenerator:
subgoals.append(step) subgoals.append(step)
# Arbitrary wait for UI to display nicely # Arbitrary wait for UI to display nicely
body.append(AstStatement(StatementType.DO_ACTION, AstLiteral("wait", [AstNumber(2000)]))) body.append(
AstStatement(
StatementType.DO_ACTION,
AstLiteral("wait", [AstNumber(settings.behaviour_settings.trigger_time_to_wait)]),
)
)
body.append( body.append(
AstStatement( AstStatement(
@@ -418,6 +592,18 @@ class AgentSpeakGenerator:
self._process_goal(subgoal, phase, continues_response=True) self._process_goal(subgoal, phase, continues_response=True)
def _add_fallbacks(self): def _add_fallbacks(self):
"""
Adds fallback plans for robust execution, preventing crashes.
This method creates fallback plans that provide default empty implementations
for key goals. These fallbacks ensure that the system can continue execution
even when no specific plans are applicable, preventing crashes.
The fallbacks are created for:
- check_triggers: When no triggers are applicable
- transition_phase: When phase transition conditions aren't met
- force_transition_phase: When forced transitions aren't possible
"""
# Trigger fallback # Trigger fallback
self._asp.plans.append( self._asp.plans.append(
AstPlan( AstPlan(
@@ -450,18 +636,57 @@ class AgentSpeakGenerator:
@singledispatchmethod @singledispatchmethod
def _astify(self, element: ProgramElement) -> AstExpression: def _astify(self, element: ProgramElement) -> AstExpression:
"""
Converts program elements to AgentSpeak expressions (base method).
This is the base method for the singledispatch mechanism that handles
conversion of different program element types to their AgentSpeak
representations. Specific implementations are provided for each
element type through registered methods.
:param element: The program element to convert.
:return: The corresponding AgentSpeak expression.
:raises NotImplementedError: If no specific implementation exists for the element type.
"""
raise NotImplementedError(f"Cannot convert element {element} to an AgentSpeak expression.") raise NotImplementedError(f"Cannot convert element {element} to an AgentSpeak expression.")
@_astify.register @_astify.register
def _(self, kwb: KeywordBelief) -> AstExpression: def _(self, kwb: KeywordBelief) -> AstExpression:
"""
Converts a KeywordBelief to an AgentSpeak expression.
Keyword beliefs are converted to keyword_said literals that check
if the keyword was mentioned in user input.
:param kwb: The KeywordBelief to convert.
:return: An AstLiteral representing the keyword detection.
"""
return AstLiteral("keyword_said", [AstString(kwb.keyword)]) return AstLiteral("keyword_said", [AstString(kwb.keyword)])
@_astify.register @_astify.register
def _(self, sb: SemanticBelief) -> AstExpression: def _(self, sb: SemanticBelief) -> AstExpression:
"""
Converts a SemanticBelief to an AgentSpeak expression.
Semantic beliefs are converted to literals using their slugified names,
which are used for LLM-based belief evaluation.
:param sb: The SemanticBelief to convert.
:return: An AstLiteral representing the semantic belief.
"""
return AstLiteral(self.slugify(sb)) return AstLiteral(self.slugify(sb))
@_astify.register @_astify.register
def _(self, ib: InferredBelief) -> AstExpression: def _(self, ib: InferredBelief) -> AstExpression:
"""
Converts an InferredBelief to an AgentSpeak expression.
Inferred beliefs are converted to binary operations that combine
their left and right operands using the appropriate logical operator.
:param ib: The InferredBelief to convert.
:return: An AstBinaryOp representing the logical combination.
"""
return AstBinaryOp( return AstBinaryOp(
self._astify(ib.left), self._astify(ib.left),
BinaryOperatorType.AND if ib.operator == LogicalOperator.AND else BinaryOperatorType.OR, BinaryOperatorType.AND if ib.operator == LogicalOperator.AND else BinaryOperatorType.OR,
@@ -470,59 +695,187 @@ class AgentSpeakGenerator:
@_astify.register @_astify.register
def _(self, norm: Norm) -> AstExpression: def _(self, norm: Norm) -> AstExpression:
"""
Converts a Norm to an AgentSpeak expression.
Norms are converted to literals with either 'norm' or 'critical_norm'
functors depending on their critical flag, with the norm text as an argument.
Note that currently, critical norms are not yet functionally supported. They are possible
to astify for future use.
:param norm: The Norm to convert.
:return: An AstLiteral representing the norm.
"""
functor = "critical_norm" if norm.critical else "norm" functor = "critical_norm" if norm.critical else "norm"
return AstLiteral(functor, [AstString(norm.norm)]) return AstLiteral(functor, [AstString(norm.norm)])
@_astify.register @_astify.register
def _(self, phase: Phase) -> AstExpression: def _(self, phase: Phase) -> AstExpression:
"""
Converts a Phase to an AgentSpeak expression.
Phases are converted to phase literals with their unique identifier
as an argument, which is used for phase tracking and transitions.
:param phase: The Phase to convert.
:return: An AstLiteral representing the phase.
"""
return AstLiteral("phase", [AstString(str(phase.id))]) return AstLiteral("phase", [AstString(str(phase.id))])
@_astify.register @_astify.register
def _(self, goal: Goal, achieved: bool = False) -> AstExpression: def _(self, goal: Goal, achieved: bool = False) -> AstExpression:
"""
Converts a Goal to an AgentSpeak expression.
Goals are converted to literals using their slugified names. If the
achieved parameter is True, the literal is prefixed with 'achieved_'.
:param goal: The Goal to convert.
:param achieved: Whether to represent this as an achieved goal.
:return: An AstLiteral representing the goal.
"""
return AstLiteral(f"{'achieved_' if achieved else ''}{self._slugify_str(goal.name)}") return AstLiteral(f"{'achieved_' if achieved else ''}{self._slugify_str(goal.name)}")
@_astify.register @_astify.register
def _(self, trigger: Trigger) -> AstExpression: def _(self, trigger: Trigger) -> AstExpression:
"""
Converts a Trigger to an AgentSpeak expression.
Triggers are converted to literals using their slugified names,
which are used to identify and execute trigger plans.
:param trigger: The Trigger to convert.
:return: An AstLiteral representing the trigger.
"""
return AstLiteral(self.slugify(trigger)) return AstLiteral(self.slugify(trigger))
@_astify.register @_astify.register
def _(self, sa: SpeechAction) -> AstExpression: def _(self, sa: SpeechAction) -> AstExpression:
"""
Converts a SpeechAction to an AgentSpeak expression.
Speech actions are converted to say literals with the text content
as an argument, which are used for direct speech output.
:param sa: The SpeechAction to convert.
:return: An AstLiteral representing the speech action.
"""
return AstLiteral("say", [AstString(sa.text)]) return AstLiteral("say", [AstString(sa.text)])
@_astify.register @_astify.register
def _(self, ga: GestureAction) -> AstExpression: def _(self, ga: GestureAction) -> AstExpression:
"""
Converts a GestureAction to an AgentSpeak expression.
Gesture actions are converted to gesture literals with the gesture
type and name as arguments, which are used for physical robot gestures.
:param ga: The GestureAction to convert.
:return: An AstLiteral representing the gesture action.
"""
gesture = ga.gesture gesture = ga.gesture
return AstLiteral("gesture", [AstString(gesture.type), AstString(gesture.name)]) return AstLiteral("gesture", [AstString(gesture.type), AstString(gesture.name)])
@_astify.register @_astify.register
def _(self, la: LLMAction) -> AstExpression: def _(self, la: LLMAction) -> AstExpression:
"""
Converts an LLMAction to an AgentSpeak expression.
LLM actions are converted to reply_with_goal literals with the
conversational goal as an argument, which are used for LLM-generated
responses guided by specific goals.
:param la: The LLMAction to convert.
:return: An AstLiteral representing the LLM action.
"""
return AstLiteral("reply_with_goal", [AstString(la.goal)]) return AstLiteral("reply_with_goal", [AstString(la.goal)])
@singledispatchmethod @singledispatchmethod
@staticmethod @staticmethod
def slugify(element: ProgramElement) -> str: def slugify(element: ProgramElement) -> str:
"""
Converts program elements to slugs (base method).
This is the base method for the singledispatch mechanism that handles
conversion of different program element types to their slug representations.
Specific implementations are provided for each element type through
registered methods.
Slugs are used outside of AgentSpeak, mostly for identifying what to send to the AgentSpeak
program as beliefs.
:param element: The program element to convert to a slug.
:return: The slug string representation.
:raises NotImplementedError: If no specific implementation exists for the element type.
"""
raise NotImplementedError(f"Cannot convert element {element} to a slug.") raise NotImplementedError(f"Cannot convert element {element} to a slug.")
@slugify.register @slugify.register
@staticmethod @staticmethod
def _(n: Norm) -> str: def _(n: Norm) -> str:
"""
Converts a Norm to a slug.
Norms are converted to slugs with the 'norm_' prefix followed by
the slugified norm text.
:param n: The Norm to convert.
:return: The slug string representation.
"""
return f"norm_{AgentSpeakGenerator._slugify_str(n.norm)}" return f"norm_{AgentSpeakGenerator._slugify_str(n.norm)}"
@slugify.register @slugify.register
@staticmethod @staticmethod
def _(sb: SemanticBelief) -> str: def _(sb: SemanticBelief) -> str:
"""
Converts a SemanticBelief to a slug.
Semantic beliefs are converted to slugs with the 'semantic_' prefix
followed by the slugified belief name.
:param sb: The SemanticBelief to convert.
:return: The slug string representation.
"""
return f"semantic_{AgentSpeakGenerator._slugify_str(sb.name)}" return f"semantic_{AgentSpeakGenerator._slugify_str(sb.name)}"
@slugify.register @slugify.register
@staticmethod @staticmethod
def _(g: BaseGoal) -> str: def _(g: BaseGoal) -> str:
"""
Converts a BaseGoal to a slug.
Goals are converted to slugs using their slugified names directly.
:param g: The BaseGoal to convert.
:return: The slug string representation.
"""
return AgentSpeakGenerator._slugify_str(g.name) return AgentSpeakGenerator._slugify_str(g.name)
@slugify.register @slugify.register
@staticmethod @staticmethod
def _(t: Trigger): def _(t: Trigger) -> str:
"""
Converts a Trigger to a slug.
Triggers are converted to slugs with the 'trigger_' prefix followed by
the slugified trigger name.
:param t: The Trigger to convert.
:return: The slug string representation.
"""
return f"trigger_{AgentSpeakGenerator._slugify_str(t.name)}" return f"trigger_{AgentSpeakGenerator._slugify_str(t.name)}"
@staticmethod @staticmethod
def _slugify_str(text: str) -> str: def _slugify_str(text: str) -> str:
"""
Converts a text string to a slug.
This helper method converts arbitrary text to a URL-friendly slug format
by converting to lowercase, removing special characters, and replacing
spaces with underscores. It also removes common stopwords.
:param text: The text string to convert.
:return: The slugified string.
"""
return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"]) return slugify(text, separator="_", stopwords=["a", "an", "the", "we", "you", "I"])

View File

@@ -1,6 +1,7 @@
import asyncio import asyncio
import copy import copy
import json import json
import logging
import time import time
from collections.abc import Iterable from collections.abc import Iterable
@@ -19,6 +20,9 @@ from control_backend.schemas.ri_message import GestureCommand, RIEndpoint, Speec
DELIMITER = ";\n" # TODO: temporary until we support lists in AgentSpeak DELIMITER = ";\n" # TODO: temporary until we support lists in AgentSpeak
experiment_logger = logging.getLogger(settings.logging_settings.experiment_logger_name)
class BDICoreAgent(BaseAgent): class BDICoreAgent(BaseAgent):
""" """
BDI Core Agent. BDI Core Agent.
@@ -207,6 +211,9 @@ class BDICoreAgent(BaseAgent):
else: else:
term = agentspeak.Literal(name) term = agentspeak.Literal(name)
if name != "user_said":
experiment_logger.observation(f"Formed new belief: {name}{f'={args}' if args else ''}")
self.bdi_agent.call( self.bdi_agent.call(
agentspeak.Trigger.addition, agentspeak.Trigger.addition,
agentspeak.GoalType.belief, agentspeak.GoalType.belief,
@@ -244,6 +251,9 @@ class BDICoreAgent(BaseAgent):
new_args = (agentspeak.Literal(arg) for arg in args) new_args = (agentspeak.Literal(arg) for arg in args)
term = agentspeak.Literal(name, new_args) term = agentspeak.Literal(name, new_args)
if name != "user_said":
experiment_logger.observation(f"Removed belief: {name}{f'={args}' if args else ''}")
result = self.bdi_agent.call( result = self.bdi_agent.call(
agentspeak.Trigger.removal, agentspeak.Trigger.removal,
agentspeak.GoalType.belief, agentspeak.GoalType.belief,
@@ -386,6 +396,8 @@ class BDICoreAgent(BaseAgent):
body=str(message_text), body=str(message_text),
) )
experiment_logger.chat(str(message_text), extra={"role": "assistant"})
self.add_behavior(self.send(chat_history_message)) self.add_behavior(self.send(chat_history_message))
yield yield

View File

@@ -1,10 +1,12 @@
import asyncio import asyncio
import json import json
import logging
import zmq import zmq
from pydantic import ValidationError from pydantic import ValidationError
from zmq.asyncio import Context from zmq.asyncio import Context
import control_backend
from control_backend.agents import BaseAgent from control_backend.agents import BaseAgent
from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator
from control_backend.core.config import settings from control_backend.core.config import settings
@@ -19,17 +21,21 @@ from control_backend.schemas.program import (
Program, Program,
) )
experiment_logger = logging.getLogger(settings.logging_settings.experiment_logger_name)
class BDIProgramManager(BaseAgent): class BDIProgramManager(BaseAgent):
""" """
BDI Program Manager Agent. BDI Program Manager Agent.
This agent is responsible for receiving high-level programs (sequences of instructions/goals) This agent is responsible for receiving high-level programs (sequences of instructions/goals)
from the external HTTP API (via ZMQ) and translating them into core beliefs (norms and goals) from the external HTTP API (via ZMQ), transforming it into an AgentSpeak program, sharing the
for the BDI Core Agent. In the future, it will be responsible for determining when goals are program and its components to other agents, and keeping agents informed of the current state.
met, and passing on new norms and goals accordingly.
:ivar sub_socket: The ZMQ SUB socket used to receive program updates. :ivar sub_socket: The ZMQ SUB socket used to receive program updates.
:ivar _program: The current Program.
:ivar _phase: The current Phase.
:ivar _goal_mapping: A mapping of goal IDs to goals.
""" """
_program: Program _program: Program
@@ -38,16 +44,28 @@ class BDIProgramManager(BaseAgent):
def __init__(self, **kwargs): def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.sub_socket = None self.sub_socket = None
self._goal_mapping: dict[str, Goal] = {}
def _initialize_internal_state(self, program: Program): def _initialize_internal_state(self, program: Program):
"""
Initialize the state of the program manager given a new Program. Reset the tracking of the
current phase to the first phase, make a mapping of goal IDs to goals, used during the life
of the program.
:param program: The new program.
"""
self._program = program self._program = program
self._phase = program.phases[0] # start in first phase self._phase = program.phases[0] # start in first phase
self._goal_mapping: dict[str, Goal] = {} self._goal_mapping = {}
for phase in program.phases: for phase in program.phases:
for goal in phase.goals: for goal in phase.goals:
self._populate_goal_mapping_with_goal(goal) self._populate_goal_mapping_with_goal(goal)
def _populate_goal_mapping_with_goal(self, goal: Goal): def _populate_goal_mapping_with_goal(self, goal: Goal):
"""
Recurse through the given goal and its subgoals and add all goals found to the
``self._goal_mapping``.
:param goal: The goal to add to the ``self._goal_mapping``, including subgoals.
"""
self._goal_mapping[str(goal.id)] = goal self._goal_mapping[str(goal.id)] = goal
for step in goal.plan.steps: for step in goal.plan.steps:
if isinstance(step, Goal): if isinstance(step, Goal):
@@ -63,7 +81,7 @@ class BDIProgramManager(BaseAgent):
asl_str = asg.generate(program) asl_str = asg.generate(program)
file_name = "src/control_backend/agents/bdi/agentspeak.asl" file_name = settings.behaviour_settings.agentspeak_file
with open(file_name, "w") as f: with open(file_name, "w") as f:
f.write(asl_str) f.write(asl_str)
@@ -88,6 +106,16 @@ class BDIProgramManager(BaseAgent):
await 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): async def _transition_phase(self, old: str, new: str):
"""
When receiving a signal from the BDI core that the phase has changed, apply this change to
the current state and inform other agents about the change.
:param old: The ID of the old phase.
:param new: The ID of the new phase.
"""
if self._phase is None:
return
if old != str(self._phase.id): if old != str(self._phase.id):
self.logger.warning( self.logger.warning(
f"Phase transition desync detected! ASL requested move from '{old}', " f"Phase transition desync detected! ASL requested move from '{old}', "
@@ -126,6 +154,13 @@ class BDIProgramManager(BaseAgent):
self.add_behavior(self.send(msg)) self.add_behavior(self.send(msg))
def _extract_current_beliefs(self) -> list[Belief]: def _extract_current_beliefs(self) -> list[Belief]:
"""Extract beliefs from the current phase."""
assert self._phase is not None, (
"Invalid state, no phase set. Call this method only when "
"a program has been received and the end-phase has not "
"been reached."
)
beliefs: list[Belief] = [] beliefs: list[Belief] = []
for norm in self._phase.norms: for norm in self._phase.norms:
@@ -139,6 +174,7 @@ class BDIProgramManager(BaseAgent):
@staticmethod @staticmethod
def _extract_beliefs_from_belief(belief: Belief) -> list[Belief]: def _extract_beliefs_from_belief(belief: Belief) -> list[Belief]:
"""Recursively extract beliefs from the given belief."""
if isinstance(belief, InferredBelief): if isinstance(belief, InferredBelief):
return BDIProgramManager._extract_beliefs_from_belief( return BDIProgramManager._extract_beliefs_from_belief(
belief.left belief.left
@@ -146,9 +182,7 @@ class BDIProgramManager(BaseAgent):
return [belief] return [belief]
async def _send_beliefs_to_semantic_belief_extractor(self): async def _send_beliefs_to_semantic_belief_extractor(self):
""" """Extract beliefs from the program and send them to the Semantic Belief Extractor Agent."""
Extract beliefs from the program and send them to the Semantic Belief Extractor Agent.
"""
beliefs = BeliefList(beliefs=self._extract_current_beliefs()) beliefs = BeliefList(beliefs=self._extract_current_beliefs())
message = InternalMessage( message = InternalMessage(
@@ -168,9 +202,9 @@ class BDIProgramManager(BaseAgent):
:return: All goals within and including the given goal. :return: All goals within and including the given goal.
""" """
goals: list[Goal] = [goal] goals: list[Goal] = [goal]
for plan in goal.plan: for step in goal.plan.steps:
if isinstance(plan, Goal): if isinstance(step, Goal):
goals.extend(BDIProgramManager._extract_goals_from_goal(plan)) goals.extend(BDIProgramManager._extract_goals_from_goal(step))
return goals return goals
def _extract_current_goals(self) -> list[Goal]: def _extract_current_goals(self) -> list[Goal]:
@@ -179,6 +213,12 @@ class BDIProgramManager(BaseAgent):
:return: A list of Goal objects. :return: A list of Goal objects.
""" """
assert self._phase is not None, (
"Invalid state, no phase set. Call this method only when "
"a program has been received and the end-phase has not "
"been reached."
)
goals: list[Goal] = [] goals: list[Goal] = []
for goal in self._phase.goals: for goal in self._phase.goals:
@@ -241,6 +281,18 @@ class BDIProgramManager(BaseAgent):
await self.send(extractor_msg) await self.send(extractor_msg)
self.logger.debug("Sent message to extractor agent to clear history.") self.logger.debug("Sent message to extractor agent to clear history.")
@staticmethod
def _rollover_experiment_logs():
"""
A new experiment program started; make a new experiment log file.
"""
handlers = logging.getLogger(settings.logging_settings.experiment_logger_name).handlers
for handler in handlers:
if isinstance(handler, control_backend.logging.DatedFileHandler):
experiment_logger.action("Doing rollover...")
handler.do_rollover()
experiment_logger.debug("Finished rollover.")
async def _receive_programs(self): async def _receive_programs(self):
""" """
Continuous loop that receives program updates from the HTTP endpoint. Continuous loop that receives program updates from the HTTP endpoint.
@@ -261,6 +313,7 @@ class BDIProgramManager(BaseAgent):
self._initialize_internal_state(program) self._initialize_internal_state(program)
await self._send_program_to_user_interrupt(program) await self._send_program_to_user_interrupt(program)
await self._send_clear_llm_history() await self._send_clear_llm_history()
self._rollover_experiment_logs()
await asyncio.gather( await asyncio.gather(
self._create_agentspeak_and_send_to_bdi(program), self._create_agentspeak_and_send_to_bdi(program),

View File

@@ -134,6 +134,10 @@ class TextBeliefExtractorAgent(BaseAgent):
self.logger.warning("Received unexpected message from %s", msg.sender) self.logger.warning("Received unexpected message from %s", msg.sender)
def _reset_phase(self): def _reset_phase(self):
"""
Delete all state about the current phase, such as what beliefs exist and which ones are
true.
"""
self.conversation = ChatHistory(messages=[]) self.conversation = ChatHistory(messages=[])
self.belief_inferrer.available_beliefs.clear() self.belief_inferrer.available_beliefs.clear()
self._current_beliefs = BeliefState() self._current_beliefs = BeliefState()
@@ -141,6 +145,11 @@ class TextBeliefExtractorAgent(BaseAgent):
self._current_goal_completions = {} self._current_goal_completions = {}
def _handle_beliefs_message(self, msg: InternalMessage): def _handle_beliefs_message(self, msg: InternalMessage):
"""
Handle the message from the Program Manager agent containing the beliefs that exist for this
phase.
:param msg: A list of beliefs.
"""
try: try:
belief_list = BeliefList.model_validate_json(msg.body) belief_list = BeliefList.model_validate_json(msg.body)
except ValidationError: except ValidationError:
@@ -158,6 +167,11 @@ class TextBeliefExtractorAgent(BaseAgent):
) )
def _handle_goals_message(self, msg: InternalMessage): def _handle_goals_message(self, msg: InternalMessage):
"""
Handle the message from the Program Manager agent containing the goals that exist for this
phase.
:param msg: A list of goals.
"""
try: try:
goals_list = GoalList.model_validate_json(msg.body) goals_list = GoalList.model_validate_json(msg.body)
except ValidationError: except ValidationError:
@@ -177,6 +191,11 @@ class TextBeliefExtractorAgent(BaseAgent):
) )
def _handle_goal_achieved_message(self, msg: InternalMessage): def _handle_goal_achieved_message(self, msg: InternalMessage):
"""
Handle message that gets sent when goals are marked achieved from a user interrupt. This
goal should then not be changed by this agent anymore.
:param msg: List of goals that are marked achieved.
"""
# NOTE: When goals can be marked unachieved, remember to re-add them to the goal_inferrer # NOTE: When goals can be marked unachieved, remember to re-add them to the goal_inferrer
try: try:
goals_list = GoalList.model_validate_json(msg.body) goals_list = GoalList.model_validate_json(msg.body)
@@ -210,6 +229,10 @@ class TextBeliefExtractorAgent(BaseAgent):
await self.send(belief_msg) await self.send(belief_msg)
async def _infer_new_beliefs(self): async def _infer_new_beliefs(self):
"""
Determine which beliefs hold and do not hold for the current conversation state. When
beliefs change, a message is sent to the BDI core.
"""
conversation_beliefs = await self.belief_inferrer.infer_from_conversation(self.conversation) conversation_beliefs = await self.belief_inferrer.infer_from_conversation(self.conversation)
new_beliefs = conversation_beliefs - self._current_beliefs new_beliefs = conversation_beliefs - self._current_beliefs
@@ -233,6 +256,10 @@ class TextBeliefExtractorAgent(BaseAgent):
await self.send(message) await self.send(message)
async def _infer_goal_completions(self): async def _infer_goal_completions(self):
"""
Determine which goals have been achieved given the current conversation state. When
a goal's achieved state changes, a message is sent to the BDI core.
"""
goal_completions = await self.goal_inferrer.infer_from_conversation(self.conversation) goal_completions = await self.goal_inferrer.infer_from_conversation(self.conversation)
new_achieved = [ new_achieved = [
@@ -377,19 +404,22 @@ class SemanticBeliefInferrer:
for beliefs in self._split_into_chunks(self.available_beliefs, n_parallel) for beliefs in self._split_into_chunks(self.available_beliefs, n_parallel)
] ]
) )
retval = BeliefState() new_beliefs = BeliefState()
# Collect beliefs from all parallel calls
for beliefs in all_beliefs: for beliefs in all_beliefs:
if beliefs is None: if beliefs is None:
continue continue
# For each, convert them to InternalBeliefs
for belief_name, belief_holds in beliefs.items(): for belief_name, belief_holds in beliefs.items():
# Skip beliefs that were marked not possible to determine
if belief_holds is None: if belief_holds is None:
continue continue
belief = InternalBelief(name=belief_name, arguments=None) belief = InternalBelief(name=belief_name, arguments=None)
if belief_holds: if belief_holds:
retval.true.add(belief) new_beliefs.true.add(belief)
else: else:
retval.false.add(belief) new_beliefs.false.add(belief)
return retval return new_beliefs
@staticmethod @staticmethod
def _split_into_chunks[T](items: list[T], n: int) -> list[list[T]]: def _split_into_chunks[T](items: list[T], n: int) -> list[list[T]]:

View File

@@ -145,7 +145,7 @@ class RICommunicationAgent(BaseAgent):
# At this point, we have a valid response # At this point, we have a valid response
try: try:
self.logger.debug("Negotiation successful. Handling rn") self.logger.debug("Negotiation successful.")
await self._handle_negotiation_response(received_message) await self._handle_negotiation_response(received_message)
# Let UI know that we're connected # Let UI know that we're connected
topic = b"ping" topic = b"ping"

View File

@@ -1,5 +1,6 @@
import asyncio import asyncio
import json import json
import logging
import re import re
import uuid import uuid
from collections.abc import AsyncGenerator from collections.abc import AsyncGenerator
@@ -14,6 +15,8 @@ from control_backend.core.config import settings
from ...schemas.llm_prompt_message import LLMPromptMessage from ...schemas.llm_prompt_message import LLMPromptMessage
from .llm_instructions import LLMInstructions from .llm_instructions import LLMInstructions
experiment_logger = logging.getLogger(settings.logging_settings.experiment_logger_name)
class LLMAgent(BaseAgent): class LLMAgent(BaseAgent):
""" """
@@ -170,7 +173,7 @@ class LLMAgent(BaseAgent):
*self.history, *self.history,
] ]
message_id = str(uuid.uuid4()) # noqa message_id = str(uuid.uuid4())
try: try:
full_message = "" full_message = ""
@@ -179,10 +182,9 @@ class LLMAgent(BaseAgent):
full_message += token full_message += token
current_chunk += token current_chunk += token
self.logger.llm( experiment_logger.chat(
"Received token: %s",
full_message, full_message,
extra={"reference": message_id}, # Used in the UI to update old logs extra={"role": "assistant", "reference": message_id, "partial": True},
) )
# Stream the message in chunks separated by punctuation. # Stream the message in chunks separated by punctuation.
@@ -197,6 +199,11 @@ class LLMAgent(BaseAgent):
# Yield any remaining tail # Yield any remaining tail
if current_chunk: if current_chunk:
yield current_chunk yield current_chunk
experiment_logger.chat(
full_message,
extra={"role": "assistant", "reference": message_id, "partial": False},
)
except httpx.HTTPError as err: except httpx.HTTPError as err:
self.logger.error("HTTP error.", exc_info=err) self.logger.error("HTTP error.", exc_info=err)
yield "LLM service unavailable." yield "LLM service unavailable."

View File

@@ -1,4 +1,5 @@
import asyncio import asyncio
import logging
import numpy as np import numpy as np
import zmq import zmq
@@ -10,6 +11,8 @@ from control_backend.core.config import settings
from .speech_recognizer import SpeechRecognizer from .speech_recognizer import SpeechRecognizer
experiment_logger = logging.getLogger(settings.logging_settings.experiment_logger_name)
class TranscriptionAgent(BaseAgent): class TranscriptionAgent(BaseAgent):
""" """
@@ -25,6 +28,8 @@ class TranscriptionAgent(BaseAgent):
:ivar audio_in_socket: The ZMQ SUB socket instance. :ivar audio_in_socket: The ZMQ SUB socket instance.
:ivar speech_recognizer: The speech recognition engine instance. :ivar speech_recognizer: The speech recognition engine instance.
:ivar _concurrency: Semaphore to limit concurrent transcriptions. :ivar _concurrency: Semaphore to limit concurrent transcriptions.
:ivar _current_speech_reference: The reference of the current user utterance, for synchronising
experiment logs.
""" """
def __init__(self, audio_in_address: str): def __init__(self, audio_in_address: str):
@@ -39,6 +44,7 @@ class TranscriptionAgent(BaseAgent):
self.audio_in_socket: azmq.Socket | None = None self.audio_in_socket: azmq.Socket | None = None
self.speech_recognizer = None self.speech_recognizer = None
self._concurrency = None self._concurrency = None
self._current_speech_reference: str | None = None
async def setup(self): async def setup(self):
""" """
@@ -63,6 +69,10 @@ class TranscriptionAgent(BaseAgent):
self.logger.info("Finished setting up %s", self.name) self.logger.info("Finished setting up %s", self.name)
async def handle_message(self, msg: InternalMessage):
if msg.thread == "voice_activity":
self._current_speech_reference = msg.body
async def stop(self): async def stop(self):
""" """
Stop the agent and close sockets. Stop the agent and close sockets.
@@ -96,24 +106,25 @@ class TranscriptionAgent(BaseAgent):
async def _share_transcription(self, transcription: str): async def _share_transcription(self, transcription: str):
""" """
Share a transcription to the other agents that depend on it. Share a transcription to the other agents that depend on it, and to experiment logs.
Currently sends to: Currently sends to:
- :attr:`settings.agent_settings.text_belief_extractor_name` - :attr:`settings.agent_settings.text_belief_extractor_name`
- The UI via the experiment logger
:param transcription: The transcribed text. :param transcription: The transcribed text.
""" """
receiver_names = [ experiment_logger.chat(
settings.agent_settings.text_belief_extractor_name, transcription,
] extra={"role": "user", "reference": self._current_speech_reference, "partial": False},
)
for receiver_name in receiver_names: message = InternalMessage(
message = InternalMessage( to=settings.agent_settings.text_belief_extractor_name,
to=receiver_name, sender=self.name,
sender=self.name, body=transcription,
body=transcription, )
) await self.send(message)
await self.send(message)
async def _transcribing_loop(self) -> None: async def _transcribing_loop(self) -> None:
""" """
@@ -129,10 +140,9 @@ class TranscriptionAgent(BaseAgent):
audio = np.frombuffer(audio_data, dtype=np.float32) audio = np.frombuffer(audio_data, dtype=np.float32)
speech = await self._transcribe(audio) speech = await self._transcribe(audio)
if not speech: if not speech:
self.logger.info("Nothing transcribed.") self.logger.debug("Nothing transcribed.")
continue continue
self.logger.info("Transcribed speech: %s", speech)
await self._share_transcription(speech) await self._share_transcription(speech)
except Exception as e: except Exception as e:
self.logger.error(f"Error in transcription loop: {e}") self.logger.error(f"Error in transcription loop: {e}")

View File

@@ -1,4 +1,6 @@
import asyncio import asyncio
import logging
import uuid
import numpy as np import numpy as np
import torch import torch
@@ -12,6 +14,8 @@ from control_backend.schemas.internal_message import InternalMessage
from ...schemas.program_status import PROGRAM_STATUS, ProgramStatus from ...schemas.program_status import PROGRAM_STATUS, ProgramStatus
from .transcription_agent.transcription_agent import TranscriptionAgent from .transcription_agent.transcription_agent import TranscriptionAgent
experiment_logger = logging.getLogger(settings.logging_settings.experiment_logger_name)
class SocketPoller[T]: class SocketPoller[T]:
""" """
@@ -252,6 +256,18 @@ class VADAgent(BaseAgent):
if prob > prob_threshold: if prob > prob_threshold:
if self.i_since_speech > non_speech_patience + begin_silence_length: if self.i_since_speech > non_speech_patience + begin_silence_length:
self.logger.debug("Speech started.") self.logger.debug("Speech started.")
reference = str(uuid.uuid4())
experiment_logger.chat(
"...",
extra={"role": "user", "reference": reference, "partial": True},
)
await self.send(
InternalMessage(
to=settings.agent_settings.transcription_name,
body=reference,
thread="voice_activity",
)
)
self.audio_buffer = np.append(self.audio_buffer, chunk) self.audio_buffer = np.append(self.audio_buffer, chunk)
self.i_since_speech = 0 self.i_since_speech = 0
continue continue
@@ -269,9 +285,10 @@ class VADAgent(BaseAgent):
assert self.audio_out_socket is not None assert self.audio_out_socket is not None
await self.audio_out_socket.send(self.audio_buffer[: -2 * len(chunk)].tobytes()) await self.audio_out_socket.send(self.audio_buffer[: -2 * len(chunk)].tobytes())
# At this point, we know that the speech has ended. # At this point, we know that there is no speech.
# Prepend the last chunk that had no speech, for a more fluent boundary # Prepend the last few chunks that had no speech, for a more fluent boundary.
self.audio_buffer = chunk self.audio_buffer = np.append(self.audio_buffer, chunk)
self.audio_buffer = self.audio_buffer[-begin_silence_length * len(chunk) :]
async def handle_message(self, msg: InternalMessage): async def handle_message(self, msg: InternalMessage):
""" """

View File

@@ -1,4 +1,5 @@
import json import json
import logging
import zmq import zmq
from zmq.asyncio import Context from zmq.asyncio import Context
@@ -8,7 +9,7 @@ from control_backend.agents.bdi.agentspeak_generator import AgentSpeakGenerator
from control_backend.core.agent_system import InternalMessage from control_backend.core.agent_system import InternalMessage
from control_backend.core.config import settings from control_backend.core.config import settings
from control_backend.schemas.belief_message import Belief, BeliefMessage from control_backend.schemas.belief_message import Belief, BeliefMessage
from control_backend.schemas.program import ConditionalNorm, Program from control_backend.schemas.program import ConditionalNorm, Goal, Program
from control_backend.schemas.ri_message import ( from control_backend.schemas.ri_message import (
GestureCommand, GestureCommand,
PauseCommand, PauseCommand,
@@ -16,6 +17,8 @@ from control_backend.schemas.ri_message import (
SpeechCommand, SpeechCommand,
) )
experiment_logger = logging.getLogger(settings.logging_settings.experiment_logger_name)
class UserInterruptAgent(BaseAgent): class UserInterruptAgent(BaseAgent):
""" """
@@ -246,6 +249,16 @@ class UserInterruptAgent(BaseAgent):
self._cond_norm_map = {} self._cond_norm_map = {}
self._cond_norm_reverse_map = {} self._cond_norm_reverse_map = {}
def _register_goal(goal: Goal):
"""Recursively register goals and their subgoals."""
slug = AgentSpeakGenerator.slugify(goal)
self._goal_map[str(goal.id)] = slug
self._goal_reverse_map[slug] = str(goal.id)
for step in goal.plan.steps:
if isinstance(step, Goal):
_register_goal(step)
for phase in program.phases: for phase in program.phases:
for trigger in phase.triggers: for trigger in phase.triggers:
slug = AgentSpeakGenerator.slugify(trigger) slug = AgentSpeakGenerator.slugify(trigger)
@@ -253,8 +266,7 @@ class UserInterruptAgent(BaseAgent):
self._trigger_reverse_map[slug] = str(trigger.id) self._trigger_reverse_map[slug] = str(trigger.id)
for goal in phase.goals: for goal in phase.goals:
self._goal_map[str(goal.id)] = AgentSpeakGenerator.slugify(goal) _register_goal(goal)
self._goal_reverse_map[AgentSpeakGenerator.slugify(goal)] = str(goal.id)
for goal, id in self._goal_reverse_map.items(): for goal, id in self._goal_reverse_map.items():
self.logger.debug(f"Goal mapping: UI ID {goal} -> {id}") self.logger.debug(f"Goal mapping: UI ID {goal} -> {id}")
@@ -296,6 +308,7 @@ class UserInterruptAgent(BaseAgent):
:param text_to_say: The string that the robot has to say. :param text_to_say: The string that the robot has to say.
""" """
experiment_logger.chat(text_to_say, extra={"role": "assistant"})
cmd = SpeechCommand(data=text_to_say, is_priority=True) cmd = SpeechCommand(data=text_to_say, is_priority=True)
out_msg = InternalMessage( out_msg = InternalMessage(
to=settings.agent_settings.robot_speech_name, to=settings.agent_settings.robot_speech_name,
@@ -335,6 +348,7 @@ class UserInterruptAgent(BaseAgent):
belief_name = f"force_{asl}" belief_name = f"force_{asl}"
else: else:
self.logger.warning("Tried to send belief with unknown type") self.logger.warning("Tried to send belief with unknown type")
return
belief = Belief(name=belief_name, arguments=None) belief = Belief(name=belief_name, arguments=None)
self.logger.debug(f"Sending belief to BDI Core: {belief_name}") self.logger.debug(f"Sending belief to BDI Core: {belief_name}")
# Conditional norms are unachieved by removing the belief # Conditional norms are unachieved by removing the belief

View File

@@ -1,8 +1,9 @@
import logging import logging
from pathlib import Path
import zmq import zmq
from fastapi import APIRouter from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse from fastapi.responses import FileResponse, StreamingResponse
from zmq.asyncio import Context from zmq.asyncio import Context
from control_backend.core.config import settings from control_backend.core.config import settings
@@ -38,3 +39,29 @@ async def log_stream():
yield f"data: {message}\n\n" yield f"data: {message}\n\n"
return StreamingResponse(gen(), media_type="text/event-stream") return StreamingResponse(gen(), media_type="text/event-stream")
LOGGING_DIR = Path(settings.logging_settings.experiment_log_directory).resolve()
@router.get("/logs/files")
@router.get("/api/logs/files")
async def log_directory():
"""
Get a list of all log files stored in the experiment log file directory.
"""
return [f.name for f in LOGGING_DIR.glob("*.log")]
@router.get("/logs/files/{filename}")
@router.get("/api/logs/files/{filename}")
async def log_file(filename: str):
# Prevent path-traversal
file_path = (LOGGING_DIR / filename).resolve() # This .resolve() is important
if not file_path.is_relative_to(LOGGING_DIR):
raise HTTPException(status_code=400, detail="Invalid filename.")
if not file_path.is_file():
raise HTTPException(status_code=404, detail="File not found.")
return FileResponse(file_path, filename=file_path.name)

View File

@@ -77,6 +77,8 @@ class BehaviourSettings(BaseModel):
:ivar transcription_words_per_token: Estimated words per token for transcription timing. :ivar transcription_words_per_token: Estimated words per token for transcription timing.
:ivar transcription_token_buffer: Buffer for transcription tokens. :ivar transcription_token_buffer: Buffer for transcription tokens.
:ivar conversation_history_length_limit: The maximum amount of messages to extract beliefs from. :ivar conversation_history_length_limit: The maximum amount of messages to extract beliefs from.
:ivar trigger_time_to_wait: Amount of milliseconds to wait before informing the UI about trigger
completion.
""" """
# ATTENTION: When adding/removing settings, make sure to update the .env.example file # ATTENTION: When adding/removing settings, make sure to update the .env.example file
@@ -100,6 +102,10 @@ class BehaviourSettings(BaseModel):
# Text belief extractor settings # Text belief extractor settings
conversation_history_length_limit: int = 10 conversation_history_length_limit: int = 10
# AgentSpeak related settings
trigger_time_to_wait: int = 2000
agentspeak_file: str = "src/control_backend/agents/bdi/agentspeak.asl"
class LLMSettings(BaseModel): class LLMSettings(BaseModel):
""" """
@@ -154,6 +160,20 @@ class SpeechModelSettings(BaseModel):
openai_model_name: str = "small.en" openai_model_name: str = "small.en"
class LoggingSettings(BaseModel):
"""
Configuration for logging.
:ivar logging_config_file: Path to the logging configuration file.
:ivar experiment_log_directory: Location of the experiment logs. Must match the logging config.
:ivar experiment_logger_name: Name of the experiment logger. Must match the logging config.
"""
logging_config_file: str = ".logging_config.yaml"
experiment_log_directory: str = "experiment_logs"
experiment_logger_name: str = "experiment"
class Settings(BaseSettings): class Settings(BaseSettings):
""" """
Global application settings. Global application settings.
@@ -175,6 +195,8 @@ class Settings(BaseSettings):
ri_host: str = "localhost" ri_host: str = "localhost"
logging_settings: LoggingSettings = LoggingSettings()
zmq_settings: ZMQSettings = ZMQSettings() zmq_settings: ZMQSettings = ZMQSettings()
agent_settings: AgentSettings = AgentSettings() agent_settings: AgentSettings = AgentSettings()

View File

@@ -1 +1,4 @@
from .dated_file_handler import DatedFileHandler as DatedFileHandler
from .optional_field_formatter import OptionalFieldFormatter as OptionalFieldFormatter
from .partial_filter import PartialFilter as PartialFilter
from .setup_logging import setup_logging as setup_logging from .setup_logging import setup_logging as setup_logging

View File

@@ -0,0 +1,38 @@
from datetime import datetime
from logging import FileHandler
from pathlib import Path
class DatedFileHandler(FileHandler):
def __init__(self, file_prefix: str, **kwargs):
if not file_prefix:
raise ValueError("`file_prefix` argument cannot be empty.")
self._file_prefix = file_prefix
kwargs["filename"] = self._make_filename()
super().__init__(**kwargs)
def _make_filename(self) -> str:
"""
Create the filename for the current logfile, using the configured file prefix and the
current date and time. If the directory does not exist, it gets created.
:return: A filepath.
"""
filepath = Path(f"{self._file_prefix}-{datetime.now():%Y%m%d-%H%M%S}.log")
if not filepath.parent.is_dir():
filepath.parent.mkdir(parents=True, exist_ok=True)
return str(filepath)
def do_rollover(self):
"""
Close the current logfile and create a new one with the current date and time.
"""
self.acquire()
try:
if self.stream:
self.stream.close()
self.baseFilename = self._make_filename()
self.stream = self._open()
finally:
self.release()

View File

@@ -0,0 +1,67 @@
import logging
import re
class OptionalFieldFormatter(logging.Formatter):
"""
Logging formatter that supports optional fields marked by `?`.
Optional fields are denoted by placing a `?` after the field name inside
the parentheses, e.g., `%(role?)s`. If the field is not provided in the
log call's `extra` dict, it will use the default value from `defaults`
or `None` if no default is specified.
:param fmt: Format string with optional `%(name?)s` style fields.
:type fmt: str or None
:param datefmt: Date format string, passed to parent :class:`logging.Formatter`.
:type datefmt: str or None
:param style: Formatting style, must be '%'. Passed to parent.
:type style: str
:param defaults: Default values for optional fields when not provided.
:type defaults: dict or None
:example:
>>> formatter = OptionalFieldFormatter(
... fmt="%(asctime)s %(levelname)s %(role?)s %(message)s",
... defaults={"role": ""-""}
... )
>>> handler = logging.StreamHandler()
>>> handler.setFormatter(formatter)
>>> logger = logging.getLogger(__name__)
>>> logger.addHandler(handler)
>>>
>>> logger.chat("Hello there!", extra={"role": "USER"})
2025-01-15 10:30:00 CHAT USER Hello there!
>>>
>>> logger.info("A logging message")
2025-01-15 10:30:01 INFO - A logging message
.. note::
Only `%`-style formatting is supported. The `{` and `$` styles are not
compatible with this formatter.
.. seealso::
:class:`logging.Formatter` for base formatter documentation.
"""
# Match %(name?)s or %(name?)d etc.
OPTIONAL_PATTERN = re.compile(r"%\((\w+)\?\)([sdifFeEgGxXocrba%])")
def __init__(self, fmt=None, datefmt=None, style="%", defaults=None):
self.defaults = defaults or {}
self.optional_fields = set(self.OPTIONAL_PATTERN.findall(fmt or ""))
# Convert %(name?)s to %(name)s for standard formatting
normalized_fmt = self.OPTIONAL_PATTERN.sub(r"%(\1)\2", fmt or "")
super().__init__(normalized_fmt, datefmt, style)
def format(self, record):
for field, _ in self.optional_fields:
if not hasattr(record, field):
default = self.defaults.get(field, None)
setattr(record, field, default)
return super().format(record)

View File

@@ -0,0 +1,10 @@
import logging
class PartialFilter(logging.Filter):
"""
Class to filter any log records that have the "partial" attribute set to ``True``.
"""
def filter(self, record):
return getattr(record, "partial", False) is not True

View File

@@ -37,7 +37,7 @@ def add_logging_level(level_name: str, level_num: int, method_name: str | None =
setattr(logging, method_name, log_to_root) setattr(logging, method_name, log_to_root)
def setup_logging(path: str = ".logging_config.yaml") -> None: def setup_logging(path: str = settings.logging_settings.logging_config_file) -> None:
""" """
Setup logging configuration of the CB. Tries to load the logging configuration from a file, Setup logging configuration of the CB. Tries to load the logging configuration from a file,
in which we specify custom loggers, formatters, handlers, etc. in which we specify custom loggers, formatters, handlers, etc.
@@ -65,7 +65,7 @@ def setup_logging(path: str = ".logging_config.yaml") -> None:
# Patch ZMQ PUBHandler to know about custom levels # Patch ZMQ PUBHandler to know about custom levels
if custom_levels: if custom_levels:
for logger_name in ("control_backend",): for logger_name in config.get("loggers", {}):
logger = logging.getLogger(logger_name) logger = logging.getLogger(logger_name)
for handler in logger.handlers: for handler in logger.handlers:
if isinstance(handler, PUBHandler): if isinstance(handler, PUBHandler):

View File

@@ -7,7 +7,7 @@ class InternalMessage(BaseModel):
""" """
Standard message envelope for communication between agents within the Control Backend. Standard message envelope for communication between agents within the Control Backend.
:ivar to: The name of the destination agent. :ivar to: The name(s) of the destination agent(s).
:ivar sender: The name of the sending agent. :ivar sender: The name of the sending agent.
:ivar body: The string payload (often a JSON-serialized model). :ivar body: The string payload (often a JSON-serialized model).
:ivar thread: An optional thread identifier/topic to categorize the message (e.g., 'beliefs'). :ivar thread: An optional thread identifier/topic to categorize the message (e.g., 'beliefs').

View File

@@ -22,6 +22,13 @@ class ProgramElement(BaseModel):
class LogicalOperator(Enum): class LogicalOperator(Enum):
""" """
Logical operators for combining beliefs. Logical operators for combining beliefs.
These operators define how beliefs can be combined to form more complex
logical conditions. They are used in inferred beliefs to create compound
beliefs from simpler ones.
AND: Both operands must be true for the result to be true.
OR: At least one operand must be true for the result to be true.
""" """
AND = "AND" AND = "AND"
@@ -36,7 +43,15 @@ class KeywordBelief(ProgramElement):
""" """
Represents a belief that is activated when a specific keyword is detected in the user's speech. Represents a belief that is activated when a specific keyword is detected in the user's speech.
Keyword beliefs provide a simple but effective way to detect specific topics
or intentions in user speech. They are triggered when the exact keyword
string appears in the transcribed user input.
:ivar keyword: The string to look for in the transcription. :ivar keyword: The string to look for in the transcription.
Example:
A keyword belief with keyword="robot" would be activated when the user
says "I like the robot" or "Tell me about robots".
""" """
name: str = "" name: str = ""
@@ -48,8 +63,18 @@ class SemanticBelief(ProgramElement):
Represents a belief whose truth value is determined by an LLM analyzing the conversation Represents a belief whose truth value is determined by an LLM analyzing the conversation
context. context.
Semantic beliefs provide more sophisticated belief detection by using
an LLM to analyze the conversation context and determine
if the belief should be considered true. This allows for more nuanced
and context-aware belief evaluation.
:ivar description: A natural language description of what this belief represents, :ivar description: A natural language description of what this belief represents,
used as a prompt for the LLM. used as a prompt for the LLM.
Example:
A semantic belief with description="The user is expressing frustration"
would be activated when the LLM determines that the user's statements
indicate frustration, even if no specific keywords are used.
""" """
description: str description: str
@@ -59,6 +84,11 @@ class InferredBelief(ProgramElement):
""" """
Represents a belief derived from other beliefs using logical operators. Represents a belief derived from other beliefs using logical operators.
Inferred beliefs allow for the creation of complex belief structures by
combining simpler beliefs using logical operators. This enables the
representation of sophisticated conditions and relationships between
different aspects of the conversation or context.
:ivar operator: The :class:`LogicalOperator` (AND/OR) to apply. :ivar operator: The :class:`LogicalOperator` (AND/OR) to apply.
:ivar left: The left operand (another belief). :ivar left: The left operand (another belief).
:ivar right: The right operand (another belief). :ivar right: The right operand (another belief).
@@ -74,8 +104,16 @@ 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.
Norms represent guidelines, principles, or rules that should govern the
robot's behavior during interactions. They can be either basic (always
active in their phase) or conditional (active only when specific beliefs
are true).
:ivar norm: The textual description of the norm. :ivar norm: The textual description of the norm.
:ivar critical: Whether this norm is considered critical and should be strictly enforced. :ivar critical: Whether this norm is considered critical and should be strictly enforced.
Critical norms are currently not supported yet, but are intended for norms that should
ABSOLUTELY NOT be violated, possible cheched by additional validator agents.
""" """
name: str = "" name: str = ""
@@ -86,6 +124,13 @@ class Norm(ProgramElement):
class BasicNorm(Norm): class BasicNorm(Norm):
""" """
A simple behavioral norm that is always considered for activation when its phase is active. A simple behavioral norm that is always considered for activation when its phase is active.
Basic norms are the most straightforward type of norms. They are active
throughout their assigned phase and provide consistent behavioral guidance
without any additional conditions.
These norms are suitable for general principles that should always apply
during a particular interaction phase.
""" """
pass pass
@@ -95,7 +140,20 @@ class ConditionalNorm(Norm):
""" """
A behavioral norm that is only active when a specific condition (belief) is met. A behavioral norm that is only active when a specific condition (belief) is met.
Conditional norms provide context-sensitive behavioral guidance. They are
only active and considered for activation when their associated condition
(belief) is true. This allows for more nuanced and adaptive behavior that
responds to the specific context of the interaction.
An important note, is that the current implementation of these norms for keyword-based beliefs
is that they only hold for 1 turn, as keyword-based conditions often express temporary
conditions.
:ivar condition: The :class:`Belief` that must hold for this norm to be active. :ivar condition: The :class:`Belief` that must hold for this norm to be active.
Example:
A conditional norm with the condition "user is frustrated" might specify
that the robot should use more empathetic language and avoid complex topics.
""" """
condition: Belief condition: Belief
@@ -107,7 +165,12 @@ type PlanElement = Goal | Action
class Plan(ProgramElement): class Plan(ProgramElement):
""" """
Represents a list of steps to execute. Each of these steps can be a goal (with its own plan) Represents a list of steps to execute. Each of these steps can be a goal (with its own plan)
or a simple action. or a simple action.
Plans define sequences of actions and subgoals that the robot should execute
to achieve a particular objective. They form the procedural knowledge of
the behavior program, specifying what the robot should do in different
situations.
:ivar steps: The actions or subgoals to execute, in order. :ivar steps: The actions or subgoals to execute, in order.
""" """
@@ -123,6 +186,10 @@ class BaseGoal(ProgramElement):
:ivar description: A description of the goal, used to determine if it has been achieved. :ivar description: A description of the goal, used to determine if it has been achieved.
:ivar can_fail: Whether we can fail to achieve the goal after executing the plan. :ivar can_fail: Whether we can fail to achieve the goal after executing the plan.
The can_fail attribute determines whether goal achievement is binary
(success/failure) or whether it can be determined through conversation
analysis.
""" """
description: str = "" description: str = ""
@@ -132,9 +199,13 @@ class BaseGoal(ProgramElement):
class Goal(BaseGoal): class Goal(BaseGoal):
""" """
Represents an objective to be achieved. To reach the goal, we should execute the corresponding 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 plan. It inherits from the BaseGoal a variable `can_fail`, which, if true, will cause the
completion to be determined based on the conversation. completion to be determined based on the conversation.
Goals extend base goals by including a specific plan to achieve the objective.
They form the core of the robot's proactive behavior, defining both what
should be accomplished and how to accomplish it.
Instances of this goal are not hashable because a plan is not hashable. Instances of this goal are not hashable because a plan is not hashable.
:ivar plan: The plan to execute. :ivar plan: The plan to execute.
@@ -163,6 +234,10 @@ class Gesture(BaseModel):
:ivar type: Whether to use a specific "single" gesture or a random one from a "tag" category. :ivar type: Whether to use a specific "single" gesture or a random one from a "tag" category.
:ivar name: The identifier for the gesture or tag. :ivar name: The identifier for the gesture or tag.
The type field determines how the gesture is selected:
- "single": Use the specific gesture identified by name
- "tag": Select a random gesture from the category identified by name
""" """
type: Literal["tag", "single"] type: Literal["tag", "single"]
@@ -185,6 +260,10 @@ class LLMAction(ProgramElement):
An action that triggers an LLM-generated conversational response. An action that triggers an LLM-generated conversational response.
:ivar goal: A temporary conversational goal to guide the LLM's response generation. :ivar goal: A temporary conversational goal to guide the LLM's response generation.
The goal parameter provides high-level guidance to the LLM about what
the response should aim to achieve, while allowing the LLM flexibility
in how to express it.
""" """
name: str = "" name: str = ""
@@ -222,6 +301,10 @@ class Program(BaseModel):
""" """
The top-level container for a complete robot behavior definition. The top-level container for a complete robot behavior definition.
The Program class represents the complete specification of a robot's
behavioral logic. It contains all the phases, norms, goals, triggers,
and actions that define how the robot should behave during interactions.
:ivar phases: An ordered list of :class:`Phase` objects defining the interaction flow. :ivar phases: An ordered list of :class:`Phase` objects defining the interaction flow.
""" """

View File

@@ -1,5 +1,5 @@
import json import json
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
import zmq import zmq
@@ -19,6 +19,12 @@ def zmq_context(mocker):
return mock_context return mock_context
@pytest.fixture(autouse=True)
def mock_experiment_logger():
with patch("control_backend.agents.actuation.robot_gesture_agent.experiment_logger") as logger:
yield logger
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_setup_bind(zmq_context, mocker): async def test_setup_bind(zmq_context, mocker):
"""Setup binds and subscribes to internal commands.""" """Setup binds and subscribes to internal commands."""

View File

@@ -26,6 +26,12 @@ def agent():
return agent return agent
@pytest.fixture(autouse=True)
def mock_experiment_logger():
with patch("control_backend.agents.bdi.bdi_core_agent.experiment_logger") as logger:
yield logger
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_setup_loads_asl(mock_agentspeak_env, agent): async def test_setup_loads_asl(mock_agentspeak_env, agent):
# Mock file opening # Mock file opening

View File

@@ -8,7 +8,17 @@ import pytest
from control_backend.agents.bdi.bdi_program_manager import BDIProgramManager from control_backend.agents.bdi.bdi_program_manager import BDIProgramManager
from control_backend.core.agent_system import InternalMessage from control_backend.core.agent_system import InternalMessage
from control_backend.schemas.program import BasicNorm, Goal, Phase, Plan, Program from control_backend.schemas.program import (
BasicNorm,
ConditionalNorm,
Goal,
InferredBelief,
KeywordBelief,
Phase,
Plan,
Program,
Trigger,
)
# Fix Windows Proactor loop for zmq # Fix Windows Proactor loop for zmq
if sys.platform.startswith("win"): if sys.platform.startswith("win"):
@@ -59,7 +69,7 @@ async def test_create_agentspeak_and_send_to_bdi(mock_settings):
await manager._create_agentspeak_and_send_to_bdi(program) await manager._create_agentspeak_and_send_to_bdi(program)
# Check file writing # Check file writing
mock_file.assert_called_with("src/control_backend/agents/bdi/agentspeak.asl", "w") mock_file.assert_called_with(mock_settings.behaviour_settings.agentspeak_file, "w")
handle = mock_file() handle = mock_file()
handle.write.assert_called() handle.write.assert_called()
@@ -67,7 +77,7 @@ async def test_create_agentspeak_and_send_to_bdi(mock_settings):
msg: InternalMessage = manager.send.await_args[0][0] msg: InternalMessage = manager.send.await_args[0][0]
assert msg.thread == "new_program" assert msg.thread == "new_program"
assert msg.to == mock_settings.agent_settings.bdi_core_name assert msg.to == mock_settings.agent_settings.bdi_core_name
assert msg.body == "src/control_backend/agents/bdi/agentspeak.asl" assert msg.body == mock_settings.behaviour_settings.agentspeak_file
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -295,3 +305,98 @@ async def test_setup(mock_settings):
# 3. Adds behavior # 3. Adds behavior
manager.add_behavior.assert_called() manager.add_behavior.assert_called()
@pytest.mark.asyncio
async def test_send_program_to_user_interrupt(mock_settings):
"""Test directly sending the program to the user interrupt agent."""
mock_settings.agent_settings.user_interrupt_name = "user_interrupt_agent"
manager = BDIProgramManager(name="program_manager_test")
manager.send = AsyncMock()
program = Program.model_validate_json(make_valid_program_json())
await manager._send_program_to_user_interrupt(program)
assert manager.send.await_count == 1
msg = manager.send.await_args[0][0]
assert msg.to == "user_interrupt_agent"
assert msg.thread == "new_program"
assert "Basic Phase" in msg.body
@pytest.mark.asyncio
async def test_complex_program_extraction():
manager = BDIProgramManager(name="program_manager_test")
# 1. Create Complex Components
# Inferred Belief (A & B)
belief_left = KeywordBelief(id=uuid.uuid4(), name="b1", keyword="hot")
belief_right = KeywordBelief(id=uuid.uuid4(), name="b2", keyword="sunny")
inferred_belief = InferredBelief(
id=uuid.uuid4(), name="b_inf", operator="AND", left=belief_left, right=belief_right
)
# Conditional Norm
cond_norm = ConditionalNorm(
id=uuid.uuid4(), name="norm_cond", norm="wear_hat", condition=inferred_belief
)
# Trigger with Inferred Belief condition
dummy_plan = Plan(id=uuid.uuid4(), name="dummy_plan", steps=[])
trigger = Trigger(id=uuid.uuid4(), name="trigger_1", condition=inferred_belief, plan=dummy_plan)
# Nested Goal
sub_goal = Goal(
id=uuid.uuid4(),
name="sub_goal",
description="desc",
plan=Plan(id=uuid.uuid4(), name="empty", steps=[]),
can_fail=True,
)
parent_goal = Goal(
id=uuid.uuid4(),
name="parent_goal",
description="desc",
# The plan contains the sub_goal as a step
plan=Plan(id=uuid.uuid4(), name="parent_plan", steps=[sub_goal]),
can_fail=False,
)
# 2. Assemble Program
phase = Phase(
id=uuid.uuid4(),
name="Complex Phase",
norms=[cond_norm],
goals=[parent_goal],
triggers=[trigger],
)
program = Program(phases=[phase])
# 3. Initialize Internal State (Triggers _populate_goal_mapping -> Nested Goal logic)
manager._initialize_internal_state(program)
# Assertion for Line 53-54 (Mapping population)
# Both parent and sub-goal should be mapped
assert str(parent_goal.id) in manager._goal_mapping
assert str(sub_goal.id) in manager._goal_mapping
# 4. Test Belief Extraction (Triggers lines 132-133, 142-146)
beliefs = manager._extract_current_beliefs()
# Should extract recursive beliefs from cond_norm and trigger
# Inferred belief splits into Left + Right. Since we use it twice, we get duplicates
# checking existence is enough.
belief_names = [b.name for b in beliefs]
assert "b1" in belief_names
assert "b2" in belief_names
# 5. Test Goal Extraction (Triggers lines 173, 185)
goals = manager._extract_current_goals()
goal_names = [g.name for g in goals]
assert "parent_goal" in goal_names
assert "sub_goal" in goal_names

View File

@@ -18,6 +18,12 @@ def mock_httpx_client():
yield mock_client yield mock_client
@pytest.fixture(autouse=True)
def mock_experiment_logger():
with patch("control_backend.agents.llm.llm_agent.experiment_logger") as logger:
yield logger
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_llm_processing_success(mock_httpx_client, mock_settings): async def test_llm_processing_success(mock_httpx_client, mock_settings):
# Setup the mock response for the stream # Setup the mock response for the stream

View File

@@ -14,6 +14,15 @@ from control_backend.agents.perception.transcription_agent.transcription_agent i
) )
@pytest.fixture(autouse=True)
def mock_experiment_logger():
with patch(
"control_backend.agents.perception"
".transcription_agent.transcription_agent.experiment_logger"
) as logger:
yield logger
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_transcription_agent_flow(mock_zmq_context): async def test_transcription_agent_flow(mock_zmq_context):
mock_sub = MagicMock() mock_sub = MagicMock()

View File

@@ -24,7 +24,9 @@ def audio_out_socket():
@pytest.fixture @pytest.fixture
def vad_agent(audio_out_socket): def vad_agent(audio_out_socket):
return VADAgent("tcp://localhost:5555", False) agent = VADAgent("tcp://localhost:5555", False)
agent._internal_pub_socket = AsyncMock()
return agent
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
@@ -44,6 +46,12 @@ def patch_settings(monkeypatch):
monkeypatch.setattr(vad_agent.settings.vad_settings, "sample_rate_hz", 16_000, raising=False) monkeypatch.setattr(vad_agent.settings.vad_settings, "sample_rate_hz", 16_000, raising=False)
@pytest.fixture(autouse=True)
def mock_experiment_logger():
with patch("control_backend.agents.perception.vad_agent.experiment_logger") as logger:
yield logger
async def simulate_streaming_with_probabilities(streaming, probabilities: list[float]): async def simulate_streaming_with_probabilities(streaming, probabilities: list[float]):
""" """
Simulates a streaming scenario with given VAD model probabilities for testing purposes. Simulates a streaming scenario with given VAD model probabilities for testing purposes.
@@ -84,14 +92,15 @@ async def test_voice_activity_detected(audio_out_socket, vad_agent):
Test a scenario where there is voice activity detected between silences. Test a scenario where there is voice activity detected between silences.
""" """
speech_chunk_count = 5 speech_chunk_count = 5
probabilities = [0.0] * 5 + [1.0] * speech_chunk_count + [0.0] * 5 begin_silence_chunks = settings.behaviour_settings.vad_begin_silence_chunks
probabilities = [0.0] * 15 + [1.0] * speech_chunk_count + [0.0] * 5
vad_agent.audio_out_socket = audio_out_socket vad_agent.audio_out_socket = audio_out_socket
await simulate_streaming_with_probabilities(vad_agent, probabilities) await simulate_streaming_with_probabilities(vad_agent, probabilities)
audio_out_socket.send.assert_called_once() audio_out_socket.send.assert_called_once()
data = audio_out_socket.send.call_args[0][0] data = audio_out_socket.send.call_args[0][0]
assert isinstance(data, bytes) assert isinstance(data, bytes)
assert len(data) == 512 * 4 * (speech_chunk_count + 1) assert len(data) == 512 * 4 * (begin_silence_chunks + speech_chunk_count)
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -101,8 +110,9 @@ async def test_voice_activity_short_pause(audio_out_socket, vad_agent):
short pause. short pause.
""" """
speech_chunk_count = 5 speech_chunk_count = 5
begin_silence_chunks = settings.behaviour_settings.vad_begin_silence_chunks
probabilities = ( probabilities = (
[0.0] * 5 + [1.0] * speech_chunk_count + [0.0] + [1.0] * speech_chunk_count + [0.0] * 5 [0.0] * 15 + [1.0] * speech_chunk_count + [0.0] + [1.0] * speech_chunk_count + [0.0] * 5
) )
vad_agent.audio_out_socket = audio_out_socket vad_agent.audio_out_socket = audio_out_socket
await simulate_streaming_with_probabilities(vad_agent, probabilities) await simulate_streaming_with_probabilities(vad_agent, probabilities)
@@ -110,8 +120,8 @@ async def test_voice_activity_short_pause(audio_out_socket, vad_agent):
audio_out_socket.send.assert_called_once() audio_out_socket.send.assert_called_once()
data = audio_out_socket.send.call_args[0][0] data = audio_out_socket.send.call_args[0][0]
assert isinstance(data, bytes) assert isinstance(data, bytes)
# Expecting 13 chunks (2*5 with speech, 1 pause between, 1 as padding) # Expecting 13 chunks (2*5 with speech, 1 pause between, begin_silence_chunks as padding)
assert len(data) == 512 * 4 * (speech_chunk_count * 2 + 1 + 1) assert len(data) == 512 * 4 * (speech_chunk_count * 2 + 1 + begin_silence_chunks)
@pytest.mark.asyncio @pytest.mark.asyncio

View File

@@ -1,12 +1,13 @@
import asyncio import asyncio
import json import json
from unittest.mock import AsyncMock, MagicMock from unittest.mock import AsyncMock, MagicMock, patch
import pytest import pytest
from control_backend.agents.user_interrupt.user_interrupt_agent import UserInterruptAgent from control_backend.agents.user_interrupt.user_interrupt_agent import UserInterruptAgent
from control_backend.core.agent_system import InternalMessage from control_backend.core.agent_system import InternalMessage
from control_backend.core.config import settings from control_backend.core.config import settings
from control_backend.schemas.belief_message import BeliefMessage
from control_backend.schemas.program import ( from control_backend.schemas.program import (
ConditionalNorm, ConditionalNorm,
Goal, Goal,
@@ -29,6 +30,14 @@ def agent():
return agent return agent
@pytest.fixture(autouse=True)
def mock_experiment_logger():
with patch(
"control_backend.agents.user_interrupt.user_interrupt_agent.experiment_logger"
) as logger:
yield logger
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_send_to_speech_agent(agent): async def test_send_to_speech_agent(agent):
"""Verify speech command format.""" """Verify speech command format."""
@@ -309,3 +318,375 @@ async def test_send_pause_command(agent):
m for m in agent.send.call_args_list if m.args[0].to == settings.agent_settings.vad_name m for m in agent.send.call_args_list if m.args[0].to == settings.agent_settings.vad_name
).args[0] ).args[0]
assert vad_msg.body == "RESUME" assert vad_msg.body == "RESUME"
@pytest.mark.asyncio
async def test_setup(agent):
"""Test the setup method initializes sockets correctly."""
with patch("control_backend.agents.user_interrupt.user_interrupt_agent.Context") as MockContext:
mock_ctx_instance = MagicMock()
MockContext.instance.return_value = mock_ctx_instance
mock_sub = MagicMock()
mock_pub = MagicMock()
mock_ctx_instance.socket.side_effect = [mock_sub, mock_pub]
# MOCK add_behavior so we don't rely on internal attributes
agent.add_behavior = MagicMock()
await agent.setup()
# Check sockets
mock_sub.connect.assert_called_with(settings.zmq_settings.internal_sub_address)
mock_pub.connect.assert_called_with(settings.zmq_settings.internal_pub_address)
# Verify add_behavior was called
agent.add_behavior.assert_called_once()
@pytest.mark.asyncio
async def test_receive_loop_json_error(agent):
"""Verify that malformed JSON is caught and logged without crashing the loop."""
agent.sub_socket.recv_multipart.side_effect = [
(b"topic", b"INVALID{JSON"),
asyncio.CancelledError,
]
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
agent.logger.error.assert_called_with("Received invalid JSON payload on topic %s", b"topic")
@pytest.mark.asyncio
async def test_receive_loop_override_trigger(agent):
"""Verify routing 'override' to a Trigger."""
agent._trigger_map["101"] = "trigger_slug"
payload = json.dumps({"type": "override", "context": "101"}).encode()
agent.sub_socket.recv_multipart.side_effect = [(b"topic", payload), asyncio.CancelledError]
agent._send_to_bdi = AsyncMock()
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
agent._send_to_bdi.assert_awaited_once_with("force_trigger", "trigger_slug")
@pytest.mark.asyncio
async def test_receive_loop_override_norm(agent):
"""Verify routing 'override' to a Conditional Norm."""
agent._cond_norm_map["202"] = "norm_slug"
payload = json.dumps({"type": "override", "context": "202"}).encode()
agent.sub_socket.recv_multipart.side_effect = [(b"topic", payload), asyncio.CancelledError]
agent._send_to_bdi_belief = AsyncMock()
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
agent._send_to_bdi_belief.assert_awaited_once_with("norm_slug", "cond_norm")
@pytest.mark.asyncio
async def test_receive_loop_override_missing(agent):
"""Verify warning log when an override ID is not found in any map."""
payload = json.dumps({"type": "override", "context": "999"}).encode()
agent.sub_socket.recv_multipart.side_effect = [(b"topic", payload), asyncio.CancelledError]
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
agent.logger.warning.assert_called_with("Could not determine which element to override.")
@pytest.mark.asyncio
async def test_receive_loop_unachieve_logic(agent):
"""Verify success and failure paths for override_unachieve."""
agent._cond_norm_map["202"] = "norm_slug"
success_payload = json.dumps({"type": "override_unachieve", "context": "202"}).encode()
fail_payload = json.dumps({"type": "override_unachieve", "context": "999"}).encode()
agent.sub_socket.recv_multipart.side_effect = [
(b"topic", success_payload),
(b"topic", fail_payload),
asyncio.CancelledError,
]
agent._send_to_bdi_belief = AsyncMock()
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
# Assert success call (True flag for unachieve)
agent._send_to_bdi_belief.assert_any_call("norm_slug", "cond_norm", True)
# Assert failure log
agent.logger.warning.assert_called_with(
"Could not determine which conditional norm to unachieve."
)
@pytest.mark.asyncio
async def test_receive_loop_pause_resume(agent):
"""Verify pause and resume toggle logic and logging."""
pause_payload = json.dumps({"type": "pause", "context": "true"}).encode()
resume_payload = json.dumps({"type": "pause", "context": ""}).encode()
agent.sub_socket.recv_multipart.side_effect = [
(b"topic", pause_payload),
(b"topic", resume_payload),
asyncio.CancelledError,
]
agent._send_pause_command = AsyncMock()
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
agent._send_pause_command.assert_any_call("true")
agent._send_pause_command.assert_any_call("")
agent.logger.info.assert_any_call("Sent pause command.")
agent.logger.info.assert_any_call("Sent resume command.")
@pytest.mark.asyncio
async def test_receive_loop_phase_control(agent):
"""Verify experiment flow control (next_phase)."""
payload = json.dumps({"type": "next_phase", "context": ""}).encode()
agent.sub_socket.recv_multipart.side_effect = [(b"topic", payload), asyncio.CancelledError]
agent._send_experiment_control_to_bdi_core = AsyncMock()
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
agent._send_experiment_control_to_bdi_core.assert_awaited_once_with("next_phase")
@pytest.mark.asyncio
async def test_handle_message_unknown_thread(agent):
"""Test handling of an unknown message thread (lines 213-214)."""
msg = InternalMessage(to="me", thread="unknown_thread", body="test")
await agent.handle_message(msg)
agent.logger.debug.assert_called_with(
"Received internal message on unhandled thread: unknown_thread"
)
@pytest.mark.asyncio
async def test_send_to_bdi_belief_edge_cases(agent):
"""
Covers:
- Unknown asl_type warning (lines 326-328)
- unachieve=True logic (lines 334-337)
"""
# 1. Unknown Type
await agent._send_to_bdi_belief("slug", "unknown_type")
agent.logger.warning.assert_called_with("Tried to send belief with unknown type")
agent.send.assert_not_called()
# Reset mock for part 2
agent.send.reset_mock()
# 2. Unachieve = True
await agent._send_to_bdi_belief("slug", "cond_norm", unachieve=True)
agent.send.assert_awaited()
sent_msg = agent.send.call_args.args[0]
# Verify it is a delete operation
body_obj = BeliefMessage.model_validate_json(sent_msg.body)
# Verify 'delete' has content
assert body_obj.delete is not None
assert len(body_obj.delete) == 1
assert body_obj.delete[0].name == "force_slug"
# Verify 'create' is empty (handling both None and [])
assert not body_obj.create
@pytest.mark.asyncio
async def test_send_experiment_control_unknown(agent):
"""Test sending an unknown experiment control type (lines 366-367)."""
await agent._send_experiment_control_to_bdi_core("invalid_command")
agent.logger.warning.assert_called_with(
"Received unknown experiment control type '%s' to send to BDI Core.", "invalid_command"
)
# Ensure it still sends an empty message (as per code logic, though thread is empty)
agent.send.assert_awaited()
msg = agent.send.call_args[0][0]
assert msg.thread == ""
@pytest.mark.asyncio
async def test_create_mapping_recursive_goals(agent):
"""Verify that nested subgoals are correctly registered in the mapping."""
import uuid
# 1. Setup IDs
parent_goal_id = uuid.uuid4()
child_goal_id = uuid.uuid4()
# 2. Create the child goal
child_goal = Goal(
id=child_goal_id,
name="child_goal",
description="I am a subgoal",
plan=Plan(id=uuid.uuid4(), name="p_child", steps=[]),
)
# 3. Create the parent goal and put the child goal inside its plan steps
parent_goal = Goal(
id=parent_goal_id,
name="parent_goal",
description="I am a parent",
plan=Plan(id=uuid.uuid4(), name="p_parent", steps=[child_goal]), # Nested here
)
# 4. Build the program
phase = Phase(
id=uuid.uuid4(),
name="phase1",
norms=[],
goals=[parent_goal], # Only the parent is top-level
triggers=[],
)
prog = Program(phases=[phase])
# 5. Execute mapping
msg = InternalMessage(to="me", thread="new_program", body=prog.model_dump_json())
await agent.handle_message(msg)
# 6. Assertions
# Check parent
assert str(parent_goal_id) in agent._goal_map
assert agent._goal_map[str(parent_goal_id)] == "parent_goal"
# Check child (This confirms the recursion worked)
assert str(child_goal_id) in agent._goal_map
assert agent._goal_map[str(child_goal_id)] == "child_goal"
assert agent._goal_reverse_map["child_goal"] == str(child_goal_id)
@pytest.mark.asyncio
async def test_receive_loop_advanced_scenarios(agent):
"""
Covers:
- JSONDecodeError (lines 86-88)
- Override: Trigger found (lines 108-109)
- Override: Norm found (lines 114-115)
- Override: Nothing found (line 134)
- Override Unachieve: Success & Fail (lines 136-145)
- Pause: Context true/false logs (lines 150-157)
- Next Phase (line 160)
"""
# 1. Setup Data Maps
agent._trigger_map["101"] = "trigger_slug"
agent._cond_norm_map["202"] = "norm_slug"
# 2. Define Payloads
# A. Invalid JSON
bad_json = b"INVALID{JSON"
# B. Override -> Trigger
override_trigger = json.dumps({"type": "override", "context": "101"}).encode()
# C. Override -> Norm
override_norm = json.dumps({"type": "override", "context": "202"}).encode()
# D. Override -> Unknown
override_fail = json.dumps({"type": "override", "context": "999"}).encode()
# E. Unachieve -> Success
unachieve_success = json.dumps({"type": "override_unachieve", "context": "202"}).encode()
# F. Unachieve -> Fail
unachieve_fail = json.dumps({"type": "override_unachieve", "context": "999"}).encode()
# G. Pause (True)
pause_true = json.dumps({"type": "pause", "context": "true"}).encode()
# H. Pause (False/Resume)
pause_false = json.dumps({"type": "pause", "context": ""}).encode()
# I. Next Phase
next_phase = json.dumps({"type": "next_phase", "context": ""}).encode()
# 3. Setup Socket
agent.sub_socket.recv_multipart.side_effect = [
(b"topic", bad_json),
(b"topic", override_trigger),
(b"topic", override_norm),
(b"topic", override_fail),
(b"topic", unachieve_success),
(b"topic", unachieve_fail),
(b"topic", pause_true),
(b"topic", pause_false),
(b"topic", next_phase),
asyncio.CancelledError, # End loop
]
# Mock internal helpers to verify calls
agent._send_to_bdi = AsyncMock()
agent._send_to_bdi_belief = AsyncMock()
agent._send_pause_command = AsyncMock()
agent._send_experiment_control_to_bdi_core = AsyncMock()
# 4. Run Loop
try:
await agent._receive_button_event()
except asyncio.CancelledError:
pass
# 5. Assertions
# JSON Error
agent.logger.error.assert_called_with("Received invalid JSON payload on topic %s", b"topic")
# Override Trigger
agent._send_to_bdi.assert_awaited_with("force_trigger", "trigger_slug")
# Override Norm
# We expect _send_to_bdi_belief to be called for the norm
# Note: The loop calls _send_to_bdi_belief(asl_cond_norm, "cond_norm")
agent._send_to_bdi_belief.assert_any_call("norm_slug", "cond_norm")
# Override Fail (Warning log)
agent.logger.warning.assert_any_call("Could not determine which element to override.")
# Unachieve Success
# Loop calls _send_to_bdi_belief(asl_cond_norm, "cond_norm", True)
agent._send_to_bdi_belief.assert_any_call("norm_slug", "cond_norm", True)
# Unachieve Fail
agent.logger.warning.assert_any_call("Could not determine which conditional norm to unachieve.")
# Pause Logic
agent._send_pause_command.assert_any_call("true")
agent.logger.info.assert_any_call("Sent pause command.")
# Resume Logic
agent._send_pause_command.assert_any_call("")
agent.logger.info.assert_any_call("Sent resume command.")
# Next Phase
agent._send_experiment_control_to_bdi_core.assert_awaited_with("next_phase")

View File

@@ -1,7 +1,7 @@
from unittest.mock import patch from unittest.mock import MagicMock, patch
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from starlette.responses import StreamingResponse from starlette.responses import StreamingResponse
@@ -61,3 +61,67 @@ async def test_log_stream_endpoint_lines(client):
# Optional: assert subscribe/connect were called # Optional: assert subscribe/connect were called
assert dummy_socket.subscribed # at least some log levels subscribed assert dummy_socket.subscribed # at least some log levels subscribed
assert dummy_socket.connected # connect was called assert dummy_socket.connected # connect was called
@patch("control_backend.api.v1.endpoints.logs.LOGGING_DIR")
def test_files_endpoint(LOGGING_DIR, client):
file_1, file_2 = MagicMock(), MagicMock()
file_1.name = "file_1"
file_2.name = "file_2"
LOGGING_DIR.glob.return_value = [file_1, file_2]
result = client.get("/api/logs/files")
assert result.status_code == 200
assert result.json() == ["file_1", "file_2"]
@patch("control_backend.api.v1.endpoints.logs.FileResponse")
@patch("control_backend.api.v1.endpoints.logs.LOGGING_DIR")
def test_log_file_endpoint_success(LOGGING_DIR, MockFileResponse, client):
mock_file_path = MagicMock()
mock_file_path.is_relative_to.return_value = True
mock_file_path.is_file.return_value = True
mock_file_path.name = "test.log"
LOGGING_DIR.__truediv__ = MagicMock(return_value=mock_file_path)
mock_file_path.resolve.return_value = mock_file_path
MockFileResponse.return_value = MagicMock()
result = client.get("/api/logs/files/test.log")
assert result.status_code == 200
MockFileResponse.assert_called_once_with(mock_file_path, filename="test.log")
@pytest.mark.asyncio
@patch("control_backend.api.v1.endpoints.logs.LOGGING_DIR")
async def test_log_file_endpoint_path_traversal(LOGGING_DIR):
from control_backend.api.v1.endpoints.logs import log_file
mock_file_path = MagicMock()
mock_file_path.is_relative_to.return_value = False
LOGGING_DIR.__truediv__ = MagicMock(return_value=mock_file_path)
mock_file_path.resolve.return_value = mock_file_path
with pytest.raises(HTTPException) as exc_info:
await log_file("../secret.txt")
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Invalid filename."
@patch("control_backend.api.v1.endpoints.logs.LOGGING_DIR")
def test_log_file_endpoint_file_not_found(LOGGING_DIR, client):
mock_file_path = MagicMock()
mock_file_path.is_relative_to.return_value = True
mock_file_path.is_file.return_value = False
LOGGING_DIR.__truediv__ = MagicMock(return_value=mock_file_path)
mock_file_path.resolve.return_value = mock_file_path
result = client.get("/api/logs/files/nonexistent.log")
assert result.status_code == 404
assert result.json()["detail"] == "File not found."

View File

@@ -94,3 +94,55 @@ async def test_experiment_stream_direct_call():
mock_socket.connect.assert_called() mock_socket.connect.assert_called()
mock_socket.subscribe.assert_called_with(b"experiment") mock_socket.subscribe.assert_called_with(b"experiment")
mock_socket.close.assert_called() mock_socket.close.assert_called()
@pytest.mark.asyncio
async def test_status_stream_direct_call():
"""
Test the status stream, ensuring it handles messages and sends pings on timeout.
"""
mock_socket = AsyncMock()
# Define the sequence of events for the socket:
# 1. Successfully receive a message
# 2. Timeout (which should trigger the ': ping' yield)
# 3. Another message (which won't be reached because we'll simulate disconnect)
mock_socket.recv_multipart.side_effect = [
(b"topic", b"status_update"),
TimeoutError(),
(b"topic", b"ignored_msg"),
]
mock_socket.close = MagicMock()
mock_socket.connect = MagicMock()
mock_socket.subscribe = MagicMock()
mock_context = MagicMock()
mock_context.socket.return_value = mock_socket
# Mock the ZMQ Context to return our mock_socket
with patch(
"control_backend.api.v1.endpoints.user_interact.Context.instance", return_value=mock_context
):
mock_request = AsyncMock()
# is_disconnected sequence:
# 1. False -> Process "status_update"
# 2. False -> Process TimeoutError (yield ping)
# 3. True -> Break loop (client disconnected)
mock_request.is_disconnected.side_effect = [False, False, True]
# Call the status_stream function explicitly
response = await user_interact.status_stream(mock_request)
lines = []
async for line in response.body_iterator:
lines.append(line)
# Assertions
assert "data: status_update\n\n" in lines
assert ": ping\n\n" in lines # Verify lines 91-92 (ping logic)
mock_socket.connect.assert_called()
mock_socket.subscribe.assert_called_with(b"status")
mock_socket.close.assert_called()

View File

@@ -32,6 +32,7 @@ def mock_settings():
mock.agent_settings.vad_name = "vad_agent" mock.agent_settings.vad_name = "vad_agent"
mock.behaviour_settings.sleep_s = 0.01 # Speed up tests mock.behaviour_settings.sleep_s = 0.01 # Speed up tests
mock.behaviour_settings.comm_setup_max_retries = 1 mock.behaviour_settings.comm_setup_max_retries = 1
mock.behaviour_settings.agentspeak_file = "src/control_backend/agents/bdi/agentspeak.asl"
yield mock yield mock

View File

@@ -0,0 +1,45 @@
from unittest.mock import MagicMock, patch
import pytest
from control_backend.logging.dated_file_handler import DatedFileHandler
@patch("control_backend.logging.dated_file_handler.DatedFileHandler._open")
def test_reset(open_):
stream = MagicMock()
open_.return_value = stream
# A file should be opened when the logger is created
handler = DatedFileHandler(file_prefix="anything")
assert open_.call_count == 1
# Upon reset, the current file should be closed, and a new one should be opened
handler.do_rollover()
assert stream.close.call_count == 1
assert open_.call_count == 2
@patch("control_backend.logging.dated_file_handler.Path")
@patch("control_backend.logging.dated_file_handler.DatedFileHandler._open")
def test_creates_dir(open_, Path_):
stream = MagicMock()
open_.return_value = stream
test_path = MagicMock()
test_path.parent.is_dir.return_value = False
Path_.return_value = test_path
DatedFileHandler(file_prefix="anything")
# The directory should've been created
test_path.parent.mkdir.assert_called_once()
@patch("control_backend.logging.dated_file_handler.DatedFileHandler._open")
def test_invalid_constructor(_):
with pytest.raises(ValueError):
DatedFileHandler(file_prefix=None)
with pytest.raises(ValueError):
DatedFileHandler(file_prefix="")

View File

@@ -0,0 +1,218 @@
import logging
import pytest
from control_backend.logging.optional_field_formatter import OptionalFieldFormatter
@pytest.fixture
def logger():
"""Create a fresh logger for each test."""
logger = logging.getLogger(f"test_{id(object())}")
logger.setLevel(logging.DEBUG)
logger.handlers = []
return logger
@pytest.fixture
def log_output(logger):
"""Capture log output and return a function to get it."""
class ListHandler(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(self.format(record))
handler = ListHandler()
logger.addHandler(handler)
def get_output():
return handler.records
return get_output
def test_optional_field_present(logger, log_output):
"""Optional field should appear when provided in extra."""
formatter = OptionalFieldFormatter("%(levelname)s - %(role?)s - %(message)s")
logger.handlers[0].setFormatter(formatter)
logger.info("test message", extra={"role": "user"})
assert log_output() == ["INFO - user - test message"]
def test_optional_field_missing_no_default(logger, log_output):
"""Missing optional field with no default should be None."""
formatter = OptionalFieldFormatter("%(levelname)s - %(role?)s - %(message)s")
logger.handlers[0].setFormatter(formatter)
logger.info("test message")
assert log_output() == ["INFO - None - test message"]
def test_optional_field_missing_with_default(logger, log_output):
"""Missing optional field should use provided default."""
formatter = OptionalFieldFormatter(
"%(levelname)s - %(role?)s - %(message)s", defaults={"role": "assistant"}
)
logger.handlers[0].setFormatter(formatter)
logger.info("test message")
assert log_output() == ["INFO - assistant - test message"]
def test_optional_field_overrides_default(logger, log_output):
"""Provided extra value should override default."""
formatter = OptionalFieldFormatter(
"%(levelname)s - %(role?)s - %(message)s", defaults={"role": "assistant"}
)
logger.handlers[0].setFormatter(formatter)
logger.info("test message", extra={"role": "user"})
assert log_output() == ["INFO - user - test message"]
def test_multiple_optional_fields(logger, log_output):
"""Multiple optional fields should work independently."""
formatter = OptionalFieldFormatter(
"%(levelname)s - %(role?)s - %(request_id?)s - %(message)s", defaults={"role": "assistant"}
)
logger.handlers[0].setFormatter(formatter)
logger.info("test", extra={"request_id": "123"})
assert log_output() == ["INFO - assistant - 123 - test"]
def test_mixed_optional_and_required_fields(logger, log_output):
"""Standard fields should work alongside optional fields."""
formatter = OptionalFieldFormatter("%(levelname)s %(name)s %(role?)s %(message)s")
logger.handlers[0].setFormatter(formatter)
logger.info("test", extra={"role": "user"})
output = log_output()[0]
assert "INFO" in output
assert "user" in output
assert "test" in output
def test_no_optional_fields(logger, log_output):
"""Formatter should work normally with no optional fields."""
formatter = OptionalFieldFormatter("%(levelname)s %(message)s")
logger.handlers[0].setFormatter(formatter)
logger.info("test message")
assert log_output() == ["INFO test message"]
def test_integer_format_specifier(logger, log_output):
"""Optional fields with %d specifier should work."""
formatter = OptionalFieldFormatter(
"%(levelname)s %(count?)d %(message)s", defaults={"count": 0}
)
logger.handlers[0].setFormatter(formatter)
logger.info("test", extra={"count": 42})
assert log_output() == ["INFO 42 test"]
def test_float_format_specifier(logger, log_output):
"""Optional fields with %f specifier should work."""
formatter = OptionalFieldFormatter(
"%(levelname)s %(duration?)f %(message)s", defaults={"duration": 0.0}
)
logger.handlers[0].setFormatter(formatter)
logger.info("test", extra={"duration": 1.5})
assert "1.5" in log_output()[0]
def test_empty_string_default(logger, log_output):
"""Empty string default should work."""
formatter = OptionalFieldFormatter("%(levelname)s %(role?)s %(message)s", defaults={"role": ""})
logger.handlers[0].setFormatter(formatter)
logger.info("test")
assert log_output() == ["INFO test"]
def test_none_format_string():
"""None format string should not raise."""
formatter = OptionalFieldFormatter(fmt=None)
assert formatter.optional_fields == set()
def test_optional_fields_parsed_correctly():
"""Check that optional fields are correctly identified."""
formatter = OptionalFieldFormatter("%(asctime)s %(role?)s %(level?)d %(name)s")
assert formatter.optional_fields == {("role", "s"), ("level", "d")}
def test_format_string_normalized():
"""Check that ? is removed from format string."""
formatter = OptionalFieldFormatter("%(role?)s %(message)s")
assert "?" not in formatter._style._fmt
assert "%(role)s" in formatter._style._fmt
def test_field_with_underscore(logger, log_output):
"""Field names with underscores should work."""
formatter = OptionalFieldFormatter("%(levelname)s %(user_id?)s %(message)s")
logger.handlers[0].setFormatter(formatter)
logger.info("test", extra={"user_id": "abc123"})
assert log_output() == ["INFO abc123 test"]
def test_field_with_numbers(logger, log_output):
"""Field names with numbers should work."""
formatter = OptionalFieldFormatter("%(levelname)s %(field2?)s %(message)s")
logger.handlers[0].setFormatter(formatter)
logger.info("test", extra={"field2": "value"})
assert log_output() == ["INFO value test"]
def test_multiple_log_calls(logger, log_output):
"""Formatter should work correctly across multiple log calls."""
formatter = OptionalFieldFormatter(
"%(levelname)s %(role?)s %(message)s", defaults={"role": "other"}
)
logger.handlers[0].setFormatter(formatter)
logger.info("first", extra={"role": "assistant"})
logger.info("second")
logger.info("third", extra={"role": "user"})
assert log_output() == [
"INFO assistant first",
"INFO other second",
"INFO user third",
]
def test_default_not_mutated(logger, log_output):
"""Original defaults dict should not be mutated."""
defaults = {"role": "other"}
formatter = OptionalFieldFormatter("%(levelname)s %(role?)s %(message)s", defaults=defaults)
logger.handlers[0].setFormatter(formatter)
logger.info("test")
assert defaults == {"role": "other"}

View File

@@ -0,0 +1,83 @@
import logging
import pytest
from control_backend.logging import PartialFilter
@pytest.fixture
def logger():
"""Create a fresh logger for each test."""
logger = logging.getLogger(f"test_{id(object())}")
logger.setLevel(logging.DEBUG)
logger.handlers = []
return logger
@pytest.fixture
def log_output(logger):
"""Capture log output and return a function to get it."""
class ListHandler(logging.Handler):
def __init__(self):
super().__init__()
self.records = []
def emit(self, record):
self.records.append(self.format(record))
handler = ListHandler()
handler.addFilter(PartialFilter())
handler.setFormatter(logging.Formatter("%(message)s"))
logger.addHandler(handler)
return lambda: handler.records
def test_no_partial_attribute(logger, log_output):
"""Records without partial attribute should pass through."""
logger.info("normal message")
assert log_output() == ["normal message"]
def test_partial_true_filtered(logger, log_output):
"""Records with partial=True should be filtered out."""
logger.info("partial message", extra={"partial": True})
assert log_output() == []
def test_partial_false_passes(logger, log_output):
"""Records with partial=False should pass through."""
logger.info("complete message", extra={"partial": False})
assert log_output() == ["complete message"]
def test_partial_none_passes(logger, log_output):
"""Records with partial=None should pass through."""
logger.info("message", extra={"partial": None})
assert log_output() == ["message"]
def test_partial_truthy_value_passes(logger, log_output):
"""
Records with truthy but non-True partial should pass through, that is, only when it's exactly
``True`` should it pass.
"""
logger.info("message", extra={"partial": "yes"})
assert log_output() == ["message"]
def test_multiple_records_mixed(logger, log_output):
"""Filter should handle mixed records correctly."""
logger.info("first")
logger.info("second", extra={"partial": True})
logger.info("third", extra={"partial": False})
logger.info("fourth", extra={"partial": True})
logger.info("fifth")
assert log_output() == ["first", "third", "fifth"]