Skip to content

Commit a81c052

Browse files
committed
feat: Implement streaming event architecture with AgentEvent and EventType classes
1 parent 7f79e01 commit a81c052

4 files changed

Lines changed: 553 additions & 1 deletion

File tree

BaseAgent/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
"""
44

55
from .base_agent import BaseAgent
6+
from .events import AgentEvent, EventType
67

78
__version__ = "0.1.0"
89
__author__ = "BaseAgent Contributors"
9-
__all__ = ["BaseAgent"]
10+
__all__ = ["BaseAgent", "AgentEvent", "EventType"]

BaseAgent/base_agent.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import os
22
import re
33
from collections import defaultdict
4+
from collections.abc import AsyncIterator
45
from pathlib import Path
56
from typing import Any, Callable
67
from dotenv import load_dotenv
@@ -746,6 +747,173 @@ def go(self, prompt: str):
746747

747748
return self.log, input.content
748749

750+
async def run_stream(
751+
self,
752+
prompt: str,
753+
event_types: set | None = None,
754+
) -> AsyncIterator["AgentEvent"]:
755+
"""Stream typed AgentEvent objects as the agent executes.
756+
757+
Uses LangGraph's ``astream_events`` (v2) under the hood, mapping each
758+
node lifecycle event to a typed AgentEvent. The caller can optionally
759+
filter to a subset of event types.
760+
761+
Args:
762+
prompt: The user task to execute.
763+
event_types: Optional set of EventType values to include. When
764+
``None`` (default), all events are yielded.
765+
766+
Yields:
767+
AgentEvent objects in emission order.
768+
769+
Example::
770+
771+
async for event in agent.run_stream("What is 2+2?"):
772+
print(event.event_type, event.content[:80])
773+
774+
# Filter to only final answers and errors:
775+
from BaseAgent.events import EventType
776+
async for event in agent.run_stream(
777+
"Analyse data.csv",
778+
event_types={EventType.FINAL_ANSWER, EventType.ERROR},
779+
):
780+
print(event.to_json())
781+
"""
782+
from BaseAgent.events import AgentEvent # local import avoids top-level cycle risk
783+
784+
self.critic_count = 0
785+
self.user_task = prompt
786+
787+
inputs = {"input": [HumanMessage(content=prompt)], "next_step": None}
788+
config = {"recursion_limit": 500, "configurable": {"thread_id": 42}}
789+
790+
async for raw_event in self.app.astream_events(inputs, config=config, version="v2"):
791+
agent_event = self._map_langgraph_event(raw_event)
792+
if agent_event is None:
793+
continue
794+
if event_types is not None and agent_event.event_type not in event_types:
795+
continue
796+
yield agent_event
797+
798+
def _map_langgraph_event(self, event: dict) -> "AgentEvent | None":
799+
"""Map a raw LangGraph v2 event dict to an AgentEvent.
800+
801+
Processes ``on_chain_start`` and ``on_chain_end`` events for the three
802+
core graph nodes (``retrieve``, ``generate``, ``execute``). All other
803+
events return ``None`` and are silently dropped.
804+
805+
Node → event mapping:
806+
- ``retrieve`` start → RETRIEVAL_START
807+
- ``retrieve`` end → RETRIEVAL_COMPLETE
808+
- ``generate`` end → THINKING | FINAL_ANSWER | ERROR (based on tag)
809+
- ``execute`` start → CODE_EXECUTING (with code content)
810+
- ``execute`` end → CODE_RESULT (with observation content)
811+
"""
812+
from BaseAgent.events import AgentEvent, EventType
813+
from langchain_core.messages import AIMessage
814+
815+
event_name = event.get("event", "")
816+
node_name = event.get("metadata", {}).get("langgraph_node", "")
817+
818+
if node_name not in {"retrieve", "generate", "execute", "self_critic"}:
819+
return None
820+
821+
if event_name == "on_chain_start":
822+
if node_name == "retrieve":
823+
return AgentEvent(
824+
event_type=EventType.RETRIEVAL_START,
825+
content="Starting resource retrieval",
826+
node_name=node_name,
827+
)
828+
829+
if node_name == "execute":
830+
# Parse the code from the state that was passed into this node
831+
state = event.get("data", {}).get("input", {})
832+
if isinstance(state, dict):
833+
messages = state.get("input", [])
834+
for msg in reversed(messages):
835+
if isinstance(msg, AIMessage):
836+
code_match = re.search(
837+
r"<execute>(.*?)</execute>", msg.content, re.DOTALL
838+
)
839+
if code_match:
840+
return AgentEvent(
841+
event_type=EventType.CODE_EXECUTING,
842+
content=code_match.group(1).strip(),
843+
node_name=node_name,
844+
)
845+
return None
846+
847+
elif event_name == "on_chain_end":
848+
output = event.get("data", {}).get("output", {})
849+
if not isinstance(output, dict):
850+
return None
851+
messages = output.get("input", [])
852+
if not messages:
853+
return None
854+
855+
if node_name == "retrieve":
856+
return AgentEvent(
857+
event_type=EventType.RETRIEVAL_COMPLETE,
858+
content="Resource retrieval complete",
859+
node_name=node_name,
860+
)
861+
862+
if node_name == "generate":
863+
# The generate node appends the raw LLM response as an AIMessage.
864+
# Find the most-recently appended AIMessage and parse its tags.
865+
for msg in reversed(messages):
866+
if not isinstance(msg, AIMessage):
867+
continue
868+
content = msg.content
869+
870+
answer_match = re.search(
871+
r"<solution>(.*?)</solution>", content, re.DOTALL
872+
)
873+
if answer_match:
874+
return AgentEvent(
875+
event_type=EventType.FINAL_ANSWER,
876+
content=answer_match.group(1).strip(),
877+
node_name=node_name,
878+
)
879+
880+
think_match = re.search(
881+
r"<think>(.*?)</think>", content, re.DOTALL
882+
)
883+
if think_match:
884+
return AgentEvent(
885+
event_type=EventType.THINKING,
886+
content=think_match.group(1).strip(),
887+
node_name=node_name,
888+
)
889+
890+
# Parsing-error messages injected by the node itself
891+
if "terminated due to" in content or "There are no tags" in content:
892+
return AgentEvent(
893+
event_type=EventType.ERROR,
894+
content=content,
895+
node_name=node_name,
896+
)
897+
break
898+
899+
if node_name == "execute":
900+
# The execute node appends <observation>...</observation> as an AIMessage
901+
for msg in reversed(messages):
902+
if not isinstance(msg, AIMessage):
903+
continue
904+
obs_match = re.search(
905+
r"<observation>(.*?)</observation>", msg.content, re.DOTALL
906+
)
907+
if obs_match:
908+
return AgentEvent(
909+
event_type=EventType.CODE_RESULT,
910+
content=obs_match.group(1).strip(),
911+
node_name=node_name,
912+
)
913+
break
914+
915+
return None
916+
749917
def _clear_execution_plots(self):
750918
"""
751919
Clear execution plots before new execution.

BaseAgent/events.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
"""Typed event schema for BaseAgent streaming API.
2+
3+
AgentEvent objects are yielded by BaseAgent.run_stream() as the agent executes.
4+
Each event has a typed EventType so a frontend can render different phases
5+
(thinking, code execution, results, errors, final answers) distinctly.
6+
7+
Typical event sequence for a task that requires code execution:
8+
9+
RETRIEVAL_START (if use_tool_retriever=True)
10+
RETRIEVAL_COMPLETE (if use_tool_retriever=True)
11+
THINKING (agent reasoning block)
12+
CODE_EXECUTING (code about to run)
13+
CODE_RESULT (execution output)
14+
THINKING (next reasoning step, if any)
15+
...
16+
FINAL_ANSWER (solution tag found, agent terminates)
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
from dataclasses import asdict, dataclass, field
23+
from datetime import datetime, timezone
24+
from enum import Enum
25+
from typing import Any
26+
27+
28+
class EventType(str, Enum):
29+
"""Types of events emitted by the agent during execution."""
30+
31+
THINKING = "thinking"
32+
"""Agent reasoning block (<think> tag content)."""
33+
34+
CODE_EXECUTING = "code_executing"
35+
"""Code block about to be executed (<execute> tag content)."""
36+
37+
CODE_RESULT = "code_result"
38+
"""Output returned from code execution (<observation> tag content)."""
39+
40+
TOOL_SELECTED = "tool_selected"
41+
"""A specific tool was selected for use (emitted by callers that parse tool calls)."""
42+
43+
ERROR = "error"
44+
"""An error occurred during generation or execution."""
45+
46+
FINAL_ANSWER = "final_answer"
47+
"""Agent produced a final answer (<solution> tag content)."""
48+
49+
RETRIEVAL_START = "retrieval_start"
50+
"""Tool retriever started selecting resources."""
51+
52+
RETRIEVAL_COMPLETE = "retrieval_complete"
53+
"""Tool retriever finished selecting resources."""
54+
55+
56+
@dataclass
57+
class AgentEvent:
58+
"""A typed event emitted during agent execution.
59+
60+
Attributes:
61+
event_type: The kind of event (see EventType).
62+
content: Human-readable payload for this event — code text, execution
63+
output, reasoning text, answer text, or an error message.
64+
node_name: The LangGraph node that produced this event
65+
(``"retrieve"``, ``"generate"``, ``"execute"``, etc.).
66+
timestamp: ISO 8601 UTC timestamp, auto-populated at creation.
67+
metadata: Optional key-value pairs for additional context (e.g.
68+
model name, token counts, language of the code block).
69+
"""
70+
71+
event_type: EventType
72+
content: str
73+
node_name: str = ""
74+
timestamp: str = field(
75+
default_factory=lambda: datetime.now(timezone.utc).isoformat()
76+
)
77+
metadata: dict[str, Any] = field(default_factory=dict)
78+
79+
def to_dict(self) -> dict[str, Any]:
80+
"""Return a JSON-serialisable dict.
81+
82+
The ``event_type`` field is serialised to its string value so the dict
83+
can be passed directly to ``json.dumps`` without a custom encoder.
84+
"""
85+
d = asdict(self)
86+
d["event_type"] = self.event_type.value
87+
return d
88+
89+
def to_json(self) -> str:
90+
"""Return a compact JSON string suitable for SSE or WebSocket payloads."""
91+
return json.dumps(self.to_dict())

0 commit comments

Comments
 (0)