Skip to content

Conversation

@yileicn
Copy link
Member

@yileicn yileicn commented Jan 23, 2026

PR Type

Enhancement


Description

  • Convert synchronous routing context methods to async Task-based methods

  • Replace synchronous GetAgent method with async Task<Agent?> GetAgent

  • Update IRoutingService methods to return Task-wrapped results

  • Make IHttpRequestHook.OnAddHttpHeaders async Task method

  • Update all call sites to await async method invocations


Diagram Walkthrough

flowchart LR
  IRoutingContext["IRoutingContext<br/>Sync Methods"]
  IRoutingService["IRoutingService<br/>Sync Methods"]
  IBotSharpRepository["IBotSharpRepository<br/>GetAgent Sync"]
  IHttpRequestHook["IHttpRequestHook<br/>Sync Method"]
  
  IRoutingContext -->|"Convert to async Task"| IRoutingContextAsync["IRoutingContext<br/>Async Task Methods"]
  IRoutingService -->|"Convert to async Task"| IRoutingServiceAsync["IRoutingService<br/>Async Task Methods"]
  IBotSharpRepository -->|"Remove sync, consolidate"| IBotSharpRepositoryAsync["IBotSharpRepository<br/>Async GetAgent"]
  IHttpRequestHook -->|"Convert to async Task"| IHttpRequestHookAsync["IHttpRequestHook<br/>Async Task Method"]
  
  IRoutingContextAsync -->|"Update call sites"| CallSites["All Call Sites<br/>Await Invocations"]
  IRoutingServiceAsync -->|"Update call sites"| CallSites
  IBotSharpRepositoryAsync -->|"Update call sites"| CallSites
  IHttpRequestHookAsync -->|"Update call sites"| CallSites
Loading

File Walkthrough

Relevant files
Enhancement
33 files
IHttpRequestHook.cs
Make OnAddHttpHeaders method async Task                                   
+1/-1     
IBotSharpRepository.cs
Remove sync GetAgent, consolidate to async GetAgent           
+1/-3     
IRoutingContext.cs
Convert Push, Pop, PopTo, Replace, Empty to async Task     
+5/-5     
IRoutingService.cs
Make GetRoutableAgents, GetRulesByAgentName/Id async Task
+4/-4     
BasicAgentHook.cs
Update to await async GetUtilityContent and GetAgent calls
+7/-9     
AgentService.GetAgents.cs
Replace GetAgentAsync call with GetAgent async method       
+1/-1     
AgentService.UpdateAgent.cs
Replace GetAgentAsync calls with GetAgent async method     
+2/-2     
ConversationService.SendMessage.cs
Await async routing context Push and Pop methods                 
+3/-3     
ConversationService.cs
Replace GetAgentAsync with GetAgent async method call       
+1/-1     
FileRepository.Agent.cs
Convert GetAgent to async Task, remove GetAgentAsync wrapper
+4/-9     
FallbackToRouterFn.cs
Await async PopTo routing context method                                 
+1/-1     
RouteToAgentFn.cs
Await async Push and HasMissingRequiredField method calls
+4/-4     
RoutingAgentHook.cs
Await async GetRoutableAgents and GetRulesByAgentId calls
+4/-4     
HFReasoner.cs
Await async FixMalformedResponse and context Push, Empty calls
+5/-5     
NaiveReasoner.cs
Await async FixMalformedResponse, Pop, Empty method calls
+5/-5     
OneStepForwardReasoner.cs
Await async FixMalformedResponse, Pop, Empty method calls
+5/-5     
ReasonerHelper.cs
Convert FixMalformedResponse to async Task method               
+4/-3     
RoutingContext.cs
Convert all routing context methods to async Task-based   
+19/-19 
RoutingService.HasMissingRequiredField.cs
Convert HasMissingRequiredField to async Task with tuple return
+20/-11 
RoutingService.InstructLoop.cs
Await async Push routing context method                                   
+1/-1     
RoutingService.cs
Convert GetRoutableAgents, GetRulesByAgentName/Id to async Task
+11/-9   
UserService.Token.cs
Convert BuildToken and GenerateJwtToken to async Task methods
+12/-12 
TranslationController.cs
Replace GetAgentAsync calls with GetAgent async method     
+2/-2     
WelcomeHook.cs
Replace GetAgentAsync call with GetAgent async method       
+1/-1     
HandleHttpRequestFn.cs
Await async PrepareRequestHeaders method call                       
+3/-3     
BasicHttpRequestHook.cs
Convert OnAddHttpHeaders to async Task method                       
+2/-1     
MongoRepository.Agent.cs
Remove sync GetAgent, consolidate to async GetAgent method
+1/-13   
SequentialPlanner.cs
Await async Empty and Pop routing context method calls     
+5/-5     
SqlGenerationPlanner.cs
Await async FixMalformedResponse and Empty method calls   
+3/-3     
TwoStageTaskPlanner.cs
Await async FixMalformedResponse and Empty method calls   
+3/-3     
SqlDriverCrontabHook.cs
Await async Push routing context method call                         
+1/-1     
TwilioMessageQueueService.cs
Convert GetHints to async Task, await GetAgent call           
+3/-3     
TwilioStreamMiddleware.cs
Await async Push routing context method call                         
+1/-1     

@qodo-code-review
Copy link

qodo-code-review bot commented Jan 23, 2026

PR Compliance Guide 🔍

Below is a summary of compliance checks for this PR:

Security Compliance
🟢
No security concerns identified No security vulnerabilities detected by AI analysis. Human verification advised for critical code.
Ticket Compliance
🎫 No ticket provided
  • Create ticket/issue
Codebase Duplication Compliance
Codebase context is not defined

Follow the guide to enable codebase context checks.

Custom Compliance
🟢
Generic: Comprehensive Audit Trails

Objective: To create a detailed and reliable record of critical system actions for security analysis
and compliance.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Meaningful Naming and Self-Documenting Code

Objective: Ensure all identifiers clearly express their purpose and intent, making code
self-documenting

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Error Handling

Objective: To prevent the leakage of sensitive system information through error messages while
providing sufficient detail for internal debugging.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Secure Logging Practices

Objective: To ensure logs are useful for debugging and auditing without exposing sensitive
information like PII, PHI, or cardholder data.

Status: Passed

Learn more about managing compliance generic rules or creating your own custom rules

🔴
Generic: Robust Error Handling and Edge Case Management

Objective: Ensure comprehensive error handling that provides meaningful context and graceful
degradation

Status:
Missing null checks: The new async HasMissingRequiredField deserializes message.FunctionArgs and uses its
fields without guarding against null/invalid JSON, which can cause runtime exceptions
instead of graceful handling.

Referred Code
public async Task<(bool hasMissing, string reason, string agentId)> HasMissingRequiredField(RoleDialogModel message)
{
    var reason = string.Empty;
    var agentId = string.Empty;
    var args = JsonSerializer.Deserialize<RoutingArgs>(message.FunctionArgs);
    var routing = _services.GetRequiredService<IRoutingService>();

    var routingRules = routing.GetRulesByAgentName(args.AgentName);

Learn more about managing compliance generic rules or creating your own custom rules

Generic: Security-First Input Validation and Data Handling

Objective: Ensure all data inputs are validated, sanitized, and handled securely to prevent
vulnerabilities

Status:
Unvalidated request input: The controller uses model.Text.Split("\r\n") without validating that model and
model.Text are non-null, allowing malformed requests to trigger exceptions rather than
being rejected cleanly.

Referred Code
[HttpPost("/translate")]
public async Task<TranslationResponseModel> Translate([FromBody] TranslationRequestModel model)
{
    var db = _services.GetRequiredService<IBotSharpRepository>();
    var agent = await db.GetAgent(BuiltInAgentId.AIAssistant);
    var translator = _services.GetRequiredService<ITranslationService>();
    var states = _services.GetRequiredService<IConversationStateService>();
    states.SetState("max_tokens", "8192");
    var text = await translator.Translate(agent, Guid.NewGuid().ToString(), model.Text.Split("\r\n"), language: model.ToLang);

Learn more about managing compliance generic rules or creating your own custom rules

  • Update
Compliance status legend 🟢 - Fully Compliant
🟡 - Partial Compliant
🔴 - Not Compliant
⚪ - Requires Further Human Verification
🏷️ - Compliance label

@qodo-code-review
Copy link

qodo-code-review bot commented Jan 23, 2026

PR Code Suggestions ✨

Latest suggestions up to a3f27ab

CategorySuggestion                                                                                                                                    Impact
Possible issue
Fix null handling in filtering

Add a null check for the profiles list in GetRoutableAgents to prevent a
potential NullReferenceException during agent profile filtering.

src/Infrastructure/BotSharp.Core/Routing/RoutingService.cs [98-145]

 public async Task<RoutableAgent[]> GetRoutableAgents(List<string> profiles)
 {
     var db = _services.GetRequiredService<IBotSharpRepository>();
 
     var filter = new AgentFilter
     {
         Disabled = false
     };
 
     var agents = await db.GetAgents(filter);
     var routableAgents = agents.Where(x => x.Type == AgentType.Task || x.Type == AgentType.Planning || x.Type == AgentType.A2ARemote).Select(x => new RoutableAgent
     {
         AgentId = x.Id,
         Description = x.Description,
         ...
     }).ToArray();
 
     // Handle profile.
     // Router profile must match the agent profile
-    if (routableAgents.Length > 0 && profiles.Count > 0)
+    if (routableAgents.Length > 0 && profiles != null && profiles.Count > 0)
     {
         routableAgents = routableAgents.Where(x => x.Profiles != null &&
                 x.Profiles.Exists(x1 => profiles.Exists(y => x1 == y)))
             .ToArray();
     }
     else if (profiles == null || profiles.Count == 0)
     {
         routableAgents = routableAgents.Where(x => x.Profiles == null ||
             x.Profiles.Count == 0)
         .ToArray();
     }
 
     return routableAgents;
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 8

__

Why: The suggestion correctly identifies a potential NullReferenceException if profiles is null, as profiles.Count would be accessed. Adding a profiles != null check prevents this crash, improving the robustness of the agent routing logic.

Medium
Build JSON safely via parsing

Refactor the AppendPropertyToArgs methods to use JsonNode for JSON manipulation
instead of string concatenation to ensure the generated JSON is always valid and
robust.

src/Infrastructure/BotSharp.Core/Routing/RoutingService.HasMissingRequiredField.cs [114-123]

 private string AppendPropertyToArgs(string args, string key, string value)
 {
-    return args.Substring(0, args.Length - 1) + $", \"{key}\": \"{value}\"" + "}";
+    var node = JsonNode.Parse(args) as JsonObject ?? new JsonObject();
+    node[key] = value;
+    return node.ToJsonString();
 }
 
 private string AppendPropertyToArgs(string args, string key, IEnumerable<string> values)
 {
-    string fields = string.Join(",", values.Select(x => $"\"{x}\""));
-    return args.Substring(0, args.Length - 1) + $", \"{key}\": [{fields}]" + "}";
+    var node = JsonNode.Parse(args) as JsonObject ?? new JsonObject();
+    node[key] = new JsonArray(values.Select(v => (JsonNode?)v).ToArray());
+    return node.ToJsonString();
 }
  • Apply / Chat
Suggestion importance[1-10]: 7

__

Why: The suggestion correctly points out that building JSON via string manipulation is fragile and can lead to invalid JSON. Using a proper JSON library like System.Text.Json.Nodes is a much more robust approach, preventing potential runtime errors.

Medium
Add null guards in helper

Add null checks for the args and agentsResult.Items variables in
FixMalformedResponse to prevent NullReferenceException and improve the
robustness of the routing logic.

src/Infrastructure/BotSharp.Core/Routing/Reasoning/ReasonerHelper.cs [9-30]

 public static async Task FixMalformedResponse(IServiceProvider services, FunctionCallFromLlm args)
 {
+    if (args == null) return;
+
     var agentService = services.GetRequiredService<IAgentService>();
     var agentsResult = await agentService.GetAgents(new AgentFilter
     {
         Types = [AgentType.Task]
     });
-    var agents = agentsResult.Items.ToList();
+
+    var agents = agentsResult?.Items?.ToList() ?? [];
     var malformed = false;
 
     // Sometimes it populate malformed Function in Agent name
     if (!string.IsNullOrEmpty(args.Function) &&
         args.Function == args.AgentName)
     {
         args.Function = "route_to_agent";
         malformed = true;
     }
 
     // Another case of malformed response
     if (string.IsNullOrEmpty(args.AgentName) &&
         agents.Select(x => x.Name).Contains(args.Function))
     ...
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 7

__

Why: The suggestion correctly identifies that args from an LLM and agentsResult.Items from the repository could be null. Adding null checks makes the FixMalformedResponse helper more robust and prevents potential crashes in the critical routing path.

Medium
Learned
best practice
Await async hook execution

Await the hook emission so hooks finish before building hints and to surface any
exceptions; if HookEmitter.Emit is synchronous, consider adding/using an async
overload.

src/Plugins/BotSharp.Plugin.Twilio/Services/TwilioMessageQueueService.cs [161-167]

 private static async Task<string> GetHints(string agentId, AssistantMessage reply, IServiceProvider sp)
 {
     var agentService = sp.GetRequiredService<IAgentService>();
     var agent = await agentService.GetAgent(agentId);
     var extraWords = new List<string>();
-    HookEmitter.Emit<IRealtimeHook>(sp, hook => extraWords.AddRange(hook.OnModelTranscriptPrompt(agent)),
+    await HookEmitter.Emit<IRealtimeHook>(sp, hook => extraWords.AddRange(hook.OnModelTranscriptPrompt(agent)),
         agentId);
 
     ...
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 6

__

Why:
Relevant best practice - In async/concurrent code, ensure async operations are awaited so work completes deterministically and exceptions aren't lost.

Low
Add null/empty input guards

Guard message, message.FunctionArgs, and the deserialized args (and
args.AgentName) before use, returning (false, "", message.CurrentAgentId) when
missing to avoid runtime exceptions.

src/Infrastructure/BotSharp.Core/Routing/RoutingService.HasMissingRequiredField.cs [12-19]

 public async Task<(bool hasMissing, string reason, string agentId)> HasMissingRequiredField(RoleDialogModel message)
 {
     var reason = string.Empty;
-    var agentId = string.Empty;
+    var agentId = message?.CurrentAgentId ?? string.Empty;
+
+    if (message == null || string.IsNullOrWhiteSpace(message.FunctionArgs))
+        return (false, reason, agentId);
+
     var args = JsonSerializer.Deserialize<RoutingArgs>(message.FunctionArgs);
+    if (args == null || string.IsNullOrWhiteSpace(args.AgentName))
+        return (false, reason, agentId);
+
     var routing = _services.GetRequiredService<IRoutingService>();
-
     var routingRules = await routing.GetRulesByAgentName(args.AgentName);
 
     ...
 }

[To ensure code accuracy, apply this suggestion manually]

Suggestion importance[1-10]: 5

__

Why:
Relevant best practice - Add explicit null/empty guards before dereferencing/deserializing inputs and return safe defaults instead of throwing.

Low
  • More

Previous suggestions

✅ Suggestions up to commit 766477d
CategorySuggestion                                                                                                                                    Impact
Possible issue
Check entry ID before popping

Before calling routing.Context.PopTo, add a check to ensure
routing.Context.EntryAgentId is not null or empty to prevent unintended routing
stack modifications.

src/Infrastructure/BotSharp.Core/Routing/Functions/FallbackToRouterFn.cs [20]

-routing.Context.PopTo(routing.Context.EntryAgentId, "pop to entry agent");
+if (!string.IsNullOrEmpty(routing.Context.EntryAgentId))
+{
+    await routing.Context.PopTo(routing.Context.EntryAgentId, "pop to entry agent");
+}
Suggestion importance[1-10]: 7

__

Why: This suggestion correctly identifies a potential issue where PopTo could be called with a null or empty EntryAgentId, leading to unexpected behavior. Adding a guard clause improves the robustness of the routing logic.

Medium
Learned
best practice
Prevent duplicate hook side effects
Suggestion Impact:The commit replaces calls to `await Push(...)` with `_stack.Push(...)` in the Replace flow (and related routing logic), preventing the extra enqueue hook emission and thus avoiding duplicated hook side effects while still emitting only the replacement hook.

code diff:

@@ -194,13 +194,13 @@
 
         if (_stack.Count == 0)
         {
-            await Push(agentId);
+            _stack.Push(agentId);
         }
         else if (_stack.Peek() != agentId)
         {
             fromAgent = _stack.Peek();
             _stack.Pop();
-            await Push(agentId);
+            _stack.Push(agentId);
 
             await HookEmitter.Emit<IRoutingHook>(_services, async hook => await hook.OnAgentReplaced(fromAgent, toAgent, reason: reason),
                 agentId);

Replace currently calls Push, which emits OnAgentEnqueued, and then also emits
OnAgentReplaced, causing duplicated hook side effects; update Replace to only
emit the replacement hook and avoid invoking Push's enqueue logic.

src/Infrastructure/BotSharp.Core/Routing/RoutingContext.cs [199-207]

 else if (_stack.Peek() != agentId)
 {
     fromAgent = _stack.Peek();
     _stack.Pop();
-    await Push(agentId);
+    _stack.Push(agentId);
 
     await HookEmitter.Emit<IRoutingHook>(_services, async hook => await hook.OnAgentReplaced(fromAgent, toAgent, reason: reason),
         agentId);
 }

[Suggestion processed]

Suggestion importance[1-10]: 6

__

Why:
Relevant best practice - Avoid redundant/duplicated operations and side effects (e.g., duplicate hook emissions) when refactoring sync code to async.

Low
Add safe guards for deserialization

Guard against message/FunctionArgs being null/empty or deserialization returning
null to avoid runtime exceptions; return a safe default tuple when inputs are
invalid.

src/Infrastructure/BotSharp.Core/Routing/RoutingService.HasMissingRequiredField.cs [12-19]

 public async Task<(bool hasMissing, string reason, string agentId)> HasMissingRequiredField(RoleDialogModel message)
 {
     var reason = string.Empty;
-    var agentId = string.Empty;
-    var args = JsonSerializer.Deserialize<RoutingArgs>(message.FunctionArgs);
+    var agentId = message?.CurrentAgentId ?? string.Empty;
+
+    if (message == null || string.IsNullOrWhiteSpace(message.FunctionArgs))
+    {
+        return (false, reason, agentId);
+    }
+
+    RoutingArgs? args;
+    try
+    {
+        args = JsonSerializer.Deserialize<RoutingArgs>(message.FunctionArgs);
+    }
+    catch
+    {
+        return (false, reason, agentId);
+    }
+
+    if (args == null || string.IsNullOrWhiteSpace(args.AgentName))
+    {
+        return (false, reason, agentId);
+    }
+
     var routing = _services.GetRequiredService<IRoutingService>();
-
     var routingRules = routing.GetRulesByAgentName(args.AgentName);
Suggestion importance[1-10]: 5

__

Why:
Relevant best practice - Add explicit null/empty guards before dereferencing/deserializing inputs and return safe defaults instead of throwing.

Low
General
Avoid deadlock on awaits

Add .ConfigureAwait(false) to the agentService.GetAgentOptions call to prevent
potential deadlocks by avoiding capturing the synchronization context.

src/Infrastructure/BotSharp.Core/Routing/RoutingContext.cs [90]

-var agents = await agentService.GetAgentOptions([agentId], byName: true);
+var agents = await agentService.GetAgentOptions([agentId], byName: true)
+                                .ConfigureAwait(false);
Suggestion importance[1-10]: 5

__

Why: The suggestion to use ConfigureAwait(false) is a good practice for library code to prevent deadlocks, but its impact is reduced in modern ASP.NET Core which has no synchronization context.

Low
Support cancellation tokens

Add an optional CancellationToken parameter to the Push method and other async
methods in IRoutingContext to allow for cancellation of the operations.

src/Infrastructure/BotSharp.Abstraction/Routing/IRoutingContext.cs [16]

-Task Push(string agentId, string? reason = null, bool updateLazyRouting = true);
+Task Push(string agentId, string? reason = null, bool updateLazyRouting = true, CancellationToken cancellationToken = default);
Suggestion importance[1-10]: 4

__

Why: Adding a CancellationToken is a good practice for async methods, but it requires significant refactoring across the codebase for a moderate benefit, as the operations are not typically long-running.

Low

@yileicn yileicn changed the title optimize IRoutingContext use async method optimize IRoutingContext,IRoutingService,IHttpRequestHook use async method Jan 23, 2026
@yileicn
Copy link
Member Author

yileicn commented Jan 23, 2026

/describe

@qodo-code-review
Copy link

PR Description updated to latest commit (a3f27ab)

@yileicn
Copy link
Member Author

yileicn commented Jan 23, 2026

/review /improve

@qodo-code-review
Copy link

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Concurrency

The routing stack is mutated across multiple new async methods (Push/Pop/PopTo/Replace/Empty) and now awaits hook emission inside those mutations. Validate that the routing context lifetime/scope guarantees single-threaded access per conversation/request, or add synchronization to prevent interleaving stack operations when multiple awaits/callers touch the same context concurrently.

public async Task Push(string agentId, string? reason = null, bool updateLazyRouting = true)
{
    // Convert id to name
    if (!Guid.TryParse(agentId, out _))
    {
        var agentService = _services.GetRequiredService<IAgentService>();
        var agents = await agentService.GetAgentOptions([agentId], byName: true);

        if (agents.Count > 0)
        {
            agentId = agents.First().Id;
        }
    }

    if (_stack.Count == 0 || _stack.Peek() != agentId)
    {
        var preAgentId = _stack.Count == 0 ? agentId : _stack.Peek();
        _stack.Push(agentId);

        await HookEmitter.Emit<IRoutingHook>(_services, async hook => await hook.OnAgentEnqueued(agentId, preAgentId, reason: reason),
            agentId);

        UpdateLazyRoutingAgent(updateLazyRouting);
    }
}

/// <summary>
/// Pop current agent
/// </summary>
public async Task Pop(string? reason = null, bool updateLazyRouting = true)
{
    if (_stack.Count == 0)
    {
        return;
    }

    var agentId = _stack.Pop();
    var currentAgentId = GetCurrentAgentId();

    await HookEmitter.Emit<IRoutingHook>(_services, async hook => await hook.OnAgentDequeued(agentId, currentAgentId, reason: reason),
        agentId);

    if (string.IsNullOrEmpty(currentAgentId))
    {
        return;
    }

    // Run the routing rule
    var agentService = _services.GetRequiredService<IAgentService>();
    var agent = await agentService.GetAgent(currentAgentId);

    var message = new RoleDialogModel(AgentRole.User, $"Try to route to agent {agent.Name}")
    {
        CurrentAgentId = currentAgentId,
        FunctionName = "route_to_agent",
        FunctionArgs = JsonSerializer.Serialize(new FunctionCallFromLlm
        {
            Function = "route_to_agent",
            AgentName = agent.Name,
            NextActionReason = $"User manually route to agent {agent.Name}"
        })
    };

    var routing = _services.GetRequiredService<IRoutingService>();
    var (missingfield, _, redirectedAgentId) = await routing.HasMissingRequiredField(message);
    if (missingfield)
    {
        if (currentAgentId != redirectedAgentId)
        {
            _stack.Push(redirectedAgentId);
        }
    }

    UpdateLazyRoutingAgent(updateLazyRouting);
}

public async Task PopTo(string agentId, string reason, bool updateLazyRouting = true)
{
    var currentAgentId = GetCurrentAgentId();
    while (!string.IsNullOrEmpty(currentAgentId) && 
        currentAgentId != agentId)
    {
        await Pop(reason, updateLazyRouting: updateLazyRouting);
        currentAgentId = GetCurrentAgentId();
    }
}

public string FirstGoalAgentId()
{
    if (_stack.Count == 1)
    {
        return GetCurrentAgentId();
    }
    else if (_stack.Count > 1)
    {
        return _stack.ToArray()[_stack.Count - 2];
    }

    return string.Empty;
}

public bool ContainsAgentId(string agentId)
{
    return _stack.ToArray().Contains(agentId);
}

public async Task Replace(string agentId, string? reason = null, bool updateLazyRouting = true)
{
    var fromAgent = agentId;
    var toAgent = agentId;

    if (_stack.Count == 0)
    {
        _stack.Push(agentId);
    }
    else if (_stack.Peek() != agentId)
    {
        fromAgent = _stack.Peek();
        _stack.Pop();
        _stack.Push(agentId);

        await HookEmitter.Emit<IRoutingHook>(_services, async hook => await hook.OnAgentReplaced(fromAgent, toAgent, reason: reason),
            agentId);
    }

    UpdateLazyRoutingAgent(updateLazyRouting);
}

public async Task Empty(string? reason = null)
{
    if (_stack.Count == 0)
    {
        return;
    }

    var agentId = GetCurrentAgentId();
    _stack.Clear();
    await HookEmitter.Emit<IRoutingHook>(_services, async hook => await hook.OnAgentQueueEmptied(agentId, reason: reason),
        agentId);
}
Possible Issue

The method now awaits rule retrieval and agent loading, and conditionally appends JSON properties to FunctionArgs. Ensure the redirect path safely handles missing routing rules and missing redirect agent records, and verify the FunctionArgs mutation logic is robust for all valid/invalid JSON inputs (not assuming a trailing brace/object form).

    public async Task<(bool hasMissing, string reason, string agentId)> HasMissingRequiredField(RoleDialogModel message)
    {
        var reason = string.Empty;
        var agentId = string.Empty;
        var args = JsonSerializer.Deserialize<RoutingArgs>(message.FunctionArgs);
        var routing = _services.GetRequiredService<IRoutingService>();

        var routingRules = await routing.GetRulesByAgentName(args.AgentName);

        if (routingRules == null || !routingRules.Any())
        {
            agentId = message.CurrentAgentId;
            return (false, reason, agentId);
        }

        agentId = routingRules.First().AgentId;
        // Add routed agent
        message.FunctionArgs = AppendPropertyToArgs(message.FunctionArgs, "route_to", agentId);

        // Check required fields
        var root = JsonSerializer.Deserialize<JsonElement>(message.FunctionArgs);
        var missingFields = new List<string>();
        foreach (var field in routingRules.Where(x => x.Required).Select(x => x.Field))
        {
            if (!root.EnumerateObject().Any(x => x.Name == field))
            {
                missingFields.Add(field);
            }
            else if (root.EnumerateObject().Any(x => x.Name == field) &&
                string.IsNullOrEmpty(root.EnumerateObject().FirstOrDefault(x => x.Name == field).Value.ToString()))
            {
                missingFields.Add(field);
            }
        }

        // Check if states contains the field according conversation context.
        var states = _services.GetRequiredService<IConversationStateService>();
        foreach (var field in missingFields.ToList())
        {
            if (!string.IsNullOrEmpty(states.GetState(field)))
            {
                var value = states.GetState(field);

                // Check if the value is correct data type
                var rule = routingRules.First(x => x.Field == field);
                if (rule.FieldType == "number")
                {
                    if (!long.TryParse(value, out var longValue))
                    {
                        states.SetState(field, "", isNeedVersion: true, source: StateSource.Application);
                        continue;
                    }
                }
                message.FunctionArgs = AppendPropertyToArgs(message.FunctionArgs, field, value);
                missingFields.Remove(field);
            }
        }

        if (missingFields.Any())
        {
            var logger = _services.GetRequiredService<ILogger<RouteToAgentFn>>();

            // Add field to args
            message.FunctionArgs = AppendPropertyToArgs(message.FunctionArgs, "missing_fields", missingFields);
            reason = $"missing some information: {string.Join(", ", missingFields)}";
            // message.Content = reason;
            logger.LogWarning(reason);

            // Handle redirect
            var routingRule = routingRules.FirstOrDefault(x => missingFields.Contains(x.Field));
            if (!string.IsNullOrEmpty(routingRule?.RedirectTo))
            {
                var db = _services.GetRequiredService<IBotSharpRepository>();
                var record = await db.GetAgent(routingRule.RedirectTo);

                if (record != null)
                {
                    // Add redirected agent
                    message.FunctionArgs = AppendPropertyToArgs(message.FunctionArgs, "redirect_to", record.Name);
                    agentId = routingRule.RedirectTo;
#if DEBUG
                    Console.WriteLine($"*** Routing redirect to {record.Name.ToUpper()} ***");
#else
                    logger.LogInformation($"*** Routing redirect to {record.Name.ToUpper()} ***");
#endif
                }
                else
                {
                    // back to router
                    agentId = message.CurrentAgentId;
                }
            }
            else
            {
                // back to router
                agentId = message.CurrentAgentId;
            }
        }

        return (missingFields.Any(), reason, agentId);
    }
Behavior Change

Token creation flows were converted to async, including JWT generation and caching of token expiry. Validate that all callers now correctly await these methods, and confirm the new cache write timing does not introduce partial failures (e.g., token issued but cache write fails) and that exception handling/logging expectations still match previous behavior.

    var (token, jwt) = await BuildToken(record);
    foreach (var hook in hooks)
    {
        hook.UserAuthenticated(record, token);
    }

    return token;
}

public async Task<Token?> RenewToken(string refreshToken, string? accessToken = null)
{
    if (string.IsNullOrWhiteSpace(refreshToken))
    {
        return null;
    }

    try
    {
        User? user = null;

        var hooks = _services.GetServices<IAuthenticationHook>();
        foreach (var hook in hooks)
        {
            user = await hook.RenewAuthentication(refreshToken, accessToken);
            if (user != null)
            {
                break;
            }
        }

        if (user == null)
        {
            // Validate the incoming JWT (signature, issuer, audience, lifetime)
            var config = _services.GetRequiredService<IConfiguration>();
            var validationParameters = new TokenValidationParameters
            {
                IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(config["Jwt:Key"])),
                ValidateIssuerSigningKey = true,
                ValidateIssuer = false,
                ValidateAudience = false,
                ValidateLifetime = false,
                ClockSkew = TimeSpan.Zero
            };

            var tokenHandler = new JwtSecurityTokenHandler();
            var principal = tokenHandler.ValidateToken(refreshToken, validationParameters, out var validatedToken);
            var userId = principal?.Claims?
                .FirstOrDefault(x => x.Type.IsEqualTo(JwtRegisteredClaimNames.NameId)
                                   || x.Type.IsEqualTo(ClaimTypes.NameIdentifier)
                                   || x.Type.IsEqualTo("uid")
                                   || x.Type.IsEqualTo("user_id")
                                   || x.Type.IsEqualTo("userId"))?.Value;

            if (string.IsNullOrEmpty(userId))
            {
                return null;
            }

            user = await GetUser(userId);
            if (user == null
                || user.IsDisabled
                || (user.Id != userId && user.ExternalId != userId))
            {
                return null;
            }
        }

        // Issue a new access token
        var (newToken, _) = await BuildToken(user);

        // Notify hooks for token issuance
        foreach (var hook in hooks)
        {
            hook.UserAuthenticated(user, newToken);
        }

        return newToken;
    }
    catch (SecurityTokenException ex)
    {
        _logger.LogWarning(ex, "Invalid token presented for refresh.");
        return null;
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Failed to refresh token.");
        return null;
    }
}

public async Task<Token> ActiveUser(UserActivationModel model)
{
    var id = model.UserName;
    var db = _services.GetRequiredService<IBotSharpRepository>();
    var record = id.Contains("@") ? await db.GetUserByEmail(id) : await db.GetUserByUserName(id);

    if (record == null)
    {
        record = await db.GetUserByPhone(id, regionCode: (string.IsNullOrWhiteSpace(model.RegionCode) ? "CN" : model.RegionCode));
    }

    //if (record == null)
    //{
    //    record = await db.GetUserByPhoneV2(id, regionCode: (string.IsNullOrWhiteSpace(model.RegionCode) ? "CN" : model.RegionCode));
    //}

    if (record == null)
    {
        return default;
    }

    if (record.VerificationCode != model.VerificationCode || (record.VerificationCodeExpireAt != null && DateTime.UtcNow > record.VerificationCodeExpireAt))
    {
        return default;
    }

    if (record.Verified)
    {
        return default;
    }

    await db.UpdateUserVerified(record.Id);

    var accessToken = await GenerateJwtToken(record);
    var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
    var token = new Token
    {
        AccessToken = accessToken,
        ExpireTime = jwt.Payload.Exp.Value,
        TokenType = "Bearer",
        Scope = "api"
    };
    return token;
}

public async Task<Token> CreateTokenByUser(User user)
{
    var accessToken = await GenerateJwtToken(user);
    var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
    var token = new Token
    {
        AccessToken = accessToken,
        ExpireTime = jwt.Payload.Exp.Value,
        TokenType = "Bearer",
        Scope = "api"
    };
    return token;
}

public async Task<Token?> GetAffiliateToken(string authorization)
{
    var base64 = Encoding.UTF8.GetString(Convert.FromBase64String(authorization));
    var (id, password, regionCode) = base64.SplitAsTuple(":");
    var db = _services.GetRequiredService<IBotSharpRepository>();
    var record = await db.GetAffiliateUserByPhone(id);
    var isCanLogin = record != null && !record.IsDisabled && record.Type == UserType.Affiliate;
    if (!isCanLogin)
    {
        return default;
    }

    if (Utilities.HashTextMd5($"{password}{record.Salt}") != record.Password)
    {
        return default;
    }

    var (token, jwt) = await BuildToken(record);

    return token;
}

public async Task<Token?> GetAdminToken(string authorization)
{
    var base64 = Encoding.UTF8.GetString(Convert.FromBase64String(authorization));
    var (id, password, regionCode) = base64.SplitAsTuple(":");
    var db = _services.GetRequiredService<IBotSharpRepository>();
    var record = await db.GetUserByPhone(id, type: UserType.Internal);
    var isCanLogin = record != null && !record.IsDisabled
        && record.Type == UserType.Internal && new List<string>
        {
            UserRole.Root,UserRole.Admin
        }.Contains(record.Role);
    if (!isCanLogin)
    {
        return default;
    }

    if (Utilities.HashTextMd5($"{password}{record.Salt}") != record.Password)
    {
        return default;
    }

    var (token, jwt) = await BuildToken(record);

    return token;
}

public async Task<DateTime> GetUserTokenExpires()
{
    var _cacheService = _services.GetRequiredService<ICacheService>();
    return await _cacheService.GetAsync<DateTime>(GetUserTokenExpiresCacheKey(_user.Id));
}

#region Private methods
private async Task<(Token, JwtSecurityToken)> BuildToken(User record)
{
    var accessToken = await GenerateJwtToken(record);
    var jwt = new JwtSecurityTokenHandler().ReadJwtToken(accessToken);
    var token = new Token
    {
        AccessToken = accessToken,
        ExpireTime = jwt.Payload.Exp.Value,
        TokenType = "Bearer",
        Scope = "api"
    };
    return (token, jwt);
}

private async Task<string> GenerateJwtToken(User user)
{
    var claims = new List<Claim>
    {
        new Claim(JwtRegisteredClaimNames.NameId, user.Id),
        new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName),
        new Claim(JwtRegisteredClaimNames.Email, user?.Email ?? string.Empty),
        new Claim(JwtRegisteredClaimNames.GivenName, user?.FirstName ?? string.Empty),
        new Claim(JwtRegisteredClaimNames.FamilyName, user?.LastName ?? string.Empty),
        new Claim("source", user.Source),
        new Claim("external_id", user.ExternalId ?? string.Empty),
        new Claim("type", user.Type ?? UserType.Client),
        new Claim("role", user.Role ?? UserRole.User),
        new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
        new Claim("phone", user.Phone ?? string.Empty),
        new Claim("affiliate_id", user.AffiliateId ?? string.Empty),
        new Claim("employee_id", user.EmployeeId ?? string.Empty),
        new Claim("regionCode", user.RegionCode ?? "CN")
    };

    var validators = _services.GetServices<IAuthenticationHook>();
    foreach (var validator in validators)
    {
        validator.AddClaims(claims);
    }

    var config = _services.GetRequiredService<IConfiguration>();
    var issuer = config["Jwt:Issuer"];
    var audience = config["Jwt:Audience"];
    var expireInMinutes = int.Parse(config["Jwt:ExpireInMinutes"] ?? "120");
    var key = Encoding.ASCII.GetBytes(config["Jwt:Key"]);
    var expires = DateTime.UtcNow.AddMinutes(expireInMinutes);
    var tokenDescriptor = new SecurityTokenDescriptor
    {
        Subject = new ClaimsIdentity(claims),
        Expires = expires,
        Issuer = issuer,
        Audience = audience,
        SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(key),
            SecurityAlgorithms.HmacSha256Signature)
    };
    var tokenHandler = new JwtSecurityTokenHandler();
    var token = tokenHandler.CreateToken(tokenDescriptor);
    await SaveUserTokenExpiresCache(user.Id, expires, expireInMinutes);
    return tokenHandler.WriteToken(token);

@yileicn
Copy link
Member Author

yileicn commented Jan 23, 2026

/improve

@qodo-code-review
Copy link

Persistent suggestions updated to latest commit a3f27ab

@JackJiang1234
Copy link
Contributor

reviewed

@yileicn yileicn merged commit 040009b into SciSharp:master Jan 23, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants