Compare commits

...
Author SHA1 Message Date
Peter Ibekwe dca9dc081b Removed unnecessary export command 2026-05-01 15:38:49 -07:00
Peter Ibekwe 3c91ba4050 Fix conversation ID dot parsing for http executor 2026-04-30 19:00:25 -07:00
Peter Ibekwe 7999bf3c2d Ran pyupgrade and pright to fix CI issues 2026-04-30 16:26:38 -07:00
Peter Ibekwe ccf22ac963 Add Python parity for HttpRequestAction in declarative workflow 2026-04-30 15:19:58 -07:00
Peter IbekweandGitHub 6853f64de8 .NET: Add declarative HttpRequestAction sample (#5572)
* Add declarative HttpRequestAction support to workflows

* Clean up response body for diagnostics  and fix tests.

* Fix merge with main.

* Remove redundant fallback for request content headers.

* Add declarative InvokeHttpRequest sample

* Fix solution file and update sample yaml comments

* Add final newline to sample class to fix formatting failure
2026-04-29 19:19:31 +00:00
570a4d54c2 Python: Support OpenAI and Gemini allowed_tools tool choice (#5322)
* Support OpenAI allowed_tools in ToolMode (#5309)

Add allowed_tools field to ToolMode TypedDict, enabling users to restrict
which tools the model may call via the OpenAI allowed_tools tool_choice
type. This preserves prompt caching by keeping all tools in the tools list
while limiting which ones the model can invoke.

- Add allowed_tools: list[str] to ToolMode TypedDict
- Add validation in validate_tool_mode() (only valid when mode == "auto")
- Convert to OpenAI API format in _prepare_options()
- Add tests for validation and API payload generation

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

* Python: Support OpenAI `allowed_tools` tool choice in Python SDK

Fixes #5309

* Fix #5309: Validate allowed_tools shape and add Chat Completions client support

- validate_tool_mode now checks allowed_tools is a non-string sequence of
  strings and normalizes to list[str], raising ContentError for invalid types
- Add missing allowed_tools branch in _chat_completion_client._prepare_options
  so allowed_tools is emitted as the OpenAI allowed_tools wire format instead
  of being silently dropped
- Add tests for invalid allowed_tools types (string, int, mixed), empty list,
  tuple normalization, and Chat Completions client payload generation

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

* fix: support allowed_tools with mode 'required' in addition to 'auto'

OpenAI's allowed_tools tool_choice type supports both mode 'auto' and
'required'. Update validation, client conversion, and tests to allow
both modes instead of restricting to 'auto' only.

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

* fix: use Gemini VALIDATED mode for allowed_tools, warn in unsupported providers

- Use FunctionCallingConfigMode.VALIDATED instead of ANY when allowed_tools
  is set with auto mode in Gemini, preserving optional tool-call semantics.
- Handle allowed_tools in required mode with required_function_name precedence.
- Fix allowed_names guard to use identity check (is not None) so empty lists
  are preserved.
- Bump google-genai minimum to >=1.32.0 (VALIDATED added in that version).
- Add warnings in Anthropic and Bedrock when allowed_tools is set but not
  supported.
- Add Gemini unit tests for allowed_tools with auto, required, empty list,
  and required_function_name precedence scenarios.

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

* fix: Chat Completions API does not support allowed_tools, add integration tests

- Chat Completions API (_chat_completion_client.py) now warns and falls
  back to plain mode when allowed_tools is set, since the /chat/completions
  endpoint does not support the allowed_tools type.
- Add allowed_tools integration test param to both OpenAIChatClient
  (Responses API) and OpenAIChatCompletionClient parametrized option tests.
- Update Chat Completions unit tests to reflect the warn-and-fallback
  behavior.

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

* fix: remove unused walrus operator variable in chat completion client

Remove assigned-but-never-used variable 'allowed' flagged by ruff F841.

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-29 17:43:47 +00:00
Evan MattsonandGitHub f5419b9f38 Python: bump package versions for 1.2.2 release (#5561)
* Python: bump package versions for 1.2.2 release

PATCH bump (1.2.1 -> 1.2.2) for the released cohort. Five PRs land in this
window:

- agent-framework-openai: fix file_search citations breaking the assistant-
  message history roundtrip (#5557) — drives the released-tier PATCH
- agent-framework-orchestrations: [BREAKING] standardize orchestration
  terminal outputs as AgentResponse (#5301)
- agent-framework-core, agent-framework-declarative: preserve Workflow.run()
  shared state across calls, accept list[Message] in declarative start
  executor, and coerce Enum values when serializing PowerFx symbols (#5531)
- agent-framework-foundry-hosting: add hosted Durable Workflow support
  (#5531)
- agent-framework-azure-contentunderstanding: new alpha package — Azure AI
  Content Understanding context provider (#4829)
- dependencies: workspace package dependency refresh (#5555)

Per lockstep convention, all 21 beta packages stamp 1.0.0b260429 and all 4
alpha packages (now including the new contentunderstanding) stamp
1.0.0a260429. Date stamp reflects 2026-04-29 Pacific. Every non-core package
floor on agent-framework-core is raised to >=1.2.2; the new
contentunderstanding package's stale >=1.0.0 floor is brought into line.

Two follow-on fixes bundled to keep validate-dependency-bounds-test green
at lowest-direct resolution:
- Bump agent-framework-azure-contentunderstanding's azure-ai-content
  understanding lower bound from >=1.0.0 to >=1.0.1 (1.0.0 ships without
  proper typing — pyright reports 65 unknown-type errors)
- Add pyright ignore comments to core/foundry/__init__.pyi for the new
  alpha package's type-stub imports, since alpha packages are not in
  core's [all] extra and therefore aren't installed at lowest-direct

* Python: add #5552 to 1.2.2 CHANGELOG

Add the streaming-span observability fix to the Fixed section. PR is on
upstream/main but not yet pulled into origin/main; the code itself will
land via the PR merge.

* Python: address PR #5561 review feedback on dependency bounds

Two packaging fixes flagged in review:

1. agent-framework-azure-contentunderstanding: add agent-framework-foundry
   as a runtime dependency. The package's README directs users to
   `pip install agent-framework-azure-contentunderstanding --pre` and the
   basic example imports `FoundryChatClient` from `agent_framework.foundry`,
   so the documented install path was failing with ImportError. Pulling
   agent-framework-foundry into deps makes the advertised entry path
   self-contained.

2. agent-framework-foundry: bump agent-framework-openai lower bound from
   >=1.1.0 to >=1.2.2,<2. Foundry imports private modules from
   agent_framework_openai (`_chat_client.py:22`, `_agent.py:34`), so
   resolvers were free to pair foundry==1.2.2 with older OpenAI versions
   that lack this release's coordinated Responses/history fix. Lockstep the
   floor with the released cohort to prevent mismatched installs.

Both changes pass `validate-dependency-bounds-test` lower + upper at
their respective packages.
2026-04-29 17:51:48 +09:00
Tao ChenandGitHub 03e47b5232 Python: Fix spans not correctly nested when using streaming (#5552)
* Fix spans not correctly nested when using streaming

* fix pre commit

* Address comments
2026-04-29 08:21:28 +00:00
Evan MattsonandGitHub 46ab47b9e1 Python: Fix file_search citations breaking assistant history roundtrip (#5557)
* Python: Fix file_search citations breaking assistant history roundtrip

The Responses API rejects 'input_file' inside an assistant message, but the
SDK was emitting it whenever an assistant Message contained a hosted_file
content (which is what file_search citations become). Three coordinated fixes:

1. _prepare_content_for_openai now skips hosted_file for the assistant role
   instead of mapping to input_file (which the API rejects there).

2. The streaming response.output_text.annotation.added handler attaches
   file_citation, container_file_citation, and file_path as annotations on
   text content, matching the non-streaming path. Previously streaming
   produced standalone HostedFileContent items that always tripped (1).

3. output_text serialization preserves Annotation objects on roundtrip via a
   new _annotations_to_output_text helper instead of hardcoding 'annotations'
   to []. file_search citations now survive multi-agent forwarding.

Closes #5556.

* Address PR review

- _annotations_to_output_text: fan out one entry per annotated_region for
  url_citation/container_file_citation (Annotation.annotated_regions is a
  Sequence; the API form carries one start/end per entry).
- Validate region span bounds are ints before emitting; skip otherwise.
- Add test for the file_path branch (annotation with file_id only).
- Add test verifying streamed citation events coalesce onto surrounding
  text via _finalize_response so span indices reference the merged text,
  not the empty-text streaming carrier.
2026-04-29 07:38:19 +00:00
Evan MattsonandGitHub 094f9903b3 Python: Update package dependencies (#5555)
* Update dependencies

* Preserve mcp[ws] and uvicorn[standard] extras in override-dependencies

Bare-package overrides on mcp and uvicorn dropped the [ws] and [standard]
extras (and their transitive deps like httptools, watchfiles) from the
generated lock. Re-add the extras to the overrides so the lock matches
what workspace packages actually request.
2026-04-29 06:18:03 +00:00
8b71f9459a Python: Feature/hosted dwf (#5531)
* Fix declarative Workflow.as_agent() by accepting list[Message] in start executor

The declarative start executor (JoinExecutor) only advertised dict and str
in its input_types, so WorkflowAgent.__init__ rejected it with
'Workflow's start executor cannot handle list[Message]'.

Add list[Message] to the JoinExecutor handler annotation and add a
matching branch in DeclarativeActionExecutor._ensure_state_initialized
that extracts the last user-message text and falls through to the
string-input initialization path, so =System.LastMessageText works
end-to-end via as_agent().

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

* Populate Conversation.messages from list[Message] trigger

When Workflow.as_agent() is invoked with a list[Message], the start executor now populates Conversation.messages / Conversation.history / System.conversations.{id}.messages with prior turns only (excluding the latest user message), and surfaces the latest user message via Inputs.input and System.LastMessage*. This matches InvokeAzureAgent's contract that the messages binding holds prior turns and the executor itself appends the new user input before invoking, avoiding double-append of the trailing user turn while preserving full history (incl. assistant/system/tool roles and multi-modal content) for downstream actions.

* Coerce Enum values when serializing PowerFx symbols

MessageRole and other str-subclass Enums passed isinstance(v, str) and were forwarded to pythonnet unchanged. pythonnet then raised 'MessageRole value cannot be converted to System.String' for every PowerFx primitive when ConditionGroup/Expr eval walked the symbol table containing Conversation.messages. Reduce Enum members to their underlying value before the primitive check so eval sees plain strings/ints.

* Foundry hosting: pass full conversation history to workflow agents

_handle_inner_workflow only forwarded the latest user turn to WorkflowAgent.run, even though _handle_inner_agent already prepends history fetched from Foundry storage to the messages it sends a regular agent. Declarative workflows reset Conversation.messages on every run (state.initialize), so checkpoint replay alone does not give them prior turns - the host has to pass them in, the same way it does for non-workflow agents. Mirror that contract: fetch context.get_history() and pass [*history, *input_messages] to the workflow agent.

* feat(workflows): support combined message + checkpoint_id for multi-turn continuation

Allow Workflow.run(message=..., checkpoint_id=...) so callers can restore
prior workflow state from a checkpoint AND deliver a new message to the
start executor in a single call. The existing reset_context logic
already preserves shared state when checkpoint_id is set, so this gives
us 'fresh start executor invocation with prior state intact' - exactly
what hosted multi-turn declarative workflows need.

- _workflow.py: drop the message+checkpoint_id mutual exclusion and
  update _execute_with_message_or_checkpoint to do both (restore then
  execute) when both are provided.
- _agent.py: in _run_core's checkpoint branch, also forward
  input_messages so WorkflowAgent.run(messages, checkpoint_id=...) works
  end-to-end. Falls back to the legacy 'restore only' behavior when
  messages are absent.
- _declarative_base.py: detect continuation in _ensure_state_initialized
  by checking whether DECLARATIVE_STATE_KEY already exists in shared
  state; if so, refresh inputs/LastMessage* and append non-user trigger
  messages instead of calling state.initialize() (which would wipe
  Conversation/Local/System).
- foundry_hosting/_responses.py: collapse the host's two-call pattern
  (restore-only, then fresh run) into a single combined call now that
  the underlying APIs support it.
- tests: drop the assertion that combined message+checkpoint_id raises.

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

* Pivot: preserve workflow state across run() calls

Replace the prior 'combined message + checkpoint_id in one run()' approach
with a cleaner default: Workflow.run no longer wipes shared state or runner-
context messages between calls. Iteration counting and per-run kwargs still
reset on a fresh-message run; checkpoint and responses runs are continuations
that preserve everything.

This lets a WorkflowAgent be invoked repeatedly on the same instance and
maintain multi-turn context (e.g. accumulated Conversation.messages) without
asking developers to opt in. Hosted-agent multi-turn pattern becomes two
explicit calls: restore-from-checkpoint (drive to idle), then run-with-message.

Key changes:
- _workflow.py: drop _state.clear() and reset_for_new_run() from run().
  Reset iteration count and run kwargs on fresh-message runs only.
  Restore 'Cannot provide both message and checkpoint_id' validation.
  Add async guard: fresh-message run with un-drained pending executor
  messages from a prior run is invalid.
- _runner.py: clear _state before import_state in restore_from_checkpoint
  so restore is authoritative (import_state merges, not replaces).
- _agent.py: revert checkpoint branch to restore-only (no message forward).
- _responses.py (foundry_hosting): two-call host pattern - restore checkpoint
  silently, then run with new user input.
- tests: state-preservation is the new default; rebuild Workflow for clean slate.

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

* Fix CI lint and mypy issues from prior pivot commit

- _workflow.py: collapse nested if (SIM102), drop redundant assignment (RET504)
- _declarative_base.py: remove unused last_user_msg = tail assignment
  whose Message | None type clashed with the prior Message-typed branch

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

* Address PR review: fix Inputs.input update and checkpoint storage path

- _declarative_base.py: continuation branch was writing 'Inputs.input' via
  state.set, which routes to the Custom namespace and never updates the
  PowerFx-visible Workflow.Inputs.input. Update state_data['Inputs'] in
  place via get_state_data / set_state_data so =Workflow.Inputs.input and
  =inputs.input see the new turn's user text on continuation.
- _declarative_base.py: refresh docstring to clarify that on a list[Message]
  trigger, Conversation.messages excludes the current user message at the
  start of the turn (agent executors append it before invoking the inner
  agent).
- _responses.py: when previous_response_id is supplied (no conversation_id),
  the prior checkpoint lives under <storage>/<previous_response_id> but new
  checkpoints must land under <storage>/<current_response_id> for the next
  turn to find them. Hold onto restore_storage from the get_latest lookup
  and pass it to the restore-only run; pass write_storage (current id) to
  the message-delivery run and to checkpoint cleanup.

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

* Fix pyright errors in _declarative_base.py for CI

- Replace state._state.get(...) protected access with new public
  is_initialized() method on DeclarativeWorkflowState (also clearer intent
  for the continuation detection use case).
- Add narrow pyright ignores for the Any-typed trigger paths that pyright
  cannot fully narrow (the list[Message] isinstance loop and the
  fallback-DefaultTransform branch).

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

* Address Copilot review batch: tests + Workflow.reset escape hatch

* Add Workflow.reset() public method as recovery escape hatch when an
  in-flight run aborted (e.g. WorkflowConvergenceException) and the
  workflow is not checkpointed. Update the in-flight messages guard's
  error message to point callers at it.

* Add test_workflow_run_inflight_messages_guard exercising both the
  guard (sync + streaming) and the reset() recovery path.
* Add test_workflow_reset_rejects_concurrent_runs to lock down the
  in-progress guard on reset.

* Add test_as_agent_continuation_preserves_prior_state covering the
  is_continuation branch in _ensure_state_initialized: stamps a marker
  between calls and asserts it survives, while Inputs.input and
  System.LastMessageText refresh to the new turn.

* Add test_powerfx_safe.py regression tests for the Enum branch in
  _make_powerfx_safe (str-subclass, int-subclass, plain Enum, and
  Enums nested in dict/list).

* Drop redundant @pytest.mark.asyncio on
  test_as_agent_round_trip_with_last_message_text (asyncio_mode='auto').

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

* Skip restore-only pre-pass when checkpoint has pending request_info

Address Copilot review on _responses.py: the restore-only checkpoint
replay populates self._agent.pending_requests for any request_info
events captured in the checkpoint. The follow-up run(input_messages)
call would then route through WorkflowAgent._process_pending_requests,
which expects function-response content and rejects plain text input
as 'unexpected content while awaiting request info responses'.

Workflows resumed from a checkpoint that was idle-with-pending-requests
would therefore fail every subsequent plain-text user turn. Inspect the
loaded checkpoint and skip the pre-pass when its
pending_request_info_events dict is non-empty. Workflows that don't use
request_info (the current sample set) are unaffected; workflows that do
will fall through to a fresh-message run rather than silently corrupting
the routing state.

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

* Loosen azure-ai-agentserver-* pins to major version

The exact-version pins on azure-ai-agentserver-{core,responses,invocations}
forced foundry-hosting consumers to upgrade in lockstep with every beta
bump from upstream. Switch to '>=current,<next-major' so we pick up patch
and feature updates within the same major series without a coordinated
release.

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

* Drop Workflow.reset(); checkpointing is the recovery path

The in-flight-messages guard prevented silent misbehavior, but the
companion Workflow.reset() escape hatch only cleared _messages while
leaving iteration count, executor-local state, and shared State
mutations in an indeterminate condition after a mid-run failure. That
gave a false sense of recovery.

Recovery from a mid-run failure is supported only via checkpoint
restoration. Keep the guard and reframe its error message accordingly;
remove reset() and its tests.

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

* Address Tao's review on PR 5531

- Rename Workflow._run_workflow_with_tracing parameter
  is_fresh_message_run -> is_continuation (default False, inverted).
  Fresh-message turns reset per-run accounting; continuations
  (checkpoint restores, responses replays) preserve it.
- Simplify the in-flight-messages guard: _validate_run_params already
  enforces that 'message' is mutually exclusive with 'checkpoint_id'
  and 'responses', so the additional checks were dead code.
- foundry_hosting _responses: move the restore-only pre-pass above
  emit_created/emit_in_progress; restore is preparation, not run
  progress. Drop the skip-restore gate (state preservation requires
  unconditional restore) and instead clear agent.pending_requests
  after the restore-only call. Collapse over-conditioned check.

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

* Don't clear pending_requests after restore-only pre-pass

Pending requests in the restored checkpoint represent genuinely
outstanding HITL requests. The next user input may carry function
responses (Responses API `function_call_output` items become
FunctionResultContent / FunctionApprovalResponseContent), which
`WorkflowAgent._process_pending_requests` correctly extracts and
matches against the populated `pending_requests`. Clearing them
after restore would silently drop that state and force the next turn
to be treated as a fresh input even when the caller is responding to
the outstanding requests.

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

---------

Co-authored-by: alliscode <bentho@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Evan Mattson <35585003+moonbox3@users.noreply.github.com>
2026-04-29 00:51:49 +00:00
87 changed files with 5408 additions and 1253 deletions
+5 -4
View File
@@ -163,10 +163,10 @@
<Project Path="samples/02-agents/AgentsWithFoundry/Agent_Step25_ToolboxServerSideTools/Agent_Step25_ToolboxServerSideTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/Evaluation/">
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_CustomEvals/Evaluation_CustomEvals.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_ExpectedOutputs/Evaluation_ExpectedOutputs.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_Multimodal/Evaluation_Multimodal.csproj" />
<Project Path="samples/02-agents/Evaluation/Evaluation_SimpleEval/Evaluation_SimpleEval.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/AgentWithMemory/">
<File Path="samples/02-agents/AgentWithMemory/README.md" />
@@ -226,6 +226,7 @@
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
<Project Path="samples/03-workflows/Declarative/StudentTeacher/StudentTeacher.csproj" />
@@ -347,17 +348,17 @@
<File Path="samples/02-agents/A2A/README.md" />
<Project Path="samples/02-agents/A2A/A2AAgent_AsFunctionTools/A2AAgent_AsFunctionTools.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_PollingForTaskCompletion/A2AAgent_PollingForTaskCompletion.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_ProtocolSelection/A2AAgent_ProtocolSelection.csproj" />
<Project Path="samples/02-agents/A2A/A2AAgent_StreamReconnection/A2AAgent_StreamReconnection.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/">
<Project Path="samples/05-end-to-end/AgentWithPurview/AgentWithPurview.csproj" />
<Project Path="samples/05-end-to-end/M365Agent/M365Agent.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/Evaluation/">
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_FoundryQuality/Evaluation_FoundryQuality.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_MixedProviders/Evaluation_MixedProviders.csproj" />
<Project Path="samples/05-end-to-end/Evaluation/Evaluation_ConversationSplits/Evaluation_ConversationSplits.csproj" />
</Folder>
<Folder Name="/Samples/05-end-to-end/A2AClientServer/">
<File Path="samples/05-end-to-end/A2AClientServer/README.md" />
@@ -543,8 +544,8 @@
<Project Path="src/Microsoft.Agents.AI.Declarative/Microsoft.Agents.AI.Declarative.csproj" />
<Project Path="src/Microsoft.Agents.AI.DevUI/Microsoft.Agents.AI.DevUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<InjectIsExternalInitOnLegacy>true</InjectIsExternalInitOnLegacy>
<InjectSharedFoundryAgents>true</InjectSharedFoundryAgents>
<InjectSharedWorkflowsExecution>true</InjectSharedWorkflowsExecution>
<InjectSharedWorkflowsSettings>true</InjectSharedWorkflowsSettings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative\Microsoft.Agents.AI.Workflows.Declarative.csproj" />
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Workflows.Declarative.Foundry\Microsoft.Agents.AI.Workflows.Declarative.Foundry.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="InvokeHttpRequest.yaml">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -0,0 +1,76 @@
#
# This workflow demonstrates using HttpRequestAction to call a REST API directly
# from the workflow without going through an AI agent first.
#
# HttpRequestAction allows workflows to:
# - Fetch data from external HTTP endpoints
# - Store the parsed response in workflow variables for later use
# - Add the response body to the conversation so a downstream agent can
# answer questions based on it
#
# This sample fetches public metadata for the dotnet/runtime repository from
# the GitHub REST API (no authentication required) and uses an agent to
# answer follow-up questions about it.
#
# Example input:
# How many subscribers does the repository have?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_http_request_demo
actions:
# Capture the original user message for input to the follow-up agent.
- kind: SetVariable
id: set_user_message
variable: Local.InputMessage
value: =System.LastMessage
# Set the repository org/name used to form the request URL.
- kind: SetVariable
id: set_repo_name
variable: Local.RepoName
value: microsoft/agent-framework
# Invoke the GitHub repo API. The response body is parsed into Local.RepoInfo
# and also added to the conversation (via conversationId) so the agent below
# can answer questions based on it.
- kind: HttpRequestAction
id: fetch_repo_info
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-sample
response: Local.RepoInfo
# Display a confirmation message showing key fields from the parsed response.
- kind: SendMessage
id: show_repo_summary
message: "Fetched repo: visibility={Local.RepoInfo.visibility}, description={Local.RepoInfo.description}"
# Use the agent to summarize the repo using the conversation context.
- kind: InvokeAzureAgent
id: summarize_repo
conversationId: =System.ConversationId
agent:
name: GitHubRepoInfoAgent
input:
messages: =UserMessage("Please provide a brief summary of this GitHub repository based on the data already in the conversation.")
output:
autoSend: true
messages: Local.AgentResponse
# Allow the user to ask follow-up questions about the repo in a loop.
- kind: InvokeAzureAgent
id: invoke_followup
conversationId: =System.ConversationId
agent:
name: GitHubRepoInfoAgent
input:
messages: =Local.InputMessage
externalLoop:
when: =Upper(System.LastMessage.Text) <> "EXIT"
@@ -0,0 +1,95 @@
// Copyright (c) Microsoft. All rights reserved.
using Azure.AI.Projects;
using Azure.AI.Projects.Agents;
using Azure.Identity;
using Microsoft.Agents.AI.Workflows.Declarative;
using Microsoft.Extensions.Configuration;
using Shared.Foundry;
using Shared.Workflows;
namespace Demo.Workflows.Declarative.InvokeHttpRequest;
/// <summary>
/// Demonstrates a workflow that uses HttpRequestAction to call a REST API
/// directly from the workflow.
/// </summary>
/// <remarks>
/// <para>
/// The HttpRequestAction allows workflows to issue HTTP requests and:
/// </para>
/// <list type="bullet">
/// <item>Fetch data from external REST endpoints</item>
/// <item>Store the parsed response in workflow variables</item>
/// <item>Add the response body to the conversation so an agent can answer
/// questions based on it</item>
/// </list>
/// <para>
/// This sample fetches public metadata for the dotnet/runtime repository from
/// the GitHub REST API (no authentication required) and uses a Foundry agent
/// to answer follow-up questions about it. Type "EXIT" to end the conversation.
/// </para>
/// <para>
/// See the README.md file in the parent folder (../README.md) for detailed
/// information about the configuration required to run this sample.
/// </para>
/// </remarks>
internal sealed class Program
{
public static async Task Main(string[] args)
{
// Initialize configuration
IConfiguration configuration = Application.InitializeConfig();
Uri foundryEndpoint = new(configuration.GetValue(Application.Settings.FoundryEndpoint));
// Ensure sample agent exists in Foundry. The agent has no tools - it answers
// questions about the GitHub repository using only the JSON data that the
// HttpRequestAction adds to the conversation.
await CreateAgentAsync(foundryEndpoint, configuration);
// Get input from command line or console
string workflowInput = Application.GetInput(args);
// The default HttpRequestHandler is sufficient for this sample because the
// GitHub REST endpoint used here does not require authentication. For
// authenticated endpoints, supply a custom Func<HttpRequestInfo, ..., HttpClient?>
// to DefaultHttpRequestHandler so each request can be routed through a
// pre-configured (cached) HttpClient with the appropriate credentials.
await using DefaultHttpRequestHandler httpRequestHandler = new();
// Create the workflow factory with the HTTP request handler
WorkflowFactory workflowFactory = new("InvokeHttpRequest.yaml", foundryEndpoint)
{
HttpRequestHandler = httpRequestHandler
};
// Execute the workflow
WorkflowRunner runner = new() { UseJsonCheckpoints = true };
await runner.ExecuteAsync(workflowFactory.CreateWorkflow, workflowInput);
}
private static async Task CreateAgentAsync(Uri foundryEndpoint, IConfiguration configuration)
{
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
AIProjectClient aiProjectClient = new(foundryEndpoint, new DefaultAzureCredential());
await aiProjectClient.CreateAgentAsync(
agentName: "GitHubRepoInfoAgent",
agentDefinition: DefineAgent(configuration),
agentDescription: "Answers questions about a GitHub repository using HTTP response data in the conversation");
}
private static DeclarativeAgentDefinition DefineAgent(IConfiguration configuration)
{
return new DeclarativeAgentDefinition(configuration.GetValue(Application.Settings.FoundryModel))
{
Instructions =
"""
Answer the user's questions about the GitHub repository using only the
JSON data already present in the conversation history.
If the answer is not contained in the conversation, say so plainly
rather than guessing. Be concise and helpful.
"""
};
}
}
@@ -16,6 +16,60 @@ internal static class ChatMessageExtensions
public static RecordValue ToRecord(this ChatMessage message) =>
FormulaValue.NewRecordFromFields(message.GetMessageFields());
/// <summary>
/// Merges the user-authored <paramref name="input"/> with the round-tripped
/// <paramref name="inputMessage"/> returned by <c>AgentProvider.CreateMessageAsync</c>
/// to produce the value stored in <c>System.LastMessage</c>.
/// </summary>
/// <remarks>
/// The agent service often strips or alters <see cref="TextContent"/> on round-trip,
/// while replacing inline media (<see cref="DataContent"/>, <see cref="UriContent"/>)
/// with server-side references (typically <see cref="HostedFileContent"/>).
/// We want both: the original text (so <c>=System.LastMessage.Text</c> works) and
/// the server's media references (so subsequent actions don't re-upload large blobs).
/// <para>
/// Strategy: keep <paramref name="inputMessage"/> as the base — it has the server-generated
/// <see cref="ChatMessage.MessageId"/> and any provider-augmented metadata, and is forward-
/// compatible with new properties added on <see cref="ChatMessage"/> in the abstractions
/// layer. Only the <see cref="ChatMessage.Contents"/> list is mutated to substitute
/// original <see cref="TextContent"/> items in place (and append any extras the round-trip
/// dropped). Non-text content items returned by the service are left untouched so
/// server-side references survive.
/// </para>
/// </remarks>
public static ChatMessage MergeForLastMessage(this ChatMessage input, ChatMessage? inputMessage)
{
if (inputMessage is null)
{
return input;
}
// Build a queue of the original text items, in order. Fall back to ChatMessage.Text
// if the input has no explicit TextContent entries.
Queue<TextContent> originalTexts = new(input.Contents.OfType<TextContent>());
if (originalTexts.Count == 0 && !string.IsNullOrEmpty(input.Text))
{
originalTexts.Enqueue(new TextContent(input.Text));
}
// Replace TextContent items in inputMessage.Contents with the originals, in order.
for (int i = 0; i < inputMessage.Contents.Count && originalTexts.Count > 0; i++)
{
if (inputMessage.Contents[i] is TextContent)
{
inputMessage.Contents[i] = originalTexts.Dequeue();
}
}
// Append any remaining original text items that the round-trip dropped entirely.
while (originalTexts.Count > 0)
{
inputMessage.Contents.Add(originalTexts.Dequeue());
}
return inputMessage;
}
public static TableValue ToTable(this IEnumerable<ChatMessage> messages) =>
FormulaValue.NewTable(TypeSchema.Message.RecordType, messages.Select(message => message.ToRecord()));
@@ -43,7 +43,11 @@ internal sealed class DeclarativeWorkflowExecutor<TInput>(
await declarativeContext.QueueConversationUpdateAsync(conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await options.AgentProvider.CreateMessageAsync(conversationId, input, cancellationToken).ConfigureAwait(false);
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
// Use the original input for System.LastMessage to ensure Text is preserved (the
// service may strip text on round-trip), but substitute server-side media references
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await context.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
@@ -58,7 +58,6 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
public override async ValueTask HandleAsync(TInput message, IWorkflowContext context, CancellationToken cancellationToken)
{
DeclarativeWorkflowContext declarativeContext = new(context, this._state);
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
ChatMessage input = (this._inputTransform ?? DefaultInputTransform).Invoke(message);
@@ -69,7 +68,13 @@ public abstract class RootExecutor<TInput> : Executor<TInput>, IResettableExecut
await declarativeContext.QueueConversationUpdateAsync(this._conversationId, isExternal: true, cancellationToken).ConfigureAwait(false);
ChatMessage inputMessage = await this._agentProvider.CreateMessageAsync(this._conversationId, input, cancellationToken).ConfigureAwait(false);
await declarativeContext.SetLastMessageAsync(inputMessage).ConfigureAwait(false);
// Use the original input for System.LastMessage to ensure Text is preserved (the
// service may strip text on round-trip), but substitute server-side media references
// (e.g., HostedFileContent) so subsequent actions don't re-upload large blobs.
await declarativeContext.SetLastMessageAsync(input.MergeForLastMessage(inputMessage)).ConfigureAwait(false);
await this.ExecuteAsync(message, declarativeContext, cancellationToken).ConfigureAwait(false);
await declarativeContext.SendResultMessageAsync(this.Id, cancellationToken).ConfigureAwait(false);
}
@@ -25,6 +25,9 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
// Assign to provide MCP tool capabilities
public IMcpToolHandler? McpToolHandler { get; init; }
// Assign to enable HttpRequestAction support
public IHttpRequestHandler? HttpRequestHandler { get; init; }
/// <summary>
/// Create the workflow from the declarative YAML. Includes definition of the
/// <see cref="DeclarativeWorkflowOptions" /> and the associated <see cref="ResponseAgentProvider"/>.
@@ -46,6 +49,7 @@ internal sealed class WorkflowFactory(string workflowFile, Uri foundryEndpoint)
ConversationId = this.ConversationId,
LoggerFactory = this.LoggerFactory,
McpToolHandler = this.McpToolHandler,
HttpRequestHandler = this.HttpRequestHandler,
};
string workflowPath = Path.Combine(AppContext.BaseDirectory, workflowFile);
@@ -162,7 +162,10 @@ internal sealed class WorkflowRunner
case RequestInfoEvent requestInfo:
Debug.WriteLine($"REQUEST #{requestInfo.Request.RequestId}");
externalResponse = requestInfo.Request;
if (response is null || !string.Equals(requestInfo.Request.RequestId, response.RequestId, StringComparison.Ordinal))
{
externalResponse = requestInfo.Request;
}
break;
case ConversationUpdateEvent invokeEvent:
@@ -769,4 +769,165 @@ public sealed class ChatMessageExtensionsTests
break;
}
}
[Fact]
public void MergeForLastMessageReturnsInputWhenInputMessageIsNull()
{
// Arrange
ChatMessage input = new(ChatRole.User, "hello") { MessageId = "local" };
// Act
ChatMessage result = input.MergeForLastMessage(null);
// Assert
Assert.Same(input, result);
}
[Fact]
public void MergeForLastMessageReturnsSameInstanceAsRoundTripped()
{
// Arrange: returning the round-tripped instance keeps the merge forward-compatible
// with future ChatMessage properties (e.g., new metadata fields) without explicit copies.
ChatMessage input = new(ChatRole.User, "original");
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Same(roundTripped, result);
}
[Fact]
public void MergeForLastMessagePrefersOriginalTextOverRoundTrippedText()
{
// Arrange
ChatMessage input = new(ChatRole.User, "original text");
ChatMessage roundTripped = new(ChatRole.User, "stripped") { MessageId = "server-id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("server-id", result.MessageId);
Assert.Equal("original text", result.Text);
TextContent text = Assert.IsType<TextContent>(Assert.Single(result.Contents));
Assert.Equal("original text", text.Text);
}
[Fact]
public void MergeForLastMessageReplacesTextInPlaceAndKeepsServerMedia()
{
// Arrange
HostedFileContent serverRef = new("file-abc");
ChatMessage input = new(ChatRole.User, [new TextContent("look at this:"), new DataContent("data:image/jpeg;base64,QUJD", "image/jpeg")]);
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped"), serverRef]) { MessageId = "server-id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: server's text slot is replaced with original text; server's media reference is preserved.
Assert.Equal("server-id", result.MessageId);
Assert.Collection(result.Contents,
c => Assert.Equal("look at this:", Assert.IsType<TextContent>(c).Text),
c => Assert.Same(serverRef, c));
}
[Fact]
public void MergeForLastMessageAppendsOriginalTextWhenRoundTripHasNoTextSlot()
{
// Arrange: round-tripped message has only media (no text slot to replace).
HostedFileContent serverRef = new("file-1");
ChatMessage input = new(ChatRole.User, [new TextContent("middle"), new DataContent("data:image/jpeg;base64,QUE=", "image/jpeg")]);
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: media kept; original text appended at end.
Assert.Collection(result.Contents,
c => Assert.Same(serverRef, c),
c => Assert.Equal("middle", Assert.IsType<TextContent>(c).Text));
}
[Fact]
public void MergeForLastMessageReplacesMultipleTextSlotsInOrder()
{
// Arrange: input has two text items; round-tripped has two text slots interleaved with media.
HostedFileContent firstRef = new("file-1");
HostedFileContent secondRef = new("file-2");
ChatMessage input = new(ChatRole.User, [new TextContent("first"), new TextContent("second")]);
ChatMessage roundTripped = new(ChatRole.User, [firstRef, new TextContent("a"), secondRef, new TextContent("b")]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Collection(result.Contents,
c => Assert.Same(firstRef, c),
c => Assert.Equal("first", Assert.IsType<TextContent>(c).Text),
c => Assert.Same(secondRef, c),
c => Assert.Equal("second", Assert.IsType<TextContent>(c).Text));
}
[Fact]
public void MergeForLastMessageFallsBackToInputTextWhenInputHasNoTextContent()
{
// Arrange: ChatMessage(role, "string") populates Text but no explicit TextContent
// when Contents is initially empty in some construction paths. Verify we still
// recover the original Text via input.Text.
ChatMessage input = new(ChatRole.User, "fallback text");
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("fallback text", Assert.IsType<TextContent>(Assert.Single(result.Contents)).Text);
}
[Fact]
public void MergeForLastMessagePreservesServerAuthoredProperties()
{
// Arrange: server (round-trip) is authoritative for metadata. Returning the
// round-tripped instance means any future ChatMessage property is automatically
// preserved without code changes here.
ChatMessage input = new(ChatRole.User, "hi")
{
AuthorName = "client-side",
AdditionalProperties = new AdditionalPropertiesDictionary { ["client"] = "value" },
};
ChatMessage roundTripped = new(ChatRole.User, [new TextContent("stripped")])
{
MessageId = "server",
AuthorName = "server-side",
AdditionalProperties = new AdditionalPropertiesDictionary { ["server"] = "value" },
};
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert
Assert.Equal("server", result.MessageId);
Assert.Equal("server-side", result.AuthorName);
Assert.NotNull(result.AdditionalProperties);
Assert.True(result.AdditionalProperties.ContainsKey("server"));
Assert.False(result.AdditionalProperties.ContainsKey("client"));
}
[Fact]
public void MergeForLastMessageHandlesEmptyInputContents()
{
// Arrange
ChatMessage input = new(ChatRole.User, new List<AIContent>());
HostedFileContent serverRef = new("file-only");
ChatMessage roundTripped = new(ChatRole.User, [serverRef]) { MessageId = "id" };
// Act
ChatMessage result = input.MergeForLastMessage(roundTripped);
// Assert: nothing to splice; round-tripped returned unchanged.
Assert.Same(roundTripped, result);
Assert.Equal("file-only", Assert.IsType<HostedFileContent>(Assert.Single(result.Contents)).FileId);
}
}
+17 -1
View File
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.2.2] - 2026-04-29
### Added
- **agent-framework-azure-contentunderstanding**: New alpha package — Azure AI Content Understanding context provider that auto-analyzes file attachments (documents, images, audio, video) and injects structured results into the LLM context, with multi-document session state, configurable timeout, output filtering via `AnalysisSection`, and auto-registered `list_documents` / `get_analyzed_document` tools ([#4829](https://github.com/microsoft/agent-framework/pull/4829))
- **agent-framework-foundry-hosting**: Add hosted Durable Workflow support — propagate full conversation history to workflow agents and wire `Workflow.as_agent()` end-to-end via the foundry hosting layer ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
### Changed
- **agent-framework-orchestrations**: [BREAKING] Standardize orchestration terminal outputs as `AgentResponse` so `Workflow.as_agent()` returns the final answer only; aligns sequential-approval (`with_request_info`) and concurrent (`intermediate_outputs=True`) flows on the same output contract ([#5301](https://github.com/microsoft/agent-framework/pull/5301))
- **agent-framework-core**, **agent-framework-declarative**: Preserve `Workflow.run()` shared state across calls so multi-turn `WorkflowAgent` invocations retain context, accept `list[Message]` input in the declarative start executor, and coerce `Enum` values when serializing PowerFx symbols ([#5531](https://github.com/microsoft/agent-framework/pull/5531))
- **dependencies**: Update workspace package dependencies and preserve `mcp[ws]` / `uvicorn[standard]` extras through override-dependencies in `/python` ([#5555](https://github.com/microsoft/agent-framework/pull/5555))
### Fixed
- **agent-framework-core**: Fix observability spans not being correctly nested when using streaming ([#5552](https://github.com/microsoft/agent-framework/pull/5552))
- **agent-framework-openai**: Fix `file_search` citations breaking the assistant-message history roundtrip — skip `hosted_file` content in the assistant role so the Responses API no longer rejects `input_file` ([#5557](https://github.com/microsoft/agent-framework/pull/5557))
## [1.2.1] - 2026-04-28
### Added
@@ -1003,7 +1018,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.2.1...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.2.2...HEAD
[1.2.2]: https://github.com/microsoft/agent-framework/compare/python-1.2.1...python-1.2.2
[1.2.1]: https://github.com/microsoft/agent-framework/compare/python-1.2.0...python-1.2.1
[1.2.0]: https://github.com/microsoft/agent-framework/compare/python-1.1.1...python-1.2.0
[1.1.1]: https://github.com/microsoft/agent-framework/compare/python-1.1.0...python-1.1.1
+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.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260428"
version = "1.0.0b260429"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"ag-ui-protocol>=0.1.16,<0.2",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
@@ -872,6 +872,8 @@ class RawAnthropicClient(
tool_mode = validate_tool_mode(options.get("tool_choice"))
if tool_mode is None:
return result or None
if "allowed_tools" in tool_mode:
logger.warning("allowed_tools is not supported by Anthropic; the setting will be ignored")
allow_multiple = options.get("allow_multiple_tool_calls")
match tool_mode.get("mode"):
case "auto":
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Anthropic integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -4,7 +4,7 @@ description = "Azure Content Understanding integration for Microsoft Agent Frame
authors = [{ name = "Microsoft", email = "af-support@microsoft.com" }]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260401"
version = "1.0.0a260429"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,9 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.0.0,<2",
"azure-ai-contentunderstanding>=1.0.0,<1.1",
"agent-framework-core>=1.2.2,<2",
"agent-framework-foundry>=1.2.2,<2",
"azure-ai-contentunderstanding>=1.0.1,<1.1",
"aiohttp>=3.9,<4",
"filetype>=1.2,<2",
]
@@ -15,12 +15,10 @@ import os
from pathlib import Path
from agent_framework import Agent, Content, Message
from agent_framework.foundry import FoundryChatClient
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from agent_framework.foundry import ContentUnderstandingContextProvider
load_dotenv()
"""
@@ -15,12 +15,10 @@ import os
from pathlib import Path
from agent_framework import Agent, AgentSession, Content, Message
from agent_framework.foundry import FoundryChatClient
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from agent_framework.foundry import ContentUnderstandingContextProvider
load_dotenv()
"""
@@ -16,12 +16,10 @@ import time
from pathlib import Path
from agent_framework import Agent, AgentSession, Content, Message
from agent_framework.foundry import FoundryChatClient
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from agent_framework.foundry import ContentUnderstandingContextProvider
load_dotenv()
"""
@@ -16,13 +16,11 @@ import os
from pathlib import Path
from agent_framework import Agent, AgentSession, Content, Message
from agent_framework.foundry import FoundryChatClient
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import BaseModel, Field
from agent_framework.foundry import ContentUnderstandingContextProvider
load_dotenv()
"""
@@ -21,13 +21,11 @@ Run with DevUI:
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.foundry import ContentUnderstandingContextProvider, FoundryChatClient
from azure.core.credentials import AzureKeyCredential
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from agent_framework.foundry import ContentUnderstandingContextProvider
load_dotenv()
# --- Auth ---
@@ -32,17 +32,16 @@ Run with DevUI:
import os
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient
from agent_framework.foundry import (
ContentUnderstandingContextProvider,
FileSearchConfig,
FoundryChatClient,
)
from azure.core.credentials import AzureKeyCredential
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from openai import AzureOpenAI
from agent_framework.foundry import (
ContentUnderstandingContextProvider,
FileSearchConfig,
)
load_dotenv()
# --- Auth ---
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
@@ -405,6 +405,8 @@ class BedrockChatClient(
tool_config = self._prepare_tools(options.get("tools"))
if tool_mode := validate_tool_mode(options.get("tool_choice")):
if "allowed_tools" in tool_mode:
logger.warning("allowed_tools is not supported by Bedrock; the setting will be ignored")
match tool_mode.get("mode"):
case "none":
# Bedrock doesn't support toolChoice "none".
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Amazon Bedrock integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
+67 -8
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import base64
import contextlib
import json
import logging
import re
@@ -2890,6 +2891,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
self._wrap_inner: bool = False
self._map_update: Callable[[Any], UpdateT | Awaitable[UpdateT]] | None = None
self._pull_context_manager_factories: list[Callable[[], contextlib.AbstractContextManager[Any]]] = []
def map(
self,
@@ -3008,11 +3010,18 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
return self
async def __anext__(self) -> UpdateT:
if self._iterator is None:
stream = await self._get_stream()
self._iterator = stream.__aiter__()
try:
update: UpdateT = await self._iterator.__anext__()
with contextlib.ExitStack() as stack:
for factory in self._pull_context_manager_factories:
stack.enter_context(factory())
# Resolve the underlying stream inside the pull contexts so that any
# spans/contexts created during stream resolution (e.g. inner chat
# completion spans created on the first pull of a wrapped agent stream)
# inherit the active context (e.g. an outer agent invoke span).
if self._iterator is None:
stream = await self._get_stream()
self._iterator = stream.__aiter__()
update: UpdateT = await self._iterator.__anext__()
except StopAsyncIteration:
self._consumed = True
await self._run_cleanup_hooks()
@@ -3038,9 +3047,25 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
update = hooked
return update
async def _resolve_stream_with_pull_contexts(self) -> AsyncIterable[UpdateT]:
"""Resolve the underlying stream while activating any registered pull context managers.
Used by ``__await__`` and ``get_final_response`` so that any spans/contexts created
during stream resolution (e.g. when the source is an Awaitable that internally
creates child telemetry spans) inherit the same active context as iterator pulls.
``__anext__`` resolves the stream inside its own ExitStack and so calls ``_get_stream``
directly.
"""
if self._stream is not None:
return await self._get_stream()
with contextlib.ExitStack() as stack:
for factory in self._pull_context_manager_factories:
stack.enter_context(factory())
return await self._get_stream()
def __await__(self) -> Any:
async def _wrap() -> ResponseStream[UpdateT, FinalT]:
await self._get_stream()
await self._resolve_stream_with_pull_contexts()
return self
return _wrap().__await__()
@@ -3064,10 +3089,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
"""
if self._wrap_inner:
if self._inner_stream is None:
# Use _get_stream() to resolve the awaitable - this properly handles
# Use _resolve_stream_with_pull_contexts() so that any spans/contexts
# created while resolving the awaitable (e.g. inner telemetry spans)
# inherit the same active context as iterator pulls. This also handles
# the case where _stream_source and _inner_stream_source are the same
# coroutine (e.g., from from_awaitable), avoiding double-await errors.
await self._get_stream()
await self._resolve_stream_with_pull_contexts()
if self._inner_stream is None:
raise RuntimeError("Inner stream not available")
if not self._finalized and not self._consumed:
@@ -3177,6 +3204,25 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
self._cleanup_hooks.append(hook)
return self
def with_pull_context_manager(
self,
cm_factory: Callable[[], contextlib.AbstractContextManager[Any]],
) -> ResponseStream[UpdateT, FinalT]:
"""Register a context manager factory invoked around each underlying iterator pull.
The factory is called once per ``__anext__`` and the returned context manager wraps
the await of the underlying iterator. This is useful for state that needs to be
active while the inner async work runs - for example, attaching an OpenTelemetry
span to the current context so child spans created by inner code (HTTP clients,
tool execution) are correctly parented.
Because the context manager is entered and exited within the same ``__anext__``
invocation, attach/detach style operations remain symmetric in the same async
context regardless of where the stream is iterated.
"""
self._pull_context_manager_factories.append(cm_factory)
return self
async def _run_cleanup_hooks(self) -> None:
if self._cleanup_run:
return
@@ -3200,10 +3246,12 @@ class ToolMode(TypedDict, total=False):
Fields:
mode: One of "auto", "required", or "none".
required_function_name: Optional function name when `mode == "required"`.
allowed_tools: Optional list of tool names when `mode` is `"auto"` or `"required"`.
"""
mode: Literal["auto", "required", "none"]
required_function_name: str
allowed_tools: list[str]
# region TypedDict-based Chat Options
@@ -3436,7 +3484,7 @@ def validate_tool_mode(
Returns:
A ToolMode dict (contains keys: "mode", and optionally
"required_function_name"), or ``None`` when not provided.
"required_function_name" or "allowed_tools"), or ``None`` when not provided.
Raises:
ContentError: If the tool_choice string is invalid.
@@ -3453,6 +3501,17 @@ def validate_tool_mode(
raise ContentError(f"Invalid tool choice: {tool_choice['mode']}")
if tool_choice["mode"] != "required" and "required_function_name" in tool_choice:
raise ContentError("tool_choice with mode other than 'required' cannot have 'required_function_name'")
if tool_choice["mode"] not in ("auto", "required") and "allowed_tools" in tool_choice:
raise ContentError("tool_choice 'allowed_tools' is only valid when mode is 'auto' or 'required'")
if "allowed_tools" in tool_choice:
allowed_tools = tool_choice["allowed_tools"]
if isinstance(allowed_tools, str) or not isinstance(allowed_tools, Sequence):
raise ContentError("tool_choice 'allowed_tools' must be a non-string sequence of strings")
if not all(isinstance(tool_name, str) for tool_name in allowed_tools):
raise ContentError("tool_choice 'allowed_tools' must contain only strings")
normalized_tool_choice = dict(tool_choice)
normalized_tool_choice["allowed_tools"] = list(allowed_tools)
return cast(ToolMode, normalized_tool_choice)
return tool_choice
@@ -437,6 +437,13 @@ class WorkflowAgent(BaseAgent):
yield event
elif checkpoint_id is not None:
# Restore the prior workflow state from the checkpoint. Shared
# state (e.g. accumulated conversation history maintained by the
# workflow's executors) survives across turns because Workflow.run
# no longer wipes state per call. Callers who want to deliver a
# new user message after restore should make a second
# `workflow.run(message=...)` call - they are NOT mutually
# exclusive on the same instance, but each must be its own call.
if streaming:
async for event in self.workflow.run(
stream=True,
@@ -278,7 +278,12 @@ class Runner:
"Please rebuild the original workflow before resuming."
)
# Restore state
# Restore state. Clear first so import_state (which merges) does
# not leak stale keys from a prior run on this Workflow instance.
# This matters more now that Workflow.run() no longer wipes state
# per call - the only reset point for shared state on a reused
# instance is at restore time.
self._state.clear()
self._state.import_state(checkpoint.state)
# Restore executor states using the restored state
await self._restore_executor_states()
@@ -299,7 +299,7 @@ class Workflow(DictConvertible):
async def _run_workflow_with_tracing(
self,
initial_executor_fn: Callable[[], Awaitable[None]] | None = None,
reset_context: bool = True,
is_continuation: bool = False,
streaming: bool = False,
function_invocation_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
client_kwargs: Mapping[str, Mapping[str, Any]] | Mapping[str, Any] | None = None,
@@ -310,13 +310,19 @@ class Workflow(DictConvertible):
of external callers to maintain context across different workflow runs.
Args:
initial_executor_fn: Optional function to execute initial executor
reset_context: Whether to reset the context for a new run
streaming: Whether to enable streaming mode for agents
initial_executor_fn: Optional function to execute initial executor.
is_continuation: True when this run is a continuation of prior
work (a checkpoint restore or a responses-only replay) rather
than a fresh new turn delivered via the start executor with
``message=...``. Continuations preserve per-run accounting
(iteration counter and run kwargs) from the prior turn;
fresh-message runs reset them. Shared workflow state is
preserved in both cases.
streaming: Whether to enable streaming mode for agents.
function_invocation_kwargs: Optional kwargs to store in State for function
invocations in subagents
invocations in subagents.
client_kwargs: Optional kwargs to store in State for chat client
invocations in subagents
invocations in subagents.
Yields:
WorkflowEvent: The events generated during the workflow execution.
@@ -345,16 +351,26 @@ class Workflow(DictConvertible):
in_progress = WorkflowEvent.status(WorkflowRunState.IN_PROGRESS)
yield in_progress # noqa: RUF070
# Reset context for a new run if supported
if reset_context:
# Per-run reset for fresh-message runs only. We deliberately
# do NOT clear shared workflow state (`_state.clear()`) or the
# runner context's in-flight messages (`reset_for_new_run()`)
# here - state and pending work persist across `run()` calls
# so that a `WorkflowAgent` can deliver multi-turn input on
# the same instance and have prior turns' context survive.
# Iteration counting and per-run kwargs ARE per-run though,
# so they're reset here.
if not is_continuation:
self._runner.reset_iteration_count()
self._runner.context.reset_for_new_run()
self._state.clear()
# Store run kwargs in State so executors can access them.
# Only overwrite when new kwargs are explicitly provided or state was
# just cleared (fresh run). On continuation (reset_context=False) with
# no new kwargs, preserve the kwargs from the original run.
# Per-run kwargs semantics:
# - On a fresh message run, prior kwargs go away (set to {}
# by default, or to the new kwargs if provided). This
# prevents stale kwargs from a prior turn leaking into the
# current turn.
# - On a continuation (checkpoint restore or responses), the
# prior run's kwargs are preserved unless the caller
# explicitly provides new kwargs.
if function_invocation_kwargs is not None or client_kwargs is not None:
combined_kwargs: dict[str, Any] = {}
if function_invocation_kwargs is not None:
@@ -366,11 +382,12 @@ class Workflow(DictConvertible):
client_kwargs, "client_kwargs"
)
self._state.set(WORKFLOW_RUN_KWARGS_KEY, combined_kwargs)
elif reset_context:
elif not is_continuation:
self._state.set(WORKFLOW_RUN_KWARGS_KEY, {})
self._state.commit() # Commit immediately so kwargs are available
# Set streaming mode after reset
# Set streaming mode (always set explicitly per run since
# reset_for_new_run() no longer runs to clear it).
self._runner_context.set_streaming(streaming)
# Execute initial setup if provided
@@ -585,13 +602,31 @@ class Workflow(DictConvertible):
if checkpoint_storage is not None:
self._runner.context.set_runtime_checkpoint_storage(checkpoint_storage)
initial_executor_fn, reset_context = self._resolve_execution_mode(
message, responses, checkpoint_id, checkpoint_storage
)
# Async validation: a fresh-message run is only allowed when the
# runner context has fully drained from any prior run. If it still
# has in-flight executor messages, the prior run didn't complete -
# the caller must either resume from a checkpoint or wait for the
# prior run to drain. (Pending request_info events are intentionally
# NOT blocked here: a follow-up run with message=... is the normal
# way to deliver a response to those pending requests, e.g. via
# WorkflowAgent._process_pending_requests.)
# NOTE: _validate_run_params already enforces that ``message`` is
# mutually exclusive with both ``checkpoint_id`` and ``responses``,
# so we don't need to re-check those here.
if message is not None and await self._runner.context.has_messages():
raise RuntimeError(
"Cannot start a new run with 'message' while in-flight executor "
"messages remain from a prior run. Resume from a checkpoint "
"(checkpoint_id=...) or wait for the prior run to complete. "
"Workflows that need to recover from a mid-run failure must use "
"checkpointing; there is no in-process recovery path."
)
initial_executor_fn = self._resolve_execution_mode(message, responses, checkpoint_id, checkpoint_storage)
async for event in self._run_workflow_with_tracing(
initial_executor_fn=initial_executor_fn,
reset_context=reset_context,
is_continuation=(message is None),
streaming=streaming,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=client_kwargs,
@@ -674,12 +709,8 @@ class Workflow(DictConvertible):
responses: Mapping[str, Any] | None,
checkpoint_id: str | None,
checkpoint_storage: CheckpointStorage | None,
) -> tuple[Callable[[], Awaitable[None]], bool]:
"""Determine the initial executor function and reset_context flag based on parameters.
Returns:
A tuple of (initial_executor_fn, reset_context).
"""
) -> Callable[[], Awaitable[None]]:
"""Determine the initial executor function based on parameters."""
if responses is not None:
if checkpoint_id is not None:
# Combined: restore checkpoint then send responses
@@ -689,13 +720,9 @@ class Workflow(DictConvertible):
else:
# Send responses only (requires pending requests in workflow state)
initial_executor_fn = functools.partial(self._send_responses_internal, responses)
return initial_executor_fn, False
return initial_executor_fn
# Regular run or checkpoint restoration
initial_executor_fn = functools.partial(
self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage
)
reset_context = message is not None and checkpoint_id is None
return initial_executor_fn, reset_context
return functools.partial(self._execute_with_message_or_checkpoint, message, checkpoint_id, checkpoint_storage)
async def _restore_and_send_responses(
self,
@@ -21,10 +21,15 @@ _IMPORTS = [
"AgentFactory",
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"DeclarativeActionError",
"DeclarativeLoaderError",
"DeclarativeWorkflowError",
"DefaultHttpRequestHandler",
"ExternalInputRequest",
"ExternalInputResponse",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"ProviderLookupError",
"ProviderTypeMapping",
"WorkflowFactory",
@@ -4,10 +4,15 @@ from agent_framework_declarative import (
AgentExternalInputRequest,
AgentExternalInputResponse,
AgentFactory,
DeclarativeActionError,
DeclarativeLoaderError,
DeclarativeWorkflowError,
DefaultHttpRequestHandler,
ExternalInputRequest,
ExternalInputResponse,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
ProviderLookupError,
ProviderTypeMapping,
WorkflowFactory,
@@ -18,10 +23,15 @@ __all__ = [
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentFactory",
"DeclarativeActionError",
"DeclarativeLoaderError",
"DeclarativeWorkflowError",
"DefaultHttpRequestHandler",
"ExternalInputRequest",
"ExternalInputResponse",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"ProviderLookupError",
"ProviderTypeMapping",
"WorkflowFactory",
@@ -15,7 +15,10 @@ from typing import Any
_IMPORTS: dict[str, tuple[str, str]] = {
"AnalysisSection": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
"AnthropicFoundryClient": ("agent_framework_anthropic", "agent-framework-anthropic"),
"ContentUnderstandingContextProvider": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
"ContentUnderstandingContextProvider": (
"agent_framework_azure_contentunderstanding",
"agent-framework-azure-contentunderstanding",
),
"DocumentStatus": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
"FileSearchBackend": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
"FileSearchConfig": ("agent_framework_azure_contentunderstanding", "agent-framework-azure-contentunderstanding"),
@@ -4,12 +4,12 @@
# Install the relevant packages for full type support.
from agent_framework_anthropic import AnthropicFoundryClient, RawAnthropicFoundryClient
from agent_framework_azure_contentunderstanding import (
AnalysisSection,
ContentUnderstandingContextProvider,
DocumentStatus,
FileSearchBackend,
FileSearchConfig,
from agent_framework_azure_contentunderstanding import ( # pyright: ignore[reportMissingImports]
AnalysisSection, # pyright: ignore[reportUnknownVariableType]
ContentUnderstandingContextProvider, # pyright: ignore[reportUnknownVariableType]
DocumentStatus, # pyright: ignore[reportUnknownVariableType]
FileSearchBackend, # pyright: ignore[reportUnknownVariableType]
FileSearchConfig, # pyright: ignore[reportUnknownVariableType]
)
from agent_framework_foundry import (
FoundryAgent,
@@ -26,6 +26,7 @@ from time import perf_counter, time_ns
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedDict, cast, overload
from dotenv import load_dotenv
from opentelemetry import context as otel_context
from opentelemetry import metrics, trace
from . import __version__ as version_info
@@ -1277,27 +1278,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
)
if stream:
result_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super_get_response(
messages=messages,
stream=True,
options=opts,
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=merged_client_kwargs,
),
)
span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL)
# Create span directly without trace.use_span() context attachment.
# Streaming spans are closed asynchronously in cleanup hooks, which run
# in a different async context than creation — using use_span() would
# cause "Failed to detach context" errors from OpenTelemetry.
operation = attributes.get(OtelAttr.OPERATION, "operation")
span_name = attributes.get(OtelAttr.REQUEST_MODEL, "unknown")
span = get_tracer().start_span(f"{operation} {span_name}")
span.set_attributes(attributes)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
_capture_messages(
span=span,
@@ -1319,6 +1301,24 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
def _record_duration() -> None:
duration_state["duration"] = perf_counter() - start_time
try:
result_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super_get_response(
messages=messages,
stream=True,
options=opts,
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=merged_client_kwargs,
),
)
except Exception as exception:
capture_exception(span=span, exception=exception, timestamp=time_ns())
_close_span()
raise
async def _finalize_stream() -> None:
from ._types import ChatResponse
@@ -1357,11 +1357,18 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
finally:
_close_span()
# Register a weak reference callback to close the span if stream is garbage collected
# without being consumed. This ensures spans don't leak if users don't consume streams.
wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = result_stream.with_cleanup_hook(
_record_duration
).with_cleanup_hook(_finalize_stream)
# The pull context manager attaches the span around each underlying iterator pull so
# that child spans created during the pull (e.g. HTTP requests, inner tool execution)
# are parented under this chat span. Attach and detach happen in the same async
# context as the pull, avoiding cross-context cleanup issues. The weakref finalizer
# ensures the span is closed even if the stream is garbage collected without being
# consumed.
wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = (
result_stream
.with_cleanup_hook(_record_duration)
.with_cleanup_hook(_finalize_stream)
.with_pull_context_manager(lambda: _activate_span(span))
)
weakref.finalize(wrapped_stream, _close_span)
return wrapped_stream
@@ -1543,23 +1550,8 @@ class AgentTelemetryLayer:
inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({})
if stream:
try:
run_result: object = execute()
if isinstance(run_result, ResponseStream):
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
elif isinstance(run_result, Awaitable):
result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
else:
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
except Exception:
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token)
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
raise
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)
operation = attributes.get(OtelAttr.OPERATION, "operation")
span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown")
span = get_tracer().start_span(f"{operation} {span_name}")
span.set_attributes(attributes)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
_capture_messages(
span=span,
@@ -1581,6 +1573,21 @@ class AgentTelemetryLayer:
def _record_duration() -> None:
duration_state["duration"] = perf_counter() - start_time
try:
run_result: object = execute()
if isinstance(run_result, ResponseStream):
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
elif isinstance(run_result, Awaitable):
result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
else:
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
except Exception as exception:
capture_exception(span=span, exception=exception, timestamp=time_ns())
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token)
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
_close_span()
raise
async def _finalize_stream() -> None:
from ._types import AgentResponse
@@ -1620,9 +1627,18 @@ class AgentTelemetryLayer:
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
_close_span()
wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook(
_record_duration
).with_cleanup_hook(_finalize_stream)
# The pull context manager attaches the span around each underlying iterator pull so
# that child spans created during the pull (e.g. inner chat completion spans from the
# underlying ChatTelemetryLayer) are parented under this agent invoke span. Attach and
# detach happen in the same async context as the pull, avoiding cross-context cleanup
# issues. The weakref finalizer ensures the span is closed even if the stream is
# garbage collected without being consumed.
wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = (
result_stream
.with_cleanup_hook(_record_duration)
.with_cleanup_hook(_finalize_stream)
.with_pull_context_manager(lambda: _activate_span(span))
)
weakref.finalize(wrapped_stream, _close_span)
return wrapped_stream
@@ -1809,6 +1825,27 @@ def get_function_span(
)
@contextlib.contextmanager
def _activate_span(span: trace.Span) -> Generator[None]:
"""Attach ``span`` as the current span in the OpenTelemetry context.
Designed to be used as a per-pull context manager registered on a
``ResponseStream`` via ``with_pull_context_manager``: it attaches the span
before each underlying iterator pull and detaches immediately after, so
child spans created during the pull (HTTP clients, inner chat completions,
tool execution) are correctly parented under ``span``.
Because attach and detach happen within the same ``__anext__`` invocation
(and therefore the same async task / contextvars context), there is no risk
of "Failed to detach context" warnings from cross-context cleanup.
"""
token = otel_context.attach(trace.set_span_in_context(span))
try:
yield
finally:
otel_context.detach(token)
@contextlib.contextmanager
def _get_span(
attributes: dict[str, Any],
@@ -1831,6 +1868,29 @@ def _get_span(
yield current_span
def _start_streaming_span(attributes: dict[str, Any], span_name_attribute: str) -> trace.Span:
"""Start a non-current span for a streaming operation.
Unlike :func:`_get_span`, the returned span is not attached to the current
OpenTelemetry context. The caller is responsible for:
- Ending the span via cleanup hooks on the wrapped
:class:`~agent_framework._types.ResponseStream`.
- Activating the span around each iterator pull via
:func:`_activate_span` registered with ``with_pull_context_manager`` so
that child spans created during stream production inherit it as parent.
Streaming spans are closed asynchronously in cleanup hooks that run in a
different async context than creation, so attaching the span at creation
time would cause "Failed to detach context" errors from OpenTelemetry.
"""
operation = attributes.get(OtelAttr.OPERATION, "operation")
span_name = attributes.get(span_name_attribute, "unknown")
span = get_tracer().start_span(f"{operation} {span_name}")
span.set_attributes(attributes)
return span
def _get_instructions_from_options(options: Any) -> str | list[str] | None:
"""Extract instructions from options dict."""
if options is None:
+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.2.1"
version = "1.2.2"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -3313,3 +3313,487 @@ async def test_agent_invoke_span_aggregates_usage_on_max_iterations_exhaustion(s
# The invoke_agent span must aggregate usage from the in-loop call and the final exhaustion call
assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 500
assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 100
# region Test span nesting (parent-child relationships)
@pytest.mark.parametrize("stream", [False, True])
async def test_chat_span_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool):
"""The inner chat span must be a child of the outer agent invoke span."""
class NestedChatClient(ChatTelemetryLayer, BaseChatClient[Any]):
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant")
yield ChatResponseUpdate(
contents=[Content.from_text(" world")], role="assistant", finish_reason="stop"
)
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse(
messages=[Message(role="assistant", contents=["Hello world"])],
response_id="resp_1",
usage_details=UsageDetails(input_token_count=3, output_token_count=4),
finish_reason="stop",
)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
return ChatResponse(
messages=[Message(role="assistant", contents=["Hello world"])],
response_id="resp_1",
usage_details=UsageDetails(input_token_count=3, output_token_count=4),
finish_reason="stop",
)
return _get()
agent = Agent(
client=NestedChatClient(),
id="nested_agent_id",
name="nested_agent",
default_options={"model": "NestedModel"},
)
span_exporter.clear()
if stream:
result_stream = agent.run("Test message", stream=True)
async for _ in result_stream:
pass
await result_stream.get_final_response()
else:
await agent.run("Test message")
spans = span_exporter.get_finished_spans()
assert len(spans) == 2
span_by_op = {s.attributes[OtelAttr.OPERATION.value]: s for s in spans}
agent_span = span_by_op[OtelAttr.AGENT_INVOKE_OPERATION]
chat_span = span_by_op[OtelAttr.CHAT_COMPLETION_OPERATION]
# Agent span has no parent (it is the root)
assert agent_span.parent is None
# Chat span's parent must be the agent span
assert chat_span.parent is not None
assert chat_span.parent.span_id == agent_span.context.span_id
assert chat_span.parent.trace_id == agent_span.context.trace_id
# Both spans must share the same trace
assert chat_span.context.trace_id == agent_span.context.trace_id
@pytest.mark.parametrize("stream", [False, True])
async def test_function_call_spans_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool):
"""All inner spans (chat completions and execute_tool) must be children of the agent span."""
from agent_framework import Content
from agent_framework._tools import FunctionInvocationLayer
@tool(name="get_weather", description="Get the weather for a location")
def get_weather(location: str) -> str:
return f"The weather in {location} is sunny."
class NestedToolChatClient(FunctionInvocationLayer, ChatTelemetryLayer, BaseChatClient[Any]):
def __init__(self) -> None:
super().__init__()
self.call_count = 0
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
self.call_count += 1
is_first = self.call_count == 1
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
if is_first:
yield ChatResponseUpdate(
contents=[
Content.from_function_call(
call_id="call_123",
name="get_weather",
arguments='{"location": "Seattle"}',
)
],
role="assistant",
)
else:
yield ChatResponseUpdate(
contents=[Content.from_text("The weather in Seattle is sunny!")],
role="assistant",
finish_reason="stop",
)
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse.from_updates(updates)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
if is_first:
return ChatResponse(
messages=[
Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_123",
name="get_weather",
arguments='{"location": "Seattle"}',
)
],
)
],
)
return ChatResponse(
messages=[Message(role="assistant", contents=["The weather in Seattle is sunny!"])],
finish_reason="stop",
)
return _get()
agent = Agent(
client=NestedToolChatClient(),
id="tool_agent_id",
name="tool_agent",
default_options={"model": "ToolModel", "tools": [get_weather], "tool_choice": "auto"},
)
span_exporter.clear()
if stream:
result_stream = agent.run("What's the weather in Seattle?", stream=True)
async for _ in result_stream:
pass
await result_stream.get_final_response()
else:
await agent.run("What's the weather in Seattle?")
spans = span_exporter.get_finished_spans()
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
tool_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.TOOL_EXECUTION_OPERATION]
assert len(invoke_spans) == 1, f"Expected 1 invoke_agent span, got {len(invoke_spans)}"
assert len(chat_spans) == 2, f"Expected 2 chat spans, got {len(chat_spans)}"
assert len(tool_spans) == 1, f"Expected 1 execute_tool span, got {len(tool_spans)}"
agent_span = invoke_spans[0]
assert agent_span.parent is None
# All inner spans must be parented under the agent invoke span
for inner in (*chat_spans, *tool_spans):
assert inner.parent is not None, f"Span {inner.name} has no parent"
assert inner.parent.span_id == agent_span.context.span_id, (
f"Span {inner.name} parent={inner.parent.span_id} != agent={agent_span.context.span_id}"
)
assert inner.context.trace_id == agent_span.context.trace_id
@pytest.mark.parametrize("stream", [False, True])
async def test_chat_span_nested_under_explicit_outer_span(
span_exporter: InMemorySpanExporter, mock_chat_client, stream: bool
):
"""Chat telemetry spans (including streaming) must inherit a user-provided outer span as parent."""
from agent_framework.observability import get_tracer
client = mock_chat_client()
span_exporter.clear()
tracer = get_tracer()
with tracer.start_as_current_span("outer") as outer_span:
outer_ctx = outer_span.get_span_context()
if stream:
stream_obj = client.get_response(
stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}
)
async for _ in stream_obj:
pass
await stream_obj.get_final_response()
else:
await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
spans = span_exporter.get_finished_spans()
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
assert len(chat_spans) == 1
chat_span = chat_spans[0]
assert chat_span.parent is not None
assert chat_span.parent.span_id == outer_ctx.span_id
assert chat_span.context.trace_id == outer_ctx.trace_id
@pytest.mark.parametrize("stream", [False, True])
async def test_http_span_nested_under_chat_span(span_exporter: InMemorySpanExporter, stream: bool):
"""A span created inside ``_inner_get_response`` (e.g. an HTTP client call to the LLM provider)
must be parented under the chat completion span.
This validates that the chat span context is active while the inner client implementation
runs, both for non-streaming responses and while streaming updates are being pulled.
"""
from agent_framework.observability import get_tracer
tracer = get_tracer()
class HttpEmittingClient(ChatTelemetryLayer, BaseChatClient[Any]):
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
# Simulate an HTTP request to the model provider while producing the stream.
with tracer.start_as_current_span("HTTP POST"):
pass
yield ChatResponseUpdate(contents=[Content.from_text("hi")], role="assistant", finish_reason="stop")
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse.from_updates(updates)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
# Simulate an HTTP request to the model provider during the call.
with tracer.start_as_current_span("HTTP POST"):
pass
return ChatResponse(
messages=[Message(role="assistant", contents=["done"])],
usage_details=UsageDetails(input_token_count=1, output_token_count=1),
)
return _get()
span_exporter.clear()
client = HttpEmittingClient()
if stream:
result_stream = client.get_response(
stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}
)
async for _ in result_stream:
pass
await result_stream.get_final_response()
else:
await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
spans = span_exporter.get_finished_spans()
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
http_spans = [s for s in spans if s.name == "HTTP POST"]
assert len(chat_spans) == 1
assert len(http_spans) == 1
chat_span = chat_spans[0]
http_span = http_spans[0]
assert http_span.parent is not None
assert http_span.parent.span_id == chat_span.context.span_id
assert http_span.context.trace_id == chat_span.context.trace_id
# region Test ResponseStream.with_pull_context_manager
async def test_with_pull_context_manager_enters_and_exits_per_pull():
"""The registered factory is entered and exited symmetrically around each iterator pull."""
import contextlib
events: list[str] = []
@contextlib.contextmanager
def cm():
events.append("enter")
try:
yield
finally:
events.append("exit")
async def src() -> AsyncIterable[int]:
yield 1
yield 2
stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates))
stream.with_pull_context_manager(cm)
pulled = [u async for u in stream]
assert pulled == [1, 2]
# Enter/exit must be balanced and there must be at least one pair per yielded update.
assert events.count("enter") == events.count("exit")
assert events.count("enter") >= 2
# Verify symmetric ordering (no overlapping pairs).
for i in range(0, len(events), 2):
assert events[i] == "enter"
assert events[i + 1] == "exit"
async def test_with_pull_context_manager_exits_on_iteration_error():
"""The pull context is exited even when the underlying stream raises mid-iteration."""
import contextlib
events: list[str] = []
@contextlib.contextmanager
def cm():
events.append("enter")
try:
yield
finally:
events.append("exit")
async def src() -> AsyncIterable[int]:
yield 1
raise RuntimeError("boom")
stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates))
stream.with_pull_context_manager(cm)
with pytest.raises(RuntimeError, match="boom"):
async for _ in stream:
pass
# Enter/exit balanced even on the failing pull.
assert events.count("enter") == events.count("exit")
assert events.count("enter") >= 2
async def test_with_pull_context_manager_wraps_stream_resolution_via_await():
"""Awaiting a ``from_awaitable`` stream resolves the inner stream under the pull contexts."""
import contextlib
events: list[str] = []
@contextlib.contextmanager
def cm():
events.append("enter")
try:
yield
finally:
events.append("exit")
async def inner() -> AsyncIterable[int]:
yield 1
async def make_stream() -> ResponseStream[int, list[int]]:
# Record that we resolve while a pull context is active.
events.append("resolving")
return ResponseStream(inner(), finalizer=lambda updates: list(updates))
stream: ResponseStream[int, list[int]] = ResponseStream.from_awaitable(make_stream())
stream.with_pull_context_manager(cm)
await stream # Triggers _resolve_stream_with_pull_contexts via __await__
assert "resolving" in events
resolve_index = events.index("resolving")
assert events[resolve_index - 1] == "enter" # Pull context active during resolution
# region Test streaming telemetry error paths
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_streaming_super_failure_closes_span(span_exporter: InMemorySpanExporter, enable_sensitive_data):
"""If the underlying client raises synchronously when constructing the stream, the chat
span is ended and the exception is recorded (no span leak)."""
class FailingClient(ChatTelemetryLayer, BaseChatClient[Any]):
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
raise RuntimeError("inner failed")
span_exporter.clear()
client = FailingClient()
with pytest.raises(RuntimeError, match="inner failed"):
client.get_response(stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
spans = span_exporter.get_finished_spans()
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
assert len(chat_spans) == 1
assert chat_spans[0].status.status_code == StatusCode.ERROR
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvars(
span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""If ``execute()`` raises synchronously during streaming agent invocation, the agent span is
ended, the exception is recorded, and the telemetry contextvars are reset."""
from agent_framework.observability import (
INNER_ACCUMULATED_USAGE,
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS,
)
class _FailingExecuteAgent:
AGENT_PROVIDER_NAME = "test_provider"
def __init__(self):
self._id = "failing_execute"
self._name = "Failing Execute"
self._description = "Agent whose stream call raises synchronously"
self._default_options: dict[str, Any] = {}
@property
def id(self):
return self._id
@property
def name(self):
return self._name
@property
def description(self):
return self._description
@property
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
if stream:
raise RuntimeError("execute failed")
raise NotImplementedError
class FailingExecuteAgent(AgentTelemetryLayer, _FailingExecuteAgent):
pass
# Sentinel values to detect that contextvars were reset to their pre-call state.
sentinel_fields: set[str] = set()
sentinel_usage: dict[str, Any] = {}
fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(sentinel_fields)
usage_token = INNER_ACCUMULATED_USAGE.set(sentinel_usage)
try:
agent = FailingExecuteAgent()
span_exporter.clear()
with pytest.raises(RuntimeError, match="execute failed"):
agent.run(messages="Hello", stream=True)
# Contextvars must be back to the sentinel values registered before the call.
assert INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.get() is sentinel_fields
assert INNER_ACCUMULATED_USAGE.get() is sentinel_usage
finally:
INNER_ACCUMULATED_USAGE.reset(usage_token)
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(fields_token)
spans = span_exporter.get_finished_spans()
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
assert len(agent_spans) == 1
assert agent_spans[0].status.status_code == StatusCode.ERROR
@@ -1087,16 +1087,20 @@ def test_chat_tool_mode():
required_any: ToolMode = {"mode": "required"}
required_mode: ToolMode = {"mode": "required", "required_function_name": "example_function"}
none_mode: ToolMode = {"mode": "none"}
allowed_mode: ToolMode = {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}
# Check the type and content
assert auto_mode["mode"] == "auto"
assert "required_function_name" not in auto_mode
assert "allowed_tools" not in auto_mode
assert required_any["mode"] == "required"
assert "required_function_name" not in required_any
assert required_mode["mode"] == "required"
assert required_mode["required_function_name"] == "example_function"
assert none_mode["mode"] == "none"
assert "required_function_name" not in none_mode
assert allowed_mode["mode"] == "auto"
assert allowed_mode["allowed_tools"] == ["get_weather", "search_docs"]
# equality of dicts
assert {"mode": "required", "required_function_name": "example_function"} == {
@@ -1154,6 +1158,45 @@ def test_chat_options_tool_choice_validation():
with raises(ContentError):
validate_tool_mode({"mode": "auto", "required_function_name": "should_not_be_here"})
# Valid allowed_tools
assert validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather"]}) == {
"mode": "auto",
"allowed_tools": ["get_weather"],
}
assert validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]}) == {
"mode": "auto",
"allowed_tools": ["get_weather", "search_docs"],
}
# allowed_tools valid with required mode
assert validate_tool_mode({"mode": "required", "allowed_tools": ["get_weather"]}) == {
"mode": "required",
"allowed_tools": ["get_weather"],
}
# allowed_tools invalid with none mode
with raises(ContentError):
validate_tool_mode({"mode": "none", "allowed_tools": ["get_weather"]})
# allowed_tools must be a non-string sequence of strings
with raises(ContentError):
validate_tool_mode({"mode": "auto", "allowed_tools": "get_weather"})
with raises(ContentError):
validate_tool_mode({"mode": "auto", "allowed_tools": 123})
with raises(ContentError):
validate_tool_mode({"mode": "auto", "allowed_tools": ["get_weather", 123]})
# Empty list is valid (caller explicitly allows no tools)
assert validate_tool_mode({"mode": "auto", "allowed_tools": []}) == {
"mode": "auto",
"allowed_tools": [],
}
# Tuple is normalized to list
result = validate_tool_mode({"mode": "auto", "allowed_tools": ("get_weather",)})
assert result is not None
assert result["allowed_tools"] == ["get_weather"]
def test_chat_options_merge(tool_tool, ai_tool) -> None:
"""Test merge_chat_options utility function."""
@@ -488,8 +488,13 @@ class StateTrackingExecutor(Executor):
await ctx.yield_output(existing_messages.copy()) # type: ignore
async def test_workflow_multiple_runs_no_state_collision():
"""Test that running the same workflow instance multiple times doesn't have state collision."""
async def test_workflow_multiple_runs_preserve_state():
"""Test that running the same workflow instance multiple times preserves shared state.
State preservation is the new default - calling ``Workflow.run`` repeatedly
on the same instance behaves like a chat agent maintaining memory across
turns. Callers that want fresh state should rebuild the Workflow.
"""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
@@ -503,29 +508,45 @@ async def test_workflow_multiple_runs_no_state_collision():
.build()
)
# Run 1: Should only see messages from run 1
# Run 1: Single record from run 1
result1 = await workflow.run(StateTrackingMessage(data="message1", run_id="run1"))
assert result1.get_final_state() == WorkflowRunState.IDLE
outputs1 = result1.get_outputs()
assert outputs1[0] == ["run1:message1"]
# Run 2: Should only see messages from run 2, not run 1
# Run 2: State from run 1 persists; run 2's record appends.
result2 = await workflow.run(StateTrackingMessage(data="message2", run_id="run2"))
assert result2.get_final_state() == WorkflowRunState.IDLE
outputs2 = result2.get_outputs()
assert outputs2[0] == ["run2:message2"] # Should NOT contain run1 data
assert outputs2[0] == ["run1:message1", "run2:message2"]
# Run 3: Should only see messages from run 3
# Run 3: Same - all three accumulate.
result3 = await workflow.run(StateTrackingMessage(data="message3", run_id="run3"))
assert result3.get_final_state() == WorkflowRunState.IDLE
outputs3 = result3.get_outputs()
assert outputs3[0] == ["run3:message3"] # Should NOT contain run1 or run2 data
assert outputs3[0] == ["run1:message1", "run2:message2", "run3:message3"]
# Verify that each run only processed its own message
# This confirms that the checkpointable context properly resets between runs
assert outputs1[0] != outputs2[0]
assert outputs2[0] != outputs3[0]
assert outputs1[0] != outputs3[0]
async def test_workflow_multiple_runs_no_state_collision_after_rebuild():
"""Rebuilding the Workflow gives a fresh shared-state slate."""
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
def _build():
executor = StateTrackingExecutor(id="state_executor")
return (
WorkflowBuilder(start_executor=executor, checkpoint_storage=storage)
.add_edge(executor, executor)
.build()
)
wf1 = _build()
result1 = await wf1.run(StateTrackingMessage(data="message1", run_id="run1"))
assert result1.get_outputs()[0] == ["run1:message1"]
wf2 = _build()
result2 = await wf2.run(StateTrackingMessage(data="message2", run_id="run2"))
assert result2.get_outputs()[0] == ["run2:message2"]
async def test_workflow_checkpoint_runtime_only_configuration(
@@ -932,6 +953,31 @@ async def test_agent_streaming_vs_non_streaming() -> None:
assert accumulated_text == "Hello World", f"Expected 'Hello World', got '{accumulated_text}'"
async def test_workflow_run_inflight_messages_guard(simple_executor: Executor) -> None:
"""``run(message=...)`` must reject in-flight executor messages from a prior run.
Workflows preserve state and pending messages across :meth:`Workflow.run`
calls. If a prior run aborted before the runner drained those pending
messages (e.g. it raised :class:`WorkflowConvergenceException`), the next
fresh-message call should fail loudly instead of silently mixing the
leftover messages with the new turn. The supported recovery path is to
resume from a checkpoint; there is no in-process recovery hatch.
"""
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
test_message = WorkflowMessage(data="test", source_id="test", target_id=None)
# Simulate an aborted prior run by leaving a message in the runner context.
workflow._runner.context._messages["test"] = [test_message]
assert await workflow._runner.context.has_messages()
with pytest.raises(RuntimeError, match="in-flight executor messages"):
await workflow.run(test_message)
with pytest.raises(RuntimeError, match="in-flight executor messages"):
async for _ in workflow.run(test_message, stream=True):
pass
async def test_workflow_run_parameter_validation(simple_executor: Executor) -> None:
"""Test that stream properly validate parameter combinations."""
workflow = WorkflowBuilder(start_executor=simple_executor).add_edge(simple_executor, simple_executor).build()
@@ -942,13 +988,15 @@ async def test_workflow_run_parameter_validation(simple_executor: Executor) -> N
result = await workflow.run(test_message)
assert result.get_final_state() == WorkflowRunState.IDLE
# Invalid: both message and checkpoint_id
# Invalid: message + checkpoint_id (mutually exclusive). Multi-turn
# state preservation is handled by Workflow.run preserving state across
# calls, so the host pattern is two separate calls (restore-then-run),
# not a single combined call.
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
await workflow.run(test_message, checkpoint_id="fake_id")
await workflow.run(test_message, checkpoint_id="some-checkpoint")
# Invalid: both message and checkpoint_id (streaming)
with pytest.raises(ValueError, match="Cannot provide both 'message' and 'checkpoint_id'"):
async for _ in workflow.run(test_message, checkpoint_id="fake_id", stream=True):
async for _ in workflow.run(test_message, checkpoint_id="some-checkpoint", stream=True):
pass
# Invalid: none of message or checkpoint_id
+2 -1
View File
@@ -8,7 +8,8 @@ YAML/JSON-based declarative agent and workflow definitions.
- **`WorkflowFactory`** - Creates workflows from declarative definitions
- **`WorkflowState`** - State management for declarative workflows
- **`ProviderTypeMapping`** - Maps provider types to implementations
- **`DeclarativeLoaderError`** / **`ProviderLookupError`** - Error types
- **`HttpRequestHandler`** / **`DefaultHttpRequestHandler`** - Pluggable HTTP transport for the `HttpRequestAction` declarative action (configured via `WorkflowFactory(http_request_handler=...)`)
- **`DeclarativeLoaderError`** / **`ProviderLookupError`** / **`DeclarativeWorkflowError`** / **`DeclarativeActionError`** - Error types
## External Input Handling
@@ -6,9 +6,14 @@ from ._loader import AgentFactory, DeclarativeLoaderError, ProviderLookupError,
from ._workflows import (
AgentExternalInputRequest,
AgentExternalInputResponse,
DeclarativeActionError,
DeclarativeWorkflowError,
DefaultHttpRequestHandler,
ExternalInputRequest,
ExternalInputResponse,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
WorkflowFactory,
WorkflowState,
)
@@ -22,10 +27,15 @@ __all__ = [
"AgentExternalInputRequest",
"AgentExternalInputResponse",
"AgentFactory",
"DeclarativeActionError",
"DeclarativeLoaderError",
"DeclarativeWorkflowError",
"DefaultHttpRequestHandler",
"ExternalInputRequest",
"ExternalInputResponse",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"ProviderLookupError",
"ProviderTypeMapping",
"WorkflowFactory",
@@ -25,6 +25,7 @@ from ._declarative_base import (
LoopIterationResult,
)
from ._declarative_builder import ALL_ACTION_EXECUTORS, DeclarativeWorkflowBuilder
from ._errors import DeclarativeActionError, DeclarativeWorkflowError
from ._executors_agents import (
AGENT_ACTION_EXECUTORS,
AGENT_REGISTRY_KEY,
@@ -67,6 +68,10 @@ from ._executors_external_input import (
RequestExternalInputExecutor,
WaitForInputExecutor,
)
from ._executors_http import (
HTTP_ACTION_EXECUTORS,
HttpRequestActionExecutor,
)
from ._executors_tools import (
FUNCTION_TOOL_REGISTRY_KEY,
TOOL_ACTION_EXECUTORS,
@@ -78,7 +83,13 @@ from ._executors_tools import (
ToolApprovalState,
ToolInvocationResult,
)
from ._factory import DeclarativeWorkflowError, WorkflowFactory
from ._factory import WorkflowFactory
from ._http_handler import (
DefaultHttpRequestHandler,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
)
from ._state import WorkflowState
__all__ = [
@@ -90,6 +101,7 @@ __all__ = [
"DECLARATIVE_STATE_KEY",
"EXTERNAL_INPUT_EXECUTORS",
"FUNCTION_TOOL_REGISTRY_KEY",
"HTTP_ACTION_EXECUTORS",
"TOOL_ACTION_EXECUTORS",
"TOOL_APPROVAL_STATE_KEY",
"TOOL_REGISTRY_KEY",
@@ -106,12 +118,14 @@ __all__ = [
"ContinueLoopExecutor",
"ConversationData",
"CreateConversationExecutor",
"DeclarativeActionError",
"DeclarativeActionExecutor",
"DeclarativeMessage",
"DeclarativeStateData",
"DeclarativeWorkflowBuilder",
"DeclarativeWorkflowError",
"DeclarativeWorkflowState",
"DefaultHttpRequestHandler",
"EmitEventExecutor",
"EndConversationExecutor",
"EndWorkflowExecutor",
@@ -120,6 +134,10 @@ __all__ = [
"ExternalLoopState",
"ForeachInitExecutor",
"ForeachNextExecutor",
"HttpRequestActionExecutor",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
"InvokeAzureAgentExecutor",
"InvokeFunctionToolExecutor",
"JoinExecutor",
@@ -32,10 +32,12 @@ import uuid
from collections.abc import Mapping
from dataclasses import dataclass
from decimal import Decimal as _Decimal
from enum import Enum
from typing import Any, Literal, cast
from agent_framework import (
Executor,
Message,
WorkflowContext,
)
from agent_framework._workflows._state import State
@@ -120,7 +122,20 @@ def _make_powerfx_safe(value: Any) -> Any:
Returns:
A PowerFx-safe representation of the value
"""
if value is None or isinstance(value, _POWERFX_SAFE_TYPES):
if value is None:
return value
# Enum coercion must run BEFORE the primitive type check: many MAF
# enums (e.g. MessageRole) are ``str``-subclass enums, so they pass
# ``isinstance(v, str)`` but pythonnet refuses to convert them to
# ``System.String`` and raises ``'MessageRole' value cannot be
# converted to System.<X>'`` for every PowerFx primitive type. Reduce
# to the underlying value (or its string form) so PowerFx sees a
# plain ``str``/``int``.
if isinstance(value, Enum):
return _make_powerfx_safe(value.value)
if isinstance(value, _POWERFX_SAFE_TYPES):
return value
if isinstance(value, dict):
@@ -197,6 +212,16 @@ class DeclarativeWorkflowState:
result = self._state.get(DECLARATIVE_STATE_KEY)
return cast(DeclarativeStateData, result)
def is_initialized(self) -> bool:
"""Return True when declarative state has been initialized.
Useful for distinguishing a fresh start from a continuation: when
Workflow state preserves data across run() calls (multi-turn
scenarios), the start executor needs to avoid calling initialize()
and clobbering the prior turn's Conversation/Local/System data.
"""
return self._state.get(DECLARATIVE_STATE_KEY) is not None
def set_state_data(self, data: DeclarativeStateData) -> None:
"""Set the full state data dict in state."""
self._state.set(DECLARATIVE_STATE_KEY, data)
@@ -873,6 +898,20 @@ class DeclarativeActionExecutor(Executor):
Follows .NET's DefaultTransform pattern - accepts any input type:
- dict/Mapping: Used directly as workflow.inputs
- str: Converted to {"input": value}
- list[Message]: Treated as the agent-facing message contract
(e.g. from WorkflowAgent / as_agent()). The prior conversation
history is stored in ``Conversation.messages``/
``Conversation.history`` and mirrored to
``System.conversations.{id}.messages`` so workflows that
reference ``=Conversation.messages`` (e.g. InvokeAzureAgent) see
assistant turns and other earlier messages, including non-text
content. At the start of a turn this history excludes the current
user message; that message's text is instead used as the string
input (``Inputs.input``) and surfaced via ``System.LastMessage*``
for backward compatibility with simple text-only workflows. Agent
executors are responsible for appending the current user message
to ``Conversation.messages`` immediately before invoking the
inner agent.
- DeclarativeMessage: Internal message, no initialization needed
- Any other type: Converted via str() to {"input": str(value)}
@@ -888,6 +927,100 @@ class DeclarativeActionExecutor(Executor):
if isinstance(trigger, dict):
# Structured inputs - use directly
state.initialize(trigger) # type: ignore
elif isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger): # pyright: ignore[reportUnknownVariableType]
# list[Message] (e.g. from WorkflowAgent / as_agent()).
messages_list = cast(list[Message], trigger)
# Detect continuation: if the workflow's shared state already
# carries declarative data from a prior turn (because the host
# restored a checkpoint and dispatched this run with
# reset_context=False), we MUST NOT call state.initialize() -
# that would wipe Conversation.messages, Local.*, System.* etc.
# Instead, treat the trigger as the new turn's user input only:
# update Inputs.input, append the new user message to existing
# Conversation history, and refresh System.LastMessage*.
#
# Continuation = declarative state already exists in the workflow's
# shared state (either left over in-memory from a prior turn on
# the same instance, or restored from a checkpoint just before
# this run). In that case state.initialize() would wipe Local.*,
# System.*, Conversation.* etc., destroying the cross-turn
# context we're trying to preserve.
is_continuation = state.is_initialized()
# Locate the trailing user message in the trigger.
last_user_index = -1
for idx in range(len(messages_list) - 1, -1, -1):
if str(messages_list[idx].role).lower() == "user":
last_user_index = idx
break
if last_user_index >= 0:
last_user_msg = messages_list[last_user_index]
last_user_text = last_user_msg.text or ""
last_user_id = getattr(last_user_msg, "message_id", "") or ""
history_messages = messages_list[:last_user_index] + messages_list[last_user_index + 1 :]
else:
history_messages = list(messages_list)
tail = messages_list[-1] if messages_list else None
last_user_text = (tail.text or "") if tail is not None else ""
last_user_id = getattr(tail, "message_id", "") or "" if tail is not None else ""
if is_continuation:
# Continuation turn: keep prior Conversation.messages intact.
# Refresh inputs and surface the new user message via the
# System.LastMessage* fields. We deliberately do NOT append
# the new user message to Conversation.messages here: agent
# executors append the live user input themselves before
# invoking the inner agent (matching the first-turn
# contract where Conversation.messages holds prior turns
# only).
#
# Note: ``state.set("Inputs.input", ...)`` would route to
# the Custom namespace (Inputs is not a recognized top-level
# writable namespace - see DeclarativeWorkflowState.set).
# PowerFx expressions like ``=Workflow.Inputs.input`` /
# ``=inputs.input`` read state_data["Inputs"] directly, so
# we update that dict in place via get_state_data /
# set_state_data.
state_data = state.get_state_data()
inputs_dict = state_data.get("Inputs")
if not isinstance(inputs_dict, dict):
inputs_dict = {}
state_data["Inputs"] = inputs_dict
inputs_dict["input"] = last_user_text
state.set_state_data(state_data)
# Trailing non-user messages (e.g. tool results) sandwiched
# before the new user message in the trigger are still
# appended so later actions see them.
for msg in history_messages:
state.append("Conversation.messages", msg)
state.append("Conversation.history", msg)
conversation_id = state.get("System.ConversationId")
if conversation_id:
conv_path = f"System.conversations.{conversation_id}.messages"
for msg in history_messages:
state.append(conv_path, msg)
state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
state.set("System.LastMessageText", last_user_text)
state.set("System.LastMessageId", last_user_id)
else:
# First turn: full initialization.
state.initialize({"input": last_user_text})
for msg in history_messages:
state.append("Conversation.messages", msg)
state.append("Conversation.history", msg)
conversation_id = state.get("System.ConversationId")
if conversation_id:
conv_path = f"System.conversations.{conversation_id}.messages"
for msg in history_messages:
state.append(conv_path, msg)
state.set("System.LastMessage", {"Text": last_user_text, "Id": last_user_id})
state.set("System.LastMessageText", last_user_text)
state.set("System.LastMessageId", last_user_id)
elif isinstance(trigger, str):
# String input - wrap in dict and populate System.LastMessage.Text
# so YAML expressions like =System.LastMessage.Text see the user input
@@ -895,10 +1028,11 @@ class DeclarativeActionExecutor(Executor):
state.set("System.LastMessage", {"Text": trigger, "Id": ""})
state.set("System.LastMessageText", trigger)
elif not isinstance(
trigger, (ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl)
trigger,
(ActionTrigger, ActionComplete, ConditionResult, LoopIterationResult, LoopControl), # pyright: ignore[reportUnknownArgumentType]
):
# Any other type - convert to string like .NET's DefaultTransform
input_str = str(trigger)
input_str = str(cast(Any, trigger))
state.initialize({"input": input_str})
state.set("System.LastMessage", {"Text": input_str, "Id": ""})
state.set("System.LastMessageText", input_str)
@@ -26,6 +26,7 @@ from ._declarative_base import (
DeclarativeActionExecutor,
LoopIterationResult,
)
from ._errors import DeclarativeWorkflowError
from ._executors_agents import AGENT_ACTION_EXECUTORS, InvokeAzureAgentExecutor
from ._executors_basic import BASIC_ACTION_EXECUTORS
from ._executors_control_flow import (
@@ -39,7 +40,9 @@ from ._executors_control_flow import (
SwitchEvaluatorExecutor,
)
from ._executors_external_input import EXTERNAL_INPUT_EXECUTORS
from ._executors_http import HTTP_ACTION_EXECUTORS, HttpRequestActionExecutor
from ._executors_tools import TOOL_ACTION_EXECUTORS, InvokeFunctionToolExecutor
from ._http_handler import HttpRequestHandler
logger = logging.getLogger(__name__)
@@ -51,6 +54,7 @@ ALL_ACTION_EXECUTORS = {
**AGENT_ACTION_EXECUTORS,
**EXTERNAL_INPUT_EXECUTORS,
**TOOL_ACTION_EXECUTORS,
**HTTP_ACTION_EXECUTORS,
}
# Action kinds that terminate control flow (no fall-through to successor)
@@ -85,6 +89,7 @@ ACTION_REQUIRED_FIELDS: dict[str, list[str]] = {
"WaitForHumanInput": ["variable"],
"EmitEvent": ["event"],
"InvokeFunctionTool": ["functionName"],
"HttpRequestAction": ["url"],
}
# Alternate field names that satisfy required field requirements
@@ -129,6 +134,7 @@ class DeclarativeWorkflowBuilder:
checkpoint_storage: Any | None = None,
validate: bool = True,
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
):
"""Initialize the builder.
@@ -141,6 +147,9 @@ class DeclarativeWorkflowBuilder:
validate: Whether to validate the workflow definition before building (default: True)
max_iterations: Maximum runner supersteps. Falls back to the YAML ``maxTurns``
field, then to the core default (100).
http_request_handler: Handler used to dispatch HttpRequestAction requests.
Must be supplied when the workflow contains any HttpRequestAction;
otherwise build raises ``DeclarativeWorkflowError``.
"""
self._yaml_def = yaml_definition
self._workflow_id = workflow_id or yaml_definition.get("name", "declarative_workflow")
@@ -152,6 +161,7 @@ class DeclarativeWorkflowBuilder:
self._pending_gotos: list[tuple[Any, str]] = [] # (goto_executor, target_id)
self._validate = validate
self._seen_explicit_ids: set[str] = set() # Track explicit IDs for duplicate detection
self._http_request_handler = http_request_handler
# Resolve max_iterations: explicit arg > YAML maxTurns > core default
resolved = max_iterations if max_iterations is not None else yaml_definition.get("maxTurns")
if resolved is not None and (not isinstance(resolved, int) or resolved <= 0):
@@ -458,6 +468,19 @@ class DeclarativeWorkflowBuilder:
executor = InvokeAzureAgentExecutor(action_def, id=action_id, agents=self._agents)
elif kind == "InvokeFunctionTool":
executor = InvokeFunctionToolExecutor(action_def, id=action_id, tools=self._tools)
elif kind == "HttpRequestAction":
if self._http_request_handler is None:
raise DeclarativeWorkflowError(
f"Workflow defines HttpRequestAction '{action_id}' but no "
"http_request_handler was supplied to WorkflowFactory. Pass "
"http_request_handler=DefaultHttpRequestHandler() (or a custom "
"implementation) to enable HTTP requests."
)
executor = HttpRequestActionExecutor(
action_def,
id=action_id,
http_request_handler=self._http_request_handler,
)
else:
executor = executor_class(action_def, id=action_id)
self._executors[action_id] = executor
@@ -0,0 +1,38 @@
# Copyright (c) Microsoft. All rights reserved.
"""Error types for declarative workflow executor modules.
This module exists so that executor modules and the builder (e.g.
``_executors_http``, ``_declarative_builder``) can raise declarative-specific
exceptions without importing from ``_factory``. ``_factory`` imports
``_declarative_builder`` which imports the executor modules; pulling
:class:`DeclarativeWorkflowError` from ``_factory`` into an executor or
builder module would therefore introduce a circular import.
"""
from __future__ import annotations
from agent_framework.exceptions import WorkflowException
class DeclarativeWorkflowError(WorkflowException):
"""Raised for build-time / factory-level declarative workflow errors.
Used for YAML parsing/validation issues, missing configuration (e.g. an
HTTP request handler not supplied for a workflow that contains an
``HttpRequestAction``), and other errors detected before workflow
execution begins.
"""
pass
class DeclarativeActionError(WorkflowException):
"""Raised when a declarative action fails at run time.
Used by executor modules for runtime failures (e.g. transport errors,
non-2xx responses from :class:`HttpRequestActionExecutor`). Build-time and
factory-level errors continue to use :class:`DeclarativeWorkflowError`.
"""
pass
@@ -17,6 +17,7 @@ The key insight is that control flow becomes GRAPH STRUCTURE, not executor logic
from typing import Any, cast
from agent_framework import (
Message,
WorkflowContext,
handler,
)
@@ -492,7 +493,13 @@ class JoinExecutor(DeclarativeActionExecutor):
@handler
async def handle_action(
self,
trigger: dict[str, Any] | str | ActionTrigger | ActionComplete | ConditionResult | LoopIterationResult,
trigger: dict[str, Any]
| str
| list[Message]
| ActionTrigger
| ActionComplete
| ConditionResult
| LoopIterationResult,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Simply pass through to continue the workflow."""
@@ -0,0 +1,417 @@
# Copyright (c) Microsoft. All rights reserved.
"""Executor for the ``HttpRequestAction`` declarative action.
Mirrors the .NET ``HttpRequestExecutor``: dispatches an HTTP request through the
configured :class:`HttpRequestHandler`, parses the response body, and assigns
the parsed body and response headers to the declared state paths.
Security note: response bodies can echo secrets and may be very large. Diagnostic
messages produced for non-2xx responses truncate the body to 256 characters and
collapse CR/LF/TAB to spaces (parity with .NET ``FormatBodyForDiagnostics``).
"""
from __future__ import annotations
import json
import logging
from collections.abc import Mapping
from typing import Any
import httpx
from agent_framework import (
Message,
WorkflowContext,
handler,
)
from ._declarative_base import (
ActionComplete,
DeclarativeActionExecutor,
DeclarativeWorkflowState,
)
from ._errors import DeclarativeActionError
from ._http_handler import HttpRequestHandler, HttpRequestInfo, HttpRequestResult
__all__ = [
"HTTP_ACTION_EXECUTORS",
"HttpRequestActionExecutor",
]
logger = logging.getLogger(__name__)
_MAX_BODY_DIAGNOSTIC_LENGTH = 256
_BODY_TRUNCATION_SUFFIX = " \u2026 [truncated]"
# Body discriminator aliases. Long forms match the .NET object-model type
# names so YAML produced by .NET round-trips. Short forms are the .NET YAML
# convention used in test fixtures.
_BODY_KIND_JSON = {"json", "JsonRequestContent"}
_BODY_KIND_RAW = {"raw", "RawRequestContent"}
_BODY_KIND_NONE = {"none", "NoRequestContent"}
def _get_path(action_def: Mapping[str, Any], key: str) -> str | None:
"""Extract a state path from ``response``/``responseHeaders`` field.
Supports two YAML shapes (matches .NET serialization round-trips):
- ``response: Local.MyVar`` (plain string).
- ``response: { path: Local.MyVar }`` (object form).
"""
value = action_def.get(key)
if isinstance(value, str):
return value or None
if isinstance(value, Mapping):
path = value.get("path") # type: ignore[reportUnknownMemberType, reportUnknownVariableType]
return path if isinstance(path, str) and path else None
return None
def _format_body_for_diagnostics(body: str | None) -> str:
"""Truncate and sanitise a response body for inclusion in error messages.
Mirrors the .NET ``FormatBodyForDiagnostics`` helper:
- Empty/None -> empty string.
- Replaces CR/LF/TAB with spaces.
- Truncates to 256 chars with a unicode-ellipsis ``[truncated]`` suffix.
"""
if not body:
return ""
truncated = len(body) > _MAX_BODY_DIAGNOSTIC_LENGTH
head = body[:_MAX_BODY_DIAGNOSTIC_LENGTH] if truncated else body
sanitized = head.replace("\r", " ").replace("\n", " ").replace("\t", " ")
return sanitized + _BODY_TRUNCATION_SUFFIX if truncated else sanitized
def _parse_response_body(body: str | None) -> Any:
"""Parse an HTTP response body the same way the .NET executor does.
JSON-first: if the body parses as JSON, the parsed value is returned. Other
bodies are returned as the raw string. Empty/None bodies return ``None``.
"""
if body is None or body == "":
return None
try:
return json.loads(body)
except json.JSONDecodeError:
return body
def _format_query_value(value: Any) -> str | None:
"""Format a query-parameter value for URL inclusion.
Mirrors .NET ``FormatQueryValue``: ``None`` is dropped, ``bool`` becomes
lower-case ``"true"``/``"false"``, numerics use invariant ``str()``, and
other values fall through to ``str()``.
"""
if value is None:
return None
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, str):
return value
return str(value)
def _get_messages_path(state: DeclarativeWorkflowState, conversation_id_expr: str | None) -> str | None:
"""Return the configured conversation messages path, if any.
Returns ``System.conversations.{evaluated_id}.messages`` when a
``conversation_id_expr`` is configured and evaluates to a non-empty value.
Returns ``None`` when no conversation id expression is configured or when
the expression evaluates to ``None`` or an empty string (matches .NET
``GetConversationId`` behaviour where empty becomes ``null`` and the
response is not appended).
"""
if not conversation_id_expr:
return None
evaluated = state.eval_if_expression(conversation_id_expr)
if evaluated is None or (isinstance(evaluated, str) and not evaluated):
return None
return f"System.conversations.{evaluated}.messages"
class HttpRequestActionExecutor(DeclarativeActionExecutor):
"""Executor for the ``HttpRequestAction`` declarative action.
Dispatches through the supplied :class:`HttpRequestHandler` and:
- Parses the response body (JSON-first, raw string fall-back).
- Assigns the parsed body to ``response`` path (if configured).
- Folds multi-value response headers (comma-joined) and assigns them to
``responseHeaders`` path (if configured).
- On 2xx with non-empty body and a configured ``conversationId``, appends
an Assistant :class:`agent_framework.Message` to
``System.conversations.{id}.messages``.
- On non-2xx, still publishes ``responseHeaders`` (diagnostic) and raises
:class:`DeclarativeActionError` with a status-coded message containing a
truncated/sanitised body preview.
Transport errors (``httpx.TimeoutException``, ``TimeoutError``,
``httpx.HTTPError``) become :class:`DeclarativeActionError`. ``CancelledError``
is intentionally NOT caught so that workflow cancellation propagates.
"""
def __init__(
self,
action_def: dict[str, Any],
*,
id: str | None = None,
http_request_handler: HttpRequestHandler,
) -> None:
"""Create an HTTP request action executor.
Args:
action_def: Parsed ``HttpRequestAction`` YAML dict.
id: Optional executor id (defaults to action id or generated).
http_request_handler: Handler used to dispatch HTTP requests.
Required: the builder enforces presence at workflow-build time.
"""
super().__init__(action_def, id=id)
self._http_request_handler = http_request_handler
@handler
async def handle_action(
self,
trigger: Any,
ctx: WorkflowContext[ActionComplete],
) -> None:
"""Execute the HTTP request action."""
state = await self._ensure_state_initialized(ctx, trigger)
method = self._get_method(state)
url = self._get_url(state)
headers = self._get_headers(state)
query_parameters = self._get_query_parameters(state)
body, body_content_type = self._get_body(state)
timeout_ms = self._get_timeout_ms(state)
conversation_id_expr = self._action_def.get("conversationId")
connection_name = self._get_connection_name(state)
info = HttpRequestInfo(
method=method,
url=url,
headers=headers or {},
query_parameters=query_parameters or {},
body=body,
body_content_type=body_content_type,
timeout_ms=timeout_ms,
connection_name=connection_name,
)
try:
result = await self._http_request_handler.send(info)
except (httpx.TimeoutException, TimeoutError) as exc:
raise DeclarativeActionError(f"HTTP request to '{url}' timed out.") from exc
except DeclarativeActionError:
raise
except httpx.HTTPError as exc:
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
except Exception as exc:
# Custom HttpRequestHandler implementations may raise arbitrary
# exception types. Wrap them in DeclarativeActionError so workflow
# error handling stays uniform regardless of transport. Note that
# ``asyncio.CancelledError`` is a ``BaseException`` (not
# ``Exception``) and so still propagates unmodified, preserving
# workflow-cancellation semantics.
raise DeclarativeActionError(f"HTTP request to '{url}' failed: {type(exc).__name__}") from exc
if result.is_success_status_code:
self._assign_response(state, result)
self._assign_response_headers(state, result)
self._append_response_to_conversation(state, conversation_id_expr, result.body)
await ctx.send_message(ActionComplete())
return
# Non-success path: still publish headers diagnostically, then raise.
self._assign_response_headers(state, result)
body_preview = _format_body_for_diagnostics(result.body)
if body_preview:
message = f"HTTP request to '{url}' failed with status code {result.status_code}. Body: '{body_preview}'"
else:
message = f"HTTP request to '{url}' failed with status code {result.status_code}."
raise DeclarativeActionError(message)
# ----- Field resolution ----------------------------------------------------
def _get_method(self, state: DeclarativeWorkflowState) -> str:
method = self._action_def.get("method")
evaluated = state.eval_if_expression(method) if method is not None else None
if not evaluated:
return "GET"
return str(evaluated).upper()
def _get_url(self, state: DeclarativeWorkflowState) -> str:
raw = self._action_def.get("url")
if raw is None:
raise ValueError("HttpRequestAction requires a 'url' field.")
evaluated = state.eval_if_expression(raw)
if not isinstance(evaluated, str) or not evaluated:
raise ValueError("HttpRequestAction 'url' evaluated to an empty value.")
return evaluated
def _get_headers(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
raw_headers = self._action_def.get("headers")
if not isinstance(raw_headers, Mapping) or not raw_headers:
return None
result: dict[str, str] = {}
for key, value in raw_headers.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key:
continue
evaluated = state.eval_if_expression(value)
if evaluated is None:
continue
text = str(evaluated)
if not text:
continue
result[key] = text
return result or None
def _get_query_parameters(self, state: DeclarativeWorkflowState) -> dict[str, str] | None:
raw_params = self._action_def.get("queryParameters")
if not isinstance(raw_params, Mapping) or not raw_params:
return None
result: dict[str, str] = {}
for key, value in raw_params.items(): # type: ignore[reportUnknownVariableType]
if not isinstance(key, str) or not key or value is None:
continue
evaluated = state.eval_if_expression(value)
formatted = _format_query_value(evaluated)
if formatted is not None:
result[key] = formatted
return result or None
def _get_body(self, state: DeclarativeWorkflowState) -> tuple[str | None, str | None]:
raw_body = self._action_def.get("body")
if raw_body is None:
return None, None
if not isinstance(raw_body, Mapping):
raise ValueError(
"HttpRequestAction 'body' must be a mapping with a 'kind' field (json, raw) or omitted entirely."
)
kind_value: Any = raw_body.get("kind") or raw_body.get("$kind") # type: ignore[reportUnknownMemberType]
if kind_value is None:
raise ValueError(
"HttpRequestAction 'body' is missing 'kind'. Use 'json', 'raw', or omit 'body' for no request body."
)
if not isinstance(kind_value, str):
raise ValueError(f"HttpRequestAction 'body.kind' must be a string, got {kind_value!r}.")
if kind_value in _BODY_KIND_NONE:
return None, None
if kind_value in _BODY_KIND_JSON:
content_expr: Any = raw_body.get("content") # type: ignore[reportUnknownMemberType]
if content_expr is None:
return None, None
evaluated = state.eval_if_expression(content_expr)
try:
body_text = json.dumps(evaluated, default=str)
except (TypeError, ValueError) as exc:
raise ValueError(f"HttpRequestAction 'body.content' could not be serialised as JSON: {exc}") from exc
return body_text, "application/json"
if kind_value in _BODY_KIND_RAW:
content_expr = raw_body.get("content") # type: ignore[reportUnknownMemberType]
content_type_expr: Any = raw_body.get("contentType") # type: ignore[reportUnknownMemberType]
content: str | None = None
if content_expr is not None:
evaluated = state.eval_if_expression(content_expr)
content = None if evaluated is None else str(evaluated)
content_type: str | None = None
if content_type_expr is not None:
ct_eval = state.eval_if_expression(content_type_expr)
ct_text = None if ct_eval is None else str(ct_eval)
content_type = ct_text or None
# Match .NET RawRequestContent semantics: when a raw body is sent
# without an explicit content type, default to text/plain so the
# request is interpretable by servers.
if content is not None and not content_type:
content_type = "text/plain"
return content, content_type
raise ValueError(
f"HttpRequestAction 'body.kind' has unsupported value '{kind_value}'. "
"Expected one of: json, raw, JsonRequestContent, RawRequestContent, "
"NoRequestContent."
)
def _get_timeout_ms(self, state: DeclarativeWorkflowState) -> int | None:
raw = self._action_def.get("requestTimeoutInMilliseconds")
if raw is None:
return None
evaluated = state.eval_if_expression(raw)
if evaluated is None:
return None
try:
value = int(evaluated)
except (TypeError, ValueError):
logger.debug(
"HttpRequestAction: ignoring non-numeric requestTimeoutInMilliseconds=%r",
evaluated,
)
return None
return value if value > 0 else None
def _get_connection_name(self, state: DeclarativeWorkflowState) -> str | None:
connection = self._action_def.get("connection")
if not isinstance(connection, Mapping):
return None
name_expr: Any = connection.get("name") # type: ignore[reportUnknownMemberType]
if name_expr is None:
return None
evaluated = state.eval_if_expression(name_expr)
if evaluated is None:
return None
text = str(evaluated)
return text or None
# ----- Result handling -----------------------------------------------------
def _assign_response(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
path = _get_path(self._action_def, "response")
if path is None:
return
state.set(path, _parse_response_body(result.body))
def _assign_response_headers(self, state: DeclarativeWorkflowState, result: HttpRequestResult) -> None:
path = _get_path(self._action_def, "responseHeaders")
if path is None:
return
if not result.headers:
state.set(path, None)
return
# Fold multi-value headers with commas (standard HTTP folding) only at
# assignment time. The raw multi-value dict on HttpRequestResult.headers
# is left untouched so callers/tests can inspect duplicates.
flattened: dict[str, str] = {}
for key, values in result.headers.items():
flattened[key] = ",".join(values)
state.set(path, flattened)
def _append_response_to_conversation(
self,
state: DeclarativeWorkflowState,
conversation_id_expr: str | None,
body: str,
) -> None:
if not body:
return
messages_path = _get_messages_path(state, conversation_id_expr)
if messages_path is None:
return
# Mirrors InvokeAzureAgentExecutor: rely on state.append to lazily
# create the conversation entry. Avoids re-parsing the id back out
# of the dotted path string.
message = Message(role="assistant", contents=[body])
state.append(messages_path, message)
HTTP_ACTION_EXECUTORS: dict[str, type[DeclarativeActionExecutor]] = {
"HttpRequestAction": HttpRequestActionExecutor,
}
@@ -24,18 +24,16 @@ from agent_framework import (
SupportsAgentRun,
Workflow,
)
from agent_framework.exceptions import WorkflowException
from .._loader import AgentFactory
from ._declarative_builder import DeclarativeWorkflowBuilder
from ._errors import DeclarativeWorkflowError
from ._http_handler import HttpRequestHandler
logger = logging.getLogger("agent_framework.declarative")
class DeclarativeWorkflowError(WorkflowException):
"""Exception raised for errors in declarative workflow processing."""
pass
__all__ = ["WorkflowFactory"]
class WorkflowFactory:
@@ -92,6 +90,7 @@ class WorkflowFactory:
env_file: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
max_iterations: int | None = None,
http_request_handler: HttpRequestHandler | None = None,
) -> None:
"""Initialize the workflow factory.
@@ -105,6 +104,12 @@ class WorkflowFactory:
max_iterations: Optional maximum runner supersteps. Overrides the YAML ``maxTurns``
field and the core default (100). Workflows with ``GotoAction`` loops (e.g.
DeepResearch) typically need a higher value.
http_request_handler: Optional handler used to dispatch HTTP requests for
``HttpRequestAction``. Required if the workflow contains any
``HttpRequestAction``; build will fail with :class:`DeclarativeWorkflowError`
otherwise. Use :class:`agent_framework.declarative.DefaultHttpRequestHandler`
for a no-policy ``httpx``-based default, or supply your own implementation
to enforce SSRF guards, allowlisting, or auth resolution.
Examples:
.. code-block:: python
@@ -144,6 +149,7 @@ class WorkflowFactory:
self._tools: dict[str, Any] = {} # Tool registry for InvokeFunctionTool actions
self._checkpoint_storage = checkpoint_storage
self._max_iterations = max_iterations
self._http_request_handler = http_request_handler
def create_workflow_from_yaml_path(
self,
@@ -387,6 +393,7 @@ class WorkflowFactory:
tools=self._tools,
checkpoint_storage=self._checkpoint_storage,
max_iterations=self._max_iterations,
http_request_handler=self._http_request_handler,
)
workflow = graph_builder.build()
except ValueError as e:
@@ -0,0 +1,237 @@
# Copyright (c) Microsoft. All rights reserved.
"""HTTP request handler abstraction for declarative workflows.
Mirrors the .NET ``IHttpRequestHandler`` / ``DefaultHttpRequestHandler`` pair from
``Microsoft.Agents.AI.Workflows.Declarative``. Provides:
- :class:`HttpRequestInfo` — request input data passed from the executor.
- :class:`HttpRequestResult` — response data returned to the executor.
- :class:`HttpRequestHandler` — :class:`typing.Protocol` callers implement to plug
in custom transports (e.g. with allowlisting, mTLS, retries, etc.).
- :class:`DefaultHttpRequestHandler` — production-grade default backed by
``httpx.AsyncClient``.
Security note: :class:`DefaultHttpRequestHandler` performs **no** URL filtering
or SSRF protection. Production deployments should supply a custom handler that
enforces an allowlist or DNS-rebinding-resistant policy. This split mirrors the
.NET design.
"""
from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Mapping
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable
import httpx
__all__ = [
"DefaultHttpRequestHandler",
"HttpRequestHandler",
"HttpRequestInfo",
"HttpRequestResult",
]
@dataclass
class HttpRequestInfo:
"""Description of an HTTP request to be dispatched by a :class:`HttpRequestHandler`.
Mirrors the .NET ``HttpRequestInfo`` record. Field semantics:
- ``method``: HTTP method (``GET``, ``POST``, etc.). Already upper-cased by the executor.
- ``url``: Absolute URL. Already evaluated from the YAML expression.
- ``headers``: Single-value header map (case-insensitive keys per HTTP semantics
but stored as authored). Empty values are skipped by the executor.
- ``query_parameters``: String key/value pairs appended to the URL.
- ``body``: Request body bytes/text, or ``None`` for no body.
- ``body_content_type``: Content type to send (e.g. ``application/json``).
Ignored when ``body`` is ``None``.
- ``timeout_ms``: Per-request timeout in milliseconds. ``None`` => use the
handler's default.
- ``connection_name``: Optional Foundry connection name for handlers that
resolve auth/credentials by connection.
"""
method: str
url: str
headers: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
query_parameters: dict[str, str] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
body: str | None = None
body_content_type: str | None = None
timeout_ms: int | None = None
connection_name: str | None = None
@dataclass
class HttpRequestResult:
"""Response returned by a :class:`HttpRequestHandler`.
Mirrors the .NET ``HttpRequestResult`` record. ``headers`` preserves
multi-value response headers (e.g. multiple ``Set-Cookie`` headers) as a
``dict[str, list[str]]``. The executor folds duplicates into a single
comma-joined string only at the point it assigns ``responseHeaders`` to
workflow state.
Header keys are normalized to lowercase so that lookups are consistent
regardless of the server's transmitted casing (HTTP headers are
case-insensitive per RFC 7230 §3.2). Custom :class:`HttpRequestHandler`
implementations should follow the same convention.
"""
status_code: int
is_success_status_code: bool
body: str
headers: dict[str, list[str]] = field(default_factory=dict) # type: ignore[reportUnknownVariableType]
@runtime_checkable
class HttpRequestHandler(Protocol):
"""Protocol for HTTP request handlers used by ``HttpRequestAction``.
Implementations must be safe to call concurrently from multiple workflow
runs. Implementations are responsible for any URL allowlisting, SSRF
guards, retry policies, auth resolution, and other policies that the
workflow author wants applied.
"""
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
"""Dispatch ``info`` and return the response result.
Args:
info: Description of the request to send.
Returns:
The response. Implementations should NOT raise on non-2xx status
codes; instead, set ``is_success_status_code`` accordingly. They
SHOULD raise on transport-level failures (connection refused,
DNS errors, timeouts).
"""
...
ClientProvider = Callable[[HttpRequestInfo], Awaitable["httpx.AsyncClient | None"]]
class DefaultHttpRequestHandler:
"""Default :class:`HttpRequestHandler` backed by :class:`httpx.AsyncClient`.
Construction modes:
1. ``DefaultHttpRequestHandler()`` — owns an internal client created lazily
on first ``send()``. Closed by :meth:`aclose`.
2. ``DefaultHttpRequestHandler(client=existing)`` — caller-owned client.
Not closed by :meth:`aclose`.
3. ``DefaultHttpRequestHandler(client_provider=cb)`` — per-request client
lookup (parity with .NET's ``httpClientProvider`` callback). The
provider may return ``None`` to fall back to the owned/default client.
.. warning::
This handler performs **no** URL filtering or SSRF protection. Wrap or
replace it with a custom handler in production.
"""
def __init__(
self,
*,
client: httpx.AsyncClient | None = None,
client_provider: ClientProvider | None = None,
) -> None:
self._owned_client: httpx.AsyncClient | None = None
self._caller_client = client
self._client_provider = client_provider
# Guards lazy creation of ``_owned_client`` against concurrent first
# ``send()`` calls leaking duplicate clients.
self._owned_client_lock = asyncio.Lock()
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
"""Dispatch the request and return the parsed result."""
if not info.url:
raise ValueError("HttpRequestInfo.url must be a non-empty string.")
if not info.method:
raise ValueError("HttpRequestInfo.method must be a non-empty string.")
client = await self._resolve_client(info)
timeout: httpx.Timeout | object
if info.timeout_ms is not None and info.timeout_ms > 0:
timeout = httpx.Timeout(info.timeout_ms / 1000.0)
else:
timeout = httpx.USE_CLIENT_DEFAULT
headers = dict(info.headers)
content: bytes | str | None = None
if info.body is not None:
content = info.body
if not _has_header(headers, "content-type"):
# Match .NET DefaultHttpRequestHandler: when a body is sent
# without an explicit content type, default to ``text/plain``
# so the request is interpretable by servers and direct
# callers (not just the YAML executor) get sensible defaults.
headers["Content-Type"] = info.body_content_type or "text/plain"
params: Mapping[str, str] | None = info.query_parameters or None
response = await client.request(
method=info.method,
url=info.url,
params=params,
headers=headers or None,
content=content,
timeout=timeout, # type: ignore[arg-type]
)
# Preserve multi-value headers (e.g. multiple Set-Cookie) as list[str].
# Normalize names to lowercase so lookups are consistent and case
# variations from the transport do not create duplicate logical keys
# (HTTP headers are case-insensitive per RFC 7230 §3.2).
result_headers: dict[str, list[str]] = {}
for key, value in response.headers.multi_items():
result_headers.setdefault(key.lower(), []).append(value)
body_text = response.text
return HttpRequestResult(
status_code=response.status_code,
is_success_status_code=200 <= response.status_code < 300,
body=body_text,
headers=result_headers,
)
async def aclose(self) -> None:
"""Release the owned client, if any. Caller-owned clients are NOT closed."""
if self._owned_client is not None:
await self._owned_client.aclose()
self._owned_client = None
async def _resolve_client(self, info: HttpRequestInfo) -> httpx.AsyncClient:
"""Pick a client for this request: provider → caller → lazily-owned."""
if self._client_provider is not None:
provided = await self._client_provider(info)
if provided is not None:
return provided
if self._caller_client is not None:
return self._caller_client
if self._owned_client is None:
# Double-checked locking under asyncio.Lock so concurrent first
# callers don't each create a fresh httpx.AsyncClient and orphan
# one of them.
async with self._owned_client_lock:
if self._owned_client is None:
self._owned_client = httpx.AsyncClient()
return self._owned_client
async def __aenter__(self) -> DefaultHttpRequestHandler:
return self
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
await self.aclose()
def _has_header(headers: Mapping[str, str], name: str) -> bool:
"""Case-insensitive header presence check."""
needle = name.lower()
return any(key.lower() == needle for key in headers)
+3 -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.0b260428"
version = "1.0.0b260429"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"httpx>=0.27,<1",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
@@ -0,0 +1,329 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for ``DefaultHttpRequestHandler``.
These tests exercise the real handler against ``httpx.MockTransport`` (no real
network) to cover the parts of the handler not exercisable through the executor
stub: query-param URL composition, content-type forwarding, per-request
timeout overrides, multi-value response header preservation, and client
ownership semantics.
"""
from __future__ import annotations
import sys
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
# These tests don't actually need PowerFx, but the rest of the suite gates on
# Python versions and we keep behaviour consistent.
pytestmark = pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with rest of declarative suite",
)
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
DefaultHttpRequestHandler,
HttpRequestInfo,
)
def _make_handler(transport: httpx.MockTransport) -> DefaultHttpRequestHandler:
"""Return a handler with a MockTransport-backed caller-owned client."""
client = httpx.AsyncClient(transport=transport)
return DefaultHttpRequestHandler(client=client)
class TestRequestComposition:
@pytest.mark.asyncio
async def test_query_parameters_merged_into_url(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/items",
query_parameters={"q": "alpha", "limit": "5"},
)
)
finally:
await handler.aclose()
req = captured["req"]
# httpx exposes the merged URL with QS appended
assert req.url.params.get("q") == "alpha"
assert req.url.params.get("limit") == "5"
@pytest.mark.asyncio
async def test_body_content_type_forwarded(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(204)
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
body='{"k":"v"}',
body_content_type="application/json",
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "application/json"
assert req.content == b'{"k":"v"}'
@pytest.mark.asyncio
async def test_existing_content_type_header_not_overwritten(self) -> None:
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(200, text="ok")
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
headers={"Content-Type": "application/xml"}, # caller wins
body="<x/>",
body_content_type="application/json",
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "application/xml"
@pytest.mark.asyncio
async def test_body_without_content_type_defaults_to_text_plain(self) -> None:
"""Match .NET DefaultHttpRequestHandler: body without explicit content type → ``text/plain``."""
captured: dict[str, httpx.Request] = {}
def respond(request: httpx.Request) -> httpx.Response:
captured["req"] = request
return httpx.Response(204)
handler = _make_handler(httpx.MockTransport(respond))
try:
await handler.send(
HttpRequestInfo(
method="POST",
url="https://api.example.test/items",
body="hello",
# No body_content_type and no Content-Type header.
)
)
finally:
await handler.aclose()
req = captured["req"]
assert req.headers.get("content-type") == "text/plain"
assert req.content == b"hello"
class TestTimeout:
@pytest.mark.asyncio
async def test_per_request_timeout_surfaces_as_timeout_exception(self) -> None:
def respond(request: httpx.Request) -> httpx.Response:
raise httpx.TimeoutException("simulated timeout", request=request)
handler = _make_handler(httpx.MockTransport(respond))
try:
with pytest.raises(httpx.TimeoutException):
await handler.send(
HttpRequestInfo(
method="GET",
url="https://api.example.test/slow",
timeout_ms=50,
)
)
finally:
await handler.aclose()
class TestResponseHeaders:
@pytest.mark.asyncio
async def test_multi_value_headers_preserved(self) -> None:
def respond(request: httpx.Request) -> httpx.Response:
return httpx.Response(
200,
text="ok",
headers=[
("Content-Type", "application/json"),
("Set-Cookie", "a=1"),
("Set-Cookie", "b=2"),
],
)
handler = _make_handler(httpx.MockTransport(respond))
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
finally:
await handler.aclose()
assert result.is_success_status_code
# The handler keeps multi-value headers as list[str].
assert result.headers.get("set-cookie") == ["a=1", "b=2"]
assert result.headers.get("content-type") == ["application/json"]
class TestClientOwnership:
@pytest.mark.asyncio
async def test_owned_client_is_closed_on_aclose(self) -> None:
handler = DefaultHttpRequestHandler()
# Inject a MockTransport-backed client into the owned slot and verify
# aclose() releases it. Avoids real network access.
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler._owned_client = owned
assert not owned.is_closed
await handler.aclose()
assert owned.is_closed
@pytest.mark.asyncio
async def test_caller_owned_client_is_not_closed(self) -> None:
client = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler = DefaultHttpRequestHandler(client=client)
await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
await handler.aclose()
assert not client.is_closed
await client.aclose() # cleanup
@pytest.mark.asyncio
async def test_concurrent_first_send_creates_single_owned_client(self) -> None:
"""Concurrent first-send calls must not race-leak duplicate clients.
Without the lock, two concurrent calls on a fresh handler would each
observe ``_owned_client is None`` and create their own
``httpx.AsyncClient``, orphaning one. Verify that lazy initialization
is serialized: all concurrent sends end up using the same client and
``aclose()`` cleanly closes it.
"""
import asyncio
# Patch httpx.AsyncClient to count constructions, but only when called
# from inside _resolve_client (no transport=) so we don't break the
# MockTransport-backed clients used elsewhere.
original_ctor = httpx.AsyncClient
construction_count = 0
def counting_ctor(*args, **kwargs): # type: ignore[no-untyped-def]
nonlocal construction_count
if not args and not kwargs:
construction_count += 1
return original_ctor(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
return original_ctor(*args, **kwargs)
import agent_framework_declarative._workflows._http_handler as hh
hh.httpx.AsyncClient = counting_ctor # type: ignore[assignment]
try:
handler = DefaultHttpRequestHandler()
try:
await asyncio.gather(*[
handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x")) for _ in range(8)
])
finally:
await handler.aclose()
finally:
hh.httpx.AsyncClient = original_ctor # type: ignore[assignment]
assert construction_count == 1, (
f"Expected exactly 1 owned client to be lazily created but got {construction_count}"
)
class TestClientProvider:
@pytest.mark.asyncio
async def test_client_provider_overrides_default(self) -> None:
captured: dict[str, str] = {}
def primary(request: httpx.Request) -> httpx.Response:
captured["transport"] = "primary"
return httpx.Response(200, text="primary")
def provided(request: httpx.Request) -> httpx.Response:
captured["transport"] = "provided"
return httpx.Response(200, text="provided")
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
provided_client = httpx.AsyncClient(transport=httpx.MockTransport(provided))
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient:
return provided_client
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
assert result.body == "provided"
assert captured["transport"] == "provided"
finally:
await handler.aclose()
await primary_client.aclose()
await provided_client.aclose()
@pytest.mark.asyncio
async def test_client_provider_returning_none_falls_back(self) -> None:
captured: dict[str, str] = {}
def primary(request: httpx.Request) -> httpx.Response:
captured["transport"] = "primary"
return httpx.Response(200, text="primary")
async def provider(info: HttpRequestInfo) -> httpx.AsyncClient | None:
return None
primary_client = httpx.AsyncClient(transport=httpx.MockTransport(primary))
handler = DefaultHttpRequestHandler(client=primary_client, client_provider=provider)
try:
result = await handler.send(HttpRequestInfo(method="GET", url="https://api.example.test/x"))
assert result.body == "primary"
finally:
await handler.aclose()
await primary_client.aclose()
class TestValidation:
@pytest.mark.asyncio
async def test_empty_url_raises(self) -> None:
handler = DefaultHttpRequestHandler()
with pytest.raises(ValueError):
await handler.send(HttpRequestInfo(method="GET", url=""))
@pytest.mark.asyncio
async def test_empty_method_raises(self) -> None:
handler = DefaultHttpRequestHandler()
with pytest.raises(ValueError):
await handler.send(HttpRequestInfo(method="", url="https://x.test/"))
class TestAsyncContextManager:
@pytest.mark.asyncio
async def test_context_manager_closes_owned_client(self) -> None:
async with DefaultHttpRequestHandler() as handler:
owned = httpx.AsyncClient(transport=httpx.MockTransport(lambda r: httpx.Response(200, text="ok")))
handler._owned_client = owned
assert owned.is_closed
@@ -0,0 +1,645 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for HttpRequestActionExecutor.
These tests use a stub HttpRequestHandler that returns canned HttpRequestResults.
No real network or httpx transports are exercised. See
test_default_http_request_handler.py for tests that exercise the real
DefaultHttpRequestHandler against httpx.MockTransport.
"""
from __future__ import annotations
import asyncio
import sys
from typing import Any
import httpx
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = pytest.mark.skipif(
not _powerfx_available or sys.version_info >= (3, 14),
reason="PowerFx engine not available (requires dotnet runtime)",
)
from agent_framework_declarative._workflows import ( # noqa: E402
DECLARATIVE_STATE_KEY,
DeclarativeActionError,
DeclarativeWorkflowError,
HttpRequestHandler,
HttpRequestInfo,
HttpRequestResult,
WorkflowFactory,
)
class StubHandler:
"""Test stub that records the last call and returns a canned result."""
def __init__(
self,
result: HttpRequestResult | None = None,
*,
raise_exc: BaseException | None = None,
) -> None:
self.result = result
self.raise_exc = raise_exc
self.last_info: HttpRequestInfo | None = None
self.call_count = 0
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
self.call_count += 1
self.last_info = info
if self.raise_exc is not None:
raise self.raise_exc
assert self.result is not None
return self.result
def _ok(body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
return HttpRequestResult(
status_code=200,
is_success_status_code=True,
body=body,
headers=headers or {},
)
def _err(status: int = 500, body: str = "", headers: dict[str, list[str]] | None = None) -> HttpRequestResult:
return HttpRequestResult(
status_code=status,
is_success_status_code=False,
body=body,
headers=headers or {},
)
async def _run(yaml_def: dict[str, Any], handler: HttpRequestHandler) -> Any:
"""Build & run a workflow, returning final WorkflowState."""
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(yaml_def)
return await workflow.run({})
def _state(workflow: Any, events: Any) -> dict[str, Any]:
"""Read declarative state out of the workflow after run completes."""
return workflow._state.get(DECLARATIVE_STATE_KEY) or {}
# Helper used by parametrised path tests
_TEST_URL = "https://api.example.test/items"
def _action(
*,
method: str | None = None,
url: str = _TEST_URL,
headers: dict[str, Any] | None = None,
query_parameters: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
response: Any = None,
response_headers: Any = None,
conversation_id: str | None = None,
request_timeout_ms: int | None = None,
connection: dict[str, Any] | None = None,
) -> dict[str, Any]:
action: dict[str, Any] = {
"kind": "HttpRequestAction",
"id": "http_action",
"url": url,
}
if method is not None:
action["method"] = method
if headers is not None:
action["headers"] = headers
if query_parameters is not None:
action["queryParameters"] = query_parameters
if body is not None:
action["body"] = body
if response is not None:
action["response"] = response
if response_headers is not None:
action["responseHeaders"] = response_headers
if conversation_id is not None:
action["conversationId"] = conversation_id
if request_timeout_ms is not None:
action["requestTimeoutInMilliseconds"] = request_timeout_ms
if connection is not None:
action["connection"] = connection
return action
def _yaml(action: dict[str, Any]) -> dict[str, Any]:
return {"name": "http_test", "actions": [action]}
# ---------- Success path: response parsing ----------------------------------
class TestSuccessPath:
@pytest.mark.asyncio
async def test_get_parses_json_object(self) -> None:
handler = StubHandler(_ok('{"key":"value","number":42}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(method="GET", response="Local.Result")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"key": "value", "number": 42}
assert handler.last_info is not None
assert handler.last_info.method == "GET"
assert handler.last_info.url == _TEST_URL
@pytest.mark.asyncio
async def test_get_parses_plain_string(self) -> None:
handler = StubHandler(_ok("not-json content"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == "not-json content"
@pytest.mark.asyncio
async def test_get_empty_body_yields_none(self) -> None:
handler = StubHandler(_ok(""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] is None
@pytest.mark.asyncio
async def test_response_object_form_path(self) -> None:
handler = StubHandler(_ok('{"x":1}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response={"path": "Local.Result"})))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["Result"] == {"x": 1}
@pytest.mark.asyncio
async def test_no_response_path_does_not_assign(self) -> None:
handler = StubHandler(_ok('{"x":1}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
# Should complete without error and without writing anything
await workflow.run({})
# ---------- Method / headers / query params --------------------------------
class TestRequestComposition:
@pytest.mark.asyncio
async def test_default_method_is_get(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.method == "GET"
@pytest.mark.asyncio
async def test_method_uppercased(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(method="post")))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.method == "POST"
@pytest.mark.asyncio
async def test_headers_are_forwarded_and_empty_skipped(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
headers={
"Accept": "application/json",
"X-Empty": "",
"Authorization": "Bearer token",
}
)
)
)
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.headers == {
"Accept": "application/json",
"Authorization": "Bearer token",
}
@pytest.mark.asyncio
async def test_query_parameters_stringified(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
query_parameters={
"name": "alpha",
"limit": 10,
"active": True,
"ratio": 0.5,
"missing": None, # dropped
}
)
)
)
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.query_parameters == {
"name": "alpha",
"limit": "10",
"active": "true",
"ratio": "0.5",
}
# ---------- Body composition ------------------------------------------------
class TestBody:
@pytest.mark.asyncio
async def test_post_json_body_sets_content_type_and_serialises(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "json", "content": {"k": "v", "n": 1}},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body_content_type == "application/json"
assert info.body is not None
# JSON serialized, key order may vary
import json
assert json.loads(info.body) == {"k": "v", "n": 1}
@pytest.mark.asyncio
async def test_post_raw_body_uses_declared_content_type(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={
"kind": "raw",
"content": "raw body text",
"contentType": "text/plain",
},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body == "raw body text"
assert info.body_content_type == "text/plain"
@pytest.mark.asyncio
async def test_post_raw_body_without_content_type_defaults_to_text_plain(self) -> None:
"""Match .NET RawRequestContent: no contentType => default text/plain.
Otherwise the request is sent without a Content-Type header which most
servers will treat as application/octet-stream and fail to parse.
"""
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "raw", "content": "plain body"},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body == "plain body"
assert info.body_content_type == "text/plain"
@pytest.mark.asyncio
async def test_long_form_body_kinds_accepted(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
method="POST",
body={"kind": "JsonRequestContent", "content": {"k": 1}},
)
)
)
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body_content_type == "application/json"
@pytest.mark.asyncio
async def test_unknown_body_kind_raises(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(body={"kind": "weirdform", "content": "x"})))
with pytest.raises(Exception) as excinfo:
await workflow.run({})
# Should surface as ValueError (potentially wrapped by runner)
msg = str(excinfo.value)
assert "weirdform" in msg or "unsupported value" in msg
@pytest.mark.asyncio
async def test_no_body_omitted(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
await workflow.run({})
info = handler.last_info
assert info is not None
assert info.body is None
assert info.body_content_type is None
# ---------- Non-2xx and error handling -------------------------------------
class TestErrorHandling:
@pytest.mark.asyncio
async def test_non_2xx_raises_declarative_action_error(self) -> None:
handler = StubHandler(_err(status=500, body="server exploded"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "500" in msg
assert "server exploded" in msg
@pytest.mark.asyncio
async def test_non_2xx_long_body_truncated(self) -> None:
big_body = "A" * 1000
handler = StubHandler(_err(status=500, body=big_body))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "[truncated]" in msg
assert len(msg) < 512
# Should NOT contain the full 1000-char body
assert big_body not in msg
@pytest.mark.asyncio
async def test_non_2xx_empty_body_omits_body_section(self) -> None:
handler = StubHandler(_err(status=404, body=""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "404" in msg
assert "Body:" not in msg
@pytest.mark.asyncio
async def test_non_2xx_control_chars_collapsed(self) -> None:
handler = StubHandler(_err(status=500, body="line1\r\nline2\tlong"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "\r" not in msg
assert "\n" not in msg
assert "\t" not in msg
assert "line1 line2 long" in msg
@pytest.mark.asyncio
async def test_timeout_exception_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=httpx.TimeoutException("timeout"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
assert "timed out" in str(excinfo.value)
@pytest.mark.asyncio
async def test_stdlib_timeout_error_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=TimeoutError("clock"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
assert "timed out" in str(excinfo.value)
@pytest.mark.asyncio
async def test_transport_error_becomes_declarative_action_error(self) -> None:
handler = StubHandler(raise_exc=httpx.ConnectError("dns failure"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "failed" in msg
assert _TEST_URL in msg
@pytest.mark.asyncio
async def test_cancelled_error_propagates_unchanged(self) -> None:
"""CancelledError from the handler must propagate so cancellation works."""
handler = StubHandler(raise_exc=asyncio.CancelledError())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
# CancelledError is allowed to surface as either CancelledError or as
# the runner's wrapped form, but it MUST NOT be DeclarativeActionError.
with pytest.raises(BaseException) as excinfo:
await workflow.run({})
assert not isinstance(excinfo.value, DeclarativeActionError)
@pytest.mark.asyncio
async def test_generic_exception_from_custom_handler_wrapped(self) -> None:
"""A custom handler raising a non-httpx Exception must be wrapped.
Authors can plug in custom HttpRequestHandler implementations that use
any transport (requests-like clients, gRPC bridges, mock test doubles,
etc.). The executor must wrap arbitrary Exception subclasses uniformly
so that workflow error handling stays consistent across transports.
"""
handler = StubHandler(raise_exc=RuntimeError("custom transport blew up"))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action()))
with pytest.raises(DeclarativeActionError) as excinfo:
await workflow.run({})
msg = str(excinfo.value)
assert "failed" in msg
assert "RuntimeError" in msg
assert _TEST_URL in msg
# ---------- Response headers ------------------------------------------------
class TestResponseHeaders:
@pytest.mark.asyncio
async def test_response_headers_folded_with_commas(self) -> None:
handler = StubHandler(
_ok(
"ok",
headers={
"Content-Type": ["application/json"],
"Set-Cookie": ["a=1", "b=2"],
},
)
)
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
h = decl["Local"]["H"]
assert h["Content-Type"] == "application/json"
assert h["Set-Cookie"] == "a=1,b=2"
@pytest.mark.asyncio
async def test_response_headers_empty_assigned_none(self) -> None:
handler = StubHandler(_ok("ok", headers={}))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] is None
@pytest.mark.asyncio
async def test_non_2xx_still_publishes_headers(self) -> None:
handler = StubHandler(_err(status=500, body="boom", headers={"X-Trace": ["abc"]}))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response_headers="Local.H")))
with pytest.raises(DeclarativeActionError):
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
assert decl["Local"]["H"] == {"X-Trace": "abc"}
# ---------- ConversationId append -------------------------------------------
class TestConversationAppend:
@pytest.mark.asyncio
async def test_conversation_id_appends_message(self) -> None:
handler = StubHandler(_ok('{"answer":"hello"}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(
_yaml(
_action(
response="Local.Result",
conversation_id="conv-test-1",
)
)
)
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
conv = decl["System"]["conversations"].get("conv-test-1")
assert conv is not None
assert len(conv["messages"]) == 1
@pytest.mark.asyncio
async def test_empty_conversation_id_does_not_append(self) -> None:
handler = StubHandler(_ok('{"answer":"hello"}'))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(response="Local.Result", conversation_id="")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
# Auto-init creates an entry for the System.ConversationId conversation,
# but it should NOT have HTTP-appended messages from us.
for _cid, conv in decl["System"]["conversations"].items():
assert conv["messages"] == []
@pytest.mark.asyncio
async def test_empty_body_skips_conversation_append(self) -> None:
handler = StubHandler(_ok(""))
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(conversation_id="conv-test-1")))
await workflow.run({})
decl = workflow._state.get(DECLARATIVE_STATE_KEY)
# No conversation entry should have been created either.
assert "conv-test-1" not in decl["System"]["conversations"]
# ---------- Connection name -------------------------------------------------
class TestConnection:
@pytest.mark.asyncio
async def test_connection_name_forwarded(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(connection={"name": "my-connection"})))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.connection_name == "my-connection"
# ---------- Build-time validation -------------------------------------------
class TestBuildTimeValidation:
def test_missing_url_fails_validation(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
bad = {
"name": "no_url",
"actions": [{"kind": "HttpRequestAction", "id": "x"}],
}
with pytest.raises(DeclarativeWorkflowError):
factory.create_workflow_from_definition(bad)
def test_missing_handler_fails_at_build(self) -> None:
factory = WorkflowFactory() # no handler
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_definition(_yaml(_action()))
assert "http_request_handler" in str(excinfo.value)
# ---------- Timeout forwarding ----------------------------------------------
class TestTimeout:
@pytest.mark.asyncio
async def test_timeout_ms_forwarded(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=2500)))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.timeout_ms == 2500
@pytest.mark.asyncio
async def test_timeout_ms_zero_treated_as_unset(self) -> None:
handler = StubHandler(_ok())
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_definition(_yaml(_action(request_timeout_ms=0)))
await workflow.run({})
assert handler.last_info is not None
assert handler.last_info.timeout_ms is None
@@ -0,0 +1,111 @@
# Copyright (c) Microsoft. All rights reserved.
"""End-to-end YAML integration test for ``HttpRequestAction``.
Loads the ``tests/workflows/http_request.yaml`` fixture (parity with the .NET
integration fixture) through ``WorkflowFactory.create_workflow_from_yaml_path``
with a stub :class:`HttpRequestHandler` and asserts state is populated.
"""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any
import pytest
try:
import powerfx # noqa: F401
_powerfx_available = True
except (ImportError, RuntimeError):
_powerfx_available = False
pytestmark = [
pytest.mark.skipif(
not _powerfx_available,
reason="powerfx not available — declarative workflows require it.",
),
pytest.mark.skipif(
sys.version_info >= (3, 14),
reason="Skipped on Python 3.14+ to keep parity with declarative suite.",
),
]
from agent_framework_declarative import WorkflowFactory # noqa: E402
from agent_framework_declarative._workflows import DECLARATIVE_STATE_KEY # noqa: E402
from agent_framework_declarative._workflows._http_handler import ( # noqa: E402
HttpRequestInfo,
HttpRequestResult,
)
FIXTURE_PATH = Path(__file__).parent / "workflows" / "http_request.yaml"
class _StubHandler:
"""Test double that records requests and returns a canned response."""
def __init__(self, result: HttpRequestResult) -> None:
self._result = result
self.received: list[HttpRequestInfo] = []
async def send(self, info: HttpRequestInfo) -> HttpRequestResult:
self.received.append(info)
return self._result
@pytest.mark.asyncio
async def test_http_request_yaml_roundtrip() -> None:
handler = _StubHandler(
HttpRequestResult(
status_code=200,
is_success_status_code=True,
body='{"name": "runtime", "visibility": "public", "stars": 12345}',
headers={
"content-type": ["application/json"],
"x-ratelimit-remaining": ["59"],
},
)
)
factory = WorkflowFactory(http_request_handler=handler)
workflow = factory.create_workflow_from_yaml_path(FIXTURE_PATH)
await workflow.run({})
decl: dict[str, Any] = workflow._state.get(DECLARATIVE_STATE_KEY) or {}
local = decl.get("Local") or {}
assert local.get("RepoOwner") == "dotnet"
repo_info = local.get("RepoInfo")
assert isinstance(repo_info, dict), f"Expected dict body, got {type(repo_info)!r}"
assert repo_info["name"] == "runtime"
assert repo_info["visibility"] == "public"
assert repo_info["stars"] == 12345
repo_headers = local.get("RepoHeaders")
assert isinstance(repo_headers, dict)
# Single-value header surfaces as plain string.
assert repo_headers.get("content-type") == "application/json"
assert repo_headers.get("x-ratelimit-remaining") == "59"
# Stub got the right call.
assert len(handler.received) == 1
sent = handler.received[0]
assert sent.method == "GET"
assert sent.url == "https://api.github.com/repos/dotnet/runtime"
assert sent.headers["Accept"] == "application/vnd.github+json"
assert sent.headers["User-Agent"] == "agent-framework-integration-test"
@pytest.mark.asyncio
async def test_http_request_yaml_missing_handler_fails_at_build_time() -> None:
"""Without an http_request_handler, building the workflow must raise."""
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
factory = WorkflowFactory() # no handler configured
with pytest.raises(DeclarativeWorkflowError) as excinfo:
factory.create_workflow_from_yaml_path(FIXTURE_PATH)
msg = str(excinfo.value)
assert "HttpRequestAction" in msg
assert "http_request_handler" in msg
@@ -0,0 +1,59 @@
# Copyright (c) Microsoft. All rights reserved.
"""Regression tests for ``_make_powerfx_safe``.
PowerFx (via pythonnet) only accepts plain primitives, dicts, and lists.
``Enum`` instances - especially ``str``- and ``int``-subclass enums like
MAF's ``MessageRole`` - silently pass ``isinstance(v, str)`` /
``isinstance(v, int)`` checks but blow up later inside pythonnet with
``'<EnumName>' value cannot be converted to System.<X>``. These tests
pin down the Enum coercion branch so we don't regress that interop fix.
"""
from enum import Enum, IntEnum
from agent_framework_declarative._workflows._declarative_base import _make_powerfx_safe
class _StrRole(str, Enum):
USER = "user"
SYSTEM = "system"
class _IntCode(IntEnum):
ONE = 1
TWO = 2
class _PlainEnum(Enum):
X = "x"
Y = 42
def test_str_subclass_enum_reduces_to_str():
assert _make_powerfx_safe(_StrRole.USER) == "user"
assert type(_make_powerfx_safe(_StrRole.USER)) is str
def test_int_subclass_enum_reduces_to_int():
assert _make_powerfx_safe(_IntCode.ONE) == 1
assert type(_make_powerfx_safe(_IntCode.ONE)) is int
def test_plain_enum_reduces_to_underlying_value():
assert _make_powerfx_safe(_PlainEnum.X) == "x"
assert _make_powerfx_safe(_PlainEnum.Y) == 42
def test_enum_inside_dict_is_coerced():
safe = _make_powerfx_safe({"role": _StrRole.USER, "code": _IntCode.TWO})
assert safe == {"role": "user", "code": 2}
assert type(safe["role"]) is str
assert type(safe["code"]) is int
def test_enum_inside_list_is_coerced():
safe = _make_powerfx_safe([_StrRole.USER, _IntCode.ONE])
assert safe == ["user", 1]
assert type(safe[0]) is str
assert type(safe[1]) is int
@@ -4,10 +4,8 @@
import pytest
from agent_framework_declarative._workflows._factory import (
DeclarativeWorkflowError,
WorkflowFactory,
)
from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError
from agent_framework_declarative._workflows._factory import WorkflowFactory
try:
import powerfx # noqa: F401
@@ -228,6 +226,94 @@ actions:
outputs = result.get_outputs()
assert any("hello-world" in str(o) for o in outputs), f"Expected 'hello-world' in outputs but got: {outputs}"
async def test_as_agent_round_trip_with_last_message_text(self):
"""Regression test: a declarative workflow built via WorkflowFactory must be
consumable as an AIAgent via Workflow.as_agent().
Specifically, the declarative start executor must accept list[Message]
(the input passed by WorkflowAgent) and populate System.LastMessageText
so =System.LastMessageText is resolvable in the YAML.
"""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: as-agent-roundtrip-test
actions:
- kind: SetVariable
variable: Local.echo
value: =System.LastMessageText
- kind: SendActivity
activity:
text: =Local.echo
""")
agent = workflow.as_agent(name="echo-agent")
response = await agent.run("Hello there")
assert "Hello there" in response.text, (
f"Expected 'Hello there' in agent response text but got: {response.text!r}"
)
async def test_as_agent_continuation_preserves_prior_state(self):
"""Regression test for the ``is_continuation`` branch in
``DeclarativeWorkflowExecutor._ensure_state_initialized``.
Verifies, end-to-end via ``Workflow.as_agent()``:
* Turn 1 initializes the declarative state via ``state.initialize``.
* Turn 2 takes the *continuation* branch (skips ``state.initialize``),
so any non-Inputs/non-System state stamped on turn 1 survives.
* Turn 2 still refreshes ``Inputs.input`` and
``System.LastMessage*`` to the new user message.
Without state preservation, ``Workflow.run`` would clear shared state
on entry and ``state.initialize`` would re-run on every turn,
wiping the marker we stamped between calls.
"""
from agent_framework_declarative._workflows._declarative_base import DECLARATIVE_STATE_KEY
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: as-agent-continuation-test
actions:
- kind: SendActivity
activity:
text: =System.LastMessageText
""")
agent = workflow.as_agent(name="continuation-agent")
first = await agent.run("turn-1-msg")
assert first.text == "turn-1-msg", f"Expected turn-1 echo 'turn-1-msg', got: {first.text!r}"
# Stamp a marker into the declarative state between turns. The
# continuation branch must preserve it; a state-clearing run would
# wipe ``DECLARATIVE_STATE_KEY`` and force re-initialization.
state_data = workflow._state.get(DECLARATIVE_STATE_KEY)
assert isinstance(state_data, dict), "Expected declarative state to be initialized after turn 1"
state_data["Local"] = {"persisted_marker": "kept-from-turn-1"}
workflow._state.set(DECLARATIVE_STATE_KEY, state_data)
workflow._state.commit()
second = await agent.run("turn-2-msg")
assert second.text == "turn-2-msg", (
f"Expected System.LastMessageText to refresh to 'turn-2-msg', got: {second.text!r}"
)
# The continuation branch in ``_ensure_state_initialized`` must:
# 1. preserve the cross-turn marker we stamped above
# 2. refresh Inputs.input and System.LastMessage* to the new turn
post_state = workflow._state.get(DECLARATIVE_STATE_KEY)
assert isinstance(post_state, dict), "declarative state vanished between turns"
local = post_state.get("Local", {})
assert local.get("persisted_marker") == "kept-from-turn-1", (
f"Cross-turn marker was wiped (state was reset). post_state Local={local!r}"
)
assert post_state.get("Inputs", {}).get("input") == "turn-2-msg", (
f"Inputs.input not refreshed on turn 2: {post_state.get('Inputs')!r}"
)
assert post_state.get("System", {}).get("LastMessageText") == "turn-2-msg", (
f"System.LastMessageText not refreshed on turn 2: {post_state.get('System')!r}"
)
class TestWorkflowFactoryAgentRegistration:
"""Tests for agent registration."""
@@ -0,0 +1,29 @@
#
# Integration fixture: end-to-end HttpRequestAction round-trip using a
# stub HttpRequestHandler. Mirrors the .NET integration fixture in
# dotnet/tests/.../Workflows/HttpRequest.yaml.
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_http_request_test
actions:
# Set the repo owner used to form the request URL.
- kind: SetVariable
id: set_repo_owner
variable: Local.RepoOwner
value: dotnet
# Invoke the (stubbed) GitHub repo API.
- kind: HttpRequestAction
id: fetch_repo_info
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoOwner, "/runtime")
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-integration-test
response: Local.RepoInfo
responseHeaders: Local.RepoHeaders
+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.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
+3 -3
View File
@@ -4,7 +4,7 @@ description = "Microsoft Foundry integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.2.1"
version = "1.2.2"
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.2.1,<2",
"agent-framework-openai>=1.1.0,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-openai>=1.2.2,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -272,50 +272,86 @@ class ResponsesHostServer(ResponsesAgentServerHost):
if not isinstance(self._agent, WorkflowAgent):
raise RuntimeError("Agent is not a workflow agent.")
# Restore from the latest checkpoint if available, otherwise start with an empty history
# Determine the latest checkpoint (if any) so we can resume the
# workflow's prior state for this turn. The directory is keyed by
# the inbound context id (conversation_id when set, otherwise
# previous_response_id). Multi-turn declarative workflows need the
# workflow's internal state (e.g. Conversation.messages,
# intermediate Local.* variables) to survive across user turns;
# the only place that state lives is the workflow checkpoint, so
# on every turn we restore the latest checkpoint and feed the new
# input back into the start executor as a continuation rather than
# a fresh run.
latest_checkpoint_id: str | None = None
restore_storage: FileCheckpointStorage | None = None
if context_id is not None:
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=self._agent.workflow.name)
restore_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
latest_checkpoint = await restore_storage.get_latest(workflow_name=self._agent.workflow.name)
if latest_checkpoint is not None:
if not is_streaming_request:
_ = await self._agent.run(
stream=False,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
)
else:
# Consume the streaming or the invocation will result in a no-op
async for _ in self._agent.run(
stream=True,
checkpoint_id=latest_checkpoint.checkpoint_id,
checkpoint_storage=checkpoint_storage,
):
pass
latest_checkpoint_id = latest_checkpoint.checkpoint_id
# Storage that will receive checkpoints written during this turn.
# When the caller chains with previous_response_id, the next turn
# will reference the current response_id as its previous_response_id,
# so new checkpoints must land under the current response_id (or the
# conversation_id when set). When conversation_id is set, this
# matches restore_storage; when only previous_response_id was
# supplied, restore_storage points at the *prior* response's
# directory and write_storage points at the *current* response's.
write_context_id = context.conversation_id or context.response_id
write_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, write_context_id))
# Multi-turn pattern: when we have a prior checkpoint, restore it
# first (drive the workflow back to idle with prior state intact),
# then make a separate call that delivers the new user input. This
# depends on Workflow.run preserving shared state across calls. The
# restore-only call may yield events from any pending in-flight
# work in the checkpoint; we consume those internally here so they
# don't surface to the response stream as duplicates.
#
# If the restored checkpoint had pending request_info events, the
# restore-only call replays them through
# ``WorkflowAgent._convert_workflow_event_to_agent_response_updates``
# and populates ``self._agent.pending_requests``. That is the correct
# state: those requests are genuinely outstanding, and the next
# ``run(input_messages, ...)`` call may contain ``function_call_output``
# items (carried as FunctionResult/FunctionApprovalResponse content)
# that fulfill them via :meth:`WorkflowAgent._process_pending_requests`.
if latest_checkpoint_id is not None:
if is_streaming_request:
async for _ in self._agent.run(
stream=True,
checkpoint_id=latest_checkpoint_id,
checkpoint_storage=restore_storage,
):
pass
else:
await self._agent.run(
stream=False,
checkpoint_id=latest_checkpoint_id,
checkpoint_storage=restore_storage,
)
# Now run the agent with the latest input
response_event_stream = ResponseEventStream(response_id=context.response_id, model=request.model)
# Create a new checkpoint storage for this response based on the following rules:
# - If no previous response ID or conversation ID is provided,
# create a new checkpoint storage for this response
# - If a previous response ID is provided, create a new checkpoint storage for this response
# - If a conversation ID is provided, reuse the existing checkpoint storage for the conversation
context_id = context.conversation_id or context.response_id
checkpoint_storage = FileCheckpointStorage(os.path.join(self._checkpoint_storage_path, context_id))
yield response_event_stream.emit_created()
yield response_event_stream.emit_in_progress()
if not is_streaming_request:
# Run the agent in non-streaming mode
response = await self._agent.run(input_messages, stream=False, checkpoint_storage=checkpoint_storage)
# Run the agent in non-streaming mode with the new user input.
response = await self._agent.run(
input_messages,
stream=False,
checkpoint_storage=write_storage,
)
for message in response.messages:
for content in message.contents:
async for item in _to_outputs(response_event_stream, content):
yield item
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
return
@@ -323,8 +359,12 @@ class ResponsesHostServer(ResponsesAgentServerHost):
# lazily created on matching content, closed when a different type arrives.
tracker = _OutputItemTracker(response_event_stream)
# Run the workflow agent in streaming mode
async for update in self._agent.run(input_messages, stream=True, checkpoint_storage=checkpoint_storage):
# Run the workflow agent in streaming mode with the new user input.
async for update in self._agent.run(
input_messages,
stream=True,
checkpoint_storage=write_storage,
):
for content in update.contents:
for event in tracker.handle(content):
yield event
@@ -337,7 +377,7 @@ class ResponsesHostServer(ResponsesAgentServerHost):
for event in tracker.close():
yield event
await self._delete_not_latest_checkpoints(checkpoint_storage, self._agent.workflow.name)
await self._delete_not_latest_checkpoints(write_storage, self._agent.workflow.name)
yield response_event_stream.emit_completed()
@staticmethod
@@ -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.0a260428"
version = "1.0.0a260429"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,10 +23,10 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.1,<2",
"azure-ai-agentserver-core==2.0.0b3",
"azure-ai-agentserver-responses==1.0.0b5",
"azure-ai-agentserver-invocations==1.0.0b3",
"agent-framework-core>=1.2.2,<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",
]
[tool.uv]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"agent-framework-openai>=1.1.0,<2",
"foundry-local-sdk>=0.5.1,<0.5.2",
]
@@ -823,19 +823,28 @@ class RawGeminiChatClient(
match tool_mode.get("mode"):
case "auto":
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None
if "allowed_tools" in tool_mode:
function_calling_mode = types.FunctionCallingConfigMode.VALIDATED
allowed_names = list(tool_mode["allowed_tools"])
else:
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.AUTO, None
case "none":
function_calling_mode, allowed_names = types.FunctionCallingConfigMode.NONE, None
case "required":
function_calling_mode = types.FunctionCallingConfigMode.ANY
name = tool_mode.get("required_function_name")
allowed_names = [name] if name else None
if name:
allowed_names = [name]
elif "allowed_tools" in tool_mode:
allowed_names = list(tool_mode["allowed_tools"])
else:
allowed_names = None
case unknown_mode:
logger.warning("Unsupported tool_choice mode for Gemini: %s", unknown_mode)
return None
function_calling_kwargs: dict[str, Any] = {"mode": function_calling_mode}
if allowed_names:
if allowed_names is not None:
function_calling_kwargs["allowed_function_names"] = allowed_names
return types.ToolConfig(function_calling_config=types.FunctionCallingConfig(**function_calling_kwargs))
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260428"
version = "1.0.0a260429"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.1,<2.0",
"agent-framework-core>=1.2.2,<2.0",
"google-genai>=1.65.0,<2.0.0",
]
@@ -1157,6 +1157,86 @@ async def test_unknown_tool_choice_mode_is_ignored() -> None:
assert not hasattr(config, "tool_config") or config.tool_config is None
async def test_tool_choice_auto_with_allowed_tools_uses_VALIDATED() -> None:
"""Maps auto + allowed_tools to FunctionCallingConfigMode.VALIDATED with allowed_function_names."""
tool = _make_dummy_tool()
client, mock = _make_gemini_client()
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
await client.get_response(
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
options={
"tools": [tool],
"tool_choice": {"mode": "auto", "allowed_tools": ["dummy", "other"]},
},
)
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
function_calling_config = config.tool_config.function_calling_config
assert function_calling_config.mode == "VALIDATED"
assert function_calling_config.allowed_function_names == ["dummy", "other"]
async def test_tool_choice_auto_with_empty_allowed_tools_uses_VALIDATED() -> None:
"""Maps auto + empty allowed_tools to VALIDATED with empty allowed_function_names."""
tool = _make_dummy_tool()
client, mock = _make_gemini_client()
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
await client.get_response(
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
options={
"tools": [tool],
"tool_choice": {"mode": "auto", "allowed_tools": []},
},
)
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
function_calling_config = config.tool_config.function_calling_config
assert function_calling_config.mode == "VALIDATED"
assert function_calling_config.allowed_function_names == []
async def test_tool_choice_required_with_allowed_tools_uses_ANY() -> None:
"""Maps required + allowed_tools to ANY with allowed_function_names."""
tool = _make_dummy_tool()
client, mock = _make_gemini_client()
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
await client.get_response(
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
options={
"tools": [tool],
"tool_choice": {"mode": "required", "allowed_tools": ["dummy"]},
},
)
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
function_calling_config = config.tool_config.function_calling_config
assert function_calling_config.mode == "ANY"
assert function_calling_config.allowed_function_names == ["dummy"]
async def test_tool_choice_required_function_name_takes_precedence_over_allowed_tools() -> None:
"""When both required_function_name and allowed_tools are present, required_function_name wins."""
tool = _make_dummy_tool()
client, mock = _make_gemini_client()
mock.aio.models.generate_content = AsyncMock(return_value=_make_response([_make_part(text="Hi")]))
await client.get_response(
messages=[Message(role="user", contents=[Content.from_text("Hi")])],
options={
"tools": [tool],
"tool_choice": {"mode": "required", "required_function_name": "dummy", "allowed_tools": ["other"]},
},
)
config: types.GenerateContentConfig = mock.aio.models.generate_content.call_args.kwargs["config"]
function_calling_config = config.tool_config.function_calling_config
assert function_calling_config.mode == "ANY"
assert function_calling_config.allowed_function_names == ["dummy"]
# built-in tool factories
@@ -4,7 +4,7 @@ description = "GitHub Copilot integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260428"
version = "1.0.0a260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"hyperlight-sandbox>=0.3.0,<0.4",
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
"hyperlight-sandbox-python-guest>=0.3.0,<0.4",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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 = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"agent-framework-core>=1.2.1,<2",
"agent-framework-core>=1.2.2,<2",
]
[project.optional-dependencies]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"mem0ai>=1.0.0,<2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
license-files = ["LICENSE"]
urls.homepage = "https://learn.microsoft.com/en-us/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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"ollama>=0.5.3,<0.5.4",
]
@@ -241,6 +241,85 @@ OpenAIChatOptionsT = TypeVar(
# endregion
# region Helpers
def _annotations_to_output_text(annotations: Sequence[Annotation] | None) -> list[dict[str, Any]]:
"""Convert framework `Annotation` objects to Responses API `output_text` annotation dicts.
Citations from `file_search`, `code_interpreter` file paths, and url citations all collapse
to `Annotation(type="citation", ...)` in the framework. The original API form is recovered
here so assistant messages roundtrip cleanly through history forwarding.
Each Responses API annotation dict carries at most one `start_index`/`end_index` pair, so an
`Annotation` with multiple `annotated_regions` is fanned out into one entry per region.
Regions missing valid integer span bounds are skipped.
"""
if not annotations:
return []
out: list[dict[str, Any]] = []
for annotation in annotations:
if annotation.get("type") != "citation":
continue
props = annotation.get("additional_properties") or {}
regions = annotation.get("annotated_regions") or []
file_id = annotation.get("file_id")
url = annotation.get("url")
title = annotation.get("title")
container_id = props.get("container_id")
if container_id and file_id:
for region in regions:
start = region.get("start_index")
end = region.get("end_index")
if not (isinstance(start, int) and isinstance(end, int)):
continue
entry: dict[str, Any] = {
"type": "container_file_citation",
"container_id": container_id,
"file_id": file_id,
"start_index": start,
"end_index": end,
}
if url:
entry["filename"] = url
out.append(entry)
elif url and not file_id and regions:
for region in regions:
start = region.get("start_index")
end = region.get("end_index")
if not (isinstance(start, int) and isinstance(end, int)):
continue
out.append({
"type": "url_citation",
"url": url,
"title": title or "",
"start_index": start,
"end_index": end,
})
elif file_id and url:
entry = {
"type": "file_citation",
"file_id": file_id,
"filename": url,
}
if (idx := props.get("index")) is not None:
entry["index"] = idx
out.append(entry)
elif file_id:
entry = {
"type": "file_path",
"file_id": file_id,
}
if (idx := props.get("index")) is not None:
entry["index"] = idx
out.append(entry)
return out
# endregion
# region ResponsesClient
@@ -1217,6 +1296,12 @@ class RawOpenAIChatClient( # type: ignore[misc]
"type": "function",
"name": func_name,
}
elif mode == "auto" and (allowed := tool_mode.get("allowed_tools")) is not None:
run_options["tool_choice"] = {
"type": "allowed_tools",
"mode": "auto",
"tools": [{"type": "function", "name": name} for name in allowed],
}
else:
run_options["tool_choice"] = mode
else:
@@ -1374,7 +1459,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
return {
"type": "output_text",
"text": content.text,
"annotations": [],
"annotations": _annotations_to_output_text(getattr(content, "annotations", None)),
}
return {
"type": "input_text",
@@ -1522,6 +1607,13 @@ class RawOpenAIChatClient( # type: ignore[misc]
"approve": content.approved,
}
case "hosted_file":
# `input_file` is an input-only content type in the Responses API and is rejected
# inside an assistant message. Hosted-file content on an assistant message
# represents a citation produced by a hosted tool (e.g., file_search) and cannot be
# meaningfully replayed as input — drop it. The accompanying text annotations carry
# the citation context for round-tripping.
if role == "assistant":
return {}
return {
"type": "input_file",
"file_id": content.file_id,
@@ -2502,45 +2594,63 @@ class RawOpenAIChatClient( # type: ignore[misc]
ann_type = _get_ann_value("type")
ann_file_id = _get_ann_value("file_id")
# Hosted-file citations attach as text annotations (matching the non-streaming path)
# so they don't roundtrip as standalone `input_file` items in assistant history.
if ann_type == "file_path":
if ann_file_id:
annotation_obj = Annotation(
type="citation",
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
"index": _get_ann_value("index"),
},
raw_representation=annotation,
)
contents.append(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
"index": _get_ann_value("index"),
},
raw_representation=event,
)
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
)
elif ann_type == "file_citation":
if ann_file_id:
ann_filename = _get_ann_value("filename")
annotation_obj = Annotation(
type="citation",
file_id=str(ann_file_id),
url=ann_filename,
additional_properties={
"annotation_index": event.annotation_index,
"index": _get_ann_value("index"),
},
raw_representation=annotation,
)
contents.append(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
"filename": _get_ann_value("filename"),
"index": _get_ann_value("index"),
},
raw_representation=event,
)
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
)
elif ann_type == "container_file_citation":
if ann_file_id:
ann_filename = _get_ann_value("filename")
ann_start = _get_ann_value("start_index")
ann_end = _get_ann_value("end_index")
annotation_obj = Annotation(
type="citation",
file_id=str(ann_file_id),
url=ann_filename,
additional_properties={
"annotation_index": event.annotation_index,
"container_id": _get_ann_value("container_id"),
},
raw_representation=annotation,
)
if ann_start is not None and ann_end is not None:
annotation_obj["annotated_regions"] = [
TextSpanRegion(
type="text_span",
start_index=ann_start,
end_index=ann_end,
)
]
contents.append(
Content.from_hosted_file(
file_id=str(ann_file_id),
additional_properties={
"annotation_index": event.annotation_index,
"container_id": _get_ann_value("container_id"),
"filename": _get_ann_value("filename"),
"start_index": _get_ann_value("start_index"),
"end_index": _get_ann_value("end_index"),
},
raw_representation=event,
)
Content.from_text(text="", annotations=[annotation_obj], raw_representation=event)
)
elif ann_type == "url_citation":
ann_url = _get_ann_value("url")
@@ -662,6 +662,12 @@ class RawOpenAIChatCompletionClient( # type: ignore[misc]
"type": "function",
"function": {"name": func_name},
}
elif mode in ("auto", "required") and tool_mode.get("allowed_tools") is not None:
logger.warning(
"allowed_tools is not supported by the Chat Completions API; "
"the setting will be ignored. Use OpenAIChatClient (Responses API) instead."
)
run_options["tool_choice"] = mode
else:
run_options["tool_choice"] = mode
+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.2.1"
version = "1.2.2"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"openai>=1.99.0,<3",
]
@@ -1914,6 +1914,285 @@ def test_hosted_file_content_preparation() -> None:
assert result["file_id"] == "file_abc123"
def test_assistant_text_preserves_citation_annotations_on_roundtrip() -> None:
"""Citation annotations on assistant text should survive serialization back to the Responses API.
Previously `output_text.annotations` was hardcoded to `[]`, silently dropping `file_search`
citation context on every roundtrip. Preserving them keeps citations intact across
multi-agent forwarding.
"""
from agent_framework._types import Annotation, TextSpanRegion
client = OpenAIChatClient(model="test-model", api_key="test-key")
text_content = Content.from_text(
"Per the docs, the answer is X. See also the report.",
annotations=[
Annotation(
type="citation",
file_id="file-abc123",
url="guidelines.md",
additional_properties={"index": 12},
),
Annotation(
type="citation",
title="Quarterly Report",
url="https://example.com/report",
annotated_regions=[TextSpanRegion(type="text_span", start_index=40, end_index=46)],
),
Annotation(
type="citation",
file_id="file-container456",
url="data.csv",
additional_properties={"container_id": "container-789"},
annotated_regions=[TextSpanRegion(type="text_span", start_index=0, end_index=3)],
),
],
)
result = client._prepare_content_for_openai("assistant", text_content)
assert result["type"] == "output_text"
annotations = result["annotations"]
assert len(annotations) == 3
file_citation = next(a for a in annotations if a["type"] == "file_citation")
assert file_citation["file_id"] == "file-abc123"
assert file_citation["filename"] == "guidelines.md"
assert file_citation["index"] == 12
url_citation = next(a for a in annotations if a["type"] == "url_citation")
assert url_citation["url"] == "https://example.com/report"
assert url_citation["title"] == "Quarterly Report"
assert url_citation["start_index"] == 40
assert url_citation["end_index"] == 46
container = next(a for a in annotations if a["type"] == "container_file_citation")
assert container["file_id"] == "file-container456"
assert container["container_id"] == "container-789"
assert container["filename"] == "data.csv"
assert container["start_index"] == 0
assert container["end_index"] == 3
def test_assistant_text_preserves_file_path_annotation() -> None:
"""A `file_path`-style citation (file_id only, no url) should serialize as `file_path`."""
from agent_framework._types import Annotation
client = OpenAIChatClient(model="test-model", api_key="test-key")
text_content = Content.from_text(
"See attached.",
annotations=[
Annotation(
type="citation",
file_id="file-only",
additional_properties={"index": 42},
),
],
)
result = client._prepare_content_for_openai("assistant", text_content)
assert result["type"] == "output_text"
annotations = result["annotations"]
assert annotations == [{"type": "file_path", "file_id": "file-only", "index": 42}]
def test_assistant_text_fans_out_multiple_annotated_regions() -> None:
"""A url_citation with multiple `annotated_regions` should emit one entry per region.
The Responses API annotation dict carries one start/end pair, so a framework Annotation
with N regions must produce N output annotation entries.
"""
from agent_framework._types import Annotation, TextSpanRegion
client = OpenAIChatClient(model="test-model", api_key="test-key")
text_content = Content.from_text(
"See report. The report says X. Also report.",
annotations=[
Annotation(
type="citation",
title="Report",
url="https://example.com/report",
annotated_regions=[
TextSpanRegion(type="text_span", start_index=4, end_index=10),
TextSpanRegion(type="text_span", start_index=16, end_index=22),
TextSpanRegion(type="text_span", start_index=36, end_index=42),
],
),
],
)
result = client._prepare_content_for_openai("assistant", text_content)
annotations = result["annotations"]
assert len(annotations) == 3
assert all(a["type"] == "url_citation" for a in annotations)
spans = [(a["start_index"], a["end_index"]) for a in annotations]
assert spans == [(4, 10), (16, 22), (36, 42)]
def test_assistant_text_skips_regions_with_invalid_span() -> None:
"""Regions missing integer start/end bounds are skipped rather than emitted with `None`."""
from agent_framework._types import Annotation, TextSpanRegion
client = OpenAIChatClient(model="test-model", api_key="test-key")
text_content = Content.from_text(
"See report.",
annotations=[
Annotation(
type="citation",
title="Report",
url="https://example.com/report",
annotated_regions=[
TextSpanRegion(type="text_span"), # type: ignore[typeddict-item]
TextSpanRegion(type="text_span", start_index=4, end_index=10),
],
),
],
)
result = client._prepare_content_for_openai("assistant", text_content)
annotations = result["annotations"]
assert len(annotations) == 1
assert annotations[0]["start_index"] == 4
assert annotations[0]["end_index"] == 10
def test_assistant_text_without_annotations_emits_empty_list() -> None:
"""Plain assistant text should still emit `annotations: []` (Azure validation requires the field)."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
result = client._prepare_content_for_openai("assistant", Content.from_text("hello"))
assert result["type"] == "output_text"
assert result["text"] == "hello"
assert result["annotations"] == []
def test_streamed_file_citation_coalesces_onto_surrounding_text() -> None:
"""Streamed citation events emit empty-text Content with annotations; `_finalize_response`
coalesces consecutive text contents and unions their annotations, so the citation lands on
the merged assistant text content (not a stray empty-text entry).
Without this, span indices in the annotation would reference `text == ""` after roundtrip.
"""
text_event = MagicMock()
text_event.type = "response.output_text.delta"
text_event.delta = "Hello world."
text_event.item_id = "item_1"
text_event.output_index = 0
text_event.content_index = 0
citation_event = MagicMock()
citation_event.type = "response.output_text.annotation.added"
citation_event.annotation_index = 0
citation_event.annotation = {
"type": "file_citation",
"file_id": "file-abc",
"filename": "guidelines.md",
"index": 5,
}
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
update1 = client._parse_chunk_from_openai(text_event, chat_options, function_call_ids)
update2 = client._parse_chunk_from_openai(citation_event, chat_options, function_call_ids)
response = ChatResponse.from_updates([update1, update2])
assert len(response.messages) == 1
contents = response.messages[0].contents
assert len(contents) == 1
merged = contents[0]
assert merged.type == "text"
assert merged.text == "Hello world."
assert merged.annotations is not None
assert len(merged.annotations) == 1
assert merged.annotations[0]["file_id"] == "file-abc"
def test_streamed_file_citation_roundtrips_as_assistant_history() -> None:
"""End-to-end: file_citation arrives via streaming, then gets forwarded as assistant history.
Reproduces the user-reported sequential/group-chat workflow bug where one agent's
`file_search` citations became `input_file` items in the next agent's request and were
rejected by the Responses API.
"""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
text_event = MagicMock()
text_event.type = "response.output_text.delta"
text_event.delta = "According to the docs, the answer is X."
text_event.item_id = "item_1"
text_event.output_index = 0
text_event.content_index = 0
citation_event = MagicMock()
citation_event.type = "response.output_text.annotation.added"
citation_event.annotation_index = 0
citation_event.annotation = {
"type": "file_citation",
"file_id": "file-xyz789",
"filename": "guidelines.md",
"index": 12,
}
update1 = client._parse_chunk_from_openai(text_event, chat_options, function_call_ids)
update2 = client._parse_chunk_from_openai(citation_event, chat_options, function_call_ids)
assistant_history = Message(
role="assistant",
contents=[*update1.contents, *update2.contents],
)
prepared = client._prepare_message_for_openai(assistant_history)
assert len(prepared) == 1
content_items = prepared[0].get("content", [])
types = [c.get("type") for c in content_items]
assert "input_file" not in types, f"input_file leaked into assistant history: {types}"
output_text_items = [c for c in content_items if c.get("type") == "output_text"]
assert any(
any(a.get("type") == "file_citation" and a.get("file_id") == "file-xyz789" for a in c.get("annotations", []))
for c in output_text_items
), "file_citation annotation should survive the streaming → history roundtrip"
def test_hosted_file_in_assistant_message_does_not_emit_input_file() -> None:
"""Hosted file citations attached to an assistant message must not roundtrip as `input_file`.
The Responses API rejects `input_file` items inside an assistant role's content array;
`input_file` is an input-only content type. This guards the multi-agent / sequential workflow
case where one agent's `file_search` citations get forwarded as history to the next call.
"""
client = OpenAIChatClient(model="test-model", api_key="test-key")
assistant_msg = Message(
role="assistant",
contents=[
Content.from_text("According to the docs, the answer is X."),
Content.from_hosted_file(file_id="file_abc123"),
],
)
prepared = client._prepare_message_for_openai(assistant_msg)
assert len(prepared) == 1
assistant_item = prepared[0]
assert assistant_item["role"] == "assistant"
content_types = [c.get("type") for c in assistant_item.get("content", [])]
assert "input_file" not in content_types, (
f"`input_file` is not valid inside an assistant message; got {content_types}"
)
assert "output_text" in content_types
def test_function_approval_response_with_mcp_tool_call() -> None:
"""Test function approval response content with MCP server tool call content."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
@@ -2682,7 +2961,7 @@ def test_streaming_response_in_progress_type() -> None:
def test_streaming_annotation_added_with_file_path() -> None:
"""Test streaming annotation added event with file_path type extracts HostedFileContent."""
"""Streaming `file_path` should attach as a text annotation, matching non-streaming."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
@@ -2700,15 +2979,23 @@ def test_streaming_annotation_added_with_file_path() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert content.type == "hosted_file"
assert content.file_id == "file-abc123"
assert content.additional_properties is not None
assert content.additional_properties.get("annotation_index") == 0
assert content.additional_properties.get("index") == 42
assert content.type == "text"
assert content.annotations is not None
assert len(content.annotations) == 1
annotation = content.annotations[0]
assert annotation["type"] == "citation"
assert annotation["file_id"] == "file-abc123"
assert annotation["additional_properties"]["annotation_index"] == 0
assert annotation["additional_properties"]["index"] == 42
def test_streaming_annotation_added_with_file_citation() -> None:
"""Test streaming annotation added event with file_citation type extracts HostedFileContent."""
"""Streaming `file_citation` should attach as a text annotation, matching non-streaming.
Previously the streaming path produced a standalone `HostedFileContent`, which then
serialized as `input_file` in assistant history and was rejected by the Responses API.
Annotations on text content roundtrip cleanly.
"""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
@@ -2727,15 +3014,19 @@ def test_streaming_annotation_added_with_file_citation() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert content.type == "hosted_file"
assert content.file_id == "file-xyz789"
assert content.additional_properties is not None
assert content.additional_properties.get("filename") == "sample.txt"
assert content.additional_properties.get("index") == 15
assert content.type == "text"
assert content.annotations is not None
assert len(content.annotations) == 1
annotation = content.annotations[0]
assert annotation["type"] == "citation"
assert annotation["file_id"] == "file-xyz789"
assert annotation["url"] == "sample.txt"
assert annotation["additional_properties"]["annotation_index"] == 1
assert annotation["additional_properties"]["index"] == 15
def test_streaming_annotation_added_with_container_file_citation() -> None:
"""Test streaming annotation added event with container_file_citation type."""
"""Streaming `container_file_citation` should attach as a text annotation."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
chat_options = ChatOptions()
function_call_ids: dict[int, tuple[str, str]] = {}
@@ -2756,13 +3047,19 @@ def test_streaming_annotation_added_with_container_file_citation() -> None:
assert len(response.contents) == 1
content = response.contents[0]
assert content.type == "hosted_file"
assert content.file_id == "file-container123"
assert content.additional_properties is not None
assert content.additional_properties.get("container_id") == "container-456"
assert content.additional_properties.get("filename") == "data.csv"
assert content.additional_properties.get("start_index") == 10
assert content.additional_properties.get("end_index") == 50
assert content.type == "text"
assert content.annotations is not None
assert len(content.annotations) == 1
annotation = content.annotations[0]
assert annotation["type"] == "citation"
assert annotation["file_id"] == "file-container123"
assert annotation["url"] == "data.csv"
assert annotation["additional_properties"]["container_id"] == "container-456"
assert annotation["annotated_regions"] is not None
assert len(annotation["annotated_regions"]) == 1
region = annotation["annotated_regions"][0]
assert region["start_index"] == 10
assert region["end_index"] == 50
def test_streaming_annotation_added_with_url_citation() -> None:
@@ -3962,6 +4259,12 @@ def test_with_callable_api_key() -> None:
True,
id="tool_choice_required",
),
param(
"tool_choice",
{"mode": "auto", "allowed_tools": ["get_weather"]},
True,
id="tool_choice_allowed_tools",
),
param("response_format", OutputStruct, True, id="response_format_pydantic"),
param(
"response_format",
@@ -4516,6 +4819,90 @@ async def test_prepare_options_excludes_continuation_token() -> None:
assert run_options["background"] is True
async def test_prepare_options_allowed_tools() -> None:
"""Test that _prepare_options converts allowed_tools to OpenAI API format."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
@tool
def get_weather(city: str) -> str:
"""Get the weather for a city."""
return f"Sunny in {city}"
@tool
def search_docs(query: str) -> str:
"""Search documentation."""
return f"Results for {query}"
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
options: dict[str, Any] = {
"model": "test-model",
"tools": [get_weather, search_docs],
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
}
run_options = await client._prepare_options(messages, options)
assert run_options["tool_choice"] == {
"type": "allowed_tools",
"mode": "auto",
"tools": [{"type": "function", "name": "get_weather"}],
}
async def test_prepare_options_allowed_tools_multiple() -> None:
"""Test that _prepare_options converts multiple allowed_tools correctly."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
@tool
def get_weather(city: str) -> str:
"""Get the weather for a city."""
return f"Sunny in {city}"
@tool
def search_docs(query: str) -> str:
"""Search documentation."""
return f"Results for {query}"
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
options: dict[str, Any] = {
"model": "test-model",
"tools": [get_weather, search_docs],
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather", "search_docs"]},
}
run_options = await client._prepare_options(messages, options)
assert run_options["tool_choice"] == {
"type": "allowed_tools",
"mode": "auto",
"tools": [
{"type": "function", "name": "get_weather"},
{"type": "function", "name": "search_docs"},
],
}
async def test_prepare_options_auto_without_allowed_tools() -> None:
"""Test that auto mode without allowed_tools still returns plain 'auto' string."""
client = OpenAIChatClient(model="test-model", api_key="test-key")
@tool
def get_weather(city: str) -> str:
"""Get the weather for a city."""
return f"Sunny in {city}"
messages = [Message(role="user", contents=[Content.from_text(text="Hello")])]
options: dict[str, Any] = {
"model": "test-model",
"tools": [get_weather],
"tool_choice": {"mode": "auto"},
}
run_options = await client._prepare_options(messages, options)
assert run_options["tool_choice"] == "auto"
# endregion
@@ -1430,6 +1430,57 @@ def test_tool_choice_required_with_function_name(
assert prepared_options["tool_choice"]["function"]["name"] == "get_weather"
def test_tool_choice_allowed_tools_falls_back_to_mode(
openai_unit_test_env: dict[str, str],
) -> None:
"""Test that tool_choice with allowed_tools falls back to plain mode (Chat Completions API unsupported)."""
client = OpenAIChatCompletionClient()
messages = [Message(role="user", contents=["test"])]
options = {
"tools": [get_weather],
"tool_choice": {"mode": "auto", "allowed_tools": ["get_weather"]},
}
prepared_options = client._prepare_options(messages, options)
assert prepared_options["tool_choice"] == "auto"
def test_tool_choice_allowed_tools_required_mode_falls_back(
openai_unit_test_env: dict[str, str],
) -> None:
"""Test that tool_choice with allowed_tools and required mode falls back to 'required'."""
client = OpenAIChatCompletionClient()
messages = [Message(role="user", contents=["test"])]
options = {
"tools": [get_weather],
"tool_choice": {"mode": "required", "allowed_tools": ["get_weather"]},
}
prepared_options = client._prepare_options(messages, options)
assert prepared_options["tool_choice"] == "required"
def test_tool_choice_auto_dict_without_allowed_tools(
openai_unit_test_env: dict[str, str],
) -> None:
"""Test that tool_choice dict with mode auto and no allowed_tools falls through to plain 'auto'."""
client = OpenAIChatCompletionClient()
messages = [Message(role="user", contents=["test"])]
options = {
"tools": [get_weather],
"tool_choice": {"mode": "auto"},
}
prepared_options = client._prepare_options(messages, options)
assert prepared_options["tool_choice"] == "auto"
def test_response_format_dict_passthrough(openai_unit_test_env: dict[str, str]) -> None:
"""Test that response_format as dict is passed through directly."""
client = OpenAIChatCompletionClient()
@@ -1590,6 +1641,12 @@ class OutputStruct(BaseModel):
False,
id="tool_choice_required",
),
param(
"tool_choice",
{"mode": "auto", "allowed_tools": ["get_weather"]},
False,
id="tool_choice_allowed_tools",
),
param("response_format", OutputStruct, True, id="response_format_pydantic"),
param(
"response_format",
@@ -4,7 +4,7 @@ description = "Orchestration patterns for Microsoft Agent Framework. Includes Se
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
]
[tool.uv]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"azure-core>=1.30.0,<2",
"httpx>=0.27.0,<0.29",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260428"
version = "1.0.0b260429"
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.2.1,<2",
"agent-framework-core>=1.2.2,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
+5 -5
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.2.1"
version = "1.2.2"
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.2.1",
"agent-framework-core[all]==1.2.2",
]
[dependency-groups]
@@ -52,8 +52,9 @@ dev = [
[tool.uv]
package = false
prerelease = "if-necessary-or-explicit"
# Keep transitive litellm below the compromised 1.82.7/1.82.8 releases.
constraint-dependencies = ["litellm<1.82.7"]
# Security floors for transitive deps; overrides bypass litellm[proxy]'s strict pins.
constraint-dependencies = ["litellm>=1.83.7", "fastapi-sso>=0.19.0"]
override-dependencies = ["mcp[ws]>=1.27.0", "uvicorn[standard]>=0.34.0"]
environments = [
"sys_platform == 'darwin'",
"sys_platform == 'linux'",
@@ -93,7 +94,6 @@ agent-framework-orchestrations = { workspace = true }
agent-framework-purview = { workspace = true }
agent-framework-redis = { workspace = true }
agent-framework-azure-contentunderstanding = { workspace = true }
litellm = { url = "https://files.pythonhosted.org/packages/57/77/0c6eca2cb049793ddf8ce9cdcd5123a35666c4962514788c4fc90edf1d3b/litellm-1.82.1-py3-none-any.whl" }
[tool.ruff]
line-length = 120
@@ -0,0 +1,97 @@
# Copyright (c) Microsoft. All rights reserved.
"""Invoke HTTP Request sample - demonstrates the HttpRequestAction declarative action.
This sample shows how to:
1. Configure a ``WorkflowFactory`` with a ``HttpRequestHandler`` so the YAML
``HttpRequestAction`` can dispatch real HTTP calls.
2. Fetch JSON from a public REST endpoint (the GitHub repository API) and
bind the parsed response to a workflow variable.
3. Mirror the response body into the conversation via ``conversationId`` so
a downstream Foundry agent can answer questions about it using only that
conversation context.
Security note:
``DefaultHttpRequestHandler`` issues HTTP calls to whatever URL the
workflow author specifies and performs **no** allowlisting or SSRF
guards. For production use, replace it with a custom handler that
enforces an allowlist or DNS-rebinding-resistant policy and adds any
required authentication headers per call.
Run with:
python -m samples.03-workflows.declarative.invoke_http_request.main
"""
import asyncio
import os
from pathlib import Path
from agent_framework import Agent
from agent_framework.declarative import (
DefaultHttpRequestHandler,
WorkflowFactory,
)
from agent_framework.foundry import FoundryChatClient
from azure.identity import AzureCliCredential
GITHUB_REPO_INFO_AGENT_INSTRUCTIONS = """\
You answer the user's question about a GitHub repository using ONLY the JSON
data already present in the conversation history. If the answer is not
contained in the conversation, say so plainly rather than guessing. Be concise
and helpful.
"""
async def main() -> None:
"""Run the invoke HTTP request workflow."""
chat_client = FoundryChatClient(
project_endpoint=os.environ["FOUNDRY_PROJECT_ENDPOINT"],
model=os.environ["FOUNDRY_MODEL"],
credential=AzureCliCredential(),
)
# The agent has no tools — it answers the question about the GitHub
# repository using only the JSON data that ``HttpRequestAction`` adds to
# the conversation.
github_repo_info_agent = Agent(
client=chat_client,
name="GitHubRepoInfoAgent",
instructions=GITHUB_REPO_INFO_AGENT_INSTRUCTIONS,
)
agents = {"GitHubRepoInfoAgent": github_repo_info_agent}
# The default HttpRequestHandler is sufficient for this sample because
# the GitHub REST endpoint used here does not require authentication.
# For authenticated endpoints, supply a custom client_provider callback
# to DefaultHttpRequestHandler so each request can be routed through a
# pre-configured httpx.AsyncClient with the appropriate credentials.
async with DefaultHttpRequestHandler() as http_handler:
factory = WorkflowFactory(
agents=agents,
http_request_handler=http_handler,
)
workflow_path = Path(__file__).parent / "workflow.yaml"
workflow = factory.create_workflow_from_yaml_path(workflow_path)
print("=" * 60)
print("Invoke HTTP Request Workflow Demo")
print("=" * 60)
print()
print("Ask one question about the microsoft/agent-framework repo.")
print()
user_input = input("You: ").strip() # noqa: ASYNC250
if not user_input:
user_input = "Please summarize the repository."
print("\nAgent: ", end="", flush=True)
async for event in workflow.run(user_input, stream=True):
if event.type == "output" and isinstance(event.data, str):
print(event.data, end="", flush=True)
print()
if __name__ == "__main__":
asyncio.run(main())
@@ -0,0 +1,57 @@
#
# This workflow demonstrates the HttpRequestAction declarative action.
#
# HttpRequestAction lets a workflow author issue an HTTP call directly from
# YAML without writing any Python glue. It can:
#
# - fetch data from external REST endpoints,
# - store the parsed response in a workflow variable, and
# - add the response body to the conversation so a downstream agent can
# answer questions based on it.
#
# This sample fetches public metadata for the microsoft/agent-framework
# repository from the GitHub REST API (no authentication required) and uses
# a Foundry agent to answer a single question about it.
#
# Example input:
# How many open issues does the repository have?
#
kind: Workflow
trigger:
kind: OnConversationStart
id: workflow_invoke_http_request_demo
actions:
# Set the repository org/name used to form the request URL.
- kind: SetVariable
id: set_repo_name
variable: Local.RepoName
value: microsoft/agent-framework
# Invoke the GitHub repo API. The response body is parsed into
# Local.RepoInfo and also added to the conversation (via conversationId)
# so the agent below can answer questions based on it.
- kind: HttpRequestAction
id: fetch_repo_info
conversationId: =System.ConversationId
method: GET
url: =Concatenate("https://api.github.com/repos/", Local.RepoName)
headers:
Accept: application/vnd.github+json
User-Agent: agent-framework-sample
response: Local.RepoInfo
# Use the agent to answer the user's question using the conversation
# context (which now contains the GitHub JSON response). The user's
# original message is already in the conversation as System.LastMessage,
# and the executor's input fallback chain extracts its ``Text`` field
# automatically when ``input.messages`` is omitted.
- kind: InvokeAzureAgent
id: answer_question
conversationId: =System.ConversationId
agent:
name: GitHubRepoInfoAgent
output:
autoSend: true
messages: Local.AgentResponse
@@ -1199,6 +1199,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
@@ -1345,19 +1358,6 @@
}
}
},
"node_modules/tinyglobby/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1464,19 +1464,6 @@
"optional": true
}
}
},
"node_modules/vite/node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
}
}
}
+898 -927
View File
File diff suppressed because it is too large Load Diff