Skip to content

feat(agents): add .NET code deploy support (dotnet_8/9/10 runtimes)#8161

Draft
v1212 wants to merge 24 commits into
Azure:mainfrom
v1212:feature/dotnet-code-deploy
Draft

feat(agents): add .NET code deploy support (dotnet_8/9/10 runtimes)#8161
v1212 wants to merge 24 commits into
Azure:mainfrom
v1212:feature/dotnet-code-deploy

Conversation

@v1212
Copy link
Copy Markdown
Collaborator

@v1212 v1212 commented May 13, 2026

Summary

Adds .NET runtime support (dotnet_8, dotnet_9, dotnet_10) to the code deploy (ZIP upload) path introduced in #8146. This enables deploying .NET agent source code directly via ZIP upload without requiring Docker/ACR, alongside the existing Python support.


Init Flow Changes

Deploy Mode Prompt

The code deploy option now appears for both Python and .NET projects (detected via .csproj/.fsproj files). For projects that are neither Python nor .NET, the code deploy option remains hidden.

Existing Code -> Code Deploy (.NET)

azd ai agent init           # non-empty dir with .csproj
-> "Use the code in the current directory"
-> Agent name
-> Deploy mode -> Source Code (ZIP upload)
-> Runtime -> .NET 9 / .NET 8 / .NET 10
-> Entry point -> HelloWorld.dll (auto-detected from .csproj)
-> Dependency resolution -> Remote build / Bundled
-> Protocols
-> Model -> Use existing model deployment(s)
-> Subscription -> select
-> Foundry Project -> select
-> Writes agent.yaml (with code_configuration, runtime: dotnet_9), azure.yaml (language: csharp, no docker block)

Runtime Filtering

promptCodeConfig() now filters runtime choices based on detected project type:

  • .NET project only (has .csproj/.fsproj, no .py/requirements.txt): Shows only .NET 9, .NET 8, .NET 10
  • Python project only (has .py/requirements.txt, no .csproj): Shows only Python 3.11, 3.12, 3.13
  • Mixed or unknown: Shows all 6 runtime options

Language Detection in azure.yaml

Both init paths (template and from-code) correctly set language: csharp when a dotnet runtime is selected, fixing a bug where code deploy always hardcoded language: python.


Deploy Path Changes

Bundled Mode (dependency_resolution: bundled)

For .NET agents in bundled mode, packageDotnetBundled():

  1. Runs dotnet publish -c Release -r linux-x64 --self-contained false -o <temp-dir>
  2. ZIPs the published output (ready-to-run binaries for Linux)
  3. Uploads via the same multipart form-data POST used for Python

Remote Build Mode (dependency_resolution: remote_build)

For .NET agents in remote build mode:

  1. ZIPs source code (.cs, .csproj files etc.) excluding build artifacts
  2. Server-side Oryx build handles dotnet publish on the remote environment

Entry Point Prefix

.NET agents use dotnet <entry_point> as the startup command (e.g., ["dotnet", "HelloWorld.dll"]), while Python uses python <entry_point>.

250 MB ZIP Size Limit

Enforced for all code deploy packages (both Python and .NET) with a clear error message.


Files Modified

File Change
init_from_code.go Add isDotnetProject() helper, detectDefaultEntryPoint() for .dll/.csproj, filter runtime choices by project type, update showCodeDeploy to include .NET, fix noPrompt default for dotnet
init_from_code_test.go Unit tests for detectDefaultEntryPoint() with dotnet scenarios
init.go Fix language: python hardcoding bug -- now checks runtime prefix for csharp; update showCodeDeploy to use isDotnetProject()
map.go Map dotnet_8/9/10 runtimes to API format with "dotnet" command prefix
map_test.go Round-trip tests for dotnet code configuration API mapping
service_target_agent.go Add packageDotnetBundled() with dotnet publish + output capture in errors; display dotnet prefix in deploy output; add bytes import; enforce 250 MB ZIP limit
config.go Add TODO comment to CodeDeployRegions for future dynamic discovery

How to Build

cd cli/azd/extensions/azure.ai.agents
go build ./...
go test ./...
go vet ./...

Manual Test Steps

Prerequisites

  • Azure subscription with a Foundry project in a supported region (westus2, canadacentral, northcentralus)
  • A model deployment (e.g. gpt-4o, gpt-5.1) in your Foundry project
  • .NET SDK 9 installed locally (for bundled mode)
  • azd CLI installed, logged in (azd auth login)
  • Build the extension: cd cli/azd/extensions/azure.ai.agents && azd x build

Test 1: .NET Code Deploy with Remote Build

# 1. Clone a .NET agent sample (e.g., hello-world-dotnet-invocations)
mkdir test-dotnet-code-deploy && cd test-dotnet-code-deploy

# 2. Create a minimal .NET agent project with agent.yaml containing:
#   code_configuration:
#     runtime: dotnet_9
#     entry_point: HelloWorld.dll
#     dependency_resolution: remote_build

# 3. Init with existing Foundry project
azd ai agent init --project-id "<your-arm-resource-id>"
# Expected prompts:
#   1. Deploy mode -> Source Code (ZIP upload)
#   2. Runtime -> .NET 9 (only .NET options shown since it's a .NET project)
#   3. Entry point -> HelloWorld.dll (auto-detected from .csproj)
#   4. Dependency resolution -> Remote build
#   5. Protocols -> invocations
#   6. Model -> select existing deployment

# 4. Verify azure.yaml
Get-Content azure.yaml | Select-String "language"
# Expect: language: csharp

# 5. Deploy
azd deploy hello-world-dotnet-invocations
# Expect: "Packaging code" -> "Creating agent" -> "Agent is active!"

# 6. Invoke (wait ~30s for cold start)
azd ai agent invoke "What is 2+2?"
# Expect: streaming response with model reply

Test 2: .NET Code Deploy with Bundled Mode

# Same project as above, update agent.yaml:
#   dependency_resolution: bundled

azd deploy hello-world-dotnet-invocations
# Expect: "Publishing .NET project" -> "Packaging code" -> "Creating agent" -> done
# The deploy runs `dotnet publish -c Release -r linux-x64 --self-contained false` locally

azd ai agent invoke --new-session "Hello bundled dotnet!"
# Expect: streaming response

Test Results

All configurations verified end-to-end on canadacentral (Foundry project wujia-aifproject260512):

# Runtime Package Mode Deploy Invoke Notes
1 dotnet_9 remote_build PASS (19s) PASS (streaming SSE) Model: gpt-5.1
2 dotnet_9 bundled PASS PASS dotnet publish runs locally
3 python_3_12 remote_build PASS PASS Regression check (no change)

Invoke Output Example (Remote Build, dotnet_9)

Agent:    hello-world-dotnet-invocations (remote, invocations protocol)
Input:    "What is 2+2?"
Session:  (new -- server will assign)

Invocation:   inv_ec38240e65957f5600bNhzDBxxuw7IHJf7bxYXDxdsJZqtnDJW
Session:  8e0044805feaa1c000rE03tPFMgIHo0GrGeLFbQEE52lx8hrLX (assigned by server)
[hello-world-dotnet-invocations] {"type":"token","content":"2"}
{"type":"token","content":" +"}
{"type":"token","content":" 2"}
{"type":"token","content":" ="}
{"type":"token","content":" 4"}
{"type":"token","content":"."}
{"type":"done","full_text":"2 + 2 = 4."}

Unit Tests

ok  azureaiagent/internal/cmd              14.084s
ok  azureaiagent/internal/pkg/agents/agent_api    21.724s
ok  azureaiagent/internal/pkg/agents/agent_yaml    5.329s
ok  azureaiagent/internal/project           5.604s

Specific test coverage for this PR:

  • TestDetectDefaultEntryPoint -- verifies .dll detection from dotnet publish output and .csproj AssemblyName fallback
  • TestCodeConfigurationDotnetRoundTrip -- verifies dotnet_9 runtime maps to API format with ["dotnet", "HelloWorld.dll"] entry point
  • TestCodeConfigurationDotnetRemoteBuild -- verifies dependency_resolution: remote_build mapping for dotnet

Notes

  • No impact on Python code deploy: All Python paths are unchanged. Runtime filtering only affects the prompt UI.
  • No impact on container deploy: Container paths remain completely untouched.
  • --no-prompt defaults: For .NET projects, defaults to dotnet_9 runtime. For Python projects, defaults to python_3_12 (unchanged).
  • Entry point auto-detection: Looks for .csproj <AssemblyName> property first, falls back to project directory name + .dll.
  • gosec G204 suppressed: exec.Command("dotnet", "publish", csprojPath, ...) -- csprojPath is derived from user's local project directory, not external input.
  • 250 MB limit: ZIP packages exceeding 250 MB are rejected with a clear error before upload.
  • Supported regions: Same as Python code deploy -- westus2, canadacentral, northcentralus.

Dependencies

Related

Jian Wu added 21 commits May 11, 2026 16:17
Implements code-based deployment as a complementary mode to container deploy.
Agents with code_configuration in agent.yaml are deployed via multipart
ZIP upload instead of Docker/ACR, eliminating permission complexity.
Add deploy mode prompt (code vs container) to the init flow. When code deploy is selected, prompts for runtime, entry_point, and dependency_resolution, then generates agent.yaml with code_configuration and azure.yaml with language: python (no Docker).
…aults

- Fix gosec G304 warnings with nolint annotations for safe file reads
- Fix gosec G104 by properly handling tmpFile.Close() errors
- Fix gofmt formatting in excludeDirs map
- Update runtime choices to python_3_12/3_11/3_13 per spec
- Change --no-prompt deploy mode default to container (backward compat)
- Change --no-prompt runtime default to python_3_12
- Align prompt labels with spec wording
- Add container.resources + startupCommand to azure.yaml for code deploy
- Add .azure and .env/.env.* to ZIP exclusion list to prevent uploading secrets
- Skip symlinks in WalkDir to avoid including files outside agent directory
- Stream ZIP directly to temp file with io.MultiWriter for SHA-256, reducing memory usage
- Clean up temp file on all error paths using deferred cleanup
- Only fall back to create agent on 404; propagate auth/5xx/network errors
- Use context-aware select/time.After in polling loop for responsive cancellation
- Add dedicated CodeMissingCodeZipArtifact error code
- Fix entry point auto-detection to use srcDir instead of cwd
- Replace hardcoded multipart boundary with mime/multipart.Writer
- Add unit tests for zipDeployRequest multipart format and headers
…deploy and set metadata Content-Type

- Add PatchAgent call after code deploy create/update to apply agent_endpoint and agent_card fields (matching container deploy behavior)
- Use CreatePart with explicit Content-Type: application/json for the metadata multipart part instead of CreateFormField
- Update unit test to verify metadata part Content-Type header
- Add //nolint:gosec to os.ReadFile in init_from_code.go
- Handle req.Body.Close() return value in test transport
- Suppress Close/Remove errors in deferred cleanup with _ =
- Add 'mypy' to cspell.yaml words list
Add deploy mode prompt (code vs container) to the template init flow,
allowing users to choose code deploy when initializing from a template.
Skip ACR configuration when code deploy is selected. Auto-derive
startup command from entry_point for code deploy instead of prompting.
…switch

- Default to Container (Docker) for backward compatibility
- Use clearer labels: 'Container (Docker)' / 'Code deploy (ZIP upload)'
- Remove code_configuration from agent.yaml when switching to container mode
When skipACR is true (code deploy mode), skip the configureAcrConnection
call entirely to prevent prompting users for ACR configuration.
When running 'azd ai agent init' from a subdirectory with an existing
agent.manifest.yaml, the addToProject function received targetDir='.'
which wrote 'project: .' into azure.yaml. Since azure.yaml resolves
paths relative to the project root, this caused the service to point
to the wrong directory.

Fix: resolve the actual relative path from project root to cwd when
targetDir is '.', so azure.yaml gets the correct project path (e.g.
'src/hello-world-python-invocations' instead of '.').
- T1: Hide code deploy for non-Python projects (isPythonProject check)
- T2: Add TODO for region validation in code deploy
- T3: Reorder runtime list to 3.11, 3.12, 3.13
- T4: Update entry point prompt wording
- T6: Add descriptions to bundled/remote_build choices
- J1: Consolidate promptCodeConfig into single shared function
- J3: Use errors.AsType[*azcore.ResponseError] per Go 1.26
- J4: Extract shared deriveStartupCommand helper
- V1: Rename to 'Container Image (Docker)' / 'Source Code (ZIP upload)'
- V2: Unify HostedAgentDefinition with custom JSON marshal/unmarshal
- V4: Fix error message to reference 'azd package'
- V5: Extract prepareDeploy/finalizeDeploy shared helpers
- C1+C3: Verify ZIP exclusions (.azure, .env) and temp file cleanup
… default

Add region validation for code deploy at both init-time (filter project
list) and deploy-time (fail early with clear error). Supported regions:
westus2, canadacentral, northcentralus.

Unify dependency_resolution fallback default to 'remote_build' to match
--no-prompt behavior.
…ATION

Move CodeDeployRegions to project.config.go as a shared exported var,
referenced by both init filtering and deploy validation. Add explicit
check for empty AZURE_LOCATION with actionable error message.
…rompt

- Include service error code/message and x-request-id in remote build failure errors
- Rename 'container resource allocation' prompt to 'Select resources (CPU and Memory)'
Jian Wu added 3 commits May 13, 2026 16:58
Reject code packages exceeding 250 MB with a clear error message
suggesting to reduce package size or use remote_build.
…me options

- Fix template init path that hardcoded language=python for all code deploy;
  now correctly sets language=csharp when agent uses a dotnet runtime
- Filter runtime options in promptCodeConfig based on detected project type
  (Python projects see only Python runtimes, .NET only .NET, mixed sees all)
- Add isDotnetProject helper (checks for .csproj/.fsproj files)
- Enable code deploy option for .NET projects in both init paths
- Capture dotnet publish stdout/stderr in error message for better diagnostics
- Add TODO comment to CodeDeployRegions for future dynamic discovery
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant