Skip to content

Commit ec81915

Browse files
Copilotstephentoubjozkee
authored
Allow FunctionResultContent pass-through when CallId matches (#7229)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: jozkee <16040868+jozkee@users.noreply.github.com>
1 parent 143eba0 commit ec81915

File tree

5 files changed

+269
-4
lines changed

5 files changed

+269
-4
lines changed

src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ namespace Microsoft.Extensions.AI;
1414
/// Represents a function call request.
1515
/// </summary>
1616
[DebuggerDisplay("{DebuggerDisplay,nq}")]
17-
public sealed class FunctionCallContent : AIContent
17+
public class FunctionCallContent : AIContent
1818
{
1919
/// <summary>
2020
/// Initializes a new instance of the <see cref="FunctionCallContent"/> class.

src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ namespace Microsoft.Extensions.AI;
1313
/// Represents the result of a function call.
1414
/// </summary>
1515
[DebuggerDisplay("{DebuggerDisplay,nq}")]
16-
public sealed class FunctionResultContent : AIContent
16+
public class FunctionResultContent : AIContent
1717
{
1818
/// <summary>
1919
/// Initializes a new instance of the <see cref="FunctionResultContent"/> class.

src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1792,7 +1792,7 @@
17921792
]
17931793
},
17941794
{
1795-
"Type": "sealed class Microsoft.Extensions.AI.FunctionCallContent : Microsoft.Extensions.AI.AIContent",
1795+
"Type": "class Microsoft.Extensions.AI.FunctionCallContent : Microsoft.Extensions.AI.AIContent",
17961796
"Stage": "Stable",
17971797
"Methods": [
17981798
{
@@ -1824,7 +1824,7 @@
18241824
]
18251825
},
18261826
{
1827-
"Type": "sealed class Microsoft.Extensions.AI.FunctionResultContent : Microsoft.Extensions.AI.AIContent",
1827+
"Type": "class Microsoft.Extensions.AI.FunctionResultContent : Microsoft.Extensions.AI.AIContent",
18281828
"Stage": "Stable",
18291829
"Methods": [
18301830
{

src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1208,6 +1208,13 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul
12081208
object? functionResult;
12091209
if (result.Status == FunctionInvocationStatus.RanToCompletion)
12101210
{
1211+
// If the result is already a FunctionResultContent with a matching CallId, use it directly.
1212+
if (result.Result is FunctionResultContent frc &&
1213+
frc.CallId == result.CallContent.CallId)
1214+
{
1215+
return frc;
1216+
}
1217+
12111218
functionResult = result.Result ?? "Success: Function completed.";
12121219
}
12131220
else

test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -354,6 +354,264 @@ public async Task FunctionInvokerDelegateOverridesHandlingAsync()
354354
await InvokeAndAssertStreamingAsync(options, plan, configurePipeline: configure);
355355
}
356356

357+
[Theory]
358+
[InlineData(false)]
359+
[InlineData(true)]
360+
public async Task FunctionReturningFunctionResultContentWithMatchingCallId_UsesItDirectly(bool streaming)
361+
{
362+
FunctionResultContent? returnedFrc = null;
363+
364+
var options = new ChatOptions
365+
{
366+
Tools =
367+
[
368+
AIFunctionFactory.Create(() => "Result 1", "Func1"),
369+
]
370+
};
371+
372+
using var innerClient = new TestChatClient
373+
{
374+
GetResponseAsyncCallback = (msgs, opts, ct) =>
375+
{
376+
var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool);
377+
if (toolMessage is null)
378+
{
379+
return Task.FromResult(new ChatResponse(
380+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])));
381+
}
382+
else
383+
{
384+
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
385+
}
386+
},
387+
GetStreamingResponseAsyncCallback = (msgs, opts, ct) =>
388+
{
389+
var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool);
390+
if (toolMessage is null)
391+
{
392+
return YieldAsync(new ChatResponse(
393+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])).ToChatResponseUpdates());
394+
}
395+
else
396+
{
397+
return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates());
398+
}
399+
}
400+
};
401+
402+
using var client = new FunctionInvokingChatClient(innerClient)
403+
{
404+
FunctionInvoker = (ctx, cancellationToken) =>
405+
{
406+
returnedFrc = new FunctionResultContent(ctx.CallContent.CallId, "Custom result from function")
407+
{
408+
RawRepresentation = "CustomRaw"
409+
};
410+
return new ValueTask<object?>(returnedFrc);
411+
}
412+
};
413+
414+
var messages = new List<ChatMessage>
415+
{
416+
new ChatMessage(ChatRole.User, "hello"),
417+
};
418+
419+
ChatResponse response;
420+
if (streaming)
421+
{
422+
response = await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync();
423+
}
424+
else
425+
{
426+
response = await client.GetResponseAsync(messages, options);
427+
}
428+
429+
// Verify that the FunctionResultContent was used directly (same reference)
430+
var toolMessage = response.Messages.First(m => m.Role == ChatRole.Tool);
431+
var capturedFrc = Assert.Single(toolMessage.Contents.OfType<FunctionResultContent>());
432+
Assert.Same(returnedFrc, capturedFrc);
433+
Assert.Equal("Custom result from function", capturedFrc.Result);
434+
Assert.Equal("CustomRaw", capturedFrc.RawRepresentation);
435+
Assert.Equal("callId1", capturedFrc.CallId);
436+
}
437+
438+
[Theory]
439+
[InlineData(false)]
440+
[InlineData(true)]
441+
public async Task FunctionReturningFunctionResultContentWithMismatchedCallId_WrapsIt(bool streaming)
442+
{
443+
FunctionResultContent? returnedFrc = null;
444+
445+
var options = new ChatOptions
446+
{
447+
Tools =
448+
[
449+
AIFunctionFactory.Create(() => "Result 1", "Func1"),
450+
]
451+
};
452+
453+
using var innerClient = new TestChatClient
454+
{
455+
GetResponseAsyncCallback = (msgs, opts, ct) =>
456+
{
457+
var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool);
458+
if (toolMessage is null)
459+
{
460+
return Task.FromResult(new ChatResponse(
461+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])));
462+
}
463+
else
464+
{
465+
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
466+
}
467+
},
468+
GetStreamingResponseAsyncCallback = (msgs, opts, ct) =>
469+
{
470+
var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool);
471+
if (toolMessage is null)
472+
{
473+
return YieldAsync(new ChatResponse(
474+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])).ToChatResponseUpdates());
475+
}
476+
else
477+
{
478+
return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates());
479+
}
480+
}
481+
};
482+
483+
using var client = new FunctionInvokingChatClient(innerClient)
484+
{
485+
FunctionInvoker = (ctx, cancellationToken) =>
486+
{
487+
// Return a FunctionResultContent with a different CallId
488+
returnedFrc = new FunctionResultContent("differentCallId", "Result from function");
489+
return new ValueTask<object?>(returnedFrc);
490+
}
491+
};
492+
493+
var messages = new List<ChatMessage>
494+
{
495+
new ChatMessage(ChatRole.User, "hello"),
496+
};
497+
498+
ChatResponse response;
499+
if (streaming)
500+
{
501+
response = await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync();
502+
}
503+
else
504+
{
505+
response = await client.GetResponseAsync(messages, options);
506+
}
507+
508+
// Verify the result is wrapped - the outer FunctionResultContent has the correct CallId
509+
// and the inner one is reference-equal to what was returned
510+
var toolMessage = response.Messages.First(m => m.Role == ChatRole.Tool);
511+
var frc = Assert.Single(toolMessage.Contents.OfType<FunctionResultContent>());
512+
Assert.Equal("callId1", frc.CallId);
513+
Assert.Same(returnedFrc, frc.Result);
514+
var innerFrc = (FunctionResultContent)frc.Result!;
515+
Assert.Equal("differentCallId", innerFrc.CallId);
516+
Assert.Equal("Result from function", innerFrc.Result);
517+
}
518+
519+
[Theory]
520+
[InlineData(false)]
521+
[InlineData(true)]
522+
public async Task FunctionReturningDerivedFunctionResultContent_PropagatesInstanceToInnerClient(bool streaming)
523+
{
524+
DerivedFunctionResultContent? returnedFrc = null;
525+
526+
var options = new ChatOptions
527+
{
528+
Tools =
529+
[
530+
AIFunctionFactory.Create(() => "Result 1", "Func1"),
531+
]
532+
};
533+
534+
using var innerClient = new TestChatClient
535+
{
536+
GetResponseAsyncCallback = (msgs, opts, ct) =>
537+
{
538+
var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool);
539+
if (toolMessage is null)
540+
{
541+
return Task.FromResult(new ChatResponse(
542+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])));
543+
}
544+
else
545+
{
546+
return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")));
547+
}
548+
},
549+
GetStreamingResponseAsyncCallback = (msgs, opts, ct) =>
550+
{
551+
var toolMessage = msgs.FirstOrDefault(m => m.Role == ChatRole.Tool);
552+
if (toolMessage is null)
553+
{
554+
return YieldAsync(new ChatResponse(
555+
new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1")])).ToChatResponseUpdates());
556+
}
557+
else
558+
{
559+
return YieldAsync(new ChatResponse(new ChatMessage(ChatRole.Assistant, "done")).ToChatResponseUpdates());
560+
}
561+
}
562+
};
563+
564+
using var client = new FunctionInvokingChatClient(innerClient)
565+
{
566+
FunctionInvoker = (ctx, cancellationToken) =>
567+
{
568+
// Return a derived FunctionResultContent
569+
returnedFrc = new DerivedFunctionResultContent(ctx.CallContent.CallId, "Derived result")
570+
{
571+
CustomProperty = "CustomValue"
572+
};
573+
return new ValueTask<object?>(returnedFrc);
574+
}
575+
};
576+
577+
var messages = new List<ChatMessage>
578+
{
579+
new ChatMessage(ChatRole.User, "hello"),
580+
};
581+
582+
ChatResponse response;
583+
if (streaming)
584+
{
585+
response = await client.GetStreamingResponseAsync(messages, options).ToChatResponseAsync();
586+
}
587+
else
588+
{
589+
response = await client.GetResponseAsync(messages, options);
590+
}
591+
592+
// Verify that the derived FunctionResultContent instance was propagated to the inner client
593+
// and is reference-equal to what was returned
594+
var toolMessage = response.Messages.First(m => m.Role == ChatRole.Tool);
595+
var capturedFrc = Assert.Single(toolMessage.Contents.OfType<FunctionResultContent>());
596+
Assert.Same(returnedFrc, capturedFrc);
597+
Assert.IsType<DerivedFunctionResultContent>(capturedFrc);
598+
var derivedFrc = (DerivedFunctionResultContent)capturedFrc;
599+
Assert.Equal("callId1", derivedFrc.CallId);
600+
Assert.Equal("Derived result", derivedFrc.Result);
601+
Assert.Equal("CustomValue", derivedFrc.CustomProperty);
602+
}
603+
604+
/// <summary>A derived FunctionResultContent for testing purposes.</summary>
605+
private sealed class DerivedFunctionResultContent : FunctionResultContent
606+
{
607+
public DerivedFunctionResultContent(string callId, object? result)
608+
: base(callId, result)
609+
{
610+
}
611+
612+
public string? CustomProperty { get; set; }
613+
}
614+
357615
[Fact]
358616
public async Task ContinuesWithSuccessfulCallsUntilMaximumIterations()
359617
{

0 commit comments

Comments
 (0)