mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba9db9ee71 | ||
|
|
8fe2646a6e | ||
|
|
b386b246ca | ||
|
|
adcd2d33f5 |
@@ -4,7 +4,6 @@ using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Nodes;
|
||||
@@ -70,7 +69,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
||||
include: null,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return newItems.AsChatMessages().Single();
|
||||
ChatMessage[] createdMessages = [.. newItems.AsChatMessages()];
|
||||
if (createdMessages.Length != 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected exactly one chat message from created conversation item in conversation '{conversationId}', but got {createdMessages.Length}.");
|
||||
}
|
||||
|
||||
return createdMessages[0];
|
||||
|
||||
IEnumerable<ResponseItem> GetResponseItems()
|
||||
{
|
||||
@@ -208,7 +214,14 @@ public sealed class AzureAgentProvider(Uri projectEndpoint, TokenCredential proj
|
||||
{
|
||||
AgentResponseItem responseItem = await this.GetConversationClient().GetProjectConversationItemAsync(conversationId, messageId, include: null, cancellationToken).ConfigureAwait(false);
|
||||
ResponseItem[] items = [responseItem.AsResponseResultItem()];
|
||||
return items.AsChatMessages().Single();
|
||||
ChatMessage[] messages = [.. items.AsChatMessages()];
|
||||
if (messages.Length != 1)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected exactly one chat message for message '{messageId}' in conversation '{conversationId}', but got {messages.Length}.");
|
||||
}
|
||||
|
||||
return messages[0];
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
|
||||
+17
-9
@@ -49,7 +49,11 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
||||
|
||||
public async ValueTask ResumeAsync(IWorkflowContext context, ExternalInputResponse response, CancellationToken cancellationToken)
|
||||
{
|
||||
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
|
||||
ChatMessage? lastMessage = response.Messages.LastOrDefault();
|
||||
if (lastMessage is not null)
|
||||
{
|
||||
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
|
||||
}
|
||||
await this.InvokeAgentAsync(context, response.Messages, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -85,15 +89,19 @@ internal sealed class InvokeAzureAgentExecutor(InvokeAzureAgent model, ResponseA
|
||||
await this.AssignAsync(this.AgentOutput?.Messages?.Path, agentResponse.Messages.ToTable(), context).ConfigureAwait(false);
|
||||
|
||||
// Attempt to parse the last message as JSON and assign to the response object variable.
|
||||
try
|
||||
string? lastMessageText = agentResponse.Messages.LastOrDefault()?.Text;
|
||||
if (!string.IsNullOrEmpty(lastMessageText))
|
||||
{
|
||||
JsonDocument jsonDocument = JsonDocument.Parse(agentResponse.Messages.Last().Text);
|
||||
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
|
||||
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not valid json, skip assignment.
|
||||
try
|
||||
{
|
||||
using JsonDocument jsonDocument = JsonDocument.Parse(lastMessageText);
|
||||
Dictionary<string, object?> objectProperties = jsonDocument.ParseRecord(VariableType.RecordType);
|
||||
await this.AssignAsync(this.AgentOutput?.ResponseObject?.Path, objectProperties.ToFormula(), context).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid json, skip assignment.
|
||||
}
|
||||
}
|
||||
|
||||
if (this.Model.Input?.ExternalLoop?.When is not null)
|
||||
|
||||
+7
-4
@@ -122,10 +122,13 @@ internal sealed class QuestionExecutor(Question model, ResponseAgentProvider age
|
||||
string? workflowConversationId = context.GetWorkflowConversation();
|
||||
if (workflowConversationId is not null)
|
||||
{
|
||||
// Input message always defined if values has been extracted.
|
||||
ChatMessage input = response.Messages.Last();
|
||||
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
await context.SetLastMessageAsync(input).ConfigureAwait(false);
|
||||
// Input message expected to be defined when values have been extracted, but guard defensively.
|
||||
ChatMessage? input = response.Messages.LastOrDefault();
|
||||
if (input is not null)
|
||||
{
|
||||
await agentProvider.CreateMessageAsync(workflowConversationId, input, cancellationToken).ConfigureAwait(false);
|
||||
await context.SetLastMessageAsync(input).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -45,7 +45,11 @@ internal sealed class RequestExternalInputExecutor(RequestExternalInput model, R
|
||||
await agentProvider.CreateMessageAsync(workflowConversationId, inputMessage, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
await context.SetLastMessageAsync(response.Messages.Last()).ConfigureAwait(false);
|
||||
ChatMessage? lastMessage = response.Messages.LastOrDefault();
|
||||
if (lastMessage is not null)
|
||||
{
|
||||
await context.SetLastMessageAsync(lastMessage).ConfigureAwait(false);
|
||||
}
|
||||
await this.AssignAsync(this.Model.Variable?.Path, response.Messages.ToFormula(), context).ConfigureAwait(false);
|
||||
|
||||
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
@@ -77,12 +77,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
|
||||
try
|
||||
{
|
||||
// Wait for the first input before starting
|
||||
// The consumer will call EnqueueMessageAsync which signals the run loop
|
||||
// Wait for the first input before starting.
|
||||
// The consumer will call EnqueueMessageAsync which signals the run loop.
|
||||
// Note: AsyncRunHandle also signals here on checkpoint resume when there are
|
||||
// already pending requests, so the first iteration can emit a PendingRequests
|
||||
// halt signal even without unprocessed messages.
|
||||
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
this._runStatus = RunStatus.Running;
|
||||
|
||||
while (!linkedSource.Token.IsCancellationRequested)
|
||||
{
|
||||
// Start a new run-stage activity for this input→processing→halt cycle
|
||||
@@ -95,6 +96,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Events are streamed out in real-time as they happen via the event handler
|
||||
if (this._stepRunner.HasUnprocessedMessages)
|
||||
{
|
||||
// Flip to Running only when there's actual work to process.
|
||||
// This is intentionally inside the HasUnprocessedMessages branch so
|
||||
// that stale input signals cannot transiently flip status back to
|
||||
// Running after a prior halt has already been observed by callers
|
||||
// (e.g. Run.ResumeAsync returning after reading an Idle halt signal).
|
||||
this._runStatus = RunStatus.Running;
|
||||
|
||||
// Emit WorkflowStartedEvent only when there's actual work to process
|
||||
// This avoids spurious events on timeout-only loop iterations
|
||||
await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), linkedSource.Token).ConfigureAwait(false);
|
||||
@@ -129,9 +137,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream
|
||||
// Wait for next input from the consumer
|
||||
// Works for both Idle (no work) and PendingRequests (waiting for responses)
|
||||
await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false);
|
||||
|
||||
// When signaled, resume running
|
||||
this._runStatus = RunStatus.Running;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
+23
@@ -85,6 +85,29 @@ public sealed class RequestExternalInputExecutorTest(ITestOutputHelper output) :
|
||||
expectMessagesCreated: true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureResponseWithEmptyMessagesAsync()
|
||||
{
|
||||
await this.CaptureResponseTestAsync(
|
||||
displayName: nameof(CaptureResponseWithEmptyMessagesAsync),
|
||||
variableName: "TestVariable",
|
||||
messageCount: 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System);
|
||||
|
||||
// Act & Assert
|
||||
await this.CaptureResponseTestAsync(
|
||||
displayName: nameof(CaptureResponseWithEmptyMessagesAndWorkflowConversationAsync),
|
||||
variableName: "TestVariable",
|
||||
messageCount: 0,
|
||||
expectMessagesCreated: false);
|
||||
}
|
||||
|
||||
private async Task ExecuteTestAsync(
|
||||
string displayName,
|
||||
string variableName)
|
||||
|
||||
@@ -269,6 +269,29 @@ public class SampleSmokeTest
|
||||
_ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stress regression for the off-thread run-status race: after
|
||||
/// <c>Run.ResumeAsync</c> returns at a halt boundary,
|
||||
/// callers must observe a stable terminal status and never a transient
|
||||
/// <see cref="RunStatus.Running"/>. Step9 is the canonical multi-response resume
|
||||
/// sample; prior to the fix in <see cref="Execution.StreamingRunEventStream"/>,
|
||||
/// its `runStatus.Should().Be(RunStatus.Idle)` assertion failed intermittently
|
||||
/// on roughly 1-in-10 iterations under InProcess_OffThread.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
internal async Task Test_RunSample_Step9_OffThread_MultiResponseResume_StatusIsStableAsync()
|
||||
{
|
||||
const int Iterations = 50;
|
||||
|
||||
for (int i = 0; i < Iterations; i++)
|
||||
{
|
||||
using StringWriter writer = new();
|
||||
_ = await Step9EntryPoint.RunAsync(
|
||||
writer,
|
||||
ExecutionEnvironment.InProcess_OffThread.ToWorkflowExecutionEnvironment());
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
|
||||
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
|
||||
|
||||
Reference in New Issue
Block a user