Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 59 additions & 5 deletions .azure-pipelines/hosted-agent-tests/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ to GitHub's workflow approval controls. It uses read-only permissions and no
Azure credentials. Deploying agents and evaluating the evidence described below
are separate cloud E2E responsibilities; the PR policy does not run them.

For multi-agent samples, cloud E2E preparation validates each hosted agent's
project directory separately. Native A2A invocations require a completed Task
with text artifacts; a failed Task is never successful evidence. Explicitly
transient diagnostics in a failed Task's status-message parts can enter the
existing bounded readiness/model-throttle retries. Malformed responses and
nonretryable failures remain terminal, and each attempt's raw A2A response is
retained even if a later attempt succeeds.
Native A2A retries inspect only the helper's separate transport/service
diagnostics and HTTP headers, never successful artifact text. Validated A2A
service-error diagnostics containing HTTP 429 use the bounded model-throttle
retries, including failed-Task and JSON-RPC error messages. Validated A2A
artifacts are evaluated by the behavior contract rather than generic CLI error
or toolbox-prose heuristics. Dependency deployments participate in the same
three-attempt transient deployment retry loop as the selected planner; the
planner deploys only after its dependencies succeed.

## Why this contract exists

The sample author defines the agent's intended behavior. The shared CI runner can
Expand Down Expand Up @@ -114,12 +130,19 @@ also checks the published fixtures, including orphan detection. See the

## Supported protocols

The runner supports `responses` and `invocations`. Other protocols require dedicated
harnesses:
The runner supports `responses`, `invocations`, and native A2A 1.0 (`a2a`).
For A2A, it sends blocking JSON-RPC `SendMessage` requests through `azd` with
`--a2a-version 1.0` and the cell's explicit session. It checks JSON-RPC request
IDs, rejects protocol errors and non-completed Tasks even when HTTP and CLI
exit status indicate success, and captures artifact text as `assistant_text`.
The JSON-RPC body is retained as `raw`, alongside request and HTTP-header evidence.
A2A turns create independent Tasks in the same hosted session; Responses
`previous_turn` and MCP approval continuation are not supported.

Other protocols still require dedicated harnesses:

- `invocations_ws` uses sample-defined, full-duplex WebSocket frames.
- `activity` requires Bot/Teams setup and sends replies asynchronously.
- A2A requires agent-card discovery and JSON-RPC/task handling.
- Voice Live uses a separate real-time audio service.

They remain unsupported until CI has transport-specific clients and evidence
Expand All @@ -138,7 +161,7 @@ python3 .github/scripts/hosted_agent_test_spec.py validate \

python3 .github/scripts/hosted_agent_test_spec.py plan \
--spec .azure-pipelines/hosted-agent-tests/<language>/<complete-sample-path>/test-spec.yml \
--protocol <responses|invocations> \
--protocol <responses|invocations|a2a> \
--output /tmp/hosted-agent-test-plan.json
```

Expand Down Expand Up @@ -189,10 +212,41 @@ tests:
| Scope | Field | Contract |
|---|---|---|
| document | `version` | Required integer; must be `1` (Boolean `true` is not accepted). |
| document | `sample` | Required mapping; only `owner` and `experiences` are allowed. |
| document | `sample` | Required mapping; `owner` and `experiences`, plus optional native-A2A deployment settings below. |
| document | `tests` | Required non-empty sequence. |
| sample | `owner` | Required non-empty Microsoft alias. Must start with an ASCII letter and then contain only letters, digits, `.`, `_`, or `-`; `@microsoft.com` is forbidden. |
| sample | `experiences` | Required non-empty unique sequence containing `azd`, `vscode`, or both. This is registration metadata and does not alter the cloud matrix. |
| sample | `service` | A2A only. Manifest service key to invoke; required for multi-agent projects, auto-selected for a single agent. |
| sample | `endpoints` | A2A only. Mapping of environment-variable names to specialist service keys. Replaces matching `${VARIABLE}` agent env values with cell-owned A2A endpoint URLs and adds deployment dependency edges. |
| sample | `toolbox` | A2A only. Relative JSON toolbox-definition path inside the sample. Adds a cell-owned toolbox for `${TOOLBOX_ENDPOINT}` consumers, using the existing isolation, verification and cleanup lifecycle. |

For example, the travel fixture selects `service: planner`, binds
`FLIGHT_AGENT_ENDPOINT: flights` and `HOTEL_AGENT_ENDPOINT: hotels` under
`endpoints`, and sets `toolbox: toolbox.json`. CI retains all three source
projects and uniquely renames every agent; it does not collapse specialists into
the planner. The selected CI project must support native A2A and have the
sample's declared model deployment. Offline harness tests do not establish that
these cloud prerequisites are available.

Native A2A deployment publishes the cell's toolbox and specialists before its
planner. It reads each deployed specialist version from the local azd environment
and writes `FLIGHT_AGENT_VERSION`/`HOTEL_AGENT_VERSION` into the job-local planner
configuration. The planner applies `x-agent-version-override` to both specialist
Card discovery and subsequent A2A requests, so overlapping builds using stable
agent names cannot switch each other's specialist code or toolbox dependencies.

For multi-agent A2A fixtures, the deployment phase checks the selected planner's
instance identity for project-scoped Foundry Agent Consumer access and creates
an assignment only when needed. The CI identity must have permission to manage
role assignments at that scope. This is a persistent CI runtime grant, not a
job-owned temporary resource: builds reuse planner identities, so deleting a
grant at job cleanup could break another active build. Creation uses a
deterministic assignment ID for the principal/project/role; concurrent builds
converge on the same assignment. Existing grants are never deleted.
The local access result is retained for diagnostics; toolbox cleanup does not
revoke runtime access. CI waits briefly for propagation before invoking.
When retiring CI agents, an administrator must review and remove their runtime
assignments only after all builds using those identities have finished.
| test | `name` | Required non-empty string, unique within the file. |
| test | `when` | Optional mapping. `toolbox_label` is its only supported field. |
| test | `turns` | Required non-empty sequence. |
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
version: 1

sample:
owner: hkunkanoor
experiences: [azd]

tests:
- name: deterministic-echo
turns:
- input: hello
assertions:
- source: assistant_text
type: contains
value: "Echo: hello"
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
version: 1

sample:
owner: hkunkanoor
experiences: [azd]
service: planner
endpoints:
FLIGHT_AGENT_ENDPOINT: flights
HOTEL_AGENT_ENDPOINT: hotels
toolbox: toolbox.json

# Web contents vary. Assert saved research/citations, not live fares or hotel names.
tests:
- name: cited-unverified-trip-research
turns:
- input: >-
Research and save a trip for 1 traveler from SFO to SEA, departing
2027-01-10 and returning 2027-01-13. Arrive before
2027-01-11T09:00:00-08:00. Prefer refundable flight and hotel.
Hotel check-in 2027-01-10, check-out 2027-01-13, within 3000 meters
of the public landmark Seattle Convention Center Summit, Seattle.
Total budget USD 1200. Use both specialists' web search, cite sources
and mark unavailable exact-date evidence unverified. Do not book.
assertions:
- source: assistant_text
type: contains
value: "status unverified"
- source: assistant_text
type: regex
value: 'https?://\S+'
- source: assistant_text
type: regex
value: 'plan-[a-f0-9]{32}'
assertions:
- source: console_log
type: regex
value: 'A2A_PLAN_COMPLETED successful_delegations=([2-9]|[1-9][0-9]+)\b'
4 changes: 3 additions & 1 deletion .azure-pipelines/hosted-agents-samples-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,9 @@ stages:
exit 1
fi
if [ "$(basename "$fixture")" = "test-spec.yml" ]; then
if [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "invocations")' "$manifest")" = "true" ]; then
if [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "a2a")' "$manifest")" = "true" ]; then
protocol="a2a"
elif [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "invocations")' "$manifest")" = "true" ]; then
protocol="invocations"
else
protocol="responses"
Expand Down
14 changes: 9 additions & 5 deletions .azure-pipelines/scripts/hosted-agents/discover-samples.sh
Original file line number Diff line number Diff line change
Expand Up @@ -141,9 +141,9 @@ emit_entries() {
# point, keeping the runtime declaration in the sample manifest.
# A manifest-defined module/argument entry point must stay a single
# matrix value so azd can build the complete launch command.
runtime=$(yq -r '.services[] | select(.host == "azure.ai.agent") | .codeConfiguration.runtime // ""' "$yaml_file" | head -1)
runtime=$(yq -r '[.services[] | select(.host == "azure.ai.agent") | .codeConfiguration.runtime] | .[0] // ""' "$yaml_file")
[ -n "$runtime" ] || runtime="python_3_13"
entry_point=$(yq -r '.services[] | select(.host == "azure.ai.agent") | .codeConfiguration.entryPoint // ""' "$yaml_file" | head -1)
entry_point=$(yq -r '[.services[] | select(.host == "azure.ai.agent") | .codeConfiguration.entryPoint] | .[0] // ""' "$yaml_file")
[ -n "$entry_point" ] || entry_point="main.py"
deploy_modes='["container","code"]'
;;
Expand All @@ -152,7 +152,7 @@ emit_entries() {
runtime="dotnet_10"
# Prefer the manifest's authoritative code entry point. Fall back to the
# project assembly only for older manifests.
entry_point=$(yq -r '.services[] | select(.host == "azure.ai.agent") | .codeConfiguration.entryPoint // ""' "$yaml_file" | head -1)
entry_point=$(yq -r '[.services[] | select(.host == "azure.ai.agent") | .codeConfiguration.entryPoint] | .[0] // ""' "$yaml_file")
if [ -z "$entry_point" ]; then
local csproj asm
csproj=$(find "$sample_dir" -name "*.csproj" | head -1)
Expand Down Expand Up @@ -316,7 +316,9 @@ if [ "$DISCOVERY_MODE" = "changed" ]; then
echo " - $sample_dir (excluded by CI skiplist)" >&2
continue
fi
if [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "invocations")' "$yaml_file")" = "true" ]; then
if [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "a2a")' "$yaml_file")" = "true" ]; then
protocol="a2a"
elif [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "invocations")' "$yaml_file")" = "true" ]; then
protocol="invocations"
else
protocol="responses"
Expand All @@ -341,7 +343,9 @@ else
fi
agent_name=$(yq '.name // ""' "$yaml_file")
sample_id=$(echo "$sample_dir" | sed 's|samples/||' | tr '/' '-')
if [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "invocations")' "$yaml_file")" = "true" ]; then
if [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "a2a")' "$yaml_file")" = "true" ]; then
protocol="a2a"
elif [ "$(yq '[.services[] | select(.host == "azure.ai.agent") | .protocols[]?.protocol] | any_c(. == "invocations")' "$yaml_file")" = "true" ]; then
protocol="invocations"
else
protocol="responses"
Expand Down
Loading
Loading