Compare commits

...
Author SHA1 Message Date
Evan MattsonandGitHub a84ad42f6d Bump Python package versions for 1.7.0 release (#6142)
Bumps the released 1.6.0 packages agent-framework, agent-framework-core, agent-framework-foundry, and agent-framework-openai to 1.7.0, with root continuing to exactly pin agent-framework-core[all]. Bumps the changed prerelease packages agent-framework-a2a, agent-framework-chatkit, agent-framework-declarative, agent-framework-devui, and agent-framework-foundry-hosting to the 260528 date stamp, raises core floors on the packages included in this release, raises Foundry's OpenAI floor alongside OpenAI, and raises ChatKit's openai-chatkit floor to the minimum version required by the current typed API usage. No beta cohort bump was applied; the absent mistal/mistral package was intentionally not bumped because no such package exists in this branch.
2026-05-28 19:45:31 +09:00
Peter IbekweandGitHub ded17b178c Python: [Breaking] Remove Python-only declarative actions and rename alias kinds to C# canonical names (#6126)
* Remove Python-only declarative actions and rename alias kinds to C# canonical names

* Address PR comments.

* Address PR comments.

* Reduce verbose and duplicate output from sample workflow.
2026-05-28 10:16:22 +00:00
Yufeng HeandGitHub 55dc3ce734 Python: fix: pass Foundry agent default headers (#6040)
* fix: pass Foundry agent default headers

* test: loosen Foundry default header assertions
2026-05-28 10:08:14 +00:00
BaidarandGitHub 9d8e5ca4f5 Python: Allow hosted checkpoints to restore MessageRole (#6049)
* Python: Allow hosted checkpoints to restore MessageRole

Allow Responses hosting checkpoint storage to deserialize the Azure Responses MessageRole enum that hosted workflows can persist inside Agent Framework Message objects.

Add regression coverage for both direct load() and the hosted get_latest() restore path, including the plain-storage failure mode where list_checkpoints logs the blocked type and get_latest() returns None.

Ruff also normalizes a duplicate contextlib import in the touched hosting module.

* Address MessageRole checkpoint review comments

* Cover hosted MessageRole checkpoint restore path
2026-05-28 09:13:30 +00:00
af787569b3 Python: Align c# and python TodoProvider tool names (#6107)
* Align c# and python TodoProvider tool names

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Address PR review: remove __slots__ and add typed schemas for tool params

- Remove __slots__ from TodoItem, TodoInput, and TodoCompleteInput classes
  (not needed for low-instance-count objects and hinders dev scenarios)
- Add _TodoAddItemSchema and _TodoCompleteItemSchema TypedDicts to provide
  proper JSON schema for todos_add and todos_complete tool parameters
- Use typing_extensions for Python 3.10 compatibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 08:40:13 +00:00
3db2004e49 Python: read headers defensively to support stream wrappers without .headers (#6028) (#6029)
`OpenAIChatClient._inner_get_response()` reads `.headers` on the raw streaming
response returned by `client.responses.with_raw_response.create(stream=True)`
(and its three sibling call sites - retrieve-streaming, non-streaming create
and background retrieve) to surface the `x-ms-served-model` Azure header,
introduced in #5910.

When `azure-ai-projects>=2.1.0` experimental GenAI tracing is enabled
(`AZURE_EXPERIMENTAL_ENABLE_GENAI_TRACING=true`), the instrumentor wraps the
raw streaming response in an inline `AsyncStreamWrapper` that exposes
`.response` but not `.headers`. Reading `raw_create_response.headers` then
raises `AttributeError: 'AsyncStreamWrapper' object has no attribute 'headers'`,
which `FoundryChatClient` rethrows as a `ChatClientException` and breaks every
streaming call (workflows and free chat).

Fix: read the header dict via `getattr(raw_response, "headers", None)` at all
four call sites. `_extract_served_model()` already short-circuits on `None`,
so the served-model surfacing degrades gracefully (model stays the deployment
alias) instead of crashing when the response is wrapped by an instrumentor
that does not proxy `.headers`.

Regression test added:
`test_streaming_response_without_headers_attribute_does_not_crash`
simulates a stream wrapper that raises `AttributeError` on `.headers` and
asserts the stream still completes with the deployment alias as `update.model`.

Fixes #6028

Co-authored-by: Emilien Mottet <emilien.mottet@michelin.com>
2026-05-28 08:37:38 +00:00
efdabd56dc feat(a2a): add A2AAgentSession with reference_task_ids and input-required support (#5980)
* feat(a2a): link follow-up messages via reference_task_ids

Track the task_id from A2A responses (task, status_update, artifact_update,
and message payloads) on session.state and include it as reference_task_ids
on subsequent outgoing messages. This enables remote agents to correlate
follow-up messages as task refinements per the A2A spec.

Resolves #5938

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* feat(a2a): add A2AAgentSession for typed protocol state tracking

Introduce A2AAgentSession (subclass of AgentSession) with context_id,
task_id, and task_state properties. This follows the DurableAgentSession
pattern and mirrors the .NET A2AAgentSession design.

- Track task_id, context_id, and task_state from all response payload types
- Validate context_id consistency (raise on mismatch)
- Auto-assign server-generated context_id when not set
- Only A2AAgentSession gets reference tracking (no state dict fallback)
- Plain AgentSession continues to work without reference tracking
- Add serialization support (to_dict/from_dict)
- Export via agent_framework.a2a and agent_framework_a2a

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* style: remove unnecessary string annotation (pyupgrade)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: use AgentSession.from_dict for state deserialization

Avoids importing private _deserialize_state, matching the
DurableAgentSession pattern.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: track context_id from message payloads in A2AAgentSession

Previously, context_id was only captured from task, status_update, and
artifact_update payloads. Message-only responses (which carry context_id
but may lack task_id) were silently lost. This fix:

- Captures msg.context_id in the message handler
- Persists session state when either last_task_id or last_context_id is
  present (not only when task_id is truthy)
- Only updates task_id/task_state when a task_id was actually returned
- Adds a test for message-only context_id tracking

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* addressed comments

* Gate status content to INPUT_REQUIRED/terminal states (match .NET)

Match .NET's GetUserInputRequests pattern: only emit TaskStatusUpdateEvent
message content when state is INPUT_REQUIRED or terminal. Intermediate
status text (WORKING, SUBMITTED) is no longer surfaced to callers.

When state is INPUT_REQUIRED, set additional_properties['input_required']
= True so callers can distinguish input requests from final responses.

Closes #5937

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Address review: remove message task_id tracking, defensive fallbacks, and input_required flag

- Do not track task_id from Message payloads (simple interactions
  without task tracking)
- Remove 'or last_task_id' fallback from status_update and
  artifact_update handlers (spec guarantees task_id is always set)
- Remove additional_properties['input_required'] flag (content gating
  to INPUT_REQUIRED/terminal states is the signal itself)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 08:36:49 +00:00
371a869e44 Fix deprecated asyncio.iscoroutinefunction usage in test_cleanup_hooks.py (#4563)
Fixes #4522

Replace deprecated `asyncio.iscoroutinefunction()` with `inspect.iscoroutinefunction()`
to resolve Python 3.13+ deprecation warning.

Changes:
- Added `import inspect` to imports
- Replaced `asyncio.iscoroutinefunction(hook)` with `inspect.iscoroutinefunction(hook)` on line 126
- This makes the code consistent with other test methods in the same file (lines 201, 236)

The rest of the file already uses `inspect.iscoroutinefunction()` correctly, making
this change consistent with the existing codebase pattern.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Tao Chen <taochen@microsoft.com>
2026-05-28 02:29:31 +00:00
e532ced950 Add hosting samples overview README (#5407)
Co-authored-by: whenpoem <187613766+whenpoem@users.noreply.github.com>
2026-05-27 21:08:17 +00:00
44 changed files with 1625 additions and 973 deletions
@@ -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
View File
@@ -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__",
+157 -15
View File
@@ -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.
+2 -2
View File
@@ -4,7 +4,7 @@ description = "A2A integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.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",
]
+346 -50
View File
@@ -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
+3 -3
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.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"]
+1 -1
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.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",
]
@@ -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.
"""
@@ -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.
"""
@@ -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,
@@ -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.
"""
@@ -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,
}
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.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
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.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,
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.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
+2 -2
View File
@@ -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")
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.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
+34
View File
@@ -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.
+11 -11
View File
@@ -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'" },