Function Calls through Dependency Injection in Microsoft Agent Framework

My previous article on Microsoft Agent Framework (MAF) was focused on Dependency Injection (DI) in MAF.
In that article I demonstrated how AIToolscan be registered in the service container through DI and use them in the Tools property of ChatOptions.
The following code implements this behavior:
servicecollection.AddSingleton<AIAgent>(sp =>
{
return new ChatClientAgent(
chatClient: sp.GetRequiredKeyedService<IChatClient>("ChatClient"),
options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
Instructions = "You are a helpful AI assistant.You must call the provided function to greet users and return the EXACT output of the function without modifying it.",
Tools = sp.GetRequiredService<UserPlugin>().AsAITools().ToList()
},
}
);
});
The UserPluginclass above, is registered in the DI container (serviceCollection) and exposes its functions through customAsAITools()method and returns an IEnumerable<AITool>.
public IEnumerable<AITool> AsAITools()
{
yield return AIFunctionFactory.Create(Function_1);
yield return AIFunctionFactory.Create(Function_2);
.
.
.
.
yield return AIFunctionFactory.Create(Function_N);
}
The function execution is indirectly driven by the user prompt and relies on the model’s interpretation rather than explicit control.
LLM's are designed to understand intent driven through user input and infer from natural language.
Instead of relying on plugins we could centralize function calling through DI and register a AI-callable function.
Lets assume a use case.
Consider a chatbot integrated with an ERP system. Once a user logs in the user should be made aware of access level to the underlying ERP modules and the data they are allowed to query.
In this case the bot cannot rely on user inputs. Instead it should understand it from the logged in user context and then through the LLM layer be able to return the details of the user access levels.
Below we have is a function that returns access level of a logged in user. Of course this is highly simplified and the details are hardcoded for brevity. Ideally, in a production set up the following details should come from databases or API's.
Code
public static Func<string, string[]> AccessDetails = (args) =>
{
switch (args)
{
case "superadmin@azureguru.net":
return
[
"Admin Module >> Full Access",
"Sales Module >> Full Access",
"Marketing Module >> Full Access",
"Accounts Module >> Full Access"
];
break;
case "superuser@azureguru.net":
return
[
"Admin Module >> No Access",
"Sales Module >> Full Access",
"Marketing Module >> Full Access",
"Accounts Module >> Full Access"
];
break;
case "sales@azureguru.net":
return
[
"Admin Module >> No Access",
"Sales Module >> Full Access",
"Marketing Module >> Partial Access",
"Accounts Module >> No Access"
];
break;
case "marketing@azureguru.net":
return
[
"Admin Module >> No Access",
"Sales Module >> Partial Access",
"Marketing Module >> Full Access",
"Accounts Module >> No Access"
];
break;
default:
return
[
"",
"Unknown User >> Please get in touch with Admin for module access"
];
}
};
In my previous article, we registered a plugin that exposes functions as tools in a DI container. In this one, we will register a AI-callable function(AIFunction) in the DI container.
Let’s see how it works.
First we use ConfigurationBuilderto get the appsettings.json file
var configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", optional: false)
.Build();
and read the AzureOpenAI API key to fetch the API keys and set the credentials.
var credential = new AzureKeyCredential(configuration["AppSettings:ApiKey"]);
Next, we register a named keyed chat client in DI that sets the required values from appsettings.json with credentials.
servicecollection.AddKeyedChatClient("ChatClient", (sp) => new AzureOpenAIClient(
new Uri(configuration["AppSettings:EndPoint"]), credential) .GetChatClient(configuration["AppSettings:Chat_DeploymentName"]).AsIChatClient());
In the next step, we configure an AIAgent as a ChatClientAgent and register it with DI container. Also we set up the necessary ChatOptions.
servicecollection.AddSingleton<AIAgent>(sp =>
{
return new ChatClientAgent(
chatClient: sp.GetRequiredKeyedService<IChatClient>("ChatClient"),
options: new ChatClientAgentOptions
{
ChatOptions = new ChatOptions
{
Instructions = "You are a helpful AI assistant.You must call the provided function to greet users and return the EXACT output of the function without modifying it."
},
}
);
});
Note : In the above code unlike the plugin-based approach that was used in the previous article we are not registering or resolving plugins as
Toolsthrough DI.
Now comes the most important part. Registering the AI callable function in DI.
servicecollection.AddSingleton<AIFunction>(sp =>
{
return AIFunctionFactory.Create(
(Func<AIFunctionArguments, string[]>)((args) =>
{
string? userlogin = args.TryGetValue("userlogin", out var c)? c?.ToString(): "Unknown";
return AccessDetails(userlogin!);
})
);
});
Above, we register a AIFunction through factory and create the function at runtime and wrapping it.
Now you might ask what advantages would this approach have ?
The biggest advantage is that the we have centralized the routing logic. This is very useful when you don't want prompt driven function execution. Recall our use case, we don't want the LLM to control the function execution to return the access details of the logged in user.
Lets take a step back and allow me to explain it through a simple example .
Assume we have two functions that take completely different set of arguments and return a collection of string[] andint[] with arguments being string and int respectively.
Lets call them FuncAand FuncB.
FuncA
public static Func<string, string[]> FuncA= (args) =>
{
return
[
"a",
"b",
"c"
];
};
FuncB
public static Func<int ,int[]> FuncB= (args) =>
{
return
[
1,
2,
3
];
};
The conventional approach i.e. direct method call, the caller should aware of the function and its execution path.
But with this approach of function style routing, the caller has to only pass the arguments and the function decides which underlying method to execute based on passed arguments.
The following code registers functions FuncA and FuncB in the DI container.
servicecollection.AddSingleton(sp =>
{
return
(Func<Dictionary<string, object>, object>)((args) =>
{
string? FuncA_arg = args.TryGetValue("FuncA_arg", out var c) ? c?.ToString() : "Unknown";
if (FuncA_arg != "Unknown") return FuncA(c!.ToString()!);
int FuncB_arg = args.TryGetValue("FuncB_arg", out var c1) ? (int)c1! : 0;
if (FuncB_arg != 0) return FuncB((int)c1!);
return null;
});
});
The function expects Dictionary<string, object> as input of type and returns an object. Instead of object for values you can also use a CustomType<T> .
Example :Dictionary<string, MyClass>
Call to the above is as follows
var arg = new Dictionary<string, object>
{
["FuncA_arg"] = "hello"
};
var fn = serviceProvider.GetRequiredService<Func<Dictionary<string, object>, object>>();
var results= fn.Invoke(arg) as string[];
foreach (var value in results!)
{
Console.WriteLine(value);
}
Based on the arguments passed, the DI container decides the function execution.
Our argument above is of type Dictionary<string, object> with value "hello".
var arg = new Dictionary<string, object>
{
["FuncA_arg"] = "hello"
};
We then ask the DI container for a function that matches a specific signature.
var fn = serviceProvider.GetRequiredService<Func<Dictionary<string, object>, object>>();
Based on the arguments passed the function determines which logic to execute
string? FuncA_arg = args.TryGetValue("FuncA_arg", out var c) ? c?.ToString() : "Unknown";
if (FuncA_arg != "Unknown") return FuncA(c!.ToString()!);
It prints the collection from Func_A
Similarly we invoke function FuncB with arguments
var arg = new Dictionary<string, object>
{
["FuncB_arg"] = 1
};
var arg = new Dictionary<string, object>
{
["FuncB_arg"] = 1
};
var results= fn.Invoke(arg) as int[];
foreach (var value in results!)
{
Console.WriteLine(value);
}
and we get the following output
To sum up we have centralized the entry point and routing logic .I hope it makes sense.
Now lets get back to our example of AI callable function in DI for MAF.
After we register the AIFunction through AIFunctionfactory, we build the service provider.
var serviceProvider = servicecollection.BuildServiceProvider();
We save the logged user value in a variable that acts as an argument to the callable function.
string loggedinuser = "superadmin@azureguru.net";
We create an agent instance through the DI container and also create an agent session.
var agent = serviceProvider.GetRequiredService<AIAgent>();
AgentSession session = await agent.CreateSessionAsync();
Next, we pass the required arguments as type AIFunctionArguments
var arg = new AIFunctionArguments
{
["userlogin"] = loggedinuser
};
We then resolve and invoke the function and display the output to the screen
var fn = serviceProvider.GetRequiredService<AIFunction>().InvokeAsync(arg);
We add the output to the agent context through agent.RunAsync and the output is stored in the session Statebagthrough InMemoryChatHistoryProvider
Console.WriteLine("");
Console.WriteLine("You have the following module access");
Console.WriteLine(await agent.RunAsync($"Access Details: {fn.Result}: Explain in markdown format", session));
Soon I will be posting an article on Statebag and ChatHistoryProviders in MAF
If you want the output be displayed in its raw format you can just use
Console.WriteLine(fn.Result);
Output >>
Conclusion
In this article, I tried to demonstrated how you could use DI to register your AIFunctions. While it may add some overhead for simple scenarios, it becomes increasingly valuable as applications grow in complexity and manage dependencies through modular and centralized approach.
Thanks for reading !!!



