Skip to content

Commit 134d88e

Browse files
committed
feat(agui): introduce AG-UI event normalizer and enhance tool call event handling
adds AguiEventNormalizer to ensure proper event ordering and consistency for tool call events. introduces tool_call_started_set to prevent duplicate TOOL_CALL_START events and ensures correct AG-UI protocol sequence: TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → TOOL_CALL_RESULT. also removes deprecated test file and updates test coverage. fixes issue where streaming tool calls could send duplicate START events and ensures proper event sequencing for AG-UI protocol compliance. test(agui): update test coverage for new event normalization logic feat(agui): 引入AG-UI事件规范化器并增强工具调用事件处理 添加AguiEventNormalizer以确保工具调用事件的正确排序和一致性。 引入tool_call_started_set来防止重复的TOOL_CALL_START事件, 并确保正确的AG-UI协议序列:TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END → TOOL_CALL_RESULT。 同时删除了废弃的测试文件并更新了测试覆盖。 修复了流式工具调用可能发送重复START事件的问题, 并确保AG-UI协议一致性。 test(agui): 为新的事件规范化逻辑更新测试覆盖 test(agui): 为新的事件规范化逻辑更新测试覆盖 Change-Id: I9c73b3ba467825be47f4433a65ec03ab012a77cc Signed-off-by: OhYee <oyohyee@oyohyee.com>
1 parent 5f7db59 commit 134d88e

6 files changed

Lines changed: 1206 additions & 936 deletions

File tree

agentrun/integration/langgraph/agent_converter.py

Lines changed: 84 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -499,6 +499,7 @@ def _convert_stream_values_event(
499499
def _convert_astream_events_event(
500500
event_dict: Dict[str, Any],
501501
tool_call_id_map: Optional[Dict[int, str]] = None,
502+
tool_call_started_set: Optional[set] = None,
502503
) -> Iterator[Union[AgentResult, str]]:
503504
"""转换 astream_events 格式的单个事件
504505
@@ -507,6 +508,8 @@ def _convert_astream_events_event(
507508
tool_call_id_map: 可选的 index -> tool_call_id 映射字典。
508509
在流式工具调用中,第一个 chunk 有 id,后续只有 index。
509510
此映射用于确保所有 chunk 使用一致的 tool_call_id。
511+
tool_call_started_set: 可选的已发送 TOOL_CALL_START 的 tool_call_id 集合。
512+
用于确保每个工具调用只发送一次 TOOL_CALL_START。
510513
511514
Yields:
512515
str (文本内容) 或 AgentResult (事件)
@@ -527,6 +530,7 @@ def _convert_astream_events_event(
527530
for tc in _extract_tool_call_chunks(chunk):
528531
tc_index = tc.get("index")
529532
tc_raw_id = tc.get("id")
533+
tc_name = tc.get("name", "")
530534
tc_args = tc.get("args", "")
531535

532536
# 解析 tool_call_id:
@@ -552,8 +556,28 @@ def _convert_astream_events_event(
552556
else:
553557
tc_id = ""
554558

559+
if not tc_id:
560+
continue
561+
562+
# AG-UI 协议要求:先发送 TOOL_CALL_START,再发送 TOOL_CALL_ARGS
563+
# 第一次遇到某个工具调用时(有 id 和 name),先发送 TOOL_CALL_START
564+
if tc_raw_id and tc_name:
565+
if (
566+
tool_call_started_set is None
567+
or tc_id not in tool_call_started_set
568+
):
569+
yield AgentResult(
570+
event=EventType.TOOL_CALL_START,
571+
data={
572+
"tool_call_id": tc_id,
573+
"tool_call_name": tc_name,
574+
},
575+
)
576+
if tool_call_started_set is not None:
577+
tool_call_started_set.add(tc_id)
578+
555579
# 只有有 args 时才生成 TOOL_CALL_ARGS 事件
556-
if tc_args and tc_id:
580+
if tc_args:
557581
if isinstance(tc_args, (dict, list)):
558582
tc_args = _safe_json_dumps(tc_args)
559583
yield AgentResult(
@@ -598,23 +622,41 @@ def _convert_astream_events_event(
598622
tool_input = _filter_tool_input(tool_input_raw)
599623

600624
if tool_call_id:
601-
yield AgentResult(
602-
event=EventType.TOOL_CALL_START,
603-
data={
604-
"tool_call_id": tool_call_id,
605-
"tool_call_name": tool_name,
606-
},
625+
# 检查是否已在 on_chat_model_stream 中发送过 TOOL_CALL_START
626+
already_started = (
627+
tool_call_started_set is not None
628+
and tool_call_id in tool_call_started_set
607629
)
608-
if tool_input:
609-
args_str = (
610-
_safe_json_dumps(tool_input)
611-
if isinstance(tool_input, dict)
612-
else str(tool_input)
613-
)
630+
631+
if not already_started:
632+
# 非流式场景或未收到流式事件,需要发送 TOOL_CALL_START
614633
yield AgentResult(
615-
event=EventType.TOOL_CALL_ARGS,
616-
data={"tool_call_id": tool_call_id, "delta": args_str},
634+
event=EventType.TOOL_CALL_START,
635+
data={
636+
"tool_call_id": tool_call_id,
637+
"tool_call_name": tool_name,
638+
},
617639
)
640+
if tool_call_started_set is not None:
641+
tool_call_started_set.add(tool_call_id)
642+
643+
# 非流式场景下,在 START 后发送完整参数
644+
if tool_input:
645+
args_str = (
646+
_safe_json_dumps(tool_input)
647+
if isinstance(tool_input, dict)
648+
else str(tool_input)
649+
)
650+
yield AgentResult(
651+
event=EventType.TOOL_CALL_ARGS,
652+
data={"tool_call_id": tool_call_id, "delta": args_str},
653+
)
654+
655+
# AG-UI 协议:TOOL_CALL_END 表示参数传输完成,在工具执行前发送
656+
yield AgentResult(
657+
event=EventType.TOOL_CALL_END,
658+
data={"tool_call_id": tool_call_id},
659+
)
618660

619661
# 4. 工具结束
620662
elif event_type == "on_tool_end":
@@ -625,17 +667,15 @@ def _convert_astream_events_event(
625667
tool_call_id = _extract_tool_call_id(tool_input_raw) or run_id
626668

627669
if tool_call_id:
670+
# AG-UI 协议:TOOL_CALL_RESULT 在工具执行完成后发送
671+
# 注意:TOOL_CALL_END 已在 on_tool_start 中发送(表示参数传输完成)
628672
yield AgentResult(
629673
event=EventType.TOOL_CALL_RESULT,
630674
data={
631675
"tool_call_id": tool_call_id,
632676
"result": _format_tool_output(output),
633677
},
634678
)
635-
yield AgentResult(
636-
event=EventType.TOOL_CALL_END,
637-
data={"tool_call_id": tool_call_id},
638-
)
639679

640680
# 5. LLM 结束
641681
elif event_type == "on_chat_model_end":
@@ -652,6 +692,7 @@ def to_agui_events(
652692
event: Union[Dict[str, Any], Any],
653693
messages_key: str = "messages",
654694
tool_call_id_map: Optional[Dict[int, str]] = None,
695+
tool_call_started_set: Optional[set] = None,
655696
) -> Iterator[Union[AgentResult, str]]:
656697
"""将 LangGraph/LangChain 流式事件转换为 AG-UI 协议事件
657698
@@ -667,12 +708,15 @@ def to_agui_events(
667708
messages_key: state 中消息列表的 key,默认 "messages"
668709
tool_call_id_map: 可选的 index -> tool_call_id 映射字典,用于流式工具调用
669710
的 ID 一致性。如果提供,函数会自动更新此映射。
711+
tool_call_started_set: 可选的已发送 TOOL_CALL_START 的 tool_call_id 集合。
712+
用于确保每个工具调用只发送一次 TOOL_CALL_START,
713+
并在正确的时机发送 TOOL_CALL_END。
670714
671715
Yields:
672716
str (文本内容) 或 AgentResult (AG-UI 事件)
673717
674718
Example:
675-
>>> # 使用 astream_events(推荐使用 AguiEventConverter 类)
719+
>>> # 使用 astream_events(推荐使用 AgentRunConverter 类)
676720
>>> async for event in agent.astream_events(input, version="v2"):
677721
... for item in to_agui_events(event):
678722
... yield item
@@ -692,7 +736,9 @@ def to_agui_events(
692736
# 根据事件格式选择对应的转换器
693737
if _is_astream_events_format(event_dict):
694738
# astream_events 格式:{"event": "on_xxx", "data": {...}}
695-
yield from _convert_astream_events_event(event_dict, tool_call_id_map)
739+
yield from _convert_astream_events_event(
740+
event_dict, tool_call_id_map, tool_call_started_set
741+
)
696742

697743
elif _is_stream_updates_format(event_dict):
698744
# stream/astream(stream_mode="updates") 格式:{node_name: state_update}
@@ -707,11 +753,17 @@ class AgentRunConverter:
707753
"""AgentRun 事件转换器
708754
709755
将 LangGraph/LangChain 流式事件转换为 AG-UI 协议事件。
710-
此类维护必要的状态以确保流式工具调用的 tool_call_id 一致性。
756+
此类维护必要的状态以确保:
757+
1. 流式工具调用的 tool_call_id 一致性
758+
2. AG-UI 协议要求的事件顺序(TOOL_CALL_START → TOOL_CALL_ARGS → TOOL_CALL_END)
711759
712-
在流式工具调用中,第一个 chunk 包含 id,后续 chunk 只有 index。
760+
在流式工具调用中,第一个 chunk 包含 id 和 name,后续 chunk 只有 index 和 args
713761
此类维护 index -> id 的映射,确保所有相关事件使用相同的 tool_call_id。
714762
763+
同时,此类跟踪已发送 TOOL_CALL_START 的工具调用,确保:
764+
- 在流式场景中,TOOL_CALL_START 在第一个参数 chunk 前发送
765+
- 避免在 on_tool_start 中重复发送 TOOL_CALL_START
766+
715767
Example:
716768
>>> from agentrun.integration.langchain import AgentRunConverter
717769
>>>
@@ -724,6 +776,7 @@ class AgentRunConverter:
724776

725777
def __init__(self):
726778
self._tool_call_id_map: Dict[int, str] = {}
779+
self._tool_call_started_set: set = set()
727780

728781
def convert(
729782
self,
@@ -739,15 +792,21 @@ def convert(
739792
Yields:
740793
str (文本内容) 或 AgentResult (AG-UI 事件)
741794
"""
742-
yield from to_agui_events(event, messages_key, self._tool_call_id_map)
795+
yield from to_agui_events(
796+
event,
797+
messages_key,
798+
self._tool_call_id_map,
799+
self._tool_call_started_set,
800+
)
743801

744802
def reset(self):
745-
"""重置状态,清空 tool_call_id 映射
803+
"""重置状态,清空 tool_call_id 映射和已发送状态
746804
747805
在处理新的请求时,建议创建新的 AgentRunConverter 实例,
748806
而不是复用旧实例并调用 reset。
749807
"""
750808
self._tool_call_id_map.clear()
809+
self._tool_call_started_set.clear()
751810

752811

753812
# 保留向后兼容的别名

agentrun/server/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@
7777
... return "Hello, world!"
7878
"""
7979

80+
from .agui_normalizer import AguiEventNormalizer
8081
from .agui_protocol import AGUIProtocolHandler
8182
from .model import (
8283
AdditionMode,
@@ -137,4 +138,6 @@
137138
"OpenAIProtocolHandler",
138139
# Protocol - AG-UI
139140
"AGUIProtocolHandler",
141+
# Event Normalizer
142+
"AguiEventNormalizer",
140143
]

0 commit comments

Comments
 (0)