mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
502f128a9c | ||
|
|
7d233b9bc8 | ||
|
|
137a79686f |
@@ -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
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
+17
-47
@@ -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; } = [];
|
||||
}
|
||||
|
||||
-98
@@ -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 <filename></c> and <c>/session-import <filename></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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
|
||||
+19
-20
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
-29
@@ -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 |
|
||||
-36
@@ -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!")
|
||||
```
|
||||
-97
@@ -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
|
||||
|
||||
+84
-191
@@ -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
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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(
|
||||
|
||||
+2
-43
@@ -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,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,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",
|
||||
]
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user