Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd5d282827 | |||
| 535690cd1d | |||
| 85fde62a76 | |||
| 85eb53d412 | |||
| 2d7c8da6b0 | |||
| e0b0b79d9e | |||
| a4f6c26990 | |||
| 1389f304f2 | |||
| 93719f4a34 | |||
| a486374fd8 | |||
| e78604103d |
@@ -9,14 +9,12 @@
|
||||
* @param {object} opts.core - GitHub Actions core toolkit
|
||||
* @param {string} opts.teamSlug - Team slug to check membership against
|
||||
* @param {string|number} opts.issueNumber - Issue or pull request number to resolve author for
|
||||
* @param {string} [opts.username] - Explicit user to check instead of the issue or pull request author
|
||||
* @returns {Promise<{author: string|null, isTeamMember: boolean}>}
|
||||
*/
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber, username = '' }) {
|
||||
let author = username.trim() || (
|
||||
async function checkTeamMembership({ github, context, core, teamSlug, issueNumber }) {
|
||||
let author =
|
||||
context.payload.issue?.user?.login ??
|
||||
context.payload.pull_request?.user?.login
|
||||
);
|
||||
context.payload.pull_request?.user?.login;
|
||||
|
||||
if (!author) {
|
||||
const number = Number(issueNumber);
|
||||
|
||||
@@ -74,28 +74,6 @@ const BASE_OPTS = { teamSlug: 'my-team', issueNumber: '123' };
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('author resolution', () => {
|
||||
it('uses an explicit username instead of the issue author', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'issue-author' } },
|
||||
});
|
||||
let issuesGetCalled = false;
|
||||
github.rest.issues.get = async () => {
|
||||
issuesGetCalled = true;
|
||||
return { data: { user: { login: 'api-user' } } };
|
||||
};
|
||||
|
||||
const result = await checkTeamMembership({
|
||||
github,
|
||||
context,
|
||||
core,
|
||||
...BASE_OPTS,
|
||||
username: 'comment-author',
|
||||
});
|
||||
|
||||
assert.equal(result.author, 'comment-author');
|
||||
assert.equal(issuesGetCalled, false);
|
||||
});
|
||||
|
||||
it('resolves author from event payload', async () => {
|
||||
const { github, context, core } = createMocks({
|
||||
payloadIssue: { user: { login: 'payload-user' } },
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
- opened
|
||||
- reopened
|
||||
- ready_for_review
|
||||
issue_comment:
|
||||
types:
|
||||
- created
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
@@ -23,7 +20,7 @@ permissions:
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr_number || github.run_id }}
|
||||
group: devflow-pr-review-${{ github.repository }}-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -31,20 +28,9 @@ env:
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
MODEL_CONFIG_PATH: ${{ github.workspace }}/devflow/config.ci.yaml
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
if: >-
|
||||
github.event_name != 'issue_comment' ||
|
||||
(
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.body == '/review' &&
|
||||
(
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'OWNER'
|
||||
)
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
environment: github-app-auth
|
||||
outputs:
|
||||
@@ -58,7 +44,6 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
PR_HTML_URL: ${{ github.event.pull_request.html_url }}
|
||||
PR_NUMBER_COMMENT: ${{ github.event.issue.number }}
|
||||
PR_NUMBER_EVENT: ${{ github.event.pull_request.number }}
|
||||
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||
run: |
|
||||
@@ -67,9 +52,6 @@ jobs:
|
||||
if [[ "${GITHUB_EVENT_NAME}" == "pull_request_target" ]]; then
|
||||
pr_number="${PR_NUMBER_EVENT}"
|
||||
pr_url="${PR_HTML_URL}"
|
||||
elif [[ "${GITHUB_EVENT_NAME}" == "issue_comment" ]]; then
|
||||
pr_number="${PR_NUMBER_COMMENT}"
|
||||
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
|
||||
else
|
||||
pr_number="${PR_NUMBER_INPUT}"
|
||||
pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${pr_number}"
|
||||
@@ -109,11 +91,10 @@ jobs:
|
||||
repository: ${{ github.repository }}
|
||||
fallback-token: ${{ secrets.GH_ACTIONS_PR_WRITE }}
|
||||
|
||||
- name: Check review requester team membership
|
||||
- name: Check PR author team membership
|
||||
id: check
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
env:
|
||||
MEMBERSHIP_USER: ${{ github.event_name == 'issue_comment' && github.event.comment.user.login || '' }}
|
||||
TEAM_NAME: ${{ secrets.DEVELOPER_TEAM }}
|
||||
PR_NUMBER: ${{ steps.pr.outputs.pr_number }}
|
||||
with:
|
||||
@@ -126,33 +107,19 @@ jobs:
|
||||
core,
|
||||
teamSlug: process.env.TEAM_NAME,
|
||||
issueNumber: process.env.PR_NUMBER,
|
||||
username: process.env.MEMBERSHIP_USER,
|
||||
});
|
||||
core.setOutput('is_team_member', isTeamMember ? 'true' : 'false');
|
||||
if (isTeamMember) {
|
||||
core.info(`User ${author} is a team member; proceeding with review.`);
|
||||
core.info(`Author ${author} is a team member; proceeding with review.`);
|
||||
} else {
|
||||
core.info(`User ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
|
||||
core.info(`Author ${author} is not a member of ${process.env.TEAM_NAME}; skipping review.`);
|
||||
}
|
||||
|
||||
- name: React to authorized review command
|
||||
if: ${{ github.event_name == 'issue_comment' && steps.check.outputs.is_team_member == 'true' }}
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
with:
|
||||
github-token: ${{ steps.github-auth.outputs.token }}
|
||||
script: |
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
...context.repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: 'eyes',
|
||||
});
|
||||
|
||||
review:
|
||||
runs-on: ubuntu-latest
|
||||
needs: team_check
|
||||
if: ${{ needs.team_check.outputs.is_team_member == 'true' }}
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
@@ -202,8 +169,8 @@ jobs:
|
||||
id: review
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
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 }}
|
||||
PR_URL: ${{ needs.team_check.outputs.pr_url }}
|
||||
@@ -211,5 +178,4 @@ jobs:
|
||||
uv run python scripts/trigger_pr_review.py \
|
||||
--pr-url "$PR_URL" \
|
||||
--github-username "$GITHUB_ACTOR" \
|
||||
--review-compare \
|
||||
--no-require-comment-selection
|
||||
|
||||
@@ -21,6 +21,8 @@ on:
|
||||
required: true
|
||||
AZUREAI__ENDPOINT:
|
||||
required: true
|
||||
COPILOT_GITHUB_TOKEN:
|
||||
required: true
|
||||
OPENAI__APIKEY:
|
||||
required: true
|
||||
|
||||
@@ -30,7 +32,6 @@ permissions:
|
||||
jobs:
|
||||
dotnet-integration-tests:
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
strategy:
|
||||
@@ -102,7 +103,7 @@ jobs:
|
||||
env:
|
||||
COSMOSDB_ENDPOINT: https://localhost:8081
|
||||
COSMOSDB_KEY: C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw==
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
OpenAI__ApiKey: ${{ secrets.OPENAI__APIKEY }}
|
||||
OpenAI__ChatModelId: ${{ vars.OPENAI__CHATMODELID }}
|
||||
OpenAI__ChatReasoningModelId: ${{ vars.OPENAI__CHATREASONINGMODELID }}
|
||||
|
||||
@@ -102,7 +102,6 @@ jobs:
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.dotnet-changes == 'true'
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/dotnet-integration-tests.yml
|
||||
@@ -113,6 +112,7 @@ jobs:
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
AZUREAI__ENDPOINT: ${{ secrets.AZUREAI__ENDPOINT }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
python-integration-tests:
|
||||
@@ -120,7 +120,6 @@ jobs:
|
||||
needs: resolve-ref
|
||||
if: needs.resolve-ref.outputs.python-changes == 'true'
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
uses: ./.github/workflows/python-integration-tests.yml
|
||||
@@ -131,5 +130,6 @@ jobs:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
AZURE_SUBSCRIPTION_ID: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
FOUNDRY_MODELS_API_KEY: ${{ secrets.FOUNDRY_MODELS_API_KEY }}
|
||||
OPENAI__APIKEY: ${{ secrets.OPENAI__APIKEY }}
|
||||
|
||||
@@ -29,7 +29,6 @@ env:
|
||||
DEVFLOW_REF: main
|
||||
TARGET_REPO_PATH: ${{ github.workspace }}/target-repo
|
||||
DEVFLOW_PATH: ${{ github.workspace }}/devflow
|
||||
MODEL_CONFIG_PATH: ${{ github.workspace }}/devflow/config.ci.yaml
|
||||
|
||||
jobs:
|
||||
team_check:
|
||||
@@ -126,7 +125,6 @@ jobs:
|
||||
}}
|
||||
environment: integration
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
issues: write
|
||||
@@ -178,7 +176,7 @@ jobs:
|
||||
id: spam
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
SK_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
AGENT_REPO_PATH: ${{ env.TARGET_REPO_PATH }}
|
||||
@@ -203,7 +201,8 @@ jobs:
|
||||
id: repro
|
||||
working-directory: ${{ env.DEVFLOW_PATH }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_COPILOT_TOKEN: ${{ secrets.GH_COPILOT_TOKEN }}
|
||||
# Not seen by the agent prompt; used only to push a paper-trail
|
||||
# branch back to maf-dashboard at run end.
|
||||
DEVFLOW_TOKEN: ${{ secrets.DEVFLOW_TOKEN }}
|
||||
|
||||
@@ -25,6 +25,8 @@ on:
|
||||
required: true
|
||||
AZURE_SUBSCRIPTION_ID:
|
||||
required: true
|
||||
COPILOT_GITHUB_TOKEN:
|
||||
required: true
|
||||
FOUNDRY_MODELS_API_KEY:
|
||||
required: false
|
||||
OPENAI__APIKEY:
|
||||
@@ -504,12 +506,9 @@ jobs:
|
||||
name: Python Integration Tests - GitHub Copilot
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -675,12 +675,9 @@ jobs:
|
||||
needs.paths-filter.outputs.coreChanged == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
GITHUB_COPILOT_TIMEOUT: "120"
|
||||
defaults:
|
||||
run:
|
||||
|
||||
@@ -8,6 +8,9 @@ on:
|
||||
env:
|
||||
# Configure a constant location for the uv cache
|
||||
UV_CACHE_DIR: /tmp/.uv-cache
|
||||
# GitHub Copilot configuration
|
||||
GITHUB_COPILOT_MODEL: claude-opus-4.6
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -235,13 +238,6 @@ jobs:
|
||||
name: Validate 02-agents/providers/github_copilot
|
||||
runs-on: ubuntu-latest
|
||||
environment: integration
|
||||
permissions:
|
||||
copilot-requests: write
|
||||
contents: read
|
||||
id-token: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_COPILOT_MODEL: claude-opus-4.6
|
||||
defaults:
|
||||
run:
|
||||
working-directory: python
|
||||
|
||||
@@ -199,7 +199,6 @@ For environment variable configuration specific to each sample, refer to the REA
|
||||
## Contributor Resources
|
||||
|
||||
- [Contributing Guide](./CONTRIBUTING.md)
|
||||
- [Code of Conduct](./CODE_OF_CONDUCT.md)
|
||||
- [Python Development Guide](./python/DEV_SETUP.md)
|
||||
- [Design Documents](./docs/design)
|
||||
- [Architectural Decision Records](./docs/decisions)
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-07-22
|
||||
deciders: eavanvalkenburg, chetantoshniwal
|
||||
consulted: TaoChenOSU, moonbox3, peibekwe, rogerbarreto, westey-m
|
||||
informed:
|
||||
---
|
||||
|
||||
# Feature-usage bitmask in the User-Agent
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
We can see which Agent Framework packages are installed and that *some* framework
|
||||
call happened (via the existing `agent-framework-python/{version}` User-Agent),
|
||||
but we have no usage-based signal about **which features are actually exercised**
|
||||
at runtime, nor which are used *together* (e.g. workflows + MCP + Foundry). How
|
||||
can we collect a lightweight, privacy-respecting signal of feature usage for the
|
||||
traffic we can actually read, without standing up new event pipelines?
|
||||
|
||||
The detailed mechanism is in [SPEC-004](../specs/004-feature-usage-telemetry.md);
|
||||
the per-language bit tables are in
|
||||
[feature-usage-bit-registry.md](../specs/feature-usage-bit-registry.md).
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- **Transparency** — openly documented, human-decodable, user-controllable. No
|
||||
hidden or obfuscated telemetry.
|
||||
- **First-party scope / no third-party leakage** — emission requires both an
|
||||
explicitly approved client/pipeline family and an approved actual HTTPS origin
|
||||
on every request (including redirects). Credentials or an Azure setting alone
|
||||
never approve a custom gateway/origin.
|
||||
- **Live signal** — read the process's observed-feature set *so far* at request
|
||||
send time, rather than freezing it at client construction.
|
||||
- **Low cost / few moving parts** — reuse telemetry already in the request path;
|
||||
bounded fixed-width processing; as little machinery as the job needs.
|
||||
- **Privacy** — encode only coarse "observed at least once" Boolean feature
|
||||
state, never counts; no identifiers, arguments, prompts, payloads,
|
||||
model/deployment names, endpoints, or customer-defined names.
|
||||
- **Use, not presence** — package-level indexes mean a capability reached its
|
||||
first meaningful activation, not that a package was installed/imported or a
|
||||
DI container constructed an unused service.
|
||||
- **Versioning discipline** — v1 is a point-in-time decision. Adding bits later is
|
||||
easier than removing or redefining them, so the initial table should lean toward
|
||||
fewer bits and avoid forcing v2 shortly after launch.
|
||||
- **Allocation discipline** — each bit represents a stable framework-owned
|
||||
capability with a concrete product/support question and an actual-use mark
|
||||
point; implementation detail and speculative distinctions stay out.
|
||||
|
||||
## Considered Options
|
||||
|
||||
The options below are grouped by the decisions that matter: the **transport**,
|
||||
the **granularity**, and the **registry sharing model**.
|
||||
|
||||
### Transport
|
||||
|
||||
#### A. User-Agent token, first-party only, per request (chosen)
|
||||
|
||||
Stamp a `(feat=...)` comment onto the UA, but only on approved Azure/Foundry
|
||||
client pipelines, and re-evaluate it per request.
|
||||
|
||||
- Good, reuses telemetry already sent to approved backends we can read.
|
||||
- Good, request-time stamping reflects the live mask (not frozen at construction).
|
||||
- Good, first-party scoping means no fingerprint leaks to third-party providers.
|
||||
- Good, two-factor destination approval (pipeline + actual origin) denies custom
|
||||
`base_url` gateways and strips the token on unapproved redirect hops.
|
||||
- Good, maps onto .NET's existing per-request UA pipeline policies unchanged.
|
||||
- Neutral, v1 stamps only pipelines the framework creates or can configure
|
||||
through supported public hooks. It does not mutate caller-owned clients or
|
||||
reach into private SDK pipelines.
|
||||
- Bad, no signal for traffic that never hits a first-party endpoint (accepted —
|
||||
we couldn't read it anyway).
|
||||
|
||||
#### B. User-Agent token on all clients
|
||||
|
||||
- Good, simplest to wire (one static header).
|
||||
- Bad, sends a deployment fingerprint to OpenAI/Anthropic/AWS/Google logs we
|
||||
cannot read — privacy leak for zero benefit.
|
||||
- Bad, baked into static `default_headers`, so it freezes at client construction
|
||||
and reports a near-empty mask.
|
||||
|
||||
#### C. OpenTelemetry span/resource attribute
|
||||
|
||||
- Good, precise per-call usage; no UA change.
|
||||
- Bad (**privacy — the main reason to hold it**), a span attribute broadcasts the
|
||||
feature-combination fingerprint into the user's **general** telemetry pipeline,
|
||||
which is typically exported to third-party APM vendors (Datadog, Honeycomb, …).
|
||||
That re-introduces exactly the fingerprint leakage the first-party-only UA
|
||||
scoping (A) was chosen to avoid — just into a different set of third parties.
|
||||
- Bad (secondary), also a cardinality footgun (a growing, combinatorial value
|
||||
must never become a metric dimension).
|
||||
- Neutral, for the team's own goal it reaches us only if the user exports to
|
||||
Azure Monitor and we query it.
|
||||
- **Deferred, not rejected.** The version prefix lets us add it later **if** the
|
||||
User-Agent path cannot answer a concrete query and there is an acceptable
|
||||
scoped/redacted variant.
|
||||
|
||||
#### D. Bespoke usage events
|
||||
|
||||
- Good, richest detail and flexibility.
|
||||
- Bad, new data flow and cost; larger privacy surface; heavy to build and review;
|
||||
overkill for a coarse "which features" signal.
|
||||
|
||||
#### E. Install/import-time signal only (status quo-ish)
|
||||
|
||||
- Good, zero new runtime work.
|
||||
- Bad, measures installation, not usage; cannot capture feature combinations —
|
||||
does not solve the problem.
|
||||
|
||||
### Accumulation scope
|
||||
|
||||
#### S1. Process-global, monotonic mask (chosen)
|
||||
|
||||
A single mask per process; bits are OR-ed in as features are first used and never
|
||||
cleared. The token reflects "what this process has used so far."
|
||||
|
||||
- **Binary interpretation:** a set bit means the feature was observed at least
|
||||
once in this process before the request was sent. A bit repeated on later
|
||||
requests is the same Boolean observation, not another feature use. It cannot be
|
||||
summed into invocation, request, agent, user, or tenant counts.
|
||||
- Good, fits our **mixed feature lifecycle**: many features are *not* bound to an
|
||||
outbound service request — an agent/workflow may first run or build, a
|
||||
context/history provider may first participate in a session, and a host may
|
||||
start serving before the request that later emits the token. A process-wide
|
||||
mask can carry those activations forward.
|
||||
- Good, trivial and cheap: one OR under a lock (Python) / one atomic OR into one
|
||||
of two 64-bit lanes (.NET); no per-request state plumbing.
|
||||
- Good, deliberately coarse for privacy: it avoids emitting a sequence of exact
|
||||
per-call feature combinations that could reconstruct a workload's behavioral
|
||||
trace.
|
||||
- Neutral, coarser than per-call — early requests carry fewer bits than later
|
||||
ones, and the token says "this process used X", not "this call used X" or "X
|
||||
was used this many times."
|
||||
|
||||
For example, at time 1 Agent A can use MCP and a Foundry chat client. At time 2,
|
||||
Agent B in the same worker can make a normal Foundry chat call without MCP. The
|
||||
time-2 request still carries the MCP bit because MCP was previously observed in
|
||||
that process. It does **not** say Agent B used MCP, nor count a second MCP use.
|
||||
|
||||
#### S2. Per-request set, reset between calls (botocore's model — rejected)
|
||||
|
||||
AWS botocore scopes its `m/` feature codes to a `contextvars` set that is reset
|
||||
between requests, giving exact per-call attribution (and it deliberately no-ops
|
||||
when called outside a request context to avoid features bleeding across requests).
|
||||
See [Prior art](#prior-art).
|
||||
|
||||
- Good, exact per-call attribution directly in the User-Agent.
|
||||
- Bad, **assumes every feature is exercised inside a single service request** —
|
||||
true for botocore (an SDK natively bound to AWS service calls), but *not* for
|
||||
us. Our features split into request-scoped ones (a chat call, an MCP tool
|
||||
invocation) and decidedly non-request ones (workflow build/start, provider
|
||||
participation, hosting startup). The latter have no service request to attach to, so a
|
||||
per-request set would simply miss them.
|
||||
- Bad, needs `contextvars` propagation through every async/threaded path and a
|
||||
reset discipline, plus enable/disable calls around every scoped operation; the
|
||||
bleed-guard botocore documents is the warning sign.
|
||||
- Bad, creates a more detailed per-call behavioral trace, increasing the privacy
|
||||
sensitivity and review burden compared with a coarse process-lifetime Boolean.
|
||||
- Note, per-call attribution for the request-scoped subset is better served by
|
||||
the deferred OTel span path (option C) than by reshaping the UA token.
|
||||
|
||||
### Granularity
|
||||
|
||||
The mechanism can support several granularities. The remaining decision before
|
||||
implementation is how detailed v1 should be. The estimates below are
|
||||
intentionally rough; v1 uses a fixed 128-bit bound to leave useful headroom
|
||||
without making the registry unbounded.
|
||||
|
||||
#### F0. Package-level bits
|
||||
|
||||
One bit per package, set on first use of a package-owned public API, client,
|
||||
provider, or tool. It is **not** set on install, import, or assembly load.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `agent-framework-core` when `Agent`, `AgentSession`, `Workflow`, etc. is used.
|
||||
- `agent-framework-tools` when a `LocalShellTool` or `DockerShellTool` first
|
||||
executes/probes its shell capability.
|
||||
- `agent-framework-foundry` when a `FoundryChatClient`, `FoundryAgent`, etc.
|
||||
performs its first Foundry operation.
|
||||
- `agent-framework-openai` when `OpenAIChatClient`,
|
||||
`OpenAIEmbeddingClient`, etc. performs its first provider operation.
|
||||
- `agent-framework-azure-ai-search` when `AzureAISearchContextProvider` is used.
|
||||
- `agent-framework-azure-cosmos` when `CosmosHistoryProvider` is used.
|
||||
- `agent-framework-redis` when `RedisContextProvider` or `RedisHistoryProvider`
|
||||
is used.
|
||||
|
||||
Examples that do **not** get separate bits: merely installed dependencies;
|
||||
imports or DI construction with no activation; `Agent` vs `AgentSession` vs
|
||||
`InMemoryHistoryProvider`; `FunctionTool` vs `MCPStdioTool` vs `LocalShellTool`
|
||||
vs `DockerShellTool`; `FoundryChatClient` vs `FoundryAgent`; `OpenAIChatClient`
|
||||
vs `OpenAIEmbeddingClient`.
|
||||
|
||||
Rough estimate: Python ~25-35 bits; .NET ~15-25 bits.
|
||||
|
||||
- Good, lowest specificity and simplest registry.
|
||||
- Good, clearly measures usage rather than dependency inventory if bits are set
|
||||
only at package-owned public API/client/provider/tool use sites.
|
||||
- Bad, does not answer which major capability within a package is used.
|
||||
|
||||
#### F1. Package + major capability bits
|
||||
|
||||
Package bits plus selected major capabilities that are product-distinct and stable
|
||||
across implementations.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `agent-framework-core` plus `Agent`.
|
||||
- `AgentSession` plus `InMemoryHistoryProvider` / `FileHistoryProvider` as one
|
||||
history capability.
|
||||
- `Workflow` / `FunctionalWorkflow` as one workflow capability.
|
||||
- `FunctionTool`; MCP transports as one MCP capability; shell tools as one shell
|
||||
capability.
|
||||
- Skills provider plus stable source types: file, in-memory/programmatic, and
|
||||
MCP-backed skills (with .NET inline/class skill distinctions).
|
||||
- Foundry chat/agent/embedding capabilities; OpenAI chat/embedding capabilities.
|
||||
|
||||
Examples that do **not** get separate bits: `InMemoryHistoryProvider` vs
|
||||
`FileHistoryProvider`; `WorkflowBuilder`, `AgentExecutor`, `FunctionExecutor`, or
|
||||
`FanOutEdgeGroup`; `MCPStdioTool` vs `MCPStreamableHTTPTool` vs
|
||||
`MCPWebsocketTool`; `LocalShellTool` vs `DockerShellTool` vs
|
||||
`ShellEnvironmentProvider` vs `ShellPolicy`; `OpenAIChatClient` vs
|
||||
`OpenAIChatCompletionClient`; skill-source decorators such as caching, filtering,
|
||||
deduplication, and aggregation.
|
||||
|
||||
Rough estimate: Python ~60-70 indexes; .NET ~45-55 indexes. The current candidate
|
||||
registry is at 63 Python / 52 .NET assigned indexes.
|
||||
|
||||
- Good, likely answers the first product adoption questions while staying compact.
|
||||
- Good, fits comfortably within 128 bits while leaving room for additive package
|
||||
and feature growth.
|
||||
- Neutral, some provider internals remain collapsed until a later additive bit is
|
||||
justified.
|
||||
|
||||
#### F2. Public construct / concrete type bits
|
||||
|
||||
One bit per public construct that users intentionally instantiate or configure.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `Agent`, `AgentSession`, `InMemoryHistoryProvider`, `FileHistoryProvider`.
|
||||
- `Workflow`, `WorkflowBuilder`, `FunctionalWorkflow`.
|
||||
- `FunctionTool`, `MCPStdioTool`, `MCPStreamableHTTPTool`, `MCPWebsocketTool`.
|
||||
- `LocalShellTool`, `DockerShellTool`, `ShellEnvironmentProvider`, `ShellPolicy`.
|
||||
- `FoundryChatClient`, `FoundryAgent`, `OpenAIChatClient`,
|
||||
`OpenAIChatCompletionClient`, `OpenAIEmbeddingClient`.
|
||||
|
||||
Examples that do **not** get separate bits: `Agent.run` vs
|
||||
`Agent.run_streamed`; workflow edge/executor internals such as `AgentExecutor`,
|
||||
`FunctionExecutor`, or `FanOutEdgeGroup`; `LocalShellTool` persistent vs
|
||||
stateless mode; `ShellPolicy` allowlist vs denylist configuration; `FunctionTool`
|
||||
approval mode or result parser choices.
|
||||
|
||||
Rough estimate: Python ~70-100 bits; .NET ~55-80 bits.
|
||||
|
||||
- Good, concrete and directly tied to public API use.
|
||||
- Neutral, fits within 128 bits at the current estimate, but consumes much of the
|
||||
deliberate growth reserve.
|
||||
- Bad, adds many call sites and more fingerprint specificity for v1.
|
||||
|
||||
#### F3. Construct subtype / configuration bits
|
||||
|
||||
Split important constructs by mode, transport, storage, or workflow primitive
|
||||
when that distinction matters.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- `InMemoryHistoryProvider` and `FileHistoryProvider` separately.
|
||||
- `FunctionalWorkflow`, `WorkflowBuilder`, `AgentExecutor`, `FunctionExecutor`.
|
||||
- `FanOutEdgeGroup`, `FanInEdgeGroup`, `SwitchCaseEdgeGroup`.
|
||||
- `LocalShellTool` persistent, `LocalShellTool` stateless, `DockerShellTool`.
|
||||
- `MCPStdioTool`, `MCPStreamableHTTPTool`, `MCPWebsocketTool`;
|
||||
`OpenAIChatClient` vs `OpenAIChatCompletionClient`.
|
||||
|
||||
Examples that do **not** get separate bits: exact session id or persisted history
|
||||
file path; exact shell command, workdir, timeout, or output cap; exact MCP server
|
||||
command, URL, or tool names from the server; exact workflow graph shape or edge
|
||||
count; model/deployment names, prompts, tool arguments, payloads.
|
||||
|
||||
Rough estimate: Python ~110-150 bits; .NET ~85-125 bits.
|
||||
|
||||
- Good, useful where mode-level distinctions are decision-relevant.
|
||||
- Bad, trades simplicity for precision, increases fingerprint specificity, and
|
||||
may exhaust or exceed 128 bits in Python.
|
||||
|
||||
#### F4. Option / behavior flag bits
|
||||
|
||||
The most detailed framework-owned option: bits for specific modes and behavior
|
||||
switches, still excluding customer/runtime values.
|
||||
|
||||
Examples that get bits:
|
||||
|
||||
- Agent streaming used vs non-streaming used.
|
||||
- `FunctionTool` `approval_mode="always_require"` vs `"never_require"`.
|
||||
- `FunctionTool` `SKIP_PARSING` / result-parser path used.
|
||||
- MCP sampling configured; MCP long-running task support used.
|
||||
- `LocalShellTool` `clean_env` / `confine_workdir`; `DockerShellTool` container
|
||||
mode.
|
||||
|
||||
Examples that do **not** get separate bits: function names wrapped by
|
||||
`FunctionTool`; approval rule arguments or approval decisions; MCP remote tool
|
||||
names or schemas; shell command text or policy regex patterns; prompt/message
|
||||
content, model names, URLs, tenant/user/session identifiers.
|
||||
|
||||
Rough estimate: Python 150+ bits; .NET 120+ bits.
|
||||
|
||||
- Good, maximum framework-owned detail.
|
||||
- Bad, exceeds or nearly exhausts 128 bits and is too detailed for v1 without a
|
||||
concrete decision that requires it.
|
||||
|
||||
### Registry sharing model
|
||||
|
||||
#### H. Per-language bit lists (chosen)
|
||||
|
||||
Each SDK owns an independent list; the decoder picks the list using the language
|
||||
already present in the UA product token.
|
||||
|
||||
- Good, **no cross-language coordination**: each SDK numbers and evolves its
|
||||
features independently; adding a Python feature never touches .NET numbering.
|
||||
- Good, no null placeholders for one-SDK features, no "same bit, same meaning"
|
||||
rule, no SDK-aware decode caveats.
|
||||
- Good, decoding is trivial: language (from UA) + version -> list -> AND.
|
||||
- Neutral, two small lists to maintain instead of one (but they were going to
|
||||
diverge anyway — the packages differ).
|
||||
|
||||
#### I. Single shared cross-language registry
|
||||
|
||||
- Good, one list, one number space.
|
||||
- Bad, forces synchronized numbering and null placeholders for features that
|
||||
exist in only one SDK, plus SDK-aware decode rules.
|
||||
- Bad, the synchronization is pure accidental complexity — **the language is
|
||||
already in the User-Agent**, so sharing the number space buys nothing.
|
||||
|
||||
### Registry maintenance
|
||||
|
||||
#### J. Package-local indexes + parity/no-overlap test (chosen)
|
||||
|
||||
- Good, each package owns private `FeatureIndex` declarations only for its own
|
||||
rows; adding an optional-provider index does not require a core release after
|
||||
the marker API exists.
|
||||
- Good, one repository test compares the package-local declarations with the
|
||||
per-language table and rejects missing rows, wrong ids, out-of-range indexes,
|
||||
and any duplicate/overlapping index.
|
||||
- Good, no build step, no generator to own.
|
||||
|
||||
#### K. Code-generate the enums from the registry
|
||||
|
||||
- Bad, a generator + drift test + schema test to maintain a short list of
|
||||
integer constants; likely justified only if v1 deliberately chooses the most
|
||||
detailed L3/L4 granularities.
|
||||
|
||||
### Representation (how the mask is rendered as text)
|
||||
|
||||
All examples below encode the same mask — bits 0, 2, 32, 48, 56 set
|
||||
(agent + workflow + sequential-orchestration + foundry.chat_client + openai, in
|
||||
the Python v1 list) = decimal `72339073309605893`.
|
||||
|
||||
#### L. Decimal — `feat=v1.72339073309605893`
|
||||
|
||||
- Good, human-familiar; trivial to parse.
|
||||
- Neutral, no visual alignment to four-bit groups; slightly longer than hex for
|
||||
large masks. No advantage over hex.
|
||||
|
||||
#### M. Hex (chosen) — `feat=v1.101000100000005`
|
||||
|
||||
- Good, compact (≤32 chars for a 128-bit mask).
|
||||
- Good, decodes with one stdlib call in every language (`int(x, 16)` /
|
||||
two 64-bit lane parses in .NET); each hex character corresponds to four
|
||||
consecutive bit positions.
|
||||
- Good, lowercase, no `0x` prefix, no leading zeros — unambiguous and stable.
|
||||
|
||||
A grouped variant such as `feat=v1.101.0001.0000.0005` was also considered.
|
||||
Separators make the value longer and must be removed before `int(x, 16)` can
|
||||
parse it, while the ordinary hex digits already preserve fixed four-bit groups.
|
||||
|
||||
#### N. Binary — `feat=v1.100000001000000000000000100000000000000000000000000000101`
|
||||
|
||||
- Good, directly shows every zero/one position.
|
||||
- Bad, grows to 128 payload characters and is difficult to scan reliably.
|
||||
|
||||
#### O. Bit-list — `feat=v1.0,2,32,48,56`
|
||||
|
||||
- Good, most directly human-readable ("which bits").
|
||||
- Bad, needs delimiter handling and grows with the number of set bits; a full
|
||||
128-bit list is substantially larger than every fixed-width representation.
|
||||
|
||||
#### P. Alphabet / base-N (e.g. Crockford base32 `feat=v1.208004000005`, base62 `feat=v1.5LJRx1i6xJ`)
|
||||
|
||||
- Good, shortest representation.
|
||||
- Bad, needs a custom alphabet + decode table on both ends; base62 is
|
||||
case-sensitive (fragile through case-normalizing intermediaries); not
|
||||
directly readable. Premature optimization for a value that is already ≤32
|
||||
chars in hex.
|
||||
|
||||
All forms are ASCII. The table shows total bytes added to the existing
|
||||
User-Agent, including the leading space and `(feat=v1.)` wrapper:
|
||||
|
||||
| Representation | Example (5 bits) | All current Python rows (63) | All current .NET rows (52) | Full 128-bit v1 |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Hex | 26 | 34 | 30 | 43 |
|
||||
| Grouped hex | 29 | 39 | 34 | 50 |
|
||||
| Decimal | 28 | 38 | 34 | 50 |
|
||||
| Binary | 68 | 100 | 86 | 139 |
|
||||
| Bit-list | 23 | 189 | 156 | 412 |
|
||||
| Crockford base32 | 23 | 29 | 26 | 37 |
|
||||
| Base62 | 21 | 26 | 24 | 33 |
|
||||
|
||||
There is no defensible average before rollout, and the design does not depend on
|
||||
one: a process-global mask may eventually contain every assigned row. There is
|
||||
no smaller per-request bit budget because the bits are not request-scoped; the
|
||||
registry allocation tenet controls how many distinctions v1 assigns. Client
|
||||
processing is bounded by the fixed 128-bit width: marking performs one
|
||||
lock/atomic OR, and request-time stamping reads the mask, formats at most 32 hex
|
||||
characters, and replaces one User-Agent comment. It performs no registry scan,
|
||||
network call, or per-feature enable/disable bookkeeping.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen: **a request-time-stamped, first-party-only User-Agent `(feat=...)` token (A),
|
||||
with a 128-bit process-global monotonic accumulator (S1), per-language bit lists
|
||||
(H), package-local index enums kept honest by parity and no-overlap tests (J),
|
||||
rendered as lowercase hex (M).**
|
||||
|
||||
This is a bounded design with enough v1 headroom. A 128-bit
|
||||
**process-global, monotonic** mask accumulates from universal
|
||||
`mark_feature_used()` calls (so it spans build/start/participation activations
|
||||
that aren't bound to any service request — the per-request set model (S2) can't);
|
||||
the token is **stamped per request** only when both the client/pipeline and the
|
||||
actual HTTPS origin are approved, so custom origins and cross-origin redirects
|
||||
cannot inherit the fingerprint; each
|
||||
SDK owns an independent bit list selected by the language already in the UA; the
|
||||
mask is rendered as hex (`feat=v1.101000100000005`). The dedicated
|
||||
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` opt-out drops only the mask while
|
||||
keeping the base SDK identity/version User-Agent. Python's existing
|
||||
`AGENT_FRAMEWORK_USER_AGENT_DISABLED` continues to suppress its entire
|
||||
contribution, including the mask; this decision does not introduce a matching
|
||||
whole-User-Agent switch in .NET. OTel (C) is deferred — mainly because a
|
||||
broadly-emitted span attribute would leak the fingerprint into the user's
|
||||
general telemetry, against the first-party-only stance and would require
|
||||
user-side OTel setup that may still not make the data available to us — but left
|
||||
open behind the version prefix. Per-request scoping (S2), a shared registry (I),
|
||||
codegen for the initial registry (K), and the decimal/grouped-hex/binary/bit-list/
|
||||
base-N representations (L, M variant, N, O, P) are rejected as complexity or
|
||||
length the problem does not require.
|
||||
|
||||
The remaining choice before implementation is the **v1 granularity level** among
|
||||
F0-F4. This is a point-in-time decision: adding new bits later is easier than
|
||||
removing or redefining them, because removals/redefinitions require a new
|
||||
registry version and historical decode tables. For v1, prefer the least detailed
|
||||
level that answers the known product/support questions so we do not force a v2
|
||||
shortly after launch. The refreshed candidate registry uses **63 Python indexes and
|
||||
52 .NET indexes**, leaving 65 and 76 positions respectively. That headroom supports
|
||||
normal growth; it does not waive the registry's
|
||||
[allocation tenet](../specs/feature-usage-bit-registry.md#allocation-tenet).
|
||||
|
||||
### Consequences
|
||||
|
||||
- Good, adds a bounded-cost usage signal with no new data flow and few moving
|
||||
parts.
|
||||
- Good, transparent (public registry, human-decodable token) and disabled by a
|
||||
dedicated `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` mask-only opt-out. Python's
|
||||
existing whole-User-Agent opt-out also suppresses the mask.
|
||||
- Good, first-party-only + request-time stamping gives a live mask and no
|
||||
third-party fingerprint leak.
|
||||
- Good, 128 bits leaves useful v1 headroom; .NET remains lock-free by storing two
|
||||
independently atomic 64-bit lanes; per-language lists remove all cross-language
|
||||
sync; package-local enums avoid both codegen and provider→core release coupling.
|
||||
- Neutral, the token's reach equals eligible framework-configured first-party
|
||||
traffic; broader per-call signal (OTel) can be added later if needed.
|
||||
- Neutral, every set bit is a repeated Boolean observation after first use;
|
||||
request rows carrying it are not feature invocation counts.
|
||||
- Neutral, v1 granularity is intentionally a separate choice; the registry should
|
||||
start with fewer bits unless a more detailed bit answers a concrete question.
|
||||
- Bad, each feature must add an activation mark, first-party clients need a
|
||||
per-request destination-aware hook, and the registry validator must scan all
|
||||
package-local index declarations.
|
||||
|
||||
## Prior art
|
||||
|
||||
SDK telemetry-in-the-User-Agent is well-established; this design is closest to
|
||||
AWS's, and conventional in the rest. Summary of what comparable SDKs do:
|
||||
|
||||
| SDK | What's in the UA / headers | Usage-based? | Opt-out | Closest to ours? |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **AWS botocore** | structured UA with an `m/` token: a per-request set of **short feature codes** for features actually exercised (`WAITER`→`B`, `PAGINATOR`→`C`, retry mode, checksums, credential source, …) | **Yes** — registered at call time via `register_feature_id`, contextvar-scoped per request | `AWS_SDK_UA_APP_ID` sets app id (no opt-out for `m/`) | **Yes — direct analog** |
|
||||
| **OpenAI / Anthropic** (Stainless) | sidecar `X-Stainless-*` headers: lang, package version, OS, arch, runtime, runtime version; plus per-request `x-stainless-retry-count`, `x-stainless-read-timeout` | Mostly static identity (retry/timeout are per-request) | none | No (static identity) |
|
||||
| **Azure SDK** (`azure-core`) | `User-Agent: azsdk-python-{pkg}/{ver} Python/{pyver} ({platform})` | No | `AZURE_TELEMETRY_DISABLED` (tracing spans only, **not** the UA) | No |
|
||||
| **Google API core** | `x-goog-api-client: gl-python/… grpc/… gax/… gapic/…` | No | none | No |
|
||||
| **LangSmith** | `User-Agent: langsmith-py/{ver}`; usage lives in trace payloads | No (header) | opt-in via `LANGSMITH_TRACING_V2`/`LANGCHAIN_TRACING_V2`; `…HIDE_INPUTS/OUTPUTS` | No |
|
||||
|
||||
Takeaways that shaped (or validate) our choices:
|
||||
|
||||
- **AWS `m/` is the precedent for usage-based feature flags in a first-party
|
||||
User-Agent.** It validates the core idea. Its key *difference* is the encoding:
|
||||
AWS uses a **comma-separated set of 1–2 char short codes** (open-ended, no bit
|
||||
coordination, but variable length), whereas we use a fixed-width **hex
|
||||
bitmask** (compact, bounded, decode-by-AND, but needs per-language bit
|
||||
allocation). We keep the bitmask for boundedness and trivial AND-decoding;
|
||||
AWS's short-code set is recorded as a viable alternative if bit-position
|
||||
coordination ever becomes painful (it would also drop the fixed 128-bit bound).
|
||||
- **A fixed-width bitmask gives bounded token size for free.** botocore must cap
|
||||
the `m/` component at 1024 bytes and truncate at delimiter boundaries (with a
|
||||
fallback log) precisely *because* its short-code set is unbounded. Our 128-bit
|
||||
hex is ≤32 chars by construction — no size cap, no truncation logic.
|
||||
- **Scope is where we diverge most — and deliberately.** botocore collects
|
||||
features into a per-request `contextvars` set that is **reset between
|
||||
requests**, and no-ops outside a request context to prevent cross-request
|
||||
bleed. That works because every botocore feature is exercised *inside* an AWS
|
||||
service request. We are more general: some features are request-scoped (a chat
|
||||
call, an MCP tool invocation) but many are **not bound to any request**
|
||||
(workflow build/start, provider participation, hosting startup). So we use a
|
||||
**process-global, monotonic** mask (option S1), which is the only scope that can
|
||||
represent the non-request features. Our mask therefore intentionally "bleeds"
|
||||
(accumulates) for the life of the process — the opposite of botocore's reset —
|
||||
and that is the intended semantic, not the bug botocore guards against.
|
||||
- **The mechanism is private; the wire format is the contract.** botocore marks
|
||||
its whole user-agent module private and "subject to abrupt breaking changes."
|
||||
Same for us: the Python/.NET helpers are internal, and only the emitted token +
|
||||
the per-language registry tables are the stable, decodable contract.
|
||||
- **First-party-only emission** is stricter than any of the above; the closest in
|
||||
spirit is Stainless headers, which only reach the owning API. We make the
|
||||
client/pipeline allowlist explicit (initially Foundry/Azure OpenAI) rather than
|
||||
attempting to infer safety from arbitrary request URLs. Other Azure clients
|
||||
join only after telemetry access is confirmed.
|
||||
- **Opt-out naming.** `AZURE_TELEMETRY_DISABLED` is the family precedent for our
|
||||
`AGENT_FRAMEWORK_*_DISABLED` names. Separately, the cross-tool `DO_NOT_TRACK`
|
||||
convention (honored by e.g. HuggingFace Hub) is worth considering — see Open
|
||||
Questions.
|
||||
|
||||
Sources: botocore [`useragent.py`](https://github.com/boto/botocore/blob/develop/botocore/useragent.py)
|
||||
(`_USERAGENT_FEATURE_MAPPINGS`, `register_feature_id`, `_build_feature_metadata`);
|
||||
openai-python [`_base_client.py` `platform_headers()`](https://github.com/openai/openai-python/blob/main/src/openai/_base_client.py);
|
||||
anthropic-sdk-python [`_base_client.py`](https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_base_client.py);
|
||||
azure-core [`_universal.py` `UserAgentPolicy`](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/azure/core/pipeline/policies/_universal.py);
|
||||
google-api-core [`client_info.py`](https://github.com/googleapis/python-api-core/blob/main/google/api_core/client_info.py);
|
||||
langsmith-sdk [`client.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/client.py) /
|
||||
[`utils.py`](https://github.com/langchain-ai/langsmith-sdk/blob/main/python/langsmith/utils.py);
|
||||
huggingface_hub [`constants.py`](https://github.com/huggingface/huggingface_hub/blob/main/src/huggingface_hub/constants.py).
|
||||
|
||||
## Registry versioning and migration (v1 → v2)
|
||||
|
||||
The token carries a **per-language** version (`feat=v1.<hex>`); a version bump is
|
||||
independent for Python and .NET.
|
||||
|
||||
- **Additive growth stays on v1 — no bump.** Allocating a new feature to a
|
||||
reserved/unused bit is backward-compatible: an older decoder simply sees an
|
||||
unknown bit and ignores it. Normal package growth never needs a new
|
||||
version.
|
||||
- **A bump (v2) is required only for breaking changes:** renumbering or
|
||||
re-partitioning existing bits, changing the *meaning* of an already-assigned
|
||||
index, or widening beyond 128-bit. Within a version an index is **never** reused or
|
||||
reassigned — that invariant is what lets old decoders stay correct.
|
||||
- **The draft 64→128 change is still v1.** No v1 token or enum has shipped, so
|
||||
this pre-implementation repartition establishes the initial contract rather
|
||||
than migrating an existing one.
|
||||
- **Mixed-version coexistence is the norm.** A fleet runs many SDK releases at
|
||||
once, so `v1` and `v2` tokens appear simultaneously for a long time (old SDKs
|
||||
keep emitting `v1`). The decoder keeps **every** published `(language,
|
||||
version)` table and selects by the token's version; the `v1` table is retained
|
||||
indefinitely for historical decode.
|
||||
- **Unknown version → do not guess.** A decoder without the `vN` table must
|
||||
record "unknown registry version" rather than decode against an older table —
|
||||
bit meanings may differ across versions, so mis-attribution is worse than
|
||||
no data.
|
||||
- **Producing v2:** publish the v2 table alongside v1, update the affected
|
||||
package-local `FeatureIndex` declarations and SDK version constant, and emit
|
||||
`v2` from the release that ships them. Prefer staying on v1 (additive) and
|
||||
reserving a clean v2 for an eventual deliberate re-partition.
|
||||
|
||||
## Limitations
|
||||
|
||||
| Limitation | Caused by (choice) | Why we accepted it |
|
||||
| --- | --- | --- |
|
||||
| **No signal for self-hosted or third-party-only traffic.** If a process never calls Azure/Foundry, we see nothing. | First-party-only emission (A) | We can't read third-party logs anyway, and must not leak a fingerprint into them. Reach traded for privacy. |
|
||||
| **Not every first-party client is stampable.** Caller-supplied `AIProjectClient` / OpenAI clients and toolkit-owned clients may not expose a supported per-request policy hook. | Supported-hook-only emission (A) | V1 does not mutate caller-owned clients or private SDK pipelines. Those features may still appear on another eligible request from the same process-global mask. |
|
||||
| **Custom origins intentionally receive no feature token.** A customer gateway may use Azure credentials or Azure-named settings but route to a non-approved origin. | Two-factor destination classification (A) | Credentials and configuration names are not proof of telemetry ownership. Unknown/custom origins and cross-origin redirects are denied by default. |
|
||||
| **No OTel / per-call signal in v1.** | OTel deferred (C) — primarily on **privacy** and availability grounds | A broadly-emitted span attribute would push the fingerprint into the user's general telemetry / third-party APM vendors, undoing the first-party-only scoping. It also requires customer/user OTel setup, and even Foundry users may not export data where we can query it. Left open only if there is a compelling reason to add. |
|
||||
| **Mask reflects "usage so far," not the whole session.** Early requests carry fewer bits than later ones. | Process-global accumulator + request-time stamping | Honest and still useful as a Boolean process-lifetime observation. Repeated request rows must not be summed as additional uses. Reading the mask at request time makes it *grow* rather than freeze. |
|
||||
| **No per-agent / per-call attribution.** The mask is one process-wide value — "this process used X", not "this agent/call used X". | Process-global monotonic scope (S1) | A deliberate choice, not a transport limit: botocore *does* per-call attribution in the UA via a per-request `contextvars` set, but many AF activations (workflow build/start, provider participation, hosting startup) occur outside the service request that later emits the token. Per-call detail remains deferred to OTel. |
|
||||
| **Shared processes intentionally carry usage across agents and tenants.** A request can include bits first set by another workload in the same worker. | Process-global monotonic scope (S1) | The token must be interpreted only as process-level "used so far," never as request/user/tenant attribution. Privacy review must explicitly accept this. |
|
||||
| **Bits are binary, sticky observations — not countable events.** Once set, a bit appears on every later eligible request from that process, so raw request counts repeat the same observation and long-lived/high-traffic processes dominate. | Monotonic mask stamped at request time | The signal supports coarse observed-feature and co-occurrence questions only. It cannot provide first-use counts, unique-process counts, request attribution, or feature invocation frequency. |
|
||||
| **Granularity may be too coarse or too detailed.** The chosen level may miss useful distinctions or create more specificity than needed. | v1 granularity choice (F0-F4) | This is the main remaining decision. Adding bits later is easier than removing/redefining them, so v1 should lean toward fewer bits that answer known questions. |
|
||||
| **.NET snapshots span two atomic lanes.** A bit can be marked between the low/high reads, so one request may omit that just-added bit. | 128-bit width without a global lock | The mask is monotonic: the snapshot cannot invent or clear a bit, and the next request includes the addition. This matches the existing "usage so far" timing semantics. |
|
||||
| **Fingerprinting risk is reduced, not eliminated.** A feature-combination mask is still a deployment signature, and it transits intermediaries (proxies/CDNs) even when first-party-scoped. | Emitting any feature-combination value | Scope + opt-out + coarse granularity mitigate it; v1 should avoid unnecessary detailed bits. |
|
||||
|
||||
## Open Questions (for decider discussion)
|
||||
|
||||
These are unresolved and should be decided before implementation:
|
||||
|
||||
1. **Which v1 granularity level (F0-F4)?** This is the primary remaining choice.
|
||||
Adding bits later is easier than removing or redefining bits, so v1 should
|
||||
choose the least detailed level that answers known questions and avoids a quick
|
||||
v2.
|
||||
2. **Privacy approval for the v1 User-Agent signal.** Before implementation,
|
||||
confirm that a transparent, opt-out, first-party-only feature-combination
|
||||
fingerprint is acceptable, including the exact client allowlist, retention,
|
||||
access, and permitted product queries. This is a rollout precondition.
|
||||
3. **When (if ever) to add the OTel path?** Held back mainly for **privacy** and
|
||||
data availability: a span attribute broadcasts the fingerprint into the user's
|
||||
general telemetry and onward to third-party APM vendors, contradicting the
|
||||
first-party-only stance, and it requires user-side OTel setup that may not make
|
||||
the data available to us even for Foundry users. It also carries a
|
||||
metric-cardinality hazard. Revisit only if the User-Agent path cannot answer a
|
||||
concrete question.
|
||||
4. **Honor the cross-tool `DO_NOT_TRACK` convention?** Several ecosystems treat
|
||||
`DO_NOT_TRACK=1` as a universal telemetry opt-out (HuggingFace Hub honors it;
|
||||
see [Prior art](#prior-art)). Should our mask opt-out also respect
|
||||
`DO_NOT_TRACK` (in addition to `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` and
|
||||
Python's pre-existing whole-UA flag)? Cheap to add and
|
||||
community-friendly, but it widens the opt-out surface and needs a clear
|
||||
precedence rule. Recommend yes; confirm with the deciders.
|
||||
|
||||
### Decided
|
||||
|
||||
- **Dedicated opt-out flag — included.** In addition to the existing
|
||||
Python `AGENT_FRAMEWORK_USER_AGENT_DISABLED` (drops the whole UA), v1 ships
|
||||
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`, which drops **only** the feature mask
|
||||
while keeping the base SDK identity/version User-Agent. This lets a
|
||||
privacy-conscious user withhold the usage signal without losing the
|
||||
support/compat value of the SDK-version header. .NET adopts the dedicated
|
||||
mask-only flag; adding a .NET whole-User-Agent switch is outside this decision.
|
||||
- **Caller-owned clients are not modified.** V1 stamps only framework-created
|
||||
clients or clients with a supported public policy/hook registration point. It
|
||||
does not patch private pipelines; injected clients are an explicit coverage
|
||||
limitation.
|
||||
- **Destination approval is explicit and redirect-aware.** An eligible pipeline
|
||||
still emits only to a reviewed HTTPS origin. Custom origins are default-deny,
|
||||
and the token is removed on an unapproved redirect hop.
|
||||
- **Telemetry does not replace transport defaults.** Framework-created OpenAI
|
||||
clients use the SDK's default async HTTP client with the request hook added,
|
||||
preserving redirect, timeout, connection-limit, and pooling behavior.
|
||||
- **Marking uses activation, not DI construction.** Operational surfaces mark on
|
||||
first real use; a constructor marks only when construction itself exercises or
|
||||
registers the capability.
|
||||
|
||||
## More Information
|
||||
|
||||
- Mechanism & API: [SPEC-004](../specs/004-feature-usage-telemetry.md)
|
||||
- Per-language bit tables, encoding, opt-out, governance: [feature-usage-bit-registry.md](../specs/feature-usage-bit-registry.md)
|
||||
- Existing accumulator pattern: `python/packages/core/agent_framework/_telemetry.py`
|
||||
- .NET emission policies: `dotnet/src/Microsoft.Agents.AI.Foundry/AgentFrameworkUserAgentPolicy.cs`,
|
||||
`dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/HostedAgentUserAgentPolicy.cs`
|
||||
@@ -1,308 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-07-24
|
||||
deciders: eavanvalkenburg, chetantoshnival, taochenosu, moonbox3, giles17
|
||||
---
|
||||
|
||||
# Python session storage and serialization
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Python does not have a broadly shared session-store API in
|
||||
`agent-framework-core`. The alpha `agent-framework-hosting` package has a small process-local `SessionStore`, but that
|
||||
type is hosting-specific, in-memory only, and unavailable to packages such as Foundry Hosting without taking a
|
||||
dependency on the hosting helper package.
|
||||
|
||||
The alpha implementation is a prototype, not a compatibility constraint. This decision may replace its location,
|
||||
names, method shape, and behavior if another design is preferable.
|
||||
|
||||
The existing file-backed persistence surfaces solve narrower problems:
|
||||
|
||||
- `FileHistoryProvider` stores conversation `Message` records, not complete `AgentSession` snapshots;
|
||||
- `FileCheckpointStorage` stores workflow checkpoints; and
|
||||
- the Responses provider stores protocol history, but not Agent Framework runtime state carried in
|
||||
`AgentSession.state`.
|
||||
|
||||
`AgentSession.to_dict()` / `from_dict()` already provide a dictionary snapshot shape. Session state may contain
|
||||
framework or application-defined objects, and `register_state_type` provides dynamic type restoration, but the
|
||||
registration and collision behavior is not yet strong enough to serve as a durable, cold-start persistence contract.
|
||||
|
||||
The framework therefore needs to decide:
|
||||
|
||||
- where a reusable in-memory and file-backed session store belongs;
|
||||
- how a complete `AgentSession` should be serialized atomically and validated;
|
||||
- how custom nested state types are registered and restored after process restart; and
|
||||
- how to provide the required readable JSON format while leaving room for an optional optimized binary format.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
### Session-store ownership and API
|
||||
|
||||
- Make session storage reusable by core, hosting, and provider packages without creating dependency cycles.
|
||||
- Keep the smallest public API that supports in-memory use, durable implementations, and application-defined stores.
|
||||
- Define the minimum async operations required for lookup, replacement, and deletion.
|
||||
- Decide explicitly whether reads return shared instances or independent snapshots suitable for branching.
|
||||
- Simpler is better
|
||||
|
||||
### Serialization and type restoration
|
||||
|
||||
- Provide readable JSON serialization as a required capability.
|
||||
- Treat an optimized binary format as a nice-to-have only when the chosen JSON implementation supports it without a
|
||||
separate state model or substantial additional complexity.
|
||||
- Perform one typed encode and decode operation per file write/read.
|
||||
- Preserve dynamic registration of nested state types by the provider modules that own them.
|
||||
- Fail before persistence when an object cannot be restored after a cold start.
|
||||
- Keep the existing serialized `{"type": "<id>", ...}` representation compatible.
|
||||
|
||||
## Decision 1: Session-store ownership and API shape
|
||||
|
||||
### Keep `SessionStore` in `agent-framework-hosting`
|
||||
|
||||
- Good: keeps the abstraction local to app-owned hosting scenarios.
|
||||
- Bad: Foundry Hosting and other packages cannot reuse it without depending on the hosting helper package.
|
||||
- Bad: a generic session snapshot store is not inherently or only a web-hosting concern.
|
||||
- Bad: durable implementations would either be duplicated or placed in an unrelated package.
|
||||
|
||||
### Add an abstract store plus separate in-memory and file implementations
|
||||
|
||||
For example, define a `SessionStore` protocol/ABC with `InMemorySessionStore` and `FileSessionStore`.
|
||||
|
||||
- Good: clearly separates the contract from implementations.
|
||||
- Good: implementation names state their storage behavior explicitly.
|
||||
- Neutral: follows a familiar repository/adapter pattern.
|
||||
- Bad: introduces an additional public type and rename for a three-method experimental API.
|
||||
- Bad: callers must choose an implementation even for the default in-memory case.
|
||||
- Bad: the abstraction adds little value while every implementation still needs the same method overrides.
|
||||
|
||||
### Move the concrete store to core and use it as the overridable base
|
||||
|
||||
Move `SessionStore` to `agent-framework-core`, retain its in-memory behavior, and implement `FileSessionStore` by
|
||||
overriding the same async methods.
|
||||
|
||||
- Good: one public type is both the useful default and the extension point.
|
||||
- Good: existing custom stores can continue subclassing and overriding `get` / `set` / `delete`.
|
||||
- Good: core and provider packages can share the API without depending on hosting helpers.
|
||||
- Good: `FileSessionStore` remains a focused subclass while the base stays free of file-system concerns.
|
||||
- Bad: the class name does not explicitly say "in memory" when used without overrides.
|
||||
|
||||
## Decision 2: Serialization and type restoration
|
||||
|
||||
Once a file-backed store exists, it needs an on-disk format and a reliable way to reconstruct the complete
|
||||
`AgentSession`, including nested framework and application-defined state. Serialization belongs to each durable store
|
||||
implementation rather than the `SessionStore` API: the default in-memory store does not serialize, and custom stores
|
||||
remain free to choose another protocol.
|
||||
|
||||
The alternatives below compare top-level snapshot validation, JSON encoding/decoding cost, and how each option
|
||||
interacts with the dynamic custom-state registry. Binary storage is not a primary selection criterion.
|
||||
|
||||
### Considered options
|
||||
|
||||
The standard-library and optimized-JSON options are not mutually exclusive. A store can default to `json` while
|
||||
accepting caller-supplied `dumps` / `loads` callables for `orjson` or another compatible implementation. This is the
|
||||
pre-msgspec `FileHistoryProvider` design; those hooks remain only as a deprecated compatibility path.
|
||||
|
||||
### Standard library `json`
|
||||
|
||||
- Good: no additional dependency and familiar readable output.
|
||||
- Good: accepts the existing dictionary snapshots without a schema.
|
||||
- Good: can remain the fallback/default behind pluggable `dumps` / `loads`.
|
||||
- Neutral: custom state restoration still requires the framework registry.
|
||||
- Bad: slower encoding and decoding than optimized native implementations.
|
||||
- Bad: provides no typed snapshot validation during file reads.
|
||||
|
||||
### Optimized drop-in JSON libraries such as `orjson`
|
||||
|
||||
- Good: substantially faster JSON encoding and decoding than the standard library.
|
||||
- Good: can preserve the existing dictionary-oriented snapshot and custom `dumps` / `loads` shape.
|
||||
- Good: can be an opt-in codec without making the optimized package a framework dependency.
|
||||
- Neutral: returns bytes when encoding, which the file stores can already handle.
|
||||
- Neutral: custom state restoration still requires the framework registry.
|
||||
- Bad: remains an untyped top-level decode; the framework must separately validate the session snapshot shape.
|
||||
- Bad: choosing one drop-in implementation as a core dependency adds a dependency without providing typed construction.
|
||||
|
||||
### Pydantic `model_dump` / `model_validate`
|
||||
|
||||
- Good: Pydantic is already a core dependency.
|
||||
- Good: a typed session snapshot model can validate top-level fields and provide `model_dump_json` /
|
||||
`model_validate_json` for file serialization.
|
||||
- Good: validation errors include useful field paths.
|
||||
- Neutral: the dynamic `state` field remains `dict[str, Any]`, so custom nested state restoration still requires the
|
||||
framework registry.
|
||||
- Neutral: the public `AgentSession` does not need to become a Pydantic model; an internal snapshot model can bridge it.
|
||||
- Bad: benchmarked encode/decode includes model construction and dumping overhead on every operation.
|
||||
- Bad: core dependency on Pydantic run the risk of us not being able to use different versions or users of the framework being unable to upgrade or having additional extra code dealing with major version bumps in Pydantic.
|
||||
|
||||
### msgspec typed/tagged unions only
|
||||
|
||||
- Good: msgspec owns validation and reconstruction end to end.
|
||||
- Neutral: works well for a closed set of framework-owned `msgspec.Struct` types.
|
||||
- Bad: every external type must be known when the decoder schema is constructed; dynamic registration is lost.
|
||||
|
||||
### msgspec codecs plus an explicit dynamic registry
|
||||
|
||||
- Good: one typed file encode/decode and dynamic nested custom types.
|
||||
- Good: it satisfies the required readable JSON format.
|
||||
- Neutral: the same typed snapshot can also support optional MessagePack as a low-cost implementation detail.
|
||||
- Good: the registry can enforce stable IDs, codec completeness, and collision handling.
|
||||
- Neutral: a single state-payload hook still recursively applies registry codecs.
|
||||
- Bad: msgspec cannot infer dynamic types from JSON without the framework's type tags.
|
||||
|
||||
## Benchmark Evidence
|
||||
|
||||
A benchmark using a large `AgentSession` with 2,000 `Message` objects stored through
|
||||
`InMemoryHistoryProvider`, nested standard dictionaries, registered custom classes, and registered Pydantic models
|
||||
measured the complete `AgentSession.to_dict()` / codec / `AgentSession.from_dict()` path.
|
||||
The reproducible harness is
|
||||
[`python/scripts/session_serialization_benchmark.py`](../../python/scripts/session_serialization_benchmark.py):
|
||||
|
||||
```bash
|
||||
cd python
|
||||
uv run --with orjson python scripts/session_serialization_benchmark.py
|
||||
```
|
||||
|
||||
| Codec | File size | Encode median (ms) | Decode median (ms) | Round-trip median (ms) | Disk round-trip median (ms) |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: |
|
||||
| Standard library JSON | 1.57 MiB | 33.503 | 14.316 | 55.261 | 75.226 |
|
||||
| orjson | 1.57 MiB | 25.808 | 11.754 | 39.398 | 63.319 |
|
||||
| Pydantic JSON | 1.57 MiB | 28.330 | 18.344 | 53.522 | 77.096 |
|
||||
| msgspec JSON | 1.57 MiB | 26.019 | 11.379 | **38.060** | 62.230 |
|
||||
| msgspec MessagePack | **1.45 MiB** | **25.134** | **11.201** | 38.512 | **58.112** |
|
||||
|
||||
The JSON encodings produced the same 1.57 MiB file size. msgspec JSON had the best median JSON round-trip latency,
|
||||
slightly ahead of orjson, while also supporting typed top-level decoding. Pydantic validation added measurable decode
|
||||
and disk-round-trip overhead without eliminating the dynamic state registry.
|
||||
|
||||
MessagePack reduced file size to 92.2% of JSON (about 7.8% smaller) and produced the best encode, decode, and disk
|
||||
round-trip medians. Its in-memory round-trip median was effectively tied with msgspec JSON. This supports offering it
|
||||
as a nice-to-have, but it is not required to justify choosing msgspec for JSON.
|
||||
|
||||
These results are workload- and machine-dependent. The small differences between optimized JSON implementations are
|
||||
not the basis for the architectural choice. The benchmark instead confirms that the typed design does not impose a
|
||||
material regression for this representative payload:
|
||||
|
||||
- use msgspec JSON as the readable default;
|
||||
- optionally offer msgspec MessagePack when storage size or disk latency matters;
|
||||
- retain the explicit registry for dynamic custom state in both formats;
|
||||
- do not add orjson solely for a small JSON performance difference without typed decoding; and
|
||||
- do not use Pydantic as the file codec when its validation overhead does not replace the registry.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
### Decision 1: Move the concrete overridable store to core
|
||||
|
||||
`SessionStore` moves to `agent-framework-core` as an experimental public API. It remains a concrete in-memory store and
|
||||
the default used by `AgentState` in the `hosting` package. Its async `get`, `set`, and `delete` methods remain overridable for custom storage
|
||||
implementations.
|
||||
|
||||
`FileSessionStore` subclasses `SessionStore` and provides durable atomic file persistence. No separate
|
||||
`InMemorySessionStore`, protocol, or ABC is introduced. `agent-framework-hosting` consumes the core type and no longer
|
||||
owns or re-exports `SessionStore` (this will be a breaking change in the `hosting` package).
|
||||
|
||||
Actual `SessionStore` and `FileSessionStore` operations mark Python feature-usage index 17,
|
||||
`core.session_store`, following ADR-0033's use-not-presence policy. Construction and import alone do not mark the bit.
|
||||
|
||||
`SessionStore` accepts opaque non-empty keys so custom backends can use their native key contracts. `FileSessionStore`
|
||||
accepts opaque keys up to 128 characters and encodes values that are not portable filename stems; this supports
|
||||
provider IDs such as `telegram:<bot-id>:<chat-id>` without permitting path traversal. `AgentState` remains
|
||||
storage-agnostic and passes keys through unchanged; each store implementation owns backend-specific validation or
|
||||
normalization. Protocol-specific hosts such as Foundry may still derive their own stable storage key before calling the
|
||||
store.
|
||||
|
||||
Foundry Hosting exposes an experimental `FoundrySessionStore`, which is the
|
||||
default `ResponsesHostServer` store when hosted; local hosting defaults to the
|
||||
in-memory `SessionStore`. `FoundrySessionStore` currently subclasses
|
||||
`FileSessionStore`, stores snapshots under
|
||||
`/.sessions/<user-id>/<conversation-id-or-response-id>.json`, and derives the
|
||||
validated user partition from
|
||||
`azure.ai.agentserver.core.get_request_context()`. A Foundry session controls
|
||||
hosted compute and filesystem lifetime and may host multiple users and
|
||||
Responses conversations, so its ID is not used as the MAF session identifier.
|
||||
Stored-conversation requests read and write one snapshot under
|
||||
`conversation_id`. Response-chain requests read under `previous_response_id`
|
||||
and write the updated, loaded MAF session under the current `response_id`, which
|
||||
allows branching without overwriting the parent snapshot. Because Foundry does
|
||||
not infer `agent_session_id` from `previous_response_id`, response-chain callers
|
||||
must also reuse the prior response's hosted session ID so the request reaches
|
||||
the same persistent `$HOME`; conversation objects bind a stable hosted session
|
||||
automatically.
|
||||
The Foundry-specific type is the host configuration seam; its implementation
|
||||
may later move from files to a Foundry storage API without changing the generic
|
||||
core store contract. The session file API maps `/` to the hosted `$HOME`
|
||||
directory, so this API path is persisted on disk under `$HOME/.sessions`.
|
||||
|
||||
### Decision 2: Use msgspec codecs plus an explicit dynamic registry
|
||||
|
||||
Chosen option: **msgspec codecs plus an explicit dynamic registry**.
|
||||
|
||||
`FileSessionStore` uses a typed internal `msgspec.Struct` snapshot with reusable JSON and MessagePack encoders/decoders.
|
||||
JSON is the required and default format. Because msgspec can reuse the same typed snapshot and registry hooks,
|
||||
`serialization_format="msgpack"` is also exposed as an optional compact binary convenience. The complete state
|
||||
dictionary is wrapped in one custom field; its encode/decode hooks recursively translate explicitly registered types
|
||||
to and from the existing tagged mappings in either format.
|
||||
|
||||
The dependency range is `msgspec>=0.20.0,<0.22`: version 0.20.0 added Python 3.14 support, and the upper bound limits
|
||||
core to the tested 0.20/0.21 minor lines.
|
||||
|
||||
Three dependency placements were considered:
|
||||
|
||||
1. Make msgspec a standard core dependency.
|
||||
2. Make msgspec optional in core but standard in Foundry hosting.
|
||||
3. Make msgspec optional in both packages.
|
||||
|
||||
Option 3 moves installation failures to application developers even though durable session persistence is required for
|
||||
the primary `ResponsesHostServer` API to preserve Agent Framework state. Option 2 removes that burden from Foundry
|
||||
hosting but makes core's shared `_sessions` module and public types conditionally defined or lazily imported without
|
||||
removing msgspec from the default Foundry installation. Option 1 is therefore selected: msgspec is a standard core
|
||||
dependency, giving both core file providers and Foundry hosting one predictable implementation path.
|
||||
|
||||
Core already depends on the native `pydantic-core` extension, so native-wheel availability is not a new packaging
|
||||
constraint. The msgspec project is also actively tracking upcoming Python support; its merged
|
||||
[`Add 3.15-dev to CI` PR](https://github.com/msgspec/msgspec/pull/1037) exercises Python 3.15 development builds. This gives confidence that they will add support for new python version quickly.
|
||||
|
||||
The public `AgentSession` remains a normal framework class. The msgspec Struct is an internal persistence DTO rather
|
||||
than the inheritance base for runtime sessions. The Struct gives persistence one typed encode/decode operation, validates
|
||||
the snapshot envelope, and carries an explicit payload version. The benchmark's small timing spread was not used to
|
||||
choose the Struct.
|
||||
|
||||
`register_state_type` supports stable type IDs and optional codecs, rejects collisions, and provides defaults for
|
||||
`to_dict` / `from_dict` classes and Pydantic models. Type IDs share one process-wide registry, so provider packages
|
||||
should use stable package-qualified identifiers and register their own state types at module import time; consumers do
|
||||
not need to know those implementation details. One recursive serializer is shared by `AgentSession.to_dict()` and the
|
||||
durable codecs. The established implicit Pydantic registration behavior remains temporarily for compatibility, but now
|
||||
emits `DeprecationWarning`. Same-process round-trips continue to work; cold-start deserialization is not guaranteed
|
||||
without explicit provider registration. Unknown persisted type IDs remain raw dictionaries.
|
||||
|
||||
File snapshots are quarantined only when their bytes cannot be parsed as the selected JSON or MessagePack format.
|
||||
Schema errors, unsupported snapshot versions, and registered state-decoder failures leave the original file in place so
|
||||
an application fix, rollback, or compatible reader can recover it.
|
||||
|
||||
`FileHistoryProvider` also adds msgspec JSON as its default JSON Lines codec. It supports the same explicit
|
||||
`serialization_format="msgpack"` choice using length-prefixed append-only MessagePack records. Its existing `dumps` /
|
||||
`loads` extension points remain temporarily for JSON compatibility, emit `DeprecationWarning` when supplied, and do
|
||||
not apply to MessagePack. New code uses the built-in codecs. The default JSON reader falls back to the standard library
|
||||
for legacy JSON Lines containing `NaN` or infinity, and writes those non-finite values with the standard library so
|
||||
existing history semantics are preserved.
|
||||
|
||||
## Follow-up Work
|
||||
|
||||
Audit the remaining file-backed stores to determine whether they benefit from the same typed msgspec treatment and
|
||||
optional JSON / MessagePack formats. `FileCheckpointStorage` is the first candidate because it persists large,
|
||||
structured workflow state and currently uses JSON plus custom checkpoint value encoding. Its existing
|
||||
`WorkflowCheckpoint.version` field already provides a payload-shape discriminator.
|
||||
|
||||
Checkpoint migration should be reader-first. A compatibility release can detect the codec from the first byte, widen
|
||||
the two `glob("*.json")` readers to discover future formats, and continue writing only JSON. A later release can add
|
||||
opt-in MessagePack writes while retaining JSON as the default. The payload `version` should describe the checkpoint
|
||||
shape rather than the codec, which is discoverable from the bytes. MessagePack should not become the default while
|
||||
mixed-version fleets may share one checkpoint directory: older readers silently ignore non-JSON files and could resume
|
||||
from no checkpoint instead of surfacing an incompatibility.
|
||||
|
||||
`MemoryContextProvider` is another candidate because its file-backed path combines `MemoryFileStore` state with
|
||||
transcript files and still exposes `history_dumps` / `history_loads` passthroughs to the deprecated
|
||||
`FileHistoryProvider` codec hooks.
|
||||
|
||||
The follow-up should measure real framework payloads before changing formats, preserve compatibility or define a clear
|
||||
migration path for existing files, and consider whether each store needs readable JSON, compact binary storage, append
|
||||
semantics, or atomic whole-file replacement. Other candidates include file-backed todo state, but each should be
|
||||
evaluated independently rather than adopting msgspec by default solely for consistency.
|
||||
@@ -1,500 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-07-22
|
||||
deciders: eavanvalkenburg
|
||||
consulted:
|
||||
informed:
|
||||
---
|
||||
|
||||
# Feature-usage telemetry via an accumulating bitmask
|
||||
|
||||
> Companion design for [ADR-0033](../decisions/0033-feature-usage-bitmask-user-agent.md).
|
||||
> The per-language bit tables, encoding, opt-out, and governance live in
|
||||
> [feature-usage-bit-registry.md](feature-usage-bit-registry.md). The registry
|
||||
> allocates indexes; package-local `FeatureIndex` declarations implement them.
|
||||
|
||||
## What is the goal of this feature?
|
||||
|
||||
Give the Agent Framework team a lightweight signal about **which framework
|
||||
features are actually exercised** at runtime (not merely installed), so we can
|
||||
prioritise investment based on real usage. We emit a single small number — a
|
||||
*feature mask* — on the User-Agent that already goes out with each request.
|
||||
|
||||
**Reach is deliberately bounded.** The mask accumulates from *all* feature usage,
|
||||
but the `feat=` token is only stamped through an explicit allowlist of
|
||||
**first-party Azure/Foundry client pipelines** whose User-Agent telemetry the
|
||||
team can ingest (initially Foundry/Azure OpenAI). We do **not** send the token to
|
||||
third-party providers (OpenAI direct, Anthropic, Bedrock, Gemini, Ollama,
|
||||
Mistral), or to an Azure service merely because its hostname is first-party;
|
||||
doing so would leak a deployment fingerprint into logs we cannot read (see
|
||||
[Emission](#emission)).
|
||||
|
||||
The current candidate uses package-level bits plus selected major capabilities:
|
||||
one bit per orchestration pattern (sequential / concurrent / group-chat /
|
||||
magentic / handoff), **one bit per built-in context/history provider**, selected
|
||||
skill source types, and separate Foundry chat/agent/memory/evals/toolbox bits
|
||||
(plus embedding in Python).
|
||||
See the
|
||||
[registry](feature-usage-bit-registry.md). ADR-0033 still leaves final v1
|
||||
granularity open. The refreshed candidate assigns 63 Python indexes and 52 .NET
|
||||
indexes. V1 uses 128 bits, leaving 65 Python and 76 .NET positions for additive
|
||||
growth.
|
||||
|
||||
Success metric: within one release after rollout, ≥80% of **eligible,
|
||||
framework-created** first-party (Foundry) requests carry a **non-empty** feature
|
||||
token whose mask reflects features activated **after** client construction (i.e.
|
||||
the token is live, not frozen — see the request-time stamping requirement
|
||||
below). This measures transport coverage, not feature invocation volume.
|
||||
Secondary: ability to describe which process-lifetime feature bits are observed
|
||||
together in eligible traffic (e.g. "requests observed from processes that have
|
||||
used workflows"). Repeated requests carrying a bit are not additional uses.
|
||||
|
||||
This is done **transparently**: the bit registry is public, the emitted value is
|
||||
human-decodable, and a dedicated `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`
|
||||
disables the mask while preserving the base User-Agent. Python's existing
|
||||
`AGENT_FRAMEWORK_USER_AGENT_DISABLED` continues to suppress its entire
|
||||
User-Agent contribution, mask included.
|
||||
|
||||
## What is the problem being solved?
|
||||
|
||||
Today we only know which packages are *installed* (from package telemetry) or
|
||||
that *some* Agent Framework call happened (the existing
|
||||
`agent-framework-python/{version}` User-Agent). We have no usage-based signal
|
||||
about feature combinations, and no way to tell that, say, a process uses
|
||||
workflows + MCP + Foundry together. Collecting this through bespoke events would
|
||||
add cost and new data flows; folding a tiny accumulating integer into telemetry
|
||||
we already send is far cheaper and easier to reason about for privacy.
|
||||
|
||||
## Mechanism
|
||||
|
||||
### Process-global accumulator in `core`
|
||||
|
||||
The accumulator and its helpers live in the existing
|
||||
`agent_framework/_telemetry.py` (alongside `get_user_agent()` /
|
||||
`prepend_agent_framework_to_user_agent()`), so the User-Agent machinery stays in
|
||||
one module. It owns a process-global 128-bit accumulator. Python's arbitrary-size
|
||||
`int` stores it directly. A **dedicated**
|
||||
`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` that drops **only** the feature mask
|
||||
while keeping the base `agent-framework-python/{version}` User-Agent is
|
||||
introduced by this design. The existing Python
|
||||
`AGENT_FRAMEWORK_USER_AGENT_DISABLED` continues to drop the whole User-Agent
|
||||
contribution, mask included:
|
||||
|
||||
```python
|
||||
# agent_framework/_telemetry.py (same module as get_user_agent)
|
||||
# IS_TELEMETRY_ENABLED already defined here (AGENT_FRAMEWORK_USER_AGENT_DISABLED)
|
||||
|
||||
FEATURE_MASK_DISABLED_ENV_VAR = "AGENT_FRAMEWORK_FEATURE_MASK_DISABLED"
|
||||
REGISTRY_VERSION = 1
|
||||
|
||||
_feature_mask = 0
|
||||
_feature_mask_lock = threading.Lock()
|
||||
|
||||
|
||||
def _feature_mask_enabled() -> bool:
|
||||
"""Mask is on unless the UA is disabled or the dedicated flag is set."""
|
||||
if not IS_TELEMETRY_ENABLED:
|
||||
return False
|
||||
return os.environ.get(FEATURE_MASK_DISABLED_ENV_VAR, "false").lower() not in ("true", "1")
|
||||
|
||||
|
||||
def mark_feature_used(index: int) -> None:
|
||||
"""OR a feature bit into the process-global mask.
|
||||
|
||||
Called the first time a feature is exercised. Cheap and idempotent;
|
||||
a no-op when the feature mask is disabled.
|
||||
"""
|
||||
global _feature_mask
|
||||
if not _feature_mask_enabled():
|
||||
return
|
||||
if not 0 <= index < 128:
|
||||
raise ValueError(f"Feature index must be in range 0..127, got {index}")
|
||||
with _feature_mask_lock:
|
||||
_feature_mask |= 1 << index
|
||||
|
||||
|
||||
def get_feature_token() -> str | None:
|
||||
"""Return ``v<version>.<hex_mask>`` for the accumulated mask, or None."""
|
||||
if not _feature_mask_enabled() or _feature_mask == 0:
|
||||
return None
|
||||
return f"v{REGISTRY_VERSION}.{_feature_mask:x}"
|
||||
```
|
||||
|
||||
- **Per package/feature, usage-based:** `mark_feature_used()` is called at the
|
||||
feature's first meaningful activation, never at import/install time. For
|
||||
operational clients, tools, providers, and hosts, activation is the first
|
||||
public operation that exercises the capability. Construction is a valid mark
|
||||
point only when construction itself performs the capability (for example,
|
||||
registering/starting runtime resources), not merely because a DI container
|
||||
instantiated an otherwise-unused object.
|
||||
- **Process-global and monotonic — intentionally never reset.** Unlike a
|
||||
per-request scheme (e.g. botocore's `contextvars` feature set that resets
|
||||
between calls), our mask spans the whole process because many features are not
|
||||
bound to any service request — an agent or workflow may first run, a provider
|
||||
may first participate in a session, and a host may start serving independently
|
||||
of the later request that emits the token. The single global
|
||||
mask is the only scope that can represent them, and its monotonic "usage so
|
||||
far" growth is the intended semantic, not a bleed bug. Concurrency-safe via the
|
||||
module lock (Python) / two atomic 64-bit lanes in .NET.
|
||||
- **Binary and non-countable.** A set bit means "this feature was observed at
|
||||
least once in this process before this request." Repeating that bit on every
|
||||
later eligible request does not represent additional uses and must not be
|
||||
interpreted as request, invocation, agent, user, or tenant counts.
|
||||
- **No scoped enable/disable bookkeeping.** Making the mask exact per operation
|
||||
would add hot-path state changes, context propagation, and reset/error-path
|
||||
handling. It would also produce a more detailed behavioral trace and therefore
|
||||
increase privacy sensitivity. V1 deliberately keeps the coarser process-level
|
||||
Boolean.
|
||||
- **Token is safe by construction.** The emitted value is `v{int}.{hex}` —
|
||||
characters limited to `[0-9a-fv.]` — so no header-injection sanitization is
|
||||
required. A 128-bit mask is at most 32 hex characters (contrast botocore,
|
||||
which must sanitize and cap arbitrary component strings).
|
||||
- **Private API.** `mark_feature_used`, `get_feature_token`, `apply_feature_token`
|
||||
and the mask itself are internal helpers; only the emitted token and the
|
||||
per-language registry tables are the stable, decodable contract.
|
||||
- **No import cycles:** the accumulator lives in core, while each package owns
|
||||
private index constants for its own features and calls the core marker. Core
|
||||
never imports optional packages.
|
||||
|
||||
### Interpretation contract
|
||||
|
||||
At time 1, Agent A in a worker can use MCP and a Foundry chat client. At time 2,
|
||||
Agent B in the same worker can make a normal Foundry chat call without MCP. The
|
||||
time-2 request still carries the MCP bit because MCP was observed earlier in the
|
||||
process.
|
||||
|
||||
That request means only "this process has used MCP." It does not mean Agent B
|
||||
used MCP, that MCP was used on the time-2 request, or that two requests carrying
|
||||
the bit equal two MCP uses. Without a separate stable process identifier, the
|
||||
signal also cannot produce unique-process counts. Supported analysis is limited
|
||||
to coarse observed-feature prevalence and feature co-occurrence, with the
|
||||
request-weighting limitation called out explicitly.
|
||||
|
||||
### Bit constants
|
||||
|
||||
The registry is the allocation authority. Each package defines a private,
|
||||
hand-written `FeatureIndex` IntEnum (or equivalent constants) containing only
|
||||
the rows it owns. Core owns core indexes plus the accumulator; optional packages
|
||||
can allocate and ship new indexes without requiring a core release after the
|
||||
marker API exists.
|
||||
|
||||
```python
|
||||
# agent_framework_foundry/_feature_usage.py
|
||||
from enum import IntEnum
|
||||
|
||||
from agent_framework._telemetry import mark_feature_used # pyright: ignore[reportAttributeAccessIssue]
|
||||
|
||||
|
||||
class FeatureIndex(IntEnum):
|
||||
FOUNDRY_CHAT_CLIENT = 48
|
||||
|
||||
|
||||
class RawFoundryChatClient:
|
||||
async def _send_request(self) -> None:
|
||||
mark_feature_used(FeatureIndex.FOUNDRY_CHAT_CLIENT)
|
||||
...
|
||||
```
|
||||
|
||||
A repository validation test reads every package-local declaration and the
|
||||
matching language/version table. It fails when an index is out of range, missing
|
||||
from the registry, duplicated/overlapping across packages, or mapped to the wrong
|
||||
id. For reference, in v1 `FoundryChatClient` → index 48,
|
||||
`FoundryAgent` → index 49, Foundry memory → index 50.
|
||||
|
||||
### Usage activation points
|
||||
|
||||
- **Clients/embeddings/evals:** first outbound operation.
|
||||
- **Tools/MCP:** first connection, discovery, or invocation that exercises the
|
||||
tool surface.
|
||||
- **Context/history providers:** first provider hook or load/save operation, not
|
||||
constructor-only registration.
|
||||
- **Agents/workflows/orchestrations:** first run/build/start operation that
|
||||
activates the defined runtime.
|
||||
- **Hosting:** first serve/start/route activation.
|
||||
- **Constructor marking:** allowed only when construction itself performs one of
|
||||
those activations or acquires/registers the runtime resource.
|
||||
|
||||
## Emission
|
||||
|
||||
**One path in v1: the User-Agent `feat=` token, stamped at request time on an
|
||||
explicit allowlist of first-party Azure/Foundry client pipelines only.**
|
||||
|
||||
Marking (`mark_feature_used`) is **universal** — every feature sets its index
|
||||
regardless of provider. Only **emission** is scoped. A user who never calls a
|
||||
first-party endpoint emits no token; this is the honest, intended behaviour (no
|
||||
third-party leakage, no signal we couldn't read anyway).
|
||||
|
||||
The existing base User-Agent behavior (`agent-framework-python/{version}` plus
|
||||
any dynamically detected hosting prefix) is unchanged; packages continue using
|
||||
their current `default_headers`, `user_agent`, suffix, or policy mechanisms.
|
||||
`get_user_agent()` stays base-only (no `feat=`). The `feat=` token is
|
||||
**separate**, added **only** by eligible Azure/Foundry clients, and
|
||||
**re-evaluated on each request** so it reflects the mask accumulated so far. A
|
||||
helper stamps it:
|
||||
|
||||
This request-time read does not make the signal request-scoped. The payload
|
||||
remains the process-global Boolean history described above.
|
||||
|
||||
```python
|
||||
# agent_framework/_telemetry.py
|
||||
def apply_feature_token(user_agent: str) -> str:
|
||||
"""Append/refresh the live ``(feat=v<ver>.<hex>)`` comment on a UA string.
|
||||
|
||||
Re-reads the current mask on every call, so newly accumulated bits are
|
||||
reflected immediately. Idempotent: replaces an existing ``(feat=...)``
|
||||
comment rather than appending a second.
|
||||
"""
|
||||
token = get_feature_token() # None when disabled or mask == 0
|
||||
base = _strip_feature_comment(user_agent)
|
||||
return f"{base} (feat={token})" if token else base
|
||||
```
|
||||
|
||||
Emission requires **both**:
|
||||
|
||||
1. an explicitly approved framework client/pipeline family; and
|
||||
2. the actual request's normalized HTTPS origin matching that family's reviewed
|
||||
first-party origin allowlist.
|
||||
|
||||
Credentials, `use_azure`, or an Azure-named setting alone do not approve a
|
||||
destination. Approval depends on the **resolved origin**: customer-specific
|
||||
subdomains on reviewed Azure/Foundry suffixes remain eligible even when supplied
|
||||
through `base_url` / `AZURE_OPENAI_BASE_URL`, while customer gateways and unknown
|
||||
OpenAI-compatible origins are denied by default. The check runs on every actual
|
||||
request, including redirect hops; a cross-origin or otherwise unapproved redirect
|
||||
removes `(feat=...)` before sending.
|
||||
|
||||
Eligible first-party clients install a **request hook** that performs this
|
||||
classification and calls `apply_feature_token()`:
|
||||
|
||||
- **OpenAI-SDK clients created by Agent Framework**: construct the underlying
|
||||
client with
|
||||
`http_client=DefaultAsyncHttpxClient(event_hooks={"request": [_stamp_feat_hook]})`.
|
||||
Using OpenAI's `DefaultAsyncHttpxClient` preserves the SDK's redirect,
|
||||
connection-limit, and timeout defaults; a plain `httpx.AsyncClient` must not
|
||||
replace them. The hook adds or removes the token based on the approved pipeline
|
||||
plus actual-origin classification. Caller-supplied clients/transports are not
|
||||
replaced or patched.
|
||||
- **azure-core pipeline clients**: start with `AIProjectClient` paths whose
|
||||
telemetry is confirmed ingestible. When Agent Framework constructs/configures
|
||||
an approved pipeline, add a separate per-call `SansIOHTTPPolicy` whose
|
||||
`on_request` performs the same actual-origin check and calls
|
||||
`apply_feature_token()` on
|
||||
`request.http_request.headers["User-Agent"]`. Do not stamp `SearchClient`,
|
||||
`CosmosClient`, or another Azure client merely because it is first-party; add
|
||||
it to the allowlist only after confirming the data path. This mirrors .NET's
|
||||
request-time `PipelinePolicy` exactly.
|
||||
|
||||
This fixes the frozen-at-construction problem: the token is materialised at
|
||||
**send time**, not client-init time, so it carries features activated after the
|
||||
client was created. It also confines the token to first-party endpoints. Caller-owned
|
||||
clients are not patched, and toolkit-owned clients without a supported public
|
||||
hook are outside v1 coverage.
|
||||
|
||||
Encoding uses the RFC 7231 **comment** form `(feat=v1.<hex>)` (metadata, not a
|
||||
product token), placed after the agent-framework product token, e.g.:
|
||||
|
||||
```text
|
||||
foundry-hosting/agent-framework-python/1.2.3 (feat=v1.2a)
|
||||
```
|
||||
|
||||
### OpenTelemetry — not in v1
|
||||
|
||||
An OTel span attribute carrying the same value was considered but **deferred —
|
||||
primarily for privacy, not complexity**. Unlike the first-party-only UA token, a
|
||||
span attribute broadcasts the feature-combination fingerprint into the user's
|
||||
**general** telemetry pipeline, which is commonly exported to third-party APM
|
||||
vendors (Datadog, Honeycomb, …) — re-introducing exactly the leakage the
|
||||
first-party scoping was chosen to avoid. (It also carries a cardinality footgun:
|
||||
a monotonically-growing, combinatorial value must never become a metric
|
||||
dimension.) The version prefix leaves the door open to add it later **if** the
|
||||
User-Agent path cannot answer a concrete query and there is an acceptable
|
||||
scoped/redacted variant; v1 ships the UA path only. See
|
||||
[ADR-0033 → option C](../decisions/0033-feature-usage-bitmask-user-agent.md#considered-options).
|
||||
|
||||
## API Changes
|
||||
|
||||
New **internal cross-package** surface in
|
||||
`agent_framework._telemetry` (not exported from `agent_framework`):
|
||||
|
||||
- `mark_feature_used(index: int) -> None`
|
||||
- `get_feature_token() -> str | None` — returns `v<ver>.<hex>` or `None`.
|
||||
- `apply_feature_token(user_agent: str) -> str` — live, idempotent UA stamper
|
||||
used by first-party request hooks.
|
||||
- `FEATURE_MASK_DISABLED_ENV_VAR` constant — the dedicated mask-only opt-out env
|
||||
var name (`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`).
|
||||
|
||||
Each package also adds a private package-local `FeatureIndex` declaration for
|
||||
the rows it owns. The dedicated mask-only opt-out and Python's existing
|
||||
whole-User-Agent opt-out gate the Python mask; see [Opt-out](#opt-out).
|
||||
|
||||
Behavioural change to existing API:
|
||||
|
||||
- `get_user_agent()` / `prepend_agent_framework_to_user_agent()` are
|
||||
**unchanged** — they keep returning the base UA with no `feat=` token. The
|
||||
token is added only by first-party request hooks via
|
||||
`apply_feature_token()`.
|
||||
|
||||
No breaking changes: when the mask is empty or disabled, for any non-first-party
|
||||
client, or for an injected client outside the supported-hook set, output is
|
||||
byte-for-byte identical to today.
|
||||
|
||||
## Opt-out
|
||||
|
||||
The dedicated mask-only opt-out is shared by both SDKs. Python also retains its
|
||||
pre-existing whole-User-Agent opt-out:
|
||||
|
||||
| Env var | SDKs | Effect |
|
||||
| --- | --- | --- |
|
||||
| `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED` | Python and .NET | disables **only** the feature mask; the base `agent-framework-<lang>/{version}` User-Agent is still sent |
|
||||
| `AGENT_FRAMEWORK_USER_AGENT_DISABLED` | Python (existing behavior) | disables the **entire** Python AF User-Agent contribution, mask included |
|
||||
|
||||
The flags accept `true`/`1` (case-insensitive). The dedicated flag lets a
|
||||
privacy-conscious user keep contributing the SDK identity/version (useful for
|
||||
support and compat triage) while withholding the feature-usage signal. The mask
|
||||
is also disabled implicitly whenever Python's whole User-Agent is disabled. A
|
||||
new whole-User-Agent opt-out for .NET is outside this design.
|
||||
|
||||
## E2E example
|
||||
|
||||
```python
|
||||
from agent_framework import Agent
|
||||
from agent_framework_foundry import FoundryChatClient
|
||||
from agent_framework_openai import OpenAIChatClient
|
||||
|
||||
# First-party (Foundry) client: request hook stamps the live feat token.
|
||||
agent = Agent(client=FoundryChatClient(...), instructions="...")
|
||||
# Agent use marks bit 0; FoundryChatClient marks bit 48
|
||||
await agent.run("Hello")
|
||||
# Outgoing request to Foundry carries:
|
||||
# User-Agent: agent-framework-python/1.2.3 (feat=v1.<mask-at-send-time>)
|
||||
|
||||
# Third-party client: NO feat token is added (no first-party hook).
|
||||
other = Agent(client=OpenAIChatClient(...), instructions="...")
|
||||
await other.run("Hi")
|
||||
# Outgoing request to OpenAI carries only:
|
||||
# User-Agent: agent-framework-python/1.2.3
|
||||
```
|
||||
|
||||
Drop only the feature mask (keep the base User-Agent):
|
||||
|
||||
```bash
|
||||
AGENT_FRAMEWORK_FEATURE_MASK_DISABLED=true python app.py
|
||||
# Foundry request User-Agent: agent-framework-python/1.2.3 (no (feat=...) comment)
|
||||
```
|
||||
|
||||
Python only: use the existing flag to drop its entire User-Agent contribution
|
||||
(mask included):
|
||||
|
||||
```bash
|
||||
AGENT_FRAMEWORK_USER_AGENT_DISABLED=true python app.py
|
||||
```
|
||||
|
||||
## .NET mapping
|
||||
|
||||
- Core owns `FeatureUsage.MarkUsed(int index)` plus the core package's private
|
||||
index declaration. Each optional assembly owns a private `FeatureIndex` enum
|
||||
containing only its allocated rows. These are index positions `0..127`, not
|
||||
`[Flags]` values; `MarkUsed` performs the shift.
|
||||
- Store the 128-bit mask as **two `long` lanes** (`low` for bits 0–63, `high`
|
||||
for 64–127). Marking touches one lane with `Interlocked.Or` where available
|
||||
and a small `Interlocked.CompareExchange` loop on `netstandard2.0` / `net472`.
|
||||
Read each lane atomically. Since bits only move from zero to one, a concurrent
|
||||
two-lane snapshot may miss a just-added bit but can never invent or clear one;
|
||||
the next request includes it.
|
||||
- Format without depending on `UInt128`: if `high == 0`, emit `low` as lowercase
|
||||
hex; otherwise emit `high` without leading zeros followed by `low:x16`. Cast
|
||||
each signed lane to `ulong` before formatting so bits 63 and 127 are preserved.
|
||||
Reject indexes outside `0..127`.
|
||||
- **Emission is stamped at request time and first-party-scoped**, matching
|
||||
Python. The
|
||||
existing `AgentFrameworkUserAgentPolicy` / `HostedAgentUserAgentPolicy`
|
||||
pipeline policies already run per request — extend them to apply the same
|
||||
approved-pipeline + actual-origin classifier, append/refresh the `(feat=...)`
|
||||
comment only for approved destinations, and remove it on unapproved redirect
|
||||
hops. Do not register it on third-party `IChatClient`s.
|
||||
- Same **wire format** (`v<version>.<hex>` comment, hex encoding) and the same
|
||||
dedicated mask-only opt-out (`AGENT_FRAMEWORK_FEATURE_MASK_DISABLED`). The
|
||||
**mask is decoded per language**: indexes are not shared, so a decoder must
|
||||
read the language from the UA product token and select that language's table
|
||||
before decoding. (.NET's policy was already request-time, so there is no
|
||||
Python/.NET timing asymmetry.) Adding a .NET whole-User-Agent opt-out is
|
||||
outside this design.
|
||||
|
||||
## Keeping the bitmap in sync
|
||||
|
||||
[feature-usage-bit-registry.md](feature-usage-bit-registry.md) is the published
|
||||
allocation contract. Package-local `FeatureIndex` declarations are the runtime
|
||||
implementation. There is deliberately **no shared numbering across languages**
|
||||
and **no machine-readable registry file**.
|
||||
|
||||
One repository validation test gathers every package-local declaration for one
|
||||
language/version and parses the matching Markdown table. It asserts:
|
||||
|
||||
1. every declared index is within `0..127`;
|
||||
2. every `(index, id)` exactly matches one registry row;
|
||||
3. the union of declarations has no duplicate/overlapping indexes;
|
||||
4. every non-reserved registry row is declared exactly once.
|
||||
|
||||
Adding an optional-package feature therefore changes that package and the
|
||||
registry, not core. If a programmatic decoder is built later, export the table
|
||||
to JSON then.
|
||||
|
||||
### Decoding
|
||||
|
||||
```
|
||||
UA: agent-framework-python/1.2.3 (feat=v1.2a)
|
||||
│ │ └ hex mask
|
||||
│ └ version
|
||||
└ language → pick the Python table (version 1)
|
||||
```
|
||||
|
||||
Read language → pick the table; read `vN` → pick that version; `AND` the hex mask
|
||||
against each bit. Unknown bits (from a newer SDK than the decoder's copy of the
|
||||
table) are ignored.
|
||||
|
||||
## Implementation plan (post-approval)
|
||||
|
||||
1. **Privacy approval** — confirm the first-party-only feature-combination
|
||||
signal, retention, access, allowed queries, and opt-out behavior before code
|
||||
ships.
|
||||
2. **Core accumulator** — in `agent_framework/_telemetry.py` add the 128-bit
|
||||
mask, lock, `mark_feature_used(index)`, `get_feature_token`, and
|
||||
`apply_feature_token`; `get_user_agent()` stays base-only.
|
||||
3. **Package-local indexes + validation** — add private `FeatureIndex`
|
||||
declarations to packages and a repository test for exact registry parity,
|
||||
complete coverage, range, and zero overlap.
|
||||
4. **First-party request-time hooks** — use OpenAI's
|
||||
`DefaultAsyncHttpxClient` for framework-created clients and the separate
|
||||
azure-core `SansIOHTTPPolicy`. Require approved pipeline **and** approved
|
||||
actual origin on every request/redirect hop. Verify custom origins and
|
||||
cross-origin redirects never carry the token.
|
||||
5. **Mark feature usage** — call `mark_feature_used(FeatureIndex.X)` at the
|
||||
first meaningful activation. Operational clients/providers/tools mark on
|
||||
their first real operation; build/start points mark compositional features.
|
||||
Constructor-only marking requires construction itself to exercise the
|
||||
capability.
|
||||
6. **.NET parity** — package-local index enums plus the two atomic 64-bit lanes
|
||||
with `Interlocked.Or` / compare-exchange fallback; extend existing request-time
|
||||
Foundry UA policies through the shared destination classifier and formatter.
|
||||
7. **Docs & tests** — update package `AGENTS.md`/skills; tests for **both**
|
||||
Python opt-out paths (dedicated mask-only and existing whole-UA), the
|
||||
dedicated .NET mask-only opt-out, first-party scoping, and the live
|
||||
(non-frozen) UA.
|
||||
|
||||
## Limitations & open questions
|
||||
|
||||
The decision-level limitations and unresolved trade-offs — reach, per-process
|
||||
(not per-call) attribution, v1 granularity, fingerprinting residue, and the OTel
|
||||
question — are owned by the ADR (the dedicated mask-only opt-out is now decided
|
||||
and included). See
|
||||
**[ADR-0033 → Limitations](../decisions/0033-feature-usage-bitmask-user-agent.md#limitations)**
|
||||
and **[Open Questions](../decisions/0033-feature-usage-bitmask-user-agent.md#open-questions-for-decider-discussion)**.
|
||||
This spec is the implementation reference; it does not re-litigate those choices.
|
||||
|
||||
Implementation-only note:
|
||||
|
||||
- **Per-request hook overhead is negligible** (a flag check, one Python integer
|
||||
snapshot or two atomic .NET lane reads, and a string concat per first-party
|
||||
request), but benchmark the hot path once if a high-QPS Foundry scenario is in
|
||||
scope.
|
||||
@@ -1,549 +0,0 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-07-27
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Python function-calling loop contract and validation matrix
|
||||
|
||||
## Scope
|
||||
|
||||
This specification defines the required behavior and validation coverage for the Python function-calling loop.
|
||||
It covers:
|
||||
|
||||
- normal local function execution;
|
||||
- streaming and non-streaming response aggregation;
|
||||
- tool approval request and resume;
|
||||
- approved, rejected, mixed, and replayed approval rounds;
|
||||
- reasoning content and opaque reasoning signatures bound to function calls;
|
||||
- history persistence and service-side continuation;
|
||||
- error, user-input, middleware-termination, and loop-limit paths;
|
||||
- provider and transport serialization of function calls and results.
|
||||
|
||||
The primary implementation is in `python/packages/core/agent_framework/_tools.py`. History replay behavior in
|
||||
`python/packages/core/agent_framework/_sessions.py`, provider serializers, hosting packages, and UI transports are
|
||||
part of the same contract when they carry function-call loop content.
|
||||
|
||||
## Change sensitivity
|
||||
|
||||
This code is high risk. Small changes can produce duplicate side effects, orphaned calls or results, invalid
|
||||
provider histories, invisible streaming results, stale approval authority, or loops that never terminate.
|
||||
Dropping reasoning content that a service binds to a tool call can also make an otherwise balanced call/result
|
||||
transcript invalid.
|
||||
|
||||
Any change to the function-calling loop or its approval/history/serialization paths must:
|
||||
|
||||
1. identify every affected row in the scenario matrix below;
|
||||
2. add or update the corresponding regression tests;
|
||||
3. validate streaming updates, streaming finalization, and non-streaming output where applicable;
|
||||
4. validate both model-bound history and caller-visible responses;
|
||||
5. run the full core package tests plus every affected provider or transport package;
|
||||
6. run source typing, test typing, and syntax checks for every affected package;
|
||||
7. receive extra review focused on call/result pairing, exactly-once execution, and history replay.
|
||||
|
||||
A passing narrow regression test is not sufficient evidence for changes in this area.
|
||||
|
||||
### Contribution ownership
|
||||
|
||||
Issues involving this code must not be picked up by external contributors without first checking with the Agent
|
||||
Framework core team. The core team must confirm the intended behavior, affected scenario-matrix rows, ownership
|
||||
across core/providers/transports, and the required validation scope before implementation starts.
|
||||
|
||||
## Flow diagrams and code map
|
||||
|
||||
### Main function-calling flow
|
||||
|
||||
The main control flow deliberately has separate streaming and non-streaming methods. They share policy helpers, but
|
||||
their output mechanics differ: one returns an aggregated `ChatResponse`; the other yields `ChatResponseUpdate`
|
||||
items and is finalized by `ResponseStream`.
|
||||
|
||||
The diagrams use only the generic distinction between **local tools**, which Agent Framework executes, and
|
||||
**hosted-service tools**, whose calls and approval decisions are owned by a remote service. Provider-specific wire
|
||||
formats and regression tests appear later in the scenario matrix.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Entry["FunctionInvocationLayer.get_response(...)"]
|
||||
Setup["Prepare middleware, options, session, budget state,<br/>and execute_function_calls partial"]
|
||||
Enabled{"Function invocation enabled?"}
|
||||
Direct["Delegate directly to super().get_response(...)"]
|
||||
Mode{"stream?"}
|
||||
NonStream["_get_response_with_function_invocation(...)"]
|
||||
Stream["_stream_response_with_function_invocation(...)"]
|
||||
Resolve["_resolve_approval_responses(...)<br/>runs once before the model-iteration loop"]
|
||||
ApprovalAction{"approval action"}
|
||||
Immediate["Return/yield terminal result or user-input request<br/>without another model call"]
|
||||
ApprovalPolicy["Record approval executions;<br/>apply stop/function-call-limit policy"]
|
||||
Model["Call super_get_response(...)<br/>response may contain reasoning + function_call"]
|
||||
Process["_process_model_function_calls(...)"]
|
||||
FunctionAction{"function-processing action"}
|
||||
Execute["_execute_function_calls(...)"]
|
||||
Try["_try_execute_function_calls(...)"]
|
||||
Single["_execute_single_function_call(...)"]
|
||||
Handle["_handle_function_call_results(...)"]
|
||||
PostCallPolicy["Record executions; apply error/function-call-limit policy;<br/>reset required tool choice"]
|
||||
Advance["_prepare_messages_for_next_iteration(...)"]
|
||||
More{"iteration budget remains?"}
|
||||
Final["Final model call with tool_choice = none<br/>and deterministic fallback if needed"]
|
||||
Output["Return ChatResponse or complete ResponseStream"]
|
||||
|
||||
Entry --> Setup --> Enabled
|
||||
Enabled -- no --> Direct
|
||||
Enabled -- yes --> Mode
|
||||
Mode -- no --> NonStream
|
||||
Mode -- yes --> Stream
|
||||
NonStream --> Resolve
|
||||
Stream --> Resolve
|
||||
Resolve --> ApprovalAction
|
||||
ApprovalAction -- return --> Immediate --> Output
|
||||
ApprovalAction -- stop --> ApprovalPolicy
|
||||
ApprovalAction -- continue --> ApprovalPolicy
|
||||
ApprovalPolicy --> More
|
||||
Model --> Process
|
||||
Process --> Execute --> Try --> Single --> Handle --> FunctionAction
|
||||
FunctionAction -- return --> Output
|
||||
FunctionAction -- stop --> PostCallPolicy
|
||||
FunctionAction -- continue --> PostCallPolicy
|
||||
PostCallPolicy --> Advance
|
||||
Advance --> More
|
||||
More -- yes --> Model
|
||||
More -- no --> Final --> Output
|
||||
```
|
||||
|
||||
Code-reading landmarks:
|
||||
|
||||
- `get_response(...)` owns setup and selects the response mode.
|
||||
- `_get_response_with_function_invocation(...)` owns non-streaming aggregation.
|
||||
- `_stream_response_with_function_invocation(...)` owns streamed emission/finalization.
|
||||
- `_resolve_approval_responses(...)` handles only inbound approval decisions.
|
||||
- `_process_model_function_calls(...)` handles only calls from a completed model response.
|
||||
- `_try_execute_function_calls(...)` decides approval/declaration/execution behavior for a batch.
|
||||
- `_replace_approval_contents_with_results(...)` is the occurrence-aware approval transcript normalizer.
|
||||
|
||||
### Approval pause and resume
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Caller
|
||||
participant History as HistoryProvider
|
||||
participant Layer as FunctionInvocationLayer
|
||||
participant Tool
|
||||
participant Model
|
||||
|
||||
Caller->>Layer: Initial user request
|
||||
Layer->>Model: Messages + tools
|
||||
Model-->>Layer: reasoning content + function_call
|
||||
Layer->>Layer: Tool requires approval
|
||||
Layer-->>Caller: function_call + function_approval_request
|
||||
|
||||
Caller->>Layer: function_approval_response
|
||||
Layer->>Layer: Copy caller-owned messages
|
||||
Layer->>Layer: _resolve_approval_responses(...)
|
||||
|
||||
alt approved
|
||||
Layer->>Tool: Execute exactly once
|
||||
Tool-->>Layer: result or exception
|
||||
Layer->>Layer: Create terminal function_result
|
||||
else rejected
|
||||
Layer->>Layer: Create synthetic rejection function_result
|
||||
end
|
||||
|
||||
Layer-->>Caller: Terminal result message/update
|
||||
|
||||
alt tool requests more user input
|
||||
Layer-->>Caller: User-input request with assistant role
|
||||
else middleware terminates
|
||||
Layer-->>Caller: Termination result
|
||||
else error limit reached
|
||||
Layer->>Model: Normalized reasoning/call/result history, tools disabled
|
||||
Model-->>Layer: Final assistant response
|
||||
Layer-->>Caller: Final assistant response
|
||||
else continue normally
|
||||
Layer->>Model: Normalized reasoning/call/result history
|
||||
Model-->>Layer: Final assistant response or another function_call
|
||||
Layer-->>Caller: Final assistant response / continued loop
|
||||
end
|
||||
|
||||
Layer-->>History: Persist caller input + returned response
|
||||
Note over History: Later model replay filters approval request/response wrappers
|
||||
```
|
||||
|
||||
The terminal result is caller-visible in both modes. The private normalized message copy is model-visible. The
|
||||
original caller input and earlier response remain unchanged.
|
||||
|
||||
### Reasoning-bound function-call groups
|
||||
|
||||
Some hosted services bind reasoning content or an opaque reasoning signature to the function call that follows it.
|
||||
For those services, reasoning is not optional decoration; it is part of the provider-valid function-call group.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Response["Assistant response:<br/>reasoning content + function_call"]
|
||||
Group["One logical reasoning/function-call group"]
|
||||
Owner{"local or hosted-service tool?"}
|
||||
Local["Local execution"]
|
||||
Hosted["Hosted service owns tool execution/state"]
|
||||
Result["Terminal function_result or hosted result"]
|
||||
Continuation{"continuation mode"}
|
||||
Stateless["Stateless or framework-history replay"]
|
||||
Replayable{"reasoning payload/signature<br/>is replayable?"}
|
||||
Replay["Replay reasoning + call + result atomically"]
|
||||
Reject["Fail before the service call;<br/>do not send a lossy transcript"]
|
||||
Service["Hosted-service continuation"]
|
||||
Reference["Reference service-stored reasoning/call;<br/>send only the new result or approval decision"]
|
||||
Compact{"compaction needed?"}
|
||||
Atomic["Keep or exclude the complete<br/>reasoning/call/result group"]
|
||||
Caller["Caller-visible response retains reasoning<br/>with the function-call turn"]
|
||||
|
||||
Response --> Group --> Owner
|
||||
Group --> Caller
|
||||
Owner -- local --> Local --> Result
|
||||
Owner -- hosted service --> Hosted --> Result
|
||||
Result --> Compact
|
||||
Compact -- yes --> Atomic --> Continuation
|
||||
Compact -- no --> Continuation
|
||||
Continuation -- stateless / local history --> Stateless --> Replayable
|
||||
Replayable -- yes --> Replay
|
||||
Replayable -- no --> Reject
|
||||
Continuation -- service-managed --> Service --> Reference
|
||||
```
|
||||
|
||||
The generic contract is:
|
||||
|
||||
- reasoning content remains ordered immediately before or alongside the function call it explains;
|
||||
- a terminal result does not replace or discard the reasoning/call portion of the active group;
|
||||
- stateless replay includes the service-required reasoning payload or opaque signature;
|
||||
- if required reasoning cannot be reconstructed, the adapter fails before sending invalid or lossy history;
|
||||
- service-managed continuation may rely on the hosted service's stored reasoning/call items and send only new
|
||||
outputs or approval decisions;
|
||||
- compaction keeps or removes the entire reasoning/call/result group atomically.
|
||||
|
||||
In the code, core response aggregation preserves reasoning `Content` items, compaction annotations bind reasoning to
|
||||
the tool-call group, and provider adapters serialize or reconstruct the provider-specific reasoning representation.
|
||||
|
||||
### Approval correlation, replay, and reused ids
|
||||
|
||||
`call_id` is not globally unique forever. The normalizer therefore tracks open logical occurrences in transcript
|
||||
order instead of keeping one global result per id.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Scan["Scan normalized messages in order"]
|
||||
Kind{"content type"}
|
||||
Call["function_call:<br/>open a call occurrence"]
|
||||
Request["function_approval_request"]
|
||||
Bind{"unbound call occurrence<br/>with same call_id?"}
|
||||
BindExisting["Bind request id to existing occurrence<br/>and remove wrapper"]
|
||||
Duplicate{"same request identity<br/>already restored?"}
|
||||
DropDuplicate["Remove replayed duplicate wrapper"]
|
||||
Restore["Restore embedded function_call<br/>as a new occurrence"]
|
||||
Placeholder["function_result with APPROVAL_PENDING:<br/>attach placeholder to open occurrence"]
|
||||
Completed["terminal function_result:<br/>close earliest open occurrence"]
|
||||
Response["function_approval_response"]
|
||||
Pending{"response still pending?"}
|
||||
RemoveOld["Remove already-resolved historical response"]
|
||||
Decision{"approved?"}
|
||||
Approved["Pop next execution result for this call_id"]
|
||||
Rejected["Create synthetic rejection result"]
|
||||
HasPlaceholder{"occurrence has placeholder?"}
|
||||
Replace["Replace placeholder and remove response wrapper"]
|
||||
ReplaceResponse["Replace response wrapper with terminal content"]
|
||||
Close["Close occurrence; append terminal content<br/>to resumed response"]
|
||||
Next["Continue scan"]
|
||||
|
||||
Scan --> Kind
|
||||
Kind -- function_call --> Call --> Next
|
||||
Kind -- approval request --> Request --> Bind
|
||||
Bind -- yes --> BindExisting --> Next
|
||||
Bind -- no --> Duplicate
|
||||
Duplicate -- yes --> DropDuplicate --> Next
|
||||
Duplicate -- no --> Restore --> Next
|
||||
Kind -- pending placeholder --> Placeholder --> Next
|
||||
Kind -- terminal result --> Completed --> Next
|
||||
Kind -- approval response --> Response --> Pending
|
||||
Pending -- no --> RemoveOld --> Next
|
||||
Pending -- yes --> Decision
|
||||
Decision -- yes --> Approved --> HasPlaceholder
|
||||
Decision -- no --> Rejected --> HasPlaceholder
|
||||
HasPlaceholder -- yes --> Replace --> Close --> Next
|
||||
HasPlaceholder -- no --> ReplaceResponse --> Close --> Next
|
||||
Next --> Kind
|
||||
```
|
||||
|
||||
This flow corresponds to `_ApprovalCallOccurrence`, `_collect_approval_responses(...)`, and
|
||||
`_replace_approval_contents_with_results(...)`.
|
||||
|
||||
### History and service-side continuation
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Store["History backing store<br/>(may retain approval wrappers for audit)"]
|
||||
Load{"HistoryProvider.load_messages?"}
|
||||
Filter["_filter_approval_control_messages(...)"]
|
||||
Context["SessionContext model history:<br/>function_call + terminal function_result"]
|
||||
Current["Current caller input:<br/>new function_approval_response"]
|
||||
Layer["FunctionInvocationLayer private copy"]
|
||||
Local{"local or hosted-service approval?"}
|
||||
LocalResult["Execute locally and normalize to function_result"]
|
||||
Hosted["Hosted-service adapter"]
|
||||
StoredRequest["Prior service-issued approval request"]
|
||||
NewResponse["Current hosted approval decision"]
|
||||
Skip["Do not replay the stored request inline"]
|
||||
Send["Send the approval decision exactly once"]
|
||||
Later["Later turn"]
|
||||
Manual["Manual-history caller"]
|
||||
|
||||
Store --> Load
|
||||
Load -- yes --> Filter --> Context --> Layer
|
||||
Load -- no --> Layer
|
||||
Current --> Layer
|
||||
Layer --> Local
|
||||
Local -- local --> LocalResult --> Later
|
||||
Local -- hosted service --> Hosted
|
||||
StoredRequest --> Hosted --> Skip
|
||||
NewResponse --> Hosted --> Send --> Later
|
||||
Later --> Store
|
||||
Manual -. owns equivalent filtering .-> Layer
|
||||
```
|
||||
|
||||
When `load_messages=False`, no history is replayed and the history filter is intentionally not invoked. Callers
|
||||
that manually replay messages own the equivalent rule: do not resend an approval response after its terminal result.
|
||||
|
||||
## Normative contract
|
||||
|
||||
### Function calls and results
|
||||
|
||||
- Every actionable local `function_call` produces exactly one terminal `function_result`, unless execution pauses
|
||||
for a new user-input request.
|
||||
- Parallel calls retain model order in the returned transcript.
|
||||
- Reused `call_id` values are correlated by logical occurrence, not one global value per id.
|
||||
- A completed function call/result pair is inert on later turns.
|
||||
- Informational-only and declaration-only calls are not executed as local tools.
|
||||
|
||||
### Reasoning-bound calls
|
||||
|
||||
- Reasoning content or opaque reasoning metadata that a service binds to a function call is part of the same logical
|
||||
group as that call and its terminal result.
|
||||
- Active function loops preserve the reasoning content, function call, function result, and final assistant output
|
||||
in caller-visible responses.
|
||||
- Framework-managed/stateless replay includes the service-required reasoning representation before the paired call.
|
||||
- Service-managed continuation may omit inline reasoning/call items only when the hosted service already owns them.
|
||||
- Missing non-reconstructable reasoning fails explicitly before a provider request instead of silently dropping the
|
||||
content.
|
||||
- Compaction preserves or excludes the complete reasoning/call/result group atomically.
|
||||
|
||||
### Approval request and resume
|
||||
|
||||
- A tool that requires approval does not execute before an approved response.
|
||||
- An approved tool executes exactly once.
|
||||
- A rejected tool executes zero times and produces one synthetic rejection `function_result` using the original
|
||||
function `call_id`.
|
||||
- The resumed response contains the newly resolved approved and rejected terminal results before any final assistant
|
||||
message.
|
||||
- Streaming yields the same logical result content and ordering as non-streaming output and
|
||||
`ResponseStream.get_final_response()`.
|
||||
- The function invocation layer normalizes a private copy of caller messages. It must not mutate the caller's
|
||||
approval `Message`, approval `Content`, or an earlier returned response.
|
||||
- Approval-time `UserInputRequiredException` and `MiddlewareTermination` return immediately without another model
|
||||
call.
|
||||
|
||||
### Approval control content
|
||||
|
||||
- `function_approval_request` and `function_approval_response` are control-plane contents, not durable model
|
||||
transcript items.
|
||||
- A current hosted approval response must be sent once on the immediate resume request.
|
||||
- A server-issued approval request must not be replayed inline during service-side continuation.
|
||||
- History providers may retain approval control contents in their backing store for audit, but base history replay
|
||||
filters them before later model calls.
|
||||
- Callers that manually own and replay message history without a loading `HistoryProvider` must likewise omit a
|
||||
previously submitted approval response from later continuation requests.
|
||||
|
||||
### History and continuation
|
||||
|
||||
- Model-bound history contains one function call/result pair per completed logical occurrence.
|
||||
- Append-only history must not replay stale approval request/response wrappers to the model.
|
||||
- Framework-managed and service-managed continuation must preserve the same logical call/result transcript.
|
||||
- A terminal result consumes the corresponding approval authority in explicit stateless replay.
|
||||
|
||||
## Scenario-to-test matrix
|
||||
|
||||
### Normal function invocation
|
||||
|
||||
| Scenario | Required invariant | Primary regression test |
|
||||
|---|---|---|
|
||||
| Single non-streaming call | Call, result, and final assistant message are returned in order. | `packages/core/tests/core/test_function_invocation_logic.py::test_base_client_with_function_calling` |
|
||||
| String input | Flexible string input follows the same loop behavior. | `test_base_client_with_function_calling_string_input` |
|
||||
| Multiple sequential rounds | Each round retains one call/result pair. | `test_base_client_with_function_calling_resets` |
|
||||
| Streaming call | Call chunks, one result update, and final text are emitted in order. | `test_base_client_with_streaming_function_calling` |
|
||||
| Reasoning-bound call | Finalized output retains reasoning, function call, function result, and final text. | `test_streaming_function_calling_response_includes_reasoning_and_tool_results` |
|
||||
| Calls across response messages | Every actionable call is executed once. | `test_base_client_executes_function_calls_across_multiple_response_messages` |
|
||||
| Parallel calls | Results retain the corresponding call ids and execution count. | `test_max_function_calls_limits_parallel_invocations`, `test_streaming_multiple_function_calls_parallel_execution` |
|
||||
| Informational-only call | The call is returned but not executed or approved. | `test_informational_only_function_call_is_not_invoked`, `test_informational_only_function_call_does_not_request_approval`, `test_streaming_informational_only_function_call_is_not_invoked` |
|
||||
| Declaration-only call | The call is surfaced as user input and is not executed; streaming arguments appear once while finalized request metadata remains available. | `test_declaration_only_tool`, `test_streaming_declaration_only_tool_preserves_metadata_without_duplicate_arguments` |
|
||||
| Function invocation disabled | The client bypasses the invocation loop without losing invocation kwargs. | `test_function_invocation_config_enabled_false`, `test_function_invocation_config_enabled_false_preserves_invocation_kwargs`, `test_streaming_function_invocation_config_enabled_false` |
|
||||
| Runtime tool changes | Added tools become available on the next iteration and retain approval behavior. | `test_add_tools_available_next_iteration`, `test_add_tools_with_approval_required_tool` |
|
||||
|
||||
### Approval pause and resume
|
||||
|
||||
| Scenario | Required invariant | Primary regression test |
|
||||
|---|---|---|
|
||||
| Initial approval request | Assistant response contains the original call and approval request; tool does not execute. | `test_approval_requests_in_assistant_message`, `test_streaming_approval_request_generated`, `test_streaming_approval_requests_in_assistant_message` |
|
||||
| Approved non-streaming resume | Result precedes final text; tool executes once; inputs remain unchanged. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_returns_result_without_mutating_inputs[non-streaming-approved]` |
|
||||
| Rejected non-streaming resume | Rejection result precedes final text; tool executes zero times; inputs remain unchanged. | `test_approval_resume_returns_result_without_mutating_inputs[non-streaming-rejected]` |
|
||||
| Approved streaming resume | Result update precedes final text and final response matches non-streaming shape. | `test_approval_resume_returns_result_without_mutating_inputs[streaming-approved]`, `test_streaming_approval_resume_yields_terminal_result_before_model_text[approved]` |
|
||||
| Rejected streaming resume | Rejection result update precedes final text and tool executes zero times. | `test_approval_resume_returns_result_without_mutating_inputs[streaming-rejected]`, `test_streaming_approval_resume_yields_terminal_result_before_model_text[rejected]` |
|
||||
| Mixed approved/rejected batch | Every call gets one correctly correlated terminal result. | `packages/core/tests/core/test_function_invocation_logic.py::test_rejected_approval` |
|
||||
| Persisted approval replay | Resume executes with the prior call available. | `test_persisted_approval_messages_replay_correctly` |
|
||||
| Hosted approval pass-through | Hosted requests/responses are not processed as local calls. | `test_hosted_tool_approval_response`, `test_hosted_mcp_approval_response_passthrough`, `test_mixed_local_and_hosted_approval_flow` |
|
||||
| Approval-time user input | Every user-input request from one approved execution returns in order with assistant role and no extra model call; the execution consumes one call-budget unit. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_returns_all_user_input_requests_without_another_model_call`, `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_user_input_counts_toward_function_call_budget` |
|
||||
| Mixed terminal result and follow-up input | Completed siblings remain tool-role while only follow-up input requests use assistant-role messages/updates. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_separates_terminal_results_from_follow_up_requests`, `packages/openai/tests/openai/test_openai_chat_completion_client.py::test_mixed_approval_resume_roles_serialize_function_result_as_tool` |
|
||||
| Approval-time middleware termination | Terminal result returns with no extra model call in either response mode. | `packages/core/tests/core/test_function_invocation_logic.py::test_approval_resume_honors_middleware_termination` |
|
||||
| Approval re-entry after iteration budget | Pending approved calls resolve once even when prior model calls consumed `max_iterations`. | `packages/core/tests/core/test_harness_tool_approval.py::test_auto_approval_resolves_after_iteration_budget_is_exhausted` |
|
||||
| Approval resume with reasoning | Model-bound resume history retains reasoning before the call and terminal result in both modes. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_replays_reasoning_with_function_call_group` |
|
||||
|
||||
### Approval correlation and replay
|
||||
|
||||
| Scenario | Required invariant | Primary regression test |
|
||||
|---|---|---|
|
||||
| Result matching without placeholders | Results match calls by id even when the result list is reordered. | `test_replace_approval_contents_with_results_uses_result_call_ids_without_placeholders` |
|
||||
| Reused id after completion | A later round with the same id creates a second valid pair. | `test_replace_approval_contents_with_results_allows_reused_call_id_after_completion` |
|
||||
| Replayed approval wrapper | A duplicated wrapper does not restore another function call. | `test_replace_approval_contents_with_results_deduplicates_replayed_approval_request` |
|
||||
| Historical resolved response plus new round | The old response is removed from normalized input and is not converted into a rejection result. | `test_replace_approval_contents_with_results_ignores_already_resolved_response` |
|
||||
| Multiple reused-id rounds | Approved and rejected rounds retain separate call/result occurrences. | `test_replace_approval_contents_with_results_correlates_reused_call_id_occurrences` |
|
||||
| Multi-content result with reused id | Every content produced by one execution stays with that approval occurrence and cannot bleed into the next reused-id round. | `test_replace_approval_contents_with_results_keeps_multi_content_group_with_reused_call_id` |
|
||||
| Follow-up request closes one occurrence | A user-input follow-up consumes only the preceding approval authority and leaves a later reused-id response pending. | `test_collect_approval_responses_consumes_matching_follow_up_request_occurrence` |
|
||||
| Reused-id placeholders | Placeholder results consume approved results by occurrence. | `test_replace_approval_contents_with_results_correlates_reused_call_id_placeholders` |
|
||||
| Rejected placeholder | Rejection replaces the pending placeholder instead of adding a second result. | `test_replace_approval_contents_with_results_replaces_rejected_placeholder` |
|
||||
| Results reordered with placeholders | Results still match the correct call ids. | `test_replace_approval_contents_with_results_uses_result_call_ids_for_placeholders` |
|
||||
| Missing result call id | A malformed result does not steal another approval's result. | `test_replace_approval_contents_with_results_skips_results_without_call_id` |
|
||||
| Empty approval message cleanup | Fully consumed approval messages are removed from normalized model input. | `test_replace_approval_contents_with_results_prunes_emptied_messages` |
|
||||
| Later stateless turn | A prior terminal approval response cannot execute again. | `test_resolved_approval_response_is_inert_on_later_stateless_turn` |
|
||||
| Pending history turn | An unresolved approval batch is omitted atomically from unrelated model input while a later decision can still resume it once. | `packages/core/tests/core/test_harness_tool_approval.py::test_pending_approval_from_file_history_stays_resumable_without_model_orphan` |
|
||||
| Duplicate function-call prevention | Approval normalization does not create a second call for one round. | `test_no_duplicate_function_calls_after_approval_processing` |
|
||||
| Rejection call id | Rejection result uses the function call id, not only the approval id. | `test_rejection_result_uses_function_call_id` |
|
||||
|
||||
### Mixed batches and approval middleware
|
||||
|
||||
| Scenario | Required invariant | Primary regression test |
|
||||
|---|---|---|
|
||||
| Safe and approval-required calls in one batch | Hidden safe calls replay only with the matching visible approval. | `packages/core/tests/core/test_harness_tool_approval.py::test_mixed_batch_hides_already_approved_request_until_approval_replay` |
|
||||
| Restored approval state | Serialized `ToolApprovalState` restores mixed-batch behavior. | `test_mixed_batch_accepts_restored_tool_approval_state` |
|
||||
| Unrelated turn before approval | Hidden calls do not execute on an unrelated turn. | `test_hidden_mixed_batch_requests_do_not_replay_on_unrelated_turn` |
|
||||
| Multiple abandoned batches | Hidden calls replay only for the matching batch. | `test_hidden_mixed_batch_requests_replay_only_for_matching_visible_approval` |
|
||||
| Queued approvals | One unresolved approval is surfaced per run without premature execution. | `test_tool_approval_middleware_queues_multiple_approval_requests`, `test_tool_approval_middleware_queues_streamed_approval_requests` |
|
||||
| Middleware state plus hidden core state | State saves do not discard hidden mixed-batch calls. | `test_tool_approval_middleware_preserves_hidden_mixed_batch_requests` |
|
||||
| Auto-approval callback | Callback receives the original function call and executes the approved set once. | `test_tool_approval_middleware_auto_approval_rule_receives_function_call` |
|
||||
| Shared call budget | Auto-approved re-entry does not reset `max_function_calls`, and every executed approval group counts even when it pauses for input. | `test_tool_approval_middleware_auto_approved_loops_share_function_call_budget`, `test_approval_resume_user_input_counts_toward_function_call_budget` |
|
||||
| Standing tool rule | Tool-level approval applies only to later matching tools. | `test_tool_approval_middleware_always_approve_tool_rule` |
|
||||
| Hosted server boundary | Standing approval does not cross `server_label`. | `test_tool_approval_middleware_standing_rules_include_hosted_server_boundary` |
|
||||
| Argument-scoped rule | Exact arguments are required; empty arguments are not tool-wide. | `test_tool_approval_middleware_always_approve_tool_with_arguments_rule`, `test_tool_approval_middleware_empty_arguments_rule_is_not_tool_wide` |
|
||||
| Provider-injected approval tool | A tool added during `before_run` defers to in-run resolution, executes once, and emits one result. | `packages/ag-ui/tests/ag_ui/test_endpoint.py::test_endpoint_agent_approval_deferred_provider_tool_executes` |
|
||||
|
||||
### Errors, control flow, and limits
|
||||
|
||||
| Scenario | Required invariant | Primary regression test |
|
||||
|---|---|---|
|
||||
| Rejected execution | Rejection is a normal terminal result, not an exception to the caller. | `test_unapproved_tool_execution_raises_exception` |
|
||||
| Approved tool exception | Generic and detailed error modes preserve one result and one execution. | `test_approved_function_call_with_error_without_detailed_errors`, `test_approved_function_call_with_error_with_detailed_errors` |
|
||||
| Approved validation error | Validation failure returns one result without invoking the function body. | `test_approved_function_call_with_validation_error` |
|
||||
| Approved success | Successful approved execution returns one result. | `test_approved_function_call_successful_execution` |
|
||||
| Consecutive error cap | Error threshold stops repeated failures, submits collected results, and makes only the required final no-tool model call. | `test_function_invocation_config_max_consecutive_errors`, `test_streaming_function_invocation_config_max_consecutive_errors`, `test_approval_resume_error_limit_forces_final_no_tool_response` |
|
||||
| Unknown call handling | Configured false returns an error result; configured true raises. | `test_function_invocation_config_terminate_on_unknown_calls_false`, `test_function_invocation_config_terminate_on_unknown_calls_true`, streaming equivalents |
|
||||
| Middleware termination | Normal non-approval loop stops without a second model call. | `test_terminate_loop_single_function_call`, `test_terminate_loop_multiple_function_calls_one_terminates`, `test_terminate_loop_streaming_single_function_call` |
|
||||
| Maximum iterations | No orphan calls; a final no-tool response or deterministic fallback is returned. | `test_max_iterations_limit`, `test_max_iterations_no_orphaned_function_calls`, `test_max_iterations_makes_final_toolchoice_none_call`, `test_max_iterations_blank_final_fallback_synthesizes_message`, streaming equivalents |
|
||||
| Maximum function calls | Parallel overshoot is bounded after the batch; every executed result group counts even without a `function_result`; blank final responses get fallback content. | `test_max_function_calls_limits_parallel_invocations`, `test_max_function_calls_single_calls_per_iteration`, `test_user_input_request_multiple_contents_propagate`, `test_approval_resume_user_input_counts_toward_function_call_budget`, `test_max_function_calls_blank_final_fallback_synthesizes_message`, streaming equivalent |
|
||||
| Provider tool content after an active limit | Locally actionable calls and local approval requests returned despite `tool_choice="none"` are removed in both response modes. Provider-executed informational call/result pairs, hosted approval requests, and metadata-only streaming updates remain visible; fallback text never replaces retained transcript content. | `test_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_drops_unexecutable_tool_content`, `test_streaming_function_invocation_limit_preserves_metadata_after_tool_content_is_dropped`, `test_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_preserves_provider_executed_tool_pair`, `test_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_streaming_function_invocation_limit_appends_fallback_after_provider_executed_tool_pair`, `test_function_invocation_limit_preserves_hosted_approval_request`, `test_streaming_function_invocation_limit_preserves_hosted_approval_request` |
|
||||
| Conversation continuation | Conversation id updates between iterations and is cleared on stop where required. | `test_conversation_id_updated_in_options_between_tool_iterations`, `test_function_invocation_stop_clears_conversation_id_non_stream`, `test_streaming_function_invocation_stop_clears_conversation_id` |
|
||||
|
||||
### History and provider serialization
|
||||
|
||||
| Scenario | Required invariant | Primary regression test |
|
||||
|---|---|---|
|
||||
| Append-only history replay | Resolved approval wrappers do not reach a later model call; one call/result pair remains. | `packages/core/tests/core/test_harness_tool_approval.py::test_approval_resume_filters_resolved_control_items_from_file_history` |
|
||||
| Pending placeholder history | An approval response remains replayable while its only result is `[APPROVAL_PENDING]`. | `packages/core/tests/core/test_sessions.py::test_filter_approval_controls_keeps_response_for_pending_placeholder` |
|
||||
| Pending hosted history replay | Stateless hosted approval requests remain replayable until a response is recorded, then both controls become inert. | `packages/openai/tests/openai/test_openai_chat_client.py::test_stateless_history_preserves_pending_hosted_approval_request_until_response` |
|
||||
| Non-history provider plus session | Local history is still auto-injected for approval resume. | `packages/core/tests/core/test_agents.py::test_non_history_context_provider_still_injects_inmemory` |
|
||||
| Hosted per-service-call persistence | A host-managed transcript remains available throughout a local function-call loop without being persisted into the framework session and replayed on the next hosted request. | `packages/foundry_hosting/tests/test_responses.py::TestAgentSessionPersistence::test_per_service_call_persistence_preserves_function_loop_history` |
|
||||
| Service-side approval decision | Stored request is skipped; current approved or rejected response is sent. | `packages/openai/tests/openai/test_openai_chat_client.py::test_prepare_messages_strips_approval_request_but_keeps_response_under_storage` |
|
||||
| OpenAI approval serialization | Approval id and decision serialize to `mcp_approval_response`. | `test_prepare_message_for_openai_with_function_approval_response`, `test_prepare_content_for_opentool_approval_response`, `test_function_approval_response_with_mcp_tool_call` |
|
||||
| OpenAI end-to-end hosted approval | Hosted request parses, response sends, and continuation completes. | `test_end_to_end_mcp_approval_flow` |
|
||||
| Stored function call/result | Service-side storage drops server-issued calls but keeps new outputs. | `test_prepare_options_with_conversation_id_strips_server_issued_items`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
|
||||
| Stateless reasoning replay | Replay reconstructs reasoning, call, and result together; missing required reasoning fails before the request. | `test_tool_loop_store_false_replays_encrypted_reasoning_group`, `test_stateless_request_rejects_non_replayable_reasoning_bound_mcp_output`, `test_prepare_messages_for_openai_full_conversation_with_reasoning` |
|
||||
| Opaque reasoning signature replay | Provider-specific opaque reasoning metadata is captured and restored on reconstructed calls. | `packages/gemini/tests/test_gemini_client.py::test_function_call_part_captures_thought_signature_as_reasoning_content`, `test_reconstructed_function_call_replays_thought_signature_from_reasoning_content` |
|
||||
| Chat Completions approval wrappers | Framework approval wrappers are not sent as chat messages. | `packages/openai/tests/openai/test_openai_chat_completion_client.py` approval serialization tests |
|
||||
| AG-UI approval result event | Approved result emits once with content and persists in snapshot. | `packages/ag-ui/tests/ag_ui/test_approval_result_event.py::test_approval_resume_emits_tool_call_result`, `test_approval_resume_result_has_content`, `test_approval_resume_snapshot_replaces_approval_payload_with_tool_result`, `test_approval_resume_zero_updates_emits_tool_result` |
|
||||
| AG-UI rejection/mixed decision | Transport emits only the events defined for approved and rejected calls without duplicates. | `test_rejection_does_not_emit_tool_call_result`, `test_mixed_approve_reject_emits_only_approved_tool_result`, `test_resolve_approval_responses_returns_only_approved` |
|
||||
| AG-UI approval-time follow-up | The full grouped user-input pause remains in message history and emits no synthetic `TOOL_CALL_RESULT`. | `test_resolve_approval_responses_preserves_follow_up_user_input_group` |
|
||||
| AG-UI approval execution failure | A grouped executor failure becomes one deterministic terminal error result for the approved call. | `test_resolve_approval_responses_returns_failure_when_grouped_execution_raises` |
|
||||
| AG-UI no-approval path | Ordinary tool results do not gain an extra approval result event. | `test_no_approval_no_extra_tool_result` |
|
||||
| AG-UI `confirm_changes` snapshot | An accepted synthetic confirmation is replaced only when its original function call has a real result; rejection is cleaned explicitly, and missing accepted results remain inert. | `packages/ag-ui/tests/ag_ui/test_confirm_changes_snapshot.py` |
|
||||
| AG-UI malformed `confirm_changes` metadata | Non-list tool-call metadata and malformed argument JSON are ignored without guessing a target call. | `test_confirm_changes_target_ignores_non_list_tool_calls`, `test_confirm_changes_target_rejects_malformed_arguments_json` |
|
||||
| Compaction pair integrity | Adjacent and non-adjacent pairs, including assistant-embedded results and completed reused-id occurrences, remain atomic without pairing ambiguous or out-of-order ids. | `packages/core/tests/core/test_compaction.py::test_group_annotations_keep_tool_call_and_tool_result_atomic`, `test_group_annotations_include_reasoning_in_tool_call_group`, `test_group_annotations_pair_nonadjacent_function_result_by_call_id`, `test_group_annotations_pair_multiple_nonadjacent_results_with_declaration`, `test_group_annotations_pair_completed_reused_call_id_occurrences`, `test_group_annotations_close_assistant_embedded_result_before_reused_call_id`, `test_sliding_window_does_not_retain_orphan_result_after_assistant_embedded_result`, `test_sliding_window_keeps_reused_call_id_occurrences_atomic`, `test_group_annotations_do_not_pair_ambiguous_duplicate_call_ids` |
|
||||
|
||||
## Required coverage gaps
|
||||
|
||||
These scenarios are required but are not fully covered by merged tests on `main`:
|
||||
|
||||
| Gap | Tracking |
|
||||
|---|---|
|
||||
| Service-owned `previous_response_id` continuation cannot execute a terminal approval again on a later turn. | #6851 |
|
||||
|
||||
Do not mark these rows covered by nearby tests; each needs a dedicated regression at the owning layer.
|
||||
|
||||
## Minimum validation commands
|
||||
|
||||
Run from `python/` for any core function-loop change:
|
||||
|
||||
```bash
|
||||
uv run poe test -P core
|
||||
uv run poe syntax -P core
|
||||
uv run poe pyright -P core
|
||||
uv run poe test-typing -P core
|
||||
```
|
||||
|
||||
Also run every affected package. Common approval-loop changes require:
|
||||
|
||||
```bash
|
||||
uv run poe test -P openai
|
||||
uv run poe syntax -P openai
|
||||
uv run poe pyright -P openai
|
||||
uv run poe test-typing -P openai
|
||||
uv run poe test -P ag-ui
|
||||
uv run --directory packages/foundry_hosting poe test
|
||||
```
|
||||
|
||||
Run focused regression files first while iterating, but do not substitute them for the full package commands above.
|
||||
|
||||
## Review checklist
|
||||
|
||||
Before accepting an update, reviewers must confirm:
|
||||
|
||||
- the changed behavior is represented in this specification;
|
||||
- the matrix names a regression test for every affected scenario;
|
||||
- approved tools cannot execute twice;
|
||||
- rejected tools cannot execute;
|
||||
- no call or result becomes orphaned or duplicated;
|
||||
- call/result matching does not assume `call_id` is globally unique forever;
|
||||
- reasoning content or opaque signatures remain in the same logical group as the paired call/result, or replay fails
|
||||
explicitly before sending a lossy provider request;
|
||||
- caller messages and previous responses remain immutable;
|
||||
- streaming updates and final response agree with non-streaming output;
|
||||
- history replay does not reintroduce approval authority;
|
||||
- full package, syntax, source typing, and test typing checks were run.
|
||||
|
||||
## Related issues
|
||||
|
||||
- #7241 — approval-resolution result streaming
|
||||
- #7267 / #7271 and #7304 — replayed calls and reused ids
|
||||
- #7043 — provider-injected approval execution
|
||||
- #6828 — AG-UI `confirm_changes` snapshot correlation
|
||||
- #7212 — non-adjacent and reused-id compaction integrity
|
||||
- #7125 — service-side approval response serialization
|
||||
- #7045 — post-limit tool-content transcript integrity
|
||||
- #6973 — declaration-only streaming metadata and argument integrity
|
||||
- #6851 — duplicate side effects after approval continuation
|
||||
- #7383 — bind approval responses to framework-issued requests after this foundation merges
|
||||
- #6963 / #7095 — opaque reasoning-signature replay
|
||||
- #6074 / #7233 — reasoning-paired tool-call replay
|
||||
- #6450 / #6794 — provider message and tool-result serialization
|
||||
@@ -1,283 +0,0 @@
|
||||
# Feature-usage bit registry (per-language)
|
||||
|
||||
> **Status:** draft, accompanies [ADR-0033](../decisions/0033-feature-usage-bitmask-user-agent.md)
|
||||
> and [SPEC-004](004-feature-usage-telemetry.md).
|
||||
> **Version:** `1` per language · **Width:** 128-bit
|
||||
|
||||
This document is the proposed human-readable registry for the feature-usage
|
||||
mask. Until ADR-0033 is accepted and the index declarations ship, these tables
|
||||
are a **candidate mapping**, not a stable wire contract. The table is the
|
||||
allocation authority and published decoder contract; package-local private
|
||||
`FeatureIndex` declarations implement the rows they own. There is no generated
|
||||
artifact.
|
||||
|
||||
This telemetry is intentionally **transparent**: this registry is public, the
|
||||
emitted value is human-decodable, and a dedicated env var disables the mask
|
||||
without removing the base User-Agent. Python's existing whole-User-Agent opt-out
|
||||
also suppresses its mask; see [Opt-out](#opt-out).
|
||||
|
||||
## What is collected
|
||||
|
||||
A single 128-bit integer (the *feature mask*) describing **which Agent Framework
|
||||
features were exercised** in a process — not which packages are installed. The
|
||||
candidate below uses package-level bits plus selected major capabilities: core
|
||||
agent/workflow/MCP features, stable skill source types, each orchestration
|
||||
pattern, each individual built-in context/history provider, and distinct Foundry
|
||||
surfaces. ADR-0033 still leaves the final v1 granularity open. A feature sets its
|
||||
index at first meaningful activation; the SDK shifts that index, ORs the mask,
|
||||
and emits the value.
|
||||
|
||||
No identifiers, arguments, prompts, payloads, or user data are encoded — only the
|
||||
coarse Boolean \"this feature was observed at least once in this process\" per
|
||||
registered bit. A repeated bit on later requests is the same observation, not
|
||||
another use and not a count.
|
||||
|
||||
## Allocation tenet
|
||||
|
||||
**An index represents a stable, framework-owned capability whose adoption answers a
|
||||
concrete product or support question.** It has a clear actual-use mark point in a
|
||||
public entry path, and the privacy review covers the resulting distinction.
|
||||
|
||||
Keep imports, installation state, aliases, wrappers, internal helpers, and
|
||||
implementation decorators such as caching/filtering/deduplication within their
|
||||
own capability bit. Customer/runtime values — names, prompts, arguments, URLs,
|
||||
identifiers, configuration choices — never become bits. A proposed distinction
|
||||
without a concrete query and named decision owner waits.
|
||||
|
||||
Operational clients, tools, providers, and hosts mark on their first real public
|
||||
operation/participation. Constructor marking is reserved for cases where
|
||||
construction itself activates or registers the capability; DI instantiation
|
||||
alone is not usage.
|
||||
|
||||
Ids use the package/integration name for a package-level signal and add a
|
||||
capability suffix only when the row tracks a narrower surface. They describe the
|
||||
registered feature, not an inheritance hierarchy: for example, Python
|
||||
`hosting` is the base `agent-framework-hosting` package, while `hosting.a2a` is
|
||||
the separate hosting-A2A integration.
|
||||
|
||||
## Per-language, not shared
|
||||
|
||||
The two tables below are **independent**. Feature indexes are **not** shared across
|
||||
languages — Python bit 13 and .NET bit 13 do not mean the same thing. This is
|
||||
deliberate: the User-Agent product token already names the language
|
||||
(`agent-framework-python` vs `agent-framework-dotnet`), so a decoder selects the
|
||||
right table from the UA and decodes against it. Each SDK numbers and evolves its
|
||||
features independently — no cross-language synchronization, no null placeholders,
|
||||
no \"same bit, same meaning\" rule.
|
||||
|
||||
## Encoding
|
||||
|
||||
- **Width:** 128-bit unsigned integer per language.
|
||||
- **Versioning:** the emission carries the version so a decoder knows the bit
|
||||
mapping in effect (version is per language).
|
||||
- **User-Agent:** the mask is an RFC 7231 **comment** (metadata, not a product
|
||||
token), placed after the agent-framework product token:
|
||||
|
||||
```text
|
||||
agent-framework-python/1.2.3 (feat=v1.<hex_mask>)
|
||||
```
|
||||
|
||||
where `<hex_mask>` is lowercase hex, no leading zeros, no `0x` prefix. Example
|
||||
for bits 0, 1, 5 set (`0b100011 = 0x23`):
|
||||
|
||||
```text
|
||||
agent-framework-python/1.2.3 (feat=v1.23)
|
||||
```
|
||||
|
||||
- **Decoding:** read the **language** from the product token, pick that table;
|
||||
read `vN`, pick that version; test `mask & (1 << index)` for each row. Unknown indexes
|
||||
(newer SDK than the decoder's copy) are ignored.
|
||||
|
||||
## Emission scope (where the mask is sent)
|
||||
|
||||
- **Marking is universal:** every feature sets its index at first meaningful
|
||||
activation, regardless of provider.
|
||||
- **User-Agent `(feat=...)` comment — approved first-party clients only,
|
||||
stamped at request time.** Added only when both the **Azure / Foundry**
|
||||
client/pipeline family and the actual HTTPS origin are approved, re-evaluated
|
||||
on every request and redirect hop. Custom origins are default-deny and an
|
||||
unapproved redirect removes the token. It is
|
||||
**never** sent to third-party providers — a feature fingerprint must not leak
|
||||
into logs we cannot read. See [SPEC-004](004-feature-usage-telemetry.md#emission).
|
||||
- **OpenTelemetry: not in v1.** Deferred primarily for privacy (a span attribute
|
||||
would broadcast the fingerprint into the user's general telemetry / third-party
|
||||
APM vendors). Left open behind the version prefix; see
|
||||
[ADR-0033](../decisions/0033-feature-usage-bitmask-user-agent.md#considered-options).
|
||||
|
||||
## Index table — Python (`agent-framework-python`, version 1)
|
||||
|
||||
Layout: core features 0–31, orchestration patterns 32–47, and
|
||||
provider/integration packages from 48.
|
||||
|
||||
The provider/integration block is intentionally **not** partitioned by vendor
|
||||
ownership. Some packages span first- and third-party services, ownership can
|
||||
change, and protocols/storage integrations do not fit a stable first/third-party
|
||||
taxonomy. Index ranges are allocation space, not privacy or emission policy;
|
||||
the explicit destination allowlist independently ensures that the mask is sent
|
||||
only to approved first-party endpoints.
|
||||
|
||||
| Index | Id | Feature | Activated at (representative) |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | `core.agent` | Agent | `agent_framework.Agent` |
|
||||
| 1 | `core.harness_agent` | Harness agent | `agent_framework.create_harness_agent` |
|
||||
| 2 | `core.workflow` | Workflow engine (custom graphs) | `agent_framework.WorkflowBuilder` |
|
||||
| 3 | `core.mcp` | MCP tool (any transport) | `agent_framework.MCPStdioTool` |
|
||||
| 4 | `core.tool_approval` | Tool-approval harness | `agent_framework.ToolApprovalMiddleware` |
|
||||
| 5 | `core.memory_provider` | Memory context provider | `agent_framework.MemoryContextProvider` |
|
||||
| 6 | `core.skills_provider` | Skills provider | `agent_framework.SkillsProvider` |
|
||||
| 7 | `core.file_access_provider` | File-access provider | `agent_framework.FileAccessProvider` |
|
||||
| 8 | `core.compaction_provider` | Context compaction provider | `agent_framework.CompactionProvider` |
|
||||
| 9 | `core.todo_provider` | Todo provider | `agent_framework.TodoProvider` |
|
||||
| 10 | `core.agent_mode_provider` | Agent-mode provider | `agent_framework.AgentModeProvider` |
|
||||
| 11 | `core.background_agents_provider` | Background-agents provider | `agent_framework.BackgroundAgentsProvider` |
|
||||
| 12 | `core.in_memory_history_provider` | In-memory history provider | `agent_framework.InMemoryHistoryProvider` |
|
||||
| 13 | `core.file_history_provider` | File history provider | `agent_framework.FileHistoryProvider` |
|
||||
| 14 | `core.file_skills_source` | File-backed skills | `agent_framework.FileSkillsSource` |
|
||||
| 15 | `core.in_memory_skills_source` | In-memory / programmatic skills | `agent_framework.InMemorySkillsSource` |
|
||||
| 16 | `core.mcp_skills_source` | MCP-backed skills | `agent_framework.MCPSkillsSource` |
|
||||
| 17 | `core.session_store` | Agent session store | `agent_framework.SessionStore` / `FileSessionStore` |
|
||||
| 18–31 | _reserved_ | core growth | — |
|
||||
| 32 | `orchestration.sequential` | Sequential orchestration | `agent_framework_orchestrations.SequentialBuilder` |
|
||||
| 33 | `orchestration.concurrent` | Concurrent orchestration | `agent_framework_orchestrations.ConcurrentBuilder` |
|
||||
| 34 | `orchestration.group_chat` | Group-chat orchestration | `agent_framework_orchestrations.GroupChatBuilder` |
|
||||
| 35 | `orchestration.magentic` | Magentic orchestration | `agent_framework_orchestrations.MagenticBuilder` |
|
||||
| 36 | `orchestration.handoff` | Handoff orchestration | `agent_framework_orchestrations.HandoffBuilder` |
|
||||
| 37–47 | _reserved_ | orchestration growth | — |
|
||||
| 48 | `foundry.chat_client` | Foundry chat client | `agent_framework_foundry.RawFoundryChatClient` |
|
||||
| 49 | `foundry.agent` | Foundry agent | `agent_framework_foundry.FoundryAgent` |
|
||||
| 50 | `foundry.memory` | Foundry memory provider | `agent_framework_foundry.FoundryMemoryProvider` |
|
||||
| 51 | `foundry.embedding` | Foundry embedding client | `agent_framework_foundry.RawFoundryEmbeddingClient` |
|
||||
| 52 | `foundry.evals` | Foundry evaluations | `agent_framework_foundry.FoundryEvals` |
|
||||
| 53 | `foundry.toolbox` | Foundry Toolbox MCP tool | `agent_framework_foundry_hosting.FoundryToolbox` |
|
||||
| 54 | `foundry_local` | Foundry Local client | `agent_framework_foundry_local.FoundryLocalClient` |
|
||||
| 55 | `foundry_hosting` | Foundry hosting layer | `agent_framework_foundry_hosting.ResponsesHostServer` / `InvocationsHostServer` |
|
||||
| 56 | `openai` | OpenAI clients | `agent_framework_openai` |
|
||||
| 57 | `anthropic` | Anthropic clients | `agent_framework_anthropic` |
|
||||
| 58 | `bedrock` | AWS Bedrock clients | `agent_framework_bedrock` |
|
||||
| 59 | `gemini` | Gemini chat client | `agent_framework_gemini` |
|
||||
| 60 | `mistral` | Mistral embedding client | `agent_framework_mistral` |
|
||||
| 61 | `ollama` | Ollama clients | `agent_framework_ollama` |
|
||||
| 62 | `claude` | Claude Agent SDK agent | `agent_framework_claude` |
|
||||
| 63 | `copilotstudio` | Copilot Studio agent | `agent_framework_copilotstudio` |
|
||||
| 64 | `github_copilot` | GitHub Copilot agent | `agent_framework_github_copilot` |
|
||||
| 65 | `azure_ai_search` | Azure AI Search context provider | `agent_framework_azure_ai_search` |
|
||||
| 66 | `azure_cosmos` | Azure Cosmos history / checkpoint store | `agent_framework_azure_cosmos` |
|
||||
| 67 | `azure_contentunderstanding` | Azure Content Understanding context provider | `agent_framework_azure_contentunderstanding.ContentUnderstandingContextProvider` |
|
||||
| 68 | `redis` | Redis context / history provider | `agent_framework_redis` |
|
||||
| 69 | `mem0` | Mem0 memory provider | `agent_framework_mem0.Mem0ContextProvider` |
|
||||
| 70 | `purview` | Purview client | `agent_framework_purview.PurviewClient` |
|
||||
| 71 | `a2a` | A2A agent / executor | `agent_framework_a2a.A2AAgent` / `A2AExecutor` |
|
||||
| 72 | `ag_ui` | AG-UI chat client / agent | `agent_framework_ag_ui` |
|
||||
| 73 | `chatkit` | ChatKit integration | `agent_framework_chatkit` |
|
||||
| 74 | `devui` | DevUI served | `agent_framework_devui.serve` |
|
||||
| 75 | `declarative.agent` | Declarative agent definitions | `agent_framework_declarative.AgentFactory` |
|
||||
| 76 | `declarative.workflow` | Declarative workflow definitions | `agent_framework_declarative.WorkflowFactory` |
|
||||
| 77 | `durabletask` | Durable task runtime | `agent_framework_durabletask` |
|
||||
| 78 | `azurefunctions` | Azure Functions agent host | `agent_framework_azurefunctions` |
|
||||
| 79 | `tools.shell` | Shell tools | `agent_framework_tools.shell.LocalShellTool` / `DockerShellTool` |
|
||||
| 80 | `monty` | Monty CodeAct provider | `agent_framework_monty.MontyCodeActProvider` |
|
||||
| 81 | `hyperlight` | Hyperlight CodeAct provider | `agent_framework_hyperlight.HyperlightCodeActProvider` |
|
||||
| 82 | `azure_cosmos_memory` | Azure Cosmos DB semantic-memory provider | `agent_framework_azure_cosmos_memory.CosmosMemoryContextProvider` |
|
||||
| 83 | `hosting` | App-owned agent/workflow hosting state | `agent_framework_hosting.AgentState` / `WorkflowState` |
|
||||
| 84 | `hosting.a2a` | A2A hosting converters | `agent_framework_hosting_a2a.a2a_to_run` / `a2a_from_run` |
|
||||
| 85 | `hosting.mcp` | MCP hosting adapters | `agent_framework_hosting_mcp.AgentMCPTool` / `WorkflowMCPTool` |
|
||||
| 86 | `hosting.responses` | OpenAI Responses hosting converters | `agent_framework_hosting_responses.responses_to_run` |
|
||||
| 87 | `hosting.telegram` | Telegram hosting converters | `agent_framework_hosting_telegram.telegram_to_run` |
|
||||
| 88 | `lab` | Experimental Agent Framework Lab features | `agent_framework.lab` feature entry points |
|
||||
| 89–127 | _reserved_ | future packages | — |
|
||||
|
||||
## Index table — .NET (`agent-framework-dotnet`, version 1)
|
||||
|
||||
| Index | Id | Feature | Activated at (representative) |
|
||||
| --- | --- | --- | --- |
|
||||
| 0 | `core.agent` | Agent | `Microsoft.Agents.AI.ChatClientAgent` |
|
||||
| 1 | `core.harness_agent` | Harness agent | `Microsoft.Agents.AI.HarnessAgent` |
|
||||
| 2 | `core.workflow` | Workflow engine (custom graphs) | `Microsoft.Agents.AI.Workflows.WorkflowBuilder` |
|
||||
| 3 | `core.tool_approval` | Tool-approval agent | `Microsoft.Agents.AI.ToolApprovalAgent` |
|
||||
| 4 | `core.chat_history_memory_provider` | Chat-history memory provider | `Microsoft.Agents.AI.ChatHistoryMemoryProvider` |
|
||||
| 5 | `core.file_memory_provider` | File memory provider | `Microsoft.Agents.AI.FileMemoryProvider` |
|
||||
| 6 | `core.text_search_provider` | Text-search provider | `Microsoft.Agents.AI.TextSearchProvider` |
|
||||
| 7 | `core.file_access_provider` | File-access provider | `Microsoft.Agents.AI.FileAccessProvider` |
|
||||
| 8 | `core.skills_provider` | Skills provider | `Microsoft.Agents.AI.AgentSkillsProviderBuilder` |
|
||||
| 9 | `core.compaction_provider` | Context compaction provider | `Microsoft.Agents.AI.Compaction.CompactionProvider` |
|
||||
| 10 | `core.todo_provider` | Todo provider | `Microsoft.Agents.AI.TodoProvider` |
|
||||
| 11 | `core.agent_mode_provider` | Agent-mode provider | `Microsoft.Agents.AI.AgentModeProvider` |
|
||||
| 12 | `core.background_agents_provider` | Background-agents provider | `Microsoft.Agents.AI.BackgroundAgentsProvider` |
|
||||
| 13 | `core.in_memory_history_provider` | In-memory history provider | `Microsoft.Agents.AI.InMemoryChatHistoryProvider` |
|
||||
| 14 | `core.mcp` | MCP tasks / skills integration | `Microsoft.Agents.AI.Mcp.McpClientTaskExtensions` |
|
||||
| 15 | `core.file_skills_source` | File-backed skills | `Microsoft.Agents.AI.AgentFileSkillsSource` |
|
||||
| 16 | `core.in_memory_skills_source` | In-memory skills | `Microsoft.Agents.AI.AgentInMemorySkillsSource` |
|
||||
| 17 | `core.inline_skill` | Inline programmatic skill | `Microsoft.Agents.AI.AgentInlineSkill` |
|
||||
| 18 | `core.class_skill` | Class-based programmatic skill | `Microsoft.Agents.AI.AgentClassSkill` |
|
||||
| 19 | `core.mcp_skills_source` | MCP-backed skills | `Microsoft.Agents.AI.AgentSkillsProviderBuilderMcpExtensions.UseMcpSkills` |
|
||||
| 20–31 | _reserved_ | core growth | — |
|
||||
| 32 | `orchestration.sequential` | Sequential orchestration | `Microsoft.Agents.AI.Workflows.SequentialWorkflowBuilder` |
|
||||
| 33 | `orchestration.concurrent` | Concurrent orchestration | `Microsoft.Agents.AI.Workflows.ConcurrentWorkflowBuilder` |
|
||||
| 34 | `orchestration.group_chat` | Group-chat orchestration | `Microsoft.Agents.AI.Workflows.GroupChatWorkflowBuilder` |
|
||||
| 35 | `orchestration.magentic` | Magentic orchestration | `Microsoft.Agents.AI.Workflows.MagenticWorkflowBuilder` |
|
||||
| 36 | `orchestration.handoff` | Handoff orchestration | `Microsoft.Agents.AI.Workflows.HandoffWorkflowBuilder` |
|
||||
| 37–47 | _reserved_ | orchestration growth | — |
|
||||
| 48 | `foundry.chat_client` | Foundry chat client | `Microsoft.Agents.AI.Foundry.FoundryChatClient` |
|
||||
| 49 | `foundry.agent` | Foundry agent | `Microsoft.Agents.AI.Foundry.FoundryAgent` |
|
||||
| 50 | `foundry.memory` | Foundry memory provider | `Microsoft.Agents.AI.Foundry.FoundryMemoryProvider` |
|
||||
| 51 | `foundry.evals` | Foundry evaluations | `Microsoft.Agents.AI.Foundry.FoundryEvals` |
|
||||
| 52 | `foundry.toolbox` | Foundry Toolbox MCP tool | `Microsoft.Agents.AI.Foundry.HostedMcpToolboxAITool` |
|
||||
| 53 | `foundry_hosting` | Foundry hosting layer | `Microsoft.Agents.AI.Foundry.Hosting.FoundryHostingExtensions.AddFoundryResponses` |
|
||||
| 54 | `openai` | OpenAI integration | `Microsoft.Agents.AI.OpenAI` |
|
||||
| 55 | `anthropic` | Anthropic integration | `Microsoft.Agents.AI.Anthropic` |
|
||||
| 56 | `copilotstudio` | Copilot Studio agent | `Microsoft.Agents.AI.CopilotStudio.CopilotStudioAgent` |
|
||||
| 57 | `github_copilot` | GitHub Copilot agent | `Microsoft.Agents.AI.GitHub.Copilot.GitHubCopilotAgent` |
|
||||
| 58 | `azure_cosmos` | Cosmos history / checkpoint store | `Microsoft.Agents.AI.CosmosChatHistoryProvider` |
|
||||
| 59 | `valkey` | Valkey chat-history provider | `Microsoft.Agents.AI.Valkey.ValkeyChatHistoryProvider` |
|
||||
| 60 | `mem0` | Mem0 memory provider | `Microsoft.Agents.AI.Mem0.Mem0Provider` |
|
||||
| 61 | `purview` | Purview integration | `Microsoft.Agents.AI.Purview` |
|
||||
| 62 | `a2a` | A2A agent | `Microsoft.Agents.AI.A2A.A2AAgent` |
|
||||
| 63 | `hosting.ag_ui` | AG-UI hosting endpoint | `Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.AGUIEndpointRouteBuilderExtensions.MapAGUIServer` |
|
||||
| 64 | `devui` | DevUI served | `Microsoft.Agents.AI.DevUI` |
|
||||
| 65 | `declarative.agent` | Declarative agent definitions | `Microsoft.Agents.AI.PromptAgentFactory.CreateAsync` |
|
||||
| 66 | `declarative.workflow` | Declarative workflow definitions | `Microsoft.Agents.AI.Workflows.Declarative.DeclarativeWorkflowBuilder.Build` |
|
||||
| 67 | `durabletask` | Durable task runtime | `Microsoft.Agents.AI.DurableTask` |
|
||||
| 68 | `azurefunctions` | Azure Functions agent host | `Microsoft.Agents.AI.Hosting.AzureFunctions` |
|
||||
| 69 | `tools.shell` | Shell tools | `Microsoft.Agents.AI.Tools.Shell.ShellExecutor` |
|
||||
| 70 | `hyperlight` | Hyperlight CodeAct provider | `Microsoft.Agents.AI.Hyperlight.HyperlightCodeActProvider` |
|
||||
| 71 | `hosting.agent` | Hosted AF agent wrapper | `Microsoft.Agents.AI.Hosting.AIHostAgent` |
|
||||
| 72 | `local_codeact` | Local Python CodeAct provider | `Microsoft.Agents.AI.LocalCodeAct.LocalCodeActProvider` |
|
||||
| 73 | `hosting.a2a` | A2A hosting endpoints | `Microsoft.AspNetCore.Builder.A2AEndpointRouteBuilderExtensions.MapA2AJsonRpc` |
|
||||
| 74 | `hosting.openai` | OpenAI-compatible hosting endpoints | `Microsoft.AspNetCore.Builder.MicrosoftAgentAIHostingOpenAIEndpointRouteBuilderExtensions.MapOpenAIResponses` |
|
||||
| 75–127 | _reserved_ | future packages | — |
|
||||
|
||||
## Opt-out
|
||||
|
||||
The dedicated mask-only environment variable is shared by both SDKs:
|
||||
|
||||
- `AGENT_FRAMEWORK_FEATURE_MASK_DISABLED=true|1` — drops **only** the feature
|
||||
mask; the base `agent-framework-<lang>/{version}` User-Agent is still sent.
|
||||
|
||||
The dedicated flag lets a privacy-conscious user keep contributing SDK
|
||||
identity/version (useful for support and compatibility triage) while withholding
|
||||
the feature-usage signal. Python's existing
|
||||
`AGENT_FRAMEWORK_USER_AGENT_DISABLED=true|1` also suppresses its entire Agent
|
||||
Framework User-Agent contribution, mask included. Adding a matching .NET
|
||||
whole-User-Agent opt-out is outside this design.
|
||||
|
||||
## Governance
|
||||
|
||||
1. One index per package/feature, **numbered independently per language**, in the
|
||||
table for that language. New indexes are added by editing this file in a reviewed
|
||||
PR; indexes are never reused within a `(language, version)`.
|
||||
2. Each package owns a private `FeatureIndex` declaration containing only its
|
||||
rows. Core owns the accumulator API and core indexes, but never imports
|
||||
optional packages. Adding a new optional-package index therefore does not
|
||||
require a core release once the marker API exists.
|
||||
3. Adding a feature: apply the [allocation tenet](#allocation-tenet), name the
|
||||
concrete query/decision owner, add the package-local index and table row, and mark the
|
||||
stable public entry point where actual use begins.
|
||||
4. Widening beyond 128-bit or re-partitioning bumps that language's version; old
|
||||
decoders keep working because the version prefix disambiguates the mapping.
|
||||
5. A repository validation test gathers all package-local declarations for each
|
||||
`(language, version)` and asserts exact table parity, complete non-reserved
|
||||
coverage, `0..127` range, and **no duplicate/overlapping indexes**.
|
||||
|
||||
> **No machine-readable registry file ships today.** Nothing consumes one at
|
||||
> runtime (packages own private declarations). If/when a programmatic decoder is built, this
|
||||
> table is the contract to export to JSON for it then.
|
||||
@@ -31,7 +31,6 @@
|
||||
<Project Path="samples/02-agents/AgentProviders/custom/Agent_With_CustomImplementation/Agent_With_CustomImplementation.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/dapr/Agent_With_Dapr/Agent_With_Dapr.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot/Agent_With_GitHubCopilot.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot_BYOK/Agent_With_GitHubCopilot_BYOK.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/google-gemini/Agent_With_GoogleGemini/Agent_With_GoogleGemini.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/ollama/Agent_With_Ollama/Agent_With_Ollama.csproj" />
|
||||
<Project Path="samples/02-agents/AgentProviders/onnx/Agent_With_ONNX/Agent_With_ONNX.csproj" />
|
||||
@@ -67,8 +66,6 @@
|
||||
<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" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment/Agent_Step21_ShellWithEnvironment.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step22_AgentMode/Agent_Step22_AgentMode.csproj" />
|
||||
<Project Path="samples/02-agents/Agents/Agent_Step23_TodoList/Agent_Step23_TodoList.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/DeclarativeAgents/">
|
||||
<Project Path="samples/02-agents/DeclarativeAgents/ChatClient/DeclarativeChatClientAgents.csproj" />
|
||||
@@ -205,7 +202,6 @@
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step04_MemoryUsingFoundry/AgentWithMemory_Step04_MemoryUsingFoundry.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step05_BoundedChatHistory/AgentWithMemory_Step05_BoundedChatHistory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory/AgentWithMemory_Step06_MemoryUsingAgentMemory.csproj" />
|
||||
<Project Path="samples/02-agents/AgentWithMemory/AgentWithMemory_Step07_FileMemoryProvider/AgentWithMemory_Step07_FileMemoryProvider.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AgentProviders/openai/">
|
||||
<File Path="samples/02-agents/AgentProviders/openai/README.md" />
|
||||
@@ -345,9 +341,6 @@
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/HostedChatClientAgent.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/HostedChatClientAgentDocker.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/">
|
||||
<Project Path="samples/04-hosting/FoundryHostedAgents/responses/Hosted-FoundryAgent/HostedFoundryAgent.csproj" />
|
||||
</Folder>
|
||||
|
||||
@@ -23,7 +23,6 @@
|
||||
"src\\Microsoft.Agents.AI.Hosting.AzureFunctions\\Microsoft.Agents.AI.Hosting.AzureFunctions.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting.OpenAI\\Microsoft.Agents.AI.Hosting.OpenAI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Hosting\\Microsoft.Agents.AI.Hosting.csproj",
|
||||
"src\\Microsoft.Agents.AI.LocalCodeAct\\Microsoft.Agents.AI.LocalCodeAct.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mcp\\Microsoft.Agents.AI.Mcp.csproj",
|
||||
"src\\Microsoft.Agents.AI.Mem0\\Microsoft.Agents.AI.Mem0.csproj",
|
||||
"src\\Microsoft.Agents.AI.OpenAI\\Microsoft.Agents.AI.OpenAI.csproj",
|
||||
|
||||
@@ -329,80 +329,6 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step20_DynamicFunctionTools",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step20_DynamicFunctionTools",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"=== Dynamic Function Tools Sample ===",
|
||||
"=== Non-Streaming Mode ===",
|
||||
"=== Streaming Mode ===",
|
||||
"[User]",
|
||||
"[Agent]",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show the agent starting with only a RequestTools function and dynamically loading additional tools (weather, time, temperature) as needed.",
|
||||
"The output should contain weather information for Seattle and London, the current time in New York, and a Fahrenheit-to-Celsius temperature conversion.",
|
||||
"The output should demonstrate both non-streaming and streaming modes.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step21_ShellWithEnvironment",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step21_ShellWithEnvironment",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"### Stateless mode",
|
||||
"### Persistent mode",
|
||||
"--- Captured environment snapshot ---",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show an agent using a shell tool to print the current working directory.",
|
||||
"The output should demonstrate that in stateless mode side effects (such as changing directory) do not carry between calls, while in persistent mode the working directory and an environment variable (DEMO_TOKEN set to 'hello-world') carry across calls.",
|
||||
"The output should include a captured environment snapshot describing the OS, shell, and working directory.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step22_AgentMode",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step22_AgentMode",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
SkipReason = "Interactive sample that reads console input in a loop and does not exit on its own.",
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step23_TodoList",
|
||||
ProjectPath = "samples/02-agents/Agents/Agent_Step23_TodoList",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"User:",
|
||||
"Agent:",
|
||||
"--- Current todo list ---",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show an agent planning a team offsite by breaking the work into a todo list.",
|
||||
"The output should show the todo list being updated as progress is reported (for example marking items complete after the venue is booked and invites are sent) and adjusted when the plan changes to skip catering and add a group hike.",
|
||||
"The current todo list should be printed after each turn, showing item status.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
// ── AgentSkills ─────────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
@@ -510,28 +436,6 @@ internal static class AgentsSamples
|
||||
SkipReason = "Requires a running Neo4j instance; standalone sample outside the repo's CPM build.",
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "AgentWithMemory_Step07_FileMemoryProvider",
|
||||
ProjectPath = "samples/02-agents/AgentWithMemory/AgentWithMemory_Step07_FileMemoryProvider",
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"Memory files will be written to:",
|
||||
"=== First conversation ===",
|
||||
"=== Memory files on disk ===",
|
||||
"=== Second conversation (new session) ===",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should acknowledge that the user is vegetarian and travels with a dog, indicating the agent stored these preferences.",
|
||||
"The memory files section should list at least one memory file written by the agent, such as a file about the user's preferences.",
|
||||
"The second conversation should recommend a hotel and a restaurant in Paris that are consistent with the remembered preferences, for example a pet-friendly hotel and a restaurant with vegetarian options, even though it is a new session.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
// ── AgentWithRAG ────────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
@@ -858,19 +762,6 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_With_GitHubCopilot_BYOK",
|
||||
ProjectPath = "samples/02-agents/AgentProviders/github-copilot/Agent_With_GitHubCopilot_BYOK",
|
||||
RequiredEnvironmentVariables = ["BYOK_BASE_URL", "BYOK_API_KEY"],
|
||||
OptionalEnvironmentVariables = ["BYOK_PROVIDER_TYPE", "BYOK_MODEL_ID"],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should contain a user prompt and a response about the benefits of BYOK.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_With_GoogleGemini",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.302",
|
||||
"version": "10.0.301",
|
||||
"rollForward": "minor",
|
||||
"allowPrerelease": false
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.16.0</VersionPrefix>
|
||||
<VersionPrefix>1.15.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260730</DateSuffix>
|
||||
<DateSuffix>260722</DateSuffix>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' == 'true'">$(VersionPrefix)-rc$(RCNumber)</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' != ''">$(VersionPrefix)-$(VersionSuffix).$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleaseCandidate)' != 'true' AND '$(VersionSuffix)' == ''">$(VersionPrefix)-preview.$(DateSuffix).1</PackageVersion>
|
||||
<PackageVersion Condition="'$(IsReleased)' == 'true'">$(VersionPrefix)</PackageVersion>
|
||||
<GitTag>1.16.0</GitTag>
|
||||
<GitTag>1.15.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -60,7 +60,6 @@ covering basics, function tools, structured output, middleware, MCP, code interp
|
||||
| Sample | Description |
|
||||
| --- | --- |
|
||||
| [GitHub Copilot](./github-copilot/Agent_With_GitHubCopilot/) | Create an AIAgent using GitHub Copilot SDK |
|
||||
| [GitHub Copilot BYOK](./github-copilot/Agent_With_GitHubCopilot_BYOK/) | Route GitHub Copilot agent requests through your own endpoint (Bring Your Own Key) |
|
||||
|
||||
### [Google Gemini](./google-gemini/)
|
||||
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="GitHub.Copilot.SDK" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.GitHub.Copilot\Microsoft.Agents.AI.GitHub.Copilot.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to configure a GitHub Copilot agent with BYOK (Bring Your Own Key),
|
||||
// routing requests through your own endpoint (OpenAI, Azure OpenAI, Anthropic, or an
|
||||
// OpenAI-compatible service such as vLLM/LiteLLM/Ollama) instead of the GitHub Copilot backend.
|
||||
//
|
||||
// SECURITY NOTE: BYOK uses static credentials (no automatic token refresh) and usage is tracked
|
||||
// by your provider rather than GitHub. Keep API keys out of source control; load them from
|
||||
// environment variables or a secret store, as shown here.
|
||||
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
string providerType = Environment.GetEnvironmentVariable("BYOK_PROVIDER_TYPE") ?? "openai";
|
||||
string baseUrl = Environment.GetEnvironmentVariable("BYOK_BASE_URL")
|
||||
?? throw new InvalidOperationException("The BYOK_BASE_URL environment variable is not set.");
|
||||
string apiKey = Environment.GetEnvironmentVariable("BYOK_API_KEY")
|
||||
?? throw new InvalidOperationException("The BYOK_API_KEY environment variable is not set.");
|
||||
string modelId = Environment.GetEnvironmentVariable("BYOK_MODEL_ID") ?? "gpt-4o";
|
||||
|
||||
// Create and start a Copilot client
|
||||
await using CopilotClient copilotClient = new();
|
||||
await copilotClient.StartAsync();
|
||||
|
||||
// Provider routes the session through a custom endpoint instead of the GitHub Copilot backend.
|
||||
// Type is "openai", "azure", or "anthropic". WireApi "completions" is the broadly compatible
|
||||
// choice; use "responses" for providers that support the OpenAI Responses API. BYOK also
|
||||
// requires Model to be set at the session level.
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
Model = modelId,
|
||||
Provider = new ProviderConfig
|
||||
{
|
||||
Type = providerType,
|
||||
WireApi = "completions",
|
||||
BaseUrl = baseUrl,
|
||||
ApiKey = apiKey,
|
||||
ModelId = modelId,
|
||||
},
|
||||
};
|
||||
|
||||
AIAgent agent = copilotClient.AsAIAgent(sessionConfig, ownsClient: true);
|
||||
|
||||
string prompt = "What are the benefits of using your own API keys with an agent framework?";
|
||||
Console.WriteLine($"User: {prompt}\n");
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(prompt))
|
||||
{
|
||||
Console.Write(update);
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
# About BYOK (Bring Your Own Key)
|
||||
|
||||
BYOK lets you route model requests through your own API keys and infrastructure instead of the
|
||||
GitHub Copilot backend — useful for enterprise deployments, custom hosting, or direct billing
|
||||
arrangements. See [GitHub's BYOK documentation](https://docs.github.com/en/copilot/how-tos/copilot-sdk/auth/byok)
|
||||
for the full list of supported providers and configuration options.
|
||||
|
||||
# Prerequisites
|
||||
|
||||
Before you begin, ensure you have the following prerequisites:
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- GitHub Copilot CLI installed and available in your PATH (or provide a custom path)
|
||||
- An OpenAI, Azure OpenAI, Anthropic, or OpenAI-compatible endpoint and API key (e.g. vLLM,
|
||||
LiteLLM, or Ollama)
|
||||
|
||||
## Setting up GitHub Copilot CLI
|
||||
|
||||
To use this sample, you need to have the GitHub Copilot CLI installed. You can install it by
|
||||
following the instructions at:
|
||||
https://github.com/github/copilot-sdk
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `BYOK_PROVIDER_TYPE` | Provider type (`openai`, `azure`, `anthropic`) | `openai` |
|
||||
| `BYOK_BASE_URL` | Base URL of your provider endpoint | *(required)* |
|
||||
| `BYOK_API_KEY` | API key for that endpoint | *(required)* |
|
||||
| `BYOK_MODEL_ID` | Model name to request (e.g. "gpt-4o") | `gpt-4o` |
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The sample will:
|
||||
|
||||
1. Create a GitHub Copilot client with default options
|
||||
2. Configure a session with a `Provider` (BYOK) pointing at your own endpoint instead of the
|
||||
default GitHub Copilot backend
|
||||
3. Send a message to the agent
|
||||
4. Stream the response
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
```csharp
|
||||
using GitHub.Copilot;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
await using CopilotClient copilotClient = new();
|
||||
await copilotClient.StartAsync();
|
||||
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
// BYOK requires Model to also be set at the session level.
|
||||
Model = "gpt-4o",
|
||||
Provider = new ProviderConfig
|
||||
{
|
||||
Type = "azure", // or "openai", "anthropic"
|
||||
WireApi = "completions", // or "responses"
|
||||
BaseUrl = "https://api.example.com/v1",
|
||||
ApiKey = "your-api-key",
|
||||
ModelId = "your-model-id", // "deployment-name"
|
||||
},
|
||||
};
|
||||
|
||||
AIAgent agent = copilotClient.AsAIAgent(sessionConfig, ownsClient: true);
|
||||
AgentResponse response = await agent.RunAsync("Hello!");
|
||||
Console.WriteLine(response);
|
||||
```
|
||||
|
||||
> **Note:** BYOK uses static credentials only — dynamic token refresh is not automatic, and
|
||||
> model availability depends entirely on your provider's offerings. Usage is tracked through
|
||||
> your provider rather than GitHub.
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample shows how to give an agent file-based memory using the FileMemoryProvider.
|
||||
// The FileMemoryProvider exposes a set of tools to the agent (write, read, delete, list, grep and replace)
|
||||
// that allow it to store memories as individual files in an AgentFileStore.
|
||||
// Because the files are stored outside of the conversation, the agent can recall them
|
||||
// in later conversations, even after the original chat history is gone.
|
||||
//
|
||||
// The sample also shows how to control the folder that memory files are written to,
|
||||
// by supplying a state initializer callback that sets the working folder for each session.
|
||||
|
||||
#pragma warning disable MAAI001 // AgentFileStore and its implementations are experimental.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// The id of the user that we are storing memories for.
|
||||
// It is used below to give each user their own memory folder.
|
||||
const string UserId = "UID1";
|
||||
|
||||
// Create the file store that the FileMemoryProvider will use to persist memory files.
|
||||
// Here we use a file system backed store rooted at a local folder called "agent-memory",
|
||||
// but any AgentFileStore implementation can be used, e.g. InMemoryAgentFileStore or a custom
|
||||
// implementation backed by blob storage.
|
||||
var memoryRoot = Path.Combine(AppContext.BaseDirectory, "agent-memory");
|
||||
var fileStore = new FileSystemAgentFileStore(memoryRoot);
|
||||
|
||||
// The working folder that memories for this user will be written to, relative to the store root.
|
||||
// The folder you choose determines the scope and lifetime of the memories:
|
||||
// - A stable folder, like the per-user one below, gives you durable memories that are shared by
|
||||
// every session for that user. That is what allows the second conversation further down to
|
||||
// recall what the user said in the first.
|
||||
// - A unique folder per session gives you memories that are isolated to a single session, e.g.
|
||||
// generate one in the state initializer callback below:
|
||||
// _ => new FileMemoryState { WorkingFolder = Guid.NewGuid().ToString() }
|
||||
var workingFolder = $"users/{UserId}";
|
||||
|
||||
Console.WriteLine($"Memory files will be written to: {Path.Combine(memoryRoot, workingFolder)}");
|
||||
Console.WriteLine();
|
||||
|
||||
// Create the file memory provider.
|
||||
// The second parameter is a state initializer callback that is invoked whenever the provider
|
||||
// cannot find existing state in a session, i.e. typically the first time it is used with a new session.
|
||||
// It allows us to configure the folder that memory files for that session are written to.
|
||||
// If no callback is supplied, the working folder defaults to the root of the store,
|
||||
// which means all sessions share a single, flat set of memory files.
|
||||
using var fileMemoryProvider = new FileMemoryProvider(
|
||||
fileStore,
|
||||
_ => new FileMemoryState { WorkingFolder = workingFolder });
|
||||
|
||||
// Create the agent and attach the FileMemoryProvider so that the agent gets the file memory tools.
|
||||
AIAgent agent = new AIProjectClient(
|
||||
new Uri(endpoint),
|
||||
// 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.
|
||||
new DefaultAzureCredential())
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
ChatOptions = new()
|
||||
{
|
||||
ModelId = deploymentName,
|
||||
Instructions = "You are a helpful travel assistant. Remember what the user tells you about themselves so that you can give better recommendations later."
|
||||
},
|
||||
Name = "TravelAssistant",
|
||||
AIContextProviders = [fileMemoryProvider],
|
||||
});
|
||||
|
||||
// First conversation: tell the agent something worth remembering.
|
||||
// The agent should use the file_memory_write tool to store it as a file in the working folder.
|
||||
AgentSession firstSession = await agent.CreateSessionAsync();
|
||||
Console.WriteLine("=== First conversation ===");
|
||||
Console.WriteLine(await agent.RunAsync(
|
||||
"I'm vegetarian and I always travel with my dog. Please remember this for future trips.",
|
||||
firstSession));
|
||||
Console.WriteLine();
|
||||
|
||||
// Show the memory files that the agent created on disk.
|
||||
Console.WriteLine("=== Memory files on disk ===");
|
||||
foreach (var file in Directory.EnumerateFiles(Path.Combine(memoryRoot, workingFolder)))
|
||||
{
|
||||
Console.WriteLine(Path.GetFileName(file));
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
|
||||
// Second conversation: a brand new session with no chat history from the first conversation.
|
||||
// The provider surfaces the memory index to the agent, and the agent can read the memory files
|
||||
// using the file_memory_read tool, so it can still recall the user's preferences.
|
||||
AgentSession secondSession = await agent.CreateSessionAsync();
|
||||
Console.WriteLine("=== Second conversation (new session) ===");
|
||||
Console.WriteLine(await agent.RunAsync(
|
||||
"Suggest a hotel and a restaurant for my trip to Paris next week.",
|
||||
secondSession));
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
# File Based Memory with FileMemoryProvider
|
||||
|
||||
This sample demonstrates how to give an agent file-based memory using the `FileMemoryProvider`.
|
||||
|
||||
The `FileMemoryProvider` is an `AIContextProvider` that exposes a set of memory tools to the agent, allowing the agent to decide what to remember and when to recall it. Each memory is stored as an individual file in an `AgentFileStore`, so memories survive beyond the lifetime of a single conversation.
|
||||
|
||||
## Concepts
|
||||
|
||||
- **`FileMemoryProvider`**: An `AIContextProvider` that adds the following tools to the agent:
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `file_memory_write` | Write a memory file with a name, content and optional description. |
|
||||
| `file_memory_read` | Read the content of a memory file by name. |
|
||||
| `file_memory_delete` | Delete a memory file by name. |
|
||||
| `file_memory_ls` | List all memory files with their descriptions. |
|
||||
| `file_memory_grep` | Search memory file contents using a regular expression. |
|
||||
| `file_memory_replace` | Replace occurrences of a substring within a memory file. |
|
||||
| `file_memory_replace_lines` | Replace whole lines within a memory file. |
|
||||
|
||||
The provider also maintains a `memories.md` index file, which it injects into the conversation so the agent knows which memories are available without having to list them first.
|
||||
|
||||
- **`AgentFileStore`**: The pluggable storage abstraction used by the provider. This sample uses `FileSystemAgentFileStore` to store memories on the local disk, but `InMemoryAgentFileStore` or a custom implementation (e.g. backed by blob storage) can be used instead.
|
||||
|
||||
- **`FileMemoryState`**: The per-session state of the provider. Its `WorkingFolder` property determines the folder, relative to the store root, that memory files are written to.
|
||||
|
||||
## Configuring the memory folder
|
||||
|
||||
By default, all sessions share the root folder of the store, which means every session reads and writes the same flat set of memory files.
|
||||
|
||||
To scope memories, e.g. per user, per tenant or per session, pass a state initializer callback to the `FileMemoryProvider` constructor. The callback receives the `AgentSession` and is invoked whenever the provider cannot find existing state in that session, i.e. typically the first time the provider is used with a new session:
|
||||
|
||||
```csharp
|
||||
using var fileMemoryProvider = new FileMemoryProvider(
|
||||
fileStore,
|
||||
session => new FileMemoryState { WorkingFolder = $"users/{userId}" });
|
||||
```
|
||||
|
||||
In this sample, memories are written to `agent-memory/users/UID1` under the application's base directory. Because the folder is derived from a fixed user id rather than the session, a new session for the same user picks up the memories written by earlier sessions.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- A Microsoft Foundry project with a chat model deployment
|
||||
- Run `az login` to authenticate with `DefaultAzureCredential`
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the following environment variables:
|
||||
|
||||
| Variable | Description | Default |
|
||||
|---|---|---|
|
||||
| `FOUNDRY_PROJECT_ENDPOINT` | Your Foundry project endpoint | *(required)* |
|
||||
| `FOUNDRY_MODEL` | Chat model deployment name | `gpt-5.4-mini` |
|
||||
|
||||
## Running the Sample
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## How it Works
|
||||
|
||||
1. A `FileSystemAgentFileStore` is created, rooted at a local `agent-memory` folder.
|
||||
2. A `FileMemoryProvider` is created over that store, with a state initializer that puts the memories for the current user in their own working folder.
|
||||
3. The provider is attached to the agent via `ChatClientAgentOptions.AIContextProviders`, which gives the agent the `file_memory_*` tools and instructions for using them.
|
||||
4. In the first conversation, the user shares some preferences and the agent calls `file_memory_write` to store them as a file in the working folder. The sample then lists the files that were created on disk.
|
||||
5. In the second conversation, a brand new session is created with no chat history from the first conversation. The provider injects the memory index into the conversation, and the agent calls `file_memory_read` to recall the stored preferences when making its recommendations.
|
||||
@@ -10,7 +10,6 @@ These samples show how to create an agent with the Agent Framework that uses Mem
|
||||
|[Memory with Microsoft Foundry](./AgentWithMemory_Step04_MemoryUsingFoundry/)|This sample demonstrates how to create and run an agent that uses Microsoft Foundry's managed memory service to extract and retrieve individual memories.|
|
||||
|[Bounded Chat History with Overflow](./AgentWithMemory_Step05_BoundedChatHistory/)|This sample demonstrates how to create a bounded chat history provider that overflows older messages to a vector store and recalls them as memories.|
|
||||
|[Memory Using AgentMemory](./AgentWithMemory_Step06_MemoryUsingAgentMemory/)|This sample demonstrates a retail shopping assistant built with [`AgentMemory`](https://www.nuget.org/packages/AgentMemory), an unofficial .NET port of the Neo4j Labs graph-memory provider, to learn customer preferences and recommend products via graph traversal.|
|
||||
|[File Based Memory](./AgentWithMemory_Step07_FileMemoryProvider/)|This sample demonstrates how to use the `FileMemoryProvider` to give an agent tools for storing and recalling memories as files, and how to configure the folder that those memory files are written to.|
|
||||
|
||||
> **See also**: [Memory Search with Foundry Agents](../AgentProviders/foundry/Agent_Step22_MemorySearch/) - demonstrates using the built-in Memory Search tool with Microsoft Foundry agents.
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,153 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Agent Mode — Switch an agent's operating mode at runtime with AgentModeProvider
|
||||
//
|
||||
// This sample shows how to use the AgentModeProvider, an AIContextProvider that tracks the
|
||||
// agent's current operating "mode" in the session state and exposes tools (mode_get / mode_set)
|
||||
// so the agent can query and switch modes as its work progresses. The mode is folded into the
|
||||
// instructions sent to the model on every turn, so different modes can drive different behavior.
|
||||
//
|
||||
// The sample demonstrates two things:
|
||||
// 1. The built-in default modes ("plan" and "execute") that ship with the provider.
|
||||
// 2. How to customize the available modes via AgentModeProviderOptions.
|
||||
//
|
||||
// It runs a simple interactive loop. In addition to chatting with the agent, you can switch the
|
||||
// agent's mode yourself using a slash command:
|
||||
// /mode — show the current mode
|
||||
// /mode <name> — switch to the named mode
|
||||
// /help — list the available commands and modes
|
||||
// /exit — quit
|
||||
//
|
||||
// When you switch modes with /mode, the provider injects a notification on the next turn so the
|
||||
// agent clearly sees the change and adjusts its behavior accordingly.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// Set AGENT_MODE_USE_CUSTOM=true to run the sample with the custom modes defined below instead of
|
||||
// the provider's built-in "plan" / "execute" defaults.
|
||||
bool useCustomModes = string.Equals(Environment.GetEnvironmentVariable("AGENT_MODE_USE_CUSTOM"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// <create_mode_provider>
|
||||
AgentModeProvider modeProvider;
|
||||
string[] availableModes;
|
||||
|
||||
if (useCustomModes)
|
||||
{
|
||||
// Customize the set of modes by supplying AgentModeProviderOptions. Each mode has a name and a
|
||||
// block of instructions describing how the agent should behave while operating in that mode.
|
||||
// DefaultMode selects the mode new sessions start in (defaults to the first mode when omitted).
|
||||
modeProvider = new AgentModeProvider(new AgentModeProviderOptions
|
||||
{
|
||||
DefaultMode = "concise",
|
||||
Modes =
|
||||
[
|
||||
new AgentModeProviderOptions.AgentMode(
|
||||
"concise",
|
||||
"Answer in a single short sentence. Do not elaborate unless the user explicitly asks for more detail."),
|
||||
new AgentModeProviderOptions.AgentMode(
|
||||
"detailed",
|
||||
"Answer thoroughly. Explain your reasoning, provide examples, and cover relevant edge cases."),
|
||||
],
|
||||
});
|
||||
|
||||
availableModes = ["concise", "detailed"];
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use the provider's built-in modes: "plan" (interactive planning) and "execute" (autonomous
|
||||
// execution). No options are required.
|
||||
modeProvider = new AgentModeProvider();
|
||||
availableModes = ["plan", "execute"];
|
||||
}
|
||||
// </create_mode_provider>
|
||||
|
||||
// Create the agent and attach the mode provider as an AIContextProvider.
|
||||
// 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 AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "ModeAwareAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = model,
|
||||
Instructions = "You are a helpful assistant. Follow the process and behavior required by your current operating mode.",
|
||||
},
|
||||
AIContextProviders = [modeProvider],
|
||||
});
|
||||
|
||||
using var providerToDispose = modeProvider;
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
Console.WriteLine("Agent Mode sample. Type a message to chat, or use a slash command.");
|
||||
Console.WriteLine($"Available modes: {string.Join(", ", availableModes)}");
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
PrintHelp(availableModes);
|
||||
Console.WriteLine();
|
||||
|
||||
while (true)
|
||||
{
|
||||
Console.Write("> ");
|
||||
string? input = Console.ReadLine()?.Trim();
|
||||
|
||||
// Treat empty input or end-of-stream (Ctrl+D / Ctrl+Z) as a request to exit.
|
||||
if (string.IsNullOrWhiteSpace(input) || input.Equals("/exit", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (input.Equals("/help", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
PrintHelp(availableModes);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle the /mode slash command: "/mode" shows the current mode, "/mode <name>" switches to it.
|
||||
if (input.Equals("/mode", StringComparison.OrdinalIgnoreCase) || input.StartsWith("/mode ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
string[] parts = input.Split(' ', 2, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
{
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await modeProvider.SetModeAsync(session, parts[1]);
|
||||
Console.WriteLine($"Switched to \"{parts[1]}\" mode.");
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// SetModeAsync throws when the requested mode is not one of the configured modes.
|
||||
Console.WriteLine(ex.Message);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Anything else is a message for the agent. The mode provider injects the current mode (and any
|
||||
// pending mode-change notification) into the context for this turn.
|
||||
Console.WriteLine(await agent.RunAsync(input, session));
|
||||
|
||||
// Print the mode after the turn: the agent may have switched it itself via the mode_set tool as
|
||||
// its work progressed, so this reflects any change the agent made during the turn.
|
||||
Console.WriteLine($"Current mode: {await modeProvider.GetModeAsync(session)}");
|
||||
}
|
||||
|
||||
static void PrintHelp(string[] availableModes)
|
||||
{
|
||||
Console.WriteLine("Commands:");
|
||||
Console.WriteLine(" /mode Show the current mode");
|
||||
Console.WriteLine($" /mode <name> Switch mode ({string.Join(" | ", availableModes)})");
|
||||
Console.WriteLine(" /help Show this help");
|
||||
Console.WriteLine(" /exit Quit");
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
# Agent Mode
|
||||
|
||||
This sample demonstrates how to use the `AgentModeProvider` to track and switch an agent's
|
||||
operating **mode** at runtime, and drive different agent behavior depending on the active mode.
|
||||
|
||||
The `AgentModeProvider` is an `AIContextProvider` that stores the current mode in the session
|
||||
state and injects it into the instructions sent to the model on every turn. It also exposes
|
||||
`mode_get` and `mode_set` tools so the agent can query and switch modes on its own as its work
|
||||
progresses.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Attaching an `AgentModeProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
|
||||
- The provider's **built-in** modes: `plan` (interactive planning) and `execute` (autonomous execution).
|
||||
- **Customizing** the available modes with `AgentModeProviderOptions` (set the
|
||||
`AGENT_MODE_USE_CUSTOM` environment variable to `true` to switch to a simple `concise` /
|
||||
`detailed` mode set).
|
||||
- Reading and changing the mode from application code with `GetModeAsync` / `SetModeAsync`.
|
||||
- A simple interactive input loop that lets the user switch mode with a slash command. When the
|
||||
mode changes this way, the provider injects a notification on the next turn so the agent adjusts
|
||||
its behavior.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/mode` | Show the current mode |
|
||||
| `/mode <name>` | Switch to the named mode |
|
||||
| `/help` | List the available commands and modes |
|
||||
| `/exit` | Quit (an empty line also exits) |
|
||||
|
||||
Any other input is sent to the agent as a message.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Microsoft Foundry project endpoint and model configured
|
||||
- Azure CLI installed and authenticated (run `az login`)
|
||||
- User has the required role to invoke models in the Foundry project
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
To try the custom modes instead of the built-in `plan` / `execute` modes, set the
|
||||
`AGENT_MODE_USE_CUSTOM` environment variable to `true` and re-run:
|
||||
|
||||
```powershell
|
||||
$env:AGENT_MODE_USE_CUSTOM="true"
|
||||
dotnet run
|
||||
```
|
||||
@@ -1,16 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,83 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Todo List — Track work items across turns with TodoProvider
|
||||
//
|
||||
// This sample shows how to use the TodoProvider, an AIContextProvider that gives an agent a set of
|
||||
// tools for managing a todo list (todos_add, todos_complete, todos_remove, todos_get_remaining,
|
||||
// todos_get_all) along with instructions on how to use them. The todo list is stored in the
|
||||
// session state and persists across turns, so the agent can plan multi-step work, track progress,
|
||||
// and adjust the list as the conversation evolves.
|
||||
//
|
||||
// This is a scripted, non-interactive walkthrough: it sends a sequence of messages to the agent
|
||||
// and, after each turn, prints the agent's reply followed by the current todo list (read directly
|
||||
// from the provider via GetAllTodosAsync). This lets you watch the todo state evolve as the agent
|
||||
// adds, completes, and removes items.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
var endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
var model = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
// <create_todo_provider>
|
||||
// Create the TodoProvider and attach it to the agent as an AIContextProvider. The provider
|
||||
// contributes the todo-management tools and instructions to every agent invocation.
|
||||
using var todoProvider = new TodoProvider();
|
||||
|
||||
// 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 AIProjectClient(new Uri(endpoint), new DefaultAzureCredential())
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "PlanningAssistant",
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
ModelId = model,
|
||||
Instructions = "You are a helpful planning assistant. Use your todo list to plan and track multi-step work.",
|
||||
},
|
||||
AIContextProviders = [todoProvider],
|
||||
});
|
||||
// </create_todo_provider>
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
// A scripted set of turns that exercises the provider end-to-end: the agent should add todos for a
|
||||
// multi-step request, mark items complete as progress is reported, and adjust the list on a change
|
||||
// of plan.
|
||||
string[] userMessages =
|
||||
[
|
||||
"I'm organizing a small team offsite. Can you help me plan it? Break the work into a todo list.",
|
||||
"I've booked the venue and sent out the invites. Please update the list.",
|
||||
"Actually, let's skip catering and instead plan a group hike. Update the plan accordingly.",
|
||||
];
|
||||
|
||||
foreach (string userMessage in userMessages)
|
||||
{
|
||||
Console.WriteLine($"User: {userMessage}");
|
||||
Console.WriteLine($"Agent: {await agent.RunAsync(userMessage, session)}");
|
||||
|
||||
// Read the current todo list straight from the provider and print it so the state is visible.
|
||||
await PrintTodoListAsync(todoProvider, session);
|
||||
Console.WriteLine();
|
||||
}
|
||||
|
||||
static async Task PrintTodoListAsync(TodoProvider todoProvider, AgentSession session)
|
||||
{
|
||||
IReadOnlyList<TodoItem> todos = await todoProvider.GetAllTodosAsync(session);
|
||||
|
||||
Console.WriteLine("--- Current todo list ---");
|
||||
if (todos.Count == 0)
|
||||
{
|
||||
Console.WriteLine(" (empty)");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (TodoItem todo in todos)
|
||||
{
|
||||
string status = todo.IsComplete ? "x" : " ";
|
||||
Console.WriteLine($" [{status}] {todo.Id}. {todo.Title}");
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
# Todo List
|
||||
|
||||
This sample demonstrates how to use the `TodoProvider` to let an agent plan and track multi-step
|
||||
work using a todo list that persists across turns within a session.
|
||||
|
||||
The `TodoProvider` is an `AIContextProvider` that contributes todo-management tools and instructions
|
||||
to the agent, and stores the todo list in the session state. The provider exposes the following
|
||||
tools to the agent:
|
||||
|
||||
- `todos_add` — add one or more todo items (title + optional description).
|
||||
- `todos_complete` — mark one or more items complete, with a reason.
|
||||
- `todos_remove` — remove one or more items by ID.
|
||||
- `todos_get_remaining` — retrieve the incomplete items.
|
||||
- `todos_get_all` — retrieve all items (complete and incomplete).
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Attaching a `TodoProvider` to an agent via `ChatClientAgentOptions.AIContextProviders`.
|
||||
- The agent breaking a complex request into trackable todo items, marking items complete as
|
||||
progress is reported, and adjusting the list when the plan changes.
|
||||
- Reading the todo list from application code with `TodoProvider.GetAllTodosAsync`.
|
||||
|
||||
This is a **scripted, non-interactive** walkthrough: it sends a fixed sequence of messages and,
|
||||
after each turn, prints the agent's reply followed by the current todo list so you can watch the
|
||||
state evolve.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- .NET 10 SDK or later
|
||||
- Microsoft Foundry project endpoint and model configured
|
||||
- Azure CLI installed and authenticated (run `az login`)
|
||||
- User has the required role to invoke models in the Foundry project
|
||||
|
||||
## Running the sample
|
||||
|
||||
Set the required environment variables:
|
||||
|
||||
```powershell
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-project-endpoint"
|
||||
$env:FOUNDRY_MODEL="gpt-5.4-mini" # Optional, defaults to gpt-5.4-mini
|
||||
```
|
||||
|
||||
Run the sample:
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -47,9 +47,6 @@ Before you begin, ensure you have the following prerequisites:
|
||||
|[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.|
|
||||
|[Shell tool with environment-aware system prompt](./Agent_Step21_ShellWithEnvironment/)|This sample demonstrates how to use the shell tool together with the ShellEnvironmentProvider to run commands in stateless and persistent modes, injecting environment-aware instructions so the agent emits commands in the right shell idiom.|
|
||||
|[Switching agent operating mode](./Agent_Step22_AgentMode/)|This sample demonstrates how to use the AgentModeProvider to track and switch an agent's operating mode at runtime, including the built-in plan/execute modes and custom modes, with a simple input loop that switches mode using a slash command.|
|
||||
|[Tracking work with a todo list](./Agent_Step23_TodoList/)|This sample demonstrates how to use the TodoProvider to let an agent plan and track multi-step work using a todo list that persists across turns, printing the evolving todo list after each turn.|
|
||||
|
||||
## Running the samples from the console
|
||||
|
||||
|
||||
@@ -81,12 +81,7 @@ public static class Program
|
||||
}
|
||||
Console.WriteLine($"Number of checkpoints created: {checkpoints.Count}");
|
||||
|
||||
// <rehydrate_workflow>
|
||||
// A rehydrated workflow must preserve the topology and executor identities of the workflow that
|
||||
// created the checkpoint. This executor-only workflow rebuilds identically because its executors
|
||||
// use fixed ids. Agent-based workflows must recreate each local agent with the same
|
||||
// ChatClientAgentOptions.Id (and, if set, the same Name), otherwise the executor ids no longer
|
||||
// match the checkpoint and resume fails.
|
||||
// Rehydrate a new workflow instance from a saved checkpoint and continue execution
|
||||
var newWorkflow = WorkflowFactory.BuildWorkflow();
|
||||
const int CheckpointIndex = 5;
|
||||
Console.WriteLine($"\n\nHydrating a new workflow instance from the {CheckpointIndex + 1}th checkpoint.");
|
||||
@@ -94,7 +89,6 @@ public static class Program
|
||||
|
||||
await using StreamingRun newCheckpointedRun =
|
||||
await InProcessExecution.ResumeStreamingAsync(newWorkflow, savedCheckpoint, checkpointManager);
|
||||
// </rehydrate_workflow>
|
||||
|
||||
await foreach (WorkflowEvent evt in newCheckpointedRun.WatchStreamAsync())
|
||||
{
|
||||
|
||||
@@ -10,81 +10,50 @@ using Microsoft.Extensions.AI;
|
||||
/// <param name="chatClient">The <see cref="IChatClient"/> to use as the agent backend.</param>
|
||||
internal sealed class AgentRegistry(IChatClient chatClient)
|
||||
{
|
||||
// <stable_agent_identity>
|
||||
// Give each agent a stable, unique Id so its workflow executor identity stays the same when the
|
||||
// workflow is reconstructed (for example per request or dependency-injection scope), which keeps
|
||||
// checkpoints resumable. If an agent also has a Name, keep that stable too, since the executor
|
||||
// identity includes it. Use a fixed logical role here, not a conversation, request, or user id.
|
||||
internal const string IntakeAgentName = "Assistant";
|
||||
public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "intake-agent",
|
||||
Name = IntakeAgentName,
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
public AIAgent IntakeAgent { get; } = chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"""
|
||||
You receive a user request and are responsible for routing to the correct initial expert agent.
|
||||
""",
|
||||
},
|
||||
});
|
||||
// </stable_agent_identity>
|
||||
IntakeAgentName
|
||||
);
|
||||
|
||||
internal const string LiquidityAnalysisAgentName = "Liquidity Analysis";
|
||||
public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "liquidity-analysis-agent",
|
||||
Name = LiquidityAnalysisAgentName,
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
public AIAgent LiquidityAnalysisAgent { get; } = chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"""
|
||||
You are responsible for Liquidity Analysis.
|
||||
""",
|
||||
},
|
||||
});
|
||||
LiquidityAnalysisAgentName
|
||||
);
|
||||
|
||||
internal const string TaxAnalysisAgentName = "Tax Analysis";
|
||||
public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "tax-analysis-agent",
|
||||
Name = TaxAnalysisAgentName,
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are responsible for Tax Analysis.
|
||||
public AIAgent TaxAnalysisAgent { get; } = chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"""
|
||||
You are responsible for Tax Analysis.
|
||||
""",
|
||||
},
|
||||
});
|
||||
TaxAnalysisAgentName
|
||||
);
|
||||
|
||||
internal const string ForeignExchangeAgentName = "Foreign Exchange Analysis";
|
||||
public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "foreign-exchange-agent",
|
||||
Name = ForeignExchangeAgentName,
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are responsible for Foreign Exchange Analysis.
|
||||
public AIAgent ForeignExchangeAgent { get; } = chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"""
|
||||
You are responsible for Foreign Exchange Analysis.
|
||||
""",
|
||||
},
|
||||
});
|
||||
ForeignExchangeAgentName
|
||||
);
|
||||
|
||||
internal const string EquityAgentName = "Equity Analysis";
|
||||
public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = "equity-analysis-agent",
|
||||
Name = EquityAgentName,
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions =
|
||||
"""
|
||||
You are responsible for Equity Analysis.
|
||||
public AIAgent EquityAgent { get; } = chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"""
|
||||
You are responsible for Equity Analysis.
|
||||
""",
|
||||
},
|
||||
});
|
||||
EquityAgentName
|
||||
);
|
||||
|
||||
public IEnumerable<AIAgent> Experts => [this.LiquidityAnalysisAgent, this.TaxAnalysisAgent, this.ForeignExchangeAgent, this.EquityAgent];
|
||||
|
||||
|
||||
-14
@@ -205,17 +205,3 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedAzureSearchRag.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
-20
@@ -1,20 +0,0 @@
|
||||
# Keeps local-only files out of the image build context. Without this, `COPY . .` in the Dockerfile
|
||||
# would copy the local .env into a build layer, so local credentials would ship inside the image.
|
||||
.env
|
||||
.env.*
|
||||
.azure/
|
||||
.git/
|
||||
|
||||
# Build output: the image builds from source, so shipping host binaries only bloats the context and
|
||||
# risks copying binaries built for a different platform into the container.
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Agent session state written during local runs.
|
||||
.checkpoints/
|
||||
|
||||
# Note: local-feed/ and nuget.config are deliberately NOT excluded. When present (contributor mode)
|
||||
# the `dotnet restore` inside the image build resolves the Agent Framework from them.
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
# Foundry project endpoint (shape: https://<host>/api/projects/<project>)
|
||||
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
|
||||
# Model deployment name in your Foundry project.
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
|
||||
# Local development only. Bind the app to the port Foundry probes for readiness, which is the
|
||||
# port the Using-Samples REPLs expect. The Dockerfile sets this for the container; a plain
|
||||
# `dotnet run` on the host does not go through the Dockerfile, so set it here too.
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
|
||||
# Local development only. Restrict DefaultAzureCredential to developer credentials
|
||||
# (Azure CLI, Visual Studio, azd) and skip the Managed Identity probe. Without this,
|
||||
# on a machine with no managed identity DefaultAzureCredential hangs for a long time
|
||||
# probing the IMDS endpoint (169.254.169.254) before every model call. Not set in
|
||||
# Foundry, where the platform-injected managed identity is used.
|
||||
AZURE_TOKEN_CREDENTIALS=dev
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
# Foundry builds this image and runs it as the hosted agent. The build restores and publishes the
|
||||
# project inside the container, so a contributor feed dropped into this folder (local-feed/ plus
|
||||
# nuget.config) is picked up by the `dotnet restore` below without any change here.
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Foundry probes port 8088 for readiness. The .NET base image defaults ASPNETCORE_URLS to port 80,
|
||||
# so without this the probe never succeeds and every invoke fails with HTTP 424 session_not_ready.
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
|
||||
ENTRYPOINT ["dotnet", "HostedChatClientAgentDocker.dll"]
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
Container deploy sample. Foundry builds the Dockerfile in this folder and runs the resulting
|
||||
image, so unlike the source (ZIP) path there is no server-side `dotnet restore` on a bare
|
||||
folder: the restore happens inside the container build.
|
||||
|
||||
ImportDirectoryPackagesProps has to be set before the SDK props are imported, hence the explicit
|
||||
Sdk imports below instead of the usual Sdk attribute on the Project element. It stops MSBuild
|
||||
from walking up to the repository's dotnet/Directory.Packages.props, which does two things this
|
||||
sample must avoid: it turns on central package management, and it injects analyzer
|
||||
PackageReference items whose versions it also supplies. Neither exists inside the container
|
||||
build context, so without this the in-repo build would resolve differently from the image build.
|
||||
-->
|
||||
|
||||
<PropertyGroup>
|
||||
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Single target: the Dockerfile publishes without -f, so the project must not multi-target.
|
||||
The empty TargetFrameworks clears the value inherited from the repo's samples
|
||||
Directory.Build.props for in-repo builds. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>HostedChatClientAgentDocker</RootNamespace>
|
||||
<AssemblyName>HostedChatClientAgentDocker</AssemblyName>
|
||||
<UserSecretsId>7b1c3f04-24a1-4f0e-9a5e-0d2f6b8c1e57</UserSecretsId>
|
||||
<AgentFrameworkVersion>1.15.0-preview.260722.1</AgentFrameworkVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="$(AgentFrameworkVersion)" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
|
||||
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
</Project>
|
||||
-61
@@ -1,61 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample: a minimal general-purpose AI assistant hosted as a Foundry Hosted Agent
|
||||
// using the Responses protocol. It is deployed to Foundry as a container image built
|
||||
// from the Dockerfile in this folder.
|
||||
//
|
||||
// The sibling Hosted-ChatClientAgent sample is the same agent deployed the other way,
|
||||
// straight from source with no container image. Compare the two folders to see exactly
|
||||
// what the container path adds.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load a local .env file when present (local development only). In Foundry the
|
||||
// platform injects the required environment variables at runtime.
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
// Environment variables can arrive set but blank: azd substitutes an empty string when the azd
|
||||
// environment does not define the variable referenced from azure.yaml. An empty string is not
|
||||
// null, so a plain ?? chain would pass the blank straight through and fail deep inside the SDK.
|
||||
var model = FirstNonBlank(
|
||||
System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"),
|
||||
System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"),
|
||||
"gpt-4o");
|
||||
|
||||
var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent-docker";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful
|
||||
// consideration in production. Consider a specific credential (for example
|
||||
// ManagedIdentityCredential) to avoid latency, unintended credential probing, and
|
||||
// fallback security risks.
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
instructions: """
|
||||
You are a helpful AI assistant hosted as a Foundry Hosted Agent.
|
||||
You can help with a wide range of tasks including answering questions,
|
||||
providing explanations, brainstorming ideas, and offering guidance.
|
||||
Be concise, clear, and helpful in your responses.
|
||||
""",
|
||||
name: agentName,
|
||||
description: "A simple general-purpose AI assistant");
|
||||
|
||||
// Host the agent using the Responses protocol.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
app.Run();
|
||||
|
||||
// Returns the first candidate that has an actual value, ignoring null and blank entries.
|
||||
static string FirstNonBlank(params string?[] candidates) =>
|
||||
Array.Find(candidates, c => !string.IsNullOrWhiteSpace(c))!;
|
||||
-327
@@ -1,327 +0,0 @@
|
||||
# Hosted-ChatClientAgent-Dockerfile
|
||||
|
||||
A minimal general-purpose AI assistant hosted as a Foundry Hosted Agent using the Responses protocol. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served with `AddFoundryResponses` / `MapFoundryResponses`.
|
||||
|
||||
This sample deploys to Foundry as a **container image** built from the `Dockerfile` in this folder.
|
||||
|
||||
The sibling [`Hosted-ChatClientAgent`](../Hosted-ChatClientAgent/) sample is the same agent deployed the other way, straight from source with no container image. That is the default for .NET and needs no Docker, so prefer it unless you need control over the runtime image.
|
||||
|
||||
| | Source (ZIP) | Container (this sample) |
|
||||
|---|---|---|
|
||||
| Deploy mode | `code`, the default for .NET | `container`, opt in with `--deploy-mode container` |
|
||||
| Extra files | none | `Dockerfile`, `.dockerignore` |
|
||||
| Who builds | Foundry runs `dotnet restore` + `dotnet publish` on the upload | Foundry builds the `Dockerfile` |
|
||||
| Docker required | no | no, `azd` builds remotely in Azure Container Registry |
|
||||
| Listen port | the package binds it, or `env` in `azure.yaml` | `ENV ASPNETCORE_URLS` in the `Dockerfile` |
|
||||
| Extra Azure resource | none | an Azure Container Registry |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An **existing** Foundry project with an **existing** model deployment (for example `gpt-4o`).
|
||||
This sample's `azure.yaml` declares no `deployments:` block, so `azd` connects to a project and
|
||||
a deployment you already have rather than creating them. `azd ai agent init` prompts you to pick
|
||||
the project, and takes the deployment name as the `-d` argument.
|
||||
- Azure CLI logged in (`az login`)
|
||||
- Azure Developer CLI (`azd`) with the AI agents extension: `azd extension install azure.ai.agents`
|
||||
- Docker Desktop **only** if you switch to local image builds by setting `remoteBuild: false` under
|
||||
the `docker:` block in `azure.yaml`. By default `azd` builds the image in Azure Container
|
||||
Registry, so no local Docker is needed.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Program.cs` | The agent: builds the agent, hosts it with the Responses protocol. |
|
||||
| `Dockerfile` | Builds the image Foundry runs. Restores and publishes the project inside the container, and pins the listen port to 8088. |
|
||||
| `.dockerignore` | Keeps local-only files (notably `.env`) out of the image build context. |
|
||||
| `azure.yaml` | The unified `azd` project file. Declares the Foundry project and the hosted agent with `language: docker` and no `codeConfiguration`, which is what selects the container path. |
|
||||
| `HostedChatClientAgentDocker.csproj` | Self-contained project: single target framework and explicit package versions. It also opts out of the repository's central package management, which does not travel inside the image build context. |
|
||||
| `.env.example` | Template for local configuration. |
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
```env
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
AZURE_TOKEN_CREDENTIALS=dev
|
||||
```
|
||||
|
||||
> `.env` is gitignored, and `.dockerignore` keeps it out of the image. The `.env.example` template
|
||||
> is checked in as a reference.
|
||||
|
||||
> **Windows note:** write `.env` as UTF-8 **without** a byte order mark. `azd` reads the file
|
||||
> during `azd ai agent init` and fails with `unexpected character "»" in variable name` when a mark
|
||||
> is present. PowerShell's `Set-Content -Encoding UTF8BOM` adds one; use `-Encoding utf8NoBOM`.
|
||||
|
||||
> **Local development on a machine without a managed identity:** set `AZURE_TOKEN_CREDENTIALS=dev`.
|
||||
> `Program.cs` authenticates with `DefaultAzureCredential` (the pattern the hosted platform
|
||||
> expects, where a managed identity is injected). On a developer machine with no managed identity,
|
||||
> `DefaultAzureCredential` probes the Azure Instance Metadata Service (IMDS, `169.254.169.254`) and
|
||||
> blocks for a long time on the network timeout before every model call, so requests appear to
|
||||
> hang. Setting `AZURE_TOKEN_CREDENTIALS=dev` restricts `DefaultAzureCredential` to developer
|
||||
> credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable
|
||||
> is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity.
|
||||
|
||||
## Run and test locally
|
||||
|
||||
Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it
|
||||
using Agent Framework components, see the sibling [`Using-Samples`](../Using-Samples/) REPLs.
|
||||
|
||||
**Terminal 1 — host the agent:**
|
||||
|
||||
```
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile
|
||||
az login
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent starts on `http://localhost:8088`.
|
||||
|
||||
**Terminal 2 — chat with it (code-first REPL):**
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent-docker"
|
||||
dotnet run -- --local
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent-docker"
|
||||
dotnet run -- --local
|
||||
```
|
||||
|
||||
To exercise the image instead of the host build, build and run the container directly:
|
||||
|
||||
```
|
||||
docker build -t hosted-chat-client-agent-docker .
|
||||
docker run --rm -p 8088:8088 --env-file .env hosted-chat-client-agent-docker
|
||||
```
|
||||
|
||||
## Deploy to Foundry (container)
|
||||
|
||||
`azd` scaffolds the project into a working folder, so every step below runs from an **empty
|
||||
directory outside the repository**, and `-m` points at this sample's `azure.yaml`.
|
||||
|
||||
### Step 1: create the working directory and enter it
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$work = Join-Path $env:TEMP "hosted-chat-docker-work"
|
||||
mkdir $work
|
||||
cd $work
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
WORK="${TMPDIR:-/tmp}/hosted-chat-docker-work"
|
||||
mkdir -p "$WORK"
|
||||
cd "$WORK"
|
||||
```
|
||||
|
||||
### Step 2: scaffold the project
|
||||
|
||||
`--deploy-mode container` is the argument that selects the container path. Without it `azd`
|
||||
defaults to `code` for .NET, which ignores the `Dockerfile` and deploys the source as a ZIP.
|
||||
|
||||
`azd ai agent init` copies the sample into a subfolder named after the top-level `name:` in
|
||||
`azure.yaml`, which is `hosted-chat-client-agent-docker`. It also writes the adopted `azure.yaml`
|
||||
and the `azd` environment there.
|
||||
|
||||
`azd ai agent init` prompts you to pick the Foundry project, so no project argument is needed.
|
||||
`-d` is the name of an existing model deployment in that project; omit it and `azd` prompts for
|
||||
that too.
|
||||
|
||||
> Pick an **existing** project at the prompt. The prompt needs an interactive terminal: run
|
||||
> non-interactively (in CI, for example) and `azd` skips it and provisions a brand new Foundry
|
||||
> project and resource group instead. Pass `-p <project-resource-id>` when you need that to be
|
||||
> unattended.
|
||||
|
||||
`azure.yaml` passes the model deployment to the container by reading it from the `azd` environment.
|
||||
Confirm it landed there, and set it yourself if it did not:
|
||||
|
||||
```
|
||||
azd env get-values
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME <model-deployment>
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$sample = "<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml"
|
||||
|
||||
azd auth login
|
||||
azd ai agent init -m $sample -d <model-deployment> --deploy-mode container
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
SAMPLE="<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent-Dockerfile/azure.yaml"
|
||||
|
||||
azd auth login
|
||||
azd ai agent init -m "$SAMPLE" -d <model-deployment> --deploy-mode container
|
||||
```
|
||||
|
||||
### Step 3: provision and deploy
|
||||
|
||||
Contributors: if you are changing the Agent Framework source in this repository and want the
|
||||
deployed agent to run **your** build rather than the published packages, do the extra step in
|
||||
[Deploy your local framework changes](#deploy-your-local-framework-changes-contributors) now,
|
||||
before the commands below. Everyone else can ignore it.
|
||||
|
||||
```
|
||||
cd hosted-chat-client-agent-docker
|
||||
azd provision
|
||||
azd deploy
|
||||
azd ai agent invoke "Hello!"
|
||||
```
|
||||
|
||||
`azd provision` creates the Azure Container Registry the image is pushed to, alongside the rest of
|
||||
the environment. `azd deploy` builds the image (remotely in that registry by default), pushes it,
|
||||
and creates the agent version.
|
||||
|
||||
To build the image on your own machine instead, flip `remoteBuild` to `false` in `azure.yaml`:
|
||||
|
||||
```yaml
|
||||
docker:
|
||||
remoteBuild: false
|
||||
```
|
||||
|
||||
That requires Docker Desktop, and on Apple Silicon or other ARM machines you must produce an
|
||||
x86_64 image, since the hosting platform only runs `linux/amd64`.
|
||||
|
||||
You can also test the deployed agent with the REPL:
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent-docker"
|
||||
dotnet run -- --remote
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
|
||||
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent-docker"
|
||||
dotnet run -- --remote
|
||||
```
|
||||
|
||||
### Step 4: clean up
|
||||
|
||||
```
|
||||
azd down
|
||||
```
|
||||
|
||||
Then delete the working directory.
|
||||
|
||||
## Deploy your local framework changes (contributors)
|
||||
|
||||
**Skip this section unless you are changing the Agent Framework itself.** Everything above is the
|
||||
complete flow for using the sample. This section only applies when you are working on the framework
|
||||
source in this repository, or when you otherwise need a build of it that is not published on
|
||||
nuget.org.
|
||||
|
||||
The reason it exists: the project restores the **published** Agent Framework packages, and the
|
||||
`dotnet restore` inside the image build pulls them from nuget.org. So editing framework source in
|
||||
this repository changes nothing about the deployed agent, no matter how many times you rebuild
|
||||
locally. The image build context is self-contained and knows nothing about your working tree.
|
||||
|
||||
The extra step packs your local framework source into NuGet packages and puts them **inside the
|
||||
build context**, together with a `nuget.config` that points the restore at them. The restore inside
|
||||
the image build then resolves the framework from the packages you shipped instead of from
|
||||
nuget.org.
|
||||
|
||||
Run it in the flow above, **between step 2 and step 3**. Nothing else changes, and it is the same
|
||||
script the source-deploy sample uses: the `Dockerfile` copies the whole folder before restoring, so
|
||||
a feed dropped in this folder is picked up with no change to the `Dockerfile`.
|
||||
|
||||
Run it from `$work`, the working directory created in step 1, which now holds the
|
||||
`hosted-chat-client-agent-docker` folder that `azd ai agent init` scaffolded:
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
cd $work
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent-docker
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
cd "$WORK"
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh ./hosted-chat-client-agent-docker
|
||||
```
|
||||
|
||||
Then continue with step 3. The path argument is optional: called without it, the script uses the
|
||||
current directory, so you can also run it from inside `hosted-chat-client-agent-docker`.
|
||||
|
||||
The script changes three things in the scaffolded folder:
|
||||
|
||||
| Change | Detail |
|
||||
|--------|--------|
|
||||
| Creates `local-feed/` | The Agent Framework packed from your local source, stamped with a version like `1.15.0-preview-local.<timestamp>` |
|
||||
| Creates `nuget.config` | Resolves `Microsoft.Agents.AI*` from that folder and everything else from nuget.org |
|
||||
| Edits the `.csproj` | Repoints its `AgentFrameworkVersion` property at the version just packed |
|
||||
|
||||
Neither generated file is excluded by `.dockerignore`, so both reach the image build context and
|
||||
the restore inside the build uses them. The scaffolded folder is a throwaway copy, so the
|
||||
repository is left untouched.
|
||||
|
||||
Two details worth knowing:
|
||||
|
||||
- The version carries a timestamp because NuGet caches by package id and version. Reusing a version
|
||||
would silently restore the previously packed bits instead of the build you just made.
|
||||
- The whole package closure is packed, not just the two packages the sample references. Packing
|
||||
only the leaf packages lets NuGet fill the rest from nuget.org, mixing a published core with a
|
||||
locally built host, which fails to compile.
|
||||
|
||||
Before spending a deploy, build the scaffolded folder locally. A restore problem surfaces in
|
||||
seconds instead of after the image build:
|
||||
|
||||
```
|
||||
cd hosted-chat-client-agent-docker
|
||||
dotnet build -c Debug --tl:off
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
For the full hosted-agent deployment guide, see the [official container deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
|
||||
|
||||
name: hosted-chat-client-agent-docker
|
||||
services:
|
||||
ai-project:
|
||||
host: azure.ai.project
|
||||
hosted-chat-client-agent-docker:
|
||||
project: .
|
||||
host: azure.ai.agent
|
||||
# `docker` selects the container build path: Foundry builds the Dockerfile in this folder
|
||||
# instead of restoring a plain source folder. There is no codeConfiguration block here,
|
||||
# which is what tells the tooling this is a container deploy rather than a source deploy.
|
||||
language: docker
|
||||
docker:
|
||||
# azd builds the image in Azure Container Registry by default. Set this to false to
|
||||
# build on your own machine instead, which requires Docker Desktop.
|
||||
remoteBuild: true
|
||||
uses:
|
||||
- ai-project
|
||||
# ${AZURE_AI_MODEL_DEPLOYMENT_NAME} reads the model deployment `azd ai agent init` recorded
|
||||
# in the active azd environment. Without it the container falls back to the default model
|
||||
# name hardcoded in Program.cs, which may not exist in the target project.
|
||||
#
|
||||
# The listen port is not set here: the Dockerfile already sets ASPNETCORE_URLS.
|
||||
env:
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
container:
|
||||
resources:
|
||||
cpu: "0.5"
|
||||
memory: 1Gi
|
||||
description: |
|
||||
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent, deployed as a container image.
|
||||
kind: hosted
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
name: hosted-chat-client-agent-docker
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
-30
@@ -1,30 +0,0 @@
|
||||
# Controls which files are excluded from the code-deploy ZIP upload (.gitignore syntax).
|
||||
# Note: only the root .agentignore is read; subdirectory files are not supported.
|
||||
#
|
||||
# To include a file that is excluded by default, use negation: !filename
|
||||
|
||||
# azd tooling files
|
||||
azure.yaml
|
||||
.agentignore
|
||||
|
||||
# Security / secrets
|
||||
.env
|
||||
.env.*
|
||||
.azure/
|
||||
.git/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
*.user
|
||||
*.suo
|
||||
.vs/
|
||||
|
||||
# Agent session state written by FileSystemAgentSessionStore during local runs. The hosted
|
||||
# runtime writes its own under the container's home directory, so uploading the local copy
|
||||
# would ship stale sessions with the agent.
|
||||
.checkpoints/
|
||||
|
||||
# Contributor mode (scripts/Add-LocalFrameworkFeed.ps1) generates local-feed/ and nuget.config.
|
||||
# Those are deliberately NOT excluded: the server-side restore needs them to resolve the Agent
|
||||
# Framework from the packages shipped in this upload instead of nuget.org.
|
||||
+4
-15
@@ -1,17 +1,6 @@
|
||||
# Foundry project endpoint (shape: https://<host>/api/projects/<project>)
|
||||
FOUNDRY_PROJECT_ENDPOINT=<your-azure-ai-project-endpoint>
|
||||
|
||||
# Model deployment name in your Foundry project.
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
|
||||
# Local development only. Bind the app to the port Foundry probes for readiness, which is the
|
||||
# port the Using-Samples REPLs expect. Recent Microsoft.Agents.AI.Foundry.Hosting versions bind
|
||||
# it themselves, so this only matters while the project is pinned to an older published package.
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
|
||||
# Local development only. Restrict DefaultAzureCredential to developer credentials
|
||||
# (Azure CLI, Visual Studio, azd) and skip the Managed Identity probe. Without this,
|
||||
# on a machine with no managed identity DefaultAzureCredential hangs for a long time
|
||||
# probing the IMDS endpoint (169.254.169.254) before every model call. Not set in
|
||||
# Foundry, where the platform-injected managed identity is used.
|
||||
AZURE_TOKEN_CREDENTIALS=dev
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
AGENT_NAME=hosted-chat-client-agent
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
# Use the official .NET 10.0 ASP.NET runtime as a parent image
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
COPY . .
|
||||
RUN dotnet restore
|
||||
RUN dotnet publish -c Release -o /app/publish
|
||||
|
||||
# Final stage
|
||||
FROM base AS final
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/publish .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"]
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
# Dockerfile for contributors building from the agent-framework repository source.
|
||||
#
|
||||
# This project uses ProjectReference to the local Microsoft.Agents.AI.Foundry source,
|
||||
# which means a standard multi-stage Docker build cannot resolve dependencies outside
|
||||
# this folder. Instead, pre-publish the app targeting the container runtime and copy
|
||||
# the output into the container:
|
||||
#
|
||||
# dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
# docker build -f Dockerfile.contributor -t hosted-chat-client-agent .
|
||||
# docker run --rm -p 8088:8088 -e AGENT_NAME=hosted-chat-client-agent --env-file .env hosted-chat-client-agent
|
||||
#
|
||||
# For end-users consuming the NuGet package (not ProjectReference), use the standard
|
||||
# Dockerfile which performs a full dotnet restore + publish inside the container.
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS final
|
||||
WORKDIR /app
|
||||
COPY out/ .
|
||||
EXPOSE 8088
|
||||
ENV ASPNETCORE_URLS=http://+:8088
|
||||
ENTRYPOINT ["dotnet", "HostedChatClientAgent.dll"]
|
||||
+20
-34
@@ -1,47 +1,33 @@
|
||||
<Project>
|
||||
|
||||
<!--
|
||||
Source (ZIP) deploy sample. The code-deploy upload is a flat folder with no repo-level props,
|
||||
so this project is intentionally self-contained: a single target framework and explicit package
|
||||
versions. Foundry runs `dotnet restore` + `dotnet publish` on it during provisioning
|
||||
(dependencyResolution: remote_build in azure.yaml).
|
||||
|
||||
ImportDirectoryPackagesProps has to be set before the SDK props are imported, hence the explicit
|
||||
Sdk imports below instead of the usual Sdk attribute on the Project element. It stops MSBuild
|
||||
from walking up to the repository's dotnet/Directory.Packages.props, which does two things this
|
||||
sample must avoid: it turns on central package management, and it injects analyzer
|
||||
PackageReference items whose versions it also supplies. Neither exists inside the ZIP, so
|
||||
without this the in-repo build would resolve differently from the server-side build.
|
||||
-->
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<ImportDirectoryPackagesProps>false</ImportDirectoryPackagesProps>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="Sdk.props" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Single target: the Foundry dotnet_10 runtime publishes without -f, so the
|
||||
project must not multi-target. The empty TargetFrameworks clears the value
|
||||
inherited from the repo's samples Directory.Build.props for in-repo builds. -->
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<TargetFrameworks></TargetFrameworks>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<CentralPackageTransitivePinningEnabled>false</CentralPackageTransitivePinningEnabled>
|
||||
<RootNamespace>HostedChatClientAgent</RootNamespace>
|
||||
<AssemblyName>HostedChatClientAgent</AssemblyName>
|
||||
<UserSecretsId>222d2622-da26-4da0-99b4-0507fb8d41b0</UserSecretsId>
|
||||
<AgentFrameworkVersion>1.15.0-preview.260722.1</AgentFrameworkVersion>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="$(AgentFrameworkVersion)" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="$(AgentFrameworkVersion)" />
|
||||
<PackageReference Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
|
||||
<PackageReference Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageReference Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="Sdk.targets" Sdk="Microsoft.NET.Sdk.Web" />
|
||||
<!-- For contributors: uses ProjectReference to build against local source -->
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry.Hosting\Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<ProjectReference Include="..\Hosted_Shared_Contributor_Setup\Hosted_Shared_Contributor_Setup.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- For end-users: uncomment the PackageReference below and remove the ProjectReference above
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.6.1-preview.260514.1" />
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
</ItemGroup>
|
||||
-->
|
||||
|
||||
</Project>
|
||||
|
||||
+24
-26
@@ -1,39 +1,36 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample: a minimal general-purpose AI assistant hosted as a Foundry Hosted Agent
|
||||
// using the Responses protocol. It is deployed to Foundry directly from source
|
||||
// (code / ZIP upload), so the platform builds and runs it with no container image.
|
||||
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Core;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Hosted_Shared_Contributor_Setup;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
// Load a local .env file when present (local development only). In Foundry the
|
||||
// platform injects the required environment variables at runtime.
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(System.Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
// Environment variables can arrive set but blank: azd substitutes an empty string when the azd
|
||||
// environment does not define the variable referenced from azure.yaml. An empty string is not
|
||||
// null, so a plain ?? chain would pass the blank straight through and fail deep inside the SDK.
|
||||
var model = FirstNonBlank(
|
||||
System.Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME"),
|
||||
System.Environment.GetEnvironmentVariable("FOUNDRY_MODEL"),
|
||||
"gpt-4o");
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent";
|
||||
|
||||
var agentName = System.Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent";
|
||||
var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o";
|
||||
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful
|
||||
// consideration in production. Consider a specific credential (for example
|
||||
// ManagedIdentityCredential) to avoid latency, unintended credential probing, and
|
||||
// fallback security risks.
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
|
||||
// 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.
|
||||
// Use a chained credential: try a temporary dev token first (for local Docker debugging),
|
||||
// then fall back to DefaultAzureCredential (for local dev via dotnet run / managed identity running in foundry).
|
||||
TokenCredential credential = new ChainedTokenCredential(
|
||||
new DevTemporaryTokenCredential(),
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Create the agent via the AI project client using the Responses API.
|
||||
AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
.AsAIAgent(
|
||||
model: model,
|
||||
model: deployment,
|
||||
instructions: """
|
||||
You are a helpful AI assistant hosted as a Foundry Hosted Agent.
|
||||
You can help with a wide range of tasks including answering questions,
|
||||
@@ -43,15 +40,16 @@ AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential(
|
||||
name: agentName,
|
||||
description: "A simple general-purpose AI assistant");
|
||||
|
||||
// Host the agent using the Responses protocol.
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
app.Run();
|
||||
// Contributor-only: in Development, also map the per-agent OpenAI route shape that live Foundry uses
|
||||
// so a local REPL client can target this server via AIProjectClient.AsAIAgent(Uri agentEndpoint).
|
||||
// Do not use this in production. Hosted Foundry agents only support the agent-endpoint path.
|
||||
app.MapDevTemporaryLocalAgentEndpoint();
|
||||
|
||||
// Returns the first candidate that has an actual value, ignoring null and blank entries.
|
||||
static string FirstNonBlank(params string?[] candidates) =>
|
||||
Array.Find(candidates, c => !string.IsNullOrWhiteSpace(c))!;
|
||||
app.Run();
|
||||
|
||||
+81
-249
@@ -1,303 +1,135 @@
|
||||
# Hosted-ChatClientAgent
|
||||
# Hosted-ChatClientAgent
|
||||
|
||||
A minimal general-purpose AI assistant hosted as a Foundry Hosted Agent using the Responses protocol. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served with `AddFoundryResponses` / `MapFoundryResponses`.
|
||||
|
||||
This sample deploys to Foundry **directly from source (code / ZIP upload)**: the platform builds and runs your code with no container image, so there is no Dockerfile to author or container registry to manage.
|
||||
|
||||
The sibling [`Hosted-ChatClientAgent-Dockerfile`](../Hosted-ChatClientAgent-Dockerfile/) sample is the same agent deployed the other way, as a container image built from a `Dockerfile`. Source deploy is the default for .NET, so start here and switch only if you need control over the runtime image.
|
||||
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern. The agent is created inline via `AIProjectClient.AsAIAgent(model, instructions)` and served using the Responses protocol.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
|
||||
- An **existing** Foundry project with an **existing** model deployment (for example `gpt-4o`).
|
||||
This sample's `azure.yaml` declares no `deployments:` block, so `azd` connects to a project and
|
||||
a deployment you already have rather than creating them. `azd ai agent init` prompts you to pick
|
||||
the project, and takes the deployment name as the `-d` argument.
|
||||
- A Foundry project with a deployed model (e.g., `gpt-4o`)
|
||||
- Azure CLI logged in (`az login`)
|
||||
- Azure Developer CLI (`azd`) with the AI agents extension: `azd extension install azure.ai.agents`
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Program.cs` | The agent: builds the agent, hosts it with the Responses protocol. |
|
||||
| `azure.yaml` | The unified `azd` project file. Declares the Foundry project and the hosted agent with `codeConfiguration` (source/ZIP deploy), and passes the listen port and the model deployment name to the container through `env`. |
|
||||
| `.agentignore` | Controls which files are excluded from the code-deploy ZIP upload (`.gitignore` syntax). |
|
||||
| `HostedChatClientAgent.csproj` | Self-contained project: single target framework and explicit package versions. It also opts out of the repository's central package management, which does not travel inside the ZIP. |
|
||||
| `.env.example` | Template for local configuration. |
|
||||
| `../../scripts/Add-LocalFrameworkFeed.ps1`, `../../scripts/add-local-framework-feed.sh` | Contributor-only helpers, see [Deploy your local framework changes](#deploy-your-local-framework-changes-contributors). |
|
||||
|
||||
## Configuration
|
||||
|
||||
Copy the template and fill in your project endpoint:
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
copy .env.example .env
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` and set your Foundry project endpoint:
|
||||
|
||||
```env
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://<your-account>.services.ai.azure.com/api/projects/<your-project>
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME=gpt-4o
|
||||
ASPNETCORE_URLS=http://+:8088
|
||||
AZURE_TOKEN_CREDENTIALS=dev
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
FOUNDRY_MODEL=gpt-4o
|
||||
```
|
||||
|
||||
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
> **Note:** `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
> `ASPNETCORE_URLS` pins the local run to the port the `Using-Samples` REPLs expect. Recent
|
||||
> `Microsoft.Agents.AI.Foundry.Hosting` versions bind that port themselves, so it only matters
|
||||
> while this project is pinned to an older published package.
|
||||
## Running directly (contributors)
|
||||
|
||||
> **Windows note:** write `.env` as UTF-8 **without** a byte order mark. `azd` reads the file
|
||||
> during `azd ai agent init` and fails with `unexpected character "»" in variable name` when a mark
|
||||
> is present. PowerShell's `Set-Content -Encoding UTF8BOM` adds one; use `-Encoding utf8NoBOM`.
|
||||
This project uses `ProjectReference` to build against the local Agent Framework source.
|
||||
|
||||
> **Local development on a machine without a managed identity:** set `AZURE_TOKEN_CREDENTIALS=dev`.
|
||||
> `Program.cs` authenticates with `DefaultAzureCredential` (the pattern the hosted platform
|
||||
> expects, where a managed identity is injected). On a developer machine with no managed identity,
|
||||
> `DefaultAzureCredential` probes the Azure Instance Metadata Service (IMDS, `169.254.169.254`) and
|
||||
> blocks for a long time on the network timeout before every model call, so requests appear to
|
||||
> hang. Setting `AZURE_TOKEN_CREDENTIALS=dev` restricts `DefaultAzureCredential` to developer
|
||||
> credentials (Azure CLI, Visual Studio, `azd`) and skips the managed-identity probe. This variable
|
||||
> is only for local runs; the deployed agent in Foundry uses the platform-injected managed identity.
|
||||
|
||||
## Run and test locally
|
||||
|
||||
Local runs use two terminals: one hosts the agent, the other is a code-first client that talks to it
|
||||
using Agent Framework components, see the sibling [`Using-Samples`](../Using-Samples/) REPLs.
|
||||
|
||||
`AddFoundryResponses` binds the app to the port Foundry probes for readiness (8088 by default,
|
||||
overridable with the `PORT` environment variable), and `MapFoundryResponses` serves the standard
|
||||
`POST /responses` route. That is the same route the platform routes to for a deployed agent, so the
|
||||
local server needs no extra wiring: the client just points an OpenAI responses client at
|
||||
`http://localhost:8088`.
|
||||
|
||||
**Terminal 1 — host the agent:**
|
||||
|
||||
```
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent
|
||||
az login
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The agent starts on `http://localhost:8088`.
|
||||
The agent will start on `http://localhost:8088`.
|
||||
|
||||
**Terminal 2 — chat with it (code-first REPL):**
|
||||
### Test it
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent"
|
||||
dotnet run -- --local
|
||||
```
|
||||
|
||||
Bash:
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent"
|
||||
dotnet run -- --local
|
||||
azd ai agent invoke --local "Hello!"
|
||||
```
|
||||
|
||||
Without `--local` the REPL asks which agent to chat with; choose **2 (Local)**. Either way it
|
||||
points an OpenAI responses client at the local server and streams the reply.
|
||||
|
||||
## Deploy to Foundry (source / ZIP)
|
||||
|
||||
`azd` scaffolds the project into a working folder, so every step below runs from an **empty
|
||||
directory outside the repository**, and `-m` points at this sample's `azure.yaml`.
|
||||
|
||||
### Step 1: create the working directory and enter it
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$work = Join-Path $env:TEMP "hosted-chat-work"
|
||||
mkdir $work
|
||||
cd $work
|
||||
```
|
||||
|
||||
Bash:
|
||||
Or with curl (specifying the agent name explicitly):
|
||||
|
||||
```bash
|
||||
WORK="${TMPDIR:-/tmp}/hosted-chat-work"
|
||||
mkdir -p "$WORK"
|
||||
cd "$WORK"
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
|
||||
```
|
||||
|
||||
### Step 2: scaffold the project
|
||||
## Running with Docker
|
||||
|
||||
`azd ai agent init` copies the sample into a subfolder named after the top-level `name:` in
|
||||
`azure.yaml`, which is `hosted-chat-client-agent`. It also writes the adopted `azure.yaml` and the
|
||||
`azd` environment there.
|
||||
Since this project uses `ProjectReference`, the standard `Dockerfile` cannot resolve dependencies outside this folder. Use `Dockerfile.contributor` which takes a pre-published output.
|
||||
|
||||
`azd ai agent init` prompts you to pick the Foundry project, so no project argument is needed.
|
||||
`-d` is the name of an existing model deployment in that project; omit it and `azd` prompts for
|
||||
that too.
|
||||
|
||||
> Pick an **existing** project at the prompt. The prompt needs an interactive terminal: run
|
||||
> non-interactively (in CI, for example) and `azd` skips it and provisions a brand new Foundry
|
||||
> project and resource group instead. Pass `-p <project-resource-id>` when you need that to be
|
||||
> unattended.
|
||||
|
||||
`azure.yaml` passes the model deployment to the container by reading it from the `azd` environment.
|
||||
Confirm it landed there, and set it yourself if it did not:
|
||||
|
||||
```
|
||||
azd env get-values
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME <model-deployment>
|
||||
```
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
$sample = "<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml"
|
||||
|
||||
azd auth login
|
||||
azd ai agent init -m $sample -d <model-deployment>
|
||||
```
|
||||
|
||||
Bash:
|
||||
### 1. Publish for the container runtime (Linux Alpine)
|
||||
|
||||
```bash
|
||||
SAMPLE="<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/azure.yaml"
|
||||
|
||||
azd auth login
|
||||
azd ai agent init -m "$SAMPLE" -d <model-deployment>
|
||||
dotnet publish -c Debug -f net10.0 -r linux-musl-x64 --self-contained false -o out
|
||||
```
|
||||
|
||||
### Step 3: provision and deploy
|
||||
|
||||
Contributors: if you are changing the Agent Framework source in this repository and want the
|
||||
deployed agent to run **your** build rather than the published packages, do the extra step in
|
||||
[Deploy your local framework changes](#deploy-your-local-framework-changes-contributors) now,
|
||||
before the commands below. Everyone else can ignore it.
|
||||
### 2. Build the Docker image
|
||||
|
||||
```bash
|
||||
docker build -f Dockerfile.contributor -t hosted-chat-client-agent .
|
||||
```
|
||||
cd hosted-chat-client-agent
|
||||
azd provision
|
||||
|
||||
### 3. Run the container
|
||||
|
||||
Generate a bearer token on your host and pass it to the container:
|
||||
|
||||
```bash
|
||||
# Generate token (expires in ~1 hour)
|
||||
export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv)
|
||||
|
||||
# Run with token
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-chat-client-agent \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
--env-file .env \
|
||||
hosted-chat-client-agent
|
||||
```
|
||||
|
||||
> **Note:** `AGENT_NAME` is passed via `-e` to simulate the platform injection. `AZURE_BEARER_TOKEN` provides Azure credentials to the container (tokens expire after ~1 hour). The `.env` file provides the remaining configuration.
|
||||
|
||||
### 4. Test it
|
||||
|
||||
Using the Azure Developer CLI:
|
||||
|
||||
```bash
|
||||
azd ai agent invoke --local "Hello!"
|
||||
```
|
||||
|
||||
Or with curl (specifying the agent name explicitly):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"input": "Hello!", "model": "hosted-chat-client-agent"}'
|
||||
```
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
This sample includes an `azd` manifest (`agent.manifest.yaml`) and hosted agent spec (`agent.yaml`) for deployment to Foundry.
|
||||
|
||||
Initialize an `azd` project from this sample's manifest:
|
||||
|
||||
```bash
|
||||
mkdir hosted-chat-client-agent && cd hosted-chat-client-agent
|
||||
azd ai agent init -m https://github.com/microsoft/agent-framework/blob/main/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ChatClientAgent/agent.manifest.yaml
|
||||
```
|
||||
|
||||
Then deploy:
|
||||
|
||||
```bash
|
||||
azd deploy
|
||||
azd ai agent invoke "Hello!"
|
||||
```
|
||||
|
||||
`azd` packages the source into a ZIP (honoring `.agentignore`), uploads it, and Foundry runs
|
||||
`dotnet restore` + `dotnet publish` on it during provisioning (`dependencyResolution: remote_build`
|
||||
in `azure.yaml`). No Dockerfile, no container registry.
|
||||
|
||||
You can also test the deployed agent with the REPL:
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent"
|
||||
dotnet run -- --remote
|
||||
```
|
||||
|
||||
Bash:
|
||||
If you need to override defaults, set deployment-time environment variables in the `azd` environment before deploying:
|
||||
|
||||
```bash
|
||||
cd <repo>/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://<your-account>.services.ai.azure.com/api/projects/<your-project>"
|
||||
export AZURE_AI_AGENT_NAME="hosted-chat-client-agent"
|
||||
dotnet run -- --remote
|
||||
azd env set AGENT_NAME hosted-chat-client-agent
|
||||
azd env set AZURE_AI_MODEL_DEPLOYMENT_NAME gpt-4o
|
||||
```
|
||||
|
||||
### Step 4: clean up
|
||||
For end-to-end hosted agent deployment guidance, see the [official deployment guide](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent).
|
||||
|
||||
```
|
||||
azd down
|
||||
```
|
||||
## NuGet package users
|
||||
|
||||
Then delete the working directory.
|
||||
|
||||
## Deploy your local framework changes (contributors)
|
||||
|
||||
**Skip this section unless you are changing the Agent Framework itself.** Everything above is the
|
||||
complete flow for using the sample. This section only applies when you are working on the framework
|
||||
source in this repository, or when you otherwise need a build of it that is not published on
|
||||
nuget.org.
|
||||
|
||||
The reason it exists: the project restores the **published** Agent Framework packages, and Foundry
|
||||
restores from nuget.org when it builds the upload. So editing framework source in this repository
|
||||
changes nothing about the deployed agent, no matter how many times you rebuild locally. The
|
||||
uploaded folder is self-contained and knows nothing about your working tree.
|
||||
|
||||
The extra step packs your local framework source into NuGet packages and puts them **inside the
|
||||
upload**, together with a `nuget.config` that points the restore at them. The server-side restore
|
||||
then resolves the framework from the packages you shipped instead of from nuget.org.
|
||||
|
||||
Run it in the flow above, **between step 2 and step 3**. Nothing else changes.
|
||||
|
||||
Run it from `$work`, the working directory created in step 1, which now holds the
|
||||
`hosted-chat-client-agent` folder that `azd ai agent init` scaffolded:
|
||||
|
||||
PowerShell:
|
||||
|
||||
```powershell
|
||||
cd $work
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent
|
||||
```
|
||||
|
||||
Bash:
|
||||
|
||||
```bash
|
||||
cd "$WORK"
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/add-local-framework-feed.sh ./hosted-chat-client-agent
|
||||
```
|
||||
|
||||
Then continue with step 3. The path argument is optional: called without it, the script uses the
|
||||
current directory, so you can also run it from inside `hosted-chat-client-agent`.
|
||||
|
||||
The script changes three things in the scaffolded folder:
|
||||
|
||||
| Change | Detail |
|
||||
|--------|--------|
|
||||
| Creates `local-feed/` | The Agent Framework packed from your local source, stamped with a version like `1.15.0-preview-local.<timestamp>` |
|
||||
| Creates `nuget.config` | Resolves `Microsoft.Agents.AI*` from that folder and everything else from nuget.org |
|
||||
| Edits the `.csproj` | Repoints its `AgentFrameworkVersion` property at the version just packed |
|
||||
|
||||
Both generated files ship inside the ZIP, so the server-side restore resolves the framework from
|
||||
the upload. The scaffolded folder is a throwaway copy, so the repository is left untouched.
|
||||
|
||||
Two details worth knowing:
|
||||
|
||||
- The version carries a timestamp because NuGet caches by package id and version. Reusing a version
|
||||
would silently restore the previously packed bits instead of the build you just made.
|
||||
- The whole package closure is packed, not just the two packages the sample references. Packing
|
||||
only the leaf packages lets NuGet fill the rest from nuget.org, mixing a published core with a
|
||||
locally built host, which fails to compile.
|
||||
|
||||
Before spending a deploy, build the scaffolded folder locally. A restore problem surfaces in
|
||||
seconds instead of after the server-side build:
|
||||
|
||||
```
|
||||
cd hosted-chat-client-agent
|
||||
dotnet build -c Debug --tl:off
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
For the full hosted-agent deployment guide, see the [official source-code deployment doc](https://learn.microsoft.com/en-us/azure/foundry/agents/how-to/deploy-hosted-agent-code).
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor` — it performs a full `dotnet restore` and `dotnet publish` inside the container. See the commented section in `HostedChatClientAgent.csproj` for the `PackageReference` alternative.
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/AgentManifest.yaml
|
||||
name: hosted-chat-client-agent
|
||||
displayName: "Hosted Chat Client Agent"
|
||||
|
||||
description: >
|
||||
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent
|
||||
using the Agent Framework instance hosting pattern.
|
||||
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
|
||||
template:
|
||||
name: hosted-chat-client-agent
|
||||
kind: hosted
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
parameters:
|
||||
properties: []
|
||||
resources: []
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/microsoft/AgentSchema/refs/heads/main/schemas/v1.0/ContainerAgent.yaml
|
||||
kind: hosted
|
||||
name: hosted-chat-client-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
resources:
|
||||
cpu: "0.25"
|
||||
memory: 0.5Gi
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json
|
||||
|
||||
name: hosted-chat-client-agent
|
||||
services:
|
||||
ai-project:
|
||||
host: azure.ai.project
|
||||
hosted-chat-client-agent:
|
||||
project: .
|
||||
host: azure.ai.agent
|
||||
language: csharp
|
||||
uses:
|
||||
- ai-project
|
||||
codeConfiguration:
|
||||
dependencyResolution: remote_build
|
||||
entryPoint: HostedChatClientAgent.dll
|
||||
runtime: dotnet_10
|
||||
# ASPNETCORE_URLS pins the listen port. Source deploy runs this project as a plain ASP.NET
|
||||
# app, and the .NET base image defaults it to port 80, while Foundry probes port 8088 for
|
||||
# readiness, so without it every invoke fails with HTTP 424 session_not_ready. Recent
|
||||
# Microsoft.Agents.AI.Foundry.Hosting versions bind the port themselves and take precedence
|
||||
# over this value, so it only matters when the project is pinned to an older package.
|
||||
#
|
||||
# ${AZURE_AI_MODEL_DEPLOYMENT_NAME} reads the model deployment `azd ai agent init` recorded
|
||||
# in the active azd environment. Without it the container falls back to the default model
|
||||
# name hardcoded in Program.cs, which may not exist in the target project.
|
||||
env:
|
||||
ASPNETCORE_URLS: http://+:8088
|
||||
AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
|
||||
container:
|
||||
resources:
|
||||
cpu: "0.5"
|
||||
memory: 1Gi
|
||||
description: |
|
||||
A simple general-purpose AI assistant hosted as a Foundry Hosted Agent using the Agent Framework instance hosting pattern.
|
||||
kind: hosted
|
||||
metadata:
|
||||
tags:
|
||||
- AI Agent Hosting
|
||||
- Azure AI AgentServer
|
||||
- Responses Protocol
|
||||
- Streaming
|
||||
- Agent Framework
|
||||
name: hosted-chat-client-agent
|
||||
protocols:
|
||||
- protocol: responses
|
||||
version: 2.0.0
|
||||
@@ -145,17 +145,3 @@ If you are consuming the Agent Framework as a NuGet package (not building from s
|
||||
| **Model/instructions** | Set in `Program.cs` | Set in Foundry UI/CLI/API |
|
||||
| **Tools** | Defined in code | Configured in the platform |
|
||||
| **Use case** | Full control over agent behavior | Platform-managed agent with centralized config |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
@@ -157,17 +157,3 @@ If you are consuming the Agent Framework as a NuGet package (not building from
|
||||
source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See
|
||||
the commented section in `HostedLocalCodeAct.csproj` for the `PackageReference`
|
||||
alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
@@ -139,17 +139,3 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedLocalTools.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
@@ -115,16 +115,3 @@ Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the comme
|
||||
- [`Hosted-Toolbox/`](../Hosted-Toolbox/) — connects to a single Foundry Toolbox via the AF Foundry hosting bridge (`AddFoundryToolboxes` + `FoundryAITool.CreateHostedMcpToolbox`).
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as `Hosted-Toolbox/`, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
-14
@@ -135,17 +135,3 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
If consuming the Agent Framework as a NuGet package, use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedObservability.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
@@ -142,17 +142,3 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedTextRag.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
-1
@@ -190,7 +190,6 @@ Send a test email to myself. # path #4 —
|
||||
| **HTTP 404 from a tool call** | Toolbox name mismatch (`TOOLBOX_NAME` vs the name in the portal), or the toolbox was deleted. |
|
||||
| **Server logs a warning "Neither FOUNDRY_PROJECT_ENDPOINT nor AZURE_AI_PROJECT_ENDPOINT is set; toolbox support is disabled"** | Local dev without the env var set. The agent will load with zero tools and respond as if it has none. Set `AZURE_AI_PROJECT_ENDPOINT` (local-dev fallback) or `FOUNDRY_PROJECT_ENDPOINT` to your project endpoint. |
|
||||
| **Tools appear but model never invokes them** | `instructions:` in `Program.cs` may not surface what each tool is for. Tighten the `allowed_tools` lists and rephrase prompts to mention the upstream service by name. |
|
||||
| **`azd ai agent invoke` returns `404 not_found: Conversation '<id>' not found`** | `azd` saves the session and conversation per agent and reuses them on the next invoke. Once the agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server. Pass `--new-conversation` (and `--new-session` if it persists) to start a fresh one. |
|
||||
|
||||
## Region and model compatibility
|
||||
|
||||
|
||||
@@ -109,17 +109,3 @@ Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the comme
|
||||
|
||||
- [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/) — same hosting bones as this sample, but the toolbox bundles three MCP tools each authenticated differently (key, Entra agent identity, inline `Authorization`), driven by the shared `Using-Samples/SimpleAgent/` REPL.
|
||||
- [`Hosted-McpTools/`](../Hosted-McpTools/) — contrasts client-side `McpClient` vs server-side `HostedMcpServerTool` for non-toolbox MCP servers.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
-14
@@ -129,17 +129,3 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
If you are consuming the Agent Framework as a NuGet package (not building from source), use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedToolboxMcpSkills.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
-14
@@ -152,17 +152,3 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowHandoff.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
-14
@@ -135,17 +135,3 @@ For end-to-end hosted agent deployment guidance, see the [official deployment gu
|
||||
## NuGet package users
|
||||
|
||||
Use the standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented section in `HostedWorkflowSimple.csproj` for the `PackageReference` alternative.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
@@ -28,17 +28,10 @@ just a matter of changing `AZURE_AI_AGENT_NAME`.
|
||||
|
||||
## Local HTTP dev
|
||||
|
||||
`AIProjectClient` authenticates with a bearer token, and the client pipeline refuses to attach one
|
||||
to a plain `http://` endpoint, failing with `InvalidOperationException: Bearer token authentication
|
||||
is not permitted for non TLS protected (https) endpoints.` before the request is even sent. To
|
||||
target a local dev server over HTTP, the REPLs install a small `HttpSchemeRewritePolicy`: the
|
||||
client is pointed at an `https://` URI to satisfy that check, and the policy puts the scheme back
|
||||
to `http://` right before the request hits the wire. This is local-development only.
|
||||
|
||||
`SimpleAgent` applies it only on the Foundry path, and only when `FOUNDRY_PROJECT_ENDPOINT` is an
|
||||
`http://` URL. Its `--local` path needs nothing of the sort: it points an `OpenAIClient` at the
|
||||
server's standard `POST /responses` route with an api key, which carries no bearer token and so
|
||||
never hits the TLS check.
|
||||
When the target is a local `http://localhost:8088` dev server, the REPLs install a small
|
||||
`HttpSchemeRewritePolicy`: `AIProjectClient`/`BearerTokenPolicy` require HTTPS, so the client
|
||||
presents the endpoint as `https://` to satisfy the TLS check, then rewrites the scheme back to
|
||||
`http://` right before the request hits the wire. This is local-development only.
|
||||
|
||||
## The clients
|
||||
|
||||
|
||||
+28
-81
@@ -1,30 +1,43 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ClientModel;
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using DotNetEnv;
|
||||
using Microsoft.Agents.AI;
|
||||
using OpenAI;
|
||||
using OpenAI.Responses;
|
||||
using Microsoft.Agents.AI.Foundry;
|
||||
|
||||
// Load .env file if present (for local development)
|
||||
Env.TraversePath().Load();
|
||||
|
||||
// Port the Hosted-* samples listen on when run locally with `dotnet run`.
|
||||
const int LocalAgentPort = 8088;
|
||||
// FOUNDRY_PROJECT_ENDPOINT is the Foundry project endpoint. Shape:
|
||||
// https://<host>/api/projects/<project>
|
||||
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
// AZURE_AI_AGENT_NAME is the registered server-side agent name.
|
||||
string agentName = Environment.GetEnvironmentVariable("AZURE_AI_AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AZURE_AI_AGENT_NAME is not set.");
|
||||
|
||||
// Pick the server to talk to. `--local` and `--remote` mirror the flag `azd ai agent invoke`
|
||||
// exposes; with neither, ask at startup.
|
||||
bool useLocalAgent = ResolveTarget(args);
|
||||
// Derive the per-agent OpenAI endpoint that hosted Foundry agents require.
|
||||
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
|
||||
|
||||
AIAgent agent = useLocalAgent ? CreateLocalAgent() : CreateHostedAgent(agentName);
|
||||
string target = useLocalAgent ? $"http://localhost:{LocalAgentPort}" : agentName;
|
||||
// ── Create an agent-framework agent backed by the remote agent endpoint ──────
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (projectEndpoint.Scheme == "http")
|
||||
{
|
||||
// For local HTTP dev: tell AIProjectClient the endpoint is HTTPS (to satisfy
|
||||
// BearerTokenPolicy's TLS check), then swap the scheme back to HTTP right
|
||||
// before the request hits the wire.
|
||||
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = "https" }.Uri;
|
||||
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = "https" }.Uri;
|
||||
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
|
||||
}
|
||||
|
||||
var aiProjectClient = new AIProjectClient(projectEndpoint, new AzureCliCredential(), options);
|
||||
FoundryAgent agent = aiProjectClient.AsAIAgent(agentEndpoint);
|
||||
|
||||
AgentSession session = await agent.CreateSessionAsync();
|
||||
|
||||
@@ -34,7 +47,7 @@ Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine($"""
|
||||
══════════════════════════════════════════════════════════
|
||||
Simple Agent Sample
|
||||
Connected to: {target}
|
||||
Connected to: {agentEndpoint}
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
""");
|
||||
@@ -77,76 +90,10 @@ while (true)
|
||||
|
||||
Console.WriteLine("Goodbye!");
|
||||
|
||||
// Returns true when the client should target a locally running agent. `--local` and `--remote`
|
||||
// answer the question up front, which is what non-interactive runs need; with neither, ask.
|
||||
static bool ResolveTarget(string[] args)
|
||||
{
|
||||
if (args.Contains("--local", StringComparer.OrdinalIgnoreCase)) { return true; }
|
||||
if (args.Contains("--remote", StringComparer.OrdinalIgnoreCase)) { return false; }
|
||||
|
||||
return PromptForLocalTarget();
|
||||
}
|
||||
|
||||
// Asks whether to target a locally running agent or the one deployed to Foundry, and returns
|
||||
// true for local. Defaults to remote on an empty answer, matching `azd ai agent invoke`, which
|
||||
// targets Foundry unless --local is passed.
|
||||
static bool PromptForLocalTarget()
|
||||
{
|
||||
Console.ForegroundColor = ConsoleColor.Cyan;
|
||||
Console.WriteLine("Which agent do you want to chat with?");
|
||||
Console.ResetColor();
|
||||
Console.WriteLine(" [1] Foundry (deployed agent) [default]");
|
||||
Console.WriteLine($" [2] Local (dotnet run, http://localhost:{LocalAgentPort})");
|
||||
Console.Write("Choice: ");
|
||||
|
||||
string? choice = Console.ReadLine()?.Trim();
|
||||
Console.WriteLine();
|
||||
|
||||
return choice is "2";
|
||||
}
|
||||
|
||||
// Builds an agent against a Hosted-* sample running locally. The sample serves the standard
|
||||
// Responses route (POST /responses), so an OpenAI responses client pointed at the server reaches
|
||||
// it directly. The server hosts its own agent and ignores both the model id and the api key, but
|
||||
// the SDK requires them to shape the request.
|
||||
static AIAgent CreateLocalAgent()
|
||||
{
|
||||
var options = new OpenAIClientOptions { Endpoint = new Uri($"http://localhost:{LocalAgentPort}") };
|
||||
|
||||
return new OpenAIClient(new ApiKeyCredential("not-needed"), options)
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(model: "hosted-agent", name: "LocalHostedAgent");
|
||||
}
|
||||
|
||||
// Builds an agent against an agent deployed to Foundry. Hosted agents are reached through their
|
||||
// per-agent endpoint, which the platform routes to the container's /responses route.
|
||||
static AIAgent CreateHostedAgent(string agentName)
|
||||
{
|
||||
Uri projectEndpoint = new(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
Uri agentEndpoint = new($"{projectEndpoint}/agents/{agentName}/endpoint/protocols/openai");
|
||||
|
||||
var options = new AIProjectClientOptions();
|
||||
|
||||
if (projectEndpoint.Scheme == Uri.UriSchemeHttp)
|
||||
{
|
||||
// For local HTTP dev: the client pipeline refuses to attach a bearer token to a plain
|
||||
// HTTP endpoint, so point the client at an https:// URI to satisfy that check, then swap
|
||||
// the scheme back to http:// right before the request hits the wire.
|
||||
projectEndpoint = new UriBuilder(projectEndpoint) { Scheme = Uri.UriSchemeHttps }.Uri;
|
||||
agentEndpoint = new UriBuilder(agentEndpoint) { Scheme = Uri.UriSchemeHttps }.Uri;
|
||||
options.AddPolicy(new HttpSchemeRewritePolicy(), PipelinePosition.BeforeTransport);
|
||||
}
|
||||
|
||||
return new AIProjectClient(projectEndpoint, new AzureCliCredential(), options).AsAIAgent(agentEndpoint);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// For Local Development Only.
|
||||
/// Rewrites HTTPS URIs to HTTP right before transport, allowing <see cref="AIProjectClient"/> to
|
||||
/// target a local HTTP dev server while satisfying the pipeline's TLS check: bearer tokens are
|
||||
/// only attached to TLS-protected endpoints, so a plain http:// endpoint is rejected outright.
|
||||
/// For Local Development Only
|
||||
/// Rewrites HTTPS URIs to HTTP right before transport, allowing AIProjectClient
|
||||
/// to target a local HTTP dev server while satisfying BearerTokenPolicy's TLS check.
|
||||
/// </summary>
|
||||
internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
|
||||
{
|
||||
@@ -167,7 +114,7 @@ internal sealed class HttpSchemeRewritePolicy : PipelinePolicy
|
||||
var uri = message.Request.Uri!;
|
||||
if (uri.Scheme == Uri.UriSchemeHttps)
|
||||
{
|
||||
message.Request.Uri = new UriBuilder(uri) { Scheme = Uri.UriSchemeHttp }.Uri;
|
||||
message.Request.Uri = new UriBuilder(uri) { Scheme = "http" }.Uri;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+15
-50
@@ -1,7 +1,8 @@
|
||||
# SimpleAgent
|
||||
|
||||
A generic, agent-agnostic chat REPL for any hosted Foundry agent. Point it at a running
|
||||
`Hosted-*` agent and it streams replies. This is the shared client that `Hosted-Toolbox`,
|
||||
`Hosted-*` agent via `AZURE_AI_AGENT_NAME`, and it builds a `FoundryAgent` against that agent's
|
||||
per-agent OpenAI endpoint and streams replies. This is the shared client that `Hosted-Toolbox`,
|
||||
`Hosted-Toolbox-AuthPaths`, and `Hosted-McpTools` reference for their end-to-end demos.
|
||||
|
||||
It knows nothing about the agent's tools, toolboxes, files, or auth — those are entirely the
|
||||
@@ -17,51 +18,29 @@ See [`../README.md`](../README.md) for why these client REPLs exist at all.
|
||||
## Configuration
|
||||
|
||||
```env
|
||||
AZURE_AI_AGENT_NAME=<registered-server-side-agent-name>
|
||||
FOUNDRY_PROJECT_ENDPOINT=https://<host>/api/projects/<project>
|
||||
AZURE_AI_AGENT_NAME=<registered-server-side-agent-name>
|
||||
```
|
||||
|
||||
`AZURE_AI_AGENT_NAME` is always required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project
|
||||
endpoint URL, required only when you target the deployed agent.
|
||||
Both are required. `FOUNDRY_PROJECT_ENDPOINT` is the Foundry project endpoint URL and
|
||||
`AZURE_AI_AGENT_NAME` is the registered server-side agent name. The sample builds the per-agent
|
||||
OpenAI endpoint URL (`{FOUNDRY_PROJECT_ENDPOINT}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`)
|
||||
from these.
|
||||
|
||||
## Run
|
||||
|
||||
Against a local Hosted-Toolbox agent listening on `http://localhost:8088`:
|
||||
|
||||
```powershell
|
||||
cd dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/SimpleAgent
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-chat-client-agent"
|
||||
$env:FOUNDRY_PROJECT_ENDPOINT = "http://localhost:8088/api/projects/local"
|
||||
$env:AZURE_AI_AGENT_NAME = "hosted-toolbox-agent"
|
||||
dotnet run
|
||||
```
|
||||
|
||||
On startup the client asks which agent to chat with:
|
||||
|
||||
```text
|
||||
Which agent do you want to chat with?
|
||||
[1] Foundry (deployed agent) [default]
|
||||
[2] Local (dotnet run, http://localhost:8088)
|
||||
Choice:
|
||||
```
|
||||
|
||||
Pass `--local` or `--remote` to answer up front and skip the prompt, which is what scripted runs
|
||||
need:
|
||||
|
||||
```
|
||||
dotnet run -- --local
|
||||
dotnet run -- --remote
|
||||
```
|
||||
|
||||
This mirrors the `--local` flag on `azd ai agent invoke`. Use local while a `Hosted-*` sample is
|
||||
running with `dotnet run`; use remote to reach the agent deployed to Foundry.
|
||||
|
||||
The two choices differ only in how the agent is built:
|
||||
|
||||
| Target | How the client reaches it |
|
||||
|--------|---------------------------|
|
||||
| Local | An `OpenAIClient` pointed at `http://localhost:8088`, then `GetResponsesClient().AsAIAgent(...)`. That hits the standard `POST /responses` route the local server already serves. The model id and api key are placeholders: the server runs its own agent and ignores both, but the SDK requires them to shape the request. |
|
||||
| Foundry | An `AIProjectClient` plus the agent's per-agent endpoint (`{projectEndpoint}/agents/{AZURE_AI_AGENT_NAME}/endpoint/protocols/openai`), which the platform routes to the container's `/responses` route. |
|
||||
|
||||
The Foundry path also works against a local server that maps the per-agent route: set
|
||||
`FOUNDRY_PROJECT_ENDPOINT` to an `http://` URL and the client installs a scheme-rewrite policy so
|
||||
the bearer-token pipeline accepts it. See [Local HTTP dev](../README.md#local-http-dev).
|
||||
When the project endpoint is `http://`, the client presents it as `https://` to satisfy the
|
||||
bearer-token TLS check, then rewrites the scheme back to `http://` right before transport
|
||||
(local-development only).
|
||||
|
||||
## End-to-end demo
|
||||
|
||||
@@ -70,7 +49,7 @@ With a hosted agent running:
|
||||
```text
|
||||
══════════════════════════════════════════════════════════
|
||||
Simple Agent Sample
|
||||
Connected to: http://localhost:8088
|
||||
Connected to: https://localhost:8088/api/projects/local/agents/hosted-toolbox-agent/endpoint/protocols/openai
|
||||
Type a message or 'quit' to exit
|
||||
══════════════════════════════════════════════════════════
|
||||
|
||||
@@ -82,17 +61,3 @@ Goodbye!
|
||||
```
|
||||
|
||||
The client only sent a chat prompt; the agent resolved its toolbox tools server-side and answered.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**`azd ai agent invoke` fails with `404 not_found: Conversation '<id>' not found`**
|
||||
|
||||
`azd` saves the session and conversation per agent and reuses them on the next invoke. Once the
|
||||
agent is redeployed, deleted, or restarted, that saved conversation no longer exists on the server,
|
||||
so every following invoke fails even though the agent itself is healthy. Start a fresh one:
|
||||
|
||||
```
|
||||
azd ai agent invoke --new-conversation "Hello!"
|
||||
```
|
||||
|
||||
Add `--new-session` as well if the failure persists.
|
||||
|
||||
+1
-2
@@ -13,15 +13,14 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.Projects" />
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="OpenAI" />
|
||||
<PackageReference Include="System.ClientModel" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
#requires -Version 7
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Rewires an already-scaffolded hosted-agent folder to build against the local Agent Framework
|
||||
source, so `azd deploy` ships your framework changes instead of the published packages.
|
||||
.DESCRIPTION
|
||||
Source (ZIP) deploy uploads the agent folder and Foundry runs `dotnet restore` + `dotnet publish`
|
||||
on it in the cloud. That restore pulls the Agent Framework from nuget.org, so a contributor's
|
||||
local framework changes are never exercised.
|
||||
|
||||
Run this after `azd ai agent init` and before `azd provision`. It changes two things in the
|
||||
folder that `init` scaffolded:
|
||||
|
||||
local-feed/ New. The Agent Framework packed from the local source tree, stamped with a
|
||||
version derived from the repo's current VersionPrefix plus a `-preview-local`
|
||||
suffix. The whole closure is packed: packing only the leaf packages lets NuGet
|
||||
fill the rest from nuget.org, mixing a published core with a locally built host.
|
||||
nuget.config New. Maps Microsoft.Agents.AI* to that folder feed and everything else to
|
||||
nuget.org.
|
||||
the .csproj Edited. Its AgentFrameworkVersion property is repointed at the version just
|
||||
packed.
|
||||
|
||||
Neither generated file is excluded by `.agentignore`, so they travel inside the ZIP and the
|
||||
server-side restore uses them.
|
||||
|
||||
Everything else stays identical to the end-user flow: you create the working directory, run
|
||||
`azd ai agent init`, and finish with `azd provision`, `azd deploy`, and `azd ai agent invoke`.
|
||||
The scaffolded folder is a throwaway copy, so editing its project file leaves the repository
|
||||
untouched.
|
||||
.PARAMETER Path
|
||||
The folder `azd ai agent init` scaffolded, for example `./hosted-chat-client-agent`.
|
||||
Defaults to the current directory.
|
||||
.EXAMPLE
|
||||
# From the working directory, after azd ai agent init created ./hosted-chat-client-agent
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1 -Path ./hosted-chat-client-agent
|
||||
.EXAMPLE
|
||||
# From inside the scaffolded folder
|
||||
cd hosted-chat-client-agent
|
||||
<repo>/dotnet/samples/04-hosting/FoundryHostedAgents/scripts/Add-LocalFrameworkFeed.ps1
|
||||
.NOTES
|
||||
For contributors validating framework changes end to end. End users skip this script entirely and
|
||||
get the published packages.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Path = '.'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The Agent Framework closure the hosted samples resolve. Packing only the leaf packages makes
|
||||
# NuGet satisfy the rest from nuget.org, producing assembly-reference errors at build time.
|
||||
$frameworkProjects = @(
|
||||
'Microsoft.Agents.AI.Abstractions'
|
||||
'Microsoft.Agents.AI'
|
||||
'Microsoft.Agents.AI.Workflows'
|
||||
'Microsoft.Agents.AI.Foundry'
|
||||
'Microsoft.Agents.AI.Foundry.Hosting'
|
||||
)
|
||||
|
||||
$target = (Resolve-Path $Path).Path
|
||||
|
||||
if (-not (Test-Path (Join-Path $target 'azure.yaml'))) {
|
||||
throw "No azure.yaml in '$target'. Point -Path at the folder 'azd ai agent init' scaffolded."
|
||||
}
|
||||
|
||||
$projectFile = Get-ChildItem $target -Filter *.csproj -File | Select-Object -First 1
|
||||
if (-not $projectFile) {
|
||||
throw "No .csproj in '$target'. This script targets .NET hosted agents."
|
||||
}
|
||||
|
||||
if (-not (Select-String -Path $projectFile.FullName -Pattern '<AgentFrameworkVersion>' -Quiet)) {
|
||||
throw "$($projectFile.Name) has no <AgentFrameworkVersion> property to repoint at a local build."
|
||||
}
|
||||
|
||||
$hostedRoot = Split-Path -Parent $PSScriptRoot
|
||||
$dotnetRoot = (Resolve-Path (Join-Path $hostedRoot '..' '..' '..')).Path
|
||||
$srcRoot = Join-Path $dotnetRoot 'src'
|
||||
|
||||
# Derive the package version from the repo so the packages track the current release line.
|
||||
# The timestamp keeps every run unique: NuGet caches by id and version, so reusing a version would
|
||||
# silently restore the previously packed bits instead of the build you just made. It also changes
|
||||
# the ZIP contents on every run, which matters because Foundry mints a new agent version only when
|
||||
# the uploaded ZIP changes.
|
||||
$packagePropsPath = Join-Path $dotnetRoot 'nuget' 'nuget-package.props'
|
||||
$versionMatch = Select-String -Path $packagePropsPath -Pattern '<VersionPrefix>(.+?)</VersionPrefix>' | Select-Object -First 1
|
||||
if (-not $versionMatch) {
|
||||
throw "Could not read <VersionPrefix> from $packagePropsPath."
|
||||
}
|
||||
|
||||
$versionPrefix = $versionMatch.Matches[0].Groups[1].Value
|
||||
$version = "$versionPrefix-preview-local.$(Get-Date -Format 'yyyyMMddHHmmss')"
|
||||
|
||||
$feedPath = Join-Path $target 'local-feed'
|
||||
if (Test-Path $feedPath) { Remove-Item $feedPath -Recurse -Force }
|
||||
New-Item -ItemType Directory -Path $feedPath -Force | Out-Null
|
||||
|
||||
Write-Host "Wiring $(Split-Path -Leaf $target) to the local Agent Framework" -ForegroundColor Cyan
|
||||
Write-Host " version: $version"
|
||||
Write-Host ''
|
||||
|
||||
foreach ($project in $frameworkProjects) {
|
||||
$projectPath = Join-Path $srcRoot $project "$project.csproj"
|
||||
Write-Host "Packing $project..."
|
||||
|
||||
# Debug, not Release: the Release configuration runs the repo's formatting and analyzer passes,
|
||||
# which rewrite source files and fail the build on style violations. Packing only needs runnable
|
||||
# binaries, so Debug keeps the working tree untouched.
|
||||
#
|
||||
# PackageVersion (not Version) is the property the repo's packaging props use to stamp both the
|
||||
# package version and its dependency ranges, so the packed packages reference each other at this
|
||||
# version instead of the bare VersionPrefix.
|
||||
dotnet build $projectPath -c Debug -p:PackageVersion=$version --tl:off | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Build failed for $project." }
|
||||
|
||||
dotnet pack $projectPath -c Debug --no-build -o $feedPath -p:PackageVersion=$version --tl:off | Out-Null
|
||||
if ($LASTEXITCODE -ne 0) { throw "Pack failed for $project." }
|
||||
}
|
||||
|
||||
$utf8NoBom = [System.Text.UTF8Encoding]::new($false)
|
||||
|
||||
$nugetConfig = @'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by Add-LocalFrameworkFeed.ps1: resolves the Agent Framework from this upload. -->
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="local-feed" value="./local-feed" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="local-feed">
|
||||
<package pattern="Microsoft.Agents.AI*" />
|
||||
</packageSource>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
'@
|
||||
[System.IO.File]::WriteAllText((Join-Path $target 'nuget.config'), ($nugetConfig -replace "`r`n", "`n"), $utf8NoBom)
|
||||
|
||||
# The scaffolded copy is disposable, so repointing its project file at the local build is safe and
|
||||
# keeps the checked-in sample free of contributor-only scaffolding. Reruns are safe: the pattern
|
||||
# matches whatever version is currently there.
|
||||
$projectXml = [System.IO.File]::ReadAllText($projectFile.FullName)
|
||||
$projectXml = $projectXml -replace '(?<open><AgentFrameworkVersion>)[^<]*(?<close></AgentFrameworkVersion>)', "`${open}$version`${close}"
|
||||
[System.IO.File]::WriteAllText($projectFile.FullName, $projectXml, [System.Text.UTF8Encoding]::new($true))
|
||||
|
||||
Write-Host ''
|
||||
Write-Host 'Done. Continue with the standard flow:' -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Write-Host " cd `"$target`""
|
||||
Write-Host ' azd provision'
|
||||
Write-Host ' azd deploy'
|
||||
Write-Host ' azd ai agent invoke "Hello!"'
|
||||
@@ -1,152 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Rewires an already-scaffolded hosted-agent folder to build against the local Agent Framework
|
||||
# source, so `azd deploy` ships your framework changes instead of the published packages.
|
||||
#
|
||||
# Source (ZIP) deploy uploads the agent folder and Foundry runs `dotnet restore` + `dotnet publish`
|
||||
# on it in the cloud. That restore pulls the Agent Framework from nuget.org, so a contributor's
|
||||
# local framework changes are never exercised.
|
||||
#
|
||||
# Run this after `azd ai agent init` and before `azd provision`. It changes two things in the
|
||||
# folder that `init` scaffolded:
|
||||
#
|
||||
# local-feed/ New. The Agent Framework packed from the local source tree, stamped with a
|
||||
# version derived from the repo's current VersionPrefix plus a `-preview-local`
|
||||
# suffix. The whole closure is packed: packing only the leaf packages lets NuGet
|
||||
# fill the rest from nuget.org, mixing a published core with a locally built host.
|
||||
# nuget.config New. Maps Microsoft.Agents.AI* to that folder feed and everything else to
|
||||
# nuget.org.
|
||||
# the .csproj Edited. Its AgentFrameworkVersion property is repointed at the version just
|
||||
# packed.
|
||||
#
|
||||
# Neither generated file is excluded by `.agentignore`, so they travel inside the ZIP and the
|
||||
# server-side restore uses them.
|
||||
#
|
||||
# Everything else stays identical to the end-user flow: you create the working directory, run
|
||||
# `azd ai agent init`, and finish with `azd provision`, `azd deploy`, and `azd ai agent invoke`.
|
||||
# The scaffolded folder is a throwaway copy, so editing its project file leaves the repository
|
||||
# untouched.
|
||||
#
|
||||
# Usage:
|
||||
# add-local-framework-feed.sh [path-to-scaffolded-folder]
|
||||
#
|
||||
# The path defaults to the current directory.
|
||||
#
|
||||
# This is the bash counterpart of Add-LocalFrameworkFeed.ps1. For contributors validating framework
|
||||
# changes end to end. End users skip this script entirely and get the published packages.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# The Agent Framework closure the hosted samples resolve. Packing only the leaf packages makes
|
||||
# NuGet satisfy the rest from nuget.org, producing assembly-reference errors at build time.
|
||||
framework_projects=(
|
||||
Microsoft.Agents.AI.Abstractions
|
||||
Microsoft.Agents.AI
|
||||
Microsoft.Agents.AI.Workflows
|
||||
Microsoft.Agents.AI.Foundry
|
||||
Microsoft.Agents.AI.Foundry.Hosting
|
||||
)
|
||||
|
||||
target_input="${1:-.}"
|
||||
|
||||
if [[ ! -d "$target_input" ]]; then
|
||||
echo "Error: '$target_input' is not a directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
target="$(cd "$target_input" && pwd)"
|
||||
|
||||
if [[ ! -f "$target/azure.yaml" ]]; then
|
||||
echo "Error: no azure.yaml in '$target'. Point the path at the folder 'azd ai agent init' scaffolded." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
project_file="$(find "$target" -maxdepth 1 -name '*.csproj' | head -n 1)"
|
||||
|
||||
if [[ -z "$project_file" ]]; then
|
||||
echo "Error: no .csproj in '$target'. This script targets .NET hosted agents." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q '<AgentFrameworkVersion>' "$project_file"; then
|
||||
echo "Error: $(basename "$project_file") has no <AgentFrameworkVersion> property to repoint at a local build." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
hosted_root="$(dirname "$script_dir")"
|
||||
dotnet_root="$(cd "$hosted_root/../../.." && pwd)"
|
||||
src_root="$dotnet_root/src"
|
||||
|
||||
# Derive the package version from the repo so the packages track the current release line.
|
||||
# The timestamp keeps every run unique: NuGet caches by id and version, so reusing a version would
|
||||
# silently restore the previously packed bits instead of the build you just made. It also changes
|
||||
# the ZIP contents on every run, which matters because Foundry mints a new agent version only when
|
||||
# the uploaded ZIP changes.
|
||||
package_props="$dotnet_root/nuget/nuget-package.props"
|
||||
version_prefix="$(sed -n 's/.*<VersionPrefix>\([^<]*\)<\/VersionPrefix>.*/\1/p' "$package_props" | head -n 1)"
|
||||
|
||||
if [[ -z "$version_prefix" ]]; then
|
||||
echo "Error: could not read VersionPrefix from $package_props." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version="$version_prefix-preview-local.$(date +%Y%m%d%H%M%S)"
|
||||
|
||||
feed_path="$target/local-feed"
|
||||
rm -rf "$feed_path"
|
||||
mkdir -p "$feed_path"
|
||||
|
||||
echo "Wiring $(basename "$target") to the local Agent Framework"
|
||||
echo " version: $version"
|
||||
echo
|
||||
|
||||
for project in "${framework_projects[@]}"; do
|
||||
project_path="$src_root/$project/$project.csproj"
|
||||
echo "Packing $project..."
|
||||
|
||||
# Debug, not Release: the Release configuration runs the repo's formatting and analyzer passes,
|
||||
# which rewrite source files and fail the build on style violations. Packing only needs runnable
|
||||
# binaries, so Debug keeps the working tree untouched.
|
||||
#
|
||||
# PackageVersion (not Version) is the property the repo's packaging props use to stamp both the
|
||||
# package version and its dependency ranges, so the packed packages reference each other at this
|
||||
# version instead of the bare VersionPrefix.
|
||||
dotnet build "$project_path" -c Debug "-p:PackageVersion=$version" --tl:off >/dev/null
|
||||
dotnet pack "$project_path" -c Debug --no-build -o "$feed_path" "-p:PackageVersion=$version" --tl:off >/dev/null
|
||||
done
|
||||
|
||||
cat > "$target/nuget.config" <<'EOF'
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generated by add-local-framework-feed.sh: resolves the Agent Framework from this upload. -->
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="local-feed" value="./local-feed" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="local-feed">
|
||||
<package pattern="Microsoft.Agents.AI*" />
|
||||
</packageSource>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
EOF
|
||||
|
||||
# The scaffolded copy is disposable, so repointing its project file at the local build is safe and
|
||||
# keeps the checked-in sample free of contributor-only scaffolding. Reruns are safe: the pattern
|
||||
# matches whatever version is currently there. sed rewrites the line in place, leaving the file's
|
||||
# leading byte order mark untouched.
|
||||
sed -i.bak "s|<AgentFrameworkVersion>[^<]*</AgentFrameworkVersion>|<AgentFrameworkVersion>$version</AgentFrameworkVersion>|" "$project_file"
|
||||
rm -f "$project_file.bak"
|
||||
|
||||
echo
|
||||
echo "Done. Continue with the standard flow:"
|
||||
echo
|
||||
echo " cd \"$target\""
|
||||
echo " azd provision"
|
||||
echo " azd deploy"
|
||||
echo " azd ai agent invoke \"Hello!\""
|
||||
@@ -3,16 +3,13 @@
|
||||
using System;
|
||||
using System.ClientModel.Primitives;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.Core;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
@@ -57,7 +54,6 @@ public static class FoundryHostingExtensions
|
||||
ArgumentNullException.ThrowIfNull(services);
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
ConfigureFoundryListenPort(services);
|
||||
services.TryAddSingleton<AgentSessionStore>(_ => FileSystemAgentSessionStore.CreateDefault());
|
||||
services.TryAddSingleton<ResponseHandler, AgentFrameworkResponseHandler>();
|
||||
return services;
|
||||
@@ -94,7 +90,6 @@ public static class FoundryHostingExtensions
|
||||
|
||||
services.AddResponsesServer();
|
||||
services.AddHealthChecks();
|
||||
ConfigureFoundryListenPort(services);
|
||||
agentSessionStore ??= FileSystemAgentSessionStore.CreateDefault();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(agent.Name))
|
||||
@@ -254,104 +249,6 @@ public static class FoundryHostingExtensions
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration key the Foundry hosting platform populates with a non-empty value inside a
|
||||
/// hosted container. It is the documented way for container code to detect a Foundry context.
|
||||
/// </summary>
|
||||
internal const string FoundryHostingEnvironmentKey = "FOUNDRY_HOSTING_ENVIRONMENT";
|
||||
|
||||
/// <summary>
|
||||
/// Configuration key holding the HTTP listen port, matching the Agent Server SDK.
|
||||
/// </summary>
|
||||
internal const string ListenPortKey = "PORT";
|
||||
|
||||
/// <summary>
|
||||
/// Port the Foundry hosted runtime probes and routes to when <see cref="ListenPortKey"/> is
|
||||
/// not set, matching <see cref="FoundryEnvironment.Port"/>.
|
||||
/// </summary>
|
||||
internal const int DefaultListenPort = 8088;
|
||||
|
||||
/// <summary>
|
||||
/// Marker registered once per <see cref="IServiceCollection"/> so the Foundry listen-port
|
||||
/// configuration is applied at most once, even across multiple <c>AddFoundryResponses</c> calls.
|
||||
/// </summary>
|
||||
private sealed class FoundryListenPortMarker;
|
||||
|
||||
/// <summary>
|
||||
/// Binds Kestrel to the port the Foundry hosted runtime probes and routes to, so a plain
|
||||
/// <c>WebApplication.CreateBuilder</c> host (Tier 3) works with no Dockerfile. Mirrors
|
||||
/// <c>AgentHostBuilder</c>, which listens on the <c>PORT</c> value (default 8088).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The listener is added only when configuration reports a Foundry container through
|
||||
/// <see cref="FoundryHostingEnvironmentKey"/>. A listener configured in code overrides the
|
||||
/// addresses a host resolves from configuration, so adding it everywhere would silently move
|
||||
/// any non-Foundry app off its configured address.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Both values come from <see cref="IConfiguration"/> rather than from
|
||||
/// <see cref="FoundryEnvironment"/>, which caches every value in a static constructor. Reading
|
||||
/// through configuration keeps the decision observable when the host is built, honours the
|
||||
/// host's configuration sources, and lets tests supply values without mutating the process
|
||||
/// environment.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Inside a Foundry container the listener cannot be skipped based on <c>ASPNETCORE_URLS</c>:
|
||||
/// the .NET base image always sets it to port 80, so such a guard would always trip and leave
|
||||
/// the container failing the readiness probe with HTTP 424. It cannot key off the presence of
|
||||
/// <c>PORT</c> either, because the platform sets that value only when it needs a port other
|
||||
/// than the default.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Idempotent, and harmless when no Kestrel server is present (for example under
|
||||
/// <c>TestServer</c>): the <see cref="KestrelServerOptions"/> callback only runs when Kestrel
|
||||
/// is resolved.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static void ConfigureFoundryListenPort(IServiceCollection services)
|
||||
{
|
||||
if (services.Any(static d => d.ServiceType == typeof(FoundryListenPortMarker)))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
services.AddSingleton<FoundryListenPortMarker>();
|
||||
services.AddOptions<KestrelServerOptions>()
|
||||
.Configure<IConfiguration>(static (options, configuration) =>
|
||||
{
|
||||
if (string.IsNullOrEmpty(configuration[FoundryHostingEnvironmentKey]))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
options.ListenAnyIP(ResolveListenPort(configuration));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the listen port from configuration, applying the same contract as
|
||||
/// <see cref="FoundryEnvironment.Port"/>: <see cref="DefaultListenPort"/> when unset, otherwise
|
||||
/// a port number in the range 1-65535.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">The configured value is not a valid port.</exception>
|
||||
private static int ResolveListenPort(IConfiguration configuration)
|
||||
{
|
||||
var value = configuration[ListenPortKey];
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return DefaultListenPort;
|
||||
}
|
||||
|
||||
if (!int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var port) || port is < 1 or > 65535)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The {ListenPortKey} environment variable value '{value}' is not a valid port number (1-65535).");
|
||||
}
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps <c>GET /readiness</c> to the AspNetCore HealthChecks pipeline only when no
|
||||
/// route already serves that path. The duplicate guard scans
|
||||
|
||||
+1
-7
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<IsReleased>true</IsReleased>
|
||||
<IsReleaseCandidate>true</IsReleaseCandidate>
|
||||
<!-- GitHub.Copilot.SDK only supports .NET 8.0+ -->
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<NoWarn>$(NoWarn);GHCP001</NoWarn>
|
||||
@@ -14,12 +14,6 @@
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<!-- Disable package validation baseline until the first release -->
|
||||
<PropertyGroup>
|
||||
<PackageValidationBaselineVersion />
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI.Abstractions\Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -20,12 +20,6 @@ namespace Microsoft.Agents.AI.Hosting.A2A;
|
||||
[Experimental(DiagnosticIds.Experiments.AIResponseContinuations)]
|
||||
internal sealed class A2AAgentHandler : IAgentHandler
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="AgentRunOptions.AdditionalProperties"/> key under which the caller supplied
|
||||
/// <c>MessageSendParams.configuration</c> is forwarded to the hosted agent.
|
||||
/// </summary>
|
||||
private const string ConfigurationPropertyKey = "a2a.configuration";
|
||||
|
||||
private readonly AIHostAgent _hostAgent;
|
||||
private readonly AgentRunMode _runMode;
|
||||
|
||||
@@ -90,7 +84,9 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
var decisionContext = new A2ARunDecisionContext(context);
|
||||
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = CreateRunOptions(context, allowBackgroundResponses);
|
||||
var options = context.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
AgentResponse response;
|
||||
try
|
||||
@@ -141,7 +137,9 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
|
||||
List<ChatMessage> chatMessages = context.Message is not null ? [context.Message.ToChatMessage()] : [];
|
||||
|
||||
var options = CreateRunOptions(context);
|
||||
var options = context.Metadata is { Count: > 0 }
|
||||
? new AgentRunOptions { AdditionalProperties = context.Metadata.ToAdditionalProperties() }
|
||||
: null;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -167,7 +165,9 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
var decisionContext = new A2ARunDecisionContext(context);
|
||||
var allowBackgroundResponses = await this._runMode.ShouldRunInBackgroundAsync(decisionContext, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var options = CreateRunOptions(context, allowBackgroundResponses);
|
||||
var options = context.Metadata is not { Count: > 0 }
|
||||
? new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses }
|
||||
: new AgentRunOptions { AllowBackgroundResponses = allowBackgroundResponses, AdditionalProperties = context.Metadata.ToAdditionalProperties() };
|
||||
|
||||
AgentResponse response;
|
||||
try
|
||||
@@ -213,42 +213,6 @@ internal sealed class A2AAgentHandler : IAgentHandler
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the <see cref="AgentRunOptions"/> for a run, forwarding the caller supplied A2A
|
||||
/// <c>MessageSendParams.metadata</c> and <c>MessageSendParams.configuration</c> to the hosted agent.
|
||||
/// </summary>
|
||||
/// <param name="context">The A2A request context of the incoming request.</param>
|
||||
/// <param name="allowBackgroundResponses">
|
||||
/// The value to assign to <see cref="AgentRunOptions.AllowBackgroundResponses"/>. Defaults to <see langword="null"/>, which leaves it unset.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// The run options to invoke the agent with, or <see langword="null"/> when there is nothing to forward.
|
||||
/// </returns>
|
||||
private static AgentRunOptions? CreateRunOptions(RequestContext context, bool? allowBackgroundResponses = null)
|
||||
{
|
||||
AdditionalPropertiesDictionary? additionalProperties = context.Metadata is { Count: > 0 }
|
||||
? context.Metadata.ToAdditionalProperties()
|
||||
: null;
|
||||
|
||||
// Forward the whole configuration object under a well-known key so that agents can observe
|
||||
// the caller's requested configuration, including fields added to the A2A protocol in the future.
|
||||
if (context.Configuration is { } configuration)
|
||||
{
|
||||
(additionalProperties ??= [])[ConfigurationPropertyKey] = configuration;
|
||||
}
|
||||
|
||||
if (allowBackgroundResponses is null && additionalProperties is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentRunOptions
|
||||
{
|
||||
AllowBackgroundResponses = allowBackgroundResponses,
|
||||
AdditionalProperties = additionalProperties
|
||||
};
|
||||
}
|
||||
|
||||
private static Message CreateMessageFromResponse(string contextId, AgentResponse response) =>
|
||||
new()
|
||||
{
|
||||
|
||||
+17
-24
@@ -48,33 +48,26 @@ internal static class PortableValueExtensions
|
||||
|
||||
if (formulaValues[0] is RecordValue recordValue)
|
||||
{
|
||||
return FormulaValue.NewTable(ParseRecordType(recordValue), formulaValues.OfType<RecordValue>().ToArray());
|
||||
return FormulaValue.NewTable(ParseRecordType(recordValue), formulaValues.OfType<RecordValue>());
|
||||
}
|
||||
|
||||
FormulaType elementType = formulaValues[0] switch
|
||||
{
|
||||
PrimitiveValue<bool>
|
||||
or PrimitiveValue<string>
|
||||
or PrimitiveValue<int>
|
||||
or PrimitiveValue<long>
|
||||
or PrimitiveValue<decimal>
|
||||
or PrimitiveValue<float>
|
||||
or PrimitiveValue<double>
|
||||
or PrimitiveValue<TimeSpan>
|
||||
or PrimitiveValue<DateTime> => formulaValues[0].Type,
|
||||
_ => throw new DeclarativeModelException($"Unsupported table element type: {formulaValues[0].Type.GetType().Name}"),
|
||||
};
|
||||
return
|
||||
formulaValues[0] switch
|
||||
{
|
||||
PrimitiveValue<bool> => NewSingleColumnTable<bool>(),
|
||||
PrimitiveValue<string> => NewSingleColumnTable<string>(),
|
||||
PrimitiveValue<int> => NewSingleColumnTable<int>(),
|
||||
PrimitiveValue<long> => NewSingleColumnTable<long>(),
|
||||
PrimitiveValue<float> => NewSingleColumnTable<float>(),
|
||||
PrimitiveValue<decimal> => NewSingleColumnTable<decimal>(),
|
||||
PrimitiveValue<double> => NewSingleColumnTable<double>(),
|
||||
PrimitiveValue<TimeSpan> => NewSingleColumnTable<TimeSpan>(),
|
||||
PrimitiveValue<DateTime> => NewSingleColumnTable<DateTime>(),
|
||||
_ => throw new DeclarativeModelException($"Unsupported table element type: {formulaValues[0].Type.GetType().Name}"),
|
||||
};
|
||||
|
||||
RecordType singleColumnType = RecordType.Empty().Add("Value", elementType);
|
||||
RecordValue[] rows =
|
||||
[
|
||||
.. formulaValues.Select(
|
||||
value =>
|
||||
FormulaValue.NewRecordFromFields(
|
||||
singleColumnType,
|
||||
new NamedValue("Value", value))),
|
||||
];
|
||||
return FormulaValue.NewTable(singleColumnType, rows);
|
||||
TableValue NewSingleColumnTable<TValue>() =>
|
||||
FormulaValue.NewSingleColumnTable(formulaValues.OfType<PrimitiveValue<TValue>>());
|
||||
}
|
||||
|
||||
public static bool IsSystemType<TValue>(this PortableValue value, [NotNullWhen(true)] out TValue? typedValue) where TValue : struct
|
||||
|
||||
+7
-38
@@ -32,23 +32,9 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
|
||||
case TableChangeType.Add:
|
||||
ValueExpression addItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}");
|
||||
EvaluationResult<DataValue> addResult = this.Evaluator.GetValue(addItemValue);
|
||||
FormulaValue addValue = addResult.Value.ToFormula();
|
||||
RecordType recordType = tableValue.Type.ToRecord();
|
||||
RecordValue newRecord;
|
||||
TableValue resultTable;
|
||||
if (!recordType.FieldNames.Any() && !tableValue.Rows.Any())
|
||||
{
|
||||
newRecord = BuildRecordFromValue(addValue);
|
||||
resultTable = FormulaValue.NewTable(newRecord.Type, newRecord);
|
||||
}
|
||||
else
|
||||
{
|
||||
newRecord = BuildRecord(recordType, addValue);
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
resultTable = tableValue;
|
||||
}
|
||||
await this.AssignAsync(variablePath, resultTable, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ResultVariable?.Path, newRecord, context).ConfigureAwait(false);
|
||||
RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), addResult.Value.ToFormula());
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, newRecord, context).ConfigureAwait(false);
|
||||
break;
|
||||
case TableChangeType.Remove:
|
||||
ValueExpression removeItemValue = Throw.IfNull(this.Model.Value, $"{nameof(this.Model)}.{nameof(this.Model.Value)}");
|
||||
@@ -56,26 +42,19 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
|
||||
if (removeResult.Value is TableDataValue removeItemTable)
|
||||
{
|
||||
await tableValue.RemoveAsync(removeItemTable?.Values.Select(row => row.ToRecordValue()), all: true, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ResultVariable?.Path, RecordValue.Empty(), context).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, RecordValue.Empty(), context).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
case TableChangeType.Clear:
|
||||
await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
break;
|
||||
case TableChangeType.TakeFirst:
|
||||
RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value;
|
||||
if (firstRow is not null)
|
||||
{
|
||||
await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ResultVariable?.Path, firstRow, context).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, firstRow, context).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
case TableChangeType.TakeLast:
|
||||
@@ -83,23 +62,13 @@ internal sealed class EditTableExecutor(EditTable model, WorkflowFormulaState st
|
||||
if (lastRow is not null)
|
||||
{
|
||||
await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ResultVariable?.Path, lastRow, context).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.AssignAsync(this.Model.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
await this.AssignAsync(variablePath, lastRow, context).ConfigureAwait(false);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
return default;
|
||||
|
||||
static RecordValue BuildRecordFromValue(FormulaValue value) =>
|
||||
value is RecordValue recordValue ?
|
||||
recordValue :
|
||||
FormulaValue.NewRecordFromFields(new NamedValue("Value", value));
|
||||
|
||||
static RecordValue BuildRecord(RecordType recordType, FormulaValue value)
|
||||
{
|
||||
return FormulaValue.NewRecordFromFields(recordType, GetValues());
|
||||
|
||||
+9
-36
@@ -31,26 +31,14 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
|
||||
{
|
||||
ValueExpression addItemValue = Throw.IfNull(addItemOperation.Value, $"{nameof(this.Model)}.{nameof(this.Model.ChangeType)}");
|
||||
EvaluationResult<DataValue> expressionResult = this.Evaluator.GetValue(addItemValue);
|
||||
FormulaValue addValue = expressionResult.Value.ToFormula();
|
||||
RecordType recordType = tableValue.Type.ToRecord();
|
||||
TableValue resultTable;
|
||||
if (!recordType.FieldNames.Any() && !tableValue.Rows.Any())
|
||||
{
|
||||
RecordValue newRecord = BuildRecordFromValue(addValue);
|
||||
resultTable = FormulaValue.NewTable(newRecord.Type, newRecord);
|
||||
}
|
||||
else
|
||||
{
|
||||
RecordValue newRecord = BuildRecord(recordType, addValue);
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
resultTable = tableValue;
|
||||
}
|
||||
await this.AssignAsync(this.Model.ItemsVariable, resultTable, context).ConfigureAwait(false);
|
||||
RecordValue newRecord = BuildRecord(tableValue.Type.ToRecord(), expressionResult.Value.ToFormula());
|
||||
await tableValue.AppendAsync(newRecord, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, newRecord, context).ConfigureAwait(false);
|
||||
}
|
||||
else if (changeType is ClearItemsOperation)
|
||||
{
|
||||
await tableValue.ClearAsync(cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
}
|
||||
else if (changeType is RemoveItemOperation removeItemOperation)
|
||||
{
|
||||
@@ -59,45 +47,30 @@ internal sealed class EditTableV2Executor(EditTableV2 model, WorkflowFormulaStat
|
||||
if (expressionResult.Value.ToFormula() is TableValue removeItemTable)
|
||||
{
|
||||
await tableValue.RemoveAsync(removeItemTable.Rows.Select(row => row.Value), all: true, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else if (changeType is TakeLastItemOperation takeLastOperation)
|
||||
else if (changeType is TakeLastItemOperation)
|
||||
{
|
||||
RecordValue? lastRow = tableValue.Rows.LastOrDefault()?.Value;
|
||||
if (lastRow is not null)
|
||||
{
|
||||
await tableValue.RemoveAsync([lastRow], all: true, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(takeLastOperation.ResultVariable?.Path, lastRow, context).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.AssignAsync(takeLastOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, lastRow, context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
else if (changeType is TakeFirstItemOperation takeFirstOperation)
|
||||
else if (changeType is TakeFirstItemOperation)
|
||||
{
|
||||
RecordValue? firstRow = tableValue.Rows.FirstOrDefault()?.Value;
|
||||
if (firstRow is not null)
|
||||
{
|
||||
await tableValue.RemoveAsync([firstRow], all: true, cancellationToken).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, tableValue, context).ConfigureAwait(false);
|
||||
await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, firstRow, context).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await this.AssignAsync(takeFirstOperation.ResultVariable?.Path, FormulaValue.NewBlank(), context).ConfigureAwait(false);
|
||||
await this.AssignAsync(this.Model.ItemsVariable, firstRow, context).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return default;
|
||||
|
||||
static RecordValue BuildRecordFromValue(FormulaValue value) =>
|
||||
value is RecordValue recordValue ?
|
||||
recordValue :
|
||||
FormulaValue.NewRecordFromFields(new NamedValue("Value", value));
|
||||
|
||||
static RecordValue BuildRecord(RecordType recordType, FormulaValue value)
|
||||
{
|
||||
return FormulaValue.NewRecordFromFields(recordType, GetValues());
|
||||
|
||||
@@ -33,24 +33,10 @@ internal sealed class InputWaiter : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until input is signaled. This wait never expires; it completes only when
|
||||
/// <see cref="SignalInput"/> is called or <paramref name="cancellationToken"/> is cancelled.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">A token to cancel the wait.</param>
|
||||
public Task WaitForInputAsync(CancellationToken cancellationToken = default) => this._inputSignal.WaitAsync(cancellationToken);
|
||||
public Task WaitForInputAsync(CancellationToken cancellationToken = default) => this.WaitForInputAsync(null, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Waits until input is signaled or <paramref name="timeout"/> expires.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait for input.</param>
|
||||
/// <param name="cancellationToken">A token to cancel the wait.</param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the wait was released by <see cref="SignalInput"/>;
|
||||
/// <see langword="false"/> if <paramref name="timeout"/> expired first.
|
||||
/// </returns>
|
||||
public async Task<bool> WaitForInputAsync(TimeSpan timeout, CancellationToken cancellationToken = default)
|
||||
public async Task WaitForInputAsync(TimeSpan? timeout = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await this._inputSignal.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||
await this._inputSignal.WaitAsync(timeout ?? TimeSpan.FromMilliseconds(-1), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -478,7 +478,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
|
||||
if (chatHistoryProvider is not null)
|
||||
{
|
||||
@@ -510,7 +510,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
ChatHistoryProvider? chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
|
||||
if (chatHistoryProvider is not null)
|
||||
{
|
||||
@@ -980,33 +980,22 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
}
|
||||
}
|
||||
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatClientAgentSession session, ChatOptions? chatOptions)
|
||||
private ChatHistoryProvider? ResolveChatHistoryProvider(ChatOptions? chatOptions)
|
||||
{
|
||||
// A service that manages chat history server-side disengages the chat history provider so that history
|
||||
// is not stored in two places. The service is considered to store history when a conversation id is
|
||||
// present either on the options (explicitly supplied by the caller) or on the session (returned by the
|
||||
// service on a previous or the current run).
|
||||
//
|
||||
// The per-service-call persistence check must remain: PerServiceCallChatHistoryPersistingChatClient
|
||||
// calls back into LoadChatHistoryAsync/NotifyProviders (which reach here) precisely when per-service-call
|
||||
// persistence is active, and in its simulated path it stamps a sentinel onto session.ConversationId.
|
||||
// Without this check that sentinel would be mistaken for service-stored history and wrongly disengage
|
||||
// the provider the decorator depends on.
|
||||
bool serviceStoresHistory =
|
||||
!this.RequiresPerServiceCallChatHistoryPersistence
|
||||
&& !IsAGUIProviderName(this._agentMetadata.ProviderName)
|
||||
&& (!string.IsNullOrWhiteSpace(chatOptions?.ConversationId)
|
||||
|| !string.IsNullOrWhiteSpace(session.ConversationId));
|
||||
|
||||
ChatHistoryProvider? provider = serviceStoresHistory ? null : this.ChatHistoryProvider;
|
||||
ChatHistoryProvider? provider =
|
||||
chatOptions?.ConversationId is null || IsAGUIProviderName(this._agentMetadata.ProviderName)
|
||||
? this.ChatHistoryProvider
|
||||
: null;
|
||||
|
||||
// If someone provided an override ChatHistoryProvider via AdditionalProperties, we should use that instead.
|
||||
if (chatOptions?.AdditionalProperties?.TryGetValue(out ChatHistoryProvider? overrideProvider) is true)
|
||||
{
|
||||
if (this._agentOptions?.ThrowOnChatHistoryProviderConflict is true && serviceStoresHistory)
|
||||
if (!IsAGUIProviderName(this._agentMetadata.ProviderName) &&
|
||||
this._agentOptions?.ThrowOnChatHistoryProviderConflict is true &&
|
||||
string.IsNullOrWhiteSpace(chatOptions?.ConversationId) is false)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. A {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management is present (on the {nameof(ChatClientAgentSession)} or the {nameof(this.ChatOptions)}), but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
|
||||
$"Only {nameof(ChatClientAgentSession.ConversationId)} or {nameof(this.ChatHistoryProvider)} may be used, but not both. The current {nameof(ChatClientAgentSession)} has a {nameof(ChatClientAgentSession.ConversationId)} indicating server-side chat history management, but an override {nameof(this.ChatHistoryProvider)} was provided via {nameof(AgentRunOptions.AdditionalProperties)}.");
|
||||
}
|
||||
|
||||
// Validate that the override provider's StateKeys do not clash with any AIContextProvider's StateKeys.
|
||||
@@ -1041,7 +1030,7 @@ public sealed partial class ChatClientAgent : AIAgent
|
||||
ChatOptions? chatOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var chatHistoryProvider = this.ResolveChatHistoryProvider(session, chatOptions);
|
||||
var chatHistoryProvider = this.ResolveChatHistoryProvider(chatOptions);
|
||||
if (chatHistoryProvider is null)
|
||||
{
|
||||
return messages;
|
||||
|
||||
@@ -22,8 +22,7 @@ namespace Microsoft.Agents.AI;
|
||||
/// <para>
|
||||
/// The <see cref="FileAccessProvider"/> gives agents the ability to work with files
|
||||
/// in a folder that the user has granted access to. Unlike <see cref="FileMemoryProvider"/>,
|
||||
/// which provides agent-managed memory files whose scope is determined by the working folder it is
|
||||
/// configured with, <see cref="FileAccessProvider"/>
|
||||
/// which provides session-scoped memory that may be isolated per session, <see cref="FileAccessProvider"/>
|
||||
/// operates on a shared, persistent folder whose contents are visible across sessions and agents.
|
||||
/// This makes it suitable for reading input data, writing output artifacts, and working with
|
||||
/// files that have a lifetime beyond any single agent session.
|
||||
|
||||
@@ -69,9 +69,8 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
|
||||
private const string DefaultInstructions =
|
||||
"""
|
||||
## File Based Memory
|
||||
You have access to a file-based memory system via the `file_memory_*` tools for storing and retrieving information across interactions.
|
||||
These files act as your working memory and persist beyond the current conversation, so they may already contain memories written earlier,
|
||||
and anything you write now may remain available later.
|
||||
You have access to a session-scoped, file-based memory system via the `file_memory_*` tools for storing and retrieving information across interactions.
|
||||
These files act as your working memory for the current session and are isolated from other sessions.
|
||||
Use these tools to store plans, memories, processing results, or downloaded data.
|
||||
|
||||
- Use descriptive file names (e.g., "projectarchitecture.md", "userpreferences.md").
|
||||
@@ -508,7 +507,7 @@ public sealed class FileMemoryProvider : AIContextProvider, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Returns <see langword="true"/> if the normalized file name points into a subdirectory.
|
||||
/// File memory is a flat namespace within the working folder, so nested names are rejected up front.
|
||||
/// File memory is a flat, session-scoped space, so nested names are rejected up front.
|
||||
/// </summary>
|
||||
private static bool IsNestedPath(string normalizedFileName) =>
|
||||
normalizedFileName.IndexOf('/') >= 0;
|
||||
|
||||
@@ -115,13 +115,6 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
return new AgentResponse(new ChatMessage(ChatRole.Assistant, [nextQueuedItem]));
|
||||
}
|
||||
|
||||
// When the caller did not supply a session, create one and use it for every inner call.
|
||||
// The auto-approval loop re-invokes the inner agent with only the injected approval
|
||||
// responses; without a session the inner agent has no conversation history to reconstruct
|
||||
// the original request, which produces an empty request to the underlying service. Threading
|
||||
// a session preserves the history across re-invocations.
|
||||
session ??= await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. Call the inner agent in a loop. If the inner agent returns approval requests
|
||||
// that are ALL auto-approved by standing rules, we immediately re-call with the
|
||||
// collected approval responses injected. This avoids returning empty responses.
|
||||
@@ -165,11 +158,6 @@ public sealed class ToolApprovalAgent : DelegatingAIAgent
|
||||
yield break;
|
||||
}
|
||||
|
||||
// When the caller did not supply a session, create one and use it for every inner call so
|
||||
// conversation history is preserved across auto-approval re-invocations. See the non-streaming
|
||||
// RunCoreAsync for details.
|
||||
session ??= await this.InnerAgent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// 3. Stream from the inner agent in a loop. If all approval requests from the stream
|
||||
// are auto-approved by standing rules, we immediately re-stream with the collected
|
||||
// approval responses injected. This avoids returning empty streams.
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <c>AddFoundryResponses</c> adds a Kestrel listener on the Foundry hosted-runtime
|
||||
/// port for a plain <c>WebApplication.CreateBuilder</c> (Tier 3) host, so a source (ZIP) deployed
|
||||
/// agent passes the platform readiness probe with no Dockerfile pinning the port, and that it
|
||||
/// leaves the addresses of a host running outside Foundry alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every case supplies its values through an in-memory <see cref="IConfiguration"/>, so no test
|
||||
/// mutates the process environment and the class stays safe to run in parallel.
|
||||
/// </remarks>
|
||||
public sealed class FoundryListenPortTests
|
||||
{
|
||||
private const string AspNetCoreUrlsKey = "ASPNETCORE_URLS";
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_WhenHosted_ListensOnFoundryPort()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServices();
|
||||
|
||||
// Act
|
||||
services.AddFoundryResponses();
|
||||
|
||||
// Assert
|
||||
Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_WithAgentWhenHosted_ListensOnFoundryPort()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServices();
|
||||
var mockAgent = new Mock<AIAgent>();
|
||||
mockAgent.SetupGet(a => a.Name).Returns("test-agent");
|
||||
|
||||
// Act
|
||||
services.AddFoundryResponses(mockAgent.Object);
|
||||
|
||||
// Assert
|
||||
Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_WhenNotHosted_LeavesAddressesAlone()
|
||||
{
|
||||
// Arrange: outside a Foundry container the host keeps whatever addresses it resolved from
|
||||
// configuration, so registering the Responses protocol must not add a listener.
|
||||
var services = CreateServices(hosted: false);
|
||||
|
||||
// Act
|
||||
services.AddFoundryResponses();
|
||||
|
||||
// Assert
|
||||
Assert.Empty(GetCodeBackedPorts(services));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_WhenHostedWithAspNetCoreUrlsSet_StillListensOnFoundryPort()
|
||||
{
|
||||
// Arrange: the .NET base image used by source (ZIP) deploy sets ASPNETCORE_URLS to port 80.
|
||||
// Inside Foundry the listener must still be added, because a listener configured in code
|
||||
// takes precedence over that setting. Skipping it here would leave the container on port 80
|
||||
// and fail every invocation with HTTP 424 session_not_ready.
|
||||
var services = CreateServices(settings: new Dictionary<string, string?>
|
||||
{
|
||||
[AspNetCoreUrlsKey] = "http://+:80",
|
||||
});
|
||||
|
||||
// Act
|
||||
services.AddFoundryResponses();
|
||||
|
||||
// Assert
|
||||
Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_WhenHostedWithPortSet_ListensOnConfiguredPort()
|
||||
{
|
||||
// Arrange: the platform sets PORT only when it needs a port other than the default.
|
||||
var services = CreateServices(settings: new Dictionary<string, string?>
|
||||
{
|
||||
[FoundryHostingExtensions.ListenPortKey] = "9099",
|
||||
});
|
||||
|
||||
// Act
|
||||
services.AddFoundryResponses();
|
||||
|
||||
// Assert
|
||||
Assert.Equal([9099], GetCodeBackedPorts(services));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("0")]
|
||||
[InlineData("65536")]
|
||||
[InlineData("not-a-port")]
|
||||
public void AddFoundryResponses_WhenHostedWithInvalidPort_Throws(string port)
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServices(settings: new Dictionary<string, string?>
|
||||
{
|
||||
[FoundryHostingExtensions.ListenPortKey] = port,
|
||||
});
|
||||
services.AddFoundryResponses();
|
||||
|
||||
// Act & Assert
|
||||
var exception = Assert.Throws<InvalidOperationException>(() => GetCodeBackedPorts(services));
|
||||
Assert.Contains(port, exception.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddFoundryResponses_CalledTwiceWhenHosted_ListensOnFoundryPortOnce()
|
||||
{
|
||||
// Arrange
|
||||
var services = CreateServices();
|
||||
|
||||
// Act
|
||||
services.AddFoundryResponses();
|
||||
services.AddFoundryResponses();
|
||||
|
||||
// Assert: a duplicate ListenAnyIP on the same port fails Kestrel startup with
|
||||
// "address already in use", so the listener must be added exactly once.
|
||||
Assert.Equal([FoundryHostingExtensions.DefaultListenPort], GetCodeBackedPorts(services));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a service collection whose <see cref="IConfiguration"/> carries the supplied values,
|
||||
/// marking the process as Foundry-hosted unless <paramref name="hosted"/> says otherwise.
|
||||
/// </summary>
|
||||
private static ServiceCollection CreateServices(bool hosted = true, Dictionary<string, string?>? settings = null)
|
||||
{
|
||||
settings ??= [];
|
||||
if (hosted)
|
||||
{
|
||||
settings[FoundryHostingExtensions.FoundryHostingEnvironmentKey] = "foundry";
|
||||
}
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddSingleton<IConfiguration>(new ConfigurationBuilder().AddInMemoryCollection(settings).Build());
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the service provider, resolves the applied <see cref="KestrelServerOptions"/>, and
|
||||
/// returns the ports of every code-configured listener (those added via <c>ListenAnyIP</c>).
|
||||
/// </summary>
|
||||
private static List<int> GetCodeBackedPorts(IServiceCollection services)
|
||||
{
|
||||
using var provider = services.BuildServiceProvider();
|
||||
var options = provider.GetRequiredService<IOptions<KestrelServerOptions>>().Value;
|
||||
|
||||
var property = typeof(KestrelServerOptions).GetProperty(
|
||||
"CodeBackedListenOptions",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
Assert.NotNull(property);
|
||||
|
||||
var listenOptions = (IEnumerable)property!.GetValue(options)!;
|
||||
var ports = new List<int>();
|
||||
foreach (var listenOption in listenOptions)
|
||||
{
|
||||
if (listenOption.GetType().GetProperty("IPEndPoint")?.GetValue(listenOption) is IPEndPoint endpoint)
|
||||
{
|
||||
ports.Add(endpoint.Port);
|
||||
}
|
||||
}
|
||||
|
||||
return ports;
|
||||
}
|
||||
}
|
||||
+2
-13
@@ -15,20 +15,9 @@ public class GitHubCopilotAgentTests
|
||||
{
|
||||
private static void SkipIfCopilotNotConfigured()
|
||||
{
|
||||
bool actionsAuth =
|
||||
string.Equals(Environment.GetEnvironmentVariable("GITHUB_ACTIONS"), "true", StringComparison.OrdinalIgnoreCase) &&
|
||||
!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("GITHUB_TOKEN"));
|
||||
bool localOptIn =
|
||||
string.Equals(
|
||||
Environment.GetEnvironmentVariable("RUN_COPILOT_INTEGRATION_TESTS"),
|
||||
"true",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!actionsAuth && !localOptIn)
|
||||
if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("COPILOT_GITHUB_TOKEN")))
|
||||
{
|
||||
Assert.Skip(
|
||||
"GitHub Actions auth is unavailable and RUN_COPILOT_INTEGRATION_TESTS is not true; " +
|
||||
"skipping GitHub Copilot integration tests.");
|
||||
Assert.Skip("COPILOT_GITHUB_TOKEN not set; skipping GitHub Copilot integration tests.");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,11 +18,6 @@ namespace Microsoft.Agents.AI.Hosting.A2A.UnitTests;
|
||||
/// </summary>
|
||||
public sealed class A2AAgentHandlerTests
|
||||
{
|
||||
/// <summary>
|
||||
/// The <see cref="AgentRunOptions.AdditionalProperties"/> key the handler forwards the A2A configuration under.
|
||||
/// </summary>
|
||||
private const string ConfigurationPropertyKey = "a2a.configuration";
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when metadata is null, the options passed to RunAsync have
|
||||
/// AllowBackgroundResponses disabled and no AdditionalProperties.
|
||||
@@ -77,93 +72,6 @@ public sealed class A2AAgentHandlerTests
|
||||
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the caller supplies a <c>MessageSendParams.configuration</c>, it is forwarded to the
|
||||
/// agent through <see cref="AgentRunOptions.AdditionalProperties"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenConfigurationIsProvided_ForwardsConfigurationToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
|
||||
SendMessageConfiguration configuration = new()
|
||||
{
|
||||
AcceptedOutputModes = ["text/plain", "image/png"],
|
||||
HistoryLength = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
||||
Configuration = configuration
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Same(configuration, Assert.Single(capturedOptions.AdditionalProperties).Value);
|
||||
Assert.Equal(ConfigurationPropertyKey, Assert.Single(capturedOptions.AdditionalProperties).Key);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the caller supplied configuration and metadata are both forwarded to the agent.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenConfigurationAndMetadataAreProvided_ForwardsBothToRunAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(CreateAgentMock(options => capturedOptions = options));
|
||||
SendMessageConfiguration configuration = new() { HistoryLength = 5 };
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
||||
Metadata = new Dictionary<string, JsonElement>
|
||||
{
|
||||
["key1"] = JsonSerializer.SerializeToElement("value1")
|
||||
},
|
||||
Configuration = configuration
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Equal(2, capturedOptions.AdditionalProperties.Count);
|
||||
Assert.Equal("value1", capturedOptions.AdditionalProperties["key1"]?.ToString());
|
||||
Assert.Same(configuration, capturedOptions.AdditionalProperties[ConfigurationPropertyKey]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that the caller supplied configuration does not override the run mode configured on the server.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenConfigurationRequestsImmediateReturn_DoesNotOverrideRunModeAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(
|
||||
CreateAgentMock(options => capturedOptions = options),
|
||||
runMode: AgentRunMode.DisallowBackground);
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
TaskId = "", ContextId = "ctx", StreamingResponse = false,
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
||||
Configuration = new SendMessageConfiguration { ReturnImmediately = true }
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.False(capturedOptions.AllowBackgroundResponses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that when the agent response has AdditionalProperties, the returned Message.Metadata contains the converted values.
|
||||
/// </summary>
|
||||
@@ -764,36 +672,6 @@ public sealed class A2AAgentHandlerTests
|
||||
Assert.Null(capturedOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, when only a configuration is present, options carrying the
|
||||
/// configuration are passed to RunStreamingAsync.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_Streaming_WithConfiguration_PassesOptionsWithConfigurationAsync()
|
||||
{
|
||||
// Arrange
|
||||
AgentRunOptions? capturedOptions = null;
|
||||
A2AAgentHandler handler = CreateHandler(CreateStreamingAgentMockWithOptionsCapture(
|
||||
options => capturedOptions = options));
|
||||
SendMessageConfiguration configuration = new() { AcceptedOutputModes = ["text/plain"] };
|
||||
|
||||
// Act
|
||||
await InvokeExecuteAsync(handler, new RequestContext
|
||||
{
|
||||
StreamingResponse = true,
|
||||
TaskId = "",
|
||||
ContextId = "ctx",
|
||||
Message = new Message { MessageId = "test-id", Role = Role.User, Parts = [new Part { Text = "Hello" }] },
|
||||
Configuration = configuration
|
||||
});
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(capturedOptions);
|
||||
Assert.Null(capturedOptions.AllowBackgroundResponses);
|
||||
Assert.NotNull(capturedOptions.AdditionalProperties);
|
||||
Assert.Same(configuration, capturedOptions.AdditionalProperties[ConfigurationPropertyKey]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that in streaming mode, ReferenceTaskIds throws NotSupportedException.
|
||||
/// </summary>
|
||||
|
||||
-96
@@ -1,96 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading.Tasks;
|
||||
using Anthropic;
|
||||
using Shared.IntegrationTests;
|
||||
|
||||
namespace Microsoft.Agents.AI.Hosting.OpenAI.IntegrationTests;
|
||||
|
||||
/// <summary>
|
||||
/// Live integration tests for the app-owned routing helper surface (<see cref="OpenAIResponses"/> plus
|
||||
/// <see cref="AgentSessionStore"/>) exercised against a real Anthropic model. The helper surface is
|
||||
/// provider-agnostic; these tests confirm the same consumption paths — request conversion, an agent run,
|
||||
/// response rendering, and multi-turn session continuity — behave correctly end to end when the hosted
|
||||
/// agent is backed by a non-OpenAI chat client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Skipped unless the Anthropic configuration is present (<c>ANTHROPIC_API_KEY</c>), so runs without
|
||||
/// secrets stay green. The OpenAI-backed variant of these tests lives in
|
||||
/// <see cref="OpenAIResponsesHostingLiveTests"/>.
|
||||
/// </remarks>
|
||||
public sealed class AnthropicResponsesHostingLiveTests
|
||||
{
|
||||
private static string? ApiKey => Environment.GetEnvironmentVariable(TestSettings.AnthropicApiKey);
|
||||
private static string ModelName => Environment.GetEnvironmentVariable(TestSettings.AnthropicChatModelName) ?? "claude-haiku-4-5";
|
||||
|
||||
[Fact]
|
||||
public async Task NonStreamingRun_RendersResponsesShapedPayloadAsync()
|
||||
{
|
||||
// Arrange
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "ANTHROPIC_API_KEY is not configured; skipping live hosting test.");
|
||||
AIAgent agent = CreateAgent();
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
|
||||
JsonElement body = ParseBody("""{ "input": "Reply with exactly the word: apple" }""");
|
||||
|
||||
// Act
|
||||
OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId();
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId);
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
AgentResponse result = await agent.RunAsync(run.Messages, session, run.Options);
|
||||
JsonElement payload = OpenAIResponses.WriteResponse(result, responseId, responseId);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(responseId, payload.GetProperty("id").GetString());
|
||||
Assert.Equal("response", payload.GetProperty("object").GetString());
|
||||
Assert.Contains("output", payload.EnumerateObject().Select(p => p.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MultiTurn_ContinuesSessionAcrossTurnsAsync()
|
||||
{
|
||||
// Arrange
|
||||
Assert.SkipWhen(string.IsNullOrEmpty(ApiKey), "ANTHROPIC_API_KEY is not configured; skipping live hosting test.");
|
||||
AIAgent agent = CreateAgent();
|
||||
AgentSessionStore sessionStore = new InMemoryAgentSessionStore();
|
||||
|
||||
// Act: first turn establishes context, second turn continues from the first response id.
|
||||
string firstResponseId = await RunTurnAsync(agent, sessionStore, """{ "input": "Remember the number 7." }""");
|
||||
JsonElement secondBody = ParseBody($$"""{ "input": "What number did I ask you to remember?", "previous_response_id": "{{firstResponseId}}" }""");
|
||||
OpenAIResponsesRunRequest secondRun = OpenAIResponses.ToAgentRunRequest(secondBody);
|
||||
string secondSessionStoreId = OpenAIResponses.GetSessionStoreId(secondRun)!;
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, secondSessionStoreId);
|
||||
AgentResponse secondResult = await agent.RunAsync(secondRun.Messages, session, secondRun.Options);
|
||||
|
||||
// Assert: continuation succeeded and the model produced a textual answer.
|
||||
Assert.Equal(secondSessionStoreId, firstResponseId);
|
||||
Assert.False(string.IsNullOrWhiteSpace(secondResult.Text));
|
||||
}
|
||||
|
||||
private static async Task<string> RunTurnAsync(AIAgent agent, AgentSessionStore sessionStore, string bodyJson)
|
||||
{
|
||||
JsonElement body = ParseBody(bodyJson);
|
||||
OpenAIResponsesRunRequest run = OpenAIResponses.ToAgentRunRequest(body);
|
||||
string sessionStoreId = OpenAIResponses.GetSessionStoreId(run) ?? OpenAIResponses.CreateResponseId();
|
||||
AgentSession session = await sessionStore.GetSessionAsync(agent, sessionStoreId);
|
||||
string responseId = OpenAIResponses.CreateResponseId();
|
||||
_ = await agent.RunAsync(run.Messages, session, run.Options);
|
||||
await sessionStore.SaveSessionAsync(agent, responseId, session);
|
||||
return responseId;
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateAgent() =>
|
||||
new AnthropicClient { ApiKey = ApiKey }.AsAIAgent(
|
||||
ModelName,
|
||||
instructions: "You are a concise assistant.",
|
||||
name: "assistant");
|
||||
|
||||
private static JsonElement ParseBody(string json)
|
||||
{
|
||||
using JsonDocument doc = JsonDocument.Parse(json);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
}
|
||||
-1
@@ -13,7 +13,6 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting\Microsoft.Agents.AI.Hosting.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Hosting.OpenAI\Microsoft.Agents.AI.Hosting.OpenAI.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Anthropic\Microsoft.Agents.AI.Anthropic.csproj" />
|
||||
<ProjectReference Include="..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
-144
@@ -411,150 +411,6 @@ public class ChatClientAgent_ChatHistoryManagementTests
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/6120.
|
||||
/// When the service manages chat history server-side (returns a conversation id), the framework's
|
||||
/// default in-memory chat history provider must not persist the messages, even on the first turn.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_DoesNotUseDefaultInMemoryChatHistoryProvider_WhenConversationIdReturnedAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
var inMemoryProvider = Assert.IsType<InMemoryChatHistoryProvider>(agent.ChatHistoryProvider);
|
||||
Assert.Empty(inMemoryProvider.GetMessages(session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/6120.
|
||||
/// The streaming path must also refrain from populating the default in-memory chat history provider
|
||||
/// when the service returns a conversation id.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_DoesNotUseDefaultInMemoryChatHistoryProvider_WhenConversationIdReturnedAsync()
|
||||
{
|
||||
// Arrange
|
||||
ChatResponseUpdate[] returnUpdates =
|
||||
[
|
||||
new ChatResponseUpdate(role: ChatRole.Assistant, content: "response") { ConversationId = "ConvId" },
|
||||
];
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetStreamingResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).Returns(returnUpdates.ToAsyncEnumerable());
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await foreach (var _ in agent.RunStreamingAsync([new(ChatRole.User, "test")], session))
|
||||
{
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
var inMemoryProvider = Assert.IsType<InMemoryChatHistoryProvider>(agent.ChatHistoryProvider);
|
||||
Assert.Empty(inMemoryProvider.GetMessages(session));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression test for https://github.com/microsoft/agent-framework/issues/6120.
|
||||
/// Across multiple turns backed by service-stored history, the default in-memory chat history provider
|
||||
/// is never populated and prior turns are not replayed to the service (the service owns the history).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_MultiTurnServiceStoredHistory_DoesNotPopulateDefaultInMemoryProviderAsync()
|
||||
{
|
||||
// Arrange
|
||||
var capturedInputs = new List<List<ChatMessage>>();
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns<IEnumerable<ChatMessage>, ChatOptions?, CancellationToken>((msgs, _, _) =>
|
||||
{
|
||||
capturedInputs.Add(msgs.ToList());
|
||||
return Task.FromResult(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
});
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "first")], session);
|
||||
await agent.RunAsync([new(ChatRole.User, "second")], session);
|
||||
|
||||
// Assert
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
var inMemoryProvider = Assert.IsType<InMemoryChatHistoryProvider>(agent.ChatHistoryProvider);
|
||||
Assert.Empty(inMemoryProvider.GetMessages(session));
|
||||
|
||||
// The second turn should only send the new user message, since the service owns the history.
|
||||
Assert.Equal(2, capturedInputs.Count);
|
||||
Assert.Single(capturedInputs[1]);
|
||||
Assert.Equal("second", capturedInputs[1][0].Text);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the service manages chat history server-side (returns a conversation id), an explicitly-configured
|
||||
/// chat history provider is disengaged just like the default provider, even when all conflict handling is
|
||||
/// disabled. This pins the uniform "service storage disengages any provider" semantics.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_ExplicitChatHistoryProvider_Disengaged_WhenConflictHandlingDisabledAndConversationIdReturnedAsync()
|
||||
{
|
||||
// Arrange
|
||||
Mock<IChatClient> mockService = new();
|
||||
mockService.Setup(
|
||||
s => s.GetResponseAsync(
|
||||
It.IsAny<IEnumerable<ChatMessage>>(),
|
||||
It.IsAny<ChatOptions>(),
|
||||
It.IsAny<CancellationToken>())).ReturnsAsync(new ChatResponse([new(ChatRole.Assistant, "response")]) { ConversationId = "ConvId" });
|
||||
var chatHistoryProvider = new InMemoryChatHistoryProvider();
|
||||
ChatClientAgent agent = new(mockService.Object, options: new()
|
||||
{
|
||||
ChatOptions = new() { Instructions = "test instructions" },
|
||||
ChatHistoryProvider = chatHistoryProvider,
|
||||
ThrowOnChatHistoryProviderConflict = false,
|
||||
ClearOnChatHistoryProviderConflict = false,
|
||||
WarnOnChatHistoryProviderConflict = false,
|
||||
});
|
||||
|
||||
// Act
|
||||
ChatClientAgentSession? session = await agent.CreateSessionAsync() as ChatClientAgentSession;
|
||||
await agent.RunAsync([new(ChatRole.User, "test")], session);
|
||||
|
||||
// Assert — the provider reference is retained (conflict handling disabled), but it is not persisted to
|
||||
// because the service stores history.
|
||||
Assert.Equal("ConvId", session!.ConversationId);
|
||||
Assert.Same(chatHistoryProvider, agent.ChatHistoryProvider);
|
||||
Assert.Empty(chatHistoryProvider.GetMessages(session));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region ChatHistoryProvider Override Tests
|
||||
|
||||
+1
-158
@@ -1962,165 +1962,8 @@ public class ToolApprovalAgentTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when no session is supplied, the agent creates one and threads it to the inner
|
||||
/// agent across auto-approval re-invocations. Without a session, the inner agent would receive
|
||||
/// only the injected approval response (with no history) on the second call, producing an empty
|
||||
/// request to the underlying service (repro for issue #7210).
|
||||
/// Verify that when auto-approval rule does not match, request is surfaced to the caller.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var createdSession = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
|
||||
|
||||
var capturedSessions = new List<AgentSession?>();
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(createdSession));
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, session, _, _) => capturedSessions.Add(session))
|
||||
.ReturnsAsync(() =>
|
||||
{
|
||||
callCount++;
|
||||
if (callCount == 1)
|
||||
{
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, [approvalRequest])]);
|
||||
}
|
||||
|
||||
return new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]);
|
||||
});
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act — invoke WITHOUT a session.
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
// Assert — auto-approval re-invoked the inner agent, and both calls received the same,
|
||||
// non-null session so conversation history is preserved across the re-invocation.
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("Done", response.Text);
|
||||
Assert.Equal(2, capturedSessions.Count);
|
||||
Assert.All(capturedSessions, s => Assert.Same(createdSession, s));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Streaming counterpart of <see cref="RunAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync"/>:
|
||||
/// when no session is supplied, the streaming path also creates one and threads it to the inner
|
||||
/// agent across auto-approval re-invocations.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunStreamingAsync_AutoApprovalRule_NoSession_CreatesAndThreadsSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var createdSession = new ChatClientAgentSession();
|
||||
var approvalRequest = new ToolApprovalRequestContent("req1", new FunctionCallContent("call1", "ReadTool"));
|
||||
|
||||
var capturedSessions = new List<AgentSession?>();
|
||||
var callCount = 0;
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(new ValueTask<AgentSession>(createdSession));
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<IAsyncEnumerable<AgentResponseUpdate>>("RunCoreStreamingAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Returns<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, session, _, ct) =>
|
||||
{
|
||||
capturedSessions.Add(session);
|
||||
callCount++;
|
||||
AgentResponseUpdate[] streamUpdates = callCount == 1
|
||||
? [new AgentResponseUpdate(ChatRole.Assistant, [approvalRequest])]
|
||||
: [new AgentResponseUpdate(ChatRole.Assistant, "Done")];
|
||||
return ToAsyncEnumerableAsync(streamUpdates, ct);
|
||||
});
|
||||
|
||||
var options = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule]
|
||||
};
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, options);
|
||||
|
||||
// Act — invoke WITHOUT a session.
|
||||
var updates = new List<AgentResponseUpdate>();
|
||||
await foreach (var update in agent.RunStreamingAsync([new ChatMessage(ChatRole.User, "Hi")]))
|
||||
{
|
||||
updates.Add(update);
|
||||
}
|
||||
|
||||
// Assert — both inner calls received the same, non-null session.
|
||||
Assert.Equal(2, callCount);
|
||||
Assert.Equal("Done", string.Concat(updates.Select(u => u.Text)));
|
||||
Assert.Equal(2, capturedSessions.Count);
|
||||
Assert.All(capturedSessions, s => Assert.Same(createdSession, s));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verify that when no session is supplied, the agent creates exactly one session and threads
|
||||
/// it to the inner agent, even when no approval re-invocation is needed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_NoApprovalRequest_NoSession_CreatesSingleSessionAsync()
|
||||
{
|
||||
// Arrange
|
||||
var createdSession = new ChatClientAgentSession();
|
||||
var createSessionCallCount = 0;
|
||||
var capturedSessions = new List<AgentSession?>();
|
||||
var innerAgent = new Mock<AIAgent>();
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<ValueTask<AgentSession>>("CreateSessionCoreAsync", ItExpr.IsAny<CancellationToken>())
|
||||
.Returns(() =>
|
||||
{
|
||||
createSessionCallCount++;
|
||||
return new ValueTask<AgentSession>(createdSession);
|
||||
});
|
||||
innerAgent
|
||||
.Protected()
|
||||
.Setup<Task<AgentResponse>>("RunCoreAsync",
|
||||
ItExpr.IsAny<IEnumerable<ChatMessage>>(),
|
||||
ItExpr.IsAny<AgentSession?>(),
|
||||
ItExpr.IsAny<AgentRunOptions?>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<IEnumerable<ChatMessage>, AgentSession?, AgentRunOptions?, CancellationToken>(
|
||||
(_, session, _, _) => capturedSessions.Add(session))
|
||||
.ReturnsAsync(new AgentResponse([new ChatMessage(ChatRole.Assistant, "Done")]));
|
||||
|
||||
var agent = new ToolApprovalAgent(innerAgent.Object, new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [ToolApprovalAgent.AllToolsAutoApprovalRule]
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await agent.RunAsync([new ChatMessage(ChatRole.User, "Hi")]);
|
||||
|
||||
// Assert — a single session was created and threaded to the inner agent.
|
||||
Assert.Equal("Done", response.Text);
|
||||
Assert.Equal(1, createSessionCallCount);
|
||||
Assert.Single(capturedSessions);
|
||||
Assert.Same(createdSession, capturedSessions[0]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_AutoApprovalRule_DoesNotMatchSurfacesToCallerAsync()
|
||||
{
|
||||
|
||||
-93
@@ -4,8 +4,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
@@ -86,97 +84,6 @@ public sealed class PortableValueExtensionsTests
|
||||
Assert.Equal("input", textValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TableAsPortableUsesLegacyArrayShape()
|
||||
{
|
||||
// Arrange
|
||||
RecordType recordType = RecordType.Empty().Add("Id", FormulaType.Decimal);
|
||||
RecordValue record =
|
||||
FormulaValue.NewRecordFromFields(
|
||||
recordType,
|
||||
new NamedValue("Id", FormulaValue.New(1)));
|
||||
TableValue source = FormulaValue.NewTable(recordType, record);
|
||||
|
||||
// Act
|
||||
object result = source.AsPortable();
|
||||
|
||||
// Assert
|
||||
Assert.IsType<PortableValue[]>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RecordAsPortableUsesLegacyDictionaryShape()
|
||||
{
|
||||
// Arrange
|
||||
RecordValue source =
|
||||
FormulaValue.NewRecordFromFields(
|
||||
new NamedValue("Id", FormulaValue.New(1)));
|
||||
|
||||
// Act
|
||||
object result = source.AsPortable();
|
||||
|
||||
// Assert
|
||||
Assert.IsAssignableFrom<IDictionary<string, PortableValue>>(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyRecordTableSupportsAppendAsync()
|
||||
{
|
||||
// Arrange
|
||||
Dictionary<string, decimal>[] source = [new() { ["Id"] = 1 }];
|
||||
TableValue restored = Assert.IsAssignableFrom<TableValue>(new PortableValue(source.AsPortable()).ToFormula());
|
||||
RecordType recordType = restored.Type.ToRecord();
|
||||
RecordValue newRecord =
|
||||
FormulaValue.NewRecordFromFields(
|
||||
recordType,
|
||||
new NamedValue("Id", FormulaValue.New(2)));
|
||||
|
||||
// Act
|
||||
await restored.AppendAsync(newRecord, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(2, restored.Rows.Count());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LegacyPrimitiveTableSupportsAppendAsync()
|
||||
{
|
||||
// Arrange
|
||||
string[] source = ["one"];
|
||||
TableValue restored = Assert.IsAssignableFrom<TableValue>(new PortableValue(source.AsPortable()).ToFormula());
|
||||
RecordType recordType = restored.Type.ToRecord();
|
||||
RecordValue newRecord =
|
||||
FormulaValue.NewRecordFromFields(
|
||||
recordType,
|
||||
new NamedValue("Value", FormulaValue.New("two")));
|
||||
|
||||
// Act
|
||||
await restored.AppendAsync(newRecord, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
string[] values = restored.Rows
|
||||
.Select(row => Assert.IsType<StringValue>(row.Value.GetField("Value")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal(["one", "two"], values);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(false)]
|
||||
[InlineData(true)]
|
||||
public void LegacyDateTablePreservesFormulaType(bool includeTime)
|
||||
{
|
||||
// Arrange
|
||||
DateTime value = new(2026, 7, 27, includeTime ? 12 : 0, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
// Act
|
||||
TableValue restored = Assert.IsAssignableFrom<TableValue>(new PortableValue(new[] { value }.AsPortable()).ToFormula());
|
||||
|
||||
// Assert
|
||||
FormulaType expectedType = includeTime ? FormulaType.DateTime : FormulaType.Date;
|
||||
Assert.Equal(expectedType, restored.Type.GetFieldType("Value"));
|
||||
Assert.Equal(expectedType, Assert.Single(restored.Rows).Value.GetField("Value").Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DictionaryType()
|
||||
{
|
||||
|
||||
+68
-309
@@ -1,15 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Checkpointing;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.PowerFx.Types;
|
||||
|
||||
@@ -37,47 +31,13 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
displayName: nameof(AddItemToTableAsync),
|
||||
variableName: "MyTable",
|
||||
changeType: TableChangeType.Add,
|
||||
value: new RecordDataValue([new("id", new NumberDataValue(7))]),
|
||||
resultVariableName: "Result");
|
||||
value: new RecordDataValue([new("id", new NumberDataValue(7))]));
|
||||
|
||||
// Verify the variable remains a table containing the added record
|
||||
// Verify the variable now contains the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultTable.Rows.Last().Value.GetField("id"));
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(7, idValue.Value);
|
||||
Assert.Equal(7, Assert.IsType<DecimalValue>(
|
||||
Assert.IsAssignableFrom<RecordValue>(this.State.Get("Result")).GetField("id")).Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsecutiveAddsPreserveTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}]");
|
||||
this.State.Set("MyTable", tableValue);
|
||||
|
||||
EditTable firstAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddsPreserveTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(2))]));
|
||||
EditTable secondAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddsPreserveTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(3))]));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(firstAdd, this.State));
|
||||
await this.ExecuteAsync(new EditTableExecutor(secondAdd, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
decimal[] ids = resultTable.Rows
|
||||
.Select(row => Assert.IsType<DecimalValue>(row.Value.GetField("id")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal([1, 2, 3], ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -97,11 +57,9 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
new("name", new StringDataValue("Second"))
|
||||
]));
|
||||
|
||||
// Verify the variable remains a table containing the added record
|
||||
// Verify the variable now contains the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
RecordValue resultRecord = resultTable.Rows.Last().Value;
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(2, idValue.Value);
|
||||
StringValue nameValue = Assert.IsType<StringValue>(resultRecord.GetField("name"));
|
||||
@@ -125,10 +83,9 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.Add,
|
||||
value: new RecordDataValue([new("id", new NumberDataValue(1))]));
|
||||
|
||||
// Verify the variable remains a table containing the added record
|
||||
// Verify the variable now contains the added record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
RecordValue resultRecord = Assert.Single(resultTable.Rows).Value;
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(1, idValue.Value);
|
||||
}
|
||||
@@ -145,14 +102,13 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
displayName: nameof(RemoveItemFromTableAsync),
|
||||
variableName: "MyTable",
|
||||
changeType: TableChangeType.Remove,
|
||||
value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])]),
|
||||
resultVariableName: "Result");
|
||||
value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])]));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(Assert.Single(resultTable.Rows).Value.GetField("id"));
|
||||
Assert.Equal(7, idValue.Value);
|
||||
Assert.Empty(Assert.IsAssignableFrom<RecordValue>(this.State.Get("Result")).Fields);
|
||||
// Verify the variable now contains an empty record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
// Empty record should have no fields
|
||||
Assert.Empty(resultRecord.Fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -172,39 +128,11 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
new RecordDataValue([new("id", new NumberDataValue(3))])
|
||||
]));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(Assert.Single(resultTable.Rows).Value.GetField("id"));
|
||||
Assert.Equal(2, idValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAllThenRestoreThenAddPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.Set("MyTable", this.State.Engine.Eval("[{id: 1}, {id: 2}]"));
|
||||
EditTable removeAction = this.CreateModel(
|
||||
nameof(RemoveAllThenRestoreThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Remove,
|
||||
new TableDataValue([
|
||||
new RecordDataValue([new("id", new NumberDataValue(1))]),
|
||||
new RecordDataValue([new("id", new NumberDataValue(2))])
|
||||
]));
|
||||
EditTable addAction = this.CreateModel(
|
||||
nameof(RemoveAllThenRestoreThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(3))]));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(removeAction, this.State));
|
||||
this.State.Set("MyTable", new PortableValue(this.State.Get("MyTable").AsPortable()).ToFormula());
|
||||
await this.ExecuteAsync(new EditTableExecutor(addAction, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
Assert.Equal(3, Assert.IsType<DecimalValue>(Assert.Single(resultTable.Rows).Value.GetField("id")).Value);
|
||||
// Verify the variable now contains an empty record
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
// Empty record should have no fields
|
||||
Assert.Empty(resultRecord.Fields);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -219,14 +147,11 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
displayName: nameof(ClearTableAsync),
|
||||
variableName: "MyTable",
|
||||
changeType: TableChangeType.Clear,
|
||||
value: null,
|
||||
resultVariableName: "Result");
|
||||
value: null);
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
Assert.Empty(resultTable.Rows);
|
||||
Assert.Equal(FormulaType.Decimal, resultTable.Type.GetFieldType("id"));
|
||||
Assert.IsType<BlankValue>(this.State.Get("Result"));
|
||||
// Verify table is cleared
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
Assert.IsType<BlankValue>(resultValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -246,100 +171,9 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.Clear,
|
||||
value: null);
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
Assert.Empty(resultTable.Rows);
|
||||
Assert.Equal(FormulaType.Decimal, resultTable.Type.GetFieldType("id"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearThenRestoreThenAddPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.Set("MyTable", this.State.Engine.Eval("[{id: 1}, {id: 2}]"));
|
||||
EditTable clearAction = this.CreateModel(
|
||||
nameof(ClearThenRestoreThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Clear,
|
||||
value: null);
|
||||
EditTable addAction = this.CreateModel(
|
||||
nameof(ClearThenRestoreThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(3))]));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(clearAction, this.State));
|
||||
this.State.Set("MyTable", new PortableValue(this.State.Get("MyTable").AsPortable()).ToFormula());
|
||||
await this.ExecuteAsync(new EditTableExecutor(addAction, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(Assert.Single(resultTable.Rows).Value.GetField("id"));
|
||||
Assert.Equal(3, idValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearThenCheckpointResumeThenAddPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
EditTable clearModel = this.CreateModel(
|
||||
nameof(ClearThenCheckpointResumeThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Clear,
|
||||
value: null);
|
||||
EditTable addModel = this.CreateModel(
|
||||
nameof(ClearThenCheckpointResumeThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(3))]));
|
||||
|
||||
WorkflowFormulaState firstState = new(RecalcEngineFactory.Create());
|
||||
firstState.Set("MyTable", firstState.Engine.Eval("[{id: 1}, {id: 2}]"));
|
||||
Workflow firstWorkflow = BuildWorkflow(firstState);
|
||||
|
||||
InMemoryJsonStore store = new();
|
||||
CheckpointManager checkpointManager = CheckpointManager.CreateJson(store, DeclarativeWorkflowJsonOptions.Default);
|
||||
List<CheckpointInfo> checkpoints = [];
|
||||
|
||||
await using (StreamingRun run = await InProcessExecution.RunStreamingAsync(firstWorkflow, firstState, checkpointManager))
|
||||
{
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
|
||||
{
|
||||
if (evt is SuperStepCompletedEvent { CompletionInfo.Checkpoint: { } checkpoint })
|
||||
{
|
||||
checkpoints.Add(checkpoint);
|
||||
}
|
||||
}
|
||||
}
|
||||
Assert.True(checkpoints.Count >= 3);
|
||||
|
||||
WorkflowFormulaState resumedState = new(RecalcEngineFactory.Create());
|
||||
Workflow resumedWorkflow = BuildWorkflow(resumedState);
|
||||
|
||||
// Act
|
||||
await using (StreamingRun run = await InProcessExecution.ResumeStreamingAsync(resumedWorkflow, checkpoints[^2], checkpointManager))
|
||||
{
|
||||
await foreach (WorkflowEvent _ in run.WatchStreamAsync())
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resumedState.Get("MyTable"));
|
||||
Assert.Equal(3, Assert.IsType<DecimalValue>(Assert.Single(resultTable.Rows).Value.GetField("id")).Value);
|
||||
|
||||
Workflow BuildWorkflow(WorkflowFormulaState state)
|
||||
{
|
||||
TestWorkflowExecutor root = new();
|
||||
EditTableExecutor clearAction = new(clearModel, state);
|
||||
EditTableExecutor addAction = new(addModel, state);
|
||||
return
|
||||
new WorkflowBuilder(root)
|
||||
.AddEdge(root, clearAction)
|
||||
.AddEdge(clearAction, addAction)
|
||||
.Build();
|
||||
}
|
||||
// Verify table is blank
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
Assert.IsType<BlankValue>(resultValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -349,25 +183,18 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
|
||||
this.State.Set("MyTable", tableValue);
|
||||
|
||||
EditTable model = this.CreateModel(
|
||||
nameof(TakeFirstItemAsync),
|
||||
// Act, Assert
|
||||
await this.ExecuteTestAsync(
|
||||
displayName: nameof(TakeFirstItemAsync),
|
||||
variableName: "MyTable",
|
||||
changeType: TableChangeType.TakeFirst,
|
||||
value: null,
|
||||
resultVariableName: "TakenItem");
|
||||
value: null);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(model, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
decimal[] ids = resultTable.Rows
|
||||
.Select(row => Assert.IsType<DecimalValue>(row.Value.GetField("id")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal([20, 30], ids);
|
||||
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(this.State.Get("TakenItem"));
|
||||
Assert.Equal(10, Assert.IsType<DecimalValue>(resultRecord.GetField("id")).Value);
|
||||
// Verify the variable now contains the first record that was taken
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(10, idValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -379,23 +206,18 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
// Clear the table to make it empty but preserve schema
|
||||
await table.ClearAsync(CancellationToken.None);
|
||||
this.State.Set("MyTable", table);
|
||||
this.State.Set("TakenItem", FormulaValue.NewRecordFromFields(new NamedValue("id", FormulaValue.New(99))));
|
||||
|
||||
EditTable model = this.CreateModel(
|
||||
nameof(TakeFirstFromEmptyTableAsync),
|
||||
// Act, Assert
|
||||
await this.ExecuteTestAsync(
|
||||
displayName: nameof(TakeFirstFromEmptyTableAsync),
|
||||
variableName: "MyTable",
|
||||
changeType: TableChangeType.TakeFirst,
|
||||
value: null,
|
||||
resultVariableName: "TakenItem");
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(model, this.State));
|
||||
value: null);
|
||||
|
||||
// Verify table is still empty (nothing was taken, variable remains unchanged)
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Empty(resultTable.Rows);
|
||||
Assert.IsType<BlankValue>(this.State.Get("TakenItem"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -405,25 +227,18 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]");
|
||||
this.State.Set("MyTable", tableValue);
|
||||
|
||||
EditTable model = this.CreateModel(
|
||||
nameof(TakeLastItemAsync),
|
||||
// Act, Assert
|
||||
await this.ExecuteTestAsync(
|
||||
displayName: nameof(TakeLastItemAsync),
|
||||
variableName: "MyTable",
|
||||
changeType: TableChangeType.TakeLast,
|
||||
value: null,
|
||||
resultVariableName: "TakenItem");
|
||||
value: null);
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(model, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
decimal[] ids = resultTable.Rows
|
||||
.Select(row => Assert.IsType<DecimalValue>(row.Value.GetField("id")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal([10, 20], ids);
|
||||
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(this.State.Get("TakenItem"));
|
||||
Assert.Equal(30, Assert.IsType<DecimalValue>(resultRecord.GetField("id")).Value);
|
||||
// Verify the variable now contains the last record that was taken
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(30, idValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -463,9 +278,11 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.TakeFirst,
|
||||
value: null);
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
Assert.Empty(resultTable.Rows);
|
||||
// Verify variable contains the record that was taken
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(100, idValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -482,40 +299,11 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
changeType: TableChangeType.TakeLast,
|
||||
value: null);
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
Assert.Empty(resultTable.Rows);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TakeFirstThenAddPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
this.State.Set("MyTable", this.State.Engine.Eval("[{id: 1}, {id: 2}]"));
|
||||
EditTable takeAction = this.CreateModel(
|
||||
nameof(TakeFirstThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.TakeFirst,
|
||||
value: null,
|
||||
resultVariableName: "TakenItem");
|
||||
EditTable addAction = this.CreateModel(
|
||||
nameof(TakeFirstThenAddPreservesTableAsync),
|
||||
"MyTable",
|
||||
TableChangeType.Add,
|
||||
new RecordDataValue([new("id", new NumberDataValue(3))]));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableExecutor(takeAction, this.State));
|
||||
await this.ExecuteAsync(new EditTableExecutor(addAction, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("MyTable"));
|
||||
decimal[] ids = resultTable.Rows
|
||||
.Select(row => Assert.IsType<DecimalValue>(row.Value.GetField("id")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal([2, 3], ids);
|
||||
Assert.Equal(1, Assert.IsType<DecimalValue>(
|
||||
Assert.IsAssignableFrom<RecordValue>(this.State.Get("TakenItem")).GetField("id")).Value);
|
||||
// Verify variable contains the record that was taken
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(100, idValue.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -557,12 +345,11 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
EditTableExecutor action = new(model, this.State);
|
||||
await this.ExecuteAsync(action);
|
||||
|
||||
// Assert - Variable should remain a table containing the newly added record
|
||||
// Assert - Variable should contain the newly added record
|
||||
VerifyModel(model, action);
|
||||
FormulaValue resultValue = this.State.Get("MyTable");
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(resultValue);
|
||||
Assert.Equal(2, resultTable.Rows.Count());
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultTable.Rows.Last().Value.GetField("id"));
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(resultValue);
|
||||
DecimalValue idValue = Assert.IsType<DecimalValue>(resultRecord.GetField("id"));
|
||||
Assert.Equal(10, idValue.Value);
|
||||
}
|
||||
|
||||
@@ -595,11 +382,10 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
string displayName,
|
||||
string variableName,
|
||||
TableChangeType changeType,
|
||||
DataValue? value,
|
||||
string? resultVariableName = null)
|
||||
DataValue? value)
|
||||
{
|
||||
// Arrange
|
||||
EditTable model = this.CreateModel(displayName, variableName, changeType, value, resultVariableName);
|
||||
EditTable model = this.CreateModel(displayName, variableName, changeType, value);
|
||||
|
||||
// Act
|
||||
EditTableExecutor action = new(model, this.State);
|
||||
@@ -613,8 +399,7 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
string displayName,
|
||||
string variableName,
|
||||
TableChangeType changeType,
|
||||
DataValue? value,
|
||||
string? resultVariableName = null)
|
||||
DataValue? value)
|
||||
{
|
||||
ValueExpression.Builder? valueExpressionBuilder = value switch
|
||||
{
|
||||
@@ -622,26 +407,24 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
_ => new ValueExpression.Builder(ValueExpression.Literal(value))
|
||||
};
|
||||
|
||||
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder, resultVariableName);
|
||||
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
|
||||
}
|
||||
|
||||
private EditTable CreateModel(
|
||||
string displayName,
|
||||
string variableName,
|
||||
TableChangeType changeType,
|
||||
ValueExpression valueExpression,
|
||||
string? resultVariableName = null)
|
||||
ValueExpression valueExpression)
|
||||
{
|
||||
ValueExpression.Builder valueExpressionBuilder = new(valueExpression);
|
||||
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder, resultVariableName);
|
||||
return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder);
|
||||
}
|
||||
|
||||
private EditTable CreateModel(
|
||||
string displayName,
|
||||
string variableName,
|
||||
TableChangeType changeType,
|
||||
ValueExpression.Builder? valueExpression,
|
||||
string? resultVariableName = null)
|
||||
ValueExpression.Builder? valueExpression)
|
||||
{
|
||||
EditTable.Builder actionBuilder = new()
|
||||
{
|
||||
@@ -651,31 +434,7 @@ public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowAc
|
||||
ChangeType = TableChangeTypeWrapper.Get(changeType),
|
||||
Value = valueExpression,
|
||||
};
|
||||
if (resultVariableName is not null)
|
||||
{
|
||||
actionBuilder.ResultVariable = PropertyPath.Create(FormatVariablePath(resultVariableName));
|
||||
}
|
||||
|
||||
return AssignParent<EditTable>(actionBuilder);
|
||||
}
|
||||
|
||||
private sealed class InMemoryJsonStore : JsonCheckpointStore
|
||||
{
|
||||
private readonly Dictionary<CheckpointInfo, JsonElement> _store = [];
|
||||
|
||||
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(
|
||||
string sessionId, JsonElement value, CheckpointInfo? parent = null)
|
||||
{
|
||||
CheckpointInfo key = new(sessionId, Guid.NewGuid().ToString("N"));
|
||||
this._store[key] = value;
|
||||
return new(key);
|
||||
}
|
||||
|
||||
public override ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key) =>
|
||||
new(this._store[key]);
|
||||
|
||||
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(
|
||||
string sessionId, CheckpointInfo? withParent = null) =>
|
||||
new(this._store.Keys.Where(key => key.SessionId == sessionId));
|
||||
}
|
||||
}
|
||||
|
||||
+28
-218
@@ -1,9 +1,7 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
|
||||
using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
|
||||
using Microsoft.Agents.ObjectModel;
|
||||
using Microsoft.PowerFx.Types;
|
||||
@@ -146,7 +144,7 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Arrange, Act, Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
displayName: nameof(AddItemOperationWithSingleFieldRecordAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: this.CreateAddItemOperation(new RecordDataValue.Builder
|
||||
@@ -156,8 +154,8 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
["Name"] = new StringDataValue("John")
|
||||
}
|
||||
}.Build()),
|
||||
verifyAction: (variableName, resultTable) =>
|
||||
Assert.Equal("John", Assert.Single(resultTable.Rows).Value.GetField("Name").ToObject())
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("John", recordValue.GetField("Name").ToObject())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -170,46 +168,15 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
displayName: nameof(AddItemOperationWithScalarValueAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: this.CreateAddItemOperation(new StringDataValue("TestValue")),
|
||||
verifyAction: (variableName, resultTable) =>
|
||||
Assert.Equal("TestValue", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject())
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("TestValue", recordValue.GetField("Value").ToObject())
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConsecutiveAddItemOperationsPreserveTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
RecordValue initialRecord = FormulaValue.NewRecordFromFields(
|
||||
recordType,
|
||||
new NamedValue("Value", FormulaValue.New("Initial")));
|
||||
this.State.Set("TestTable", FormulaValue.NewTable(recordType, initialRecord));
|
||||
|
||||
EditTableV2 firstAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddItemOperationsPreserveTableAsync),
|
||||
"TestTable",
|
||||
this.CreateAddItemOperation(new StringDataValue("First")));
|
||||
EditTableV2 secondAdd = this.CreateModel(
|
||||
nameof(ConsecutiveAddItemOperationsPreserveTableAsync),
|
||||
"TestTable",
|
||||
this.CreateAddItemOperation(new StringDataValue("Second")));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableV2Executor(firstAdd, this.State));
|
||||
await this.ExecuteAsync(new EditTableV2Executor(secondAdd, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("TestTable"));
|
||||
string[] values = resultTable.Rows
|
||||
.Select(row => Assert.IsType<StringValue>(row.Value.GetField("Value")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal(["Initial", "First", "Second"], values);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearItemsOperationAsync()
|
||||
{
|
||||
@@ -221,43 +188,10 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
await this.ExecuteTestAsync<BlankValue>(
|
||||
displayName: nameof(ClearItemsOperationAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: new ClearItemsOperation.Builder().Build(),
|
||||
verifyAction: (_, resultTable) =>
|
||||
{
|
||||
Assert.Empty(resultTable.Rows);
|
||||
Assert.Equal(FormulaType.String, resultTable.Type.GetFieldType("Value"));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClearThenRestoreThenAddPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
|
||||
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
|
||||
this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2));
|
||||
|
||||
EditTableV2 clearAction = this.CreateModel(
|
||||
nameof(ClearThenRestoreThenAddPreservesTableAsync),
|
||||
"TestTable",
|
||||
new ClearItemsOperation.Builder().Build());
|
||||
EditTableV2 addAction = this.CreateModel(
|
||||
nameof(ClearThenRestoreThenAddPreservesTableAsync),
|
||||
"TestTable",
|
||||
this.CreateAddItemOperation(new StringDataValue("Item3")));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableV2Executor(clearAction, this.State));
|
||||
this.State.Set("TestTable", new PortableValue(this.State.Get("TestTable").AsPortable()).ToFormula());
|
||||
await this.ExecuteAsync(new EditTableV2Executor(addAction, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("TestTable"));
|
||||
Assert.Equal("Item3", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject());
|
||||
changeType: new ClearItemsOperation.Builder().Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -271,40 +205,10 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
await this.ExecuteTestAsync<BlankValue>(
|
||||
displayName: nameof(RemoveItemOperationAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: this.CreateRemoveItemOperation("Item1"),
|
||||
verifyAction: (_, resultTable) =>
|
||||
Assert.Equal("Item2", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RemoveAllThenRestoreThenAddPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
|
||||
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
|
||||
this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2));
|
||||
|
||||
EditTableV2 removeAction = this.CreateModel(
|
||||
nameof(RemoveAllThenRestoreThenAddPreservesTableAsync),
|
||||
"TestTable",
|
||||
this.CreateRemoveItemOperation("Item1", "Item2"));
|
||||
EditTableV2 addAction = this.CreateModel(
|
||||
nameof(RemoveAllThenRestoreThenAddPreservesTableAsync),
|
||||
"TestTable",
|
||||
this.CreateAddItemOperation(new StringDataValue("Item3")));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableV2Executor(removeAction, this.State));
|
||||
this.State.Set("TestTable", new PortableValue(this.State.Get("TestTable").AsPortable()).ToFormula());
|
||||
await this.ExecuteAsync(new EditTableV2Executor(addAction, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("TestTable"));
|
||||
Assert.Equal("Item3", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject());
|
||||
changeType: this.CreateRemoveItemOperation("Item1"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -318,25 +222,14 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3);
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Act
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
// Arrange, Act, Assert
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
displayName: nameof(TakeLastItemOperationWithItemsAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: new TakeLastItemOperation.Builder
|
||||
{
|
||||
ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem"))
|
||||
}.Build(),
|
||||
verifyAction: (_, resultTable) =>
|
||||
{
|
||||
string[] values = resultTable.Rows
|
||||
.Select(row => Assert.IsType<StringValue>(row.Value.GetField("Value")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal(["Item1", "Item2"], values);
|
||||
});
|
||||
|
||||
// Assert
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(this.State.Get("TakenItem"));
|
||||
Assert.Equal("Item3", resultRecord.GetField("Value").ToObject());
|
||||
changeType: new TakeLastItemOperation.Builder().Build(),
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("Item3", recordValue.GetField("Value").ToObject())
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -346,19 +239,12 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
TableValue tableValue = FormulaValue.NewTable(recordType);
|
||||
this.State.Set("TestTable", tableValue);
|
||||
this.State.Set("TakenItem", FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("stale"))));
|
||||
|
||||
// Act
|
||||
// Arrange, Act, Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
displayName: nameof(TakeLastItemOperationEmptyTableAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: new TakeLastItemOperation.Builder
|
||||
{
|
||||
ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem"))
|
||||
}.Build());
|
||||
|
||||
// Assert
|
||||
Assert.IsType<BlankValue>(this.State.Get("TakenItem"));
|
||||
changeType: new TakeLastItemOperation.Builder().Build());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -372,77 +258,14 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
TableValue tableValue = FormulaValue.NewTable(recordType, record1, record2, record3);
|
||||
this.State.Set("TestTable", tableValue);
|
||||
|
||||
// Act
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<RecordValue>(
|
||||
displayName: nameof(TakeFirstItemOperationWithItemsAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: new TakeFirstItemOperation.Builder
|
||||
{
|
||||
ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem"))
|
||||
}.Build(),
|
||||
verifyAction: (_, resultTable) =>
|
||||
{
|
||||
string[] values = resultTable.Rows
|
||||
.Select(row => Assert.IsType<StringValue>(row.Value.GetField("Value")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal(["Item2", "Item3"], values);
|
||||
});
|
||||
|
||||
// Assert
|
||||
RecordValue resultRecord = Assert.IsAssignableFrom<RecordValue>(this.State.Get("TakenItem"));
|
||||
Assert.Equal("Item1", resultRecord.GetField("Value").ToObject());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TakeFirstItemOperationWithoutResultPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
|
||||
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
|
||||
this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2));
|
||||
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
displayName: nameof(TakeFirstItemOperationWithoutResultPreservesTableAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: new TakeFirstItemOperation.Builder().Build(),
|
||||
verifyAction: (_, resultTable) =>
|
||||
Assert.Equal("Item2", Assert.Single(resultTable.Rows).Value.GetField("Value").ToObject()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TakeLastThenAddPreservesTableAsync()
|
||||
{
|
||||
// Arrange
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
RecordValue record1 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item1")));
|
||||
RecordValue record2 = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New("Item2")));
|
||||
this.State.Set("TestTable", FormulaValue.NewTable(recordType, record1, record2));
|
||||
|
||||
EditTableV2 takeAction = this.CreateModel(
|
||||
nameof(TakeLastThenAddPreservesTableAsync),
|
||||
"TestTable",
|
||||
new TakeLastItemOperation.Builder
|
||||
{
|
||||
ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem"))
|
||||
}.Build());
|
||||
EditTableV2 addAction = this.CreateModel(
|
||||
nameof(TakeLastThenAddPreservesTableAsync),
|
||||
"TestTable",
|
||||
this.CreateAddItemOperation(new StringDataValue("Item3")));
|
||||
|
||||
// Act
|
||||
await this.ExecuteAsync(new EditTableV2Executor(takeAction, this.State));
|
||||
await this.ExecuteAsync(new EditTableV2Executor(addAction, this.State));
|
||||
|
||||
// Assert
|
||||
TableValue resultTable = Assert.IsAssignableFrom<TableValue>(this.State.Get("TestTable"));
|
||||
string[] values = resultTable.Rows
|
||||
.Select(row => Assert.IsType<StringValue>(row.Value.GetField("Value")).Value)
|
||||
.ToArray();
|
||||
Assert.Equal(["Item1", "Item3"], values);
|
||||
Assert.Equal("Item2", Assert.IsAssignableFrom<RecordValue>(this.State.Get("TakenItem")).GetField("Value").ToObject());
|
||||
verifyAction: (variableName, recordValue) =>
|
||||
Assert.Equal("Item1", recordValue.GetField("Value").ToObject())
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -452,19 +275,12 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
TableValue tableValue = FormulaValue.NewTable(recordType);
|
||||
this.State.Set("TestTable", tableValue);
|
||||
this.State.Set("TakenItem", FormulaValue.NewRecordFromFields(new NamedValue("Value", FormulaValue.New("stale"))));
|
||||
|
||||
// Act
|
||||
// Act & Assert
|
||||
await this.ExecuteTestAsync<TableValue>(
|
||||
displayName: nameof(TakeFirstItemOperationEmptyTableAsync),
|
||||
variableName: "TestTable",
|
||||
changeType: new TakeFirstItemOperation.Builder
|
||||
{
|
||||
ResultVariable = PropertyPath.Create(FormatVariablePath("TakenItem"))
|
||||
}.Build());
|
||||
|
||||
// Assert
|
||||
Assert.IsType<BlankValue>(this.State.Get("TakenItem"));
|
||||
changeType: new TakeFirstItemOperation.Builder().Build());
|
||||
}
|
||||
|
||||
private async Task ExecuteTestAsync<TValue>(
|
||||
@@ -509,18 +325,12 @@ public sealed class EditTableV2ExecutorTest(ITestOutputHelper output) : Workflow
|
||||
}.Build();
|
||||
}
|
||||
|
||||
private RemoveItemOperation CreateRemoveItemOperation(params string[] itemValues)
|
||||
private RemoveItemOperation CreateRemoveItemOperation(string itemValue)
|
||||
{
|
||||
// Create a table with the item to remove
|
||||
RecordType recordType = RecordType.Empty().Add("Value", FormulaType.String);
|
||||
TableValue tableToRemove =
|
||||
FormulaValue.NewTable(
|
||||
recordType,
|
||||
itemValues.Select(
|
||||
itemValue =>
|
||||
FormulaValue.NewRecordFromFields(
|
||||
recordType,
|
||||
new NamedValue("Value", FormulaValue.New(itemValue)))));
|
||||
RecordValue recordToRemove = FormulaValue.NewRecordFromFields(recordType, new NamedValue("Value", FormulaValue.New(itemValue)));
|
||||
TableValue tableToRemove = FormulaValue.NewTable(recordType, recordToRemove);
|
||||
|
||||
// Store in state for expression evaluation
|
||||
this.State.Set("RemoveItems", tableToRemove);
|
||||
|
||||
@@ -10,12 +10,6 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
public sealed class InputWaiterTests : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Liveness backstop for waits that are expected to complete. Never the thing under test:
|
||||
/// it is set far above the timeouts being exercised so a regression fails instead of hanging.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan s_guardTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly InputWaiter _waiter = new();
|
||||
|
||||
public void Dispose()
|
||||
@@ -27,37 +21,39 @@ public sealed class InputWaiterTests : IDisposable
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_CompletesAfterSignalAsync()
|
||||
{
|
||||
// Arrange
|
||||
this._waiter.SignalInput();
|
||||
|
||||
// Act
|
||||
bool signaled = await this._waiter.WaitForInputAsync(s_guardTimeout);
|
||||
// WaitForInputAsync should complete immediately since input was already signaled
|
||||
Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None);
|
||||
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1)));
|
||||
|
||||
// Assert
|
||||
signaled.Should().BeTrue("the already-signaled input should release the wait");
|
||||
completed.Should().BeSameAs(waitTask, "the wait task should complete before the timeout");
|
||||
await waitTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_BlocksUntilSignaledAsync()
|
||||
{
|
||||
// Arrange - the no-timeout overload is used so that only SignalInput can release the wait.
|
||||
using CancellationTokenSource guard = new();
|
||||
Task waitTask = this._waiter.WaitForInputAsync(guard.Token);
|
||||
// Use the no-timeout overload so that the wait can only be released by SignalInput.
|
||||
// A finite timeout would make this test's logic racy: the component correctly
|
||||
// honors the timeout, but if the test thread is starved of CPU time (CI load,
|
||||
// GC pause) long enough for the timeout to fire, waitTask completes before
|
||||
// SignalInput is called and the "should not complete before signaled" assertion
|
||||
// flakes. Timeout behavior is covered separately below.
|
||||
Task waitTask = this._waiter.WaitForInputAsync(CancellationToken.None);
|
||||
|
||||
// Assert - the waiter stays blocked while no input has been signaled.
|
||||
Task completedBeforeSignal = await Task.WhenAny(waitTask, Task.Delay(100));
|
||||
completedBeforeSignal.Should().NotBeSameAs(
|
||||
waitTask,
|
||||
"the waiter should not complete before input is signaled");
|
||||
|
||||
// Act
|
||||
this._waiter.SignalInput();
|
||||
|
||||
// Armed only once the signal has released the wait, since cancelling a still-pending
|
||||
// waiter would fail the test rather than guard it.
|
||||
guard.CancelAfter(s_guardTimeout);
|
||||
Task completedAfterSignal = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(1)));
|
||||
completedAfterSignal.Should().BeSameAs(
|
||||
waitTask,
|
||||
"the wait task should complete after being signaled");
|
||||
|
||||
// Assert - completion alone proves the signal released the wait.
|
||||
await waitTask;
|
||||
}
|
||||
|
||||
@@ -102,34 +98,27 @@ public sealed class InputWaiterTests : IDisposable
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_CanBeSignaledMultipleTimesSequentiallyAsync()
|
||||
{
|
||||
// Arrange / Act - first signal/wait cycle
|
||||
// First signal/wait cycle
|
||||
this._waiter.SignalInput();
|
||||
bool firstSignaled = await this._waiter.WaitForInputAsync(s_guardTimeout);
|
||||
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
|
||||
|
||||
// Arrange / Act - second signal/wait cycle
|
||||
// Second signal/wait cycle
|
||||
this._waiter.SignalInput();
|
||||
bool secondSignaled = await this._waiter.WaitForInputAsync(s_guardTimeout);
|
||||
|
||||
// Assert each cycle was released by its signal rather than by an expiring timeout.
|
||||
firstSignaled.Should().BeTrue("the first signal should release the first wait");
|
||||
secondSignaled.Should().BeTrue("the second signal should release the second wait");
|
||||
await this._waiter.WaitForInputAsync(TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InputWaiter_WaitForInputAsync_CompletesWhenTimeoutExpiresAsync()
|
||||
{
|
||||
// Arrange - nothing signals this waiter, so an expiring timeout is the only thing that
|
||||
// can release the wait, and the returned flag proves which one did. The guard only
|
||||
// bounds a wait that never returns; it is not part of the assertion.
|
||||
using CancellationTokenSource guard = new();
|
||||
// Verify that a finite timeout releases the block even without a signal.
|
||||
// We only assert that it *does* complete (within a generous outer bound);
|
||||
// we intentionally do not assert that it stays blocked until the timeout,
|
||||
// because that would re-introduce the same wall-clock flakiness
|
||||
// described in BlocksUntilSignaledAsync (see comment on that test).
|
||||
Task waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300));
|
||||
|
||||
// Act
|
||||
Task<bool> waitTask = this._waiter.WaitForInputAsync(TimeSpan.FromMilliseconds(300), guard.Token);
|
||||
guard.CancelAfter(s_guardTimeout);
|
||||
|
||||
bool signaled = await waitTask;
|
||||
|
||||
// Assert
|
||||
signaled.Should().BeFalse("the wait should be released by the expiring timeout rather than by a signal");
|
||||
Task completed = await Task.WhenAny(waitTask, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
completed.Should().BeSameAs(waitTask, "the wait task should complete once the timeout expires");
|
||||
await waitTask;
|
||||
}
|
||||
}
|
||||
|
||||
-192
@@ -1,192 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Microsoft.Agents.AI.Workflows.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that a workflow hosted as an <see cref="AIAgent"/> resumes a serialized session after its inner
|
||||
/// agents are reconstructed only when each inner agent keeps a stable executor identity. A stable
|
||||
/// <see cref="ChatClientAgentOptions.Id"/> is sufficient; if an agent also sets a
|
||||
/// <see cref="ChatClientAgentOptions.Name"/>, that name must stay stable because the executor id includes it.
|
||||
/// </summary>
|
||||
public class WorkflowAgentCheckpointIdentityTests
|
||||
{
|
||||
private const string TriageName = "triage_agent";
|
||||
private const string SpecialistName = "specialist_agent";
|
||||
private const string SpecialistReply = "SPECIALIST_REPLY";
|
||||
|
||||
// Held constant across reconstruction so resume differences come only from the inner agent identities.
|
||||
private const string OuterWorkflowAgentId = "workflow-agent";
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgentSession_WithStableInnerAgentIds_ResumesAcrossReconstructionAsync()
|
||||
{
|
||||
// Arrange: build a first-generation workflow agent whose inner agents have stable, explicit ids.
|
||||
AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: true);
|
||||
AgentSession session = await firstGeneration.CreateSessionAsync();
|
||||
|
||||
// Act: complete a first turn (triage hands off to the specialist), then serialize the session.
|
||||
AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session);
|
||||
firstResponse.Text.Should().Be(
|
||||
$"{SpecialistReply}:turn:1",
|
||||
"the first turn should route triage -> specialist, and the specialist observes a single user turn");
|
||||
|
||||
JsonElement serialized = await firstGeneration.SerializeSessionAsync(session);
|
||||
|
||||
// Reconstruct a completely fresh object graph (new clients, agents, and workflow) using the same stable ids,
|
||||
// modeling a second dependency-injection scope.
|
||||
AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: true);
|
||||
AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized);
|
||||
|
||||
AgentResponse secondResponse = await secondGeneration.RunAsync("Anything else?", resumedSession);
|
||||
|
||||
// Assert: the specialist observes both user turns, which is only possible if the checkpointed conversation was
|
||||
// restored. A fresh (non-resumed) session would restart the count at turn:1, so this distinguishes a genuine
|
||||
// resume from a compatible-but-empty restart.
|
||||
secondResponse.Text.Should().Be(
|
||||
$"{SpecialistReply}:turn:2",
|
||||
"stable inner agent ids keep the executor identities compatible and the reconstructed workflow resumes from the checkpoint");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgentSession_WithoutStableInnerAgentIds_FailsAcrossReconstructionAsync()
|
||||
{
|
||||
// Arrange: build a first-generation workflow agent whose inner agents receive random ids (no explicit id).
|
||||
AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: false);
|
||||
AgentSession session = await firstGeneration.CreateSessionAsync();
|
||||
|
||||
// Complete a first turn and serialize the session; a completed handoff turn captures a checkpoint.
|
||||
AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session);
|
||||
firstResponse.Text.Should().Contain(SpecialistReply, "the first turn should route triage -> specialist");
|
||||
|
||||
JsonElement serialized = await firstGeneration.SerializeSessionAsync(session);
|
||||
|
||||
// Reconstruct with new random inner ids but the SAME outer workflow-agent id, proving that a stable outer id
|
||||
// alone does not stabilize the inner executor identities.
|
||||
AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: false);
|
||||
|
||||
// Act: deserialization itself succeeds; the incompatibility surfaces only when the resuming run validates the
|
||||
// checkpoint against the reconstructed workflow.
|
||||
AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized);
|
||||
|
||||
Func<Task> resumeAndRun = () => secondGeneration.RunAsync("Anything else?", resumedSession);
|
||||
|
||||
// Assert: the second run throws because the reconstructed executor ids no longer match the checkpoint.
|
||||
await resumeAndRun.Should().ThrowAsync<InvalidDataException>()
|
||||
.WithMessage("The specified checkpoint is not compatible with the workflow associated with this runner.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task WorkflowAgentSession_WithStableIdsButChangedInnerNames_FailsAcrossReconstructionAsync()
|
||||
{
|
||||
// Arrange: first generation uses stable ids and the default inner names.
|
||||
AIAgent firstGeneration = BuildWorkflowAgent(useStableInnerIds: true);
|
||||
AgentSession session = await firstGeneration.CreateSessionAsync();
|
||||
|
||||
AgentResponse firstResponse = await firstGeneration.RunAsync("Please help me.", session);
|
||||
firstResponse.Text.Should().Contain(SpecialistReply, "the first turn should route triage -> specialist");
|
||||
|
||||
JsonElement serialized = await firstGeneration.SerializeSessionAsync(session);
|
||||
|
||||
// Reconstruct with the SAME stable ids but different inner names. Because the executor id is derived from
|
||||
// both the name and the id, changing only the name still breaks checkpoint compatibility.
|
||||
AIAgent secondGeneration = BuildWorkflowAgent(useStableInnerIds: true, nameSuffix: "-renamed");
|
||||
|
||||
// Act: deserialization succeeds; the incompatibility surfaces on the resuming run.
|
||||
AgentSession resumedSession = await secondGeneration.DeserializeSessionAsync(serialized);
|
||||
|
||||
Func<Task> resumeAndRun = () => secondGeneration.RunAsync("Anything else?", resumedSession);
|
||||
|
||||
// Assert: changing a set name invalidates the executor identity even though the id is stable.
|
||||
await resumeAndRun.Should().ThrowAsync<InvalidDataException>()
|
||||
.WithMessage("The specified checkpoint is not compatible with the workflow associated with this runner.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fresh Handoff workflow-as-agent object graph. Every call constructs new chat clients, agents, and a
|
||||
/// new workflow, modeling reconstruction across dependency-injection scopes.
|
||||
/// </summary>
|
||||
/// <param name="useStableInnerIds">
|
||||
/// When <see langword="true"/>, each inner agent is assigned a deterministic <see cref="ChatClientAgentOptions.Id"/>.
|
||||
/// When <see langword="false"/>, the id is left unset so each agent receives a random per-instance id.
|
||||
/// </param>
|
||||
/// <param name="nameSuffix">
|
||||
/// Optional suffix appended to each inner agent's <see cref="ChatClientAgentOptions.Name"/>. Used to simulate a
|
||||
/// reconstruction that keeps ids stable but changes names.
|
||||
/// </param>
|
||||
private static AIAgent BuildWorkflowAgent(bool useStableInnerIds, string nameSuffix = "")
|
||||
{
|
||||
AIAgent triage = CreateTriageClient().AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = useStableInnerIds ? "triage-agent" : null,
|
||||
Name = TriageName + nameSuffix,
|
||||
Description = "Routes the request to a specialist.",
|
||||
ChatOptions = new() { Instructions = "Always hand off to the specialist." },
|
||||
});
|
||||
|
||||
AIAgent specialist = CreateSpecialistClient().AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Id = useStableInnerIds ? "specialist-agent" : null,
|
||||
Name = SpecialistName + nameSuffix,
|
||||
Description = "Handles the request once triage hands off.",
|
||||
ChatOptions = new() { Instructions = "Answer the request." },
|
||||
});
|
||||
|
||||
Workflow workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(triage)
|
||||
.WithHandoff(triage, specialist)
|
||||
.Build();
|
||||
|
||||
return workflow.AsAIAgent(id: OuterWorkflowAgentId, name: OuterWorkflowAgentId);
|
||||
}
|
||||
|
||||
// Triage hands off to the specialist by calling the handoff tool present on the request.
|
||||
private static StatelessMockChatClient CreateTriageClient() => new((messages, options) =>
|
||||
{
|
||||
string handoffTool = options?.Tools?
|
||||
.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name
|
||||
?? throw new InvalidOperationException("Expected a handoff tool to be available to the triage agent.");
|
||||
|
||||
return new ChatResponse(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("handoff-call", handoffTool)]));
|
||||
});
|
||||
|
||||
// The specialist echoes how many user turns it has observed. Because a genuine resume restores the prior turn
|
||||
// from the checkpoint, the count advances across turns, distinguishing a real resume from a fresh restart.
|
||||
private static StatelessMockChatClient CreateSpecialistClient() => new((messages, _) =>
|
||||
{
|
||||
int observedUserTurns = messages.Count(m => m.Role == ChatRole.User);
|
||||
return new ChatResponse(new ChatMessage(ChatRole.Assistant, $"{SpecialistReply}:turn:{observedUserTurns}"));
|
||||
});
|
||||
|
||||
/// <summary>
|
||||
/// A minimal <see cref="IChatClient"/> whose response is a pure function of the request, so reconstructing it
|
||||
/// preserves behavior.
|
||||
/// </summary>
|
||||
private sealed class StatelessMockChatClient(Func<IEnumerable<ChatMessage>, ChatOptions?, ChatResponse> responseFactory) : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(IEnumerable<ChatMessage> messages, ChatOptions? options = null, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(responseFactory(messages, options));
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
foreach (var update in (await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)).ToChatResponseUpdates())
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey = null) => null;
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,6 @@ If the user states target versions or a date explicitly, use exactly what they s
|
||||
|
||||
## Non-negotiable rules
|
||||
|
||||
- **Release workflow owns the CHANGELOG**: individual feature/fix PRs do not edit
|
||||
`python/CHANGELOG.md`; this workflow creates the entries centrally from merged changes.
|
||||
- **CHANGELOG-driven bumps**: only packages mentioned in the new CHANGELOG section get version bumps. Exceptions: root follows core (==pin); user-opted cohort bump on betas.
|
||||
- **Follow `python-package-management` for package lifecycle and versioning rules** — do not duplicate those
|
||||
rules in this release workflow.
|
||||
@@ -146,9 +144,6 @@ Root `agent-framework` is touched when `python/pyproject.toml`, `python/agent_fr
|
||||
|
||||
### 4. Draft CHANGELOG entries (THIS DRIVES THE BUMP LIST)
|
||||
|
||||
Individual feature/fix PRs intentionally do not add CHANGELOG entries. During release preparation, derive this section
|
||||
centrally from the merged PRs since the last released tag.
|
||||
|
||||
Locate `## [Unreleased]` and the top existing release header. INSERT a new section between them.
|
||||
|
||||
**New section structure:**
|
||||
|
||||
@@ -6,8 +6,6 @@ Instructions for AI coding agents working in the Python codebase.
|
||||
- [DEV_SETUP.md](DEV_SETUP.md) - Development environment setup and available poe tasks
|
||||
- [CODING_STANDARD.md](CODING_STANDARD.md) - Coding standards, docstring format, and performance guidelines
|
||||
- [samples/SAMPLE_GUIDELINES.md](samples/SAMPLE_GUIDELINES.md) - Sample structure and guidelines
|
||||
- [Python function-calling loop specification](../docs/specs/004-python-function-calling-loop.md) - Required
|
||||
behavior, scenario-to-test mapping, coverage gaps, and extra validation for function-loop changes
|
||||
|
||||
**Agent Skills** (`.github/skills/`) — detailed, task-specific instructions loaded on demand:
|
||||
- `python-development` — coding standards, type annotations, docstrings, logging, performance
|
||||
@@ -39,13 +37,6 @@ team norms from a single conversation without explicit confirmation.
|
||||
match the feature-lifecycle stages documented in the
|
||||
`python-feature-lifecycle` skill.
|
||||
|
||||
## Changelog Ownership
|
||||
|
||||
- Individual feature, fix, documentation, and dependency PRs must not update
|
||||
`python/CHANGELOG.md`.
|
||||
- CHANGELOG entries and release sections are assembled centrally during the
|
||||
Python release-preparation workflow.
|
||||
|
||||
## Pull Request Description Guidance
|
||||
|
||||
When preparing a PR description:
|
||||
@@ -57,16 +48,6 @@ When preparing a PR description:
|
||||
|
||||
Run `uv run poe` from the `python/` directory to see available commands. See [DEV_SETUP.md](DEV_SETUP.md) for detailed usage.
|
||||
|
||||
## Function-Calling Loop Changes
|
||||
|
||||
Changes to the Python function-calling loop, approval resume behavior, function-call history, provider
|
||||
serialization, or transport result handling must follow
|
||||
[the function-calling loop specification](../docs/specs/004-python-function-calling-loop.md). This area requires
|
||||
extra validation because small changes can duplicate side effects, orphan call/result pairs, replay stale approval
|
||||
authority, or make streaming and non-streaming behavior diverge. Update the specification and its scenario-to-test
|
||||
mapping whenever coverage or behavior changes. External contributors must check with the Agent Framework core team
|
||||
before picking up issues in this area.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
|
||||
+1
-49
@@ -7,53 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.13.0] - 2026-07-30
|
||||
|
||||
### Added
|
||||
- **agent-framework-core**, **agent-framework-foundry-hosting**: Add bounded, in-memory archive skill discovery for MCP sources and expose archive controls through `FoundryToolbox` ([#7121](https://github.com/microsoft/agent-framework/pull/7121))
|
||||
- **agent-framework-core**, **agent-framework-foundry-hosting**, **agent-framework-hosting**, **agent-framework-hosting-responses**, **agent-framework-hosting-telegram**: Add reusable session stores and persist complete Foundry Responses sessions ([#7306](https://github.com/microsoft/agent-framework/pull/7306))
|
||||
- **agent-framework-a2a**, **agent-framework-ag-ui**, **agent-framework-anthropic**, **agent-framework-azure-ai-search**, **agent-framework-azure-contentunderstanding**, **agent-framework-azure-cosmos**, **agent-framework-azure-cosmos-memory**, **agent-framework-azurefunctions**, **agent-framework-bedrock**, **agent-framework-chatkit**, **agent-framework-claude**, **agent-framework-copilotstudio**, **agent-framework-core**, **agent-framework-declarative**, **agent-framework-devui**, **agent-framework-durabletask**, **agent-framework-foundry**, **agent-framework-foundry-hosting**, **agent-framework-foundry-local**, **agent-framework-gemini**, **agent-framework-github-copilot**, **agent-framework-hosting**, **agent-framework-hosting-a2a**, **agent-framework-hosting-mcp**, **agent-framework-hosting-responses**, **agent-framework-hosting-telegram**, **agent-framework-hyperlight**, **agent-framework-lab**, **agent-framework-mem0**, **agent-framework-mistral**, **agent-framework-monty**, **agent-framework-ollama**, **agent-framework-openai**, **agent-framework-orchestrations**, **agent-framework-purview**, **agent-framework-redis**, **agent-framework-tools**: Add process-wide feature-usage telemetry with first-party User-Agent reporting ([#7420](https://github.com/microsoft/agent-framework/pull/7420))
|
||||
- **agent-framework-core**, **agent-framework-openai**: Add OpenAI cache-write token usage details to observability data ([#7369](https://github.com/microsoft/agent-framework/pull/7369))
|
||||
- **agent-framework-openai**: Support ephemeral per-request instructions in the Responses API ([#7292](https://github.com/microsoft/agent-framework/pull/7292))
|
||||
- **agent-framework-foundry-hosting**: Support asynchronous credentials in `FoundryToolbox` ([#7208](https://github.com/microsoft/agent-framework/pull/7208))
|
||||
- **samples**: Demonstrate archive-backed MCP skills with progressive disclosure in the Foundry Toolbox sample ([#7121](https://github.com/microsoft/agent-framework/pull/7121))
|
||||
- **samples**: Add FileMemoryProvider, TodoProvider, AgentModeProvider, and GitHub Copilot BYOK samples ([#7309](https://github.com/microsoft/agent-framework/pull/7309), [#7336](https://github.com/microsoft/agent-framework/pull/7336), [#7428](https://github.com/microsoft/agent-framework/pull/7428))
|
||||
|
||||
### Changed
|
||||
- **agent-framework-ag-ui**, **agent-framework-core**: [BREAKING] Make workflow checkpoints fully replayable from initial input and human-in-the-loop responses ([#7374](https://github.com/microsoft/agent-framework/pull/7374))
|
||||
- **agent-framework-devui**: Update frontend transitive dependencies ([#7232](https://github.com/microsoft/agent-framework/pull/7232))
|
||||
- **samples**: Remove numeric prefixes from Foundry Hosted Agent sample names and update frontend dependencies ([#7314](https://github.com/microsoft/agent-framework/pull/7314), [#7315](https://github.com/microsoft/agent-framework/pull/7315), [#7405](https://github.com/microsoft/agent-framework/pull/7405))
|
||||
- **tests**: Add isolated lower- and upper-bound release validation and improve Python package-management tooling ([#7274](https://github.com/microsoft/agent-framework/pull/7274), [#7342](https://github.com/microsoft/agent-framework/pull/7342))
|
||||
|
||||
### Fixed
|
||||
- **agent-framework-core**: Restrict unpickler module-prefix allowances to type objects ([#5923](https://github.com/microsoft/agent-framework/pull/5923))
|
||||
- **agent-framework-core**: Apply MCP `header_provider` headers to initialization and ambient requests ([#7305](https://github.com/microsoft/agent-framework/pull/7305))
|
||||
- **agent-framework-core**: Enforce the documented target-type check in `SerializationMixin.from_dict` ([#7256](https://github.com/microsoft/agent-framework/pull/7256))
|
||||
- **agent-framework-ag-ui**: Preserve model emission order in `MESSAGES_SNAPSHOT` events ([#7239](https://github.com/microsoft/agent-framework/pull/7239))
|
||||
- **agent-framework-foundry**: Prevent agent-reference requests from inheriting `OPENAI_CHAT_MODEL` ([#7283](https://github.com/microsoft/agent-framework/pull/7283))
|
||||
- **agent-framework-core**: Bound summarization input before provider calls ([#7375](https://github.com/microsoft/agent-framework/pull/7375))
|
||||
- **agent-framework-openai**: Sanitize Chat Completions message author names ([#7127](https://github.com/microsoft/agent-framework/pull/7127))
|
||||
- **agent-framework-anthropic**: Prevent streaming token usage from being counted twice ([#7162](https://github.com/microsoft/agent-framework/pull/7162))
|
||||
- **agent-framework-core**: Preserve declaration-only streaming metadata ([#7409](https://github.com/microsoft/agent-framework/pull/7409))
|
||||
- **agent-framework-openai**: Preserve approval decisions across Responses API continuations ([#7407](https://github.com/microsoft/agent-framework/pull/7407))
|
||||
- **agent-framework-core**: Omit tool content returned after function invocation limits are reached ([#7408](https://github.com/microsoft/agent-framework/pull/7408))
|
||||
- **agent-framework-core**: Keep function-call and result occurrences atomic during compaction ([#7406](https://github.com/microsoft/agent-framework/pull/7406))
|
||||
- **agent-framework-ag-ui**: Correlate `confirm_changes` snapshots by call ID ([#7411](https://github.com/microsoft/agent-framework/pull/7411))
|
||||
- **agent-framework-ag-ui**: Defer provider-injected approvals to in-run execution ([#7410](https://github.com/microsoft/agent-framework/pull/7410))
|
||||
- **agent-framework-foundry-hosting**: Store hosted checkpoints under the durable home directory ([#7220](https://github.com/microsoft/agent-framework/pull/7220))
|
||||
- **agent-framework-ag-ui**, **agent-framework-core**: Improve function approval resume and replay behavior ([#7345](https://github.com/microsoft/agent-framework/pull/7345))
|
||||
- **agent-framework-openai**: Pass raw JSON Schema response formats through Chat Completions without wrapping ([#7199](https://github.com/microsoft/agent-framework/pull/7199))
|
||||
- **agent-framework-core**: Extract keywords from non-English text for topic selection ([#7130](https://github.com/microsoft/agent-framework/pull/7130))
|
||||
- **agent-framework-gemini**: Preserve harness tool declarations ([#7322](https://github.com/microsoft/agent-framework/pull/7322))
|
||||
- **agent-framework-core**: Handle callable-class middleware safely during type detection ([#7333](https://github.com/microsoft/agent-framework/pull/7333))
|
||||
- **agent-framework-core**: Reject Windows junctions in `FileSystemAgentFileStore` ([#7291](https://github.com/microsoft/agent-framework/pull/7291))
|
||||
- **agent-framework-azurefunctions**, **agent-framework-core**, **agent-framework-durabletask**: Preserve sub-workflow state during checkpoint restoration ([#7097](https://github.com/microsoft/agent-framework/pull/7097))
|
||||
- **agent-framework-core**: Prevent duplicate function calls after approval round trips ([#7271](https://github.com/microsoft/agent-framework/pull/7271))
|
||||
- **agent-framework-foundry-hosting**: Preserve authentication credentials across `FoundryToolbox` reconnections ([#7202](https://github.com/microsoft/agent-framework/pull/7202))
|
||||
- **agent-framework-ag-ui**: Scope workflow caches correctly ([#7277](https://github.com/microsoft/agent-framework/pull/7277))
|
||||
- **samples**: Fix stale `agent.json` references in the A2A sample ([#7281](https://github.com/microsoft/agent-framework/pull/7281))
|
||||
- **tests**: Fix GitHub Actions token propagation for Python integration and sample-validation workflows ([#7427](https://github.com/microsoft/agent-framework/pull/7427))
|
||||
|
||||
## [github-copilot-1.0.0] - 2026-07-23
|
||||
|
||||
### Added
|
||||
@@ -1474,8 +1427,7 @@ Release candidate for **agent-framework-core** and **agent-framework-azure-ai**
|
||||
|
||||
For more information, see the [announcement blog post](https://devblogs.microsoft.com/foundry/introducing-microsoft-agent-framework-the-open-source-engine-for-agentic-ai-apps/).
|
||||
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.13.0...HEAD
|
||||
[1.13.0]: https://github.com/microsoft/agent-framework/compare/python-1.12.1...python-1.13.0
|
||||
[Unreleased]: https://github.com/microsoft/agent-framework/compare/python-1.12.1...HEAD
|
||||
[1.12.1]: https://github.com/microsoft/agent-framework/compare/python-1.12.0...python-1.12.1
|
||||
[1.12.0]: https://github.com/microsoft/agent-framework/compare/python-1.11.0...python-1.12.0
|
||||
[1.11.0]: https://github.com/microsoft/agent-framework/compare/python-1.10.0...python-1.11.0
|
||||
|
||||
+1
-2
@@ -384,8 +384,7 @@ uv run poe check -S
|
||||
```
|
||||
|
||||
#### `validate-dependency-bounds-test`
|
||||
Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs tests plus Pyright (or a
|
||||
package-specific `dependency-pyright` task) across all packages and stops on first failure:
|
||||
Run workspace-wide dependency compatibility gates at lower and upper resolutions. This runs test + pyright across all packages and stops on first failure:
|
||||
```bash
|
||||
uv run poe validate-dependency-bounds-test
|
||||
# Defaults to --package "*"; pass a package to scope test mode
|
||||
|
||||
@@ -80,63 +80,12 @@ listed below.
|
||||
helper checks defined in `agent_framework/_evaluation.py`
|
||||
- `agent-framework-foundry`: `FoundryEvals`, `evaluate_traces`, and `evaluate_foundry_target`
|
||||
|
||||
#### `FILE_HISTORY`
|
||||
#### `SKILLS`
|
||||
|
||||
- `agent-framework-core`: `FileHistoryProvider` from `agent_framework/_sessions.py`
|
||||
|
||||
#### `FIDES`
|
||||
|
||||
- `agent-framework-core`: security labeling, content indirection, policy enforcement, and secure MCP
|
||||
APIs from `agent_framework/security.py`, including `IntegrityLabel`, `ConfidentialityLabel`,
|
||||
`ContentLabel`, `ContentVariableStore`, `SecureAgentConfig`, and `SecureMCPToolProxy`
|
||||
|
||||
#### `FOUNDRY_TOOLS`
|
||||
|
||||
- `agent-framework-foundry`: released-service tool helpers on `FoundryChatClient`, currently
|
||||
`get_bing_grounding_tool` and `get_azure_ai_search_tool`
|
||||
|
||||
#### `FOUNDRY_PREVIEW_TOOLS`
|
||||
|
||||
- `agent-framework-foundry`: preview-service tool helpers on `FoundryChatClient`, including Bing
|
||||
Custom Search, SharePoint, Fabric, Memory Search, Computer Use, Browser Automation, and A2A
|
||||
|
||||
#### `FUNCTIONAL_WORKFLOWS`
|
||||
|
||||
- `agent-framework-core`: functional workflow APIs from
|
||||
`agent_framework/_workflows/_functional.py`, including `RunContext`, `step`,
|
||||
`FunctionalWorkflow`, `workflow`, and `FunctionalWorkflowAgent`
|
||||
|
||||
#### `HARNESS`
|
||||
|
||||
- `agent-framework-core`: experimental harness APIs for background agents, file access, looping,
|
||||
memory, and file-backed todo storage under `agent_framework/_harness/`
|
||||
|
||||
#### `MCP_LONG_RUNNING_TASKS`
|
||||
|
||||
- `agent-framework-core`: `MCPTaskOptions` from `agent_framework/_mcp.py`
|
||||
|
||||
#### `MCP_SKILLS`
|
||||
|
||||
- `agent-framework-core`: `MCPSkillResource`, `MCPSkill`, and `MCPSkillsSource` from
|
||||
- `agent-framework-core`: exported skills APIs from `agent_framework`, including `Skill`,
|
||||
`SkillResource`, `SkillScript`, `SkillScriptRunner`, and `SkillsProvider` from
|
||||
`agent_framework/_skills.py`
|
||||
|
||||
#### `PROGRESSIVE_TOOLS`
|
||||
|
||||
- `agent-framework-core`: `FunctionInvocationContext.add_tools` and
|
||||
`FunctionInvocationContext.remove_tools` from `agent_framework/_middleware.py`
|
||||
|
||||
#### `SESSION_STORE`
|
||||
|
||||
- `agent-framework-core`: `SessionStore` and `FileSessionStore` from
|
||||
`agent_framework/_sessions.py`
|
||||
- `agent-framework-foundry-hosting`: `FoundrySessionStore` from
|
||||
`agent_framework_foundry_hosting/_session_store.py`
|
||||
|
||||
#### `TO_PROMPT_AGENT`
|
||||
|
||||
- `agent-framework-foundry`: `to_prompt_agent` from
|
||||
`agent_framework_foundry/_to_prompt_agent.py`
|
||||
|
||||
### Release-candidate features
|
||||
|
||||
There are currently no feature-level `rc` APIs.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user