fix(workflow): sync iteration conversation variables (#26368)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
parent
6462328620
commit
b6cea71023
3 changed files with 402 additions and 4 deletions
|
|
@ -10,6 +10,8 @@ from typing_extensions import TypeIs
|
||||||
|
|
||||||
from core.variables import IntegerVariable, NoneSegment
|
from core.variables import IntegerVariable, NoneSegment
|
||||||
from core.variables.segments import ArrayAnySegment, ArraySegment
|
from core.variables.segments import ArrayAnySegment, ArraySegment
|
||||||
|
from core.variables.variables import VariableUnion
|
||||||
|
from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID
|
||||||
from core.workflow.entities import VariablePool
|
from core.workflow.entities import VariablePool
|
||||||
from core.workflow.enums import (
|
from core.workflow.enums import (
|
||||||
ErrorStrategy,
|
ErrorStrategy,
|
||||||
|
|
@ -217,6 +219,13 @@ class IterationNode(Node):
|
||||||
graph_engine=graph_engine,
|
graph_engine=graph_engine,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Sync conversation variables after each iteration completes
|
||||||
|
self._sync_conversation_variables_from_snapshot(
|
||||||
|
self._extract_conversation_variable_snapshot(
|
||||||
|
variable_pool=graph_engine.graph_runtime_state.variable_pool
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# Update the total tokens from this iteration
|
# Update the total tokens from this iteration
|
||||||
self.graph_runtime_state.total_tokens += graph_engine.graph_runtime_state.total_tokens
|
self.graph_runtime_state.total_tokens += graph_engine.graph_runtime_state.total_tokens
|
||||||
iter_run_map[str(index)] = (datetime.now(UTC).replace(tzinfo=None) - iter_start_at).total_seconds()
|
iter_run_map[str(index)] = (datetime.now(UTC).replace(tzinfo=None) - iter_start_at).total_seconds()
|
||||||
|
|
@ -235,7 +244,10 @@ class IterationNode(Node):
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
||||||
# Submit all iteration tasks
|
# Submit all iteration tasks
|
||||||
future_to_index: dict[Future[tuple[datetime, list[GraphNodeEventBase], object | None, int]], int] = {}
|
future_to_index: dict[
|
||||||
|
Future[tuple[datetime, list[GraphNodeEventBase], object | None, int, dict[str, VariableUnion]]],
|
||||||
|
int,
|
||||||
|
] = {}
|
||||||
for index, item in enumerate(iterator_list_value):
|
for index, item in enumerate(iterator_list_value):
|
||||||
yield IterationNextEvent(index=index)
|
yield IterationNextEvent(index=index)
|
||||||
future = executor.submit(
|
future = executor.submit(
|
||||||
|
|
@ -252,7 +264,7 @@ class IterationNode(Node):
|
||||||
index = future_to_index[future]
|
index = future_to_index[future]
|
||||||
try:
|
try:
|
||||||
result = future.result()
|
result = future.result()
|
||||||
iter_start_at, events, output_value, tokens_used = result
|
iter_start_at, events, output_value, tokens_used, conversation_snapshot = result
|
||||||
|
|
||||||
# Update outputs at the correct index
|
# Update outputs at the correct index
|
||||||
outputs[index] = output_value
|
outputs[index] = output_value
|
||||||
|
|
@ -264,6 +276,9 @@ class IterationNode(Node):
|
||||||
self.graph_runtime_state.total_tokens += tokens_used
|
self.graph_runtime_state.total_tokens += tokens_used
|
||||||
iter_run_map[str(index)] = (datetime.now(UTC).replace(tzinfo=None) - iter_start_at).total_seconds()
|
iter_run_map[str(index)] = (datetime.now(UTC).replace(tzinfo=None) - iter_start_at).total_seconds()
|
||||||
|
|
||||||
|
# Sync conversation variables after iteration completion
|
||||||
|
self._sync_conversation_variables_from_snapshot(conversation_snapshot)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Handle errors based on error_handle_mode
|
# Handle errors based on error_handle_mode
|
||||||
match self._node_data.error_handle_mode:
|
match self._node_data.error_handle_mode:
|
||||||
|
|
@ -288,7 +303,7 @@ class IterationNode(Node):
|
||||||
item: object,
|
item: object,
|
||||||
flask_app: Flask,
|
flask_app: Flask,
|
||||||
context_vars: contextvars.Context,
|
context_vars: contextvars.Context,
|
||||||
) -> tuple[datetime, list[GraphNodeEventBase], object | None, int]:
|
) -> tuple[datetime, list[GraphNodeEventBase], object | None, int, dict[str, VariableUnion]]:
|
||||||
"""Execute a single iteration in parallel mode and return results."""
|
"""Execute a single iteration in parallel mode and return results."""
|
||||||
with preserve_flask_contexts(flask_app=flask_app, context_vars=context_vars):
|
with preserve_flask_contexts(flask_app=flask_app, context_vars=context_vars):
|
||||||
iter_start_at = datetime.now(UTC).replace(tzinfo=None)
|
iter_start_at = datetime.now(UTC).replace(tzinfo=None)
|
||||||
|
|
@ -307,8 +322,17 @@ class IterationNode(Node):
|
||||||
|
|
||||||
# Get the output value from the temporary outputs list
|
# Get the output value from the temporary outputs list
|
||||||
output_value = outputs_temp[0] if outputs_temp else None
|
output_value = outputs_temp[0] if outputs_temp else None
|
||||||
|
conversation_snapshot = self._extract_conversation_variable_snapshot(
|
||||||
|
variable_pool=graph_engine.graph_runtime_state.variable_pool
|
||||||
|
)
|
||||||
|
|
||||||
return iter_start_at, events, output_value, graph_engine.graph_runtime_state.total_tokens
|
return (
|
||||||
|
iter_start_at,
|
||||||
|
events,
|
||||||
|
output_value,
|
||||||
|
graph_engine.graph_runtime_state.total_tokens,
|
||||||
|
conversation_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
def _handle_iteration_success(
|
def _handle_iteration_success(
|
||||||
self,
|
self,
|
||||||
|
|
@ -430,6 +454,23 @@ class IterationNode(Node):
|
||||||
|
|
||||||
return variable_mapping
|
return variable_mapping
|
||||||
|
|
||||||
|
def _extract_conversation_variable_snapshot(self, *, variable_pool: VariablePool) -> dict[str, VariableUnion]:
|
||||||
|
conversation_variables = variable_pool.variable_dictionary.get(CONVERSATION_VARIABLE_NODE_ID, {})
|
||||||
|
return {name: variable.model_copy(deep=True) for name, variable in conversation_variables.items()}
|
||||||
|
|
||||||
|
def _sync_conversation_variables_from_snapshot(self, snapshot: dict[str, VariableUnion]) -> None:
|
||||||
|
parent_pool = self.graph_runtime_state.variable_pool
|
||||||
|
parent_conversations = parent_pool.variable_dictionary.get(CONVERSATION_VARIABLE_NODE_ID, {})
|
||||||
|
|
||||||
|
current_keys = set(parent_conversations.keys())
|
||||||
|
snapshot_keys = set(snapshot.keys())
|
||||||
|
|
||||||
|
for removed_key in current_keys - snapshot_keys:
|
||||||
|
parent_pool.remove((CONVERSATION_VARIABLE_NODE_ID, removed_key))
|
||||||
|
|
||||||
|
for name, variable in snapshot.items():
|
||||||
|
parent_pool.add((CONVERSATION_VARIABLE_NODE_ID, name), variable)
|
||||||
|
|
||||||
def _append_iteration_info_to_event(
|
def _append_iteration_info_to_event(
|
||||||
self,
|
self,
|
||||||
event: GraphNodeEventBase,
|
event: GraphNodeEventBase,
|
||||||
|
|
|
||||||
316
api/tests/fixtures/workflow/update-conversation-variable-in-iteration.yml
vendored
Normal file
316
api/tests/fixtures/workflow/update-conversation-variable-in-iteration.yml
vendored
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
app:
|
||||||
|
description: 'This chatflow receives a sys.query, writes it into the `answer` variable,
|
||||||
|
and then outputs the `answer` variable.
|
||||||
|
|
||||||
|
|
||||||
|
`answer` is a conversation variable with a blank default value; it will be updated
|
||||||
|
in an iteration node.
|
||||||
|
|
||||||
|
|
||||||
|
if this chatflow works correctly, it will output the `sys.query` as the same.'
|
||||||
|
icon: 🤖
|
||||||
|
icon_background: '#FFEAD5'
|
||||||
|
mode: advanced-chat
|
||||||
|
name: update-conversation-variable-in-iteration
|
||||||
|
use_icon_as_answer_icon: false
|
||||||
|
dependencies: []
|
||||||
|
kind: app
|
||||||
|
version: 0.4.0
|
||||||
|
workflow:
|
||||||
|
conversation_variables:
|
||||||
|
- description: ''
|
||||||
|
id: c30af82d-b2ec-417d-a861-4dd78584faa4
|
||||||
|
name: answer
|
||||||
|
selector:
|
||||||
|
- conversation
|
||||||
|
- answer
|
||||||
|
value: ''
|
||||||
|
value_type: string
|
||||||
|
environment_variables: []
|
||||||
|
features:
|
||||||
|
file_upload:
|
||||||
|
allowed_file_extensions:
|
||||||
|
- .JPG
|
||||||
|
- .JPEG
|
||||||
|
- .PNG
|
||||||
|
- .GIF
|
||||||
|
- .WEBP
|
||||||
|
- .SVG
|
||||||
|
allowed_file_types:
|
||||||
|
- image
|
||||||
|
allowed_file_upload_methods:
|
||||||
|
- local_file
|
||||||
|
- remote_url
|
||||||
|
enabled: false
|
||||||
|
fileUploadConfig:
|
||||||
|
audio_file_size_limit: 50
|
||||||
|
batch_count_limit: 5
|
||||||
|
file_size_limit: 15
|
||||||
|
image_file_size_limit: 10
|
||||||
|
video_file_size_limit: 100
|
||||||
|
workflow_file_upload_limit: 10
|
||||||
|
image:
|
||||||
|
enabled: false
|
||||||
|
number_limits: 3
|
||||||
|
transfer_methods:
|
||||||
|
- local_file
|
||||||
|
- remote_url
|
||||||
|
number_limits: 3
|
||||||
|
opening_statement: ''
|
||||||
|
retriever_resource:
|
||||||
|
enabled: true
|
||||||
|
sensitive_word_avoidance:
|
||||||
|
enabled: false
|
||||||
|
speech_to_text:
|
||||||
|
enabled: false
|
||||||
|
suggested_questions: []
|
||||||
|
suggested_questions_after_answer:
|
||||||
|
enabled: false
|
||||||
|
text_to_speech:
|
||||||
|
enabled: false
|
||||||
|
language: ''
|
||||||
|
voice: ''
|
||||||
|
graph:
|
||||||
|
edges:
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: start
|
||||||
|
targetType: code
|
||||||
|
id: 1759032354471-source-1759032363865-target
|
||||||
|
source: '1759032354471'
|
||||||
|
sourceHandle: source
|
||||||
|
target: '1759032363865'
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: code
|
||||||
|
targetType: iteration
|
||||||
|
id: 1759032363865-source-1759032379989-target
|
||||||
|
source: '1759032363865'
|
||||||
|
sourceHandle: source
|
||||||
|
target: '1759032379989'
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
- data:
|
||||||
|
isInIteration: true
|
||||||
|
isInLoop: false
|
||||||
|
iteration_id: '1759032379989'
|
||||||
|
sourceType: iteration-start
|
||||||
|
targetType: assigner
|
||||||
|
id: 1759032379989start-source-1759032394460-target
|
||||||
|
source: 1759032379989start
|
||||||
|
sourceHandle: source
|
||||||
|
target: '1759032394460'
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 1002
|
||||||
|
- data:
|
||||||
|
isInIteration: false
|
||||||
|
isInLoop: false
|
||||||
|
sourceType: iteration
|
||||||
|
targetType: answer
|
||||||
|
id: 1759032379989-source-1759032410331-target
|
||||||
|
source: '1759032379989'
|
||||||
|
sourceHandle: source
|
||||||
|
target: '1759032410331'
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 0
|
||||||
|
- data:
|
||||||
|
isInIteration: true
|
||||||
|
isInLoop: false
|
||||||
|
iteration_id: '1759032379989'
|
||||||
|
sourceType: assigner
|
||||||
|
targetType: code
|
||||||
|
id: 1759032394460-source-1759032476318-target
|
||||||
|
source: '1759032394460'
|
||||||
|
sourceHandle: source
|
||||||
|
target: '1759032476318'
|
||||||
|
targetHandle: target
|
||||||
|
type: custom
|
||||||
|
zIndex: 1002
|
||||||
|
nodes:
|
||||||
|
- data:
|
||||||
|
selected: false
|
||||||
|
title: Start
|
||||||
|
type: start
|
||||||
|
variables: []
|
||||||
|
height: 52
|
||||||
|
id: '1759032354471'
|
||||||
|
position:
|
||||||
|
x: 30
|
||||||
|
y: 302
|
||||||
|
positionAbsolute:
|
||||||
|
x: 30
|
||||||
|
y: 302
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 242
|
||||||
|
- data:
|
||||||
|
code: "\ndef main():\n return {\n \"result\": [1],\n }\n"
|
||||||
|
code_language: python3
|
||||||
|
outputs:
|
||||||
|
result:
|
||||||
|
children: null
|
||||||
|
type: array[number]
|
||||||
|
selected: false
|
||||||
|
title: Code
|
||||||
|
type: code
|
||||||
|
variables: []
|
||||||
|
height: 52
|
||||||
|
id: '1759032363865'
|
||||||
|
position:
|
||||||
|
x: 332
|
||||||
|
y: 302
|
||||||
|
positionAbsolute:
|
||||||
|
x: 332
|
||||||
|
y: 302
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 242
|
||||||
|
- data:
|
||||||
|
error_handle_mode: terminated
|
||||||
|
height: 204
|
||||||
|
is_parallel: false
|
||||||
|
iterator_input_type: array[number]
|
||||||
|
iterator_selector:
|
||||||
|
- '1759032363865'
|
||||||
|
- result
|
||||||
|
output_selector:
|
||||||
|
- '1759032476318'
|
||||||
|
- result
|
||||||
|
output_type: array[string]
|
||||||
|
parallel_nums: 10
|
||||||
|
selected: false
|
||||||
|
start_node_id: 1759032379989start
|
||||||
|
title: Iteration
|
||||||
|
type: iteration
|
||||||
|
width: 808
|
||||||
|
height: 204
|
||||||
|
id: '1759032379989'
|
||||||
|
position:
|
||||||
|
x: 634
|
||||||
|
y: 302
|
||||||
|
positionAbsolute:
|
||||||
|
x: 634
|
||||||
|
y: 302
|
||||||
|
selected: true
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 808
|
||||||
|
zIndex: 1
|
||||||
|
- data:
|
||||||
|
desc: ''
|
||||||
|
isInIteration: true
|
||||||
|
selected: false
|
||||||
|
title: ''
|
||||||
|
type: iteration-start
|
||||||
|
draggable: false
|
||||||
|
height: 48
|
||||||
|
id: 1759032379989start
|
||||||
|
parentId: '1759032379989'
|
||||||
|
position:
|
||||||
|
x: 60
|
||||||
|
y: 78
|
||||||
|
positionAbsolute:
|
||||||
|
x: 694
|
||||||
|
y: 380
|
||||||
|
selectable: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom-iteration-start
|
||||||
|
width: 44
|
||||||
|
zIndex: 1002
|
||||||
|
- data:
|
||||||
|
isInIteration: true
|
||||||
|
isInLoop: false
|
||||||
|
items:
|
||||||
|
- input_type: variable
|
||||||
|
operation: over-write
|
||||||
|
value:
|
||||||
|
- sys
|
||||||
|
- query
|
||||||
|
variable_selector:
|
||||||
|
- conversation
|
||||||
|
- answer
|
||||||
|
write_mode: over-write
|
||||||
|
iteration_id: '1759032379989'
|
||||||
|
selected: false
|
||||||
|
title: Variable Assigner
|
||||||
|
type: assigner
|
||||||
|
version: '2'
|
||||||
|
height: 84
|
||||||
|
id: '1759032394460'
|
||||||
|
parentId: '1759032379989'
|
||||||
|
position:
|
||||||
|
x: 204
|
||||||
|
y: 60
|
||||||
|
positionAbsolute:
|
||||||
|
x: 838
|
||||||
|
y: 362
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 242
|
||||||
|
zIndex: 1002
|
||||||
|
- data:
|
||||||
|
answer: '{{#conversation.answer#}}'
|
||||||
|
selected: false
|
||||||
|
title: Answer
|
||||||
|
type: answer
|
||||||
|
variables: []
|
||||||
|
height: 104
|
||||||
|
id: '1759032410331'
|
||||||
|
position:
|
||||||
|
x: 1502
|
||||||
|
y: 302
|
||||||
|
positionAbsolute:
|
||||||
|
x: 1502
|
||||||
|
y: 302
|
||||||
|
selected: false
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 242
|
||||||
|
- data:
|
||||||
|
code: "\ndef main():\n return {\n \"result\": '',\n }\n"
|
||||||
|
code_language: python3
|
||||||
|
isInIteration: true
|
||||||
|
isInLoop: false
|
||||||
|
iteration_id: '1759032379989'
|
||||||
|
outputs:
|
||||||
|
result:
|
||||||
|
children: null
|
||||||
|
type: string
|
||||||
|
selected: false
|
||||||
|
title: Code 2
|
||||||
|
type: code
|
||||||
|
variables: []
|
||||||
|
height: 52
|
||||||
|
id: '1759032476318'
|
||||||
|
parentId: '1759032379989'
|
||||||
|
position:
|
||||||
|
x: 506
|
||||||
|
y: 76
|
||||||
|
positionAbsolute:
|
||||||
|
x: 1140
|
||||||
|
y: 378
|
||||||
|
sourcePosition: right
|
||||||
|
targetPosition: left
|
||||||
|
type: custom
|
||||||
|
width: 242
|
||||||
|
zIndex: 1002
|
||||||
|
viewport:
|
||||||
|
x: 120.39999999999998
|
||||||
|
y: 85.20000000000005
|
||||||
|
zoom: 0.7
|
||||||
|
rag_pipeline_variables: []
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
"""Validate conversation variable updates inside an iteration workflow.
|
||||||
|
|
||||||
|
This test uses the ``update-conversation-variable-in-iteration`` fixture, which
|
||||||
|
routes ``sys.query`` into the conversation variable ``answer`` from within an
|
||||||
|
iteration container. The workflow should surface that updated conversation
|
||||||
|
variable in the final answer output.
|
||||||
|
|
||||||
|
Code nodes in the fixture are mocked because their concrete outputs are not
|
||||||
|
relevant to verifying variable propagation semantics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from .test_mock_config import MockConfigBuilder
|
||||||
|
from .test_table_runner import TableTestRunner, WorkflowTestCase
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_conversation_variable_in_iteration():
|
||||||
|
fixture_name = "update-conversation-variable-in-iteration"
|
||||||
|
user_query = "ensure conversation variable syncs"
|
||||||
|
|
||||||
|
mock_config = (
|
||||||
|
MockConfigBuilder()
|
||||||
|
.with_node_output("1759032363865", {"result": [1]})
|
||||||
|
.with_node_output("1759032476318", {"result": ""})
|
||||||
|
.build()
|
||||||
|
)
|
||||||
|
|
||||||
|
case = WorkflowTestCase(
|
||||||
|
fixture_path=fixture_name,
|
||||||
|
use_auto_mock=True,
|
||||||
|
mock_config=mock_config,
|
||||||
|
query=user_query,
|
||||||
|
expected_outputs={"answer": user_query},
|
||||||
|
description="Conversation variable updated within iteration should flow to answer output.",
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = TableTestRunner()
|
||||||
|
result = runner.run_test_case(case)
|
||||||
|
|
||||||
|
assert result.success, f"Workflow execution failed: {result.error}"
|
||||||
|
assert result.actual_outputs is not None
|
||||||
|
assert result.actual_outputs.get("answer") == user_query
|
||||||
Loading…
Add table
Add a link
Reference in a new issue