feat: surface separated-mode LLM reasoning in CLI and workflow run preview (#37828)

This commit is contained in:
L1nSn0w
2026-06-24 08:02:28 +00:00
committed by GitHub
parent 32dc9ff2d9
commit 94d365ea5e
35 changed files with 1198 additions and 11 deletions
@@ -31,6 +31,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
@@ -47,6 +48,7 @@ from core.app.entities.task_entities import (
MessageAudioEndStreamResponse,
MessageAudioStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
StreamResponse,
TextChunkStreamResponse,
WorkflowAppBlockingResponse,
@@ -571,6 +573,22 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
yield self._text_chunk_to_stream_response(delta_text, from_variable_selector=event.from_variable_selector)
def _handle_reasoning_chunk_event(
self, event: QueueReasoningChunkEvent, **kwargs
) -> Generator[StreamResponse, None, None]:
"""Handle reasoning chunk events."""
# is_final with empty reasoning is still forwarded as the "thinking finished" signal
if not event.reasoning and not event.is_final:
return
yield ReasoningChunkStreamResponse(
task_id=self._application_generate_entity.task_id,
data=ReasoningChunkStreamResponse.Data(
reasoning=event.reasoning,
node_id=event.from_node_id,
is_final=event.is_final,
),
)
def _handle_agent_log_event(self, event: QueueAgentLogEvent, **kwargs) -> Generator[StreamResponse, None, None]:
"""Handle agent log events."""
yield self._workflow_response_converter.handle_agent_log(
@@ -600,6 +618,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueuePingEvent: self._handle_ping_event,
QueueErrorEvent: self._handle_error_event,
QueueTextChunkEvent: self._handle_text_chunk_event,
QueueReasoningChunkEvent: self._handle_reasoning_chunk_event,
# Workflow events
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,
+2 -1
View File
@@ -743,7 +743,8 @@ class ReasoningChunkStreamResponse(StreamResponse):
Data entity
"""
message_id: str
# chat apps set this; workflow runs have no message
message_id: str | None = None
reasoning: str
node_id: str | None = None
is_final: bool = False
@@ -29,6 +29,7 @@ from core.app.entities.queue_entities import (
QueueNodeExceptionEvent,
QueueNodeFailedEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueRetrieverResourcesEvent,
QueueStopEvent,
QueueTextChunkEvent,
@@ -46,6 +47,7 @@ from core.app.entities.task_entities import (
MessageAudioStreamResponse,
MessageEndStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
)
from core.base.tts.app_generator_tts_publisher import AudioTrunk
from core.workflow.system_variables import build_system_variables
@@ -196,6 +198,42 @@ class TestAdvancedChatGenerateTaskPipeline:
assert pipeline._task_state.answer == "hi"
assert responses
def test_handle_reasoning_chunk_event_emits_on_nonempty(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
assert response.data.message_id == pipeline._message_id
assert response.data.reasoning == "pondering"
assert response.data.node_id == "llm-1"
assert response.data.is_final is False
# reasoning never touches the answer stream
assert pipeline._task_state.answer == ""
def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert responses == []
def test_handle_reasoning_chunk_event_emits_empty_final_marker(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
assert response.data.reasoning == ""
assert response.data.is_final is True
def test_listen_audio_msg_returns_audio_stream(self):
pipeline = _make_pipeline()
publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="stream", audio="data"))
@@ -319,6 +357,43 @@ class TestAdvancedChatGenerateTaskPipeline:
assert responses == ["done"]
assert pipeline._recorded_files
def test_handle_node_succeeded_event_records_llm_reasoning(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: []
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
pipeline._save_output_for_event = lambda event, node_execution_id: None
event = SimpleNamespace(
node_type=BuiltinNodeTypes.LLM,
outputs={"reasoning_content": "first pass "},
node_execution_id="exec",
node_id="llm-1",
)
list(pipeline._handle_node_succeeded_event(event))
assert pipeline._task_state.metadata.reasoning == {"llm-1": "first pass "}
def test_handle_node_succeeded_event_accumulates_reasoning_across_passes(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: []
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"
pipeline._save_output_for_event = lambda event, node_execution_id: None
def _llm_event(reasoning: str):
return SimpleNamespace(
node_type=BuiltinNodeTypes.LLM,
outputs={"reasoning_content": reasoning},
node_execution_id="exec",
node_id="llm-1",
)
# Same node id across iteration/loop passes must accumulate, not overwrite.
list(pipeline._handle_node_succeeded_event(_llm_event("pass one ")))
list(pipeline._handle_node_succeeded_event(_llm_event("pass two")))
assert pipeline._task_state.metadata.reasoning == {"llm-1": "pass one pass two"}
def test_iteration_and_loop_handlers(self):
pipeline = _make_pipeline()
pipeline._workflow_run_id = "run-id"
@@ -16,6 +16,7 @@ from core.app.entities.queue_entities import (
QueueNodeFailedEvent,
QueueNodeRetryEvent,
QueueNodeSucceededEvent,
QueueReasoningChunkEvent,
QueueTextChunkEvent,
QueueWorkflowPausedEvent,
QueueWorkflowStartedEvent,
@@ -34,6 +35,7 @@ from graphon.graph_events import (
NodeRunHumanInputFormFilledEvent,
NodeRunIterationSucceededEvent,
NodeRunLoopFailedEvent,
NodeRunReasoningChunkEvent,
NodeRunRetryEvent,
NodeRunStartedEvent,
NodeRunStreamChunkEvent,
@@ -395,6 +397,17 @@ class TestWorkflowBasedAppRunner:
is_final=False,
),
)
runner._handle_event(
workflow_entry,
NodeRunReasoningChunkEvent(
id="exec",
node_id="node",
node_type=BuiltinNodeTypes.LLM,
selector=["node", "reasoning_content"],
chunk="thinking",
is_final=False,
),
)
runner._handle_event(
workflow_entry,
NodeRunAgentLogEvent(
@@ -442,6 +455,7 @@ class TestWorkflowBasedAppRunner:
)
assert any(isinstance(event, QueueTextChunkEvent) for event in published)
assert any(isinstance(event, QueueReasoningChunkEvent) for event in published)
assert any(isinstance(event, QueueAgentLogEvent) for event in published)
assert any(isinstance(event, QueueIterationCompletedEvent) for event in published)
assert any(isinstance(event, QueueLoopCompletedEvent) for event in published)
@@ -26,6 +26,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
@@ -40,6 +41,7 @@ from core.app.entities.task_entities import (
MessageAudioEndStreamResponse,
MessageAudioStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
WorkflowAppPausedBlockingResponse,
WorkflowFinishStreamResponse,
WorkflowStartStreamResponse,
@@ -265,6 +267,41 @@ class TestWorkflowGenerateTaskPipeline:
assert responses[0].data.text == "hi"
assert published == [queue_message]
def test_handle_reasoning_chunk_event_emits_on_nonempty(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
# workflow runs have no message, so the id is omitted
assert response.data.message_id is None
assert response.data.reasoning == "pondering"
assert response.data.node_id == "llm-1"
assert response.data.is_final is False
def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert responses == []
def test_handle_reasoning_chunk_event_emits_empty_final_marker(self):
pipeline = _make_pipeline()
event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True)
responses = list(pipeline._handle_reasoning_chunk_event(event))
assert len(responses) == 1
response = responses[0]
assert isinstance(response, ReasoningChunkStreamResponse)
assert response.data.reasoning == ""
assert response.data.is_final is True
def test_dispatch_event_handles_node_failed(self):
pipeline = _make_pipeline()
pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done"