Merge remote-tracking branch 'origin/dev' into feat/belief-from-text
# Conflicts: # src/control_backend/main.py
This commit is contained in:
@@ -1,35 +1,71 @@
|
||||
import logging
|
||||
|
||||
import agentspeak
|
||||
from spade.behaviour import OneShotBehaviour
|
||||
from spade.message import Message
|
||||
from spade_bdi.bdi import BDIAgent
|
||||
|
||||
from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetter
|
||||
from control_backend.agents.bdi.behaviours.belief_setter import BeliefSetterBehaviour
|
||||
from control_backend.agents.bdi.behaviours.receive_llm_resp_behaviour import (
|
||||
ReceiveLLMResponseBehaviour,
|
||||
)
|
||||
from control_backend.core.config import settings
|
||||
|
||||
|
||||
class BDICoreAgent(BDIAgent):
|
||||
"""
|
||||
This is the Brain agent that does the belief inference with AgentSpeak.
|
||||
This is the Brain agent that does the belief inference with AgentSpeak.
|
||||
This is a continous process that happens automatically in the background.
|
||||
This class contains all the actions that can be called from AgentSpeak plans.
|
||||
It has the BeliefSetter behaviour.
|
||||
It has the BeliefSetter behaviour and can aks and recieve requests from the LLM agent.
|
||||
"""
|
||||
logger = logging.getLogger("BDI Core")
|
||||
|
||||
async def setup(self):
|
||||
belief_setter = BeliefSetter()
|
||||
self.add_behaviour(belief_setter)
|
||||
logger = logging.getLogger("bdi_core_agent")
|
||||
|
||||
async def setup(self) -> None:
|
||||
"""
|
||||
Initializes belief behaviors and message routing.
|
||||
"""
|
||||
self.logger.info("BDICoreAgent setup started")
|
||||
|
||||
self.add_behaviour(BeliefSetterBehaviour())
|
||||
self.add_behaviour(ReceiveLLMResponseBehaviour())
|
||||
|
||||
await self._send_to_llm("Hi pepper, how are you?")
|
||||
# This is the example message currently sent to the llm at the start of the Program
|
||||
|
||||
self.logger.info("BDICoreAgent setup complete")
|
||||
|
||||
def add_custom_actions(self, actions) -> None:
|
||||
"""
|
||||
Registers custom AgentSpeak actions callable from plans.
|
||||
"""
|
||||
|
||||
def add_custom_actions(self, actions):
|
||||
@actions.add(".reply", 1)
|
||||
def _reply(agent, term, intention):
|
||||
message = agentspeak.grounded(term.args[0], intention.scope)
|
||||
self.logger.info(f"Replying to message: {message}")
|
||||
reply = self._send_to_llm(message)
|
||||
self.logger.info(f"Received reply: {reply}")
|
||||
def _reply(agent: "BDICoreAgent", term, intention):
|
||||
"""
|
||||
Sends text to the LLM (AgentSpeak action).
|
||||
Example: .reply("Hello LLM!")
|
||||
"""
|
||||
message_text = agentspeak.grounded(term.args[0], intention.scope)
|
||||
self.logger.info("Reply action sending: %s", message_text)
|
||||
|
||||
self._send_to_llm(message_text)
|
||||
yield
|
||||
|
||||
def _send_to_llm(self, message) -> str:
|
||||
"""TODO: implement"""
|
||||
return f"This is a reply to {message}"
|
||||
async def _send_to_llm(self, text: str):
|
||||
"""
|
||||
Sends a text query to the LLM Agent asynchronously.
|
||||
"""
|
||||
|
||||
class SendBehaviour(OneShotBehaviour):
|
||||
async def run(self) -> None:
|
||||
msg = Message(
|
||||
to= settings.agent_settings.llm_agent_name + '@' + settings.agent_settings.host,
|
||||
body= text
|
||||
)
|
||||
|
||||
await self.send(msg)
|
||||
self.agent.logger.debug("Message sent to LLM: %s", text)
|
||||
|
||||
self.add_behaviour(SendBehaviour())
|
||||
@@ -1,28 +1,28 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from spade.agent import Message
|
||||
from spade.behaviour import CyclicBehaviour
|
||||
from spade_bdi.bdi import BDIAgent
|
||||
from spade_bdi.bdi import BDIAgent, BeliefNotInitiated
|
||||
|
||||
from control_backend.core.config import settings
|
||||
|
||||
class BeliefSetter(CyclicBehaviour):
|
||||
|
||||
class BeliefSetterBehaviour(CyclicBehaviour):
|
||||
"""
|
||||
This is the behaviour that the BDI agent runs.
|
||||
This behaviour waits for incoming message and processes it based on sender.
|
||||
Currently, t only waits for messages containing beliefs from Belief Collector and adds these to its KB.
|
||||
This is the behaviour that the BDI agent runs. This behaviour waits for incoming
|
||||
message and processes it based on sender.
|
||||
"""
|
||||
|
||||
agent: BDIAgent
|
||||
logger = logging.getLogger("BDI/Belief Setter")
|
||||
|
||||
|
||||
async def run(self):
|
||||
msg = await self.receive(timeout=0.1)
|
||||
if msg:
|
||||
self.logger.info(f"Received message {msg.body}")
|
||||
self._process_message(msg)
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
def _process_message(self, message: Message):
|
||||
sender = message.sender.node # removes host from jid and converts to str
|
||||
@@ -33,28 +33,38 @@ class BeliefSetter(CyclicBehaviour):
|
||||
self.logger.debug("Processing message from belief collector.")
|
||||
self._process_belief_message(message)
|
||||
case _:
|
||||
self.logger.debug("Not the belief agent, discarding message")
|
||||
pass
|
||||
|
||||
def _process_belief_message(self, message: Message):
|
||||
if not message.body: return
|
||||
if not message.body:
|
||||
return
|
||||
|
||||
match message.thread:
|
||||
case "beliefs":
|
||||
try:
|
||||
beliefs: dict[str, list[list[str]]] = json.loads(message.body)
|
||||
beliefs: dict[str, list[str]] = json.loads(message.body)
|
||||
self._set_beliefs(beliefs)
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error("Could not decode beliefs into JSON format: %s", e)
|
||||
case _:
|
||||
pass
|
||||
|
||||
|
||||
def _set_beliefs(self, beliefs: dict[str, list[list[str]]]):
|
||||
def _set_beliefs(self, beliefs: dict[str, list[str]]):
|
||||
"""Remove previous values for beliefs and update them with the provided values."""
|
||||
if self.agent.bdi is None:
|
||||
self.logger.warning("Cannot set beliefs, since agent's BDI is not yet initialized.")
|
||||
return
|
||||
|
||||
for belief, arguments_list in beliefs.items():
|
||||
for arguments in arguments_list:
|
||||
self.agent.bdi.set_belief(belief, *arguments)
|
||||
self.logger.info("Set belief %s with arguments %s", belief, arguments)
|
||||
# Set new beliefs (outdated beliefs are automatically removed)
|
||||
for belief, arguments in beliefs.items():
|
||||
self.agent.bdi.set_belief(belief, *arguments)
|
||||
|
||||
# Special case: if there's a new user message, flag that we haven't responded yet
|
||||
if belief == "user_said":
|
||||
try:
|
||||
self.agent.bdi.remove_belief("responded")
|
||||
except BeliefNotInitiated:
|
||||
pass
|
||||
|
||||
self.logger.info("Set belief %s with arguments %s", belief, arguments)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import logging
|
||||
|
||||
from spade.behaviour import CyclicBehaviour
|
||||
|
||||
from control_backend.core.config import settings
|
||||
|
||||
|
||||
class ReceiveLLMResponseBehaviour(CyclicBehaviour):
|
||||
"""
|
||||
Adds behavior to receive responses from the LLM Agent.
|
||||
"""
|
||||
logger = logging.getLogger("BDI/LLM Reciever")
|
||||
async def run(self):
|
||||
msg = await self.receive(timeout=2)
|
||||
if not msg:
|
||||
return
|
||||
|
||||
sender = msg.sender.node
|
||||
match sender:
|
||||
case settings.agent_settings.llm_agent_name:
|
||||
content = msg.body
|
||||
self.logger.info("Received LLM response: %s", content)
|
||||
#Here the BDI can pass the message back as a response
|
||||
case _:
|
||||
self.logger.debug("Not from the llm, discarding message")
|
||||
pass
|
||||
127
src/control_backend/agents/llm/llm.py
Normal file
127
src/control_backend/agents/llm/llm.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
LLM Agent module for routing text queries from the BDI Core Agent to a local LLM
|
||||
service and returning its responses back to the BDI Core Agent.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from spade.agent import Agent
|
||||
from spade.behaviour import CyclicBehaviour
|
||||
from spade.message import Message
|
||||
|
||||
from control_backend.agents.llm.llm_instructions import LLMInstructions
|
||||
from control_backend.core.config import settings
|
||||
|
||||
|
||||
class LLMAgent(Agent):
|
||||
"""
|
||||
Agent responsible for processing user text input and querying a locally
|
||||
hosted LLM for text generation. Receives messages from the BDI Core Agent
|
||||
and responds with processed LLM output.
|
||||
"""
|
||||
|
||||
logger = logging.getLogger("llm_agent")
|
||||
|
||||
class ReceiveMessageBehaviour(CyclicBehaviour):
|
||||
"""
|
||||
Cyclic behaviour to continuously listen for incoming messages from
|
||||
the BDI Core Agent and handle them.
|
||||
"""
|
||||
|
||||
async def run(self):
|
||||
"""
|
||||
Receives SPADE messages and processes only those originating from the
|
||||
configured BDI agent.
|
||||
"""
|
||||
msg = await self.receive(timeout=1)
|
||||
if not msg:
|
||||
return
|
||||
|
||||
sender = msg.sender.node
|
||||
self.agent.logger.info(
|
||||
"Received message: %s from %s",
|
||||
msg.body,
|
||||
sender,
|
||||
)
|
||||
|
||||
if sender == settings.agent_settings.bdi_core_agent_name:
|
||||
self.agent.logger.debug("Processing message from BDI Core Agent")
|
||||
await self._process_bdi_message(msg)
|
||||
else:
|
||||
self.agent.logger.debug("Message ignored (not from BDI Core Agent)")
|
||||
|
||||
async def _process_bdi_message(self, message: Message):
|
||||
"""
|
||||
Forwards user text to the LLM and replies with the generated text.
|
||||
"""
|
||||
user_text = message.body
|
||||
llm_response = await self._query_llm(user_text)
|
||||
await self._reply(llm_response)
|
||||
|
||||
async def _reply(self, msg: str):
|
||||
"""
|
||||
Sends a response message back to the BDI Core Agent.
|
||||
"""
|
||||
reply = Message(
|
||||
to=settings.agent_settings.bdi_core_agent_name + '@' + settings.agent_settings.host,
|
||||
body=msg
|
||||
)
|
||||
await self.send(reply)
|
||||
self.agent.logger.info("Reply sent to BDI Core Agent")
|
||||
|
||||
async def _query_llm(self, prompt: str) -> str:
|
||||
"""
|
||||
Sends a chat completion request to the local LLM service.
|
||||
|
||||
:param prompt: Input text prompt to pass to the LLM.
|
||||
:return: LLM-generated content or fallback message.
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||||
# Example dynamic content for future (optional)
|
||||
|
||||
instructions = LLMInstructions()
|
||||
developer_instruction = instructions.build_developer_instruction()
|
||||
|
||||
response = await client.post(
|
||||
settings.llm_settings.local_llm_url,
|
||||
headers={"Content-Type": "application/json"},
|
||||
json={
|
||||
"model": settings.llm_settings.local_llm_model,
|
||||
"messages": [
|
||||
{
|
||||
"role": "developer",
|
||||
"content": developer_instruction
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
],
|
||||
"temperature": 0.3
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
data: dict[str, Any] = response.json()
|
||||
return data.get("choices", [{}])[0].get(
|
||||
"message", {}
|
||||
).get("content", "No response")
|
||||
except httpx.HTTPError as err:
|
||||
self.agent.logger.error("HTTP error: %s", err)
|
||||
return "LLM service unavailable."
|
||||
except Exception as err:
|
||||
self.agent.logger.error("Unexpected error: %s", err)
|
||||
return "Error processing the request."
|
||||
|
||||
async def setup(self):
|
||||
"""
|
||||
Sets up the SPADE behaviour to filter and process messages from the
|
||||
BDI Core Agent.
|
||||
"""
|
||||
self.logger.info("LLMAgent setup complete")
|
||||
|
||||
behaviour = self.ReceiveMessageBehaviour()
|
||||
self.add_behaviour(behaviour)
|
||||
44
src/control_backend/agents/llm/llm_instructions.py
Normal file
44
src/control_backend/agents/llm/llm_instructions.py
Normal file
@@ -0,0 +1,44 @@
|
||||
class LLMInstructions:
|
||||
"""
|
||||
Defines structured instructions that are sent along with each request
|
||||
to the LLM to guide its behavior (norms, goals, etc.).
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def default_norms() -> str:
|
||||
return """
|
||||
Be friendly and respectful.
|
||||
Make the conversation feel natural and engaging.
|
||||
""".strip()
|
||||
|
||||
@staticmethod
|
||||
def default_goals() -> str:
|
||||
return """
|
||||
Try to learn the user's name during conversation.
|
||||
""".strip()
|
||||
|
||||
def __init__(self, norms: str | None = None, goals: str | None = None):
|
||||
self.norms = norms if norms is not None else self.default_norms()
|
||||
self.goals = goals if goals is not None else self.default_goals()
|
||||
|
||||
def build_developer_instruction(self) -> str:
|
||||
"""
|
||||
Builds a multi-line formatted instruction string for the LLM.
|
||||
Includes only non-empty structured fields.
|
||||
"""
|
||||
sections = [
|
||||
"You are a Pepper robot engaging in natural human conversation.",
|
||||
"Keep responses between 1–5 sentences, unless instructed otherwise.\n",
|
||||
]
|
||||
|
||||
if self.norms:
|
||||
sections.append("Norms to follow:")
|
||||
sections.append(self.norms)
|
||||
sections.append("")
|
||||
|
||||
if self.goals:
|
||||
sections.append("Goals to reach:")
|
||||
sections.append(self.goals)
|
||||
sections.append("")
|
||||
|
||||
return "\n".join(sections).strip()
|
||||
74
src/control_backend/agents/ri_command_agent.py
Normal file
74
src/control_backend/agents/ri_command_agent.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import json
|
||||
import logging
|
||||
from spade.agent import Agent
|
||||
from spade.behaviour import CyclicBehaviour
|
||||
import zmq
|
||||
|
||||
from control_backend.core.config import settings
|
||||
from control_backend.core.zmq_context import context
|
||||
from control_backend.schemas.ri_message import SpeechCommand
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RICommandAgent(Agent):
|
||||
subsocket: zmq.Socket
|
||||
pubsocket: zmq.Socket
|
||||
address = ""
|
||||
bind = False
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jid: str,
|
||||
password: str,
|
||||
port: int = 5222,
|
||||
verify_security: bool = False,
|
||||
address="tcp://localhost:0000",
|
||||
bind=False,
|
||||
):
|
||||
super().__init__(jid, password, port, verify_security)
|
||||
self.address = address
|
||||
self.bind = bind
|
||||
|
||||
class SendCommandsBehaviour(CyclicBehaviour):
|
||||
async def run(self):
|
||||
"""
|
||||
Run the command publishing loop indefinetely.
|
||||
"""
|
||||
assert self.agent is not None
|
||||
# Get a message internally (with topic command)
|
||||
topic, body = await self.agent.subsocket.recv_multipart()
|
||||
|
||||
# Try to get body
|
||||
try:
|
||||
body = json.loads(body)
|
||||
message = SpeechCommand.model_validate(body)
|
||||
|
||||
# Send to the robot.
|
||||
await self.agent.pubsocket.send_json(message.model_dump())
|
||||
except Exception as e:
|
||||
logger.error("Error processing message: %s", e)
|
||||
|
||||
async def setup(self):
|
||||
"""
|
||||
Setup the command agent
|
||||
"""
|
||||
logger.info("Setting up %s", self.jid)
|
||||
|
||||
# To the robot
|
||||
self.pubsocket = context.socket(zmq.PUB)
|
||||
if self.bind:
|
||||
self.pubsocket.bind(self.address)
|
||||
else:
|
||||
self.pubsocket.connect(self.address)
|
||||
|
||||
# Receive internal topics regarding commands
|
||||
self.subsocket = context.socket(zmq.SUB)
|
||||
self.subsocket.connect(settings.zmq_settings.internal_comm_address)
|
||||
self.subsocket.setsockopt(zmq.SUBSCRIBE, b"command")
|
||||
|
||||
# Add behaviour to our agent
|
||||
commands_behaviour = self.SendCommandsBehaviour()
|
||||
self.add_behaviour(commands_behaviour)
|
||||
|
||||
logger.info("Finished setting up %s", self.jid)
|
||||
165
src/control_backend/agents/ri_communication_agent.py
Normal file
165
src/control_backend/agents/ri_communication_agent.py
Normal file
@@ -0,0 +1,165 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from spade.agent import Agent
|
||||
from spade.behaviour import CyclicBehaviour
|
||||
import zmq
|
||||
|
||||
from control_backend.core.config import settings
|
||||
from control_backend.core.zmq_context import context
|
||||
from control_backend.schemas.message import Message
|
||||
from control_backend.agents.ri_command_agent import RICommandAgent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RICommunicationAgent(Agent):
|
||||
req_socket: zmq.Socket
|
||||
_address = ""
|
||||
_bind = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
jid: str,
|
||||
password: str,
|
||||
port: int = 5222,
|
||||
verify_security: bool = False,
|
||||
address="tcp://localhost:0000",
|
||||
bind=False,
|
||||
):
|
||||
super().__init__(jid, password, port, verify_security)
|
||||
self._address = address
|
||||
self._bind = bind
|
||||
|
||||
class ListenBehaviour(CyclicBehaviour):
|
||||
async def run(self):
|
||||
"""
|
||||
Run the listening (ping) loop indefinetely.
|
||||
"""
|
||||
assert self.agent is not None
|
||||
|
||||
# We need to listen and sent pings.
|
||||
message = {"endpoint": "ping", "data": {"id": "e.g. some reference id"}}
|
||||
await self.agent.req_socket.send_json(message)
|
||||
|
||||
# Wait up to three seconds for a reply:)
|
||||
try:
|
||||
message = await asyncio.wait_for(self.agent.req_socket.recv_json(), timeout=3.0)
|
||||
|
||||
# We didnt get a reply :(
|
||||
except asyncio.TimeoutError as e:
|
||||
logger.info("No ping retrieved in 3 seconds, killing myself.")
|
||||
self.kill()
|
||||
|
||||
logger.debug('Received message "%s"', message)
|
||||
if "endpoint" not in message:
|
||||
logger.error("No received endpoint in message, excepted ping endpoint.")
|
||||
return
|
||||
|
||||
# See what endpoint we received
|
||||
match message["endpoint"]:
|
||||
case "ping":
|
||||
await asyncio.sleep(1)
|
||||
case _:
|
||||
logger.info(
|
||||
"Received message with topic different than ping, while ping expected."
|
||||
)
|
||||
|
||||
async def setup(self, max_retries: int = 5):
|
||||
"""
|
||||
Try to setup the communication agent, we have 5 retries in case we dont have a response yet.
|
||||
"""
|
||||
logger.info("Setting up %s", self.jid)
|
||||
retries = 0
|
||||
|
||||
# Let's try a certain amount of times before failing connection
|
||||
while retries < max_retries:
|
||||
# Bind request socket
|
||||
self.req_socket = context.socket(zmq.REQ)
|
||||
if self._bind:
|
||||
self.req_socket.bind(self._address)
|
||||
else:
|
||||
self.req_socket.connect(self._address)
|
||||
|
||||
# Send our message and receive one back:)
|
||||
message = {"endpoint": "negotiate/ports", "data": None}
|
||||
await self.req_socket.send_json(message)
|
||||
|
||||
try:
|
||||
received_message = await asyncio.wait_for(self.req_socket.recv_json(), timeout=20.0)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"No connection established in 20 seconds (attempt %d/%d)",
|
||||
retries + 1,
|
||||
max_retries,
|
||||
)
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Unexpected error during negotiation: %s", e)
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
# Validate endpoint
|
||||
endpoint = received_message.get("endpoint")
|
||||
if endpoint != "negotiate/ports":
|
||||
# TODO: Should this send a message back?
|
||||
logger.error(
|
||||
"Invalid endpoint '%s' received (attempt %d/%d)",
|
||||
endpoint,
|
||||
retries + 1,
|
||||
max_retries,
|
||||
)
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
# At this point, we have a valid response
|
||||
try:
|
||||
for port_data in received_message["data"]:
|
||||
id = port_data["id"]
|
||||
port = port_data["port"]
|
||||
bind = port_data["bind"]
|
||||
|
||||
if not bind:
|
||||
addr = f"tcp://localhost:{port}"
|
||||
else:
|
||||
addr = f"tcp://*:{port}"
|
||||
|
||||
match id:
|
||||
case "main":
|
||||
if addr != self._address:
|
||||
if not bind:
|
||||
self.req_socket.connect(addr)
|
||||
else:
|
||||
self.req_socket.bind(addr)
|
||||
case "actuation":
|
||||
ri_commands_agent = RICommandAgent(
|
||||
settings.agent_settings.ri_command_agent_name
|
||||
+ "@"
|
||||
+ settings.agent_settings.host,
|
||||
settings.agent_settings.ri_command_agent_name,
|
||||
address=addr,
|
||||
bind=bind,
|
||||
)
|
||||
await ri_commands_agent.start()
|
||||
case _:
|
||||
logger.warning("Unhandled negotiation id: %s", id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error unpacking negotiation data: %s", e)
|
||||
retries += 1
|
||||
continue
|
||||
|
||||
# setup succeeded
|
||||
break
|
||||
|
||||
else:
|
||||
logger.error("Failed to set up RICommunicationAgent after %d retries", max_retries)
|
||||
return
|
||||
|
||||
# Set up ping behaviour
|
||||
listen_behaviour = self.ListenBehaviour()
|
||||
self.add_behaviour(listen_behaviour)
|
||||
logger.info("Finished setting up %s", self.jid)
|
||||
156
src/control_backend/agents/vad_agent.py
Normal file
156
src/control_backend/agents/vad_agent.py
Normal file
@@ -0,0 +1,156 @@
|
||||
import logging
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
import zmq
|
||||
import zmq.asyncio as azmq
|
||||
from spade.agent import Agent
|
||||
from spade.behaviour import CyclicBehaviour
|
||||
|
||||
from control_backend.core.config import settings
|
||||
from control_backend.core.zmq_context import context as zmq_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SocketPoller[T]:
|
||||
"""
|
||||
Convenience class for polling a socket for data with a timeout, persisting a zmq.Poller for
|
||||
multiple usages.
|
||||
"""
|
||||
|
||||
def __init__(self, socket: azmq.Socket, timeout_ms: int = 100):
|
||||
"""
|
||||
:param socket: The socket to poll and get data from.
|
||||
:param timeout_ms: A timeout in milliseconds to wait for data.
|
||||
"""
|
||||
self.socket = socket
|
||||
self.poller = zmq.Poller()
|
||||
self.poller.register(self.socket, zmq.POLLIN)
|
||||
self.timeout_ms = timeout_ms
|
||||
|
||||
async def poll(self, timeout_ms: int | None = None) -> T | None:
|
||||
"""
|
||||
Get data from the socket, or None if the timeout is reached.
|
||||
|
||||
:param timeout_ms: If given, the timeout. Otherwise, `self.timeout_ms` is used.
|
||||
:return: Data from the socket or None.
|
||||
"""
|
||||
timeout_ms = timeout_ms or self.timeout_ms
|
||||
socks = dict(self.poller.poll(timeout_ms))
|
||||
if socks.get(self.socket) == zmq.POLLIN:
|
||||
return await self.socket.recv()
|
||||
return None
|
||||
|
||||
|
||||
class Streaming(CyclicBehaviour):
|
||||
def __init__(self, audio_in_socket: azmq.Socket, audio_out_socket: azmq.Socket):
|
||||
super().__init__()
|
||||
self.audio_in_poller = SocketPoller[bytes](audio_in_socket)
|
||||
self.model, _ = torch.hub.load(
|
||||
repo_or_dir="snakers4/silero-vad", model="silero_vad", force_reload=False
|
||||
)
|
||||
self.audio_out_socket = audio_out_socket
|
||||
|
||||
self.audio_buffer = np.array([], dtype=np.float32)
|
||||
self.i_since_speech = 100 # Used to allow small pauses in speech
|
||||
|
||||
async def run(self) -> None:
|
||||
data = await self.audio_in_poller.poll()
|
||||
if data is None:
|
||||
if len(self.audio_buffer) > 0:
|
||||
logger.debug("No audio data received. Discarding buffer until new data arrives.")
|
||||
self.audio_buffer = np.array([], dtype=np.float32)
|
||||
self.i_since_speech = 100
|
||||
return
|
||||
|
||||
# copy otherwise Torch will be sad that it's immutable
|
||||
chunk = np.frombuffer(data, dtype=np.float32).copy()
|
||||
prob = self.model(torch.from_numpy(chunk), 16000).item()
|
||||
|
||||
if prob > 0.5:
|
||||
if self.i_since_speech > 3:
|
||||
logger.debug("Speech started.")
|
||||
self.audio_buffer = np.append(self.audio_buffer, chunk)
|
||||
self.i_since_speech = 0
|
||||
return
|
||||
self.i_since_speech += 1
|
||||
|
||||
# prob < 0.5, so speech maybe ended. Wait a bit more before to be more certain
|
||||
if self.i_since_speech <= 3:
|
||||
self.audio_buffer = np.append(self.audio_buffer, chunk)
|
||||
return
|
||||
|
||||
# Speech probably ended. Make sure we have a usable amount of data.
|
||||
if len(self.audio_buffer) >= 3 * len(chunk):
|
||||
logger.debug("Speech ended.")
|
||||
await self.audio_out_socket.send(self.audio_buffer[: -2 * len(chunk)].tobytes())
|
||||
|
||||
# At this point, we know that the speech has ended.
|
||||
# Prepend the last chunk that had no speech, for a more fluent boundary
|
||||
self.audio_buffer = chunk
|
||||
|
||||
|
||||
class VADAgent(Agent):
|
||||
"""
|
||||
An agent which listens to an audio stream, does Voice Activity Detection (VAD), and sends
|
||||
fragments with detected speech to other agents over ZeroMQ.
|
||||
"""
|
||||
|
||||
def __init__(self, audio_in_address: str, audio_in_bind: bool):
|
||||
jid = settings.agent_settings.vad_agent_name + "@" + settings.agent_settings.host
|
||||
super().__init__(jid, settings.agent_settings.vad_agent_name)
|
||||
|
||||
self.audio_in_address = audio_in_address
|
||||
self.audio_in_bind = audio_in_bind
|
||||
|
||||
self.audio_in_socket: azmq.Socket | None = None
|
||||
self.audio_out_socket: azmq.Socket | None = None
|
||||
|
||||
async def stop(self):
|
||||
"""
|
||||
Stop listening to audio, stop publishing audio, close sockets.
|
||||
"""
|
||||
if self.audio_in_socket is not None:
|
||||
self.audio_in_socket.close()
|
||||
self.audio_in_socket = None
|
||||
if self.audio_out_socket is not None:
|
||||
self.audio_out_socket.close()
|
||||
self.audio_out_socket = None
|
||||
return await super().stop()
|
||||
|
||||
def _connect_audio_in_socket(self):
|
||||
self.audio_in_socket = zmq_context.socket(zmq.SUB)
|
||||
self.audio_in_socket.setsockopt_string(zmq.SUBSCRIBE, "")
|
||||
if self.audio_in_bind:
|
||||
self.audio_in_socket.bind(self.audio_in_address)
|
||||
else:
|
||||
self.audio_in_socket.connect(self.audio_in_address)
|
||||
self.audio_in_poller = SocketPoller[bytes](self.audio_in_socket)
|
||||
|
||||
def _connect_audio_out_socket(self) -> int | None:
|
||||
"""Returns the port bound, or None if binding failed."""
|
||||
try:
|
||||
self.audio_out_socket = zmq_context.socket(zmq.PUB)
|
||||
return self.audio_out_socket.bind_to_random_port("tcp://*", max_tries=100)
|
||||
except zmq.ZMQBindError:
|
||||
logger.error("Failed to bind an audio output socket after 100 tries.")
|
||||
self.audio_out_socket = None
|
||||
return None
|
||||
|
||||
async def setup(self):
|
||||
logger.info("Setting up %s", self.jid)
|
||||
|
||||
self._connect_audio_in_socket()
|
||||
|
||||
audio_out_port = self._connect_audio_out_socket()
|
||||
if audio_out_port is None:
|
||||
await self.stop()
|
||||
return
|
||||
|
||||
streaming = Streaming(self.audio_in_socket, self.audio_out_socket)
|
||||
self.add_behaviour(streaming)
|
||||
|
||||
# ... start agents dependent on the output audio fragments here
|
||||
|
||||
logger.info("Finished setting up %s", self.jid)
|
||||
22
src/control_backend/api/v1/endpoints/command.py
Normal file
22
src/control_backend/api/v1/endpoints/command.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
|
||||
from zmq import Socket
|
||||
|
||||
from control_backend.schemas.ri_message import SpeechCommand, RIEndpoint
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/command", status_code=202)
|
||||
async def receive_command(command: SpeechCommand, request: Request):
|
||||
# Validate and retrieve data.
|
||||
SpeechCommand.model_validate(command)
|
||||
topic = b"command"
|
||||
pub_socket: Socket = request.app.state.internal_comm_socket
|
||||
pub_socket.send_multipart([topic, command.model_dump_json().encode()])
|
||||
|
||||
|
||||
return {"status": "Command received"}
|
||||
@@ -1,6 +1,6 @@
|
||||
from fastapi import APIRouter, Request
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from zmq import Socket
|
||||
|
||||
from control_backend.schemas.message import Message
|
||||
@@ -9,6 +9,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/message", status_code=202)
|
||||
async def receive_message(message: Message, request: Request):
|
||||
logger.info("Received message: %s", message.message)
|
||||
|
||||
@@ -2,7 +2,8 @@ from fastapi import APIRouter, Request
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# TODO: implement
|
||||
@router.get("/sse")
|
||||
async def sse(request: Request):
|
||||
pass
|
||||
pass
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
from fastapi.routing import APIRouter
|
||||
|
||||
from control_backend.api.v1.endpoints import message, sse
|
||||
from control_backend.api.v1.endpoints import message, sse, command
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(
|
||||
message.router,
|
||||
tags=["Messages"]
|
||||
)
|
||||
api_router.include_router(message.router, tags=["Messages"])
|
||||
|
||||
api_router.include_router(
|
||||
sse.router,
|
||||
tags=["SSE"]
|
||||
)
|
||||
api_router.include_router(sse.router, tags=["SSE"])
|
||||
|
||||
api_router.include_router(command.router, tags=["Commands"])
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
from re import L
|
||||
from pydantic import BaseModel
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ZMQSettings(BaseModel):
|
||||
internal_comm_address: str = "tcp://localhost:5560"
|
||||
|
||||
|
||||
class AgentSettings(BaseModel):
|
||||
host: str = "localhost"
|
||||
host: str = "localhost"
|
||||
bdi_core_agent_name: str = "bdi_core"
|
||||
belief_collector_agent_name: str = "belief_collector"
|
||||
vad_agent_name: str = "vad_agent"
|
||||
llm_agent_name: str = "llm_agent"
|
||||
test_agent_name: str = "test_agent"
|
||||
|
||||
ri_communication_agent_name: str = "ri_communication_agent"
|
||||
ri_command_agent_name: str = "ri_command_agent"
|
||||
|
||||
|
||||
class LLMSettings(BaseModel):
|
||||
local_llm_url: str = "http://145.107.82.68:1234/v1/chat/completions"
|
||||
local_llm_model: str = "openai/gpt-oss-120b"
|
||||
|
||||
class Settings(BaseSettings):
|
||||
app_title: str = "PepperPlus"
|
||||
|
||||
@@ -19,7 +30,9 @@ class Settings(BaseSettings):
|
||||
zmq_settings: ZMQSettings = ZMQSettings()
|
||||
|
||||
agent_settings: AgentSettings = AgentSettings()
|
||||
|
||||
|
||||
llm_settings: LLMSettings = LLMSettings()
|
||||
|
||||
model_config = SettingsConfigDict(env_file=".env")
|
||||
|
||||
settings = Settings()
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
# Standard library imports
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
# External imports
|
||||
import contextlib
|
||||
import logging
|
||||
|
||||
import zmq
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
import logging
|
||||
from spade.agent import Agent, Message
|
||||
from spade.behaviour import OneShotBehaviour
|
||||
import zmq
|
||||
|
||||
# Internal imports
|
||||
from control_backend.agents.ri_communication_agent import RICommunicationAgent
|
||||
from control_backend.agents.bdi.bdi_core import BDICoreAgent
|
||||
from control_backend.agents.vad_agent import VADAgent
|
||||
from control_backend.agents.llm.llm import LLMAgent
|
||||
from control_backend.agents.bdi.text_extractor import TBeliefExtractor
|
||||
from control_backend.agents.bdi.test_agent import SenderAgent
|
||||
from control_backend.api.v1.router import api_router
|
||||
from control_backend.core.config import AgentSettings, settings
|
||||
from control_backend.core.config import settings
|
||||
from control_backend.core.zmq_context import context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("%s starting up.", app.title)
|
||||
@@ -33,29 +34,50 @@ async def lifespan(app: FastAPI):
|
||||
app.state.internal_comm_socket = internal_comm_socket
|
||||
logger.info("Internal publishing socket bound to %s", internal_comm_socket)
|
||||
|
||||
|
||||
# Initiate agents
|
||||
bdi_core = BDICoreAgent(settings.agent_settings.bdi_core_agent_name + '@' + settings.agent_settings.host, "placeholder", "src/control_backend/agents/bdi/rules.asl")
|
||||
ri_communication_agent = RICommunicationAgent(
|
||||
settings.agent_settings.ri_communication_agent_name + "@" + settings.agent_settings.host,
|
||||
settings.agent_settings.ri_communication_agent_name,
|
||||
address="tcp://*:5555",
|
||||
bind=True,
|
||||
)
|
||||
await ri_communication_agent.start()
|
||||
|
||||
llm_agent = LLMAgent(settings.agent_settings.llm_agent_name + '@' + settings.agent_settings.host,
|
||||
settings.agent_settings.llm_agent_name)
|
||||
await llm_agent.start()
|
||||
|
||||
bdi_core = BDICoreAgent(settings.agent_settings.bdi_core_agent_name + '@' + settings.agent_settings.host,
|
||||
settings.agent_settings.bdi_core_agent_name, "src/control_backend/agents/bdi/rules.asl")
|
||||
await bdi_core.start()
|
||||
|
||||
text_belief_extractor = TBeliefExtractor(settings.agent_settings.belief_collector_agent_name + '@' + settings.agent_settings.host, "placehodler")
|
||||
await text_belief_extractor.start()
|
||||
|
||||
test_agent = SenderAgent(settings.agent_settings.test_agent_name + '@' + settings.agent_settings.host, "placeholder")
|
||||
await test_agent.start()
|
||||
|
||||
|
||||
_temp_vad_agent = VADAgent("tcp://localhost:5558", False)
|
||||
await _temp_vad_agent.start()
|
||||
|
||||
yield
|
||||
|
||||
|
||||
logger.info("%s shutting down.", app.title)
|
||||
|
||||
|
||||
# if __name__ == "__main__":
|
||||
app = FastAPI(title=settings.app_title, lifespan=lifespan)
|
||||
|
||||
# This middleware allows other origins to communicate with us
|
||||
app.add_middleware(
|
||||
CORSMiddleware, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
|
||||
allow_origins=[settings.ui_url], # address of our UI application
|
||||
allow_methods=["*"], # GET, POST, etc.
|
||||
CORSMiddleware, # https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS
|
||||
allow_origins=[settings.ui_url], # address of our UI application
|
||||
allow_methods=["*"], # GET, POST, etc.
|
||||
)
|
||||
|
||||
app.include_router(api_router, prefix="") # TODO: make prefix /api/v1
|
||||
app.include_router(api_router, prefix="") # TODO: make prefix /api/v1
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class Message(BaseModel):
|
||||
message: str
|
||||
message: str
|
||||
|
||||
20
src/control_backend/schemas/ri_message.py
Normal file
20
src/control_backend/schemas/ri_message.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from enum import Enum
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field, ValidationError
|
||||
|
||||
|
||||
class RIEndpoint(str, Enum):
|
||||
SPEECH = "actuate/speech"
|
||||
PING = "ping"
|
||||
NEGOTIATE_PORTS = "negotiate/ports"
|
||||
|
||||
|
||||
class RIMessage(BaseModel):
|
||||
endpoint: RIEndpoint
|
||||
data: Any
|
||||
|
||||
|
||||
class SpeechCommand(RIMessage):
|
||||
endpoint: RIEndpoint = RIEndpoint(RIEndpoint.SPEECH)
|
||||
data: str
|
||||
Reference in New Issue
Block a user