Compare commits

..
Author SHA1 Message Date
Roger BarretoandGitHub 502f128a9c Merge branch 'main' into test-it-bump-aaip210b2 2026-05-18 17:03:59 +01:00
Roger Barreto 7d233b9bc8 Address PR review: forward pipeline settings; add UTs
- CreateProjectClientOptions also carries RetryPolicy, NetworkTimeout, ClientLoggingOptions, MessageLoggingPolicy (was Transport+UserAgentApplicationId only).

- Make CreateProjectClientOptions internal so tests can verify the copy directly.

- Add AsAIAgent(Uri) UTs covering tools forwarding to inner ChatOptions and null tools handling.

- Add CreateProjectClientOptions UTs covering null caller and full pipeline-settings copy.
2026-05-15 17:44:46 +01:00
Roger Barreto 137a79686f .NET: Bump Azure.AI.Projects to 2.1.0-beta.2 and add agent-endpoint AsAIAgent path
Bumps Azure.AI.Projects to 2.1.0-beta.2 with the matching transitive pins (Azure.Core 1.55.0, System.ClientModel 1.11.0).

Foundry agent endpoint plumbing:
* FoundryAgent now routes the agent-endpoint constructor through the new GetProjectResponsesClientForAgentEndpoint helper.
* Adds an internal FoundryAgent ctor that takes an existing AIProjectClient plus a parsed agent endpoint so the public extension does not need to construct a second project client.
* Adds public AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) extension. This is the path consumer samples are expected to use for hosted agents because version selection happens server-side.
* Trims the dangling "If you want to construct a FoundryAgent against a project endpoint..." sentence from ParseAgentEndpoint.

Unit tests:
* Four new tests in AzureAIProjectChatClientExtensionsTests cover the AIProjectClient.AsAIAgent(Uri agentEndpoint, ...) overload. 263/263 pass.

Consumer samples (Using-Samples):
* SimpleAgent and SessionFilesClient now read AZURE_AI_PROJECT_ENDPOINT and AZURE_AI_AGENT_NAME (both required, throw on missing), derive the agent endpoint with new Uri($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai"), then call aiProjectClient.AsAIAgent(agentEndpoint, ...).
* SessionFilesClient README updated.

Contributor samples (responses/*):
* New HostedContributorRouteExtensions.MapDevTemporaryLocalAgentEndpoint() wildcard route extension so localhost contributor servers accept the per-agent OpenAI endpoint shape the production Hosted runtime exposes.
* All 11 contributor Program.cs files call MapDevTemporaryLocalAgentEndpoint() with a contributor-only warning comment.
* Hosted-Files and Hosted-AzureSearchRag were importing Hosted_Shared_Contributor_Setup but never calling AddDevTemporaryLocalContributorSetup(). Both now call it so HostedSessionIsolationKeyProvider resolves correctly in dev.
* Hosted-AzureSearchRag, Hosted-Files, Hosted-MemoryAgent csprojs drop stale VersionOverride="2.1.0-beta.1" pins.
* Hosted-AzureSearchRag and Hosted-Files csprojs add ProjectReference to Hosted_Shared_Contributor_Setup.
* Hosted-Observability/.dockerignore removed the out/ exclusion that was blocking COPY out/ . in Dockerfile.contributor.

Verified:
* Full solution-scoped build of changed projects: green.
* Scoped CI-parity dotnet format via WSL2 + Docker (mcr.microsoft.com/dotnet/sdk:10.0) over every changed csproj: clean.
* Foundry unit tests: 263/263.
* Contributor docker smoke for 8 hosted samples (publish + docker build + docker run + curl POST to the wildcard route): HTTP 200 / 500 with route matched.
* End-to-end smoke against the real Azure Foundry project with a fresh bearer token: Hosted-Files contributor container served HTTP 200, the agent invoked ListBundledFiles, and returned the expected file name.
2026-05-15 15:43:29 +01:00
146 changed files with 2087 additions and 6859 deletions
+2 -8
View File
@@ -32,13 +32,7 @@ runs:
if grep -q "name = \"$pkg\"" "$f"; then
pkg_dir=$(dirname "$f" | sed 's|python/||')
echo "Excluding workspace package: $pkg ($pkg_dir)"
if awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/{found=1} END{exit !found}' python/pyproject.toml; then
if ! awk '/^\[tool\.uv\.workspace\]/{f=1;next} /^\[/{f=0} f && /^exclude = \[/ && index($0, "\"'"$pkg_dir"'\"")' python/pyproject.toml | grep -q .; then
sed -i.bak '/\[tool\.uv\.workspace\]/,/^\[/ { /^exclude = \[/ s|\]|, "'"$pkg_dir"'"]| }' python/pyproject.toml
fi
else
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
fi
sed -i.bak '/\[tool\.uv\.workspace\]/a\exclude = ["'"$pkg_dir"'"]' python/pyproject.toml
sed -i.bak '/'"$pkg"' = { workspace = true }/d' python/pyproject.toml
fi
done
@@ -46,4 +40,4 @@ runs:
- name: Install the project
shell: bash
run: |
cd python && uv sync --all-packages --all-extras --dev --prerelease=if-necessary-or-explicit
cd python && uv sync --all-packages --all-extras --dev -U --prerelease=if-necessary-or-explicit
-1
View File
@@ -112,7 +112,6 @@
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
<!-- Inference SDKs -->
<PackageVersion Include="Microsoft.ML.OnnxRuntimeGenAI" Version="0.10.0" />
<PackageVersion Include="Microsoft.ML.Tokenizers" Version="2.0.0" />
-1
View File
@@ -124,7 +124,6 @@
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
</Folder>
@@ -24,11 +24,6 @@ public static class AnsiEscapes
/// </summary>
public static string MoveCursor(int row, int column) => $"\x1b[{row};{column}H";
/// <summary>
/// Erases the current line from the cursor position to the end of the line (EL 0).
/// </summary>
public static string EraseToEndOfLine => "\x1b[0K";
/// <summary>
/// Erases the entire current line (EL 2).
/// </summary>
@@ -39,7 +39,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
{
foreach (string line in props.Title.Split('\n'))
{
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(line);
row++;
@@ -51,7 +51,7 @@ public class ListSelection : ConsoleReactiveComponent<ListSelectionProps, Consol
for (int i = 0; i < totalItems; i++)
{
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
bool isSelected = i == props.SelectedIndex;
@@ -58,11 +58,11 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
public override void RenderCore(TextInputProps props, ConsoleReactiveState state)
{
int promptLength = props.Prompt.Length;
int textWidth = props.Width - promptLength;
int textWidth = this.Width - promptLength;
string indent = new(' ', promptLength);
// First line: prompt + start of text
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(props.Prompt);
@@ -90,7 +90,7 @@ public class TextInput : ConsoleReactiveComponent<TextInputProps, ConsoleReactiv
while (offset < props.Text.Length)
{
int chunk = Math.Min(textWidth, props.Text.Length - offset);
Console.Write(AnsiEscapes.MoveCursor(props.Y + row, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + row, this.X));
Console.Write(AnsiEscapes.EraseEntireLine);
Console.Write(indent);
Console.Write(props.Text[offset..(offset + chunk)]);
@@ -17,7 +17,7 @@ public record TextPanelProps : ConsoleReactiveProps
/// <summary>
/// A component that renders a list of pre-rendered string items vertically.
/// Designed for rendering dynamic items in a non-scroll region that may be
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveProps.Height"/>
/// re-rendered on each update. If the component's <see cref="ConsoleReactiveComponent.Height"/>
/// exceeds the number of output lines, leftover lines are erased.
/// </summary>
public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiveState>
@@ -51,18 +51,18 @@ public class TextPanel : ConsoleReactiveComponent<TextPanelProps, ConsoleReactiv
for (int j = 0; j < lineCount; j++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + currentRow));
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + currentRow));
Console.Write(lines[j]);
currentRow++;
}
}
// If the component height exceeds the output, erase leftover lines
if (props.Height > currentRow)
if (this.Height > currentRow)
{
for (int i = currentRow; i < props.Height; i++)
for (int i = currentRow; i < this.Height; i++)
{
Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y + i));
Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y + i));
}
}
}
@@ -52,7 +52,7 @@ public class TextScrollPanel : ConsoleReactiveComponent<TextScrollPanelProps, Te
}
// Move cursor to the bottom of the scroll area
Console.Write(AnsiEscapes.MoveCursor(props.Y + props.Height - 1, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y + this.Height - 1, this.X));
// Output only new items since last rendered
for (int i = state.RenderedCount; i < props.Items.Count; i++)
@@ -9,6 +9,9 @@ namespace Harness.ConsoleReactiveComponents;
/// </summary>
public record TopBottomRuleProps : ConsoleReactiveProps
{
/// <summary>Gets the width of the horizontal rules in characters.</summary>
public int Width { get; init; }
/// <summary>Gets the foreground color of the horizontal rules. If <c>null</c>, the default terminal color is used.</summary>
public ConsoleColor? Color { get; init; }
}
@@ -29,7 +32,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
int childrenHeight = 0;
foreach (var child in props.Children)
{
childrenHeight += child.BaseProps?.Height ?? 0;
childrenHeight += child.Height;
}
// Top rule + children + bottom rule
@@ -48,11 +51,11 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
}
// Top rule
Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
Console.Write(AnsiEscapes.MoveCursor(this.Y, this.X));
Console.Write(rule);
// Render children stacked below the top rule
int currentY = props.Y + 1;
int currentY = this.Y + 1;
if (props.Color.HasValue)
{
@@ -61,9 +64,10 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
foreach (var child in props.Children)
{
child.BaseProps = child.BaseProps! with { X = props.X, Y = currentY };
child.X = this.X;
child.Y = currentY;
child.Render();
currentY += child.BaseProps.Height;
currentY += child.Height;
}
if (props.Color.HasValue)
@@ -72,7 +76,7 @@ public class TopBottomRule : ConsoleReactiveComponent<TopBottomRuleProps, Consol
}
// Bottom rule
Console.Write(AnsiEscapes.MoveCursor(currentY, props.X));
Console.Write(AnsiEscapes.MoveCursor(currentY, this.X));
Console.Write(rule);
if (props.Color.HasValue)
@@ -3,8 +3,8 @@
namespace Harness.ConsoleReactiveFramework;
/// <summary>
/// Abstract base class for all console UI components. Provides access to layout
/// through <see cref="BaseProps"/> and a <see cref="Render"/> method for drawing to the console.
/// Abstract base class for all console UI components. Provides layout properties
/// (position and size) and a <see cref="Render"/> method for drawing to the console.
/// Derive from <see cref="ConsoleReactiveComponent{TProps, TState}"/> instead of this class directly.
/// </summary>
public abstract class ConsoleReactiveComponent
@@ -13,21 +13,20 @@ public abstract class ConsoleReactiveComponent
{
}
/// <summary>
/// Gets or sets the component's props as the base <see cref="ConsoleReactiveProps"/> type.
/// Used by parent components to set layout (X, Y, Width, Height) on children without
/// knowing the concrete props type.
/// </summary>
public abstract ConsoleReactiveProps? BaseProps { get; set; }
/// <summary>Gets or sets the 1-based column position of the component.</summary>
public int X { get; set; }
/// <summary>Gets or sets the 1-based row position of the component.</summary>
public int Y { get; set; }
/// <summary>Gets or sets the width of the component in columns.</summary>
public int Width { get; set; }
/// <summary>Gets or sets the height of the component in rows.</summary>
public int Height { get; set; }
/// <summary>Renders the component to the console at its current position.</summary>
public abstract void Render();
/// <summary>
/// Invalidates the component's cached render state, causing the next <see cref="Render"/> call
/// to proceed even if props and state have not changed. Use after a screen erase to force repaint.
/// </summary>
public abstract void Invalidate();
}
/// <summary>
@@ -47,13 +46,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
/// <summary>Gets or sets the component's props (external configuration).</summary>
public TProps? Props { get; set; }
/// <inheritdoc/>
public override ConsoleReactiveProps? BaseProps
{
get => this.Props;
set => this.Props = (TProps?)value;
}
/// <summary>Gets or sets the component's internal state.</summary>
protected TState? State { get; set; }
@@ -81,8 +73,8 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
return;
}
if (EqualityComparer<TProps>.Default.Equals(this.Props, this._lastRenderedProps)
&& EqualityComparer<TState>.Default.Equals(this.State, this._lastRenderedState))
if (ReferenceEquals(this.Props, this._lastRenderedProps)
&& ReferenceEquals(this.State, this._lastRenderedState))
{
return;
}
@@ -94,16 +86,6 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
}
}
/// <inheritdoc/>
public override void Invalidate()
{
lock (this._renderLock)
{
this._lastRenderedProps = default;
this._lastRenderedState = default;
}
}
/// <summary>
/// Called by <see cref="Render"/> to perform the actual rendering. Override this in derived classes.
/// </summary>
@@ -113,23 +95,11 @@ public abstract class ConsoleReactiveComponent<TProps, TState> : ConsoleReactive
}
/// <summary>
/// Base record for component props. Provides layout properties (position and size)
/// and an optional <see cref="Children"/> collection for composing child components.
/// Base record for component props. Provides an optional <see cref="Children"/> collection
/// for composing child components.
/// </summary>
public record ConsoleReactiveProps
{
/// <summary>Gets the 1-based column position of the component.</summary>
public int X { get; init; }
/// <summary>Gets the 1-based row position of the component.</summary>
public int Y { get; init; }
/// <summary>Gets the width of the component in columns.</summary>
public int Width { get; init; }
/// <summary>Gets the height of the component in rows.</summary>
public int Height { get; init; }
/// <summary>Gets the child components to render within this component.</summary>
public IReadOnlyList<ConsoleReactiveComponent> Children { get; init; } = [];
}
@@ -1,98 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text.Json;
using Microsoft.Agents.AI;
namespace Harness.Shared.Console.Commands;
/// <summary>
/// Handles <c>/session-export &lt;filename&gt;</c> and <c>/session-import &lt;filename&gt;</c>
/// commands for serializing the current session to a file and restoring a session from a file.
/// </summary>
public sealed class SessionCommandHandler : CommandHandler
{
private readonly AIAgent _agent;
/// <summary>
/// Initializes a new instance of the <see cref="SessionCommandHandler"/> class.
/// </summary>
/// <param name="agent">The agent used for session serialization and deserialization.</param>
public SessionCommandHandler(AIAgent agent)
{
this._agent = agent;
}
/// <inheritdoc/>
public override string? GetHelpText() => "/session-export <file> | /session-import <file>";
/// <inheritdoc/>
public override async ValueTask<bool> TryHandleAsync(string input, AgentSession session, IUXStateDriver ux)
{
string command = input.Split(' ', 2)[0];
if (command.Equals("/session-export", StringComparison.OrdinalIgnoreCase))
{
await this.HandleExportAsync(input, session, ux).ConfigureAwait(false);
return true;
}
if (command.Equals("/session-import", StringComparison.OrdinalIgnoreCase))
{
await this.HandleImportAsync(input, ux).ConfigureAwait(false);
return true;
}
return false;
}
private async Task HandleExportAsync(string input, AgentSession session, IUXStateDriver ux)
{
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
await ux.WriteInfoLineAsync("Usage: /session-export <filename>").ConfigureAwait(false);
return;
}
string filename = parts[1];
try
{
JsonElement serialized = await this._agent.SerializeSessionAsync(session).ConfigureAwait(false);
string json = JsonSerializer.Serialize(serialized);
await File.WriteAllTextAsync(filename, json).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"Session exported to {filename}").ConfigureAwait(false);
}
catch (Exception ex)
{
await ux.WriteInfoLineAsync($"Failed to export session to {filename}: {ex.Message}").ConfigureAwait(false);
}
}
private async Task HandleImportAsync(string input, IUXStateDriver ux)
{
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length < 2)
{
await ux.WriteInfoLineAsync("Usage: /session-import <filename>").ConfigureAwait(false);
return;
}
string filename = parts[1];
try
{
string json = await File.ReadAllTextAsync(filename).ConfigureAwait(false);
JsonElement element = JsonSerializer.Deserialize<JsonElement>(json);
AgentSession newSession = await this._agent.DeserializeSessionAsync(element).ConfigureAwait(false);
await ux.ReplaceSessionAsync(newSession).ConfigureAwait(false);
await ux.WriteInfoLineAsync($"Session imported from {filename}").ConfigureAwait(false);
}
catch (FileNotFoundException)
{
await ux.WriteInfoLineAsync($"File not found: {filename}").ConfigureAwait(false);
}
catch (Exception ex)
{
await ux.WriteInfoLineAsync($"Failed to import session from {filename}: {ex.Message}").ConfigureAwait(false);
}
}
}
@@ -43,7 +43,7 @@ public class AgentModeAndHelp : ConsoleReactiveComponent<AgentModeAndHelpProps,
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveAndEraseLine(props.Y));
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
bool hasMode = props.Mode is not null;
@@ -35,7 +35,6 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
];
private readonly Timer _timer;
private AgentStatusProps? _previousProps;
/// <summary>
/// Initializes a new instance of the <see cref="AgentStatus"/> class.
@@ -86,12 +85,7 @@ public class AgentStatus : ConsoleReactiveComponent<AgentStatusProps, AgentStatu
}
System.Console.Write(AnsiEscapes.SaveCursor);
System.Console.Write(AnsiEscapes.MoveCursor(props.Y, props.X));
if (props != this._previousProps)
{
System.Console.Write(AnsiEscapes.EraseToEndOfLine);
this._previousProps = props;
}
System.Console.Write(AnsiEscapes.MoveAndEraseLine(this.Y));
if (props.ShowSpinner)
{
@@ -1,67 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Diagnostics;
using System.Globalization;
using OpenTelemetry;
namespace Harness.Shared.Console;
/// <summary>
/// A simple OpenTelemetry span exporter that writes completed activities (spans) to a text file.
/// Each span is formatted as a human-readable block with timestamps, operation name, duration,
/// status, and any tags/events.
/// </summary>
public sealed class FileSpanExporter : BaseExporter<Activity>
{
private readonly string _filePath;
private readonly object _lock = new();
public FileSpanExporter(string filePath)
{
this._filePath = filePath;
Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
}
public override ExportResult Export(in Batch<Activity> batch)
{
lock (this._lock)
{
using var writer = new StreamWriter(this._filePath, append: true);
foreach (var activity in batch)
{
WriteActivity(writer, activity);
}
}
return ExportResult.Success;
}
private static void WriteActivity(StreamWriter writer, Activity activity)
{
var start = activity.StartTimeUtc.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
var duration = activity.Duration.TotalMilliseconds.ToString("F1", CultureInfo.InvariantCulture);
writer.WriteLine($"[{start}] {activity.OperationName} ({duration}ms) [{activity.Status}]");
if (!string.IsNullOrEmpty(activity.DisplayName) && activity.DisplayName != activity.OperationName)
{
writer.WriteLine($" DisplayName: {activity.DisplayName}");
}
foreach (var tag in activity.Tags)
{
writer.WriteLine($" {tag.Key}: {tag.Value}");
}
foreach (var ev in activity.Events)
{
writer.WriteLine($" Event: {ev.Name} @ {ev.Timestamp:HH:mm:ss.fff}");
foreach (var tag in ev.Tags)
{
writer.WriteLine($" {tag.Key}: {tag.Value}");
}
}
writer.WriteLine();
}
}
@@ -19,14 +19,14 @@ namespace Harness.Shared.Console;
public sealed class HarnessAgentRunner : IDisposable
{
private readonly AIAgent _agent;
private readonly AgentSession _session;
private readonly AgentModeProvider? _modeProvider;
private readonly MessageInjectingChatClient? _messageInjector;
private readonly IReadOnlyList<CommandHandler> _commandHandlers;
private readonly IReadOnlyList<ConsoleObserver> _observers;
private readonly IUXStateDriver _ux;
private readonly SemaphoreSlim _inputGate = new(1, 1);
private AgentSession _session;
private readonly SemaphoreSlim _inputGate = new(1, 1);
/// <summary>
/// Initializes a new instance of the <see cref="HarnessAgentRunner"/> class.
@@ -62,25 +62,6 @@ public sealed class HarnessAgentRunner : IDisposable
/// </summary>
public string HelpText { get; }
/// <summary>
/// Replaces the current session with the specified session. Used by the UX driver
/// when importing a serialized session. Acquires the input gate to ensure no
/// concurrent agent turn is reading the session.
/// </summary>
/// <param name="newSession">The new session to use.</param>
internal async Task ReplaceSessionAsync(AgentSession newSession)
{
await this._inputGate.WaitAsync().ConfigureAwait(false);
try
{
this._session = newSession;
}
finally
{
this._inputGate.Release();
}
}
/// <inheritdoc/>
public void Dispose() => this._inputGate.Dispose();
@@ -63,7 +63,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
getState: () => this.State!,
setState: s => this.SetState(s),
requestShutdown: () => this._shutdownTcs.TrySetResult(true),
replaceSession: s => this.Runner!.ReplaceSessionAsync(s),
modeColors: modeColors);
this.Runner = runnerFactory(this._uxDriver);
@@ -371,7 +370,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
};
bottomChildHeight = ListSelection.CalculateHeight(listProps);
listProps = listProps with { Height = bottomChildHeight };
this._listSelection.Height = bottomChildHeight;
this._listSelection.Props = listProps;
bottomChild = this._listSelection;
}
@@ -398,7 +397,8 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
}
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
@@ -412,7 +412,8 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
};
bottomChildHeight = TextInput.CalculateHeight(textInputProps, state.ConsoleWidth);
textInputProps = textInputProps with { Width = state.ConsoleWidth, Height = bottomChildHeight };
this._textInput.Width = state.ConsoleWidth;
this._textInput.Height = bottomChildHeight;
this._textInput.Props = textInputProps;
bottomChild = this._textInput;
}
@@ -457,16 +458,6 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
this._textScrollPanel.Reset();
this._resizedSinceLastRender = false;
// Invalidate all children so they re-render even if props haven't changed
this._rule.Invalidate();
this._textScrollPanel.Invalidate();
this._textPanel.Invalidate();
this._queuedPanel.Invalidate();
this._agentStatus.Invalidate();
this._modeAndHelp.Invalidate();
this._textInput.Invalidate();
this._listSelection.Invalidate();
}
this._scrollRegionBottom = scrollBottom;
@@ -478,35 +469,35 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
? state.ScrollAreaContentItems.Take(state.ScrollAreaContentItems.Count - 1).ToList()
: [];
this._textScrollPanel.X = 1;
this._textScrollPanel.Y = 1;
this._textScrollPanel.Width = state.ConsoleWidth;
this._textScrollPanel.Height = scrollBottom;
this._textScrollPanel.Props = new TextScrollPanelProps
{
X = 1,
Y = 1,
Width = state.ConsoleWidth,
Height = scrollBottom,
Items = scrollItems,
};
this._textScrollPanel.Render();
// Render the text panel for the last (dynamic) item just below the scroll region
this._textPanel.X = 1;
this._textPanel.Y = scrollBottom + 1;
this._textPanel.Width = state.ConsoleWidth;
this._textPanel.Height = textPanelHeight;
this._textPanel.Props = new TextPanelProps
{
X = 1,
Y = scrollBottom + 1,
Width = state.ConsoleWidth,
Height = textPanelHeight,
Items = lastItems,
};
this._textPanel.Render();
// Render queued input items between text panel and agent status
int queuedPanelY = scrollBottom + textPanelHeight + 1;
this._queuedPanel.X = 1;
this._queuedPanel.Y = queuedPanelY;
this._queuedPanel.Width = state.ConsoleWidth;
this._queuedPanel.Height = queuedPanelHeight;
this._queuedPanel.Props = new TextPanelProps
{
X = 1,
Y = queuedPanelY,
Width = state.ConsoleWidth,
Height = queuedPanelHeight,
Items = state.QueuedItems,
};
this._queuedPanel.Render();
@@ -515,41 +506,32 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
int agentStatusY = queuedPanelY + queuedPanelHeight;
if (showStatusAndHelp)
{
this._agentStatus.Props = agentStatusProps with
{
X = 1,
Y = agentStatusY,
Width = state.ConsoleWidth,
Height = agentStatusHeight,
};
this._agentStatus.X = 1;
this._agentStatus.Y = agentStatusY;
this._agentStatus.Width = state.ConsoleWidth;
this._agentStatus.Height = agentStatusHeight;
this._agentStatus.Props = agentStatusProps;
this._agentStatus.Render();
}
// Render the bottom rule + child below the agent status
this._rule.Props = ruleProps with
{
X = 1,
Y = agentStatusY + agentStatusHeight,
};
this._rule.X = 1;
this._rule.Y = agentStatusY + agentStatusHeight;
this._rule.Props = ruleProps;
this._rule.Render();
// Render the mode-and-help line below the bottom rule
if (showStatusAndHelp)
{
int modeAndHelpY = agentStatusY + agentStatusHeight + ruleHeight;
this._modeAndHelp.Props = modeAndHelpProps with
{
X = 1,
Y = modeAndHelpY,
Width = state.ConsoleWidth,
Height = modeAndHelpHeight,
};
int modeAndHelpY = this._rule.Y + ruleHeight;
this._modeAndHelp.X = 1;
this._modeAndHelp.Y = modeAndHelpY;
this._modeAndHelp.Width = state.ConsoleWidth;
this._modeAndHelp.Height = modeAndHelpHeight;
this._modeAndHelp.Props = modeAndHelpProps;
this._modeAndHelp.Render();
}
// Clear the bottom padding line
System.Console.Write(AnsiEscapes.MoveAndEraseLine(state.ConsoleHeight));
// Position cursor for natural typing appearance
this.PositionCursor(state);
}
@@ -563,7 +545,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
int textWidth = state.ConsoleWidth - promptLength;
int textLength = state.InputText.Length;
int textInputY = (this._rule.Props?.Y ?? 0) + 1;
int textInputY = this._rule.Y + 1;
if (textWidth <= 0 || textLength == 0)
{
@@ -581,7 +563,7 @@ public class HarnessAppComponent : ConsoleReactiveComponent<ConsoleReactiveProps
&& state.ListSelectionIndex == state.ListSelectionOptions.Count)
{
int titleLines = state.ListSelectionTitle?.Split('\n').Length ?? 0;
int customOptionY = (this._rule.Props?.Y ?? 0) + 1 + titleLines + state.ListSelectionOptions.Count;
int customOptionY = this._rule.Y + 1 + titleLines + state.ListSelectionOptions.Count;
int cursorCol = 2 + state.ListSelectionCustomInputText.Length + 1;
System.Console.Write(AnsiEscapes.MoveCursor(customOptionY, cursorCol));
}
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Text;
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
@@ -25,8 +24,6 @@ public static class HarnessConsole
{
options ??= new();
System.Console.OutputEncoding = Encoding.UTF8;
// Null means use defaults; an explicit (possibly empty) list means use exactly what was provided.
var observers = options.Observers
?? HarnessConsoleOptions.BuildDefaultObservers();
@@ -36,9 +33,7 @@ public static class HarnessConsole
var modeProvider = agent.GetService<AgentModeProvider>();
var messageInjector = agent.GetService<MessageInjectingChatClient>();
AgentSession session = options.SessionFactory is not null
? await options.SessionFactory(agent)
: await agent.CreateSessionAsync();
AgentSession session = await agent.CreateSessionAsync();
using var component = new HarnessAppComponent(
placeholder: userPrompt,
@@ -68,7 +63,6 @@ public static class HarnessConsole
System.Console.ResetColor();
System.Console.Write(AnsiEscapes.ResetScrollRegion);
System.Console.Write(AnsiEscapes.EraseScrollbackBuffer);
System.Console.Write(AnsiEscapes.EraseEntireScreen);
System.Console.Write(AnsiEscapes.MoveCursor(1, 1));
System.Console.WriteLine("Goodbye!");
@@ -45,12 +45,6 @@ public class HarnessConsoleOptions
/// </summary>
public Dictionary<string, ConsoleColor> ModeColors { get; set; } = new(DefaultModeColors, StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Gets or sets an optional factory for creating the <see cref="AgentSession"/>.
/// When <see langword="null"/> (the default), <see cref="AIAgent.CreateSessionAsync"/> is used.
/// </summary>
public Func<AIAgent, Task<AgentSession>>? SessionFactory { get; set; }
/// <summary>
/// Creates the default set of observers without planning support.
/// Includes tool call display, tool approval, error display, reasoning display,
@@ -134,7 +128,6 @@ public class HarnessConsoleOptions
new ExitCommandHandler(),
new TodoCommandHandler(todoProvider),
new ModeCommandHandler(modeProvider, modeColors ?? DefaultModeColors),
new SessionCommandHandler(agent),
];
}
}
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using Harness.ConsoleReactiveComponents;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
@@ -17,7 +16,6 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
private readonly Func<HarnessAppComponentState> _getState;
private readonly Action<HarnessAppComponentState> _setState;
private readonly Action _requestShutdown;
private readonly Func<AgentSession, Task> _replaceSession;
private readonly IReadOnlyDictionary<string, ConsoleColor>? _modeColors;
private readonly List<string> _outputItems = [];
private readonly object _stateLock = new();
@@ -34,19 +32,16 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
/// <param name="getState">Returns the component's current state.</param>
/// <param name="setState">Replaces the component's state and triggers a re-render.</param>
/// <param name="requestShutdown">Callback invoked when a command handler requests application shutdown.</param>
/// <param name="replaceSession">Callback invoked to replace the current agent session (e.g., on import).</param>
/// <param name="modeColors">Optional mapping of mode names to console colors.</param>
public HarnessConsoleUXStateDriver(
Func<HarnessAppComponentState> getState,
Action<HarnessAppComponentState> setState,
Action requestShutdown,
Func<AgentSession, Task> replaceSession,
IReadOnlyDictionary<string, ConsoleColor>? modeColors = null)
{
this._getState = getState;
this._setState = setState;
this._requestShutdown = requestShutdown;
this._replaceSession = replaceSession;
this._modeColors = modeColors;
this._currentMode = getState().ModeText;
}
@@ -410,7 +405,4 @@ internal sealed class HarnessConsoleUXStateDriver : IUXStateDriver
/// <inheritdoc/>
public void RequestShutdown() => this._requestShutdown();
/// <inheritdoc/>
public Task ReplaceSessionAsync(AgentSession newSession) => this._replaceSession(newSession);
}
@@ -1,55 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
#pragma warning disable VSTHRD002 // Synchronous waits are required by OpenTelemetry enrichment callbacks.
using OpenTelemetry;
using OpenTelemetry.Trace;
namespace Harness.Shared.Console;
/// <summary>
/// Provides factory methods for creating pre-configured OpenTelemetry tracing for harness samples.
/// </summary>
public static class HarnessTracing
{
/// <summary>
/// Creates a <see cref="TracerProvider"/> that captures spans from the specified source and HTTP client activity,
/// enriching HTTP spans with full request/response headers and bodies, and exports all spans to a timestamped
/// text file in the application base directory.
/// </summary>
/// <param name="sourceName">The activity source name to subscribe to (e.g., "Harness.Research").</param>
/// <returns>A configured <see cref="TracerProvider"/>, or <see langword="null"/> if the builder returns null.</returns>
public static TracerProvider? CreateFileTracerProvider(string sourceName)
{
var traceLogPath = Path.Combine(AppContext.BaseDirectory, $"traces_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{Guid.NewGuid()}.log");
return Sdk.CreateTracerProviderBuilder()
.AddSource(sourceName)
.AddHttpClientInstrumentation((options) =>
{
options.EnrichWithHttpRequestMessage = (activity, request) =>
{
activity.SetTag("http.request.headers", request.Headers.ToString());
if (request.Content != null)
{
activity.SetTag("http.request.content.headers", request.Content.Headers.ToString());
var content = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
activity.SetTag("http.request.content.body", content);
}
};
options.EnrichWithHttpResponseMessage = (activity, response) =>
{
activity.SetTag("http.response.headers", response.Headers.ToString());
if (response.Content != null)
{
activity.SetTag("http.response.content.headers", response.Content.Headers.ToString());
var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
activity.SetTag("http.response.content.body", content);
}
};
})
.AddProcessor(new SimpleActivityExportProcessor(new FileSpanExporter(traceLogPath)))
.Build();
}
}
@@ -7,11 +7,6 @@
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="OpenTelemetry" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
<ProjectReference Include="..\ConsoleReactiveFramework\ConsoleReactiveFramework.csproj" />
@@ -1,6 +1,5 @@
// Copyright (c) Microsoft. All rights reserved.
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
namespace Harness.Shared.Console;
@@ -118,11 +117,4 @@ public interface IUXStateDriver
/// on the owning component.
/// </summary>
void RequestShutdown();
/// <summary>
/// Replaces the current agent session with the specified session (e.g., after importing
/// a serialized session from a file).
/// </summary>
/// <param name="newSession">The new session to use.</param>
Task ReplaceSessionAsync(AgentSession newSession);
}
@@ -13,8 +13,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -16,25 +16,20 @@
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Harness.Shared.Console.ToolFormatters;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
using SampleApp;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.Research";
// Set up OpenTelemetry tracing that writes spans to a text file.
// This captures all agent activity (tool calls, model invocations, compaction, etc.)
// as well as HTTP requests made by the underlying HttpClient transport.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create a HarnessAgent with the Harness providers (TodoProvider and AgentModeProvider)
// and research-focused instructions including the mandatory planning workflow.
@@ -68,22 +63,23 @@ var instructions =
// Only custom instructions, a WebBrowsingTool, and FileAccess opt-out are needed.
AIAgent agent =
// Create an OpenAIClient that communicates with the Foundry responses service.
new AIProjectClient(
new Uri(endpoint),
new OpenAIClient(
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) }) // Enable retries to improve resiliency.
.GetProjectOpenAIClient()
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3) // Enable retries to improve resiliency.
})
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsIChatClientWithStoredOutputDisabled(deploymentName) // We want to manage chat history locally (not stored in the responses service), so that we can manage compaction ourselves.
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "ResearchAgent",
Description = "A research assistant that plans and executes research tasks.",
DisableFileAccess = true, // If enabled, this would allow the agent to read/write files in a working directory
OpenTelemetrySourceName = TracingSourceName, // Use our custom source name so spans are captured by the TracerProvider above.
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
FileMemoryStore = new FileSystemAgentFileStore( // Configure the file memory provider to store files in a local folder called "agent-files".
Path.Combine(AppContext.BaseDirectory, "agent-files")),
ChatOptions = new ChatOptions
@@ -13,8 +13,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -13,41 +13,36 @@
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.SubAgents";
// Set up OpenTelemetry tracing that writes spans to a text file.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create the AIProjectClient for communicating with the Foundry responses service.
var projectClient = new AIProjectClient(
new Uri(endpoint),
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
// --- Background agent: Web Search Agent ---
// This agent uses the HarnessAgent's built-in HostedWebSearchTool to search the web.
// Features not needed by this sub-agent are disabled.
AIAgent webSearchAgent =
projectClient
.GetProjectOpenAIClient()
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "WebSearchAgent",
Description = "An agent that can search the web to find information.",
OpenTelemetrySourceName = TracingSourceName,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
@@ -87,15 +82,19 @@ var parentInstructions =
// This agent orchestrates the sub-agent to look up stock prices in parallel.
// Most features are disabled since the parent only needs SubAgentsProvider.
AIAgent parentAgent =
projectClient
.GetProjectOpenAIClient()
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "StockPriceResearcher",
Description = "An agent that researches stock prices using background agents.",
OpenTelemetrySourceName = TracingSourceName,
DisableTodoProvider = true,
DisableAgentModeProvider = true,
DisableFileMemory = true, // If enabled, this would allow the agent to store memories as files in a directory associated with the current session
@@ -13,8 +13,8 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
@@ -16,21 +16,18 @@
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
using OpenAI;
using OpenAI.Responses;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var endpoint = Environment.GetEnvironmentVariable("AZURE_FOUNDRY_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_FOUNDRY_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.DataProcessing";
// Set up OpenTelemetry tracing that writes spans to a text file.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
var instructions =
"""
@@ -61,18 +58,19 @@ var instructions =
// sample's working/ folder (copied to the output directory) so it works regardless of cwd.
// Unused features are disabled.
AIAgent agent =
new AIProjectClient(
new Uri(endpoint),
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
.GetProjectOpenAIClient()
new OpenAIClient(
new BearerTokenPolicy(new DefaultAzureCredential(), "https://ai.azure.com/.default"),
new OpenAIClientOptions()
{
Endpoint = new Uri(endpoint),
RetryPolicy = new ClientRetryPolicy(3)
})
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsIChatClientWithStoredOutputDisabled(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "DataAnalyst",
Description = "A data analyst assistant that reads, analyzes, and processes data files.",
OpenTelemetrySourceName = TracingSourceName,
FileAccessStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "working")),
DisableTodoProvider = true,
DisableAgentModeProvider = true,
@@ -1,29 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
<ProjectReference Include="..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
</ItemGroup>
<ItemGroup>
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
@@ -1,122 +0,0 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates a HarnessAgent with ALL features enabled, plus:
// - Hyperlight CodeAct (HyperlightCodeActProvider) for sandboxed Python code execution
// - Skills (AgentSkillsProvider) discovering a local "regex-tester" skill
//
// The agent can plan tasks with todos, manage modes, store memories, read/write files,
// search the web, approve sensitive tools, discover and use skills, and execute arbitrary
// Python code in a Hyperlight sandbox — all pre-configured by the HarnessAgent.
//
// Try asking: "Help me write a regex that matches valid email addresses, then test it."
//
// Special commands:
// /todos — Display the current todo list without invoking the agent.
// /mode — Get or set the current agent mode.
// /exit — End the session.
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
using System.ClientModel.Primitives;
using Azure.AI.Projects;
using Azure.Identity;
using Harness.Shared.Console;
using HyperlightSandbox.Guest.Python;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Hyperlight;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("AZURE_AI_PROJECT_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME") ?? "gpt-5.4";
const int MaxContextWindowTokens = 1_050_000;
const int MaxOutputTokens = 128_000;
const string TracingSourceName = "Harness.CodeExecution";
// Set up OpenTelemetry tracing that writes spans to a text file.
using var tracerProvider = HarnessTracing.CreateFileTracerProvider(TracingSourceName);
// Create the HyperlightCodeActProvider with the Python/Wasm backend.
// The guest module path is resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
using var codeAct = new HyperlightCodeActProvider(
HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
var instructions =
"""
## Technical Assistant Instructions
You are a code-powered technical assistant. You can execute Python code in a sandboxed environment
to solve problems precisely rather than guessing. You also have access to skills that provide
structured workflows for specific technical tasks.
### Code Execution
When a problem requires computation, validation, or testing:
- Write Python code and use `execute_code` to run it in the sandbox.
- Always verify results by running the code rather than reasoning about what would happen.
- If code fails, read the error message carefully, fix the issue, and retry.
### Skills
You have access to discoverable skills. When a task matches a skill's description:
- Follow the skill's instructions carefully.
- Use the skill's reference materials for context.
- Combine the skill's workflow with code execution when appropriate.
### Planning and Research
For complex tasks:
- Break the problem into steps using your todo list.
- Research background information using web search when needed.
- Save important findings to file memory for later reference.
### Presenting Results
- Show your work: include the code you ran and its output.
- Explain what each part of your solution does.
- If applicable, save final results to file memory.
""";
// Create the agent with ALL HarnessAgent features enabled plus Hyperlight CodeAct.
// No Disable* flags are set — TodoProvider, AgentModeProvider, FileMemory, FileAccess,
// ToolApproval, WebSearch, and AgentSkillsProvider are all active.
AIAgent agent =
new AIProjectClient(
new Uri(endpoint),
new DefaultAzureCredential(),
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) })
.GetProjectOpenAIClient()
.GetResponsesClient()
.AsIChatClient(deploymentName)
.AsHarnessAgent(MaxContextWindowTokens, MaxOutputTokens, new HarnessAgentOptions
{
Name = "CodeExecutionAgent",
Description = "A technical assistant with sandboxed code execution and skill-based workflows.",
OpenTelemetrySourceName = TracingSourceName,
// Point the file memory at a local folder for persistent memory across sessions.
FileMemoryStore = new FileSystemAgentFileStore(Path.Combine(AppContext.BaseDirectory, "agent-files")),
// Add the HyperlightCodeActProvider so the agent can execute Python code in a sandbox.
AIContextProviders = [codeAct],
ChatOptions = new ChatOptions
{
Instructions = instructions,
MaxOutputTokens = MaxOutputTokens,
Reasoning = new() { Effort = ReasoningEffort.Medium },
},
});
// Run the interactive console session using the shared HarnessConsole helper.
await HarnessConsole.RunAgentAsync(
agent,
userPrompt: "Ask me a technical question, or try: \"Help me write a regex that matches valid email addresses.\"",
new HarnessConsoleOptions
{
Observers = HarnessConsoleOptions.BuildObserversWithPlanning(
agent,
planModeName: "plan",
executionModeName: "execute",
maxContextWindowTokens: MaxContextWindowTokens,
maxOutputTokens: MaxOutputTokens),
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
});
@@ -1,51 +0,0 @@
# Harness Step 04 — Code Execution (Hyperlight + Skills)
This sample demonstrates a HarnessAgent with **all features enabled**, plus:
- **Hyperlight CodeAct** — sandboxed Python code execution via `execute_code` (requires KVM)
- **Skills** — file-based skill discovery (a `regex-tester` skill is included)
The agent can plan tasks, manage modes, store memories, read/write files, search the web, approve sensitive operations, discover and use skills, and execute arbitrary Python code — all pre-configured by the HarnessAgent.
## Prerequisites
- .NET 10 SDK
- An Azure AI Foundry project endpoint
- KVM-capable host (the Hyperlight sandbox runs code in micro-VMs)
## Environment Variables
| Variable | Description |
|----------|-------------|
| `AZURE_AI_PROJECT_ENDPOINT` | Your Azure AI Foundry project endpoint |
| `AZURE_AI_MODEL_DEPLOYMENT_NAME` | Model deployment name (default: `gpt-5.4`) |
## Running
```bash
dotnet run
```
## What to Try
- **Regex testing**: "Help me write a regex that matches valid email addresses, then test it against some examples."
- **Code execution**: "Calculate the first 20 prime numbers using the Sieve of Eratosthenes."
- **Skill + code combo**: "I need a regex for ISO 8601 dates — test it thoroughly with edge cases."
## Included Skill
The `skills/regex-tester/` skill instructs the agent to validate regex patterns by executing Python test code in the Hyperlight sandbox. It includes a regex cheatsheet as reference material.
## Features Enabled
| Feature | Description |
|---------|-------------|
| TodoProvider | Task planning and tracking (`/todos` command) |
| AgentModeProvider | Mode switching (`/mode` command) |
| FileMemoryProvider | Persistent memory stored as files |
| FileAccessProvider | Read/write files in a working directory |
| ToolApproval | Don't-ask-again approval for sensitive tools |
| WebSearch | Built-in hosted web search |
| AgentSkillsProvider | Discovers and uses skills from the `skills/` folder |
| HyperlightCodeActProvider | Sandboxed Python execution via `execute_code` |
| OpenTelemetry | Trace logging to a text file |
@@ -1,36 +0,0 @@
---
name: regex-tester
description: Validate, test, and debug regular expressions by executing them against sample inputs. Use when asked to build, verify, or explain a regex pattern.
---
## Usage
When the user asks you to create, validate, or debug a regular expression:
1. **Understand the requirement** — clarify what the pattern should match and what it should reject.
2. **Consult the cheatsheet** — review `references/regex-cheatsheet.md` for syntax reminders if needed.
3. **Write and execute test code** — use the `execute_code` tool to run Python code that:
- Compiles the regex with `re.compile()`
- Tests it against a set of positive examples (should match) and negative examples (should not match)
- Extracts and displays any capturing groups
- Reports pass/fail for each test case
4. **Iterate** — if any test fails, refine the pattern and re-run until all cases pass.
5. **Present the result** — give the user the final pattern, explain what each part does, and show the test results.
## Example Test Script
```python
import re
pattern = re.compile(r'^[\w.+-]+@[\w-]+\.[\w.-]+$')
positives = ["user@example.com", "first.last+tag@sub.domain.org"]
negatives = ["@missing.com", "no-at-sign", "spaces in@address.com"]
for s in positives:
assert pattern.match(s), f"FAIL: expected match for '{s}'"
for s in negatives:
assert not pattern.match(s), f"FAIL: expected no match for '{s}'"
print("All tests passed!")
```
@@ -1,97 +0,0 @@
# Regex Quick Reference (Python `re` module)
## Character Classes
| Pattern | Matches |
|---------|---------|
| `.` | Any character except newline |
| `\d` | Digit `[0-9]` |
| `\D` | Non-digit |
| `\w` | Word character `[a-zA-Z0-9_]` |
| `\W` | Non-word character |
| `\s` | Whitespace `[ \t\n\r\f\v]` |
| `\S` | Non-whitespace |
| `[abc]` | Any of a, b, or c |
| `[^abc]`| Any character except a, b, c |
| `[a-z]` | Range: a through z |
## Quantifiers
| Pattern | Meaning |
|---------|---------|
| `*` | 0 or more (greedy) |
| `+` | 1 or more (greedy) |
| `?` | 0 or 1 (greedy) |
| `{n}` | Exactly n |
| `{n,}` | n or more |
| `{n,m}` | Between n and m |
| `*?`, `+?`, `??` | Non-greedy versions |
## Anchors
| Pattern | Meaning |
|---------|---------|
| `^` | Start of string (or line with `re.MULTILINE`) |
| `$` | End of string (or line with `re.MULTILINE`) |
| `\b` | Word boundary |
| `\B` | Non-word boundary |
## Groups and Backreferences
| Pattern | Meaning |
|---------|---------|
| `(...)` | Capturing group |
| `(?:...)`| Non-capturing group |
| `(?P<name>...)` | Named group |
| `\1` | Backreference to group 1 |
| `(?=...)` | Positive lookahead |
| `(?!...)` | Negative lookahead |
| `(?<=...)` | Positive lookbehind |
| `(?<!...)` | Negative lookbehind |
## Flags
| Flag | Effect |
|------|--------|
| `re.IGNORECASE` / `re.I` | Case-insensitive matching |
| `re.MULTILINE` / `re.M` | `^`/`$` match line boundaries |
| `re.DOTALL` / `re.S` | `.` matches newline |
| `re.VERBOSE` / `re.X` | Allow comments and whitespace |
## Common Patterns
| Use Case | Pattern |
|----------|---------|
| Email (simple) | `^[\w.+-]+@[\w-]+\.[\w.-]+$` |
| IPv4 address | `^\d{1,3}(\.\d{1,3}){3}$` |
| ISO date | `^\d{4}-\d{2}-\d{2}$` |
| URL (http/https) | `^https?://[^\s/$.?#].[^\s]*$` |
| Phone (US) | `^(\+1)?[-.\s]?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}$` |
## Python API
```python
import re
# Test if a string matches
re.match(r'pattern', "string") # match at start
re.search(r'pattern', "string") # match anywhere
re.fullmatch(r'pattern', "string") # match entire string
# Find all matches
re.findall(r'\d+', "abc 123 def 456") # ['123', '456']
# Named groups
m = re.match(r'(?P<year>\d{4})-(?P<month>\d{2})-(?P<day>\d{2})', "2025-01-15")
m.group('year') # '2025'
# Replace
re.sub(r'\d+', 'X', "abc 123 def") # 'abc X def'
# Split
re.split(r',+', "a,b,,c") # ['a', 'b', 'c']
# Compile for reuse
pattern = re.compile(r'^\d{4}-\d{2}-\d{2}$')
pattern.match("2025-01-15") # Match object
```
@@ -130,7 +130,7 @@ public sealed class HarnessAgent : DelegatingAIAgent
if (options?.DisableOpenTelemetry is not true)
{
builder.UseOpenTelemetry(sourceName: options?.OpenTelemetrySourceName);
builder.UseOpenTelemetry();
}
return builder.Build();
@@ -183,8 +183,6 @@ public sealed class HarnessAgent : DelegatingAIAgent
AIContextProviders = contextProviders,
UseProvidedChatClientAsIs = true,
RequirePerServiceCallChatHistoryPersistence = true,
WarnOnChatHistoryProviderConflict = false,
ThrowOnChatHistoryProviderConflict = false,
});
}
@@ -206,16 +206,4 @@ public sealed class HarnessAgentOptions
/// following the Semantic Conventions for Generative AI systems.
/// </remarks>
public bool DisableOpenTelemetry { get; set; }
/// <summary>
/// Gets or sets the OpenTelemetry source name used by the <see cref="OpenTelemetryAgent"/> wrapper.
/// </summary>
/// <remarks>
/// When <see langword="null"/> (the default), the framework's default source name
/// (<c>"Experimental.Microsoft.Agents.AI"</c>) is used.
/// Set this to a custom value to enable filtering spans from a specific <see cref="System.Diagnostics.ActivitySource"/>
/// in your <c>TracerProvider</c> configuration.
/// This property is ignored when <see cref="DisableOpenTelemetry"/> is <see langword="true"/>.
/// </remarks>
public string? OpenTelemetrySourceName { get; set; }
}
@@ -12,7 +12,6 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
@@ -28,8 +27,6 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// </remarks>
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
{
private const string FilenameAdditionalPropertyName = "filename";
/// <summary>
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
/// to the MCP protocol <c>tools/list</c> discovery operation.
@@ -275,46 +272,46 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
internal static AIContent ConvertContentBlock(ContentBlock block)
{
// Delegate to the MCP SDK's canonical converter. It maps every known
// ContentBlock subtype (Text/Image/Audio/EmbeddedResource/ToolUse/ToolResult)
// and sets RawRepresentation + AdditionalProperties from block.Meta.
// It intentionally returns null for ResourceLinkBlock — map that to
// UriContent here so callers always receive a usable AIContent.
return block.ToAIContent() ?? block switch
return block switch
{
ResourceLinkBlock link => new UriContent(link.Uri, link.MimeType ?? "application/octet-stream")
{
RawRepresentation = link,
AdditionalProperties = CreateAdditionalProperties(link),
},
_ => new TextContent(block.ToString() ?? string.Empty)
{
RawRepresentation = block,
AdditionalProperties = CreateAdditionalProperties(block),
},
TextContentBlock text => new TextContent(text.Text),
ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"),
AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"),
EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
private static AdditionalPropertiesDictionary? CreateAdditionalProperties(ContentBlock block)
private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block)
{
AdditionalPropertiesDictionary? properties = null;
if (block.Meta is not null)
return block.Resource switch
{
foreach (var property in block.Meta)
{
properties ??= new AdditionalPropertiesDictionary();
properties.Add(property.Key, property.Value);
}
TextResourceContents text => new TextContent(text.Text),
BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"),
_ => new TextContent(block.ToString() ?? string.Empty),
};
}
private static DataContent CreateDataContent(ReadOnlyMemory<byte> base64Utf8Data, string mediaType)
{
if (base64Utf8Data.IsEmpty)
{
return new DataContent($"data:{mediaType};base64,", mediaType);
}
if (block is ResourceLinkBlock { Name: { Length: > 0 } name })
#if NET8_0_OR_GREATER
string base64 = Encoding.UTF8.GetString(base64Utf8Data.Span);
#else
string base64 = Encoding.UTF8.GetString(base64Utf8Data.ToArray());
#endif
// If it's already a data URI, use it directly
if (base64.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
{
properties ??= new AdditionalPropertiesDictionary();
properties.TryAdd(FilenameAdditionalPropertyName, name);
return new DataContent(base64, mediaType);
}
return properties;
return new DataContent($"data:{mediaType};base64,{base64}", mediaType);
}
private static string SerializeToolsList(IEnumerable<Tool> tools)
@@ -31,7 +31,6 @@ public class HarnessAgentOptionsTests
Assert.False(options.DisableAgentModeProvider);
Assert.False(options.DisableAgentSkillsProvider);
Assert.False(options.DisableOpenTelemetry);
Assert.Null(options.OpenTelemetrySourceName);
Assert.Null(options.MaximumIterationsPerRequest);
Assert.Null(options.FileMemoryStore);
Assert.Null(options.FileAccessStore);
@@ -76,7 +75,6 @@ public class HarnessAgentOptionsTests
DisableAgentSkillsProvider = true,
AgentSkillsSource = skillsSource,
DisableOpenTelemetry = true,
OpenTelemetrySourceName = "custom-source",
};
// Assert
@@ -102,6 +100,5 @@ public class HarnessAgentOptionsTests
Assert.True(options.DisableAgentSkillsProvider);
Assert.Same(skillsSource, options.AgentSkillsSource);
Assert.True(options.DisableOpenTelemetry);
Assert.Equal("custom-source", options.OpenTelemetrySourceName);
}
}
@@ -678,25 +678,6 @@ public class HarnessAgentTests
Assert.Null(agent.GetService<OpenTelemetryAgent>());
}
/// <summary>
/// Verify that a custom OpenTelemetrySourceName is accepted without error.
/// </summary>
[Fact]
public void OpenTelemetry_CustomSourceNameIsAccepted()
{
// Arrange
var chatClient = new Mock<IChatClient>().Object;
var options = CreateAllDisabledOptions();
options.DisableOpenTelemetry = false;
options.OpenTelemetrySourceName = "MyApp.AgentTracing";
// Act
var agent = new HarnessAgent(chatClient, TestMaxContextWindowTokens, TestMaxOutputTokens, options);
// Assert
Assert.NotNull(agent.GetService<OpenTelemetryAgent>());
}
#endregion
#region Feature: WebSearch
@@ -445,9 +445,8 @@ public sealed class DefaultMcpToolHandlerTests
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
TextContent textContent = result.Should().BeOfType<TextContent>().Subject;
textContent.Text.Should().Be("hello world");
textContent.RawRepresentation.Should().BeSameAs(block);
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("hello world");
}
[Fact]
@@ -463,17 +462,13 @@ public sealed class DefaultMcpToolHandlerTests
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/png");
dataContent.Uri.Should().Be("data:image/png;base64,");
dataContent.Data.IsEmpty.Should().BeTrue();
dataContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithBase64Payload_ShouldReturnDataContent()
{
// Arrange
const string Base64Payload = "iVBORw0KGgo=";
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo=");
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = "image/png" };
// Act
@@ -482,9 +477,39 @@ public sealed class DefaultMcpToolHandlerTests
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/png");
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
dataContent.Uri.Should().Be($"data:image/png;base64,{Base64Payload}");
dataContent.RawRepresentation.Should().BeSameAs(block);
dataContent.Uri.Should().Be("data:image/png;base64,iVBORw0KGgo=");
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
{
// Arrange
const string DataUri = "data:image/jpeg;base64,/9j/4AAQ";
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "image/jpeg" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/jpeg");
dataContent.Uri.Should().Be(DataUri);
}
[Fact]
public void ConvertContentBlock_ImageContentBlock_WithNullMimeType_ShouldDefaultToImageWildcard()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo=");
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("image/*");
}
[Fact]
@@ -500,17 +525,13 @@ public sealed class DefaultMcpToolHandlerTests
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/wav");
dataContent.Uri.Should().Be("data:audio/wav;base64,");
dataContent.Data.IsEmpty.Should().BeTrue();
dataContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithBase64Payload_ShouldReturnDataContent()
{
// Arrange
const string Base64Payload = "UklGRiQA";
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = "audio/wav" };
// Act
@@ -519,9 +540,39 @@ public sealed class DefaultMcpToolHandlerTests
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/wav");
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
dataContent.Uri.Should().Be($"data:audio/wav;base64,{Base64Payload}");
dataContent.RawRepresentation.Should().BeSameAs(block);
dataContent.Uri.Should().Be("data:audio/wav;base64,UklGRiQA");
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
{
// Arrange
const string DataUri = "data:audio/mp3;base64,//uQxAAA";
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "audio/mp3" };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/mp3");
dataContent.Uri.Should().Be(DataUri);
}
[Fact]
public void ConvertContentBlock_AudioContentBlock_WithNullMimeType_ShouldDefaultToAudioWildcard()
{
// Arrange
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("audio/*");
}
[Fact]
@@ -542,18 +593,15 @@ public sealed class DefaultMcpToolHandlerTests
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
TextContent textContent = result.Should().BeOfType<TextContent>().Subject;
textContent.Text.Should().Be("embedded text payload");
textContent.RawRepresentation.Should().BeSameAs(block);
result.Should().BeOfType<TextContent>()
.Which.Text.Should().Be("embedded text payload");
}
[Fact]
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
{
// Arrange
const string Base64Payload = "UklGRiQA";
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
EmbeddedResourceBlock block = new()
{
Resource = new BlobResourceContents
@@ -570,65 +618,21 @@ public sealed class DefaultMcpToolHandlerTests
// Assert
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/zip");
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
dataContent.Uri.Should().Be($"data:application/zip;base64,{Base64Payload}");
dataContent.RawRepresentation.Should().BeSameAs(block);
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
}
[Fact]
public void ConvertContentBlock_ResourceLinkBlock_WithUri_ShouldReturnUriContent()
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
{
// Arrange
ResourceLinkBlock block = new()
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
EmbeddedResourceBlock block = new()
{
Uri = "https://example.com/resource.bin",
Name = "resource.bin",
MimeType = "application/zip",
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.Uri.ToString().Should().Be("https://example.com/resource.bin");
uriContent.MediaType.Should().Be("application/zip");
uriContent.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ResourceLinkBlock_WithNullMimeType_ShouldDefaultToOctetStream()
{
// Arrange
ResourceLinkBlock block = new()
{
Uri = "https://example.com/resource",
Name = "resource",
MimeType = null,
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.Uri.ToString().Should().Be("https://example.com/resource");
uriContent.MediaType.Should().Be("application/octet-stream");
}
[Fact]
public void ConvertContentBlock_ResourceLinkBlock_WithMeta_ShouldPropagateToAdditionalProperties()
{
// Arrange
ResourceLinkBlock block = new()
{
Uri = "https://example.com/resource.bin",
Name = string.Empty,
MimeType = "application/zip",
Meta = new System.Text.Json.Nodes.JsonObject
Resource = new BlobResourceContents
{
["traceId"] = "abc-123",
["priority"] = 7,
Blob = new ReadOnlyMemory<byte>(base64Bytes),
Uri = "resource://example.bin",
MimeType = null!,
},
};
@@ -636,120 +640,9 @@ public sealed class DefaultMcpToolHandlerTests
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.AdditionalProperties.Should().NotBeNull();
uriContent.AdditionalProperties!.Should().HaveCount(2);
uriContent.AdditionalProperties["traceId"].Should().BeSameAs(block.Meta!["traceId"]);
uriContent.AdditionalProperties["priority"].Should().BeSameAs(block.Meta["priority"]);
}
[Fact]
public void ConvertContentBlock_ResourceLinkBlock_WithName_ShouldMapNameToFilenameAdditionalProperty()
{
// Arrange
ResourceLinkBlock block = new()
{
Uri = "https://example.com/resource.bin",
Name = "resource.bin",
MimeType = "application/zip",
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
uriContent.AdditionalProperties.Should().NotBeNull();
uriContent.AdditionalProperties!["filename"].Should().Be("resource.bin");
}
[Fact]
public void ConvertContentBlock_ToolUseContentBlock_ShouldReturnFunctionCallContent()
{
// Arrange
using JsonDocument input = JsonDocument.Parse("{\"city\":\"Seattle\",\"unit\":\"celsius\"}");
ToolUseContentBlock block = new()
{
Id = "call-1",
Name = "get_weather",
Input = input.RootElement.Clone(),
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
FunctionCallContent call = result.Should().BeOfType<FunctionCallContent>().Subject;
call.CallId.Should().Be("call-1");
call.Name.Should().Be("get_weather");
call.Arguments.Should().NotBeNull();
call.Arguments!.Should().ContainKey("city");
call.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ToolResultContentBlock_NotError_ShouldReturnFunctionResultContent()
{
// Arrange
ToolResultContentBlock block = new()
{
ToolUseId = "call-1",
Content = [new TextContentBlock { Text = "ok" }],
IsError = false,
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
FunctionResultContent functionResult = result.Should().BeOfType<FunctionResultContent>().Subject;
functionResult.CallId.Should().Be("call-1");
functionResult.Exception.Should().BeNull();
functionResult.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_ToolResultContentBlock_WithIsError_ShouldSetException()
{
// Arrange
ToolResultContentBlock block = new()
{
ToolUseId = "call-2",
Content = [new TextContentBlock { Text = "boom" }],
IsError = true,
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
FunctionResultContent functionResult = result.Should().BeOfType<FunctionResultContent>().Subject;
functionResult.CallId.Should().Be("call-2");
functionResult.Exception.Should().NotBeNull();
functionResult.RawRepresentation.Should().BeSameAs(block);
}
[Fact]
public void ConvertContentBlock_BlockWithMeta_ShouldPropagateToAdditionalProperties()
{
// Arrange
TextContentBlock block = new()
{
Text = "hello",
Meta = new System.Text.Json.Nodes.JsonObject
{
["traceId"] = "abc-123",
["priority"] = 7,
},
};
// Act
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
// Assert
result.AdditionalProperties.Should().NotBeNull();
result.AdditionalProperties!.Should().ContainKey("traceId");
result.AdditionalProperties.Should().ContainKey("priority");
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
dataContent.MediaType.Should().Be("application/octet-stream");
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
}
#endregion
+2 -20
View File
@@ -7,23 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.5.0] - 2026-05-19
### Added
- **agent-framework-core**, **agent-framework-foundry**, **agent-framework-openai**: Record actual served model from Azure OpenAI ([#5910](https://github.com/microsoft/agent-framework/pull/5910))
- **samples**: New Foundry Hosted Agents samples for RAG, Skills, and Memory ([#5822](https://github.com/microsoft/agent-framework/pull/5822))
### Changed
- **agent-framework-core**, **agent-framework-azurefunctions**, **agent-framework-devui**, **agent-framework-foundry**, **agent-framework-orchestrations**: Improve handling of intermediate outputs for workflows and orchestrations ([#5623](https://github.com/microsoft/agent-framework/pull/5623))
- **agent-framework-durabletask**: Pin `durabletask` and `durabletask-azuremanaged` floors to `>=1.4.0` and exclude upstream `durabletask` 1.4.1, 1.4.2, and 1.4.3 from the supported version range.
- **agent-framework-orchestrations**: Bumped package to release candidate stage.
### Fixed
- **agent-framework-core**: Parse YAML block scalars in SKILL.md frontmatter ([#5863](https://github.com/microsoft/agent-framework/pull/5863))
- **agent-framework-github-copilot**: Include tools added by `ContextProvider.before_run` in session creation ([#5780](https://github.com/microsoft/agent-framework/pull/5780))
- **agent-framework-hyperlight**: Skip symlinks when staging sandbox input ([#5919](https://github.com/microsoft/agent-framework/pull/5919))
- **agent-framework-purview**: Remove duplicate pop in `InMemoryCacheProvider.remove` ([#5795](https://github.com/microsoft/agent-framework/pull/5795))
## [1.4.0] - 2026-05-14
### Added
@@ -84,7 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **agent-framework-foundry-hosting**: Add hosted Durable Workflow support — propagate full conversation history to workflow agents and wire `Workflow.as_agent()` end-to-end via the foundry hosting layer ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
### Changed
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent participant output designation flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent (`intermediate_outputs=True`) flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
- **agent-framework-core**, **agent-framework-declarative**: Preserve `Workflow.run()` shared state across calls so multi-turn `WorkflowAgent` invocations retain context, accept `list[Message]` input in the declarative start executor, and coerce `Enum` values when serializing PowerFx symbols ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
- **dependencies**: Update workspace package dependencies and preserve `mcp[ws]` / `uvicorn[standard]` extras through override-dependencies in `/python` ([#5555](https://github.com/microsoft/agent-framework/pull/5555))
@@ -1088,8 +1071,7 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...HEAD
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...HEAD
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
[1.3.0]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...python-1.3.0
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"a2a-sdk>=1.0.0,<2",
]
+2 -2
View File
@@ -22,10 +22,10 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<1"
"uvicorn[standard]>=0.30.0,<0.42.0"
]
[project.optional-dependencies]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260519"
version = "1.0.0a260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-foundry>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-foundry>=1.4.0,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -22,7 +22,6 @@ from typing import TYPE_CHECKING, Any, TypeVar, cast
import azure.durable_functions as df
import azure.functions as func
from agent_framework import AgentExecutor, SupportsAgentRun, Workflow, WorkflowEvent
from agent_framework._workflows._runner_context import YieldOutputEventType
from agent_framework_durabletask import (
DEFAULT_MAX_POLL_RETRIES,
DEFAULT_POLL_INTERVAL_SECONDS,
@@ -308,18 +307,6 @@ class AgentFunctionApp(DFAppBase):
async def run() -> dict[str, Any]:
# Create runner context and shared state
runner_context = CapturingRunnerContext()
workflow = self.workflow
def classify_yielded_output(executor_id: str) -> YieldOutputEventType | None:
if workflow is None:
return "output"
if workflow.is_terminal_executor(executor_id):
return "output"
if workflow.is_intermediate_executor(executor_id):
return "intermediate"
return None
runner_context.set_yield_output_classifier(classify_yielded_output)
shared_state = State()
# Deserialize shared state values to reconstruct dataclasses/Pydantic models
@@ -19,7 +19,6 @@ from agent_framework import (
WorkflowEvent,
WorkflowMessage,
)
from agent_framework._workflows._runner_context import YieldOutputClassifier, YieldOutputEventType
from agent_framework._workflows._state import State
@@ -42,7 +41,6 @@ class CapturingRunnerContext(RunnerContext):
self._pending_request_info_events: dict[str, WorkflowEvent[Any]] = {}
self._workflow_id: str | None = None
self._streaming: bool = False
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
# region Messaging
@@ -146,14 +144,6 @@ class CapturingRunnerContext(RunnerContext):
"""Check if streaming mode is enabled (always False in activity context)."""
return self._streaming
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
"""Set the classifier used by WorkflowContext.yield_output()."""
self._yield_output_classifier = classifier
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
return self._yield_output_classifier(executor_id)
# endregion Workflow Configuration
# region Request Info Events
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,8 +22,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-durabletask>=1.0.0b260519,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
]
@@ -107,7 +107,7 @@ class TestCapturingRunnerContext:
@pytest.mark.asyncio
async def test_add_event_queues_event(self, context: CapturingRunnerContext) -> None:
"""Test that add_event queues events correctly."""
event = WorkflowEvent("output", executor_id="exec_1", data="output")
event = WorkflowEvent.output(executor_id="exec_1", data="output")
await context.add_event(event)
@@ -120,7 +120,7 @@ class TestCapturingRunnerContext:
@pytest.mark.asyncio
async def test_drain_events_clears_queue(self, context: CapturingRunnerContext) -> None:
"""Test that drain_events clears the event queue."""
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
await context.drain_events() # First drain
events = await context.drain_events() # Second drain
@@ -132,14 +132,14 @@ class TestCapturingRunnerContext:
"""Test has_events returns correct boolean."""
assert await context.has_events() is False
await context.add_event(WorkflowEvent("output", executor_id="e", data="test"))
await context.add_event(WorkflowEvent.output(executor_id="e", data="test"))
assert await context.has_events() is True
@pytest.mark.asyncio
async def test_next_event_waits_for_event(self, context: CapturingRunnerContext) -> None:
"""Test that next_event returns queued events."""
event = WorkflowEvent("output", executor_id="e", data="waited")
event = WorkflowEvent.output(executor_id="e", data="waited")
await context.add_event(event)
result = await context.next_event()
@@ -171,7 +171,7 @@ class TestCapturingRunnerContext:
async def test_reset_for_new_run_clears_state(self, context: CapturingRunnerContext) -> None:
"""Test that reset_for_new_run clears all state."""
await context.send_message(WorkflowMessage(data="test", target_id="t", source_id="s"))
await context.add_event(WorkflowEvent("output", executor_id="e", data="event"))
await context.add_event(WorkflowEvent.output(executor_id="e", data="event"))
context.set_streaming(True)
context.reset_for_new_run()
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
+1 -8
View File
@@ -79,14 +79,7 @@ agent_framework/
### Workflows (`_workflows/`)
- **`Workflow`** - Graph-based workflow definition
- **`WorkflowBuilder`** - Fluent API for building workflows, including explicit
`output_from` / `intermediate_output_from` selection for caller-facing emissions. `output_from`
is an allow-list for **Workflow Output**; unselected executor payloads are hidden unless
`intermediate_output_from` selects them as **Intermediate Output**. Use `output_from="all"` for
explicit all-output behavior and `intermediate_output_from="all_other"` for visible progress from
every output-capable executor not selected by `output_from`.
- **`WorkflowRunResult`** - Non-streaming workflow result with Workflow Output `get_outputs()`
and Intermediate Output `get_intermediate_outputs()` accessors
- **`WorkflowBuilder`** - Fluent API for building workflows
- **Orchestrators**: `SequentialOrchestrator`, `ConcurrentOrchestrator`, `GroupChatOrchestrator`, `MagenticOrchestrator`, `HandoffOrchestrator`
## Built-in Providers
@@ -651,7 +651,9 @@ def _validate_compatibility(compatibility: str | None) -> None:
ValueError: If the value exceeds the maximum allowed length.
"""
if compatibility is not None and len(compatibility) > MAX_COMPATIBILITY_LENGTH:
raise ValueError(f"Skill compatibility must be {MAX_COMPATIBILITY_LENGTH} characters or fewer.")
raise ValueError(
f"Skill compatibility must be {MAX_COMPATIBILITY_LENGTH} characters or fewer."
)
def _build_skill_content(
@@ -731,7 +733,6 @@ class InlineSkill(Skill):
instructions="Use this skill for DB tasks.",
)
@skill.resource
def get_schema() -> str:
return "CREATE TABLE ..."
@@ -2612,7 +2613,11 @@ class FileSkillsSource(SkillsSource):
# Reject absolute paths (check both POSIX and Windows-style roots
# so validation is consistent regardless of the host OS)
if os.path.isabs(directory) or normalized.startswith("/") or re.match(r"^[A-Za-z]:[/\\]", directory):
if (
os.path.isabs(directory)
or normalized.startswith("/")
or re.match(r"^[A-Za-z]:[/\\]", directory)
):
logger.warning(
"Skipping directory '%s': absolute paths are not allowed.",
directory,
@@ -32,7 +32,6 @@ from .._types import (
from ..exceptions import AgentInvalidRequestException, AgentInvalidResponseException
from ._checkpoint import CheckpointStorage
from ._events import (
AGENT_FORWARDED_EVENT_TYPES,
WorkflowEvent,
)
from ._message_utils import normalize_messages_input
@@ -105,7 +104,7 @@ class WorkflowAgent(BaseAgent):
Note:
Only output events (type='output') and request_info events (type='request_info') from
the workflow are considered and converted to agent responses of the WorkflowAgent.
Other workflow events are ignored. Use `output_from` in WorkflowBuilder to control
Other workflow events are ignored. Use `with_output_from` in WorkflowBuilder to control
which executors' outputs are surfaced as agent responses.
"""
if id is None:
@@ -301,7 +300,7 @@ class WorkflowAgent(BaseAgent):
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
if event.type in AGENT_FORWARDED_EVENT_TYPES:
if event.type == "output" or event.type == "request_info":
output_events.append(event)
result = self._convert_workflow_events_to_agent_response(response_id, output_events)
@@ -515,11 +514,7 @@ class WorkflowAgent(BaseAgent):
response_id: str,
output_events: list[WorkflowEvent[Any]],
) -> AgentResponse:
"""Convert a list of workflow events to an AgentResponse.
Caller-facing workflow events are forwarded as agent messages. Terminal and
intermediate event payloads keep their original content types.
"""
"""Convert a list of workflow output events to an AgentResponse."""
messages: list[Message] = []
raw_representations: list[object] = []
merged_usage: UsageDetails | None = None
@@ -540,19 +535,14 @@ class WorkflowAgent(BaseAgent):
raw_representations.append(output_event)
else:
data = output_event.data
# Anything that isn't `output` is intermediate — this branch only sees
# events that already passed the lifecycle filter and weren't request_info.
is_intermediate = output_event.type != "output"
if isinstance(data, AgentResponseUpdate):
# AgentResponseUpdate is a streaming-only payload. Accepting it
# in non-streaming runs would make message ordering depend on
# partial chunks for both terminal and intermediate events.
event_label = "Intermediate" if is_intermediate else "Output"
# We cannot support AgentResponseUpdate in non-streaming mode. This is because the message
# sequence cannot be guaranteed when there are streaming updates in between non-streaming
# responses.
raise AgentInvalidRequestException(
f"{event_label} event with AgentResponseUpdate data cannot be emitted "
"in non-streaming mode. Please ensure executors emit AgentResponse "
"for non-streaming workflows."
"Output event with AgentResponseUpdate data cannot be emitted in non-streaming mode. "
"Please ensure executors emit AgentResponse for non-streaming workflows."
)
if isinstance(data, AgentResponse):
@@ -636,21 +626,16 @@ class WorkflowAgent(BaseAgent):
) -> list[AgentResponseUpdate]:
"""Convert a workflow event to a list of AgentResponseUpdate objects.
Forwarding rule:
Events with type='output' and type='request_info' are processed.
Other workflow events are ignored as they are workflow-internal.
- ``type='output'`` — terminal user-facing emission. Forwarded as-is.
- ``type='intermediate'`` (and the deprecated ``type='data'``) — forwarded
as-is.
- ``type='request_info'`` — request-info translation (unchanged).
- Everything else (lifecycle, diagnostics, executor bookkeeping,
orchestration-internal events like ``group_chat``/``handoff_sent``/
``magentic_orchestrator``) is dropped.
For 'output' events, AgentExecutor yields AgentResponseUpdate for streaming updates
via ctx.yield_output(). This method converts those to agent response updates.
Returns:
A list of AgentResponseUpdate objects. Empty list if the event is not relevant.
"""
# TODO(evmattso): https://github.com/microsoft/agent-framework/issues/5885
if event.type not in AGENT_FORWARDED_EVENT_TYPES:
return []
if event.type != "request_info":
if event.type == "output":
data = event.data
executor_id = event.executor_id
@@ -123,7 +123,7 @@ class AgentExecutor(Executor):
- run(stream=True): Emits incremental output events (type='output') as the agent produces tokens
- run(): Emits a single output event (type='output') containing the complete response
Use `output_from` in WorkflowBuilder to control whether the AgentResponse
Use `with_output_from` in WorkflowBuilder to control whether the AgentResponse
or AgentResponseUpdate objects are yielded as workflow outputs.
Messages sent to downstream executors will always be the complete AgentResponse. In
@@ -478,7 +478,7 @@ class AgentExecutor(Executor):
# Prefer stream finalization when available so result hooks run
# (e.g., thread conversation updates). Fall back to reconstructing from updates
# for compatibility/custom agents that return a plain async iterable.
# for legacy/custom agents that return a plain async iterable.
# TODO(evmattso): Integrate workflow agent run handling around ResponseStream so
# AgentExecutor does not need this conditional stream-finalization branch.
maybe_get_final_response = getattr(stream, "get_final_response", None)
@@ -38,12 +38,7 @@ class EdgeRunner(ABC):
self._executors = executors
@abstractmethod
async def send_message(
self,
message: WorkflowMessage,
state: State,
ctx: RunnerContext,
) -> bool:
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
"""Send a message through the edge group.
Args:
@@ -95,12 +90,7 @@ class SingleEdgeRunner(EdgeRunner):
super().__init__(edge_group, executors)
self._edge = edge_group.edges[0]
async def send_message(
self,
message: WorkflowMessage,
state: State,
ctx: RunnerContext,
) -> bool:
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
"""Send a message through the single edge."""
should_execute = False
target_id: str | None = None
@@ -172,12 +162,7 @@ class FanOutEdgeRunner(EdgeRunner):
Callable[[Any, list[str]], list[str]] | None, getattr(edge_group, "selection_func", None)
)
async def send_message(
self,
message: WorkflowMessage,
state: State,
ctx: RunnerContext,
) -> bool:
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
"""Send a message through all edges in the fan-out edge group."""
deliverable_edges: list[Edge] = []
single_target_edge: Edge | None = None
@@ -268,11 +253,7 @@ class FanOutEdgeRunner(EdgeRunner):
# Execute outside the span
if single_target_edge:
await self._execute_on_target(
single_target_edge.target_id,
[single_target_edge.source_id],
message,
state,
ctx,
single_target_edge.target_id, [single_target_edge.source_id], message, state, ctx
)
return True
@@ -304,12 +285,7 @@ class FanInEdgeRunner(EdgeRunner):
# Key is the source executor ID, value is a list of messages
self._buffer: dict[str, list[WorkflowMessage]] = defaultdict(list)
async def send_message(
self,
message: WorkflowMessage,
state: State,
ctx: RunnerContext,
) -> bool:
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
"""Send a message through all edges in the fan-in edge group."""
execution_data: dict[str, Any] | None = None
with create_edge_group_processing_span(
@@ -386,11 +362,7 @@ class FanInEdgeRunner(EdgeRunner):
# Execute outside the span if needed
if execution_data:
await self._execute_on_target(
execution_data["target_id"],
execution_data["source_ids"],
execution_data["message"],
state,
ctx,
execution_data["target_id"], execution_data["source_ids"], execution_data["message"], state, ctx
)
return True
@@ -5,7 +5,6 @@ from __future__ import annotations
import builtins
import sys
import traceback as _traceback
import warnings
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
@@ -107,9 +106,8 @@ WorkflowEventType = Literal[
"status", # Workflow state changed (use .state)
"failed", # Workflow terminated with error (use .details)
# Data events
"output", # Executor yielded final terminal output (use .executor_id, .data)
"intermediate", # Executor emitted intermediate (non-terminal) output (use .executor_id, .data)
"data", # DEPRECATED — compatibility alias for intermediate emissions; use type='intermediate' instead.
"output", # Executor yielded final output (use .executor_id, .data)
"data", # Executor emitted data during execution (use .executor_id, .data)
# Request events (human-in-the-loop)
"request_info", # Executor requests external info (use .request_id, .source_executor_id)
# Diagnostic events (warnings/errors from user code)
@@ -130,34 +128,21 @@ WorkflowEventType = Literal[
]
# Event types forwarded across the ``workflow.as_agent()`` boundary. Anything not
# in this set — lifecycle events, diagnostics, executor bookkeeping, and
# orchestration-internal events (``group_chat``, ``handoff_sent``,
# ``magentic_orchestrator``) — stays inside the workflow and is not surfaced to
# agent callers. Internal to the ``_workflows`` package.
AGENT_FORWARDED_EVENT_TYPES: frozenset[str] = frozenset({
"output",
"intermediate",
"data", # deprecated alias for intermediate; retained for backward compat
"request_info",
})
class WorkflowEvent(Generic[DataT]):
"""Unified event for all workflow emissions.
This single generic class handles all workflow events through a `type` discriminator,
following the same pattern as the `Content` class.
Use factory methods for convenient construction of lifecycle, diagnostic, request,
and executor bookkeeping events. Workflow ``output`` and ``intermediate`` events
are emitted by ``ctx.yield_output(...)`` based on workflow output selection.
Use factory methods for convenient construction:
- `WorkflowEvent.started()` - workflow run began
- `WorkflowEvent.status(state)` - workflow state changed
- `WorkflowEvent.failed(details)` - workflow terminated with error
- `WorkflowEvent.warning(message)` - warning from user code
- `WorkflowEvent.error(exception)` - error from user code
- `WorkflowEvent.output(executor_id, data)` - executor yielded final output
- `WorkflowEvent.data(executor_id, data)` - executor emitted data (e.g., AgentResponse)
- `WorkflowEvent.request_info(...)` - executor requests external info
- `WorkflowEvent.superstep_started(iteration)` - superstep began
- `WorkflowEvent.superstep_completed(iteration)` - superstep ended
@@ -173,13 +158,14 @@ class WorkflowEvent(Generic[DataT]):
Examples:
.. code-block:: python
# Create lifecycle events via factory methods
# Create events via factory methods
started = WorkflowEvent.started()
status = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
output = WorkflowEvent.output("agent1", result_data)
# Type-safe access to event data
event: WorkflowEvent[AgentResponse] = WorkflowEvent("data", executor_id="agent1", data=response)
data: AgentResponse = event.data
# Emit typed data from executor
event: WorkflowEvent[AgentResponse] = WorkflowEvent.data("agent1", response)
data: AgentResponse = event.data # Type-safe access
# Check event type
if event.type == "status":
@@ -278,19 +264,17 @@ class WorkflowEvent(Generic[DataT]):
return WorkflowEvent("error", data=exception)
@classmethod
def emit(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
"""Create a 'data' event (deprecated alias for intermediate emissions).
def output(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
"""Create an 'output' event when an executor yields final output."""
return cls("output", executor_id=executor_id, data=data)
.. deprecated::
Use ``ctx.yield_output(...)`` and configure ``intermediate_output_from`` instead.
Will be removed in a future major release along with the ``type='data'`` event variant.
@classmethod
def emit(cls, executor_id: str, data: DataT) -> WorkflowEvent[DataT]:
"""Create a 'data' event when an executor emits data during execution.
This is the primary method for executors to emit typed data
(e.g., AgentResponse, AgentResponseUpdate, custom data).
"""
warnings.warn(
"WorkflowEvent.emit() / type='data' are deprecated; use ctx.yield_output() from an "
"intermediate-designated executor. Will be removed in a future major release.",
DeprecationWarning,
stacklevel=2,
)
return cls("data", executor_id=executor_id, data=data)
@classmethod
@@ -982,8 +982,7 @@ class FunctionalWorkflow:
# Emit the return value as the workflow output.
if return_value is not None:
with _framework_event_origin():
await ctx.add_event(WorkflowEvent("output", executor_id=self.name, data=return_value))
await ctx.add_event(WorkflowEvent.output(self.name, return_value))
# Persist step cache for response-only replay
self._last_step_cache = dict(ctx._step_cache)
@@ -4,11 +4,10 @@ from __future__ import annotations
import asyncio
import logging
from collections.abc import Callable
from copy import copy
from dataclasses import dataclass
from enum import Enum
from typing import Any, Literal, Protocol, TypeVar, runtime_checkable
from typing import Any, Protocol, TypeVar, runtime_checkable
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import INTERNAL_SOURCE_ID
@@ -19,8 +18,6 @@ from ._typing_utils import is_instance_of
logger = logging.getLogger(__name__)
T = TypeVar("T")
YieldOutputEventType = Literal["output", "intermediate"]
YieldOutputClassifier = Callable[[str], YieldOutputEventType | None]
class MessageType(Enum):
@@ -266,14 +263,6 @@ class RunnerContext(Protocol):
"""
...
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
"""Set the classifier used by WorkflowContext.yield_output()."""
...
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
...
class InProcRunnerContext:
"""In-process execution context for local execution and optional checkpointing."""
@@ -297,7 +286,6 @@ class InProcRunnerContext:
# Streaming flag - set by workflow's run(..., stream=True) vs run(..., stream=False)
self._streaming: bool = False
self._yield_output_classifier: YieldOutputClassifier = lambda _executor_id: "output"
# region Messaging and Events
async def send_message(self, message: WorkflowMessage) -> None:
@@ -492,11 +480,3 @@ class InProcRunnerContext:
A dictionary mapping request IDs to their corresponding WorkflowEvent (type='request_info').
"""
return dict(self._pending_request_info_events)
def set_yield_output_classifier(self, classifier: YieldOutputClassifier) -> None:
"""Set the classifier used by WorkflowContext.yield_output()."""
self._yield_output_classifier = classifier
def classify_yielded_output(self, executor_id: str) -> YieldOutputEventType | None:
"""Classify an executor's yield_output payload as output, intermediate, or hidden."""
return self._yield_output_classifier(executor_id)
@@ -104,7 +104,6 @@ class WorkflowGraphValidator:
executors: dict[str, Executor],
start_executor: Executor,
output_executors: list[str],
intermediate_executors: list[str] | None = None,
) -> None:
"""Validate the entire workflow graph.
@@ -113,7 +112,6 @@ class WorkflowGraphValidator:
executors: Map of executor IDs to executor instances
start_executor: The starting executor
output_executors: List of output executor IDs
intermediate_executors: List of intermediate executor IDs
Raises:
WorkflowValidationError: If any validation fails
@@ -160,7 +158,7 @@ class WorkflowGraphValidator:
self._validate_graph_connectivity(start_executor.id)
self._validate_self_loops()
self._validate_dead_ends()
self._output_validation(output_executors, intermediate_executors or [])
self._output_validation(output_executors)
def _validate_handler_output_annotations(self) -> None:
"""Validate that each handler's ctx parameter is annotated with WorkflowContext[T].
@@ -358,15 +356,8 @@ class WorkflowGraphValidator:
# region Output Validation
def _output_validation(self, output_executors: list[str], intermediate_executors: list[str]) -> None:
"""Validate that designated executors exist and have workflow output annotations."""
overlap = sorted(set(output_executors).intersection(intermediate_executors))
if overlap:
raise WorkflowValidationError(
f"Executors cannot be both output and intermediate designated: {overlap}",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
def _output_validation(self, output_executors: list[str]) -> None:
"""Validate that output executors exist in the workflow and have the correct workflow context annotations."""
for output_id in output_executors:
if output_id not in self._executors:
raise WorkflowValidationError(
@@ -381,20 +372,6 @@ class WorkflowGraphValidator:
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
for intermediate_id in intermediate_executors:
if intermediate_id not in self._executors:
raise WorkflowValidationError(
f"Intermediate executor '{intermediate_id}' is not present in the workflow graph",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
intermediate_executor = self._executors[intermediate_id]
if not intermediate_executor.workflow_output_types:
raise WorkflowValidationError(
f"Intermediate executor '{intermediate_id}' must have output type annotations defined.",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
# endregion
# region Additional Validation Scenarios
@@ -438,7 +415,6 @@ def validate_workflow_graph(
executors: dict[str, Executor],
start_executor: Executor,
output_executors: list[str],
intermediate_executors: list[str] | None = None,
) -> None:
"""Convenience function to validate a workflow graph.
@@ -447,7 +423,6 @@ def validate_workflow_graph(
executors: Map of executor IDs to executor instances
start_executor: The starting executor instance
output_executors: List of output executor IDs
intermediate_executors: List of intermediate executor IDs
Raises:
WorkflowValidationError: If any validation fails
@@ -458,5 +433,4 @@ def validate_workflow_graph(
executors,
start_executor,
output_executors,
intermediate_executors,
)
@@ -10,9 +10,7 @@ import json
import logging
import types
import uuid
import warnings
from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any, Literal, overload
from .._sessions import ContextProvider
@@ -36,7 +34,6 @@ from ._runner import Runner
from ._runner_context import RunnerContext
from ._state import State
from ._typing_utils import is_instance_of, try_coerce_to_type
from ._validation import ValidationTypeEnum, WorkflowValidationError
if TYPE_CHECKING:
from ._agent import WorkflowAgent
@@ -44,60 +41,6 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
_MISSING: Any = object()
def _coalesce_renamed_kwarg(old_name: str, old_value: Any, new_name: str, new_value: Any) -> Any:
"""Resolve a renamed keyword argument while keeping the deprecated name working.
Pass ``_MISSING`` (not ``None``) for the value that was not supplied — ``None`` is
a legitimate user-supplied value for these kwargs.
"""
old_supplied = old_value is not _MISSING
new_supplied = new_value is not _MISSING
if old_supplied and new_supplied:
raise TypeError(f"Cannot pass both `{old_name}` (deprecated) and `{new_name}`; use `{new_name}` only.")
if old_supplied:
warnings.warn(
f"`{old_name}` is deprecated and will be removed in a future version; use `{new_name}` instead.",
DeprecationWarning,
stacklevel=3,
)
return old_value
if new_supplied:
return new_value
return None
def _coalesce_output_from_kwarg(
output_from: Any,
output_executors: Any,
) -> Any:
"""Resolve output-selection aliases to canonical ``output_from``."""
supplied = [
name
for name, value in (
("output_from", output_from),
("output_executors", output_executors),
)
if value is not _MISSING
]
if len(supplied) > 1:
formatted = ", ".join(f"`{name}`" for name in supplied)
raise TypeError(f"Cannot pass multiple workflow output selection parameters ({formatted}); use `output_from`.")
if output_executors is not _MISSING:
warnings.warn(
"`output_executors` is deprecated and will be removed in a future version; use `output_from` instead.",
DeprecationWarning,
stacklevel=3,
)
return output_executors
if output_from is not _MISSING:
return output_from
return None
class WorkflowRunResult(list[WorkflowEvent]):
"""Container for events generated during non-streaming workflow execution.
@@ -130,14 +73,6 @@ class WorkflowRunResult(list[WorkflowEvent]):
"""
return [event.data for event in self if event.type == "output"]
def get_intermediate_outputs(self) -> list[Any]:
"""Get all intermediate outputs from the workflow run result.
Returns:
A list of intermediate outputs produced by the workflow during its execution.
"""
return [event.data for event in self if event.type == "intermediate"]
def get_request_info_events(self) -> list[WorkflowEvent[Any]]:
"""Get all request info events from the workflow run result.
@@ -167,42 +102,6 @@ class WorkflowRunResult(list[WorkflowEvent]):
# region Workflow
@dataclass(frozen=True)
class OutputDesignation:
"""Immutable rule for labeling executor yields as terminal, intermediate, or hidden outputs.
``outputs`` is ``None`` in omitted-selection compatibility mode (every yield is terminal). In explicit mode,
``outputs`` and ``intermediates`` are disjoint executor ID sets; unlisted executor
yields are hidden from caller-facing output/intermediate events.
Package-internal value type owned by ``Workflow``; not exported from ``agent_framework``.
"""
outputs: frozenset[str] | None = field(default=None)
intermediates: frozenset[str] = field(default_factory=lambda: frozenset[str]())
def is_terminal(self, executor_id: str) -> bool:
"""Return True when ``executor_id``'s yields should be labeled type='output'."""
if self.outputs is None:
return True
return executor_id in self.outputs
def is_intermediate(self, executor_id: str) -> bool:
"""Return True when ``executor_id``'s yields should be labeled type='intermediate'."""
if self.outputs is None:
return False
return executor_id in self.intermediates
def classify(self, executor_id: str) -> Literal["output", "intermediate"] | None:
"""Return the workflow event type for this executor's yield, or None when hidden."""
if self.outputs is None:
return "output"
if executor_id in self.outputs:
return "output"
if executor_id in self.intermediates:
return "intermediate"
return None
class Workflow(DictConvertible):
"""A graph-based execution engine that orchestrates connected executors.
@@ -283,11 +182,7 @@ class Workflow(DictConvertible):
name: str,
description: str | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
output_from: list[str] | None = _MISSING,
intermediate_output_from: list[str] | None = _MISSING,
*,
output_executors: list[str] | None = _MISSING,
intermediate_executors: list[str] | None = _MISSING,
output_executors: list[str] | None = None,
):
"""Initialize the workflow with a list of edges.
@@ -303,21 +198,9 @@ class Workflow(DictConvertible):
better observability and management.
description: Optional description of what the workflow does. If the workflow is built using
WorkflowBuilder, this will be the description of the builder.
output_from: List of executor IDs designated as workflow outputs, or
``None`` for omitted-selection compatibility behavior when ``intermediate_output_from`` is also
``None``.
intermediate_output_from: List of executor IDs designated as intermediate outputs.
In explicit designation mode, unlisted executor yields are hidden from
caller-facing output/intermediate events.
output_executors: Deprecated alias for ``output_from``. Will be removed
in a future version.
intermediate_executors: Deprecated alias for ``intermediate_output_from``. Will be
removed in a future version.
output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs.
If None or empty, all executor outputs are treated as workflow outputs.
"""
output_from = _coalesce_output_from_kwarg(output_from, output_executors)
intermediate_output_from = _coalesce_renamed_kwarg(
"intermediate_executors", intermediate_executors, "intermediate_output_from", intermediate_output_from
)
self.edge_groups = list(edge_groups)
self.executors = dict(executors)
self.start_executor_id = start_executor.id
@@ -332,20 +215,12 @@ class Workflow(DictConvertible):
self.graph_signature = self._compute_graph_signature()
self.graph_signature_hash = self._hash_graph_signature(self.graph_signature)
# Single value type encodes omitted-selection compatibility vs explicit output-designation policy.
output_designation_ids = (
frozenset(output_from)
if output_from is not None
else (frozenset[str]() if intermediate_output_from is not None else None)
)
self._output_designation: OutputDesignation = OutputDesignation(
outputs=output_designation_ids,
intermediates=frozenset(intermediate_output_from or []),
)
# Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs.
# If None or empty, all executor outputs are considered workflow outputs.
self._output_executors = list(output_executors) if output_executors else list(self.executors.keys())
# Store non-serializable runtime objects as private attributes
self._runner_context = runner_context
self._runner_context.set_yield_output_classifier(self._output_designation.classify)
self._state = State()
self._runner: Runner = Runner(
self.edge_groups,
@@ -379,12 +254,7 @@ class Workflow(DictConvertible):
"max_iterations": self.max_iterations,
"edge_groups": [group.to_dict() for group in self.edge_groups],
"executors": {executor_id: executor.to_dict() for executor_id, executor in self.executors.items()},
"output_executors": (
sorted(self._output_designation.outputs) if self._output_designation.outputs is not None else None
),
"intermediate_executors": (
sorted(self._output_designation.intermediates) if self._output_designation.outputs is not None else None
),
"output_executors": self._output_executors,
}
if self.description is not None:
@@ -419,44 +289,8 @@ class Workflow(DictConvertible):
return self.executors[self.start_executor_id]
def get_output_executors(self) -> list[Executor]:
"""Get the list of output executors in the workflow.
In omitted-selection compatibility mode (no explicit ``output_from``), returns every
executor in the workflow. In explicit mode, returns only the designated output executors.
"""
designated = self._output_designation.outputs
if designated is None:
return list(self.executors.values())
return [self._get_designated_executor(executor_id, kind="Output") for executor_id in designated]
def get_intermediate_executors(self) -> list[Executor]:
"""Get the list of intermediate executors in the workflow."""
return [
self._get_designated_executor(executor_id, kind="Intermediate")
for executor_id in self._output_designation.intermediates
]
def _get_designated_executor(self, executor_id: str, *, kind: str) -> Executor:
try:
return self.executors[executor_id]
except KeyError as exc:
raise WorkflowValidationError(
f"{kind} executor '{executor_id}' is not present in the workflow graph",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
) from exc
def is_terminal_executor(self, executor_id: str) -> bool:
"""Return True when ``executor_id``'s yields are labeled type='output'.
Public read-only predicate over the workflow's output designation. External
observers (e.g., orchestration tests, DevUI mappers) should consult this rather
than re-encoding the rule as a set-membership check.
"""
return self._output_designation.is_terminal(executor_id)
def is_intermediate_executor(self, executor_id: str) -> bool:
"""Return True when ``executor_id``'s yields are labeled type='intermediate'."""
return self._output_designation.is_intermediate(executor_id)
"""Get the list of output executors in the workflow."""
return [self.executors[executor_id] for executor_id in self._output_executors]
def get_executors_list(self) -> list[Executor]:
"""Get the list of executors in the workflow."""
@@ -797,6 +631,8 @@ class Workflow(DictConvertible):
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
):
if event.type == "output" and not self._should_yield_output_event(event):
continue
if event.type == "request_info" and event.request_id in (responses or {}):
# Don't yield request_info events for which we have responses to send -
# these are considered "handled". This prevents the caller from seeing
@@ -989,6 +825,22 @@ class Workflow(DictConvertible):
)
return {GLOBAL_KWARGS_KEY: dict(kwargs)}
def _should_yield_output_event(self, event: WorkflowEvent[Any]) -> bool:
"""Determine if an output event should be yielded as a workflow output.
Args:
event: The WorkflowEvent with type='output' to evaluate.
Returns:
True if the event should be yielded as a workflow output, False otherwise.
"""
# If no specific output executors are defined, yield all outputs
if not self._output_executors:
return True
# Check if the event's source executor is in the list of output executors
return event.executor_id in self._output_executors
# Graph signature helpers
def _compute_graph_signature(self) -> dict[str, Any]:
@@ -3,9 +3,8 @@
import logging
import sys
import uuid
import warnings
from collections.abc import Callable, Sequence
from typing import Any, Literal
from typing import Any
from .._agents import SupportsAgentRun
from ..observability import OtelAttr, capture_exception, create_workflow_span
@@ -28,12 +27,8 @@ from ._edge import (
)
from ._executor import Executor
from ._runner_context import InProcRunnerContext
from ._validation import ValidationTypeEnum, WorkflowValidationError, validate_workflow_graph
from ._workflow import (
_MISSING, # pyright: ignore[reportPrivateUsage]
Workflow,
_coalesce_output_from_kwarg, # pyright: ignore[reportPrivateUsage]
)
from ._validation import validate_workflow_graph
from ._workflow import Workflow
if sys.version_info >= (3, 11):
from typing import Self # type: ignore # pragma: no cover
@@ -43,12 +38,6 @@ else:
logger = logging.getLogger(__name__)
_ALL_OUTPUTS: Literal["all"] = "all"
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
_OutputSelection = list[Executor | SupportsAgentRun] | Literal["all"] | None
_IntermediateOutputSelection = list[Executor | SupportsAgentRun] | Literal["all", "all_other"] | None
_AnyOutputSelection = _OutputSelection | _IntermediateOutputSelection
class WorkflowBuilder:
"""A builder class for constructing workflows.
@@ -94,9 +83,7 @@ class WorkflowBuilder:
*,
start_executor: Executor | SupportsAgentRun,
checkpoint_storage: CheckpointStorage | None = None,
output_from: list[Executor | SupportsAgentRun] | Literal["all"] | None = _MISSING,
intermediate_output_from: _IntermediateOutputSelection = _MISSING,
output_executors: list[Executor | SupportsAgentRun] | None = _MISSING,
output_executors: list[Executor | SupportsAgentRun] | None = None,
):
"""Initialize the WorkflowBuilder.
@@ -111,39 +98,9 @@ class WorkflowBuilder:
start_executor: The starting executor for the workflow. Can be an Executor instance
or SupportsAgentRun instance.
checkpoint_storage: Optional checkpoint storage for enabling workflow state persistence.
output_from: Designates which executors emit workflow output
(``type='output'`` workflow events). Pass ``"all"`` to explicitly select every
executor with declared workflow output types.
intermediate_output_from: Designates which executors emit intermediate output
(``type='intermediate'`` workflow events). Pass ``"all"`` to select every executor
with declared workflow output types as intermediate (no executor emits ``output``).
Pass ``"all_other"`` to select every executor with declared workflow output types
that is not selected by ``output_from``.
If neither ``output_from`` nor ``intermediate_output_from`` is provided,
omitted-selection compatibility behavior applies and every ``yield_output`` produces
``type='output'``. If either is provided, explicit mode applies: listed
workflow-output executors emit ``output``, listed intermediate executors emit
``intermediate``, and unlisted executor yields are hidden.
Output selection behavior:
- Omit both selections: every ``yield_output`` emits ``output`` for compatibility,
with a deprecation warning.
- ``output_from="all"``: every output-capable executor emits ``output``.
- ``output_from=[A]``: only A emits ``output``; other executor payloads are hidden.
- ``output_from=[A], intermediate_output_from="all_other"``: A emits ``output``;
all other output-capable executors emit ``intermediate``.
- ``intermediate_output_from="all_other"``: no executor emits ``output``; every
output-capable executor emits ``intermediate``.
- ``output_from=[], intermediate_output_from="all_other"``: no executor emits
``output``; every output-capable executor emits ``intermediate``.
- ``output_from=[A], intermediate_output_from=[B, C]``: A emits ``output``; B and C
emit ``intermediate``; other executor payloads are hidden.
output_executors: **Deprecated** alias for ``output_from``. Will be removed in a
future version.
output_executors: Optional list of executors whose outputs should be collected.
If not provided, outputs from all executors are collected.
"""
output_from = _coalesce_output_from_kwarg(output_from, output_executors)
if intermediate_output_from is _MISSING:
intermediate_output_from = None
self._edge_groups: list[EdgeGroup] = []
self._executors: dict[str, Executor] = {}
self._start_executor: Executor | None = None
@@ -156,13 +113,8 @@ class WorkflowBuilder:
# being created for the same agent.
self._agent_wrappers: dict[str, Executor] = {}
# ``None`` for both means omitted-selection compatibility behavior
# (every yield_output produces type='output').
# If either is provided, explicit mode applies and unlisted executor yields are hidden.
self._output_from: _OutputSelection = self._coerce_output_from(output_from)
self._intermediate_output_from: _IntermediateOutputSelection = self._coerce_intermediate_output_from(
intermediate_output_from
)
# Output executors filter; if set, only outputs from these executors are yielded
self._output_executors: list[Executor | SupportsAgentRun] = output_executors if output_executors else []
# Set the start executor
self._set_start_executor(start_executor)
@@ -632,96 +584,6 @@ class WorkflowBuilder:
if existing is not wrapped:
self._add_executor(wrapped)
def _coerce_output_from(self, output_from: Any) -> _OutputSelection:
"""Coerce workflow-output selection while preserving the explicit ``"all"`` literal."""
if output_from is None:
return None
if output_from == _ALL_OUTPUTS:
return _ALL_OUTPUTS
if isinstance(output_from, str):
raise ValueError(f"Unsupported output_from literal {output_from!r}; use 'all' or a list of executors.")
return list(output_from)
def _coerce_intermediate_output_from(self, intermediate_output_from: Any) -> _IntermediateOutputSelection:
"""Coerce intermediate-output selection and reject output-only literals."""
if intermediate_output_from is None:
return None
if isinstance(intermediate_output_from, str):
if intermediate_output_from == _ALL_OUTPUTS:
return _ALL_OUTPUTS
if intermediate_output_from == _ALL_OTHER_OUTPUTS:
return _ALL_OTHER_OUTPUTS
raise ValueError(
f"Unsupported intermediate_output_from literal {intermediate_output_from!r}; "
"use 'all', 'all_other', or a list of executors."
)
return list(intermediate_output_from)
def _resolve_designated_executor_ids(
self,
designated: _AnyOutputSelection,
) -> list[str] | None:
"""Resolve an optional designation list into executor IDs without mutating the graph."""
if designated is None:
return None
if designated == _ALL_OUTPUTS:
return [executor_id for executor_id, executor in self._executors.items() if executor.workflow_output_types]
if designated == _ALL_OTHER_OUTPUTS:
raise ValueError("intermediate_output_from='all_other' must be expanded relative to output_from.")
ids: list[str] = []
for item in designated:
if isinstance(item, Executor):
ids.append(item.id)
elif isinstance(item, SupportsAgentRun):
ids.append(resolve_agent_id(item))
else:
raise TypeError(
"WorkflowBuilder expected designation entries to be Executor or SupportsAgentRun instances; "
f"got {type(item).__name__}."
)
return ids
def _validate_designation_lists(
self,
output_executor_ids: list[str] | None,
intermediate_executor_ids: list[str] | None,
) -> None:
"""Validate builder-level designation rules that need omitted-vs-explicit context."""
explicit_mode = output_executor_ids is not None or intermediate_executor_ids is not None
if not explicit_mode:
return
output_ids = output_executor_ids or []
intermediate_ids = intermediate_executor_ids or []
if not output_ids and not intermediate_ids:
raise WorkflowValidationError(
"Explicit workflow output designation must include at least one output or intermediate executor.",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
duplicate_outputs = sorted({executor_id for executor_id in output_ids if output_ids.count(executor_id) > 1})
if duplicate_outputs:
raise WorkflowValidationError(
f"Duplicate output executor designation(s): {duplicate_outputs}",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
duplicate_intermediates = sorted({
executor_id for executor_id in intermediate_ids if intermediate_ids.count(executor_id) > 1
})
if duplicate_intermediates:
raise WorkflowValidationError(
f"Duplicate intermediate executor designation(s): {duplicate_intermediates}",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
overlap = sorted(set(output_ids).intersection(intermediate_ids))
if overlap:
raise WorkflowValidationError(
f"Executors cannot be both output and intermediate designated: {overlap}",
validation_type=ValidationTypeEnum.OUTPUT_VALIDATION,
)
def build(self) -> Workflow:
"""Build and return the constructed workflow.
@@ -763,43 +625,6 @@ class WorkflowBuilder:
# Workflows can be reused multiple times
events2 = await workflow.run("world")
print(events2.get_outputs()) # ['WORLD']
# Select one executor as Workflow Output.
workflow = WorkflowBuilder(start_executor=executor, output_from=[executor]).build()
events = await workflow.run("hello")
print(events.get_outputs()) # ['HELLO']
print(events.get_intermediate_outputs()) # []
# Make one executor Workflow Output and every other output-capable executor Intermediate Output.
workflow = (
WorkflowBuilder(
start_executor=planner,
output_from=[answerer],
intermediate_output_from="all_other",
)
.add_edge(planner, answerer)
.build()
)
events = await workflow.run("hello")
print(events.get_outputs()) # outputs from answerer
print(events.get_intermediate_outputs()) # outputs from planner
# Build a progress-only workflow: no Workflow Output, all output-capable executors are intermediate.
workflow = (
WorkflowBuilder(start_executor=planner, intermediate_output_from="all_other")
.add_edge(planner, answerer)
.build()
)
events = await workflow.run("hello")
print(events.get_outputs()) # []
print(events.get_intermediate_outputs()) # outputs from planner and answerer
# Explicitly preserve all-output behavior without relying on omitted-selection compatibility.
workflow = (
WorkflowBuilder(start_executor=planner, output_from="all").add_edge(planner, answerer).build()
)
events = await workflow.run("hello")
print(events.get_outputs()) # outputs from planner and answerer
"""
# Create workflow build span that includes validation and workflow creation
with create_workflow_span(OtelAttr.WORKFLOW_BUILD_SPAN) as span:
@@ -812,47 +637,19 @@ class WorkflowBuilder:
"Starting executor must be set via the start_executor constructor parameter before building."
)
if self._output_from is None and self._intermediate_output_from is None:
warnings.warn(
"WorkflowBuilder built without explicit output_from or intermediate_output_from; "
"every yield_output produces type='output' for compatibility. Pass output_from='all', "
"output_from=[...], or intermediate_output_from=[...] to opt into explicit designation - "
"explicit designation will be required in a future version.",
DeprecationWarning,
stacklevel=2,
)
start_executor = self._start_executor
executors = self._executors
edge_groups = self._edge_groups
output_ids = self._resolve_designated_executor_ids(self._output_from)
intermediate_output_ids: list[str] | None
if self._intermediate_output_from == _ALL_OTHER_OUTPUTS:
output_ids_for_all_other = output_ids or []
intermediate_output_ids = [
executor_id
for executor_id, executor in self._executors.items()
if executor.workflow_output_types and executor_id not in output_ids_for_all_other
]
else:
intermediate_output_ids = self._resolve_designated_executor_ids(self._intermediate_output_from)
self._validate_designation_lists(output_ids, intermediate_output_ids)
explicit_mode = output_ids is not None or intermediate_output_ids is not None
output_for_workflow: list[str] | None = output_ids if explicit_mode else None
if explicit_mode and output_for_workflow is None:
output_for_workflow = []
intermediate_output_for_workflow: list[str] | None = intermediate_output_ids if explicit_mode else None
if explicit_mode and intermediate_output_for_workflow is None:
intermediate_output_for_workflow = []
output_executors = [ex.id for ex in self._output_executors if isinstance(ex, Executor)] + [
resolve_agent_id(agent) for agent in self._output_executors if isinstance(agent, SupportsAgentRun)
]
# Perform validation before creating the workflow
validate_workflow_graph(
edge_groups,
executors,
start_executor,
output_for_workflow or [],
intermediate_output_for_workflow or [],
output_executors,
)
# Add validation completed event
@@ -869,8 +666,7 @@ class WorkflowBuilder:
self._name,
description=self._description,
max_iterations=self._max_iterations,
output_from=output_for_workflow,
intermediate_output_from=intermediate_output_for_workflow,
output_executors=output_executors,
)
build_attributes: dict[str, Any] = {
OtelAttr.WORKFLOW_BUILDER_NAME: self._name,
@@ -201,7 +201,6 @@ def validate_workflow_context_annotation(
# Event types reserved for framework lifecycle (not allowed from user code)
_FRAMEWORK_LIFECYCLE_EVENT_TYPES: frozenset[str] = frozenset({"started", "status", "failed"})
_OUTPUT_SELECTION_EVENT_TYPES: frozenset[str] = frozenset({"output", "intermediate"})
class WorkflowContext(Generic[OutT, W_OutT]):
@@ -338,20 +337,7 @@ class WorkflowContext(Generic[OutT, W_OutT]):
await self._runner_context.send_message(msg)
async def yield_output(self, output: W_OutT) -> None:
"""Yield an output from this executor.
The framework labels the resulting workflow event based on the workflow's explicit
output designation:
- Omitted-selection compatibility behavior: every yield produces ``type='output'``.
- Explicit mode: output-designated executors produce ``type='output'``,
intermediate-designated executors produce ``type='intermediate'``, and
unlisted executor yields are hidden from caller-facing events.
Whether a given executor produces ``output`` or ``intermediate`` events is fixed at
workflow-build time via ``output_from`` / ``intermediate_output_from`` on
:class:`WorkflowBuilder`; an executor cannot vary the label per yield. To change an
executor's role, list it under a different designation when building the workflow.
"""Set the output of the workflow.
Args:
output: The output to yield. This must conform to the workflow output type(s)
@@ -361,24 +347,12 @@ class WorkflowContext(Generic[OutT, W_OutT]):
# (deepcopy to capture state at yield time)
self._yielded_outputs.append(copy.deepcopy(output))
event_type = self._runner_context.classify_yielded_output(self._executor_id)
if event_type is None:
return
with _framework_event_origin():
event = WorkflowEvent(event_type, executor_id=self._executor_id, data=output)
event = WorkflowEvent.output(self._executor_id, output)
await self._runner_context.add_event(event)
async def add_event(self, event: WorkflowEvent[Any]) -> None:
"""Add an event to the workflow context."""
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _OUTPUT_SELECTION_EVENT_TYPES:
warning_msg = (
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event directly, "
"which is reserved for ctx.yield_output(). The event was ignored."
)
logger.warning(warning_msg)
await self._runner_context.add_event(WorkflowEvent.warning(warning_msg))
return
if event.origin == WorkflowEventSource.EXECUTOR and event.type in _FRAMEWORK_LIFECYCLE_EVENT_TYPES:
warning_msg = (
f"Executor '{self._executor_id}' attempted to emit a '{event.type}' event, "
@@ -16,7 +16,6 @@ from ._const import GLOBAL_KWARGS_KEY, WORKFLOW_RUN_KWARGS_KEY
from ._events import (
WorkflowEvent,
WorkflowRunState,
_framework_event_origin, # type: ignore[reportPrivateUsage]
)
from ._executor import Executor, handler
from ._request_info_mixin import response_handler
@@ -553,12 +552,10 @@ class WorkflowExecutor(Executor):
# Collect all events from the workflow
request_info_events = result.get_request_info_events()
outputs = result.get_outputs()
intermediate_outputs = result.get_intermediate_outputs()
workflow_run_state = result.get_final_state()
logger.debug(
f"WorkflowExecutor {self.id} processing workflow result with "
f"{len(outputs)} outputs, {len(intermediate_outputs)} intermediate outputs, "
f"and {len(request_info_events)} request info events. "
f"{len(outputs)} outputs and {len(request_info_events)} request info events. "
f"Workflow run state: {workflow_run_state}"
)
@@ -569,19 +566,6 @@ class WorkflowExecutor(Executor):
else:
await asyncio.gather(*[ctx.send_message(output) for output in outputs])
# Pipe sub-workflow intermediate emissions up through the parent's event stream.
# Bypasses the parent's yield-output classifier so the 'intermediate' label is preserved
# across the encapsulation boundary; uses this WorkflowExecutor's id as the source
# so outer callers don't need to know the sub-workflow's internal executor layout.
if intermediate_outputs:
async def _forward_intermediate_output(output: Any) -> None:
with _framework_event_origin():
event = WorkflowEvent("intermediate", executor_id=self.id, data=output)
await ctx.add_event(event)
await asyncio.gather(*[_forward_intermediate_output(output) for output in intermediate_outputs])
# Process request info events
for event in request_info_events:
request_id = event.request_id
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.5.0"
version = "1.4.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -2567,15 +2567,10 @@ async def test_shared_local_storage_cross_provider_responses_history_does_not_le
responses_second.incomplete = None
responses_second.output = [responses_text_item]
def _as_raw(resp: MagicMock) -> MagicMock:
resp.parse = MagicMock(return_value=resp)
resp.headers = {}
return resp
with patch.object(
responses_client.client.responses.with_raw_response,
responses_client.client.responses,
"create",
side_effect=[_as_raw(responses_first), _as_raw(responses_second)],
side_effect=[responses_first, responses_second],
) as mock_responses_create:
responses_result = await responses_agent.run("Find me a hotel in Paris", session=session)
+3 -1
View File
@@ -4227,7 +4227,9 @@ async def test_mcp_tool_call_tool_forwards_tool_list_meta():
self.session.call_tool = AsyncMock(
return_value=types.CallToolResult(content=[types.TextContent(type="text", text="result")])
)
self.session.list_prompts = AsyncMock(return_value=types.ListPromptsResult(prompts=[]))
self.session.list_prompts = AsyncMock(
return_value=types.ListPromptsResult(prompts=[])
)
def get_mcp_client(self) -> _AsyncGeneratorContextManager[Any, None]:
return None
@@ -272,7 +272,9 @@ async def test_agent_executor_tool_call_with_approval() -> None:
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
workflow = (
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
)
# Act
events = await workflow.run("Invoke tool requiring approval")
@@ -341,7 +343,9 @@ async def test_agent_executor_parallel_tool_call_with_approval() -> None:
tools=[mock_tool_requiring_approval],
)
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
workflow = (
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
)
# Act
events = await workflow.run("Invoke tool requiring approval")
@@ -508,7 +512,9 @@ async def test_agent_executor_declaration_only_tool_emits_request_info() -> None
tools=[declaration_only_tool],
)
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
workflow = (
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
)
# Act
events = await workflow.run("Use the client side tool")
@@ -581,7 +587,9 @@ async def test_agent_executor_parallel_declaration_only_tool_emits_request_info(
tools=[declaration_only_tool],
)
workflow = WorkflowBuilder(start_executor=agent, output_from=[test_executor]).add_edge(agent, test_executor).build()
workflow = (
WorkflowBuilder(start_executor=agent, output_executors=[test_executor]).add_edge(agent, test_executor).build()
)
# Act
events = await workflow.run("Use the client side tool")
@@ -9,7 +9,7 @@ from agent_framework._workflows._events import WorkflowEvent
def test_workflow_event_with_agent_response_data_type() -> None:
"""Verify WorkflowEvent[AgentResponse].data is typed as AgentResponse."""
response = AgentResponse(messages=[Message(role="assistant", contents=["Hello"])])
event: WorkflowEvent[AgentResponse] = WorkflowEvent("intermediate", executor_id="test", data=response)
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
# This assignment should pass type checking without a cast
data: AgentResponse = event.data
@@ -20,7 +20,7 @@ def test_workflow_event_with_agent_response_data_type() -> None:
def test_workflow_event_with_agent_response_update_data_type() -> None:
"""Verify WorkflowEvent[AgentResponseUpdate].data is typed as AgentResponseUpdate."""
update = AgentResponseUpdate()
event: WorkflowEvent[AgentResponseUpdate] = WorkflowEvent("intermediate", executor_id="test", data=update)
event: WorkflowEvent[AgentResponseUpdate] = WorkflowEvent.emit(executor_id="test", data=update)
# This assignment should pass type checking without a cast
data: AgentResponseUpdate = event.data
@@ -30,7 +30,7 @@ def test_workflow_event_with_agent_response_update_data_type() -> None:
def test_workflow_event_repr() -> None:
"""Verify WorkflowEvent.__repr__ uses consistent format."""
response = AgentResponse(messages=[Message(role="assistant", contents=["Hello"])])
event: WorkflowEvent[AgentResponse] = WorkflowEvent("intermediate", executor_id="test", data=response)
event: WorkflowEvent[AgentResponse] = WorkflowEvent.emit(executor_id="test", data=response)
repr_str = repr(event)
assert "WorkflowEvent" in repr_str
@@ -177,7 +177,7 @@ async def test_agent_executor_populates_full_conversation_non_streaming() -> Non
agent_exec = AgentExecutor(agent, id="agent1-exec")
capturer = _CaptureFullConversation(id="capture")
wf = WorkflowBuilder(start_executor=agent_exec, output_from=[capturer]).add_edge(agent_exec, capturer).build()
wf = WorkflowBuilder(start_executor=agent_exec, output_executors=[capturer]).add_edge(agent_exec, capturer).build()
# Act: use run() to test non-streaming mode
result = await wf.run("hello world")
@@ -344,7 +344,7 @@ async def test_agent_executor_full_conversation_round_trip_does_not_duplicate_hi
coordinator = _RoundTripCoordinator(target_agent_id="writer_agent")
wf = (
WorkflowBuilder(start_executor=agent_exec, output_from=[coordinator])
WorkflowBuilder(start_executor=agent_exec, output_executors=[coordinator])
.add_edge(agent_exec, coordinator)
.add_edge(coordinator, agent_exec)
.build()
@@ -450,7 +450,7 @@ async def test_run_request_with_full_history_clears_service_session_id() -> None
coordinator = _FullHistoryReplayCoordinator(id="coord", target_exec=spy_exec)
wf = (
WorkflowBuilder(start_executor=tool_exec, output_from=[coordinator])
WorkflowBuilder(start_executor=tool_exec, output_executors=[coordinator])
.add_edge(tool_exec, coordinator)
.add_edge(coordinator, spy_exec)
.build()
@@ -478,7 +478,7 @@ async def test_from_response_preserves_service_session_id() -> None:
# Simulate a prior run on the spy executor.
spy_exec._session.service_session_id = "resp_PREVIOUS_RUN" # pyright: ignore[reportPrivateUsage]
wf = WorkflowBuilder(start_executor=tool_exec, output_from=[spy_exec]).add_edge(tool_exec, spy_exec).build()
wf = WorkflowBuilder(start_executor=tool_exec, output_executors=[spy_exec]).add_edge(tool_exec, spy_exec).build()
result = await wf.run("start")
assert result.get_outputs() is not None
@@ -517,7 +517,7 @@ async def test_with_text_preserves_full_conversation_through_custom_executor() -
capturer = _CaptureFullConversation(id="capture")
wf = (
WorkflowBuilder(start_executor=agent1, output_from=[capturer])
WorkflowBuilder(start_executor=agent1, output_executors=[capturer])
.add_chain([agent1, agent2, _upper_case_executor, agent3, capturer])
.build()
)
@@ -165,13 +165,13 @@ class TestEventEmission:
@workflow
async def pipeline(x: int, ctx: RunContext) -> int:
await ctx.add_event(WorkflowEvent("intermediate", executor_id="pipeline", data="custom_data"))
await ctx.add_event(WorkflowEvent.emit("pipeline", "custom_data"))
return x
result = await pipeline.run(1)
intermediate_events = [e for e in result if e.type == "intermediate"]
assert len(intermediate_events) == 1
assert intermediate_events[0].data == "custom_data"
data_events = [e for e in result if e.type == "data"]
assert len(data_events) == 1
assert data_events[0].data == "custom_data"
# ---------------------------------------------------------------------------
@@ -1,137 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the ``OutputDesignation`` value type and the ``Workflow.is_terminal_executor``
public predicate that delegates to it.
The states the value type encodes:
- Omitted-selection compatibility: ``outputs=None`` -> every executor is terminal.
- Explicit: disjoint ``outputs`` and ``intermediates`` sets classify listed executors,
and hide unlisted executors.
"""
from __future__ import annotations
import pytest
from typing_extensions import Never
from agent_framework import (
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowValidationError,
executor,
)
from agent_framework._workflows._runner_context import InProcRunnerContext
from agent_framework._workflows._workflow import OutputDesignation, Workflow
# ---------------------------------------------------------------------------
# OutputDesignation value type
# ---------------------------------------------------------------------------
def test_omitted_selection_designation_marks_every_executor_as_terminal() -> None:
designation = OutputDesignation() # designated defaults to None
assert designation.outputs is None
assert designation.is_terminal("anything")
assert designation.is_terminal("else")
assert designation.classify("anything") == "output"
def test_strict_empty_designation_marks_no_executor_as_terminal() -> None:
designation = OutputDesignation(outputs=frozenset())
assert designation.outputs == frozenset()
assert not designation.is_terminal("anything")
assert not designation.is_terminal("else")
assert designation.classify("anything") is None
def test_strict_designated_set_only_terminal_for_members() -> None:
designation = OutputDesignation(outputs=frozenset({"alpha", "beta"}), intermediates=frozenset({"gamma"}))
assert designation.is_terminal("alpha")
assert designation.is_terminal("beta")
assert not designation.is_terminal("gamma")
assert designation.is_intermediate("gamma")
assert designation.classify("alpha") == "output"
assert designation.classify("gamma") == "intermediate"
assert designation.classify("delta") is None
def test_designation_is_frozen() -> None:
from dataclasses import FrozenInstanceError
designation = OutputDesignation(outputs=frozenset({"alpha"}))
with pytest.raises(FrozenInstanceError):
designation.outputs = frozenset({"beta"}) # type: ignore[misc]
# ---------------------------------------------------------------------------
# Workflow.is_terminal_executor delegates to the designation
# ---------------------------------------------------------------------------
@executor
async def _emit_one(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("hello")
@executor
async def _downstream(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("downstream")
def test_is_terminal_executor_omitted_selection_returns_true_for_any_id() -> None:
"""Omitted-selection compatibility behavior: every executor is terminal."""
import warnings
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
workflow = WorkflowBuilder(start_executor=_emit_one).build()
assert workflow.is_terminal_executor(_emit_one.id)
assert workflow.is_terminal_executor("anything-else")
def test_is_intermediate_executor_explicit_list_returns_true_only_for_designated() -> None:
"""Explicit mode tracks intermediate-designated executors separately."""
workflow = WorkflowBuilder(start_executor=_emit_one, intermediate_output_from=[_emit_one]).build()
assert not workflow.is_terminal_executor(_emit_one.id)
assert not workflow.is_terminal_executor("nope")
assert workflow.is_intermediate_executor(_emit_one.id)
assert not workflow.is_intermediate_executor("nope")
def test_is_terminal_executor_strict_list_returns_true_only_for_designated() -> None:
"""Strict mode with a designated list: only listed executors are terminal."""
workflow = (
WorkflowBuilder(start_executor=_emit_one, output_from=[_emit_one]).add_edge(_emit_one, _downstream).build()
)
assert workflow.is_terminal_executor(_emit_one.id)
assert not workflow.is_terminal_executor(_downstream.id)
def test_get_output_executors_throws_when_designation_references_missing_executor() -> None:
workflow = Workflow(
[],
{_emit_one.id: _emit_one},
_emit_one,
InProcRunnerContext(),
"test",
output_from=["missing"],
)
with pytest.raises(WorkflowValidationError, match="Output executor 'missing' is not present"):
workflow.get_output_executors()
def test_get_intermediate_executors_throws_when_designation_references_missing_executor() -> None:
workflow = Workflow(
[],
{_emit_one.id: _emit_one},
_emit_one,
InProcRunnerContext(),
"test",
output_from=[],
intermediate_output_from=["missing"],
)
with pytest.raises(WorkflowValidationError, match="Intermediate executor 'missing' is not present"):
workflow.get_intermediate_executors()
@@ -1,287 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the explicit output/intermediate selection contract on WorkflowBuilder."""
from __future__ import annotations
import warnings
from typing import Any
import pytest
from typing_extensions import Never
from agent_framework import (
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowValidationError,
executor,
)
@executor
async def _emit_one(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("hello")
@executor
async def _start(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output("from-start")
await ctx.send_message("downstream")
@executor
async def _downstream(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("from-downstream")
def test_designation_unset_emits_deprecation_warning() -> None:
"""State A: WorkflowBuilder built without explicit designation warns."""
with pytest.warns(DeprecationWarning, match="output_from or intermediate_output_from") as warning_info:
WorkflowBuilder(start_executor=_emit_one).build()
assert str(warning_info[0].message) == (
"WorkflowBuilder built without explicit output_from or intermediate_output_from; "
"every yield_output produces type='output' for compatibility. Pass output_from='all', "
"output_from=[...], or intermediate_output_from=[...] to opt into explicit designation - "
"explicit designation will be required in a future version."
)
@pytest.mark.asyncio
async def test_designation_unset_preserves_compatibility_all_output_behavior() -> None:
"""Omitted designation keeps compatibility all-output behavior while warning."""
with pytest.warns(DeprecationWarning, match="output_from or intermediate_output_from"):
workflow = WorkflowBuilder(start_executor=_start).add_edge(_start, _downstream).build()
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == ["from-start", "from-downstream"]
assert result.get_intermediate_outputs() == []
@pytest.mark.asyncio
async def test_output_from_all_emits_all_outputs_without_omitted_selection_warning() -> None:
"""Explicit all-output designation emits every executor payload without omitted-selection warning."""
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
workflow = WorkflowBuilder(start_executor=_start, output_from="all").add_edge(_start, _downstream).build()
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == ["from-start", "from-downstream"]
assert result.get_intermediate_outputs() == []
@pytest.mark.asyncio
async def test_output_from_all_with_empty_intermediate_list_is_valid() -> None:
"""Explicit all-output plus an empty intermediate list is a concrete no-intermediate selection."""
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
workflow = (
WorkflowBuilder(start_executor=_start, output_from="all", intermediate_output_from=[])
.add_edge(_start, _downstream)
.build()
)
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == ["from-start", "from-downstream"]
assert result.get_intermediate_outputs() == []
@pytest.mark.asyncio
async def test_intermediate_output_from_all_other_marks_non_outputs_as_intermediate() -> None:
"""All-other intermediate designation classifies every non-output executor yield as intermediate."""
workflow = (
WorkflowBuilder(
start_executor=_start,
output_from=[_downstream],
intermediate_output_from="all_other",
)
.add_edge(_start, _downstream)
.build()
)
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == ["from-downstream"]
assert result.get_intermediate_outputs() == ["from-start"]
@pytest.mark.asyncio
async def test_all_other_streaming_events_mark_non_outputs_as_intermediate() -> None:
"""All-other emits intermediate events while streaming, not just in collected results."""
workflow = (
WorkflowBuilder(
start_executor=_start,
output_from=[_downstream],
intermediate_output_from="all_other",
)
.add_edge(_start, _downstream)
.build()
)
outputs: list[str] = []
intermediates: list[str] = []
async for event in workflow.run([Message(role="user", contents=["hi"])], stream=True):
if event.type == "output":
outputs.append(event.data)
elif event.type == "intermediate":
intermediates.append(event.data)
assert outputs == ["from-downstream"]
assert intermediates == ["from-start"]
def test_all_other_expands_to_concrete_intermediate_executor_selection_at_build_time() -> None:
"""The runner receives concrete executor IDs after all-other expansion."""
workflow = (
WorkflowBuilder(
start_executor=_start,
output_from=[_downstream],
intermediate_output_from="all_other",
)
.add_edge(_start, _downstream)
.build()
)
assert {executor.id for executor in workflow.get_output_executors()} == {_downstream.id}
assert {executor.id for executor in workflow.get_intermediate_executors()} == {_start.id}
assert workflow.is_intermediate_executor(_start.id)
assert not workflow.is_intermediate_executor(_downstream.id)
@pytest.mark.asyncio
async def test_all_other_with_omitted_output_from_emits_only_intermediate_outputs() -> None:
"""All-other intermediate designation opts out of omitted-selection all-output behavior."""
workflow = (
WorkflowBuilder(
start_executor=_start,
intermediate_output_from="all_other",
)
.add_edge(_start, _downstream)
.build()
)
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == []
assert result.get_intermediate_outputs() == ["from-start", "from-downstream"]
@pytest.mark.asyncio
async def test_all_other_with_empty_output_from_emits_only_intermediate_outputs() -> None:
"""All-other intermediate designation treats an empty output list as selecting no workflow outputs."""
workflow = (
WorkflowBuilder(
start_executor=_start,
output_from=[],
intermediate_output_from="all_other",
)
.add_edge(_start, _downstream)
.build()
)
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == []
assert result.get_intermediate_outputs() == ["from-start", "from-downstream"]
@pytest.mark.asyncio
async def test_all_other_with_output_from_all_expands_to_empty_intermediate_selection() -> None:
"""All-other is empty when every output-capable executor is already selected as workflow output."""
workflow = (
WorkflowBuilder(
start_executor=_start,
output_from="all",
intermediate_output_from="all_other",
)
.add_edge(_start, _downstream)
.build()
)
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == ["from-start", "from-downstream"]
assert result.get_intermediate_outputs() == []
@pytest.mark.asyncio
async def test_intermediate_output_from_all_routes_every_yield_to_intermediate() -> None:
"""``intermediate_output_from="all"`` designates every output-capable executor as intermediate."""
workflow = (
WorkflowBuilder(start_executor=_start, intermediate_output_from="all").add_edge(_start, _downstream).build()
)
result = await workflow.run([Message(role="user", contents=["hi"])])
assert result.get_outputs() == []
assert result.get_intermediate_outputs() == ["from-start", "from-downstream"]
def test_output_from_all_other_is_rejected() -> None:
"""The all-other literal is only valid for intermediate output selection."""
with pytest.raises(ValueError, match="output_from.*all_other"):
WorkflowBuilder(start_executor=_emit_one, output_from="all_other") # type: ignore[arg-type]
@pytest.mark.parametrize(
("output_from", "intermediate_output_from"),
[([_emit_one], None), (None, [_emit_one]), ([], [_emit_one])],
ids=["output_list", "intermediate_list", "empty_output_with_intermediate"],
)
def test_explicit_designation_with_executor_does_not_warn(output_from, intermediate_output_from) -> None:
"""State B: any explicit designation with at least one executor opts into explicit mode without warning."""
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
WorkflowBuilder(
start_executor=_emit_one,
output_from=output_from,
intermediate_output_from=intermediate_output_from,
).build()
@pytest.mark.parametrize(
("output_from", "intermediate_output_from"),
[([], None), (None, []), ([], [])],
ids=["empty_output", "empty_intermediate", "both_empty"],
)
def test_empty_explicit_designation_fails(output_from, intermediate_output_from) -> None:
"""State C: explicit mode needs at least one output or intermediate executor."""
with pytest.raises(WorkflowValidationError, match="at least one output or intermediate executor"):
WorkflowBuilder(
start_executor=_emit_one,
output_from=output_from,
intermediate_output_from=intermediate_output_from,
).build()
def test_passing_both_output_executors_and_output_from_raises_type_error() -> None:
"""State D: supplying a deprecated alias and the canonical kwarg is unambiguous user error."""
with pytest.raises(TypeError, match="Cannot pass multiple workflow output selection parameters"):
WorkflowBuilder(
start_executor=_emit_one,
output_executors=[_emit_one],
output_from=[_emit_one],
)
def test_intermediate_executors_builder_parameter_is_not_public() -> None:
"""The branch-only intermediate_executors builder parameter is not supported."""
builder_type: Any = WorkflowBuilder
with pytest.raises(TypeError, match="unexpected keyword argument 'intermediate_executors'"):
builder_type(
start_executor=_emit_one,
intermediate_executors=[_emit_one],
)
def test_final_output_from_builder_parameter_is_not_public() -> None:
"""The branch-only final_output_from builder parameter is not supported."""
builder_type: Any = WorkflowBuilder
with pytest.raises(TypeError, match="unexpected keyword argument 'final_output_from'"):
builder_type(
start_executor=_emit_one,
final_output_from=[_emit_one],
)
@@ -158,9 +158,7 @@ async def test_runner_run_iteration_preserves_message_order_per_edge_runner() ->
def __init__(self) -> None:
self.received: list[int] = []
async def send_message(
self, message: WorkflowMessage, state: State, ctx: RunnerContext, *args: object, **kwargs: object
) -> bool:
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
message_data = message.data
assert isinstance(message_data, MockMessage)
self.received.append(message_data.data)
@@ -190,9 +188,7 @@ async def test_runner_run_iteration_delivers_different_edge_runners_concurrently
self.release = asyncio.Event()
self.call_count = 0
async def send_message(
self, message: WorkflowMessage, state: State, ctx: RunnerContext, *args: object, **kwargs: object
) -> bool:
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
self.call_count += 1
self.started.set()
await self.release.wait()
@@ -203,9 +199,7 @@ async def test_runner_run_iteration_delivers_different_edge_runners_concurrently
self.probe_completed = asyncio.Event()
self.call_count = 0
async def send_message(
self, message: WorkflowMessage, state: State, ctx: RunnerContext, *args: object, **kwargs: object
) -> bool:
async def send_message(self, message: WorkflowMessage, state: State, ctx: RunnerContext) -> bool:
self.call_count += 1
self.probe_completed.set()
return True
@@ -772,7 +766,7 @@ async def test_runner_with_pre_loop_events():
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
# Add an event before running
await ctx.add_event(WorkflowEvent("output", executor_id="test_executor", data="pre-loop-output"))
await ctx.add_event(WorkflowEvent.output(executor_id="test_executor", data="pre-loop-output"))
events: list[WorkflowEvent] = []
async for event in runner.run_until_convergence():
@@ -897,7 +891,7 @@ class ExecutorThatFailsWithEvents(Executor):
# First emit an output event to the workflow context
await ctx.yield_output(f"output-before-failure-{message.data}")
# Add some events directly to the runner context
await self._runner_ctx.add_event(WorkflowEvent("output", executor_id=self.id, data="pending-event"))
await self._runner_ctx.add_event(WorkflowEvent.output(executor_id=self.id, data="pending-event"))
# Fail on the specified iteration
if self._iteration_count >= self._fail_on_iteration:
raise RuntimeError("Executor failed with pending events")
@@ -799,48 +799,3 @@ def test_comprehensive_edge_groups_workflow_serialization() -> None:
assert len(fan_in_groups[0]["edges"]) == 2, "FanInEdgeGroup should have 2 edges (from parallel_1 and parallel_2)"
for single_group in single_groups:
assert len(single_group["edges"]) == 1, "Each SingleEdgeGroup should have exactly 1 edge"
def test_to_dict_preserves_compatibility_wire_keys_for_output_designation() -> None:
"""to_dict() must emit the compatibility wire keys regardless of the Python kwarg names.
The Python API renamed ``output_executors`` -> ``output_from`` and
uses ``intermediate_output_from`` for intermediate selection, but the serialized
dict must keep the old keys so existing checkpoints stay readable. This is a
regression guard against accidental renames of the wire format.
"""
class _Yielder(Executor):
@handler
async def handle(self, message: str, ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output(message)
await ctx.send_message(message)
class _Terminal(Executor):
@handler
async def handle(self, message: str, ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output(f"final: {message}")
start = _Yielder(id="start")
progress = _Yielder(id="progress")
final = _Terminal(id="final")
workflow = (
WorkflowBuilder(
start_executor=start,
output_from=[final],
intermediate_output_from=[progress],
)
.add_edge(start, progress)
.add_edge(progress, final)
.build()
)
d = workflow.to_dict()
assert "output_executors" in d, "wire key 'output_executors' must be preserved"
assert "intermediate_executors" in d, "wire key 'intermediate_executors' must be preserved"
assert "output_from" not in d, "new Python kwarg name must NOT leak into the wire format"
assert "intermediate_output_from" not in d, "new Python kwarg name must NOT leak into the wire format"
assert d["output_executors"] == ["final"]
assert d["intermediate_executors"] == ["progress"]
@@ -1,118 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for the runner's explicit output selection event labeling."""
from __future__ import annotations
import warnings
from typing import Any
import pytest
from typing_extensions import Never
from agent_framework import (
Message,
WorkflowBuilder,
WorkflowContext,
executor,
)
@executor
async def _start(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output("from-start")
await ctx.send_message("downstream")
@executor
async def _downstream(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("from-downstream")
def _input_msg() -> list[Message]:
return [Message(role="user", contents=["hi"])]
@pytest.mark.asyncio
async def test_strict_mode_designated_executor_emits_output_events() -> None:
"""Output-designated executor yields produce type='output' events."""
workflow = WorkflowBuilder(start_executor=_start, output_from=[_start]).add_edge(_start, _downstream).build()
output_events: list[Any] = []
intermediate_events: list[Any] = []
async for event in workflow.run(_input_msg(), stream=True):
if event.type == "output":
output_events.append(event)
elif event.type == "intermediate":
intermediate_events.append(event)
assert any(ev.data == "from-start" for ev in output_events), "designated executor's yield is type='output'"
assert intermediate_events == []
assert all(ev.data != "from-downstream" for ev in output_events), "unlisted executor yield is hidden"
@pytest.mark.asyncio
async def test_intermediate_designated_executor_emits_intermediate_events() -> None:
"""Intermediate-designated executor yields produce type='intermediate' events."""
workflow = (
WorkflowBuilder(start_executor=_start, intermediate_output_from=[_downstream])
.add_edge(_start, _downstream)
.build()
)
output_events: list[Any] = []
intermediate_events: list[Any] = []
async for event in workflow.run(_input_msg(), stream=True):
if event.type == "output":
output_events.append(event)
elif event.type == "intermediate":
intermediate_events.append(event)
assert len(output_events) == 0
assert {ev.data for ev in intermediate_events} == {"from-downstream"}
@pytest.mark.asyncio
async def test_omitted_selection_keeps_all_yields_as_output() -> None:
"""Omitted output selection preserves today's behavior: all yields are type='output'."""
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
workflow = WorkflowBuilder(start_executor=_start).add_edge(_start, _downstream).build()
output_events: list[Any] = []
intermediate_events: list[Any] = []
async for event in workflow.run(_input_msg(), stream=True):
if event.type == "output":
output_events.append(event)
elif event.type == "intermediate":
intermediate_events.append(event)
assert {ev.data for ev in output_events} == {"from-start", "from-downstream"}
assert len(intermediate_events) == 0
@pytest.mark.asyncio
async def test_strict_mode_get_outputs_returns_only_designated() -> None:
"""WorkflowRunResult.get_outputs() returns only output-designated payloads."""
workflow = (
WorkflowBuilder(
start_executor=_start,
output_from=[_downstream],
intermediate_output_from=[_start],
)
.add_edge(_start, _downstream)
.build()
)
result = await workflow.run(_input_msg())
assert result.get_outputs() == ["from-downstream"]
assert result.get_intermediate_outputs() == ["from-start"]
@pytest.mark.asyncio
async def test_hidden_yields_remain_in_executor_completion_events() -> None:
"""Hidden yield_output payloads stay available through executor_completed observability."""
workflow = WorkflowBuilder(start_executor=_start, output_from=[_downstream]).add_edge(_start, _downstream).build()
result = await workflow.run(_input_msg())
assert result.get_outputs() == ["from-downstream"]
assert result.get_intermediate_outputs() == []
assert not any(event.type in {"output", "intermediate"} and event.data == "from-start" for event in result)
completed = [event for event in result if event.type == "executor_completed" and event.executor_id == _start.id]
assert completed
assert completed[0].data == ["downstream", "from-start"]
@@ -617,75 +617,3 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
# Key assertion: Only the second request should be received, not a duplicate of the first
assert len(request_events) == 1
assert request_events[0].data.prompt == "Second request"
async def test_sub_workflow_intermediate_outputs_propagate_to_parent() -> None:
"""A child workflow's intermediate emissions must bubble up through the parent.
Regression guard for the bug where WorkflowExecutor._process_workflow_result only
forwarded result.get_outputs() and silently dropped result.get_intermediate_outputs().
The forwarded event must carry the WorkflowExecutor's own id as the source so outer
callers don't have to know the child's internal executor layout, and it must keep
type='intermediate' regardless of how the parent designates the WorkflowExecutor.
"""
class _ProgressEmitter(Executor):
def __init__(self) -> None:
super().__init__(id="progress_emitter")
@handler
async def run(self, message: str, ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output(f"progress: {message}")
await ctx.send_message(message)
class _Finalizer(Executor):
def __init__(self) -> None:
super().__init__(id="finalizer")
@handler
async def run(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output(f"final: {message}")
progress = _ProgressEmitter()
finalizer = _Finalizer()
child = (
WorkflowBuilder(
start_executor=progress,
output_from=[finalizer],
intermediate_output_from=[progress],
)
.add_edge(progress, finalizer)
.build()
)
sub = WorkflowExecutor(child, id="sub")
class _ParentSink(Executor):
def __init__(self) -> None:
super().__init__(id="parent_sink")
self.received: list[str] = []
@handler
async def run(self, message: str, ctx: WorkflowContext[Never, str]) -> None:
self.received.append(message)
await ctx.yield_output(message)
sink = _ParentSink()
parent = WorkflowBuilder(start_executor=sub, output_from=[sink]).add_edge(sub, sink).build()
intermediate_events: list[WorkflowEvent[Any]] = []
output_events: list[WorkflowEvent[Any]] = []
async for event in parent.run("hello", stream=True):
if event.type == "intermediate":
intermediate_events.append(event)
elif event.type == "output":
output_events.append(event)
# The child's intermediate emission bubbled up labeled with the WorkflowExecutor id,
# not the child's internal executor id.
assert len(intermediate_events) == 1, [(e.executor_id, e.data) for e in intermediate_events]
assert intermediate_events[0].executor_id == "sub"
assert intermediate_events[0].data == "progress: hello"
# The parent's own terminal output is unaffected.
assert any(e.executor_id == "parent_sink" and e.data == "final: hello" for e in output_events)
@@ -550,10 +550,12 @@ def test_output_validation_with_valid_output_executors():
executor2 = OutputExecutor(id="executor2")
# Build workflow with valid output executors
workflow = WorkflowBuilder(start_executor=executor1, output_from=[executor2]).add_edge(executor1, executor2).build()
workflow = (
WorkflowBuilder(start_executor=executor1, output_executors=[executor2]).add_edge(executor1, executor2).build()
)
assert workflow is not None
assert {ex.id for ex in workflow.get_output_executors()} == {"executor2"}
assert workflow._output_executors == ["executor2"] # pyright: ignore[reportPrivateUsage]
def test_output_validation_with_multiple_valid_output_executors():
@@ -563,14 +565,14 @@ def test_output_validation_with_multiple_valid_output_executors():
executor3 = OutputExecutor(id="executor3")
workflow = (
WorkflowBuilder(start_executor=executor1, output_from=[executor1, executor3])
WorkflowBuilder(start_executor=executor1, output_executors=[executor1, executor3])
.add_edge(executor1, executor2)
.add_edge(executor2, executor3)
.build()
)
assert workflow is not None
assert {ex.id for ex in workflow.get_output_executors()} == {"executor1", "executor3"}
assert set(workflow._output_executors) == {"executor1", "executor3"} # pyright: ignore[reportPrivateUsage]
def test_output_validation_fails_for_nonexistent_executor():
@@ -596,7 +598,7 @@ def test_output_validation_fails_for_executor_without_output_types():
with pytest.raises(WorkflowValidationError) as exc_info:
(
WorkflowBuilder(start_executor=executor1, output_from=[no_output_executor])
WorkflowBuilder(start_executor=executor1, output_executors=[no_output_executor])
.add_edge(executor1, no_output_executor)
.build()
)
@@ -606,77 +608,16 @@ def test_output_validation_fails_for_executor_without_output_types():
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_empty_explicit_designation_fails():
"""Test that explicit mode rejects an empty output/intermediate designation."""
def test_output_validation_empty_list_passes():
"""Test that output validation passes with an empty output executors list."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
with pytest.raises(WorkflowValidationError) as exc_info:
WorkflowBuilder(start_executor=executor1, output_from=[]).add_edge(executor1, executor2).build()
assert "at least one output or intermediate executor" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_with_valid_intermediate_executors():
"""Test that output validation passes when intermediate executors exist and have output types."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
workflow = (
WorkflowBuilder(start_executor=executor1, intermediate_output_from=[executor1])
.add_edge(executor1, executor2)
.build()
)
workflow = WorkflowBuilder(start_executor=executor1, output_executors=[]).add_edge(executor1, executor2).build()
assert workflow is not None
assert {ex.id for ex in workflow.get_intermediate_executors()} == {"executor1"}
assert workflow.is_intermediate_executor("executor1")
assert not workflow.is_terminal_executor("executor2")
def test_output_validation_fails_for_designation_overlap():
"""Test that an executor cannot be both terminal and intermediate."""
executor1 = OutputExecutor(id="executor1")
with pytest.raises(WorkflowValidationError) as exc_info:
WorkflowBuilder(
start_executor=executor1,
output_from=[executor1],
intermediate_output_from=[executor1],
).build()
assert "both output and intermediate" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_fails_for_duplicate_designation():
"""Test that duplicate output or intermediate designation entries are rejected."""
executor1 = OutputExecutor(id="executor1")
with pytest.raises(WorkflowValidationError) as exc_info:
WorkflowBuilder(start_executor=executor1, output_from=[executor1, executor1]).build()
assert "Duplicate output executor designation" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
def test_output_validation_fails_for_unknown_intermediate_executor():
"""Test that intermediate designation rejects executors outside the workflow graph."""
executor1 = OutputExecutor(id="executor1")
executor2 = OutputExecutor(id="executor2")
missing = OutputExecutor(id="missing")
with pytest.raises(WorkflowValidationError) as exc_info:
(
WorkflowBuilder(start_executor=executor1, intermediate_output_from=[missing])
.add_edge(executor1, executor2)
.build()
)
assert "not present in the workflow graph" in str(exc_info.value)
assert "missing" in str(exc_info.value)
assert exc_info.value.validation_type == ValidationTypeEnum.OUTPUT_VALIDATION
# All executors are outputs
assert workflow._output_executors == ["executor1", "executor2"] # type: ignore
def test_output_validation_with_direct_validate_workflow_graph():
@@ -1056,7 +1056,7 @@ class PassthroughExecutor(Executor):
async def test_output_executors_empty_yields_all_outputs() -> None:
"""Test that omitted output selection yields all outputs for compatibility."""
"""Test that when _output_executors is empty (default), all outputs are yielded."""
# Create executors that each produce different outputs
executor_a = PassthroughExecutor(id="executor_a", output_value=10)
executor_b = OutputProducerExecutor(id="executor_b", output_value=20)
@@ -1085,7 +1085,9 @@ async def test_output_executors_filters_outputs_non_streaming() -> None:
# Build workflow with a -> b
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
.add_edge(executor_a, executor_b)
.build()
)
result = await workflow.run(NumberMessage(data=0))
@@ -1108,7 +1110,9 @@ async def test_output_executors_filters_outputs_streaming() -> None:
# Build workflow with a -> b
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_a]).add_edge(executor_a, executor_b).build()
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_a])
.add_edge(executor_a, executor_b)
.build()
)
# Collect outputs from streaming
@@ -1132,7 +1136,7 @@ async def test_output_executors_with_multiple_specified_executors() -> None:
# Build workflow with a -> b -> c
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_a, executor_c])
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_a, executor_c])
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_c)
.build()
@@ -1150,15 +1154,12 @@ async def test_output_executors_with_multiple_specified_executors() -> None:
async def test_output_executors_with_nonexistent_executor_id() -> None:
"""Test that specifying a non-existent executor ID doesn't break the workflow."""
from agent_framework._workflows._workflow import OutputDesignation
executor_a = OutputProducerExecutor(id="executor_a", output_value=42)
workflow = WorkflowBuilder(start_executor=executor_a).build()
# Designate a nonexistent executor so the workflow-level filter drops every yield.
workflow._output_designation = OutputDesignation(outputs=frozenset({"nonexistent_executor"})) # type: ignore[attr-defined]
workflow._runner.context.set_yield_output_classifier(workflow._output_designation.classify) # type: ignore[attr-defined,reportPrivateUsage]
# Set output_executors to an ID that doesn't exist
workflow._output_executors = ["nonexistent_executor"] # type: ignore
result = await workflow.run(NumberMessage(data=0))
outputs = result.get_outputs()
@@ -1198,7 +1199,7 @@ async def test_output_executors_filtering_with_fan_in() -> None:
# Build fan-in workflow: start -> [a, b] -> aggregator
workflow = (
WorkflowBuilder(start_executor=executor_start, output_from=[aggregator])
WorkflowBuilder(start_executor=executor_start, output_executors=[aggregator])
.add_fan_out_edges(executor_start, [executor_a, executor_b])
.add_fan_in_edges([executor_a, executor_b], aggregator)
.build()
@@ -1217,7 +1218,7 @@ async def test_output_executors_filtering_with_run_responses() -> None:
"""Test output filtering works correctly with run(responses=...) method."""
executor = MockExecutorRequestApproval(id="approval_executor")
workflow = WorkflowBuilder(start_executor=executor, output_from=[executor]).build()
workflow = WorkflowBuilder(start_executor=executor, output_executors=[executor]).build()
# Run workflow which will request approval
result = await workflow.run(NumberMessage(data=42))
@@ -1251,11 +1252,8 @@ async def test_output_executors_filtering_with_run_responses_streaming() -> None
request_events = [e for e in events_list if e.type == "request_info"]
assert len(request_events) == 1
# Designate a different executor so the workflow-level filter drops the approval yield.
from agent_framework._workflows._workflow import OutputDesignation
workflow._output_designation = OutputDesignation(outputs=frozenset({"other_executor"})) # type: ignore[attr-defined]
workflow._runner.context.set_yield_output_classifier(workflow._output_designation.classify) # type: ignore[attr-defined,reportPrivateUsage]
# Set output_executors to exclude the approval executor
workflow._output_executors = ["other_executor"] # type: ignore
# Send approval response via streaming
responses = {request_events[0].request_id: ApprovalMessage(approved=True)}
@@ -923,7 +923,7 @@ class TestWorkflowAgent:
# Build workflow: start -> agent1 (no output) -> agent2 (output visible)
workflow = (
WorkflowBuilder(start_executor=start_exec, output_from=[start_exec, agent2])
WorkflowBuilder(start_executor=start_exec, output_executors=[start_exec, agent2])
.add_edge(start_exec, agent1)
.add_edge(agent1, agent2)
.build()
@@ -1,353 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for WorkflowAgent forwarding of intermediate workflow events.
Covers:
- type='intermediate' surfaces as AgentResponseUpdate without content-type rewriting
- type='data' (compatibility alias via WorkflowEvent.emit) is forwarded
- Message.additional_properties survives the intermediate translation path
- Terminal yields keep using regular text content (backward compat)
"""
from __future__ import annotations
import warnings
import pytest
from typing_extensions import Never
from agent_framework import (
AgentResponse,
AgentResponseUpdate,
Content,
Message,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
executor,
)
from agent_framework.exceptions import AgentInvalidRequestException
@pytest.mark.asyncio
async def test_workflow_agent_forwards_intermediate_events_without_content_rewrite() -> None:
"""An intermediate yield from an intermediate-designated executor surfaces through as_agent
as an AgentResponseUpdate carrying its original content type."""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output("intermediate progress")
await ctx.send_message("downstream")
@executor
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("FINAL")
workflow = (
WorkflowBuilder(
start_executor=emit,
output_from=[terminal],
intermediate_output_from=[emit],
)
.add_edge(emit, terminal)
.build()
)
agent = workflow.as_agent("test")
updates: list[AgentResponseUpdate] = []
async for update in agent.run("hi", stream=True):
updates.append(update)
text = " ".join(c.text for u in updates for c in u.contents if c.type == "text")
reasoning_text = " ".join(c.text for u in updates for c in u.contents if c.type == "text_reasoning")
assert "intermediate progress" in text
assert "FINAL" in text
assert reasoning_text == ""
@pytest.mark.asyncio
async def test_workflow_agent_text_accessor_includes_forwarded_intermediate_text() -> None:
"""Intermediate text is forwarded as text until issue 5885 defines the final mapping."""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output("invisible-progress")
await ctx.send_message("forward")
@executor
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("the-answer")
workflow = (
WorkflowBuilder(
start_executor=emit,
output_from=[terminal],
intermediate_output_from=[emit],
)
.add_edge(emit, terminal)
.build()
)
agent = workflow.as_agent("test")
response = await agent.run("hi")
assert isinstance(response, AgentResponse)
assert "invisible-progress" in response.text
assert "the-answer" in response.text
@pytest.mark.asyncio
async def test_workflow_agent_hidden_yields_do_not_surface_non_streaming() -> None:
"""In explicit designation mode, unlisted executor yields stay out of agent responses."""
@executor
async def hidden(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output("hidden-progress")
await ctx.send_message("forward")
@executor
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("visible-answer")
workflow = WorkflowBuilder(start_executor=hidden, output_from=[terminal]).add_edge(hidden, terminal).build()
agent = workflow.as_agent("test")
response = await agent.run("hi")
all_text = " ".join(c.text for m in response.messages for c in m.contents if hasattr(c, "text"))
assert response.text == "visible-answer"
assert "hidden-progress" not in all_text
@pytest.mark.asyncio
async def test_workflow_agent_hidden_yields_do_not_surface_streaming() -> None:
"""In explicit designation mode, unlisted executor yields stay out of agent updates."""
@executor
async def hidden(messages: list[Message], ctx: WorkflowContext[str, str]) -> None:
await ctx.yield_output("hidden-progress")
await ctx.send_message("forward")
@executor
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("visible-answer")
workflow = WorkflowBuilder(start_executor=hidden, output_from=[terminal]).add_edge(hidden, terminal).build()
agent = workflow.as_agent("test")
updates: list[AgentResponseUpdate] = []
async for update in agent.run("hi", stream=True):
updates.append(update)
all_text = " ".join(c.text for u in updates for c in u.contents if hasattr(c, "text"))
assert "visible-answer" in all_text
assert "hidden-progress" not in all_text
@pytest.mark.asyncio
async def test_workflow_agent_data_event_emit_factory_still_forwarded() -> None:
"""Even the deprecated WorkflowEvent.emit() / type='data' path is forwarded."""
@executor
async def emit_data_alias(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
await ctx.add_event(WorkflowEvent.emit("emit_data_alias", "data-alias-payload"))
await ctx.yield_output("DONE")
workflow = WorkflowBuilder(start_executor=emit_data_alias, output_from=[emit_data_alias]).build()
agent = workflow.as_agent("test")
updates: list[AgentResponseUpdate] = []
async for update in agent.run("hi", stream=True):
updates.append(update)
text = " ".join(c.text for u in updates for c in u.contents if c.type == "text")
assert "data-alias-payload" in text
@pytest.mark.asyncio
async def test_workflow_agent_intermediate_message_preserves_additional_properties() -> None:
"""Message.additional_properties survives intermediate forwarding.
Producer-attached metadata (tracking_id, conversation_id, etc.) must not disappear
for messages flowing through intermediate-designated executors.
"""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[str, AgentResponse]) -> None:
msg = Message(
role="assistant",
contents=[Content.from_text(text="hi")],
additional_properties={"tracking_id": "abc-123"},
)
await ctx.yield_output(AgentResponse(messages=[msg]))
await ctx.send_message("forward")
@executor
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("done")
workflow = (
WorkflowBuilder(
start_executor=emit,
output_from=[terminal],
intermediate_output_from=[emit],
)
.add_edge(emit, terminal)
.build()
)
agent = workflow.as_agent("test")
response = await agent.run("hi")
intermediate_msgs = [m for m in response.messages if any(c.type == "text" and c.text == "hi" for c in m.contents)]
assert intermediate_msgs, "expected at least one intermediate message in the response"
assert intermediate_msgs[0].additional_properties.get("tracking_id") == "abc-123"
@pytest.mark.asyncio
async def test_workflow_agent_terminal_text_stays_text_not_reasoning() -> None:
"""A designated executor's text yield surfaces as Content.text."""
@executor
async def only(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("the-answer")
workflow = WorkflowBuilder(start_executor=only, output_from=[only]).build()
agent = workflow.as_agent("test")
response = await agent.run("hi")
assert response.text == "the-answer"
# No text_reasoning content because everything from `only` is terminal.
assert all(c.type != "text_reasoning" for m in response.messages for c in m.contents)
@pytest.mark.asyncio
async def test_workflow_agent_non_streaming_rejects_terminal_update() -> None:
"""A terminal event carrying AgentResponseUpdate is streaming-only and invalid in run()."""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[Never, AgentResponseUpdate]) -> None:
await ctx.yield_output(AgentResponseUpdate(contents=[Content.from_text(text="partial")], role="assistant"))
workflow = WorkflowBuilder(start_executor=emit, output_from=[emit]).build()
agent = workflow.as_agent("test")
with pytest.raises(AgentInvalidRequestException, match="AgentResponseUpdate"):
await agent.run("hi")
@pytest.mark.asyncio
async def test_workflow_agent_non_streaming_rejects_intermediate_update() -> None:
"""An intermediate event carrying AgentResponseUpdate is streaming-only and invalid in run()."""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[str, AgentResponseUpdate]) -> None:
await ctx.yield_output(AgentResponseUpdate(contents=[Content.from_text(text="partial")], role="assistant"))
await ctx.send_message("forward")
@executor
async def terminal(message: str, ctx: WorkflowContext[Never, str]) -> None:
await ctx.yield_output("FINAL")
workflow = (
WorkflowBuilder(
start_executor=emit,
output_from=[terminal],
intermediate_output_from=[emit],
)
.add_edge(emit, terminal)
.build()
)
agent = workflow.as_agent("test")
with pytest.raises(AgentInvalidRequestException, match="AgentResponseUpdate"):
await agent.run("hi")
@pytest.mark.asyncio
async def test_workflow_agent_streaming_update_payloads_preserve_classification() -> None:
"""Streaming AgentResponseUpdate payloads preserve original content types."""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[str, AgentResponseUpdate]) -> None:
await ctx.yield_output(
AgentResponseUpdate(contents=[Content.from_text(text="intermediate-chunk")], role="assistant")
)
await ctx.send_message("forward")
@executor
async def terminal(message: str, ctx: WorkflowContext[Never, AgentResponseUpdate]) -> None:
await ctx.yield_output(
AgentResponseUpdate(contents=[Content.from_text(text="terminal-chunk")], role="assistant")
)
workflow = (
WorkflowBuilder(
start_executor=emit,
output_from=[terminal],
intermediate_output_from=[emit],
)
.add_edge(emit, terminal)
.build()
)
agent = workflow.as_agent("test")
updates: list[AgentResponseUpdate] = []
async for update in agent.run("hi", stream=True):
updates.append(update)
text = " ".join(c.text for u in updates for c in u.contents if c.type == "text")
reasoning_text = " ".join(c.text for u in updates for c in u.contents if c.type == "text_reasoning")
assert "intermediate-chunk" in text
assert "terminal-chunk" in text
assert reasoning_text == ""
@pytest.mark.asyncio
async def test_workflow_agent_drops_orchestration_internal_events() -> None:
"""Orchestration-internal event types (group_chat / handoff_sent / magentic_orchestrator)
must not surface through workflow.as_agent(). Their dataclass payloads would otherwise
be stringified by the generic fallback path and leak into response history."""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
# Construct typed orchestration-internal events directly to assert they get
# dropped at the agent boundary regardless of payload.
await ctx.add_event(WorkflowEvent("group_chat", data={"orchestrator": "details"})) # type: ignore[arg-type]
await ctx.add_event(WorkflowEvent("handoff_sent", data={"target": "agent_b"})) # type: ignore[arg-type]
await ctx.add_event(WorkflowEvent("magentic_orchestrator", data={"plan": "..."})) # type: ignore[arg-type]
await ctx.yield_output("FINAL")
workflow = WorkflowBuilder(start_executor=emit, output_from=[emit]).build()
agent = workflow.as_agent("test")
response = await agent.run("hi")
all_text = " ".join(c.text for m in response.messages for c in m.contents if hasattr(c, "text"))
assert "orchestrator" not in all_text
assert "agent_b" not in all_text
assert "plan" not in all_text
assert response.text == "FINAL"
@pytest.mark.asyncio
async def test_workflow_agent_drops_orchestration_internal_events_streaming() -> None:
"""Streaming counterpart — orchestration-internal events stay inside the workflow."""
@executor
async def emit(messages: list[Message], ctx: WorkflowContext[Never, str]) -> None:
await ctx.add_event(WorkflowEvent("group_chat", data={"orchestrator": "details"})) # type: ignore[arg-type]
await ctx.yield_output("FINAL")
workflow = WorkflowBuilder(start_executor=emit, output_from=[emit]).build()
agent = workflow.as_agent("test")
updates: list[AgentResponseUpdate] = []
async for update in agent.run("hi", stream=True):
updates.append(update)
all_text = " ".join(c.text for u in updates for c in u.contents if hasattr(c, "text"))
assert "orchestrator" not in all_text
assert "FINAL" in all_text
@@ -254,10 +254,10 @@ def test_switch_case_with_agents():
def test_with_output_from_returns_builder():
"""Test that with_output_from returns the builder for method chaining."""
executor_a = MockExecutor(id="executor_a")
builder = WorkflowBuilder(output_from=[executor_a], start_executor=executor_a)
builder = WorkflowBuilder(output_executors=[executor_a], start_executor=executor_a)
# Verify builder was created with output_from
assert builder._output_from == [executor_a] # pyright: ignore[reportPrivateUsage]
# Verify builder was created with output_executors
assert builder._output_executors == [executor_a] # pyright: ignore[reportPrivateUsage]
def test_with_output_from_with_executor_instances():
@@ -266,11 +266,13 @@ def test_with_output_from_with_executor_instances():
executor_b = MockExecutor(id="executor_b")
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
.add_edge(executor_a, executor_b)
.build()
)
# Verify that the workflow was built with the correct output executors
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_b"}
assert workflow._output_executors == ["executor_b"] # type: ignore
def test_with_output_from_with_agent_instances():
@@ -278,10 +280,10 @@ def test_with_output_from_with_agent_instances():
agent_a = DummyAgent(id="agent_a", name="writer")
agent_b = DummyAgent(id="agent_b", name="reviewer")
workflow = WorkflowBuilder(start_executor=agent_a, output_from=[agent_b]).add_edge(agent_a, agent_b).build()
workflow = WorkflowBuilder(start_executor=agent_a, output_executors=[agent_b]).add_edge(agent_a, agent_b).build()
# Verify that the workflow was built with the agent's name as output executor
assert {ex.id for ex in workflow.get_output_executors()} == {"reviewer"}
assert workflow._output_executors == ["reviewer"] # type: ignore
def test_with_output_from_with_executor_instances_by_id():
@@ -290,10 +292,12 @@ def test_with_output_from_with_executor_instances_by_id():
executor_b = MockExecutor(id="ExecutorB")
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
.add_edge(executor_a, executor_b)
.build()
)
assert {ex.id for ex in workflow.get_output_executors()} == {"ExecutorB"}
assert workflow._output_executors == ["ExecutorB"] # type: ignore
def test_with_output_from_with_multiple_executors():
@@ -303,27 +307,29 @@ def test_with_output_from_with_multiple_executors():
executor_c = MockExecutor(id="executor_c")
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_a, executor_c])
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_a, executor_c])
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_c)
.build()
)
# Verify that the workflow was built with both output executors
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_a", "executor_c"}
assert set(workflow._output_executors) == {"executor_a", "executor_c"} # type: ignore
def test_with_output_from_can_be_set_to_different_value():
"""Test that output_from can be set at construction time."""
"""Test that output_executors can be set at construction time."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_b]).add_edge(executor_a, executor_b).build()
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_b])
.add_edge(executor_a, executor_b)
.build()
)
# Verify that the setting is applied
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_b"}
assert workflow._output_executors == ["executor_b"] # type: ignore
def test_with_output_from_with_agent_instances_resolves_name():
@@ -332,37 +338,37 @@ def test_with_output_from_with_agent_instances_resolves_name():
agent_reviewer = DummyAgent(id="agent2", name="reviewer")
workflow = (
WorkflowBuilder(start_executor=agent_writer, output_from=[agent_reviewer])
WorkflowBuilder(start_executor=agent_writer, output_executors=[agent_reviewer])
.add_edge(agent_writer, agent_reviewer)
.build()
)
assert {ex.id for ex in workflow.get_output_executors()} == {"reviewer"}
assert workflow._output_executors == ["reviewer"] # type: ignore
def test_with_output_from_in_constructor():
"""Test that output_from works correctly when set in the constructor."""
"""Test that output_executors works correctly when set in the constructor."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
executor_c = MockExecutor(id="executor_c")
# Build workflow with output_from in the constructor
# Build workflow with output_executors in the constructor
workflow = (
WorkflowBuilder(start_executor=executor_a, output_from=[executor_c])
WorkflowBuilder(start_executor=executor_a, output_executors=[executor_c])
.add_edge(executor_a, executor_b)
.add_edge(executor_b, executor_c)
.build()
)
# Verify that the setting persists through the chain
assert {ex.id for ex in workflow.get_output_executors()} == {"executor_c"}
assert workflow._output_executors == ["executor_c"] # type: ignore
def test_with_output_from_with_invalid_executor_raises_validation_error():
"""Test that with_output_from with an invalid executor raises an error."""
executor_a = MockExecutor(id="executor_a")
builder = WorkflowBuilder(start_executor=executor_a, output_from=[MockExecutor(id="executor_b")])
builder = WorkflowBuilder(start_executor=executor_a, output_executors=[MockExecutor(id="executor_b")])
# Attempting to set output from an executor not in the workflow should raise an error
with pytest.raises(
@@ -5,7 +5,6 @@ from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import TYPE_CHECKING, Any
import pytest
from typing_extensions import Never
from agent_framework import (
@@ -73,31 +72,6 @@ async def test_executor_cannot_emit_framework_lifecycle_event(caplog: "LogCaptur
assert any("attempted to emit" in message and "'status'" in message for message in list(caplog.messages))
@pytest.mark.parametrize(
"event",
[
WorkflowEvent("output", executor_id="exec", data="output-payload"),
WorkflowEvent("intermediate", executor_id="exec", data="intermediate-payload"),
],
)
async def test_executor_cannot_emit_output_selection_events(
event: WorkflowEvent[Any],
caplog: "LogCaptureFixture",
) -> None:
async with make_context() as (ctx, runner_ctx):
caplog.clear()
with caplog.at_level("WARNING"):
await ctx.add_event(event)
events: list[WorkflowEvent] = await runner_ctx.drain_events()
assert len(events) == 1
assert events[0].type == "warning"
data = events[0].data
assert isinstance(data, str)
assert "reserved for ctx.yield_output()" in data
assert event.data not in [emitted.data for emitted in events]
async def test_executor_emits_normal_event() -> None:
async with make_context() as (ctx, runner_ctx):
# Create a normal event to test event emission
@@ -1,34 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for WorkflowEvent factory methods and WorkflowEvent.emit() deprecation."""
from __future__ import annotations
import warnings
import pytest
from agent_framework import AgentResponse, Message
from agent_framework._workflows._events import WorkflowEvent
def test_workflow_event_output_selection_factories_are_not_public() -> None:
"""Callers should use ctx.yield_output(), not direct output/intermediate factories."""
assert not hasattr(WorkflowEvent, "output")
assert not hasattr(WorkflowEvent, "intermediate")
def test_workflow_event_emit_emits_deprecation_warning() -> None:
"""Calling WorkflowEvent.emit() raises a DeprecationWarning recommending the new path."""
response = AgentResponse(messages=[Message(role="assistant", contents=["x"])])
with pytest.warns(DeprecationWarning, match="yield_output"):
WorkflowEvent.emit(executor_id="t", data=response)
def test_workflow_event_emit_still_returns_data_event() -> None:
"""During the deprecation window, emit() still produces a type='data' event."""
response = AgentResponse(messages=[Message(role="assistant", contents=["x"])])
with warnings.catch_warnings():
warnings.simplefilter("ignore", DeprecationWarning)
event = WorkflowEvent.emit(executor_id="t", data=response)
assert event.type == "data"
@@ -377,7 +377,7 @@ async def test_kwargs_preserved_on_response_continuation() -> None:
from agent_framework import WorkflowBuilder
agent = _ApprovalCapturingAgent()
workflow = WorkflowBuilder(start_executor=agent, output_from=[agent]).build()
workflow = WorkflowBuilder(start_executor=agent, output_executors=[agent]).build()
# Initial run with function_invocation_kwargs — workflow should pause for approval
fi_kwargs = {"token": "abc"}
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"httpx>=0.27,<1",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
@@ -72,17 +72,6 @@ def _stringify_name(value: Any) -> str:
return value if isinstance(value, str) else str(value)
def _workflow_output_metadata(event_type: Any, executor_id: Any) -> dict[str, Any] | None:
"""Return metadata that preserves workflow yield designation on visible output."""
if event_type not in ("output", "intermediate", "data"):
return None
return {
"workflow_event_type": event_type,
"workflow_output_kind": "terminal" if event_type == "output" else "intermediate",
"executor_id": executor_id,
}
def _serialize_content_recursive(value: Any) -> Any:
"""Recursively serialize Agent Framework Content objects to JSON-compatible values.
@@ -211,21 +200,15 @@ class MessageMapper:
try:
from agent_framework import AgentResponse, AgentResponseUpdate, WorkflowEvent
# Handle WorkflowEvent with type='output', 'intermediate', or 'data' wrapping
# AgentResponseUpdate. This must be checked BEFORE generic WorkflowEvent check.
# Note: AgentExecutor uses type='output' for streaming updates from designated
# executors and type='intermediate' from non-designated executors. type='data'
# is the deprecated legacy variant retained for backward compat.
if isinstance(raw_event, WorkflowEvent) and raw_event.type in ("output", "intermediate", "data"):
# Handle WorkflowEvent with type='output' or 'data' wrapping AgentResponseUpdate
# This must be checked BEFORE generic WorkflowEvent check
# Note: AgentExecutor uses type='output' for streaming updates
if isinstance(raw_event, WorkflowEvent) and raw_event.type in ("output", "data"):
event_data = getattr(cast(Any, raw_event), "data", None)
if isinstance(event_data, AgentResponseUpdate):
# Preserve executor_id in context for proper output routing
context["current_executor_id"] = getattr(cast(Any, raw_event), "executor_id", None)
context["current_workflow_event_type"] = raw_event.type
try:
return await self._convert_agent_update(event_data, context)
finally:
context.pop("current_workflow_event_type", None)
return await self._convert_agent_update(event_data, context)
# Handle complete agent response (AgentResponse) - for non-streaming agent execution
if isinstance(raw_event, AgentResponse):
@@ -650,13 +633,6 @@ class MessageMapper:
# Check if we're in an executor context with an existing item
executor_id = context.get("current_executor_id")
executor_item_key = f"exec_item_{executor_id}" if executor_id else None
workflow_metadata = _workflow_output_metadata(context.get("current_workflow_event_type"), executor_id)
if has_text_content and workflow_metadata is not None:
current_metadata = context.get("current_message_workflow_metadata")
if current_metadata != workflow_metadata:
context.pop("current_message_id", None)
context["current_message_workflow_metadata"] = workflow_metadata
# If we have an executor item, use it for deltas instead of creating a message
if has_text_content and executor_item_key and executor_item_key in context:
@@ -668,15 +644,6 @@ class MessageMapper:
message_id = f"msg_{uuid4().hex[:8]}"
context["current_message_id"] = message_id
context["output_index"] = context.get("output_index", -1) + 1
message_item = ResponseOutputMessage(
type="message",
id=message_id,
role="assistant",
content=[],
status="in_progress",
)
if workflow_metadata is not None:
cast(Any, message_item).metadata = workflow_metadata
# Add message output item
events.append(
@@ -684,7 +651,9 @@ class MessageMapper:
type="response.output_item.added",
output_index=context["output_index"],
sequence_number=self._next_sequence(context),
item=message_item,
item=ResponseOutputMessage(
type="message", id=message_id, role="assistant", content=[], status="in_progress"
),
)
)
@@ -706,18 +675,17 @@ class MessageMapper:
# Special handling for TextContent to use proper delta events
if content.type == "text" and "current_message_id" in context:
# Stream text content via proper delta events
delta_event = ResponseTextDeltaEvent(
type="response.output_text.delta",
output_index=context["output_index"],
content_index=context.get("content_index", 0),
item_id=context["current_message_id"],
delta=content.text,
logprobs=[], # We don't have logprobs from Agent Framework
sequence_number=self._next_sequence(context),
events.append(
ResponseTextDeltaEvent(
type="response.output_text.delta",
output_index=context["output_index"],
content_index=context.get("content_index", 0),
item_id=context["current_message_id"],
delta=content.text,
logprobs=[], # We don't have logprobs from Agent Framework
sequence_number=self._next_sequence(context),
)
)
if workflow_metadata is not None:
cast(Any, delta_event).metadata = workflow_metadata
events.append(delta_event)
elif content.type in self.content_mappers:
# Use existing mappers for other content types
mapped_events = await self.content_mappers[content.type](content, context)
@@ -931,14 +899,10 @@ class MessageMapper:
return events
# Handle yield events (output / intermediate / data) by extracting visible
# text from the payload. All three render as a visible message item so the
# gap that previously dropped intermediate yields into generic completed-
# trace events is closed.
if event_type in ("output", "intermediate", "data"):
# Handle output events separately to preserve output data
if event_type == "output":
output_data = getattr(event, "data", None)
executor_id = getattr(event, "executor_id", "unknown")
workflow_metadata = _workflow_output_metadata(event_type, executor_id)
if output_data is not None:
# Import required types
@@ -996,8 +960,6 @@ class MessageMapper:
content=[text_content],
status="completed",
)
if workflow_metadata is not None:
cast(Any, output_message).metadata = workflow_metadata
# Emit output_item.added for each yield_output
logger.debug(
@@ -96,14 +96,6 @@ function getStateBadgeClass(state: ExecutorState) {
}
}
function getMessageText(item: unknown): string {
const content = (item as { content?: Array<{ type: string; text?: string }> }).content;
return content
?.filter((content) => content.type === "output_text" && content.text)
.map((content) => content.text)
.join("\n") ?? "";
}
function ExecutorRunItem({
run,
isExpanded,
@@ -290,12 +282,7 @@ export function ExecutionTimeline({
});
} else if (item && item.type === "message" && "metadata" in item && item.id) {
// Handle message items from Magentic agents
const metadata = item.metadata as {
agent_id?: string;
executor_id?: string;
source?: string;
workflow_output_kind?: string;
} | undefined;
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
const executorId = metadata.agent_id;
const itemId = item.id;
@@ -311,21 +298,6 @@ export function ExecutionTimeline({
timestamp: uiTimestamp,
runNumber,
});
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
const executorId = metadata.executor_id;
const itemId = item.id;
const runNumber = (runCount.get(executorId) || 0) + 1;
runCount.set(executorId, runNumber);
runs.push({
executorId,
executorName: truncateText(executorId, 35),
itemId,
state: item.status === "completed" ? "completed" : "running",
output: itemOutputs[itemId] || getMessageText(item),
timestamp: uiTimestamp,
runNumber,
});
}
}
}
@@ -355,12 +327,7 @@ export function ExecutionTimeline({
}
} else if (item && item.type === "message" && "metadata" in item && item.id) {
// Handle message completion from Magentic agents
const metadata = item.metadata as {
agent_id?: string;
executor_id?: string;
source?: string;
workflow_output_kind?: string;
} | undefined;
const metadata = item.metadata as { agent_id?: string; source?: string } | undefined;
if (metadata?.agent_id && metadata?.source === "magentic") {
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
@@ -369,14 +336,6 @@ export function ExecutionTimeline({
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || "";
}
} else if (metadata?.executor_id && metadata.workflow_output_kind === "intermediate") {
const itemId = item.id;
const existingRun = runs.find((r) => r.itemId === itemId);
if (existingRun) {
existingRun.state = item.status === "completed" ? "completed" : "failed";
existingRun.output = itemOutputs[itemId] || getMessageText(item);
}
}
}
}
@@ -663,7 +663,6 @@ export function WorkflowView({
item &&
item.type === "message" &&
(!("metadata" in item) || !(item.metadata as { source?: string } | undefined)?.source) &&
(item.metadata as { workflow_output_kind?: string } | undefined)?.workflow_output_kind !== "intermediate" &&
"content" in item &&
Array.isArray(item.content)
) {
@@ -1122,30 +1121,27 @@ export function WorkflowView({
// Handle workflow output messages
if (item && item.type === "message" && "content" in item && Array.isArray(item.content)) {
const metadata = item.metadata as { workflow_output_kind?: string } | undefined;
if (metadata?.workflow_output_kind !== "intermediate") {
// Extract text from message content
for (const content of item.content as Array<{ type: string; text?: string }>) {
if (content.type === "output_text" && content.text) {
const text = content.text; // Capture for closure
// Append to workflow result (support multiple yield_output calls)
setWorkflowResult((prev) => {
if (prev && prev.length > 0) {
// If there's existing output, add separator
return prev + "\n\n" + text;
}
return text;
});
// Try to parse as JSON for structured metadata
try {
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null) {
workflowMetadata.current = parsed;
}
} catch {
// Not JSON, keep as text
// Extract text from message content
for (const content of item.content as Array<{ type: string; text?: string }>) {
if (content.type === "output_text" && content.text) {
const text = content.text; // Capture for closure
// Append to workflow result (support multiple yield_output calls)
setWorkflowResult((prev) => {
if (prev && prev.length > 0) {
// If there's existing output, add separator
return prev + "\n\n" + text;
}
return text;
});
// Try to parse as JSON for structured metadata
try {
const parsed = JSON.parse(text);
if (typeof parsed === "object" && parsed !== null) {
workflowMetadata.current = parsed;
}
} catch {
// Not JSON, keep as text
}
}
}
@@ -376,7 +376,6 @@ export interface ResponseTextDeltaEvent extends ResponseStreamEvent {
content_index: number;
sequence_number: number;
logprobs: Record<string, unknown>[];
metadata?: Record<string, unknown>;
}
// OpenAI Response for non-streaming
@@ -398,7 +397,6 @@ export interface ResponseOutputMessage {
content: ResponseOutputText[];
id: string;
status: "completed" | "failed" | "in_progress";
metadata?: Record<string, unknown>;
}
export interface ResponseOutputText {
+4 -4
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,18 +23,18 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<1"
"uvicorn[standard]>=0.30.0,<0.42.0"
]
[project.optional-dependencies]
dev = [
"pytest==9.0.3",
"watchdog==6.0.0",
"agent-framework-orchestrations==1.0.0rc1",
"agent-framework-orchestrations==1.0.0b260402",
]
all = [
"pytest==9.0.3",
@@ -517,8 +517,7 @@ async def test_magentic_executor_event_with_agent_delta_metadata(
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='agent_delta' is handled correctly.
This tests the ACTUAL event format Magentic emits - not a fake MagenticAgentDeltaEvent class.
Magentic emits type='intermediate' WorkflowEvent instances with additional_properties
containing magentic_event_type.
Magentic uses WorkflowEvent.emit() with additional_properties containing magentic_event_type.
"""
from agent_framework._types import AgentResponseUpdate
from agent_framework._workflows._events import WorkflowEvent
@@ -533,7 +532,7 @@ async def test_magentic_executor_event_with_agent_delta_metadata(
"agent_id": "writer_agent",
},
)
event = WorkflowEvent("intermediate", executor_id="magentic_executor", data=update)
event = WorkflowEvent.emit(executor_id="magentic_executor", data=update)
events = await mapper.convert_event(event, test_request)
@@ -548,8 +547,8 @@ async def test_magentic_executor_event_with_agent_delta_metadata(
async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='orchestrator_message' is handled.
Magentic emits orchestrator planning/instruction messages using type='intermediate'
WorkflowEvent instances with additional_properties containing magentic_event_type='orchestrator_message'.
Magentic emits orchestrator planning/instruction messages using WorkflowEvent.emit()
with additional_properties containing magentic_event_type='orchestrator_message'.
"""
from agent_framework._types import AgentResponseUpdate
from agent_framework._workflows._events import WorkflowEvent
@@ -565,7 +564,7 @@ async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_r
"orchestrator_id": "magentic_orchestrator",
},
)
event = WorkflowEvent("intermediate", executor_id="magentic_orchestrator", data=update)
event = WorkflowEvent.emit(executor_id="magentic_orchestrator", data=update)
events = await mapper.convert_event(event, test_request)
@@ -596,7 +595,7 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
contents=[Content.from_text(text="Regular workflow response")],
role="assistant",
)
regular_event = WorkflowEvent("intermediate", executor_id="regular_executor", data=regular_update)
regular_event = WorkflowEvent.emit(executor_id="regular_executor", data=regular_update)
# 2. Magentic workflow (with additional_properties)
magentic_update = AgentResponseUpdate(
@@ -604,7 +603,7 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
role="assistant",
additional_properties={"magentic_event_type": "agent_delta"},
)
magentic_event = WorkflowEvent("intermediate", executor_id="magentic_executor", data=magentic_update)
magentic_event = WorkflowEvent.emit(executor_id="magentic_executor", data=magentic_update)
# Both should be the SAME class
assert type(regular_event) is type(magentic_event)
@@ -654,7 +653,7 @@ async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentF
"""Test output event (type='output') is converted to output_item.added."""
from agent_framework._workflows._events import WorkflowEvent
event = WorkflowEvent("output", executor_id="final_executor", data="Final workflow output")
event = WorkflowEvent.output(executor_id="final_executor", data="Final workflow output")
events = await mapper.convert_event(event, test_request)
# output event (type='output') should emit output_item.added
@@ -663,9 +662,6 @@ async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentF
# Check item contains the output text
item = events[0].item
assert item.type == "message"
assert item.metadata["workflow_event_type"] == "output"
assert item.metadata["workflow_output_kind"] == "terminal"
assert item.metadata["executor_id"] == "final_executor"
assert any("Final workflow output" in str(c) for c in item.content)
@@ -679,104 +675,13 @@ async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_
Message(role="user", contents=[Content.from_text(text="Hello")]),
Message(role="assistant", contents=[Content.from_text(text="World")]),
]
event = WorkflowEvent("output", executor_id="complete", data=messages)
event = WorkflowEvent.output(executor_id="complete", data=messages)
events = await mapper.convert_event(event, test_request)
assert len(events) == 1
assert events[0].type == "response.output_item.added"
async def test_workflow_intermediate_event_with_agent_response_update_dispatched(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""A WorkflowEvent with type='intermediate' wrapping an AgentResponseUpdate is mapped
just like type='output' / type='data' — to OpenAI text-delta events."""
from agent_framework._workflows._events import WorkflowEvent
update = AgentResponseUpdate(
contents=[Content.from_text(text="intermediate progress")],
role="assistant",
author_name="non-designated-agent",
)
event = WorkflowEvent("intermediate", executor_id="non_designated", data=update)
events = await mapper.convert_event(event, test_request)
assert len(events) >= 1
added_events = [e for e in events if getattr(e, "type", "") == "response.output_item.added"]
assert added_events
item = added_events[0].item
assert item.metadata["workflow_event_type"] == "intermediate"
assert item.metadata["workflow_output_kind"] == "intermediate"
assert item.metadata["executor_id"] == "non_designated"
text_events = [e for e in events if getattr(e, "type", "") == "response.output_text.delta"]
assert len(text_events) >= 1
assert text_events[0].metadata["workflow_event_type"] == "intermediate"
assert text_events[0].metadata["workflow_output_kind"] == "intermediate"
assert text_events[0].metadata["executor_id"] == "non_designated"
assert text_events[0].delta == "intermediate progress"
async def test_workflow_intermediate_event_with_string_payload_renders_visible_text(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""A WorkflowEvent with type='intermediate' wrapping a plain string surfaces as a
visible output item — not a generic completed-trace event. Without this, executors
that ``await ctx.yield_output("plan: …")`` from non-designated nodes are silently
dropped in DevUI."""
from agent_framework._workflows._events import WorkflowEvent
event = WorkflowEvent("intermediate", executor_id="planner", data="plan: starting work")
events = await mapper.convert_event(event, test_request)
assert len(events) == 1
assert events[0].type == "response.output_item.added"
item = events[0].item
assert item.type == "message"
assert item.metadata["workflow_event_type"] == "intermediate"
assert item.metadata["workflow_output_kind"] == "intermediate"
assert item.metadata["executor_id"] == "planner"
assert any("plan: starting work" in str(c) for c in item.content)
async def test_workflow_intermediate_event_with_message_payload_renders_visible_text(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""type='intermediate' wrapping a Message surfaces visibly — same path as type='output'."""
from agent_framework import Message
from agent_framework._workflows._events import WorkflowEvent
msg = Message(role="assistant", contents=[Content.from_text(text="research note")])
event = WorkflowEvent("intermediate", executor_id="researcher", data=msg)
events = await mapper.convert_event(event, test_request)
assert len(events) == 1
assert events[0].type == "response.output_item.added"
item = events[0].item
assert item.metadata["workflow_event_type"] == "intermediate"
assert item.metadata["workflow_output_kind"] == "intermediate"
assert item.metadata["executor_id"] == "researcher"
assert any("research note" in str(c) for c in item.content)
async def test_workflow_data_event_keeps_intermediate_compatibility_metadata(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Deprecated type='data' workflow events remain visible and explicitly intermediate."""
from agent_framework._workflows._events import WorkflowEvent
with pytest.warns(DeprecationWarning):
event = WorkflowEvent.emit(executor_id="legacy", data="legacy progress")
events = await mapper.convert_event(event, test_request)
assert len(events) == 1
assert events[0].type == "response.output_item.added"
item = events[0].item
assert item.metadata["workflow_event_type"] == "data"
assert item.metadata["workflow_output_kind"] == "intermediate"
assert item.metadata["executor_id"] == "legacy"
assert any("legacy progress" in str(c) for c in item.content)
# =============================================================================
# failed event (type='failed') Tests
# =============================================================================
+4 -4
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260519"
version = "1.0.0b260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,9 +22,9 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"durabletask>=1.4.0,!=1.4.1,!=1.4.2,!=1.4.3,<2",
"durabletask-azuremanaged>=1.4.0,<2",
"agent-framework-core>=1.4.0,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
]
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.5.0"
version = "1.4.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-openai>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"agent-framework-openai>=1.4.0,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -86,28 +86,12 @@ def _with_foundry_debug() -> Any:
return decorator
def _as_raw(mock_response: MagicMock) -> MagicMock:
"""Wrap ``mock_response`` so it looks like an OpenAI ``with_raw_response`` wrapper.
The chat client now calls ``responses.with_raw_response.{create,parse}`` and then
``.parse()`` on the returned wrapper to get the actual response payload, plus
``.headers`` to surface the ``x-ms-served-model`` Azure header.
"""
mock_response.parse = MagicMock(return_value=mock_response)
mock_response.headers = {}
return mock_response
def _make_mock_openai_client() -> MagicMock:
client = MagicMock()
client.default_headers = {}
client.responses = MagicMock()
client.responses.create = AsyncMock()
client.responses.parse = AsyncMock()
client.responses.with_raw_response = MagicMock()
client.responses.with_raw_response.create = AsyncMock()
client.responses.with_raw_response.parse = AsyncMock()
client.responses.with_raw_response.retrieve = AsyncMock()
client.files = MagicMock()
client.files.create = AsyncMock()
client.files.delete = AsyncMock()
@@ -486,7 +470,7 @@ async def test_content_filter_exception() -> None:
body={"error": {"code": "content_filter", "message": "Content filter error"}},
)
mock_error.code = "content_filter"
client.client.responses.with_raw_response.create.side_effect = mock_error
client.client.responses.create.side_effect = mock_error
with pytest.raises(OpenAIContentFilterException) as exc_info:
await client.get_response(messages=[Message(role="user", contents=["Test message"])])
@@ -510,7 +494,7 @@ async def test_response_format_parse_path() -> None:
mock_parsed_response.usage = None
mock_parsed_response.finish_reason = None
mock_parsed_response.conversation = None
client.client.responses.with_raw_response.parse = AsyncMock(return_value=_as_raw(mock_parsed_response))
client.client.responses.parse = AsyncMock(return_value=mock_parsed_response)
response = await client.get_response(
messages=[Message(role="user", contents=["Test message"])],
@@ -538,7 +522,7 @@ async def test_response_format_parse_path_with_conversation_id() -> None:
mock_parsed_response.finish_reason = None
mock_parsed_response.conversation = MagicMock()
mock_parsed_response.conversation.id = "conversation_456"
client.client.responses.with_raw_response.parse = AsyncMock(return_value=_as_raw(mock_parsed_response))
client.client.responses.parse = AsyncMock(return_value=mock_parsed_response)
response = await client.get_response(
messages=[Message(role="user", contents=["Test message"])],
@@ -578,7 +562,7 @@ async def test_response_format_dict_parse_path() -> None:
mock_message_item.type = "message"
mock_message_item.content = [mock_message_content]
mock_response.output = [mock_message_item]
client.client.responses.with_raw_response.create = AsyncMock(return_value=_as_raw(mock_response))
client.client.responses.create = AsyncMock(return_value=mock_response)
response = await client.get_response(
messages=[Message(role="user", contents=["Test message"])],
@@ -603,7 +587,7 @@ async def test_bad_request_error_non_content_filter() -> None:
body={"error": {"code": "invalid_request", "message": "Invalid request"}},
)
mock_error.code = "invalid_request"
client.client.responses.with_raw_response.parse = AsyncMock(side_effect=mock_error)
client.client.responses.parse = AsyncMock(side_effect=mock_error)
with pytest.raises(ChatClientException) as exc_info:
await client.get_response(
@@ -1816,7 +1816,7 @@ class TestEvaluateWorkflow:
WorkflowEvent.executor_completed("writer", [aer1]),
WorkflowEvent.executor_invoked("reviewer", [aer1]),
WorkflowEvent.executor_completed("reviewer", [aer2]),
WorkflowEvent("output", executor_id="end", data=final_output),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
@@ -1845,7 +1845,7 @@ class TestEvaluateWorkflow:
events = [
WorkflowEvent.executor_invoked("agent", "Test query"),
WorkflowEvent.executor_completed("agent", [aer]),
WorkflowEvent("output", executor_id="end", data=final_output),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
@@ -1875,7 +1875,7 @@ class TestEvaluateWorkflow:
WorkflowEvent.executor_completed("input-conversation", None),
WorkflowEvent.executor_invoked("planner", "Plan trip"),
WorkflowEvent.executor_completed("planner", [aer]),
WorkflowEvent("output", executor_id="end", data=final_output),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
@@ -1941,7 +1941,7 @@ class TestEvaluateWorkflow:
WorkflowEvent.executor_completed("input-conversation", None),
WorkflowEvent.executor_invoked("researcher", "What's the weather?"),
WorkflowEvent.executor_completed("researcher", [aer]),
WorkflowEvent("output", executor_id="end", data=[Message("assistant", ["Weather is sunny"])]),
WorkflowEvent.output("end", [Message("assistant", ["Weather is sunny"])]),
]
wf_result = WorkflowRunResult(events, [])
@@ -2050,7 +2050,7 @@ class TestEvaluateWorkflow:
events = [
WorkflowEvent.executor_invoked("agent", "Test query"),
WorkflowEvent.executor_completed("agent", [aer]),
WorkflowEvent("output", executor_id="end", data=final_output),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
@@ -2089,7 +2089,7 @@ class TestEvaluateWorkflow:
events = [
WorkflowEvent.executor_invoked("agent", "Test query"),
WorkflowEvent.executor_completed("agent", [aer]),
WorkflowEvent("output", executor_id="end", data=final_output),
WorkflowEvent.output("end", final_output),
]
wf_result = WorkflowRunResult(events, [])
@@ -4,7 +4,7 @@ description = "Foundry Hosting integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260519"
version = "1.0.0a260514"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.5.0,<2",
"agent-framework-core>=1.4.0,<2",
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b5,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",

Some files were not shown because too many files have changed in this diff Show More