Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7464a59228 | |||
| 01ec3b7bcf | |||
| ce96fd4b72 | |||
| 52237b8eff | |||
| e6cc2c09af | |||
| 7440b1c376 | |||
| 3f4ffc6c2c | |||
| d43e52df69 | |||
| fbaa346eec | |||
| f5c078b70f | |||
| 9f4526a41e | |||
| 13fc425bf5 | |||
| 5f9ac6b394 | |||
| 1aca7601b8 | |||
| 9a5312b278 | |||
| 978cfcd9e4 | |||
| 7a73455e56 | |||
| 1b4484829c | |||
| 346d3f0820 | |||
| 9cc020e486 | |||
| 9ef8fadeac | |||
| 23bfa49575 | |||
| c47f20d9a2 | |||
| ab90300a71 | |||
| c64f8d9e86 | |||
| 0b49609176 | |||
| 2aae66d063 | |||
| 5adc038b16 | |||
| 7f31bcc294 | |||
| 62456f044d | |||
| 1249275997 | |||
| f280742c01 | |||
| 0438ee61c6 | |||
| 76f2c1a0c9 | |||
| 094d8d209a | |||
| 783e8c4568 | |||
| 12b029858e | |||
| e26c8591ae | |||
| 7f551f057a | |||
| 0661c69b78 | |||
| 2889475bce | |||
| 339bbeb881 | |||
| 5e0793542d | |||
| de39be9e58 | |||
| fb4be3bb1f | |||
| b31c8981a4 | |||
| 96e009f87a | |||
| 93189e437d | |||
| 3d17615b6e | |||
| 03a96faf43 | |||
| 07981847ed | |||
| 81e425b44f | |||
| 757a832dbf | |||
| cc20093da1 | |||
| 9c4cd07899 | |||
| cba9a1c050 | |||
| c67372ff32 | |||
| bcd2800d4c | |||
| 38b5f70d1f | |||
| 868744aeea | |||
| 5ac5038545 | |||
| fc10ef31bd | |||
| c09408cbd6 | |||
| 329d59eff4 | |||
| a5fcd33967 | |||
| ccba1fbbef | |||
| 2c7aadce4e | |||
| 8be157e2a1 | |||
| 7ca73c0645 | |||
| 0260ea0e61 | |||
| 24581d6865 | |||
| 331d17c5a1 | |||
| db80926f31 | |||
| 48436f8ab6 | |||
| 551b44f04f | |||
| 09ea690062 | |||
| 62f0024707 | |||
| e38592a23c | |||
| 08a09e7ebb | |||
| c1e20632f7 | |||
| c41676682e | |||
| effbd17325 | |||
| bc8dd4b63c | |||
| 0f1fa21070 | |||
| 300dfa7e36 | |||
| e56f34c521 | |||
| 51c05fc862 | |||
| b7fc23c61f | |||
| 00e4d4ffde | |||
| 2cb97545bf | |||
| d50698bb79 | |||
| 059e1e055f | |||
| 43f2095244 | |||
| f01bea77bb | |||
| a25756b9ec | |||
| f9b2fbb676 | |||
| 7f3a2aec38 | |||
| 09fbccfb20 | |||
| 4cb1a6651d | |||
| 3cc511f0ca | |||
| a2f56f7688 |
@@ -42,7 +42,7 @@ jobs:
|
||||
coreChanged: ${{ steps.filter.outputs.core }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -610,12 +610,12 @@ jobs:
|
||||
python-version: "3.13"
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: dotnet-test-results-*
|
||||
path: dotnet-test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
@@ -632,7 +632,7 @@ jobs:
|
||||
run: cat dotnet-integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/dotnet-integration-report-history.json
|
||||
key: dotnet-integration-report-history-${{ github.run_id }}
|
||||
|
||||
@@ -54,11 +54,14 @@ jobs:
|
||||
- name: Find csproj files
|
||||
id: find-csproj
|
||||
if: github.event_name != 'pull_request' || steps.changed-files.outputs.added_modified != '' || steps.changed-files.outcome == 'failure'
|
||||
env:
|
||||
ADDED_MODIFIED: ${{ steps.changed-files.outputs.added_modified }}
|
||||
run: |
|
||||
csproj_files=()
|
||||
exclude_files=("Experimental.Orchestration.Flow.csproj" "Experimental.Orchestration.Flow.UnitTests.csproj" "Experimental.Orchestration.Flow.IntegrationTests.csproj")
|
||||
set -f
|
||||
if [[ ${{ steps.changed-files.outcome }} == 'success' ]]; then
|
||||
for file in ${{ steps.changed-files.outputs.added_modified }}; do
|
||||
for file in $ADDED_MODIFIED; do
|
||||
echo "$file was changed"
|
||||
dir="./$file"
|
||||
while [[ $dir != "." && $dir != "/" && $dir != $GITHUB_WORKSPACE ]]; do
|
||||
@@ -80,6 +83,7 @@ jobs:
|
||||
csproj_files=($(printf "%s\n" "${csproj_files[@]}" | sort -u))
|
||||
echo "Found ${#csproj_files[@]} unique csproj/slnx files: ${csproj_files[*]}"
|
||||
echo "csproj_files=${csproj_files[*]}" >> $GITHUB_OUTPUT
|
||||
set +f
|
||||
|
||||
- name: Pull container dotnet/sdk:${{ matrix.dotnet }}
|
||||
if: steps.find-csproj.outputs.csproj_files != ''
|
||||
@@ -88,8 +92,11 @@ jobs:
|
||||
# This step will run dotnet format on each of the unique csproj files and fail if any changes are made
|
||||
- name: Run dotnet format
|
||||
if: steps.find-csproj.outputs.csproj_files != ''
|
||||
env:
|
||||
CSPROJ_FILES: ${{ steps.find-csproj.outputs.csproj_files }}
|
||||
run: |
|
||||
for csproj in ${{ steps.find-csproj.outputs.csproj_files }}; do
|
||||
set -f
|
||||
for csproj in $CSPROJ_FILES; do
|
||||
echo "Running dotnet format on $csproj"
|
||||
docker run --rm -v $(pwd):/app -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} /bin/sh -c "dotnet format $csproj --verify-no-changes --verbosity diagnostic"
|
||||
docker run --rm -v "$(pwd):/app" -w /app mcr.microsoft.com/dotnet/sdk:${{ matrix.dotnet }} dotnet format "$csproj" --verify-no-changes --verbosity diagnostic
|
||||
done
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
with:
|
||||
path: ~/.cache/prek
|
||||
key: prek|${{ matrix.python-version }}|${{ hashFiles('python/.pre-commit-config.yaml') }}
|
||||
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # v1
|
||||
- uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||
name: Run Pre-commit Hooks (excluding poe-check)
|
||||
env:
|
||||
SKIP: poe-check
|
||||
|
||||
@@ -177,7 +177,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -546,12 +546,12 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
@@ -568,7 +568,7 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-integration-${{ github.run_id }}
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
pythonChanges: ${{ steps.filter.outputs.python}}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
githubCopilotChanged: ${{ steps.filter.outputs.github_copilot }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
- uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -298,7 +298,7 @@ jobs:
|
||||
run: curl -fsSL https://ollama.com/install.sh | sh
|
||||
working-directory: .
|
||||
- name: Cache Ollama models
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ~/.ollama/models
|
||||
key: ollama-models-qwen2.5-1.5b-nomic-embed-text-v1
|
||||
@@ -743,12 +743,12 @@ jobs:
|
||||
python-version: ${{ env.UV_PYTHON }}
|
||||
os: ${{ runner.os }}
|
||||
- name: Download all test results from current run
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: test-results-*
|
||||
path: test-results/
|
||||
- name: Restore report history cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
@@ -765,7 +765,7 @@ jobs:
|
||||
run: cat integration-test-report.md >> $GITHUB_STEP_SUMMARY
|
||||
- name: Save report history cache
|
||||
if: always()
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: python/integration-report-history.json
|
||||
key: integration-report-history-merge-${{ github.run_id }}
|
||||
|
||||
@@ -693,7 +693,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Download all validation reports
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: validation-report-*
|
||||
path: reports/
|
||||
@@ -701,7 +701,7 @@ jobs:
|
||||
|
||||
- name: Restore validation history
|
||||
id: cache-restore
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
@@ -719,7 +719,7 @@ jobs:
|
||||
run: cat trend-report.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save validation history
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: validation-history/
|
||||
key: validation-history-${{ github.run_id }}
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
echo "PR_NUMBER=$ARTIFACT_PR_NUMBER" >> "$GITHUB_ENV"
|
||||
- name: Pytest coverage comment
|
||||
id: coverageComment
|
||||
uses: MishaKav/pytest-coverage-comment@26f986d2599c288bb62f623d29c2da98609e9cd4 # v1.6.0
|
||||
uses: MishaKav/pytest-coverage-comment@dd5b80bde6d16941f336518e92929e89069d8451 # v1.7.2
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
issue-number: ${{ env.PR_NUMBER }}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
[](https://learn.microsoft.com/en-us/agent-framework/)
|
||||
[](https://pypi.org/project/agent-framework/)
|
||||
[](https://www.nuget.org/profiles/MicrosoftAgentFramework/)
|
||||
[](https://github.com/microsoft/agent-framework/stargazers)
|
||||
[](https://github.com/microsoft/agent-framework)
|
||||
|
||||
|
||||
Microsoft Agent Framework (MAF) is an open, multi-language framework for building **production-grade AI agents and multi-agent workflows** in **.NET and Python**.
|
||||
|
||||
@@ -1,144 +1,518 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-11
|
||||
date: 2026-06-30
|
||||
deciders: eavanvalkenburg
|
||||
consulted: rogerbarreto, moonbox3
|
||||
---
|
||||
|
||||
# Python minimal hosting core and pluggable channels
|
||||
# Python protocol helpers and optional execution state
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
Agent Framework has several protocol-specific hosting surfaces. App authors who want one agent or workflow on multiple protocols must compose servers, routes, middleware, session handling, and lifecycle code by hand.
|
||||
Agent Framework needs to help applications expose agents and workflows over external protocols such as OpenAI
|
||||
Responses, Telegram, Activity Protocol, and future transports.
|
||||
|
||||
We will introduce a small Python hosting core that owns the common server shape and leaves protocol details inside channel packages. The first public contract must be intentionally narrow so Python can ship a base contract before adding identity linking, proactive delivery, or multicast behavior. Other language implementations may reuse the same conceptual boundary, but this ADR records the Python decision.
|
||||
FastAPI, Starlette, Azure Functions, Django, Telegram SDKs, Bot Framework SDKs, and other app frameworks already own
|
||||
route registration, dependency injection, middleware, authentication, background tasks, lifecycle, and native client
|
||||
calls. Agent Framework should not duplicate those surfaces unless a specific hosting environment requires it.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Keep the first host easy to explain: one app, one hostable target, one or more channels.
|
||||
- Reuse Agent Framework's existing agent, workflow, session, history, and checkpoint primitives.
|
||||
- Let channel packages own protocol parsing, protocol responses, authentication details, and native command surfaces.
|
||||
- Make session continuity explicit through a channel-supplied `ChannelSession(isolation_key=...)`.
|
||||
- Avoid approving cross-channel identity and delivery semantics before their safety model is reviewed.
|
||||
- Keep the released surface small enough to explain without first teaching a channel framework.
|
||||
- Provide reusable Agent Framework run translation that works with FastAPI, Django, and other web frameworks.
|
||||
- Let app/framework code own route declaration, auth, middleware, native SDK clients, command handling, and background
|
||||
work.
|
||||
- Keep stateful execution support explicit: session lookup/storage and workflow checkpoint lookup/storage may still need
|
||||
a small AF-owned home.
|
||||
|
||||
## Considered Options
|
||||
|
||||
1. Keep only protocol-specific hosts.
|
||||
2. Ship a large hosting core with identity linking, authorization, background delivery, active-channel routing, and multicast in v1.
|
||||
3. Ship a minimal host/channel core now and track linking/multicast as follow-up work.
|
||||
1. Create protocol-specific hosts.
|
||||
2. Ship a full host/channel framework with route contribution and channel hooks.
|
||||
3. Ship protocol conversion helpers plus optional execution state.
|
||||
|
||||
### Keep only protocol-specific hosts
|
||||
### 1. Create protocol-specific hosts
|
||||
|
||||
- Good: no new abstraction or package surface.
|
||||
- Neutral: each protocol can continue evolving independently.
|
||||
- Bad: every multi-channel app still has to compose servers, lifecycle, and session handling by hand.
|
||||
- Good: no new shared abstraction.
|
||||
- Neutral: each protocol host can evolve independently.
|
||||
- Bad: every package reinvents AF input/result mapping, session-key conventions, and stateful execution helpers.
|
||||
|
||||
### Ship the large cross-channel host in v1
|
||||
### 2. Ship a full host/channel framework
|
||||
|
||||
- Good: the richest cross-channel scenarios are available immediately.
|
||||
- Neutral: the host becomes the natural place to demonstrate identity and delivery policy.
|
||||
- Bad: v1 becomes a security-sensitive identity and delivery system before the safety model is reviewed.
|
||||
- Good: one object can assemble routes, channels, session handling, hooks, and lifecycle callbacks.
|
||||
- Good: app code using the supported host shape can be short.
|
||||
- Bad: the framework owns concerns already handled by web frameworks, protocol SDKs and/or other services.
|
||||
- Bad: users must understand `Channel`, contribution, hook, and host-dispatch concepts before they can see how a request
|
||||
becomes `agent.run(...)`.
|
||||
- Bad: the abstraction is hard to reuse outside the chosen web framework.
|
||||
|
||||
### Ship the minimal core now
|
||||
### 3. Ship protocol helpers plus optional execution state
|
||||
|
||||
- Good: the host/channel boundary can be implemented, tested, and explained without solving linking and durable delivery at the same time.
|
||||
- Neutral: apps that need richer behavior must build it locally or wait for ADR-0028 follow-up work.
|
||||
- Bad: proactive delivery and multicast scenarios are deliberately absent from v1.
|
||||
- Good: protocol packages provide the Agent Framework run value directly: `<protocol>_to_run(...)` and
|
||||
`<protocol>_from_run(...)` style helpers.
|
||||
- Good: apps keep native FastAPI, Starlette, Azure Functions, Django, Bot Framework, or Telegram SDK code.
|
||||
- Good: helper functions can be tested without a web framework app or host pipeline.
|
||||
- Good: small state objects can still own target-coupled state: `AgentState` pairs an agent target with a `SessionStore`,
|
||||
and `WorkflowState` resolves a workflow target while reusing the existing `CheckpointStorage` abstraction.
|
||||
- Good: provides maximum configurability in handling input and outputs (outside of the conversions)
|
||||
- Bad: building a first iteration of a new Host is more verbose.
|
||||
- Bad: samples show more explicit route/client code than a fully assembled channel host.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Chosen option: **minimal host/channel core now, follow-up enhancements later**.
|
||||
Chosen option: **3. Ship protocol helpers plus optional execution state**.
|
||||
|
||||
`AgentFrameworkHost` owns:
|
||||
Protocol packages own:
|
||||
|
||||
- one application object,
|
||||
- one hostable target (`SupportsAgentRun` agent-compatible object or a `Workflow`), and
|
||||
- one or more channels.
|
||||
- parsing protocol-native input into Agent Framework run input and options;
|
||||
- rendering `AgentResponse`, `AgentResponseUpdate`, workflow results, or workflow updates back into protocol-native
|
||||
response/event payloads;
|
||||
- protocol-specific isolation/session id helper functions when useful, such as `telegram_session_id(update)`;
|
||||
- protocol-specific typing/update event helpers where the protocol has a native concept.
|
||||
|
||||
Channels own:
|
||||
Application or web-framework code owns:
|
||||
|
||||
- contributed routes, middleware, commands, and lifecycle callbacks,
|
||||
- protocol-native request parsing into `ChannelRequest`,
|
||||
- protocol-native rendering of the originating response, and
|
||||
- any channel-specific authentication or signature validation.
|
||||
- HTTP route declaration and route grouping;
|
||||
- dependency injection;
|
||||
- authentication and authorization;
|
||||
- middleware;
|
||||
- background tasks and webhook acknowledgement policy;
|
||||
- native protocol SDK clients and outbound calls;
|
||||
- command registration and command dispatch;
|
||||
- request/response status codes and framework-specific error handling;
|
||||
- choosing the isolation/session id source for the current deployment and route.
|
||||
|
||||
The host owns:
|
||||
The application builder can make the server exactly as they see fit, but this is outside the responsibilities of this proposed scheme.
|
||||
This might include implementing other known API surfaces from vendors like OpenAI, such as creating conversations, vector stores, deleting things, etc.
|
||||
If they want they can build the full OpenAI API, but it will include code that does not rely on agent-framework-hosting, which is fine.
|
||||
They are responsible for what they expose.
|
||||
|
||||
- route/lifecycle aggregation,
|
||||
- invocation of the target,
|
||||
- `ChannelSession(isolation_key=...)` to `AgentSession` resolution and caching,
|
||||
- `reset_session(isolation_key=...)`,
|
||||
- host-level middleware, including Foundry isolation middleware only when the Foundry hosting environment flag is present,
|
||||
- invocation of per-channel hooks (`ChannelRunHook`, `ChannelResponseHook`, `ChannelStreamUpdateHook`), and
|
||||
- workflow checkpoint wiring through an explicit `checkpoint_location`.
|
||||
The optional execution-state helpers, if provided, are limited to shared execution state:
|
||||
|
||||
`ChannelIdentity`, when present, is request metadata only. In v1 it is not a linking, authorization, or delivery key.
|
||||
- `AgentState`: one `SupportsAgentRun`-compatible target plus a `SessionStore`;
|
||||
- `WorkflowState`: one `Workflow`, `WorkflowBuilder`-shaped builder, orchestration builder, or workflow factory;
|
||||
- `SessionStore`: plain async storage (`get` / `set` / `delete`) by an app-selected id.
|
||||
|
||||
### Trust boundary for `isolation_key`
|
||||
The store does not create sessions. `AgentState` provides the target-aware `get_or_create_session(...)` helper because
|
||||
only the state object has both the store and the resolved agent target. Workflow checkpointing should use the existing
|
||||
`CheckpointStorage` abstraction directly; app/state code may keep a small cursor (`session_id -> checkpoint_id`) when it
|
||||
needs to resume a workflow for a session.
|
||||
|
||||
The host treats `ChannelSession.isolation_key` as a session partition key, not as proof of identity. Channels or host middleware must authenticate and authorize any externally supplied value before passing it to the host. For example, a Responses caller must not be allowed to choose an arbitrary `previous_response_id` or header-derived key unless the platform or middleware has already established that the caller owns that conversation. The host deliberately does not infer that trust from the string itself.
|
||||
These objects are **not** app objects, channel registries, or route owners. They do not own FastAPI/Starlette setup,
|
||||
route contribution, protocol dispatch, command projection, or native SDK calls.
|
||||
|
||||
### Hook ownership
|
||||
### Helper naming and families
|
||||
|
||||
Channels provide hook configuration and protocol-native context. The host invokes those hooks as part of the common invocation pipeline:
|
||||
Helpers should be protocol-specific, not generic. Avoid a generic `protocol_to_run(...)` name in public samples because it
|
||||
hides the protocol-specific contract behind a second abstraction.
|
||||
|
||||
- `ChannelRunHook` runs after channel parsing and before target invocation.
|
||||
- `ChannelResponseHook` runs after target invocation and before the originating channel serializes its response.
|
||||
- `ChannelStreamUpdateHook` is applied by the host while the channel consumes streamed updates because streaming serialization is protocol-specific.
|
||||
Protocol packages should consider these helper families. This table is a set of examples, not a required protocol or
|
||||
checklist. Not every protocol needs every helper, but when a protocol has the concept the naming should stay consistent:
|
||||
|
||||
`ChannelStreamUpdateHook` is an update hook, not a final-response sanitizer. Channels that use it for redaction or filtering must also apply equivalent policy to any final response they render. Channels choose whether the response is streaming before run hooks execute.
|
||||
| Helper family | Shape | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Run conversion | `<protocol>_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. |
|
||||
| Final rendering | `<protocol>_from_run(...)` | Convert a final `AgentResponse` / workflow result into protocol-native response payloads or operations. |
|
||||
| Stream rendering | `<protocol>_from_streaming_run(...)` | Convert `ResponseStream` / workflow updates into protocol-native events or operations. |
|
||||
| Session id extraction | `<protocol>_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. |
|
||||
| Command/action parsing | `<protocol>_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. |
|
||||
|
||||
This keeps hook call conventions centralized while leaving protocol payload parsing and response formatting in channel packages.
|
||||
Examples:
|
||||
|
||||
### State owned by v1
|
||||
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
|
||||
`responses_session_id(...)`;
|
||||
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
|
||||
`telegram_session_id(...)`, `telegram_command(...)`;
|
||||
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
|
||||
- `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`.
|
||||
|
||||
`state_dir` is limited to host-owned local files for reset-session aliases and workflow checkpoint path derivation. It does not store linked identities, active-channel state, response-routing state, continuation records, durable runner queues, or delivery attempts. Those storage concerns belong to ADR-0028.
|
||||
The app still owns what a parsed command means. For example, a Telegram `/new`, Discord slash command, Bot Framework
|
||||
command activity, or A2A cancellation/request action may parse through a command/action helper, but the route or SDK
|
||||
handler decides whether that command clears a session, cancels a task, calls an agent, or is ignored.
|
||||
|
||||
Additional helper functions can be protocol-specific when the concept is not broadly shared. Examples include
|
||||
`telegram_chat_id(...)`, `telegram_callback_query_id(...)`, `telegram_media_file_id(...)`,
|
||||
`discord_interaction_id(...)`, `a2a_task_id(...)`, `a2a_context_id(...)`, and MCP tool/prompt/resource helpers. These
|
||||
helpers should still stay side-effect-free: they extract, normalize, or describe protocol data, while app/native SDK code
|
||||
performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers
|
||||
handlers.
|
||||
|
||||
### Security responsibilities for application builders
|
||||
|
||||
The application builder owns the trust boundary. Protocol helper packages can parse native payloads and expose candidate
|
||||
ids or operations, but they do not authenticate callers, authorize access to state, or decide which side effects are
|
||||
allowed.
|
||||
|
||||
Application code that uses these helpers are responsible for (this means that we advice you to think through these topics,
|
||||
but ultimately, the choice of which controls are needed for the intended use case is up to the application builder):
|
||||
|
||||
- authenticate the caller through the app's normal mechanism before using protocol-provided ids;
|
||||
- authorize any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading
|
||||
state for it;
|
||||
- bind externally supplied ids to the authenticated user, tenant, workspace, installation, or chat context before using
|
||||
them as `SessionStore` keys or checkpoint cursor keys;
|
||||
- treat `<protocol>_session_id(...)` results as untrusted candidate keys until that ownership check has passed;
|
||||
- keep platform-provided isolation helpers fail-closed outside their trusted hosting environment;
|
||||
- authorize command/action effects such as reset, cancel, approve, submit, or tool invocation after parsing them;
|
||||
- opt in explicitly before resolving protected media/resource/file URLs and passing them to a remote model provider;
|
||||
- persist post-run session or checkpoint state only after `agent.run(...)`, `workflow.run(...)`, or stream finalization has
|
||||
updated that state.
|
||||
|
||||
For Foundry specifically, helpers may read values established by Foundry hosting middleware, but must not treat raw
|
||||
request headers as trusted Foundry isolation when the app is running outside Foundry. Implementations must test that
|
||||
non-Foundry requests do not accept spoofable isolation headers as platform-provided keys.
|
||||
|
||||
For workflow checkpointing, the checkpoint boundary must be at least as specific as the authorized session/tenant
|
||||
boundary. A shared storage lookup such as "latest checkpoint for workflow name" is safe only when the storage is already
|
||||
scoped to the authorized session. In a shared durable store, map the authorized `session_id` to a checkpoint id or other
|
||||
cursor and load that specific checkpoint.
|
||||
|
||||
### Session continuity
|
||||
|
||||
Session continuity remains explicit. Run parsing and isolation/session id selection are separate operations because
|
||||
isolation can come from more than one source:
|
||||
|
||||
- protocol input, such as OpenAI Responses `previous_response_id`, a Telegram chat id, or an Activity conversation id;
|
||||
- running environment, such as Foundry Hosted Agents user/chat isolation context;
|
||||
- app-specific trusted middleware or route state.
|
||||
|
||||
The app chooses which helper to call for that route and deployment. For example:
|
||||
|
||||
- `responses_session_id(body)` from `agent-framework-hosting-responses`, which can return either a `resp_*` previous
|
||||
response id or a `conv_*` conversation id when present;
|
||||
- `telegram_session_id(update)` from `agent-framework-hosting-telegram`, which can choose the chat, user, thread, or
|
||||
other Telegram-native partitioning logic for that helper;
|
||||
- `activity_session_id(activity)`, `discord_session_id(interaction_or_message)`, or
|
||||
`a2a_session_id(request_context)` from their respective protocol packages;
|
||||
- `foundry_user_isolation_key()` or `foundry_chat_isolation_key()` from `agent-framework-foundry-hosting`.
|
||||
|
||||
Keep these helpers outside `responses_to_run(...)`, `telegram_to_run(...)`, and other run-input parsers. That makes the
|
||||
trust boundary visible: using a request-derived key is a different decision than using a platform-provided isolation key.
|
||||
|
||||
The application builder is also responsible for deciding whether the hosting environment is **persistent** (for example,
|
||||
a long-running container or web app) or **transient** (for example, Azure Functions, Foundry Hosted Agents, or any
|
||||
environment where process memory is not a reliable continuity boundary). That decision controls which state mechanisms are
|
||||
safe to use:
|
||||
|
||||
- persistent single-process apps may use in-memory state for local development or simple deployments, while still needing
|
||||
durable state for multi-replica continuity;
|
||||
- transient apps must not rely on in-memory `SessionStore` state between calls and need a durable session store or a
|
||||
service-owned continuation id;
|
||||
- workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable
|
||||
`session_id -> checkpoint_id` cursor because in-process workflow state and in-memory checkpoint cursors do not survive
|
||||
transient execution.
|
||||
|
||||
A `SessionStore` stores `session_id -> AgentSession`, but it does not create sessions. `AgentState` resolves the agent
|
||||
target and creates the session on first use:
|
||||
|
||||
For agent targets:
|
||||
|
||||
```python
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
result = await target.run(messages, session=session, options=options)
|
||||
```
|
||||
|
||||
If the protocol mints a new continuation id as part of the response being created (for example, OpenAI Responses
|
||||
`resp_*` ids), store the **post-run** session explicitly under that new id:
|
||||
|
||||
```python
|
||||
session = await state.get_or_create_session(previous_response_id)
|
||||
target = await state.get_target()
|
||||
result = await target.run(messages, session=session, options=options)
|
||||
await state.set_session(response_id, session)
|
||||
```
|
||||
|
||||
`agent.run(...)` may update the session object (for example, with service continuation state), so the explicit store call
|
||||
belongs after the run, not before it.
|
||||
|
||||
The session id is a partition key, not proof of identity. App or platform code must authenticate and authorize any
|
||||
externally supplied key before using it.
|
||||
|
||||
### Workflow checkpoints
|
||||
|
||||
Workflow checkpointing is execution state, not protocol state. `WorkflowState` pairs a workflow target with checkpoint
|
||||
state, but it should not wrap or replace the existing `CheckpointStorage` abstraction. Apps should pass the actual
|
||||
`CheckpointStorage` they want the workflow to use. If an app needs per-session resume, it can keep a small cursor from
|
||||
authorized `session_id` to `checkpoint_id` (or an equivalent store-specific resume token).
|
||||
|
||||
Workflow runs do not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default. The
|
||||
runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. App/state code that owns the storage can
|
||||
observe the latest id by querying the storage after a run, for example
|
||||
`await storage.get_latest(workflow_name=target.name)`.
|
||||
|
||||
For workflow targets, app code adapts the protocol helper output into the workflow's expected input and invokes the
|
||||
workflow through the state object's target:
|
||||
|
||||
```python
|
||||
# session_id must already be authenticated and authorized for this caller
|
||||
target = await state.get_target()
|
||||
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
|
||||
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
|
||||
if latest is not None:
|
||||
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
|
||||
```
|
||||
|
||||
If a route wants to resume from a prior checkpoint, it explicitly chooses the checkpoint and passes it to
|
||||
`workflow.run(...)`:
|
||||
|
||||
```python
|
||||
# session_id must already be authenticated and authorized for this caller
|
||||
target = await state.get_target()
|
||||
checkpoint_id = await checkpoint_cursor_store.get(session_id)
|
||||
if checkpoint_id is None:
|
||||
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
|
||||
else:
|
||||
result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage)
|
||||
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
|
||||
if latest is not None:
|
||||
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
|
||||
```
|
||||
|
||||
`workflow.run(...)` writes checkpoints to the provided storage, so storage selection must be explicit at the route layer.
|
||||
Protocol helper packages should not own checkpoint layout, route lifecycle, or durable execution.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
The following are deliberately **not** part of the v1 contract:
|
||||
The following remain outside the v1 protocol-helper contract. Some are deliberately app-owned in v1; others are possible
|
||||
future framework work only after a separate design.
|
||||
|
||||
- cross-channel identity linking (`IdentityLinker`, `local_identity_link`, or `agent-framework-hosting-entra`),
|
||||
- identity allowlists or authorization policy (`IdentityAllowlist`, `AuthPolicy`),
|
||||
- response routing beyond the originating channel (`ResponseTarget`, active channel, specific linked channel, `all_linked`),
|
||||
- push or payload codecs (`ChannelPush`, `ChannelPushCodec`),
|
||||
- background/continuation delivery,
|
||||
- durable task runners (`DurableTaskRunner`, `InProcessTaskRunner`),
|
||||
- retry/replay policy (`RetryPolicy`),
|
||||
- fan-out, multicast, or all-linked delivery,
|
||||
- confidentiality tiers and `LinkPolicy`, and
|
||||
- a host-level multi-agent router.
|
||||
### App-owned in v1
|
||||
|
||||
These areas are follow-up enhancements covered by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are not prerequisites for shipping or using the v1 host.
|
||||
The app builder owns these concerns with normal web-framework, SDK, platform, or application code:
|
||||
|
||||
- authentication, authorization policy, and allowlists;
|
||||
- deciding whether identities across protocols map to the same `session_id`;
|
||||
- non-originating sends using native SDK clients;
|
||||
- background work, durable execution, retry, and replay when app code owns the work;
|
||||
- routing between multiple agents.
|
||||
|
||||
This is easier in the protocol-helper model than it was in the host/channel model: app code already owns the native SDK
|
||||
clients, route handlers, authenticated caller context, session id selection, and outbound send calls. An app can link
|
||||
channels by choosing the same authorized `session_id` for multiple protocols, and can do non-originating delivery by
|
||||
calling the destination protocol's native client directly. That does not make a reusable framework feature safe by
|
||||
default; it just means the app-specific version no longer has to fight a host abstraction.
|
||||
|
||||
### Future framework work
|
||||
|
||||
The following require a reviewed identity, storage, delivery, replay, and observability model before becoming reusable
|
||||
framework features:
|
||||
|
||||
- reusable cross-channel identity linking;
|
||||
- framework-owned proactive or non-originating delivery;
|
||||
- fan-out, multicast, selected-channel, active-channel, or all-linked delivery;
|
||||
- framework-owned delivery observability, dead-letter handling, and replay semantics;
|
||||
- cross-channel confidentiality and link policy.
|
||||
|
||||
These possible framework enhancements are tracked by [ADR-0028](0028-hosting-linking-multicast-enhancements.md). They are
|
||||
not prerequisites for shipping or using the v1 protocol-helper surface. ADR-0028 was written against the earlier
|
||||
host/channel framing and must be revised to align with this protocol-helper and execution-state boundary before those
|
||||
enhancements are implemented.
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- The host/channel model can be implemented and tested without designing a security-sensitive identity graph.
|
||||
- Existing and new channel packages can share one Starlette app, middleware stack, lifecycle, and target invocation path.
|
||||
- Session continuity is explicit and debuggable: two channels share history only when they produce the same `isolation_key`.
|
||||
- Hook invocation is centralized in the host, so channels do not each invent the call convention.
|
||||
- The released surface is smaller and easier to inspect: helpers plus state, not a channel framework.
|
||||
- Protocol helpers can be used from FastAPI, Starlette, Azure Functions, Django, CLI tools, tests, or native SDK webhook
|
||||
handlers.
|
||||
- App authors can use the authentication, dependency injection, lifecycle, and background-task tools they already know.
|
||||
- Session continuity stays explicit and debuggable.
|
||||
- Workflow checkpointing can still be centralized if needed without making protocol packages own routing.
|
||||
|
||||
Negative:
|
||||
|
||||
- Apps that need OAuth linking, allowlists, proactive messages, or multicast must continue to implement those behaviors outside the v1 host.
|
||||
- Some richer cross-channel scenarios from the original design move to a separate decision and validation cycle.
|
||||
- The host must document `isolation_key` trust clearly because it now provides the shared session boundary.
|
||||
|
||||
## Validation Gates
|
||||
|
||||
Before this ADR is accepted:
|
||||
|
||||
- A sample can expose one target on multiple channels with one `AgentFrameworkHost` and no handwritten Starlette route composition.
|
||||
- Built-in channel tests prove that routes, commands, startup, and shutdown callbacks are contributed by channels and aggregated by the host.
|
||||
- Session tests prove that identical `ChannelSession.isolation_key` values resolve to the same cached `AgentSession`, and `reset_session` rotates that mapping.
|
||||
- Channel tests prove that each channel renders only its own originating response; there is no host-level push, multicast, or active-channel delivery path.
|
||||
- Workflow tests or samples use an explicit `checkpoint_location`.
|
||||
- Foundry isolation middleware is documented and covered by integration or contract tests, including the non-Foundry case where raw isolation headers are ignored.
|
||||
- The v1 API and packages do not expose the removed symbols or packages listed in [Non-goals for v1](#non-goals-for-v1).
|
||||
- The Python spec is updated to match this simplified contract and uses "public", "stable", or "released" terminology for Agent Framework APIs.
|
||||
- Multi-protocol samples include explicit route/client code.
|
||||
- Apps that want a batteries-included ASGI app must write or depend on an app-specific wrapper.
|
||||
- Existing unreleased code and docs that mention channels, contribution, or hooks must be revised before release.
|
||||
|
||||
## More Information
|
||||
|
||||
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md)
|
||||
- Follow-up linking and multicast ADR: [ADR-0028](0028-hosting-linking-multicast-enhancements.md). That ADR still uses
|
||||
some earlier host/channel terminology and must be aligned before implementation work starts.
|
||||
|
||||
## Appendix: Developer experience sketch
|
||||
|
||||
The examples below are sketches, not runtime-ready sample code. They show the minimum shape a developer would need to
|
||||
build: where protocol helpers are called, where app-owned auth/authorization belongs, where state is loaded/stored, and
|
||||
where native framework code remains in charge.
|
||||
|
||||
### Optional execution state
|
||||
|
||||
`AgentState` and `WorkflowState` stay small: they are target-specific state holders, not app hosts.
|
||||
|
||||
```python
|
||||
from typing import Protocol
|
||||
|
||||
from agent_framework import AgentSession, SupportsAgentRun, Workflow
|
||||
|
||||
|
||||
class SupportsBuild(Protocol):
|
||||
def build(self) -> Workflow: ...
|
||||
|
||||
|
||||
class SessionStore:
|
||||
async def get(self, session_id: str) -> AgentSession | None: ...
|
||||
async def set(self, session_id: str, session: AgentSession) -> None: ...
|
||||
async def delete(self, session_id: str) -> None: ...
|
||||
|
||||
|
||||
class CheckpointCursorStore:
|
||||
async def get(self, session_id: str) -> str | None: ...
|
||||
async def set(self, session_id: str, checkpoint_id: str) -> None: ...
|
||||
async def delete(self, session_id: str) -> None: ...
|
||||
|
||||
|
||||
class AgentState:
|
||||
def __init__(self, target: SupportsAgentRun, *, session_store: SessionStore | None = None) -> None: ...
|
||||
async def get_target(self) -> SupportsAgentRun: ...
|
||||
async def get_or_create_session(self, session_id: str) -> AgentSession: ...
|
||||
async def set_session(self, session_id: str, session: AgentSession) -> None: ...
|
||||
|
||||
|
||||
class WorkflowState:
|
||||
def __init__(self, target: Workflow | SupportsBuild) -> None: ...
|
||||
async def get_target(self) -> Workflow: ...
|
||||
```
|
||||
|
||||
`WorkflowState` accepts direct `Workflow` instances, workflow factories, and builder-shaped objects with
|
||||
`build() -> Workflow`. That structurally covers `WorkflowBuilder` and the builders in `agent_framework_orchestrations`
|
||||
without making `agent-framework-hosting` depend on the orchestration package.
|
||||
|
||||
### Responses-only route
|
||||
|
||||
This sketch shows the intended Responses-only shape. The protocol package owns the Agent Framework run conversion helpers and
|
||||
response-id minting details; the application owns FastAPI routing, auth, policy adjustment, and response construction.
|
||||
|
||||
```python
|
||||
import os
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from agent_framework import Agent, ResponseStream
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue]
|
||||
from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_from_streaming_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue]
|
||||
from fastapi import Body, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
app = FastAPI()
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Assistant",
|
||||
instructions="Be concise and helpful.",
|
||||
)
|
||||
state = AgentState(agent)
|
||||
|
||||
|
||||
@app.post("/responses")
|
||||
async def responses(body: dict = Body(...), x_api_key: str | None = Header(default=None)) -> JSONResponse | StreamingResponse:
|
||||
if x_api_key != os.environ["RESPONSES_API_KEY"]:
|
||||
raise HTTPException(status_code=401, detail="bad api key")
|
||||
|
||||
# parse the request body into a set of AF objects
|
||||
run = responses_to_run(body)
|
||||
# get the candidate session id from the body
|
||||
# can be a resp_* for previous_response_id or a conv_* for a conversation
|
||||
candidate_session_id = responses_session_id(body)
|
||||
# create a new response_id for this run
|
||||
response_id = create_response_id()
|
||||
|
||||
# the developer can make any adjustments to the request, i.e.:
|
||||
run["options"]["store"] = False
|
||||
run["options"].pop("model", None)
|
||||
# the options here are of the shape defined by the ChatClient/Agent
|
||||
|
||||
# load the session (or create a new one) - this is optional
|
||||
# verify this caller owns candidate_session_id before loading it; API-key auth
|
||||
# alone does not prove ownership of a caller-supplied resp_* or conv_* id
|
||||
session_id = candidate_session_id or response_id
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
|
||||
if run["stream"]:
|
||||
stream = target.run(
|
||||
run["messages"],
|
||||
stream=True,
|
||||
session=session,
|
||||
options=run["options"],
|
||||
)
|
||||
async def stream_events() -> AsyncIterator[str]:
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id=response_id,
|
||||
session_id=candidate_session_id,
|
||||
):
|
||||
yield event
|
||||
# agent.run may update the session during stream finalization, so store the post-run session explicitly
|
||||
await state.set_session(response_id, session)
|
||||
|
||||
return StreamingResponse(stream_events(), media_type="text/event-stream")
|
||||
|
||||
result = await target.run(
|
||||
run["messages"],
|
||||
session=session,
|
||||
options=run["options"],
|
||||
)
|
||||
# agent.run may update the session, so store the post-run session explicitly under the response id
|
||||
# this might also be skipped, if the app chooses to respect `store=False` policy
|
||||
await state.set_session(response_id, session)
|
||||
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
|
||||
|
||||
```
|
||||
|
||||
### Responses-only Django class-based view
|
||||
|
||||
The same helper surface can be used without FastAPI. A Django app owns URL routing, CSRF/auth policy, request parsing,
|
||||
and `JsonResponse` construction. In a real Django project this would live in the app's normal view module (for example
|
||||
`assistant/views.py`) and be routed from that app's `urls.py`; Django discovers it through its standard project/app
|
||||
layout, not through Agent Framework. This sketch shows the non-streaming path only; the streaming branch is the same
|
||||
state/finalization pattern shown in the FastAPI sketch and is omitted here to avoid duplicating it.
|
||||
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
|
||||
from agent_framework import Agent
|
||||
from agent_framework.openai import OpenAIChatClient
|
||||
from agent_framework_hosting import AgentState # pyright: ignore[reportAttributeAccessIssue]
|
||||
from agent_framework_hosting_responses import create_response_id, responses_from_run, responses_session_id, responses_to_run # pyright: ignore[reportAttributeAccessIssue]
|
||||
from django.http import HttpRequest, HttpResponseBadRequest, HttpResponseForbidden, JsonResponse
|
||||
from django.views import View
|
||||
|
||||
|
||||
agent = Agent(
|
||||
client=OpenAIChatClient(),
|
||||
name="Assistant",
|
||||
instructions="Be concise and helpful.",
|
||||
)
|
||||
state = AgentState(agent)
|
||||
|
||||
|
||||
class ResponsesView(View):
|
||||
async def post(self, request: HttpRequest) -> JsonResponse:
|
||||
if request.headers.get("x-api-key") != os.environ["RESPONSES_API_KEY"]:
|
||||
return HttpResponseForbidden("bad api key")
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponseBadRequest("invalid json")
|
||||
|
||||
run = responses_to_run(body)
|
||||
candidate_session_id = responses_session_id(body)
|
||||
response_id = create_response_id()
|
||||
options = run["options"]
|
||||
# verify this caller owns candidate_session_id before loading it; API-key auth
|
||||
# alone does not prove ownership of a caller-supplied resp_* or conv_* id
|
||||
session_id = candidate_session_id or response_id
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
result = await target.run(
|
||||
run["messages"],
|
||||
session=session,
|
||||
options=options,
|
||||
)
|
||||
await state.set_session(response_id, session)
|
||||
return JsonResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
|
||||
```
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
---
|
||||
status: accepted
|
||||
contact: rogerbarreto
|
||||
date: 2026-06-30
|
||||
deciders: rogerbarreto
|
||||
consulted: []
|
||||
informed: []
|
||||
---
|
||||
|
||||
# Per-agent and per-user session-storage isolation for Foundry Hosting
|
||||
|
||||
Builds on [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md).
|
||||
|
||||
## Context and Problem Statement
|
||||
|
||||
A Foundry hosted container can serve many end users (and, in .NET, many agents) over its lifetime. The
|
||||
`AgentSessionStore` persists each turn's `AgentSession` (which for a workflow agent carries the workflow
|
||||
checkpoint, and which also carries the tool-approval mapping via `ToolApprovalIdMap` in the session state
|
||||
bag). [ADR-0030](0030-hosted-platform-context-agentserver-2.0.md) protected cross-user access only through
|
||||
the strict-resume identity check (a 403 when the persisted `HostedSessionContext.UserId` does not match the
|
||||
live request). The persisted artifacts themselves were keyed by `conversationId` (+ agent name), not
|
||||
physically partitioned per user, so a forged `conversation_id` would still resolve to another user's file
|
||||
path before the identity check rejected it.
|
||||
|
||||
The Python hosting package added physical per-user partitioning (`<root>/<user_id>/<context_id>`) plus a
|
||||
reject-style path-traversal guard. We want .NET to provide the same defense-in-depth, adapted to the .NET
|
||||
hosting model.
|
||||
|
||||
## Decision Drivers
|
||||
|
||||
- Defense in depth: a forged/guessed id must not even resolve to another tenant's storage path, independent
|
||||
of the identity check.
|
||||
- Multi-agent hosting: a single .NET container hosts multiple agents resolved from keyed DI, so the layout
|
||||
must isolate per agent as well as per user (Python hosts a single agent and needs no agent layer).
|
||||
- Path-traversal safety (CWE-22) for the untrusted, platform-injected user id.
|
||||
- Back-compat for local development (no `x-agent-user-id` header) and for direct/non-hosted store use.
|
||||
- Keep the change contained and avoid the async-iterator `AsyncLocal` revert hazard from ADR-0030.
|
||||
|
||||
## Considered Options
|
||||
|
||||
- **Path partition inside `FileSystemAgentSessionStore`**, threading the user id explicitly through the
|
||||
`AgentSessionStore` API, with self-describing prefixed segments.
|
||||
- A delegating store that prefixes the conversation id with the user id (the
|
||||
`IsolationKeyScopedAgentSessionStore` pattern from `Microsoft.Agents.AI.Hosting`). Rejected: still needs the
|
||||
user id on the read path and yields a flat key rather than nested per-tenant directories.
|
||||
- An `AsyncLocal<string?>` user-context set by the handler. Rejected: the session is saved in the handler's
|
||||
`finally` after the streaming `yield`s, where an `AsyncLocal` set up front is reverted (the same hazard
|
||||
that forced explicit call-id re-application in ADR-0030). Explicit threading is safer and clearer.
|
||||
- A separate per-user approval store (as in Python). Rejected as unnecessary: see below.
|
||||
|
||||
## Decision Outcome
|
||||
|
||||
Path layout with self-describing, prefixed segments; user id threaded explicitly:
|
||||
|
||||
{root}/ a-{agentName} / u-{userId} / c-{contextId}.json
|
||||
|
||||
- `a-` (agent), `u-` (user), `c-` (context) are constant literals applied to the sanitized/validated value,
|
||||
so a collapsed layout is never ambiguous and a user id can never masquerade as an agent name.
|
||||
- `contextId` is `HostedConversationKey.Resolve` (conversation_id, else the partition of
|
||||
previous_response_id, else of the minted response id).
|
||||
- The agent and context layers are always present (Foundry always deploys a named agent). The only
|
||||
collapse is the `u-` layer: present when a user id is resolved (Foundry header, or local dev fallback),
|
||||
absent for raw local runs with no header (`{root}/a-{agent}/c-{conv}.json`). There is no user-only or
|
||||
no-agent layout.
|
||||
|
||||
Other elements:
|
||||
|
||||
- `string? userId` was added as a **required** parameter (no default) on `AgentSessionStore.GetSessionAsync` /
|
||||
`SaveSessionAsync` (a contained, breaking change to the experimental Foundry abstraction; both in-tree
|
||||
implementations and the two handler call sites were updated). It is required rather than optional so a
|
||||
caller can never silently persist a session unscoped; a genuine no-user caller (local without the header,
|
||||
or a non-hosted direct caller) passes `null` explicitly. `AgentFrameworkResponseHandler` resolves the user
|
||||
id before loading the session.
|
||||
- Path-traversal guard: the user id is rejected (not sanitized) when it is not a single safe path segment
|
||||
(path separators, NUL, drive letters, rooted paths, all-dot segments). After building the path, the
|
||||
fully-resolved path is asserted to remain under the storage root.
|
||||
- The strict-resume 403 identity check from ADR-0030 is **kept** as the second defense layer (it still
|
||||
catches a session that reaches the wrong partition, e.g. via a non-partitioning custom store or in-process
|
||||
tampering).
|
||||
- **No separate approval store.** The tool-approval mapping lives in `ToolApprovalIdMap` ->
|
||||
`AgentSessionStateBag`, which is serialized into the session checkpoint, so partitioning the session path
|
||||
isolates pending approvals per tenant automatically. (Python needs a separate per-user approval store only
|
||||
because it models approvals as a standalone store.)
|
||||
|
||||
## Consequences
|
||||
|
||||
Positive:
|
||||
|
||||
- Cross-tenant isolation is now defense-in-depth: physical per-agent/per-user partitioning plus the identity
|
||||
check. Approvals and workflow checkpoints inherit the partitioning because they ride in the session.
|
||||
- Self-describing prefixes make the on-disk layout auditable and collision-free across collapse cases.
|
||||
|
||||
Negative:
|
||||
|
||||
- Breaking change to the experimental Foundry `AgentSessionStore` API (added `userId`).
|
||||
- The on-disk layout and leaf filename change (`<conv>.json` -> `c-<conv>.json`), orphaning sessions written
|
||||
by the ADR-0030 release. Acceptable for an experimental package; a fresh session is created on next use.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Encryption at rest and quota enforcement remain platform concerns.
|
||||
- Non-Foundry hosting layers can adopt an equivalent scheme independently.
|
||||
|
||||
## Update (2026-07-01): local runs no longer fail closed; sample dev provider removed
|
||||
|
||||
Superseding the ADR-0026/0030 behavior where a `null` result from `HostedSessionIsolationKeyProvider`
|
||||
always became a 500, `AgentFrameworkResponseHandler` now branches on `FoundryEnvironment.IsHosted`:
|
||||
|
||||
- **Hosted** (`IsHosted == true`, production): a `null` identity is still a hard error (500). Isolation
|
||||
stays strict; the platform always injects `x-agent-user-id`.
|
||||
- **Not hosted** (local `docker run` / `dotnet run`): a `null` identity is tolerated. Per-user isolation
|
||||
is simply not triggered — the handler passes `userId == null` to the store (the documented "no user
|
||||
partition", `{root}/a-{agent}/c-{conv}.json`), stamps no `HostedSessionContext`, and runs no
|
||||
strict-resume check. Contributors can run a hosted image locally with zero extra setup.
|
||||
|
||||
Consequently the sample-side `DevTemporaryLocalUserIdProvider` and `AddDevTemporaryLocalContributorSetup`
|
||||
were removed. To simulate distinct users locally, send an `x-agent-user-id` request header; the default
|
||||
`PlatformHostedSessionIsolationKeyProvider` reads it via `ResponseContext.PlatformContext.UserIdKey`
|
||||
(the SDK's `PlatformContext.FromRequest` populates it from the header unconditionally, hosted or not).
|
||||
@@ -1,320 +1,348 @@
|
||||
---
|
||||
status: proposed
|
||||
contact: eavanvalkenburg
|
||||
date: 2026-06-11
|
||||
date: 2026-07-08
|
||||
deciders: eavanvalkenburg
|
||||
---
|
||||
|
||||
# Python hosting core and pluggable channels
|
||||
# Python protocol helpers and optional execution state
|
||||
|
||||
## Scope
|
||||
|
||||
This specification is the Python implementation plan for [ADR-0027](../decisions/0027-hosting-channels.md). It documents the simplified v1 host/channel contract only.
|
||||
This specification is the Python implementation plan for
|
||||
[ADR-0027](../decisions/0027-hosting-channels.md). It documents the helper-first v1 contract for Python hosting.
|
||||
|
||||
The v1 contract is:
|
||||
|
||||
- `AgentFrameworkHost` owns one Starlette app, one hostable target, and one or more channels.
|
||||
- A hostable target is either a `SupportsAgentRun`-compatible agent or a `Workflow`.
|
||||
- Channels contribute routes, middleware, commands, and lifecycle callbacks.
|
||||
- Channels parse protocol-native input into `ChannelRequest`.
|
||||
- Channels render their own originating response.
|
||||
- Session continuity is explicit: a channel supplies `ChannelSession(isolation_key=...)`, and the host resolves/caches an `AgentSession` for that key.
|
||||
- The host invokes `ChannelRunHook` and `ChannelResponseHook`; channels provide hook configuration and protocol context.
|
||||
|
||||
The host does not link identities, route responses to other channels, run background continuations, or multicast in v1. Those enhancements are tracked in [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md).
|
||||
- protocol packages expose helper functions that convert protocol-native input to Agent Framework run values;
|
||||
- protocol packages expose helper functions that convert Agent Framework run results or streams back to protocol-native
|
||||
payloads or operations;
|
||||
- application/framework code owns routes, native SDK clients, authentication, command policy, webhooks, response status
|
||||
codes, and outbound sends;
|
||||
- `agent-framework-hosting` provides small optional state holders for Agent Framework targets;
|
||||
- state helpers do not own web apps, route contribution, protocol dispatch, command projection, or native SDK calls.
|
||||
|
||||
## Goals
|
||||
|
||||
- Let an app expose one agent or workflow on multiple protocols without handwritten Starlette composition.
|
||||
- Keep protocol parsing and response formatting inside channel packages.
|
||||
- Provide one session-resolution path shared by all channels.
|
||||
- Keep the channel authoring surface small enough for new channels to implement.
|
||||
- Preserve full-fidelity agent and workflow results until a channel decides how to render them.
|
||||
- Let apps expose agents and workflows from FastAPI, Starlette, Django, Azure Functions, native SDK webhooks, CLIs, and
|
||||
tests without adopting a host/channel framework.
|
||||
- Keep protocol parsing and response formatting inside protocol packages.
|
||||
- Keep session continuity explicit and app-owned at the trust boundary.
|
||||
- Reuse Agent Framework primitives: `AgentSession`, `CheckpointStorage`, `Agent.run(...)`, `Workflow.run(...)`, and
|
||||
`ResponseStream`.
|
||||
- Preserve full-fidelity Agent Framework results until a protocol helper renders them.
|
||||
|
||||
## Non-goals for v1
|
||||
|
||||
The following are removed from the v1 implementation pass:
|
||||
### App-owned in v1
|
||||
|
||||
- `IdentityLinker`, `IdentityAllowlist`, `AuthPolicy`, and `LinkPolicy`
|
||||
- `ResponseTarget`, active-channel routing, `all_linked`, fan-out, and multicast
|
||||
- `ChannelPush` and `ChannelPushCodec`
|
||||
- `DurableTaskRunner`, `InProcessTaskRunner`, and `RetryPolicy`
|
||||
- continuation tokens and background delivery
|
||||
- confidentiality tiers
|
||||
- `agent-framework-hosting-entra`
|
||||
- `local_identity_link`
|
||||
The app builder owns these concerns with normal web-framework, SDK, platform, or application code:
|
||||
|
||||
These are follow-up design topics, not hidden requirements of the v1 host.
|
||||
- authentication, authorization policy, and allowlists;
|
||||
- deciding whether identities across protocols map to the same `session_id`;
|
||||
- non-originating sends using native SDK clients;
|
||||
- background work, durable execution, retry, and replay when app code owns the work;
|
||||
- routing between multiple agents.
|
||||
|
||||
The helper-first model makes app-owned linking and non-originating delivery easier than the old host/channel model because
|
||||
app code already owns the native SDK clients, authenticated caller context, session id selection, and outbound sends.
|
||||
|
||||
### Future framework work
|
||||
|
||||
The following require a separate reviewed design before becoming reusable framework features:
|
||||
|
||||
- reusable cross-channel identity linking;
|
||||
- framework-owned proactive or non-originating delivery;
|
||||
- fan-out, multicast, selected-channel, active-channel, or all-linked delivery;
|
||||
- framework-owned delivery observability, dead-letter handling, and replay semantics;
|
||||
- cross-channel confidentiality and link policy.
|
||||
|
||||
[ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) tracks possible follow-up work in this area and
|
||||
must be aligned with the helper-first model before implementation. Old vocabulary such as `IdentityLinker`,
|
||||
`ResponseTarget`, `ChannelPush`, `ChannelPushCodec`, `DurableTaskRunner`, `RetryPolicy`, and `LinkPolicy` is not v1 API.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Import surface | Contents |
|
||||
| Package | Import surface | v1 helper-first contents |
|
||||
|---|---|---|
|
||||
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentFrameworkHost`, channel protocols, key request/result types, hooks, `reset_session`, state-path helpers. |
|
||||
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | `ResponsesChannel`. |
|
||||
| `agent-framework-hosting-invocations` | `agent_framework_hosting_invocations` | `InvocationsChannel`. |
|
||||
| `agent-framework-hosting-telegram` | `agent_framework_hosting_telegram` | `TelegramChannel` and Telegram command helpers. |
|
||||
| `agent-framework-hosting-activity-protocol` | `agent_framework_hosting_activity_protocol` | `ActivityProtocolChannel` for Activity Protocol over Azure Bot Service. |
|
||||
| `agent-framework-hosting-discord` | `agent_framework_hosting_discord` | `DiscordChannel` and Discord command/interaction helpers. |
|
||||
| `agent-framework-foundry-hosting` | `agent_framework.foundry_hosting` | Foundry isolation middleware and Foundry-backed hosting helpers usable with the v1 host. |
|
||||
| `agent-framework-hosting` | `agent_framework_hosting` | `AgentState`, `WorkflowState`, `SessionStore`, and run-argument `TypedDict`s. |
|
||||
| `agent-framework-hosting-responses` | `agent_framework_hosting_responses` | Responses helpers: request parsing, session id extraction, response id creation, response rendering, streaming rendering. |
|
||||
| Future protocol packages | e.g. `agent_framework_hosting_telegram` | Protocol-specific helpers such as `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_session_id(...)`, and command/media helpers when useful. |
|
||||
|
||||
Channel packages may depend on their native SDKs. The core hosting package should not depend on channel SDKs or on top-level legacy protocol hosts.
|
||||
The core hosting package must not depend on protocol SDKs. Protocol packages may depend on their native protocol SDKs if
|
||||
needed, but helper functions should stay usable from plain app code and tests.
|
||||
|
||||
## Key Types
|
||||
## Helper naming and families
|
||||
|
||||
### `AgentFrameworkHost`
|
||||
Helper names are protocol-specific. Avoid a generic `protocol_to_run(...)` public surface.
|
||||
|
||||
The host constructor accepts:
|
||||
Protocol packages may provide the following helper families when the protocol has the concept:
|
||||
|
||||
- `target`: one `SupportsAgentRun`-compatible object or one `Workflow`
|
||||
- `channels`: one or more `Channel` instances
|
||||
- optional Starlette middleware
|
||||
- optional `state_dir`
|
||||
- optional workflow `checkpoint_location`
|
||||
| Helper family | Shape | Purpose |
|
||||
| --- | --- | --- |
|
||||
| Run conversion | `<protocol>_to_run(...)` | Convert one protocol-native call/update/request into `Agent.run` or `Workflow.run` values. |
|
||||
| Final rendering | `<protocol>_from_run(...)` | Convert a final `AgentResponse` or workflow result into protocol-native response payloads or operations. |
|
||||
| Stream rendering | `<protocol>_from_streaming_run(...)` | Convert `ResponseStream` or workflow updates into protocol-native events or operations. |
|
||||
| Session id extraction | `<protocol>_session_id(...)` | Extract the protocol's natural continuation/partition key from the call, if present. |
|
||||
| Command/action parsing | `<protocol>_command(...)` | Parse a protocol-native command/action/operation name without deciding app policy. |
|
||||
|
||||
The host exposes:
|
||||
Examples:
|
||||
|
||||
- `app`: the canonical Starlette ASGI application
|
||||
- `serve(...)`: a convenience wrapper for local serving
|
||||
- `reset_session(isolation_key: str)`: rotate the cached `AgentSession` for a host-tracked conversation
|
||||
- `responses_to_run(...)`, `responses_from_run(...)`, `responses_from_streaming_run(...)`,
|
||||
`responses_session_id(...)`;
|
||||
- `telegram_to_run(...)`, `telegram_from_run(...)`, `telegram_from_streaming_run(...)`,
|
||||
`telegram_session_id(...)`, `telegram_command(...)`;
|
||||
- `activity_to_run(...)`, `activity_from_run(...)`, `activity_session_id(...)`, `activity_command(...)`;
|
||||
- `discord_to_run(...)`, `discord_from_run(...)`, `discord_session_id(...)`, `discord_command(...)`.
|
||||
|
||||
`state_dir` is narrowed to v1 host-owned local files only:
|
||||
This table is a naming guide, not a required checklist. A protocol package should add only the helpers that match native
|
||||
protocol concepts and current samples.
|
||||
|
||||
- session aliases (`isolation_key` to current `AgentSession` id), and
|
||||
- workflow checkpoint paths when the app chooses the host-provided file layout.
|
||||
Protocol-specific helpers may also exist for native details such as `telegram_chat_id(...)`,
|
||||
`telegram_callback_query_id(...)`, `telegram_media_file_id(...)`, `discord_interaction_id(...)`, `a2a_task_id(...)`,
|
||||
`a2a_context_id(...)`, or MCP tool/prompt/resource helpers. These helpers should stay side-effect-free. App/native SDK
|
||||
code performs acknowledgements, sends/edits messages, resolves protected file URLs, applies rate limits, and registers
|
||||
handlers.
|
||||
|
||||
It is not a store for identity links, continuations, active-channel state, delivery attempts, or multicast payloads.
|
||||
## `agent-framework-hosting` state helpers
|
||||
|
||||
Externally supplied isolation keys are trusted only after the channel or host middleware has authenticated and authorized the caller. The host uses `isolation_key` as a partition key; the string itself is not proof of identity or ownership.
|
||||
### `SessionStore`
|
||||
|
||||
### `Channel`
|
||||
|
||||
A channel implements a small protocol:
|
||||
|
||||
- declare a stable channel id/name,
|
||||
- contribute routes, middleware, commands, and lifecycle callbacks,
|
||||
- parse inbound protocol data into `ChannelRequest`,
|
||||
- call the host through `ChannelContext.run(...)` or `ChannelContext.run_stream(...)`, and
|
||||
- serialize the returned result to the originating protocol response.
|
||||
|
||||
Channels own protocol authentication, signature validation, native command registration, and protocol-specific error bodies.
|
||||
|
||||
### `ChannelContribution`
|
||||
|
||||
`ChannelContribution` is the channel's host-facing contribution:
|
||||
|
||||
- Starlette routes and optional middleware,
|
||||
- native command descriptors,
|
||||
- startup and shutdown callbacks, and
|
||||
- any channel-local metadata needed by the package.
|
||||
|
||||
The host aggregates contributions but does not interpret protocol payloads.
|
||||
|
||||
### `ChannelRequest`
|
||||
|
||||
`ChannelRequest` is the host-neutral request envelope produced by a channel. It carries:
|
||||
|
||||
- target input,
|
||||
- optional `ChannelSession`,
|
||||
- optional `ChannelIdentity`,
|
||||
- options and attributes produced by the channel, and
|
||||
- request metadata useful to hooks and context providers.
|
||||
|
||||
The host may pass attributes through to context providers and middleware. Channels should treat attributes as a documented extension bag, not as a cross-channel delivery contract.
|
||||
|
||||
### `ChannelSession`
|
||||
|
||||
`ChannelSession(isolation_key=...)` is the only v1 session-continuity mechanism.
|
||||
|
||||
When a request contains an isolation key:
|
||||
|
||||
1. The host looks up or creates the cached `AgentSession` for that key.
|
||||
2. The target runs with that `AgentSession` when the target is an agent.
|
||||
3. `reset_session(isolation_key)` rotates the alias so the next request starts a new conversation.
|
||||
|
||||
If two channels produce the same isolation key on the same host, they share the same cached session. If they produce different keys, they do not share session state.
|
||||
|
||||
### `ChannelIdentity`
|
||||
|
||||
`ChannelIdentity` is optional request metadata such as channel id, native user id, tenant id, claims, or display attributes.
|
||||
|
||||
In v1, `ChannelIdentity` does not link channels, authorize callers, select delivery destinations, or imply that two identities should share an `AgentSession`. A channel that wants shared history must still produce the same `ChannelSession.isolation_key`.
|
||||
|
||||
### Hooks
|
||||
|
||||
Hooks are optional and channel-owned:
|
||||
|
||||
- `ChannelRunHook`: runs after channel parsing and before host invocation; returns the `ChannelRequest` to execute.
|
||||
- `ChannelResponseHook`: runs after target completion and before the originating channel renders a one-shot response.
|
||||
- `ChannelStreamUpdateHook`: the host applies it to streamed updates before the originating channel serializes the stream.
|
||||
|
||||
Common uses include adapting chat text into workflow inputs, enforcing deployment-specific options, flattening rich output for text-only protocols, or filtering streamed updates for a protocol. Stream update hooks are update-only; they do not automatically sanitize `get_final_response()` output. Channels choose their response transport from the parsed protocol request before invoking run hooks.
|
||||
|
||||
### `HostedRunResult`
|
||||
|
||||
`HostedRunResult[T]` wraps the target's full-fidelity result plus the resolved `AgentSession | None`.
|
||||
|
||||
- Agent targets produce `HostedRunResult[AgentResponse]`.
|
||||
- Workflow targets produce `HostedRunResult[WorkflowRunResult]`.
|
||||
|
||||
The host does not flatten, filter, or translate the result. Each channel decides how much of the result its protocol can carry.
|
||||
|
||||
## Host Behavior
|
||||
|
||||
1. `AgentFrameworkHost` builds one Starlette app and asks each channel for its contribution.
|
||||
2. A channel route receives a protocol-native request.
|
||||
3. The channel validates/parses the native payload and creates `ChannelRequest`.
|
||||
4. The channel passes the request, optional `ChannelRunHook`, and protocol-native context to the host.
|
||||
5. The host invokes `ChannelRunHook`, if configured, and receives the prepared request.
|
||||
6. The host resolves an `AgentSession` from `ChannelSession.isolation_key` when present.
|
||||
7. The host invokes the agent or workflow target.
|
||||
8. The host wraps the result in `HostedRunResult` or the streaming equivalent.
|
||||
9. The host invokes `ChannelResponseHook`, if configured, for non-streaming/final response shaping.
|
||||
10. The host applies stream update hooks while the channel consumes streams; the channel renders the originating protocol response.
|
||||
|
||||
There is no host-level route from one channel's request to another channel's response in v1.
|
||||
|
||||
## Workflow Checkpoints
|
||||
|
||||
Workflow checkpointing is explicit. Apps either configure checkpoint storage on the workflow itself or pass a `checkpoint_location` to the host so the workflow dispatch path can use the intended file location.
|
||||
|
||||
`state_dir` may provide a conventional location for workflow checkpoint files, but checkpointing is still opt-in and separate from agent session history. Checkpoints are workflow-runtime state, not channel state and not identity-link state.
|
||||
|
||||
## Foundry Isolation Middleware
|
||||
|
||||
V1 keeps Foundry isolation as middleware rather than as a channel-linking feature.
|
||||
|
||||
The middleware is installed only when the Foundry hosting environment flag is present. In that environment it reads Foundry-provided isolation values at the trusted hosting boundary, exposes them as read-only request context for Foundry-aware history or memory providers, and rejects unsafe session resumes when the live isolation context does not match persisted session context. Outside Foundry, raw isolation headers are ignored unless an app supplies its own trusted middleware.
|
||||
|
||||
This middleware does not create cross-channel identity links and does not authorize non-Foundry channels.
|
||||
|
||||
## Current Channels
|
||||
|
||||
### Responses
|
||||
|
||||
`ResponsesChannel` exposes the OpenAI-compatible Responses API shape. It maps request body fields such as input, options, and conversation identifiers into `ChannelRequest`, and it renders Responses-compatible one-shot or streaming responses.
|
||||
|
||||
Responses session continuity uses a channel-selected `isolation_key`, commonly derived from a response/conversation id, caller-provided session id, Foundry isolation context, or deployment-specific request metadata.
|
||||
|
||||
### Invocations
|
||||
|
||||
`InvocationsChannel` exposes an invocation endpoint for server-side callers and tools. It maps the request body into `ChannelRequest` and renders the invocation result on the same HTTP response.
|
||||
|
||||
Invocations is useful for typed workflow inputs because a `ChannelRunHook` can translate the request body into the workflow's expected input type.
|
||||
|
||||
### Telegram
|
||||
|
||||
`TelegramChannel` supports webhook or polling transport, native command registration, and message rendering back to the originating Telegram chat.
|
||||
|
||||
The channel chooses a default `isolation_key` from Telegram-native data such as chat id, user id, or a configured user/chat scope. A `/new` or equivalent command may call `reset_session` for that isolation key.
|
||||
|
||||
### Activity Protocol
|
||||
|
||||
`ActivityChannel` supports Activity Protocol requests, typically through Azure Bot Service for Teams, Web Chat, and other Bot Framework-fronted surfaces.
|
||||
|
||||
The channel maps incoming `Activity` objects to `ChannelRequest` and renders a reply activity to the originating conversation. Proactive Activity delivery, active-channel routing, and all-linked fan-out are not v1 host semantics.
|
||||
|
||||
### Discord
|
||||
|
||||
`DiscordChannel` supports Discord messages, slash commands, and interactions as channel-native input.
|
||||
|
||||
The channel maps Discord-native user, guild, channel, thread, and interaction data into `ChannelRequest` metadata and a configured `ChannelSession.isolation_key`. It renders the result to the originating Discord response path.
|
||||
|
||||
## High-level Samples
|
||||
|
||||
### One agent on Responses
|
||||
`SessionStore` is an in-memory async lookup:
|
||||
|
||||
```python
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[ResponsesChannel()],
|
||||
class SessionStore:
|
||||
async def get(self, session_id: str) -> AgentSession | None: ...
|
||||
async def set(self, session_id: str, session: AgentSession) -> None: ...
|
||||
async def delete(self, session_id: str) -> None: ...
|
||||
```
|
||||
|
||||
The store does not create sessions. It stores `session_id -> AgentSession` values supplied by callers.
|
||||
|
||||
The built-in store has no TTL or eviction. This is intentional for local/dev and simple process-local scenarios: protocols
|
||||
such as OpenAI Responses can continue from any prior response id. Durable or multi-replica deployments should provide a
|
||||
durable store and their own TTL/eviction policy.
|
||||
|
||||
### `AgentState`
|
||||
|
||||
`AgentState` holds an agent target and an optional `SessionStore`:
|
||||
|
||||
```python
|
||||
state = AgentState(agent)
|
||||
state = AgentState(create_agent)
|
||||
state = AgentState(create_agent, cache_target=False)
|
||||
```
|
||||
|
||||
The target may be:
|
||||
|
||||
- a `SupportsAgentRun` instance;
|
||||
- a synchronous factory;
|
||||
- an asynchronous factory;
|
||||
- an awaitable target.
|
||||
|
||||
`AgentState` provides:
|
||||
|
||||
- `await get_target()`;
|
||||
- synchronous `target` only after a target is already available/resolved;
|
||||
- `session_store`;
|
||||
- `await get_or_create_session(session_id)`;
|
||||
- `await set_session(session_id, session)`.
|
||||
|
||||
`get_or_create_session(...)` resolves the target and calls `target.create_session(session_id=...)` only when the store has
|
||||
no session for that id.
|
||||
|
||||
Apps must store the post-run session explicitly after `agent.run(...)` or stream finalization:
|
||||
|
||||
```python
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
result = await target.run(messages, session=session, options=options)
|
||||
await state.set_session(response_id, session)
|
||||
```
|
||||
|
||||
### `WorkflowState`
|
||||
|
||||
`WorkflowState` resolves a workflow target. It does not own checkpoint storage.
|
||||
|
||||
The target may be:
|
||||
|
||||
- a `Workflow` instance;
|
||||
- a `WorkflowBuilder` or other object with `build() -> Workflow`;
|
||||
- a synchronous factory;
|
||||
- an asynchronous factory;
|
||||
- an awaitable target.
|
||||
|
||||
`WorkflowState` provides:
|
||||
|
||||
- `await get_target()`;
|
||||
- synchronous `target` only after a target is already available/resolved.
|
||||
|
||||
Workflow checkpointing uses Agent Framework's existing `CheckpointStorage` abstraction directly. Apps that need
|
||||
per-session workflow resume should keep an app-owned cursor such as `session_id -> checkpoint_id`. When the app uses
|
||||
file-backed cursor storage, the file-based checkpoint storage should share the same app storage root and should be
|
||||
scoped to the current authenticated user/tenant/session bucket, for example
|
||||
`storage/checkpoints/<session-bucket>/` beside `storage/checkpoint_cursors.json`:
|
||||
|
||||
```python
|
||||
# session_id must already be authenticated and authorized for this caller
|
||||
target = await workflow_state.get_target()
|
||||
checkpoint_id = await checkpoint_cursor_store.get(session_id)
|
||||
if checkpoint_id is None:
|
||||
result = await target.run(message=workflow_input, checkpoint_storage=checkpoint_storage)
|
||||
else:
|
||||
result = await target.run(checkpoint_id=checkpoint_id, checkpoint_storage=checkpoint_storage)
|
||||
latest = await checkpoint_storage.get_latest(workflow_name=target.name)
|
||||
if latest is not None:
|
||||
await checkpoint_cursor_store.set(session_id, latest.checkpoint_id)
|
||||
```
|
||||
|
||||
`Workflow.run(...)` does not currently emit a checkpoint id on `WorkflowRunResult` or normal workflow events by default.
|
||||
The runner receives checkpoint ids internally from `CheckpointStorage.save(...)`. Apps that own the storage can query
|
||||
`get_latest(workflow_name=...)` after the run if they need to update a cursor.
|
||||
|
||||
## `agent-framework-hosting-responses`
|
||||
|
||||
The Responses package provides the helper-first surface for OpenAI Responses-shaped requests.
|
||||
|
||||
### Request helpers
|
||||
|
||||
- `messages_from_responses_input(input) -> list[Message]`
|
||||
- `responses_to_run(body) -> AgentRunArgs`
|
||||
- `responses_session_id(body) -> str | None`
|
||||
- `create_response_id() -> str`
|
||||
|
||||
`responses_to_run(...)` returns values corresponding to `Agent.run(...)`:
|
||||
|
||||
```python
|
||||
run = responses_to_run(body)
|
||||
messages = run["messages"]
|
||||
options = run["options"]
|
||||
stream = run["stream"]
|
||||
```
|
||||
|
||||
It excludes protocol transport/session fields from `options` and remaps known Responses option names such as
|
||||
`max_output_tokens -> max_tokens`.
|
||||
|
||||
`responses_session_id(...)` returns:
|
||||
|
||||
- `previous_response_id` when present (`resp_*`);
|
||||
- otherwise `conversation_id` when present (`conv_*`);
|
||||
- otherwise `None`.
|
||||
|
||||
The helper only extracts the candidate key. App code decides whether to trust and use that key.
|
||||
|
||||
### Response helpers
|
||||
|
||||
- `responses_from_run(result, *, response_id, session_id=None) -> dict[str, Any]`
|
||||
- `responses_from_streaming_run(stream, *, response_id, session_id=None) -> AsyncIterator[str]`
|
||||
|
||||
`responses_from_run(...)` renders a full Responses JSON payload from an `AgentResponse`. It renders the full set of
|
||||
OpenAI Responses output item types supported by Agent Framework content.
|
||||
|
||||
`responses_from_streaming_run(...)` renders Server-Sent Event strings for a `ResponseStream`. It emits a created event,
|
||||
text deltas, and a completed event. The final completed payload is produced through `responses_from_run(...)`; the helper
|
||||
also preserves the model id observed on streaming updates when the finalized `AgentResponse` no longer carries raw model
|
||||
metadata.
|
||||
|
||||
## Security responsibilities
|
||||
|
||||
Protocol helper packages parse and render. They do not authenticate callers, authorize access to state, or decide which
|
||||
side effects are allowed.
|
||||
|
||||
Application code that uses these helpers is responsible for:
|
||||
|
||||
- authenticating the caller through the app's normal mechanism before using protocol-provided ids;
|
||||
- authorizing any caller-supplied session, checkpoint, task, context, conversation, thread, or response id before loading
|
||||
state for it;
|
||||
- binding externally supplied ids to the authenticated user, tenant, workspace, installation, or chat context before
|
||||
using them as `SessionStore` keys or checkpoint cursor keys;
|
||||
- treating `<protocol>_session_id(...)` results as untrusted candidate keys until that ownership check has passed;
|
||||
- keeping platform-provided isolation helpers fail-closed outside their trusted hosting environment;
|
||||
- authorizing command/action effects such as reset, cancel, approve, submit, or tool invocation after parsing them;
|
||||
- opting in explicitly before resolving protected media/resource/file URLs and passing them to a remote model provider;
|
||||
- persisting post-run session or checkpoint state only after `agent.run(...)`, `workflow.run(...)`, or stream finalization
|
||||
has updated that state.
|
||||
|
||||
## Persistent versus transient hosting
|
||||
|
||||
The application builder decides whether the server is persistent or transient.
|
||||
|
||||
- Persistent single-process apps, such as a long-running container or web app, may use in-memory state for local
|
||||
development or simple deployments. Multi-replica persistent apps still need durable state for continuity.
|
||||
- Transient apps, such as Azure Functions, Foundry Hosted Agents, or any environment where process memory is not a
|
||||
reliable boundary, must not rely on in-memory `SessionStore` state between calls. They need a durable session store or
|
||||
a service-owned continuation id.
|
||||
- Workflow hosts must choose an explicit `CheckpointStorage` and, when they need per-session resume, a durable
|
||||
`session_id -> checkpoint_id` cursor. File-backed checkpoint storage and file-backed cursor storage should live under
|
||||
the same app storage root, with checkpoints scoped to the current authenticated user/tenant/session bucket so a
|
||||
"latest checkpoint" lookup cannot cross conversations. In-process workflow state and in-memory checkpoint cursors do
|
||||
not survive transient execution.
|
||||
|
||||
## Minimal FastAPI Responses shape
|
||||
|
||||
This is the shape the local Responses sample should demonstrate. It is not an app framework.
|
||||
|
||||
```python
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from agent_framework import ResponseStream
|
||||
from agent_framework_hosting import AgentState
|
||||
from agent_framework_hosting_responses import (
|
||||
create_response_id,
|
||||
responses_from_run,
|
||||
responses_from_streaming_run,
|
||||
responses_session_id,
|
||||
responses_to_run,
|
||||
)
|
||||
from fastapi import Body, FastAPI, HTTPException
|
||||
from fastapi.responses import JSONResponse, StreamingResponse
|
||||
|
||||
app = host.app
|
||||
app = FastAPI()
|
||||
state = AgentState(create_agent)
|
||||
|
||||
|
||||
@app.post("/responses", response_model=None)
|
||||
async def responses(body: dict = Body(...)) -> JSONResponse | StreamingResponse:
|
||||
run = responses_to_run(body)
|
||||
candidate_session_id = responses_session_id(body)
|
||||
response_id = create_response_id()
|
||||
|
||||
# Verify this caller owns candidate_session_id before loading it.
|
||||
session_id = candidate_session_id or response_id
|
||||
session = await state.get_or_create_session(session_id)
|
||||
target = await state.get_target()
|
||||
|
||||
if run["stream"]:
|
||||
stream = target.run(run["messages"], stream=True, session=session, options=run["options"])
|
||||
if not isinstance(stream, ResponseStream):
|
||||
raise HTTPException(status_code=500, detail="agent did not return a response stream")
|
||||
|
||||
async def events() -> AsyncIterator[str]:
|
||||
async for event in responses_from_streaming_run(
|
||||
stream,
|
||||
response_id=response_id,
|
||||
session_id=candidate_session_id,
|
||||
):
|
||||
yield event
|
||||
await state.set_session(response_id, session)
|
||||
|
||||
return StreamingResponse(events(), media_type="text/event-stream")
|
||||
|
||||
result = await target.run(run["messages"], session=session, options=run["options"])
|
||||
await state.set_session(response_id, session)
|
||||
return JSONResponse(responses_from_run(result, response_id=response_id, session_id=candidate_session_id))
|
||||
```
|
||||
|
||||
### One agent on multiple channels
|
||||
## Validation
|
||||
|
||||
```python
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[
|
||||
ResponsesChannel(),
|
||||
InvocationsChannel(),
|
||||
TelegramChannel(bot_token=os.environ["TELEGRAM_BOT_TOKEN"]),
|
||||
],
|
||||
)
|
||||
Implementation validation must cover:
|
||||
|
||||
host.serve(host="localhost", port=8000)
|
||||
```
|
||||
|
||||
The host owns one Starlette app. Each channel contributes its own routes and renders its own response.
|
||||
|
||||
### Adapting a request before execution
|
||||
|
||||
```python
|
||||
from dataclasses import replace
|
||||
|
||||
|
||||
def enforce_options(request: ChannelRequest) -> ChannelRequest:
|
||||
options = dict(request.options or {})
|
||||
options["temperature"] = 0
|
||||
return replace(request, options=options)
|
||||
|
||||
|
||||
host = AgentFrameworkHost(
|
||||
target=agent,
|
||||
channels=[ResponsesChannel(run_hook=enforce_options)],
|
||||
)
|
||||
```
|
||||
|
||||
### Workflow with explicit checkpoints
|
||||
|
||||
```python
|
||||
host = AgentFrameworkHost(
|
||||
target=workflow,
|
||||
channels=[InvocationsChannel(run_hook=adapt_to_workflow_input)],
|
||||
checkpoint_location=Path("./.af-hosting/workflow_checkpoints"),
|
||||
)
|
||||
```
|
||||
|
||||
The hook adapts channel-native input to the workflow's typed input. Checkpoints use the explicit workflow checkpoint location, not identity-link or delivery storage.
|
||||
|
||||
### Message channel reset command
|
||||
|
||||
```python
|
||||
async def new_chat(context):
|
||||
if context.request.session is not None:
|
||||
await context.host.reset_session(context.request.session.isolation_key)
|
||||
await context.reply("Started a new conversation.")
|
||||
```
|
||||
|
||||
Telegram, Activity Protocol, and Discord can expose equivalent native commands when their protocols support them.
|
||||
|
||||
## Follow-up Enhancements
|
||||
|
||||
See [ADR-0028](../decisions/0028-hosting-linking-multicast-enhancements.md) for the deferred design covering:
|
||||
|
||||
- cross-channel identity linking,
|
||||
- authorization and allowlists,
|
||||
- non-originating response delivery,
|
||||
- active-channel routing,
|
||||
- multicast and all-linked delivery,
|
||||
- background runs and continuation tokens,
|
||||
- durable delivery runners,
|
||||
- retry/replay semantics, and
|
||||
- payload serialization.
|
||||
|
||||
Those enhancements must layer on top of this v1 contract without requiring v1 users to adopt them.
|
||||
|
||||
## Validation Gates
|
||||
|
||||
The Python implementation should be considered complete when:
|
||||
|
||||
- a sample uses one `AgentFrameworkHost` with multiple channels and no manual Starlette route composition,
|
||||
- each current channel has contract tests for route contribution, lifecycle, request parsing, hooks, and originating response rendering,
|
||||
- session tests prove shared `isolation_key` values share an `AgentSession` and `reset_session` rotates it,
|
||||
- workflow tests or samples use explicit `checkpoint_location`,
|
||||
- Foundry isolation middleware is covered by integration or contract tests,
|
||||
- no v1 package exposes the removed linking, multicast, durable-runner, or continuation APIs, and
|
||||
- this spec and ADR-0027 remain aligned.
|
||||
- `SessionStore` plain get/set/delete behavior;
|
||||
- `AgentState` target resolution, target caching, and get-or-create session behavior;
|
||||
- `WorkflowState` target resolution for direct workflows, factories, `WorkflowBuilder`, and orchestration-style builders;
|
||||
- Responses request parsing and option remapping;
|
||||
- Responses session id extraction;
|
||||
- Responses response rendering, including rich output item mapping;
|
||||
- Responses streaming SSE rendering;
|
||||
- HTTP round-trip tests showing a native FastAPI route using `AgentState` and Responses helpers;
|
||||
- sample type checking for the local Responses sample.
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- Aspire.* -->
|
||||
<PackageVersion Include="Anthropic" Version="12.20.0" />
|
||||
<PackageVersion Include="Anthropic" Version="12.31.0" />
|
||||
<PackageVersion Include="Anthropic.Foundry" Version="0.6.0" />
|
||||
<PackageVersion Include="Aspire.Hosting" Version="$(AspireAppHostSdkVersion)" />
|
||||
<PackageVersion Include="Aspire.Azure.AI.OpenAI" Version="13.0.0-preview.1.25560.3" />
|
||||
@@ -27,10 +27,10 @@
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Invocations" Version="1.0.0-beta.5" />
|
||||
<PackageVersion Include="Azure.AI.AgentServer.Responses" Version="1.0.0-beta.6" />
|
||||
<PackageVersion Include="Azure.Search.Documents" Version="12.0.0" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.3" />
|
||||
<PackageVersion Include="Azure.AI.Projects" Version="2.1.0-beta.4" />
|
||||
<PackageVersion Include="Azure.AI.Agents.Persistent" Version="1.2.0-beta.10" />
|
||||
<PackageVersion Include="Azure.AI.OpenAI" Version="2.9.0-beta.1" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.57.0" />
|
||||
<PackageVersion Include="Azure.Core" Version="1.60.0" />
|
||||
<PackageVersion Include="Azure.Identity" Version="1.21.0" />
|
||||
<PackageVersion Include="DotNetEnv" Version="3.1.1" />
|
||||
<PackageVersion Include="Azure.Monitor.OpenTelemetry.Exporter" Version="1.5.0" />
|
||||
@@ -42,18 +42,24 @@
|
||||
<!-- Newtonsoft.Json -->
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<!-- System.* -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Bcl.HashCode" Version="6.0.0" />
|
||||
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.13.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.14.0" />
|
||||
<PackageVersion Include="System.CodeDom" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Collections.Immutable" Version="10.0.1" />
|
||||
<PackageVersion Include="System.CommandLine" Version="2.0.0-rc.2.25502.107" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Diagnostics.DiagnosticSource" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Linq.AsyncEnumerable" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Net.Http.Json" Version="10.0.0" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Net.ServerSentEvents" Version="10.0.8" />
|
||||
<!-- AG-UI .NET SDK packages (published by the AG-UI team). -->
|
||||
<PackageVersion Include="AGUI.Abstractions" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Formatting" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Protobuf" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Client" Version="0.0.3" />
|
||||
<PackageVersion Include="AGUI.Server" Version="0.0.3" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.8" />
|
||||
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
|
||||
<PackageVersion Include="System.Net.Security" Version="4.3.2" />
|
||||
@@ -70,7 +76,8 @@
|
||||
<!-- Microsoft.AspNetCore.* -->
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.OpenApi" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.OpenApi" Version="2.7.5" /> <!-- Pin patched OpenAPI.NET to remediate GHSA-v5pm-xwqc-g5wc -->
|
||||
<PackageVersion Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.0.0" />
|
||||
<!-- Microsoft.Extensions.* -->
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.6.0" />
|
||||
@@ -87,12 +94,12 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Configuration.UserSecrets" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.FileSystemGlobbing" Version="10.0.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.8" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.9" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Console" Version="10.0.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.0.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.VectorData.Abstractions" Version="9.7.0" />
|
||||
@@ -100,7 +107,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.InMemory" Version="1.67.0-preview" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Qdrant" Version="1.67.0-preview" />
|
||||
<!-- Agent SDKs -->
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0" />
|
||||
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.5" />
|
||||
<PackageVersion Include="Microsoft.Agents.CopilotStudio.Client" Version="1.3.171-beta" />
|
||||
<!-- M365 Agents SDK -->
|
||||
<PackageVersion Include="AdaptiveCards" Version="3.1.0" />
|
||||
@@ -120,7 +127,7 @@
|
||||
<PackageVersion Include="OllamaSharp" Version="5.4.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.10.0" />
|
||||
<!-- Identity -->
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.83.1" />
|
||||
<PackageVersion Include="Microsoft.Identity.Client.Extensions.Msal" Version="4.84.2" />
|
||||
<!-- Workflows -->
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel" Version="2026.2.4.1" />
|
||||
<PackageVersion Include="Microsoft.Agents.ObjectModel.Json" Version="2026.2.4.1" />
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step01_MeetYourClaw/Claw_Step01_MeetYourClaw.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step02_WorkingWithData/Claw_Step02_WorkingWithData.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/Claw_Step03_ScalingCapabilities.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
@@ -605,7 +606,6 @@
|
||||
<Project Path="src/Aspire.Hosting.AgentFramework.DevUI/Aspire.Hosting.AgentFramework.DevUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.A2A/Microsoft.Agents.AI.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Abstractions/Microsoft.Agents.AI.Abstractions.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AGUI/Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Anthropic/Microsoft.Agents.AI.Anthropic.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.AzureAI.Persistent/Microsoft.Agents.AI.AzureAI.Persistent.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.CopilotStudio/Microsoft.Agents.AI.CopilotStudio.csproj" />
|
||||
@@ -664,7 +664,6 @@
|
||||
<Project Path="tests/Aspire.Hosting.AgentFramework.DevUI.UnitTests/Aspire.Hosting.AgentFramework.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.A2A.UnitTests/Microsoft.Agents.AI.A2A.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Abstractions.UnitTests/Microsoft.Agents.AI.Abstractions.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AGUI.UnitTests/Microsoft.Agents.AI.AGUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Anthropic.UnitTests/Microsoft.Agents.AI.Anthropic.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests/Microsoft.Agents.AI.AzureAI.Persistent.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.CosmosNoSql.UnitTests/Microsoft.Agents.AI.CosmosNoSql.UnitTests.csproj" />
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
"projects": [
|
||||
"src\\Microsoft.Agents.AI.A2A\\Microsoft.Agents.AI.A2A.csproj",
|
||||
"src\\Microsoft.Agents.AI.Abstractions\\Microsoft.Agents.AI.Abstractions.csproj",
|
||||
"src\\Microsoft.Agents.AI.AGUI\\Microsoft.Agents.AI.AGUI.csproj",
|
||||
"src\\Microsoft.Agents.AI.Anthropic\\Microsoft.Agents.AI.Anthropic.csproj",
|
||||
"src\\Microsoft.Agents.AI.GitHub.Copilot\\Microsoft.Agents.AI.GitHub.Copilot.csproj",
|
||||
"src\\Microsoft.Agents.AI.Harness\\Microsoft.Agents.AI.Harness.csproj",
|
||||
|
||||
@@ -335,8 +335,8 @@ internal static class AgentsSamples
|
||||
{
|
||||
Name = "Agent_Step01_FileBasedSkills",
|
||||
ProjectPath = "samples/02-agents/AgentSkills/Agent_Step01_FileBasedSkills",
|
||||
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"Converting units with file-based skills",
|
||||
@@ -354,8 +354,8 @@ internal static class AgentsSamples
|
||||
{
|
||||
Name = "Agent_Step06_McpBasedSkills",
|
||||
ProjectPath = "samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills",
|
||||
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
|
||||
RequiredEnvironmentVariables = ["FOUNDRY_PROJECT_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["FOUNDRY_MODEL"],
|
||||
MustContain =
|
||||
[
|
||||
"Discovering MCP-based skills",
|
||||
@@ -701,7 +701,7 @@ internal static class AgentsSamples
|
||||
OptionalEnvironmentVariables = ["AZURE_AI_MODEL_DEPLOYMENT_NAME"],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should contain a list of countries or information about countries that use the EUR currency.",
|
||||
"The output should contain the current EUR exchange rate against USD and GBP as numeric values.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Central version prefix - applies to all nuget packages. -->
|
||||
<VersionPrefix>1.12.0</VersionPrefix>
|
||||
<VersionPrefix>1.13.0</VersionPrefix>
|
||||
<RCNumber>1</RCNumber>
|
||||
<DateSuffix>260629</DateSuffix>
|
||||
<DateSuffix>260703</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.12.0</GitTag>
|
||||
<GitTag>1.13.0</GitTag>
|
||||
|
||||
<Configurations>Debug;Release;Publish</Configurations>
|
||||
<IsPackable>true</IsPackable>
|
||||
|
||||
@@ -35,7 +35,7 @@ A basic AG-UI server and client that demonstrate the foundational concepts.
|
||||
A basic AG-UI server that hosts an AI agent accessible via HTTP. Demonstrates:
|
||||
|
||||
- Creating an ASP.NET Core web application
|
||||
- Setting up an AG-UI server endpoint with `MapAGUI`
|
||||
- Setting up an AG-UI server endpoint with `MapAGUIServer`
|
||||
- Creating an AI agent from an Azure OpenAI chat client
|
||||
- Streaming responses via Server-Sent Events (SSE)
|
||||
|
||||
@@ -204,7 +204,7 @@ dotnet run
|
||||
### Server-Side
|
||||
|
||||
1. Client sends HTTP POST request with messages
|
||||
2. ASP.NET Core endpoint receives the request via `MapAGUI`
|
||||
2. ASP.NET Core endpoint receives the request via `MapAGUIServer`
|
||||
3. Agent processes messages using Agent Framework
|
||||
4. Responses are streamed back as Server-Sent Events (SSE)
|
||||
|
||||
@@ -214,14 +214,14 @@ dotnet run
|
||||
2. Server responds with SSE stream
|
||||
3. Client parses events into `AgentResponseUpdate` objects
|
||||
4. Updates are displayed based on content type
|
||||
5. `ConversationId` maintains conversation context
|
||||
5. The client sends the full message history each turn (the stateless AG-UI client does not rely on a server-assigned `ConversationId`)
|
||||
|
||||
### Protocol Features
|
||||
|
||||
- **HTTP POST** for requests
|
||||
- **Server-Sent Events (SSE)** for streaming responses
|
||||
- **JSON** for event serialization
|
||||
- **Thread IDs** (as `ConversationId`) for conversation context
|
||||
- **Thread IDs** (read from the `RUN_STARTED` event's raw representation) for conversation context. `AGUIChatClient` is stateless and intentionally does not surface a `ConversationId`.
|
||||
- **Run IDs** (as `ResponseId`) for tracking individual executions
|
||||
|
||||
## Security considerations
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.AGUI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
@@ -14,7 +15,7 @@ using HttpClient httpClient = new()
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
AGUIChatClient chatClient = new(httpClient, serverUrl);
|
||||
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "agui-client",
|
||||
@@ -49,7 +50,7 @@ try
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? sessionId = null;
|
||||
string? threadId = null;
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
|
||||
{
|
||||
@@ -58,9 +59,11 @@ try
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
sessionId = chatUpdate.ConversationId;
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
@@ -84,7 +87,7 @@ try
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Session: {sessionId}]");
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
@@ -36,6 +36,6 @@ AIAgent agent = chatClient.AsAIAgent(
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUI("/", agent);
|
||||
app.MapAGUIServer("/", agent);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.AGUI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
@@ -14,7 +15,7 @@ using HttpClient httpClient = new()
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
AGUIChatClient chatClient = new(httpClient, serverUrl);
|
||||
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "agui-client",
|
||||
@@ -49,7 +50,7 @@ try
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? sessionId = null;
|
||||
string? threadId = null;
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
|
||||
{
|
||||
@@ -58,9 +59,11 @@ try
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
sessionId = chatUpdate.ConversationId;
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
@@ -116,7 +119,7 @@ try
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Session: {sessionId}]");
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(SampleJsonSerializerContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
@@ -93,7 +93,7 @@ ChatClientAgent agent = chatClient.AsAIAgent(
|
||||
tools: tools);
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUI("/", agent);
|
||||
app.MapAGUIServer("/", agent);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.AGUI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:8888";
|
||||
@@ -26,7 +27,7 @@ using HttpClient httpClient = new()
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
AGUIChatClient chatClient = new(httpClient, serverUrl);
|
||||
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
|
||||
|
||||
AIAgent agent = chatClient.AsAIAgent(
|
||||
name: "agui-client",
|
||||
@@ -62,7 +63,7 @@ try
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? sessionId = null;
|
||||
string? threadId = null;
|
||||
|
||||
await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
|
||||
{
|
||||
@@ -71,9 +72,11 @@ try
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
sessionId = chatUpdate.ConversationId;
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"\n[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"\n[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
@@ -109,7 +112,7 @@ try
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Session: {sessionId}]");
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.ResetColor();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ using OpenAI.Chat;
|
||||
|
||||
WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.AddAGUI();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
@@ -36,6 +36,6 @@ AIAgent agent = chatClient.AsAIAgent(
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUI("/", agent);
|
||||
app.MapAGUIServer("/", agent);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.AGUI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
string serverUrl = Environment.GetEnvironmentVariable("AGUI_SERVER_URL") ?? "http://localhost:5100";
|
||||
@@ -13,7 +13,7 @@ using HttpClient httpClient = new()
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
AGUIChatClient chatClient = new(httpClient, serverUrl);
|
||||
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
|
||||
|
||||
// Create agent
|
||||
ChatClientAgent baseAgent = chatClient.AsAIAgent(
|
||||
|
||||
@@ -25,7 +25,7 @@ builder.Services.AddHttpLogging(logging =>
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(ApprovalJsonContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// WARNING: When adding session persistence (e.g., WithInMemorySessionStore), or running in production,
|
||||
// make sure to also register a SessionIsolationKeyProvider to scope sessions by principal in multi-user
|
||||
@@ -73,5 +73,5 @@ ChatClientAgent baseAgent = openAIChatClient.AsAIAgent(
|
||||
// Wrap with ServerFunctionApprovalAgent
|
||||
var agent = new ServerFunctionApprovalAgent(baseAgent, jsonOptions.SerializerOptions);
|
||||
|
||||
app.MapAGUI("/", agent);
|
||||
app.MapAGUIServer("/", agent);
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.AGUI\Microsoft.Agents.AI.AGUI.csproj" />
|
||||
<PackageReference Include="AGUI.Client" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Client;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.AGUI;
|
||||
using Microsoft.Extensions.AI;
|
||||
using RecipeClient;
|
||||
|
||||
@@ -17,7 +18,7 @@ using HttpClient httpClient = new()
|
||||
Timeout = TimeSpan.FromSeconds(60)
|
||||
};
|
||||
|
||||
AGUIChatClient chatClient = new(httpClient, serverUrl);
|
||||
AGUIChatClient chatClient = new(new(httpClient, serverUrl));
|
||||
|
||||
AIAgent baseAgent = chatClient.AsAIAgent(
|
||||
name: "recipe-client",
|
||||
@@ -65,7 +66,7 @@ try
|
||||
|
||||
// Stream the response
|
||||
bool isFirstUpdate = true;
|
||||
string? sessionId = null;
|
||||
string? threadId = null;
|
||||
bool stateReceived = false;
|
||||
|
||||
Console.WriteLine();
|
||||
@@ -77,9 +78,11 @@ try
|
||||
// First update indicates run started
|
||||
if (isFirstUpdate)
|
||||
{
|
||||
sessionId = chatUpdate.ConversationId;
|
||||
// AGUIChatClient is stateless and never surfaces a ConversationId; the thread
|
||||
// id is carried on the AG-UI RUN_STARTED event's raw representation.
|
||||
threadId = (chatUpdate.RawRepresentation as RunStartedEvent)?.ThreadId;
|
||||
Console.ForegroundColor = ConsoleColor.Yellow;
|
||||
Console.WriteLine($"[Run Started - Session: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.WriteLine($"[Run Started - Thread: {threadId}, Run: {chatUpdate.ResponseId}]");
|
||||
Console.ResetColor();
|
||||
isFirstUpdate = false;
|
||||
}
|
||||
@@ -113,7 +116,7 @@ try
|
||||
}
|
||||
|
||||
Console.ForegroundColor = ConsoleColor.Green;
|
||||
Console.WriteLine($"\n[Run Finished - Session: {sessionId}]");
|
||||
Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
|
||||
Console.ResetColor();
|
||||
|
||||
// Display final state if received
|
||||
|
||||
@@ -12,7 +12,7 @@ WebApplicationBuilder builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddHttpClient().AddLogging();
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.TypeInfoResolverChain.Add(RecipeSerializerContext.Default));
|
||||
builder.Services.AddAGUI();
|
||||
builder.Services.AddAGUIServer();
|
||||
|
||||
// Configure to listen on port 8888
|
||||
builder.WebHost.UseUrls("http://localhost:8888");
|
||||
@@ -61,6 +61,6 @@ AIAgent baseAgent = chatClient.AsAIAgent(
|
||||
AIAgent agent = new SharedStateAgent(baseAgent, jsonOptions.SerializerOptions);
|
||||
|
||||
// Map the AG-UI agent endpoint
|
||||
app.MapAGUI("/", agent);
|
||||
app.MapAGUIServer("/", agent);
|
||||
|
||||
await app.RunAsync();
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using AGUI.Abstractions;
|
||||
using AGUI.Server;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -34,10 +36,9 @@ internal sealed class SharedStateAgent : DelegatingAIAgent
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Check if the client sent state in the request
|
||||
if (options is not ChatClientAgentRunOptions { ChatOptions.AdditionalProperties: { } properties } chatRunOptions ||
|
||||
!properties.TryGetValue("ag_ui_state", out object? stateObj) ||
|
||||
stateObj is not JsonElement state ||
|
||||
state.ValueKind != JsonValueKind.Object)
|
||||
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions } chatRunOptions ||
|
||||
!chatOptions.TryGetRunAgentInput(out RunAgentInput? agentInput) ||
|
||||
agentInput.State is not { ValueKind: JsonValueKind.Object } state)
|
||||
{
|
||||
// No state management requested, pass through to inner agent
|
||||
await foreach (var update in this.InnerAgent.RunStreamingAsync(messages, session, options, cancellationToken).ConfigureAwait(false))
|
||||
|
||||
+24
-30
@@ -12,7 +12,7 @@ using Microsoft.Extensions.AI;
|
||||
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
|
||||
|
||||
const string AgentInstructions = "You are a helpful assistant that can use the countries API to retrieve information about countries by their currency code. When calling the API, always pass fields=name to limit the response to just country names.";
|
||||
const string AgentInstructions = "You are a helpful assistant that can retrieve the latest currency exchange rates using the Frankfurter API. Always call the API to get live data rather than guessing.";
|
||||
// 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.
|
||||
@@ -25,44 +25,45 @@ AIAgent agent = aiProjectClient.AsAIAgent(deploymentName,
|
||||
name: "OpenAPIToolsAgent",
|
||||
tools: [openApiTool]);
|
||||
|
||||
// Run the agent with a question about countries
|
||||
Console.WriteLine(await agent.RunAsync("What countries use the Euro (EUR) as their currency? Please list them."));
|
||||
// Run the agent with a question about EUR exchange rates
|
||||
Console.WriteLine(await agent.RunAsync("What is the latest EUR exchange rate against the US Dollar (USD) and British Pound (GBP)?"));
|
||||
|
||||
OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition()
|
||||
{
|
||||
// A simple OpenAPI specification for the REST Countries API
|
||||
const string CountriesOpenApiSpec = """
|
||||
// OpenAPI spec for Frankfurter — a free, no-auth exchange rate API backed by ECB data.
|
||||
// See https://www.frankfurter.dev/ for documentation.
|
||||
const string FrankfurterOpenApiSpec = """
|
||||
{
|
||||
"openapi": "3.1.0",
|
||||
"info": {
|
||||
"title": "REST Countries API",
|
||||
"description": "Retrieve information about countries by currency code",
|
||||
"version": "v3.1"
|
||||
"title": "Frankfurter Exchange Rate API",
|
||||
"description": "Free currency exchange rates from the European Central Bank",
|
||||
"version": "v1"
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "https://restcountries.com/v3.1"
|
||||
"url": "https://api.frankfurter.dev/v1"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/currency/{currency}": {
|
||||
"/latest": {
|
||||
"get": {
|
||||
"description": "Get countries that use a specific currency code (e.g., USD, EUR, GBP)",
|
||||
"operationId": "GetCountriesByCurrency",
|
||||
"description": "Get the latest exchange rates for a given base currency",
|
||||
"operationId": "GetLatestExchangeRates",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "currency",
|
||||
"in": "path",
|
||||
"description": "Currency code (e.g., USD, EUR, GBP)",
|
||||
"required": true,
|
||||
"name": "from",
|
||||
"in": "query",
|
||||
"description": "Base currency code (e.g. EUR, USD, GBP). Defaults to EUR.",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "fields",
|
||||
"name": "to",
|
||||
"in": "query",
|
||||
"description": "Comma-separated list of fields to include in the response (e.g., name,currencies)",
|
||||
"description": "Comma-separated list of target currency codes (e.g. USD,GBP,JPY).",
|
||||
"required": false,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
@@ -71,20 +72,14 @@ OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition()
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response with list of countries",
|
||||
"description": "Latest exchange rates",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "No countries found for the currency"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,12 +88,11 @@ OpenApiFunctionDefinition CreateOpenAPIFunctionDefinition()
|
||||
}
|
||||
""";
|
||||
|
||||
// Create the OpenAPI function definition
|
||||
return new(
|
||||
"get_countries",
|
||||
BinaryData.FromString(CountriesOpenApiSpec),
|
||||
"get_exchange_rates",
|
||||
BinaryData.FromString(FrankfurterOpenApiSpec),
|
||||
new OpenAPIAnonymousAuthenticationDetails())
|
||||
{
|
||||
Description = "Retrieve information about countries by currency code"
|
||||
Description = "Get live currency exchange rates from the European Central Bank via Frankfurter"
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ This sample shows how to use OpenAPI tools with a `ChatClientAgent` using the Re
|
||||
## What this sample demonstrates
|
||||
|
||||
- Defining an OpenAPI specification inline
|
||||
- Creating an `OpenAPIFunctionDefinition` for the REST Countries API
|
||||
- Creating an `OpenAPIFunctionDefinition` for the Frankfurter exchange rate API
|
||||
- Using `FoundryAITool.CreateOpenApiTool()` with `ChatClientAgent`
|
||||
- Server-side execution of OpenAPI tool calls
|
||||
|
||||
|
||||
+7
-6
@@ -90,7 +90,7 @@ static async Task<string> CreateSampleToolboxAsync(string name, string endpoint,
|
||||
// Delete existing toolbox if present (ignore 404).
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
await toolboxClient.DeleteAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
@@ -99,12 +99,13 @@ static async Task<string> CreateSampleToolboxAsync(string name, string endpoint,
|
||||
}
|
||||
|
||||
// Create a fresh version with a single MCP tool.
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: "api-specs",
|
||||
serverUri: new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
MCPToolboxTool mcpTool = new("api-specs")
|
||||
{
|
||||
ServerUri = new Uri("https://gitmcp.io/Azure/azure-rest-api-specs"),
|
||||
ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval),
|
||||
};
|
||||
|
||||
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
ToolboxVersion created = (await toolboxClient.CreateVersionAsync(
|
||||
name: name,
|
||||
tools: [mcpTool],
|
||||
description: "Sample toolbox with an MCP tool — created by Agent_Step25 sample.")).Value;
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -38,7 +38,17 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), new DefaultAzureCredentia
|
||||
Instructions = "You are a helpful assistant that can convert units.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
})
|
||||
.AsBuilder()
|
||||
.UseToolApproval(new ToolApprovalAgentOptions
|
||||
{
|
||||
// NOTE: Auto-approving all skill tools is done here for simplicity in
|
||||
// this demonstration. In production, you should prompt the user before
|
||||
// allowing script execution. See Agent_Step07_SkillsAutoApproval for a
|
||||
// walkthrough of the full approval flow.
|
||||
AutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule],
|
||||
})
|
||||
.Build();
|
||||
|
||||
// --- Example: Unit conversion ---
|
||||
Console.WriteLine("Converting units with file-based skills");
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001</NoWarn>
|
||||
<NoWarn>$(NoWarn);</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
|
||||
<NoWarn>$(NoWarn);IDE0051</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001;IDE0051</NoWarn>
|
||||
<NoWarn>$(NoWarn);IDE0051</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001;CA1812;IDE0051</NoWarn>
|
||||
<NoWarn>$(NoWarn);CA1812;IDE0051</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001;MCPEXP001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -63,7 +63,17 @@ AIAgent agent = new AIProjectClient(new Uri(openAiEndpoint), new DefaultAzureCre
|
||||
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
});
|
||||
})
|
||||
.AsBuilder()
|
||||
.UseToolApproval(new ToolApprovalAgentOptions
|
||||
{
|
||||
// NOTE: Auto-approving all skill tools is done here for simplicity in
|
||||
// this demonstration. In production, you should prompt the user before
|
||||
// allowing skill tools to execute. See Agent_Step07_SkillsAutoApproval
|
||||
// for a walkthrough of the full approval flow.
|
||||
AutoApprovalRules = [AgentSkillsProvider.AllToolsAutoApprovalRule],
|
||||
})
|
||||
.Build();
|
||||
|
||||
// --- Run ---
|
||||
Console.WriteLine(new string('-', 60));
|
||||
|
||||
@@ -32,3 +32,12 @@ $env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Discovering skills over MCP means an external MCP server controls what skill content (including
|
||||
instructions and, for archive-type entries, extracted files) reaches the agent. A compromised or
|
||||
untrustworthy server could return adversarial content designed to manipulate the agent (indirect
|
||||
prompt injection) or to exfiltrate data through skill instructions/scripts. Only connect `UseMcpSkills`
|
||||
to MCP servers you have vetted and trust, and keep the conservative archive size/file-count limits in
|
||||
`AgentMcpSkillsSourceOptions` unless you have a specific reason to raise them.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
|
||||
@@ -130,3 +130,13 @@ AIAgent agent = agentChatClient
|
||||
This places the compaction provider at the agent level instead of the chat client level, which allows you to use different compaction strategies for different agents that share the same chat client.
|
||||
|
||||
> Note: In this mode the `CompactionProvider` is not engaged during the tool calling loop. Agent-level `AIContextProviders` run before chat history is stored, so any synthetic summary messages produced by `CompactionProvider` can become part of the persisted history when using `ChatHistoryProvider`. If you want to compact only the request context while preserving the original stored history, register `CompactionProvider` on the `ChatClientBuilder` via `UseAIContextProviders(...)` instead of on `ChatClientAgentOptions`.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Most compaction strategies in this pipeline (tool-result summarization, sliding window, truncation) only
|
||||
remove or reorder existing messages and carry no additional risk. `SummarizationCompactionStrategy` is
|
||||
the exception: it calls out to an LLM to produce replacement summary content that permanently becomes
|
||||
part of chat history. A compromised or malicious summarization service could return a summary containing
|
||||
unsafe instructions, creating a persistent indirect-prompt-injection vector. Using
|
||||
`SummarizationCompactionStrategy` is optional and requires explicit configuration — only point its
|
||||
`IChatClient` at a summarization service you trust as much as the primary model.
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<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" />
|
||||
<PackageReference Include="Hyperlight.HyperlightSandbox.Guest.Python" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Harness\Microsoft.Agents.AI.Harness.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Hyperlight\Microsoft.Agents.AI.Hyperlight.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\..\src\Microsoft.Agents.AI.Tools.Shell\Microsoft.Agents.AI.Tools.Shell.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console\Harness_Shared_Console.csproj" />
|
||||
<ProjectReference Include="..\..\Harness_Shared_Console_OpenAI\Harness_Shared_Console_OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="skills\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
<Content Include="working\**\*" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Net.Http.Headers;
|
||||
using Azure.Core;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// Helpers for wiring centrally-managed <b>Foundry skills</b> into the claw via a Foundry Toolbox
|
||||
/// MCP endpoint. These are opt-in: skills published to the toolbox are discovered at runtime, so
|
||||
/// they can be managed and updated without changing or redeploying the agent.
|
||||
/// </summary>
|
||||
internal static class FoundrySkills
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects to a Foundry Toolbox MCP endpoint and returns a connected <see cref="McpClient"/>.
|
||||
/// The caller owns the returned client and its HTTP client.
|
||||
/// </summary>
|
||||
/// <param name="toolboxMcpServerUrl">The Foundry Toolbox MCP server URL.</param>
|
||||
/// <param name="credential">Credential used to obtain a bearer token for the toolbox.</param>
|
||||
/// <returns>The connected MCP client and the underlying HTTP client; both must be disposed by the caller.</returns>
|
||||
public static async Task<(McpClient McpClient, HttpClient HttpClient)> ConnectAsync(
|
||||
string toolboxMcpServerUrl,
|
||||
TokenCredential credential)
|
||||
{
|
||||
var httpClient = new HttpClient(new BearerTokenHandler(credential, "https://ai.azure.com/.default")
|
||||
{
|
||||
InnerHandler = new HttpClientHandler(),
|
||||
});
|
||||
|
||||
try
|
||||
{
|
||||
McpClient mcpClient = await McpClient.CreateAsync(
|
||||
new HttpClientTransport(
|
||||
new HttpClientTransportOptions
|
||||
{
|
||||
Endpoint = new Uri(toolboxMcpServerUrl),
|
||||
Name = "foundry_toolbox",
|
||||
TransportMode = HttpTransportMode.StreamableHttp,
|
||||
AdditionalHeaders = new Dictionary<string, string>
|
||||
{
|
||||
["Foundry-Features"] = "Toolboxes=V1Preview",
|
||||
},
|
||||
},
|
||||
httpClient));
|
||||
|
||||
return (mcpClient, httpClient);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The MCP client never took ownership of the HTTP client, so dispose it here.
|
||||
httpClient.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BearerTokenHandler(TokenCredential credential, string scope) : DelegatingHandler
|
||||
{
|
||||
private readonly TokenRequestContext _tokenContext = new([scope]);
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
AccessToken token = await credential.GetTokenAsync(this._tokenContext, cancellationToken).ConfigureAwait(false);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token);
|
||||
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
+228
@@ -0,0 +1,228 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// "Scaling its capabilities" — Post 3 of the "Build your own claw and agent harness with Microsoft
|
||||
// Agent Framework" series.
|
||||
// See: https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/.
|
||||
//
|
||||
// This sample builds on Post 2's personal finance assistant and makes it *more capable* in four ways:
|
||||
// 1. Skills — package finance know-how (valuation, risk-scoring) as discoverable SKILL.md
|
||||
// files the agent loads on demand. Optionally fold in centrally-managed Foundry
|
||||
// skills from a Foundry Toolbox MCP endpoint (opt-in via FOUNDRY_TOOLBOX_MCP_SERVER_URL).
|
||||
// 2. Shell — a sandboxed shell, confined to the trade-confirmation vault, that the agent
|
||||
// uses to reorganize the accumulated confirmation files (year/month, rename,
|
||||
// archive). Guarded by a deny-list policy and a confined working directory.
|
||||
// 3. CodeAct — the agent writes and runs Python to crunch portfolio numbers, in a sandboxed
|
||||
// Hyperlight micro-VM (needs hardware virtualization).
|
||||
// 4. Background agents — fan out a per-ticker research sub-agent so several tickers are researched
|
||||
// concurrently, then aggregated.
|
||||
//
|
||||
// Special commands (handled by the shared HarnessConsole):
|
||||
// /todos — Display the current todo list without invoking the agent.
|
||||
// /mode — Get or set the current agent mode.
|
||||
// /exit — End the session.
|
||||
|
||||
#pragma warning disable OPENAI001 // Suppress experimental API warnings for Responses API usage.
|
||||
#pragma warning disable MAAI001 // Suppress experimental API warnings for Agents AI experiments.
|
||||
|
||||
using System.ClientModel.Primitives;
|
||||
using Azure.AI.Projects;
|
||||
using Azure.Identity;
|
||||
using ClawSample;
|
||||
using Harness.Shared.Console;
|
||||
using Harness.Shared.Console.OpenAI;
|
||||
using Harness.Shared.Console.ToolFormatters;
|
||||
using HyperlightSandbox.Guest.Python;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Hyperlight;
|
||||
using Microsoft.Agents.AI.Tools.Shell;
|
||||
using Microsoft.Extensions.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";
|
||||
|
||||
// The two folders the claw works in: the working folder (portfolio.csv, reports) and the
|
||||
// trade-confirmation "vault" inside it that the shell will reorganize.
|
||||
var workingDir = Path.Combine(AppContext.BaseDirectory, "working");
|
||||
var vaultDir = Path.Combine(workingDir, "confirmations");
|
||||
var skillsDir = Path.Combine(AppContext.BaseDirectory, "skills");
|
||||
|
||||
// <instructions>
|
||||
var instructions =
|
||||
"""
|
||||
## Personal Finance Assistant Instructions
|
||||
|
||||
You are a personal finance and investing assistant. You help the user understand their
|
||||
portfolio and watchlist, value individual stocks, gauge portfolio risk, research the market,
|
||||
and keep their records tidy.
|
||||
|
||||
### Working style
|
||||
|
||||
- The user's holdings live in a file called portfolio.csv. Read it with the file_access tools
|
||||
before answering questions about their portfolio, and never modify it unless asked.
|
||||
- You have skills for valuation and risk-scoring. When a question matches a skill, load it and
|
||||
follow its instructions (read its references, run its scripts) rather than guessing.
|
||||
- When asked to research several tickers, delegate each one to the background research agent so
|
||||
they run concurrently, then summarize the findings together.
|
||||
- The user's trade confirmations accumulate in the working/confirmations folder. When asked to
|
||||
tidy or reorganize them, use the run_shell tool: inspect the folder first, then move files into
|
||||
a year/month layout and rename them to YYYY-MM-DD_TICKER_BUY|SELL.txt. Explain your plan before
|
||||
running commands that change anything.
|
||||
- To buy or sell, use the place_trade tool. This takes a real action, so the user will be asked
|
||||
to approve it before it runs — explain what you are about to do first.
|
||||
|
||||
### Important
|
||||
|
||||
You provide information and analysis only — you are not a licensed financial advisor and you
|
||||
must not present your output as personalized investment advice. Remind the user to do their own
|
||||
research before making decisions.
|
||||
""";
|
||||
// </instructions>
|
||||
|
||||
// <create_client>
|
||||
// Construct an IChatClient backed by a Microsoft Foundry project (see Post 1 for details).
|
||||
var credential = new DefaultAzureCredential();
|
||||
var projectClient = 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.
|
||||
credential,
|
||||
new AIProjectClientOptions { RetryPolicy = new ClientRetryPolicy(3) });
|
||||
|
||||
IChatClient chatClient = projectClient
|
||||
.GetProjectOpenAIClient()
|
||||
.GetResponsesClient()
|
||||
.AsIChatClient(deploymentName);
|
||||
// </create_client>
|
||||
|
||||
// <skills>
|
||||
// The harness turns a skills provider on by default (it discovers SKILL.md files from the working
|
||||
// directory). Here we build our own so we can point it at this sample's skills/ folder and, when
|
||||
// configured, fold in centrally-managed Foundry skills — all behind one provider.
|
||||
var skillsBuilder = new AgentSkillsProviderBuilder()
|
||||
// File-based skills: valuation and risk-scoring. SubprocessScriptRunner runs their Python scripts.
|
||||
.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync);
|
||||
|
||||
// Foundry skills (opt-in): discovered live from a Foundry Toolbox MCP endpoint, so they can be
|
||||
// managed and updated centrally without changing or redeploying this agent.
|
||||
HttpClient? toolboxHttpClient = null;
|
||||
ModelContextProtocol.Client.McpClient? toolboxMcpClient = null;
|
||||
var toolboxUrl = Environment.GetEnvironmentVariable("FOUNDRY_TOOLBOX_MCP_SERVER_URL");
|
||||
if (!string.IsNullOrWhiteSpace(toolboxUrl))
|
||||
{
|
||||
(toolboxMcpClient, toolboxHttpClient) = await FoundrySkills.ConnectAsync(toolboxUrl, credential);
|
||||
skillsBuilder.UseMcpSkills(toolboxMcpClient);
|
||||
Console.WriteLine("Foundry skills enabled (Toolbox MCP).");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Foundry skills disabled. Set FOUNDRY_TOOLBOX_MCP_SERVER_URL to enable them.");
|
||||
}
|
||||
|
||||
AgentSkillsProvider skillsProvider = skillsBuilder.Build();
|
||||
// </skills>
|
||||
|
||||
// <background>
|
||||
// Background agents: a lean, web-search-only research sub-agent. Passing it to the harness exposes
|
||||
// the background_agents_* tools so the claw can start several research tasks concurrently and
|
||||
// collect the results.
|
||||
AIAgent researchAgent = ResearchAgent.Create(chatClient);
|
||||
// </background>
|
||||
|
||||
// <shell>
|
||||
// A sandboxed shell, confined to the trade-confirmation vault. ConfineWorkingDirectory re-anchors
|
||||
// every command to the vault, and the deny-list policy pre-filters obviously destructive commands.
|
||||
// (Patterns are a UX guardrail, not a security boundary — for hard isolation use DockerShellExecutor.)
|
||||
await using var shell = new LocalShellExecutor(new LocalShellExecutorOptions
|
||||
{
|
||||
WorkingDirectory = vaultDir,
|
||||
ConfineWorkingDirectory = true,
|
||||
Policy = new ShellPolicy(denyList:
|
||||
[
|
||||
@"\brm\s+-rf\b",
|
||||
@"\bsudo\b",
|
||||
@":\(\)\s*\{", // fork-bomb shape
|
||||
@"\bmkfs\b",
|
||||
@">\s*/dev/sd",
|
||||
]),
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
});
|
||||
// </shell>
|
||||
|
||||
// <codeact>
|
||||
// CodeAct: a sandboxed Python interpreter the model can write and run code in to crunch numbers.
|
||||
// It runs on Hyperlight (a micro-VM, so it needs hardware virtualization). The guest module path is
|
||||
// resolved automatically from the Hyperlight.HyperlightSandbox.Guest.Python NuGet package.
|
||||
using var codeAct = new HyperlightCodeActProvider(HyperlightCodeActProviderOptions.CreateForWasm(PythonGuestModule.GetModulePath()));
|
||||
// </codeact>
|
||||
|
||||
// <create_agent>
|
||||
// Turn the chat client into a HarnessAgent. On top of Post 2's file access and approvals we add the
|
||||
// four "scaling" capabilities: skills (our own provider), background agents, a confined shell, and
|
||||
// CodeAct.
|
||||
List<AIContextProvider> contextProviders = [skillsProvider, codeAct];
|
||||
|
||||
AIAgent agent = chatClient.AsHarnessAgent(new HarnessAgentOptions
|
||||
{
|
||||
// File access: portfolio.csv, reports, and the confirmations vault all live under working/.
|
||||
FileAccessStore = new FileSystemAgentFileStore(workingDir),
|
||||
// We supply our own skills provider (file + optional Foundry), so turn off the default one.
|
||||
DisableAgentSkillsProvider = true,
|
||||
// Fan-out research is delegated to this background agent.
|
||||
BackgroundAgents = [researchAgent],
|
||||
// The confined shell, exposed as the approval-gated run_shell tool.
|
||||
ShellExecutor = shell,
|
||||
// Keep reading the portfolio frictionless while writes, trades, and shell commands still prompt.
|
||||
ToolApprovalAgentOptions = new ToolApprovalAgentOptions
|
||||
{
|
||||
AutoApprovalRules = [FileAccessProvider.ReadOnlyToolsAutoApprovalRule],
|
||||
},
|
||||
// Start in "execute" mode for quick lookups and actions; switch any time with /mode plan.
|
||||
AgentModeProviderOptions = new AgentModeProviderOptions { DefaultMode = "execute" },
|
||||
// Our skills provider plus CodeAct.
|
||||
AIContextProviders = contextProviders,
|
||||
ChatOptions = new ChatOptions
|
||||
{
|
||||
Instructions = instructions,
|
||||
Tools =
|
||||
[
|
||||
StockTools.CreateGetStockPriceTool(),
|
||||
TradingTools.CreatePlaceTradeTool(),
|
||||
],
|
||||
Reasoning = new() { Effort = ReasoningEffort.Medium },
|
||||
},
|
||||
});
|
||||
// </create_agent>
|
||||
|
||||
try
|
||||
{
|
||||
// <run>
|
||||
// Run the interactive console session. The default planning observers already include a tool
|
||||
// approval observer, so the place_trade and run_shell approval prompts are surfaced automatically.
|
||||
await HarnessConsole.RunAgentAsync(
|
||||
agent,
|
||||
userPrompt: "Ask me to value a stock, score your portfolio risk, research some tickers, or tidy your trade confirmations.",
|
||||
new HarnessConsoleOptions
|
||||
{
|
||||
Observers = [
|
||||
new OpenAIResponsesWebSearchDisplayObserver(),
|
||||
new OpenAIResponsesErrorObserver(),
|
||||
.. HarnessConsoleOptions.BuildObserversWithPlanning(
|
||||
agent,
|
||||
planModeName: "plan",
|
||||
executionModeName: "execute",
|
||||
toolFormatters: ToolCallFormatter.BuildDefaultToolFormatters())],
|
||||
CommandHandlers = HarnessConsoleOptions.BuildDefaultCommandHandlers(agent),
|
||||
});
|
||||
// </run>
|
||||
}
|
||||
finally
|
||||
{
|
||||
codeAct?.Dispose();
|
||||
if (toolboxMcpClient is not null)
|
||||
{
|
||||
await toolboxMcpClient.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
toolboxHttpClient?.Dispose();
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
# Scaling its capabilities (Post 3) — .NET
|
||||
|
||||
The third runnable sample from the [**"Build your own claw and agent harness with Microsoft Agent Framework"** blog](https://devblogs.microsoft.com/agent-framework/build-your-own-claw-and-agent-harness-with-microsoft-agent-framework)
|
||||
series ([Part 3 — Scaling its capabilities](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/)).
|
||||
It builds on Post 2's personal finance assistant and makes it *more capable* along four axes.
|
||||
|
||||
## What this sample demonstrates
|
||||
|
||||
- **Skills** — finance know-how (`valuation`, `risk-scoring`) is packaged as discoverable `SKILL.md`
|
||||
files under `skills/`, which the agent loads on demand. The sample builds its own provider with
|
||||
`AgentSkillsProviderBuilder.UseFileSkills([skillsDir], scriptRunner: new SubprocessScriptRunner().RunAsync)`
|
||||
so the skills' Python scripts can run, and sets `DisableAgentSkillsProvider = true` to replace the
|
||||
harness default. Optionally folds in centrally-managed **Foundry skills** discovered live from a
|
||||
Foundry **Toolbox MCP** endpoint via `FoundrySkills.ConnectAsync(...)` + `UseMcpSkills(...)`
|
||||
(opt-in; see below).
|
||||
- **Shell** — a `LocalShellExecutor` confined to the trade-confirmation vault
|
||||
(`working/confirmations/`) lets the agent tidy the accumulated confirmation files (reorganize into
|
||||
`year/month`, rename to `YYYY-MM-DD_TICKER_BUY|SELL.txt`). `ConfineWorkingDirectory` re-anchors
|
||||
every command to the vault and a `ShellPolicy` deny-list pre-filters obviously destructive
|
||||
commands. Exposed as the `run_shell` tool, which prompts for approval before each command runs.
|
||||
(The deny-list is a UX guardrail, not a security boundary — for hard isolation use a
|
||||
`DockerShellExecutor`.)
|
||||
- **CodeAct** — a `HyperlightCodeActProvider` gives the agent a sandboxed Python interpreter to
|
||||
crunch portfolio numbers by writing and running code. It runs on Hyperlight (a micro-VM), so it
|
||||
requires hardware virtualization. The guest module path is resolved automatically from the
|
||||
`Hyperlight.HyperlightSandbox.Guest.Python` NuGet package via `PythonGuestModule.GetModulePath()`.
|
||||
- **Background agents** — a lean, web-search-only `ResearchAgent` is registered via
|
||||
`HarnessAgentOptions.BackgroundAgents`, exposing the `background_agents_*` tools so the main agent
|
||||
can fan out per-ticker research concurrently and aggregate the findings.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. A Microsoft Foundry project with a deployed model (e.g. `gpt-5.4`).
|
||||
2. Azure CLI installed and authenticated (`az login`).
|
||||
3. *(For CodeAct)* a host with hardware virtualization enabled (Hyperlight runs the Python
|
||||
interpreter in a micro-VM).
|
||||
|
||||
## Environment variables
|
||||
|
||||
```bash
|
||||
export FOUNDRY_PROJECT_ENDPOINT="https://your-project.services.ai.azure.com/api/projects/your-project"
|
||||
# Optional (defaults to gpt-5.4)
|
||||
export FOUNDRY_MODEL="gpt-5.4"
|
||||
|
||||
# Optional — enable centrally-managed Foundry skills (Foundry Toolbox MCP endpoint URL):
|
||||
export FOUNDRY_TOOLBOX_MCP_SERVER_URL="https://your-project.services.ai.azure.com/.../toolboxes/your-toolbox/mcp?api-version=v1"
|
||||
```
|
||||
|
||||
When `FOUNDRY_TOOLBOX_MCP_SERVER_URL` is not set, the sample runs with the local file skills only and
|
||||
prints a note.
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd dotnet
|
||||
dotnet run --project samples/02-agents/Harness/BuildYourOwnClaw/Claw_Step03_ScalingCapabilities
|
||||
```
|
||||
|
||||
## What to expect
|
||||
|
||||
The sample starts an interactive loop in **execute** mode (quick lookups don't need a plan). Try
|
||||
these in order:
|
||||
|
||||
1. `Value MSFT for me.` — the agent loads the `valuation` skill and follows its instructions
|
||||
(reading references and running its script).
|
||||
2. `Score the risk of my portfolio.` — the agent reads `portfolio.csv` and loads the `risk-scoring`
|
||||
skill.
|
||||
3. `/mode plan`, then `Tidy up my trade confirmations.` — switching to plan mode first makes the
|
||||
agent inspect `working/confirmations/` and propose a reorganization plan before touching anything;
|
||||
once you approve it switches to execute and uses the shell to reorganize and rename the files,
|
||||
**prompting you to approve** each command.
|
||||
4. `Work out the total value of my portfolio.` — the agent writes and runs Python via CodeAct.
|
||||
5. `Research MSFT, NVDA and SPY and summarize the latest news.` — the agent fans the tickers out to
|
||||
the background research agent and aggregates the results.
|
||||
6. `What's the capital of France?` — with a `financial-agent-rules` skill published to your Foundry
|
||||
toolbox and Foundry skills enabled (`FOUNDRY_TOOLBOX_MCP_SERVER_URL`), the agent loads it,
|
||||
recognizes the question is off-topic, and politely declines, steering you back to finance.
|
||||
|
||||
See the [Part 3 blog post](https://devblogs.microsoft.com/agent-framework/agent-harness-scaling-the-claw-or-harness-capabilities/)
|
||||
for more on the `financial-agent-rules` skill — including the SKILL.md to publish to your Foundry toolbox.
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the background "research" agent that the main claw fans work out to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This sub-agent doesn't need any of the harness machinery, so it's a plain
|
||||
/// <see cref="ChatClientAgent"/> with a single tool: the hosted web search. The parent claw
|
||||
/// delegates a per-ticker research task to one of these and they run concurrently.
|
||||
/// </remarks>
|
||||
internal static class ResearchAgent
|
||||
{
|
||||
/// <summary>Creates a web-search-only background agent for delegated ticker research.</summary>
|
||||
/// <param name="chatClient">The chat client the background agent should use.</param>
|
||||
public static AIAgent Create(IChatClient chatClient) =>
|
||||
chatClient.AsAIAgent(
|
||||
instructions:
|
||||
"You research a single stock ticker. Use the web search tool to find the most " +
|
||||
"recent, relevant news and commentary, then return a short, factual summary " +
|
||||
"(3-4 bullet points) with no preamble.",
|
||||
name: "TickerResearchAgent",
|
||||
description: "Searches the web for recent news and commentary about a single stock ticker.",
|
||||
// The only tool it needs: the same hosted web search the harness would have added.
|
||||
tools: [new HostedWebSearchTool()]);
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// A custom function tool that gives our "claw" access to (illustrative) stock prices.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The prices and earnings figures returned here are mock data for demonstration purposes only and
|
||||
/// are not real market quotes. In a real assistant you would call a market-data API instead. The
|
||||
/// trailing earnings-per-share value is included so the valuation skill has something to work with.
|
||||
/// </remarks>
|
||||
internal static class StockTools
|
||||
{
|
||||
/// <summary>A delayed, illustrative stock quote, including a trailing earnings-per-share figure.</summary>
|
||||
public sealed record StockQuote(string Symbol, decimal Price, decimal TrailingEps, string Currency, DateTimeOffset AsOf);
|
||||
|
||||
// A tiny in-memory book of (price, trailing EPS) so the sample runs without any external dependency.
|
||||
private static readonly Dictionary<string, (decimal Price, decimal Eps)> s_priceBook = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MSFT"] = (462.97m, 11.80m),
|
||||
["AAPL"] = (229.35m, 6.13m),
|
||||
["GOOGL"] = (178.12m, 7.54m),
|
||||
["AMZN"] = (201.45m, 4.18m),
|
||||
["NVDA"] = (134.81m, 2.95m),
|
||||
["SPY"] = (612.40m, 23.10m),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the latest (delayed, illustrative) stock price and trailing EPS for a ticker symbol.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol, e.g. <c>MSFT</c> or <c>AAPL</c>.</param>
|
||||
[Description("Gets the latest (delayed, illustrative) stock price and trailing earnings per share for a ticker symbol.")]
|
||||
public static StockQuote GetStockPrice(
|
||||
[Description("The stock ticker symbol, e.g. MSFT or AAPL.")] string symbol)
|
||||
{
|
||||
if (!s_priceBook.TryGetValue(symbol, out var data))
|
||||
{
|
||||
// Deterministic pseudo-values for unknown symbols so the sample stays self-contained.
|
||||
// Derive a stable seed from the characters — string.GetHashCode() is randomized per
|
||||
// process and Math.Abs(int.MinValue) throws, so neither is safe for repeatable output.
|
||||
var seed = 0;
|
||||
foreach (var ch in symbol.ToUpperInvariant())
|
||||
{
|
||||
seed = (seed * 31 + ch) % 1_000_000;
|
||||
}
|
||||
|
||||
var price = 50m + seed % 45000 / 100m;
|
||||
data = (price, Math.Round(price / 20m, 2));
|
||||
}
|
||||
|
||||
return new StockQuote(symbol.ToUpperInvariant(), data.Price, data.Eps, "USD", DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>Creates the <see cref="AIFunction"/> wrapper used to expose the tool to the agent.</summary>
|
||||
public static AIFunction CreateGetStockPriceTool() => AIFunctionFactory.Create(GetStockPrice, "get_stock_price");
|
||||
}
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// Sample subprocess-based skill script runner.
|
||||
// Executes file-based skill scripts as local subprocesses.
|
||||
// This is provided for demonstration purposes only.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Executes file-based skill scripts as local subprocesses.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This runner uses the script's absolute path and converts the arguments
|
||||
/// to CLI arguments. When the LLM sends a JSON array, each element is used
|
||||
/// as a positional argument. It is intended for demonstration purposes only.
|
||||
/// </remarks>
|
||||
internal sealed class SubprocessScriptRunner
|
||||
{
|
||||
/// <summary>Maximum time a skill script is allowed to run before it is terminated.</summary>
|
||||
private static readonly TimeSpan s_scriptTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SubprocessScriptRunner"/> class.
|
||||
/// </summary>
|
||||
/// <param name="loggerFactory">
|
||||
/// Optional logger factory. When provided, script outcomes (success output, stderr, non-zero
|
||||
/// exit codes, and failures) are written to the log in addition to being returned to the LLM.
|
||||
/// </param>
|
||||
public SubprocessScriptRunner(ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<SubprocessScriptRunner>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a skill script as a local subprocess.
|
||||
/// </summary>
|
||||
public async Task<object?> RunAsync(
|
||||
AgentFileSkill skill,
|
||||
AgentFileSkillScript script,
|
||||
JsonElement? arguments,
|
||||
IServiceProvider? serviceProvider,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
this._logger.LogDebug("Running script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
|
||||
if (!File.Exists(script.FullPath))
|
||||
{
|
||||
this._logger.LogError("Script file not found for skill '{SkillName}': {ScriptPath}", skill.Frontmatter.Name, script.FullPath);
|
||||
return $"Error: Script file not found: {script.FullPath}";
|
||||
}
|
||||
|
||||
string extension = Path.GetExtension(script.FullPath);
|
||||
string? interpreter = extension switch
|
||||
{
|
||||
// Windows Python installs commonly expose "python" rather than "python3".
|
||||
".py" => OperatingSystem.IsWindows() ? "python" : "python3",
|
||||
".js" => "node",
|
||||
".sh" => "bash",
|
||||
".ps1" => "pwsh",
|
||||
_ => null,
|
||||
};
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
WorkingDirectory = Path.GetDirectoryName(script.FullPath) ?? ".",
|
||||
};
|
||||
|
||||
if (interpreter is not null)
|
||||
{
|
||||
startInfo.FileName = interpreter;
|
||||
startInfo.ArgumentList.Add(script.FullPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
startInfo.FileName = script.FullPath;
|
||||
}
|
||||
|
||||
if (arguments is { ValueKind: JsonValueKind.Array } json)
|
||||
{
|
||||
// Positional CLI arguments
|
||||
foreach (var element in json.EnumerateArray())
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.String)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"File-based skill scripts only accept string CLI arguments but received a JSON element of kind '{element.ValueKind}'. " +
|
||||
"All array elements must be JSON strings.");
|
||||
}
|
||||
|
||||
startInfo.ArgumentList.Add(element.GetString()!);
|
||||
}
|
||||
}
|
||||
else if (arguments is not null && arguments.Value.ValueKind != JsonValueKind.Null && arguments.Value.ValueKind != JsonValueKind.Undefined)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Expected a JSON array of CLI arguments but received {arguments.Value.ValueKind}. " +
|
||||
"File-based skill scripts expect positional arguments as a JSON array of strings.");
|
||||
}
|
||||
|
||||
// Bound the script's lifetime: cancel after a timeout, or when the caller cancels.
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(s_scriptTimeout);
|
||||
CancellationToken runToken = timeoutCts.Token;
|
||||
|
||||
Process? process = null;
|
||||
try
|
||||
{
|
||||
process = Process.Start(startInfo);
|
||||
if (process is null)
|
||||
{
|
||||
this._logger.LogError("Failed to start process for script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
return $"Error: Failed to start process for script '{script.Name}'.";
|
||||
}
|
||||
|
||||
Task<string> outputTask = process.StandardOutput.ReadToEndAsync(runToken);
|
||||
Task<string> errorTask = process.StandardError.ReadToEndAsync(runToken);
|
||||
|
||||
await process.WaitForExitAsync(runToken).ConfigureAwait(false);
|
||||
|
||||
string output = await outputTask.ConfigureAwait(false);
|
||||
string error = await errorTask.ConfigureAwait(false);
|
||||
|
||||
if (!string.IsNullOrEmpty(error))
|
||||
{
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
this._logger.LogWarning(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' succeeded but wrote to stderr:\n{Stderr}",
|
||||
script.Name, skill.Frontmatter.Name, error.Trim());
|
||||
}
|
||||
|
||||
output += $"\nStderr:\n{error}";
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
this._logger.LogError(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' exited with code {ExitCode}.{Stderr}",
|
||||
script.Name, skill.Frontmatter.Name, process.ExitCode,
|
||||
string.IsNullOrEmpty(error) ? string.Empty : $"\nStderr:\n{error.Trim()}");
|
||||
|
||||
output += $"\nScript exited with code {process.ExitCode}";
|
||||
}
|
||||
|
||||
string result = string.IsNullOrEmpty(output) ? "(no output)" : output.Trim();
|
||||
|
||||
if (process.ExitCode == 0)
|
||||
{
|
||||
this._logger.LogInformation(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' completed successfully. Output:\n{Output}",
|
||||
script.Name, skill.Frontmatter.Name, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The timeout fired (the caller did not cancel). Kill the process and report a timeout.
|
||||
process?.Kill(entireProcessTree: true);
|
||||
this._logger.LogError(
|
||||
"Script '{ScriptName}' from skill '{SkillName}' timed out after {Timeout} seconds.",
|
||||
script.Name, skill.Frontmatter.Name, s_scriptTimeout.TotalSeconds);
|
||||
return $"Error: Script '{script.Name}' timed out after {s_scriptTimeout.TotalSeconds:0} seconds.";
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// The caller cancelled: kill the process to avoid leaving orphaned subprocesses, then rethrow.
|
||||
process?.Kill(entireProcessTree: true);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
this._logger.LogError(ex, "Failed to execute script '{ScriptName}' from skill '{SkillName}'.", script.Name, skill.Frontmatter.Name);
|
||||
return $"Error: Failed to execute script '{script.Name}': {ex.Message}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
process?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace ClawSample;
|
||||
|
||||
/// <summary>
|
||||
/// Sensitive "claw" tools that take real-world actions and therefore require human approval.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// These tools only simulate their effects (no orders are placed, no email is sent). They exist
|
||||
/// to demonstrate how the harness gates risky actions behind an approval prompt.
|
||||
/// </remarks>
|
||||
internal static class TradingTools
|
||||
{
|
||||
// <place_trade>
|
||||
/// <summary>
|
||||
/// Places a (simulated) buy or sell order for a given symbol and quantity.
|
||||
/// </summary>
|
||||
/// <param name="symbol">The stock ticker symbol to trade, e.g. <c>MSFT</c>.</param>
|
||||
/// <param name="action">Either <c>buy</c> or <c>sell</c>.</param>
|
||||
/// <param name="quantity">The number of shares to trade.</param>
|
||||
[Description("Places a buy or sell order for a given symbol and quantity.")]
|
||||
public static string PlaceTrade(
|
||||
[Description("The stock ticker symbol to trade, e.g. MSFT.")] string symbol,
|
||||
[Description("Either 'buy' or 'sell'.")] string action,
|
||||
[Description("The number of shares to trade.")] int quantity)
|
||||
{
|
||||
var isBuy = action.Equals("buy", StringComparison.OrdinalIgnoreCase);
|
||||
var isSell = action.Equals("sell", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isBuy && !isSell)
|
||||
{
|
||||
return $"Invalid action '{action}'. Use 'buy' or 'sell'.";
|
||||
}
|
||||
|
||||
if (quantity <= 0)
|
||||
{
|
||||
return $"Invalid quantity '{quantity}'. Quantity must be a positive whole number of shares.";
|
||||
}
|
||||
|
||||
var verb = isSell ? "Sold" : "Bought";
|
||||
var confirmation = $"TRADE-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
|
||||
return $"{verb} {quantity} share(s) of {symbol.ToUpperInvariant()}. Confirmation: {confirmation}.";
|
||||
}
|
||||
// </place_trade>
|
||||
|
||||
/// <summary>
|
||||
/// Creates an approval-required <see cref="AIFunction"/> for <see cref="PlaceTrade"/>.
|
||||
/// Wrapping the function in <see cref="ApprovalRequiredAIFunction"/> tells the harness to
|
||||
/// surface an approval request before the function ever runs.
|
||||
/// </summary>
|
||||
public static AIFunction CreatePlaceTradeTool() =>
|
||||
new ApprovalRequiredAIFunction(AIFunctionFactory.Create(PlaceTrade, "place_trade"));
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: risk-scoring
|
||||
description: Score how concentrated and risky a portfolio is on a 0-100 scale from its position weights. Use when the user asks how risky their portfolio is, whether it is too concentrated, or for a diversification check.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks about portfolio risk or concentration:
|
||||
|
||||
1. Read `references/risk-bands.md` to understand the score bands and what drives them.
|
||||
2. Compute each holding's market value (shares × price) — use the `get_stock_price` tool for current
|
||||
prices if you do not already have them.
|
||||
3. Run `scripts/risk_score.py` with one `--position VALUE` argument per holding,
|
||||
e.g. `--position 18518 --position 17201 --position 16177`.
|
||||
4. Report the 0-100 score, the band it falls in, and the largest single-position weight, then suggest
|
||||
(in general terms) whether the portfolio looks well diversified or concentrated.
|
||||
|
||||
Remind the user this is a crude concentration measure, not a complete risk model, and not advice.
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
# Risk-scoring guide (illustrative)
|
||||
|
||||
This skill scores **concentration risk** — how much a portfolio depends on its largest positions —
|
||||
on a 0-100 scale, where higher means riskier.
|
||||
|
||||
## How the score is built
|
||||
|
||||
1. Convert each position to a weight: `weight = position_value / total_value`.
|
||||
2. Compute the Herfindahl-Hirschman Index (HHI): `HHI = sum(weight^2)`.
|
||||
- A perfectly even portfolio of *n* holdings has `HHI = 1/n` (low).
|
||||
- A single-stock portfolio has `HHI = 1` (maximum concentration).
|
||||
3. Scale to 0-100: `score = round(HHI * 100)`.
|
||||
|
||||
## Score bands
|
||||
|
||||
| Score | Band | Interpretation |
|
||||
|---------|--------------------|-------------------------------------------------|
|
||||
| 0-20 | Well diversified | No single holding dominates. |
|
||||
| 21-40 | Moderately diversified | Some tilt, but broadly spread. |
|
||||
| 41-60 | Concentrated | A few positions carry most of the risk. |
|
||||
| 61-100 | Highly concentrated| Heavily dependent on one or two positions. |
|
||||
|
||||
Also watch the **largest single-position weight**: above ~25% is usually worth flagging regardless
|
||||
of the overall score.
|
||||
|
||||
This measures concentration only — it ignores volatility, correlation, sector exposure, and leverage,
|
||||
so it is a starting point, not a verdict.
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
# Portfolio risk-scoring script
|
||||
# Scores concentration risk on a 0-100 scale using the Herfindahl-Hirschman Index (HHI).
|
||||
#
|
||||
# weight_i = position_i / total
|
||||
# HHI = sum(weight_i ^ 2)
|
||||
# score = round(HHI * 100) # higher = more concentrated = riskier
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/risk_score.py --position 18518 --position 17201 --position 16177
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Score portfolio concentration risk (0-100).")
|
||||
parser.add_argument(
|
||||
"--position",
|
||||
type=float,
|
||||
action="append",
|
||||
required=True,
|
||||
help="Market value of one holding. Pass once per position.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
positions = args.position
|
||||
if any(p <= 0 for p in positions):
|
||||
print(json.dumps({"error": "Each position value must be a positive market value."}))
|
||||
return
|
||||
|
||||
total = sum(positions)
|
||||
if total <= 0:
|
||||
print(json.dumps({"error": "Total portfolio value must be positive."}))
|
||||
return
|
||||
|
||||
weights = [p / total for p in positions]
|
||||
hhi = sum(w * w for w in weights)
|
||||
score = round(hhi * 100)
|
||||
|
||||
if score <= 20:
|
||||
band = "Well diversified"
|
||||
elif score <= 40:
|
||||
band = "Moderately diversified"
|
||||
elif score <= 60:
|
||||
band = "Concentrated"
|
||||
else:
|
||||
band = "Highly concentrated"
|
||||
|
||||
print(json.dumps({
|
||||
"positions": len(positions),
|
||||
"score": score,
|
||||
"band": band,
|
||||
"largest_weight_pct": round(max(weights) * 100, 1),
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
---
|
||||
name: valuation
|
||||
description: Estimate whether a stock looks cheap or expensive using a price-to-earnings (P/E) based fair-value method. Use when the user asks if a stock is over- or under-valued, or for a fair-value / target price.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user asks whether a stock is fairly valued, over-valued, or under-valued:
|
||||
|
||||
1. Read `references/valuation-guide.md` to pick a sensible target P/E for the company's sector.
|
||||
2. Run `scripts/valuation_metrics.py` with the current price, trailing EPS, and the target P/E,
|
||||
e.g. `--price 462.97 --eps 11.80 --target-pe 32`.
|
||||
3. Report the computed P/E, the fair-value estimate, and the percentage upside/downside, then state
|
||||
plainly whether the stock looks cheap or expensive on this measure.
|
||||
|
||||
Always remind the user that a single P/E heuristic is not investment advice and ignores growth,
|
||||
debt, and many other factors.
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Valuation guide (illustrative)
|
||||
|
||||
A quick price-to-earnings (P/E) sanity check:
|
||||
|
||||
- **P/E = price ÷ trailing earnings per share (EPS)**
|
||||
- **Fair value = trailing EPS × target P/E**
|
||||
- **Upside/downside = (fair value − price) ÷ price**
|
||||
|
||||
## Typical target P/E by sector
|
||||
|
||||
These are rough, illustrative anchors only — not live market multiples.
|
||||
|
||||
| Sector | Conservative target P/E | Growth target P/E |
|
||||
|-----------------------|-------------------------|-------------------|
|
||||
| Mega-cap technology | 28 | 35 |
|
||||
| Semiconductors | 25 | 40 |
|
||||
| Consumer staples | 18 | 22 |
|
||||
| Financials / banks | 11 | 14 |
|
||||
| Broad market (index) | 19 | 21 |
|
||||
|
||||
## How to read the result
|
||||
|
||||
- Fair value **well above** the current price ⇒ the stock looks **cheap** on this measure.
|
||||
- Fair value **well below** the current price ⇒ the stock looks **expensive** on this measure.
|
||||
- Within ~5% ⇒ roughly **fairly valued**.
|
||||
|
||||
This is one crude lens. It ignores growth rates, balance-sheet strength, and cash flow, so never
|
||||
present it as a recommendation.
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
# Valuation metrics script
|
||||
# Computes a simple price-to-earnings (P/E) based fair-value estimate.
|
||||
#
|
||||
# fair_value = eps * target_pe
|
||||
# pe = price / eps
|
||||
# upside = (fair_value - price) / price
|
||||
#
|
||||
# Usage:
|
||||
# python scripts/valuation_metrics.py --price 462.97 --eps 11.80 --target-pe 32
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Compute a P/E based fair-value estimate.")
|
||||
parser.add_argument("--price", type=float, required=True, help="Current share price.")
|
||||
parser.add_argument("--eps", type=float, required=True, help="Trailing earnings per share.")
|
||||
parser.add_argument("--target-pe", type=float, required=True, help="Target P/E from the guide.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.eps <= 0:
|
||||
print(json.dumps({"error": "EPS must be positive to compute a P/E ratio."}))
|
||||
return
|
||||
|
||||
if args.price <= 0:
|
||||
print(json.dumps({"error": "Price must be positive to compute valuation metrics."}))
|
||||
return
|
||||
|
||||
if args.target_pe <= 0:
|
||||
print(json.dumps({"error": "Target P/E must be positive."}))
|
||||
return
|
||||
|
||||
pe = args.price / args.eps
|
||||
fair_value = args.eps * args.target_pe
|
||||
upside = (fair_value - args.price) / args.price
|
||||
|
||||
if upside > 0.05:
|
||||
verdict = "looks cheap"
|
||||
elif upside < -0.05:
|
||||
verdict = "looks expensive"
|
||||
else:
|
||||
verdict = "roughly fairly valued"
|
||||
|
||||
print(json.dumps({
|
||||
"price": round(args.price, 2),
|
||||
"eps": round(args.eps, 2),
|
||||
"target_pe": round(args.target_pe, 2),
|
||||
"pe": round(pe, 2),
|
||||
"fair_value": round(fair_value, 2),
|
||||
"upside_pct": round(upside * 100, 1),
|
||||
"verdict": verdict,
|
||||
}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-55AA44BB
|
||||
Date: 2025-06-21
|
||||
Symbol: NVDA
|
||||
Action: SELL
|
||||
Quantity: 20
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-77CC88DD
|
||||
Date: 2024-05-08
|
||||
Symbol: SPY
|
||||
Action: SELL
|
||||
Quantity: 15
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-9F8E7D6C
|
||||
Date: 2024-11-03
|
||||
Symbol: AAPL
|
||||
Action: BUY
|
||||
Quantity: 75
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-1234ABCD
|
||||
Date: 2025-09-12
|
||||
Symbol: AMZN
|
||||
Action: BUY
|
||||
Quantity: 30
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-EE11FF22
|
||||
Date: 2025-01-30
|
||||
Symbol: GOOGL
|
||||
Action: BUY
|
||||
Quantity: 25
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
TRADE CONFIRMATION
|
||||
Confirmation: TRADE-A1B2C3D4
|
||||
Date: 2024-02-14
|
||||
Symbol: MSFT
|
||||
Action: BUY
|
||||
Quantity: 40
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
symbol,shares,cost_basis,purchase_date
|
||||
MSFT,40,312.50,2023-02-14
|
||||
AAPL,75,168.20,2022-11-03
|
||||
NVDA,120,42.80,2021-06-21
|
||||
AMZN,30,142.10,2023-09-12
|
||||
GOOGL,25,128.45,2024-01-30
|
||||
SPY,60,418.90,2024-05-08
|
||||
|
+54
-10
@@ -1,12 +1,13 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Harness.Shared.Console.ToolFormatters;
|
||||
|
||||
/// <summary>
|
||||
/// Formats <c>file_memory_*</c> tool calls, showing file names and search patterns
|
||||
/// with tree-view corners for save operations.
|
||||
/// with tree-view corners for write and edit operations.
|
||||
/// </summary>
|
||||
public sealed class FileMemoryToolFormatter : ToolCallFormatter
|
||||
{
|
||||
@@ -16,14 +17,16 @@ public sealed class FileMemoryToolFormatter : ToolCallFormatter
|
||||
/// <inheritdoc/>
|
||||
public override string? FormatDetail(FunctionCallContent call) => call.Name switch
|
||||
{
|
||||
"file_memory_save_file" => FormatSaveFile(call),
|
||||
"file_memory_read_file" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_delete_file" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_search_files" => FormatSearchFiles(call),
|
||||
"file_memory_write" => FormatWriteFile(call),
|
||||
"file_memory_read" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_delete" => FormatStringArg(call, "fileName"),
|
||||
"file_memory_replace" => FormatReplaceFile(call),
|
||||
"file_memory_replace_lines" => FormatReplaceLinesFile(call),
|
||||
"file_memory_grep" => FormatGrep(call),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
private static string? FormatSaveFile(FunctionCallContent call)
|
||||
private static string? FormatWriteFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetStringArgumentValue(call, "fileName");
|
||||
string? description = GetStringArgumentValue(call, "description");
|
||||
@@ -38,19 +41,60 @@ public sealed class FileMemoryToolFormatter : ToolCallFormatter
|
||||
: $"\n └─ {fileName} (with description)";
|
||||
}
|
||||
|
||||
private static string? FormatSearchFiles(FunctionCallContent call)
|
||||
private static string? FormatReplaceFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetStringArgumentValue(call, "fileName");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
bool replaceAll = string.Equals(GetStringArgumentValue(call, "replaceAll"), "true", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return replaceAll
|
||||
? $"\n └─ {fileName} (replace all)"
|
||||
: $"\n └─ {fileName} (replace)";
|
||||
}
|
||||
|
||||
private static string? FormatReplaceLinesFile(FunctionCallContent call)
|
||||
{
|
||||
string? fileName = GetStringArgumentValue(call, "fileName");
|
||||
|
||||
if (fileName is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
int count = GetEditsCount(call, "edits");
|
||||
|
||||
return $"\n └─ {fileName} ({count} line(s))";
|
||||
}
|
||||
|
||||
private static int GetEditsCount(FunctionCallContent call, string paramName)
|
||||
{
|
||||
if (call.Arguments?.TryGetValue(paramName, out object? value) == true &&
|
||||
value is JsonElement je && je.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
return je.GetArrayLength();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static string? FormatGrep(FunctionCallContent call)
|
||||
{
|
||||
string? pattern = GetStringArgumentValue(call, "regexPattern");
|
||||
string? filePattern = GetStringArgumentValue(call, "filePattern");
|
||||
string? globPattern = GetStringArgumentValue(call, "globPattern");
|
||||
|
||||
if (pattern is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrEmpty(filePattern)
|
||||
return string.IsNullOrEmpty(globPattern)
|
||||
? $"(/{pattern}/)"
|
||||
: $"(/{pattern}/ in {filePattern})";
|
||||
: $"(/{pattern}/ in {globPattern})";
|
||||
}
|
||||
|
||||
private static string? FormatStringArg(FunctionCallContent call, string paramName)
|
||||
|
||||
+8
@@ -51,3 +51,11 @@ BAC, MSFT, BA
|
||||
```
|
||||
|
||||
The parent agent will delegate each ticker lookup to the web search background agent concurrently and present the results in a table.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
`BackgroundAgentsProvider` delegates work to the agents you supply — the parent sends them text input
|
||||
and receives back whatever they produce. A compromised or malicious background agent could exfiltrate
|
||||
data it receives, or return adversarial output designed to influence the parent agent via indirect
|
||||
prompt injection once its result is retrieved. Only supply background agents you have vetted and trust
|
||||
with the data the parent may pass to them.
|
||||
|
||||
@@ -37,7 +37,7 @@ var instructions =
|
||||
You are a data analyst assistant. You have access to a folder of data files via the file_access_* tools.
|
||||
|
||||
## Getting started
|
||||
- Start by listing available files with file_access_list_files to see what data is available.
|
||||
- Start by listing available files with file_access_ls to see what data is available.
|
||||
- Read the files to understand their structure and contents.
|
||||
|
||||
## Working with data
|
||||
@@ -46,7 +46,7 @@ var instructions =
|
||||
- When calculations are needed, work through them step by step and show your reasoning.
|
||||
|
||||
## Writing output
|
||||
- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_save_file to write them.
|
||||
- When asked to produce output files (e.g., reports, summaries, filtered data), use file_access_write to write them.
|
||||
- Use appropriate file formats: CSV for tabular data, Markdown for reports.
|
||||
- Confirm what you wrote and where.
|
||||
|
||||
|
||||
@@ -57,3 +57,11 @@ dotnet run --project samples/02-agents/Harness/Harness_Step05_Loop
|
||||
## What to Expect
|
||||
|
||||
The program runs the four demos in order. Each loop is executed with `RunStreamingAsync`, so output is printed live and every re-invocation of the inner agent is marked with a `--- run N ---` header (detected via a change in the streamed `ResponseId`) — this lets you see exactly when the `LoopAgent` loops. Each streamed message is prefixed with `User:` or `Agent:` based on its role, so the loop's on-behalf-of feedback messages (surfaced as `User` turns) are visually distinct from the agent's responses (`Agent`). Each demo finishes by printing its aggregated final response. Demo 4 also prints an `Auto-approving: ...` line each time the `ToolApprovalAgent` heuristic approves the `DeployService` tool call, showing how approval-aware agents integrate with the loop.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Demo 3 uses `AIJudgeLoopEvaluator`, which is an explicit opt-in to sending the original request and the
|
||||
agent's latest response to a second, external judge `IChatClient` on every iteration. A compromised or
|
||||
malicious judge endpoint could exfiltrate that data, or return a manipulated verdict/gap analysis that
|
||||
gets fed back into the loop as feedback — a form of indirect prompt injection. Only configure a judge
|
||||
client that points at a service you trust as much as the primary model.
|
||||
|
||||
@@ -19,3 +19,23 @@ Samples accompanying the [*Build your own agent harness or claw with Microsoft A
|
||||
| --- | --- |
|
||||
| [Claw_Step01_MeetYourClaw](./BuildYourOwnClaw/Claw_Step01_MeetYourClaw/README.md) | Post 1 — a minimal HarnessAgent with a custom `get_stock_price` tool, web search, and planning |
|
||||
| [Claw_Step02_WorkingWithData](./BuildYourOwnClaw/Claw_Step02_WorkingWithData/README.md) | Post 2 — file access, approvals, and durable memory (file memory plus optional Foundry memory) |
|
||||
| [Claw_Step03_ScalingCapabilities](./BuildYourOwnClaw/Claw_Step03_ScalingCapabilities/README.md) | Post 3 — scaling the claw with skills (plus optional Foundry skills), a confined shell, CodeAct, and background agents |
|
||||
|
||||
## Security Considerations
|
||||
|
||||
Several harness providers extend the agent's trust boundary to external systems the developer
|
||||
configures — see the security notes in the individual sample READMEs (and the XML docs on the
|
||||
corresponding types) before enabling them in production:
|
||||
- **`BackgroundAgentsProvider`** — delegates work to developer-supplied agents (see
|
||||
[Harness_Step02_Research_WithBackgroundAgents](./Harness_Step02_Research_WithBackgroundAgents/README.md)).
|
||||
- **`AIJudgeLoopEvaluator`** (used by `LoopAgent`) — sends conversation content to a second, external
|
||||
judge chat client (see [Harness_Step05_Loop](./Harness_Step05_Loop/README.md)).
|
||||
- **`AgentSkillsProvider`** with external skill sources (e.g. `UseMcpSkills`) — loads skill content,
|
||||
and potentially scripts, from a remote source (see
|
||||
[AgentSkills samples](../AgentSkills/Agent_Step06_McpBasedSkills/README.md)).
|
||||
- **`SummarizationCompactionStrategy`** — used for in-loop context compaction via
|
||||
`HarnessAgentOptions.CompactionStrategy`, calls out to an LLM whose output becomes permanent chat
|
||||
history (see [Agent_Step18_CompactionPipeline](../Agents/Agent_Step18_CompactionPipeline/README.md)).
|
||||
|
||||
In every case, the capability is opt-in and requires explicit configuration by the developer, who is
|
||||
responsible for vetting the external service, agent, skill source, or provider before enabling it.
|
||||
|
||||
@@ -155,7 +155,7 @@ internal sealed class Program
|
||||
|
||||
try
|
||||
{
|
||||
await toolboxClient.DeleteToolboxAsync(name);
|
||||
await toolboxClient.DeleteAsync(name);
|
||||
Console.WriteLine($"Deleted existing toolbox '{name}'");
|
||||
}
|
||||
catch (ClientResultException ex) when (ex.Status == 404)
|
||||
@@ -163,14 +163,15 @@ internal sealed class Program
|
||||
// Toolbox does not exist.
|
||||
}
|
||||
|
||||
ProjectsAgentTool webTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateWebSearchTool());
|
||||
WebSearchToolboxTool webTool = new();
|
||||
|
||||
ProjectsAgentTool mcpTool = ProjectsAgentTool.AsProjectTool(ResponseTool.CreateMcpTool(
|
||||
serverLabel: serverLabel,
|
||||
serverUri: new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)));
|
||||
MCPToolboxTool mcpTool = new(serverLabel)
|
||||
{
|
||||
ServerUri = new Uri("https://learn.microsoft.com/api/mcp"),
|
||||
ToolCallApprovalPolicy = new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval),
|
||||
};
|
||||
|
||||
ToolboxVersion created = (await toolboxClient.CreateToolboxVersionAsync(
|
||||
ToolboxVersion created = (await toolboxClient.CreateVersionAsync(
|
||||
name: name,
|
||||
tools: [webTool, mcpTool],
|
||||
description: "Sample toolbox combining Foundry web search with the Microsoft Learn MCP tools for the declarative InvokeFoundryToolboxMcp sample.")).Value;
|
||||
|
||||
+1
-4
@@ -7,7 +7,4 @@ SKILL_NAMES=support-style,escalation-policy
|
||||
# Set to true to provision sample skills to Foundry on startup (first-run convenience).
|
||||
# In production, skills are provisioned externally — leave this unset or false.
|
||||
PROVISION_SAMPLE_SKILLS=true
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
# When running outside the Foundry platform the platform-injected user-identity key is absent.
|
||||
# This variable provides a fallback value for local Docker debugging only.
|
||||
HOSTED_USER_ISOLATION_KEY=local-dev-user
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
@@ -112,7 +112,6 @@ AIAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
+3
-4
@@ -14,9 +14,9 @@
|
||||
- .env populated with FOUNDRY_PROJECT_ENDPOINT and model deployment
|
||||
- Skills provisioned to Foundry (set PROVISION_SAMPLE_SKILLS=true on first run)
|
||||
.NOTES
|
||||
This script is for local Docker debugging only. The Foundry platform supplies the
|
||||
isolation keys for every inbound request in production and the dev fallback used here
|
||||
must not be enabled in production deployments.
|
||||
This script is for local Docker debugging only. Running locally the container needs no user
|
||||
identity: per-user isolation simply is not triggered. On the Foundry platform the caller identity
|
||||
(x-agent-user-id) is supplied automatically for every request.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -50,7 +50,6 @@ function Start-Container {
|
||||
docker run -d --name $ContainerName -p ${Port}:8088 `
|
||||
-e AGENT_NAME=hosted-agent-skills `
|
||||
-e AZURE_BEARER_TOKEN=$bearer `
|
||||
-e HOSTED_USER_ISOLATION_KEY=smoke-user `
|
||||
--env-file .env `
|
||||
$ImageName | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker run failed." }
|
||||
|
||||
-1
@@ -70,7 +70,6 @@ AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
+1
-3
@@ -14,8 +14,7 @@ Env.TraversePath().Load();
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-chat-client-agent";
|
||||
|
||||
var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o";
|
||||
|
||||
@@ -44,7 +43,6 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
@@ -179,7 +179,6 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
@@ -37,7 +37,6 @@ FoundryAgent agent = aiProjectClient.AsAIAgent(agentRecord);
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
@@ -107,7 +107,6 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
@@ -116,7 +116,6 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
@@ -85,7 +85,6 @@ AIAgent agent = new AIProjectClient(projectEndpoint, credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
+1
-4
@@ -5,7 +5,4 @@ FOUNDRY_MODEL=gpt-4o
|
||||
AZURE_AI_EMBEDDING_DEPLOYMENT_NAME=text-embedding-ada-002
|
||||
AZURE_AI_MEMORY_STORE_ID=hosted-memory-sample
|
||||
AGENT_NAME=hosted-memory-agent
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
# When running outside the Foundry platform the platform-injected user-identity key is absent.
|
||||
# This variable provides a fallback value for local Docker debugging only.
|
||||
HOSTED_USER_ISOLATION_KEY=local-dev-user
|
||||
AZURE_BEARER_TOKEN=DefaultAzureCredential
|
||||
+8
-3
@@ -28,8 +28,7 @@ Env.TraversePath().Load();
|
||||
|
||||
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
|
||||
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME")
|
||||
?? throw new InvalidOperationException("AGENT_NAME is not set.");
|
||||
var agentName = Environment.GetEnvironmentVariable("AGENT_NAME") ?? "hosted-memory-agent";
|
||||
var deployment = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-4o";
|
||||
var embeddingDeployment = Environment.GetEnvironmentVariable("AZURE_AI_EMBEDDING_DEPLOYMENT_NAME") ?? "text-embedding-ada-002";
|
||||
var memoryStoreName = Environment.GetEnvironmentVariable("AZURE_AI_MEMORY_STORE_ID") ?? "hosted-memory-sample";
|
||||
@@ -75,9 +74,15 @@ ChatClientAgent agent = projectClient.AsAIAgent(new ChatClientAgentOptions()
|
||||
});
|
||||
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
//
|
||||
// Per-user memory isolation comes from the platform-injected x-agent-user-id header, resolved by the
|
||||
// default HostedSessionIsolationKeyProvider into the session's HostedSessionContext. This sample scopes
|
||||
// memory per user via HostedFoundryMemoryProviderScopes.PerUser(), which REQUIRES that context: a
|
||||
// request with no resolved user identity throws. So locally you must send an x-agent-user-id request
|
||||
// header (see scripts/smoke.ps1); vary it to simulate distinct users. On the Foundry platform the
|
||||
// header is always present, so no local provider registration is needed.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
+16
-21
@@ -39,12 +39,6 @@ ASPNETCORE_URLS=http://+:8088
|
||||
ASPNETCORE_ENVIRONMENT=Development
|
||||
```
|
||||
|
||||
For local container runs only (the platform supplies these in production):
|
||||
|
||||
```env
|
||||
HOSTED_USER_ISOLATION_KEY=alice
|
||||
```
|
||||
|
||||
> `.env` is gitignored. The `.env.example` template is checked in as a reference.
|
||||
|
||||
## How memory scoping works
|
||||
@@ -56,14 +50,11 @@ HOSTED_USER_ISOLATION_KEY=alice
|
||||
| Session | The handler stores the resolved value on the session as a `HostedSessionContext` on the first request, and validates it on every subsequent request that resumes the same conversation (mismatch returns 403). |
|
||||
| Memory provider | The sample's `stateInitializer` reads `session.GetHostedContext().UserId` and uses it as the `FoundryMemoryProviderScope`. Memories are partitioned per user. |
|
||||
|
||||
When running outside the Foundry platform the header is absent. The sample registers
|
||||
`DevTemporaryLocalSessionIsolationKeyProvider` (via `AddDevTemporaryLocalContributorSetup`) which
|
||||
falls back to the `HOSTED_USER_ISOLATION_KEY` environment variable,
|
||||
defaulting to a single `local-dev-*` bucket when it is not set.
|
||||
|
||||
> **Production warning.** Never register `DevTemporaryLocalSessionIsolationKeyProvider` in
|
||||
> production. The Foundry platform sets the user-identity key for every inbound request, and
|
||||
> client-supplied environment variables can be forged.
|
||||
This sample scopes memory per user via `HostedFoundryMemoryProviderScopes.PerUser()`, which requires a
|
||||
resolved user identity — a request with none throws. So locally you **must** send an `x-agent-user-id`
|
||||
request header (vary it to simulate distinct users); the default `HostedSessionIsolationKeyProvider`
|
||||
reads it exactly as it reads the platform-injected value. On the Foundry platform the header is always
|
||||
present, so no local provider registration is needed.
|
||||
|
||||
## Running directly (contributors)
|
||||
|
||||
@@ -78,9 +69,13 @@ The agent starts on `http://localhost:8088`.
|
||||
|
||||
### Test it
|
||||
|
||||
Per-user memories require an identity. Send an `x-agent-user-id` header to scope the call to a user
|
||||
(locally you set it yourself; on the platform it is set for you):
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-agent-user-id: alice" \
|
||||
-d '{"input": "Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
@@ -90,6 +85,7 @@ previous call as `previous_response_id`:
|
||||
```bash
|
||||
curl -X POST http://localhost:8088/responses \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "x-agent-user-id: alice" \
|
||||
-d '{"input": "What do you already know about my upcoming trip?", "previous_response_id": "<id>", "model": "hosted-memory-agent"}'
|
||||
```
|
||||
|
||||
@@ -118,23 +114,22 @@ export AZURE_BEARER_TOKEN=$(az account get-access-token --resource https://ai.az
|
||||
docker run --rm -p 8088:8088 \
|
||||
-e AGENT_NAME=hosted-memory-agent \
|
||||
-e AZURE_BEARER_TOKEN=$AZURE_BEARER_TOKEN \
|
||||
-e HOSTED_USER_ISOLATION_KEY=alice \
|
||||
--env-file .env \
|
||||
hosted-memory-agent
|
||||
```
|
||||
|
||||
### 4. Smoke test the running container
|
||||
|
||||
A scripted smoke test that exercises memory recall and per-user isolation across two simulated
|
||||
users is provided at `scripts/smoke.ps1`. From the sample folder:
|
||||
A scripted smoke test that exercises memory recall and per-user isolation is provided at
|
||||
`scripts/smoke.ps1`. From the sample folder:
|
||||
|
||||
```powershell
|
||||
pwsh ./scripts/smoke.ps1
|
||||
```
|
||||
|
||||
The script publishes the project, builds the image, runs the container with two distinct
|
||||
`HOSTED_USER_ISOLATION_KEY` values, drives a multi-turn conversation per user, asserts that each
|
||||
user only sees their own memories, and exits non-zero on failure.
|
||||
The script publishes the project, builds the image, runs a **single** container, and drives two users
|
||||
(alice, bob) against it by varying the `x-agent-user-id` request header. It asserts that each user
|
||||
only sees their own memories, and exits non-zero on failure.
|
||||
|
||||
## Deploying to Foundry (azd spec)
|
||||
|
||||
@@ -177,4 +172,4 @@ standard `Dockerfile` instead of `Dockerfile.contributor`. See the commented sec
|
||||
| **Agent definition** | Inline (`AsAIAgent(model, instructions)`) | Inline, plus `AIContextProviders = [memoryProvider]` |
|
||||
| **State** | None beyond the conversation history | Per-user memories persisted in Foundry Memory |
|
||||
| **Identity** | Not used | Required: `HostedSessionContext.UserId` flows into the memory scope |
|
||||
| **Local dev** | `AddDevTemporaryLocalContributorSetup()` keeps requests succeeding when isolation headers are absent | Same; additionally honours `HOSTED_USER_ISOLATION_KEY` to simulate distinct users |
|
||||
| **Local dev** | Works with no identity header (per-user isolation not triggered) | Requires an `x-agent-user-id` header (memory is per-user); vary it to simulate distinct users |
|
||||
+21
-22
@@ -3,19 +3,17 @@
|
||||
.SYNOPSIS
|
||||
Local smoke test for the Hosted-MemoryAgent sample.
|
||||
.DESCRIPTION
|
||||
Publishes the sample, builds the contributor Docker image, runs the container twice with two
|
||||
distinct HOSTED_USER_ISOLATION_KEY values, drives a multi-turn conversation per user via curl
|
||||
invocations, and asserts that each user only sees their own remembered details.
|
||||
Exits non-zero on failure.
|
||||
Publishes the sample, builds the contributor Docker image, runs ONE container, and drives two
|
||||
users (alice, bob) against it by varying the x-agent-user-id request header. Asserts that each
|
||||
user only sees their own remembered details. Exits non-zero on failure.
|
||||
|
||||
Prerequisites:
|
||||
- Docker
|
||||
- az login (token is fetched from the host)
|
||||
- .env populated with FOUNDRY_PROJECT_ENDPOINT and model deployments
|
||||
.NOTES
|
||||
This script is for local Docker debugging only. The Foundry platform supplies the isolation
|
||||
keys for every inbound request in production and the dev fallback used here must not be
|
||||
enabled in production deployments.
|
||||
The x-agent-user-id header is set here only to simulate distinct users locally. On the Foundry
|
||||
platform it is supplied automatically for every request.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -44,12 +42,11 @@ Write-Host '==> Fetching bearer token ...'
|
||||
$bearer = az account get-access-token --resource https://ai.azure.com --query accessToken -o tsv
|
||||
if (-not $bearer) { throw 'Failed to obtain bearer token. Run az login.' }
|
||||
|
||||
function Start-Container([string]$UserKey, [string]$ContainerName) {
|
||||
function Start-Container([string]$ContainerName) {
|
||||
docker rm -f $ContainerName 2>$null | Out-Null
|
||||
docker run -d --name $ContainerName -p ${Port}:8088 `
|
||||
-e AGENT_NAME=hosted-memory-agent `
|
||||
-e AZURE_BEARER_TOKEN=$bearer `
|
||||
-e HOSTED_USER_ISOLATION_KEY=$UserKey `
|
||||
--env-file .env `
|
||||
$ImageName | Out-Host
|
||||
if ($LASTEXITCODE -ne 0) { throw "docker run failed for $ContainerName." }
|
||||
@@ -57,11 +54,14 @@ function Start-Container([string]$UserKey, [string]$ContainerName) {
|
||||
Start-Sleep -Seconds 6
|
||||
}
|
||||
|
||||
function Invoke-Agent([string]$Prompt, [string]$PreviousResponseId = $null) {
|
||||
function Invoke-Agent([string]$Prompt, [string]$UserId, [string]$PreviousResponseId = $null) {
|
||||
$body = @{ input = $Prompt; model = 'hosted-memory-agent' }
|
||||
if ($PreviousResponseId) { $body['previous_response_id'] = $PreviousResponseId }
|
||||
$json = $body | ConvertTo-Json -Compress
|
||||
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Body $json
|
||||
# x-agent-user-id is the identity the Foundry platform injects in production. Sending it locally
|
||||
# is how a contributor drives per-user isolation.
|
||||
$headers = @{ 'x-agent-user-id' = $UserId }
|
||||
$resp = Invoke-RestMethod -Method Post -Uri "http://localhost:$Port/responses" -ContentType 'application/json' -Headers $headers -Body $json
|
||||
return $resp
|
||||
}
|
||||
|
||||
@@ -80,23 +80,23 @@ function Assert-NotContains([string]$Haystack, [string]$Needle, [string]$Label)
|
||||
}
|
||||
|
||||
try {
|
||||
# One container serves BOTH users; per-user isolation is driven purely by the x-agent-user-id
|
||||
# header, exactly as the Foundry platform does in production (there the platform sets it).
|
||||
Start-Container -ContainerName 'hosted-memory-smoke'
|
||||
|
||||
Write-Host '==> Phase 1: alice teaches the agent her trip details ...'
|
||||
Start-Container -UserKey 'alice' -ContainerName 'hosted-memory-smoke-alice'
|
||||
$r1 = Invoke-Agent -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.'
|
||||
$r2 = Invoke-Agent -Prompt 'I am travelling with my sister and we love finding scenic viewpoints.' -PreviousResponseId $r1.id
|
||||
$r1 = Invoke-Agent -UserId 'alice' -Prompt 'Hi! My name is Taylor and I am planning a hiking trip to Patagonia in November.'
|
||||
$r2 = Invoke-Agent -UserId 'alice' -Prompt 'I am travelling with my sister and we love finding scenic viewpoints.' -PreviousResponseId $r1.id
|
||||
|
||||
Write-Host "==> Waiting $RecallDelaySeconds s for memory extraction ..."
|
||||
Start-Sleep -Seconds $RecallDelaySeconds
|
||||
|
||||
$r3 = Invoke-Agent -Prompt 'What do you already know about my upcoming trip?' -PreviousResponseId $r2.id
|
||||
$r3 = Invoke-Agent -UserId 'alice' -Prompt 'What do you already know about my upcoming trip?' -PreviousResponseId $r2.id
|
||||
$aliceText = ($r3.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-Contains $aliceText 'Patagonia' 'alice recall: Patagonia'
|
||||
|
||||
docker rm -f hosted-memory-smoke-alice | Out-Null
|
||||
|
||||
Write-Host '==> Phase 2: bob starts a fresh container with a different user isolation key ...'
|
||||
Start-Container -UserKey 'bob' -ContainerName 'hosted-memory-smoke-bob'
|
||||
$b1 = Invoke-Agent -Prompt 'Hello, what trip am I planning?'
|
||||
Write-Host '==> Phase 2: bob asks the SAME container with a different x-agent-user-id ...'
|
||||
$b1 = Invoke-Agent -UserId 'bob' -Prompt 'Hello, what trip am I planning?'
|
||||
$bobText = ($b1.output | ForEach-Object { $_.content | ForEach-Object { $_.text } }) -join ' '
|
||||
Assert-NotContains $bobText 'Patagonia' 'bob isolation: no leak of alice memories'
|
||||
|
||||
@@ -104,6 +104,5 @@ try {
|
||||
Write-Host '==> All smoke assertions passed.'
|
||||
}
|
||||
finally {
|
||||
docker rm -f hosted-memory-smoke-alice 2>$null | Out-Null
|
||||
docker rm -f hosted-memory-smoke-bob 2>$null | Out-Null
|
||||
docker rm -f hosted-memory-smoke 2>$null | Out-Null
|
||||
}
|
||||
-1
@@ -64,7 +64,6 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
@@ -51,7 +51,6 @@ AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
|
||||
// Host the agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
+2
-2
@@ -62,7 +62,7 @@ TokenCredential credential = new ChainedTokenCredential(
|
||||
new DefaultAzureCredential());
|
||||
|
||||
// Notes on toolbox wiring — there are two ways to attach a Foundry Toolbox to an agent:
|
||||
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(name)
|
||||
// - Server-side "baked-in" (what this sample uses): calling AddFoundryToolboxes(credential, name)
|
||||
// below registers the toolbox with the Foundry.Hosting layer, which resolves that
|
||||
// toolbox's MCP tools once at startup and automatically makes them available to the
|
||||
// agent on every request. The agent code does nothing per request.
|
||||
@@ -94,7 +94,7 @@ builder.Services.AddFoundryResponses(agent);
|
||||
// Pre-register the toolbox name so FoundryToolboxService resolves the foundry-toolbox://
|
||||
// marker at request time. With FOUNDRY_PROJECT_ENDPOINT injected by the platform, startup
|
||||
// MCP tools/list against the toolbox proxy is typically <100ms in-region.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
builder.Services.AddFoundryToolboxes(credential, toolboxName);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
+1
-2
@@ -8,7 +8,7 @@ Drive the agent across the auth paths with the shared [`Using-Samples/SimpleAgen
|
||||
|
||||
| Aspect | This sample | Existing siblings |
|
||||
|---|---|---|
|
||||
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
|
||||
| Toolbox marker pattern | `FoundryAITool.CreateHostedMcpToolbox(name)` + `AddFoundryToolboxes(credential, name)` | Same as [`Hosted-Toolbox/`](../Hosted-Toolbox/) |
|
||||
| Tools per toolbox | **Three MCP tools, each with a different auth method** | `Hosted-Toolbox/`: typically one demo tool |
|
||||
| Consumption | Server-side (Foundry resolves the marker) | Same |
|
||||
| Client | Shared [`Using-Samples/SimpleAgent/`](../Using-Samples/SimpleAgent/) REPL, pointed at this agent | `Hosted-Toolbox/`: any client |
|
||||
@@ -203,4 +203,3 @@ Inline `authorization` on a toolbox tool entry stores credentials **inside the t
|
||||
- Local development against a test MCP server with a throwaway token.
|
||||
|
||||
For everything else use `project_connection_id` and let the platform inject credentials.
|
||||
|
||||
|
||||
@@ -72,13 +72,12 @@ var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Register the agent and response handler
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
// Register Foundry Toolbox: connects to the MCP proxy at startup and makes tools available.
|
||||
// The toolbox name must match a toolbox registered in your Foundry project.
|
||||
// When FOUNDRY_PROJECT_ENDPOINT is absent (e.g., in local development without Foundry
|
||||
// infrastructure), startup succeeds without error and no toolbox tools are loaded.
|
||||
builder.Services.AddFoundryToolboxes(toolboxName);
|
||||
builder.Services.AddFoundryToolboxes(credential, toolboxName);
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
A hosted Foundry agent that loads tools from a single Foundry Toolbox via the AF Foundry hosting bridge.
|
||||
|
||||
`AddFoundryToolboxes(name)` registers a `FoundryToolboxService` that connects to the Foundry Toolboxes MCP proxy at startup, discovers the toolbox's bundled tools via `tools/list`, and makes them available to the agent on every request. The agent code does nothing per request; the toolbox is baked in on the server.
|
||||
`AddFoundryToolboxes(credential, name)` registers a `FoundryToolboxService` that connects to the Foundry Toolboxes MCP proxy at startup, discovers the toolbox's bundled tools via `tools/list`, and makes them available to the agent on every request. The agent code does nothing per request; the toolbox is baked in on the server.
|
||||
|
||||
This is the minimal toolbox intro. For a richer walkthrough where a single toolbox bundles three MCP tools each authenticated differently, see [`Hosted-Toolbox-AuthPaths/`](../Hosted-Toolbox-AuthPaths/).
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ displayName: "Hosted Toolbox"
|
||||
|
||||
description: >
|
||||
A hosted agent that loads its tools from a single Foundry Toolbox via the
|
||||
AF Foundry hosting bridge. AddFoundryToolboxes(name) connects to the Foundry
|
||||
AF Foundry hosting bridge. AddFoundryToolboxes(credential, name) connects to the Foundry
|
||||
Toolboxes MCP proxy at startup and exposes the toolbox's bundled tools to the
|
||||
agent on every request. The toolbox itself is provisioned out of band; see this
|
||||
sample's README for the portal walkthrough.
|
||||
|
||||
-1
@@ -91,7 +91,6 @@ AIAgent agent = new AIProjectClient(new Uri(projectEndpoint), credential)
|
||||
// ── Build the host ───────────────────────────────────────────────────────────
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
+4
-4
@@ -13,10 +13,10 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<!-- AgentServer 1.0.0-beta.26 (pulled transitively via Foundry.Hosting) requires Azure.Core 1.59.0.
|
||||
This sample disables transitive pinning and references Azure.Core directly, so override just
|
||||
this project to the SDK-required version without moving the solution-wide central pin. -->
|
||||
<PackageReference Include="Azure.Core" VersionOverride="1.59.0" />
|
||||
<!-- AgentServer (pulled transitively via Foundry.Hosting) requires Azure.Core >= 1.59.0.
|
||||
The solution-wide central pin is 1.60.0, which satisfies that floor, so this project
|
||||
references Azure.Core directly (transitive pinning is disabled here) without a version override. -->
|
||||
<PackageReference Include="Azure.Core" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="DotNetEnv" />
|
||||
<PackageReference Include="Microsoft.Extensions.AI.OpenAI" />
|
||||
|
||||
-1
@@ -53,7 +53,6 @@ AIAgent agent = new WorkflowBuilder(frenchAgent)
|
||||
// Host the workflow agent as a Foundry Hosted Agent using the Responses API.
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
builder.Services.AddFoundryResponses(agent);
|
||||
builder.Services.AddDevTemporaryLocalContributorSetup(); // Local Docker debugging only - must not be used in production.
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapFoundryResponses();
|
||||
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Azure.AI.AgentServer.Responses;
|
||||
using Azure.AI.AgentServer.Responses.Models;
|
||||
using Microsoft.Agents.AI.Foundry.Hosting;
|
||||
|
||||
namespace Hosted_Shared_Contributor_Setup;
|
||||
|
||||
/// <summary>
|
||||
/// A <see cref="HostedSessionIsolationKeyProvider"/> for local Docker debugging only.
|
||||
///
|
||||
/// When the Foundry platform's <c>x-agent-user-id</c> header is absent (i.e., when the container is
|
||||
/// running outside the Foundry platform), the hosting layer rejects every request with a 500 because
|
||||
/// the default <see cref="HostedSessionIsolationKeyProvider"/> returns null. This provider supplies a
|
||||
/// fallback value from the <c>HOSTED_USER_ISOLATION_KEY</c> environment variable, defaulting to the
|
||||
/// constant below when it is not set.
|
||||
///
|
||||
/// This should NOT be used in production. The Foundry platform sets the user id for every inbound
|
||||
/// request and forging it client-side defeats the per-user partitioning. The dev fallback exists
|
||||
/// solely so a contributor can <c>docker run</c> the sample on their laptop and drive a few requests
|
||||
/// end to end.
|
||||
/// </summary>
|
||||
public sealed class DevTemporaryLocalSessionIsolationKeyProvider : HostedSessionIsolationKeyProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment variable that supplies the user isolation key when the platform header is absent.
|
||||
/// </summary>
|
||||
public const string UserIsolationKeyEnvironmentVariable = "HOSTED_USER_ISOLATION_KEY";
|
||||
|
||||
/// <summary>
|
||||
/// Default user isolation key used when neither the platform header nor the environment variable
|
||||
/// supplies a value. All local requests collapse onto this single bucket unless overridden.
|
||||
/// </summary>
|
||||
public const string DefaultLocalUserIsolationKey = "local-dev-user";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<HostedSessionContext?> GetKeysAsync(
|
||||
ResponseContext context,
|
||||
CreateResponse request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var userKey = !string.IsNullOrWhiteSpace(context?.PlatformContext?.UserIdKey)
|
||||
? context!.PlatformContext!.UserIdKey
|
||||
: Environment.GetEnvironmentVariable(UserIsolationKeyEnvironmentVariable);
|
||||
if (string.IsNullOrWhiteSpace(userKey))
|
||||
{
|
||||
userKey = DefaultLocalUserIsolationKey;
|
||||
}
|
||||
|
||||
return new ValueTask<HostedSessionContext?>(new HostedSessionContext(userKey!));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user