Skip to content

Instantly share code, notes, and snippets.

@rkttu
Created August 19, 2026 07:12
Show Gist options
  • Select an option

  • Save rkttu/7288cd7b0064b6e71243e9cd657a38de to your computer and use it in GitHub Desktop.

Select an option

Save rkttu/7288cd7b0064b6e71243e9cd657a38de to your computer and use it in GitHub Desktop.
Microsoft Foundry Hosted Agent Sample (uv Python, MS Agent Framework & LangGraph)
# Foundry project endpoint — auto-injected in hosted containers.
# Only set manually when running the agent locally.
# FOUNDRY_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
# Model deployment name — must match a deployment in your Foundry project.
AZURE_AI_MODEL_DEPLOYMENT_NAME=
# Application Insights — auto-injected in hosted containers.
# Set for local telemetry (optional but recommended).
# APPLICATIONINSIGHTS_CONNECTION_STRING=InstrumentationKey=...
# ─────────────────────────────────────────────────────────────────────────
# Build output
#
# `deploy.py` stages and zips inside a temp directory, so it never dirties
# the source tree. But running `uv pip install --target` or `uv export -o`
# by hand does, and so does packaging the sample as a real project. The
# patterns below cover both cases.
# ─────────────────────────────────────────────────────────────────────────
build/
dist/
sdist/
target/
out/
*.egg-info/
.eggs/
# Common `--target` / `-o` directory names used ad hoc
vendor/
_vendor/
site-packages/
publish/
publish-*/
app-publish/
linux-x64/
bundle/
# Packaging output that can land loose in the tree
*.zip
*.tar
*.tar.gz
*.tgz
*.whl
# Exported dependency lists — regenerate with `uv export --script`
requirements.txt
requirements-*.txt
constraints.txt
# uv / packaging leftovers
.uv/
uv-cache/
*.py.lock
pip-log.txt
pip-delete-this-directory.txt
# ─────────────────────────────────────────────────────────────────────────
# Python runtime artifacts
# ─────────────────────────────────────────────────────────────────────────
__pycache__/
*.py[cod]
*$py.class
*.so
*.pyd
.Python
# Virtual environments
.venv/
venv/
env/
ENV/
# Test and type-checker caches
.pytest_cache/
.mypy_cache/
.ruff_cache/
.pytype/
.tox/
.nox/
.coverage
.coverage.*
htmlcov/
coverage.xml
.hypothesis/
# ─────────────────────────────────────────────────────────────────────────
# Secrets and local configuration
# ─────────────────────────────────────────────────────────────────────────
.env
.env.*
!.env.example
*.local.json
secrets.json
*.pfx
*.p12
*.key
*.pem
# ─────────────────────────────────────────────────────────────────────────
# Azure tooling
# ─────────────────────────────────────────────────────────────────────────
.azure/
.azd/
azd.env
# ─────────────────────────────────────────────────────────────────────────
# IDE and editor
# ─────────────────────────────────────────────────────────────────────────
.vs/
.vscode/*
!.vscode/extensions.json
!.vscode/launch.json
!.vscode/settings.json
!.vscode/tasks.json
.idea/
*.swp
*.swo
*~
# ─────────────────────────────────────────────────────────────────────────
# OS junk
# ─────────────────────────────────────────────────────────────────────────
Thumbs.db
ehthumbs.db
Desktop.ini
$RECYCLE.BIN/
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
# ─────────────────────────────────────────────────────────────────────────
# Scratch
# ─────────────────────────────────────────────────────────────────────────
*.tmp
*.temp
*.bak
*.orig
*.rej
tmp/
temp/
scratch/

Hosted Agent — uv 단일 파일 스크립트 샘플

Microsoft Foundry hosted agentuv 단일 파일 스크립트로 만들고 로컬 PC에서 바로 배포하는 최소 예제입니다. 의존성은 PEP 723 인라인 메타데이터로 선언합니다. pyproject.toml도, 관리해야 할 requirements.txt도, Dockerfile도, CI 파이프라인도 필요 없습니다.

에이전트는 프레임워크별로 서로 바꿔 쓸 수 있는 두 가지 버전으로 제공됩니다. 둘 다 동일한 OpenAI 호환 /responses 프로토콜을 사용하며, 배포 스크립트 하나로 어느 쪽이든 배포할 수 있습니다.

파일 역할
agent_msaf.py Microsoft Agent Framework 기반 에이전트.
agent_lg.py 동일한 에이전트의 LangGraph 버전.
deploy.py 로컬 배포 스크립트. publish → 버전 생성 → active 대기 → 엔드포인트 라우팅 → 스모크 테스트 순으로 진행합니다. 두 에이전트 중 아무거나 인수로 받습니다.
.env.example 에이전트를 로컬에서 띄울 때 쓰는 템플릿. .env로 복사해서 사용합니다.

🇺🇸 The English version of this document is README.md.

🔷 C# 버전 — 이 샘플이 이식해 온 .NET 원본은 별도 gist에 있습니다: Hosted Agent — .NET File-Based App 샘플.

두 샘플은 단계별로 서로 대응하며, 의미 있는 차이는 .NET 샘플과 다른 점에 정리했습니다.


사전 준비

  • uv 0.9 이상docs.astral.sh/uv에서 설치하십시오. 시스템에 Python이 설치되어 있지 않아도 됩니다. uv가 필요한 인터프리터를 직접 받아옵니다.
  • Azure CLI 2.80 이상 — 버전이 낮으면 az upgrade를 실행하십시오.
  • 모델 배포가 하나 이상 있는 Microsoft Foundry 프로젝트
  • 본인 계정에 대한 프로젝트 스코프의 Foundry Project Manager 역할
  • 역할을 할당할 수 있는 권한 — 아래 3. 에이전트 자신의 ID에 역할 부여를 보십시오.

로그인한 뒤, Foundry 데이터 플레인 토큰이 실제로 발급되는지 확인하십시오.

az login
az account get-access-token --scope https://ai.azure.com/.default

az account show는 로컬 프로필 파일만 읽기 때문에 갱신 토큰이 만료된 뒤에도 성공합니다. 위의 get-access-token 명령이 진짜 검증입니다.


빠른 시작

1. 배포 스크립트 설정

deploy.py는 셸에서 FOUNDRY_로 시작하는 환경변수만 읽습니다.

PowerShell

$env:FOUNDRY_PROJECT_ENDPOINT = 'https://<account>.services.ai.azure.com/api/projects/<project>'
$env:FOUNDRY_MODEL_NAME       = '<모델 배포 이름>'

cmd

set FOUNDRY_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
set FOUNDRY_MODEL_NAME=<모델 배포 이름>

bash

export FOUNDRY_PROJECT_ENDPOINT='https://<account>.services.ai.azure.com/api/projects/<project>'
export FOUNDRY_MODEL_NAME='<모델 배포 이름>'

FOUNDRY_MODEL_NAME은 Foundry 프로젝트에 실제로 배포된 배포 이름입니다. 모델 계열 이름이 아니라는 점에 주의하십시오.

2. 배포

프레임워크를 골라 해당 스크립트를 넘기십시오.

uv run deploy.py agent_msaf.py    # Microsoft Agent Framework
uv run deploy.py agent_lg.py      # LangGraph

두 번째 인수로 에이전트 이름을 직접 지정할 수도 있습니다.

uv run deploy.py agent_msaf.py my-agent

생략하면 파일명에서 파생합니다. agent_msaf.pyagent-msaf, agent_lg.pyagent-lg가 되므로, 둘 다 배포하면 한 에이전트의 두 버전이 아니라 서로 독립된 에이전트 두 개가 프로젝트에 생깁니다. 이름은 영숫자로 시작하고 끝나야 하며, 중간에 하이픈을 쓸 수 있고, 최대 63자입니다. 이 이름이 에이전트 호출 URL에 사용됩니다.

밑줄(_)은 허용되지 않습니다. 이름의 출처인 Python 파일명에서는 밑줄이 지극히 정상이기 때문에 걸려 넘어지기 쉽습니다. agent_msaf.pyagent-msaf로 파생되지만, agent_msaf를 직접 인수로 넘기면 거부됩니다. deploy.py는 직접 넘긴 이름을 이 규칙에 미리 검사하고 올바른 형태를 제안합니다. 그러지 않으면 publish, vendoring, 압축, 수 MB 업로드가 모두 끝난 뒤에야 서버가 의미를 알기 어려운 (invalid_parameters) ValidationError로 답합니다.

3. 에이전트 자신의 ID에 역할 부여

agent_msaf.py에는 이 작업이 전혀 필요 없습니다. 두 에이전트는 호출하는 모델 경로가 다르고, 그 두 경로는 Azure RBAC에서 서로 다른 데이터 작업이기 때문입니다.

경로 역할 할당 필요
agent_msaf.py POST /openai/v1/responses 아니오
agent_lg.py POST /openai/v1/chat/completions

호스팅 에이전트의 관리 ID는 Responses 경로 — 자기 자신의 프로토콜 표면 — 는 사용할 수 있지만, chat/completions는 범용 추론이라 명시적인 권한 부여가 필요합니다. ID 모델도, 엔드포인트도, 자격 증명도 같고 경로만 다릅니다.

실제 프로젝트에서 확인할 수 있습니다. Foundry 계정 스코프의 역할 할당 목록을 보면 agent_msaf의 ID에는 아무 역할도 없는데 정상 응답한다는 것을 알 수 있습니다.

ChatOpenAIuse_responses_api=True를 주면 agent_lg.py도 Responses 경로로 바뀌고 실제로 역할 요구가 사라집니다. 다만 azure-ai-agentserver-langgraph 1.0.0b17이 그 경로에서 LangChain이 내보내는 청크를 변환하지 못합니다. 스트리밍에서 델타가 하나도 나오지 않고 response.output_text.done이 빈 문자열이 되어, 논스트리밍은 되는데 플레이그라운드에는 빈 답변이 표시됩니다. 어댑터가 이를 처리하기 전까지는 역할을 부여하는 쪽이 더 쌉니다.

배포가 Active에 도달하고 트래픽 라우팅까지 끝난 뒤에도 첫 호출에서 실패할 수 있습니다. 컨테이너는 사용자 계정이 아니라 에이전트와 함께 생성된 자신의 관리 ID로 실행되며, 이 ID에는 역할 할당이 하나도 없는 상태로 시작합니다. 모델 호출은 데이터 작업이므로, 역할이 없으면 첫 호출이 이렇게 돌아옵니다.

401 PermissionDenied — The principal `<guid>` lacks the required data action
Microsoft.CognitiveServices/accounts/OpenAI/deployments/chat/completions/action

deploy.py는 버전이 Active가 되는 즉시 그 principal을 [ident ] 줄에 출력합니다. Foundry 리소스 스코프에서 Foundry User 역할을 부여하십시오.

az role assignment create \
  --assignee-object-id <ident 줄에 출력된 principal id> \
  --assignee-principal-type ServicePrincipal \
  --role 53ca6127-db72-4b80-b1b0-d745d6d5456d \
  --scope /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>

그런 다음 deploy.py를 다시 실행하십시오. 버전은 콘텐츠 주소 기반이라 번들이 그대로면 다시 업로드되지 않으므로, 사실상 스모크 테스트만 다시 도는 셈입니다.

이 명령에서 두 가지를 짚어 둡니다. 역할을 이름이 아니라 id로 넘기는 이유는 최근 이름이 바뀌었기 때문입니다. Foundry User의 옛 이름이 Azure AI User이고, 이름 변경이 진행되는 동안 두 이름이 함께 통용됩니다. 그리고 이름이 Cognitive Services로 시작하는 역할은 쓰지 마십시오. 그것들은 AI Services 리소스를 직접 겨냥한 역할이며 Foundry 프로젝트에는 적용되지 않습니다.

배포할 때마다가 아니라 에이전트마다 한 번입니다. ID는 에이전트에 속하고 새 버전이 올라가도 그대로이므로, 재배포해도 역할 할당은 유지됩니다. 다만 에이전트 이름마다 각자의 ID를 갖습니다. agent_lg.py 옆에 agent_msaf.py를 배포하면 별도의 principal이 생겨 따로 부여해야 하고, 에이전트를 삭제한 뒤 다시 만들면 새 ID가 생겨 다시 부여해야 합니다. 특정 에이전트가 어떤 ID로 실행 중인지는 이렇게 확인합니다.

next(iter(sorted(client.agents.list_versions(name), key=lambda v: int(v.version)))).instance_identity.principal_id

agents/read 권한이 없으면 대화 기록을 가져오지 못한다는 경고가 먼저 나타납니다. 치명적이지는 않아서 에이전트는 기록 없이 응답하지만, 원인과 해결책은 위와 동일합니다.

4. 확인

스크립트는 마지막에 스모크 테스트(ping)를 실행하고 응답을 출력합니다. Foundry 포털에서 아래 프롬프트로도 확인해 보십시오.

What is Microsoft Foundry?
Explain how hosted agents work in Foundry.
Give me three ideas for a weekend project.
Summarize the benefits of using the Agent Framework.

두 에이전트, 하나의 계약

두 스크립트는 의도적으로 동일한 에이전트입니다. 지시문도, 모델도, 엔드포인트도 같습니다. 그래서 남는 차이가 곧 프레임워크의 차이입니다. Foundry는 둘을 구분하지 않습니다. 양쪽 모두 Responses 프로토콜을 말하는 hosted agent이고, deploy.py도 별도 플래그 없이 둘 다 처리합니다.

agent_msaf.py agent_lg.py
프레임워크 Microsoft Agent Framework LangGraph
에이전트 객체 Agent(FoundryChatClient(...), instructions=...) create_agent(model, tools=[], system_prompt=...)CompiledStateGraph
호스트 어댑터 ResponsesHostServer(agent).run() from_langgraph(graph).run(port=...)
호스팅 패키지 agent-framework-foundry-hosting azure-ai-agentserver-langgraph
모델 클라이언트 FoundryChatClient(프로젝트와 직접 통신) 프로젝트의 AsyncOpenAI 클라이언트를 감싼 ChatOpenAI
번들 크기 압축 약 19 MiB 압축 약 45 MiB
수신 포트 PORT를 직접 읽음 DEFAULT_AD_PORT를 읽으므로, 스크립트가 PORT를 명시적으로 전달

어느 쪽을 확장하든 미리 알아둘 만한 점이 두 가지 있습니다.

두 호스팅 스택은 메이저 버전이 다릅니다. agent-framework-foundry-hostingazure-ai-agentserver-core 2.x 위에 올라가고, azure-ai-agentserver-langgraph는 1.x를 고정합니다. 두 스택은 한 환경을 공유할 수 없습니다. 이 샘플에 단일 파일 스크립트가 잘 맞는 이유가 바로 이것입니다. 각 스크립트가 자기 해석 결과를 따로 갖고, 서로를 제약하지 않습니다.

LangGraph는 프로젝트의 OpenAI 호환 경로로 모델에 접근합니다. 그 URL을 직접 조립하는 대신, agent_lg.pyAIProjectClient.get_openai_client()AsyncOpenAI 클라이언트를 요청합니다. 이 클라이언트는 이미 {프로젝트 엔드포인트}/openai/v1을 가리키고 있고, 스스로 갱신되는 Entra ID 베어러 토큰 공급자에 연결되어 있습니다. 그 클라이언트를 ChatOpenAI에 넘깁니다. deploy.py가 스모크 테스트에 쓰는 인증 경로와 동일합니다. 스크립트의 api_key="unused"ChatOpenAI가 키 없이는 생성되지 않기 때문에 두는 자리표시자일 뿐입니다.

agent_lg.py가 반드시 맞춰야 하는 세 가지

셋 다 자명하지 않고, 하나같이 배포가 성공한 뒤 첫 호출에서, 엉뚱한 곳을 가리키는 메시지와 함께 실패합니다.

from_langgraph()에는 시그니처와 달리 credentials가 사실상 필수입니다. 어댑터는 매 요청마다 프로젝트의 Foundry 도구 레지스트리를 해석하며, 도구를 하나도 선언하지 않은 그래프도 예외가 아닙니다. 자격 증명이 없으면 ThrowingFoundryToolRuntime이 만들어지고, 첫 호출은 HTTP 200에 {"code": "server_error", "message": "FoundryToolRuntime is not configured..."}를 담아 돌려줍니다.

어댑터는 FOUNDRY_PROJECT_ENDPOINT가 아니라 AZURE_AI_PROJECT_ENDPOINT를 읽습니다. 후자는 v1 agent-server 시절의 이름입니다. Foundry가 주입하는 것은 전자이고, from_langgraph()는 엔드포인트 인수를 받지 않으므로 환경변수가 유일한 접점입니다. agent_lg.pyos.environ.setdefault로 둘을 이어 줍니다. 이걸 빠뜨리면 자격 증명을 넘겼더라도 같은 FoundryToolRuntime is not configured 오류가 납니다.

ChatOpenAI에는 root_async_clientasync_client를 함께 넘겨야 합니다. 클라이언트 생성 여부를 판단하는 조건이 async_client만 보고 root_async_client는 보지 않기 때문에, 루트만 넘기면 조건이 충족되지 않아 LangChain이 api.openai.com을 향하는 기본 클라이언트를 만들고 두 필드를 조용히 덮어씁니다. 증상은 설정한 적도 없는 호스트를 지목하는 401 Incorrect API key provided: unused입니다.

도구를 추가하려면 agent_lg.pytools=[] 목록에 넣거나(LangGraph가 도구 호출 루프를 라우팅합니다), agent_msaf.pyAgent(...)tools=를 전달하십시오. 어느 쪽이든 호스팅 계약은 달라지지 않습니다.


에이전트 로컬 실행

두 에이전트 모두 .env 파일(python-dotenv 사용) 또는 프로세스 환경변수에서 설정을 읽습니다. deploy.py가 쓰는 변수들과는 서로 다른 집합이라는 점에 주의하십시오.

cp .env.example .env
# .env 를 편집한 뒤, 둘 중 하나를 실행:
uv run agent_msaf.py
uv run agent_lg.py

첫 실행에서 해당 스크립트의 인라인 의존성을 해석해 캐시 환경에 설치하며, 이후 실행은 곧바로 시작됩니다. 두 스크립트는 서로 다른 캐시 환경을 사용하므로 간섭하지 않습니다.

호스트는 8088 포트에 바인딩되며(PORT로 변경 가능), GET /readiness에 준비 상태 프로브를 노출합니다.

1턴 — 에이전트 호출:

curl -sS -X POST http://localhost:8088/responses \
  -H "Content-Type: application/json" \
  -d '{"input": "What is Microsoft Foundry?", "stream": false}' | jq .

2턴 — 이전 응답의 id로 이어서 질문:

curl -sS -X POST http://localhost:8088/responses \
  -H "Content-Type: application/json" \
  -d '{"input": "Can you summarize that?", "previous_response_id": "<id>", "stream": false}' | jq .

멀티턴 대화는 자동으로 동작합니다. 프레임워크가 매 요청마다 이전 턴들로부터 대화 기록을 다시 구성합니다. 로컬에서는 이 기록이 프로세스 안에 있어 재시작하면 사라지고, Foundry에 호스팅되면 서버 측 영구 저장소를 사용합니다.


환경변수

deploy.py — 로컬 PC에서 실행

변수 필수 기본값 설명
FOUNDRY_PROJECT_ENDPOINT 대상 프로젝트의 데이터 플레인 엔드포인트. 포털의 프로젝트 개요 페이지에서 복사합니다.
FOUNDRY_MODEL_NAME 모델 배포 이름. 컨테이너에 AZURE_AI_MODEL_DEPLOYMENT_NAME으로 주입됩니다.
FOUNDRY_AUTH 아니오 cli cliAzureCliCredential만 사용합니다. defaultDefaultAzureCredential 체인을 사용합니다(CI / 관리 ID용).
FOUNDRY_TENANT_ID 아니오 여러 테넌트에 로그인되어 있을 때 대상 테넌트를 고정합니다.

기본값이 cli인 이유. DefaultAzureCredentialEnvironmentCredential을 먼저 시도합니다. 머신에 AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET이 남아 있으면 그 서비스 주체로 인증을 시도하고, 실패 시 다음 자격 증명으로 넘어가지 않고 ClientAuthenticationError를 던져 체인이 그 자리에서 끊깁니다(AADSTS700016 등). az login을 해두어도 그 지점까지 도달하지 못합니다. 기본값을 cli로 두면 로컬 배포 결과가 결정론적으로 유지됩니다.

agent_msaf.py / agent_lg.py — 컨테이너 안에서 실행(또는 .env로 로컬 실행)

변수 필수 설명
FOUNDRY_PROJECT_ENDPOINT 호스팅 환경에서는 플랫폼이 자동 주입합니다. 로컬 실행 시에만 직접 설정하십시오.
AZURE_AI_MODEL_DEPLOYMENT_NAME 호스팅 시 deploy.pyFOUNDRY_MODEL_NAME 값으로 설정합니다.
APPLICATIONINSIGHTS_CONNECTION_STRING 아니오 호스팅 환경에서는 자동 주입됩니다. 로컬에서 Application Insights로 추적을 보내려면 설정하십시오. 없으면 에이전트가 경고를 출력합니다.
PORT 아니오 수신 포트. 기본값 8088.

FOUNDRY_PROJECT_ENDPOINTAPPLICATIONINSIGHTS_CONNECTION_STRING은 플랫폼이 주입하는 예약 변수이므로, hosted agent의 환경변수로 선언하지 마십시오.


deploy.py가 실제로 하는 일

  1. exportuv export --script <agent>.py가 PEP 723 인라인 메타데이터 블록을 완전히 고정된 requirements 목록으로 해석합니다. 선언된 의존성과 그것들이 끌고 오는 모든 패키지가 정확한 버전으로 나열됩니다.
  2. vendoruv pip install --target으로 그 패키지들을 내 PC가 아니라 컨테이너의 플랫폼에 맞춰 설치합니다(--python-platform x86_64-manylinux_2_28 --python-version 3.13). 이 교차 해석이 없으면 Windows나 macOS에서 만든 번들에 리눅스 컨테이너가 로드할 수 없는 바이너리 확장이 조용히 들어갑니다. --only-binary :all:은 "해당 플랫폼용 휠 없음"을 잘못된 인터프리터 기준의 소스 빌드 대신 이 자리에서의 오류로 만듭니다.
  3. entry point 확인 — 스크립트는 vendoring된 패키지 옆, 번들 루트에 복사됩니다. 그래야 python <name>.py가 그 디렉터리를 sys.path 맨 앞에 두어 임포트가 해결됩니다. 대신 스크립트 자신의 이름이 모듈 네임스페이스에 들어가므로, azure.pyjson.py 같은 이름은 실제 모듈을 가려버립니다. 표준 라이브러리와 번들 패키지 양쪽에 대해 충돌을 검사하는 이유는, 그러지 않으면 첫 호출 때 424 session_not_ready로만 드러나기 때문입니다.
  4. pack — 전체를 zip으로 묶되 내용이 아카이브 루트에 오도록 합니다. bin/은 제외합니다. 내 PC용으로 생성된 콘솔 스크립트 런처(Windows에서는 .exe 심)일 뿐이고, 컨테이너는 위의 entry point만 실행합니다.
  5. 인증https://ai.azure.com/.default에 대한 프리플라이트 get_token으로, API 파이프라인 깊숙한 곳이 아니라 여기서 원인이 드러나는 메시지와 함께 빠르게 실패합니다.
  6. 버전 생성create_version_from_code가 zip을 업로드합니다(SDK가 SHA-256 계산을 대신합니다). 콘텐츠 주소 기반 버전 관리이므로 코드와 정의가 모두 동일하면 새 버전을 만들지 않고 기존 버전을 그대로 반환합니다.
  7. active 대기 — 5초 간격으로 폴링하며 10분 데드라인을 둡니다. bundled 의존성 해석은 서버 빌드를 건너뛰므로 빠르게 끝납니다.
  8. 엔드포인트 라우팅 — 새 버전이 트래픽 100%를 받도록 에이전트를 패치합니다. 트래픽 분할은 아직 지원되지 않습니다.
  9. 스모크 테스트 — 컨테이너는 호출 시점에 세션 단위로 기동하므로, entry point 오류나 readiness 실패는 여기서 처음 드러납니다. 생략할 수 없는 단계입니다.
  10. 정리 — 임시 디렉터리는 성공 시 삭제하고 실패 시 남겨둡니다. 스테이징된 번들을 열어보며 원인을 추적할 수 있습니다. 경로는 [publish] 로그 줄에 출력됩니다.

manylinux2014이 아니라 manylinux_2_28인 이유

더 오래된 manylinux2014 태그(glibc 2.17)가 호환성 면에서 더 안전해 보이지만 그렇지 않습니다. numpy 2.5를 비롯해 점점 더 많은 패키지가 이 태그용 휠을 더 이상 배포하지 않기 때문에, numpy에 도달하는 의존성 그래프(LangGraph가 그렇습니다)는 아예 해석에 실패합니다. manylinux_2_28(glibc 2.28: RHEL 8, Debian 10, Ubuntu 20.04 이상)이 현재 휠들이 실제로 겨냥하는 기준이며, Foundry 런타임 이미지도 이를 만족합니다. TARGET_PLATFORM, TARGET_PYTHON, PYTHON_RUNTIMEdeploy.py 상단에 함께 모여 있고, 서로 일관되게 유지되어야 합니다.


.NET 샘플과 다른 점

전체 구조는 동일합니다. 갈라지는 지점은 "publish"의 실제 동작 방식입니다.

.NET file-based app uv 단일 파일 스크립트
인라인 의존성 #:package Foo@1.2.3 PEP 723 # /// script 블록
실행 dotnet run agent.cs uv run agent_msaf.py
Linux용 publish dotnet publish -r linux-x64 uv export + uv pip install --target --python-platform
압축 SDK가 폴더 경로를 받아 처리 deploy.py가 직접 수행. SDK는 스트림을 받음
entry point ["dotnet", "<assembly>.dll"], *.runtimeconfig.json에서 역산 ["python", "<name>.py"], 모듈 가림 여부를 검사
런타임 ID dotnet_10 python_3_13
함정 publish 기본값이 NativeAOT이므로 꺼야 함 크로스 플랫폼 휠, 그리고 스크립트 이름의 모듈 가림

양쪽 모두 dependency_resolution: bundled를 사용합니다. 업로드한 트리를 서버 빌드 없이 그대로 실행하며, 인라인 의존성 메타데이터와 프라이빗 패키지 인덱스가 문제없이 동작하는 이유가 바로 이것입니다.

프리릴리스 패키지

Foundry 호스팅 패키지들은 프리릴리스로 배포되고, 그것들이 끌어오는 Azure SDK 의존성도 프리릴리스 범위로 고정되어 있습니다. uv는 기본적으로 프리릴리스를 거부하며, 더 좁은 explicit 모드는 스크립트에 직접 쓴 마커만 인정하고 전이 의존성은 인정하지 않습니다. 그래서 두 에이전트 스크립트 모두 해석 전체를 opt-in 합니다.

# [tool.uv]
# prerelease = "allow"

deploy.py는 이 설정이 필요 없습니다. 안정 버전 azure-ai-projects로만 동작합니다.


빌드 산출물과 .gitignore

deploy.py는 임시 디렉터리 안에서 스테이징과 압축을 수행하므로, 정상 경로에서는 작업 트리를 전혀 건드리지 않습니다.

하지만 같은 과정을 직접 실행하면 이야기가 다릅니다. uv pip install --target ./vendor는 수십 MB에 달하는 패키지를 트리에 떨어뜨리고, uv export -o requirements.txt는 소스가 아니라 생성 산출물인 파일을 만듭니다. 함께 제공되는 .gitignore는 이 경우들과 함께, 흔히 쓰는 디렉터리 이름(vendor/, bundle/, publish-*/, site-packages/), 패키징 산출물(*.zip, *.whl, dist/), Python 런타임 산출물(__pycache__/, *.so, *.pyd, .venv/), 도구 캐시(.ruff_cache/, .mypy_cache/, .pytest_cache/), Azure 도구 상태(.azure/, .azd/), 비밀 파일(.env, *.pem)까지 덮습니다. .env.example은 예외로 추적됩니다.

uv run 자체는 안전합니다. 환경을 uv 전용 캐시 디렉터리에 두기 때문에 작업 트리에는 아무것도 쓰지 않습니다.

vendoring된 번들을 직접 확인하고 싶다면, 이미 무시되는 경로로 출력하십시오.

uv export --script agent_lg.py --format requirements-txt --no-hashes --no-header --no-annotate > requirements.txt
uv pip install -r requirements.txt --target ./vendor \
  --python-version 3.13 --python-platform x86_64-manylinux_2_28 \
  --only-binary :all: --link-mode copy

문제 해결

증상 원인과 해결
Failed to acquire a token 갱신 토큰 만료. az login --tenant <tenant-id>를 실행하십시오. az account show는 이 상황에서도 성공한다는 점을 기억하십시오.
AADSTS700016 환경에 AZURE_CLIENT_ID / AZURE_CLIENT_SECRET이 남아 있습니다. FOUNDRY_AUTH=cli로 실행하거나 해당 변수를 제거하십시오.
az를 찾을 수 없음 cmd /c az version으로 확인하십시오. Windows에서 Azure CLI는 .cmd 심(shim)이라 로그인 셸이 아닌 환경의 PATH에서 빠져 있을 수 있습니다.
No solution found when resolving script dependencies 의존성 그래프 어딘가에 프리릴리스가 필요합니다. 에이전트 스크립트의 [tool.uv] prerelease = "allow" 블록이 # /// 울타리 안에 있고 모든 줄이 주석 처리되어 있는지 확인하십시오.
vendoring 중 <pkg> has no usable wheels 해당 패키지에 TARGET_PLATFORM용 휠이 없습니다. deploy.pymanylinux 기준을 올리거나, 휠이 있는 버전으로 고정하거나, 의존성을 제거하십시오.
컨테이너 기동 시 ModuleNotFoundError 거의 항상 플랫폼 불일치입니다. 번들이 잘못된 OS나 Python 버전으로 vendoring된 것입니다. deploy.pyTARGET_PLATFORM / TARGET_PYTHONPYTHON_RUNTIME과 일치해야 합니다.
httpx를 언급하는 ImportError openai 3이 httpx2로 옮겨갔지만 azure-ai-projects는 여전히 httpx를 임포트합니다. openai>=2.45,<3 고정을 유지하십시오.
shadows the standard library module 스크립트 파일명이 실제 모듈과 충돌합니다. 이름을 바꾸십시오.
버전 생성 시 (invalid_parameters) ValidationError 에이전트 이름이 유효하지 않습니다. 대개 두 번째 인수로 넘긴 이름에 밑줄이 들어간 경우입니다. 하이픈을 쓰십시오(agent_msaf가 아니라 agent-msaf).
is not a valid Foundry agent name 같은 문제를, 업로드가 시작되기 전에 로컬에서 잡아낸 것입니다. 메시지에 올바른 형태가 제안됩니다.
424 session_not_ready entry point 불일치 또는 컨테이너 기동 실패. 포털의 버전 상세를 확인하십시오.
The agent deployed but failed on its first invocation 버전은 Active이고 라우팅도 끝났으며, 컨테이너 자체가 실패한 것입니다. 메시지에 서버가 준 code / message가 그대로 담깁니다. uv run <agent>.py로 로컬에서 동일한 경로를 재현할 수 있습니다.
401 PermissionDenied ... lacks the required data action 에이전트의 관리 ID에 역할이 없습니다. 3. 에이전트 자신의 ID에 역할 부여를 보십시오. 프레임워크가 아니라 ID 수준의 문제이므로 두 에이전트 모두에 동일하게 해당합니다.
does not have permissions for ... AIServices/agents/read (경고) 같은 역할 누락의 약한 증상입니다. 대화 기록을 가져오지 못해 멀티턴 문맥이 사라지지만 단일 턴 응답은 정상입니다.
FoundryToolRuntime is not configured LangGraph 전용. from_langgraph()credentials를 넘기지 않았거나 AZURE_AI_PROJECT_ENDPOINT가 비어 있습니다. agent_lg.py가 반드시 맞춰야 하는 세 가지를 보십시오.
401 Incorrect API key provided: unused LangGraph 전용. ChatOpenAI가 주입한 클라이언트를 api.openai.com을 향하는 기본 클라이언트로 바꿔치기한 것입니다. root_async_client와 함께 async_client도 넘기십시오.
로컬 실행 시 CERTIFICATE_VERIFY_FAILED 사내 TLS 검사(Zscaler 등). httpx는 certifi를 쓰며 OS 신뢰 저장소를 보지 않습니다. certifi의 cacert.pem과 사내 루트 CA를 이어붙인 번들을 만들어 SSL_CERT_FILE로 지정하십시오. 호스팅 컨테이너에는 해당하지 않습니다.
호스팅에서는 되는데 로컬에서 AADSTS700016 셸에 AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET이 남아 있습니다. DefaultAzureCredentialaz login보다 그 서비스 주체를 먼저 시도합니다. 로컬 실행 시에는 제거하십시오.
Cannot create item resource helper / OutputTextEventGenerator did not process message (경고) LangGraph 전용이며 표시상의 문제입니다. LangChain이 내용이 빈 AIMessageChunk(맨 앞 청크와 finish_reason만 담은 마지막 청크)를 내보내는데, 어댑터의 이벤트 생성기에 내용이 없는 청크를 처리하는 분기가 없어 로그만 남기고 건너뜁니다. 텍스트를 담은 청크는 모두 정상 변환되며, 스트림은 완전한 output_text.doneresponse.completed로 끝납니다.
Conversation with ID conv_... not found (경고, 404) 대화의 첫 턴에서는 정상입니다. 가져올 기록이 아직 없을 뿐이고 어댑터는 No historical items found로 계속 진행합니다. 같은 경고의 403 형태와는 다르며, 403은 역할 할당 누락을 뜻합니다.
missing a PEP 723 '# /// script' metadata block 경고 에이전트에 인라인 의존성 메타데이터가 없어, 함께 번들될 패키지가 하나도 없습니다.
에이전트가 PORT를 무시함 LangGraph 호스트만 그렇습니다(DEFAULT_AD_PORT를 읽습니다). agent_lg.py는 이미 PORT를 명시적으로 전달하므로, 마지막 줄을 수정하더라도 그 인수는 유지하십시오.
버전이 Active로 전이하지 않음 10분 데드라인 후 TimeoutError. 포털에서 버전의 error 객체를 확인하십시오.
Provisioning failed 포털의 버전 상세 페이지를 여십시오. 거기 있는 error.code / error.message가 실제 진단입니다.

참고

  • .py 파일 모두 #!/usr/bin/env -S uv run --script 셔뱅을 갖고 있어, Unix 계열에서는 chmod +x agent_msaf.py && ./agent_msaf.py로 실행할 수 있습니다.
  • 에이전트는 cpu: "0.5", memory: "1Gi"로 배포됩니다. 더 필요하면 deploy.pyHostedAgentDefinition을 조정하십시오.
  • 번들은 압축 기준 약 19 MB(Agent Framework)에서 45 MB(LangGraph) 정도입니다. 250 MB 업로드 제한에는 한참 못 미치지만, 의존성을 늘릴 때 염두에 두십시오.
  • 두 에이전트 모두 azure.identity.aio비동기 DefaultAzureCredential을 사용합니다. 비동기 Azure SDK로 프로젝트와 통신하므로 토큰 갱신이 이벤트 루프를 막으면 안 되기 때문입니다. 평범한 스크립트인 deploy.py는 동기 버전을 사용합니다.
  • 여기 고정된 패키지 버전들은 프리뷰입니다. API 변경을 감안하십시오.

Hosted Agent — uv Single-File Script Sample

A minimal, end-to-end sample that builds a Microsoft Foundry hosted agent as a uv single-file script — dependencies declared inline with PEP 723 — and deploys it from your local machine. No pyproject.toml, no requirements.txt to maintain, no Dockerfile, no CI pipeline.

The agent comes in two interchangeable flavors, one per framework. Both speak the same OpenAI-compatible /responses protocol, and one deployment script deploys either:

File Role
agent_msaf.py The agent, on the Microsoft Agent Framework.
agent_lg.py The same agent, on LangGraph.
deploy.py A local deployment script: publish → create version → wait for active → route the endpoint → smoke test. Takes either agent as its argument.
.env.example Template for running an agent locally. Copy to .env.

🇰🇷 한국어 문서는 README.ko.md를 참고하세요.

🔷 C# version — the .NET original this sample was ported from lives in its own gist: Hosted Agent — .NET File-Based App Sample.

The two samples track each other step for step — the interesting differences are called out under How this differs from the .NET sample.


Prerequisites

  • uv 0.9 or later — install from docs.astral.sh/uv. You do not need a system Python; uv fetches the interpreter it needs.
  • Azure CLI 2.80 or later — run az upgrade if you are behind.
  • A Microsoft Foundry project with at least one model deployment.
  • The Foundry Project Manager role at project scope, for you.
  • The ability to assign roles — see Grant the agent's identity a role below.

Sign in and confirm you can actually mint a token for the Foundry data plane:

az login
az account get-access-token --scope https://ai.azure.com/.default

az account show only reads a local profile file, so it succeeds even after your refresh token has expired. The get-access-token call above is the real check.


Quick start

1. Configure the deployment script

deploy.py reads only FOUNDRY_-prefixed environment variables from your shell.

PowerShell

$env:FOUNDRY_PROJECT_ENDPOINT = 'https://<account>.services.ai.azure.com/api/projects/<project>'
$env:FOUNDRY_MODEL_NAME       = '<your-model-deployment-name>'

cmd

set FOUNDRY_PROJECT_ENDPOINT=https://<account>.services.ai.azure.com/api/projects/<project>
set FOUNDRY_MODEL_NAME=<your-model-deployment-name>

bash

export FOUNDRY_PROJECT_ENDPOINT='https://<account>.services.ai.azure.com/api/projects/<project>'
export FOUNDRY_MODEL_NAME='<your-model-deployment-name>'

FOUNDRY_MODEL_NAME is the deployment name from your Foundry project — not the model family name.

2. Deploy

Pick a framework and pass its script:

uv run deploy.py agent_msaf.py    # Microsoft Agent Framework
uv run deploy.py agent_lg.py      # LangGraph

Optionally pass an explicit agent name as the second argument:

uv run deploy.py agent_msaf.py my-agent

When omitted, the name is derived from the file name — agent_msaf.py becomes agent-msaf and agent_lg.py becomes agent-lg, so deploying both gives you two independent agents in the same project rather than two versions of one. The name must start and end with an alphanumeric character, may contain hyphens in between, and is capped at 63 characters. It appears in the agent's invocation URL.

Underscores are not allowed, which is easy to trip over because they are perfectly legal in the Python file name they come from. agent_msaf.py derives to agent-msaf, but passing agent_msaf explicitly is rejected. deploy.py checks an explicitly passed name against that rule up front and suggests the corrected form — otherwise the publish, the vendoring, the packing and a multi-megabyte upload would all complete before the service answered with an opaque (invalid_parameters) ValidationError.

3. Grant the agent's identity a role

agent_msaf.py needs none of this. The two agents differ in which model route they call, and the two routes are separate data actions in Azure RBAC:

route needs a role assignment
agent_msaf.py POST /openai/v1/responses no
agent_lg.py POST /openai/v1/chat/completions yes

A hosted agent's managed identity may use the Responses route — that is its own protocol surface — but chat/completions is general-purpose inference and needs an explicit grant. Same identity model, same endpoint, same credential; only the route differs.

You can verify this on a live project: list the role assignments at the Foundry account scope and you will find the agent_msaf identity holds nothing at all while still answering.

Setting use_responses_api=True on ChatOpenAI switches agent_lg.py to the Responses route and does remove the role requirement — but azure-ai-agentserver-langgraph 1.0.0b17 cannot convert the chunks LangChain emits on that path. Streaming yields no deltas and an empty response.output_text.done, so the playground renders a blank answer while non-streaming still works. Until the adapter handles it, granting the role is the cheaper price.

A deployment can reach Active, route traffic, and still fail on its first call. The container does not run as you — it runs as its own managed identity, created with the agent and holding no role assignments at all. Calling the model is a data action, so without a role the first invocation comes back as:

401 PermissionDenied — The principal `<guid>` lacks the required data action
Microsoft.CognitiveServices/accounts/OpenAI/deployments/chat/completions/action

deploy.py prints that principal on the [ident ] line as soon as the version is Active. Grant it the Foundry User role on the Foundry resource:

az role assignment create \
  --assignee-object-id <principal-id-from-the-ident-line> \
  --assignee-principal-type ServicePrincipal \
  --role 53ca6127-db72-4b80-b1b0-d745d6d5456d \
  --scope /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>

Then re-run deploy.py — the version is content-addressed, so an unchanged bundle is not re-uploaded and the run is effectively just the smoke test.

Two notes on that command. The role is passed by id because it was recently renamed: Foundry User was called Azure AI User, and both names are still in circulation while the rename rolls out. And do not reach for a role whose name begins with Cognitive Services — those target AI Services resources directly and do not apply to Foundry projects.

You do this once per agent, not once per deploy. The identity belongs to the agent and survives new versions, so redeploying keeps the assignment. But each agent name gets its own identity: deploying agent_msaf.py alongside agent_lg.py creates a second principal that needs its own grant, and deleting and recreating an agent produces a fresh identity that needs granting again. Check what an agent is running as with:

next(iter(sorted(client.agents.list_versions(name), key=lambda v: int(v.version)))).instance_identity.principal_id

A missing agents/read permission shows up first, as a warning about fetching conversation history. It is survivable — the agent answers without history — but it is the same root cause and the same fix.

4. Verify

The script ends with a smoke test (ping) and prints the response. You can also open the Foundry portal and try:

What is Microsoft Foundry?
Explain how hosted agents work in Foundry.
Give me three ideas for a weekend project.
Summarize the benefits of using the Agent Framework.

Two agents, one contract

The two scripts are deliberately the same agent — same instructions, same model, same endpoint — so that what is left is the framework. Foundry cannot tell them apart: both are hosted agents speaking the Responses protocol, and deploy.py handles either without a flag.

agent_msaf.py agent_lg.py
Framework Microsoft Agent Framework LangGraph
Agent object Agent(FoundryChatClient(...), instructions=...) create_agent(model, tools=[], system_prompt=...)CompiledStateGraph
Host adapter ResponsesHostServer(agent).run() from_langgraph(graph).run(port=...)
Hosting package agent-framework-foundry-hosting azure-ai-agentserver-langgraph
Model client FoundryChatClient (talks to the project directly) ChatOpenAI wrapping the project's AsyncOpenAI client
Bundle size ~19 MiB zipped ~45 MiB zipped
Listen port reads PORT itself reads DEFAULT_AD_PORT; the script passes PORT through explicitly

Two details are worth knowing before you extend either one.

The two hosting stacks are different major versions. agent-framework-foundry-hosting builds on azure-ai-agentserver-core 2.x; azure-ai-agentserver-langgraph pins 1.x. They cannot share one environment — which is precisely why single-file scripts suit this sample: each script carries its own resolution and neither constrains the other.

LangGraph reaches the model through the project's OpenAI-compatible route. Rather than assembling that URL by hand, agent_lg.py asks AIProjectClient.get_openai_client() for an AsyncOpenAI client already pointed at {project endpoint}/openai/v1 and already wired to an Entra ID bearer-token provider that refreshes on its own, then hands that client to ChatOpenAI. That is the same auth path deploy.py uses for its smoke test. The api_key="unused" in the script is a placeholder that exists only because ChatOpenAI refuses to construct without one.

Three things agent_lg.py has to get right

None of these are obvious, and each one fails at the first invocation — after a successful deploy — with a message that points somewhere else entirely.

from_langgraph() needs credentials, despite the signature saying otherwise. The adapter resolves the project's Foundry tool registry on every request, including for a graph that declares no tools. Without a credential it builds a ThrowingFoundryToolRuntime, and the first call returns HTTP 200 carrying {"code": "server_error", "message": "FoundryToolRuntime is not configured..."}.

The adapter reads AZURE_AI_PROJECT_ENDPOINT, not FOUNDRY_PROJECT_ENDPOINT. That is the v1 agent-server spelling; Foundry injects the other one, and from_langgraph() takes no endpoint argument, so the environment is the only seam. agent_lg.py bridges the two with os.environ.setdefault. Miss it and you get the same FoundryToolRuntime is not configured error even after passing credentials.

ChatOpenAI needs both root_async_client and async_client. Its client-creation guard tests async_client and never root_async_client, so passing only the root leaves the guard unsatisfied: LangChain builds a default client aimed at api.openai.com and silently replaces both fields. The symptom is 401 Incorrect API key provided: unused naming a host you never configured.

To add tools, add them to the tools=[] list in agent_lg.py (LangGraph routes the tool-calling loop) or pass tools= to Agent(...) in agent_msaf.py. The hosting contract does not change either way.


Running an agent locally

Both agents read their configuration from a .env file (via python-dotenv) or from the process environment. Note that these are a different set of variables from the ones deploy.py uses.

cp .env.example .env
# edit .env, then pick one:
uv run agent_msaf.py
uv run agent_lg.py

The first run resolves and installs that script's inline dependencies into a cached environment; subsequent runs start immediately. The two scripts get separate cached environments and do not interfere.

The host binds to port 8088 (override with PORT) and exposes a readiness probe at GET /readiness.

Turn 1 — invoke the agent:

curl -sS -X POST http://localhost:8088/responses \
  -H "Content-Type: application/json" \
  -d '{"input": "What is Microsoft Foundry?", "stream": false}' | jq .

Turn 2 — follow up using the id from the previous response:

curl -sS -X POST http://localhost:8088/responses \
  -H "Content-Type: application/json" \
  -d '{"input": "Can you summarize that?", "previous_response_id": "<id>", "stream": false}' | jq .

Multi-turn conversation works automatically: the framework rebuilds the history from prior turns on every request. Locally that history lives in-process and is lost on restart; when hosted by Foundry it uses durable server-side storage.


Environment variables

deploy.py — runs on your machine

Variable Required Default Description
FOUNDRY_PROJECT_ENDPOINT yes Data-plane endpoint of the target project. Copy it from the project overview page in the portal.
FOUNDRY_MODEL_NAME yes Model deployment name. Injected into the container as AZURE_AI_MODEL_DEPLOYMENT_NAME.
FOUNDRY_AUTH no cli cli uses AzureCliCredential only. default uses the DefaultAzureCredential chain (CI / managed identity).
FOUNDRY_TENANT_ID no Pins the target tenant when you are signed in to more than one.

Why cli is the default. DefaultAzureCredential tries EnvironmentCredential first. If AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET are left over on your machine, it authenticates as that service principal and — on failure — raises ClientAuthenticationError rather than falling through, breaking the chain on the spot (AADSTS700016 and friends). Your az login session is then never reached. Defaulting to cli keeps local deployments deterministic.

agent_msaf.py / agent_lg.py — run inside the container (or locally via .env)

Variable Required Description
FOUNDRY_PROJECT_ENDPOINT yes Auto-injected by the platform when hosted. Set it manually only for local runs.
AZURE_AI_MODEL_DEPLOYMENT_NAME yes Set by deploy.py from FOUNDRY_MODEL_NAME when hosted.
APPLICATIONINSIGHTS_CONNECTION_STRING no Auto-injected when hosted. Set it locally to send traces to Application Insights; the agent logs a warning when it is missing.
PORT no Listen port. Defaults to 8088.

Do not declare FOUNDRY_PROJECT_ENDPOINT or APPLICATIONINSIGHTS_CONNECTION_STRING in the hosted agent's environment — they are reserved and injected by the platform.


What deploy.py actually does

  1. Exportuv export --script <agent>.py resolves the PEP 723 inline metadata block into a fully pinned requirements list: the declared dependencies plus everything they pull in, at exact versions.
  2. Vendoruv pip install --target installs those packages for the container's platform, not yours, via --python-platform x86_64-manylinux_2_28 --python-version 3.13. Without that cross-resolution a Windows or macOS machine would bundle binary extensions the Linux container cannot load. --only-binary :all: turns a missing wheel into an error here instead of a source build against the wrong interpreter.
  3. Check the entry point — the script is copied to the root of the bundle, next to the vendored packages, so python <name>.py puts that directory first on sys.path. That also means the script's own name enters the module namespace, so a script called azure.py or json.py would shadow a real module. Both the stdlib and the bundled packages are checked for a collision, because the failure would otherwise only surface as a 424 session_not_ready on the first invocation.
  4. Pack — everything is zipped with its contents at the archive root. bin/ is dropped: it holds console-script launchers generated for your machine (.exe shims on Windows), and the container only ever runs the entry point.
  5. Authenticate — a preflight get_token against https://ai.azure.com/.default fails fast with an actionable message instead of dying deep inside the API pipeline.
  6. Create a versioncreate_version_from_code uploads the zip (the SDK computes the SHA-256). Versioning is content-addressed: identical code and definition returns the existing version rather than creating a new one.
  7. Wait for Active — polls every 5 seconds with a 10-minute deadline. bundled dependency resolution skips the server-side build, so this is fast.
  8. Route the endpoint — patches the agent so the new version takes 100% of traffic. Traffic splitting is not supported yet.
  9. Smoke test — containers start per session on the first invocation, so entry-point errors and readiness failures only surface here. This step is not optional.
  10. Clean up — the temp directory is deleted on success and kept on failure so you can inspect the staged bundle. Its path is printed on the [publish] log line.

Why manylinux_2_28 and not manylinux2014

The older manylinux2014 tag (glibc 2.17) looks like the safer, more compatible choice. It is not: numpy 2.5 and a growing number of packages no longer publish wheels for it, so a dependency graph that reaches numpy — LangGraph's does — fails to resolve at all. manylinux_2_28 (glibc 2.28: RHEL 8, Debian 10, Ubuntu 20.04 and newer) is what current wheels actually target, and the Foundry runtime images satisfy it. TARGET_PLATFORM, TARGET_PYTHON, and PYTHON_RUNTIME sit together at the top of deploy.py and must stay consistent with one another.


How this differs from the .NET sample

The shape is identical; the mechanics of "publish" are where the two runtimes part ways.

.NET file-based app uv single-file script
Inline dependencies #:package Foo@1.2.3 PEP 723 # /// script block
Run it dotnet run agent.cs uv run agent_msaf.py
Publish for Linux dotnet publish -r linux-x64 uv export + uv pip install --target --python-platform
Zipping done by the SDK from a folder path done by deploy.py; the SDK takes a stream
Entry point ["dotnet", "<assembly>.dll"], read back from *.runtimeconfig.json ["python", "<name>.py"], checked for module shadowing
Runtime id dotnet_10 python_3_13
Sharp edge NativeAOT is the publish default and must be turned off cross-platform wheels and script-name shadowing

Both use dependency_resolution: bundled — the uploaded tree runs as-is with no server-side build, which is what makes inline dependency metadata and private package indexes work.

Pre-release packages

The Foundry hosting packages ship as pre-releases and pull in Azure SDK dependencies pinned to pre-release ranges of their own. uv rejects pre-releases by default, and its narrower explicit mode only covers markers written in the script — not transitive ones — so both agent scripts opt the whole resolution in:

# [tool.uv]
# prerelease = "allow"

deploy.py needs no such opt-in; it runs on the stable azure-ai-projects release.


Build artifacts and .gitignore

deploy.py stages and zips inside a temp directory, so the normal path never touches your working tree.

Running the same steps by hand does. uv pip install --target ./vendor drops tens of megabytes of packages into your tree, and uv export -o requirements.txt writes a lock file that is generated output, not source. The bundled .gitignore covers those, the ad-hoc directory names people reach for (vendor/, bundle/, publish-*/, site-packages/), packaging output (*.zip, *.whl, dist/), Python runtime artifacts (__pycache__/, *.so, *.pyd, .venv/), tool caches (.ruff_cache/, .mypy_cache/, .pytest_cache/), Azure tooling state (.azure/, .azd/), and secrets (.env, *.pem) — with .env.example explicitly kept.

uv run itself is harmless: it keeps its environments in uv's own cache directory and writes nothing to the working tree.

If you want to inspect a vendored bundle yourself, send it to an already-ignored path:

uv export --script agent_lg.py --format requirements-txt --no-hashes --no-header --no-annotate > requirements.txt
uv pip install -r requirements.txt --target ./vendor \
  --python-version 3.13 --python-platform x86_64-manylinux_2_28 \
  --only-binary :all: --link-mode copy

Troubleshooting

Symptom Cause and fix
Failed to acquire a token Refresh token expired. Run az login --tenant <tenant-id>. Remember that az account show succeeds regardless.
AADSTS700016 Leftover AZURE_CLIENT_ID / AZURE_CLIENT_SECRET in your environment. Use FOUNDRY_AUTH=cli, or clear them.
az not found Check with cmd /c az version. Azure CLI is a .cmd shim on Windows and may be missing from a non-login shell's PATH.
No solution found when resolving script dependencies A pre-release is needed somewhere in the graph. Confirm the [tool.uv] prerelease = "allow" block in the agent script is inside the # /// fence and that every line in it is commented out.
<pkg> has no usable wheels during vendoring The package ships no wheel for TARGET_PLATFORM. Raise the manylinux baseline in deploy.py, pin a version that has one, or drop the dependency.
ModuleNotFoundError at container start Almost always a platform mismatch — the bundle was vendored for the wrong OS or Python version. TARGET_PLATFORM / TARGET_PYTHON in deploy.py must match PYTHON_RUNTIME.
ImportError mentioning httpx openai 3 moved to httpx2, but azure-ai-projects still imports httpx. Keep the openai>=2.45,<3 pin.
shadows the standard library module Your script's file name collides with a real module. Rename it.
(invalid_parameters) ValidationError on create The agent name is not a valid one — almost always an underscore in a name passed as the second argument. Use hyphens: agent-msaf, not agent_msaf.
is not a valid Foundry agent name The same problem, caught locally before anything is uploaded. The message suggests the corrected form.
424 session_not_ready Entry-point mismatch, or the container failed to start. Check the version details in the portal.
The agent deployed but failed on its first invocation The version is Active and routed; the container itself faulted. The message carries the server's own code / message. Reproduce locally with uv run <agent>.py — it is the same code path.
401 PermissionDenied ... lacks the required data action The agent's managed identity has no role. See Grant the agent's identity a role. Affects both agents equally — it is identity-level, not framework-level.
does not have permissions for ... AIServices/agents/read (warning) Same missing role, milder symptom: conversation history cannot be fetched, so multi-turn context is lost while single turns still answer.
FoundryToolRuntime is not configured LangGraph only. Either from_langgraph() got no credentials, or AZURE_AI_PROJECT_ENDPOINT is unset. See Three things agent_lg.py has to get right.
401 Incorrect API key provided: unused LangGraph only. ChatOpenAI replaced the injected client with a default one aimed at api.openai.com — pass async_client alongside root_async_client.
CERTIFICATE_VERIFY_FAILED running an agent locally Corporate TLS interception (Zscaler and similar). httpx uses certifi and ignores the OS trust store. Point SSL_CERT_FILE at a bundle that concatenates certifi's cacert.pem with your corporate root CA. Does not affect hosted containers.
Agent works hosted but fails locally with AADSTS700016 Leftover AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET in your shell. DefaultAzureCredential tries that service principal before your az login. Clear them for local runs.
Cannot create item resource helper / OutputTextEventGenerator did not process message (warning) LangGraph only, and cosmetic. LangChain emits empty AIMessageChunks — a leading one and a trailing one carrying only finish_reason — and the adapter's event generators have no branch for zero-content chunks, so they log and skip. Every chunk that carries text converts fine; the stream still ends with a complete output_text.done and response.completed.
Conversation with ID conv_... not found (warning, 404) Expected on a conversation's first turn — there is no history to fetch yet, and the adapter continues with No historical items found. Distinct from the 403 form of this warning, which is the missing role assignment.
missing a PEP 723 '# /// script' metadata block warning The agent has no inline dependency metadata, so nothing would be bundled with it.
Agent ignores PORT Only the LangGraph host does that natively (it reads DEFAULT_AD_PORT). agent_lg.py already forwards PORT explicitly — keep that argument if you edit the last line.
Version never reaches Active 10-minute deadline, then TimeoutError. Inspect the version's error object in the portal.
Provisioning failed Open the version details page in the portal — the error.code / error.message there is the real diagnosis.

Notes

  • All three .py files carry a #!/usr/bin/env -S uv run --script shebang, so on Unix-like systems you can chmod +x agent_msaf.py && ./agent_msaf.py.
  • Agents are deployed with cpu: "0.5" and memory: "1Gi". Adjust the HostedAgentDefinition in deploy.py if you need more.
  • Bundles run roughly 19 MB (Agent Framework) to 45 MB (LangGraph) zipped — well under the 250 MB upload limit, but worth keeping in mind as you add dependencies.
  • Both agents use the async DefaultAzureCredential from azure.identity.aio, because they talk to the project over the async Azure SDK and token refresh must not block the event loop. deploy.py, a plain script, uses the synchronous one.
  • The package versions pinned here are previews. Expect API drift.
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = [
# "agent-framework-foundry==1.10.1",
# "agent-framework-foundry-hosting==1.0.0b260813",
# "azure-identity",
# "python-dotenv",
# ]
#
# [tool.uv]
# # The Foundry hosting packages ship as pre-releases, and they pull in Azure SDK
# # dependencies that are themselves pinned to pre-release ranges. uv rejects
# # pre-releases by default, and its narrower "explicit" mode only covers markers
# # written here — not the transitive ones — so the whole resolution has to opt in.
# prerelease = "allow"
# ///
"""Hello World — Agent Framework Responses agent for Python.
To deploy this hosted agent, follow the steps below.
1. Install uv and the Azure CLI on the computer where you intend to proceed with
the deployment.
2. Log in using the `az login` command, and then verify that the token is
successfully obtained using the
`az account get-access-token --scope https://ai.azure.com/.default` command.
3. Deploy the model to be used on the MS Foundry portal. Then, identify the name
of the deployed model and assign it to the `FOUNDRY_MODEL_NAME` environment
variable.
4. Obtain the Foundry project endpoint URL. Assign this URL to the
`FOUNDRY_PROJECT_ENDPOINT` environment variable.
5. `uv run deploy.py <script file name> [agent name to expose to MS Foundry]`
Minimal hosted agent that uses the Microsoft Agent Framework (`agent_framework`)
to create an `Agent` backed by a Foundry model, then hosts it using
`ResponsesHostServer` from `agent-framework-foundry-hosting`.
This sample demonstrates the simplest possible Agent Framework integration: the
agent framework manages the LLM call, conversation history, and response
lifecycle automatically — there is no response handler to implement.
`ResponsesHostServer.run()` handles the HTTP contract, port binding, health
probes, SSE lifecycle, and OpenTelemetry tracing.
Multi-turn conversation works automatically: on each request the framework
rebuilds the conversation history from prior turns. Pass `previous_response_id`
from one response as the input to the next call to maintain conversation
context. Locally, history is stored in-process (lost on restart); when hosted by
Foundry it uses durable server-side storage.
Required environment variables:
FOUNDRY_PROJECT_ENDPOINT — Foundry project endpoint (auto-injected in
hosted containers)
AZURE_AI_MODEL_DEPLOYMENT_NAME — Model deployment name (set by `deploy.py`)
See `agent_lg.py` for the same agent built on LangGraph; it deploys the same way.
Usage:
uv run agent_msaf.py
# Turn 1 — invoke the agent:
curl -sS -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "What is Microsoft Foundry?", "stream": false}' | jq .
# Turn 2 — follow up using the id from the previous response:
curl -sS -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Can you summarize that?", "previous_response_id": "<id>", "stream": false}' | jq .
"""
import os
import sys
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient, ResponsesHostServer
from azure.identity.aio import DefaultAzureCredential
from dotenv import load_dotenv
# Load environment variables from a .env file if present (for local development).
# load_dotenv() walks up from this file's directory and never clobbers a value
# that is already set in the process environment.
load_dotenv()
if not os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"):
print(
"[WARNING] APPLICATIONINSIGHTS_CONNECTION_STRING not set — traces will not be sent "
"to Application Insights. Set it to enable local telemetry. "
"(This variable is auto-injected in hosted Foundry containers — do not set it "
"in the deployed agent's environment.)",
file=sys.stderr,
)
project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not project_endpoint:
raise RuntimeError("FOUNDRY_PROJECT_ENDPOINT environment variable is not set.")
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME")
if not deployment:
raise RuntimeError("AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is not set.")
# Create an Agent backed by a Foundry model.
# The agent framework manages the LLM call, conversation sessions, and response
# lifecycle. FoundryChatClient talks to the project over the async Azure SDK, so
# it takes an async credential — token refresh must not block the event loop.
agent = Agent(
FoundryChatClient(
project_endpoint=project_endpoint,
model=deployment,
credential=DefaultAzureCredential(),
),
instructions="You are a helpful AI assistant. Be concise and informative.",
name="hello-world",
description="A minimal Hello World agent using the Agent Framework",
)
# ResponsesHostServer.run() auto-configures:
# - the ASGI server on port 8088 (or the PORT environment variable)
# - GET /readiness health probe
# - OpenTelemetry traces and metrics
# - x-platform-server response header
ResponsesHostServer(agent).run()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = [
# "agent-framework-foundry==1.10.1",
# "agent-framework-foundry-hosting==1.0.0b260813",
# "azure-identity",
# "python-dotenv",
# ]
#
# [tool.uv]
# # The Foundry hosting packages ship as pre-releases, and they pull in Azure SDK
# # dependencies that are themselves pinned to pre-release ranges. uv rejects
# # pre-releases by default, and its narrower "explicit" mode only covers markers
# # written here — not the transitive ones — so the whole resolution has to opt in.
# prerelease = "allow"
# ///
"""Hello World — Agent Framework Responses agent for Python.
To deploy this hosted agent, follow the steps below.
1. Install uv and the Azure CLI on the computer where you intend to proceed with
the deployment.
2. Log in using the `az login` command, and then verify that the token is
successfully obtained using the
`az account get-access-token --scope https://ai.azure.com/.default` command.
3. Deploy the model to be used on the MS Foundry portal. Then, identify the name
of the deployed model and assign it to the `FOUNDRY_MODEL_NAME` environment
variable.
4. Obtain the Foundry project endpoint URL. Assign this URL to the
`FOUNDRY_PROJECT_ENDPOINT` environment variable.
5. `uv run deploy.py <script file name> [agent name to expose to MS Foundry]`
Minimal hosted agent that uses the Microsoft Agent Framework (`agent_framework`)
to create an `Agent` backed by a Foundry model, then hosts it using
`ResponsesHostServer` from `agent-framework-foundry-hosting`.
This sample demonstrates the simplest possible Agent Framework integration: the
agent framework manages the LLM call, conversation history, and response
lifecycle automatically — there is no response handler to implement.
`ResponsesHostServer.run()` handles the HTTP contract, port binding, health
probes, SSE lifecycle, and OpenTelemetry tracing.
Multi-turn conversation works automatically: on each request the framework
rebuilds the conversation history from prior turns. Pass `previous_response_id`
from one response as the input to the next call to maintain conversation
context. Locally, history is stored in-process (lost on restart); when hosted by
Foundry it uses durable server-side storage.
Required environment variables:
FOUNDRY_PROJECT_ENDPOINT — Foundry project endpoint (auto-injected in
hosted containers)
AZURE_AI_MODEL_DEPLOYMENT_NAME — Model deployment name (set by `deploy.py`)
See `agent_lg.py` for the same agent built on LangGraph; it deploys the same way.
Usage:
uv run agent_msaf.py
# Turn 1 — invoke the agent:
curl -sS -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "What is Microsoft Foundry?", "stream": false}' | jq .
# Turn 2 — follow up using the id from the previous response:
curl -sS -X POST http://localhost:8088/responses \
-H "Content-Type: application/json" \
-d '{"input": "Can you summarize that?", "previous_response_id": "<id>", "stream": false}' | jq .
"""
import os
import sys
from agent_framework import Agent
from agent_framework.foundry import FoundryChatClient, ResponsesHostServer
from azure.identity.aio import DefaultAzureCredential
from dotenv import load_dotenv
# Load environment variables from a .env file if present (for local development).
# load_dotenv() walks up from this file's directory and never clobbers a value
# that is already set in the process environment.
load_dotenv()
if not os.environ.get("APPLICATIONINSIGHTS_CONNECTION_STRING"):
print(
"[WARNING] APPLICATIONINSIGHTS_CONNECTION_STRING not set — traces will not be sent "
"to Application Insights. Set it to enable local telemetry. "
"(This variable is auto-injected in hosted Foundry containers — do not set it "
"in the deployed agent's environment.)",
file=sys.stderr,
)
project_endpoint = os.environ.get("FOUNDRY_PROJECT_ENDPOINT")
if not project_endpoint:
raise RuntimeError("FOUNDRY_PROJECT_ENDPOINT environment variable is not set.")
deployment = os.environ.get("AZURE_AI_MODEL_DEPLOYMENT_NAME")
if not deployment:
raise RuntimeError("AZURE_AI_MODEL_DEPLOYMENT_NAME environment variable is not set.")
# Create an Agent backed by a Foundry model.
# The agent framework manages the LLM call, conversation sessions, and response
# lifecycle. FoundryChatClient talks to the project over the async Azure SDK, so
# it takes an async credential — token refresh must not block the event loop.
agent = Agent(
FoundryChatClient(
project_endpoint=project_endpoint,
model=deployment,
credential=DefaultAzureCredential(),
),
instructions="You are a helpful AI assistant. Be concise and informative.",
name="hello-world",
description="A minimal Hello World agent using the Agent Framework",
)
# ResponsesHostServer.run() auto-configures:
# - the ASGI server on port 8088 (or the PORT environment variable)
# - GET /readiness health probe
# - OpenTelemetry traces and metrics
# - x-platform-server response header
ResponsesHostServer(agent).run()
#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.13,<3.14"
# dependencies = [
# "azure-ai-projects==2.4.0",
# "azure-identity",
# # azure-ai-projects declares `openai>=2.8.0` with no upper bound, but it is
# # written against the 2.x client and imports httpx, which only the 2.x line
# # still pulls in — openai 3 moved to httpx2 and the import fails outright.
# "openai>=2.8,<3",
# ]
# ///
# ════════════════════════════════════════════════════════════════════════
# Foundry hosted agent deployment script (uv single-file scripts only)
#
# Usage:
# uv run deploy.py <agent.py> [agent-name]
#
# Examples:
# uv run deploy.py agent_msaf.py
# uv run deploy.py agent_lg.py my-agent
#
# This script runs on your local machine and performs, in order:
# publish -> create version -> wait for active -> route endpoint -> smoke test
#
# ── Arguments ──────────────────────────────────────────────────────────
# [0] agent.py Required. Path to the uv single-file script to deploy,
# e.g. agent_msaf.py or agent_lg.py.
# [1] agent-name Optional. Derived from the file name when omitted.
# e.g. my_agent.py -> my-agent, main.py -> main
#
# Publish output goes to a temporary directory under the system temp dir.
# It never pollutes the source tree, so no .gitignore entry is required
# for it. The directory is deleted on success and kept on failure so you
# can inspect the artifacts. The path is printed on the [publish] log line.
#
# ── Environment variables ──────────────────────────────────────────────
#
# [Required]
# FOUNDRY_PROJECT_ENDPOINT
# Data-plane endpoint of the target Foundry project.
# Format: https://<account>.services.ai.azure.com/api/projects/<project>
# Copy it from the project overview page in the portal.
#
# FOUNDRY_MODEL_NAME
# Name of the model deployment the agent will call (e.g. gpt-5.6-terra).
# It must be a deployment that actually exists in the project. Note that
# this is the deployment name, not the model family name.
# The value is injected into the container as AZURE_AI_MODEL_DEPLOYMENT_NAME.
#
# [Optional]
# FOUNDRY_AUTH default "cli"
# "cli" Use AzureCliCredential only, reusing your az login session.
# "default" Use the DefaultAzureCredential chain. For CI or managed identity.
#
# FOUNDRY_TENANT_ID no default
# Pins the target tenant when you are signed in to several of them.
#
# ── Authentication ─────────────────────────────────────────────────────
# Sign in with az login to the tenant that owns the project beforehand.
# The Foundry Project Manager role is required at project scope.
#
# Why the default is AzureCliCredential rather than DefaultAzureCredential:
# DefaultAzureCredential tries EnvironmentCredential first, so if
# AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET are left over on the
# machine it authenticates as that service principal and, on failure, breaks
# the chain (AADSTS700016 and friends). Your az login session is then never
# reached, which makes cli the safe choice for local deployments.
#
# Check the signed-in tenant:
# az account show --query "{tenant:tenantId, user:user.name}" -o table
#
# ── Note: do not confuse this with .env ────────────────────────────────
# The .env.example shipped with this sample is for running the agent itself
# locally. The AZURE_AI_MODEL_DEPLOYMENT_NAME and
# APPLICATIONINSIGHTS_CONNECTION_STRING values in it are injected by the
# platform in a deployed container, so this script does not read them.
# This script only uses the FOUNDRY_-prefixed variables listed above.
#
# ── Example run (cmd) ──────────────────────────────────────────────────
# set FOUNDRY_PROJECT_ENDPOINT=https://myacct.services.ai.azure.com/api/projects/myproj
# set FOUNDRY_MODEL_NAME=gpt-5.6-terra
# uv run deploy.py agent_msaf.py
#
# ── Example run (PowerShell) ───────────────────────────────────────────
# $env:FOUNDRY_PROJECT_ENDPOINT = 'https://myacct.services.ai.azure.com/api/projects/myproj'
# $env:FOUNDRY_MODEL_NAME = 'gpt-5.6-terra'
# uv run deploy.py agent_msaf.py
#
# ════════════════════════════════════════════════════════════════════════
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import zipfile
from datetime import datetime, timedelta, timezone
from pathlib import Path
from azure.ai.projects import AIProjectClient
from azure.ai.projects.models import (
AgentEndpointConfig,
AgentEndpointProtocol,
AgentVersionStatus,
CodeConfiguration,
CodeDependencyResolution,
FixedRatioVersionSelectionRule,
HostedAgentDefinition,
ProtocolConfiguration,
ProtocolVersionRecord,
ResponsesProtocolConfiguration,
VersionSelector,
)
from azure.identity import AzureCliCredential, DefaultAzureCredential
# The container image the agent runs in. PYTHON_RUNTIME picks the interpreter on
# the Foundry side; TARGET_PLATFORM and TARGET_PYTHON pick the wheels bundled for
# it. They have to agree, or the vendored binary extensions fail to import at
# container start.
#
# manylinux_2_28 is the glibc 2.28 baseline (RHEL 8, Debian 10, Ubuntu 20.04 and
# newer), which the Foundry runtime images satisfy. The older manylinux2014
# (glibc 2.17) tag looks like the safer choice but is not: numpy 2.5 and a
# growing number of packages no longer publish wheels for it, so a dependency
# graph that reaches numpy — LangGraph's does — fails to resolve at all.
PYTHON_RUNTIME = "python_3_13"
TARGET_PYTHON = "3.13"
TARGET_PLATFORM = "x86_64-manylinux_2_28"
USAGE = """\
Usage:
uv run deploy.py <agent.py> [agent-name]
Arguments:
<agent.py> Required. Path to the uv single-file script to deploy.
[agent-name] Optional. Derived from the file name when omitted
(e.g. my_agent.py -> my-agent).
For environment variables, see the comment block at the top of this file.\
"""
# ── Helpers ─────────────────────────────────────────────────────────────
def require(name: str) -> str:
value = os.environ.get(name)
if not value:
raise RuntimeError(f"Environment variable {name} is not set.")
return value
def env(name: str, fallback: str) -> str:
return os.environ.get(name) or fallback
def to_agent_name(stem: str) -> str:
"""Convert a file name into a valid Foundry agent name.
Rules: must start and end with a lowercase alphanumeric character, hyphens are
allowed in between, and the length is capped at 63 characters.
"""
name = re.sub(r"[^a-z0-9]", "-", stem.lower())
name = re.sub(r"-{2,}", "-", name).strip("-")
if len(name) > 63:
name = name[:63].rstrip("-")
if not name or not name[0].isalnum():
raise RuntimeError(
f"Could not derive a valid agent name from '{stem}'. "
"Specify one explicitly as the second argument."
)
return name
# The service's own rule: alphanumeric at both ends, hyphens allowed in between,
# 63 characters max. Underscores are the trip-up — legal in a Python module name,
# illegal here — so `agent_msaf.py` derives to `agent-msaf`, but the same name
# typed by hand as `agent_msaf` is rejected.
AGENT_NAME_PATTERN = re.compile(r"[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?")
def validate_agent_name(name: str) -> str:
"""Reject a name the service would reject, before anything expensive happens.
An explicitly passed name is otherwise taken on trust and only checked
server-side, which means the publish, the vendoring, the packing and a
multi-megabyte upload all complete before the request comes back as an
opaque `(invalid_parameters) ValidationError`. Checking it here costs
nothing and fails on the first line of output instead.
"""
if AGENT_NAME_PATTERN.fullmatch(name):
return name
try:
suggestion = f" Try '{to_agent_name(name)}'."
except RuntimeError:
suggestion = ""
raise RuntimeError(
f"'{name}' is not a valid Foundry agent name. It must start and end with an "
f"alphanumeric character, may contain hyphens in between, and is capped at "
f"63 characters — underscores are not allowed.{suggestion}"
)
def run(*command: str) -> None:
"""Run a subprocess, letting its output stream straight to the console."""
exit_code = subprocess.run(command).returncode
if exit_code != 0:
raise RuntimeError(f"{command[0]} {command[1]} failed (exit {exit_code}).")
def capture(*command: str) -> str:
"""Run a subprocess and return its stdout. stderr still streams to the console."""
result = subprocess.run(command, stdout=subprocess.PIPE, text=True, encoding="utf-8")
if result.returncode != 0:
raise RuntimeError(f"{command[0]} {command[1]} failed (exit {result.returncode}).")
return result.stdout
def main() -> int:
# ── Arguments ───────────────────────────────────────────────────────
args = sys.argv[1:]
if not args or args[0] in ("-h", "--help", "/?"):
print(USAGE)
return 1 if not args else 0
agent_source = Path(args[0]).resolve()
if not agent_source.is_file():
print(f"Source file not found: {agent_source}", file=sys.stderr)
return 1
if agent_source.suffix.lower() != ".py":
print(f"Not a Python script (.py): {agent_source}", file=sys.stderr)
return 1
source_stem = agent_source.stem
# The agent's dependencies live in its PEP 723 inline metadata block. Without
# one there is nothing for `uv export` to resolve, and the agent would reach
# the container with no packages bundled alongside it.
head = agent_source.read_text(encoding="utf-8").splitlines()[:40]
if not any(line.strip() == "# /// script" for line in head):
print(f"[warn ] {agent_source.name} is missing a PEP 723 '# /// script' metadata block.")
if shutil.which("uv") is None:
print(
"uv not found on PATH. See https://docs.astral.sh/uv/ to install it.",
file=sys.stderr,
)
return 1
# ── Configuration ───────────────────────────────────────────────────
# require: raises immediately when unset. env: falls back to the second argument.
project_endpoint = require("FOUNDRY_PROJECT_ENDPOINT") # required
model_name = require("FOUNDRY_MODEL_NAME") # required
# Agent name: explicit argument first, otherwise derived from the file name.
# Either way it is valid before it leaves this line — a derived name is valid
# by construction, and an explicit one is checked against the same rule.
agent_name = validate_agent_name(args[1]) if len(args) > 1 else to_agent_name(source_stem)
# Publish output goes to a temporary directory. What ultimately matters is the
# result deployed to Foundry; the local artifacts are useless once uploaded, and
# this keeps the source tree clean. mkdtemp always hands back a fresh empty
# directory, so stale artifacts can never end up in the zip and no pre-cleanup
# logic is needed.
publish_dir = Path(tempfile.mkdtemp(prefix=f"foundry-{source_stem}-"))
stage_dir = publish_dir / "app"
stage_dir.mkdir()
print(f"[target] {agent_source.name}")
print(f"[deploy] agent name : {agent_name} (used in the invocation URL)")
# ── 1. Publish the agent itself ─────────────────────────────────────
print(f"[publish] -> {publish_dir}")
# Resolve the PEP 723 metadata block into a pinned requirements list. This is
# the uv equivalent of restoring a project file: the inline dependencies plus
# everything they pull in, at exact versions.
requirements = publish_dir / "requirements.txt"
requirements.write_text(
capture(
"uv",
"export",
"--script",
str(agent_source),
"--format",
"requirements-txt",
"--no-hashes",
"--no-header",
"--no-annotate",
),
encoding="utf-8",
)
# Vendor those packages for the container's platform, not this machine's.
# --python-platform / --python-version cross-resolve the wheels the way
# `dotnet publish -r linux-x64` cross-publishes; without them a Windows or
# macOS build silently bundles binary extensions the container cannot load.
# --only-binary :all: is what turns "no wheel for that platform" into an error
# here instead of a source build against the wrong interpreter.
run(
"uv",
"pip",
"install",
"-r",
str(requirements),
"--target",
str(stage_dir),
"--python-version",
TARGET_PYTHON,
"--python-platform",
TARGET_PLATFORM,
"--only-binary",
":all:",
"--link-mode",
"copy",
)
# ── 2. Place the entry point ────────────────────────────────────────
# The script sits at the root of the bundle, next to the vendored packages, so
# `python <name>.py` puts that directory first on sys.path and the imports
# resolve with no PYTHONPATH involved. The flip side of that layout is that the
# script's own name enters the module namespace: a script named azure.py or
# json.py would shadow a real module and fail at import time — surfacing as a
# 424 session_not_ready on the first invocation, long after this script exited.
# Both collisions are checked here instead.
if source_stem in sys.stdlib_module_names:
raise RuntimeError(
f"'{agent_source.name}' shadows the standard library module '{source_stem}'. "
"Rename the script."
)
if (stage_dir / source_stem).is_dir() or (stage_dir / f"{source_stem}.py").exists():
raise RuntimeError(
f"'{agent_source.name}' shadows the bundled package '{source_stem}'. "
"Rename the script."
)
entry_script = agent_source.name
shutil.copy2(agent_source, stage_dir / entry_script)
print(f"[deploy] entry point: python {entry_script} (container-internal only)")
# ── 3. Pack the bundle ──────────────────────────────────────────────
# The zip is the container's file system, so its contents sit at the root — no
# wrapper directory. `bin/` holds console-script launchers generated for THIS
# machine (.exe shims on Windows); the container only ever runs the entry point
# above, so they are dead weight and are dropped, along with uv's own install
# lock and any bytecode cache.
code_zip = publish_dir / "code.zip"
file_count = 0
def is_bundled(relative: Path) -> bool:
return (
relative.parts[0] not in ("bin", ".lock")
and "__pycache__" not in relative.parts
)
with zipfile.ZipFile(code_zip, "w", zipfile.ZIP_DEFLATED) as archive:
for path in sorted(stage_dir.rglob("*")):
if not path.is_file():
continue
relative = path.relative_to(stage_dir)
if not is_bundled(relative):
continue
archive.write(path, relative.as_posix())
file_count += 1
print(f"[pack ] {file_count} files, {code_zip.stat().st_size / 1024 / 1024:.1f} MiB")
# ── 4. Agent definition ─────────────────────────────────────────────
definition = HostedAgentDefinition(
cpu="0.5",
memory="1Gi",
protocol_versions=[
ProtocolVersionRecord(protocol=AgentEndpointProtocol.RESPONSES, version="2.0.0")
],
code_configuration=CodeConfiguration(
runtime=PYTHON_RUNTIME,
entry_point=["python", entry_script],
# Bundled: run the uploaded tree as-is. No server-side build is
# involved, so neither inline script metadata nor private indexes are
# a problem.
dependency_resolution=CodeDependencyResolution.BUNDLED,
),
# FOUNDRY_PROJECT_ENDPOINT is a reserved variable injected by the platform,
# so we do not set it here.
environment_variables={"AZURE_AI_MODEL_DEPLOYMENT_NAME": model_name},
)
# ── Authentication ──────────────────────────────────────────────────
# DefaultAzureCredential is a chain and EnvironmentCredential ranks ahead of
# AzureCliCredential. If AZURE_CLIENT_ID / AZURE_TENANT_ID / AZURE_CLIENT_SECRET
# are left over, it tries the service principal first and, on failure, raises
# ClientAuthenticationError rather than falling through, which breaks the chain
# on the spot. Your az login session is then never reached (AADSTS700016 and
# friends).
leftover_spn = os.environ.get("AZURE_CLIENT_ID")
tenant_id = os.environ.get("FOUNDRY_TENANT_ID")
auth_mode = env("FOUNDRY_AUTH", "cli").lower()
if auth_mode == "cli":
# Default for local deployment. Uses only the az login session, so the
# result is deterministic.
credential = AzureCliCredential(tenant_id=tenant_id)
elif auth_mode == "default":
# For CI and similar, where a service principal or managed identity is
# needed.
credential = DefaultAzureCredential(
**({"interactive_browser_tenant_id": tenant_id} if tenant_id else {})
)
else:
raise RuntimeError(f"Invalid FOUNDRY_AUTH value: '{auth_mode}'. Use 'cli' or 'default'.")
if auth_mode == "default" and leftover_spn:
print(
f"[warn ] AZURE_CLIENT_ID={leftover_spn} is set, so authentication starts with that "
"service principal. Run with FOUNDRY_AUTH=cli to use your az login session instead."
)
print(f"[auth ] {auth_mode}{'' if tenant_id is None else f' (tenant {tenant_id})'}")
# Preflight. Catch authentication failures here rather than deep inside the API
# pipeline. Note: az account show only reads the local profile, so it succeeds
# even when the session is dead. Only acquiring a token actually verifies it.
try:
probe = credential.get_token("https://ai.azure.com/.default")
expires = datetime.fromtimestamp(probe.expires_on, tz=timezone.utc).astimezone()
print(f"[auth ] token acquired (expires {expires:%Y-%m-%d %H:%M})")
except Exception as ex:
raise RuntimeError(
f"Failed to acquire a token (mode={auth_mode}).\n"
"\n"
"Run the following command directly to diagnose the cause:\n"
" az account get-access-token --scope https://ai.azure.com/.default\n"
"\n"
"Common causes:\n"
" - Expired refresh token. az account show only reads a local file, so it\n"
" succeeds even in this case.\n"
" Fix: az login --tenant <tenant-id>\n"
" - Azure CLI older than 2.80. Fix: az upgrade\n"
" - az not found from cmd.exe. Check: cmd /c az version\n"
"\n"
f"Original error: {ex}"
) from ex
# allow_preview opts this client into the hosted-agent APIs — both the code
# upload below and the per-agent OpenAI endpoint used by the smoke test.
project_client = AIProjectClient(
endpoint=project_endpoint,
credential=credential,
allow_preview=True,
)
# ── 5. Create the version ───────────────────────────────────────────
# The SDK computes the SHA-256 of the stream for you. Versioning is
# content-addressed, so when both the code and the definition are unchanged no
# new version is created and the existing latest version is returned instead.
# The stream's name must end in .zip — the SDK reads it to name the upload part.
with code_zip.open("rb") as code:
created = project_client.agents.create_version_from_code(
agent_name,
definition=definition,
code=code,
)
print(f"[create] version {created.version}")
# ── 6. Wait for active ──────────────────────────────────────────────
# Bundled mode has no server-side build, so it finishes far faster than
# remote_build.
deadline = datetime.now(timezone.utc) + timedelta(minutes=10)
while created.status not in (AgentVersionStatus.ACTIVE, AgentVersionStatus.FAILED):
if datetime.now(timezone.utc) > deadline:
raise TimeoutError("The version did not transition to active.")
time.sleep(5)
created = project_client.agents.get_version(agent_name, created.version)
print(f"[status] {created.status}")
if created.status == AgentVersionStatus.FAILED:
raise RuntimeError(
"Provisioning failed. Check the error on the version details page in the portal."
)
# The container runs as its own managed identity — not as whoever ran this
# script — and that identity is what calls the model. It starts with no role
# assignments, so printing it here turns granting one from a portal hunt into
# a copy-paste. See the RBAC hint on the smoke-test failure below.
identity = getattr(created, "instance_identity", None)
principal_id = getattr(identity, "principal_id", None)
if principal_id:
print(f"[ident ] principal {principal_id} (needs the Foundry User role)")
# ── 7. Endpoint routing ─────────────────────────────────────────────
# Traffic splitting is not supported yet; one version receives 100% at a time.
project_client.agents.update_details(
agent_name,
agent_endpoint=AgentEndpointConfig(
version_selector=VersionSelector(
version_selection_rules=[
FixedRatioVersionSelectionRule(
agent_version=created.version,
traffic_percentage=100,
)
]
),
protocol_configuration=ProtocolConfiguration(
responses=ResponsesProtocolConfiguration()
),
),
)
print(f"[route ] {agent_name} -> version {created.version}")
# ── 8. Smoke test ───────────────────────────────────────────────────
# The container starts per session on the first invocation, which means entry
# point errors and readiness failures only surface here. One call right after
# deployment is mandatory.
responses_client = project_client.get_openai_client(agent_name=agent_name)
response = responses_client.responses.create(input="ping")
# A container that failed at runtime still answers HTTP 200. The body is
# either a Response carrying an `error`, or a bare {"code", "message"}
# envelope that the OpenAI client parses into a Response all the same,
# leaving `output` as None. Reading .output_text first turns both into
# `TypeError: 'NoneType' object is not iterable` — a message that says
# nothing about what actually broke — so the payload is unpacked before it is
# trusted. Extra fields survive model_dump(), which is what makes the bare
# envelope readable at all.
payload = response.model_dump()
failure = payload.get("error") or (
{"code": payload.get("code"), "message": payload.get("message")}
if payload.get("message")
else None
)
if failure or not response.output:
detail = str(failure or "the agent returned no output")
# A permission fault is the one failure here that is not about the code at
# all, and its message names an object id with no hint of what to do with
# it. Recognise it and spell out the remedy.
if any(
marker in detail
for marker in ("PermissionDenied", "does not have permissions", "Forbidden")
):
hint = f"""
This is an RBAC problem, not a code problem. The container runs as its own
managed identity, which starts with no role assignments. Grant it the Foundry
User role on the Foundry resource — that is the role covering the project data
actions, including calling a model deployment:
az role assignment create \\
--assignee-object-id {principal_id or "<principal-id-printed-above>"} \\
--assignee-principal-type ServicePrincipal \\
--role 53ca6127-db72-4b80-b1b0-d745d6d5456d \\
--scope /subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.CognitiveServices/accounts/<account>
The role is passed by id because it was recently renamed: Foundry User was
called Azure AI User, and both names are still in circulation. Do not reach for
a role whose name begins with "Cognitive Services" — those target AI Services
resources directly and do not apply to Foundry projects.
Re-run this script once the assignment has propagated."""
else:
hint = f"""
The version is Active and routed, so this is a fault inside the running
container rather than a deployment problem. Its logs are on the agent's page in
the portal. Reproduce it locally with:
uv run {agent_source.name}"""
raise RuntimeError(
f"The agent deployed but failed on its first invocation.\n\n {detail}\n{hint}"
)
print(f"[smoke ] {response.output_text}")
# ── 9. Cleanup ──────────────────────────────────────────────────────
# Delete the temporary directory only on the success path. If an exception is
# raised along the way it is left behind so you can open the publish output and
# trace the cause.
shutil.rmtree(publish_dir)
print("[done ] deployment complete")
print(
"[info ] Open the Microsoft Foundry portal (ai.azure.com) and run the prompts below "
"to confirm the agent responds.\n"
"\n"
"What is Microsoft Foundry?\n"
"Explain how hosted agents work in Foundry.\n"
"Give me three ideas for a weekend project.\n"
"Summarize the benefits of using the Agent Framework."
)
return 0
if __name__ == "__main__":
sys.exit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment