Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions src/vellum/workflows/runner/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,7 +240,7 @@ def __init__(
self._entrypoints = self.workflow.get_entrypoints()

# Check if workflow requires a trigger but none was provided
self._validate_no_trigger_provided()
self._validate_no_trigger_provided(inputs)

# This queue is responsible for sending events from WorkflowRunner to the outside world
self._workflow_event_outer_queue: Queue[WorkflowEvent] = Queue()
Expand Down Expand Up @@ -369,13 +369,19 @@ def _filter_entrypoints_for_trigger(self, trigger: BaseTrigger) -> None:
if specific_entrypoints:
self._entrypoints = specific_entrypoints

def _validate_no_trigger_provided(self) -> None:
def _validate_no_trigger_provided(self, inputs: Optional[InputsType] = None) -> None:
"""
Validate that workflow can run without a trigger.

If workflow has IntegrationTrigger(s) but no ManualTrigger, it requires a trigger instance.
If workflow has both, filter entrypoints to ManualTrigger path only.

Special case: If workflow has exactly one IntegrationTrigger, no ManualTrigger, and no inputs
are provided, automatically instantiate the trigger with empty kwargs and use it as the entrypoint.

Args:
inputs: The inputs provided to the workflow run

Raises:
WorkflowInitializationException: If workflow requires trigger but none was provided
"""
Expand All @@ -388,6 +394,15 @@ def _validate_no_trigger_provided(self) -> None:

if workflow_integration_triggers:
if not self._has_manual_trigger():
# Special case: If exactly one IntegrationTrigger and no inputs provided,
if len(workflow_integration_triggers) == 1 and inputs is None:
trigger_class = workflow_integration_triggers[0]
default_trigger = trigger_class()
self._validate_and_bind_trigger(default_trigger)
self._filter_entrypoints_for_trigger(default_trigger)
self._trigger = default_trigger
Comment on lines 395 to +403

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Binding empty trigger fills state with descriptors instead of failing

The auto-trigger path instantiates a blank IntegrationTrigger and immediately calls _validate_and_bind_trigger on it. Because the instance has no attributes, BaseTrigger.bind_to_state iterates the class descriptors and writes those TriggerAttributeReference objects directly into state.meta.trigger_attributes. As a result, workflows that actually reference trigger attributes (e.g., SimpleSlackWorkflow.message) will read those descriptors and happily format strings like "SlackMessageTrigger.message" instead of raising the expected "Missing trigger attribute" error, so the workflow is marked fulfilled when it should be rejected. The new tests added in this change (test_single_trigger_with_attribute_references_still_fails) will therefore fail, and the original bug remains. Consider skipping _validate_and_bind_trigger for the default trigger or detecting missing required attributes before binding so that attribute access still raises a NodeException.

Useful? React with 👍 / 👎.

return

# Workflow has ONLY IntegrationTrigger - this is an error
raise WorkflowInitializationException(
message="Workflow has IntegrationTrigger which requires trigger parameter",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
"""Tests for default entrypoint behavior when no trigger is provided."""

from tests.workflows.integration_trigger_execution.nodes.slack_message_trigger import SlackMessageTrigger
from tests.workflows.integration_trigger_execution.workflows.multi_trigger_workflow import MultiTriggerWorkflow
from tests.workflows.integration_trigger_execution.workflows.routing_only_workflow import RoutingOnlyWorkflow
from tests.workflows.integration_trigger_execution.workflows.simple_workflow import SimpleSlackWorkflow


def test_single_trigger_no_inputs_defaults_to_entrypoint():
"""
Tests that workflow with single IntegrationTrigger and no inputs defaults to entrypoint.
"""

workflow = RoutingOnlyWorkflow()

# WHEN we run the workflow without trigger and without inputs
result = workflow.run()

# THEN it should execute successfully
assert result.name == "workflow.execution.fulfilled"

assert result.outputs.result == "Workflow executed successfully"


def test_single_trigger_with_attribute_references_still_fails():
"""
Tests that workflow referencing trigger attributes still fails without trigger data.
"""

# GIVEN a workflow with SlackMessageTrigger that references trigger attributes
workflow = SimpleSlackWorkflow()

# WHEN we run the workflow without trigger and without inputs
result = workflow.run()

assert result.name == "workflow.execution.rejected"

assert "Missing trigger attribute" in result.body.error.message


def test_multiple_triggers_no_inputs_uses_manual_path():
"""
Tests that workflow with multiple triggers and no inputs uses ManualTrigger path.
"""

# GIVEN a workflow with both ManualTrigger and IntegrationTrigger
workflow = MultiTriggerWorkflow()

# WHEN we run the workflow without trigger and without inputs
result = workflow.run()

# THEN it should execute successfully via ManualTrigger path (existing behavior)
assert result.name == "workflow.execution.fulfilled"

assert result.outputs.manual_result == "Manual execution"


def test_explicit_trigger_param_works_unchanged():
"""
Tests that providing explicit trigger parameter still works as before.
"""

workflow = SimpleSlackWorkflow()

# AND a valid Slack trigger instance
trigger = SlackMessageTrigger(
message="Explicit trigger test",
channel="C123456",
user="U789012",
)

# WHEN we run the workflow with the trigger
result = workflow.run(trigger=trigger)

# THEN it should execute successfully
assert result.name == "workflow.execution.fulfilled"

# AND the node should have access to trigger outputs
assert result.outputs.result == "Received 'Explicit trigger test' from channel C123456"


def test_single_trigger_no_inputs_stream_works():
"""
Tests that workflow.stream() with single IntegrationTrigger and no inputs works.
"""

workflow = RoutingOnlyWorkflow()

events = list(workflow.stream())

# THEN we should get workflow events
assert len(events) > 0

# AND the final event should be fulfilled
last_event = events[-1]
assert last_event.name == "workflow.execution.fulfilled"

assert last_event.outputs.result == "Workflow executed successfully"
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,18 @@ def test_stream_execution_with_trigger_event():


def test_error_when_trigger_event_missing():
"""Test that workflow raises error when IntegrationTrigger present but trigger missing."""
# GIVEN a workflow with SlackMessageTrigger
"""Test that workflow with IntegrationTrigger that references attributes fails without trigger data."""
# GIVEN a workflow with SlackMessageTrigger that references trigger attributes
workflow = SimpleSlackWorkflow()

# WHEN we run the workflow without trigger
# THEN it should raise WorkflowInitializationException
with pytest.raises(WorkflowInitializationException) as exc_info:
workflow.run()
result = workflow.run()

# THEN it should be rejected due to missing trigger attributes
assert result.name == "workflow.execution.rejected"

assert "IntegrationTrigger" in str(exc_info.value)
assert "trigger" in str(exc_info.value)
# AND the error should indicate missing trigger attribute
assert "Missing trigger attribute" in result.body.error.message


def test_error_when_trigger_event_provided_but_no_integration_trigger():
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
"""Workflow with IntegrationTrigger that doesn't reference trigger attributes."""

from vellum.workflows import BaseWorkflow
from vellum.workflows.nodes.bases import BaseNode

from tests.workflows.integration_trigger_execution.nodes.slack_message_trigger import SlackMessageTrigger


class ConstantOutputNode(BaseNode):
"""Node that returns a constant output without referencing trigger attributes."""

class Outputs(BaseNode.Outputs):
result: str

def run(self) -> Outputs:
return self.Outputs(result="Workflow executed successfully")


class RoutingOnlyWorkflow(BaseWorkflow):
"""Workflow with IntegrationTrigger used only for routing, not for data access."""

graph = SlackMessageTrigger >> ConstantOutputNode

class Outputs(BaseWorkflow.Outputs):
result = ConstantOutputNode.Outputs.result