-
Notifications
You must be signed in to change notification settings - Fork 2k
Expand file tree
/
Copy pathworker.py
More file actions
211 lines (161 loc) · 7.42 KB
/
Copy pathworker.py
File metadata and controls
211 lines (161 loc) · 7.42 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
# Copyright (c) Microsoft. All rights reserved.
"""Worker that hosts a MAF Workflow composed of a nested sub-workflow.
This sample shows workflow *composition* on the Durable Task host. A
``WorkflowExecutor`` embeds an inner workflow as a node inside an outer workflow.
``DurableAIAgentWorker.configure_workflow`` walks the composition and
auto-registers a durable orchestration for *each* workflow:
- ``dafx-sentiment_analysis`` - the inner workflow, run as a durable **child
orchestration** whenever the outer workflow reaches the ``WorkflowExecutor`` node.
- ``dafx-review_pipeline`` - the outer workflow.
Each workflow's agent executors become durable entities and its non-agent
executors become durable activities, scoped per workflow so the same executor id
in two workflows never collides.
Composition layout::
review_pipeline (outer)
intake (executor)
-> sentiment_sub = WorkflowExecutor(sentiment_analysis)
sentiment_agent (agent) -> sentiment_formatter (executor)
-> reporter (executor)
The inner workflow yields a string; because ``allow_direct_output`` is left at its
default (``False``), that output is forwarded to the outer workflow as a message
delivered to ``reporter``, which produces the final result.
Prerequisites:
- Set ``FOUNDRY_PROJECT_ENDPOINT`` and ``FOUNDRY_MODEL``.
- Sign in with Azure CLI (``az login``) for ``AzureCliCredential``.
- Start a Durable Task Scheduler (e.g. the DTS emulator on ``localhost:8080``).
Run the worker (this process), then run ``client.py`` in another process.
"""
import asyncio
import logging
import os
from typing import Any
from agent_framework import (
Agent,
AgentExecutorResponse,
Executor,
Workflow,
WorkflowBuilder,
WorkflowContext,
WorkflowExecutor,
handler,
)
from agent_framework.azure import DurableAIAgentWorker
from agent_framework.foundry import FoundryChatClient, FoundryChatOptions
from azure.identity import AzureCliCredential
from azure.identity.aio import AzureCliCredential as AsyncAzureCliCredential
from dotenv import load_dotenv
from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker
from pydantic import BaseModel, ValidationError
from typing_extensions import Never
load_dotenv()
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
SENTIMENT_AGENT_NAME = "SentimentAgent"
INNER_WORKFLOW_NAME = "sentiment_analysis"
OUTER_WORKFLOW_NAME = "review_pipeline"
SENTIMENT_INSTRUCTIONS = (
"You classify the sentiment of a customer product review. "
"Return JSON with fields sentiment (one of 'positive', 'neutral', 'negative') "
"and confidence (a number between 0 and 1)."
)
class SentimentResult(BaseModel):
"""Structured output from the sentiment agent."""
sentiment: str
confidence: float
class SentimentFormatterExecutor(Executor):
"""Inner-workflow executor that turns the agent's JSON into a summary line."""
@handler
async def format_sentiment(self, agent_response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None:
text = agent_response.agent_response.text
try:
result = SentimentResult.model_validate_json(text)
summary = f"{result.sentiment} (confidence {result.confidence:.0%})"
except ValidationError:
summary = "unknown (could not parse sentiment)"
await ctx.yield_output(summary)
class IntakeExecutor(Executor):
"""Outer-workflow entry point that normalizes the review before analysis."""
@handler
async def intake(self, review: str, ctx: WorkflowContext[str]) -> None:
normalized = review.strip()
logger.info("Intake received review (%d chars)", len(normalized))
await ctx.send_message(normalized)
class ReporterExecutor(Executor):
"""Outer-workflow executor that consumes the sub-workflow's forwarded output."""
@handler
async def report(self, sentiment_summary: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(f"Review analysis complete -> sentiment: {sentiment_summary}")
def _create_chat_client() -> FoundryChatClient:
"""Create an Azure AI Foundry chat client using AzureCliCredential."""
return FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AsyncAzureCliCredential(),
)
def create_inner_workflow(chat_client: FoundryChatClient) -> Workflow:
"""Build the inner ``sentiment_analysis`` workflow (agent -> formatter)."""
sentiment_agent = Agent(
client=chat_client,
name=SENTIMENT_AGENT_NAME,
instructions=SENTIMENT_INSTRUCTIONS,
default_options=FoundryChatOptions[Any](response_format=SentimentResult),
)
sentiment_formatter = SentimentFormatterExecutor(id="sentiment_formatter")
return (
WorkflowBuilder(name=INNER_WORKFLOW_NAME, start_executor=sentiment_agent)
.add_edge(sentiment_agent, sentiment_formatter)
.build()
)
def create_workflow() -> Workflow:
"""Build the outer ``review_pipeline`` workflow that embeds the inner workflow."""
chat_client = _create_chat_client()
inner_workflow = create_inner_workflow(chat_client)
intake = IntakeExecutor(id="intake")
# WorkflowExecutor embeds the inner workflow as a single node in the outer
# workflow. On the durable host this node runs as a child orchestration.
sentiment_sub = WorkflowExecutor(inner_workflow, id="sentiment_sub")
reporter = ReporterExecutor(id="reporter")
return (
WorkflowBuilder(name=OUTER_WORKFLOW_NAME, start_executor=intake)
.add_edge(intake, sentiment_sub)
.add_edge(sentiment_sub, reporter)
.build()
)
def get_worker(
taskhub: str | None = None, endpoint: str | None = None, log_handler: logging.Handler | None = None
) -> DurableTaskSchedulerWorker:
"""Create a configured DurableTaskSchedulerWorker."""
taskhub_name = taskhub or os.getenv("TASKHUB", "default")
endpoint_url = endpoint or os.getenv("ENDPOINT", "http://localhost:8080")
credential = None if endpoint_url == "http://localhost:8080" else AzureCliCredential()
return DurableTaskSchedulerWorker(
host_address=endpoint_url,
secure_channel=endpoint_url != "http://localhost:8080",
taskhub=taskhub_name,
token_credential=credential,
log_handler=log_handler,
)
def setup_worker(worker: DurableTaskSchedulerWorker) -> DurableAIAgentWorker:
"""Register the outer workflow and its nested sub-workflow on the worker."""
agent_worker = DurableAIAgentWorker(worker)
workflow = create_workflow()
# A single call walks the composition: it registers the outer workflow plus
# every nested sub-workflow (here, sentiment_analysis) as its own durable
# orchestration, deduped by workflow name.
agent_worker.configure_workflow(workflow)
logger.info("✓ Configured workflow '%s' with embedded sub-workflow '%s'", OUTER_WORKFLOW_NAME, INNER_WORKFLOW_NAME)
return agent_worker
async def main() -> None:
"""Start the worker and block until interrupted."""
worker = get_worker()
setup_worker(worker)
logger.info("Worker is ready and listening for work items. Press Ctrl+C to stop.")
try:
worker.start()
while True:
await asyncio.sleep(1)
except KeyboardInterrupt:
logger.info("Worker shutdown initiated")
logger.info("Worker stopped")
if __name__ == "__main__":
asyncio.run(main())