Skip to content

FunctionInvokingChatClient: FunctionInvocationContext.Terminate is ignored when the function throws #7800

Closed

Description

@gjz22

Description

If an AIFunction sets FunctionInvokingChatClient.CurrentContext.Terminate = true and then throws, the function-invocation loop does not terminate. The exception is captured and turned into an error FunctionResultContent, but the termination request is dropped. The loop then calls the inner IChatClient again.

This is a common pattern. A tool hits an unrecoverable error (an auth failure, a policy violation, a budget exceeded) and needs to stop the agent loop right away, rather than let the model retry or keep going.

Reproduction

var tool = AIFunctionFactory.Create(string () =>
{
    FunctionInvokingChatClient.CurrentContext!.Terminate = true;
    throw new InvalidOperationException("Unrecoverable");
}, "Func1");

using var client = new FunctionInvokingChatClient(innerClient);
var response = await client.GetResponseAsync("hello", new ChatOptions { Tools = [tool] });

Expected: the response ends with the Tool message holding the error result, and the inner client is called only once.
Actual: the inner client is called a second time with the error result, and the loop goes on.

The same thing happens with GetStreamingResponseAsync, with approved ApprovalRequiredAIFunction calls, and with FunctionInvokingRealtimeClientSession.

Root cause

In src/Libraries/Microsoft.Extensions.AI/Common/FunctionInvocationProcessor.cs, ProcessSingleFunctionCallAsync hard-codes terminate: false in the exception path:

catch (Exception ex) when (captureExceptions && !cancellationToken.IsCancellationRequested)
{
    return new(terminate: false, FunctionInvocationStatus.Exception, callContent, result: null, exception: ex);
}

Proposed fix

Use the context's value, as the success path already does:

return new(context.Terminate, FunctionInvocationStatus.Exception, callContent, result: null, exception: ex);

Behavioral notes

  • When calls run one at a time, a throwing function that requested termination stops the remaining calls in that turn, the same as a terminating function that doesn't throw.
  • With AllowConcurrentInvocation = true, all calls in the turn finish, then the loop stops.
  • FunctionInvocationResult can now have Terminate == true with Status == Exception.
  • The realtime session also stops without sending results, the same as it does for non-throwing termination.

I have a fix with unit tests ready (chat streaming and non-streaming, serial and concurrent, approvals, realtime) and am happy to open a PR.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area-aiMicrosoft.Extensions.AI libraries

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions