66from langgraph .graph .state import CompiledStateGraph
77from loguru import logger
88
9+ from app .agent .schemas import StructuredResponse
910from app .api .schemas import ConfigDict
11+ from app .api .streaming .data_sources import resolve_data_source_names
1012from app .api .streaming .schemas import EventData , StreamEvent , ToolCall , ToolOutput
1113from app .api .streaming .security import sanitize_markdown_links
1214from app .db .database import AsyncDatabase , sessionmaker
@@ -84,35 +86,6 @@ def _truncate_json(
8486 return json .dumps (data , ensure_ascii = False , indent = 2 )
8587
8688
87- def _parse_thinking (message : AIMessage ) -> str | None :
88- """Parse thinking content from an AI message.
89-
90- Some models (e.g., Gemini 3) return `message.content` as a list of typed blocks,
91- which may include `{"type": "thinking", "thinking": "..."}` entries. When
92- `content` is a plain string, no thinking is available.
93-
94- Args:
95- message (AIMessage): The AI message from where to parse the thinking.
96-
97- Returns:
98- str | None: The concatenated thinking text, or None if no thinking blocks exist.
99- """
100- if isinstance (message .content , str ):
101- return None
102-
103- blocks = [
104- block
105- for block in message .content
106- if isinstance (block , dict )
107- and block .get ("type" ) == "thinking"
108- and isinstance (block .get ("thinking" ), str )
109- ]
110-
111- thinking = "" .join (block ["thinking" ] for block in blocks )
112-
113- return thinking or None
114-
115-
11689def _process_chunk (chunk : dict [str , Any ]) -> StreamEvent | None :
11790 """Process a streaming chunk from a react agent workflow into a standardized StreamEvent.
11891
@@ -128,7 +101,27 @@ def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
128101 - None for ignored chunks
129102 """
130103 if "model" in chunk :
131- ai_messages : list [AIMessage ] = chunk ["model" ]["messages" ]
104+ update : dict [str , Any ] = chunk ["model" ]
105+
106+ # When `response_format` is set (see app.main:91), the model node sets `structured_response`
107+ # (a StructuredResponse) on the turn it produces the final answer. This is the final answer;
108+ # the accompanying structured-output tool call / ToolMessage in `update["messages"]` is
109+ # internal and must not be emitted as a tool_call.
110+ structured : StructuredResponse | None = update .get ("structured_response" )
111+
112+ if structured is not None :
113+ response_text = sanitize_markdown_links (structured .response )
114+ structured_response = structured .model_dump ()
115+ structured_response ["response" ] = response_text
116+ return StreamEvent (
117+ type = "final_answer" ,
118+ data = EventData (
119+ content = response_text ,
120+ structured_response = structured_response ,
121+ ),
122+ )
123+
124+ ai_messages : list [AIMessage ] = update ["messages" ]
132125
133126 # If no messages are returned, the model returned an empty response
134127 # with no tool calls. This also counts as a final (but empty) answer.
@@ -145,7 +138,7 @@ def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
145138 )
146139 for tool_call in message .tool_calls
147140 ]
148- content = _parse_thinking ( message ) or message .text
141+ content = message .text
149142 else :
150143 event_type = "final_answer"
151144 tool_calls = None
@@ -183,15 +176,17 @@ def _process_chunk(chunk: dict[str, Any]) -> StreamEvent | None:
183176 ]
184177
185178 return StreamEvent (
186- type = "tool_output" , data = EventData (tool_outputs = tool_outputs )
179+ type = "tool_output" ,
180+ data = EventData (tool_outputs = tool_outputs ),
187181 )
188182 elif "ModelCallLimitMiddleware.before_model" in chunk :
189183 # before_model runs on every model iteration; only the limit-exceeded
190184 # path sets jump_to="end", so check that rather than the key's presence.
191185 update = chunk ["ModelCallLimitMiddleware.before_model" ] or {}
192186 if update .get ("jump_to" ) == "end" :
193187 event_data = EventData (
194- content = ErrorMessage .MODEL_CALL_LIMIT_REACHED , tool_calls = None
188+ content = ErrorMessage .MODEL_CALL_LIMIT_REACHED ,
189+ tool_calls = None ,
195190 )
196191 return StreamEvent (type = "model_call_limit" , data = event_data )
197192 return None
@@ -223,6 +218,7 @@ async def run_agent(
223218 events = []
224219 artifacts = []
225220 assistant_message = ""
221+ structured_response : dict [str , Any ] | None = None
226222 status : MessageStatus | None = None
227223
228224 try :
@@ -245,6 +241,9 @@ async def run_agent(
245241 artifacts .append (output .artifact )
246242 elif event .type == "final_answer" :
247243 assistant_message = event .data .content
244+ if event .data .structured_response is not None :
245+ await resolve_data_source_names (event .data .structured_response )
246+ structured_response = event .data .structured_response
248247 status = MessageStatus .SUCCESS
249248 elif event .type == "model_call_limit" :
250249 assistant_message = event .data .content
@@ -280,6 +279,7 @@ async def run_agent(
280279 content = assistant_message ,
281280 artifacts = artifacts or None ,
282281 events = events or None ,
282+ structured_response = structured_response ,
283283 status = status or MessageStatus .ERROR ,
284284 )
285285 try :
0 commit comments