Compare commits

..
Author SHA1 Message Date
Tao Chen 5769c0e8ab Fix tests 2026-04-23 15:04:58 -07:00
Tao Chen bf4c0be52b comments 2026-04-23 14:56:37 -07:00
Tao Chen a3362e2896 Add more tests 2026-04-23 14:51:23 -07:00
Tao Chen 28a86d6d73 Fix other providers 2026-04-23 14:31:26 -07:00
Tao Chen 03f7dc86d3 Fix hosting user agent missing 2026-04-23 13:49:27 -07:00
69adf6d97e .NET: Fix off-thread RunStatus race where GetStatusAsync can return Running after ResumeAsync halts (#5412)
* Fix off-thread RunStatus race where GetStatusAsync can return Running after ResumeAsync halts

* Apply suggestion from @Copilot

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

* Simplify test comment.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 15:27:28 +00:00
westeyandGitHub 6851a9cdc8 .NET: Add dynamic tool expansion sample (#5425)
* Add dynamic tool expansion sample

* Address PR comments

* Remove tool names from tool call response to avoid confusing LLM
2026-04-23 15:10:57 +00:00
westeyandGitHub dfca81ff21 .NET: Update Aspire package to be preview (#5444)
* Update Aspire package to be preview

* Also update readme file

* Include README.md in pack
2026-04-23 15:10:49 +00:00
Evan MattsonandGitHub fbbc2ebe86 Propagate integration-test model credentials to issue-triage repro (#5443)
Scopes the triage job to the integration GitHub Environment, adds
the azure/login OIDC step, and exposes the same OpenAI / Azure
OpenAI / Foundry / Anthropic env vars the integration test
workflow uses. This lets the triage agent write repro code that
constructs model clients from the environment without any secrets
entering the agent prompt or generated-code literals.

Azure OpenAI and Foundry continue to authenticate via AAD
(DefaultAzureCredential), so there is no API key to leak for
those providers.
2026-04-23 21:01:24 +09:00
c9e6033048 Automated issue triage workflow (#5419)
* Automated issue triage workflow

* Bump dependencies

* Fix issue-triage workflow: security, reliability, and testability

Address six review comments on the issue-triage workflow:

1. Change trigger from issues:opened to issues:labeled so the
   secret-backed triage flow is only triggered by a maintainer-
   controlled signal.

2. Include inputs.issue_number in the concurrency group so
   workflow_dispatch runs for the same issue are properly
   de-duplicated.

3. Improve team membership error handling to fail closed: verify
   the team exists before checking membership, and only treat a
   404 as 'not a member' (all other errors fail the job).

4. Use optional chaining (issue.user?.login) for the API-fetched
   issue to handle deleted GitHub accounts without crashing.

5. Extract the inline github-script into a testable module at
   .github/scripts/check_team_membership.js with 10 tests in
   .github/tests/test_check_team_membership.js covering all
   code paths (payload/API author resolution, deleted accounts,
   team lookup failure, 404 vs non-404 membership errors).

6. Make the spam gate actually stop the job by exiting non-zero
   instead of just logging, so future steps cannot accidentally
   run for spam issues.

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

* Make issue-triage workflow manually triggered only for initial testing

Remove the 'issues' event trigger, keeping only 'workflow_dispatch' so the
workflow can be tested manually before enabling automatic triggers.

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 20:22:04 +09:00
9ca55dcc0c Python: (chore): update changelog (#5438)
* update changelog

* Update python/CHANGELOG.md

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

* Update python/CHANGELOG.md

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

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-23 17:50:29 +09:00
58ff4ad3a9 Python: Hyperlight: thread-confine sandbox, skip parsing on host callbacks, schema/tool cleanup (#5424)
* improved parsing of tool call results and tweaks

* Address PR review: skip_parsing flag, broader registry close, comment fix

- FunctionTool.invoke now takes a boolean skip_parsing flag instead of the
  SKIP_PARSING sentinel; the sentinel is still accepted as result_parser at
  construction time to opt out of parsing for every call. The two paths are
  equivalent.
- _SandboxRegistry.close now invokes any sandbox close/shutdown hook on the
  entry's own worker thread (PyO3 unsendable), then shuts the worker down,
  then cleans up the per-entry temporary directories.
- Clarified the _SandboxWorker.shutdown comment to describe the actual
  ThreadPoolExecutor.shutdown(wait=False, cancel_futures=False) semantics.
- Hyperlight host callback uses skip_parsing=True (the new flag).

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

* Drop redundant 'is not SKIP_PARSING' guard that mypy 1.x flags

After callable(configured_parser) the sentinel is already excluded; the extra
identity check tripped mypy's non-overlapping identity warning.

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

* fixed sandbox working on copy of tool

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 08:14:51 +00:00
66e02c10e3 .NET: [Breaking] Migrate A2A agent and hosting to A2A SDK v1 (#5423)
* update a2a agent to the latest a2a sdk (#5257)

* Move A2A samples from 04-hosting to 02-agents (#5267)

Move the A2A sample projects (A2AAgent_AsFunctionTools and
A2AAgent_PollingForTaskCompletion) from samples/04-hosting/A2A/ to
samples/02-agents/A2A/ to better align with the sample directory
structure. Update solution file and samples README accordingly.

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

* .NET: Fix stream reconnection for A2AAgent (#5275)

* Add SSE stream reconnection support to A2AAgent

Implement automatic reconnection for SSE streams that disconnect mid-task,
using the Last-Event-ID header to resume from where the stream left off.

Changes:
- Add InvokeStreamingWithReconnectAsync method to A2AAgent with configurable
  max retries and delay between attempts
- Add new log messages for reconnection events
- Add A2AAgent_StreamReconnection sample demonstrating the feature
- Update existing polling sample to use simplified SendMessageAsync API
- Add unit tests for stream reconnection logic

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

* address comments

* Address PR review feedback

- Dispose SSE enumerator before GetTaskAsync fallback to release HTTP connection
- Wrap StreamWriter in using blocks with leaveOpen:true and explicit UTF-8 encoding
- Print update.Text instead of update object in stream reconnection sample

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

---------

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

* .NET: Use IA2AClientFactory to create A2AClient (#5277)

* Refactor A2A extensions to use IA2AClientFactory and add ProtocolSelection sample

- Update A2AAgentCardExtensions to accept IA2AClientFactory instead of A2AClientOptions
- Update A2ACardResolverExtensions to accept IA2AClientFactory
- Update A2AClientExtensions to accept IA2AClientFactory
- Update A2AAgent to use IA2AClientFactory for client creation
- Add A2AAgent_ProtocolSelection sample demonstrating protocol selection
- Add comprehensive unit tests for all changes
- Update README files with new sample reference

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

* Reorder params: options before loggerFactory in A2A extensions

Move A2AClientOptions parameter before ILoggerFactory in AsAIAgent
and GetAIAgentAsync extension methods to follow the repo convention
of keeping LoggerFactory and CancellationToken as the last parameters.

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

---------

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

* .NET: Migrate A2A hosting to A2A SDK v1 (#5363)

* .NET: Migrate A2A hosting to A2A SDK v1

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

* remove unused agent card

---------

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

* .NET: Split A2A endpoint mapping into protocol-specific methods (#5413)

* .NET: Refactor A2A hosting registration into A2AServerServiceCollectionExtensions

- Rename A2AHostingOptions to A2AServerRegistrationOptions
- Move server registration logic from A2AEndpointRouteBuilderExtensions
  and AIAgentExtensions into new A2AServerServiceCollectionExtensions
- Remove A2AProtocolBinding and AIAgentExtensions (consolidated)
- Update samples and tests to use the new registration API

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

* address copilot comments

---------

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

* Remove unnecessary using directive in AgentWebChat.AgentHost

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

* restore AsyncEnumerable package version

* address copilot initial feedback

* address automated code review and formatting issues

* fix formatting issues

* Add DI wiring verification tests for AddA2AServer

Add three tests to A2AServerServiceCollectionExtensionsTests that verify
custom keyed services are actually wired through to the A2AServer, not
just that the server resolves non-null:

- Custom IAgentHandler: verifies the keyed handler is invoked when
  processing a SendMessageRequest instead of the default A2AAgentHandler.
- Custom AgentSessionStore (no handler): verifies the keyed session
  store's GetSessionAsync is called during request processing when no
  custom handler is registered.
- Default stores end-to-end: verifies the InMemoryAgentSessionStore and
  InMemoryTaskStore defaults successfully process a request. Uses a new
  CreateAgentMockForRequests helper that includes SerializeSessionCoreAsync
  setup needed by InMemoryAgentSessionStore.

All tests call A2AServer.SendMessageAsync directly (no HTTP layer needed)
and use CancellationToken timeouts to guard against hangs.

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-23 07:53:00 +00:00
Evan MattsonandGitHub acec9caa2f Python: Bump Python package versions for a release. (#5432)
* Bump Python version for a release.

* Revert lockstep bumps on unchanged connectors

Per PR review: only connectors that changed (or whose published metadata
changed) should get new versions. Keeps released tier at 1.1.1, a2a/ag-ui
at 1.0.0b260422, foundry-hosting at 1.0.0a260422; reverts the 19 unchanged
betas and 2 unchanged alphas to 1.0.0b260421/1.0.0a260421. Reverts all 26
non-core agent-framework-core floors to >=1.1.0,<2 since no connector
actually depends on a 1.1.1 API or bug fix.

* Restore lockstep prerelease bumps and raise core floors to >=1.1.1

Reverses the lean-revert: all beta packages stamped 1.0.0b260423 and alpha
packages stamped 1.0.0a260423 (Asia date, matching release cut time). All
26 non-core packages raise agent-framework-core lower bound from >=1.1.0,<2
to >=1.1.1,<2 to signal the validated cohort for this release. CHANGELOG
date updated to 2026-04-23.
2026-04-23 16:40:14 +09:00
Evan MattsonandGitHub 5d4873888f Don't fail if review issue occurs (#5434) 2026-04-23 13:24:21 +09:00
Evan MattsonandGitHub e2f161c8a0 Pin to specific release (#5430) 2026-04-23 08:23:56 +09:00
3f23e1dfbf Python: Flaky test report (#5342)
* Add flaky test trend reporting to CI workflows

Parse JUnit XML (pytest.xml) from each integration test job and
aggregate results into a markdown trend report showing per-test
pass/fail/skip status across the last 5 runs.

Changes:
- Add python/scripts/flaky_report/ package (JUnit XML parser + trend
  report generator following the sample_validation pattern)
- Add upload-artifact steps to all 6 integration test jobs in both
  python-merge-tests.yml and python-integration-tests.yml
- Add python-flaky-test-report aggregation job with history caching
- Add --junitxml=pytest.xml to integration-tests.yml jobs (already
  present in merge-tests.yml)
- Fix Cosmos job --junitxml path (use absolute path since uv run
  --directory changes cwd)

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

* Fix flaky report: handle missing test results gracefully

- Guard against missing reports directory in load_current_run()
- Only run report job when at least one integration test job completed
  (skip when all jobs are skipped, e.g. on pull_request events)

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

* Address PR review: fix provider names and if-expression precedence

- Use explicit provider name mapping in _derive_provider() so OpenAI
  renders correctly instead of 'Openai'
- Fix operator precedence in workflow if-expressions by wrapping
  success/failure checks in parentheses

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

* Add File column and xfail detection to flaky test report

- Add File column showing module name (e.g., test_openai_chat_client)
  to disambiguate tests with the same function name across files
- Detect pytest xfail tests in JUnit XML (type=pytest.xfail) and
  show them with a distinct warning emoji instead of skip emoji
- Update legend to include xfail explanation

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

* Add Foundry embedding env vars to merge-tests workflow

Sync the Foundry integration job in python-merge-tests.yml with
python-integration-tests.yml by adding FOUNDRY_MODELS_ENDPOINT,
FOUNDRY_MODELS_API_KEY, FOUNDRY_EMBEDDING_MODEL, and
FOUNDRY_IMAGE_EMBEDDING_MODEL. Once the repo variables/secrets
are configured, the embedding integration test will run in CI.

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

* Fix File column showing class name instead of module name

When a test is inside a class, pytest writes the classname as e.g.
'pkg.test_file.TestClass'. The previous rsplit logic extracted
'TestClass' instead of 'test_file'. Now detect uppercase-starting
segments as class names and use the preceding segment instead.

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

* Address PR review: UTC timestamps, XML error handling, summary fix, docstring

- Use datetime.now(timezone.utc) for accurate UTC timestamps
- Catch ET.ParseError per-file so corrupt XML doesn't crash the report
- Remove separate 'error' key from summary (errors folded into 'failed')
- Fix _short_name docstring to show actual dotted classname::name format

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

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 20:16:50 +00:00
Evan MattsonandGitHub d75f874d78 Adjust dev status for alpha from 4 to 3 in foundry hosting pyproject.toml (#5387) 2026-04-22 17:48:02 +00:00
0b50455e75 Python: Pass client thread_id as session_id when constructing AgentSession in AG-UI (#5384)
* Pass thread_id as session_id when constructing AgentSession in AG-UI

run_agent_stream() was constructing AgentSession without passing the
client's thread_id as session_id, causing every request to receive a
random UUID. This broke session continuity for HistoryProvider
implementations that rely on session_id matching the client's thread_id.

Pass session_id=thread_id in both the service-session and non-service
code paths so the session identity is consistent with the AG-UI client.

Fixes #5357

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

* Add test for service_session with no thread_id edge case (#5357)

When use_service_session=True but no thread_id/threadId is in the payload,
verify session_id is a generated UUID and service_session_id is None.

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

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 17:45:25 +00:00
3ae86f098e Python: Propagate thread_id and forwarded_props through AG-UI to A2A context_id (#5383)
* Propagate session.service_session_id as A2A context_id

When A2AAgent is used behind the AG-UI protocol, the client thread_id is
stored in session.service_session_id but was never forwarded as the A2A
context_id. This broke session continuity across the AG-UI → A2A boundary.

Add an optional context_id keyword argument to _prepare_message_for_a2a()
and pass session.service_session_id from run(). The explicit
message.additional_properties["context_id"] still takes precedence.

Fixes #5345

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

* Add integration tests for session context_id wiring in run() (#5345)

- Enhance MockA2AClient.send_message to capture last_message for assertions
- Add test_run_passes_session_service_session_id_as_context_id: verifies
  run() passes session.service_session_id through to A2A message context_id
- Add test_run_message_context_id_takes_precedence_over_session: verifies
  explicit message context_id wins over session fallback
- Update _prepare_message_for_a2a docstring to document context_id param
  and its precedence rules

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

* Address review feedback for #5345: Python: [Bug]: Inconvenient passing of context_id / thread_id in A2A/AG-UI implementations

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-22 17:44:41 +00:00
Evan MattsonandGitHub fffd0acb3e Python: fix(foundry): reconcile toolbox hosted-tool payloads with Responses API (#5414)
* fix(foundry): reconcile toolbox hosted-tool payloads with Responses API

* docs(foundry): update create_sample_toolbox docstring to reflect all tools created
2026-04-22 17:43:26 +00:00
82 changed files with 2820 additions and 340 deletions
+61
View File
@@ -0,0 +1,61 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Resolve the issue author and check their team membership.
*
* @param {object} opts
* @param {object} opts.github - Octokit REST client from actions/github-script
* @param {object} opts.context - GitHub Actions context
* @param {object} opts.core - GitHub Actions core toolkit
* @param {string} opts.teamSlug - Team slug to check membership against
* @param {string|number} opts.issueNumber - Issue number to resolve author for
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
*/
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
let author = context.payload.issue?.user?.login;
if (!author) {
const { data: issue } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: Number(issueNumber),
});
author = issue.user?.login;
}
if (!author) {
core.setFailed('Could not determine issue author (user may be deleted).');
return { author: null, isTeamMember: false };
}
try {
await github.rest.teams.getByName({
org: context.repo.owner,
team_slug: teamSlug,
});
} catch (error) {
core.setFailed(`Team lookup failed for ${teamSlug}: ${error.message}`);
throw error;
}
let isTeamMember = false;
try {
const teamMembership = await github.rest.teams.getMembershipForUserInOrg({
org: context.repo.owner,
team_slug: teamSlug,
username: author,
});
isTeamMember = teamMembership.data.state === 'active';
} catch (error) {
if (error.status === 404) {
core.info(`Author ${author} is not a member of team ${teamSlug}.`);
isTeamMember = false;
} else {
core.setFailed(`Team membership lookup failed for ${author}: ${error.message}`);
throw error;
}
}
return { author, isTeamMember };
}
module.exports = checkTeamMembership;
+178
View File
@@ -0,0 +1,178 @@
// Copyright (c) Microsoft. All rights reserved.
/**
* Tests for check_team_membership.js.
*
* Run with: node --test .github/tests/test_check_team_membership.js
*/
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const checkTeamMembership = require('../scripts/check_team_membership.js');
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function createMocks({ payloadIssue = undefined, apiUser = 'api-user', teamState = 'active' } = {}) {
const core = {
_infoMessages: [],
_failedMessages: [],
info(msg) { this._infoMessages.push(msg); },
setFailed(msg) { this._failedMessages.push(msg); },
};
const context = {
payload: { issue: payloadIssue },
repo: { owner: 'test-org', repo: 'test-repo' },
};
const github = {
rest: {
issues: {
get: async () => ({
data: { user: apiUser ? { login: apiUser } : null },
}),
},
teams: {
getByName: async () => ({}),
getMembershipForUserInOrg: async () => ({
data: { state: teamState },
}),
},
},
};
return { core, context, github };
}
const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
// ---------------------------------------------------------------------------
// Author resolution
// ---------------------------------------------------------------------------
describe('author resolution', () => {
it('resolves author from event payload', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'payload-user' } },
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'payload-user');
});
it('resolves author via API when payload issue is absent', async () => {
const { github, context, core } = createMocks({ apiUser: 'api-user' });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'api-user');
});
it('resolves author via API when payload issue user is null (deleted account)', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: null },
apiUser: 'fetched-user',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, 'fetched-user');
});
it('handles deleted account when API also returns null user', async () => {
const { github, context, core } = createMocks({ apiUser: null });
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.author, null);
assert.equal(result.isTeamMember, false);
assert.ok(core._failedMessages.some(m => m.includes('deleted')));
});
});
// ---------------------------------------------------------------------------
// Team lookup
// ---------------------------------------------------------------------------
describe('team lookup', () => {
it('fails the job when team lookup errors', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const error = new Error('Bad credentials');
github.rest.teams.getByName = async () => { throw error; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === error,
);
assert.ok(core._failedMessages.some(m => m.includes('Team lookup failed')));
});
});
// ---------------------------------------------------------------------------
// Team membership
// ---------------------------------------------------------------------------
describe('team membership', () => {
it('returns true for active team member', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'member' } },
teamState: 'active',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, true);
});
it('returns false for pending team member', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'pending-user' } },
teamState: 'pending',
});
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, false);
});
it('treats 404 membership response as non-member without failing', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'outsider' } },
});
const notFoundError = new Error('Not Found');
notFoundError.status = 404;
github.rest.teams.getMembershipForUserInOrg = async () => { throw notFoundError; };
const result = await checkTeamMembership({ github, context, core, ...BASE_OPTS });
assert.equal(result.isTeamMember, false);
assert.equal(core._failedMessages.length, 0);
assert.ok(core._infoMessages.some(m => m.includes('not a member')));
});
it('fails the job on non-404 membership errors', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const serverError = new Error('Internal Server Error');
serverError.status = 500;
github.rest.teams.getMembershipForUserInOrg = async () => { throw serverError; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === serverError,
);
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
});
it('fails the job on membership errors without status code', async () => {
const { github, context, core } = createMocks({
payloadIssue: { user: { login: 'user1' } },
});
const networkError = new Error('ECONNREFUSED');
github.rest.teams.getMembershipForUserInOrg = async () => { throw networkError; };
await assert.rejects(
() => checkTeamMembership({ github, context, core, ...BASE_OPTS }),
(err) => err === networkError,
);
assert.ok(core._failedMessages.some(m => m.includes('membership lookup failed')));
});
});
+4
View File
@@ -108,6 +108,10 @@ jobs:
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
timeout-minutes: 60
# Advisory check: failures here should not block the PR. The reviewer
# posts comments as a best-effort signal; if the pipeline breaks, the
# PR author should still be able to merge without a red required check.
continue-on-error: true
steps:
# Safe checkout: base repo only, not the untrusted PR head.
+199
View File
@@ -0,0 +1,199 @@
name: Issue Triage
on:
workflow_dispatch:
inputs:
issue_number:
description: Issue number to triage
required: true
type: string
permissions:
contents: read
issues: write
id-token: write
concurrency:
group: issue-triage-${{ github.repository }}-${{ github.event.issue.number || inputs.issue_number || github.run_id }}
cancel-in-progress: true
env:
DEVFLOW_REPOSITORY: ${{ vars.DF_REPO }}
DEVFLOW_REF: main
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
DEVFLOW_PATH: ${{ github.workspace }}/devflow
jobs:
team_check:
runs-on: ubuntu-latest
outputs:
is_team_member: ${{ steps.check.outputs.is_team_member }}
issue_number: ${{ steps.issue.outputs.issue_number }}
repo: ${{ steps.issue.outputs.repo }}
steps:
- name: Resolve issue metadata
id: issue
shell: bash
env:
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
run: |
set -euo pipefail
if [[ "${GITHUB_EVENT_NAME}" == "issues" ]]; then
issue_number="${ISSUE_NUMBER_EVENT}"
else
issue_number="${ISSUE_NUMBER_INPUT}"
fi
if [[ ! "$issue_number" =~ ^[1-9][0-9]*$ ]]; then
echo "Could not determine issue number; for workflow_dispatch runs, the 'issue_number' input is required." >&2
exit 1
fi
echo "issue_number=${issue_number}" >> "$GITHUB_OUTPUT"
echo "repo=${GITHUB_REPOSITORY}" >> "$GITHUB_OUTPUT"
- name: Checkout scripts
uses: actions/checkout@v6
with:
sparse-checkout: .github/scripts
fetch-depth: 1
persist-credentials: false
- name: Check issue author team membership
id: check
uses: actions/github-script@v8
env:
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
ISSUE_NUMBER: ${{ steps.issue.outputs.issue_number }}
with:
github-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
script: |
const checkTeamMembership = require('./.github/scripts/check_team_membership.js');
const { author, isTeamMember } = await checkTeamMembership({
github,
context,
core,
teamSlug: process.env.TEAM_NAME,
issueNumber: process.env.ISSUE_NUMBER,
});
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
if (isTeamMember) {
core.info(`Author ${author} is a team member; skipping auto-triage.`);
} else {
core.info(`Author ${author} is not a team member; proceeding with triage.`);
}
triage:
runs-on: ubuntu-latest
needs: team_check
if: ${{ needs.team_check.outputs.is_team_member == 'false' }}
environment: integration
timeout-minutes: 60
steps:
# Safe checkout: base repo only.
- name: Checkout target repo base
uses: actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: false
path: target-repo
# Private DevFlow (maf-dashboard) checkout.
- name: Checkout DevFlow
uses: actions/checkout@v6
with:
repository: ${{ env.DEVFLOW_REPOSITORY }}
ref: ${{ env.DEVFLOW_REF }}
token: ${{ secrets.DEVFLOW_TOKEN }}
fetch-depth: 1
persist-credentials: false
path: devflow
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
version: "0.11.x"
enable-cache: true
- name: Install DevFlow dependencies
working-directory: ${{ env.DEVFLOW_PATH }}
run: uv sync --frozen
- name: Azure CLI Login
uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Classify issue relevance
id: spam
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
run: |
uv run python scripts/classify_issue_spam.py \
--repo "$ISSUE_REPO" \
--issue-number "$ISSUE_NUMBER" \
--repo-path "${TARGET_REPO_PATH}" \
--apply-labels
- name: Stop after spam gate
if: ${{ steps.spam.outputs.decision != 'allow' }}
shell: bash
env:
SPAM_DECISION: ${{ steps.spam.outputs.decision }}
run: |
echo "Stopping: spam gate decided: ${SPAM_DECISION}"
exit 1
- name: Reproduce reported issue
if: ${{ steps.spam.outputs.decision == 'allow' }}
id: repro
working-directory: ${{ env.DEVFLOW_PATH }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
ISSUE_REPO: ${{ needs.team_check.outputs.repo }}
ISSUE_NUMBER: ${{ needs.team_check.outputs.issue_number }}
# Model-provider settings for generated repro code. Never enter the
# agent prompt; consumed by SDK constructors via os.environ. Azure
# OpenAI and Foundry auth via AAD from the azure/login step above.
OPENAI_API_KEY: ${{ secrets.OPENAI__APIKEY }}
OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.OPENAI__CHATMODELID }}
OPENAI_CHAT_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_MODEL: ${{ vars.OPENAI__RESPONSESMODELID }}
OPENAI_EMBEDDING_MODEL: ${{ vars.OPENAI_EMBEDDING_MODEL_ID }}
AZURE_OPENAI_ENDPOINT: ${{ vars.AZUREOPENAI__ENDPOINT }}
AZURE_OPENAI_CHAT_COMPLETION_MODEL: ${{ vars.AZUREOPENAI__CHATDEPLOYMENTNAME }}
AZURE_OPENAI_CHAT_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_MODEL: ${{ vars.AZUREOPENAI__RESPONSESDEPLOYMENTNAME }}
AZURE_OPENAI_EMBEDDING_MODEL: ${{ vars.AZURE_OPENAI_EMBEDDING_DEPLOYMENT_NAME }}
FOUNDRY_PROJECT_ENDPOINT: ${{ vars.FOUNDRY_PROJECT_ENDPOINT }}
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
ANTHROPIC_CHAT_MODEL: ${{ vars.ANTHROPIC_CHAT_MODEL_ID }}
run: |
uv run python scripts/trigger_issue_repro.py \
--repo "$ISSUE_REPO" \
--issue-number "$ISSUE_NUMBER" \
--github-username "$GITHUB_ACTOR"
+114 -1
View File
@@ -87,6 +87,14 @@ jobs:
-n logical --dist worksteal
--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@v7
with:
name: test-results-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure OpenAI integration tests
python-tests-azure-openai:
@@ -130,6 +138,14 @@ jobs:
-n logical --dist worksteal
--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@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Misc integration tests (Anthropic, Hyperlight, Ollama, MCP)
python-tests-misc-integration:
@@ -173,6 +189,14 @@ jobs:
-n logical --dist worksteal
--timeout=120 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 30
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
if-no-files-found: ignore
- name: Stop local MCP server
if: always()
shell: bash
@@ -249,6 +273,14 @@ jobs:
-x
--timeout=360 --session-timeout=900 --timeout_method thread
--retries 2 --retry-delay 5
--junitxml=pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
if-no-files-found: ignore
# Foundry integration tests
python-tests-foundry:
@@ -295,6 +327,14 @@ jobs:
-n logical --dist worksteal
--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@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure Cosmos integration tests
python-tests-cosmos:
@@ -339,7 +379,80 @@ jobs:
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
if-no-files-found: ignore
# Flaky test trend report (aggregates per-job JUnit XML results)
python-flaky-test-report:
name: Flaky Test Report
if: >
always() &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs:
[
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-foundry,
python-tests-cosmos,
]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
with:
ref: ${{ inputs.checkout-ref }}
persist-credentials: false
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
restore-keys: |
flaky-report-history-integration-
- name: Generate trend report
run: >
uv run python scripts/flaky_report/aggregate.py
../test-results/
flaky-report-history.json
flaky-test-report.md
- name: Post to Job Summary
if: always()
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-integration-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: flaky-test-report
path: |
python/flaky-test-report.md
python/flaky-report-history.json
python-integration-tests-check:
if: always()
+110 -1
View File
@@ -181,6 +181,13 @@ jobs:
display-options: fEX
fail-on-empty: false
title: OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure OpenAI integration tests
python-tests-azure-openai:
@@ -244,6 +251,13 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Azure OpenAI integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-azure-openai
path: ./python/pytest.xml
if-no-files-found: ignore
# Misc integration tests (Anthropic, Ollama, MCP)
python-tests-misc-integration:
@@ -321,6 +335,13 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Misc integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-misc
path: ./python/pytest.xml
if-no-files-found: ignore
# Azure Functions + Durable Task integration tests
python-tests-functions:
@@ -392,6 +413,13 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Functions integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-functions
path: ./python/pytest.xml
if-no-files-found: ignore
python-tests-foundry:
name: Python Integration Tests - Foundry
@@ -409,6 +437,10 @@ jobs:
FOUNDRY_MODEL: ${{ vars.FOUNDRY_MODEL }}
FOUNDRY_AGENT_NAME: ${{ vars.FOUNDRY_AGENT_NAME }}
FOUNDRY_AGENT_VERSION: ${{ vars.FOUNDRY_AGENT_VERSION }}
FOUNDRY_MODELS_ENDPOINT: ${{ vars.FOUNDRY_MODELS_ENDPOINT || '' }}
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY || '' }}
FOUNDRY_EMBEDDING_MODEL: ${{ vars.FOUNDRY_EMBEDDING_MODEL || '' }}
FOUNDRY_IMAGE_EMBEDDING_MODEL: ${{ vars.FOUNDRY_IMAGE_EMBEDDING_MODEL || '' }}
LOCAL_MCP_URL: ${{ vars.LOCAL_MCP__URL }}
defaults:
run:
@@ -448,6 +480,13 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-foundry
path: ./python/pytest.xml
if-no-files-found: ignore
# TODO: Add python-tests-lab
@@ -497,7 +536,7 @@ jobs:
echo "Cosmos DB emulator did not become ready in time." >&2
exit 1
- name: Test with pytest (Cosmos integration)
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=pytest.xml
run: uv run --directory packages/azure-cosmos poe integration-tests -n logical --dist worksteal --timeout=120 --session-timeout=900 --timeout_method thread --retries 2 --retry-delay 5 --junitxml=${{ github.workspace }}/python/pytest.xml
working-directory: ./python
- name: Surface failing tests
if: always()
@@ -508,6 +547,76 @@ jobs:
display-options: fEX
fail-on-empty: false
title: Cosmos integration test results
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: test-results-cosmos
path: ./python/pytest.xml
if-no-files-found: ignore
# Flaky test trend report (aggregates per-job JUnit XML results)
python-flaky-test-report:
name: Flaky Test Report
if: >
always() &&
(contains(join(needs.*.result, ','), 'success') ||
contains(join(needs.*.result, ','), 'failure'))
needs:
[
python-tests-openai,
python-tests-azure-openai,
python-tests-misc-integration,
python-tests-functions,
python-tests-foundry,
python-tests-cosmos,
]
runs-on: ubuntu-latest
defaults:
run:
working-directory: python
steps:
- uses: actions/checkout@v6
- name: Set up python and install the project
uses: ./.github/actions/python-setup
with:
python-version: ${{ env.UV_PYTHON }}
os: ${{ runner.os }}
- name: Download all test results from current run
uses: actions/download-artifact@v4
with:
pattern: test-results-*
path: test-results/
- name: Restore flaky report history cache
uses: actions/cache/restore@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
restore-keys: |
flaky-report-history-merge-
- name: Generate trend report
run: >
uv run python scripts/flaky_report/aggregate.py
../test-results/
flaky-report-history.json
flaky-test-report.md
- name: Post to Job Summary
if: always()
run: cat flaky-test-report.md >> $GITHUB_STEP_SUMMARY
- name: Save flaky report history cache
if: always()
uses: actions/cache/save@v4
with:
path: python/flaky-report-history.json
key: flaky-report-history-merge-${{ github.run_id }}
- name: Upload unified trend report
if: always()
uses: actions/upload-artifact@v7
with:
name: flaky-test-report
path: |
python/flaky-test-report.md
python/flaky-report-history.json
python-integration-tests-check:
if: always()
+2 -3
View File
@@ -4,9 +4,6 @@
<BuildType Name="Publish" />
<BuildType Name="Release" />
</Configurations>
<Folder Name="/src/Aspire.Hosting.AgentFramework.DevUI/">
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
</Folder>
<Folder Name="/Samples/">
<File Path="samples/AGENTS.md" />
<File Path="samples/README.md" />
@@ -67,6 +64,7 @@
<Project Path="samples/02-agents/Agents/Agent_Step17_AdditionalAIContext/Agent_Step17_AdditionalAIContext.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step18_CompactionPipeline/Agent_Step18_CompactionPipeline.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step19_InFunctionLoopCheckpointing/Agent_Step19_InFunctionLoopCheckpointing.csproj" />
<Project Path="samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools/Agent_Step20_DynamicFunctionTools.csproj" />
</Folder>
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
@@ -533,6 +531,7 @@
<File Path="tests/Directory.Build.props" />
</Folder>
<Folder Name="/src/">
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFrameworks>net10.0</TargetFrameworks>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.AI.OpenAI" />
<PackageReference Include="Azure.Identity" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,281 @@
// Copyright (c) Microsoft. All rights reserved.
// This sample demonstrates how to dynamically expand the set of function tools available to an
// agent during a function-calling loop. The agent starts with a single "RequestTools" function.
// When the model calls RequestTools with a description of the capabilities needed, the function
// uses the ambient FunctionInvocationContext to add new tools to ChatOptions.Tools. The agent
// can then use the newly added tools in subsequent iterations of the same function-calling loop.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT") ?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
var deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
// Pre-defined tool implementations that can be loaded on demand.
[Description("Get the current weather for a city.")]
static string GetWeather([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 55°F, cloudy with light rain.",
"NEW YORK" => "New York: 72°F, sunny and warm.",
"LONDON" => "London: 48°F, overcast with fog.",
_ => $"{city}: weather data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
};
[Description("Get the current local time for a city.")]
static string GetTime([Description("The city name.")] string city) =>
city.ToUpperInvariant() switch
{
"SEATTLE" => "Seattle: 9:00 AM PST",
"NEW YORK" => "New York: 12:00 PM EST",
"LONDON" => "London: 5:00 PM GMT",
_ => $"{city}: time data not available, please provide one of the following city names: 'Seattle', 'New York', 'London'."
};
[Description("Convert a temperature from Fahrenheit to Celsius.")]
static string ConvertFahrenheitToCelsius([Description("The temperature in Fahrenheit.")] double fahrenheit) =>
$"{fahrenheit}°F = {(fahrenheit - 32) * 5 / 9:F1}°C";
// A registry of tool sets that can be loaded by description keyword.
Dictionary<string, List<AITool>> toolCatalog = new(StringComparer.OrdinalIgnoreCase)
{
["weather"] = [AIFunctionFactory.Create(GetWeather, name: "GetWeather")],
["time"] = [AIFunctionFactory.Create(GetTime, name: "GetTime")],
["temperature"] = [AIFunctionFactory.Create(ConvertFahrenheitToCelsius, name: "ConvertFahrenheitToCelsius")],
};
// The RequestTools function uses the ambient FunctionInvocationContext to add tools dynamically.
AIFunction requestToolsFunction = AIFunctionFactory.Create(
[Description("Request additional tools to be loaded based on a description of the functionality needed. " +
"Call this when you need capabilities that are not yet available in your current tool set.")] (
[Description("A description of the functionality required, e.g. 'weather', 'time', or 'temperature conversion'.")] string description
) =>
{
// Access the ambient FunctionInvocationContext provided by FunctionInvokingChatClient.
var context = FunctionInvokingChatClient.CurrentContext
?? throw new InvalidOperationException("No ambient FunctionInvocationContext available.");
var tools = context.Options?.Tools;
if (tools is null)
{
return "Unable to register new tools: ChatOptions.Tools is not available.";
}
// Find matching tool sets from the catalog.
List<string> addedToolNames = [];
foreach (var kvp in toolCatalog)
{
var keyword = kvp.Key;
var catalogTools = kvp.Value;
if (description.Contains(keyword, StringComparison.OrdinalIgnoreCase))
{
foreach (var tool in catalogTools)
{
// Avoid adding duplicates.
if (tool is AIFunction fn && !tools.Any(t => t is AIFunction existing && existing.Name == fn.Name))
{
tools.Add(tool);
addedToolNames.Add(fn.Name);
}
}
}
}
return addedToolNames.Count > 0
? "Successfully loaded tools"
: $"No tools matched the description '{description}'. Available categories: {string.Join(", ", toolCatalog.Keys)}.";
},
name: "RequestTools");
// Create the agent with only the RequestTools function initially.
// Insert chat client middleware that logs the tools available on each LLM call,
// making the dynamic expansion visible in the console output.
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
AIAgent agent = new AzureOpenAIClient(
new Uri(endpoint),
new DefaultAzureCredential())
.GetChatClient(deploymentName)
.AsIChatClient()
.AsBuilder()
.Use(getResponseFunc: ToolLoggingMiddleware, getStreamingResponseFunc: ToolLoggingStreamingMiddleware)
.BuildAIAgent(
instructions: """
You are a helpful assistant. You start with limited tools.
When you need functionality that you don't currently have, call RequestTools with a description
of what you need. After new tools are loaded, use them to answer the user's question.
""",
tools: [requestToolsFunction]);
// Run a conversation that triggers dynamic tool expansion.
Console.WriteLine("=== Dynamic Function Tools Sample ===\n");
string[] prompts =
[
"What's the weather like in Seattle and London?",
"What time is it in New York?",
"Can you convert those temperatures to Celsius?"
];
// --- Non-Streaming Mode ---
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("=== Non-Streaming Mode ===");
Console.ResetColor();
Console.WriteLine();
AgentSession session = await agent.CreateSessionAsync();
foreach (var prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
var response = await agent.RunAsync(prompt, session);
// Print all message contents including tool calls, tool results, and text.
foreach (var message in response.Messages)
{
foreach (var content in message.Contents)
{
switch (content)
{
case FunctionCallContent functionCall:
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
Console.ResetColor();
break;
case FunctionResultContent functionResult:
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
Console.ResetColor();
break;
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("[Agent] ");
Console.ResetColor();
Console.WriteLine(textContent.Text);
break;
}
}
}
Console.WriteLine();
}
// --- Streaming Mode ---
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine("=== Streaming Mode ===");
Console.ResetColor();
Console.WriteLine();
AgentSession streamingSession = await agent.CreateSessionAsync();
foreach (var prompt in prompts)
{
Console.ForegroundColor = ConsoleColor.Green;
Console.Write("[User] ");
Console.ResetColor();
Console.WriteLine(prompt);
bool inAgentText = false;
await foreach (var update in agent.RunStreamingAsync(prompt, streamingSession))
{
foreach (var content in update.Contents)
{
switch (content)
{
case FunctionCallContent functionCall:
if (inAgentText)
{
Console.WriteLine();
inAgentText = false;
}
Console.ForegroundColor = ConsoleColor.Yellow;
Console.WriteLine($" [Tool Call] {functionCall.Name}({string.Join(", ", functionCall.Arguments?.Select(a => $"{a.Key}: {a.Value}") ?? [])})");
Console.ResetColor();
break;
case FunctionResultContent functionResult:
Console.ForegroundColor = ConsoleColor.DarkYellow;
Console.WriteLine($" [Tool Result] {functionResult.CallId} => {functionResult.Result}");
Console.ResetColor();
break;
case TextContent textContent when !string.IsNullOrWhiteSpace(textContent.Text):
if (!inAgentText)
{
Console.ForegroundColor = ConsoleColor.Cyan;
Console.Write("[Agent] ");
Console.ResetColor();
inAgentText = true;
}
Console.Write(textContent.Text);
break;
}
}
}
if (inAgentText)
{
Console.WriteLine();
}
Console.WriteLine();
}
// Chat client middleware that logs the number and names of tools on each LLM request.
async Task<ChatResponse> ToolLoggingMiddleware(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
IChatClient innerChatClient,
CancellationToken cancellationToken)
{
LogTools(options);
return await innerChatClient.GetResponseAsync(messages, options, cancellationToken);
}
// Streaming version of the tool logging middleware.
async IAsyncEnumerable<ChatResponseUpdate> ToolLoggingStreamingMiddleware(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
IChatClient innerChatClient,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
{
LogTools(options);
await foreach (var update in innerChatClient.GetStreamingResponseAsync(messages, options, cancellationToken))
{
yield return update;
}
}
// Shared helper to log the current tool set.
void LogTools(ChatOptions? options)
{
if (options?.Tools is { Count: > 0 } tools)
{
var toolNames = tools.OfType<AIFunction>().Select(t => t.Name);
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($" [Middleware] LLM call with {tools.Count} tool(s): {string.Join(", ", toolNames)}");
Console.ResetColor();
}
else
{
Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine(" [Middleware] LLM call with 0 tools");
Console.ResetColor();
}
}
@@ -0,0 +1,38 @@
# Dynamic Function Tools
This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop.
## What it demonstrates
- The agent starts with only a single `RequestTools` function
- When the model needs capabilities it doesn't have, it calls `RequestTools` with a description of the functionality needed
- The `RequestTools` function uses the ambient `FunctionInvokingChatClient.CurrentContext` to access `ChatOptions.Tools` and add new tools at runtime
- The agent then uses the newly added tools in subsequent iterations of the same function-calling loop
## How it works
1. A tool catalog maps keywords (e.g. "weather", "time", "temperature") to pre-built `AIFunction` instances
2. The `RequestTools` function matches the description against catalog keywords and adds matching tools to `ChatOptions.Tools`
3. `FunctionInvokingChatClient` automatically picks up the new tools on the next iteration of its loop
## Prerequisites
- .NET 10 SDK or later
- Azure OpenAI service endpoint and deployment configured
- Azure CLI installed and authenticated (for Azure credential authentication)
- User has the `Cognitive Services OpenAI Contributor` role for the Azure OpenAI resource
## Running the sample
Set the required environment variables:
```powershell
$env:AZURE_OPENAI_ENDPOINT="https://your-resource.openai.azure.com/"
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
```
Run the sample:
```powershell
dotnet run
```
@@ -46,6 +46,7 @@ Before you begin, ensure you have the following prerequisites:
|[Providing additional AI Context to an agent using multiple AIContextProviders](./Agent_Step17_AdditionalAIContext/)|This sample demonstrates how to inject additional AI context into a ChatClientAgent using multiple custom AIContextProvider components that are attached to the agent.|
|[Using compaction pipeline with an agent](./Agent_Step18_CompactionPipeline/)|This sample demonstrates how to use a compaction pipeline to efficiently limit the size of the conversation history for an agent.|
|[In-function-loop checkpointing](./Agent_Step19_InFunctionLoopCheckpointing/)|This sample demonstrates how to persist chat history after each service call during a tool-calling loop, enabling crash recovery and mid-run observability.|
|[Dynamic function tools](./Agent_Step20_DynamicFunctionTools/)|This sample demonstrates how to dynamically expand the set of function tools available to an agent during a function-calling loop using the ambient FunctionInvocationContext.|
## Running the samples from the console
@@ -2,14 +2,22 @@
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<IsPackable>true</IsPackable>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<VersionSuffix>preview</VersionSuffix>
<!-- Suppress analyzer warnings for Aspire integration code -->
<!-- IL2026/IL3050: Suppress trimming/AOT warnings - DevUI is a dev-only tool not intended for AOT -->
<NoWarn>$(NoWarn);CA1873;RCS1061;VSTHRD002;IL2026;IL3050</NoWarn>
</PropertyGroup>
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
<PropertyGroup>
<!-- NuGet Package Settings -->
<Title>Microsoft Agent Framework DevUI for Aspire</Title>
<PackageTags>aspire integration hosting agent-framework devui ai agents</PackageTags>
<Description>Microsoft Agent Framework DevUI support for Aspire.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="Aspire.Hosting.AgentFramework.DevUI.UnitTests" />
</ItemGroup>
@@ -22,4 +30,7 @@
<PackageReference Include="Aspire.Hosting" />
</ItemGroup>
<ItemGroup>
<None Include="README.md" Pack="true" PackagePath="/" />
</ItemGroup>
</Project>
@@ -77,12 +77,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
try
{
// Wait for the first input before starting
// The consumer will call EnqueueMessageAsync which signals the run loop
// Wait for the first input before starting.
// The consumer will call EnqueueMessageAsync which signals the run loop.
// Note: AsyncRunHandle also signals here on checkpoint resume when there are
// already pending requests, so the first iteration can emit a PendingRequests
// halt signal even without unprocessed messages.
await this._inputWaiter.WaitForInputAsync(cancellationToken: linkedSource.Token).ConfigureAwait(false);
this._runStatus = RunStatus.Running;
while (!linkedSource.Token.IsCancellationRequested)
{
// Start a new run-stage activity for this input→processing→halt cycle
@@ -95,6 +96,13 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Events are streamed out in real-time as they happen via the event handler
if (this._stepRunner.HasUnprocessedMessages)
{
// Flip to Running only when there's actual work to process.
// This is intentionally inside the HasUnprocessedMessages branch so
// that stale input signals cannot transiently flip status back to
// Running after a prior halt has already been observed by callers
// (e.g. Run.ResumeAsync returning after reading an Idle halt signal).
this._runStatus = RunStatus.Running;
// Emit WorkflowStartedEvent only when there's actual work to process
// This avoids spurious events on timeout-only loop iterations
await this._eventChannel.Writer.WriteAsync(new WorkflowStartedEvent(), linkedSource.Token).ConfigureAwait(false);
@@ -129,9 +137,6 @@ internal sealed class StreamingRunEventStream : IRunEventStream
// Wait for next input from the consumer
// Works for both Idle (no work) and PendingRequests (waiting for responses)
await this._inputWaiter.WaitForInputAsync(linkedSource.Token).ConfigureAwait(false);
// When signaled, resume running
this._runStatus = RunStatus.Running;
}
}
catch (OperationCanceledException)
@@ -269,6 +269,29 @@ public class SampleSmokeTest
_ = await Step9EntryPoint.RunAsync(writer, environment.ToWorkflowExecutionEnvironment());
}
/// <summary>
/// Stress regression for the off-thread run-status race: after
/// <c>Run.ResumeAsync</c> returns at a halt boundary,
/// callers must observe a stable terminal status and never a transient
/// <see cref="RunStatus.Running"/>. Step9 is the canonical multi-response resume
/// sample; prior to the fix in <see cref="Execution.StreamingRunEventStream"/>,
/// its `runStatus.Should().Be(RunStatus.Idle)` assertion failed intermittently
/// on roughly 1-in-10 iterations under InProcess_OffThread.
/// </summary>
[Fact]
internal async Task Test_RunSample_Step9_OffThread_MultiResponseResume_StatusIsStableAsync()
{
const int Iterations = 50;
for (int i = 0; i < Iterations; i++)
{
using StringWriter writer = new();
_ = await Step9EntryPoint.RunAsync(
writer,
ExecutionEnvironment.InProcess_OffThread.ToWorkflowExecutionEnvironment());
}
}
[Theory]
[InlineData(ExecutionEnvironment.InProcess_Lockstep)]
[InlineData(ExecutionEnvironment.InProcess_OffThread)]
+22
View File
@@ -7,6 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [1.1.1] - 2026-04-23
### Added
- **agent-framework-core**: Add `expected_output` ground-truth support to `evaluate_workflow` for similarity evaluators ([#5234](https://github.com/microsoft/agent-framework/pull/5234))
- **agent-framework-ag-ui**, **agent-framework-a2a**: Propagate `thread_id` and `forwarded_props` through AG-UI to A2A `context_id` ([#5383](https://github.com/microsoft/agent-framework/pull/5383))
- **samples**: Add second approval-required tool (`set_stop_loss`) to `concurrent_builder_tool_approval` sample ([#4875](https://github.com/microsoft/agent-framework/pull/4875))
- **agent-framework-core**: Add `SKIP_PARSING` sentinel for `FunctionTool.invoke` to bypass `Content`-wrapping and return raw function results ([#5424](https://github.com/microsoft/agent-framework/pull/5424))
### Changed
- **agent-framework-foundry-hosting**: Correct Development Status classifier from Beta (4) to Alpha (3) to match the package's lifecycle stage ([#5387](https://github.com/microsoft/agent-framework/pull/5387))
- **tests**: Add Python flaky test report workflow ([#5342](https://github.com/microsoft/agent-framework/pull/5342))
- **agent-framework-hyperlight**: Simplify host callback to pass raw Python results via `SKIP_PARSING`, switch `execute_code` input schema to a plain JSON-schema dict, and tighten public API surface ([#5424](https://github.com/microsoft/agent-framework/pull/5424))
### Fixed
- **agent-framework-openai**: Fix OpenAI Responses streaming to propagate `created_at` from the final `response.completed` event ([#5382](https://github.com/microsoft/agent-framework/pull/5382))
- **agent-framework-openai**: Fix `OpenAIEmbeddingClient` to use `AsyncOpenAI` for `/openai/v1` endpoints ([#5137](https://github.com/microsoft/agent-framework/pull/5137))
- **agent-framework-openai**: Exclude null `file_id` from `input_image` payload to prevent schema 400 errors ([#5125](https://github.com/microsoft/agent-framework/pull/5125))
- **agent-framework-foundry**: Reconcile Toolbox hosted-tool payloads with the Responses API ([#5414](https://github.com/microsoft/agent-framework/pull/5414))
- **agent-framework-ag-ui**: Pass client `thread_id` as `session_id` when constructing `AgentSession` ([#5384](https://github.com/microsoft/agent-framework/pull/5384))
- **agent-framework-hyperlight**: Thread-confine `WasmSandbox` interactions via per-entry `ThreadPoolExecutor` to eliminate the PyO3 `unsendable` panic when touched from asyncio worker threads
([#5424](https://github.com/microsoft/agent-framework/pull/5424))
## [1.1.0] - 2026-04-21
### Added
@@ -295,7 +295,10 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
else:
if not normalized_messages:
raise ValueError("At least one message is required when starting a new task (no continuation_token).")
a2a_message = self._prepare_message_for_a2a(normalized_messages[-1])
a2a_message = self._prepare_message_for_a2a(
normalized_messages[-1],
context_id=session.service_session_id if session else None,
)
a2a_stream = self.client.send_message(a2a_message)
provider_session = session
@@ -584,7 +587,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
return AgentResponse.from_updates(updates)
return AgentResponse(messages=[], response_id=task.id, raw_representation=task)
def _prepare_message_for_a2a(self, message: Message) -> A2AMessage:
def _prepare_message_for_a2a(self, message: Message, *, context_id: str | None = None) -> A2AMessage:
"""Prepare a Message for the A2A protocol.
Transforms Agent Framework Message objects into A2A protocol Messages by:
@@ -593,6 +596,13 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
- Converting file references (URI/data/hosted_file) to FilePart objects
- Preserving metadata and additional properties from the original message
- Setting the role to 'user' as framework messages are treated as user input
Args:
message: The framework Message to convert.
context_id: Optional fallback context identifier (e.g. derived from
``AgentSession.service_session_id``). When the *message* already
carries a ``context_id`` in its ``additional_properties`` that
value takes precedence; otherwise this fallback is used.
"""
parts: list[A2APart] = []
if not message.contents:
@@ -672,7 +682,7 @@ class A2AAgent(AgentTelemetryLayer, BaseAgent):
role=A2ARole("user"),
parts=parts,
message_id=message.message_id or uuid.uuid4().hex,
context_id=message.additional_properties.get("context_id"),
context_id=message.additional_properties.get("context_id") or context_id,
metadata=metadata,
)
+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.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"a2a-sdk>=0.3.5,<0.3.24",
]
@@ -46,6 +46,7 @@ class MockA2AClient:
self.responses: list[Any] = []
self.resubscribe_responses: list[Any] = []
self.get_task_response: Task | None = None
self.last_message: Any = None
def add_message_response(self, message_id: str, text: str, role: str = "agent") -> None:
"""Add a mock Message response."""
@@ -111,6 +112,7 @@ class MockA2AClient:
async def send_message(self, message: Any) -> AsyncIterator[Any]:
"""Mock send_message method that yields responses."""
self.last_message = message
self.call_count += 1
# All queued responses are delivered as a single streaming batch per call.
@@ -539,6 +541,37 @@ def test_prepare_message_for_a2a_forwards_context_id() -> None:
assert result.metadata == {"trace_id": "trace-456"}
def test_prepare_message_for_a2a_uses_fallback_context_id() -> None:
"""Test that context_id kwarg is used when message has no context_id property."""
agent = A2AAgent(client=MagicMock(), http_client=None)
message = Message(
role="user",
contents=[Content.from_text(text="Hello")],
)
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
assert result.context_id == "session-ctx-1"
def test_prepare_message_for_a2a_message_context_id_takes_precedence() -> None:
"""Test that message.additional_properties context_id wins over the fallback."""
agent = A2AAgent(client=MagicMock(), http_client=None)
message = Message(
role="user",
contents=[Content.from_text(text="Hello")],
additional_properties={"context_id": "explicit-ctx"},
)
result = agent._prepare_message_for_a2a(message, context_id="session-ctx-1")
assert result.context_id == "explicit-ctx"
def test_parse_contents_from_a2a_with_data_part() -> None:
"""Test conversion of A2A DataPart."""
@@ -868,6 +901,43 @@ async def test_poll_task_completed(a2a_agent: A2AAgent, mock_a2a_client: MockA2A
# endregion
# region Session context_id Integration Tests
@mark.asyncio
async def test_run_passes_session_service_session_id_as_context_id(mock_a2a_client: MockA2AClient) -> None:
"""Test that run() wires session.service_session_id to the A2A message context_id."""
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
mock_a2a_client.add_message_response("msg-ctx", "reply")
session = AgentSession(service_session_id="svc-session-42")
await agent.run("Hello", session=session)
assert mock_a2a_client.last_message is not None
assert mock_a2a_client.last_message.context_id == "svc-session-42"
@mark.asyncio
async def test_run_message_context_id_takes_precedence_over_session(mock_a2a_client: MockA2AClient) -> None:
"""Test that an explicit context_id on the message wins over session.service_session_id."""
agent = A2AAgent(name="Test Agent", id="test-agent", client=mock_a2a_client, http_client=None)
mock_a2a_client.add_message_response("msg-ctx2", "reply")
session = AgentSession(service_session_id="svc-session-42")
message = Message(
role="user",
contents=[Content.from_text(text="Hello")],
additional_properties={"context_id": "explicit-ctx"},
)
await agent.run(messages=[message], session=session)
assert mock_a2a_client.last_message is not None
assert mock_a2a_client.last_message.context_id == "explicit-ctx"
# endregion
# region Context Provider Tests
@@ -790,9 +790,9 @@ async def run_agent_stream(
# Create session (with service session support)
if config.use_service_session:
supplied_thread_id = input_data.get("thread_id") or input_data.get("threadId")
session = AgentSession(service_session_id=supplied_thread_id)
session = AgentSession(session_id=thread_id, service_session_id=supplied_thread_id)
else:
session = AgentSession()
session = AgentSession(session_id=thread_id)
# Inject metadata for AG-UI orchestration (Feature #2: Azure-safe truncation)
base_metadata: dict[str, Any] = {
+2 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "agent-framework-ag-ui"
version = "1.0.0b260421"
version = "1.0.0b260423"
description = "AG-UI protocol integration for Agent Framework"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"ag-ui-protocol==0.1.13",
"fastapi>=0.115.0,<0.133.1",
"uvicorn[standard]>=0.30.0,<0.42.0"
@@ -183,6 +183,7 @@ class StubAgent(SupportsAgentRun):
self.client = client or SimpleNamespace(function_invocation_configuration=None)
self.messages_received: list[Any] = []
self.tools_received: list[Any] | None = None
self.last_session: AgentSession | None = None
@overload
def run(
@@ -216,6 +217,7 @@ class StubAgent(SupportsAgentRun):
async def _stream() -> AsyncIterator[AgentResponseUpdate]:
self.messages_received = [] if messages is None else list(messages) # type: ignore[arg-type]
self.last_session = session
self.tools_received = kwargs.get("tools")
for update in self.updates:
yield update
@@ -1640,3 +1640,115 @@ class TestReasoningInSnapshot:
# close: MsgEnd(block2) + End(block2)
assert isinstance(close[0], ReasoningMessageEndEvent)
assert close[0].message_id == "block2"
async def test_session_id_matches_thread_id():
"""Session created by run_agent_stream uses the client thread_id as session_id."""
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub)
payload = {
"thread_id": "my-thread-123",
"run_id": "run-1",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
assert stub.last_session.session_id == "my-thread-123"
async def test_session_id_matches_camel_case_thread_id():
"""Session uses threadId (camelCase) as session_id when snake_case is absent."""
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub)
payload = {
"threadId": "camel-thread-456",
"run_id": "run-2",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
assert stub.last_session.session_id == "camel-thread-456"
async def test_session_id_matches_thread_id_with_service_session():
"""Session uses thread_id as session_id even when use_service_session is enabled."""
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub, use_service_session=True)
payload = {
"thread_id": "service-thread-789",
"run_id": "run-3",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
assert stub.last_session.session_id == "service-thread-789"
assert stub.last_session.service_session_id == "service-thread-789"
async def test_session_id_generated_when_no_thread_id():
"""Session gets a generated UUID as session_id when no thread_id is provided."""
import uuid
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub)
payload = {
"run_id": "run-4",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
# Should be a valid UUID (auto-generated)
uuid.UUID(stub.last_session.session_id)
async def test_service_session_no_thread_id_generates_uuid():
"""With use_service_session=True and no thread_id, session_id is a UUID and service_session_id is None."""
import uuid
from conftest import StubAgent
from agent_framework_ag_ui import AgentFrameworkAgent
stub = StubAgent()
agent = AgentFrameworkAgent(agent=stub, use_service_session=True)
payload = {
"run_id": "run-5",
"messages": [{"role": "user", "content": "Hello"}],
}
_ = [event async for event in agent.run(payload)]
assert stub.last_session is not None
# session_id should be a valid auto-generated UUID
uuid.UUID(stub.last_session.session_id)
# service_session_id should be None since no thread_id was supplied
assert stub.last_session.service_session_id is None
@@ -6,13 +6,13 @@ from collections.abc import Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from anthropic import AsyncAnthropicBedrock
@@ -94,7 +94,7 @@ class RawAnthropicBedrockClient(RawAnthropicClient[AnthropicOptionsT], Generic[A
aws_profile=settings.get("aws_profile"),
aws_session_token=session_token_secret.get_secret_value() if session_token_secret is not None else None,
base_url=settings.get("anthropic_bedrock_base_url"),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
super().__init__(
@@ -8,7 +8,6 @@ from collections.abc import AsyncIterable, Awaitable, Callable, Mapping, Sequenc
from typing import Any, ClassVar, Final, Generic, Literal, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
Annotation,
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
@@ -28,6 +27,7 @@ from agent_framework import (
tool,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._tools import SHELL_TOOL_KIND_VALUE
from agent_framework._types import _get_data_bytes_as_str # type: ignore
from agent_framework.observability import ChatTelemetryLayer
@@ -332,7 +332,7 @@ class RawAnthropicClient(
anthropic_client = AsyncAnthropic(
api_key=api_key_secret.get_secret_value(),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
# Initialize parent
@@ -604,7 +604,7 @@ class RawAnthropicClient(
run_options["betas"] = self._prepare_betas(options)
# extra headers
run_options["extra_headers"] = {"User-Agent": AGENT_FRAMEWORK_USER_AGENT}
run_options["extra_headers"] = {"User-Agent": get_user_agent()}
# Handle user option -> metadata.user_id (Anthropic uses metadata.user_id instead of user)
if user := run_options.pop("user", None):
@@ -6,13 +6,13 @@ from collections.abc import Awaitable, Callable, Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from anthropic import AsyncAnthropicFoundry
@@ -91,14 +91,14 @@ class RawAnthropicFoundryClient(RawAnthropicClient[AnthropicOptionsT], Generic[A
base_url=base_url_setting,
api_key=api_key_value,
azure_ad_token_provider=azure_ad_token_provider,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
else:
anthropic_client = AsyncAnthropicFoundry(
resource=resource_setting,
api_key=api_key_value,
azure_ad_token_provider=azure_ad_token_provider,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
super().__init__(
@@ -6,13 +6,13 @@ from collections.abc import Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
)
from agent_framework._settings import load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from anthropic import NOT_GIVEN, AsyncAnthropicVertex
@@ -89,7 +89,7 @@ class RawAnthropicVertexClient(RawAnthropicClient[AnthropicOptionsT], Generic[An
access_token=access_token,
credentials=credentials,
base_url=settings.get("anthropic_vertex_base_url"),
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
super().__init__(
+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.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"anthropic>=0.80.0,<0.80.1",
]
@@ -3,7 +3,8 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, ChatMiddlewareLayer, FunctionInvocationLayer
from agent_framework import ChatMiddlewareLayer, FunctionInvocationLayer
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_anthropic import (
@@ -61,7 +62,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_settings(tmp_path)
resource="test-resource",
api_key="test-key",
azure_ad_token_provider=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -85,7 +86,7 @@ def test_raw_anthropic_foundry_client_creates_sdk_client_from_base_url_settings(
base_url="https://test-resource.services.ai.azure.com/anthropic/",
api_key="test-key",
azure_ad_token_provider=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -130,7 +131,7 @@ def test_raw_anthropic_bedrock_client_creates_sdk_client_from_arguments() -> Non
aws_profile=None,
aws_session_token=None,
base_url=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -152,5 +153,5 @@ def test_raw_anthropic_vertex_client_creates_sdk_client_from_arguments() -> None
access_token=None,
credentials=None,
base_url=None,
default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT},
default_headers={"User-Agent": get_user_agent()},
)
@@ -14,7 +14,6 @@ from collections.abc import Awaitable, Callable
from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypedDict, overload
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AgentSession,
Annotation,
Content,
@@ -25,6 +24,7 @@ from agent_framework import (
SupportsGetEmbeddings,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from agent_framework.exceptions import SettingNotFoundError
from azure.core.credentials import AzureKeyCredential, TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -535,7 +535,7 @@ class AzureAISearchContextProvider(ContextProvider):
endpoint=self.endpoint,
index_name=self.index_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
self._index_client: SearchIndexClient | None = None
@@ -544,7 +544,7 @@ class AzureAISearchContextProvider(ContextProvider):
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
self._knowledge_base_initialized = False
@@ -640,7 +640,7 @@ class AzureAISearchContextProvider(ContextProvider):
self._index_client = SearchIndexClient(
endpoint=self.endpoint,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
if not self.index_name:
logger.warning("Cannot auto-discover vector field: index_name is not set.")
@@ -740,7 +740,7 @@ class AzureAISearchContextProvider(ContextProvider):
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
self._knowledge_base_initialized = True
return
@@ -802,7 +802,7 @@ class AzureAISearchContextProvider(ContextProvider):
endpoint=self.endpoint,
knowledge_base_name=knowledge_base_name,
credential=self.credential,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
async def _agentic_search(self, messages: list[Message]) -> list[Message]:
@@ -4,7 +4,7 @@ description = "Azure AI Search integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"azure-search-documents>=11.7.0b2,<11.7.0b3",
]
@@ -7,8 +7,8 @@ from __future__ import annotations
import logging
from typing import Any, TypedDict
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework._workflows._checkpoint import CheckpointID, WorkflowCheckpoint
from agent_framework._workflows._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from agent_framework.exceptions import WorkflowCheckpointException
@@ -194,7 +194,7 @@ class CosmosCheckpointStorage:
self._cosmos_client = CosmosClient(
url=settings["endpoint"], # type: ignore[arg-type]
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
user_agent_suffix=get_user_agent(),
)
self._owns_client = True
@@ -10,9 +10,10 @@ import uuid
from collections.abc import Sequence
from typing import Any, ClassVar, TypedDict
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message
from agent_framework import Message
from agent_framework._sessions import HistoryProvider
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
from azure.cosmos import PartitionKey
@@ -121,7 +122,7 @@ class CosmosHistoryProvider(HistoryProvider):
self._cosmos_client = CosmosClient(
url=settings["endpoint"], # type: ignore[arg-type]
credential=credential or settings["key"].get_secret_value(), # type: ignore[arg-type,union-attr]
user_agent_suffix=AGENT_FRAMEWORK_USER_AGENT,
user_agent_suffix=get_user_agent(),
)
self._owns_client = True
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Azure Cosmos DB history provider integration for Microsoft Agent
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"azure-cosmos>=4.3.0,<5",
]
@@ -4,7 +4,7 @@ description = "Azure Functions integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"agent-framework-durabletask",
"azure-functions>=1.24.0,<2",
"azure-functions-durable>=1.3.1,<2",
@@ -13,7 +13,6 @@ from typing import Any, ClassVar, Generic, Literal, TypedDict
from uuid import uuid4
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
@@ -31,6 +30,7 @@ from agent_framework import (
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.exceptions import ChatClientInvalidResponseException
from agent_framework.observability import ChatTelemetryLayer
from boto3.session import Session as Boto3Session
@@ -299,7 +299,7 @@ class BedrockChatClient(
self._bedrock_client = session.client(
"bedrock-runtime",
region_name=region,
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
config=BotoConfig(user_agent_extra=get_user_agent()),
)
super().__init__(
@@ -11,7 +11,6 @@ from collections.abc import Sequence
from typing import Any, ClassVar, Generic, TypedDict
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseEmbeddingClient,
Embedding,
EmbeddingGenerationOptions,
@@ -20,6 +19,7 @@ from agent_framework import (
UsageDetails,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import EmbeddingTelemetryLayer
from boto3.session import Session as Boto3Session
from botocore.client import BaseClient
@@ -140,7 +140,7 @@ class RawBedrockEmbeddingClient(
self._bedrock_client = boto3_session.client(
"bedrock-runtime",
region_name=region_name or resolved_region,
config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT),
config=BotoConfig(user_agent_extra=get_user_agent()),
)
self.model: str = settings["embedding_model"] # type: ignore[assignment] # pyright: ignore[reportTypedDictNotRequiredAccess]
+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.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"boto3>=1.35.0,<2.0.0",
"botocore>=1.35.0,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "OpenAI ChatKit integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"openai-chatkit>=1.4.1,<2.0.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Claude Agent SDK integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"claude-agent-sdk>=0.1.36,<0.1.49",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Copilot Studio integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"microsoft-agents-copilotstudio-client>=0.3.1,<0.3.2",
]
@@ -125,6 +125,7 @@ from ._telemetry import (
prepend_agent_framework_to_user_agent,
)
from ._tools import (
SKIP_PARSING,
FunctionInvocationConfiguration,
FunctionInvocationLayer,
FunctionTool,
@@ -258,6 +259,7 @@ __all__ = [
"GROUP_INDEX_KEY",
"GROUP_KIND_KEY",
"GROUP_TOKEN_COUNT_KEY",
"SKIP_PARSING",
"SUMMARIZED_BY_SUMMARY_ID_KEY",
"SUMMARY_OF_GROUP_IDS_KEY",
"SUMMARY_OF_MESSAGE_IDS_KEY",
@@ -4,9 +4,6 @@ from __future__ import annotations
import logging
import os
from collections.abc import Generator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any, Final
from . import __version__ as version_info
@@ -29,34 +26,73 @@ USER_AGENT_KEY: Final[str] = "User-Agent"
HTTP_USER_AGENT: Final[str] = "agent-framework-python"
AGENT_FRAMEWORK_USER_AGENT = f"{HTTP_USER_AGENT}/{version_info}" # type: ignore[has-type]
_user_agent_prefixes: ContextVar[tuple[str, ...]] = ContextVar("_user_agent_prefixes", default=())
# This environment variable is reserved by the Foundry hosting environment to
# indicate that the agent is running in a hosted environment.
_FOUNDRY_HOSTING_ENV_VAR = "FOUNDRY_HOSTING_ENVIRONMENT"
# This prefix is added to the user agent string when the agent is running in a hosted environment.
_HOSTED_USER_AGENT_PREFIX = "foundry-hosting"
_user_agent_prefixes: set[str] = set()
_hosted_env_detected: bool = False
@contextmanager
def user_agent_prefix(prefix: str) -> Generator[None]:
"""Context manager that adds a prefix to the user agent string for the current scope.
def _add_user_agent_prefix(prefix: str) -> None:
"""Permanently add a prefix to the user agent string.
This is useful for upstream layers that want to identify themselves in telemetry
for the duration of a request without permanently mutating global state.
This is used by hosting layers to identify themselves in telemetry.
Once added, the prefix applies to all subsequent user agent strings.
Args:
prefix: The prefix to add (e.g. "foundry-hosting").
"""
current = _user_agent_prefixes.get()
token = _user_agent_prefixes.set((*current, prefix)) if prefix and prefix not in current else None
if prefix:
_user_agent_prefixes.add(prefix)
def _detect_hosted_environment() -> None:
"""Detect if running in a hosted environment and add the user agent prefix.
Checks the ``FOUNDRY_HOSTING_ENVIRONMENT`` env var first, then falls back
to checking whether the agent server SDK is installed (via
``importlib.util.find_spec``) before importing it, to avoid unnecessary
import overhead for non-hosted scenarios.
"""
global _hosted_env_detected
if _hosted_env_detected:
return
_hosted_env_detected = True
env_value = os.environ.get(_FOUNDRY_HOSTING_ENV_VAR)
if env_value is not None:
# Env var exists — trust its value and skip the fallback.
if env_value:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
return
# Env var not set — fall back to AgentConfig as a second layer of defense.
# Use find_spec to avoid the cost of a full import when the SDK is not installed.
import importlib.util
try:
yield
finally:
if token is not None:
_user_agent_prefixes.reset(token)
if importlib.util.find_spec("azure.ai.agentserver.core") is None:
return
except (ModuleNotFoundError, ValueError):
return
try:
from azure.ai.agentserver.core import AgentConfig # pyright: ignore[reportMissingImports]
if AgentConfig.from_env().is_hosted:
_add_user_agent_prefix(_HOSTED_USER_AGENT_PREFIX)
except (ImportError, AttributeError):
pass
def _get_user_agent() -> str:
"""Return the full user agent string including any context-scoped prefixes."""
prefixes = _user_agent_prefixes.get()
if not prefixes:
def get_user_agent() -> str:
"""Return the full user agent string including any registered prefixes."""
_detect_hosted_environment()
if not _user_agent_prefixes:
return AGENT_FRAMEWORK_USER_AGENT
return f"{'/'.join(prefixes)}/{AGENT_FRAMEWORK_USER_AGENT}"
return f"{'/'.join(sorted(_user_agent_prefixes))}/{AGENT_FRAMEWORK_USER_AGENT}"
def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None) -> dict[str, Any]:
@@ -89,7 +125,7 @@ def prepend_agent_framework_to_user_agent(headers: dict[str, Any] | None = None)
"""
if not IS_TELEMETRY_ENABLED:
return headers or {}
user_agent = _get_user_agent()
user_agent = get_user_agent()
if not headers:
return {USER_AGENT_KEY: user_agent}
headers[USER_AGENT_KEY] = f"{user_agent} {headers[USER_AGENT_KEY]}" if USER_AGENT_KEY in headers else user_agent
+88 -12
View File
@@ -94,6 +94,33 @@ ApprovalMode: TypeAlias = Literal["always_require", "never_require"]
ChatClientT = TypeVar("ChatClientT", bound="SupportsChatGetResponse[Any]")
ResponseModelBoundT = TypeVar("ResponseModelBoundT", bound=BaseModel)
class _SkipParsingSentinel:
"""Sentinel signaling that :meth:`FunctionTool.invoke` should return the raw value.
When passed as ``result_parser`` to :class:`FunctionTool` (or the ``@tool`` decorator),
the default :meth:`FunctionTool.parse_result` is bypassed and the wrapped function's
return value is returned unchanged from :meth:`FunctionTool.invoke`. Callers may also
request the raw value on a per-call basis by passing ``skip_parsing=True`` to
:meth:`FunctionTool.invoke`.
Use the module-level ``SKIP_PARSING`` singleton — do not instantiate this class.
"""
_instance: ClassVar[_SkipParsingSentinel | None] = None
def __new__(cls) -> _SkipParsingSentinel:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __repr__(self) -> str:
return "SKIP_PARSING"
SKIP_PARSING: Final[_SkipParsingSentinel] = _SkipParsingSentinel()
"""Sentinel for ``FunctionTool(result_parser=...)`` meaning "do not parse the result"."""
# region Helpers
@@ -279,7 +306,7 @@ class FunctionTool(SerializationMixin):
additional_properties: dict[str, Any] | None = None,
func: Callable[..., Any] | None = None,
input_model: type[BaseModel] | Mapping[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
**kwargs: Any,
) -> None:
"""Initialize the FunctionTool.
@@ -327,9 +354,11 @@ class FunctionTool(SerializationMixin):
result_parser: An optional callable with signature ``Callable[[Any], str]`` that
overrides the default result parsing behavior. When provided, this callable
is used to convert the raw function return value to a string instead of the
built-in :meth:`parse_result` logic. Depending on your function, it may be
easiest to just do the serialization directly in the function body rather
than providing a custom ``result_parser``.
built-in :meth:`parse_result` logic. Pass the :data:`SKIP_PARSING` sentinel
instead of a callable to opt out of parsing entirely; in that case
:meth:`invoke` returns the wrapped function's raw return value. Depending
on your function, it may be easiest to just do the serialization directly
in the function body rather than providing a custom ``result_parser``.
**kwargs: Additional keyword arguments.
"""
# Core attributes (formerly from BaseTool)
@@ -508,31 +537,65 @@ class FunctionTool(SerializationMixin):
self.invocation_exception_count += 1
raise
@overload
async def invoke(
self,
*,
arguments: BaseModel | Mapping[str, Any] | None = None,
context: FunctionInvocationContext | None = None,
tool_call_id: str | None = None,
skip_parsing: Literal[True],
**kwargs: Any,
) -> list[Content]:
) -> Any: ...
@overload
async def invoke(
self,
*,
arguments: BaseModel | Mapping[str, Any] | None = None,
context: FunctionInvocationContext | None = None,
tool_call_id: str | None = None,
skip_parsing: Literal[False] = False,
**kwargs: Any,
) -> list[Content]: ...
async def invoke(
self,
*,
arguments: BaseModel | Mapping[str, Any] | None = None,
context: FunctionInvocationContext | None = None,
tool_call_id: str | None = None,
skip_parsing: bool = False,
**kwargs: Any,
) -> list[Content] | Any:
"""Run the AI function with the provided arguments as a Pydantic model.
The raw return value of the wrapped function is automatically parsed into a
``list[Content]`` using :meth:`parse_result` or the custom ``result_parser``
if one was provided. Every result — text, rich media, or serialized objects —
is represented uniformly as Content items.
configured on the tool. Every result — text, rich media, or serialized
objects — is represented uniformly as Content items.
Parsing can be skipped in two ways: configure the tool with
``result_parser=SKIP_PARSING`` to always skip parsing, or pass
``skip_parsing=True`` per call. Either way the wrapped function's raw value
is returned. This is intended for callers (e.g. sandboxed runtimes) that
consume the value from Python directly and would otherwise undo the
``Content`` wrapping.
Keyword Args:
arguments: A mapping or model instance containing the arguments for the function.
context: Explicit function invocation context carrying runtime kwargs.
tool_call_id: Optional tool call identifier used for telemetry and tracing.
skip_parsing: When ``True``, bypass parsing and return the wrapped function's
raw value instead of a ``list[Content]``. Defaults to ``False``.
kwargs: Direct function argument values. When provided, every keyword
must match a declared tool parameter. Runtime data must be passed
via ``context``.
Returns:
A list of Content items representing the tool output.
``list[Content]`` by default. The raw function return value (``Any``) when
``skip_parsing=True`` (or the tool was constructed with
``result_parser=SKIP_PARSING``).
Raises:
TypeError: If arguments is not mapping-like or fails schema checks.
@@ -544,7 +607,9 @@ class FunctionTool(SerializationMixin):
from ._types import Content
from .observability import OBSERVABILITY_SETTINGS
parser = self.result_parser or FunctionTool.parse_result
configured_parser = self.result_parser
skip_parsing = skip_parsing or configured_parser is SKIP_PARSING
parser = configured_parser if callable(configured_parser) else FunctionTool.parse_result
parameter_names = set(self.parameters().get("properties", {}).keys())
direct_argument_kwargs = (
@@ -616,6 +681,10 @@ class FunctionTool(SerializationMixin):
logger.debug(f"Function arguments: {observable_kwargs}")
res = self.__call__(**call_kwargs)
result = await res if inspect.isawaitable(res) else res
if skip_parsing:
logger.info(f"Function {self.name} succeeded.")
logger.debug(f"Function result: {type(result).__name__}")
return result
try:
parsed = parser(result)
except Exception:
@@ -671,6 +740,13 @@ class FunctionTool(SerializationMixin):
logger.error(f"Function failed. Error: {exception}")
raise
else:
if skip_parsing:
logger.info(f"Function {self.name} succeeded.")
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED: # type: ignore[name-defined]
result_str = str(result)
span.set_attribute(OtelAttr.TOOL_RESULT, result_str)
logger.debug(f"Function result: {result_str}")
return result
try:
parsed = parser(result)
except Exception:
@@ -1067,7 +1143,7 @@ def tool(
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
) -> FunctionTool: ...
@@ -1083,7 +1159,7 @@ def tool(
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
) -> Callable[[Callable[..., Any]], FunctionTool]: ...
@@ -1098,7 +1174,7 @@ def tool(
max_invocations: int | None = None,
max_invocation_exceptions: int | None = None,
additional_properties: dict[str, Any] | None = None,
result_parser: Callable[[Any], str | list[Content]] | None = None,
result_parser: Callable[[Any], str | list[Content]] | _SkipParsingSentinel | None = None,
) -> FunctionTool | Callable[[Callable[..., Any]], FunctionTool]:
"""Decorate a function to turn it into a FunctionTool that can be passed to models and executed automatically.
+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.1.0"
version = "1.1.1"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
+178 -41
View File
@@ -1,14 +1,21 @@
# Copyright (c) Microsoft. All rights reserved.
from unittest.mock import patch
import os
from unittest.mock import MagicMock, patch
import agent_framework._telemetry as _telemetry_mod
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
USER_AGENT_KEY,
USER_AGENT_TELEMETRY_DISABLED_ENV_VAR,
prepend_agent_framework_to_user_agent,
)
from agent_framework._telemetry import user_agent_prefix
from agent_framework._telemetry import (
_FOUNDRY_HOSTING_ENV_VAR,
_HOSTED_USER_AGENT_PREFIX,
_add_user_agent_prefix,
_detect_hosted_environment,
)
# region Test constants
@@ -83,7 +90,7 @@ def test_prepend_to_empty_headers():
def test_prepend_to_empty_dict():
"""Test prepending to empty headers dict."""
headers = {}
headers: dict[str, str] = {}
result = prepend_agent_framework_to_user_agent(headers)
assert "User-Agent" in result
@@ -99,54 +106,184 @@ def test_modifies_original_dict():
assert "User-Agent" in headers
# region Test user_agent_prefix context manager
# region Test _add_user_agent_prefix
def test_user_agent_prefix_adds_prefix():
"""Test that the context manager adds a prefix within its scope."""
with user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
# Prefix is removed after exiting the context
def test_add_user_agent_prefix_adds_prefix():
"""Test that _add_user_agent_prefix permanently adds a prefix."""
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("test-host")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
assert result["User-Agent"].startswith("test-host/")
assert AGENT_FRAMEWORK_USER_AGENT in result["User-Agent"]
_telemetry_mod._user_agent_prefixes.clear()
def test_user_agent_prefix_ignores_duplicates():
"""Test that duplicate prefixes are not added within nested scopes."""
with user_agent_prefix("test-host"), user_agent_prefix("test-host"):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].count("test-host") == 1
def test_add_user_agent_prefix_ignores_duplicates():
"""Test that duplicate prefixes are not added."""
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("test-host")
_add_user_agent_prefix("test-host")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"].count("test-host") == 1
_telemetry_mod._user_agent_prefixes.clear()
def test_user_agent_prefix_ignores_empty():
def test_add_user_agent_prefix_ignores_empty():
"""Test that empty strings are not added as prefixes."""
with user_agent_prefix(""):
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
def test_user_agent_prefix_restores_on_exit():
"""Test that prefixes are fully restored after the context manager exits."""
with user_agent_prefix("test-host"):
pass
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
_telemetry_mod._user_agent_prefixes.clear()
def test_user_agent_prefix_nesting():
"""Test that nested context managers compose prefixes correctly."""
with user_agent_prefix("outer"):
with user_agent_prefix("inner"):
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
# Inner prefix removed
result = prepend_agent_framework_to_user_agent()
assert "outer" in result["User-Agent"]
assert "inner" not in result["User-Agent"]
# Both removed
def test_add_user_agent_prefix_multiple():
"""Test that multiple prefixes compose correctly."""
_telemetry_mod._user_agent_prefixes.clear()
_add_user_agent_prefix("outer")
_add_user_agent_prefix("inner")
result = prepend_agent_framework_to_user_agent()
assert result["User-Agent"] == AGENT_FRAMEWORK_USER_AGENT
assert "outer" in result["User-Agent"]
assert "inner" in result["User-Agent"]
_telemetry_mod._user_agent_prefixes.clear()
# region Test _detect_hosted_environment
def test_detect_hosted_env_var_truthy_adds_prefix():
"""Test that a truthy FOUNDRY_HOSTING_ENVIRONMENT env var adds the prefix."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_env_var_empty_skips_prefix():
"""Test that an empty FOUNDRY_HOSTING_ENVIRONMENT env var does NOT add the prefix."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: ""}):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_env_var_set_skips_agent_config_fallback():
"""Test that when the env var is set, AgentConfig is never consulted even if import would fail."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
import builtins
real_import = builtins.__import__
def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
if "agentserver" in name:
raise AssertionError("AgentConfig should not be imported when env var is set")
return real_import(name, *args, **kwargs)
with (
patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "prod"}),
patch("builtins.__import__", side_effect=_block_agentconfig),
):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def _mock_agent_config(*, is_hosted: bool) -> MagicMock:
"""Create a mock azure.ai.agentserver.core module with AgentConfig."""
mock_config = MagicMock()
mock_config.is_hosted = is_hosted
mock_module = MagicMock()
mock_module.AgentConfig.from_env.return_value = mock_config
return mock_module
def test_detect_hosted_fallback_agent_config_is_hosted():
"""Test that AgentConfig fallback adds the prefix when is_hosted is True."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
mock_module = _mock_agent_config(is_hosted=True)
mock_spec = MagicMock()
with (
patch.dict("os.environ", env, clear=True),
patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}),
patch("importlib.util.find_spec", return_value=mock_spec),
):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_fallback_agent_config_not_hosted():
"""Test that AgentConfig fallback does NOT add the prefix when is_hosted is False."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
mock_module = _mock_agent_config(is_hosted=False)
mock_spec = MagicMock()
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
with (
patch.dict("os.environ", env, clear=True),
patch.dict("sys.modules", {"azure.ai.agentserver.core": mock_module}),
patch("importlib.util.find_spec", return_value=mock_spec),
):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
def test_detect_hosted_fallback_import_error():
"""Test that ImportError from AgentConfig is silently handled."""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
env = {k: v for k, v in os.environ.items() if k != _FOUNDRY_HOSTING_ENV_VAR}
with patch.dict("os.environ", env, clear=True):
# The real import may succeed or fail depending on the environment;
# force the ImportError path by making the import raise.
import builtins
real_import = builtins.__import__
def _block_agentconfig(name: str, *args, **kwargs): # type: ignore[no-untyped-def]
if "agentserver" in name:
raise ImportError("mocked")
return real_import(name, *args, **kwargs)
with patch("builtins.__import__", side_effect=_block_agentconfig):
_detect_hosted_environment()
assert _HOSTED_USER_AGENT_PREFIX not in _telemetry_mod._user_agent_prefixes
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
# region Test module-level auto-detection
def test_lazy_detection_on_get_user_agent():
"""Test that get_user_agent() lazily detects the hosted environment.
Since detection is deferred to the first ``get_user_agent()`` call,
this verifies the prefix is included without any explicit call to
``_detect_hosted_environment()`` by consumer code.
"""
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
with patch.dict("os.environ", {_FOUNDRY_HOSTING_ENV_VAR: "production"}):
user_agent = _telemetry_mod.get_user_agent()
assert _HOSTED_USER_AGENT_PREFIX in _telemetry_mod._user_agent_prefixes
assert user_agent.startswith(f"{_HOSTED_USER_AGENT_PREFIX}/")
# Clean up
_telemetry_mod._user_agent_prefixes.clear()
_telemetry_mod._hosted_env_detected = False
@@ -8,6 +8,7 @@ from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanE
from pydantic import BaseModel
from agent_framework import (
SKIP_PARSING,
Content,
FunctionTool,
tool,
@@ -1300,4 +1301,165 @@ def test_normalize_tools_flattens_mapping_like_toolbox_with_tools_attr() -> None
assert normalized[1] is standalone
# region SKIP_PARSING sentinel & skip_parsing
async def test_invoke_skip_parsing_returns_native_value() -> None:
"""invoke(skip_parsing=True) returns the wrapped function's raw value."""
@tool
def get_weather(city: str) -> dict[str, Any]:
"""Get the weather."""
return {"city": city, "temperature_c": 21.5, "conditions": "partly cloudy"}
raw = await get_weather.invoke(arguments={"city": "Seattle"}, skip_parsing=True)
assert isinstance(raw, dict)
assert raw == {"city": "Seattle", "temperature_c": 21.5, "conditions": "partly cloudy"}
async def test_invoke_skip_parsing_passes_through_custom_objects() -> None:
"""skip_parsing must not call str()/repr() on the result."""
class Custom: # noqa: B903
def __init__(self, value: int) -> None:
self.value = value
@tool
def make() -> Custom:
"""Make a custom object."""
return Custom(42)
raw = await make.invoke(skip_parsing=True)
assert isinstance(raw, Custom)
assert raw.value == 42
async def test_invoke_skip_parsing_awaits_async_functions() -> None:
@tool
async def slow(x: int) -> int:
"""Async tool."""
return x * 2
raw = await slow.invoke(arguments={"x": 21}, skip_parsing=True)
assert raw == 42
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] = []
def parser(value: Any) -> str:
parser_calls.append(value)
return "PARSED"
@tool(result_parser=parser)
def make_dict() -> dict[str, int]:
"""Returns a dict."""
return {"a": 1}
raw = await make_dict.invoke(skip_parsing=True)
assert raw == {"a": 1}
assert parser_calls == []
# Sanity: omitting skip_parsing still applies the configured parser.
parsed = await make_dict.invoke()
assert parsed[0].type == "text"
assert parsed[0].text == "PARSED"
async def test_constructor_skip_parsing_sentinel_returns_raw_by_default() -> None:
"""Constructing a tool with result_parser=SKIP_PARSING makes invoke return the raw value."""
@tool(result_parser=SKIP_PARSING)
def make_dict() -> dict[str, int]:
"""Returns a dict."""
return {"a": 1}
raw = await make_dict.invoke()
assert raw == {"a": 1}
async def test_invoke_skip_parsing_validates_arguments() -> None:
"""Argument validation is shared with the default path."""
@tool
def adder(x: int, y: int) -> int:
"""Add."""
return x + y
with pytest.raises(TypeError):
await adder.invoke(arguments={"x": "not-an-int", "y": 1}, skip_parsing=True)
async def test_invoke_skip_parsing_rejects_unexpected_runtime_kwargs() -> None:
@tool
async def echo(message: str) -> str:
"""Echo."""
return message
with pytest.raises(TypeError, match="Unexpected keyword argument"):
await echo.invoke(arguments={"message": "hi"}, skip_parsing=True, api_token="secret")
async def test_invoke_skip_parsing_raises_for_declaration_only_tool() -> None:
declared = FunctionTool(name="dummy", description="declaration only")
from agent_framework.exceptions import ToolException
with pytest.raises(ToolException):
await declared.invoke(arguments={}, skip_parsing=True)
async def test_invoke_skip_parsing_records_telemetry(span_exporter: InMemorySpanExporter) -> None:
"""skip_parsing participates in OTEL spans and records str(raw) as TOOL_RESULT."""
@tool(name="raw_tool", description="raw tool")
def returns_dict(x: int) -> dict[str, int]:
"""Returns a dict."""
return {"value": x}
span_exporter.clear()
raw = await returns_dict.invoke(arguments={"x": 5}, tool_call_id="raw_call", skip_parsing=True)
assert raw == {"value": 5}
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
span = spans[0]
assert span.attributes[OtelAttr.TOOL_NAME] == "raw_tool"
assert span.attributes[OtelAttr.TOOL_CALL_ID] == "raw_call"
assert span.attributes[OtelAttr.TOOL_RESULT] == "{'value': 5}"
async def test_invoke_default_path_records_parsed_telemetry(
span_exporter: InMemorySpanExporter,
) -> None:
"""Regression: omitting skip_parsing still records the parsed result in telemetry."""
def parser(value: Any) -> str:
return f"parsed:{value}"
@tool(name="parsed_tool", description="parsed", result_parser=parser)
def returns_int() -> int:
"""Returns an int."""
return 7
span_exporter.clear()
parsed = await returns_int.invoke(tool_call_id="parsed_call")
assert parsed[0].text == "parsed:7"
spans = span_exporter.get_finished_spans()
assert len(spans) == 1
assert spans[0].attributes[OtelAttr.TOOL_RESULT] == "parsed:7"
def test_skip_parsing_is_singleton() -> None:
"""SKIP_PARSING is a singleton; instantiation returns the same object."""
from agent_framework._tools import _SkipParsingSentinel
assert _SkipParsingSentinel() is SKIP_PARSING
assert repr(SKIP_PARSING) == "SKIP_PARSING"
# endregion
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Declarative specification support for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"powerfx>=0.0.32,<0.0.35; python_version < '3.14'",
"pyyaml>=6.0,<7.0",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Debug UI for Microsoft Agent Framework with OpenAI-compatible API
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"openai>=1.99.0,<3",
"opentelemetry-sdk>=1.39.0,<2",
"fastapi>=0.115.0,<0.133.1",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Durable Task integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"durabletask>=1.3.0,<2",
"durabletask-azuremanaged>=1.3.0,<2",
"python-dateutil>=2.8.0,<3",
@@ -15,7 +15,6 @@ from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequen
from typing import TYPE_CHECKING, Any, ClassVar, Generic, cast
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AgentMiddlewareLayer,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
@@ -28,6 +27,7 @@ from agent_framework import (
load_settings,
)
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import AgentTelemetryLayer, ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
@@ -190,7 +190,7 @@ class RawFoundryAgentChatClient( # type: ignore[misc]
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential,
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
"user_agent": get_user_agent(),
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
@@ -8,7 +8,6 @@ from collections.abc import Awaitable, Callable, Mapping, Sequence
from typing import TYPE_CHECKING, Any, ClassVar, Generic, Literal
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
ChatMiddlewareLayer,
Content,
FunctionInvocationConfiguration,
@@ -17,6 +16,7 @@ from agent_framework import (
)
from agent_framework._compaction import CompactionStrategy, TokenizerProtocol
from agent_framework._feature_stage import ExperimentalFeature, experimental
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from agent_framework_openai._chat_client import OpenAIChatOptions, RawOpenAIChatClient
from azure.ai.projects.aio import AIProjectClient
@@ -198,7 +198,7 @@ class RawFoundryChatClient( # type: ignore[misc]
project_client_kwargs: dict[str, Any] = {
"endpoint": project_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
"user_agent": get_user_agent(),
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
@@ -455,8 +455,18 @@ class RawFoundryChatClient( # type: ignore[misc]
Returns:
An MCPTool configuration ready to pass to an Agent.
Raises:
ValueError: If neither ``url`` nor ``project_connection_id`` is supplied
— one is required by the Foundry Responses API.
"""
mcp = FoundryMCPTool(server_label=name.replace(" ", "_"), server_url=url or "", **kwargs)
if not url and not project_connection_id:
raise ValueError("MCP tool requires either 'url' or 'project_connection_id' to be specified.")
mcp_kwargs: dict[str, Any] = {"server_label": name.replace(" ", "_"), **kwargs}
if url:
mcp_kwargs["server_url"] = url
mcp = FoundryMCPTool(**mcp_kwargs)
if description:
mcp["server_description"] = description
@@ -14,13 +14,13 @@ from contextlib import AbstractAsyncContextManager
from typing import TYPE_CHECKING, Any, ClassVar
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
AgentSession,
ContextProvider,
Message,
SessionContext,
load_settings,
)
from agent_framework._telemetry import get_user_agent
from azure.ai.projects.aio import AIProjectClient
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -119,7 +119,7 @@ class FoundryMemoryProvider(ContextProvider):
project_client_kwargs: dict[str, Any] = {
"endpoint": resolved_endpoint,
"credential": credential, # type: ignore[arg-type]
"user_agent": AGENT_FRAMEWORK_USER_AGENT,
"user_agent": get_user_agent(),
}
if allow_preview is not None:
project_client_kwargs["allow_preview"] = allow_preview
@@ -133,26 +133,55 @@ def select_toolbox_tools(
return selected
def _validate_hosted_tool_payload(sanitized: Mapping[str, Any]) -> None:
"""Fail fast on hosted tool payloads that would always be rejected by the Responses API.
These mismatches are not injectable defaults — the caller must supply the
missing information — so surfacing a clear error here points at the toolbox
definition instead of letting the API return a generic 400.
"""
tool_type = sanitized.get("type")
if tool_type == "file_search" and not sanitized.get("vector_store_ids"):
raise ValueError(
"'file_search' tool is missing required 'vector_store_ids'. "
"If this came from a Foundry toolbox, update the toolbox definition "
"to include at least one vector store ID."
)
if tool_type == "mcp" and not sanitized.get("server_url") and not sanitized.get("project_connection_id"):
raise ValueError(
"'mcp' tool is missing both 'server_url' and 'project_connection_id'. "
"If this came from a Foundry toolbox, update the toolbox definition "
"to include one of these."
)
@experimental(feature_id=ExperimentalFeature.TOOLBOXES)
def sanitize_foundry_response_tool(tool_item: Any) -> Any:
"""Return a Responses-API-safe tool payload for Foundry hosted tools.
Azure AI Projects toolbox reads can currently return hosted tool objects with
extra read-model decoration fields such as top-level ``name`` and
``description``. Azure AI Foundry rejects at least ``name`` on Responses API
requests with:
Reconciles known mismatches between toolbox reads and the Responses API:
``Unknown parameter: 'tools[0].name'``.
1. Toolbox reads can return hosted tool objects decorated with read-model
fields such as top-level ``name`` and ``description``. The Responses API
rejects at least ``name`` with ``Unknown parameter: 'tools[0].name'``.
These fields are stripped from non-function hosted tool payloads.
2. ``code_interpreter`` tools stored in a toolbox without a ``container``
field (the Azure SDK treats it as optional) are rejected by the Responses
API with ``Missing required parameter: 'tools[N].container'``. A default
``{"type": "auto"}`` container is injected when absent.
3. Hosted tools that are structurally incomplete in ways that cannot be
defaulted (``file_search`` without ``vector_store_ids``, ``mcp`` without
either ``server_url`` or ``project_connection_id``) raise ``ValueError``
with a message that points at the toolbox definition.
We defensively strip these decoration fields for non-function hosted tools so
the round-trip
``toolbox.tools -> Agent(..., tools=...) -> run()`` works, while the Azure
SDK/service behavior is corrected upstream.
These are workarounds until the toolbox/Responses proxy normalizes payloads
server-side.
"""
if isinstance(tool_item, FoundryMCPTool):
sanitized: dict[str, Any] = dict(cast("Mapping[str, Any]", tool_item))
sanitized.pop("name", None)
sanitized.pop("description", None)
_validate_hosted_tool_payload(sanitized)
return sanitized
if isinstance(tool_item, Mapping):
@@ -161,6 +190,9 @@ def sanitize_foundry_response_tool(tool_item: Any) -> Any:
sanitized = dict(mapping)
sanitized.pop("name", None)
sanitized.pop("description", None)
if sanitized.get("type") == "code_interpreter" and "container" not in sanitized:
sanitized["container"] = {"type": "auto"}
_validate_hosted_tool_payload(sanitized)
return sanitized
return cast(Any, tool_item)
+2 -2
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.1.0"
version = "1.1.1"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"agent-framework-openai>=1.1.0,<2",
"azure-ai-inference>=1.0.0b9,<1.0.0b10",
"azure-ai-projects>=2.1.0,<3.0",
@@ -12,7 +12,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent_framework import ChatResponse, Content, Message, SupportsChatGetResponse, tool
from agent_framework._telemetry import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._telemetry import get_user_agent
from agent_framework.exceptions import ChatClientException, ChatClientInvalidRequestException
from agent_framework_openai import OpenAIContentFilterException
from azure.ai.projects.models import MCPTool as FoundryMCPTool
@@ -199,7 +199,7 @@ def test_init_with_project_endpoint_creates_project_client() -> None:
assert factory.call_args.kwargs["endpoint"] == _TEST_FOUNDRY_PROJECT_ENDPOINT
assert factory.call_args.kwargs["credential"] is credential
assert factory.call_args.kwargs["allow_preview"] is True
assert factory.call_args.kwargs["user_agent"] == AGENT_FRAMEWORK_USER_AGENT
assert factory.call_args.kwargs["user_agent"] == get_user_agent()
def test_init_with_empty_model_raises(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -607,6 +607,14 @@ def test_get_mcp_tool_with_project_connection_id() -> None:
assert tool_config["project_connection_id"] == "conn-123"
assert tool_config["allowed_tools"] == ["search_docs"]
assert tool_config["server_label"] == "Docs_MCP"
# ``server_url`` should not be fabricated when only a project connection is supplied.
assert "server_url" not in tool_config
def test_get_mcp_tool_requires_url_or_project_connection_id() -> None:
"""Missing both ``url`` and ``project_connection_id`` is always invalid."""
with pytest.raises(ValueError, match="url.*project_connection_id"):
FoundryChatClient.get_mcp_tool(name="x")
def test_prepare_tools_for_openai_strips_extraneous_name_from_foundry_mcp_tool() -> None:
@@ -655,6 +663,103 @@ def test_prepare_tools_for_openai_strips_read_model_fields_from_toolbox_code_int
assert "description" not in prepared
def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_dict() -> None:
"""Toolbox-returned code_interpreter without a container must get a default injected.
The Azure SDK treats ``container`` as optional, but the Responses API rejects
``code_interpreter`` entries without one. The sanitizer backfills ``{"type": "auto"}``.
"""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
tool = {
"type": "code_interpreter",
"name": "code_interpreter_t6bbtm",
}
response_tools = client._prepare_tools_for_openai([tool])
assert len(response_tools) == 1
prepared = response_tools[0]
assert prepared["type"] == "code_interpreter"
assert prepared["container"] == {"type": "auto"}
assert "name" not in prepared
def test_prepare_tools_for_openai_injects_default_container_for_code_interpreter_sdk_instance() -> None:
"""SDK ``CodeInterpreterTool`` instances without a container must also be backfilled.
Reproduces the toolbox creation path that calls
``CodeInterpreterTool(name="code_interpreter")`` without a container.
"""
from azure.ai.projects.models import CodeInterpreterTool
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
response_tools = client._prepare_tools_for_openai([CodeInterpreterTool(name="code_interpreter")])
assert len(response_tools) == 1
prepared = response_tools[0]
assert prepared["type"] == "code_interpreter"
assert prepared["container"] == {"type": "auto"}
assert "name" not in prepared
def test_prepare_tools_for_openai_preserves_existing_code_interpreter_container() -> None:
"""An already-populated container must not be overwritten by the sanitizer."""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
explicit_container = {"file_ids": ["file_123"], "type": "auto"}
tool = {"type": "code_interpreter", "container": explicit_container}
response_tools = client._prepare_tools_for_openai([tool])
assert response_tools[0]["container"] == explicit_container
def test_prepare_tools_for_openai_rejects_file_search_without_vector_store_ids() -> None:
"""``file_search`` without ``vector_store_ids`` is always invalid — surface a clear error."""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
with pytest.raises(ValueError, match="vector_store_ids"):
client._prepare_tools_for_openai([{"type": "file_search", "name": "fs"}])
def test_prepare_tools_for_openai_rejects_mcp_without_server_destination() -> None:
"""``mcp`` with neither ``server_url`` nor ``project_connection_id`` is always invalid."""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
tool = FoundryMCPTool(server_label="orphan")
with pytest.raises(ValueError, match="server_url.*project_connection_id"):
client._prepare_tools_for_openai([tool])
def test_prepare_tools_for_openai_accepts_mcp_with_only_project_connection_id() -> None:
"""MCP tools backed by a Foundry connection (no ``server_url``) must still pass validation."""
project_client = MagicMock()
project_client.get_openai_client.return_value = _make_mock_openai_client()
client = FoundryChatClient(project_client=project_client, model="test-model")
tool = FoundryMCPTool(server_label="githubmcp")
tool["project_connection_id"] = "githubmcp"
response_tools = client._prepare_tools_for_openai([tool])
assert len(response_tools) == 1
assert response_tools[0]["project_connection_id"] == "githubmcp"
assert "server_url" not in response_tools[0]
def test_prepare_tools_for_openai_strips_name_from_non_function_hosted_tool_dicts() -> None:
"""All non-function hosted tool payloads should drop top-level read-model names."""
project_client = MagicMock()
@@ -7,8 +7,9 @@ import os
from unittest.mock import AsyncMock, Mock, patch
import pytest
from agent_framework import AGENT_FRAMEWORK_USER_AGENT, AgentResponse, Message
from agent_framework import AgentResponse, Message
from agent_framework._sessions import AgentSession, SessionContext
from agent_framework._telemetry import get_user_agent
from agent_framework_foundry._memory_provider import FoundryMemoryProvider
@@ -94,7 +95,7 @@ def test_init_with_project_endpoint_and_credential(mock_project_client: AsyncMoc
endpoint="https://test.project.endpoint",
credential=mock_credential,
allow_preview=True,
user_agent=AGENT_FRAMEWORK_USER_AGENT,
user_agent=get_user_agent(),
)
@@ -1,7 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from agent_framework import AgentSession, BaseAgent, SupportsAgentRun
from agent_framework._telemetry import user_agent_prefix
from azure.ai.agentserver.invocations import InvocationAgentServerHost
from starlette.requests import Request
from starlette.responses import JSONResponse, Response, StreamingResponse
@@ -11,8 +10,6 @@ from typing_extensions import Any, AsyncGenerator
class InvocationsHostServer(InvocationAgentServerHost):
"""An invocations server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
def __init__(
self,
agent: BaseAgent,
@@ -42,11 +39,6 @@ class InvocationsHostServer(InvocationAgentServerHost):
async def _handle_invoke(self, request: Request) -> Response:
"""Invoke the agent with the given request."""
with user_agent_prefix(self.USER_AGENT_PREFIX):
return await self._handle_invoke_inner(request)
async def _handle_invoke_inner(self, request: Request) -> Response:
"""Core invoke handler logic."""
data = await request.json()
session_id: str = request.state.session_id
@@ -20,7 +20,6 @@ from agent_framework import (
SupportsAgentRun,
WorkflowAgent,
)
from agent_framework._telemetry import user_agent_prefix
from azure.ai.agentserver.responses import (
ResponseContext,
ResponseEventStream,
@@ -90,7 +89,6 @@ logger = logging.getLogger(__name__)
class ResponsesHostServer(ResponsesAgentServerHost):
"""A responses server host for an agent."""
USER_AGENT_PREFIX = "foundry-hosting"
# TODO(@taochen): Allow a different checkpoint storage that stores checkpoints externally
CHECKPOINT_STORAGE_PATH = "/.checkpoints"
@@ -150,37 +148,32 @@ class ResponsesHostServer(ResponsesAgentServerHost):
self._is_workflow_agent = True
self._agent = agent
self.response_handler(self._handler) # pyright: ignore[reportUnknownMemberType]
self.response_handler(self._handle_response) # pyright: ignore[reportUnknownMemberType]
@staticmethod
def _is_streaming_request(request: CreateResponse) -> bool:
"""Check if the request is a streaming request."""
return request.stream is not None and request.stream is True
async def _handler(
def _handle_response(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response."""
with user_agent_prefix(self.USER_AGENT_PREFIX):
async for event in self._handle_inner(request, context, cancellation_signal):
yield event
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
return self._handle_workflow_agent(request, context)
async def _handle_inner(
return self._handle_regular_agent(request, context)
async def _handle_regular_agent(
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Core handler logic."""
if self._is_workflow_agent:
# Workflow agents are handled differently because they require checkpoint restoration
async for event in self._handle_workflow_agent(request, context, cancellation_signal):
yield event
return
"""Handle the creation of a response for a regular (non-workflow) agent."""
input_text = await context.get_input_text()
history = await context.get_history()
messages: list[str | Content | Message] = [*_to_messages(history), input_text]
@@ -243,7 +236,6 @@ class ResponsesHostServer(ResponsesAgentServerHost):
self,
request: CreateResponse,
context: ResponseContext,
cancellation_signal: asyncio.Event,
) -> AsyncIterable[ResponseStreamEvent | dict[str, Any]]:
"""Handle the creation of a response for a workflow agent.
@@ -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.0a260421"
version = "1.0.0a260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -12,7 +12,7 @@ urls.release_notes = "https://github.com/microsoft/agent-framework/releases?q=ta
urls.issues = "https://github.com/microsoft/agent-framework/issues"
classifiers = [
"License :: OSI Approved :: MIT License",
"Development Status :: 4 - Alpha",
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
@@ -23,7 +23,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"azure-ai-agentserver-core==2.0.0b2",
"azure-ai-agentserver-responses==1.0.0b4",
"azure-ai-agentserver-invocations==1.0.0b2",
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Foundry Local integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"agent-framework-openai>=1.1.0,<2",
"foundry-local-sdk>=0.5.1,<0.5.2",
]
@@ -10,7 +10,6 @@ from typing import Any, ClassVar, Generic, cast
from uuid import uuid4
from agent_framework import (
AGENT_FRAMEWORK_USER_AGENT,
BaseChatClient,
ChatAndFunctionMiddlewareTypes,
ChatMiddlewareLayer,
@@ -28,6 +27,7 @@ from agent_framework import (
validate_tool_mode,
)
from agent_framework._settings import SecretString, load_settings
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import ChatTelemetryLayer
from google import genai
from google.auth.credentials import Credentials
@@ -355,7 +355,7 @@ class RawGeminiChatClient(
)
client_kwargs: dict[str, Any] = {
"http_options": {"headers": {"x-goog-api-client": AGENT_FRAMEWORK_USER_AGENT}},
"http_options": {"headers": {"x-goog-api-client": get_user_agent()}},
}
if configured_vertexai is not None:
client_kwargs["vertexai"] = configured_vertexai
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Google Gemini integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260421"
version = "1.0.0a260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2.0",
"agent-framework-core>=1.1.1,<2.0",
"google-genai>=1.0.0,<2.0.0",
]
@@ -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.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"github-copilot-sdk>=0.2.1,<=0.2.1; python_version >= '3.11'",
]
+6
View File
@@ -130,3 +130,9 @@ codeact = HyperlightCodeActProvider(
- `allowed_domains` accepts a single string target such as `"github.com"` to
allow all backend-supported methods, an explicit `(target, method_or_methods)`
tuple such as `("github.com", "GET")`, or an `AllowedDomain` named tuple.
- Tools registered with the sandbox return their native Python value
(`dict`, `list`, primitives, or custom objects) directly to the guest via the
Hyperlight FFI. Any `result_parser` configured on a `FunctionTool` is
intended for LLM-facing consumers and does not run on the sandbox path —
apply formatting inside the tool function itself if you need it for
in-sandbox consumers.
@@ -2,42 +2,45 @@
from __future__ import annotations
import ast
import asyncio
import copy
import mimetypes
import shutil
import threading
import time
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from concurrent.futures import Future, ThreadPoolExecutor
from contextlib import suppress
from copy import copy
from dataclasses import dataclass, field
from pathlib import Path, PurePosixPath
from tempfile import TemporaryDirectory
from typing import Annotated, Any, Protocol, TypeGuard, cast
from typing import Any, Protocol, TypeGuard, TypeVar, cast
from urllib.parse import urlparse
from agent_framework import Content, FunctionTool
from agent_framework._tools import ApprovalMode, normalize_tools
from pydantic import BaseModel, Field
from ._instructions import build_codeact_instructions, build_execute_code_description
from ._types import AllowedDomain, AllowedDomainInput, FileMount, FileMountHostPath, FileMountInput
DEFAULT_HYPERLIGHT_BACKEND = "wasm"
DEFAULT_HYPERLIGHT_MODULE = "python_guest.path"
EXECUTE_CODE_INPUT_DESCRIPTION = "Python code to execute in an isolated Hyperlight sandbox."
EXECUTE_CODE_TOOL_DESCRIPTION = "Execute Python in an isolated Hyperlight sandbox."
OUTPUT_FILE_RETRY_ATTEMPTS = 10
OUTPUT_FILE_RETRY_DELAY_SECONDS = 0.1
class _ExecuteCodeInput(BaseModel):
code: Annotated[str, Field(description=EXECUTE_CODE_INPUT_DESCRIPTION)]
@dataclass(frozen=True, slots=True)
class _StoredFileMount:
host_path: Path
mount_path: str
EXECUTE_CODE_INPUT_SCHEMA: dict[str, Any] = {
"type": "object",
"title": "_ExecuteCodeInput",
"properties": {
"code": {
"type": "string",
"title": "Code",
"description": "Python code to execute in an isolated Hyperlight sandbox.",
},
},
"required": ["code"],
}
@dataclass(frozen=True, slots=True)
@@ -85,13 +88,43 @@ class SandboxRuntime(Protocol):
def execute(self, *, config: _RunConfig, code: str) -> list[Content]: ...
_T = TypeVar("_T")
class _SandboxWorker:
"""Single-threaded executor that confines all sandbox operations to one OS thread.
The Hyperlight ``WasmSandbox`` is declared ``unsendable`` in PyO3, meaning it can only be
accessed from the OS thread that created it; touching it from any other thread triggers a
Rust panic that cannot be caught from Python. Every cached :class:`_SandboxEntry` therefore
owns its own ``_SandboxWorker``, and *all* lifecycle and execution calls against the
underlying sandbox object must be routed through :meth:`submit`/:meth:`run`.
"""
__slots__ = ("_executor",)
def __init__(self, *, name: str = "hl-sandbox") -> None:
self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix=name)
def submit(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> Future[_T]:
return self._executor.submit(fn, *args, **kwargs)
def run(self, fn: Callable[..., _T], /, *args: Any, **kwargs: Any) -> _T:
return self._executor.submit(fn, *args, **kwargs).result()
def shutdown(self) -> None:
# Do not block on shutdown; stop accepting new tasks, but allow the currently running
# task and any already-queued tasks to finish before the worker thread exits.
self._executor.shutdown(wait=False, cancel_futures=False)
@dataclass
class _SandboxEntry:
sandbox: Any
snapshot: Any
input_dir: TemporaryDirectory[str] | None
output_dir: TemporaryDirectory[str] | None
lock: threading.RLock
worker: _SandboxWorker = field(default_factory=_SandboxWorker)
def _load_sandbox_class() -> type[Any]:
@@ -106,10 +139,6 @@ def _load_sandbox_class() -> type[Any]:
return Sandbox
def _passthrough_result_parser(result: Any) -> str:
return repr(result)
def _collect_tools(*tool_groups: Any) -> list[FunctionTool]:
tools_by_name: dict[str, FunctionTool] = {}
@@ -166,7 +195,7 @@ def _is_file_mount_pair(value: Any) -> TypeGuard[FileMount | tuple[FileMountHost
return isinstance(host_path, (str, Path)) and isinstance(mount_path, str)
def _normalize_file_mount_input(file_mount: FileMountInput) -> _StoredFileMount:
def _normalize_file_mount_input(file_mount: FileMountInput) -> FileMount:
host_path: FileMountHostPath
mount_path: str
if isinstance(file_mount, str):
@@ -176,7 +205,7 @@ def _normalize_file_mount_input(file_mount: FileMountInput) -> _StoredFileMount:
host_path = file_mount[0]
mount_path = file_mount[1]
return _StoredFileMount(
return FileMount(
host_path=_resolve_existing_path(host_path),
mount_path=_normalize_mount_path(mount_path),
)
@@ -445,18 +474,13 @@ def _build_execution_contents(
def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
sandbox_tool = copy.copy(tool_obj)
# Auto-assign a passthrough parser so the raw return value round-trips through
# `ast.literal_eval` in the sandbox callback below. User-supplied parsers are
# left in place so callers can customize how results are exposed to the guest.
if sandbox_tool.result_parser is None:
sandbox_tool.result_parser = _passthrough_result_parser
sandbox_tool = copy(tool_obj)
def _callback(**kwargs: Any) -> Any:
async def _invoke() -> list[Content]:
return await sandbox_tool.invoke(arguments=kwargs)
async def _invoke() -> Any:
return await sandbox_tool.invoke(arguments=kwargs, skip_parsing=True)
# FunctionTool.invoke() is always async. The real Hyperlight backend invokes
# FunctionTool.invoke() is async. The real Hyperlight backend invokes
# registered callbacks synchronously via FFI, so this must be a sync function.
# We run the async call on a dedicated thread to avoid conflicts with any
# event loop that may be running on the current thread.
@@ -474,22 +498,11 @@ def _make_sandbox_callback(tool_obj: FunctionTool) -> Callable[..., Any]:
worker.join()
if error_box:
raise error_box[0]
contents: list[Content] = result_box[0]
values: list[Any] = []
for content in contents:
if content.type == "text" and content.text is not None:
try:
values.append(ast.literal_eval(content.text))
except (SyntaxError, ValueError):
values.append(content.text)
continue
values.append(content.to_dict())
if len(values) == 1:
return values[0]
return values
# Return the raw value. The Hyperlight FFI marshals primitives (dict, list,
# str, int, float, bool, None) natively into the guest, and falls back to
# repr()/str() for unsupported types — so the guest receives real Python
# objects without a lossy host-side serialization round-trip.
return result_box[0]
return _callback
@@ -509,7 +522,7 @@ def _clear_directory(output_dir: TemporaryDirectory[str] | None) -> None:
pass
class _SandboxRegistry:
class _SandboxRegistry(SandboxRuntime):
def __init__(self) -> None:
self._entries: dict[tuple[Any, ...], _SandboxEntry] = {}
self._entries_lock = threading.RLock()
@@ -517,28 +530,54 @@ class _SandboxRegistry:
def execute(self, *, config: _RunConfig, code: str) -> list[Content]:
"""Execute code in a cached sandbox matching the given config.
Entries are keyed by ``config.cache_key()``. Concurrent calls with the same
key are serialized by the entry lock so they never race, but they share the
same sandbox instance. For true parallel execution, use distinct provider
instances or configs that produce different cache keys.
Entries are keyed by ``config.cache_key()``. All operations against the underlying
sandbox object are routed through the entry's dedicated single-threaded worker, which
both serializes concurrent callers and satisfies the PyO3 ``unsendable`` invariant
that the sandbox can only be touched from the thread that created it.
"""
entry = self._get_or_create_entry(config)
return entry.worker.run(self._run_on_worker, entry, code)
@staticmethod
def _run_on_worker(entry: _SandboxEntry, code: str) -> list[Content]:
entry.sandbox.restore(entry.snapshot)
_clear_directory(entry.output_dir)
result = entry.sandbox.run(code=code)
return _build_execution_contents(
result=result,
sandbox=entry.sandbox,
output_dir=entry.output_dir,
code=code,
)
def _get_or_create_entry(self, config: _RunConfig) -> _SandboxEntry:
cache_key = config.cache_key()
with self._entries_lock:
entry = self._entries.get(cache_key)
if entry is None:
entry = self._create_entry(config)
self._entries[cache_key] = entry
return entry
with entry.lock:
entry.sandbox.restore(entry.snapshot)
_clear_directory(entry.output_dir)
result = entry.sandbox.run(code=code)
return _build_execution_contents(
result=result,
sandbox=entry.sandbox,
output_dir=entry.output_dir,
code=code,
)
def close(self) -> None:
"""Shut down all per-entry worker threads and release per-entry resources.
Safe to call multiple times. Runs any sandbox close hook on the entry's
own worker thread to honor the PyO3 ``unsendable`` invariant.
"""
with self._entries_lock:
entries = list(self._entries.values())
self._entries.clear()
for entry in entries:
close_hook = getattr(entry.sandbox, "close", None) or getattr(entry.sandbox, "shutdown", None)
if callable(close_hook):
with suppress(Exception):
entry.worker.run(close_hook)
entry.worker.shutdown()
for tmp_dir in (entry.input_dir, entry.output_dir):
if tmp_dir is not None:
with suppress(Exception):
tmp_dir.cleanup()
def _create_entry(self, config: _RunConfig) -> _SandboxEntry:
input_dir_handle = TemporaryDirectory() if config.filesystem_enabled else None
@@ -578,26 +617,37 @@ class _SandboxRegistry:
methods=list(allowed_domain.methods) if allowed_domain.methods is not None else None,
)
sandbox = _create_sandbox()
_configure_sandbox(sandbox=sandbox, expand_missing_scheme=False)
worker = _SandboxWorker()
def _build_sandbox() -> tuple[Any, Any]:
sandbox = _create_sandbox()
_configure_sandbox(sandbox=sandbox, expand_missing_scheme=False)
try:
sandbox.run("None")
except RuntimeError as exc:
if not _should_retry_allowed_domain_registration(error=exc, allowed_domains=config.allowed_domains):
raise
sandbox = _create_sandbox()
_configure_sandbox(sandbox=sandbox, expand_missing_scheme=True)
sandbox.run("None")
snapshot = sandbox.snapshot()
return sandbox, snapshot
try:
sandbox.run("None")
except RuntimeError as exc:
if not _should_retry_allowed_domain_registration(error=exc, allowed_domains=config.allowed_domains):
raise
sandbox, snapshot = worker.run(_build_sandbox)
except BaseException:
worker.shutdown()
raise
sandbox = _create_sandbox()
_configure_sandbox(sandbox=sandbox, expand_missing_scheme=True)
sandbox.run("None")
snapshot = sandbox.snapshot()
return _SandboxEntry(
sandbox=sandbox,
snapshot=snapshot,
input_dir=input_dir_handle,
output_dir=output_dir_handle,
lock=threading.RLock(),
worker=worker,
)
@@ -619,10 +669,10 @@ class HyperlightExecuteCodeTool(FunctionTool):
) -> None:
super().__init__(
name="execute_code",
description=EXECUTE_CODE_INPUT_DESCRIPTION,
description=EXECUTE_CODE_TOOL_DESCRIPTION,
approval_mode="never_require",
func=self._run_code,
input_model=_ExecuteCodeInput,
input_model=EXECUTE_CODE_INPUT_SCHEMA,
)
self._state_lock = threading.RLock()
self._registry = _registry or _SandboxRegistry()
@@ -632,7 +682,7 @@ class HyperlightExecuteCodeTool(FunctionTool):
self._module: str | None = module
self._module_path: str | None = module_path
self._managed_tools: list[FunctionTool] = []
self._file_mounts: dict[str, _StoredFileMount] = {}
self._file_mounts: dict[str, FileMount] = {}
self._allowed_domains: dict[str, AllowedDomain] = {}
if tools is not None:
@@ -648,7 +698,7 @@ class HyperlightExecuteCodeTool(FunctionTool):
def description(self) -> str:
state_lock = getattr(self, "_state_lock", None)
if state_lock is None:
return str(self.__dict__.get("description", EXECUTE_CODE_INPUT_DESCRIPTION))
return str(self.__dict__.get("description", EXECUTE_CODE_TOOL_DESCRIPTION))
with state_lock:
allowed_domains = sorted(self._allowed_domains.values(), key=lambda value: value.target)
@@ -841,9 +891,9 @@ class HyperlightExecuteCodeTool(FunctionTool):
workspace_signature = _path_tree_signature(workspace_root) if workspace_root is not None else ()
normalized_mounts = tuple(
_NormalizedFileMount(
host_path=mount.host_path,
host_path=Path(mount.host_path),
mount_path=mount.mount_path,
path_signature=_path_tree_signature(mount.host_path),
path_signature=_path_tree_signature(Path(mount.host_path)),
)
for mount in stored_mounts
)
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Hyperlight CodeAct integrations for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0a260421"
version = "1.0.0a260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"hyperlight-sandbox>=0.3.0,<0.4",
"hyperlight-sandbox-backend-wasm>=0.3.0,<0.4 ; ((sys_platform == 'linux' and platform_machine == 'x86_64') or (sys_platform == 'win32' and platform_machine == 'AMD64')) and python_version < '3.14'",
"hyperlight-sandbox-python-guest>=0.3.0,<0.4",
@@ -937,3 +937,191 @@ async def test_run_code_does_not_block_event_loop() -> None:
assert concurrent_ran, "Event loop was blocked during sandbox execution"
assert result[0].type == "text"
class _ThreadAffinityFakeSandbox(_FakeSandbox):
"""Fake sandbox that records the OS thread of every method invocation.
Mirrors the PyO3 ``unsendable`` invariant of ``hyperlight_sandbox.WasmSandbox``:
if ``__init__``, ``register_tool``, ``allow_domain``, ``run``, ``snapshot`` or ``restore``
are ever called from more than one thread for a given instance, the test fails.
"""
affinity_failures: list[str] = []
def __init__(self, **kwargs: Any) -> None:
super().__init__(**kwargs)
self._owner_thread = threading.get_ident()
self.thread_ids: set[int] = {self._owner_thread}
def _record(self, method: str) -> None:
ident = threading.get_ident()
self.thread_ids.add(ident)
if ident != self._owner_thread:
_ThreadAffinityFakeSandbox.affinity_failures.append(
f"{method} called from thread {ident}, expected {self._owner_thread}"
)
def register_tool(self, name_or_tool: Any, callback: Any | None = None) -> None:
self._record("register_tool")
super().register_tool(name_or_tool, callback)
def allow_domain(self, target: str, methods: list[str] | None = None) -> None:
self._record("allow_domain")
super().allow_domain(target, methods)
def run(self, code: str) -> _FakeResult:
self._record("run")
return super().run(code)
def snapshot(self) -> str:
self._record("snapshot")
return super().snapshot()
def restore(self, snapshot: Any) -> None:
self._record("restore")
super().restore(snapshot)
async def test_sandbox_calls_are_pinned_to_owning_worker_thread(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Regression: WasmSandbox is unsendable; every sandbox call must run on its owner thread."""
_ThreadAffinityFakeSandbox.instances.clear()
_ThreadAffinityFakeSandbox.affinity_failures.clear()
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ThreadAffinityFakeSandbox)
execute_code = HyperlightExecuteCodeTool()
# Invoke many times concurrently; asyncio.to_thread will spread these across the default
# executor's worker threads, which previously caused PyO3 to panic when a different thread
# touched the cached sandbox.
results = await asyncio.gather(*[execute_code.invoke(arguments={"code": "None"}) for _ in range(8)])
for result in results:
assert result[0].type == "text"
assert _ThreadAffinityFakeSandbox.affinity_failures == []
assert len(_ThreadAffinityFakeSandbox.instances) == 1
sandbox = _ThreadAffinityFakeSandbox.instances[0]
# All sandbox-touching calls must have stayed on a single owning thread, distinct from the
# caller thread that asyncio.to_thread used for dispatch.
assert sandbox.thread_ids == {sandbox._owner_thread}
assert sandbox._owner_thread != threading.get_ident()
async def test_sandbox_owner_thread_persists_across_dispatch_threads(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Sequential calls landing on different dispatch threads still share one sandbox thread."""
_ThreadAffinityFakeSandbox.instances.clear()
_ThreadAffinityFakeSandbox.affinity_failures.clear()
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ThreadAffinityFakeSandbox)
execute_code = HyperlightExecuteCodeTool()
for _ in range(5):
result = await execute_code.invoke(arguments={"code": "None"})
assert result[0].type == "text"
assert _ThreadAffinityFakeSandbox.affinity_failures == []
assert len(_ThreadAffinityFakeSandbox.instances) == 1
def test_sandbox_registry_close_shuts_down_workers(monkeypatch: pytest.MonkeyPatch) -> None:
_FakeSandbox.instances.clear()
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _FakeSandbox)
registry = execute_code_module._SandboxRegistry()
execute_code = HyperlightExecuteCodeTool(_registry=registry)
asyncio.run(execute_code.invoke(arguments={"code": "None"}))
entries = list(registry._entries.values())
assert len(entries) == 1
worker = entries[0].worker
registry.close()
assert registry._entries == {}
# Submitting after shutdown must fail; this proves the executor was actually torn down.
with pytest.raises(RuntimeError):
worker.submit(lambda: None)
def test_sandbox_registry_close_releases_per_entry_resources(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
"""close() must invoke any sandbox close hook and release temp directories."""
close_calls: list[int] = []
class _ClosableFakeSandbox(_FakeSandbox):
def close(self) -> None:
close_calls.append(1)
_FakeSandbox.instances.clear()
monkeypatch.setattr(execute_code_module, "_load_sandbox_class", lambda: _ClosableFakeSandbox)
workspace = tmp_path / "workspace"
workspace.mkdir()
registry = execute_code_module._SandboxRegistry()
execute_code = HyperlightExecuteCodeTool(workspace_root=workspace, _registry=registry)
asyncio.run(execute_code.invoke(arguments={"code": "None"}))
entries = list(registry._entries.values())
assert len(entries) == 1
entry = entries[0]
assert entry.input_dir is not None and entry.output_dir is not None
input_path = Path(entry.input_dir.name)
output_path = Path(entry.output_dir.name)
assert input_path.exists() and output_path.exists()
registry.close()
assert close_calls == [1]
assert not input_path.exists()
assert not output_path.exists()
async def test_make_sandbox_callback_returns_native_dict() -> None:
"""Host tool returning a dict must be forwarded as a native dict (no repr round-trip)."""
@tool
def get_weather(city: str) -> dict[str, Any]:
"""Get weather."""
return {"city": city, "temp_c": 21.5}
callback = execute_code_module._make_sandbox_callback(get_weather)
result = callback(city="Seattle")
assert isinstance(result, dict)
assert result == {"city": "Seattle", "temp_c": 21.5}
async def test_make_sandbox_callback_bypasses_user_result_parser() -> None:
"""Documented behavior change: result_parser is bypassed in the sandbox path."""
parser_calls: list[Any] = []
def parser(value: Any) -> str:
parser_calls.append(value)
return "PARSED"
@tool(result_parser=parser)
def make_payload() -> dict[str, int]:
"""Returns a dict."""
return {"a": 1, "b": 2}
callback = execute_code_module._make_sandbox_callback(make_payload)
result = callback()
assert result == {"a": 1, "b": 2}
assert parser_calls == [], "result_parser must not run on the sandbox path"
async def test_make_sandbox_callback_propagates_exceptions() -> None:
@tool
def boom(x: int) -> int:
"""Always fails."""
raise RuntimeError("nope")
callback = execute_code_module._make_sandbox_callback(boom)
with pytest.raises(RuntimeError, match="nope"):
callback(x=1)
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Experimental modules for Microsoft Agent Framework"
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://aka.ms/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -22,7 +22,7 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
]
[project.optional-dependencies]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Mem0 integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"mem0ai>=1.0.0,<2",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Ollama integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.1,<2",
"ollama>=0.5.3,<0.5.4",
]
+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.1.0"
version = "1.1.1"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"openai>=1.99.0,<3",
]
@@ -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.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
]
[tool.uv]
@@ -11,7 +11,7 @@ from typing import Any, Literal, TypeVar, Union, overload
from uuid import uuid4
import httpx
from agent_framework import AGENT_FRAMEWORK_USER_AGENT
from agent_framework._telemetry import get_user_agent
from agent_framework.observability import get_tracer
from azure.core.credentials import TokenCredential
from azure.core.credentials_async import AsyncTokenCredential
@@ -189,7 +189,7 @@ class PurviewClient:
payload = model.model_dump(by_alias=True, exclude_none=True, mode="json")
request_headers = {
"Authorization": f"Bearer {token}",
"User-Agent": AGENT_FRAMEWORK_USER_AGENT,
"User-Agent": get_user_agent(),
"Content-Type": "application/json",
}
if correlation_id:
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Microsoft Purview (Graph dataSecurityAndGovernance) integration f
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
license-files = ["LICENSE"]
urls.homepage = "https://github.com/microsoft/agent-framework"
urls.source = "https://github.com/microsoft/agent-framework/tree/main/python"
@@ -24,7 +24,7 @@ classifiers = [
"Typing :: Typed",
]
dependencies = [
"agent-framework-core>=1.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"azure-core>=1.30.0,<2",
"httpx>=0.27.0,<0.29",
]
+2 -2
View File
@@ -4,7 +4,7 @@ description = "Redis integration for Microsoft Agent Framework."
authors = [{ name = "Microsoft", email = "af-support@microsoft.com"}]
readme = "README.md"
requires-python = ">=3.10"
version = "1.0.0b260421"
version = "1.0.0b260423"
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.1.0,<2",
"agent-framework-core>=1.1.1,<2",
"redis>=6.4.0,<7.2.1",
"redisvl>=0.11.0,<0.16",
"numpy>=2.2.6,<3"
+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.1.0"
version = "1.1.1"
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.1.0",
"agent-framework-core[all]==1.1.1",
]
[dependency-groups]
@@ -42,11 +42,12 @@ def create_sample_toolbox(name: str) -> str:
Toolboxes are normally configured in the Foundry portal or a deployment
script, not the application itself. This helper exists so the samples can
be run end-to-end without first setting a toolbox up by hand — delete any
existing toolbox under ``name``, then create a fresh version containing a
single MCP tool. Returns the created version identifier.
existing toolbox under ``name``, then create a fresh version containing an
MCP tool, a web search tool, and a code interpreter tool. Returns the
created version identifier.
"""
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import MCPTool, Tool
from azure.ai.projects.models import CodeInterpreterTool, MCPTool, Tool, WebSearchTool
from azure.core.exceptions import ResourceNotFoundError
with (
@@ -67,6 +68,9 @@ def create_sample_toolbox(name: str) -> str:
)
]
tools.append(WebSearchTool(name="web_search"))
tools.append(CodeInterpreterTool(name="code_interpreter"))
created = project_client.beta.toolboxes.create_version(
name=name,
description="Toolbox version with MCP require_approval set to 'never'.",
@@ -3,6 +3,7 @@
import os
import subprocess
from random import randint
from typing import Annotated
from agent_framework import Agent, tool
from agent_framework.foundry import FoundryChatClient
@@ -10,7 +11,6 @@ from agent_framework_foundry_hosting import ResponsesHostServer
from azure.identity import AzureCliCredential
from dotenv import load_dotenv
from pydantic import Field
from typing import Annotated
# Load environment variables from .env file
load_dotenv()
+11
View File
@@ -0,0 +1,11 @@
# Copyright (c) Microsoft. All rights reserved.
"""Flaky test report aggregation and trend generation.
Parses JUnit XML (``pytest.xml``) files produced by each CI job, merges
them with historical data, and generates a markdown trend report showing
per-test status across the last N runs.
Usage:
uv run python -m scripts.flaky_report <reports-dir> <history-file> <output-file>
"""
+20
View File
@@ -0,0 +1,20 @@
# Copyright (c) Microsoft. All rights reserved.
"""CLI entry point for the flaky test report tool.
Usage:
uv run python -m scripts.flaky_report <reports-dir> <history-file> <output-file>
Example (from python/ directory):
uv run python -m scripts.flaky_report \\
../flaky-reports/ \\
flaky-report-history.json \\
flaky-test-report.md
"""
import sys
from scripts.flaky_report.aggregate import main
if __name__ == "__main__":
sys.exit(main())
+396
View File
@@ -0,0 +1,396 @@
# Copyright (c) Microsoft. All rights reserved.
"""Aggregate per-provider JUnit XML test results and generate a trend report.
Parses ``pytest.xml`` (JUnit XML) files produced by each CI job, merges them
into a single run, combines with historical data, and generates a markdown
trend table — the same pattern used by ``scripts/sample_validation/aggregate.py``.
Usage (from CI):
python aggregate.py <reports-dir> <history-file> <output-file>
The reports directory is expected to contain subdirectories named
``test-results-<provider>/`` each containing a ``pytest.xml`` file
(created by ``actions/download-artifact``).
"""
from __future__ import annotations
import json
import sys
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
MAX_HISTORY = 5
STATUS_EMOJI = {
"passed": "âś…",
"failed": "❌",
"skipped": "⏭️",
"xfailed": "⚠️",
"error": "❌",
}
def _format_run_label(timestamp: str) -> str:
"""Format a timestamp as a compact column label (e.g. '04-16 00:57')."""
try:
dt = datetime.fromisoformat(timestamp)
return dt.strftime("%m-%d %H:%M")
except (ValueError, TypeError):
return timestamp[:16]
def _derive_provider(directory_name: str) -> str:
"""Derive a provider label from a report directory name.
``test-results-openai`` → ``OpenAI``
``test-results-azure-openai`` → ``Azure OpenAI``
"""
raw = directory_name.replace("test-results-", "")
known = {
"openai": "OpenAI",
"azure-openai": "Azure OpenAI",
"misc": "Misc (Anthropic, Ollama, MCP)",
"functions": "Functions",
"foundry": "Foundry",
"cosmos": "Cosmos",
"unit": "Unit",
}
if raw in known:
return known[raw]
parts = raw.split("-")
return " ".join(p.capitalize() for p in parts)
def _parse_junit_xml(xml_path: Path) -> list[dict[str, str]]:
"""Parse a JUnit XML file and return a list of test result dicts.
Each dict has keys: ``nodeid``, ``status``, ``duration``, ``message``.
"""
results: list[dict[str, str]] = []
try:
tree = ET.parse(xml_path) # noqa: S314
except ET.ParseError as exc:
print(f"Warning: failed to parse JUnit XML report '{xml_path}': {exc}", file=sys.stderr)
return results
root = tree.getroot()
# Handle both <testsuites><testsuite>... and <testsuite>... layouts
testcases: list[ET.Element] = []
if root.tag == "testsuites":
for suite in root.findall("testsuite"):
testcases.extend(suite.findall("testcase"))
elif root.tag == "testsuite":
testcases = list(root.findall("testcase"))
for tc in testcases:
classname = tc.get("classname", "")
name = tc.get("name", "")
duration = tc.get("time", "0")
# Use classname::name as a stable identifier.
# pytest writes classname as the dotted module path (possibly including
# a test class), e.g. "packages.openai.tests.openai.test_chat_client"
# or "packages.openai.tests.openai.test_chat_client.TestClass".
nodeid = f"{classname}::{name}" if classname else name
# Extract module/file name from classname for display context.
# pytest writes classname as a dotted path. For tests inside a class
# it appends the class name, e.g.:
# "packages.foundry.tests.foundry.test_foundry_embedding_client.TestFoundryEmbeddingIntegration"
# We want the file-level module: "test_foundry_embedding_client"
if classname:
parts = classname.rsplit(".", 2)
# If the last segment starts with uppercase it's a class name — take the one before it
if len(parts) >= 2 and parts[-1][0:1].isupper():
module = parts[-2]
else:
module = parts[-1]
else:
module = ""
# Determine status from child elements
failure = tc.find("failure")
error = tc.find("error")
skipped = tc.find("skipped")
if failure is not None:
status = "failed"
message = failure.get("message", "")
elif error is not None:
status = "error"
message = error.get("message", "")
elif skipped is not None:
# pytest marks xfail as <skipped type="pytest.xfail">
skip_type = skipped.get("type", "")
status = "xfailed" if "xfail" in skip_type else "skipped"
message = skipped.get("message", "")
else:
status = "passed"
message = ""
results.append({
"nodeid": nodeid,
"status": status,
"duration": duration,
"message": message,
"module": module,
})
return results
# ---------------------------------------------------------------------------
# Loading
# ---------------------------------------------------------------------------
def load_current_run(reports_dir: Path) -> dict[str, Any]:
"""Load per-provider JUnit XML reports from the current CI run and merge.
Args:
reports_dir: Directory containing ``test-results-<provider>/`` subdirs.
Returns:
Merged run dict with ``timestamp``, ``summary``, ``results``.
"""
combined_results: dict[str, dict[str, str]] = {} # nodeid → {status, provider}
# actions/download-artifact creates: reports_dir/test-results-openai/pytest.xml
xml_files: list[tuple[str, Path]] = []
if reports_dir.is_dir():
for subdir in sorted(reports_dir.iterdir()):
if subdir.is_dir():
xml_file = subdir / "pytest.xml"
if xml_file.exists():
xml_files.append((subdir.name, xml_file))
if not xml_files:
print(f"Warning: No pytest.xml files found in {reports_dir}")
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"summary": {
"total": 0,
"passed": 0,
"failed": 0,
"skipped": 0,
},
"results": {},
}
for dir_name, xml_file in xml_files:
print(f" Loading: {xml_file}")
provider = _derive_provider(dir_name)
tests = _parse_junit_xml(xml_file)
for test in tests:
combined_results[test["nodeid"]] = {
"status": test["status"],
"provider": provider,
"module": test.get("module", ""),
}
# Build summary counts using mutually exclusive status buckets.
# Errors are folded into the failed count for display purposes.
statuses = [r["status"] for r in combined_results.values()]
summary = {
"total": len(statuses),
"passed": statuses.count("passed"),
"failed": statuses.count("failed") + statuses.count("error"),
"skipped": statuses.count("skipped"),
}
return {
"timestamp": datetime.now(timezone.utc).isoformat(),
"summary": summary,
"results": combined_results,
}
def load_history(history_path: Path) -> list[dict[str, Any]]:
"""Load previous run history from a cache file."""
if history_path.exists():
with open(history_path, encoding="utf-8") as f:
data = json.load(f)
runs = data.get("runs", [])
print(f" Loaded {len(runs)} previous run(s) from history")
return runs
print(" No previous history found")
return []
def save_history(history_path: Path, runs: list[dict[str, Any]]) -> None:
"""Save run history, keeping only the last ``MAX_HISTORY`` entries."""
history_path.parent.mkdir(parents=True, exist_ok=True)
trimmed = runs[-MAX_HISTORY:]
with open(history_path, "w", encoding="utf-8") as f:
json.dump({"runs": trimmed}, f, indent=2)
print(f" Saved {len(trimmed)} run(s) to history")
# ---------------------------------------------------------------------------
# Report generation
# ---------------------------------------------------------------------------
def _short_name(nodeid: str) -> str:
"""Extract a short test name from a full nodeid.
``packages.openai.tests.openai.test_openai_chat_client::test_integration_options``
→ ``test_integration_options``
"""
return nodeid.split("::")[-1] if "::" in nodeid else nodeid
def generate_trend_report(runs: list[dict[str, Any]]) -> str:
"""Generate a markdown trend report from run history."""
lines = [
"# 🔬 Flaky Test Report",
"",
f"*Generated: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}*",
"",
]
# --- Overall status table (most recent first) ---
lines.append("## Overall Status (Last 5 Runs)")
lines.append("")
lines.append("| Run | Total | ✅ Passed | ❌ Failed | ⏭️ Skipped |")
lines.append("|-----|-------|-----------|-----------|------------|")
for run in reversed(runs):
s = run.get("summary", {})
total = s.get("total", 0)
label = _format_run_label(run["timestamp"])
lines.append(
f"| {label} "
f"| {total} "
f"| {s.get('passed', 0)}/{total} "
f"| {s.get('failed', 0)}/{total} "
f"| {s.get('skipped', 0)}/{total} |"
)
for _ in range(MAX_HISTORY - len(runs)):
lines.append("| N/A | N/A | N/A | N/A | N/A |")
lines.append("")
# --- Per-test results table ---
lines.append("## Per-Test Results")
lines.append("")
# Collect all test nodeids, providers, and modules across all runs
all_tests: dict[str, str] = {} # nodeid → provider (from most recent run)
all_modules: dict[str, str] = {} # nodeid → module (from most recent run)
for run in runs:
for nodeid, info in run.get("results", {}).items():
provider = info.get("provider", "Unknown") if isinstance(info, dict) else "Unknown"
module = info.get("module", "") if isinstance(info, dict) else ""
all_tests[nodeid] = provider
all_modules[nodeid] = module
if not all_tests:
lines.append("*No test results available.*")
return "\n".join(lines)
# Build header (most recent run first)
header = "| Test | File | Provider |"
separator = "|------|------|----------|"
for run in reversed(runs):
label = _format_run_label(run["timestamp"])
header += f" {label} |"
separator += "------------|"
for _ in range(MAX_HISTORY - len(runs)):
header += " N/A |"
separator += "-----|"
lines.append(header)
lines.append(separator)
# Sort by provider then test name
for nodeid in sorted(all_tests, key=lambda n: (all_tests[n], n)):
provider = all_tests[nodeid]
module = all_modules.get(nodeid, "")
short = _short_name(nodeid)
row = f"| `{short}` | `{module}` | {provider} |"
for run in reversed(runs):
result = run.get("results", {}).get(nodeid)
if result is None:
emoji = "N/A"
else:
status = result.get("status", "N/A") if isinstance(result, dict) else result
emoji = STATUS_EMOJI.get(status, "âť“")
row += f" {emoji} |"
for _ in range(MAX_HISTORY - len(runs)):
row += " N/A |"
lines.append(row)
lines.append("")
lines.append("**Legend:** ✅ Passed · ❌ Failed · ⏭️ Skipped · ⚠️ Expected Failure (xfail) · N/A Not available")
lines.append("")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI entry point
# ---------------------------------------------------------------------------
def main() -> int:
if len(sys.argv) != 4:
print("Usage: python aggregate.py <reports-dir> <history-file> <output-file>")
return 1
reports_dir = Path(sys.argv[1])
history_path = Path(sys.argv[2])
output_path = Path(sys.argv[3])
print("Aggregating test results from JUnit XML...")
# Load current run's per-provider XML reports
print(f"\nLoading reports from {reports_dir}:")
current_run = load_current_run(reports_dir)
s = current_run.get("summary", {})
total = s.get("total", 0)
print(
f" Current run: {s.get('passed', 0)} passed, "
f"{s.get('failed', 0)} failed, "
f"{s.get('skipped', 0)} skipped "
f"(total: {total})"
)
# Load history and append current run (skip empty runs to avoid polluting trend)
print(f"\nLoading history from {history_path}:")
runs = load_history(history_path)
if total > 0:
runs.append(current_run)
runs = runs[-MAX_HISTORY:]
else:
print(" Skipping history append (no test results in current run)")
# Save updated history
print(f"\nSaving history to {history_path}:")
save_history(history_path, runs)
# Generate trend report
print("\nGenerating trend report...")
report = generate_trend_report(runs)
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(report, encoding="utf-8")
print(f"Trend report written to {output_path}")
# Print the report to stdout for CI visibility
print("\n" + "=" * 80)
print(report)
return 0
if __name__ == "__main__":
sys.exit(main())
+28 -28
View File
@@ -96,7 +96,7 @@ wheels = [
[[package]]
name = "agent-framework"
version = "1.1.0"
version = "1.1.1"
source = { virtual = "." }
dependencies = [
{ name = "agent-framework-core", extra = ["all"], marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -151,7 +151,7 @@ dev = [
[[package]]
name = "agent-framework-a2a"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/a2a" }
dependencies = [
{ name = "a2a-sdk", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -166,7 +166,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ag-ui"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/ag-ui" }
dependencies = [
{ name = "ag-ui-protocol", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -194,7 +194,7 @@ provides-extras = ["dev"]
[[package]]
name = "agent-framework-anthropic"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/anthropic" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -209,7 +209,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-ai-search"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/azure-ai-search" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -224,7 +224,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azure-cosmos"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/azure-cosmos" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -239,7 +239,7 @@ requires-dist = [
[[package]]
name = "agent-framework-azurefunctions"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/azurefunctions" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -261,7 +261,7 @@ dev = []
[[package]]
name = "agent-framework-bedrock"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/bedrock" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -278,7 +278,7 @@ requires-dist = [
[[package]]
name = "agent-framework-chatkit"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/chatkit" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -293,7 +293,7 @@ requires-dist = [
[[package]]
name = "agent-framework-claude"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/claude" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -308,7 +308,7 @@ requires-dist = [
[[package]]
name = "agent-framework-copilotstudio"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/copilotstudio" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -323,7 +323,7 @@ requires-dist = [
[[package]]
name = "agent-framework-core"
version = "1.1.0"
version = "1.1.1"
source = { editable = "packages/core" }
dependencies = [
{ name = "opentelemetry-api", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -395,7 +395,7 @@ provides-extras = ["all"]
[[package]]
name = "agent-framework-declarative"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/declarative" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -420,7 +420,7 @@ dev = [{ name = "types-pyyaml", specifier = "==6.0.12.20250915" }]
[[package]]
name = "agent-framework-devui"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/devui" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -458,7 +458,7 @@ provides-extras = ["dev", "all"]
[[package]]
name = "agent-framework-durabletask"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/durabletask" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -485,7 +485,7 @@ dev = [{ name = "types-python-dateutil", specifier = "==2.9.0.20260402" }]
[[package]]
name = "agent-framework-foundry"
version = "1.1.0"
version = "1.1.1"
source = { editable = "packages/foundry" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -504,7 +504,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-hosting"
version = "1.0.0a260421"
version = "1.0.0a260423"
source = { editable = "packages/foundry_hosting" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -523,7 +523,7 @@ requires-dist = [
[[package]]
name = "agent-framework-foundry-local"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/foundry_local" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -540,7 +540,7 @@ requires-dist = [
[[package]]
name = "agent-framework-gemini"
version = "1.0.0a260421"
version = "1.0.0a260423"
source = { editable = "packages/gemini" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -555,7 +555,7 @@ requires-dist = [
[[package]]
name = "agent-framework-github-copilot"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/github_copilot" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -570,7 +570,7 @@ requires-dist = [
[[package]]
name = "agent-framework-hyperlight"
version = "1.0.0a260421"
version = "1.0.0a260423"
source = { editable = "packages/hyperlight" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -589,7 +589,7 @@ requires-dist = [
[[package]]
name = "agent-framework-lab"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/lab" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -670,7 +670,7 @@ dev = [
[[package]]
name = "agent-framework-mem0"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/mem0" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -685,7 +685,7 @@ requires-dist = [
[[package]]
name = "agent-framework-ollama"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/ollama" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -700,7 +700,7 @@ requires-dist = [
[[package]]
name = "agent-framework-openai"
version = "1.1.0"
version = "1.1.1"
source = { editable = "packages/openai" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -715,7 +715,7 @@ requires-dist = [
[[package]]
name = "agent-framework-orchestrations"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/orchestrations" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -726,7 +726,7 @@ requires-dist = [{ name = "agent-framework-core", editable = "packages/core" }]
[[package]]
name = "agent-framework-purview"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/purview" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -743,7 +743,7 @@ requires-dist = [
[[package]]
name = "agent-framework-redis"
version = "1.0.0b260421"
version = "1.0.0b260423"
source = { editable = "packages/redis" }
dependencies = [
{ name = "agent-framework-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },