2525]
2626
2727
28+ def _coerce_action_payload (payload : Any ) -> dict [str , Any ]:
29+ """Normalise tool arguments into a mapping suitable for summaries."""
30+
31+ if isinstance (payload , dict ):
32+ return payload
33+
34+ if isinstance (payload , str ):
35+ try :
36+ parsed = json .loads (payload )
37+ except json .JSONDecodeError :
38+ return {"input" : payload }
39+ if isinstance (parsed , dict ):
40+ return parsed
41+ return {"value" : parsed }
42+
43+ if payload is None :
44+ return {}
45+
46+ if isinstance (payload , (list , tuple , set )):
47+ return {"items" : [item for item in payload ]}
48+
49+ return {"value" : payload }
50+
51+
52+ def normalize_tool_fragment (fragment : Any ) -> dict [str , Any ] | None :
53+ """Coerce streaming tool fragments into the tool-call snapshot format."""
54+
55+ if not isinstance (fragment , dict ):
56+ return None
57+
58+ candidate = dict (fragment )
59+
60+ embedded = candidate .pop ("tool_call" , None )
61+ if isinstance (embedded , dict ):
62+ candidate = {** embedded , ** candidate }
63+
64+ # Promote common identifier fields for streaming events.
65+ identifier = first_non_empty (
66+ coerce_optional_string (candidate .get (key ))
67+ for key in ("id" , "tool_call_id" , "call_id" )
68+ )
69+ if identifier :
70+ candidate ["id" ] = identifier
71+
72+ raw_type = coerce_optional_string (candidate .get ("type" ))
73+ tool_name = coerce_optional_string (candidate .get ("tool_name" ))
74+ tool_type = coerce_optional_string (candidate .get ("tool_type" ))
75+
76+ normalized_type : str | None = None
77+ if raw_type and raw_type .endswith ("_call" ):
78+ normalized_type = raw_type
79+ elif tool_name :
80+ normalized_type = f"{ tool_name .replace ('.' , '_' )} _call"
81+ elif tool_type :
82+ normalized_type = f"{ tool_type .replace ('.' , '_' )} _call"
83+ elif raw_type and raw_type .endswith ("_delta" ):
84+ normalized_type = f"{ raw_type [: - len ('_delta' )]} _call"
85+
86+ if normalized_type :
87+ candidate ["type" ] = normalized_type
88+
89+ status = first_non_empty (
90+ coerce_optional_string (candidate .get (key ))
91+ for key in ("status" , "state" )
92+ )
93+ if status :
94+ candidate ["status" ] = status
95+
96+ if "arguments" in candidate and "action" not in candidate :
97+ candidate ["action" ] = _coerce_action_payload (
98+ candidate .pop ("arguments" )
99+ )
100+
101+ if "call_arguments" in candidate and "action" not in candidate :
102+ candidate ["action" ] = _coerce_action_payload (
103+ candidate .pop ("call_arguments" )
104+ )
105+
106+ if "input" in candidate and "action" not in candidate :
107+ candidate ["action" ] = _coerce_action_payload (candidate .pop ("input" ))
108+
109+ if not coerce_optional_string (candidate .get ("type" )):
110+ return None
111+
112+ return candidate
113+
114+
28115def emit_trace_updates_from_item (
29116 item : Any ,
30117 * ,
@@ -58,8 +145,12 @@ def accumulate_stream_tool_event(
58145 """Merge incremental fragments for a single tool call while streaming."""
59146 fragments = collect_stream_fragments (item )
60147 event_id = first_non_empty (
61- coerce_optional_string (fragment .get ("id" ))
148+ first_non_empty (
149+ coerce_optional_string (fragment .get (key ))
150+ for key in ("id" , "tool_call_id" , "call_id" )
151+ )
62152 for fragment in fragments
153+ if isinstance (fragment , dict )
63154 )
64155
65156 tool_events : dict [str , dict [str , Any ]] = stream_state .setdefault (
@@ -97,13 +188,16 @@ def extract_stream_tool_fragment(
97188 existing : dict [str , Any ],
98189) -> dict [str , Any ] | None :
99190 for fragment in fragments :
100- item_type = coerce_optional_string (fragment . get ( "type" ) )
101- if item_type and item_type . endswith ( "_call" ) :
102- return fragment
191+ normalized_fragment = normalize_tool_fragment (fragment )
192+ if normalized_fragment is not None :
193+ return normalized_fragment
103194
104195 if (
105- fragment .get ("response" ) is not None
106- or fragment .get ("result" ) is not None
196+ isinstance (fragment , dict )
197+ and (
198+ fragment .get ("response" ) is not None
199+ or fragment .get ("result" ) is not None
200+ )
107201 ):
108202 snapshot = dict (existing )
109203 if fragment .get ("response" ) is not None :
@@ -114,7 +208,8 @@ def extract_stream_tool_fragment(
114208 snapshot .setdefault ("result" , {}).update (
115209 fragment .get ("result" ) or {}
116210 )
117- return snapshot
211+ normalized_snapshot = normalize_tool_fragment (snapshot )
212+ return normalized_snapshot or snapshot
118213
119214 return None
120215
@@ -123,31 +218,34 @@ def merge_tool_fragment(
123218 existing : dict [str , Any ],
124219 fragment : dict [str , Any ],
125220) -> dict [str , Any ]:
126- merged = dict (existing )
221+ normalized_existing = normalize_tool_fragment (existing ) or existing
222+ normalized_fragment = normalize_tool_fragment (fragment ) or fragment
223+
224+ merged = dict (normalized_existing )
127225
128- if fragment .get ("type" ):
129- merged ["type" ] = fragment ["type" ]
130- if fragment .get ("id" ):
131- merged ["id" ] = fragment ["id" ]
132- if fragment .get ("status" ):
133- merged ["status" ] = fragment ["status" ]
226+ if normalized_fragment .get ("type" ):
227+ merged ["type" ] = normalized_fragment ["type" ]
228+ if normalized_fragment .get ("id" ):
229+ merged ["id" ] = normalized_fragment ["id" ]
230+ if normalized_fragment .get ("status" ):
231+ merged ["status" ] = normalized_fragment ["status" ]
134232
135- action_fragment = fragment .get ("action" )
233+ action_fragment = normalized_fragment .get ("action" )
136234 if action_fragment is not None :
137235 merged_action = merged .get ("action" , {})
138236 merged_action = merge_action_payload (merged_action , action_fragment )
139237 merged ["action" ] = merged_action
140238
141- if fragment .get ("response" ) is not None :
239+ if normalized_fragment .get ("response" ) is not None :
142240 merged ["response" ] = merge_response_payload (
143241 merged .get ("response" ),
144- fragment ["response" ],
242+ normalized_fragment ["response" ],
145243 )
146244
147- if fragment .get ("result" ) is not None :
245+ if normalized_fragment .get ("result" ) is not None :
148246 merged ["result" ] = merge_response_payload (
149247 merged .get ("result" ),
150- fragment ["result" ],
248+ normalized_fragment ["result" ],
151249 )
152250
153251 return merged
0 commit comments