Skip to content

Proposal: Deprecate dapr-agents @workflow and @task decorators in favor of native Dapr Workflows decorators #227

@bibryam

Description

@bibryam

Background

The Dapr Python SDK provides two primary ways to create workflows:

  1. Legacy Client Approach (now deprecated):
client = DaprClient()
client.invoke_workflow(...)
  1. Workflow Extension (Preferred):
wfr = WorkflowRuntime()

@wfr.workflow(name="task_chain_workflow")
def my_workflow(ctx: DaprWorkflowContext):
    result = yield ctx.call_activity(my_activity)
    return result

@wfr.activity(name="activity_2")
def my_activity(ctx):
    return "activity output"

dapr-agents introduces a third abstraction layer with custom decorators:

@workflow(name="task_chain_workflow")
def my_workflow(ctx: DaprWorkflowContext):
    result = yield ctx.call_activity(get_character)
    return result

@task(description="Pick a random character from The Lord of the Rings")
def get_character() -> str:
    pass

Based on user feedback and my analysis, this third approach creates more complexity than benefits it offers.

Limitations and Complexity Issues

1. Limited Runtime Control and Configuration

Impact: Lost Access to Core Dapr Features

The dapr-agents approach abstracts away the WorkflowRuntime and DaprWorkflowClient, making core Dapr capabilities inaccessible:

# dapr-agents abstracts this away:
# wfr = WorkflowRuntime()  # Hidden in WorkflowApp
# wf_client = DaprWorkflowClient()  # Hidden in WorkflowApp

Some Limitations:

  • Single Runtime Pattern: One WorkflowApp manages everything vs multiple isolated runtimes with native SDK
  • Lost Runtime Control: No access to wfr instance for advanced configuration, such as: custom retry policies per activity, fine-grained timeout control
  • Hidden Client Initialization: Automatic DaprWorkflowClient creation prevents custom setup, manual workflow scheduling with custom instance IDs
  • Automatic Runtime Lifecycle: Can't control start/shutdown timing for integration scenarios

2. Cannot Safely Combine @task and @activity

Real-World Problem: Most workflows combine LLM and non-LLM tasks:

# This FAILS - incompatible registration approaches:
wfr = WorkflowRuntime()

# Native SDK activities
@wfr.activity(name="database_lookup")  
def lookup_user_data(ctx):
    # Direct database call - no LLM needed
    return database.get_user(user_id)

# dapr-agents tasks
@task(description="Generate personalized content")  
def generate_content(user_data: str) -> str:
    pass  # LLM call via @task

3. Hidden Dependencies and Magic Behavior

Automatic OpenAI Dependency Creation:

# In WorkflowTask.model_post_init():
if self.description and not self.llm:
    try:
        self.llm = OpenAIChatClient()  # Hidden dependency!
    except Exception as e:
        logger.warning(f"Could not create default OpenAI client: {e}")

Problems:

  • ✅ Implicit dependency when using description="" parameter
  • ✅ Hidden import: from dapr_agents.llm.openai import OpenAIChatClient
  • ✅ Silent failure if OpenAI environment variables missing

Proposal

Deprecate and Remove:

  • @workflow() decorator
  • @task() decorator
  • WorkflowApp workflow lifecycle management

Offer Alternative Approach

Provide Helper Methods and Documentation:

Instead of adding new workflow and task decorators in dapr-agents, we should provide lightweight helper methods and utilities inside the existing LLM clients and Agent APIs. These helpers would make it simple to call LLMs directly inside a Dapr activity without introducing hidden dependencies or runtime abstractions.

For example, dapr-agents could expose a convenience method such as llm.run_prompt() or llm.ask() that works seamlessly when invoked inside an activity. This allows developers to write workflows with the native WorkflowRuntime API, register activities normally, and still take advantage of dapr-agents LLM integrations when needed.

This strategy keeps runtime control in the hands of the developer, avoids mixing registration models (@task vs @activity), and ensures dependencies remain explicit. It also reduces complexity for debugging and makes documentation easier: we can show clear patterns where non-LLM activities and LLM-powered activities coexist naturally.

Benefits

  1. Full Dapr Control: Access to all native WorkflowRuntime capabilities
  2. Safe Task Combination: Mix LLM and non-LLM activities seamlessly
  3. Explicit Dependencies: Clear LLM client imports and setup, easier to learn, and maintain.
  4. Simpler Debugging: Direct stack traces without abstraction layers

Future Exploration

After deprecation, explore if a minimal activity annotation variation could provide LLM convenience without the limitations:

@wfr.activity(name="content_generation")
@llm_enabled(description="Generate marketing content")  
def generate_content(ctx, topic: str) -> str:
    pass  # Optional: Pure LLM calls where beneficial

This would need to:

  • ✅ Preserve runtime control
  • ✅ Work alongside native @wfr.activity
  • ✅ Avoid hidden dependencies

Implementation approach

Phase 1: Deprecation and Helper Methods

  • Mark @workflow and @task as deprecated

  • Add WorkflowHelpers module for common LLM patterns

  • Update documentation and quickstarts

  • Show native Dapr integration patterns

Phase 3: Removal

  • Remove deprecated decorators
  • Clean up WorkflowApp workflow management
  • Focus on LLM client and agent utilities only

Conclusion

While the @workflow and @task abstractions demonstrate the powerful patterns possible with dapr-agents (as shown in the building effective Dapr Agents article), they introduce complexity that outweighs their benefits.

The proposed approach preserves all the capabilities demonstrated in those patterns while maintaining compatibility with native Dapr workflows and providing users full control over their workflow runtime and configuration.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

Status

We're Working On It

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions