|
1 | 1 | import os |
2 | 2 | import re |
3 | 3 | from collections import defaultdict |
| 4 | +from collections.abc import AsyncIterator |
4 | 5 | from pathlib import Path |
5 | 6 | from typing import Any, Callable |
6 | 7 | from dotenv import load_dotenv |
@@ -746,6 +747,173 @@ def go(self, prompt: str): |
746 | 747 |
|
747 | 748 | return self.log, input.content |
748 | 749 |
|
| 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 | + |
749 | 917 | def _clear_execution_plots(self): |
750 | 918 | """ |
751 | 919 | Clear execution plots before new execution. |
|
0 commit comments