Compare commits

..
Author SHA1 Message Date
01fc518b29 Python: bump package versions for 1.8.0 release (#6351)
- Released cohort (core, openai, foundry, root): 1.7.0 -> 1.8.0
- agent-framework-github-copilot: promote to RC (1.0.0rc1)
- agent-framework-orchestrations: rc2 -> rc3 (bug fix)
- Beta/alpha packages with changes: a2a, anthropic, azurefunctions, bedrock,
  foundry-hosting, mistral bumped to new date stamp (260604)
- Inter-package dependency bounds updated for changed packages
- CHANGELOG.md and PACKAGE_STATUS.md updated

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 23:03:24 +00:00
f3c3efed43 Python: Add GitHub Copilot integration tests to CI workflows (#6346)
Add a dedicated integration test job for the github_copilot package to both
python-integration-tests.yml and python-merge-tests.yml.

The job:
- Runs 6 integration tests marked with @pytest.mark.integration
- Uses COPILOT_GITHUB_TOKEN secret from the integration environment
- Follows the same pattern as other provider integration jobs
- Includes path filtering in merge-tests (github_copilot package + core changes)
- Added to needs lists in report and check jobs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 22:06:26 +00:00
bbccb7c28c .NET: Bump ModelContextProtocol from 1.1.0 to 1.2.0 (#3956) (#6239)
Co-authored-by: Neeraj Karamchandani <neerajkaramchandani@mac.mynetworksettings.com>
2026-06-04 21:51:15 +01:00
Tao ChenandGitHub dbc312a78a Python: Fix toolbox consent flow in hosted agent (#6249)
* Fix toolbox consent flow in hosted agent

* Resolve conflict

* Make unused tool as comment

* Fix tests
2026-06-04 20:28:59 +00:00
bb9ed63a34 .NET: Restructure skill script schemas XML and remove resources from body (#6343)
* Restore UTF-8 BOMs and fix BuildScriptSchemasBlock doc comment

- Restore UTF-8 BOM on all changed files to match repo convention
- Fix XML doc: <schema name=...> -> <schema script=...> to match emitted output

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

* Address PR review comments: fix doc remarks and rename tests

- Update script doc remarks to clarify only parameter schemas are included
- Fix grammar: 'arguments format' -> 'argument format'
- Rename misleading test methods to match actual assertions
- Clarify comment about removed wrapper element

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

---------

Co-authored-by: SergeyMenshykh <SergeMenshikh@outlook.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 21:15:29 +01:00
6b94315161 Python: Add timeout parameter to FoundryAgent to fix ConnectTimeout on multi-turn conversations (#6263)
* Python: fix ConnectTimeout on multi-turn FoundryAgent conversations (#6241)

Expose a `timeout` parameter on `RawFoundryAgentChatClient`,
`_FoundryAgentChatClient`, `RawFoundryAgent`, `FoundryAgent`, and
`RawOpenAIChatClient` so callers can override the HTTP timeout used by
the underlying AsyncOpenAI client.

Root cause: `RawFoundryAgentChatClient.__init__` called
`project_client.get_openai_client()` without configuring any timeout,
inheriting the OpenAI SDK default of `httpx.Timeout(connect=5.0)`.
When connections are recycled between turns under load, the 5 s connect
timeout fires and surfaces as `openai.APITimeoutError`.

Fix:
- `load_openai_service_settings` (`_shared.py`): accept `timeout` and
  include it in `client_args` for all three `AsyncOpenAI`/
  `AsyncAzureOpenAI` construction paths.
- `RawOpenAIChatClient.__init__` (`_chat_client.py`): accept `timeout`
  and forward to `load_openai_service_settings`.
- `RawFoundryAgentChatClient.__init__` (`_agent.py`): accept `timeout`
  and set `openai_client.timeout = timeout` on the client returned by
  `get_openai_client()` before passing it to the base class.
- `_FoundryAgentChatClient`, `RawFoundryAgent`, `FoundryAgent`: accept
  and propagate `timeout` through the construction chain.

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

* Add timeout parameter to FoundryAgent and RawOpenAIChatClient

Expose a timeout parameter on RawFoundryAgentChatClient,
_FoundryAgentChatClient, RawFoundryAgent, FoundryAgent, and
RawOpenAIChatClient. When provided, the value is applied to the
underlying AsyncOpenAI client so that connect timeouts under load
or after connection recycling can be tuned by callers.

Previously, get_openai_client() was called without any timeout
override, so the SDK default of httpx.Timeout(connect=5.0) was
inherited and could fire on multi-turn conversations where the
underlying connection is recycled between turns.

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

* Python: Add `timeout` parameter to `FoundryAgent` to fix `ConnectTimeout` on multi-turn conversations

Fixes #6241

* fix(foundry): use with_options to avoid mutating shared OpenAI client timeout (#6241)

Replace direct assignment  with
 in
RawFoundryAgentChatClient.__init__.

The Azure AI Projects SDK caches and returns a shared AsyncOpenAI client
per AIProjectClient. Mutating its .timeout attribute leaked the override
to all other code paths sharing that client (other agents, user code).
with_options() returns a new client instance with the override applied,
leaving the original shared client untouched.

Update tests to assert with_options is called with the correct timeout
and that the original shared client's timeout attribute is not mutated.

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

* test(foundry): assert with_options return value flows to instance.client (#6241)

The four timeout propagation tests verified that with_options was called
but did not confirm that the returned (timeout-configured) client was
actually stored on the instance. A silent discard of the return value
would have left the tests green while the timeout had no effect.

Each test now captures the constructed instance and asserts:
  assert <instance>.client is openai_client_mock.with_options.return_value

Affected tests:
- test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client
- test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled
- test_foundry_agent_chat_client_init_propagates_timeout
- test_foundry_agent_init_propagates_timeout_to_openai_client

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-06-04 18:25:18 +00:00
Yufeng HeandGitHub bc0e65d716 fix: drop hosted MCP calls when reasoning is stripped (#6210) 2026-06-04 18:11:24 +00:00
4268080c20 Python: Fix spurious Magentic custom manager warning (#6261)
* Fix magentic manager warning

* Use typing_extensions.Sentinel for _MISSING sentinel value

Replace the bare object() sentinel with typing_extensions.Sentinel per
PEP 661 (now final). Sentinel provides a proper name and repr
('<_MISSING>') and is the idiomatic approach going forward.

Refs #4306

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

* fix: correct Sentinel type annotation for max_stall_count param (#6261)

Use int | Sentinel for max_stall_count parameter type annotation instead
of int with cast(Any, _MISSING) to properly express that the parameter
can hold either an int or the _MISSING sentinel value. This fixes the
pyright reportUnnecessaryComparison errors caused by the types int and
Sentinel having no overlap.

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

* Rename _MISSING sentinel to UNSET in orchestrations

The sentinel is user-visible as a default in public init signatures, so
use UNSET (no leading underscore) instead of the private _MISSING name.
Drop the now-unnecessary reportPrivateUsage ignores on the UNSET imports.

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-06-04 08:59:04 +00:00
fe08574a7c Python: [BREAKING] Upgrade github-copilot-sdk to v1.0.0 (stable) (#6292)
* Python: Upgrade github-copilot-sdk to v1.0.0 (stable)

Upgrade agent-framework-github-copilot from github-copilot-sdk 1.0.0b2 to the
stable 1.0.0 release, adapting to all breaking API changes.

Source changes (_agent.py):
- SubprocessConfig removed: use RuntimeConnection.for_stdio(path=...) +
  CopilotClient kwargs (connection, log_level, base_directory)
- Import paths: copilot.generated.session_events -> copilot.session_events
- Settings: copilot_home -> base_directory (env GITHUB_COPILOT_BASE_DIRECTORY)
- Default deny handler: PermissionDecisionUserNotAvailable() (from
  copilot.generated.rpc)

Test changes:
- Updated imports and client-construction assertions (kwargs-based)
- Permission handler tests use concrete decision types
  (PermissionDecisionApproveOnce, PermissionDecisionDeniedInteractivelyByUser)

Sample changes:
- Permission handlers use PermissionHandler.approve_all or sync
  approve_and_log pattern (v1.0.0 protocol v3 dispatch is incompatible
  with blocking input() in permission handlers)
- Function approval sample uses asyncio.to_thread for interactive prompts
- Simplified imports across all samples

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

* Address PR review: scope permission handlers, widen type, add test

- Shell sample: only approve kind='shell', deny others
- URL sample: only approve kind='url', deny others
- Use getattr() for kind-specific attributes to satisfy pyright
- Widen PermissionHandlerType to accept async handlers (matches SDK)
- Add test for _deny_all_permissions return value

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

* Fix validation script and strengthen test assertion

- Update scripts/sample_validation/create_dynamic_workflow_executor.py to
  use copilot.session_events imports and PermissionHandler.approve_all
- Assert isinstance(result, PermissionDecisionUserNotAvailable) instead of
  stringly-typed kind check

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

* Add integration tests for GitHubCopilotAgent

Add 6 integration tests mirroring .NET coverage:
- Basic non-streaming response
- Streaming response
- Function tool invocation
- Session context (multi-turn)
- Session resume by ID
- Shell command execution

Tests require COPILOT_GITHUB_TOKEN env var (skipped otherwise).
Each test cleans up its Copilot session via delete_session.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 08:42:35 +00:00
f970a699d8 Python: Fix compaction message-id collisions and tool-loop summary persistence (#6299)
* Fix compaction message-id collisions and tool-loop summary persistence

Fixes two bugs in the compaction strategies:

- #5237: incremental group annotation assigned message ids by position
  within the re-annotated slice, so moving the re-annotation start back to
  a previous group start restarted ids at 0 and produced collisions
  (e.g. a user message reusing an assistant message's id), merging groups
  and causing tool-result compaction to wrongly exclude messages.
  group_messages/_ensure_message_ids now take an id_offset and guard
  against existing-id collisions; annotate_message_groups threads the
  slice start index through as the offset.

- #4991: the function-invocation loop copied the message list each
  iteration, so summaries inserted by compaction landed in a throwaway
  copy and were lost across tool-loop iterations (only the persistent
  excluded flags survived). _prepare_messages_for_model_call now compacts
  the list in place when messages is a list, so inserted summaries persist.

Adds regression tests (incremental id uniqueness, existing-id collision
avoidance, idempotency, and tool-loop summary persistence including
streaming and conversation-id modes).

Also adds a summarization.py sample demonstrating SummarizationStrategy
directly with a real client, and reworks advanced.py with tool-call
groups and a real summarizer.

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

* Guard incremental message-id assignment against prefix-id collisions

Addresses PR review on #5237: _ensure_message_ids only guarded against
collisions within the re-annotated slice. A preexisting (e.g. user-supplied)
id in the preserved prefix could still be reassigned in the suffix when the
id was numerically out of position, merging groups across the re-annotation
boundary again.

group_messages/_ensure_message_ids now accept reserved_ids, and
annotate_message_groups passes the preserved prefix's ids so auto-assigned
suffix ids never collide across the full list. Adds a regression test
reproducing the out-of-position prefix-id collision.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-04 08:37:59 +00:00
Yufeng HeandGitHub f29bae8fbc Python: run sync tools off the event loop (#5773)
* fix: run sync tools off event loop

* chore: silence harness tool marker type check
2026-06-04 04:42:08 +00:00
Peter IbekweandGitHub c3901a4ddd Fix Observability/WorkflowAsAnAgent sampl (#6316) 2026-06-03 23:52:50 +00:00
Evan MattsonandGitHub ba617fc3b5 Don't count dependabot prs as part of the limit (#6317) 2026-06-04 08:31:36 +09:00
71 changed files with 1866 additions and 547 deletions
+16 -1
View File
@@ -8,6 +8,7 @@ function getPullRequest(context) {
return {
author: pullRequest.user.login,
authorType: pullRequest.user.type,
labels: pullRequest.labels?.map((label) => label.name).filter(Boolean) ?? [],
number: pullRequest.number,
};
@@ -49,6 +50,10 @@ function hasLabel(labels, labelName) {
return labels.some((label) => label.toLowerCase() === labelName.toLowerCase());
}
function isDependabotAuthor({ author, authorType }) {
return authorType === 'Bot' && author.toLowerCase() === 'dependabot[bot]';
}
function buildLimitMessage({ author, exemptLabelName, maxOpenPrs, openPrCount }) {
return [
`Thank you for your contribution, @${author}.`,
@@ -83,7 +88,17 @@ async function getOpenPrCount({ github, owner, repo, author, pullRequestNumber }
async function enforcePrLimit({ github, context, core, exemptLabelName, maxOpenPrs, labelName }) {
const { owner, repo } = context.repo;
const { author, labels, number } = getPullRequest(context);
const { author, authorType, labels, number } = getPullRequest(context);
if (isDependabotAuthor({ author, authorType })) {
core.info(`Author ${author} is Dependabot; skipping open PR limit enforcement.`);
return {
author,
closed: false,
dependabotExempt: true,
openPrCount: null,
};
}
if (hasLabel(labels, exemptLabelName)) {
core.info(`PR #${number} has the ${exemptLabelName} label; skipping open PR limit enforcement.`);
+26 -1
View File
@@ -16,7 +16,7 @@ const { enforcePrLimit } = require('../scripts/pr_limit_moderation.js');
// Helpers
// ---------------------------------------------------------------------------
function createContext({ author = 'community-user', labels = [], number = 123 } = {}) {
function createContext({ author = 'community-user', authorType = 'User', labels = [], number = 123 } = {}) {
return {
repo: {
owner: 'microsoft',
@@ -28,6 +28,7 @@ function createContext({ author = 'community-user', labels = [], number = 123 }
labels: labels.map((name) => ({ name })),
user: {
login: author,
type: authorType,
},
},
},
@@ -296,6 +297,30 @@ describe('PR limit enforcement', () => {
assert.deepEqual(github.calls, []);
});
it('does not close Dependabot PRs', async () => {
const github = createGithub({
itemNumbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
pullRequests: createPullRequestPage({
author: 'dependabot[bot]',
numbers: [123, ...Array.from({ length: 25 }, (_, index) => index + 1)],
}),
});
const result = await enforcePrLimit({
github,
context: createContext({ author: 'dependabot[bot]', authorType: 'Bot' }),
core: createCore(),
exemptLabelName: 'pr-limit-exempt',
maxOpenPrs: 10,
labelName: 'too-many-prs',
});
assert.equal(result.closed, false);
assert.equal(result.dependabotExempt, true);
assert.equal(result.openPrCount, null);
assert.deepEqual(github.calls, []);
});
it('counts the current PR when the author has more than one page of open PRs', async () => {
const github = createGithub({
itemNumbers: [123, ...Array.from({ length: 100 }, (_, index) => index + 1)],
+42 -1
View File
@@ -474,6 +474,45 @@ jobs:
path: ./python/pytest.xml
if-no-files-found: ignore
# GitHub Copilot integration tests
python-tests-github-copilot:
name: Python Integration Tests - GitHub Copilot
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
GITHUB_COPILOT_TIMEOUT: "120"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (GitHub Copilot integration)
run: >
uv run pytest --import-mode=importlib
packages/github_copilot/tests
-m integration
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-github-copilot
path: ./python/pytest.xml
if-no-files-found: ignore
# Integration test trend report (aggregates per-job JUnit XML results)
python-integration-test-report:
name: Integration Test Report
@@ -490,6 +529,7 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
runs-on: ubuntu-latest
defaults:
@@ -553,7 +593,8 @@ jobs:
python-tests-functions,
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos
python-tests-cosmos,
python-tests-github-copilot
]
steps:
- name: Fail workflow if tests failed
+57
View File
@@ -40,6 +40,7 @@ jobs:
foundryChanged: ${{ steps.filter.outputs.foundry }}
foundryHostingChanged: ${{ steps.filter.outputs.foundry_hosting }}
cosmosChanged: ${{ steps.filter.outputs.cosmos }}
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
@@ -85,6 +86,8 @@ jobs:
- 'python/packages/foundry_hosting/**'
cosmos:
- 'python/packages/azure-cosmos/**'
github_copilot:
- 'python/packages/github_copilot/**'
# run only if 'python' files were changed
- name: python tests
if: steps.filter.outputs.python == 'true'
@@ -658,6 +661,58 @@ jobs:
path: ./python/pytest.xml
if-no-files-found: ignore
# GitHub Copilot integration tests
python-tests-github-copilot:
name: Python Tests - GitHub Copilot Integration
needs: paths-filter
if: >
github.event_name != 'pull_request' &&
needs.paths-filter.outputs.pythonChanges == 'true' &&
(github.event_name != 'merge_group' ||
needs.paths-filter.outputs.githubCopilotChanged == 'true' ||
needs.paths-filter.outputs.coreChanged == 'true')
runs-on: ubuntu-latest
environment: integration
timeout-minutes: 60
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
GITHUB_COPILOT_TIMEOUT: "120"
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
- name: Set up python and install the project
id: python-setup
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Test with pytest (GitHub Copilot integration)
run: >
uv run pytest --import-mode=importlib
packages/github_copilot/tests
-m integration
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Surface failing tests
if: always()
uses: pmeier/pytest-results-action@20b595761ba9bf89e115e875f8bc863f913bc8ad # v0.7.2
with:
path: ./python/pytest.xml
summary: true
display-options: fEX
fail-on-empty: false
title: GitHub Copilot integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: test-results-github-copilot
path: ./python/pytest.xml
if-no-files-found: ignore
# Integration test trend report (aggregates per-job JUnit XML results)
python-integration-test-report:
name: Integration Test Report
@@ -674,6 +729,7 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
runs-on: ubuntu-latest
defaults:
@@ -735,6 +791,7 @@ jobs:
python-tests-foundry,
python-tests-foundry-hosting,
python-tests-cosmos,
python-tests-github-copilot,
]
steps:
- name: Fail workflow if tests failed
+1 -1
View File
@@ -109,7 +109,7 @@
<PackageVersion Include="A2A" Version="1.0.0-preview2" />
<PackageVersion Include="A2A.AspNetCore" Version="1.0.0-preview2" />
<!-- MCP -->
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />
<PackageVersion Include="ModelContextProtocol" Version="1.2.0" />
<!-- Hyperlight -->
<PackageVersion Include="Hyperlight.HyperlightSandbox.Api" Version="0.4.0" />
<PackageVersion Include="Hyperlight.HyperlightSandbox.Guest.Python" Version="0.4.0" />
@@ -50,12 +50,16 @@ internal static partial class WorkflowHelper
/// <summary>
/// Executor that starts the concurrent processing by sending messages to the agents.
/// </summary>
private sealed partial class ConcurrentStartExecutor() : Executor("ConcurrentStartExecutor")
[SendsMessage(typeof(List<ChatMessage>))]
[SendsMessage(typeof(TurnToken))]
private sealed partial class ConcurrentStartExecutor()
: Executor("ConcurrentStartExecutor", declareCrossRunShareable: true), IResettableExecutor
{
[MessageHandler]
internal ValueTask RouteMessages(List<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
internal ValueTask RouteMessages(IEnumerable<ChatMessage> messages, IWorkflowContext context, CancellationToken cancellationToken)
{
return context.SendMessageAsync(messages, cancellationToken: cancellationToken);
List<ChatMessage> payload = messages as List<ChatMessage> ?? messages.ToList();
return context.SendMessageAsync(payload, cancellationToken: cancellationToken);
}
[MessageHandler]
@@ -63,13 +67,16 @@ internal static partial class WorkflowHelper
{
return context.SendMessageAsync(token, cancellationToken: cancellationToken);
}
public ValueTask ResetAsync() => default;
}
/// <summary>
/// Executor that aggregates the results from the concurrent agents.
/// </summary>
[YieldsOutput(typeof(List<ChatMessage>))]
private sealed partial class ConcurrentAggregationExecutor() : Executor<List<ChatMessage>>("ConcurrentAggregationExecutor")
[YieldsOutput(typeof(string))]
private sealed partial class ConcurrentAggregationExecutor() :
Executor<List<ChatMessage>>("ConcurrentAggregationExecutor"), IResettableExecutor
{
private readonly List<ChatMessage> _messages = [];
@@ -90,5 +97,11 @@ internal static partial class WorkflowHelper
await context.YieldOutputAsync(formattedMessages, cancellationToken);
}
}
public ValueTask ResetAsync()
{
this._messages.Clear();
return default;
}
}
}
@@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="Azure.AI.Projects" />
<PackageReference Include="Azure.Identity" />
<PackageReference Include="ModelContextProtocol" VersionOverride="1.2.0" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="DotNetEnv" />
</ItemGroup>
@@ -49,14 +49,13 @@ public sealed class AgentFileSkill : AgentSkill
/// <inheritdoc/>
/// <remarks>
/// Returns the raw SKILL.md content. When the skill has scripts, a
/// <c>&lt;scripts&gt;&lt;script name="..."&gt;&lt;parameters_schema&gt;...&lt;/parameters_schema&gt;&lt;/script&gt;&lt;/scripts&gt;</c>
/// block is appended with a per-script entry describing the expected argument format.
/// <c>&lt;script_schemas&gt;</c> block is appended describing the argument format.
/// The result is cached after the first access.
/// </remarks>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
var content = this._content ??= this._scripts is { Count: > 0 }
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptsBlock(this._scripts)
? this._originalContent + AgentInlineSkillContentBuilder.BuildScriptSchemasBlock(this._scripts)
: this._originalContent;
return new(content);
}
@@ -114,7 +114,6 @@ public abstract class AgentClassSkill<
this.Frontmatter.Name,
this.Frontmatter.Description,
this.Instructions,
this.Resources,
this.Scripts));
}
@@ -147,11 +146,17 @@ public abstract class AgentClassSkill<
/// Gets the resources associated with this skill, or <see langword="null"/> if none.
/// </summary>
/// <remarks>
/// <para>
/// The default implementation returns resources discovered via reflection by scanning
/// <typeparamref name="TSelf"/> for members annotated with <see cref="AgentSkillResourceAttribute"/>.
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
/// Override this property in derived classes to provide skill-specific resources.
/// </para>
/// <para>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference resources by name in the skill's instructions or in other resources.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillResource>? Resources => this._resources.Value;
@@ -159,11 +164,17 @@ public abstract class AgentClassSkill<
/// Gets the scripts associated with this skill, or <see langword="null"/> if none.
/// </summary>
/// <remarks>
/// <para>
/// The default implementation returns scripts discovered via reflection by scanning
/// <typeparamref name="TSelf"/> for methods annotated with <see cref="AgentSkillScriptAttribute"/>.
/// This discovery is compatible with Native AOT because <typeparamref name="TSelf"/> is annotated with
/// <see cref="DynamicallyAccessedMembersAttribute"/>. The result is cached after the first access.
/// Override this property in derived classes to provide skill-specific scripts.
/// </para>
/// <para>
/// Only script parameter schemas are included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference scripts by name in the skill's instructions or in a resource.
/// </para>
/// </remarks>
public virtual IReadOnlyList<AgentSkillScript>? Scripts => this._scripts.Value;
@@ -184,6 +195,10 @@ public abstract class AgentClassSkill<
/// <summary>
/// Creates a skill resource backed by a static value.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
@@ -194,6 +209,10 @@ public abstract class AgentClassSkill<
/// <summary>
/// Creates a skill resource backed by a delegate that produces a dynamic value.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="description">An optional description of the resource.</param>
@@ -208,6 +227,10 @@ public abstract class AgentClassSkill<
/// <summary>
/// Creates a skill script backed by a delegate.
/// </summary>
/// <remarks>
/// Only the script's parameter schema is included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
/// </remarks>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
/// <param name="description">An optional description of the script.</param>
@@ -95,7 +95,7 @@ public sealed class AgentInlineSkill : AgentSkill
/// <inheritdoc/>
public override ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._resources, this._scripts));
return new(this._cachedContent ??= AgentInlineSkillContentBuilder.Build(this.Frontmatter.Name, this.Frontmatter.Description, this._instructions, this._scripts));
}
/// <inheritdoc/>
@@ -115,6 +115,10 @@ public sealed class AgentInlineSkill : AgentSkill
/// <summary>
/// Registers a static resource with this skill.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="value">The static resource value.</param>
/// <param name="description">An optional description of the resource.</param>
@@ -129,6 +133,10 @@ public sealed class AgentInlineSkill : AgentSkill
/// Registers a dynamic resource with this skill, backed by a C# delegate.
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// Resources are not automatically included in the skill body.
/// To enable discovery, reference the resource by name in the skill's instructions or in another resource.
/// </remarks>
/// <param name="name">The resource name.</param>
/// <param name="method">A method that produces the resource value when requested.</param>
/// <param name="description">An optional description of the resource.</param>
@@ -147,6 +155,10 @@ public sealed class AgentInlineSkill : AgentSkill
/// Registers a script with this skill, backed by a C# delegate.
/// The delegate's parameters and return type are automatically marshaled via <c>AIFunctionFactory</c>.
/// </summary>
/// <remarks>
/// Only the script's parameter schema is included in the skill body (as a <c>&lt;script_schemas&gt;</c> block).
/// To enable discovery, reference the script by name in the skill's instructions or in a resource.
/// </remarks>
/// <param name="name">The script name.</param>
/// <param name="method">A method to execute when the script is invoked.</param>
/// <param name="description">An optional description of the script.</param>
@@ -12,19 +12,17 @@ namespace Microsoft.Agents.AI;
internal static class AgentInlineSkillContentBuilder
{
/// <summary>
/// Builds the complete skill content containing name, description, instructions, resources, and scripts.
/// Builds the complete skill content containing name, description, instructions, and script parameter schemas.
/// </summary>
/// <param name="name">The skill name.</param>
/// <param name="description">The skill description.</param>
/// <param name="instructions">The raw instructions text.</param>
/// <param name="resources">Optional resources associated with the skill.</param>
/// <param name="scripts">Optional scripts associated with the skill.</param>
/// <returns>An XML-structured content string.</returns>
public static string Build(
string name,
string description,
string instructions,
IReadOnlyList<AgentSkillResource>? resources,
IReadOnlyList<AgentSkillScript>? scripts)
{
_ = Throw.IfNullOrWhitespace(name);
@@ -39,41 +37,24 @@ internal static class AgentInlineSkillContentBuilder
.Append(EscapeXmlString(instructions))
.Append("\n</instructions>");
if (resources is { Count: > 0 })
{
sb.Append("\n\n<resources>\n");
foreach (var resource in resources)
{
if (resource.Description is not null)
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\" description=\"{EscapeXmlString(resource.Description)}\"/>\n");
}
else
{
sb.Append($" <resource name=\"{EscapeXmlString(resource.Name)}\"/>\n");
}
}
sb.Append("</resources>");
}
if (scripts is { Count: > 0 })
{
sb.Append('\n');
sb.Append(BuildScriptsBlock(scripts));
sb.Append(BuildScriptSchemasBlock(scripts));
}
return sb.ToString();
}
/// <summary>
/// Builds a <c>&lt;scripts&gt;...&lt;/scripts&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;script name="..."&gt;</c> element with optional
/// <c>description</c> attribute and <c>&lt;parameters_schema&gt;</c> child element.
/// Builds a <c>&lt;script_schemas&gt;...&lt;/script_schemas&gt;</c> XML block for the given scripts.
/// Each script is emitted as a <c>&lt;schema script="..."&gt;</c> element containing only
/// the parameter schema. This block serves as a reference for the model to know how to
/// format arguments when calling scripts, not as a discovery mechanism.
/// </summary>
/// <param name="scripts">The scripts to include in the block.</param>
/// <returns>An XML string starting with <c>\n&lt;scripts&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptsBlock(IReadOnlyList<AgentSkillScript> scripts)
/// <returns>An XML string starting with <c>\n&lt;script_schemas&gt;</c>, or an empty string if the list is empty.</returns>
public static string BuildScriptSchemasBlock(IReadOnlyList<AgentSkillScript> scripts)
{
_ = Throw.IfNull(scripts);
@@ -83,32 +64,23 @@ internal static class AgentInlineSkillContentBuilder
}
var sb = new StringBuilder();
sb.Append("\n<scripts>\n");
sb.Append("\n<script_schemas>\n");
foreach (var script in scripts)
{
var parametersSchema = script.ParametersSchema;
if (script.Description is null && parametersSchema is null)
if (parametersSchema is null)
{
sb.Append($" <script name=\"{EscapeXmlString(script.Name)}\"/>\n");
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\"/>\n");
}
else
{
sb.Append(script.Description is not null
? $" <script name=\"{EscapeXmlString(script.Name)}\" description=\"{EscapeXmlString(script.Description)}\">\n"
: $" <script name=\"{EscapeXmlString(script.Name)}\">\n");
if (parametersSchema is not null)
{
sb.Append($" <parameters_schema>{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</parameters_schema>\n");
}
sb.Append(" </script>\n");
sb.Append($" <schema script=\"{EscapeXmlString(script.Name)}\">{EscapeXmlString(parametersSchema.Value.GetRawText(), preserveQuotes: true)}</schema>\n");
}
}
sb.Append("</scripts>");
sb.Append("</script_schemas>");
return sb.ToString();
}
@@ -51,9 +51,8 @@ public sealed class AgentClassSkillTests
// Act & Assert — Content is cached
Assert.Same(await skill.GetContentAsync(), await skill.GetContentAsync());
// Act & Assert — Content includes parameter schema from typed script
Assert.Contains("parameters_schema", await skill.GetContentAsync());
Assert.Contains("value", await skill.GetContentAsync());
// Act & Assert — Content includes parameter schema from typed script (with preserved quotes)
Assert.Contains("\"value\"", await skill.GetContentAsync());
}
[Fact]
@@ -383,10 +382,9 @@ public sealed class AgentClassSkillTests
// Arrange
var skill = new AttributedFullSkill();
// Act & Assert — Content includes reflected resources and scripts
Assert.Contains("<resources>", await skill.GetContentAsync());
Assert.Contains("conversion-table", await skill.GetContentAsync());
Assert.Contains("<scripts>", await skill.GetContentAsync());
// Act & Assert — Content no longer includes resources in body; scripts are in script_schemas
Assert.DoesNotContain("<resources>", await skill.GetContentAsync());
Assert.Contains("<script_schemas>", await skill.GetContentAsync());
Assert.Contains("convert", await skill.GetContentAsync());
// Act & Assert — discovered members are cached
@@ -504,7 +502,7 @@ public sealed class AgentClassSkillTests
}
[Fact]
public async Task Content_IncludesDescription_ForReflectedResourcesAsync()
public async Task Content_DoesNotRenderResources_InBodyAsync()
{
// Arrange
var skill = new AttributedResourcePropertiesSkill();
@@ -512,8 +510,8 @@ public sealed class AgentClassSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — descriptions from [Description] attribute appear in synthesized content
Assert.Contains("Some important data.", content);
// Assert — resources are no longer rendered in body content
Assert.DoesNotContain("<resources>", content);
}
[Fact]
@@ -122,11 +122,10 @@ public sealed class AgentFileSkillScriptTests
// Assert — content starts with original and appends per-script entries
Assert.StartsWith("Original content", content);
Assert.Contains("<scripts>", content);
Assert.Contains("<script name=\"build\">", content);
Assert.Contains("<script name=\"deploy\">", content);
Assert.Contains("<parameters_schema>", content);
Assert.Contains("</scripts>", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("<schema script=\"build\">", content);
Assert.Contains("<schema script=\"deploy\">", content);
Assert.Contains("</script_schemas>", content);
}
[Fact]
@@ -149,7 +149,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_IncludesResourcesAddedBeforeFirstAccessAsync()
public async Task Content_DoesNotIncludeResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -158,13 +158,12 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("config", content);
// Assert — resources are no longer rendered in the body; they're accessed via GetResourceAsync
Assert.DoesNotContain("<resources>", content);
}
[Fact]
public async Task Content_IncludesDelegateResourcesAddedBeforeFirstAccessAsync()
public async Task Content_DoesNotIncludeDelegateResourcesInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -173,9 +172,8 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("dynamic", content);
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
}
[Fact]
@@ -189,7 +187,7 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<scripts>", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("run", content);
}
@@ -209,7 +207,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_IncludesResourcesAndScriptsAddedBeforeFirstAccessAsync()
public async Task Content_IncludesScriptSchemasAddedBeforeFirstAccessAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -220,9 +218,8 @@ public sealed class AgentInlineSkillTests
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("<resources>", content);
Assert.Contains("r1", content);
Assert.Contains("<scripts>", content);
Assert.DoesNotContain("<resources>", content);
Assert.Contains("<script_schemas>", content);
Assert.Contains("s1", content);
}
@@ -236,8 +233,9 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert — JSON schema should be present and XML content chars escaped
Assert.Contains("parameters_schema", content);
// Assert — JSON schema should be present inside <schema> element (no extra wrapper) with preserved quotes
Assert.Contains("<schema script=\"search\">", content);
Assert.Contains("\"query\"", content);
Assert.DoesNotContain("<![CDATA[", content);
}
@@ -429,7 +427,7 @@ public sealed class AgentInlineSkillTests
// Assert
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("<scripts>", content);
Assert.DoesNotContain("<script_schemas>", content);
}
[Fact]
@@ -463,7 +461,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_ScriptWithDescription_IncludesDescriptionAttributeAsync()
public async Task Content_ScriptWithDescription_DoesNotEmitDescriptionAttributeAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -472,8 +470,10 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("description=\"Runs something.\"", content);
// Assert — description is no longer emitted in the script_schemas block;
// the block only contains parameter schemas for calling scripts.
Assert.Contains("<schema script=\"my-script\"", content);
Assert.DoesNotContain("description=\"Runs something.\"", content);
}
[Fact]
@@ -492,7 +492,7 @@ public sealed class AgentInlineSkillTests
}
[Fact]
public async Task Content_ResourceWithDescription_IncludesDescriptionAttributeAsync()
public async Task Content_ResourceWithDescription_NotRenderedInBodyAsync()
{
// Arrange
var skill = new AgentInlineSkill("my-skill", "A valid skill.", "Instructions.");
@@ -502,9 +502,10 @@ public sealed class AgentInlineSkillTests
// Act
var content = await skill.GetContentAsync();
// Assert
Assert.Contains("description=\"A described resource.\"", content);
Assert.DoesNotContain("no-desc\" description", content);
// Assert — resources are no longer rendered in the body
Assert.DoesNotContain("<resources>", content);
Assert.DoesNotContain("with-desc", content);
Assert.DoesNotContain("no-desc", content);
}
[Fact]
+39 -1
View File
@@ -7,6 +7,43 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.8.0] - 2026-06-04
### Added
- **agent-framework-core**: Add MCP-based skills discovery (`McpSkillsSource`) ([#6169](https://github.com/microsoft/agent-framework/pull/6169))
- **agent-framework-core**: Progressive tool exposure via `FunctionInvocationContext` ([#6233](https://github.com/microsoft/agent-framework/pull/6233))
- **agent-framework-core**: Add background agent support to harness agent ([#6155](https://github.com/microsoft/agent-framework/pull/6155))
- **agent-framework-core**: Add `AgentFileStore` and `FileAccessProvider` for file access operations ([#6099](https://github.com/microsoft/agent-framework/pull/6099))
- **agent-framework-core**: Coalesce code interpreter history chunks ([#5801](https://github.com/microsoft/agent-framework/pull/5801))
- **agent-framework-core**: Run sync tools off the event loop ([#5773](https://github.com/microsoft/agent-framework/pull/5773))
- **agent-framework-bedrock**: Implement native structured output support via Converse API ([#6052](https://github.com/microsoft/agent-framework/pull/6052))
- **agent-framework-foundry**: Add Foundry Adaptive Evals integration for rubric-generation ([#6101](https://github.com/microsoft/agent-framework/pull/6101))
- **agent-framework-foundry**: Add `timeout` parameter to `FoundryAgent` to fix `ConnectTimeout` on multi-turn conversations ([#6263](https://github.com/microsoft/agent-framework/pull/6263))
- **agent-framework-mistral**: Add Mistral AI embedding client package ([#5480](https://github.com/microsoft/agent-framework/pull/5480))
- **agent-framework-a2a**: Expose `supported_protocol_bindings` as configurable parameter ([#6098](https://github.com/microsoft/agent-framework/pull/6098))
- **agent-framework-a2a**: Set `message_id` on `AgentResponseUpdate` for message-bearing paths ([#6163](https://github.com/microsoft/agent-framework/pull/6163))
- **agent-framework-foundry-hosting**: Persist hosted MCP call/results as canonical `mcp_call` output ([#6070](https://github.com/microsoft/agent-framework/pull/6070))
### Changed
- **agent-framework-github-copilot**: [BREAKING] Upgrade `github-copilot-sdk` to v1.0.0 (stable) ([#6292](https://github.com/microsoft/agent-framework/pull/6292))
- **agent-framework-core**: [BREAKING — experimental] Refactor Skill API to async resource and script lookup ([#6135](https://github.com/microsoft/agent-framework/pull/6135))
- **agent-framework-github-copilot**: Promote to release candidate (`1.0.0rc1`)
- **agent-framework-declarative**: Promote to release candidate (`1.0.0rc1`) ([#6256](https://github.com/microsoft/agent-framework/pull/6256))
### Fixed
- **agent-framework-core**: Fix compaction message-id collisions and tool-loop summary persistence ([#6299](https://github.com/microsoft/agent-framework/pull/6299))
- **agent-framework-core**: Fix observability unsafe serialization of function-call arguments containing dataclass/framework objects ([#6026](https://github.com/microsoft/agent-framework/pull/6026))
- **agent-framework-core**: Consolidate MCP reliability fixes ([#6145](https://github.com/microsoft/agent-framework/pull/6145))
- **agent-framework-core**: Backfill chat span request model if unknown and response model is available ([#6160](https://github.com/microsoft/agent-framework/pull/6160))
- **agent-framework-anthropic**: Skip orphan anthropic thinking signatures ([#5784](https://github.com/microsoft/agent-framework/pull/5784))
- **agent-framework-foundry**: Fix `FoundryAgent` stripping model from `PromptAgent` requests ([#5526](https://github.com/microsoft/agent-framework/pull/5526))
- **agent-framework-foundry-hosting**: Fix toolbox consent flow in hosted agent ([#6249](https://github.com/microsoft/agent-framework/pull/6249))
- **agent-framework-foundry-hosting**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
- **agent-framework-openai**: Fix OTLP HTTP base-endpoint losing `/v1/{signal}` auto-append ([#5913](https://github.com/microsoft/agent-framework/pull/5913))
- **agent-framework-openai**: Drop hosted MCP calls when reasoning is stripped ([#6210](https://github.com/microsoft/agent-framework/pull/6210))
- **agent-framework-orchestrations**: Fix spurious Magentic custom manager warning ([#6261](https://github.com/microsoft/agent-framework/pull/6261))
- **agent-framework-azurefunctions**: Fix integration test worker crashes on Py3.13 ([#4260](https://github.com/microsoft/agent-framework/pull/4260))
## [1.7.0] - 2026-05-28
### Added
@@ -1132,7 +1169,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.7.0...HEAD
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.8.0...HEAD
[1.8.0]: https://github.com/microsoft/agent-framework/compare/python-1.7.0...python-1.8.0
[1.7.0]: https://github.com/microsoft/agent-framework/compare/python-1.6.0...python-1.7.0
[1.6.0]: https://github.com/microsoft/agent-framework/compare/python-1.5.0...python-1.6.0
[1.5.0]: https://github.com/microsoft/agent-framework/compare/python-1.4.0...python-1.5.0
+1 -1
View File
@@ -33,7 +33,7 @@ Status is grouped into these buckets:
| `agent-framework-foundry` | `python/packages/foundry` | `released` |
| `agent-framework-foundry-local` | `python/packages/foundry_local` | `beta` |
| `agent-framework-gemini` | `python/packages/gemini` | `alpha` |
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `beta` |
| `agent-framework-github-copilot` | `python/packages/github_copilot` | `rc` |
| `agent-framework-hyperlight` | `python/packages/hyperlight` | `beta` |
| `agent-framework-lab` | `python/packages/lab` | `beta` |
| `agent-framework-mem0` | `python/packages/mem0` | `beta` |
+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.0b260528"
version = "1.0.0b260604"
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.7.0,<2",
"agent-framework-core>=1.8.0,<2",
"a2a-sdk>=1.0.0,<2",
]
+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.0b260521"
version = "1.0.0b260604"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"agent-framework-core>=1.8.0,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -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.0b260521"
version = "1.0.0b260604"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,8 +22,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"agent-framework-durabletask>=1.0.0b260521,<2",
"agent-framework-core>=1.8.0,<2",
"agent-framework-durabletask>=1.0.0b260604,<2",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
]
@@ -795,10 +795,7 @@ class BedrockChatClient(
schema = copy.deepcopy(schema_src)
else:
if not isinstance(response_format, type) or not issubclass(response_format, BaseModel):
raise TypeError(
"response_format must be None, a dict JSON schema, "
"or a Pydantic BaseModel subclass."
)
raise TypeError("response_format must be None, a dict JSON schema, or a Pydantic BaseModel subclass.")
# response_format is a Pydantic model class
schema = response_format.model_json_schema()
name = response_format.__name__
@@ -817,9 +814,7 @@ class BedrockChatClient(
return {
"textFormat": {
"type": "json_schema",
"structure": {
"jsonSchema": json_schema
},
"structure": {"jsonSchema": json_schema},
}
}
@@ -840,9 +835,7 @@ class BedrockChatClient(
if node_id in visited:
return
visited.add(node_id)
if node.get("type") == "object" or (
"properties" in node and "type" not in node
):
if node.get("type") == "object" or ("properties" in node and "type" not in node):
existing = node.get("additionalProperties")
if existing is None or existing is True:
node["additionalProperties"] = False
+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.0b260521"
version = "1.0.0b260604"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"agent-framework-core>=1.8.0,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
@@ -238,6 +238,7 @@ async def test_chat_response_value_populated_streaming() -> None:
async def test_unsupported_model_validation_exception() -> None:
"""When a model doesn't support outputConfig, a clear error should be raised."""
class _FailingStubBedrockRuntime:
def converse(self, **kwargs: Any) -> dict[str, Any]:
# Simulate botocore ClientError for ValidationException
@@ -380,8 +380,15 @@ class BaseChatClient(SerializationMixin, ABC, Generic[OptionsCoT]):
return prepared_messages
from ._compaction import apply_compaction
# Compact the caller's list in place when possible. A compaction operation has
# two halves: exclusion flags (mutated on shared Message objects) and inserted
# summary messages. Operating on the original list keeps both halves on the list
# the function-invocation tool loop reuses across iterations; otherwise inserted
# summaries would be lost on a throwaway copy while exclusions persisted, silently
# dropping older groups (issue #4991).
working_messages = messages if isinstance(messages, list) else prepared_messages
return await apply_compaction(
prepared_messages,
working_messages,
strategy=compaction_strategy,
tokenizer=tokenizer,
)
@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import logging
from collections.abc import Mapping, Sequence
from collections.abc import Iterable, Mapping, Sequence
from typing import (
TYPE_CHECKING,
Any,
@@ -92,10 +92,23 @@ def _is_reasoning_only_assistant(message: Message) -> bool:
return all(content.type == "text_reasoning" for content in message.contents)
def _ensure_message_ids(messages: list[Message]) -> None:
def _ensure_message_ids(
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
) -> None:
existing_ids: set[str] = set(reserved_ids) if reserved_ids is not None else set()
existing_ids.update(message.message_id for message in messages if message.message_id)
for index, message in enumerate(messages):
if not message.message_id:
message.message_id = f"msg_{index}"
if message.message_id:
continue
candidate = f"msg_{id_offset + index}"
if candidate in existing_ids:
counter = id_offset + len(messages)
candidate = f"msg_{counter}"
while candidate in existing_ids:
counter += 1
candidate = f"msg_{counter}"
message.message_id = candidate
existing_ids.add(candidate)
def _group_id_for(message: Message, group_index: int) -> str:
@@ -104,14 +117,27 @@ def _group_id_for(message: Message, group_index: int) -> str:
return f"group_index_{group_index}"
def group_messages(messages: list[Message]) -> list[dict[str, Any]]:
def group_messages(
messages: list[Message], *, id_offset: int = 0, reserved_ids: Iterable[str] | None = None
) -> list[dict[str, Any]]:
"""Compute group spans and metadata for annotation.
Args:
messages: The messages (or a slice of them) to group.
Keyword Args:
id_offset: Absolute starting index used when auto-assigning ``message_id``
values, so incremental annotation of a list slice produces ids that
stay unique across the full list.
reserved_ids: Message ids that already exist outside ``messages`` (for
example in a preserved prefix). Auto-assigned ids are guaranteed not
to collide with these, preventing duplicate ids across the full list.
Returns:
Ordered list of lightweight span dicts with keys:
``group_id``, ``kind``, ``start_index``, ``end_index``, ``has_reasoning``.
"""
_ensure_message_ids(messages)
_ensure_message_ids(messages, id_offset=id_offset, reserved_ids=reserved_ids)
spans: list[dict[str, Any]] = []
i = 0
group_index = 0
@@ -439,7 +465,8 @@ def annotate_message_groups(
if previous_group_index is not None:
group_index_offset = previous_group_index + 1
spans = group_messages(messages[start_index:])
reserved_ids = {message.message_id for message in messages[:start_index] if message.message_id}
spans = group_messages(messages[start_index:], id_offset=start_index, reserved_ids=reserved_ids)
for span_index, span in enumerate(spans):
group_id = str(span["group_id"])
kind = _coerce_group_kind(span["kind"])
@@ -349,6 +349,8 @@ class BackgroundAgentsProvider(ContextProvider):
_save_provider_state(session, provider_state, source_id=source_id)
return f"Background task {task_id} started on agent '{agent_name}'."
background_agents_start_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
@tool(name="background_agents_wait_for_first_completion", approval_mode="never_require")
async def background_agents_wait_for_first_completion(task_ids: list[int]) -> str:
"""Block until the first of the specified background tasks completes. Returns the completed task's ID."""
@@ -471,6 +473,8 @@ class BackgroundAgentsProvider(ContextProvider):
_save_provider_state(session, provider_state, source_id=source_id)
return f"Task {task_id} continued with new input."
background_agents_continue_task._invoke_sync_on_event_loop = True # pyright: ignore[reportPrivateUsage]
@tool(name="background_agents_clear_completed_task", approval_mode="never_require")
def background_agents_clear_completed_task(task_id: int) -> str:
"""Remove a completed or failed task and release its session to free memory."""
+14 -4
View File
@@ -292,6 +292,7 @@ class FunctionTool(SerializationMixin):
"_cached_parameters",
"_input_schema",
"_schema_supplied",
"_invoke_sync_on_event_loop",
}
def __init__(
@@ -366,6 +367,7 @@ class FunctionTool(SerializationMixin):
self.description = description
self.kind = kind
self.additional_properties = additional_properties
self._invoke_sync_on_event_loop = False
for key, value in kwargs.items():
setattr(self, key, value)
@@ -537,6 +539,16 @@ class FunctionTool(SerializationMixin):
self.invocation_exception_count += 1
raise
async def _invoke_function(self, call_kwargs: Mapping[str, Any]) -> Any:
"""Run sync tools off the event loop during async invocation."""
func = self.func.func if isinstance(self.func, FunctionTool) else self.func
if inspect.iscoroutinefunction(func) or getattr(self, "_invoke_sync_on_event_loop", False):
res = self.__call__(**call_kwargs)
return await res if inspect.isawaitable(res) else res
res = await asyncio.to_thread(self.__call__, **call_kwargs)
return await res if inspect.isawaitable(res) else res
@overload
async def invoke(
self,
@@ -679,8 +691,7 @@ class FunctionTool(SerializationMixin):
if not OBSERVABILITY_SETTINGS.ENABLED: # type: ignore[name-defined]
logger.info(f"Function name: {self.name}")
logger.debug(f"Function arguments: {observable_kwargs}")
res = self.__call__(**call_kwargs)
result = await res if inspect.isawaitable(res) else res
result = await self._invoke_function(call_kwargs)
if skip_parsing:
logger.info(f"Function {self.name} succeeded.")
logger.debug(f"Function result: {type(result).__name__}")
@@ -730,8 +741,7 @@ class FunctionTool(SerializationMixin):
start_time_stamp = perf_counter()
end_time_stamp: float | None = None
try:
res = self.__call__(**call_kwargs)
result = await res if inspect.isawaitable(res) else res
result = await self._invoke_function(call_kwargs)
end_time_stamp = perf_counter()
except Exception as exception:
end_time_stamp = perf_counter()
+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.7.0"
version = "1.8.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -11,10 +11,14 @@ from agent_framework import (
GROUP_TOKEN_COUNT_KEY,
BaseChatClient,
ChatResponse,
ChatResponseUpdate,
Content,
Message,
SlidingWindowStrategy,
SupportsChatGetResponse,
ToolResultCompactionStrategy,
TruncationStrategy,
tool,
)
@@ -258,6 +262,196 @@ async def test_base_client_default_tokenizer_without_strategy_annotates_messages
assert captured_token_counts == [[19, 19]]
def _tool_call_response(call_id: str, location: str) -> ChatResponse:
return ChatResponse(
messages=Message(
role="assistant",
contents=[
Content.from_function_call(
call_id=call_id,
name="lookup_weather",
arguments=f'{{"location": "{location}"}}',
)
],
),
response_id=f"resp_{call_id}",
)
def _is_tool_result_summary(message: Message) -> bool:
text = message.text or ""
return message.role == "assistant" and text.startswith("[Tool results:")
async def test_function_loop_persists_inserted_summaries_across_iterations(
chat_client_base: SupportsChatGetResponse,
) -> None:
# Regression test for #4991: compaction inserts summary messages and excludes the
# originals. Across tool-loop iterations the exclusion flags persisted (shared Message
# objects) but the inserted summaries were dropped (they only lived on a throwaway copy),
# so older tool groups were silently lost with no summary representing them.
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
@tool(name="lookup_weather", approval_mode="never_require")
def lookup_weather(location: str) -> str:
return f"Weather in {location}: sunny"
chat_client_base.run_responses = [ # type: ignore[attr-defined]
_tool_call_response("call_1", "London"),
_tool_call_response("call_2", "Paris"),
_tool_call_response("call_3", "Tokyo"),
]
captured_inputs: list[list[Message]] = []
original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
async def _capture(
*,
messages: list[Message],
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
captured_inputs.append(list(messages))
return await original(messages=messages, options=options, **kwargs)
chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
await chat_client_base.get_response(
[Message(role="user", contents=["What is the weather in London?"])],
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
)
# The final model call should represent every compacted tool group with a summary.
# Two older tool groups get collapsed (London, Paris) while the last (Tokyo) is kept.
final_input = captured_inputs[-1]
summaries = [message for message in final_input if _is_tool_result_summary(message)]
summary_text = " ".join(message.text or "" for message in summaries)
assert len(summaries) == 2, [message.text for message in final_input]
assert "London" in summary_text
assert "Paris" in summary_text
def _tool_call_update(call_id: str, location: str) -> list[ChatResponseUpdate]:
return [
ChatResponseUpdate(
contents=[
Content.from_function_call(
call_id=call_id,
name="lookup_weather",
arguments=f'{{"location": "{location}"}}',
)
],
role="assistant",
finish_reason="stop",
response_id=f"resp_{call_id}",
)
]
async def test_function_loop_persists_inserted_summaries_across_iterations_streaming(
chat_client_base: SupportsChatGetResponse,
) -> None:
# Streaming counterpart of the #4991 regression test: the summary persistence fix in
# ``_prepare_messages_for_model_call`` must cover the streaming tool loop too.
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
@tool(name="lookup_weather", approval_mode="never_require")
def lookup_weather(location: str) -> str:
return f"Weather in {location}: sunny"
chat_client_base.streaming_responses = [ # type: ignore[attr-defined]
_tool_call_update("call_1", "London"),
_tool_call_update("call_2", "Paris"),
_tool_call_update("call_3", "Tokyo"),
]
captured_inputs: list[list[Message]] = []
original = chat_client_base._get_streaming_response # type: ignore[attr-defined]
def _capture(
*,
messages: list[Message],
options: dict[str, Any],
**kwargs: Any,
):
captured_inputs.append(list(messages))
return original(messages=messages, options=options, **kwargs)
chat_client_base._get_streaming_response = _capture # type: ignore[attr-defined,method-assign]
stream = chat_client_base.get_response(
[Message(role="user", contents=["What is the weather in London?"])],
stream=True,
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
)
async for _ in stream:
pass
final_input = captured_inputs[-1]
summaries = [message for message in final_input if _is_tool_result_summary(message)]
summary_text = " ".join(message.text or "" for message in summaries)
assert len(summaries) == 2, [message.text for message in final_input]
assert "London" in summary_text
assert "Paris" in summary_text
async def test_function_loop_compaction_conversation_id_mode_does_not_resend_history(
chat_client_base: SupportsChatGetResponse,
) -> None:
# In conversation-id mode the server owns prior context, so the tool loop clears
# ``prepped_messages`` and only sends the latest message. Compaction must not fight that
# by re-inserting summaries or re-sending earlier turns.
chat_client_base.function_invocation_configuration["enabled"] = True # type: ignore[attr-defined]
chat_client_base.function_invocation_configuration["max_iterations"] = 3 # type: ignore[attr-defined]
chat_client_base.compaction_strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1) # type: ignore[attr-defined]
@tool(name="lookup_weather", approval_mode="never_require")
def lookup_weather(location: str) -> str:
return f"Weather in {location}: sunny"
def _conversation_tool_call(call_id: str, location: str) -> ChatResponse:
response = _tool_call_response(call_id, location)
response.conversation_id = "conv_1"
return response
chat_client_base.run_responses = [ # type: ignore[attr-defined]
_conversation_tool_call("call_1", "London"),
_conversation_tool_call("call_2", "Paris"),
_conversation_tool_call("call_3", "Tokyo"),
]
captured_inputs: list[list[Message]] = []
original = chat_client_base._get_non_streaming_response # type: ignore[attr-defined]
async def _capture(
*,
messages: list[Message],
options: dict[str, Any],
**kwargs: Any,
) -> ChatResponse:
captured_inputs.append(list(messages))
return await original(messages=messages, options=options, **kwargs)
chat_client_base._get_non_streaming_response = _capture # type: ignore[attr-defined,method-assign]
await chat_client_base.get_response(
[Message(role="user", contents=["What is the weather in London?"])],
options={"tools": [lookup_weather]}, # type: ignore[typeddict-unknown-key]
)
# After the conversation id is established the loop only forwards the latest message,
# so subsequent model calls never receive the full history or summary messages.
for sent in captured_inputs[1:]:
assert len(sent) <= 1, [message.text for message in sent]
assert not any(_is_tool_result_summary(message) for message in sent)
def test_base_client_as_agent_does_not_copy_client_compaction_defaults(
chat_client_base: SupportsChatGetResponse,
) -> None:
@@ -196,6 +196,64 @@ def test_append_compaction_message_annotates_new_message() -> None:
assert isinstance(_group_id(messages[1]), str)
def test_incremental_annotation_assigns_unique_message_ids() -> None:
# Regression test for #5237: ``_ensure_message_ids`` assigned ``msg_{index}``
# using the position within the slice handed to ``group_messages``. Successive
# incremental annotations restart the index at 0, so distinct messages collided
# on the same ``message_id``.
messages: list[Message] = []
for turn in range(4):
messages.append(Message(role="user", contents=[f"user {turn}"]))
annotate_message_groups(messages)
messages.append(Message(role="assistant", contents=[f"assistant {turn}"]))
annotate_message_groups(messages)
message_ids = [message.message_id for message in messages]
assert all(message_ids), "every message should receive an id"
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
def test_ensure_message_ids_avoids_existing_id_collisions() -> None:
# An auto-generated ``msg_{index}`` must not collide with an id already present
# on another message (user-supplied or assigned by an earlier annotation pass).
messages = [
Message(role="user", contents=["zero"]),
Message(role="assistant", contents=["one"], message_id="msg_2"),
Message(role="user", contents=["two"]),
]
annotate_message_groups(messages)
message_ids = [message.message_id for message in messages]
assert message_ids[1] == "msg_2"
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
def test_incremental_annotation_avoids_prefix_id_collision() -> None:
# Regression for the PR review on #5237: when only a suffix is re-annotated,
# an auto-assigned ``msg_{index}`` in the suffix must not collide with a
# preexisting id carried by a message in the *preserved prefix* (a group
# before the one re-annotation pulls back to). Otherwise ``_group_id_for``
# derives the same group id and merges groups across the boundary.
messages = [
# Out-of-position, user-supplied id that matches the ``msg_{index}`` the
# suffix pass would assign to the appended message below. This message is
# two groups back, so it stays outside the re-annotated slice.
Message(role="user", contents=["zero"], message_id="msg_2"),
Message(role="user", contents=["one"]),
]
annotate_message_groups(messages)
assert messages[0].message_id == "msg_2"
assert messages[1].message_id == "msg_1"
messages.append(Message(role="user", contents=["two"]))
annotate_message_groups(messages, from_index=2)
message_ids = [message.message_id for message in messages]
assert all(message_ids), "every message should receive an id"
assert len(set(message_ids)) == len(message_ids), f"duplicate message ids: {message_ids}"
assert messages[0].message_id == "msg_2"
async def test_truncation_strategy_keeps_system_anchor() -> None:
messages = [
Message(role="system", contents=["you are helpful"]),
@@ -484,6 +542,44 @@ async def test_tool_result_compaction_collapses_old_groups_into_summary() -> Non
assert any(m.role == "tool" for m in projected)
async def test_tool_result_compaction_is_idempotent_after_summary_insertion() -> None:
"""Re-running compaction after a mid-list summary insertion must not duplicate it.
Mirrors a subsequent tool-loop iteration (issue #4991): the inserted summary and the
excluded originals now persist on the same list, so a second annotate + compaction pass
over the same groups should be a no-op rather than collapsing the group again.
"""
messages = [
Message(role="user", contents=["u"]),
_assistant_function_call("call-1"),
_tool_result("call-1", "r1"),
_assistant_function_call("call-2"),
_tool_result("call-2", "r2"),
Message(role="assistant", contents=["done"]),
]
strategy = ToolResultCompactionStrategy(keep_last_tool_call_groups=1)
annotate_message_groups(messages)
assert await strategy(messages) is True
summaries_after_first = [m for m in messages if (m.text or "").startswith("[Tool results:")]
assert len(summaries_after_first) == 1
summary = summaries_after_first[0]
summary_group_ids = _group_unknown_value(summary, SUMMARY_OF_GROUP_IDS_KEY)
# Second pass over the same (now partially compacted) list.
annotate_message_groups(messages)
changed = await strategy(messages)
assert changed is False
summaries_after_second = [m for m in messages if (m.text or "").startswith("[Tool results:")]
assert len(summaries_after_second) == 1
assert _group_unknown_value(summaries_after_second[0], SUMMARY_OF_GROUP_IDS_KEY) == summary_group_ids
# The kept tool-call group stays atomic and included.
projected = included_messages(messages)
assert any(m.role == "tool" for m in projected)
async def test_tool_result_compaction_zero_collapses_all() -> None:
"""With keep=0, all tool-call groups are collapsed into summaries."""
messages = [
@@ -1,4 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import threading
from typing import Annotated, Any, Literal, get_args, get_origin
from unittest.mock import Mock
@@ -1346,6 +1348,45 @@ async def test_invoke_skip_parsing_awaits_async_functions() -> None:
assert raw == 42
async def test_invoke_sync_tool_does_not_block_event_loop() -> None:
release_tool = threading.Event()
tool_thread_ids: list[int] = []
event_loop_thread_id = threading.get_ident()
@tool
def wait_for_release() -> str:
tool_thread_ids.append(threading.get_ident())
return "released" if release_tool.wait(timeout=0.2) else "timed out"
async def release_soon() -> None:
await asyncio.sleep(0.01)
release_tool.set()
tool_task = asyncio.create_task(wait_for_release.invoke(skip_parsing=True))
release_task = asyncio.create_task(release_soon())
assert await asyncio.wait_for(tool_task, timeout=1) == "released"
await release_task
assert tool_thread_ids
assert tool_thread_ids[0] != event_loop_thread_id
async def test_invoke_sync_tool_can_stay_on_event_loop() -> None:
event_loop_thread_id = threading.get_ident()
tool_thread_ids: list[int] = []
@tool
def needs_event_loop() -> str:
tool_thread_ids.append(threading.get_ident())
asyncio.get_running_loop()
return "ok"
needs_event_loop._invoke_sync_on_event_loop = True
assert await needs_event_loop.invoke(skip_parsing=True) == "ok"
assert tool_thread_ids == [event_loop_thread_id]
async def test_invoke_skip_parsing_bypasses_configured_result_parser() -> None:
"""The tool's own result_parser is bypassed when skip_parsing=True is requested."""
parser_calls: list[Any] = []
@@ -191,6 +191,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
compaction_strategy: CompactionStrategy | None = None,
tokenizer: TokenizerProtocol | None = None,
additional_properties: dict[str, Any] | None = None,
timeout: float | None = None,
) -> None:
"""Initialize a raw Foundry Agent client.
@@ -211,6 +212,8 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
compaction_strategy: Optional per-client compaction override.
tokenizer: Optional tokenizer for compaction strategies.
additional_properties: Additional properties stored on the client instance.
timeout: HTTP timeout in seconds for requests. When not provided, the
OpenAI SDK default is used (connect: 5s, total: 600s).
"""
settings = load_settings(
FoundryAgentSettings,
@@ -260,8 +263,11 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
openai_client_kwargs["default_headers"] = dict(default_headers)
if allow_preview:
openai_client_kwargs["agent_name"] = self.agent_name
openai_client = self.project_client.get_openai_client(**openai_client_kwargs)
if timeout is not None:
openai_client = openai_client.with_options(timeout=timeout)
super().__init__(
async_client=self.project_client.get_openai_client(**openai_client_kwargs),
async_client=openai_client,
default_headers=default_headers,
instruction_role=instruction_role,
compaction_strategy=compaction_strategy,
@@ -537,6 +543,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
additional_properties: dict[str, Any] | None = None,
middleware: (Sequence[ChatAndFunctionMiddlewareTypes] | None) = None,
function_invocation_configuration: FunctionInvocationConfiguration | None = None,
timeout: float | None = None,
) -> None:
"""Initialize a Foundry Agent client with full middleware support.
@@ -556,6 +563,8 @@ class _FoundryAgentChatClient( # type: ignore[misc]
additional_properties: Additional properties stored on the client instance.
middleware: Optional sequence of middleware.
function_invocation_configuration: Optional function invocation configuration.
timeout: HTTP timeout in seconds for requests. When not provided, the
OpenAI SDK default is used (connect: 5s, total: 600s).
"""
super().__init__(
project_endpoint=project_endpoint,
@@ -573,6 +582,7 @@ class _FoundryAgentChatClient( # type: ignore[misc]
additional_properties=additional_properties,
middleware=middleware,
function_invocation_configuration=function_invocation_configuration,
timeout=timeout,
)
@@ -625,6 +635,7 @@ class RawFoundryAgent( # type: ignore[misc]
compaction_strategy: CompactionStrategy | None = None,
tokenizer: TokenizerProtocol | None = None,
additional_properties: Mapping[str, Any] | None = None,
timeout: float | None = None,
) -> None:
"""Initialize a Foundry Agent.
@@ -657,6 +668,8 @@ class RawFoundryAgent( # type: ignore[misc]
compaction_strategy: Optional agent-level in-run compaction override.
tokenizer: Optional agent-level tokenizer override.
additional_properties: Additional properties stored on the local agent wrapper.
timeout: HTTP timeout in seconds for requests. When not provided, the
OpenAI SDK default is used (connect: 5s, total: 600s).
"""
# Create the client
actual_client_type = client_type or _FoundryAgentChatClient
@@ -675,6 +688,7 @@ class RawFoundryAgent( # type: ignore[misc]
"default_headers": default_headers,
"env_file_path": env_file_path,
"env_file_encoding": env_file_encoding,
"timeout": timeout,
}
if function_invocation_configuration is not None:
if not issubclass(actual_client_type, FunctionInvocationLayer):
@@ -912,6 +926,7 @@ class FoundryAgent( # type: ignore[misc]
compaction_strategy: CompactionStrategy | None = None,
tokenizer: TokenizerProtocol | None = None,
additional_properties: Mapping[str, Any] | None = None,
timeout: float | None = None,
) -> None:
"""Initialize a Foundry Agent with full middleware and telemetry.
@@ -958,6 +973,8 @@ class FoundryAgent( # type: ignore[misc]
compaction_strategy: Optional agent-level in-run compaction override.
tokenizer: Optional agent-level tokenizer override.
additional_properties: Additional properties stored on the local agent wrapper.
timeout: HTTP timeout in seconds for requests. When not provided, the
OpenAI SDK default is used (connect: 5s, total: 600s).
"""
super().__init__(
project_endpoint=project_endpoint,
@@ -983,4 +1000,5 @@ class FoundryAgent( # type: ignore[misc]
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
additional_properties=additional_properties,
timeout=timeout,
)
+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.7.0"
version = "1.8.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.7.0,<2",
"agent-framework-openai>=1.7.0,<2",
"agent-framework-core>=1.8.0,<2",
"agent-framework-openai>=1.8.0,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
]
@@ -109,9 +109,67 @@ def test_raw_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
assert "compaction_strategy" in signature.parameters
assert "tokenizer" in signature.parameters
assert "additional_properties" in signature.parameters
assert "timeout" in signature.parameters
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
def test_raw_foundry_agent_chat_client_init_applies_timeout_to_openai_client() -> None:
"""Test that timeout is applied via with_options without mutating the shared OpenAI client."""
mock_project = MagicMock()
openai_client_mock = MagicMock()
openai_client_mock.timeout = 5.0
mock_project.get_openai_client.return_value = openai_client_mock
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
timeout=60.0,
)
openai_client_mock.with_options.assert_called_once_with(timeout=60.0)
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
assert client.client is openai_client_mock.with_options.return_value
def test_raw_foundry_agent_chat_client_init_timeout_none_leaves_client_unchanged() -> None:
"""Test that timeout=None does not call with_options and leaves the shared client intact."""
mock_project = MagicMock()
openai_client_mock = MagicMock()
openai_client_mock.timeout = 5.0
mock_project.get_openai_client.return_value = openai_client_mock
RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
timeout=None,
)
openai_client_mock.with_options.assert_not_called()
assert openai_client_mock.timeout == 5.0
def test_raw_foundry_agent_chat_client_init_applies_timeout_with_preview_enabled() -> None:
"""Test that timeout uses with_options even when allow_preview=True (hosted agent path)."""
mock_project = MagicMock()
openai_client_mock = MagicMock()
openai_client_mock.timeout = 5.0
mock_project.get_openai_client.return_value = openai_client_mock
client = RawFoundryAgentChatClient(
project_client=mock_project,
agent_name="hosted-agent",
allow_preview=True,
timeout=120.0,
)
openai_client_mock.with_options.assert_called_once_with(timeout=120.0)
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
assert client.client is openai_client_mock.with_options.return_value
def test_raw_foundry_agent_chat_client_as_agent_preserves_client_type() -> None:
"""Test that as_agent() wraps the client in FoundryAgent using the same client class."""
@@ -552,9 +610,29 @@ def test_foundry_agent_chat_client_init_uses_explicit_parameters() -> None:
assert "compaction_strategy" in signature.parameters
assert "tokenizer" in signature.parameters
assert "additional_properties" in signature.parameters
assert "timeout" in signature.parameters
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
def test_foundry_agent_chat_client_init_propagates_timeout() -> None:
"""Test that _FoundryAgentChatClient calls with_options instead of mutating the shared client."""
mock_project = MagicMock()
openai_client_mock = MagicMock()
openai_client_mock.timeout = 5.0
mock_project.get_openai_client.return_value = openai_client_mock
client = _FoundryAgentChatClient(
project_client=mock_project,
agent_name="test-agent",
timeout=45.0,
)
openai_client_mock.with_options.assert_called_once_with(timeout=45.0)
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
assert client.client is openai_client_mock.with_options.return_value
def test_raw_foundry_agent_init_creates_client() -> None:
"""Test that RawFoundryAgent creates a client internally."""
@@ -629,6 +707,7 @@ def test_raw_foundry_agent_init_uses_explicit_parameters() -> None:
assert "compaction_strategy" in signature.parameters
assert "tokenizer" in signature.parameters
assert "additional_properties" in signature.parameters
assert "timeout" in signature.parameters
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
@@ -641,9 +720,47 @@ def test_foundry_agent_init_uses_explicit_parameters() -> None:
assert "compaction_strategy" in signature.parameters
assert "tokenizer" in signature.parameters
assert "additional_properties" in signature.parameters
assert "timeout" in signature.parameters
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
def test_foundry_agent_init_propagates_timeout_to_openai_client() -> None:
"""Test that FoundryAgent uses with_options instead of mutating the shared OpenAI client."""
mock_project = MagicMock()
openai_client_mock = MagicMock()
openai_client_mock.timeout = 5.0
mock_project.get_openai_client.return_value = openai_client_mock
agent = FoundryAgent(
project_client=mock_project,
agent_name="test-agent",
timeout=90.0,
)
openai_client_mock.with_options.assert_called_once_with(timeout=90.0)
assert openai_client_mock.timeout == 5.0, "Original shared client must not be mutated"
assert agent.client.client is openai_client_mock.with_options.return_value
def test_foundry_agent_init_timeout_none_leaves_client_default() -> None:
"""Test that FoundryAgent with timeout=None does not call with_options or mutate the client."""
mock_project = MagicMock()
openai_client_mock = MagicMock()
openai_client_mock.timeout = 5.0
mock_project.get_openai_client.return_value = openai_client_mock
FoundryAgent(
project_client=mock_project,
agent_name="test-agent",
timeout=None,
)
openai_client_mock.with_options.assert_not_called()
assert openai_client_mock.timeout == 5.0
def test_raw_foundry_agent_init_rejects_invalid_client_type() -> None:
"""Test that invalid client_type raises TypeError."""
@@ -11,7 +11,7 @@ import tempfile
import threading
from collections.abc import AsyncIterable, AsyncIterator, Generator, Mapping, Sequence
from contextlib import AbstractAsyncContextManager, AsyncExitStack, suppress
from dataclasses import asdict, is_dataclass
from dataclasses import asdict, dataclass, is_dataclass
from pathlib import Path
from typing import Protocol, cast
@@ -264,28 +264,73 @@ def _checkpoint_storage_for_context(root: str, context_id: str) -> FileCheckpoin
# Foundry Toolbox Auth integration
# Consent-URL error code returned by the Foundry MCP gateway when calling `/list`
CONSENT_ERROR_CODE = -32007
CONSENT_ERROR_CODE = -32006
def consent_url_from_error(exc: BaseException) -> str | None:
"""Return the consent URL when ``exc`` wraps a Foundry MCP gateway consent error.
@dataclass
class ConsentError:
name: str
consent_url: str
The Agent Framework MCP layer surfaces gateway consent failures by wrapping the underlying
``McpError`` inside an :class:`AgentFrameworkException` (typically a ``ToolExecutionException``
raised from ``MCPStreamableHTTPTool.__aenter__``). This helper inspects ``exc.args`` for a
wrapped ``McpError`` whose ``error.code`` is :data:`CONSENT_ERROR_CODE`; when found, the
consent link the gateway returned in ``error.message`` is returned. Returns ``None`` for
anything else, so callers can do ``if (url := consent_url_from_error(ex)) is None: raise``.
def consent_url_from_error(exc: BaseException) -> list[ConsentError] | None:
"""Return the consent URLs when ``exc`` wraps Foundry MCP gateway consent errors.
Args:
exc: The exception to inspect.
Returns:
The consent URL if ``exc`` wraps a consent ``McpError``, otherwise ``None``.
The consent URL(s) extracted from the error, or ``None`` if no consent error was found.
"""
inner_exception = next((arg for arg in exc.args if isinstance(arg, McpError)), None)
if inner_exception is not None and inner_exception.error.code == CONSENT_ERROR_CODE:
return inner_exception.error.message
# Parse the error message
# The error message is structured with the following format:
# "tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {"errors":[{"name": ..."
# where the second part is a JSON string that can be deserialized into an object with the following shape:
# ruff: disable[ERA001]
# {
# "errors" : [
# {
# "name": "Name of the MCP tool that requires consent",
# "type" : "mcp",
# "error": {
# "code": "CONSENT_REQUIRED",
# "message": consent_url,
# }
# }
# ]
# }
# ruff: enable[ERA001]
try:
consent_errors: list[ConsentError] = []
error_message_start = inner_exception.error.message.find("{")
if error_message_start == -1:
logger.warning("Consent error message does not contain JSON: %s", inner_exception.error.message)
return None
consent_details_json = inner_exception.error.message[error_message_start:]
consent_details = json.loads(consent_details_json)
if "errors" not in consent_details or not isinstance(consent_details["errors"], list):
logger.warning("Consent error message JSON does not contain 'errors' list: %s", consent_details_json)
return None
for error in consent_details["errors"]:
if (
isinstance(error, dict)
and error.get("type") == "mcp" # type: ignore
and "error" in error
and isinstance(error["error"], dict)
and error["error"].get("code") == "CONSENT_REQUIRED" # type: ignore
and "message" in error["error"]
):
consent_url = error["error"]["message"] # type: ignore
if isinstance(consent_url, str):
consent_errors.append(ConsentError(name=error.get("name", "Unknown"), consent_url=consent_url)) # type: ignore
else:
logger.warning("Consent URL in error message is not a valid URL: %s", consent_url) # type: ignore
if consent_errors:
return consent_errors
except json.JSONDecodeError:
logger.warning("Failed to parse consent details JSON: %s", inner_exception.error.message)
return None
@@ -448,18 +493,19 @@ class ResponsesHostServer(ResponsesAgentServerHost):
try:
await self._ensure_agent_ready()
except AgentFrameworkException as ex:
consent_url = consent_url_from_error(ex)
if consent_url is None:
consent_errors = consent_url_from_error(ex)
if consent_errors is None:
raise
logger.warning("OAuth consent required for Foundry MCP gateway.")
oauth_item = OAuthConsentRequestOutputItem(
id=IdGenerator.new_id("oacr"),
consent_link=consent_url,
server_label="Foundry Toolbox",
)
builder = response_event_stream.add_output_item(oauth_item.id)
yield builder.emit_added(oauth_item)
yield builder.emit_done(oauth_item)
for consent_error in consent_errors:
logger.warning("Consent URL for tool '%s': %s", consent_error.name, consent_error.consent_url)
oauth_item = OAuthConsentRequestOutputItem(
id=IdGenerator.new_id("oacr"),
consent_link=consent_error.consent_url,
server_label=consent_error.name,
)
builder = response_event_stream.add_output_item(oauth_item.id)
yield builder.emit_added(oauth_item)
yield builder.emit_done(oauth_item)
yield response_event_stream.emit_completed()
return
@@ -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.0a260528"
version = "1.0.0a260604"
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.7.0,<2",
"agent-framework-core>=1.8.0,<2",
"azure-ai-agentserver-core>=2.0.0b3,<3",
"azure-ai-agentserver-responses>=1.0.0b7,<2",
"azure-ai-agentserver-invocations>=1.0.0b3,<2",
@@ -39,6 +39,7 @@ from agent_framework_foundry_hosting import ResponsesHostServer
from agent_framework_foundry_hosting._responses import (
_AZURE_RESPONSES_MESSAGE_ROLE_TYPE, # pyright: ignore[reportPrivateUsage]
CONSENT_ERROR_CODE,
ConsentError,
FileBasedFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
InMemoryFunctionApprovalStorage, # pyright: ignore[reportPrivateUsage]
_item_to_message, # pyright: ignore[reportPrivateUsage]
@@ -2118,15 +2119,11 @@ class TestMultiTurnMixedContent:
assert resp2.json()["status"] == "completed"
second_call_messages = agent.run.call_args_list[1].kwargs["messages"]
mcp_call_contents = [
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"
]
mcp_call_contents = [c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_call"]
mcp_result_contents = [
c for m in second_call_messages for c in m.contents if c.type == "mcp_server_tool_result"
]
function_result_contents = [
c for m in second_call_messages for c in m.contents if c.type == "function_result"
]
function_result_contents = [c for m in second_call_messages for c in m.contents if c.type == "function_result"]
assert len(mcp_call_contents) >= 1
assert len(mcp_result_contents) >= 1
@@ -3264,7 +3261,10 @@ class TestCheckpointContextPathValidation:
# region Agent lifecycle (lazy entry & OAuth consent surfacing)
def _make_consent_error(url: str = "https://consent.example.com/auth") -> Exception:
def _make_consent_error(
url: str = "https://consent.example.com/auth",
name: str = "Foundry Toolbox",
) -> Exception:
"""Build an exception wrapping a Foundry MCP gateway consent error.
Mirrors the real-world wrapping produced by ``MCPStreamableHTTPTool.__aenter__``,
@@ -3272,17 +3272,34 @@ def _make_consent_error(url: str = "https://consent.example.com/auth") -> Except
``ToolExecutionException`` (an ``AgentFrameworkException`` subclass) with the
original error attached via ``inner_exception``. ``consent_url_from_error``
then finds the wrapped ``McpError`` in ``exc.args``.
The McpError message uses the structured Foundry MCP gateway format:
a human-readable prefix followed by a JSON document describing each
failed tool source and its consent URL.
"""
from agent_framework.exceptions import ToolExecutionException
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=url))
payload = json.dumps({
"errors": [
{
"name": name,
"type": "mcp",
"error": {
"code": "CONSENT_REQUIRED",
"message": url,
},
}
]
})
message = f"tools/list failed for 1 tool source(s), succeeded for 0 tool source(s) {payload}"
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message=message))
return ToolExecutionException("MCP consent required", inner_exception=inner)
class TestConsentUrlFromError:
def test_returns_consent_url_when_inner_arg_is_consent_mcp_error(self) -> None:
exc = _make_consent_error("https://example.com/consent")
assert consent_url_from_error(exc) == "https://example.com/consent"
exc = _make_consent_error("https://example.com/consent", name="my-tool")
assert consent_url_from_error(exc) == [ConsentError(name="my-tool", consent_url="https://example.com/consent")]
def test_returns_none_when_no_mcp_error_in_args(self) -> None:
assert consent_url_from_error(Exception("boom")) is None
@@ -3299,6 +3316,13 @@ class TestConsentUrlFromError:
bare = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="https://x"))
assert consent_url_from_error(bare) is None
def test_returns_none_when_message_has_no_json(self) -> None:
from agent_framework.exceptions import ToolExecutionException
inner = McpError(ErrorData(code=CONSENT_ERROR_CODE, message="no json here"))
exc = ToolExecutionException("MCP consent required", inner_exception=inner)
assert consent_url_from_error(exc) is None
class TestAgentLifecycle:
async def test_agent_entered_lazily_on_first_request(self) -> None:
@@ -37,9 +37,10 @@ from agent_framework.exceptions import AgentException
from agent_framework.observability import AgentTelemetryLayer
try:
from copilot import CopilotClient, CopilotSession, SubprocessConfig
from copilot.generated.session_events import PermissionRequest, SessionEvent, SessionEventType
from copilot import CopilotClient, CopilotSession, RuntimeConnection
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
from copilot.session import MCPServerConfig, PermissionRequestResult, ProviderConfig, SystemMessageConfig
from copilot.session_events import PermissionRequest, SessionEvent, SessionEventType
from copilot.tools import Tool as CopilotTool
from copilot.tools import ToolInvocation, ToolResult
except ImportError as _copilot_import_error:
@@ -57,8 +58,10 @@ else:
DEFAULT_TIMEOUT_SECONDS: float = 60.0
"""Default timeout in seconds for Copilot requests."""
PermissionHandlerType = Callable[[PermissionRequest, dict[str, str]], PermissionRequestResult]
"""Type for permission request handlers."""
PermissionHandlerType = Callable[
[PermissionRequest, dict[str, str]], "PermissionRequestResult | Awaitable[PermissionRequestResult]"
]
"""Type for permission request handlers. Supports both sync and async callbacks."""
FunctionApprovalCallback = Callable[[Content], "bool | Awaitable[bool]"]
@@ -121,7 +124,7 @@ def _deny_all_permissions(
_invocation: dict[str, str],
) -> PermissionRequestResult:
"""Default permission handler that denies all requests."""
return PermissionRequestResult()
return PermissionDecisionUserNotAvailable()
class GitHubCopilotSettings(TypedDict, total=False):
@@ -140,9 +143,9 @@ class GitHubCopilotSettings(TypedDict, total=False):
Can be set via environment variable GITHUB_COPILOT_TIMEOUT.
log_level: CLI log level.
Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL.
copilot_home: Directory where the CLI stores session state, configuration,
base_directory: Directory where the CLI stores session state, configuration,
and other persistent data. Can be set via environment variable
GITHUB_COPILOT_COPILOT_HOME. Defaults to ~/.copilot when not set.
GITHUB_COPILOT_BASE_DIRECTORY. Defaults to ~/.copilot when not set.
Only applicable when the SDK spawns the CLI process (ignored when
connecting to an external server via a pre-configured client).
"""
@@ -151,7 +154,7 @@ class GitHubCopilotSettings(TypedDict, total=False):
model: str | None
timeout: float | None
log_level: str | None
copilot_home: str | None
base_directory: str | None
class GitHubCopilotOptions(TypedDict, total=False):
@@ -314,7 +317,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
provider: ProviderConfig | None = opts.pop("provider", None)
instruction_directories: list[str] | None = opts.pop("instruction_directories", None)
on_function_approval: FunctionApprovalCallback | None = opts.pop("on_function_approval", None)
copilot_home = opts.pop("copilot_home", None)
base_directory = opts.pop("base_directory", None)
self._settings = load_settings(
GitHubCopilotSettings,
@@ -323,7 +326,7 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
model=model,
timeout=timeout,
log_level=log_level,
copilot_home=copilot_home,
base_directory=base_directory,
env_file_path=env_file_path,
env_file_encoding=env_file_encoding,
)
@@ -362,14 +365,16 @@ class RawGitHubCopilotAgent(BaseAgent, Generic[OptionsT]):
if self._client is None:
cli_path = self._settings.get("cli_path") or None
log_level = self._settings.get("log_level") or None
copilot_home = self._settings.get("copilot_home") or None
base_directory = self._settings.get("base_directory") or None
subprocess_kwargs: dict[str, Any] = {"cli_path": cli_path}
client_kwargs: dict[str, Any] = {}
if cli_path:
client_kwargs["connection"] = RuntimeConnection.for_stdio(path=cli_path)
if log_level:
subprocess_kwargs["log_level"] = log_level
if copilot_home:
subprocess_kwargs["copilot_home"] = copilot_home
self._client = CopilotClient(SubprocessConfig(**subprocess_kwargs))
client_kwargs["log_level"] = log_level
if base_directory:
client_kwargs["base_directory"] = base_directory
self._client = CopilotClient(**client_kwargs)
try:
await self._client.start()
@@ -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.0b260521"
version = "1.0.0rc1"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,8 +23,8 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"github-copilot-sdk>=1.0.0b2,<=1.0.0b2; python_version >= '3.11'",
"agent-framework-core>=1.8.0,<2",
"github-copilot-sdk>=1.0.0,<2; python_version >= '3.11'",
]
[tool.uv]
@@ -2,6 +2,7 @@
# ruff: noqa: E402
import os
import unittest.mock
from datetime import datetime, timezone
from typing import Any
@@ -20,9 +21,11 @@ from agent_framework import (
ContextProvider,
HistoryProvider,
Message,
tool,
)
from agent_framework.exceptions import AgentException
from copilot.generated.session_events import (
from copilot.session import PermissionHandler
from copilot.session_events import (
Data,
SessionEvent,
SessionEventType,
@@ -308,27 +311,27 @@ class TestGitHubCopilotAgentLifecycle:
)
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.cli_path == "/custom/path"
assert call_args.log_level == "debug"
kwargs = MockClient.call_args.kwargs
assert kwargs["connection"].path == "/custom/path"
assert kwargs["log_level"] == "debug"
async def test_start_passes_copilot_home_to_subprocess_config(self) -> None:
"""Test that copilot_home is passed through to SubprocessConfig."""
async def test_start_passes_base_directory_to_client(self) -> None:
"""Test that base_directory is passed through to CopilotClient."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
MockClient.return_value = mock_client
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"copilot_home": "/custom/copilot/home"}
default_options={"base_directory": "/custom/copilot/home"}
)
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/custom/copilot/home"
kwargs = MockClient.call_args.kwargs
assert kwargs["base_directory"] == "/custom/copilot/home"
async def test_start_copilot_home_not_set_when_unspecified(self) -> None:
"""Test that copilot_home is not included in SubprocessConfig when not specified."""
async def test_start_base_directory_not_set_when_unspecified(self) -> None:
"""Test that base_directory is not included in client kwargs when not specified."""
with patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient:
mock_client = MagicMock()
mock_client.start = AsyncMock()
@@ -337,14 +340,14 @@ class TestGitHubCopilotAgentLifecycle:
agent = GitHubCopilotAgent()
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home is None
kwargs = MockClient.call_args.kwargs
assert "base_directory" not in kwargs
async def test_start_copilot_home_from_env_variable(self) -> None:
"""Test that copilot_home can be set via GITHUB_COPILOT_COPILOT_HOME env variable."""
async def test_start_base_directory_from_env_variable(self) -> None:
"""Test that base_directory can be set via GITHUB_COPILOT_BASE_DIRECTORY env variable."""
with (
patch("agent_framework_github_copilot._agent.CopilotClient") as MockClient,
patch.dict("os.environ", {"GITHUB_COPILOT_COPILOT_HOME": "/env/copilot/home"}),
patch.dict("os.environ", {"GITHUB_COPILOT_BASE_DIRECTORY": "/env/copilot/home"}),
):
mock_client = MagicMock()
mock_client.start = AsyncMock()
@@ -353,8 +356,8 @@ class TestGitHubCopilotAgentLifecycle:
agent = GitHubCopilotAgent()
await agent.start()
call_args = MockClient.call_args[0][0]
assert call_args.copilot_home == "/env/copilot/home"
kwargs = MockClient.call_args.kwargs
assert kwargs["base_directory"] == "/env/copilot/home"
class TestGitHubCopilotAgentRun:
@@ -1053,11 +1056,11 @@ class TestGitHubCopilotAgentSessionManagement:
mock_session: MagicMock,
) -> None:
"""Test that resumed session config includes tools and permission handler."""
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
def my_handler(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
return PermissionRequestResult(kind="approved")
return PermissionDecisionApproveOnce()
def my_tool(arg: str) -> str:
"""A test tool."""
@@ -1869,6 +1872,15 @@ class TestGitHubCopilotAgentErrorHandling:
class TestGitHubCopilotAgentPermissions:
"""Test cases for permission handling."""
def test_deny_all_permissions_returns_user_not_available(self) -> None:
"""Test that the default deny handler returns PermissionDecisionUserNotAvailable."""
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
from agent_framework_github_copilot._agent import _deny_all_permissions
result = _deny_all_permissions(MagicMock(), {})
assert isinstance(result, PermissionDecisionUserNotAvailable)
def test_no_permission_handler_when_not_provided(self) -> None:
"""Test that no handler is set when on_permission_request is not provided."""
agent = GitHubCopilotAgent()
@@ -1876,13 +1888,14 @@ class TestGitHubCopilotAgentPermissions:
def test_permission_handler_set_when_provided(self) -> None:
"""Test that a handler is set when on_permission_request is provided."""
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
def approve_shell(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
if request.kind == "shell":
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
return PermissionDecisionApproveOnce()
return PermissionDecisionDeniedInteractivelyByUser()
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
default_options={"on_permission_request": approve_shell}
@@ -1895,13 +1908,14 @@ class TestGitHubCopilotAgentPermissions:
mock_session: MagicMock,
) -> None:
"""Test that session config includes permission handler when provided."""
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
from copilot.session import PermissionDecisionApproveOnce, PermissionRequestResult
from copilot.session_events import PermissionRequest
def approve_shell_read(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
if request.kind in ("shell", "read"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
return PermissionDecisionApproveOnce()
return PermissionDecisionDeniedInteractivelyByUser()
agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent(
client=mock_client,
@@ -2705,3 +2719,163 @@ class TestGitHubCopilotAgentContextProviders:
assert call_kwargs.get("tools") is not None
tool_names = [t.name for t in call_kwargs["tools"]]
assert "load_skill" in tool_names
# ---------------------------------------------------------------------------
# Integration tests — require COPILOT_GITHUB_TOKEN env var
# ---------------------------------------------------------------------------
skip_if_copilot_integration_tests_disabled = pytest.mark.skipif(
os.getenv("COPILOT_GITHUB_TOKEN", "") == "",
reason="No COPILOT_GITHUB_TOKEN provided; skipping integration tests.",
)
@tool(approval_mode="never_require")
def get_weather(location: str) -> str:
"""Get the weather for a given location."""
return f"The weather in {location} is sunny with a high of 25C."
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_copilot_integration_tests_disabled
async def test_integration_run_with_simple_prompt_returns_response() -> None:
"""Integration test: basic non-streaming response."""
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant. Keep your answers short.",
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
session = agent.create_session()
response = await agent.run("What is 2 + 2? Answer with just the number.", session=session)
assert response is not None
assert len(response.messages) > 0
assert "4" in response.text
if session.service_session_id and agent._client:
await agent._client.delete_session(session.service_session_id)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_copilot_integration_tests_disabled
async def test_integration_run_streaming_returns_updates() -> None:
"""Integration test: streaming response yields updates."""
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant. Keep your answers short.",
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
session = agent.create_session()
updates = []
async for chunk in agent.run("Count from 1 to 5.", stream=True, session=session):
updates.append(chunk)
assert len(updates) > 0
full_text = "".join(u.text for u in updates if u.text)
assert len(full_text) > 0
if session.service_session_id and agent._client:
await agent._client.delete_session(session.service_session_id)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_copilot_integration_tests_disabled
async def test_integration_run_with_function_tool_invokes_tool() -> None:
"""Integration test: function tool is invoked by the agent."""
agent = GitHubCopilotAgent(
instructions="You are a helpful weather agent. Use the get_weather tool to answer weather questions.",
tools=[get_weather],
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
session = agent.create_session()
response = await agent.run("What's the weather like in Seattle?", session=session)
assert response is not None
assert len(response.messages) > 0
assert any(word in response.text.lower() for word in ["sunny", "25", "weather", "seattle"])
if session.service_session_id and agent._client:
await agent._client.delete_session(session.service_session_id)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_copilot_integration_tests_disabled
async def test_integration_run_with_session_maintains_context() -> None:
"""Integration test: session maintains conversation context across turns."""
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant. Keep your answers short.",
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
session = agent.create_session()
response1 = await agent.run("My name is Alice.", session=session)
assert response1 is not None
response2 = await agent.run("What is my name?", session=session)
assert response2 is not None
assert "alice" in response2.text.lower()
if session.service_session_id and agent._client:
await agent._client.delete_session(session.service_session_id)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_copilot_integration_tests_disabled
async def test_integration_run_with_session_resume_continues_conversation() -> None:
"""Integration test: session can be resumed by ID."""
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant. Keep your answers short.",
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
session1 = agent.create_session()
await agent.run("Remember this number: 42.", session=session1)
session_id = session1.service_session_id
assert session_id is not None
session2 = AgentSession()
session2.service_session_id = session_id
response = await agent.run("What number did I ask you to remember?", session=session2)
assert response is not None
assert "42" in response.text
if agent._client:
await agent._client.delete_session(session_id)
@pytest.mark.flaky
@pytest.mark.integration
@skip_if_copilot_integration_tests_disabled
async def test_integration_run_with_shell_permissions_executes_command() -> None:
"""Integration test: shell commands can be executed with permission handler."""
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant that can execute shell commands.",
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
session = agent.create_session()
response = await agent.run("Run a shell command to print 'hello world'", session=session)
assert response is not None
assert "hello" in response.text.lower()
if session.service_session_id and agent._client:
await agent._client.delete_session(session.service_session_id)
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Mistral AI integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260505"
version = "1.0.0a260604"
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.1.0,<2",
"agent-framework-core>=1.8.0,<2",
"mistralai>=2.0.0,<3",
]
@@ -385,6 +385,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
timeout: float | None = None,
) -> None:
"""Initialize a raw OpenAI Chat client.
@@ -406,6 +407,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
env_file_path: Optional ``.env`` file that is checked before the process environment
for ``OPENAI_*`` values.
env_file_encoding: Encoding for the ``.env`` file.
timeout: Optional timeout in seconds for requests.
"""
...
@@ -427,6 +429,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
timeout: float | None = None,
) -> None:
"""Initialize a raw OpenAI Chat client.
@@ -455,6 +458,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
env_file_path: Optional ``.env`` file that is checked before process environment
variables for ``AZURE_OPENAI_*`` values.
env_file_encoding: Encoding for the ``.env`` file.
timeout: Optional timeout in seconds for requests.
"""
...
@@ -476,6 +480,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
additional_properties: dict[str, Any] | None = None,
env_file_path: str | None = None,
env_file_encoding: str | None = None,
timeout: float | None = None,
) -> None:
"""Initialize a raw OpenAI Chat client.
@@ -511,6 +516,8 @@ class RawOpenAIChatClient( # type: ignore[misc]
variables. The same file is used for both ``OPENAI_*`` and ``AZURE_OPENAI_*``
lookups.
env_file_encoding: Encoding for the ``.env`` file.
timeout: HTTP timeout in seconds for requests. When not provided, the
OpenAI SDK default is used (connect: 5s, total: 600s).
Notes:
Environment resolution and routing precedence are:
@@ -541,6 +548,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
openai_model_fields=("chat_model", "model"),
azure_model_fields=("chat_model", "model"),
responses_mode=True,
timeout=timeout,
)
self.client = client
@@ -1454,10 +1462,21 @@ class RawOpenAIChatClient( # type: ignore[misc]
Returns:
The prepared chat messages for a request.
"""
drops_reasoning_without_storage = not request_uses_service_side_storage and any(
content.type == "text_reasoning" for message in chat_messages for content in message.contents
)
drop_mcp_call_ids: set[str] = set()
if drops_reasoning_without_storage:
for message in chat_messages:
for content in message.contents:
if content.type == "mcp_server_tool_call" and content.call_id:
drop_mcp_call_ids.add(content.call_id)
list_of_list = [
self._prepare_message_for_openai(
message,
request_uses_service_side_storage=request_uses_service_side_storage,
drop_mcp_call_ids=drop_mcp_call_ids,
)
for message in chat_messages
]
@@ -1472,6 +1491,7 @@ class RawOpenAIChatClient( # type: ignore[misc]
message: Message,
*,
request_uses_service_side_storage: bool = True,
drop_mcp_call_ids: set[str] | None = None,
) -> list[dict[str, Any]]:
"""Prepare a chat message for the OpenAI Responses API format."""
all_messages: list[dict[str, Any]] = []
@@ -1491,7 +1511,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
# (replays_local_storage) still need stripping when the request also carries a continuation
# marker, since the server-stored items would otherwise duplicate the inline ones. Without
# storage, standalone reasoning items are invalid per the API ("reasoning was provided
# without its required following item"), so the reasoning branch always drops.
# without its required following item"), so the reasoning branch always drops. When that
# happens, `_prepare_messages_for_openai` also drops the paired hosted-MCP IDs across
# message boundaries rather than replaying bare MCP items.
drop_mcp_call_ids = drop_mcp_call_ids or set()
for content in message.contents:
match content.type:
case "text_reasoning":
@@ -1546,7 +1569,10 @@ class RawOpenAIChatClient( # type: ignore[misc]
# server-side `id`, so under continuation it would duplicate
# the prior response's items (#3295). Drop the call here; the
# orphan result is dropped by the coalesce step that follows.
if request_uses_service_side_storage:
#
# Without storage, a reasoning + hosted-MCP pair cannot be replayed
# partially: reasoning is stripped above, and a bare mcp_call is rejected.
if request_uses_service_side_storage or content.call_id in drop_mcp_call_ids:
continue
prepared_mcp = self._prepare_content_for_openai(
message.role,
@@ -162,6 +162,7 @@ def load_openai_service_settings(
openai_model_fields: Sequence[OpenAIModelSettingName] = ("model",),
azure_model_fields: Sequence[OpenAIModelSettingName] = ("model",),
responses_mode: bool = False,
timeout: float | None = None,
) -> tuple[dict[str, Any], AsyncOpenAI, bool]:
"""Load OpenAI settings, including Azure OpenAI model aliases.
@@ -218,6 +219,8 @@ def load_openai_service_settings(
}
if base_url := openai_settings.get("base_url"):
client_args["base_url"] = base_url
if timeout is not None:
client_args["timeout"] = timeout
return openai_settings, AsyncOpenAI(**client_args), False # type: ignore[return-value]
checked_openai = True
azure_settings = load_settings(
@@ -299,8 +302,12 @@ def load_openai_service_settings(
openai_args["api_key"] = _ensure_async_token_provider(client_args["azure_ad_token_provider"])
elif "api_key" in client_args:
openai_args["api_key"] = client_args["api_key"]
if timeout is not None:
openai_args["timeout"] = timeout
return azure_settings, AsyncOpenAI(**openai_args), True # type: ignore[return-value]
if timeout is not None:
client_args["timeout"] = timeout
return azure_settings, AsyncAzureOpenAI(**client_args), True # type: ignore[return-value]
+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.7.0"
version = "1.8.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.7.0,<2",
"agent-framework-core>=1.8.0,<2",
"openai>=1.99.0,<3",
]
@@ -36,7 +36,7 @@ from agent_framework.exceptions import (
ChatClientInvalidRequestException,
SettingNotFoundError,
)
from openai import BadRequestError
from openai import AsyncOpenAI, BadRequestError
from openai.types.responses.response_reasoning_item import Summary
from openai.types.responses.response_reasoning_summary_text_delta_event import (
ResponseReasoningSummaryTextDeltaEvent,
@@ -55,7 +55,7 @@ from pydantic import BaseModel
from pytest import param
from agent_framework_openai import OpenAIChatClient
from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY
from agent_framework_openai._chat_client import OPENAI_LOCAL_SHELL_CALL_ITEM_ID_KEY, RawOpenAIChatClient
from agent_framework_openai._exceptions import OpenAIContentFilterException
skip_if_openai_integration_tests_disabled = pytest.mark.skipif(
@@ -194,6 +194,26 @@ def test_init_uses_explicit_parameters() -> None:
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
def test_raw_openai_chat_client_init_uses_explicit_parameters() -> None:
signature = inspect.signature(RawOpenAIChatClient.__init__)
assert "additional_properties" in signature.parameters
assert "compaction_strategy" in signature.parameters
assert "tokenizer" in signature.parameters
assert "timeout" in signature.parameters
assert all(parameter.kind != inspect.Parameter.VAR_KEYWORD for parameter in signature.parameters.values())
def test_raw_openai_chat_client_accepts_preconfigured_client_with_timeout() -> None:
"""Test that timeout is accepted without error when async_client is pre-provided."""
mock_client = MagicMock(spec=AsyncOpenAI)
mock_client.timeout = 5.0
client = RawOpenAIChatClient(async_client=mock_client, timeout=30.0)
assert client is not None
def test_openai_chat_client_supports_all_tool_protocols() -> None:
assert isinstance(OpenAIChatClient, SupportsCodeInterpreterTool)
assert isinstance(OpenAIChatClient, SupportsWebSearchTool)
@@ -5648,6 +5668,79 @@ def test_prepare_messages_for_openai_coalesces_mcp_call_and_result_into_single_i
assert fco_items == [], f"unexpected orphan function_call_output items: {fco_items}"
def test_prepare_messages_for_openai_drops_mcp_call_when_paired_reasoning_is_stripped() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(
role="assistant",
contents=[
Content.from_text_reasoning(id="rs_abc123", text="Need the MCP server."),
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q": "cats"}',
),
],
),
Message(
role="tool",
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp_abc123",
output=[Content.from_text(text="found 10 cats")],
)
],
),
]
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
types = [item.get("type") for item in result if isinstance(item, dict)]
assert "reasoning" not in types
assert "mcp_call" not in types
assert "function_call_output" not in types
def test_prepare_messages_for_openai_drops_mcp_call_across_reasoning_messages() -> None:
client = OpenAIChatClient(model="test-model", api_key="test-key")
messages = [
Message(
role="assistant",
contents=[Content.from_text_reasoning(id="rs_abc123", text="Need a tool call.")],
),
Message(
role="assistant",
contents=[
Content.from_mcp_server_tool_call(
call_id="mcp_abc123",
tool_name="search",
server_name="api_specs",
arguments='{"q": "cats"}',
)
],
),
Message(
role="tool",
contents=[
Content.from_mcp_server_tool_result(
call_id="mcp_abc123",
output=[Content.from_text(text="found 10 cats")],
)
],
),
]
result = client._prepare_messages_for_openai(messages, request_uses_service_side_storage=False)
types = [item.get("type") for item in result if isinstance(item, dict)]
assert "reasoning" not in types
assert "mcp_call" not in types
assert "function_call_output" not in types
def test_prepare_messages_for_openai_drops_orphan_mcp_server_tool_result() -> None:
"""When an mcp_server_tool_result has no matching mcp_server_tool_call in
the message list, it must be dropped, NOT serialized as a
@@ -19,7 +19,7 @@ from typing_extensions import Never
from ._orchestration_request_info import AgentApprovalExecutor
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
UNSET,
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
@@ -213,7 +213,7 @@ class ConcurrentBuilder:
*,
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the ConcurrentBuilder.
@@ -52,7 +52,7 @@ from ._base_group_chat_orchestrator import (
from ._orchestration_request_info import AgentApprovalExecutor
from ._orchestrator_helpers import clean_conversation_for_handoff
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
UNSET,
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
@@ -626,7 +626,7 @@ class GroupChatBuilder:
termination_condition: TerminationCondition | None = None,
max_rounds: int | None = None,
checkpoint_storage: CheckpointStorage | None = None,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the GroupChatBuilder.
@@ -54,7 +54,7 @@ from agent_framework._workflows._workflow_context import WorkflowContext
from ._base_group_chat_orchestrator import TerminationCondition
from ._orchestrator_helpers import clean_conversation_for_handoff
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
UNSET,
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
@@ -597,7 +597,7 @@ class HandoffBuilder:
description: str | None = None,
checkpoint_storage: CheckpointStorage | None = None,
termination_condition: TerminationCondition | None = None,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
r"""Initialize a HandoffBuilder for creating conversational handoff workflows.
@@ -28,7 +28,7 @@ from agent_framework._workflows._request_info_mixin import response_handler
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_builder import WorkflowBuilder
from agent_framework._workflows._workflow_context import WorkflowContext
from typing_extensions import Never
from typing_extensions import Never, Sentinel
from ._base_group_chat_orchestrator import (
BaseGroupChatOrchestrator,
@@ -39,7 +39,7 @@ from ._base_group_chat_orchestrator import (
ParticipantRegistry,
)
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
UNSET,
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
@@ -1411,13 +1411,13 @@ class MagenticBuilder:
task_ledger_plan_update_prompt: str | None = None,
progress_ledger_prompt: str | None = None,
final_answer_prompt: str | None = None,
max_stall_count: int = 3,
max_stall_count: int | Sentinel = UNSET,
max_reset_count: int | None = None,
max_round_count: int | None = None,
# Existing params
enable_plan_review: bool = False,
checkpoint_storage: CheckpointStorage | None = None,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the Magentic workflow builder.
@@ -1621,7 +1621,7 @@ class MagenticBuilder:
progress_ledger_prompt: str | None = None,
final_answer_prompt: str | None = None,
# Limits
max_stall_count: int = 3,
max_stall_count: int | Sentinel = UNSET,
max_reset_count: int | None = None,
max_round_count: int | None = None,
) -> None:
@@ -1656,8 +1656,10 @@ class MagenticBuilder:
"Exactly one of manager, manager_agent, manager_factory, or manager_agent_factory must be provided."
)
resolved_max_stall_count: int = 3 if max_stall_count is UNSET else cast(int, max_stall_count)
def _log_warning_if_constructor_args_provided() -> None:
if any(
if max_stall_count is not UNSET or any(
arg is not None
for arg in [
task_ledger,
@@ -1668,7 +1670,6 @@ class MagenticBuilder:
task_ledger_plan_update_prompt,
progress_ledger_prompt,
final_answer_prompt,
max_stall_count,
max_reset_count,
max_round_count,
]
@@ -1689,7 +1690,7 @@ class MagenticBuilder:
task_ledger_plan_update_prompt=task_ledger_plan_update_prompt,
progress_ledger_prompt=progress_ledger_prompt,
final_answer_prompt=final_answer_prompt,
max_stall_count=max_stall_count,
max_stall_count=resolved_max_stall_count,
max_reset_count=max_reset_count,
max_round_count=max_round_count,
)
@@ -1707,7 +1708,7 @@ class MagenticBuilder:
"task_ledger_plan_update_prompt": task_ledger_plan_update_prompt,
"progress_ledger_prompt": progress_ledger_prompt,
"final_answer_prompt": final_answer_prompt,
"max_stall_count": max_stall_count,
"max_stall_count": resolved_max_stall_count,
"max_reset_count": max_reset_count,
"max_round_count": max_round_count,
}
@@ -8,8 +8,9 @@ from typing import Any, Literal
from agent_framework import SupportsAgentRun
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._executor import Executor
from typing_extensions import Sentinel
_MISSING = object()
UNSET = Sentinel("UNSET")
_ALL_OUTPUTS: Literal["all"] = "all"
_ALL_OTHER_OUTPUTS: Literal["all_other"] = "all_other"
_ParticipantOutputSpecifier = str | SupportsAgentRun | Executor
@@ -20,10 +21,10 @@ _WorkflowExecutorSpecifier = Executor | SupportsAgentRun
def _coalesce_output_from( # pyright: ignore[reportUnusedFunction]
*,
output_from: Any = _MISSING,
output_from: Any = UNSET,
) -> _ParticipantOutputSelection:
"""Resolve orchestration output selection to ``output_from``."""
if output_from is not _MISSING:
if output_from is not UNSET:
return _coerce_output_from(output_from)
return None
@@ -33,7 +33,7 @@ from agent_framework._workflows._workflow_context import WorkflowContext
from ._orchestration_request_info import AgentApprovalExecutor
from ._participant_output_config import (
_MISSING, # pyright: ignore[reportPrivateUsage]
UNSET,
_coalesce_output_from, # pyright: ignore[reportPrivateUsage]
_coerce_intermediate_output_from, # pyright: ignore[reportPrivateUsage]
_ParticipantIntermediateOutputSelection, # pyright: ignore[reportPrivateUsage]
@@ -99,7 +99,7 @@ class SequentialBuilder:
participants: Sequence[SupportsAgentRun | Executor],
checkpoint_storage: CheckpointStorage | None = None,
chain_only_agent_responses: bool = False,
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, _MISSING),
output_from: Sequence[_ParticipantOutputSpecifier] | Literal["all"] | None = cast(Any, UNSET),
intermediate_output_from: _ParticipantIntermediateOutputSelection = None,
) -> None:
"""Initialize the SequentialBuilder.
@@ -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.0rc2"
version = "1.0.0rc3"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.6.0,<2",
"agent-framework-core>=1.8.0,<2",
]
[tool.uv]
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
import sys
from collections.abc import AsyncIterable, Awaitable, Sequence
from dataclasses import dataclass
@@ -987,6 +988,33 @@ def test_magentic_builder_requires_exactly_one_manager_option():
MagenticBuilder(participants=[agent], manager=manager, manager_factory=manager_factory)
def test_magentic_with_custom_manager_does_not_warn_without_standard_manager_options(caplog: Any) -> None:
caplog.set_level(logging.WARNING, logger="agent_framework_orchestrations._magentic")
MagenticBuilder(participants=[StubAgent("agentA", "reply")], manager=FakeManager())
assert "Custom manager provided; all other manager arguments will be ignored." not in caplog.text
def test_magentic_with_custom_manager_factory_does_not_warn_without_standard_manager_options(caplog: Any) -> None:
caplog.set_level(logging.WARNING, logger="agent_framework_orchestrations._magentic")
def manager_factory() -> MagenticManagerBase:
return FakeManager()
MagenticBuilder(participants=[StubAgent("agentA", "reply")], manager_factory=manager_factory)
assert "Custom manager provided; all other manager arguments will be ignored." not in caplog.text
def test_magentic_with_custom_manager_warns_when_standard_manager_option_is_provided(caplog: Any) -> None:
caplog.set_level(logging.WARNING, logger="agent_framework_orchestrations._magentic")
MagenticBuilder(participants=[StubAgent("agentA", "reply")], manager=FakeManager(), max_stall_count=3)
assert "Custom manager provided; all other manager arguments will be ignored." in caplog.text
async def test_magentic_with_manager_factory():
"""Test workflow creation using manager_factory."""
factory_call_count = 0
@@ -1037,6 +1065,20 @@ async def test_magentic_with_agent_factory():
assert event_count > 0
def test_magentic_agent_factory_uses_default_max_stall_count() -> None:
def agent_factory() -> SupportsAgentRun:
return cast(SupportsAgentRun, StubManagerAgent())
participant = StubAgent("agentA", "reply from agentA")
workflow = MagenticBuilder(participants=[participant], manager_agent_factory=agent_factory).build()
orchestrator = next(e for e in workflow.executors.values() if isinstance(e, MagenticOrchestrator))
manager = orchestrator._manager # type: ignore[reportPrivateUsage]
assert isinstance(manager, StandardMagenticManager)
assert manager.max_stall_count == 3
async def test_magentic_manager_factory_reusable_builder():
"""Test that the builder can be reused to build multiple workflows with manager factory."""
factory_call_count = 0
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Agent Framework for building AI Agents with Python. Thi
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.7.0"
version = "1.8.0"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core[all]==1.7.0",
"agent-framework-core[all]==1.8.0",
]
[dependency-groups]
@@ -5,7 +5,8 @@ This folder demonstrates context compaction patterns introduced by ADR-0019.
## Files
- `basics.py` — builds a local message list and applies each built-in strategy one at a time.
- `advanced.py` — composes multiple strategies with `TokenBudgetComposedStrategy`.
- `summarization.py` — runs `SummarizationStrategy` directly with a real summarizing chat client.
- `advanced.py` — composes multiple strategies with `TokenBudgetComposedStrategy`, including a real summarizer and tool-call groups.
- `agent_client_overrides.py` — shows client defaults, agent-level overrides, and per-run compaction overrides.
- `custom.py` — defines a custom strategy implementing the `CompactionStrategy` protocol.
- `tiktoken_tokenizer.py` — shows a `TokenizerProtocol` implementation backed by `tiktoken`.
@@ -15,7 +16,8 @@ Run samples with:
```bash
uv run samples/02-agents/compaction/basics.py
uv run samples/02-agents/compaction/advanced.py
uv run samples/02-agents/compaction/summarization.py # requires OPENAI_API_KEY
uv run samples/02-agents/compaction/advanced.py # requires OPENAI_API_KEY
uv run samples/02-agents/compaction/agent_client_overrides.py
uv run samples/02-agents/compaction/custom.py
uv run samples/02-agents/compaction/tiktoken_tokenizer.py
+138 -44
View File
@@ -1,11 +1,14 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any
from typing import Any, cast
from agent_framework import (
GROUP_ANNOTATION_KEY,
GROUP_TOKEN_COUNT_KEY,
SUMMARY_OF_MESSAGE_IDS_KEY,
CharacterEstimatorTokenizer,
ChatResponse,
Content,
Message,
SelectiveToolCallCompactionStrategy,
SlidingWindowStrategy,
@@ -15,36 +18,48 @@ from agent_framework import (
apply_compaction,
included_token_count,
)
from agent_framework.openai import OpenAIChatClient
from dotenv import load_dotenv
"""This sample demonstrates composed in-run compaction with a token budget.
load_dotenv()
"""This sample demonstrates composed in-run compaction under a token budget.
A long, tool-using conversation is compacted with a single
``TokenBudgetComposedStrategy`` that runs three strategies in order until the
included-token count fits the budget:
1. ``SelectiveToolCallCompactionStrategy`` — drop older tool-call groups
(assistant ``function_call`` + ``tool`` result messages) that are expensive
and rarely needed verbatim once acted upon.
2. ``SummarizationStrategy`` — use a *real* chat client to summarize the oldest
remaining turns into a single linked summary message.
3. ``SlidingWindowStrategy`` — as a final guard, keep only the most recent
groups if the budget is still exceeded.
Key components:
- TokenBudgetComposedStrategy
- Sequential strategy composition
- Summarization with a SupportsChatGetResponse-compatible summarizer client
- TokenBudgetComposedStrategy with ordered, escalating strategies
- A real OpenAIChatClient used as the summarizer (not a stub)
- Tool-call groups in the history so tool-call compaction is meaningful
- Token accounting before/after via a TokenizerProtocol
Run with:
uv run samples/02-agents/compaction/advanced.py # requires OPENAI_API_KEY
"""
class BudgetSummaryClient:
async def get_response(
self,
messages: list[Message],
*,
stream: bool = False,
options: dict[str, Any] | None = None,
**kwargs: Any,
) -> ChatResponse:
summary_text = f"Budget summary generated from {len(messages)} prompt messages."
return ChatResponse(messages=[Message(role="assistant", contents=[summary_text])])
def _build_long_history() -> list[Message]:
history = [Message(role="system", contents=["You are a migration copilot."])]
for i in range(1, 8):
"""Build a long, tool-using migration conversation to create token pressure."""
history: list[Message] = [
Message(role="system", contents=["You are a migration copilot that plans and executes database migrations."]),
]
# A few verbose planning turns to build up token pressure.
for i in range(1, 5):
history.append(
Message(
role="user",
contents=[f"Iteration {i}: capture migration requirements and edge cases."],
contents=[f"Iteration {i}: capture migration requirements, constraints, and edge cases in detail."],
)
)
history.append(
@@ -52,17 +67,62 @@ def _build_long_history() -> list[Message]:
role="assistant",
contents=[
(
f"Iteration {i}: detailed plan with dependencies, rollback guidance, and testing details. "
"This sentence is intentionally long to create token pressure."
f"Iteration {i}: produced a detailed plan covering dependencies, rollback guidance, data "
"backfill, and a full testing matrix. This response is intentionally verbose to add pressure."
)
],
)
)
# A tool-call group: the assistant inspects the schema via a tool.
history.append(
Message(
role="assistant",
contents=[Content.from_function_call(call_id="call_1", name="inspect_schema", arguments='{"db":"legacy"}')],
)
)
history.append(
Message(
role="tool",
contents=[Content.from_function_result(call_id="call_1", result="tables: users, orders, invoices, events")],
)
)
history.append(Message(role="assistant", contents=["Schema inspection found four core tables to migrate."]))
# The most recent turn — this should survive compaction verbatim.
history.append(Message(role="user", contents=["What is the safest order to migrate these tables?"]))
history.append(
Message(
role="assistant",
contents=["Migrate reference tables (users) first, then orders, then invoices, and events last."],
)
)
return history
def _annotation(message: Message) -> dict[str, Any] | None:
annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
return cast("dict[str, Any]", annotation) if isinstance(annotation, dict) else None
def _token_count(message: Message) -> int | None:
annotation = _annotation(message)
return annotation.get(GROUP_TOKEN_COUNT_KEY) if annotation else None
def _relation(message: Message) -> str:
"""Describe how a projected message relates to the original messages."""
annotation = _annotation(message)
if annotation is None:
return ""
summarizes = annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY)
if summarizes:
return f" <- summary of {summarizes}"
return ""
async def main() -> None:
# 1. Build synthetic history representing long-running in-run growth.
# 1. Build synthetic history representing long-running, tool-using growth.
messages = _build_long_history()
# 2. Configure tokenizer and measure token count before compaction.
@@ -70,22 +130,35 @@ async def main() -> None:
annotate_message_groups(messages, tokenizer=tokenizer)
budget_before = included_token_count(messages)
# 3. Configure composed strategy stack.
print("Before compaction message set:")
for msg in messages:
text_preview = msg.text[:80] if msg.text else "<non-text>"
print(f"- [{msg.role}] {text_preview} ({msg.message_id}, {_token_count(msg)} tokens)")
print()
# 3. Create a real summarizer client. SummarizationStrategy only requires a
# SupportsChatGetResponse-compatible client.
summarizer = OpenAIChatClient(model="gpt-4o-mini")
# 4. Configure the composed strategy stack. Strategies run in order and the
# composed strategy stops as soon as the included-token budget is met.
# The budget is set high enough that the generated summary fits within it:
# a tighter budget would trip the composed fallback, which excludes the
# oldest group first (the summary) once the included set exceeds the
# budget. SlidingWindowStrategy remains as a recency safety net for longer
# histories; for this sample summarization alone reaches budget, so the
# window does not need to fire.
composed = TokenBudgetComposedStrategy(
token_budget=200,
token_budget=400,
tokenizer=tokenizer,
strategies=[
SelectiveToolCallCompactionStrategy(keep_last_tool_call_groups=0),
SummarizationStrategy(
client=BudgetSummaryClient(),
target_count=3,
threshold=3,
),
SummarizationStrategy(client=summarizer, target_count=3, threshold=2),
SlidingWindowStrategy(keep_last_groups=4),
],
)
# 4. Apply compaction and inspect the budget result.
# 5. Apply compaction and inspect the budget result.
projected = await apply_compaction(messages, strategy=composed, tokenizer=tokenizer)
budget_after = included_token_count(messages)
@@ -95,23 +168,44 @@ async def main() -> None:
print("Projected roles:", [m.role for m in projected])
print("Projected messages with token counts:")
for msg in projected:
group = msg.additional_properties.get("_group")
token_count = group.get("token_count") if isinstance(group, dict) else None
text_preview = msg.text[:80] if msg.text else "<non-text>"
print(f"- [{msg.role}] {text_preview} ({token_count} tokens)")
print(f"- [{msg.role}] {text_preview} ({msg.message_id}, {_token_count(msg)} tokens){_relation(msg)}")
# 6. Surface the model-generated summary, if summarization fired.
for msg in messages:
annotation = _annotation(msg)
if annotation and annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY):
print("\nGenerated summary:")
print(f" {msg.text}")
print(f" summarizes: {annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY)}")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output:
Projected messages after compaction: 3
Included token count before compaction: 793
Included token count after compaction: 144
Projected roles: ['system', 'user', 'assistant']
Sample output (summary text and token counts vary because the summary is generated by the model):
Before compaction message set:
- [system] You are a migration copilot that plans and executes database migrations. (msg_0, 46 tokens)
- [user] Iteration 1: capture migration requirements, constraints, and edge cases in deta (msg_1, 48 tokens)
- [assistant] Iteration 1: produced a detailed plan covering dependencies, rollback guidance, (msg_2, 73 tokens)
...
- [user] What is the safest order to migrate these tables? (msg_12, 40 tokens)
- [assistant] Migrate reference tables (users) first, then orders, then invoices, and events l (msg_13, 50 tokens)
Projected messages after compaction: 5
Included token count before compaction: 757
Included token count after compaction: 274
Projected roles: ['system', 'assistant', 'assistant', 'user', 'assistant']
Projected messages with token counts:
- [system] You are a migration copilot. (35 tokens)
- [user] Iteration 7: capture migration requirements and edge cases. (43 tokens)
- [assistant] Iteration 7: detailed plan with dependencies, rollback guidance, and testing det (66 tokens)
- [system] You are a migration copilot that plans and executes database migrations. (msg_0, 46 tokens)
- [assistant] Across four planning turns the user and assistant... (summary_14, 96 tokens) <- summary of [msg_1..8]
- [assistant] Schema inspection found four core tables to migrate. (msg_11, 42 tokens)
- [user] What is the safest order to migrate these tables? (msg_12, 40 tokens)
- [assistant] Migrate reference tables (users) first, then orders, then invoices, and events l (msg_13, 50 tokens)
Generated summary:
Across four planning turns the user and assistant defined the migration requirements...
summarizes: ['msg_1', 'msg_2', 'msg_3', 'msg_4', 'msg_5', 'msg_6', 'msg_7', 'msg_8']
"""
@@ -0,0 +1,159 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
from typing import Any, cast
from agent_framework import (
GROUP_ANNOTATION_KEY,
SUMMARIZED_BY_SUMMARY_ID_KEY,
SUMMARY_OF_MESSAGE_IDS_KEY,
Message,
SummarizationStrategy,
apply_compaction,
)
from agent_framework.openai import OpenAIChatClient
from dotenv import load_dotenv
load_dotenv()
"""This sample demonstrates the SummarizationStrategy directly.
Unlike SlidingWindow/Truncation strategies that simply drop older groups,
``SummarizationStrategy`` calls a real chat client to *summarize* the oldest
message groups, replaces them with a single linked summary message, and keeps
the most recent turns verbatim. This preserves long-range context (decisions,
goals, unresolved items) while bounding the prompt size.
Key components:
- SummarizationStrategy with a real OpenAIChatClient summarizer
- ``apply_compaction`` to run the strategy over a message list
- Bidirectional summary trace metadata (summary -> originals, original -> summary)
Run with:
uv run samples/02-agents/compaction/summarization.py # requires OPENAI_API_KEY
"""
def _annotation(message: Message) -> dict[str, Any] | None:
annotation = message.additional_properties.get(GROUP_ANNOTATION_KEY)
return cast("dict[str, Any]", annotation) if isinstance(annotation, dict) else None
def _build_history() -> list[Message]:
"""Build a multi-turn conversation long enough to trigger summarization."""
return [
Message(role="system", contents=["You are a project planning assistant."]),
Message(role="user", contents=["We are migrating a monolith to microservices. Where do we start?"]),
Message(
role="assistant",
contents=["Start by mapping bounded contexts and identifying the highest-churn modules to extract first."],
),
Message(role="user", contents=["The billing module changes most often. What are the risks of extracting it?"]),
Message(
role="assistant",
contents=["Main risks: distributed transactions, invoices-table ownership, and latency on hot paths."],
),
Message(role="user", contents=["How should we handle the shared invoices table?"]),
Message(
role="assistant",
contents=["Use the strangler-fig pattern: dual-write during transition, then make billing the owner."],
),
Message(role="user", contents=["What is the most recent decision we made?"]),
Message(role="assistant", contents=["We decided to extract billing first using the strangler-fig pattern."]),
]
def _print_messages(label: str, messages: list[Message]) -> None:
print(f"\n--- {label} ---")
print(f"Message count: {len(messages)}")
for index, message in enumerate(messages, start=1):
text = message.text or ", ".join(content.type for content in message.contents)
print(f"{index:02d}. [{message.role}] {text[:90]}")
async def main() -> None:
# 1. Create a real summarizing client. SummarizationStrategy only requires a
# SupportsChatGetResponse-compatible client, so any chat client works.
summarizer = OpenAIChatClient(model="gpt-4o-mini")
# 2. Build a conversation and show it before compaction.
messages = _build_history()
_print_messages("Before compaction", messages)
# 3. Configure the strategy. It triggers once the included non-system message
# count exceeds ``target_count + threshold`` (here 4 + 2 = 6), summarizing
# the oldest groups down toward ``target_count`` while keeping recent turns.
strategy = SummarizationStrategy(
client=summarizer,
target_count=4,
threshold=2,
)
# 4. Apply the strategy. The oldest groups are summarized into a single
# assistant message; the projected list is what the model would receive.
projected = await apply_compaction(messages, strategy=strategy)
_print_messages("After compaction (SummarizationStrategy)", projected)
# 5. Inspect the generated summary and its bidirectional trace metadata.
print("\n--- Summary trace ---")
for message in messages:
annotation = _annotation(message)
if annotation is None:
continue
summarizes = annotation.get(SUMMARY_OF_MESSAGE_IDS_KEY)
if summarizes:
print(f"Generated summary ({message.message_id}):")
print(f" {message.text}")
print(f" summarizes original ids: {summarizes}")
summarized_by: dict[str | None, Any] = {}
for message in messages:
annotation = _annotation(message)
if annotation is None:
continue
summary_id = annotation.get(SUMMARIZED_BY_SUMMARY_ID_KEY)
if summary_id:
summarized_by[message.message_id] = summary_id
if summarized_by:
print("Originals replaced by the summary:")
for original_id, summary_id in summarized_by.items():
print(f" {original_id} -> {summary_id}")
if __name__ == "__main__":
asyncio.run(main())
"""
Sample output (summary text varies because it is generated by the model):
--- Before compaction ---
Message count: 9
01. [system] You are a project planning assistant.
02. [user] We are migrating a monolith to microservices. Where do we start?
03. [assistant] Start by mapping bounded contexts and identifying the highest-churn modules to ex
04. [user] The billing module changes most often. What are the risks of extracting it?
05. [assistant] Main risks: distributed transactions, data ownership of the invoices table, and lat
06. [user] How should we handle the shared invoices table?
07. [assistant] Use the strangler-fig pattern: dual-write during transition, then make billing the
08. [user] What is the most recent decision we made?
09. [assistant] We decided to extract billing first using the strangler-fig pattern.
--- After compaction (SummarizationStrategy) ---
Message count: 6
01. [system] You are a project planning assistant.
02. [assistant] The user is migrating a monolith to microservices and decided to extract the billin
03. [user] How should we handle the shared invoices table?
04. [assistant] Use the strangler-fig pattern: dual-write during transition, then make billing the
05. [user] What is the most recent decision we made?
06. [assistant] We decided to extract billing first using the strangler-fig pattern.
--- Summary trace ---
Generated summary (summary_9):
The user is migrating a monolith to microservices and decided to extract the billing module first...
summarizes original ids: ['msg_1', 'msg_2', 'msg_3', 'msg_4', 'msg_5']
Originals replaced by the summary:
msg_1 -> summary_9
msg_2 -> summary_9
msg_3 -> summary_9
msg_4 -> summary_9
msg_5 -> summary_9
"""
@@ -23,7 +23,7 @@ The following environment variables can be configured:
| `GITHUB_COPILOT_MODEL` | Model to use (e.g., "gpt-5", "claude-sonnet-4") | Server default |
| `GITHUB_COPILOT_TIMEOUT` | Request timeout in seconds | `60` |
| `GITHUB_COPILOT_LOG_LEVEL` | CLI log level | `info` |
| `GITHUB_COPILOT_COPILOT_HOME` | Directory for CLI session state and config | `~/.copilot` |
| `GITHUB_COPILOT_BASE_DIRECTORY` | Directory for CLI session state and config | `~/.copilot` |
## Observability
@@ -19,8 +19,7 @@ from typing import Annotated
from agent_framework import tool
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.session import PermissionHandler
from dotenv import load_dotenv
from pydantic import Field
@@ -28,19 +27,6 @@ from pydantic import Field
load_dotenv()
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
if request.full_command_text is not None:
print(f" Command: {request.full_command_text}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# see samples/02-agents/tools/function_tool_with_approval.py
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@@ -60,7 +46,7 @@ async def non_streaming_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
@@ -77,7 +63,7 @@ async def streaming_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
@@ -97,7 +83,7 @@ async def runtime_options_example() -> None:
agent = GitHubCopilotAgent(
instructions="Always respond in exactly 3 words.",
tools=[get_weather],
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
@@ -4,8 +4,7 @@
GitHub Copilot Agent with File Operation Permissions
This sample demonstrates how to enable file read and write operations with GitHubCopilotAgent.
By providing a permission handler that approves "read" and/or "write" requests, the agent can
read from and write to files on the filesystem.
By providing a permission handler, the agent can read from and write to files on the filesystem.
SECURITY NOTE: Only enable file permissions when you trust the agent's actions.
- "read" allows the agent to read any accessible file
@@ -15,21 +14,18 @@ SECURITY NOTE: Only enable file permissions when you trust the agent's actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.generated.rpc import PermissionDecisionDeniedInteractivelyByUser
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
async def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
if request.path is not None:
print(f" Path: {request.path}")
response = input("Approve? (y/n): ").strip().lower()
response = (await asyncio.to_thread(input, "Approve? (y/n): ")).strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
return PermissionHandler.approve_all(request, context)
return PermissionDecisionDeniedInteractivelyByUser()
async def main() -> None:
@@ -32,6 +32,7 @@ from typing import Annotated
from agent_framework import Content, tool
from agent_framework.github import GitHubCopilotAgent
from copilot.session import PermissionHandler
from dotenv import load_dotenv
load_dotenv()
@@ -48,37 +49,42 @@ def get_weather_detail(location: Annotated[str, "The city and state, e.g. San Fr
)
def prompt_for_approval(call: Content) -> bool:
"""Synchronous approval prompt.
async def prompt_for_approval(call: Content) -> bool:
"""Async approval callback that prompts the user interactively.
The callback receives a ``FunctionCallContent`` so the operator can review
the tool name and arguments before deciding. Returning ``True`` allows the
call; returning ``False`` denies it and a tool-error is returned to the
model.
Uses ``asyncio.to_thread`` so the event loop is not blocked by ``input()``.
"""
print(f"\n[Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = input("Approve this tool call? (y/n): ").strip().lower()
print(f"\n [Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = (await asyncio.to_thread(input, " Approve this tool call? (y/n): ")).strip().lower()
return response in ("y", "yes")
async def prompt_for_approval_async(call: Content) -> bool:
"""Async approval prompt.
def auto_approve(call: Content) -> bool:
"""Synchronous approval callback that always approves.
Use an async callback when approval requires I/O (e.g. an HTTP call to a
review service or queueing the request to a UI). ``input()`` is wrapped
with ``asyncio.to_thread`` so the event loop is not blocked.
Use a sync callback for simple, non-blocking decisions that don't require
I/O (e.g. checking an allow-list of tool names).
"""
print(f"\n[Function Approval Request - async]\n Tool: {call.name}\n Arguments: {call.arguments}")
response = await asyncio.to_thread(input, "Approve this tool call? (y/n): ")
return response.strip().lower() in ("y", "yes")
print(f"\n [Function Approval Request]\n Tool: {call.name}\n Arguments: {call.arguments}")
print(" -> Auto-approved")
return True
async def run_with_sync_callback() -> None:
print("\n=== GitHub Copilot Agent: synchronous approval callback ===")
async def run_with_interactive_callback() -> None:
"""Demonstrates an interactive approval prompt before tool execution."""
print("\n=== GitHub Copilot Agent: interactive approval callback ===")
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={"on_function_approval": prompt_for_approval},
default_options={
"on_function_approval": prompt_for_approval,
"on_permission_request": PermissionHandler.approve_all,
},
)
async with agent:
query = "Give me the detailed weather for Seattle."
@@ -87,12 +93,16 @@ async def run_with_sync_callback() -> None:
print(f"Agent: {result}")
async def run_with_async_callback() -> None:
print("\n=== GitHub Copilot Agent: asynchronous approval callback ===")
async def run_with_auto_approve_callback() -> None:
"""Demonstrates a synchronous callback that always approves."""
print("\n=== GitHub Copilot Agent: synchronous auto-approve callback ===")
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={"on_function_approval": prompt_for_approval_async},
default_options={
"on_function_approval": auto_approve,
"on_permission_request": PermissionHandler.approve_all,
},
)
async with agent:
query = "Give me the detailed weather for Tokyo."
@@ -112,6 +122,7 @@ async def run_without_callback() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful weather assistant.",
tools=[get_weather_detail],
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
query = "Give me the detailed weather for Paris."
@@ -122,8 +133,8 @@ async def run_without_callback() -> None:
async def main() -> None:
print("=== GitHub Copilot Agent: Function approval enforcement ===")
await run_with_sync_callback()
await run_with_async_callback()
await run_with_interactive_callback()
await run_with_auto_approve_callback()
await run_without_callback()
@@ -22,24 +22,13 @@ import asyncio
from pathlib import Path
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.session import PermissionHandler
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
async def default_instructions_example() -> None:
"""Example of pointing the agent at project-specific instruction directories."""
print("=== Instruction Directories (Default) ===\n")
@@ -58,7 +47,7 @@ async def default_instructions_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful coding assistant.",
default_options={
"on_permission_request": prompt_permission,
"on_permission_request": PermissionHandler.approve_all,
"instruction_directories": instruction_dirs,
},
)
@@ -79,7 +68,7 @@ async def runtime_override_example() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant.",
default_options={
"on_permission_request": prompt_permission,
"on_permission_request": PermissionHandler.approve_all,
"instruction_directories": ["/team/shared/instructions"],
},
)
@@ -15,24 +15,13 @@ of MCP-related actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import MCPServerConfig, PermissionRequestResult
from copilot.session import MCPServerConfig, PermissionHandler
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
async def main() -> None:
print("=== GitHub Copilot Agent with MCP Servers ===\n")
@@ -56,7 +45,7 @@ async def main() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant with access to the local filesystem and Microsoft Learn.",
default_options={
"on_permission_request": prompt_permission,
"on_permission_request": PermissionHandler.approve_all,
"mcp_servers": mcp_servers,
},
)
@@ -3,9 +3,8 @@
"""
GitHub Copilot Agent with Multiple Permissions
This sample demonstrates how to enable multiple permission types with GitHubCopilotAgent.
By combining different permission kinds in the handler, the agent can perform complex tasks
that require multiple capabilities.
This sample demonstrates how multiple permission types are requested when GitHubCopilotAgent
performs complex tasks that require different capabilities.
Available permission kinds:
- "shell": Execute shell commands
@@ -21,23 +20,14 @@ More permissions mean more potential for unintended actions.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
if request.full_command_text is not None:
print(f" Command: {request.full_command_text}")
if request.path is not None:
print(f" Path: {request.path}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that auto-approves and logs each permission kind."""
print(f" [Permission: {request.kind}]", flush=True)
return PermissionHandler.approve_all(request, context)
async def main() -> None:
@@ -45,14 +35,14 @@ async def main() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful development assistant that can read, write files and run commands.",
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": approve_and_log},
)
async with agent:
query = "List the first 3 Python files, then read the first one and create a summary in summary.txt"
print(f"User: {query}")
print(f"User: {query}\n")
result = await agent.run(query)
print(f"Agent: {result}\n")
print(f"\nAgent: {result}\n")
if __name__ == "__main__":
@@ -14,24 +14,10 @@ from typing import Annotated
from agent_framework import tool
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.session import PermissionHandler
from pydantic import Field
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
if request.full_command_text is not None:
print(f" Command: {request.full_command_text}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
# NOTE: approval_mode="never_require" is for sample brevity. Use "always_require" in production;
# see samples/02-agents/tools/function_tool_with_approval.py
# and samples/02-agents/tools/function_tool_with_approval_and_sessions.py.
@@ -51,7 +37,7 @@ async def example_with_automatic_session_creation() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
@@ -76,7 +62,7 @@ async def example_with_session_persistence() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent:
@@ -113,7 +99,7 @@ async def example_with_existing_session_id() -> None:
agent1 = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent1:
@@ -135,7 +121,7 @@ async def example_with_existing_session_id() -> None:
agent2 = GitHubCopilotAgent(
instructions="You are a helpful weather agent.",
tools=[get_weather],
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": PermissionHandler.approve_all},
)
async with agent2:
@@ -14,21 +14,20 @@ Shell commands have full access to your system within the permissions of the run
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
if request.full_command_text is not None:
print(f" Command: {request.full_command_text}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that approves only shell commands and logs them."""
if request.kind == "shell":
print(f"\n [Permission: {request.kind}]", flush=True)
command = getattr(request, "full_command_text", None)
if command is not None:
print(f" Command: {command}", flush=True)
return PermissionHandler.approve_all(request, context)
return PermissionDecisionUserNotAvailable()
async def main() -> None:
@@ -36,14 +35,14 @@ async def main() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant that can execute shell commands.",
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": approve_and_log},
)
async with agent:
query = "List the first 3 Python files in the current directory"
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result}\n")
print(f"\nAgent: {result}\n")
if __name__ == "__main__":
@@ -14,21 +14,20 @@ URL fetching allows the agent to access any URL accessible from your network.
import asyncio
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.generated.rpc import PermissionDecisionUserNotAvailable
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
def prompt_permission(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that prompts the user for approval."""
print(f"\n[Permission Request: {request.kind}]")
if request.url is not None:
print(f" URL: {request.url}")
response = input("Approve? (y/n): ").strip().lower()
if response in ("y", "yes"):
return PermissionRequestResult(kind="approved")
return PermissionRequestResult(kind="denied-interactively-by-user")
def approve_and_log(request: PermissionRequest, context: dict[str, str]) -> PermissionRequestResult:
"""Permission handler that approves only URL requests and logs them."""
if request.kind == "url":
print(f"\n [Permission: {request.kind}]", flush=True)
url = getattr(request, "url", None)
if url is not None:
print(f" URL: {url}", flush=True)
return PermissionHandler.approve_all(request, context)
return PermissionDecisionUserNotAvailable()
async def main() -> None:
@@ -36,14 +35,14 @@ async def main() -> None:
agent = GitHubCopilotAgent(
instructions="You are a helpful assistant that can fetch and summarize web content.",
default_options={"on_permission_request": prompt_permission},
default_options={"on_permission_request": approve_and_log},
)
async with agent:
query = "Fetch https://learn.microsoft.com/agent-framework/tutorials/quick-start and summarize its contents"
print(f"User: {query}")
result = await agent.run(query)
print(f"Agent: {result}\n")
print(f"\nAgent: {result}\n")
if __name__ == "__main__":
@@ -20,7 +20,7 @@ You can connect to MCP servers in Foundry Toolbox that use different authenticat
- **Agent identity authentication**: The tool requires an agent identity token to authenticate. Sample MCP server: `https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview` (Azure Language MCP server) with agent identity for authentication.
- **Entra Pass-through authentication**: The tool requires an Entra pass-through token to authenticate. Sample MCP server: Microsoft Outlook MCP server with Entra pass-through for authentication.
> Definitions of these authentication methods can be found in the [agent.manifest.yaml](agent.manifest.yaml) file in this sample.
> Definitions of these authentication methods can be found in the [agent.manifest.yaml](agent.manifest.yaml) file in this sample. The GitHub MCP connection defaults to using a PAT for authentication in this sample, but you can switch to OAuth2 by changing the `project_connection_id` field in the `agent.manifest.yaml` file and following the instructions in the comments.
There are also Non-MCP tools in the toolbox that support different authentication methods. Learn more at the [Foundry sample repository](https://github.com/microsoft-foundry/foundry-samples/blob/main/samples/python/hosted-agents/SUPPORTED_TOOLBOX_SCENARIOS.md).
@@ -18,92 +18,92 @@ template:
- name: AZURE_AI_MODEL_DEPLOYMENT_NAME
value: "{{AZURE_AI_MODEL_DEPLOYMENT_NAME}}"
- name: TOOLBOX_NAME
value: "agent-tools-2"
# parameters:
# properties:
# - name: mcp_endpoint
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest
# secret: false
# description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) that does not require authentication
# - name: github_pat
# # `azd ai agent init -m` will prompt for this value when initializing the agent manifest.
# # Only needed when the GitHub MCP connection is configured to use the `github-mcp-pat-conn`
# # PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection
# # instead, you can leave this empty.
# secret: true
# description: GitHub Personal Access Token used to authenticate with the GitHub MCP server (only needed when using the PAT connection; press Enter if using OAuth2 instead)
# - name: language_mcp_entra_audience
# secret: false
# description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/)
# - name: language_mcp_target_url
# secret: false
# description: URL of the Azure Language MCP server that accepts agent identity tokens (e.g. https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview)
# - name: outlook_mail_entra_audience
# secret: false
# description: Entra ID audience for the Outlook Mail MCP server
# - name: outlook_mail_entra_mcp_target
# secret: false
# description: URL of the Outlook Mail MCP server that accepts user Entra tokens
value: "agent-tools"
parameters:
properties:
- name: mcp_endpoint
# `azd ai agent init -m` will prompt for this value when initializing the agent manifest
secret: false
description: URL of the public MCP server (e.g. https://gitmcp.io/Azure/azure-rest-api-specs) that does not require authentication
- name: github_pat
# `azd ai agent init -m` will prompt for this value when initializing the agent manifest.
# Only needed when the GitHub MCP connection is configured to use the `github-mcp-pat-conn`
# PAT-based connection below; if you use the `github-mcp-oauth-conn` OAuth2 connection
# instead, you can leave this empty.
secret: true
description: GitHub Personal Access Token used to authenticate with the GitHub MCP server (only needed when using the PAT connection; press Enter if using OAuth2 instead)
# - name: language_mcp_entra_audience
# secret: false
# description: Entra ID audience for the Azure Language MCP server (e.g. https://cognitiveservices.azure.com/)
# - name: language_mcp_target_url
# secret: false
# description: URL of the Azure Language MCP server that accepts agent identity tokens (e.g. https://{foundry-resource-name}.cognitiveservices.azure.com/language/mcp?api-version=2025-11-15-preview)
# - name: outlook_mail_entra_audience
# secret: false
# description: Entra ID audience for the Outlook Mail MCP server
# - name: outlook_mail_entra_mcp_target
# secret: false
# description: URL of the Outlook Mail MCP server that accepts user Entra tokens
resources:
- kind: model
id: gpt-4.1-mini
name: AZURE_AI_MODEL_DEPLOYMENT_NAME
# - kind: connection
# # A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server
# name: github-mcp-pat-conn
# category: RemoteTool
# authType: CustomKeys
# target: https://api.githubcopilot.com/mcp
# credentials:
# type: CustomKeys
# keys:
# Authorization: "Bearer {{ github_pat }}"
# - kind: connection
# # A connection that uses OAuth2 to authenticate with the GitHub MCP server
# name: github-mcp-oauth-conn
# category: RemoteTool
# authType: OAuth2
# target: https://api.githubcopilot.com/mcp
# connectorName: foundrygithubmcp
# credentials:
# type: OAuth2
# clientId: managed
# clientSecret: managed
- kind: connection
# A connection that uses a GitHub Personal Access Token (PAT) to authenticate with the GitHub MCP server
name: github-mcp-pat-conn
category: RemoteTool
authType: CustomKeys
target: https://api.githubcopilot.com/mcp
credentials:
type: CustomKeys
keys:
Authorization: "Bearer {{ github_pat }}"
- kind: connection
# A connection that uses OAuth2 to authenticate with the GitHub MCP server
name: github-mcp-oauth-conn
category: RemoteTool
authType: OAuth2
target: https://api.githubcopilot.com/mcp
connectorName: foundrygithubmcp
credentials:
type: OAuth2
clientId: managed
clientSecret: managed
# - kind: connection
# name: language-mcp-conn
# category: RemoteTool
# authType: AgenticIdentity
# audience: "{{ language_mcp_entra_audience }}"
# target: "{{ language_mcp_target_url }}"
# # - kind: connection
# # name: outlook-mail-conn
# # category: RemoteTool
# # authType: UserEntraToken
# # audience: "{{ outlook_mail_entra_audience }}"
# # target: "{{ outlook_mail_entra_mcp_target }}"
# - kind: toolbox
# name: agent-tools
# tools:
# - type: web_search
# name: web_search
# - type: code_interpreter
# name: code_interpreter
# # - type: mcp
# # # This MCP tool doesn't require authentication
# # server_label: noauth_mcp
# # server_url: "{{ mcp_endpoint }}"
# # require_approval: "never"
# - type: mcp
# # This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2
# server_label: github
# project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication
# require_approval: "never"
# - type: mcp
# # This MCP tool uses the Azure Language MCP server with agent identity for authentication
# server_label: language-mcp
# project_connection_id: language-mcp-conn
# require_approval: "never"
# # - type: mcp
# # server_label: outlook-mail
# # project_connection_id: outlook-mail-conn
# # require_approval: "never"
# - kind: connection
# name: outlook-mail-conn
# category: RemoteTool
# authType: UserEntraToken
# audience: "{{ outlook_mail_entra_audience }}"
# target: "{{ outlook_mail_entra_mcp_target }}"
- kind: toolbox
name: agent-tools
tools:
- type: web_search
name: web_search
- type: code_interpreter
name: code_interpreter
- type: mcp
# This MCP tool doesn't require authentication
server_label: noauth_mcp
server_url: "{{ mcp_endpoint }}"
require_approval: "never"
- type: mcp
# This MCP tool uses the GitHub MCP server with a PAT for authentication or OAuth2
server_label: github
project_connection_id: github-mcp-pat-conn # use `github-mcp-oauth-conn` for OAuth2 authentication
require_approval: "never"
# - type: mcp
# # This MCP tool uses the Azure Language MCP server with agent identity for authentication
# server_label: language-mcp
# project_connection_id: language-mcp-conn
# require_approval: "never"
# - type: mcp
# server_label: outlook-mail
# project_connection_id: outlook-mail-conn
# require_approval: "never"
@@ -1,4 +1,3 @@
# agent-framework
# agent-framework-foundry-hosting
agent-framework
agent-framework-foundry-hosting
mcp>=1.24.0,<2
@@ -14,8 +14,8 @@ from agent_framework import (
handler,
)
from agent_framework.github import GitHubCopilotAgent
from copilot.generated.session_events import PermissionRequest
from copilot.session import PermissionRequestResult
from copilot.session import PermissionHandler, PermissionRequestResult
from copilot.session_events import PermissionRequest
from pydantic import BaseModel
from sample_validation.const import WORKER_COMPLETED
from sample_validation.discovery import DiscoveryResult
@@ -103,7 +103,7 @@ def prompt_permission(
logger.debug(
f"[Permission Request: {request.kind}] ({context})Automatically approved for sample validation."
)
return PermissionRequestResult(kind="approved")
return PermissionHandler.approve_all(request, context)
class CustomAgentExecutor(Executor):
+20 -20
View File
@@ -115,7 +115,7 @@ wheels = [
[[package]]
name = "agent-framework"
version = "1.7.0"
version = "1.8.0"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -170,7 +170,7 @@ dev = [
[[package]]
name = "agent-framework-a2a"
version = "1.0.0b260528"
version = "1.0.0b260604"
source = { editable = "packages/a2a" }
dependencies = [
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -213,7 +213,7 @@ provides-extras = ["dev"]
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b260521"
version = "1.0.0b260604"
source = { editable = "packages/anthropic" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -279,7 +279,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azurefunctions"
version = "1.0.0b260521"
version = "1.0.0b260604"
source = { editable = "packages/azurefunctions" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -301,7 +301,7 @@ dev = []
[[package]]
name = "agent-framework-bedrock"
version = "1.0.0b260521"
version = "1.0.0b260604"
source = { editable = "packages/bedrock" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -363,7 +363,7 @@ requires-dist = [
[[package]]
name = "agent-framework-core"
version = "1.7.0"
version = "1.8.0"
source = { editable = "packages/core" }
dependencies = [
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -529,7 +529,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260518" }]
[[package]]
name = "agent-framework-foundry"
version = "1.7.0"
version = "1.8.0"
source = { editable = "packages/foundry" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -548,7 +548,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-hosting"
version = "1.0.0a260528"
version = "1.0.0a260604"
source = { editable = "packages/foundry_hosting" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -599,7 +599,7 @@ requires-dist = [
[[package]]
name = "agent-framework-github-copilot"
version = "1.0.0b260521"
version = "1.0.0rc1"
source = { editable = "packages/github_copilot" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -609,7 +609,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "agent-framework-core", editable = "packages/core" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = "<=1.0.0b2,>=1.0.0b2" },
{ name = "github-copilot-sdk", marker = "python_full_version >= '3.11'", specifier = ">=1.0.0,<2" },
]
[[package]]
@@ -729,7 +729,7 @@ requires-dist = [
[[package]]
name = "agent-framework-mistral"
version = "1.0.0a260505"
version = "1.0.0a260604"
source = { editable = "packages/mistral" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -774,7 +774,7 @@ requires-dist = [
[[package]]
name = "agent-framework-openai"
version = "1.7.0"
version = "1.8.0"
source = { editable = "packages/openai" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -789,7 +789,7 @@ requires-dist = [
[[package]]
name = "agent-framework-orchestrations"
version = "1.0.0rc2"
version = "1.0.0rc3"
source = { editable = "packages/orchestrations" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -2608,19 +2608,19 @@ wheels = [
[[package]]
name = "github-copilot-sdk"
version = "1.0.0b2"
version = "1.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
{ name = "python-dateutil", marker = "(python_full_version >= '3.11' and sys_platform == 'darwin') or (python_full_version >= '3.11' and sys_platform == 'linux') or (python_full_version >= '3.11' and sys_platform == 'win32')" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/82/fe/2cb98d4b9f57f8062ea72775bde72aed1958305016753f7296398e0ceb45/github_copilot_sdk-1.0.0b2-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:1b5941d8b6e3d94d42a5bec6607a26f562e6535d5c981089d23d3d224b94601c", size = 67061619, upload-time = "2026-05-06T20:02:08.636Z" },
{ url = "https://files.pythonhosted.org/packages/57/45/76567821b2d36f81e6bca78c98d265e2762733f765fa51d69602b7f81867/github_copilot_sdk-1.0.0b2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b8f6a087a0cf02bb0d33976e8f8c009578d84d701a0b28d52051304791ac70", size = 63790955, upload-time = "2026-05-06T20:02:12.354Z" },
{ url = "https://files.pythonhosted.org/packages/15/67/684b0da0b1207a2bdf025c22ee075d34a1736d61a4973651035d4fd4d8dc/github_copilot_sdk-1.0.0b2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f403638c11b82bddb81c94675fc4e8014a1bb2e86a679a39fa167dcc3ad5416a", size = 69538664, upload-time = "2026-05-06T20:02:16.363Z" },
{ url = "https://files.pythonhosted.org/packages/57/1d/80d88ecf83683535d1a16d4817f1683db3b125f52a924ebdfe9764f5e4c3/github_copilot_sdk-1.0.0b2-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:433d16bb31171fee8d3a5b70259c527f63b297e83a8f8761ae1f16f14d641f32", size = 68163648, upload-time = "2026-05-06T20:02:21.139Z" },
{ url = "https://files.pythonhosted.org/packages/32/d3/b72aa2fbb3194b50b53e8cb1484f5606a1f8eedcdb0bfb5747da52079553/github_copilot_sdk-1.0.0b2-py3-none-win_amd64.whl", hash = "sha256:a6e9782dae4c3c2ab3527b45bb5de0f61998104c10e9ff64698280eaf37ab5dd", size = 62649144, upload-time = "2026-05-06T20:02:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/b6/e2/be95b8ea0ac11d1ca474e28a59284f4e395c2710734eadfb657f5de8ace2/github_copilot_sdk-1.0.0b2-py3-none-win_arm64.whl", hash = "sha256:2e97d0ce4bad67dc5929091cb429e7bbae7d4643e4908a6af256a41439000740", size = 60374365, upload-time = "2026-05-06T20:02:29.02Z" },
{ url = "https://files.pythonhosted.org/packages/7a/d2/e74fdf476d0dde5c3802b3ba360f1b1e250e55d6d39c03f578c28ac9864e/github_copilot_sdk-1.0.0-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:3cae245fb825e26a74395b74f10d9fd90bc464aa77005848ae0809c9a46c96df", size = 94986104, upload-time = "2026-06-02T14:59:55.022Z" },
{ url = "https://files.pythonhosted.org/packages/b6/81/e4d9dd01b0a563e488427aa879166287c88de3fccf7b8a95e22a6c652fc3/github_copilot_sdk-1.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b344a00a877c86ef717244e42bd01acb3694b7377644661c82fc278ccc990e37", size = 91435649, upload-time = "2026-06-02T15:00:02.567Z" },
{ url = "https://files.pythonhosted.org/packages/bd/ec/e94b8f5a299850e600ffe1fe14bd21b48e01172b9e8b490a0ebd0d0c8d27/github_copilot_sdk-1.0.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:dd3a6b7637a3b12476854aeb599c6bed030f6a166fbd942d872c9a11a695517c", size = 97301959, upload-time = "2026-06-02T15:00:11.019Z" },
{ url = "https://files.pythonhosted.org/packages/4b/bf/dfba743a11d9745b0664ec5e1ae6e05055a5cbef0ccc6d593222319184eb/github_copilot_sdk-1.0.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:bbd2c64fe37016c74620a02d778eaacbd526b4c3b668a3cdff019f831c752eee", size = 96071193, upload-time = "2026-06-02T15:00:22.634Z" },
{ url = "https://files.pythonhosted.org/packages/0e/9b/d953dcbb898f4d44efc0cb592e9a703ad43a4b673aafb5bbd763962ab2fd/github_copilot_sdk-1.0.0-py3-none-win_amd64.whl", hash = "sha256:2d46fff634eece978532b1329c0d9e1d784b08ad521e71e6af06c5c28ae2e7c5", size = 90374124, upload-time = "2026-06-02T15:00:31.376Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f7/0f9943b1439e3dcc52854140676b65d8f63405c471a77c58291a8f4bfb52/github_copilot_sdk-1.0.0-py3-none-win_arm64.whl", hash = "sha256:ebfb80395caa834df8ab16ab4aab3e5d8db883ed3b024f723c394b1514e47221", size = 87874846, upload-time = "2026-06-02T15:00:38.737Z" },
]
[[package]]