feat: make the pipeline work with Program and AgentSpeak

ref: N25B-429
This commit is contained in:
Twirre Meulenbelt
2026-01-06 15:26:44 +01:00
parent a357b6990b
commit 3406e9ac2f
7 changed files with 88 additions and 38 deletions

View File

@@ -42,9 +42,9 @@ class AgentSpeakGenerator:
def generate(self, program: Program) -> str:
self._asp = AstProgram()
self._asp.rules.append(AstRule(AstLiteral("phase", [AstString("start")])))
self._asp.rules.append(AstRule(self._astify(program.phases[0])))
self._add_keyword_inference()
self._add_response_goal()
self._add_default_plans()
self._process_phases(program.phases)
@@ -66,11 +66,16 @@ class AgentSpeakGenerator:
)
)
def _add_response_goal(self):
def _add_default_plans(self):
self._add_reply_with_goal_plan()
self._add_say_plan()
self._add_reply_plan()
def _add_reply_with_goal_plan(self):
self._asp.plans.append(
AstPlan(
TriggerType.ADDED_GOAL,
AstLiteral("generate_response_with_goal", [AstVar("Goal")]),
AstLiteral("reply_with_goal", [AstVar("Goal")]),
[AstLiteral("user_said", [AstVar("Message")])],
[
AstStatement(StatementType.ADD_BELIEF, AstLiteral("responded_this_turn")),
@@ -91,12 +96,59 @@ class AgentSpeakGenerator:
)
)
def _add_say_plan(self):
self._asp.plans.append(
AstPlan(
TriggerType.ADDED_GOAL,
AstLiteral("say", [AstVar("Text")]),
[],
[
AstStatement(StatementType.ADD_BELIEF, AstLiteral("responded_this_turn")),
AstStatement(StatementType.DO_ACTION, AstLiteral("say", [AstVar("Text")])),
],
)
)
def _add_reply_plan(self):
self._asp.plans.append(
AstPlan(
TriggerType.ADDED_GOAL,
AstLiteral("reply"),
[AstLiteral("user_said", [AstVar("Message")])],
[
AstStatement(StatementType.ADD_BELIEF, AstLiteral("responded_this_turn")),
AstStatement(
StatementType.DO_ACTION,
AstLiteral(
"findall",
[AstVar("Norm"), AstLiteral("norm", [AstVar("Norm")]), AstVar("Norms")],
),
),
AstStatement(
StatementType.DO_ACTION,
AstLiteral("reply", [AstVar("Message"), AstVar("Norms")]),
),
],
)
)
def _process_phases(self, phases: list[Phase]) -> None:
for curr_phase, next_phase in zip([None] + phases, phases + [None], strict=True):
if curr_phase:
self._process_phase(curr_phase)
self._add_phase_transition(curr_phase, next_phase)
# End phase behavior
# When deleting this, the entire `reply` plan and action can be deleted
self._asp.plans.append(
AstPlan(
type=TriggerType.ADDED_BELIEF,
trigger_literal=AstLiteral("user_said", [AstVar("Message")]),
context=[AstLiteral("phase", [AstString("end")])],
body=[AstStatement(StatementType.ACHIEVE_GOAL, AstLiteral("reply"))],
)
)
def _process_phase(self, phase: Phase) -> None:
for norm in phase.norms:
self._process_norm(norm, phase)
@@ -112,14 +164,14 @@ class AgentSpeakGenerator:
self._process_trigger(trigger, phase)
def _add_phase_transition(self, from_phase: Phase | None, to_phase: Phase | None) -> None:
from_phase_ast = (
self._astify(from_phase) if from_phase else AstLiteral("phase", [AstString("start")])
)
if from_phase is None:
return
from_phase_ast = self._astify(from_phase)
to_phase_ast = (
self._astify(to_phase) if to_phase else AstLiteral("phase", [AstString("end")])
)
context = [from_phase_ast]
context = [from_phase_ast, ~AstLiteral("responded_this_turn")]
if from_phase and from_phase.goals:
context.append(self._astify(from_phase.goals[-1], achieved=True))
@@ -221,14 +273,10 @@ class AgentSpeakGenerator:
def _step_to_statement(self, step: PlanElement) -> AstStatement:
match step:
case Goal() as g:
return AstStatement(StatementType.ACHIEVE_GOAL, self._astify(g))
case SpeechAction() | GestureAction() as a:
case Goal() | SpeechAction() | LLMAction() as a:
return AstStatement(StatementType.ACHIEVE_GOAL, self._astify(a))
case GestureAction() as a:
return AstStatement(StatementType.DO_ACTION, self._astify(a))
case LLMAction() as la:
return AstStatement(
StatementType.ACHIEVE_GOAL, self._astify(la)
) # LLM action is a goal in ASL
# TODO: separate handling of keyword and others
def _process_trigger(self, trigger: Trigger, phase: Phase) -> None:
@@ -318,7 +366,7 @@ class AgentSpeakGenerator:
@_astify.register
def _(self, la: LLMAction) -> AstExpression:
return AstLiteral("generate_response_with_goal", [AstString(la.goal)])
return AstLiteral("reply_with_goal", [AstString(la.goal)])
@staticmethod
def _slugify_str(text: str) -> str:

View File

@@ -48,6 +48,7 @@ class BDICoreAgent(BaseAgent):
# Deep copy because we don't actually want to modify the standard actions globally
self.actions = copy.deepcopy(agentspeak.stdlib.actions)
self._wake_bdi_loop = asyncio.Event()
self._bdi_loop_task = None
async def setup(self) -> None:
"""
@@ -64,7 +65,7 @@ class BDICoreAgent(BaseAgent):
await self._load_asl()
# Start the BDI cycle loop
self.add_behavior(self._bdi_loop())
self._bdi_loop_task = self.add_behavior(self._bdi_loop())
self._wake_bdi_loop.set()
self.logger.debug("Setup complete.")
@@ -72,7 +73,7 @@ class BDICoreAgent(BaseAgent):
"""
Load and parse the AgentSpeak source file.
"""
file_name = file_name or "src/control_backend/agents/bdi/rules.asl"
file_name = file_name or "src/control_backend/agents/bdi/default_behavior.asl"
try:
with open(file_name) as source:
@@ -135,7 +136,10 @@ class BDICoreAgent(BaseAgent):
# New agentspeak file
if msg.thread == "new_program":
if self._bdi_loop_task:
self._bdi_loop_task.cancel()
await self._load_asl(msg.body)
self.add_behavior(self._bdi_loop())
# The message was not a belief, handle special cases based on sender
match msg.sender:
@@ -246,20 +250,18 @@ class BDICoreAgent(BaseAgent):
the function expects (which will be located in `term.args`).
"""
@self.actions.add(".reply", 3)
@self.actions.add(".reply", 2)
def _reply(agent: "BDICoreAgent", term, intention):
"""
Let the LLM generate a response to a user's utterance with the current norms and goals.
"""
message_text = agentspeak.grounded(term.args[0], intention.scope)
norms = agentspeak.grounded(term.args[1], intention.scope)
goals = agentspeak.grounded(term.args[2], intention.scope)
self.logger.debug("Norms: %s", norms)
self.logger.debug("Goals: %s", goals)
self.logger.debug("User text: %s", message_text)
asyncio.create_task(self._send_to_llm(str(message_text), str(norms), str(goals)))
self.add_behavior(self._send_to_llm(str(message_text), str(norms), ""))
yield
@self.actions.add(".reply_with_goal", 3)
@@ -278,7 +280,7 @@ class BDICoreAgent(BaseAgent):
norms,
goal,
)
# asyncio.create_task(self._send_to_llm(str(message_text), norms, str(goal)))
self.add_behavior(self._send_to_llm(str(message_text), str(norms), str(goal)))
yield
@self.actions.add(".say", 1)
@@ -290,13 +292,14 @@ class BDICoreAgent(BaseAgent):
self.logger.debug('"say" action called with text=%s', message_text)
# speech_command = SpeechCommand(data=message_text)
# speech_message = InternalMessage(
# to=settings.agent_settings.robot_speech_name,
# sender=settings.agent_settings.bdi_core_name,
# body=speech_command.model_dump_json(),
# )
# asyncio.create_task(agent.send(speech_message))
speech_command = SpeechCommand(data=message_text)
speech_message = InternalMessage(
to=settings.agent_settings.robot_speech_name,
sender=settings.agent_settings.bdi_core_name,
body=speech_command.model_dump_json(),
)
# TODO: add to conversation history
self.add_behavior(self.send(speech_message))
yield
@self.actions.add(".gesture", 2)

View File

@@ -101,7 +101,7 @@ class BDIBeliefCollectorAgent(BaseAgent):
:return: A Belief object if the input is valid or None.
"""
try:
return Belief(name=name, arguments=arguments)
return Belief(name=name, arguments=arguments, replace=name == "user_said")
except ValidationError:
return None

View File

@@ -0,0 +1,5 @@
norms("").
+user_said(Message) : norms(Norms) <-
-user_said(Message);
.reply(Message, Norms).

View File

@@ -1,6 +0,0 @@
norms("").
goals("").
+user_said(Message) : norms(Norms) & goals(Goals) <-
-user_said(Message);
.reply(Message, Norms, Goals).

View File

@@ -194,7 +194,7 @@ class Phase(ProgramElement):
"""
name: str = ""
norms: list[Norm]
norms: list[BasicNorm | ConditionalNorm]
goals: list[Goal]
triggers: list[Trigger]