mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a84ad42f6d | ||
|
|
ded17b178c | ||
|
|
55dc3ce734 | ||
|
|
9d8e5ca4f5 | ||
|
|
af787569b3 | ||
|
|
3db2004e49 | ||
|
|
efdabd56dc | ||
|
|
371a869e44 | ||
|
|
e532ced950 |
+5
-5
@@ -7,20 +7,20 @@ using Microsoft.Extensions.AI;
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>TodoList_*</c> tool calls with tree-view output for added items
|
||||
/// Formats <c>todos_*</c> tool calls with tree-view output for added items
|
||||
/// and structured output for complete/remove operations.
|
||||
/// </summary>
|
||||
public sealed class TodoToolFormatter : ToolCallFormatter
|
||||
{
|
||||
/// <inheritdoc/>
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("TodoList_", StringComparison.Ordinal);
|
||||
public override bool CanFormat(FunctionCallContent call) => call.Name.StartsWith("todos_", StringComparison.Ordinal);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"TodoList_Add" => FormatAddTodos(call),
|
||||
"TodoList_Complete" => FormatCompleteTodos(call),
|
||||
"TodoList_Remove" => FormatIdList(call, "ids", "Remove"),
|
||||
"todos_add" => FormatAddTodos(call),
|
||||
"todos_complete" => FormatCompleteTodos(call),
|
||||
"todos_remove" => FormatIdList(call, "ids", "Remove"),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -26,11 +26,11 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// This provider exposes the following tools to the agent:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>TodoList_Add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>TodoList_Complete</c> — Mark one or more todo items as complete by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_Remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>TodoList_GetRemaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>TodoList_GetAll</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// <item><description><c>todos_add</c> — Add one or more todo items, each with a title and optional description.</description></item>
|
||||
/// <item><description><c>todos_complete</c> — Mark one or more todo items as complete by their IDs and reasons.</description></item>
|
||||
/// <item><description><c>todos_remove</c> — Remove one or more todo items by their IDs.</description></item>
|
||||
/// <item><description><c>todos_get_remaining</c> — Retrieve only incomplete todo items.</description></item>
|
||||
/// <item><description><c>todos_get_all</c> — Retrieve all todo items (complete and incomplete).</description></item>
|
||||
/// </list>
|
||||
/// </para>
|
||||
/// <para>
|
||||
@@ -53,11 +53,11 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
When a user changes the topic or changes their mind, ensure that you update the todo list accordingly by removing irrelevant/old items or adding new ones as needed.
|
||||
|
||||
Use these tools to manage your tasks:
|
||||
- Use TodoList_Add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use TodoList_Complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use TodoList_GetRemaining to check what work is still pending.
|
||||
- Use TodoList_GetAll to review the full list including completed items.
|
||||
- Use TodoList_Remove to remove items that are no longer needed (supports one or many at once).
|
||||
- Use todos_add to break down complex work into trackable items (supports adding one or many at once).
|
||||
- Use todos_complete to mark items as done when finished (supports one or many at once). Include a reason describing how the items were completed.
|
||||
- Use todos_get_remaining to check what work is still pending.
|
||||
- Use todos_get_all to review the full list including completed items.
|
||||
- Use todos_remove to remove items that are no longer needed (supports one or many at once).
|
||||
""";
|
||||
|
||||
private readonly ProviderSessionState<TodoState> _sessionState;
|
||||
@@ -229,7 +229,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Add",
|
||||
Name = "todos_add",
|
||||
Description = "Add one or more todo items. Each item has a title and an optional description. Returns the list of created todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -267,7 +267,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Complete",
|
||||
Name = "todos_complete",
|
||||
Description = "Mark one or more todo items as complete. Each entry has an ID and a reason describing how/why the item was completed. Returns the number of items that were found and marked complete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -297,7 +297,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_Remove",
|
||||
Name = "todos_remove",
|
||||
Description = "Remove one or more todo items by their IDs. Returns the number of items that were found and removed.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -319,7 +319,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetRemaining",
|
||||
Name = "todos_get_remaining",
|
||||
Description = "Retrieve the list of incomplete todo items.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
@@ -341,7 +341,7 @@ public sealed class TodoProvider : AIContextProvider, IDisposable
|
||||
},
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = "TodoList_GetAll",
|
||||
Name = "todos_get_all",
|
||||
Description = "Retrieve the full list of todo items, both complete and incomplete.",
|
||||
SerializerOptions = serializerOptions,
|
||||
}),
|
||||
|
||||
@@ -51,7 +51,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -75,7 +75,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
|
||||
// Act
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
@@ -111,8 +111,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -131,8 +131,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -156,7 +156,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
|
||||
// Act
|
||||
object? result = await completeTodos.InvokeAsync(new AIFunctionArguments() { ["items"] = new List<TodoCompleteInput> { new() { Id = 999, Reason = "Done" } } });
|
||||
@@ -173,8 +173,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Research topic" } } });
|
||||
|
||||
// Act
|
||||
@@ -200,8 +200,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Test", Description = null } } });
|
||||
|
||||
// Act
|
||||
@@ -220,8 +220,8 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, state) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First" }, new() { Title = "Second" }, new() { Title = "Third" } },
|
||||
@@ -244,7 +244,7 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction removeTodos = GetTool(tools, "TodoList_Remove");
|
||||
AIFunction removeTodos = GetTool(tools, "todos_remove");
|
||||
|
||||
// Act
|
||||
object? result = await removeTodos.InvokeAsync(new AIFunctionArguments() { ["ids"] = new List<int> { 999 } });
|
||||
@@ -265,9 +265,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "TodoList_GetRemaining");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getRemainingTodos = GetTool(tools, "todos_get_remaining");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -295,9 +295,9 @@ public class TodoProviderTests
|
||||
{
|
||||
// Arrange
|
||||
var (tools, _) = await CreateToolsWithStateAsync();
|
||||
AIFunction addTodos = GetTool(tools, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(tools, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(tools, "todos_add");
|
||||
AIFunction completeTodos = GetTool(tools, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(tools, "todos_get_all");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -332,12 +332,12 @@ public class TodoProviderTests
|
||||
|
||||
// Act — first invocation adds a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments() { ["todos"] = new List<TodoItemInput> { new() { Title = "Persisted", Description = null } } });
|
||||
|
||||
// Second invocation should see the same state
|
||||
AIContext result2 = await provider.InvokingAsync(context);
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_GetAll");
|
||||
AIFunction getAllTodos = (AIFunction)result2.Tools!.First(t => t is AIFunction f && f.Name == "todos_get_all");
|
||||
object? allResult = await getAllTodos.InvokeAsync(new AIFunctionArguments());
|
||||
|
||||
// Assert
|
||||
@@ -364,7 +364,7 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "First", Description = null }, new() { Title = "Second", Description = null } },
|
||||
@@ -393,8 +393,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Done", Description = null }, new() { Title = "Pending", Description = null } },
|
||||
@@ -556,8 +556,8 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add some todos (one with a description to cover that branch)
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Complete");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
AIFunction completeTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_complete");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput>
|
||||
@@ -622,7 +622,7 @@ public class TodoProviderTests
|
||||
|
||||
// First invocation — add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Task A" } },
|
||||
@@ -687,7 +687,7 @@ public class TodoProviderTests
|
||||
|
||||
// Add a todo
|
||||
AIContext result1 = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "TodoList_Add");
|
||||
AIFunction addTodos = (AIFunction)result1.Tools!.First(t => t is AIFunction f && f.Name == "todos_add");
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
{
|
||||
["todos"] = new List<TodoItemInput> { new() { Title = "Original" } },
|
||||
@@ -725,8 +725,8 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Act — launch multiple concurrent adds
|
||||
var tasks = Enumerable.Range(0, 10).Select(i =>
|
||||
@@ -760,9 +760,9 @@ public class TodoProviderTests
|
||||
var context = new AIContextProvider.InvokingContext(agent, session, new AIContext());
|
||||
#pragma warning restore MAAI001
|
||||
AIContext result = await provider.InvokingAsync(context);
|
||||
AIFunction addTodos = GetTool(result.Tools!, "TodoList_Add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "TodoList_Complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "TodoList_GetAll");
|
||||
AIFunction addTodos = GetTool(result.Tools!, "todos_add");
|
||||
AIFunction completeTodos = GetTool(result.Tools!, "todos_complete");
|
||||
AIFunction getAllTodos = GetTool(result.Tools!, "todos_get_all");
|
||||
|
||||
// Add initial items
|
||||
await addTodos.InvokeAsync(new AIFunctionArguments()
|
||||
|
||||
+30
-1
@@ -7,6 +7,34 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.7.0] - 2026-05-28
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**: Add `HarnessAgent` and background-agents harness provider ([#6041](https://github.com/microsoft/agent-framework/pull/6041), [#6069](https://github.com/microsoft/agent-framework/pull/6069))
|
||||
- **agent-framework-core**, **agent-framework-a2a**: Add `A2AAgentSession` with referenced task IDs and input-required support ([#5980](https://github.com/microsoft/agent-framework/pull/5980))
|
||||
- **agent-framework-foundry**: Add experimental prompt-agent conversion and deployment APIs ([#5959](https://github.com/microsoft/agent-framework/pull/5959))
|
||||
- **agent-framework-declarative**: Add Foundry Toolbox MCP invocation support and sample ([#5933](https://github.com/microsoft/agent-framework/pull/5933))
|
||||
- **samples**: Add hosting samples overview README ([#5407](https://github.com/microsoft/agent-framework/pull/5407))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-core**: Align TodoProvider tool names with the C# implementation ([#6107](https://github.com/microsoft/agent-framework/pull/6107))
|
||||
- **agent-framework-core**: Align ModeProvider tool names and instructions ([#6071](https://github.com/microsoft/agent-framework/pull/6071))
|
||||
- **agent-framework-chatkit**: Raise the `openai-chatkit` dependency floor to `>=1.6.4` to match the current typed API usage.
|
||||
- **agent-framework-declarative**: [BREAKING] Remove Python-only declarative actions and rename alias kinds to C# canonical names ([#6126](https://github.com/microsoft/agent-framework/pull/6126))
|
||||
- **tests**: Replace deprecated `asyncio.iscoroutinefunction` usage in DevUI cleanup-hook tests ([#4563](https://github.com/microsoft/agent-framework/pull/4563))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Point `@experimental` warnings at user code ([#5996](https://github.com/microsoft/agent-framework/pull/5996))
|
||||
- **agent-framework-declarative**: Fix Foreach body exit wiring ([#6050](https://github.com/microsoft/agent-framework/pull/6050))
|
||||
- **agent-framework-devui**: Fix streaming memory growth regression ([#6038](https://github.com/microsoft/agent-framework/pull/6038))
|
||||
- **agent-framework-foundry**: Pass default headers to Foundry agents ([#6040](https://github.com/microsoft/agent-framework/pull/6040))
|
||||
- **agent-framework-foundry-hosting**: Fix hosted handoff argument serialization ([#5861](https://github.com/microsoft/agent-framework/pull/5861))
|
||||
- **agent-framework-foundry-hosting**: Allow hosted checkpoints to restore `MessageRole` values ([#6049](https://github.com/microsoft/agent-framework/pull/6049))
|
||||
- **agent-framework-openai**: Preserve citation `get_url` metadata ([#6037](https://github.com/microsoft/agent-framework/pull/6037))
|
||||
- **agent-framework-openai**: Guard Chat Completions streaming against null deltas ([#5734](https://github.com/microsoft/agent-framework/pull/5734))
|
||||
- **agent-framework-openai**: Read response headers defensively for stream wrappers without `.headers` ([#6028](https://github.com/microsoft/agent-framework/pull/6028), [#6029](https://github.com/microsoft/agent-framework/pull/6029))
|
||||
- **samples**: Fix sequential workflow sample output handling ([#5976](https://github.com/microsoft/agent-framework/pull/5976))
|
||||
|
||||
## [1.6.0] - 2026-05-21
|
||||
|
||||
### Added
|
||||
@@ -1104,7 +1132,8 @@ 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.6.0...HEAD
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...HEAD
|
||||
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
|
||||
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
|
||||
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
|
||||
[1.4.0]: https://github.com/microsoft/agent-framework/compare/python-1.3.0...python-1.4.0
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import importlib.metadata
|
||||
|
||||
from ._a2a_executor import A2AExecutor
|
||||
from ._agent import A2AAgent, A2AContinuationToken
|
||||
from ._agent import A2AAgent, A2AAgentSession, A2AContinuationToken
|
||||
|
||||
try:
|
||||
__version__ = importlib.metadata.version(__name__)
|
||||
@@ -12,6 +12,7 @@ except importlib.metadata.PackageNotFoundError:
|
||||
|
||||
__all__ = [
|
||||
"A2AAgent",
|
||||
"A2AAgentSession",
|
||||
"A2AContinuationToken",
|
||||
"A2AExecutor",
|
||||
"__version__",
|
||||
|
||||
@@ -43,11 +43,89 @@ from agent_framework._types import AgentRunInputs
|
||||
from agent_framework.observability import AgentTelemetryLayer
|
||||
from google.protobuf.json_format import MessageToDict
|
||||
|
||||
__all__ = ["A2AAgent", "A2AContinuationToken"]
|
||||
__all__ = ["A2AAgent", "A2AAgentSession", "A2AContinuationToken"]
|
||||
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
class A2AAgentSession(AgentSession):
|
||||
"""Session for A2A-based agents.
|
||||
|
||||
Extends AgentSession with A2A protocol-specific state: context_id for
|
||||
conversation tracking, task_id for the most recent task, and task_state
|
||||
for detecting input-required continuations vs. task refinements.
|
||||
|
||||
Attributes:
|
||||
context_id: The A2A conversation context identifier.
|
||||
task_id: The most recent task ID returned by the remote agent.
|
||||
task_state: The state of the most recent task (e.g., completed, input-required).
|
||||
"""
|
||||
|
||||
_CONTEXT_ID_KEY = "a2a_context_id"
|
||||
_TASK_ID_KEY = "a2a_task_id"
|
||||
_TASK_STATE_KEY = "a2a_task_state"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
context_id: str | None = None,
|
||||
task_id: str | None = None,
|
||||
task_state: TaskState | None = None,
|
||||
) -> None:
|
||||
"""Initialize the A2A agent session.
|
||||
|
||||
Keyword Args:
|
||||
context_id: Optional A2A context ID for conversation tracking.
|
||||
task_id: Optional task ID from a previous interaction.
|
||||
task_state: Optional state of the most recent task.
|
||||
"""
|
||||
super().__init__(service_session_id=context_id)
|
||||
self.context_id: str | None = context_id
|
||||
self.task_id: str | None = task_id
|
||||
self.task_state: TaskState | None = task_state
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Serialize session to a plain dict for storage/transfer."""
|
||||
data = super().to_dict()
|
||||
if self.context_id is not None:
|
||||
data[self._CONTEXT_ID_KEY] = self.context_id
|
||||
if self.task_id is not None:
|
||||
data[self._TASK_ID_KEY] = self.task_id
|
||||
if self.task_state is not None:
|
||||
data[self._TASK_STATE_KEY] = self.task_state
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> A2AAgentSession:
|
||||
"""Restore session from a previously serialized dict.
|
||||
|
||||
Args:
|
||||
data: Dict from a previous ``to_dict()`` call.
|
||||
|
||||
Returns:
|
||||
Restored A2AAgentSession instance.
|
||||
"""
|
||||
data = dict(data) # defensive copy
|
||||
context_id = data.pop(cls._CONTEXT_ID_KEY, None)
|
||||
task_id = data.pop(cls._TASK_ID_KEY, None)
|
||||
task_state_value = data.pop(cls._TASK_STATE_KEY, None)
|
||||
|
||||
# TaskState is a protobuf enum (int values); store and restore as-is
|
||||
task_state: TaskState | None = task_state_value if task_state_value is not None else None
|
||||
|
||||
# Delegate state deserialization to the base class
|
||||
base_session = AgentSession.from_dict(data)
|
||||
|
||||
session = cls(
|
||||
context_id=context_id or base_session.service_session_id,
|
||||
task_id=task_id,
|
||||
task_state=task_state,
|
||||
)
|
||||
session._session_id = base_session.session_id
|
||||
session.state.update(base_session.state)
|
||||
return session
|
||||
|
||||
|
||||
class A2AContinuationToken(ContinuationToken):
|
||||
"""Continuation token for A2A protocol long-running tasks."""
|
||||
|
||||
@@ -314,10 +392,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
else:
|
||||
if not normalized_messages:
|
||||
raise ValueError("At least one message is required when starting a new task (no continuation_token).")
|
||||
a2a_message = self._prepare_message_for_a2a(
|
||||
normalized_messages[-1],
|
||||
context_id=session.service_session_id if session else None,
|
||||
)
|
||||
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1], session=session)
|
||||
request = SendMessageRequest(message=a2a_message)
|
||||
if background and not stream:
|
||||
# return_immediately only applies to non-streaming (message/send)
|
||||
@@ -392,6 +467,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
|
||||
all_updates: list[AgentResponseUpdate] = []
|
||||
streamed_artifact_ids_by_task: dict[str, set[str]] = {}
|
||||
last_task_id: str | None = None
|
||||
last_context_id: str | None = None
|
||||
last_task_state: TaskState | None = None
|
||||
# In non-streaming mode, accumulate intermediate status content so it
|
||||
# can be surfaced when the terminal event arrives (mirroring v0.3.x
|
||||
# behavior where the full Task history was available at completion).
|
||||
@@ -401,6 +479,8 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if payload_type == "message":
|
||||
# Process A2A Message
|
||||
msg = item.message
|
||||
if msg.context_id:
|
||||
last_context_id = msg.context_id
|
||||
contents = self._parse_contents_from_a2a(msg.parts)
|
||||
metadata = MessageToDict(msg.metadata) if msg.metadata else None
|
||||
update = AgentResponseUpdate(
|
||||
@@ -414,6 +494,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
yield update
|
||||
elif payload_type == "task":
|
||||
task = item.task
|
||||
last_task_id = task.id
|
||||
if task.context_id:
|
||||
last_context_id = task.context_id
|
||||
last_task_state = task.status.state
|
||||
updates = self._updates_from_task(
|
||||
task,
|
||||
background=background,
|
||||
@@ -435,20 +519,25 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
yield update
|
||||
elif payload_type == "status_update":
|
||||
status_event = item.status_update
|
||||
last_task_id = status_event.task_id
|
||||
if status_event.context_id:
|
||||
last_context_id = status_event.context_id
|
||||
last_task_state = status_event.status.state
|
||||
updates = self._updates_from_task_update_event(status_event)
|
||||
is_terminal = status_event.status.state in TERMINAL_TASK_STATES
|
||||
is_input_required = status_event.status.state == TaskState.TASK_STATE_INPUT_REQUIRED
|
||||
if emit_intermediate:
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
elif is_terminal:
|
||||
elif is_terminal or is_input_required:
|
||||
if updates:
|
||||
# Terminal event with content — discard accumulated intermediates
|
||||
# Terminal/input-required event with content — discard accumulated intermediates
|
||||
pending_updates_by_task.pop(status_event.task_id, None)
|
||||
for update in updates:
|
||||
all_updates.append(update)
|
||||
yield update
|
||||
else:
|
||||
elif is_terminal:
|
||||
# Terminal event with NO content — flush accumulated updates
|
||||
pending = pending_updates_by_task.pop(status_event.task_id, [])
|
||||
for update in pending:
|
||||
@@ -460,6 +549,9 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
pending_updates_by_task.setdefault(status_event.task_id, []).extend(updates)
|
||||
elif payload_type == "artifact_update":
|
||||
artifact_event = item.artifact_update
|
||||
last_task_id = artifact_event.task_id
|
||||
if artifact_event.context_id:
|
||||
last_context_id = artifact_event.context_id
|
||||
updates = self._updates_from_task_update_event(artifact_event)
|
||||
# Always yield artifact updates — they carry actual response
|
||||
# content (files, data). Track IDs so that a subsequent
|
||||
@@ -478,6 +570,22 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if all_updates:
|
||||
session_context._response = AgentResponse.from_updates(all_updates) # type: ignore[assignment]
|
||||
|
||||
# Persist A2A protocol state on the session for follow-up message linking.
|
||||
if isinstance(session, A2AAgentSession) and (last_task_id or last_context_id):
|
||||
# Validate context_id consistency
|
||||
if session.context_id is not None and last_context_id and session.context_id != last_context_id:
|
||||
raise RuntimeError(
|
||||
f"The context_id returned from the A2A agent ('{last_context_id}') "
|
||||
f"differs from the session's context_id ('{session.context_id}')."
|
||||
)
|
||||
# Assign server-generated context_id if not already set
|
||||
if session.context_id is None and last_context_id:
|
||||
session.context_id = last_context_id
|
||||
session.service_session_id = last_context_id
|
||||
if last_task_id:
|
||||
session.task_id = last_task_id
|
||||
session.task_state = last_task_state
|
||||
|
||||
await self._run_after_providers(session=session, context=session_context)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -601,6 +709,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
if not update_event.status.HasField("message") or not update_event.status.message.parts:
|
||||
return []
|
||||
|
||||
state = update_event.status.state
|
||||
if state not in TERMINAL_TASK_STATES and state != TaskState.TASK_STATE_INPUT_REQUIRED:
|
||||
return []
|
||||
|
||||
message = update_event.status.message
|
||||
contents = self._parse_contents_from_a2a(message.parts)
|
||||
if not contents:
|
||||
@@ -609,6 +721,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
msg_meta = MessageToDict(message.metadata) if message.metadata else {}
|
||||
event_meta = MessageToDict(update_event.metadata) if update_event.metadata else {}
|
||||
merged_metadata = {**msg_meta, **event_meta} or None
|
||||
|
||||
return [
|
||||
AgentResponseUpdate(
|
||||
contents=contents,
|
||||
@@ -647,7 +760,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
return AgentResponse.from_updates(updates)
|
||||
return AgentResponse(messages=[], response_id=task.id, raw_representation=task)
|
||||
|
||||
def _prepare_message_for_a2a(self, message: Message, *, context_id: str | None = None) -> A2AMessage:
|
||||
def _prepare_message_for_a2a(self, message: Message, *, session: AgentSession | None = None) -> A2AMessage:
|
||||
"""Prepare a Message for the A2A protocol.
|
||||
|
||||
Transforms Agent Framework Message objects into A2A protocol Messages by:
|
||||
@@ -656,14 +769,33 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
- Converting file references (URI/data/hosted_file) to FilePart objects
|
||||
- Preserving metadata and additional properties from the original message
|
||||
- Setting the role to 'user' as framework messages are treated as user input
|
||||
- Linking follow-up messages to previous tasks via reference_task_ids or task_id
|
||||
|
||||
When the session is an ``A2AAgentSession``, the method reads context_id,
|
||||
task_id, and task_state directly. If the task is in INPUT_REQUIRED state,
|
||||
the outbound message's ``task_id`` is set (continuing the same task);
|
||||
otherwise ``reference_task_ids`` is used for task refinement linking.
|
||||
|
||||
Args:
|
||||
message: The framework Message to convert.
|
||||
context_id: Optional fallback context identifier (e.g. derived from
|
||||
``AgentSession.service_session_id``). When the *message* already
|
||||
carries a ``context_id`` in its ``additional_properties`` that
|
||||
value takes precedence; otherwise this fallback is used.
|
||||
|
||||
Keyword Args:
|
||||
session: Optional session to read A2A state from. If an
|
||||
``A2AAgentSession``, context_id/task_id/task_state are used for
|
||||
linking. A plain ``AgentSession`` provides service_session_id as
|
||||
a fallback context_id.
|
||||
"""
|
||||
# Extract A2A state from the session
|
||||
context_id: str | None = None
|
||||
previous_task_id: str | None = None
|
||||
task_state: TaskState | None = None
|
||||
if isinstance(session, A2AAgentSession):
|
||||
context_id = session.context_id
|
||||
previous_task_id = session.task_id
|
||||
task_state = session.task_state
|
||||
elif session is not None:
|
||||
context_id = session.service_session_id
|
||||
|
||||
parts: list[A2APart] = []
|
||||
if not message.contents:
|
||||
raise ValueError("Message.contents is empty; cannot convert to A2AMessage.")
|
||||
@@ -722,14 +854,24 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
|
||||
|
||||
a2a_metadata = message.additional_properties.get("a2a_metadata")
|
||||
|
||||
return A2AMessage(
|
||||
a2a_message = A2AMessage(
|
||||
role=A2ARole.ROLE_USER,
|
||||
parts=parts,
|
||||
message_id=message.message_id or uuid.uuid4().hex,
|
||||
context_id=message.additional_properties.get("context_id") or context_id,
|
||||
context_id=context_id,
|
||||
metadata=a2a_metadata or {},
|
||||
)
|
||||
|
||||
if previous_task_id:
|
||||
if task_state == TaskState.TASK_STATE_INPUT_REQUIRED:
|
||||
# Task is waiting for user input — set task_id to continue the same task
|
||||
a2a_message.task_id = previous_task_id
|
||||
else:
|
||||
# Link as a follow-up (task refinement)
|
||||
a2a_message.reference_task_ids.append(previous_task_id)
|
||||
|
||||
return a2a_message
|
||||
|
||||
def _parse_contents_from_a2a(self, parts: Sequence[A2APart]) -> list[Content]:
|
||||
"""Parse A2A Parts into Agent Framework Content.
|
||||
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"a2a-sdk>=1.0.0,<2",
|
||||
]
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ from agent_framework import (
|
||||
from agent_framework.a2a import A2AAgent
|
||||
from pytest import fixture, mark, raises
|
||||
|
||||
from agent_framework_a2a import A2AContinuationToken
|
||||
from agent_framework_a2a import A2AAgentSession, A2AContinuationToken
|
||||
from agent_framework_a2a._utils import get_uri_data
|
||||
|
||||
|
||||
@@ -482,24 +482,25 @@ def test_prepare_message_for_a2a_with_multiple_contents() -> None:
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_forwards_context_id() -> None:
|
||||
"""Test conversion of Message preserves context_id without duplicating it in metadata."""
|
||||
"""Test conversion of Message uses context_id from A2AAgentSession."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Continue the task")],
|
||||
additional_properties={"context_id": "ctx-123", "a2a_metadata": {"trace_id": "trace-456"}},
|
||||
additional_properties={"a2a_metadata": {"trace_id": "trace-456"}},
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message)
|
||||
session = A2AAgentSession(context_id="ctx-123")
|
||||
result = agent._prepare_message_for_a2a(message, session=session)
|
||||
|
||||
assert result.context_id == "ctx-123"
|
||||
assert result.metadata == {"trace_id": "trace-456"}
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_uses_fallback_context_id() -> None:
|
||||
"""Test that context_id kwarg is used when message has no context_id property."""
|
||||
"""Test that service_session_id from a plain session is used when message has no context_id property."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
@@ -508,25 +509,26 @@ def test_prepare_message_for_a2a_uses_fallback_context_id() -> None:
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
|
||||
session = AgentSession(service_session_id="session-ctx-1")
|
||||
result = agent._prepare_message_for_a2a(message, session=session)
|
||||
|
||||
assert result.context_id == "session-ctx-1"
|
||||
|
||||
|
||||
def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None:
|
||||
"""Test that message.additional_properties context_id wins over the fallback."""
|
||||
def test_prepare_message_for_a2a_a2a_session_context_id_takes_precedence() -> None:
|
||||
"""Test that A2AAgentSession.context_id is used over plain session service_session_id."""
|
||||
|
||||
agent = A2AAgent(client=MagicMock(), http_client=None)
|
||||
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
additional_properties={"context_id": "explicit-ctx"},
|
||||
)
|
||||
|
||||
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
|
||||
session = A2AAgentSession(context_id="a2a-ctx")
|
||||
result = agent._prepare_message_for_a2a(message, session=session)
|
||||
|
||||
assert result.context_id == "explicit-ctx"
|
||||
assert result.context_id == "a2a-ctx"
|
||||
|
||||
|
||||
def test_parse_contents_from_a2a_with_data_part() -> None:
|
||||
@@ -758,9 +760,7 @@ async def test_background_sets_return_immediately_on_request(
|
||||
assert mock_a2a_client.last_request.configuration.return_immediately is True
|
||||
|
||||
|
||||
async def test_foreground_does_not_set_return_immediately(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
async def test_foreground_does_not_set_return_immediately(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that background=False (default) does not set configuration on SendMessageRequest."""
|
||||
mock_a2a_client.add_task_response("task-fg2", [{"id": "art-1", "content": "Done"}])
|
||||
|
||||
@@ -963,21 +963,16 @@ async def test_run_passes_session_service_session_id_as_context_id(mock_a2a_clie
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_run_message_context_id_takes_precedence_over_session(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that an explicit context_id on the message wins over session.service_session_id."""
|
||||
async def test_run_a2a_session_context_id_used_over_service_session_id(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that A2AAgentSession.context_id is used for outbound messages."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_message_response("msg-ctx2", "reply")
|
||||
|
||||
session = AgentSession(service_session_id="svc-session-42")
|
||||
message = Message(
|
||||
role="user",
|
||||
contents=[Content.from_text(text="Hello")],
|
||||
additional_properties={"context_id": "explicit-ctx"},
|
||||
)
|
||||
await agent.run(messages=[message], session=session)
|
||||
session = A2AAgentSession(context_id="a2a-ctx-99")
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert mock_a2a_client.last_message is not None
|
||||
assert mock_a2a_client.last_message.context_id == "explicit-ctx"
|
||||
assert mock_a2a_client.last_message.context_id == "a2a-ctx-99"
|
||||
|
||||
|
||||
# endregion
|
||||
@@ -1332,16 +1327,17 @@ async def test_streaming_artifact_update_event_yields_content(
|
||||
async def test_streaming_status_update_event_yields_content(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Test that streaming status update events surface message content directly from the update event."""
|
||||
"""Test that streaming status update events surface content for terminal/input-required states only."""
|
||||
# COMPLETED state should yield content (terminal)
|
||||
update_event = TaskStatusUpdateEvent(
|
||||
task_id="task-status",
|
||||
context_id="ctx-status",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
state=TaskState.TASK_STATE_COMPLETED,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Still working")],
|
||||
parts=[Part(text="Done")],
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -1352,11 +1348,60 @@ async def test_streaming_status_update_event_yields_content(
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "Still working"
|
||||
assert updates[0].text == "Done"
|
||||
assert updates[0].role == "assistant"
|
||||
assert updates[0].raw_representation == update_event
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_streaming_input_required_emits_content(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that input-required status updates emit content (gated states that pass through)."""
|
||||
update_event = TaskStatusUpdateEvent(
|
||||
task_id="task-status",
|
||||
context_id="ctx-status",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="What is your name?")],
|
||||
),
|
||||
),
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=update_event))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 1
|
||||
assert updates[0].text == "What is your name?"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_streaming_working_status_gates_content(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that intermediate WORKING status updates do NOT emit content (gated like .NET)."""
|
||||
update_event = TaskStatusUpdateEvent(
|
||||
task_id="task-status",
|
||||
context_id="ctx-status",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
message=A2AMessage(
|
||||
message_id=str(uuid4()),
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Processing...")],
|
||||
),
|
||||
),
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=update_event))
|
||||
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
async for update in a2a_agent.run("Hello", stream=True):
|
||||
updates.append(update)
|
||||
|
||||
assert len(updates) == 0
|
||||
|
||||
|
||||
async def test_streaming_artifact_update_event_does_not_duplicate_terminal_task_artifacts(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
@@ -1576,28 +1621,17 @@ async def test_task_status_update_event_metadata_merged(a2a_agent: A2AAgent, moc
|
||||
task_id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_WORKING,
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
message=A2AMessage(
|
||||
message_id="m1",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="working...")],
|
||||
parts=[Part(text="need input")],
|
||||
metadata={"msg_key": "msg_val"},
|
||||
),
|
||||
),
|
||||
metadata={"event_key": "event_val"},
|
||||
)
|
||||
terminal_task = Task(
|
||||
id="task-se",
|
||||
context_id="ctx",
|
||||
status=TaskStatus(state=TaskState.TASK_STATE_COMPLETED),
|
||||
artifacts=[
|
||||
Artifact(artifact_id="a1", parts=[Part(text="done")]),
|
||||
],
|
||||
)
|
||||
mock_a2a_client.responses.extend([
|
||||
StreamResponse(status_update=status_event),
|
||||
StreamResponse(task=terminal_task),
|
||||
])
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=status_event))
|
||||
|
||||
stream = a2a_agent.run("hello", stream=True)
|
||||
updates: list[AgentResponseUpdate] = []
|
||||
@@ -1681,11 +1715,9 @@ async def test_non_streaming_terminal_status_update_surfaces_content(
|
||||
assert response.messages[0].text == "Done! Here is your answer."
|
||||
|
||||
|
||||
async def test_non_streaming_accumulates_working_content_for_empty_terminal(
|
||||
a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient
|
||||
) -> None:
|
||||
"""Non-streaming run() accumulates WORKING content and flushes on empty terminal event."""
|
||||
# Intermediate WORKING event with content
|
||||
async def test_non_streaming_working_content_gated(a2a_agent: A2AAgent, mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Non-streaming: WORKING status content is gated and not surfaced to callers."""
|
||||
# Intermediate WORKING event with content — should be gated
|
||||
working_msg = A2AMessage(
|
||||
message_id="msg-working",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
@@ -1702,9 +1734,8 @@ async def test_non_streaming_accumulates_working_content_for_empty_terminal(
|
||||
|
||||
response = await a2a_agent.run("Hello")
|
||||
|
||||
# The accumulated WORKING content is flushed when terminal arrives empty
|
||||
assert len(response.messages) == 1
|
||||
assert response.messages[0].text == "Here is your answer from working state."
|
||||
# WORKING content is gated — nothing to accumulate or flush
|
||||
assert len(response.messages) == 0
|
||||
|
||||
|
||||
async def test_non_streaming_intermediate_discarded_when_terminal_has_content(
|
||||
@@ -1761,3 +1792,268 @@ async def test_non_streaming_artifact_update_surfaces_content(
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region Reference Task IDs Tests
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_first_message_has_no_reference_task_ids(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that the first message sent has no reference_task_ids."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-first", [{"content": "Hello back"}])
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert mock_a2a_client.last_message is not None
|
||||
assert list(mock_a2a_client.last_message.reference_task_ids) == []
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_follow_up_message_includes_reference_task_ids(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a follow-up message references the previous task_id."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-abc-123", [{"content": "First reply"}])
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# Verify task_id was persisted on session
|
||||
assert session.task_id == "task-abc-123"
|
||||
|
||||
# Send a follow-up message
|
||||
mock_a2a_client.add_task_response("task-def-456", [{"content": "Second reply"}])
|
||||
await agent.run("Follow up", session=session)
|
||||
|
||||
assert mock_a2a_client.last_message is not None
|
||||
assert list(mock_a2a_client.last_message.reference_task_ids) == ["task-abc-123"]
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_reference_task_ids_updated_after_each_interaction(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that reference_task_ids always points to the most recent task."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
session = A2AAgentSession()
|
||||
|
||||
# First interaction
|
||||
mock_a2a_client.add_task_response("task-1", [{"content": "Reply 1"}])
|
||||
await agent.run("Message 1", session=session)
|
||||
assert session.task_id == "task-1"
|
||||
|
||||
# Second interaction
|
||||
mock_a2a_client.add_task_response("task-2", [{"content": "Reply 2"}])
|
||||
await agent.run("Message 2", session=session)
|
||||
assert mock_a2a_client.last_message.reference_task_ids == ["task-1"]
|
||||
assert session.task_id == "task-2"
|
||||
|
||||
# Third interaction references the second task
|
||||
mock_a2a_client.add_task_response("task-3", [{"content": "Reply 3"}])
|
||||
await agent.run("Message 3", session=session)
|
||||
assert mock_a2a_client.last_message.reference_task_ids == ["task-2"]
|
||||
assert session.task_id == "task-3"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_task_id_tracked_from_status_update_events(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that task_id is tracked even when response only contains status update events."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Simulate a stream that only has status_update events (no full task payload)
|
||||
status_event = TaskStatusUpdateEvent(
|
||||
task_id="task-from-status",
|
||||
context_id="ctx-1",
|
||||
status=TaskStatus(
|
||||
state=TaskState.TASK_STATE_COMPLETED,
|
||||
message=A2AMessage(
|
||||
message_id="msg-status",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Done")],
|
||||
),
|
||||
),
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(status_update=status_event))
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert session.task_id == "task-from-status"
|
||||
assert session.task_state == TaskState.TASK_STATE_COMPLETED
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_no_session_does_not_crash_reference_task_ids(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that running without a session (no reference tracking) works fine."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-no-session", [{"content": "Reply"}])
|
||||
|
||||
# Should not raise — no session means no reference_task_ids
|
||||
response = await agent.run("Hello")
|
||||
assert response is not None
|
||||
assert mock_a2a_client.last_message.reference_task_ids == []
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_task_id_not_tracked_from_message_payload(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that task_id is NOT tracked from message payloads (simple interactions without task tracking)."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Simulate a response that is a message with task_id set (no task/status_update events).
|
||||
# Per A2A spec, a Message response indicates simple interaction — task_id should not be persisted.
|
||||
message_with_task = A2AMessage(
|
||||
message_id="msg-with-task",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Response")],
|
||||
task_id="task-from-message",
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(message=message_with_task))
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
assert session.task_id is None
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_context_id_assigned_from_response(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context_id is assigned from the response when not set on session."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-ctx", [{"content": "Reply"}])
|
||||
|
||||
session = A2AAgentSession()
|
||||
assert session.context_id is None
|
||||
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# context_id from the task response should be assigned
|
||||
assert session.context_id == "test-context"
|
||||
assert session.service_session_id == "test-context"
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_context_id_tracked_from_message_payload(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that context_id is captured from message-only responses (no task payload)."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Simulate a response with only a message that has context_id but no task_id
|
||||
message_with_context = A2AMessage(
|
||||
message_id="msg-ctx-only",
|
||||
role=A2ARole.ROLE_AGENT,
|
||||
parts=[Part(text="Hello!")],
|
||||
context_id="server-ctx-123",
|
||||
)
|
||||
mock_a2a_client.responses.append(StreamResponse(message=message_with_context))
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Hi", session=session)
|
||||
|
||||
# context_id should be captured even without a task_id
|
||||
assert session.context_id == "server-ctx-123"
|
||||
assert session.service_session_id == "server-ctx-123"
|
||||
assert session.task_id is None
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_context_id_mismatch_raises_error(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a context_id mismatch between session and response raises an error."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Task response has context_id="test-context" (from add_task_response helper)
|
||||
mock_a2a_client.add_task_response("task-mismatch", [{"content": "Reply"}])
|
||||
|
||||
# Session already has a different context_id
|
||||
session = A2AAgentSession(context_id="different-context")
|
||||
|
||||
with raises(RuntimeError, match="differs from the session's context_id"):
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_task_state_tracked_on_session(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that task_state is tracked on A2AAgentSession."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# Add a task that ends in INPUT_REQUIRED
|
||||
mock_a2a_client.add_in_progress_task_response(
|
||||
"task-input",
|
||||
context_id="ctx-input",
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
text="What is your name?",
|
||||
)
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Start", session=session)
|
||||
|
||||
assert session.task_id == "task-input"
|
||||
assert session.task_state == TaskState.TASK_STATE_INPUT_REQUIRED
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_plain_agent_session_no_reference_tracking(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that a plain AgentSession works but does not get reference_task_ids tracking."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
mock_a2a_client.add_task_response("task-plain", [{"content": "Reply"}])
|
||||
|
||||
session = AgentSession()
|
||||
await agent.run("Hello", session=session)
|
||||
|
||||
# Plain session does not get task_id tracking
|
||||
assert "a2a_task_id" not in session.state
|
||||
|
||||
# Follow-up has no reference_task_ids (no tracking on plain session)
|
||||
mock_a2a_client.add_task_response("task-plain-2", [{"content": "Reply 2"}])
|
||||
await agent.run("Follow up", session=session)
|
||||
assert list(mock_a2a_client.last_message.reference_task_ids) == []
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_a2a_agent_session_serialization() -> None:
|
||||
"""Test A2AAgentSession serialization and deserialization."""
|
||||
session = A2AAgentSession(
|
||||
context_id="ctx-456",
|
||||
task_id="task-789",
|
||||
task_state=TaskState.TASK_STATE_COMPLETED,
|
||||
)
|
||||
|
||||
data = session.to_dict()
|
||||
restored = A2AAgentSession.from_dict(data)
|
||||
|
||||
assert restored.session_id == session.session_id
|
||||
assert restored.context_id == "ctx-456"
|
||||
assert restored.task_id == "task-789"
|
||||
assert restored.task_state == TaskState.TASK_STATE_COMPLETED
|
||||
|
||||
|
||||
@mark.asyncio
|
||||
async def test_input_required_sets_task_id_instead_of_reference(mock_a2a_client: MockA2AClient) -> None:
|
||||
"""Test that when task_state is INPUT_REQUIRED, follow-up sets task_id (not reference_task_ids)."""
|
||||
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
|
||||
|
||||
# First turn: task ends in INPUT_REQUIRED
|
||||
mock_a2a_client.add_in_progress_task_response(
|
||||
"task-ir",
|
||||
context_id="ctx-ir",
|
||||
state=TaskState.TASK_STATE_INPUT_REQUIRED,
|
||||
text="What is your name?",
|
||||
)
|
||||
|
||||
session = A2AAgentSession()
|
||||
await agent.run("Start", session=session)
|
||||
|
||||
assert session.task_state == TaskState.TASK_STATE_INPUT_REQUIRED
|
||||
assert session.task_id == "task-ir"
|
||||
|
||||
# Second turn: follow-up should set task_id (not reference_task_ids)
|
||||
mock_a2a_client.add_in_progress_task_response(
|
||||
"task-ir-2", context_id="ctx-ir", state=TaskState.TASK_STATE_COMPLETED, text="Thanks!"
|
||||
)
|
||||
await agent.run("My name is Alice", session=session)
|
||||
|
||||
# The outbound message should have task_id set, not reference_task_ids
|
||||
last_msg = mock_a2a_client.last_message
|
||||
assert last_msg.task_id == "task-ir"
|
||||
assert list(last_msg.reference_task_ids) == []
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
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.6.0,<2",
|
||||
"openai-chatkit>=1.4.1,<2.0.0",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"openai-chatkit>=1.6.4,<2.0.0",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
|
||||
@@ -12,6 +12,8 @@ from collections.abc import Mapping, MutableMapping
|
||||
from pathlib import Path
|
||||
from typing import Any, ClassVar, cast
|
||||
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from .._feature_stage import ExperimentalFeature, experimental
|
||||
from .._serialization import SerializationMixin
|
||||
from .._sessions import AgentSession, ContextProvider, SessionContext
|
||||
@@ -32,11 +34,12 @@ DEFAULT_TODO_INSTRUCTIONS = (
|
||||
"When a user changes the topic or changes their mind, ensure that you update the todo list accordingly "
|
||||
"by removing irrelevant items or adding new ones as needed.\n\n"
|
||||
"Use these tools to manage your tasks:\n"
|
||||
"- Use add_todos to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use complete_todos to mark items as done when finished (supports one or many at once).\n"
|
||||
"- Use get_remaining_todos to check what work is still pending.\n"
|
||||
"- Use get_all_todos to review the full list including completed items.\n"
|
||||
"- Use remove_todos to remove items that are no longer needed (supports one or many at once)."
|
||||
"- Use todos_add to break down complex work into trackable items (supports adding one or many at once).\n"
|
||||
"- Use todos_complete to mark items as done when finished (supports one or many at once). "
|
||||
"Include a reason describing how the items were completed.\n"
|
||||
"- Use todos_get_remaining to check what work is still pending.\n"
|
||||
"- Use todos_get_all to review the full list including completed items.\n"
|
||||
"- Use todos_remove to remove items that are no longer needed (supports one or many at once)."
|
||||
)
|
||||
|
||||
|
||||
@@ -48,7 +51,6 @@ class TodoItem(SerializationMixin):
|
||||
title: str
|
||||
description: str | None
|
||||
is_complete: bool
|
||||
__slots__ = ("description", "id", "is_complete", "title")
|
||||
|
||||
def __init__(self, id: int, title: str, description: str | None = None, is_complete: bool = False) -> None:
|
||||
"""Initialize one todo item."""
|
||||
@@ -106,7 +108,6 @@ class TodoInput(SerializationMixin):
|
||||
|
||||
title: str
|
||||
description: str | None
|
||||
__slots__ = ("description", "title")
|
||||
|
||||
def __init__(self, title: str, description: str | None = None) -> None:
|
||||
"""Initialize one todo input."""
|
||||
@@ -137,6 +138,56 @@ class TodoInput(SerializationMixin):
|
||||
return cls(title=title, description=description)
|
||||
|
||||
|
||||
@experimental(feature_id=ExperimentalFeature.HARNESS)
|
||||
class TodoCompleteInput(SerializationMixin):
|
||||
"""Describe one todo item to mark as complete."""
|
||||
|
||||
id: int
|
||||
reason: str
|
||||
|
||||
def __init__(self, id: int, reason: str) -> None:
|
||||
"""Initialize one todo complete input."""
|
||||
if not isinstance(id, int):
|
||||
raise ValueError("Todo complete input id must be an integer.")
|
||||
if not isinstance(reason, str) or not reason.strip():
|
||||
raise ValueError("Todo complete input reason must be a non-empty string.")
|
||||
self.id = id
|
||||
self.reason = reason.strip()
|
||||
|
||||
def to_dict(self, *, exclude: set[str] | None = None, exclude_none: bool = True) -> dict[str, Any]:
|
||||
"""Serialize the todo complete input."""
|
||||
del exclude, exclude_none
|
||||
return {"id": self.id, "reason": self.reason}
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls, raw_item: MutableMapping[str, Any], /, *, dependencies: MutableMapping[str, Any] | None = None
|
||||
) -> TodoCompleteInput:
|
||||
"""Parse one todo complete input from tool arguments."""
|
||||
del dependencies
|
||||
item_id = raw_item.get("id")
|
||||
reason = raw_item.get("reason")
|
||||
if not isinstance(item_id, int):
|
||||
raise ValueError("Todo complete input id must be an integer.")
|
||||
if not isinstance(reason, str):
|
||||
raise ValueError("Todo complete input reason must be a string.")
|
||||
return cls(id=item_id, reason=reason)
|
||||
|
||||
|
||||
class _TodoAddItemSchema(TypedDict):
|
||||
"""Schema for a single todo item in the todos_add tool."""
|
||||
|
||||
title: str
|
||||
description: NotRequired[str]
|
||||
|
||||
|
||||
class _TodoCompleteItemSchema(TypedDict):
|
||||
"""Schema for a single item in the todos_complete tool."""
|
||||
|
||||
id: int
|
||||
reason: str
|
||||
|
||||
|
||||
def _parse_todo_items(items_payload: list[Any], *, source_description: str) -> list[TodoItem]:
|
||||
"""Parse persisted todo item payloads with clear corruption errors."""
|
||||
items: list[TodoItem] = []
|
||||
@@ -158,6 +209,15 @@ def _coerce_todo_input(todo: TodoInput | dict[str, Any] | Any) -> TodoInput:
|
||||
raise ValueError("Todo input must be a TodoInput instance or JSON object.")
|
||||
|
||||
|
||||
def _coerce_todo_complete_input(item: TodoCompleteInput | dict[str, Any] | Any) -> TodoCompleteInput:
|
||||
"""Normalize tool-provided complete input into a TodoCompleteInput model."""
|
||||
if isinstance(item, TodoCompleteInput):
|
||||
return item
|
||||
if isinstance(item, MutableMapping):
|
||||
return TodoCompleteInput.from_dict(cast(MutableMapping[str, Any], item))
|
||||
raise ValueError("Todo complete input must be a TodoCompleteInput instance or JSON object.")
|
||||
|
||||
|
||||
def _safe_next_id(items: list[TodoItem], next_id: int) -> int:
|
||||
"""Clamp ``next_id`` so it cannot collide with any persisted item id."""
|
||||
return max(next_id, max((item.id for item in items), default=0) + 1)
|
||||
@@ -393,11 +453,11 @@ class TodoProvider(ContextProvider):
|
||||
can provide ``TodoFileStore`` or another store implementation for file-backed or custom persistence.
|
||||
|
||||
This provider exposes the following tools to the agent:
|
||||
- ``add_todos``: Add one or more todo items, each with a title and optional description.
|
||||
- ``complete_todos``: Mark one or more todo items as complete by their IDs.
|
||||
- ``remove_todos``: Remove one or more todo items by their IDs.
|
||||
- ``get_remaining_todos``: Retrieve only incomplete todo items.
|
||||
- ``get_all_todos``: Retrieve all todo items, complete and incomplete.
|
||||
- ``todos_add``: Add one or more todo items, each with a title and optional description.
|
||||
- ``todos_complete``: Mark one or more todo items as complete by their IDs and reasons.
|
||||
- ``todos_remove``: Remove one or more todo items by their IDs.
|
||||
- ``todos_get_remaining``: Retrieve only incomplete todo items.
|
||||
- ``todos_get_all``: Retrieve all todo items, complete and incomplete.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -442,8 +502,8 @@ class TodoProvider(ContextProvider):
|
||||
"""Inject todo tools and instructions before the model runs."""
|
||||
del agent, state
|
||||
|
||||
@tool(name="add_todos", approval_mode="never_require")
|
||||
async def add_todos(todos: list[dict[str, Any]]) -> str:
|
||||
@tool(name="todos_add", approval_mode="never_require")
|
||||
async def todos_add(todos: list[_TodoAddItemSchema]) -> str:
|
||||
"""Add one or more todo items for the current session."""
|
||||
if not todos:
|
||||
raise ValueError("todos must contain at least one item.")
|
||||
@@ -465,18 +525,24 @@ class TodoProvider(ContextProvider):
|
||||
await self.store.save_state(session, existing_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in created_items])
|
||||
|
||||
@tool(name="complete_todos", approval_mode="never_require")
|
||||
async def complete_todos(ids: list[int]) -> str:
|
||||
"""Mark one or more todo items as complete by ID."""
|
||||
if not ids:
|
||||
raise ValueError("ids must contain at least one todo ID.")
|
||||
@tool(name="todos_complete", approval_mode="never_require")
|
||||
async def todos_complete(items: list[_TodoCompleteItemSchema]) -> str:
|
||||
"""Mark one or more todo items as complete.
|
||||
|
||||
Each entry has an id (int) and a reason (string) describing how/why the item was completed.
|
||||
"""
|
||||
if not items:
|
||||
raise ValueError("items must contain at least one entry.")
|
||||
|
||||
parsed = [_coerce_todo_complete_input(entry) for entry in items]
|
||||
ids = [entry.id for entry in parsed]
|
||||
|
||||
async with self._mutation_lock(session):
|
||||
items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
existing_items, next_id = await self.store.load_state(session, source_id=self.source_id)
|
||||
id_set = set(ids)
|
||||
completed_count = 0
|
||||
updated_items: list[TodoItem] = []
|
||||
for item in items:
|
||||
for item in existing_items:
|
||||
if not item.is_complete and item.id in id_set:
|
||||
updated_items.append(
|
||||
TodoItem(
|
||||
@@ -494,8 +560,8 @@ class TodoProvider(ContextProvider):
|
||||
await self.store.save_state(session, updated_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"completed": completed_count})
|
||||
|
||||
@tool(name="remove_todos", approval_mode="never_require")
|
||||
async def remove_todos(ids: list[int]) -> str:
|
||||
@tool(name="todos_remove", approval_mode="never_require")
|
||||
async def todos_remove(ids: list[int]) -> str:
|
||||
"""Remove one or more todo items by ID."""
|
||||
if not ids:
|
||||
raise ValueError("ids must contain at least one todo ID.")
|
||||
@@ -508,16 +574,16 @@ class TodoProvider(ContextProvider):
|
||||
await self.store.save_state(session, remaining_items, next_id=next_id, source_id=self.source_id)
|
||||
return json.dumps({"removed": removed_count})
|
||||
|
||||
@tool(name="get_remaining_todos", approval_mode="never_require")
|
||||
async def get_remaining_todos() -> str:
|
||||
@tool(name="todos_get_remaining", approval_mode="never_require")
|
||||
async def todos_get_remaining() -> str:
|
||||
"""Retrieve only incomplete todo items for the current session."""
|
||||
items = [
|
||||
item for item in await self.store.load_items(session, source_id=self.source_id) if not item.is_complete
|
||||
]
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
|
||||
@tool(name="get_all_todos", approval_mode="never_require")
|
||||
async def get_all_todos() -> str:
|
||||
@tool(name="todos_get_all", approval_mode="never_require")
|
||||
async def todos_get_all() -> str:
|
||||
"""Retrieve all todo items for the current session."""
|
||||
items = await self.store.load_items(session, source_id=self.source_id)
|
||||
return json.dumps([item.to_dict(exclude_none=False) for item in items])
|
||||
@@ -525,7 +591,7 @@ class TodoProvider(ContextProvider):
|
||||
context.extend_instructions(self.source_id, [self.instructions])
|
||||
context.extend_tools(
|
||||
self.source_id,
|
||||
[add_todos, complete_todos, remove_todos, get_remaining_todos, get_all_todos],
|
||||
[todos_add, todos_complete, todos_remove, todos_get_remaining, todos_get_all],
|
||||
)
|
||||
current_items = await self.store.load_items(session, source_id=self.source_id)
|
||||
context.extend_messages(
|
||||
|
||||
@@ -7,6 +7,7 @@ This module lazily re-exports objects from:
|
||||
|
||||
Supported classes:
|
||||
- A2AAgent
|
||||
- A2AAgentSession
|
||||
- A2AExecutor
|
||||
"""
|
||||
|
||||
@@ -15,7 +16,7 @@ from typing import Any
|
||||
|
||||
IMPORT_PATH = "agent_framework_a2a"
|
||||
PACKAGE_NAME = "agent-framework-a2a"
|
||||
_IMPORTS = ["A2AAgent", "A2AExecutor"]
|
||||
_IMPORTS = ["A2AAgent", "A2AAgentSession", "A2AExecutor"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
from agent_framework_a2a import A2AAgent, A2AExecutor
|
||||
from agent_framework_a2a import A2AAgent, A2AAgentSession, A2AExecutor
|
||||
|
||||
__all__ = ["A2AAgent", "A2AExecutor"]
|
||||
__all__ = ["A2AAgent", "A2AAgentSession", "A2AExecutor"]
|
||||
|
||||
@@ -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.6.0"
|
||||
version = "1.7.0"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://aka.ms/agent-framework"
|
||||
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
|
||||
|
||||
@@ -252,8 +252,8 @@ async def test_todo_provider_runs_with_file_store(tmp_path: Path, chat_client_ba
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
add_todos = _tool_by_name(tools, "todos_add")
|
||||
get_all_todos = _tool_by_name(tools, "todos_get_all")
|
||||
|
||||
await add_todos.invoke(arguments={"todos": [{"title": "Persist me"}]})
|
||||
state_path = tmp_path / "session-1" / "todos.todo.json"
|
||||
@@ -283,11 +283,11 @@ async def test_todo_provider_tools_manage_session_state(
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
complete_todos = _tool_by_name(tools, "complete_todos")
|
||||
remove_todos = _tool_by_name(tools, "remove_todos")
|
||||
get_remaining_todos = _tool_by_name(tools, "get_remaining_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
add_todos = _tool_by_name(tools, "todos_add")
|
||||
complete_todos = _tool_by_name(tools, "todos_complete")
|
||||
remove_todos = _tool_by_name(tools, "todos_remove")
|
||||
get_remaining_todos = _tool_by_name(tools, "todos_get_remaining")
|
||||
get_all_todos = _tool_by_name(tools, "todos_get_all")
|
||||
|
||||
add_result = await add_todos.invoke(
|
||||
arguments={
|
||||
@@ -302,7 +302,7 @@ async def test_todo_provider_tools_manage_session_state(
|
||||
{"id": 2, "title": "Ship feature", "description": None, "is_complete": False},
|
||||
]
|
||||
|
||||
complete_result = await complete_todos.invoke(arguments={"ids": [1]})
|
||||
complete_result = await complete_todos.invoke(arguments={"items": [{"id": 1, "reason": "Tests written"}]})
|
||||
assert json.loads(complete_result[0].text) == {"completed": 1}
|
||||
|
||||
remaining_result = await get_remaining_todos.invoke()
|
||||
@@ -334,16 +334,16 @@ async def test_todo_provider_serializes_concurrent_mutations(
|
||||
tools = options["tools"]
|
||||
assert isinstance(tools, list)
|
||||
|
||||
add_todos = _tool_by_name(tools, "add_todos")
|
||||
complete_todos = _tool_by_name(tools, "complete_todos")
|
||||
get_all_todos = _tool_by_name(tools, "get_all_todos")
|
||||
add_todos = _tool_by_name(tools, "todos_add")
|
||||
complete_todos = _tool_by_name(tools, "todos_complete")
|
||||
get_all_todos = _tool_by_name(tools, "todos_get_all")
|
||||
|
||||
await add_todos.invoke(arguments={"todos": [{"title": f"Existing {index}"} for index in range(1, 6)]})
|
||||
|
||||
await asyncio.gather(
|
||||
add_todos.invoke(arguments={"todos": [{"title": "Add A1"}, {"title": "Add A2"}]}),
|
||||
add_todos.invoke(arguments={"todos": [{"title": "Add B1"}, {"title": "Add B2"}]}),
|
||||
complete_todos.invoke(arguments={"ids": [1, 2, 3, 4, 5]}),
|
||||
complete_todos.invoke(arguments={"items": [{"id": i, "reason": "Done"} for i in range(1, 6)]}),
|
||||
)
|
||||
|
||||
get_all_result = await get_all_todos.invoke()
|
||||
|
||||
@@ -38,10 +38,8 @@ from ._executors_agents import (
|
||||
)
|
||||
from ._executors_basic import (
|
||||
BASIC_ACTION_EXECUTORS,
|
||||
AppendValueExecutor,
|
||||
ClearAllVariablesExecutor,
|
||||
CreateConversationExecutor,
|
||||
EmitEventExecutor,
|
||||
ResetVariableExecutor,
|
||||
SendActivityExecutor,
|
||||
SetMultipleVariablesExecutor,
|
||||
@@ -61,12 +59,10 @@ from ._executors_control_flow import (
|
||||
)
|
||||
from ._executors_external_input import (
|
||||
EXTERNAL_INPUT_EXECUTORS,
|
||||
ConfirmationExecutor,
|
||||
ExternalInputRequest,
|
||||
ExternalInputResponse,
|
||||
QuestionExecutor,
|
||||
RequestExternalInputExecutor,
|
||||
WaitForInputExecutor,
|
||||
)
|
||||
from ._executors_http import (
|
||||
HTTP_ACTION_EXECUTORS,
|
||||
@@ -122,11 +118,9 @@ __all__ = [
|
||||
"AgentExternalInputRequest",
|
||||
"AgentExternalInputResponse",
|
||||
"AgentResult",
|
||||
"AppendValueExecutor",
|
||||
"BaseToolExecutor",
|
||||
"BreakLoopExecutor",
|
||||
"ClearAllVariablesExecutor",
|
||||
"ConfirmationExecutor",
|
||||
"ContinueLoopExecutor",
|
||||
"ConversationData",
|
||||
"CreateConversationExecutor",
|
||||
@@ -139,7 +133,6 @@ __all__ = [
|
||||
"DeclarativeWorkflowState",
|
||||
"DefaultHttpRequestHandler",
|
||||
"DefaultMCPToolHandler",
|
||||
"EmitEventExecutor",
|
||||
"EndConversationExecutor",
|
||||
"EndWorkflowExecutor",
|
||||
"ExternalInputRequest",
|
||||
@@ -173,7 +166,6 @@ __all__ = [
|
||||
"ToolApprovalResponse",
|
||||
"ToolApprovalState",
|
||||
"ToolInvocationResult",
|
||||
"WaitForInputExecutor",
|
||||
"WorkflowFactory",
|
||||
"WorkflowState",
|
||||
]
|
||||
|
||||
+2
-2
@@ -915,9 +915,9 @@ class ActionComplete:
|
||||
|
||||
@dataclass
|
||||
class ConditionResult:
|
||||
"""Result of evaluating a condition (If/Switch).
|
||||
"""Result of evaluating a condition (If/ConditionGroup).
|
||||
|
||||
This message is output by ConditionEvaluatorExecutor and SwitchEvaluatorExecutor
|
||||
This message is output by ConditionEvaluatorExecutor and ConditionGroupEvaluatorExecutor
|
||||
to indicate which branch should be taken.
|
||||
"""
|
||||
|
||||
|
||||
+66
-85
@@ -7,7 +7,7 @@ This module provides the DeclarativeWorkflowBuilder which is analogous to
|
||||
action definitions and creates a proper workflow graph with:
|
||||
- Executor nodes for each action
|
||||
- Edges for sequential flow
|
||||
- Condition evaluator executors for If/Switch that ensure first-match semantics
|
||||
- Condition evaluator executors for If/ConditionGroup that ensure first-match semantics
|
||||
- Loop edges for foreach
|
||||
"""
|
||||
|
||||
@@ -38,7 +38,6 @@ from ._executors_control_flow import (
|
||||
ForeachNextExecutor,
|
||||
IfConditionEvaluatorExecutor,
|
||||
JoinExecutor,
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
|
||||
from ._executors_http import HTTP_ACTION_EXECUTORS, HttpRequestActionExecutor
|
||||
@@ -64,7 +63,6 @@ ALL_ACTION_EXECUTORS = {
|
||||
# Action kinds that terminate control flow (no fall-through to successor)
|
||||
# These actions transfer control elsewhere and should not have sequential edges to the next action
|
||||
TERMINATOR_ACTIONS = frozenset({
|
||||
"Goto",
|
||||
"GotoAction",
|
||||
"BreakLoop",
|
||||
"ContinueLoop",
|
||||
@@ -80,18 +78,16 @@ TERMINATOR_ACTIONS = frozenset({
|
||||
ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
"SetValue": ["path"],
|
||||
"SetVariable": ["variable"],
|
||||
"AppendValue": ["path", "value"],
|
||||
"SendActivity": ["activity"],
|
||||
"InvokeAzureAgent": ["agent"],
|
||||
"Goto": ["target"],
|
||||
"GotoAction": ["actionId"],
|
||||
"Foreach": ["items", "actions"],
|
||||
"Foreach": ["source", "actions"],
|
||||
"If": ["condition"],
|
||||
"Switch": ["value"], # Switch can use value/cases or conditions (ConditionGroup style)
|
||||
"ConditionGroup": ["conditions"],
|
||||
"Question": ["question", "variable"],
|
||||
"RequestExternalInput": ["prompt", "variable"],
|
||||
"RequestHumanInput": ["variable"],
|
||||
"WaitForHumanInput": ["variable"],
|
||||
"EmitEvent": ["event"],
|
||||
"InvokeFunctionTool": ["functionName"],
|
||||
"HttpRequestAction": ["url"],
|
||||
"InvokeMcpTool": ["serverUrl", "toolName"],
|
||||
@@ -101,11 +97,14 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
|
||||
# Key: "ActionKind.field", Value: list of alternates that satisfy the requirement
|
||||
ACTION_ALTERNATE_FIELDS: dict[str, list[str]] = {
|
||||
"SetValue.path": ["variable"],
|
||||
"Goto.target": ["actionId"],
|
||||
"GotoAction.actionId": ["target"],
|
||||
"InvokeAzureAgent.agent": ["agentName"],
|
||||
"Foreach.items": ["itemsSource", "source"], # source is used in some schemas
|
||||
"Switch.value": ["conditions"], # Switch can be condition-based instead of value-based
|
||||
# Top-level alternates that satisfy the nested-shape requirements without forcing
|
||||
# callers to spell every field in its long form.
|
||||
"Question.question": ["text"],
|
||||
"Question.variable": ["property"],
|
||||
"RequestExternalInput.prompt": ["message"],
|
||||
"RequestExternalInput.variable": ["property"],
|
||||
}
|
||||
|
||||
|
||||
@@ -115,9 +114,9 @@ class DeclarativeWorkflowBuilder:
|
||||
This builder transforms declarative action definitions into a proper
|
||||
workflow graph with executor nodes and edges. It handles:
|
||||
- Sequential actions (simple edges)
|
||||
- Conditional branching (If/Switch with condition edges)
|
||||
- Conditional branching (If/ConditionGroup with condition edges)
|
||||
- Loops (Foreach with loop edges)
|
||||
- Jumps (Goto with target edges)
|
||||
- Jumps (GotoAction with target edges)
|
||||
|
||||
Example usage:
|
||||
yaml_def = {
|
||||
@@ -299,7 +298,7 @@ class DeclarativeWorkflowBuilder:
|
||||
raise ValueError(f"Action '{kind}' is missing required field '{field}'. Action: {action_def}")
|
||||
|
||||
# Collect goto targets for circular reference detection
|
||||
if kind in ("Goto", "GotoAction"):
|
||||
if kind == "GotoAction":
|
||||
target = action_def.get("target") or action_def.get("actionId")
|
||||
if target:
|
||||
goto_targets.append((target, explicit_id))
|
||||
@@ -313,13 +312,18 @@ class DeclarativeWorkflowBuilder:
|
||||
if else_actions:
|
||||
self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
|
||||
|
||||
elif kind in ("Switch", "ConditionGroup"):
|
||||
cases = action_def.get("cases", action_def.get("conditions", []))
|
||||
for case in cases:
|
||||
case_actions = case.get("actions", [])
|
||||
if case_actions:
|
||||
self._validate_actions_recursive(case_actions, seen_ids, goto_targets, defined_ids)
|
||||
else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", [])))
|
||||
elif kind == "ConditionGroup":
|
||||
for forbidden in ("else", "default"):
|
||||
if forbidden in action_def:
|
||||
raise ValueError(
|
||||
f"Action 'ConditionGroup' field '{forbidden}' is not supported; use 'elseActions' instead."
|
||||
)
|
||||
conditions = action_def.get("conditions", [])
|
||||
for condition_branch in conditions:
|
||||
branch_actions = condition_branch.get("actions", [])
|
||||
if branch_actions:
|
||||
self._validate_actions_recursive(branch_actions, seen_ids, goto_targets, defined_ids)
|
||||
else_actions = action_def.get("elseActions", [])
|
||||
if else_actions:
|
||||
self._validate_actions_recursive(else_actions, seen_ids, goto_targets, defined_ids)
|
||||
|
||||
@@ -362,7 +366,8 @@ class DeclarativeWorkflowBuilder:
|
||||
# Check for direct self-reference
|
||||
if source_id and target_id == source_id:
|
||||
raise ValueError(
|
||||
f"Action '{source_id}' has a direct self-referencing Goto, which would cause an infinite loop."
|
||||
f"Action '{source_id}' has a direct self-referencing GotoAction, "
|
||||
"which would cause an infinite loop."
|
||||
)
|
||||
|
||||
def _resolve_pending_gotos(self, builder: WorkflowBuilder) -> None:
|
||||
@@ -380,7 +385,7 @@ class DeclarativeWorkflowBuilder:
|
||||
builder.add_edge(source=goto_executor, target=target_executor)
|
||||
else:
|
||||
available_ids = list(self._executors.keys())
|
||||
raise ValueError(f"Goto target '{target_id}' not found. Available action IDs: {available_ids}")
|
||||
raise ValueError(f"GotoAction target '{target_id}' not found. Available action IDs: {available_ids}")
|
||||
|
||||
def _create_executors_for_actions(
|
||||
self,
|
||||
@@ -453,11 +458,11 @@ class DeclarativeWorkflowBuilder:
|
||||
# Handle special control flow actions
|
||||
if kind == "If":
|
||||
return self._create_if_structure(action_def, builder, parent_context)
|
||||
if kind == "Switch" or kind == "ConditionGroup":
|
||||
return self._create_switch_structure(action_def, builder, parent_context)
|
||||
if kind == "ConditionGroup":
|
||||
return self._create_condition_group_structure(action_def, builder, parent_context)
|
||||
if kind == "Foreach":
|
||||
return self._create_foreach_structure(action_def, builder, parent_context)
|
||||
if kind == "Goto" or kind == "GotoAction":
|
||||
if kind == "GotoAction":
|
||||
return self._create_goto_reference(action_def, builder, parent_context)
|
||||
if kind == "BreakLoop":
|
||||
return self._create_break_executor(action_def, builder, parent_context)
|
||||
@@ -588,7 +593,7 @@ class DeclarativeWorkflowBuilder:
|
||||
|
||||
# Wire evaluator to branches with conditions that check ConditionResult.branch_index
|
||||
# branch_index=0 means "then" branch, branch_index=-1 (ELSE_BRANCH_INDEX) means "else"
|
||||
# For nested If/Switch structures, wire to the evaluator (entry point)
|
||||
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
|
||||
if then_entry:
|
||||
then_target = self._get_structure_entry(then_entry)
|
||||
builder.add_edge(
|
||||
@@ -634,66 +639,42 @@ class DeclarativeWorkflowBuilder:
|
||||
|
||||
return IfStructure()
|
||||
|
||||
def _create_switch_structure(
|
||||
def _create_condition_group_structure(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
builder: WorkflowBuilder,
|
||||
parent_context: dict[str, Any] | None = None,
|
||||
) -> Any:
|
||||
"""Create the graph structure for a Switch/ConditionGroup action.
|
||||
"""Create the graph structure for a ConditionGroup action.
|
||||
|
||||
Supports two schema formats:
|
||||
1. ConditionGroup schema (matches .NET):
|
||||
- conditions: list of {condition: expr, actions: [...]}
|
||||
- elseActions: default actions
|
||||
|
||||
2. Switch schema (interpreter style):
|
||||
- value: expression to match
|
||||
- cases: list of {match: value, actions: [...]}
|
||||
- default: default actions
|
||||
|
||||
Both use evaluator executors that output ConditionResult with branch_index
|
||||
for first-match semantics.
|
||||
Evaluates the action's ``conditions`` in order; the first match
|
||||
selects its ``actions`` branch. If none match, ``elseActions`` runs.
|
||||
The structure exposes an evaluator entry point and the per-branch
|
||||
entry/exit pairs used by the caller to wire downstream edges.
|
||||
|
||||
Args:
|
||||
action_def: The Switch/ConditionGroup action definition
|
||||
action_def: The ConditionGroup action definition
|
||||
builder: The workflow builder
|
||||
parent_context: Context from parent
|
||||
|
||||
Returns:
|
||||
A SwitchStructure containing branch info for wiring
|
||||
A ConditionGroupStructure containing branch info for wiring
|
||||
"""
|
||||
action_id = action_def.get("id") or f"Switch_{self._action_index}"
|
||||
action_id = action_def.get("id") or f"ConditionGroup_{self._action_index}"
|
||||
self._action_index += 1
|
||||
|
||||
# Pass the Switch's ID as context for child action naming
|
||||
# Pass the ConditionGroup's ID as context for child action naming
|
||||
branch_context = {
|
||||
**(parent_context or {}),
|
||||
"parent_id": action_id,
|
||||
}
|
||||
|
||||
# Detect schema type:
|
||||
# - If "cases" present: interpreter Switch schema (value/cases/default)
|
||||
# - If "conditions" present: ConditionGroup schema (conditions/elseActions)
|
||||
cases = action_def.get("cases", [])
|
||||
conditions = action_def.get("conditions", [])
|
||||
|
||||
if cases:
|
||||
# Interpreter Switch schema: value/cases/default
|
||||
evaluator: DeclarativeActionExecutor = SwitchEvaluatorExecutor(
|
||||
action_def,
|
||||
cases,
|
||||
id=f"{action_id}_eval",
|
||||
)
|
||||
branch_items = cases
|
||||
else:
|
||||
# ConditionGroup schema: conditions/elseActions
|
||||
evaluator = ConditionGroupEvaluatorExecutor(
|
||||
action_def,
|
||||
conditions,
|
||||
id=f"{action_id}_eval",
|
||||
)
|
||||
branch_items = conditions
|
||||
evaluator: DeclarativeActionExecutor = ConditionGroupEvaluatorExecutor(
|
||||
action_def,
|
||||
conditions,
|
||||
id=f"{action_id}_eval",
|
||||
)
|
||||
|
||||
self._executors[evaluator.id] = evaluator
|
||||
|
||||
@@ -701,7 +682,7 @@ class DeclarativeWorkflowBuilder:
|
||||
branch_entries: list[tuple[int, Any]] = [] # (branch_index, entry_executor)
|
||||
branch_exits: list[Any] = [] # All exits that need wiring to successor
|
||||
|
||||
for i, item in enumerate(branch_items):
|
||||
for i, item in enumerate(conditions):
|
||||
branch_actions = item.get("actions", [])
|
||||
# Use branch-specific context
|
||||
case_context = {**branch_context, "parent_id": f"{action_id}_case{i}"}
|
||||
@@ -714,9 +695,7 @@ class DeclarativeWorkflowBuilder:
|
||||
if branch_exit:
|
||||
branch_exits.append(branch_exit)
|
||||
|
||||
# Handle else/default branch
|
||||
# .NET uses "elseActions", interpreter uses "else" or "default"
|
||||
else_actions = action_def.get("elseActions", action_def.get("else", action_def.get("default", [])))
|
||||
else_actions = action_def.get("elseActions", [])
|
||||
default_entry = None
|
||||
default_passthrough = None
|
||||
if else_actions:
|
||||
@@ -734,7 +713,7 @@ class DeclarativeWorkflowBuilder:
|
||||
branch_exits.append(default_passthrough)
|
||||
|
||||
# Wire evaluator to branches with conditions that check ConditionResult.branch_index
|
||||
# For nested If/Switch structures, wire to the evaluator (entry point)
|
||||
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
|
||||
for branch_index, branch_entry in branch_entries:
|
||||
# Capture branch_index in closure properly using a factory function for type inference
|
||||
def make_branch_condition(expected: int) -> Any:
|
||||
@@ -762,8 +741,8 @@ class DeclarativeWorkflowBuilder:
|
||||
condition=lambda msg: isinstance(msg, ConditionResult) and msg.branch_index == ELSE_BRANCH_INDEX,
|
||||
)
|
||||
|
||||
# Create a SwitchStructure to hold all the info needed for wiring
|
||||
class SwitchStructure:
|
||||
# Create a ConditionGroupStructure to hold all the info needed for wiring
|
||||
class ConditionGroupStructure:
|
||||
def __init__(self) -> None:
|
||||
self.id = action_id
|
||||
self.evaluator = evaluator # The entry point for this structure
|
||||
@@ -771,9 +750,9 @@ class DeclarativeWorkflowBuilder:
|
||||
self.default_entry = default_entry
|
||||
self.default_passthrough = default_passthrough
|
||||
self.branch_exits = branch_exits # All exits that need wiring to successor
|
||||
self._is_switch_structure = True
|
||||
self._is_condition_group_structure = True
|
||||
|
||||
return SwitchStructure()
|
||||
return ConditionGroupStructure()
|
||||
|
||||
def _create_foreach_structure(
|
||||
self,
|
||||
@@ -823,7 +802,7 @@ class DeclarativeWorkflowBuilder:
|
||||
body_entry = self._create_executors_for_actions(body_actions, builder, loop_context)
|
||||
|
||||
if body_entry:
|
||||
# For nested If/Switch structures, wire to the evaluator (entry point)
|
||||
# For nested If/ConditionGroup structures, wire to the evaluator (entry point)
|
||||
body_target = self._get_structure_entry(body_entry)
|
||||
|
||||
# Init -> body (when has_next=True)
|
||||
@@ -835,7 +814,7 @@ class DeclarativeWorkflowBuilder:
|
||||
|
||||
# Wire from the LAST body action so the loop only advances after the
|
||||
# whole body completes. _get_branch_exit walks the chain, skips
|
||||
# terminators (Break/Continue), and returns nested If/Switch
|
||||
# terminators (Break/Continue), and returns nested If/ConditionGroup
|
||||
# structures so _get_source_exits can flatten their branch exits.
|
||||
body_exit = self._get_branch_exit(body_entry)
|
||||
if body_exit is not None:
|
||||
@@ -963,8 +942,8 @@ class DeclarativeWorkflowBuilder:
|
||||
"""Add a sequential edge between two executors.
|
||||
|
||||
Handles control flow structures:
|
||||
- If source is a structure (If/Switch), wire from all branch exits
|
||||
- If target is a structure (If/Switch), wire with conditional edges to branches
|
||||
- If source is a structure (If/ConditionGroup), wire from all branch exits
|
||||
- If target is a structure (If/ConditionGroup), wire with conditional edges to branches
|
||||
"""
|
||||
# Get all source exit points
|
||||
source_exits = self._get_source_exits(source)
|
||||
@@ -999,12 +978,12 @@ class DeclarativeWorkflowBuilder:
|
||||
) -> None:
|
||||
"""Wire a single source executor to a target (which may be a structure).
|
||||
|
||||
For If/Switch structures, wire to the evaluator executor. The evaluator
|
||||
For If/ConditionGroup structures, wire to the evaluator executor. The evaluator
|
||||
handles condition evaluation and outputs ConditionResult, which is then
|
||||
routed to the appropriate branch by edges created in _create_*_structure.
|
||||
"""
|
||||
# Check if target is an IfStructure or SwitchStructure (wire to evaluator)
|
||||
if getattr(target, "_is_if_structure", False) or getattr(target, "_is_switch_structure", False):
|
||||
# Check if target is an IfStructure or ConditionGroupStructure (wire to evaluator)
|
||||
if getattr(target, "_is_if_structure", False) or getattr(target, "_is_condition_group_structure", False):
|
||||
# Wire from source to the evaluator - the evaluator then routes to branches
|
||||
builder.add_edge(source=source, target=target.evaluator)
|
||||
|
||||
@@ -1015,7 +994,7 @@ class DeclarativeWorkflowBuilder:
|
||||
def _get_structure_entry(self, entry: Any) -> Any:
|
||||
"""Get the entry point executor for a structure or regular executor.
|
||||
|
||||
For If/Switch structures, returns the evaluator. For regular executors,
|
||||
For If/ConditionGroup structures, returns the evaluator. For regular executors,
|
||||
returns the executor itself.
|
||||
|
||||
Args:
|
||||
@@ -1024,14 +1003,16 @@ class DeclarativeWorkflowBuilder:
|
||||
Returns:
|
||||
The entry point executor
|
||||
"""
|
||||
is_structure = getattr(entry, "_is_if_structure", False) or getattr(entry, "_is_switch_structure", False)
|
||||
is_structure = getattr(entry, "_is_if_structure", False) or getattr(
|
||||
entry, "_is_condition_group_structure", False
|
||||
)
|
||||
return entry.evaluator if is_structure else entry
|
||||
|
||||
def _get_branch_exit(self, branch_entry: Any) -> Any | None:
|
||||
"""Get the exit point of a branch for downstream wiring.
|
||||
|
||||
Returns the last executor (or its ``_exit_executor``) for a linear chain,
|
||||
the nested If/Switch structure itself when the chain ends in one (so
|
||||
the nested If/ConditionGroup structure itself when the chain ends in one (so
|
||||
callers can flatten ``branch_exits`` via :meth:`_get_source_exits`), or
|
||||
``None`` when the branch is empty or ends in a terminator action.
|
||||
"""
|
||||
|
||||
-65
@@ -179,28 +179,6 @@ class SetMultipleVariablesExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class AppendValueExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the AppendValue action."""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the AppendValue action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
path = self._action_def.get("path")
|
||||
value = self._action_def.get("value")
|
||||
|
||||
if path:
|
||||
evaluated_value = state.eval_if_expression(value)
|
||||
state.append(path, evaluated_value)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class ResetVariableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the ResetVariable action."""
|
||||
|
||||
@@ -279,47 +257,6 @@ class SendActivityExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class EmitEventExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the EmitEvent action.
|
||||
|
||||
Emits a custom event to the workflow event stream.
|
||||
|
||||
Supports two schema formats:
|
||||
1. Graph mode: eventName, eventValue
|
||||
2. Interpreter mode: event.name, event.data
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, dict[str, Any]],
|
||||
) -> None:
|
||||
"""Handle the EmitEvent action."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
# Support both schema formats:
|
||||
# - Graph mode: eventName, eventValue
|
||||
# - Interpreter mode: event.name, event.data
|
||||
event_def = self._action_def.get("event", {})
|
||||
event_name = self._action_def.get("eventName") or event_def.get("name", "")
|
||||
event_value = self._action_def.get("eventValue")
|
||||
if event_value is None:
|
||||
event_value = event_def.get("data")
|
||||
|
||||
if event_name:
|
||||
evaluated_name = state.eval_if_expression(event_name)
|
||||
evaluated_value = state.eval_if_expression(event_value)
|
||||
|
||||
event_data = {
|
||||
"eventName": evaluated_name,
|
||||
"eventValue": evaluated_value,
|
||||
}
|
||||
await ctx.yield_output(event_data)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class EditTableExecutor(DeclarativeActionExecutor):
|
||||
"""Executor for the EditTable action.
|
||||
|
||||
@@ -628,11 +565,9 @@ BASIC_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"SetVariable": SetVariableExecutor,
|
||||
"SetTextVariable": SetTextVariableExecutor,
|
||||
"SetMultipleVariables": SetMultipleVariablesExecutor,
|
||||
"AppendValue": AppendValueExecutor,
|
||||
"ResetVariable": ResetVariableExecutor,
|
||||
"ClearAllVariables": ClearAllVariablesExecutor,
|
||||
"SendActivity": SendActivityExecutor,
|
||||
"EmitEvent": EmitEventExecutor,
|
||||
"ParseValue": ParseValueExecutor,
|
||||
"EditTable": EditTableExecutor,
|
||||
"EditTableV2": EditTableV2Executor,
|
||||
|
||||
+15
-110
@@ -3,7 +3,7 @@
|
||||
"""Control flow executors for the graph-based declarative workflow system.
|
||||
|
||||
Control flow in the graph-based system is handled differently than the interpreter:
|
||||
- If/Switch: Condition evaluation happens in a dedicated evaluator executor that
|
||||
- If/ConditionGroup: Condition evaluation happens in a dedicated evaluator executor that
|
||||
returns a ConditionResult with the first-matching branch index. Edge conditions
|
||||
then check the branch_index to route to the correct branch. This ensures only
|
||||
one branch executes (first-match semantics), matching the interpreter behavior.
|
||||
@@ -39,7 +39,7 @@ ELSE_BRANCH_INDEX = -1
|
||||
|
||||
|
||||
class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates conditions for ConditionGroup/Switch and outputs the first-matching branch.
|
||||
"""Evaluates conditions for ConditionGroup and outputs the first-matching branch.
|
||||
|
||||
This executor implements first-match semantics by evaluating conditions sequentially
|
||||
and outputting a ConditionResult with the index of the first matching branch.
|
||||
@@ -59,7 +59,7 @@ class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Initialize the condition evaluator.
|
||||
|
||||
Args:
|
||||
action_def: The ConditionGroup/Switch action definition
|
||||
action_def: The ConditionGroup action definition
|
||||
conditions: List of condition items, each with 'condition' and optional 'id'
|
||||
id: Optional executor ID
|
||||
"""
|
||||
@@ -99,71 +99,6 @@ class ConditionGroupEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
||||
|
||||
|
||||
class SwitchEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates a Switch action by matching a value against cases.
|
||||
|
||||
The Switch action uses a different schema than ConditionGroup:
|
||||
- value: expression to evaluate once
|
||||
- cases: list of {match: value_to_match, actions: [...]}
|
||||
- default: default actions if no case matches
|
||||
|
||||
This evaluator evaluates the value expression once, then compares it
|
||||
against each case's match value sequentially. First match wins.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
action_def: dict[str, Any],
|
||||
cases: list[dict[str, Any]],
|
||||
*,
|
||||
id: str | None = None,
|
||||
):
|
||||
"""Initialize the switch evaluator.
|
||||
|
||||
Args:
|
||||
action_def: The Switch action definition (contains 'value' expression)
|
||||
cases: List of case items, each with 'match' and optional 'actions'
|
||||
id: Optional executor ID
|
||||
"""
|
||||
super().__init__(action_def, id=id)
|
||||
self._cases = cases
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ConditionResult],
|
||||
) -> None:
|
||||
"""Evaluate the switch value and find the first matching case."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
value_expr = self._action_def.get("value")
|
||||
if not value_expr:
|
||||
# No value to switch on - use default
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
||||
return
|
||||
|
||||
# Evaluate the switch value once
|
||||
switch_value = state.eval_if_expression(value_expr)
|
||||
|
||||
# Compare against each case's match value
|
||||
for index, case_item in enumerate(self._cases):
|
||||
match_expr = case_item.get("match")
|
||||
if match_expr is None:
|
||||
continue
|
||||
|
||||
# Evaluate the match value
|
||||
match_value = state.eval_if_expression(match_expr)
|
||||
|
||||
if switch_value == match_value:
|
||||
# Found matching case
|
||||
await ctx.send_message(ConditionResult(matched=True, branch_index=index, value=switch_value))
|
||||
return
|
||||
|
||||
# No case matched - use default branch
|
||||
await ctx.send_message(ConditionResult(matched=False, branch_index=ELSE_BRANCH_INDEX))
|
||||
|
||||
|
||||
class IfConditionEvaluatorExecutor(DeclarativeActionExecutor):
|
||||
"""Evaluates a single If condition and outputs a ConditionResult.
|
||||
|
||||
@@ -221,12 +156,7 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
|
||||
"""Initialize the loop and check for first item."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
# Support multiple schema formats:
|
||||
# - Graph mode: itemsSource, items
|
||||
# - Interpreter mode: source
|
||||
items_expr = (
|
||||
self._action_def.get("itemsSource") or self._action_def.get("items") or self._action_def.get("source")
|
||||
)
|
||||
items_expr = self._action_def.get("source")
|
||||
items_raw: Any = state.eval_if_expression(items_expr) or []
|
||||
|
||||
items: list[Any]
|
||||
@@ -244,25 +174,12 @@ class ForeachInitExecutor(DeclarativeActionExecutor):
|
||||
}
|
||||
state.set_state_data(state_data)
|
||||
|
||||
# Check if we have items
|
||||
if items:
|
||||
# Set the iteration variable
|
||||
# Support multiple schema formats:
|
||||
# - Graph mode: iteratorVariable, item (default "Local.item")
|
||||
# - Interpreter mode: itemName (default "item", stored in Local scope)
|
||||
item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item")
|
||||
if not item_var:
|
||||
# Interpreter mode: itemName defaults to "item", store in Local scope
|
||||
item_name = self._action_def.get("itemName", "item")
|
||||
item_var = f"Local.{item_name}"
|
||||
|
||||
# Support multiple schema formats for index:
|
||||
# - Graph mode: indexVariable, index
|
||||
# - Interpreter mode: indexName (default "index", stored in Local scope)
|
||||
index_var = self._action_def.get("indexVariable") or self._action_def.get("index")
|
||||
if not index_var and "indexName" in self._action_def:
|
||||
index_name = self._action_def.get("indexName", "index")
|
||||
index_var = f"Local.{index_name}"
|
||||
# Bind the current item and (when requested) the index under the Local scope.
|
||||
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
||||
index_var = (
|
||||
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
||||
)
|
||||
|
||||
state.set(item_var, items[0])
|
||||
if index_var:
|
||||
@@ -325,23 +242,11 @@ class ForeachNextExecutor(DeclarativeActionExecutor):
|
||||
loop_state["index"] = current_index
|
||||
state.set_state_data(state_data)
|
||||
|
||||
# Set the iteration variable
|
||||
# Support multiple schema formats:
|
||||
# - Graph mode: iteratorVariable, item (default "Local.item")
|
||||
# - Interpreter mode: itemName (default "item", stored in Local scope)
|
||||
item_var = self._action_def.get("iteratorVariable") or self._action_def.get("item")
|
||||
if not item_var:
|
||||
# Interpreter mode: itemName defaults to "item", store in Local scope
|
||||
item_name = self._action_def.get("itemName", "item")
|
||||
item_var = f"Local.{item_name}"
|
||||
|
||||
# Support multiple schema formats for index:
|
||||
# - Graph mode: indexVariable, index
|
||||
# - Interpreter mode: indexName (default "index", stored in Local scope)
|
||||
index_var = self._action_def.get("indexVariable") or self._action_def.get("index")
|
||||
if not index_var and "indexName" in self._action_def:
|
||||
index_name = self._action_def.get("indexName", "index")
|
||||
index_var = f"Local.{index_name}"
|
||||
# Rebind the current item and (when requested) the index under the Local scope.
|
||||
item_var = f"Local.{self._action_def.get('itemName', 'item')}"
|
||||
index_var = (
|
||||
f"Local.{self._action_def.get('indexName', 'index')}" if "indexName" in self._action_def else None
|
||||
)
|
||||
|
||||
state.set(item_var, items[current_index])
|
||||
if index_var:
|
||||
@@ -486,7 +391,7 @@ class EndConversationExecutor(DeclarativeActionExecutor):
|
||||
class JoinExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that joins multiple branches back together.
|
||||
|
||||
Used after If/Switch to merge control flow back to a single path.
|
||||
Used after If/ConditionGroup to merge control flow back to a single path.
|
||||
Also used as passthrough nodes for else/default branches.
|
||||
"""
|
||||
|
||||
|
||||
+47
-148
@@ -2,14 +2,14 @@
|
||||
|
||||
"""External input executors for declarative workflows.
|
||||
|
||||
These executors handle interactions that require external input (user questions,
|
||||
confirmations, etc.), using the request_info pattern to pause the workflow and
|
||||
wait for responses.
|
||||
These executors handle interactions that require external input (user questions
|
||||
and external integrations), using the request_info pattern to pause the workflow
|
||||
and wait for responses.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, cast
|
||||
|
||||
from agent_framework import (
|
||||
WorkflowContext,
|
||||
@@ -23,18 +23,49 @@ from ._declarative_base import (
|
||||
)
|
||||
|
||||
|
||||
def _get_prompt_text(action_def: dict[str, Any], primary_key: str, fallback_key: str) -> Any:
|
||||
"""Return the prompt text from an action definition.
|
||||
|
||||
Accepts a nested ``{primary_key: {"text": ...}}`` mapping, a bare
|
||||
string under ``primary_key``, or a top-level ``fallback_key`` value.
|
||||
"""
|
||||
match action_def.get(primary_key):
|
||||
case {"text": text}:
|
||||
return text
|
||||
case str() as text:
|
||||
return text
|
||||
case _:
|
||||
return action_def.get(fallback_key, "")
|
||||
|
||||
|
||||
def _get_output_path(action_def: dict[str, Any], default: str) -> str:
|
||||
"""Return the state path where the action result should be written.
|
||||
|
||||
Looks at ``variable``, then ``output.property``, then top-level
|
||||
``property``, falling back to ``default``.
|
||||
"""
|
||||
output = action_def.get("output")
|
||||
nested = cast(dict[str, Any], output).get("property") if isinstance(output, dict) else None
|
||||
return action_def.get("variable") or nested or action_def.get("property") or default
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExternalInputRequest:
|
||||
"""Request for external input (triggers workflow pause).
|
||||
|
||||
Aligns with .NET ExternalInputRequest pattern. Used by Question, Confirmation,
|
||||
WaitForInput, and RequestExternalInput executors to signal that user input is
|
||||
needed. The workflow will pause via request_info and wait for an ExternalInputResponse.
|
||||
Aligns with .NET ExternalInputRequest pattern. Used by Question and
|
||||
RequestExternalInput executors to signal that user input is needed.
|
||||
The workflow will pause via request_info and wait for an ExternalInputResponse.
|
||||
|
||||
Attributes:
|
||||
request_id: Unique identifier for this request.
|
||||
message: The prompt or question to display to the user.
|
||||
request_type: Type of input requested (question, confirmation, user_input, external).
|
||||
request_type: A free-form discriminator describing the kind of input
|
||||
being requested. ``QuestionExecutor`` emits ``"question"`` and
|
||||
``RequestExternalInputExecutor`` defaults to ``"external"``; callers
|
||||
may supply any other string via the ``requestType`` field on a
|
||||
``RequestExternalInput`` action (e.g. ``"approval"``) and it is
|
||||
propagated unchanged.
|
||||
metadata: Additional context (choices, output_property, timeout, etc.).
|
||||
"""
|
||||
|
||||
@@ -75,15 +106,12 @@ class QuestionExecutor(DeclarativeActionExecutor):
|
||||
"""Ask the question and wait for a response."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
question_text = self._action_def.get("text") or self._action_def.get("question", "")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.answer"
|
||||
)
|
||||
question_text = _get_prompt_text(self._action_def, primary_key="question", fallback_key="text")
|
||||
output_property = _get_output_path(self._action_def, default="Local.answer")
|
||||
default_value = self._action_def.get("default", self._action_def.get("defaultValue"))
|
||||
choices = self._action_def.get("choices", [])
|
||||
default_value = self._action_def.get("defaultValue")
|
||||
allow_free_text = self._action_def.get("allowFreeText", True)
|
||||
|
||||
# Evaluate the question text if it's an expression
|
||||
evaluated_question = state.eval_if_expression(question_text)
|
||||
|
||||
# Build choices metadata
|
||||
@@ -139,133 +167,6 @@ class QuestionExecutor(DeclarativeActionExecutor):
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class ConfirmationExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that asks for a yes/no confirmation.
|
||||
|
||||
A specialized version of Question that expects a boolean response.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Ask for confirmation."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
message = self._action_def.get("text") or self._action_def.get("message", "")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.confirmed"
|
||||
)
|
||||
yes_label = self._action_def.get("yesLabel", "Yes")
|
||||
no_label = self._action_def.get("noLabel", "No")
|
||||
default_value = self._action_def.get("defaultValue", False)
|
||||
|
||||
# Evaluate the message if it's an expression
|
||||
evaluated_message = state.eval_if_expression(message)
|
||||
|
||||
# Request confirmation - workflow pauses here
|
||||
await ctx.request_info(
|
||||
ExternalInputRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
message=str(evaluated_message),
|
||||
request_type="confirmation",
|
||||
metadata={
|
||||
"output_property": output_property,
|
||||
"yes_label": yes_label,
|
||||
"no_label": no_label,
|
||||
"default_value": default_value,
|
||||
},
|
||||
),
|
||||
ExternalInputResponse,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: ExternalInputRequest,
|
||||
response: ExternalInputResponse,
|
||||
ctx: WorkflowContext[ActionComplete],
|
||||
) -> None:
|
||||
"""Handle the user's confirmation response."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
output_property = original_request.metadata.get("output_property", "Local.confirmed")
|
||||
|
||||
# Convert response to boolean
|
||||
if response.value is not None:
|
||||
confirmed = bool(response.value)
|
||||
else:
|
||||
# Interpret common affirmative responses
|
||||
user_input_lower = response.user_input.lower().strip()
|
||||
confirmed = user_input_lower in ("yes", "y", "true", "1", "confirm", "ok")
|
||||
|
||||
if output_property:
|
||||
state.set(output_property, confirmed)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class WaitForInputExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that waits for user input during a conversation.
|
||||
|
||||
Used when the workflow needs to pause and wait for the next user message
|
||||
in a conversational flow.
|
||||
"""
|
||||
|
||||
@handler
|
||||
async def handle_action(
|
||||
self,
|
||||
trigger: Any,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Wait for user input."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
prompt = self._action_def.get("prompt")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.input"
|
||||
)
|
||||
timeout_seconds = self._action_def.get("timeout")
|
||||
|
||||
# Emit prompt if specified
|
||||
if prompt:
|
||||
evaluated_prompt = state.eval_if_expression(prompt)
|
||||
await ctx.yield_output(str(evaluated_prompt))
|
||||
|
||||
# Request user input - workflow pauses here
|
||||
await ctx.request_info(
|
||||
ExternalInputRequest(
|
||||
request_id=str(uuid.uuid4()),
|
||||
message=str(prompt) if prompt else "Waiting for input...",
|
||||
request_type="user_input",
|
||||
metadata={
|
||||
"output_property": output_property,
|
||||
"timeout_seconds": timeout_seconds,
|
||||
},
|
||||
),
|
||||
ExternalInputResponse,
|
||||
)
|
||||
|
||||
@response_handler
|
||||
async def handle_response(
|
||||
self,
|
||||
original_request: ExternalInputRequest,
|
||||
response: ExternalInputResponse,
|
||||
ctx: WorkflowContext[ActionComplete, str],
|
||||
) -> None:
|
||||
"""Handle the user's input."""
|
||||
state = self._get_state(ctx.state)
|
||||
|
||||
output_property = original_request.metadata.get("output_property", "Local.input")
|
||||
|
||||
if output_property:
|
||||
state.set(output_property, response.user_input)
|
||||
|
||||
await ctx.send_message(ActionComplete())
|
||||
|
||||
|
||||
class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
"""Executor that requests external input/approval.
|
||||
|
||||
@@ -282,16 +183,15 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
"""Request external input."""
|
||||
state = await self._ensure_state_initialized(ctx, trigger)
|
||||
|
||||
message = _get_prompt_text(self._action_def, primary_key="prompt", fallback_key="message")
|
||||
output_property = _get_output_path(self._action_def, default="Local.externalInput")
|
||||
default_value = self._action_def.get("default")
|
||||
|
||||
request_type = self._action_def.get("requestType", "external")
|
||||
message = self._action_def.get("message", "")
|
||||
output_property = self._action_def.get("output", {}).get("property") or self._action_def.get(
|
||||
"property", "Local.externalInput"
|
||||
)
|
||||
timeout_seconds = self._action_def.get("timeout")
|
||||
required_fields = self._action_def.get("requiredFields", [])
|
||||
metadata = self._action_def.get("metadata", {})
|
||||
|
||||
# Evaluate the message if it's an expression
|
||||
evaluated_message = state.eval_if_expression(message)
|
||||
|
||||
# Build request metadata
|
||||
@@ -299,6 +199,7 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
**metadata,
|
||||
"output_property": output_property,
|
||||
"required_fields": required_fields,
|
||||
"default_value": default_value,
|
||||
}
|
||||
|
||||
if timeout_seconds:
|
||||
@@ -338,7 +239,5 @@ class RequestExternalInputExecutor(DeclarativeActionExecutor):
|
||||
# Mapping of external input action kinds to executor classes
|
||||
EXTERNAL_INPUT_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
|
||||
"Question": QuestionExecutor,
|
||||
"Confirmation": ConfirmationExecutor,
|
||||
"WaitForInput": WaitForInputExecutor,
|
||||
"RequestExternalInput": RequestExternalInputExecutor,
|
||||
}
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"httpx>=0.27,<1",
|
||||
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
|
||||
"pyyaml>=6.0,<7.0",
|
||||
|
||||
@@ -515,27 +515,6 @@ class TestBasicExecutorsCoverage:
|
||||
assert state.get("Local.b") == 2
|
||||
assert state.get("Local.c") == 3
|
||||
|
||||
async def test_append_value_executor(self, mock_context, mock_state):
|
||||
"""Test AppendValueExecutor."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
AppendValueExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.items", ["a"])
|
||||
|
||||
action_def = {
|
||||
"kind": "AppendValue",
|
||||
"path": "Local.items",
|
||||
"value": "b",
|
||||
}
|
||||
executor = AppendValueExecutor(action_def)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
result = state.get("Local.items")
|
||||
assert result == ["a", "b"]
|
||||
|
||||
async def test_reset_variable_executor(self, mock_context, mock_state):
|
||||
"""Test ResetVariableExecutor."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
@@ -632,52 +611,6 @@ class TestBasicExecutorsCoverage:
|
||||
|
||||
mock_context.yield_output.assert_called_once_with("Dynamic message")
|
||||
|
||||
async def test_emit_event_executor_graph_mode(self, mock_context, mock_state):
|
||||
"""Test EmitEventExecutor with graph-mode schema (eventName/eventValue)."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
EmitEventExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "EmitEvent",
|
||||
"eventName": "myEvent",
|
||||
"eventValue": {"key": "value"},
|
||||
}
|
||||
executor = EmitEventExecutor(action_def)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
mock_context.yield_output.assert_called_once()
|
||||
event_data = mock_context.yield_output.call_args[0][0]
|
||||
assert event_data["eventName"] == "myEvent"
|
||||
assert event_data["eventValue"] == {"key": "value"}
|
||||
|
||||
async def test_emit_event_executor_interpreter_mode(self, mock_context, mock_state):
|
||||
"""Test EmitEventExecutor with interpreter-mode schema (event.name/event.data)."""
|
||||
from agent_framework_declarative._workflows._executors_basic import (
|
||||
EmitEventExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "EmitEvent",
|
||||
"event": {
|
||||
"name": "interpreterEvent",
|
||||
"data": {"payload": "test"},
|
||||
},
|
||||
}
|
||||
executor = EmitEventExecutor(action_def)
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
mock_context.yield_output.assert_called_once()
|
||||
event_data = mock_context.yield_output.call_args[0][0]
|
||||
assert event_data["eventName"] == "interpreterEvent"
|
||||
assert event_data["eventValue"] == {"payload": "test"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent Executors Tests - Covering _executors_agents.py gaps
|
||||
@@ -1155,8 +1088,8 @@ class TestControlFlowCoverage:
|
||||
"""Tests for control flow executors covering uncovered code paths."""
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_foreach_with_source_alias(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with 'source' alias (interpreter mode)."""
|
||||
async def test_foreach_with_source(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with the 'source' field."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
ForeachInitExecutor,
|
||||
)
|
||||
@@ -1205,8 +1138,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="foreach_init")
|
||||
|
||||
@@ -1217,81 +1150,6 @@ class TestControlFlowCoverage:
|
||||
assert msg.current_index == 1
|
||||
assert msg.current_item == "b"
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_switch_evaluator_with_value_cases(self, mock_context, mock_state):
|
||||
"""Test SwitchEvaluatorExecutor with value/cases schema."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.status", "pending")
|
||||
|
||||
action_def = {
|
||||
"kind": "Switch",
|
||||
"value": "=Local.status",
|
||||
}
|
||||
cases = [
|
||||
{"match": "active"},
|
||||
{"match": "pending"},
|
||||
]
|
||||
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
msg = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(msg, ConditionResult)
|
||||
assert msg.matched is True
|
||||
assert msg.branch_index == 1 # Second case matched
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_switch_evaluator_default_case(self, mock_context, mock_state):
|
||||
"""Test SwitchEvaluatorExecutor falls through to default."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.status", "unknown")
|
||||
|
||||
action_def = {
|
||||
"kind": "Switch",
|
||||
"value": "=Local.status",
|
||||
}
|
||||
cases = [
|
||||
{"match": "active"},
|
||||
{"match": "pending"},
|
||||
]
|
||||
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
msg = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(msg, ConditionResult)
|
||||
assert msg.matched is False
|
||||
assert msg.branch_index == -1 # Default case
|
||||
|
||||
async def test_switch_evaluator_no_value(self, mock_context, mock_state):
|
||||
"""Test SwitchEvaluatorExecutor with no value defaults to else."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
SwitchEvaluatorExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {"kind": "Switch"} # No value
|
||||
cases = [{"match": "x"}]
|
||||
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
msg = mock_context.send_message.call_args[0][0]
|
||||
assert isinstance(msg, ConditionResult)
|
||||
assert msg.branch_index == -1
|
||||
|
||||
async def test_join_executor_accepts_condition_result(self, mock_context, mock_state):
|
||||
"""Test JoinExecutor accepts ConditionResult as trigger."""
|
||||
from agent_framework_declarative._workflows._executors_control_flow import (
|
||||
@@ -1357,8 +1215,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="missing_loop")
|
||||
|
||||
@@ -1391,8 +1249,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
|
||||
|
||||
@@ -1425,8 +1283,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
|
||||
|
||||
@@ -1459,8 +1317,8 @@ class TestControlFlowCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.data",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.data",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
|
||||
|
||||
@@ -1719,60 +1577,6 @@ class TestDeclarativeActionExecutorBase:
|
||||
class TestHumanInputExecutorsCoverage:
|
||||
"""Tests for human input executors covering uncovered code paths."""
|
||||
|
||||
async def test_wait_for_input_executor_with_prompt(self, mock_context, mock_state):
|
||||
"""Test WaitForInputExecutor with prompt."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
WaitForInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "WaitForInput",
|
||||
"prompt": "Please enter your name:",
|
||||
"property": "Local.userName",
|
||||
"timeout": 30,
|
||||
}
|
||||
executor = WaitForInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Should yield prompt first, then call request_info
|
||||
assert mock_context.yield_output.call_count == 1
|
||||
assert mock_context.yield_output.call_args_list[0][0][0] == "Please enter your name:"
|
||||
# request_info call for ExternalInputRequest
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.request_type == "user_input"
|
||||
|
||||
async def test_wait_for_input_executor_no_prompt(self, mock_context, mock_state):
|
||||
"""Test WaitForInputExecutor without prompt."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
WaitForInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "WaitForInput",
|
||||
"property": "Local.input",
|
||||
}
|
||||
executor = WaitForInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Should not yield output (no prompt), just call request_info
|
||||
assert mock_context.yield_output.call_count == 0
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.request_type == "user_input"
|
||||
|
||||
async def test_request_external_input_executor(self, mock_context, mock_state):
|
||||
"""Test RequestExternalInputExecutor."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
@@ -1786,8 +1590,8 @@ class TestHumanInputExecutorsCoverage:
|
||||
action_def = {
|
||||
"kind": "RequestExternalInput",
|
||||
"requestType": "approval",
|
||||
"message": "Please approve this request",
|
||||
"property": "Local.approvalResult",
|
||||
"prompt": {"text": "Please approve this request"},
|
||||
"variable": "Local.approvalResult",
|
||||
"timeout": 3600,
|
||||
"requiredFields": ["approver", "notes"],
|
||||
"metadata": {"priority": "high"},
|
||||
@@ -1817,8 +1621,8 @@ class TestHumanInputExecutorsCoverage:
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"question": "Select an option:",
|
||||
"property": "Local.selection",
|
||||
"question": {"text": "Select an option:"},
|
||||
"variable": "Local.selection",
|
||||
"choices": [
|
||||
{"value": "a", "label": "Option A"},
|
||||
{"value": "b"}, # No label, should use value
|
||||
@@ -1841,6 +1645,111 @@ class TestHumanInputExecutorsCoverage:
|
||||
assert choices[2] == {"value": "c", "label": "c"}
|
||||
assert request.metadata["allow_free_text"] is False
|
||||
|
||||
async def test_question_executor_reads_nested_question_text(self, mock_context, mock_state):
|
||||
"""QuestionExecutor reads ``question.text``/``variable``/``default`` into the request."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
QuestionExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"question": {"text": "What is your name?"},
|
||||
"variable": "Local.userName",
|
||||
"default": "Guest",
|
||||
}
|
||||
executor = QuestionExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
# Canonical text comes through as a plain string, not the stringified dict.
|
||||
assert request.message == "What is your name?"
|
||||
# Canonical `variable` overrides the legacy default of Local.answer.
|
||||
assert request.metadata["output_property"] == "Local.userName"
|
||||
assert request.metadata["default_value"] == "Guest"
|
||||
|
||||
async def test_question_executor_reads_top_level_alternates(self, mock_context, mock_state):
|
||||
"""Top-level ``text``/``property``/``defaultValue`` are accepted as alternates."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
QuestionExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"text": "Legacy question",
|
||||
"property": "Local.legacyAnswer",
|
||||
"defaultValue": "legacy-default",
|
||||
}
|
||||
executor = QuestionExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.message == "Legacy question"
|
||||
assert request.metadata["output_property"] == "Local.legacyAnswer"
|
||||
assert request.metadata["default_value"] == "legacy-default"
|
||||
|
||||
async def test_request_external_input_reads_nested_prompt_text(self, mock_context, mock_state):
|
||||
"""RequestExternalInputExecutor reads ``prompt.text``/``variable``/``default``."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
RequestExternalInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "RequestExternalInput",
|
||||
"prompt": {"text": "Please approve"},
|
||||
"variable": "Local.approved",
|
||||
"default": "pending",
|
||||
}
|
||||
executor = RequestExternalInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.message == "Please approve"
|
||||
assert request.metadata["output_property"] == "Local.approved"
|
||||
assert request.metadata["default_value"] == "pending"
|
||||
|
||||
async def test_request_external_input_reads_top_level_alternates(self, mock_context, mock_state):
|
||||
"""Top-level ``message``/``property`` are accepted as alternates."""
|
||||
from agent_framework_declarative._workflows._executors_external_input import (
|
||||
ExternalInputRequest,
|
||||
RequestExternalInputExecutor,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "RequestExternalInput",
|
||||
"message": "Legacy message",
|
||||
"property": "Local.legacyApproval",
|
||||
}
|
||||
executor = RequestExternalInputExecutor(action_def)
|
||||
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.message == "Legacy message"
|
||||
assert request.metadata["output_property"] == "Local.legacyApproval"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Additional Agent Executor Tests - External Loop Coverage
|
||||
@@ -2122,7 +2031,7 @@ class TestBuilderControlFlowCreation:
|
||||
|
||||
# Create a mock loop_next executor
|
||||
loop_next = ForeachNextExecutor(
|
||||
{"kind": "Foreach", "itemsProperty": "items"},
|
||||
{"kind": "Foreach", "source": "=Local.items"},
|
||||
init_executor_id="foreach_init",
|
||||
id="foreach_next",
|
||||
)
|
||||
@@ -2181,7 +2090,7 @@ class TestBuilderControlFlowCreation:
|
||||
|
||||
# Create a mock loop_next executor
|
||||
loop_next = ForeachNextExecutor(
|
||||
{"kind": "Foreach", "itemsProperty": "items"},
|
||||
{"kind": "Foreach", "source": "=Local.items"},
|
||||
init_executor_id="foreach_init",
|
||||
id="foreach_next",
|
||||
)
|
||||
@@ -2235,8 +2144,8 @@ class TestBuilderEdgeWiring:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
|
||||
@@ -2266,8 +2175,8 @@ class TestBuilderEdgeWiring:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{"kind": "BreakLoop", "id": "stop"},
|
||||
@@ -2292,8 +2201,8 @@ class TestBuilderEdgeWiring:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
|
||||
{
|
||||
@@ -2704,7 +2613,7 @@ class TestBuilderValidation:
|
||||
assert workflow is not None
|
||||
|
||||
def test_missing_required_field_foreach(self):
|
||||
"""Test Foreach without items raises error."""
|
||||
"""Test Foreach without source raises error."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
@@ -2717,7 +2626,7 @@ class TestBuilderValidation:
|
||||
builder.build()
|
||||
|
||||
assert "Foreach" in str(exc_info.value)
|
||||
assert "items" in str(exc_info.value)
|
||||
assert "source" in str(exc_info.value)
|
||||
|
||||
def test_self_referencing_goto_raises_error(self):
|
||||
"""Test that a goto referencing itself is detected."""
|
||||
@@ -2725,7 +2634,7 @@ class TestBuilderValidation:
|
||||
|
||||
yaml_def = {
|
||||
"name": "test_workflow",
|
||||
"actions": [{"id": "loop", "kind": "Goto", "target": "loop"}],
|
||||
"actions": [{"id": "loop", "kind": "GotoAction", "actionId": "loop"}],
|
||||
}
|
||||
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
@@ -2757,23 +2666,22 @@ class TestBuilderValidation:
|
||||
workflow = builder.build()
|
||||
assert workflow is not None
|
||||
|
||||
def test_validation_in_switch_branches(self):
|
||||
"""Test validation catches issues in Switch branches."""
|
||||
def test_validation_in_condition_group_branches(self):
|
||||
"""Test validation catches issues in ConditionGroup branches."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "test_workflow",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Switch",
|
||||
"value": "=Local.choice",
|
||||
"cases": [
|
||||
"kind": "ConditionGroup",
|
||||
"conditions": [
|
||||
{
|
||||
"match": "a",
|
||||
"condition": '=Local.choice = "a"',
|
||||
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "A"}}],
|
||||
},
|
||||
{
|
||||
"match": "b",
|
||||
"condition": '=Local.choice = "b"',
|
||||
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "B"}}],
|
||||
},
|
||||
],
|
||||
@@ -2796,7 +2704,7 @@ class TestBuilderValidation:
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"items": "=Local.items",
|
||||
"source": "=Local.items",
|
||||
"actions": [{"kind": "SendActivity"}], # Missing 'activity'
|
||||
}
|
||||
],
|
||||
|
||||
@@ -207,16 +207,16 @@ class TestDeclarativeActionExecutor:
|
||||
# Note: ConditionEvaluatorExecutor tests removed - conditions are now evaluated on edges
|
||||
|
||||
@_requires_powerfx
|
||||
async def test_foreach_init_with_items(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with items."""
|
||||
async def test_foreach_init_with_source(self, mock_context, mock_state):
|
||||
"""Test ForeachInitExecutor with the 'source' field."""
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
state.set("Local.items", ["a", "b", "c"])
|
||||
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachInitExecutor(action_def)
|
||||
|
||||
@@ -240,8 +240,8 @@ class TestDeclarativeActionExecutor:
|
||||
# Use a literal empty list - no expression evaluation needed
|
||||
action_def = {
|
||||
"kind": "Foreach",
|
||||
"itemsSource": [], # Direct empty list, not an expression
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": [], # Direct empty list, not an expression
|
||||
"itemName": "item",
|
||||
}
|
||||
executor = ForeachInitExecutor(action_def)
|
||||
|
||||
@@ -264,7 +264,6 @@ class TestDeclarativeWorkflowBuilder:
|
||||
"SetValue",
|
||||
"SetVariable",
|
||||
"SendActivity",
|
||||
"EmitEvent",
|
||||
"EndWorkflow",
|
||||
"InvokeAzureAgent",
|
||||
"Question",
|
||||
@@ -335,8 +334,8 @@ class TestDeclarativeWorkflowBuilder:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "process_items",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
|
||||
],
|
||||
@@ -353,13 +352,13 @@ class TestDeclarativeWorkflowBuilder:
|
||||
assert "process_items_exit" in builder._executors
|
||||
assert "show_item" in builder._executors
|
||||
|
||||
def test_build_workflow_with_switch(self):
|
||||
"""Test building a workflow with Switch control flow."""
|
||||
def test_build_workflow_with_condition_group(self):
|
||||
"""Test building a workflow with ConditionGroup control flow."""
|
||||
yaml_def = {
|
||||
"name": "switch_workflow",
|
||||
"name": "condition_group_workflow",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Switch",
|
||||
"kind": "ConditionGroup",
|
||||
"id": "check_status",
|
||||
"conditions": [
|
||||
{
|
||||
@@ -375,7 +374,7 @@ class TestDeclarativeWorkflowBuilder:
|
||||
],
|
||||
},
|
||||
],
|
||||
"else": [
|
||||
"elseActions": [
|
||||
{"kind": "SendActivity", "id": "say_unknown", "activity": {"text": "Unknown"}},
|
||||
],
|
||||
},
|
||||
@@ -385,12 +384,12 @@ class TestDeclarativeWorkflowBuilder:
|
||||
workflow = builder.build()
|
||||
|
||||
assert workflow is not None
|
||||
# Verify switch executors were created
|
||||
# Verify ConditionGroup branch executors were created
|
||||
# Note: No join executors - branches wire directly to successor
|
||||
assert "say_active" in builder._executors
|
||||
assert "say_pending" in builder._executors
|
||||
assert "say_unknown" in builder._executors
|
||||
# Entry node is created when Switch is first action
|
||||
# Entry node is created when ConditionGroup is first action
|
||||
assert "_workflow_entry" in builder._executors
|
||||
|
||||
|
||||
@@ -493,9 +492,9 @@ class TestHumanInputExecutors:
|
||||
|
||||
action_def = {
|
||||
"kind": "Question",
|
||||
"text": "What is your name?",
|
||||
"property": "Local.name",
|
||||
"defaultValue": "Anonymous",
|
||||
"question": {"text": "What is your name?"},
|
||||
"variable": "Local.name",
|
||||
"default": "Anonymous",
|
||||
}
|
||||
executor = QuestionExecutor(action_def)
|
||||
|
||||
@@ -509,36 +508,6 @@ class TestHumanInputExecutors:
|
||||
assert request.request_type == "question"
|
||||
assert "What is your name?" in request.message
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_confirmation_executor(self, mock_context, mock_state):
|
||||
"""Test ConfirmationExecutor."""
|
||||
from agent_framework_declarative._workflows import (
|
||||
ConfirmationExecutor,
|
||||
ExternalInputRequest,
|
||||
)
|
||||
|
||||
state = DeclarativeWorkflowState(mock_state)
|
||||
state.initialize()
|
||||
|
||||
action_def = {
|
||||
"kind": "Confirmation",
|
||||
"text": "Do you want to continue?",
|
||||
"property": "Local.confirmed",
|
||||
"yesLabel": "Yes, continue",
|
||||
"noLabel": "No, stop",
|
||||
}
|
||||
executor = ConfirmationExecutor(action_def)
|
||||
|
||||
# Execute
|
||||
await executor.handle_action(ActionTrigger(), mock_context)
|
||||
|
||||
# Verify request_info was called with ExternalInputRequest
|
||||
mock_context.request_info.assert_called_once()
|
||||
request = mock_context.request_info.call_args[0][0]
|
||||
assert isinstance(request, ExternalInputRequest)
|
||||
assert request.request_type == "confirmation"
|
||||
assert "continue" in request.message.lower()
|
||||
|
||||
|
||||
@_requires_powerfx
|
||||
class TestParseValueExecutor:
|
||||
|
||||
@@ -100,8 +100,8 @@ class TestGraphBasedWorkflowExecution:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "process_items",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
|
||||
],
|
||||
@@ -131,8 +131,8 @@ class TestGraphBasedWorkflowExecution:
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"id": "loop",
|
||||
"itemsSource": "=Local.items",
|
||||
"iteratorVariable": "Local.item",
|
||||
"source": "=Local.items",
|
||||
"itemName": "item",
|
||||
"actions": [
|
||||
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
|
||||
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
|
||||
@@ -151,14 +151,14 @@ class TestGraphBasedWorkflowExecution:
|
||||
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_workflow_with_switch(self):
|
||||
"""Test workflow with Switch/ConditionGroup."""
|
||||
async def test_workflow_with_condition_group(self):
|
||||
"""Test workflow with ConditionGroup."""
|
||||
yaml_def = {
|
||||
"name": "switch_workflow",
|
||||
"name": "condition_group_workflow",
|
||||
"actions": [
|
||||
{"kind": "SetValue", "id": "set_level", "path": "Local.level", "value": 2},
|
||||
{
|
||||
"kind": "Switch",
|
||||
"kind": "ConditionGroup",
|
||||
"id": "check_level",
|
||||
"conditions": [
|
||||
{
|
||||
@@ -174,7 +174,7 @@ class TestGraphBasedWorkflowExecution:
|
||||
],
|
||||
},
|
||||
],
|
||||
"else": [
|
||||
"elseActions": [
|
||||
{"kind": "SendActivity", "id": "default", "activity": {"text": "Other level"}},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -122,14 +122,16 @@ actions:
|
||||
- cherry
|
||||
itemName: fruit
|
||||
actions:
|
||||
- kind: AppendValue
|
||||
path: Local.fruits
|
||||
value: processed
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: processed
|
||||
""")
|
||||
|
||||
_result = await workflow.run({}) # noqa: F841
|
||||
# The foreach should have processed 3 items
|
||||
# We can check this by examining the workflow outputs
|
||||
result = await workflow.run({})
|
||||
outputs = result.get_outputs()
|
||||
# The foreach should have processed 3 items, emitting "processed" each time.
|
||||
processed_outputs = [o for o in outputs if "processed" in str(o)]
|
||||
assert len(processed_outputs) == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_if_workflow(self):
|
||||
@@ -556,28 +558,27 @@ actions:
|
||||
|
||||
|
||||
@_requires_powerfx
|
||||
class TestWorkflowFactorySwitch:
|
||||
"""Tests for Switch/Case action."""
|
||||
class TestWorkflowFactoryConditionGroup:
|
||||
"""Tests for ConditionGroup action."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_with_matching_case(self):
|
||||
"""Test Switch with a matching case."""
|
||||
async def test_condition_group_with_matching_condition(self):
|
||||
"""Test ConditionGroup with a matching condition."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: switch-test
|
||||
name: condition-group-test
|
||||
actions:
|
||||
- kind: SetValue
|
||||
path: Local.color
|
||||
value: red
|
||||
- kind: Switch
|
||||
value: =Local.color
|
||||
cases:
|
||||
- match: red
|
||||
- kind: ConditionGroup
|
||||
conditions:
|
||||
- condition: =Local.color = "red"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Color is red
|
||||
- match: blue
|
||||
- condition: =Local.color = "blue"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
@@ -590,29 +591,28 @@ actions:
|
||||
assert any("Color is red" in str(o) for o in outputs)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_switch_with_default(self):
|
||||
"""Test Switch falling through to default."""
|
||||
async def test_condition_group_with_else_actions(self):
|
||||
"""Test ConditionGroup falling through to elseActions when no condition matches."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: switch-default-test
|
||||
name: condition-group-else-test
|
||||
actions:
|
||||
- kind: SetValue
|
||||
path: Local.color
|
||||
value: green
|
||||
- kind: Switch
|
||||
value: =Local.color
|
||||
cases:
|
||||
- match: red
|
||||
- kind: ConditionGroup
|
||||
conditions:
|
||||
- condition: =Local.color = "red"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Red
|
||||
- match: blue
|
||||
- condition: =Local.color = "blue"
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Blue
|
||||
default:
|
||||
elseActions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Unknown color
|
||||
@@ -653,54 +653,273 @@ actions:
|
||||
|
||||
assert any("Done" in str(o) for o in outputs)
|
||||
|
||||
|
||||
class TestRenamedAliasKindsAreUnknown:
|
||||
"""Tests that the previously-accepted ``Switch``/``Goto`` kind names are now unknown.
|
||||
|
||||
YAML that still names one of these kinds falls through the existing
|
||||
unknown-kind warning path (the action is silently skipped) instead
|
||||
of being routed to ``ConditionGroup``/``GotoAction``.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_append_value(self):
|
||||
"""Test AppendValue action."""
|
||||
async def test_switch_kind_is_unknown(self, caplog):
|
||||
"""A workflow whose YAML uses kind: Switch logs an unknown-kind warning."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: append-test
|
||||
with caplog.at_level(
|
||||
"WARNING",
|
||||
logger="agent_framework_declarative._workflows._declarative_builder",
|
||||
):
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: switch-alias-removed
|
||||
actions:
|
||||
- kind: SetValue
|
||||
path: Local.list
|
||||
value: []
|
||||
- kind: AppendValue
|
||||
path: Local.list
|
||||
value: first
|
||||
- kind: AppendValue
|
||||
path: Local.list
|
||||
value: second
|
||||
- kind: Switch
|
||||
value: =Local.color
|
||||
cases:
|
||||
- match: red
|
||||
actions:
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Color is red
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Done
|
||||
""")
|
||||
result = await workflow.run({})
|
||||
|
||||
result = await workflow.run({})
|
||||
# Switch is no longer a recognised kind -> warning emitted + action skipped.
|
||||
assert any("Unknown action kind 'Switch'" in record.getMessage() for record in caplog.records)
|
||||
# The trailing SendActivity still runs so the workflow completes successfully.
|
||||
outputs = result.get_outputs()
|
||||
|
||||
assert any("Done" in str(o) for o in outputs)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_emit_event(self):
|
||||
"""Test EmitEvent action."""
|
||||
async def test_goto_kind_is_unknown(self, caplog):
|
||||
"""A workflow whose YAML uses kind: Goto logs an unknown-kind warning."""
|
||||
factory = WorkflowFactory()
|
||||
with caplog.at_level(
|
||||
"WARNING",
|
||||
logger="agent_framework_declarative._workflows._declarative_builder",
|
||||
):
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: goto-alias-removed
|
||||
actions:
|
||||
- id: target
|
||||
kind: SendActivity
|
||||
activity:
|
||||
text: Arrived
|
||||
- kind: Goto
|
||||
target: target
|
||||
""")
|
||||
result = await workflow.run({})
|
||||
|
||||
# Goto is no longer a recognised kind -> warning emitted + action skipped.
|
||||
assert any("Unknown action kind 'Goto'" in record.getMessage() for record in caplog.records)
|
||||
# The first SendActivity still emits its output.
|
||||
outputs = result.get_outputs()
|
||||
assert any("Arrived" in str(o) for o in outputs)
|
||||
|
||||
|
||||
class TestDroppedShapesAreRejected:
|
||||
"""Tests that previously-accepted alternate YAML shapes are now rejected at validation.
|
||||
|
||||
``ConditionGroup`` no longer accepts the ``value``/``cases`` shape and
|
||||
``Foreach`` no longer accepts the ``items`` field. Both kinds raise a
|
||||
``ValueError`` from the builder when the required field is missing.
|
||||
"""
|
||||
|
||||
def test_condition_group_with_cases_raises(self):
|
||||
"""ConditionGroup using value/cases (no conditions) must fail validation."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "cg-cases-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "ConditionGroup",
|
||||
"value": "=Local.color",
|
||||
"cases": [
|
||||
{"match": "red", "actions": [{"kind": "SendActivity", "activity": {"text": "Red"}}]},
|
||||
],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="conditions"):
|
||||
builder.build()
|
||||
|
||||
def test_foreach_with_items_raises(self):
|
||||
"""Foreach using items (no source) must fail validation."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "fe-items-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "Foreach",
|
||||
"items": "=Local.list",
|
||||
"actions": [{"kind": "SendActivity", "activity": {"text": "hi"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="source"):
|
||||
builder.build()
|
||||
|
||||
def test_condition_group_with_else_field_raises(self):
|
||||
"""ConditionGroup with an ``else`` field must fail fast and point at ``elseActions``."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "cg-else-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "ConditionGroup",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "=Local.x = 1",
|
||||
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
|
||||
},
|
||||
],
|
||||
"else": [{"kind": "SendActivity", "activity": {"text": "other"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="elseActions"):
|
||||
builder.build()
|
||||
|
||||
def test_condition_group_with_default_field_raises(self):
|
||||
"""ConditionGroup with a ``default`` field must fail fast and point at ``elseActions``."""
|
||||
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
|
||||
|
||||
yaml_def = {
|
||||
"name": "cg-default-rejected",
|
||||
"actions": [
|
||||
{
|
||||
"kind": "ConditionGroup",
|
||||
"conditions": [
|
||||
{
|
||||
"condition": "=Local.x = 1",
|
||||
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
|
||||
},
|
||||
],
|
||||
"default": [{"kind": "SendActivity", "activity": {"text": "other"}}],
|
||||
}
|
||||
],
|
||||
}
|
||||
builder = DeclarativeWorkflowBuilder(yaml_def)
|
||||
with pytest.raises(ValueError, match="elseActions"):
|
||||
builder.build()
|
||||
|
||||
|
||||
class TestQuestionAndRequestExternalInputShapes:
|
||||
"""Tests for accepted YAML shapes of ``Question`` and ``RequestExternalInput``.
|
||||
|
||||
Both kinds accept either a nested ``{question|prompt: {text: ...}}`` form
|
||||
or a top-level alternate (``text``/``message``) for the prompt content,
|
||||
and either ``variable`` or top-level ``property`` for the destination path.
|
||||
Missing both spellings of a required field raises during validation.
|
||||
"""
|
||||
|
||||
def test_question_nested_question_text_builds(self):
|
||||
"""A workflow whose Question uses nested ``question.text`` builds without error."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: emit-event-test
|
||||
name: question-nested
|
||||
actions:
|
||||
- kind: EmitEvent
|
||||
event:
|
||||
name: test_event
|
||||
data:
|
||||
message: Hello
|
||||
- kind: SendActivity
|
||||
activity:
|
||||
text: Event emitted
|
||||
- kind: Question
|
||||
question:
|
||||
text: "What is your name?"
|
||||
variable: Local.userName
|
||||
default: "Guest"
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
def test_request_external_input_nested_prompt_text_builds(self):
|
||||
"""A workflow whose RequestExternalInput uses nested ``prompt.text`` builds without error."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: rei-nested
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
prompt:
|
||||
text: "Please approve"
|
||||
variable: Local.approved
|
||||
default: pending
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
def test_question_missing_question_raises(self):
|
||||
"""A Question action missing both `question` and the `text` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="question"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: question-missing-question
|
||||
actions:
|
||||
- kind: Question
|
||||
variable: Local.x
|
||||
""")
|
||||
|
||||
result = await workflow.run({})
|
||||
outputs = result.get_outputs()
|
||||
def test_question_missing_variable_raises(self):
|
||||
"""A Question action missing both `variable` and the `property` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: question-missing-variable
|
||||
actions:
|
||||
- kind: Question
|
||||
question:
|
||||
text: "Hi"
|
||||
""")
|
||||
|
||||
# Workflow should complete
|
||||
assert any("Event emitted" in str(o) for o in outputs)
|
||||
def test_request_external_input_missing_prompt_raises(self):
|
||||
"""RequestExternalInput missing both `prompt` and the `message` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="prompt"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: rei-missing-prompt
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
variable: Local.x
|
||||
""")
|
||||
|
||||
def test_request_external_input_missing_variable_raises(self):
|
||||
"""RequestExternalInput missing both `variable` and the `property` alternate must fail validation."""
|
||||
factory = WorkflowFactory()
|
||||
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
|
||||
factory.create_workflow_from_yaml("""
|
||||
name: rei-missing-variable
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
prompt:
|
||||
text: "Hi"
|
||||
""")
|
||||
|
||||
def test_question_top_level_field_names_accepted(self):
|
||||
"""Top-level ``text`` + ``property`` + ``defaultValue`` are accepted on Question."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: question-legacy
|
||||
actions:
|
||||
- kind: Question
|
||||
text: "What is your name?"
|
||||
property: Local.userName
|
||||
defaultValue: "Guest"
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
def test_request_external_input_top_level_field_names_accepted(self):
|
||||
"""Top-level ``message`` + ``property`` are accepted on RequestExternalInput."""
|
||||
factory = WorkflowFactory()
|
||||
workflow = factory.create_workflow_from_yaml("""
|
||||
name: rei-legacy
|
||||
actions:
|
||||
- kind: RequestExternalInput
|
||||
message: "Please approve"
|
||||
property: Local.approved
|
||||
""")
|
||||
assert workflow is not None
|
||||
|
||||
|
||||
class TestWorkflowFactoryYamlErrors:
|
||||
|
||||
@@ -227,7 +227,6 @@ class TestHandlerCoverage:
|
||||
"OnConversationStart", # Trigger kind, not an action
|
||||
"ConditionGroup", # Decomposed into evaluator/join nodes
|
||||
"GotoAction", # Resolved as graph edges, not executor nodes
|
||||
"Goto", # Alias for GotoAction
|
||||
}
|
||||
|
||||
missing_executors = all_action_kinds - registered_executors - structural_kinds
|
||||
|
||||
@@ -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.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
license-files = ["LICENSE"]
|
||||
urls.homepage = "https://github.com/microsoft/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.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
"opentelemetry-sdk>=1.39.0,<2",
|
||||
"fastapi>=0.115.0,<0.133.1",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
"""Tests for cleanup hook registration and execution."""
|
||||
|
||||
import asyncio
|
||||
import inspect
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
@@ -123,7 +123,7 @@ async def test_register_cleanup_multiple_hooks():
|
||||
|
||||
# Execute all hooks
|
||||
for hook in hooks:
|
||||
if asyncio.iscoroutinefunction(hook):
|
||||
if inspect.iscoroutinefunction(hook):
|
||||
await hook()
|
||||
else:
|
||||
hook()
|
||||
|
||||
@@ -610,6 +610,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
@@ -639,6 +640,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
credential: Azure credential for authentication.
|
||||
project_client: An existing AIProjectClient to use.
|
||||
allow_preview: Enables preview opt-in on internally-created AIProjectClient.
|
||||
default_headers: Additional HTTP headers for requests made through the OpenAI client.
|
||||
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
|
||||
context_providers: Optional context providers for injecting dynamic context.
|
||||
middleware: Optional agent-level middleware.
|
||||
@@ -672,6 +674,7 @@ class RawFoundryAgent( # type: ignore[misc]
|
||||
"credential": credential,
|
||||
"project_client": project_client,
|
||||
"allow_preview": allow_preview,
|
||||
"default_headers": default_headers,
|
||||
"env_file_path": env_file_path,
|
||||
"env_file_encoding": env_file_encoding,
|
||||
}
|
||||
@@ -894,6 +897,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
credential: AzureCredentialTypes | None = None,
|
||||
project_client: AIProjectClient | None = None,
|
||||
allow_preview: bool | None = None,
|
||||
default_headers: Mapping[str, str] | None = None,
|
||||
tools: FunctionTool | Callable[..., Any] | Sequence[FunctionTool | Callable[..., Any]] | None = None,
|
||||
context_providers: Sequence[ContextProvider] | None = None,
|
||||
middleware: Sequence[MiddlewareTypes] | None = None,
|
||||
@@ -936,6 +940,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
Set this to ``True`` for HostedAgents that need preview-only
|
||||
session APIs, including lazy service session creation from
|
||||
``isolation_key``.
|
||||
default_headers: Additional HTTP headers for requests made through the OpenAI client.
|
||||
tools: Function tools to provide to the agent. Only ``FunctionTool`` objects are accepted.
|
||||
context_providers: Optional context providers.
|
||||
middleware: Optional agent-level middleware.
|
||||
@@ -963,6 +968,7 @@ class FoundryAgent( # type: ignore[misc]
|
||||
credential=credential,
|
||||
project_client=project_client,
|
||||
allow_preview=allow_preview,
|
||||
default_headers=default_headers,
|
||||
tools=tools,
|
||||
context_providers=context_providers,
|
||||
middleware=middleware,
|
||||
|
||||
@@ -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.6.0"
|
||||
version = "1.7.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.6.0,<2",
|
||||
"agent-framework-openai>=1.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"agent-framework-openai>=1.7.0,<2",
|
||||
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
|
||||
"azure-ai-projects>=2.1.0,<3.0",
|
||||
]
|
||||
|
||||
@@ -505,6 +505,40 @@ def test_raw_foundry_agent_init_creates_client() -> None:
|
||||
assert agent.client.agent_name == "test-agent"
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_passes_default_headers_to_client() -> None:
|
||||
"""Test that RawFoundryAgent passes default_headers to the underlying client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
default_headers = {"x-ms-user-isolation-key": "user-1"}
|
||||
|
||||
RawFoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
mock_project.get_openai_client.assert_called_once()
|
||||
assert mock_project.get_openai_client.call_args.kwargs["default_headers"] == default_headers
|
||||
|
||||
|
||||
def test_foundry_agent_init_passes_default_headers_to_client() -> None:
|
||||
"""Test that FoundryAgent passes default_headers to the underlying client."""
|
||||
|
||||
mock_project = MagicMock()
|
||||
mock_project.get_openai_client.return_value = MagicMock()
|
||||
default_headers = {"x-ms-user-isolation-key": "user-1"}
|
||||
|
||||
FoundryAgent(
|
||||
project_client=mock_project,
|
||||
agent_name="hosted-agent",
|
||||
default_headers=default_headers,
|
||||
)
|
||||
|
||||
mock_project.get_openai_client.assert_called_once()
|
||||
assert mock_project.get_openai_client.call_args.kwargs["default_headers"] == default_headers
|
||||
|
||||
|
||||
def test_raw_foundry_agent_init_with_custom_client_type() -> None:
|
||||
"""Test that client_type parameter is respected."""
|
||||
|
||||
@@ -523,6 +557,7 @@ def test_raw_foundry_agent_init_with_custom_client_type() -> None:
|
||||
def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
signature = inspect.signature(RawFoundryAgent.__init__)
|
||||
|
||||
assert "default_headers" in signature.parameters
|
||||
assert "instructions" in signature.parameters
|
||||
assert "default_options" in signature.parameters
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
@@ -534,6 +569,7 @@ def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
def test_foundry_agent_init_uses_explicit_parameters() -> None:
|
||||
signature = inspect.signature(FoundryAgent.__init__)
|
||||
|
||||
assert "default_headers" in signature.parameters
|
||||
assert "instructions" in signature.parameters
|
||||
assert "default_options" in signature.parameters
|
||||
assert "compaction_strategy" in signature.parameters
|
||||
|
||||
@@ -72,6 +72,7 @@ from azure.ai.agentserver.responses.models import (
|
||||
MessageContentOutputTextContent,
|
||||
MessageContentReasoningTextContent,
|
||||
MessageContentRefusalContent,
|
||||
MessageRole,
|
||||
OAuthConsentRequestOutputItem,
|
||||
OutputItem,
|
||||
OutputItemApplyPatchToolCall,
|
||||
@@ -116,6 +117,8 @@ from typing_extensions import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE = f"{MessageRole.__module__}:{MessageRole.__qualname__}"
|
||||
|
||||
|
||||
# region Approval Storage
|
||||
class ApprovalStorage(Protocol):
|
||||
@@ -249,7 +252,12 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
|
||||
storage_path = (root_path / context_id).resolve()
|
||||
if not storage_path.is_relative_to(root_path):
|
||||
raise RuntimeError(f"Invalid checkpoint context id: {context_id!r}")
|
||||
return FileCheckpointStorage(storage_path)
|
||||
return FileCheckpointStorage(
|
||||
storage_path,
|
||||
# Keep this provider-specific allowlist narrow. Hosted workflow
|
||||
# checkpoints can persist Azure's role enum inside Message objects.
|
||||
allowed_checkpoint_types=[_AZURE_RESPONSES_MESSAGE_ROLE_TYPE],
|
||||
)
|
||||
|
||||
|
||||
# endregion Approval Storage
|
||||
|
||||
@@ -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.0a260521"
|
||||
version = "1.0.0a260528"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.7.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",
|
||||
|
||||
@@ -13,7 +13,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from dataclasses import dataclass
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -26,6 +26,9 @@ from agent_framework import (
|
||||
Message,
|
||||
RawAgent,
|
||||
ResponseStream,
|
||||
WorkflowCheckpoint,
|
||||
WorkflowCheckpointException,
|
||||
WorkflowMessage,
|
||||
)
|
||||
from azure.ai.agentserver.responses import InMemoryResponseProvider
|
||||
from mcp import McpError
|
||||
@@ -34,6 +37,7 @@ from typing_extensions import Any
|
||||
|
||||
from agent_framework_foundry_hosting import ResponsesHostServer
|
||||
from agent_framework_foundry_hosting._responses import (
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage]
|
||||
CONSENT_ERROR_CODE,
|
||||
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
|
||||
@@ -2712,6 +2716,23 @@ class TestCheckpointContextPathValidation:
|
||||
|
||||
return _checkpoint_storage_for_context
|
||||
|
||||
@staticmethod
|
||||
def _checkpoint_with_azure_message_role() -> WorkflowCheckpoint:
|
||||
from azure.ai.agentserver.responses.models import MessageRole
|
||||
|
||||
return WorkflowCheckpoint(
|
||||
workflow_name="wf",
|
||||
graph_signature_hash="hash",
|
||||
messages={
|
||||
"executor": [
|
||||
WorkflowMessage(
|
||||
data=Message(role=MessageRole.USER, contents=[Content.from_text("hello")]),
|
||||
source_id="source",
|
||||
)
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def test_valid_segment_creates_storage_under_root(self, tmp_path: Any) -> None:
|
||||
helper = self._helper()
|
||||
root = tmp_path / "root"
|
||||
@@ -2720,6 +2741,124 @@ class TestCheckpointContextPathValidation:
|
||||
assert storage.storage_path.is_dir()
|
||||
assert storage.storage_path.parent == root.resolve()
|
||||
|
||||
def test_azure_message_role_allowlist_type_matches_generated_sdk_path(self) -> None:
|
||||
assert (
|
||||
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE
|
||||
== "azure.ai.agentserver.responses.models._generated.sdk.models.models._enums:MessageRole"
|
||||
)
|
||||
|
||||
async def test_storage_allows_azure_message_role_checkpoint_restore(self, tmp_path: Any) -> None:
|
||||
from azure.ai.agentserver.responses.models import MessageRole
|
||||
|
||||
helper = self._helper()
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
storage = helper(str(root), "resp_abc123")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
loaded = await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
loaded_message = loaded.messages["executor"][0].data
|
||||
assert isinstance(loaded_message, Message)
|
||||
assert type(loaded_message.role) is MessageRole
|
||||
assert loaded_message.role == MessageRole.USER
|
||||
assert loaded_message.text == "hello"
|
||||
|
||||
async def test_plain_storage_blocks_azure_message_role_checkpoint_restore(self, tmp_path: Any) -> None:
|
||||
storage = FileCheckpointStorage(tmp_path / "plain")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
with pytest.raises(WorkflowCheckpointException, match="MessageRole"):
|
||||
await storage.load(checkpoint.checkpoint_id)
|
||||
|
||||
async def test_get_latest_restores_azure_message_role(self, tmp_path: Any) -> None:
|
||||
from azure.ai.agentserver.responses.models import MessageRole
|
||||
|
||||
helper = self._helper()
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
storage = helper(str(root), "resp_abc123")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
latest = await storage.get_latest(workflow_name="wf")
|
||||
|
||||
assert latest is not None
|
||||
assert latest.checkpoint_id == checkpoint.checkpoint_id
|
||||
latest_message = latest.messages["executor"][0].data
|
||||
assert isinstance(latest_message, Message)
|
||||
assert type(latest_message.role) is MessageRole
|
||||
|
||||
async def test_get_latest_silently_skips_without_allowlist(
|
||||
self, tmp_path: Any, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
import logging
|
||||
|
||||
storage = FileCheckpointStorage(tmp_path / "plain")
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
|
||||
await storage.save(checkpoint)
|
||||
with caplog.at_level(logging.WARNING, logger="agent_framework"):
|
||||
latest = await storage.get_latest(workflow_name="wf")
|
||||
|
||||
assert latest is None
|
||||
assert any("MessageRole" in message for message in caplog.messages)
|
||||
|
||||
async def test_handle_inner_workflow_restores_message_role_checkpoint_from_previous_response(
|
||||
self, tmp_path: Any
|
||||
) -> None:
|
||||
from agent_framework import WorkflowAgent
|
||||
from azure.ai.agentserver.responses import ResponseContext
|
||||
from azure.ai.agentserver.responses.models import CreateResponse, ItemMessage
|
||||
|
||||
previous_response_id = "resp_previous"
|
||||
response_id = "resp_current"
|
||||
root = tmp_path / "root"
|
||||
root.mkdir()
|
||||
checkpoint_storage = self._helper()(str(root), previous_response_id)
|
||||
checkpoint = self._checkpoint_with_azure_message_role()
|
||||
await checkpoint_storage.save(checkpoint)
|
||||
|
||||
agent = MagicMock(spec=WorkflowAgent)
|
||||
agent.id = "wf-agent"
|
||||
agent.name = "wf"
|
||||
agent.description = ""
|
||||
agent.context_providers = []
|
||||
agent.workflow = MagicMock()
|
||||
agent.workflow.name = "wf"
|
||||
agent.workflow._runner_context.has_checkpointing = MagicMock(return_value=False)
|
||||
agent.run = AsyncMock(
|
||||
side_effect=[
|
||||
AgentResponse(messages=[]),
|
||||
AgentResponse(messages=[Message(role="assistant", contents=[Content.from_text("ok")])]),
|
||||
]
|
||||
)
|
||||
server = ResponsesHostServer(agent, store=InMemoryResponseProvider())
|
||||
server._checkpoint_storage_path = str(root) # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
request = CreateResponse(model="m", input="hi", previous_response_id=previous_response_id)
|
||||
context = ResponseContext(
|
||||
response_id=response_id, previous_response_id=previous_response_id, mode_flags=MagicMock()
|
||||
)
|
||||
input_item = ItemMessage({"type": "message", "role": "user", "content": "next turn"})
|
||||
|
||||
with patch.object(ResponseContext, "get_input_items", new=AsyncMock(return_value=[input_item])):
|
||||
async for _ in server._handle_inner_workflow(request, context): # pyright: ignore[reportPrivateUsage]
|
||||
pass
|
||||
|
||||
assert agent.run.call_count == 2
|
||||
restore_call = agent.run.call_args_list[0]
|
||||
assert restore_call.kwargs["checkpoint_id"] == checkpoint.checkpoint_id
|
||||
assert restore_call.kwargs["checkpoint_storage"].storage_path == (root / previous_response_id).resolve()
|
||||
|
||||
new_turn_call = agent.run.call_args_list[1]
|
||||
new_turn_messages = new_turn_call.args[0]
|
||||
assert len(new_turn_messages) == 1
|
||||
assert new_turn_messages[0].text == "next turn"
|
||||
assert new_turn_call.kwargs["checkpoint_storage"].storage_path == (root / response_id).resolve()
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_id",
|
||||
[
|
||||
|
||||
@@ -636,7 +636,11 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
continuation_token["response_id"],
|
||||
stream=True,
|
||||
)
|
||||
served_model = self._extract_served_model(raw_stream_response.headers)
|
||||
# Read headers defensively: telemetry instrumentors (e.g. azure-ai-projects
|
||||
# experimental tracing) wrap the streaming response in objects that do not
|
||||
# proxy ``.headers``. Degrade gracefully so the served-model surfacing is
|
||||
# best-effort instead of crashing the whole call.
|
||||
served_model = self._extract_served_model(getattr(raw_stream_response, "headers", None))
|
||||
async with raw_stream_response.parse() as stream_response:
|
||||
async for chunk in stream_response:
|
||||
update = self._parse_chunk_from_openai(
|
||||
@@ -677,7 +681,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
raw_create_response = await client.responses.with_raw_response.create(
|
||||
stream=True, **run_options
|
||||
)
|
||||
served_model = self._extract_served_model(raw_create_response.headers)
|
||||
# See note above on ``raw_stream_response.headers``.
|
||||
served_model = self._extract_served_model(getattr(raw_create_response, "headers", None))
|
||||
async with raw_create_response.parse() as stream_response:
|
||||
async for chunk in stream_response:
|
||||
update = self._parse_chunk_from_openai(
|
||||
@@ -706,7 +711,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
except Exception as ex:
|
||||
self._handle_request_error(ex)
|
||||
chat_response = self._parse_response_from_openai(response, options=validated_options)
|
||||
served_model = self._extract_served_model(raw_response.headers)
|
||||
# See note above on ``raw_stream_response.headers``.
|
||||
served_model = self._extract_served_model(getattr(raw_response, "headers", None))
|
||||
if served_model is not None:
|
||||
chat_response.model = served_model
|
||||
# Once the background response completes, drop the continuation_token from
|
||||
@@ -728,7 +734,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
|
||||
except Exception as ex:
|
||||
self._handle_request_error(ex)
|
||||
chat_response = self._parse_response_from_openai(response, options=validated_options)
|
||||
served_model = self._extract_served_model(raw_response.headers)
|
||||
# See note above on ``raw_stream_response.headers``.
|
||||
served_model = self._extract_served_model(getattr(raw_response, "headers", None))
|
||||
if served_model is not None:
|
||||
chat_response.model = served_model
|
||||
return chat_response
|
||||
|
||||
@@ -4,7 +4,7 @@ description = "OpenAI integrations for Microsoft Agent Framework."
|
||||
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
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.6.0,<2",
|
||||
"agent-framework-core>=1.7.0,<2",
|
||||
"openai>=1.99.0,<3",
|
||||
]
|
||||
|
||||
|
||||
@@ -841,6 +841,88 @@ async def test_served_model_header_not_captured_for_streaming_text_format() -> N
|
||||
assert update.model == "test-model"
|
||||
|
||||
|
||||
async def test_streaming_response_without_headers_attribute_does_not_crash() -> None:
|
||||
"""Regression for #6028.
|
||||
|
||||
Some telemetry instrumentors (e.g. ``azure-ai-projects`` experimental GenAI tracing,
|
||||
activated by ``AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true``) monkey-patch
|
||||
``openai.resources.responses.AsyncResponses.create`` at the class level and return
|
||||
an ``AsyncStreamWrapper`` whose class genuinely has no ``headers`` attribute. The
|
||||
``with_raw_response.create`` wrapper does not re-wrap the return value
|
||||
(``async_to_raw_response_wrapper`` only injects an extra header into the request),
|
||||
so ``raw_create_response`` in ``_inner_get_response`` ends up being the wrapper
|
||||
itself. Reading ``raw_create_response.headers`` used to raise ``AttributeError``
|
||||
and bubble up as ``ChatClientException``, breaking every streaming call. The
|
||||
defensive ``getattr(..., "headers", None)`` should now degrade gracefully:
|
||||
no served-model surfacing, but the stream still completes.
|
||||
"""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
events = [
|
||||
ResponseTextDeltaEvent(
|
||||
type="response.output_text.delta",
|
||||
content_index=0,
|
||||
item_id="text_item",
|
||||
output_index=0,
|
||||
sequence_number=1,
|
||||
logprobs=[],
|
||||
delta="Hello",
|
||||
),
|
||||
]
|
||||
|
||||
class _StreamWrapperWithoutHeaders:
|
||||
"""Mimics ``azure.ai.projects.telemetry._responses_instrumentor.AsyncStreamWrapper``:
|
||||
an async iterator that proxies the stream contents but does not expose ``.headers``.
|
||||
``hasattr(wrapper, "headers")`` returns ``False`` so ``getattr(..., "headers", None)``
|
||||
falls through to the default — matching the real instrumentor's class layout.
|
||||
"""
|
||||
|
||||
def __init__(self, events: list[object]) -> None:
|
||||
self._events = events
|
||||
self._iterator = iter(())
|
||||
|
||||
def __aiter__(self) -> "_StreamWrapperWithoutHeaders":
|
||||
self._iterator = iter(self._events)
|
||||
return self
|
||||
|
||||
async def __anext__(self) -> object:
|
||||
try:
|
||||
return next(self._iterator)
|
||||
except StopIteration as exc:
|
||||
raise StopAsyncIteration from exc
|
||||
|
||||
def parse(self) -> "_StreamWrapperWithoutHeaders":
|
||||
return self
|
||||
|
||||
async def __aenter__(self) -> "_StreamWrapperWithoutHeaders":
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc: BaseException | None,
|
||||
traceback: object | None,
|
||||
) -> None:
|
||||
return None
|
||||
|
||||
headerless_stream = _StreamWrapperWithoutHeaders(events)
|
||||
# Sanity-check the simulation: the real instrumentor's wrapper genuinely lacks ``.headers``.
|
||||
assert not hasattr(headerless_stream, "headers")
|
||||
|
||||
with (
|
||||
patch.object(client, "_prepare_request", new=AsyncMock(return_value=(client.client, {}, {}))),
|
||||
patch.object(client.client.responses, "create", new=AsyncMock(return_value=headerless_stream)),
|
||||
patch.object(client, "_get_metadata_from_response", return_value={}),
|
||||
):
|
||||
stream = client._inner_get_response(messages=[Message(role="user", contents=["Hi"])], options={}, stream=True)
|
||||
updates = [update async for update in stream]
|
||||
|
||||
assert updates, "Expected the stream to complete even when the wrapper lacks .headers"
|
||||
for update in updates:
|
||||
# No header => no override => model stays the deployment alias.
|
||||
assert update.model == "test-model"
|
||||
|
||||
|
||||
async def test_streaming_text_format_preserves_final_structured_output() -> None:
|
||||
"""Streaming structured output should still parse into the final ChatResponse value."""
|
||||
client = OpenAIChatClient(model="test-model", api_key="test-key")
|
||||
|
||||
@@ -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.6.0"
|
||||
version = "1.7.0"
|
||||
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[all]==1.6.0",
|
||||
"agent-framework-core[all]==1.7.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
|
||||
@@ -51,19 +51,16 @@ actions:
|
||||
### Variable Actions
|
||||
- `SetValue` - Set a variable in state
|
||||
- `SetVariable` - Set a variable (.NET style naming)
|
||||
- `AppendValue` - Append to a list
|
||||
- `ResetVariable` - Clear a variable
|
||||
|
||||
### Control Flow
|
||||
- `If` - Conditional branching
|
||||
- `Switch` - Multi-way branching
|
||||
- `ConditionGroup` - Multi-way branching
|
||||
- `Foreach` - Iterate over collections
|
||||
- `RepeatUntil` - Loop until condition
|
||||
- `GotoAction` - Jump to labeled action
|
||||
|
||||
### Output
|
||||
- `SendActivity` - Send text/attachments to user
|
||||
- `EmitEvent` - Emit custom events
|
||||
|
||||
### Agent Invocation
|
||||
- `InvokeAzureAgent` - Call an Azure AI agent
|
||||
@@ -74,4 +71,4 @@ actions:
|
||||
|
||||
### Human-in-Loop
|
||||
- `Question` - Request user input
|
||||
- `WaitForInput` - Pause for external input
|
||||
- `RequestExternalInput` - Request external data/approval
|
||||
|
||||
@@ -27,6 +27,7 @@ trigger:
|
||||
input:
|
||||
messages: =Workflow.Inputs.input
|
||||
output:
|
||||
autoSend: false
|
||||
response: Local.agentResponse
|
||||
responseObject: Local.orderData
|
||||
|
||||
@@ -37,6 +38,7 @@ trigger:
|
||||
arguments:
|
||||
order_data: =Local.orderData
|
||||
output:
|
||||
autoSend: false
|
||||
result: Local.orderCalculation
|
||||
|
||||
# Invoke another function tool to format the final confirmation
|
||||
@@ -47,6 +49,7 @@ trigger:
|
||||
order_data: =Local.orderData
|
||||
order_calculation: =Local.orderCalculation
|
||||
output:
|
||||
autoSend: false
|
||||
result: Local.confirmation
|
||||
|
||||
# Send the final confirmation to the user
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
This sample demonstrates control flow with conditions:
|
||||
- If/else branching
|
||||
- Switch statements
|
||||
- Nested conditions
|
||||
|
||||
## Files
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
# Human-in-Loop Workflow Sample
|
||||
|
||||
This sample demonstrates how to build interactive workflows that request user input during execution using the `Question`, `RequestExternalInput`, and `WaitForInput` actions.
|
||||
This sample demonstrates how to build interactive workflows that request user input during execution using the `Question` and `RequestExternalInput` actions.
|
||||
|
||||
## What This Sample Shows
|
||||
|
||||
- Using `Question` to prompt for user responses
|
||||
- Using `RequestExternalInput` to request external data
|
||||
- Using `WaitForInput` to pause and wait for input
|
||||
- Processing user responses to drive workflow decisions
|
||||
- Interactive conversation patterns
|
||||
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# Hosting Samples
|
||||
|
||||
This directory contains Python samples that demonstrate different ways to host Agent Framework agents. Use this page to choose the hosting model that best fits your scenario, then continue to the README in the relevant subdirectory.
|
||||
|
||||
## Hosting Options
|
||||
|
||||
| Option | Use this when you need... | Start here |
|
||||
|--------|----------------------------|------------|
|
||||
| A2A | Agent-to-Agent protocol interoperability or remote agent invocation. | [`a2a/README.md`](./a2a/README.md) |
|
||||
| Azure Functions | HTTP or serverless hosting on Azure Functions. | [`azure_functions/README.md`](./azure_functions/README.md) |
|
||||
| Durable Task | Durable execution, long-running flows, or orchestration patterns. | [`durabletask/README.md`](./durabletask/README.md) |
|
||||
| Foundry Hosted Agents | Azure AI Foundry hosted agent deployment. | [`foundry-hosted-agents/README.md`](./foundry-hosted-agents/README.md) |
|
||||
|
||||
## How to Choose
|
||||
|
||||
- Start with **A2A** if you want one agent to call or expose another agent over the A2A protocol.
|
||||
- Start with **Azure Functions** if you want an HTTP-hosted or serverless entry point using Azure Functions.
|
||||
- Start with **Durable Task** if you need persistent state, durable workflows, or orchestration across multiple steps.
|
||||
- Start with **Foundry Hosted Agents** if you want to package and deploy an agent as a hosted agent in Azure AI Foundry.
|
||||
|
||||
## Common Prerequisites
|
||||
|
||||
Most hosting samples share a small set of prerequisites:
|
||||
|
||||
- A supported Python environment for running the samples locally.
|
||||
- An Azure AI Foundry project endpoint and model deployment name for `FOUNDRY_PROJECT_ENDPOINT` and `FOUNDRY_MODEL`.
|
||||
- Azure CLI authentication via `az login` when the sample uses `AzureCliCredential`.
|
||||
- Any hosting-specific tools or extra services called out in the subdirectory README.
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Pick the hosting approach that matches your scenario.
|
||||
2. Open the corresponding README for setup and run instructions.
|
||||
3. Follow that sample's environment, dependency, and execution steps.
|
||||
Generated
+11
-11
@@ -110,7 +110,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -165,7 +165,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-a2a"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/a2a" }
|
||||
dependencies = [
|
||||
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -313,7 +313,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-chatkit"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/chatkit" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -323,7 +323,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "openai-chatkit", specifier = ">=1.4.1,<2.0.0" },
|
||||
{ name = "openai-chatkit", specifier = ">=1.6.4,<2.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -358,7 +358,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-core"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { editable = "packages/core" }
|
||||
dependencies = [
|
||||
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -432,7 +432,7 @@ provides-extras = ["all"]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-declarative"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/declarative" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -459,7 +459,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-devui"
|
||||
version = "1.0.0b260521"
|
||||
version = "1.0.0b260528"
|
||||
source = { editable = "packages/devui" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -524,7 +524,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }]
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-foundry"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { editable = "packages/foundry" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -543,7 +543,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-foundry-hosting"
|
||||
version = "1.0.0a260521"
|
||||
version = "1.0.0a260528"
|
||||
source = { editable = "packages/foundry_hosting" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
@@ -604,7 +604,7 @@ dependencies = [
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "agent-framework-core", editable = "packages/core" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0b2,<=1.0.0b2" },
|
||||
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -754,7 +754,7 @@ requires-dist = [
|
||||
|
||||
[[package]]
|
||||
name = "agent-framework-openai"
|
||||
version = "1.6.0"
|
||||
version = "1.7.0"
|
||||
source = { editable = "packages/openai" }
|
||||
dependencies = [
|
||||
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
|
||||
|
||||
Reference in New Issue
Block a user